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
@@ -74,6 +74,47 @@ export interface UnitResult {
74
74
  event?: AgentEndEvent;
75
75
  }
76
76
 
77
+ // ─── Phase pipeline types ────────────────────────────────────────────────────
78
+
79
+ type PhaseResult<T = void> =
80
+ | { action: "continue" }
81
+ | { action: "break"; reason: string }
82
+ | { action: "next"; data: T }
83
+
84
+ interface IterationContext {
85
+ ctx: ExtensionContext;
86
+ pi: ExtensionAPI;
87
+ s: AutoSession;
88
+ deps: LoopDeps;
89
+ prefs: GSDPreferences | undefined;
90
+ iteration: number;
91
+ }
92
+
93
+ interface LoopState {
94
+ recentUnits: Array<{ key: string; error?: string }>;
95
+ stuckRecoveryAttempts: number;
96
+ }
97
+
98
+ interface PreDispatchData {
99
+ state: GSDState;
100
+ mid: string;
101
+ midTitle: string;
102
+ }
103
+
104
+ interface IterationData {
105
+ unitType: string;
106
+ unitId: string;
107
+ prompt: string;
108
+ finalPrompt: string;
109
+ pauseAfterUatDispatch: boolean;
110
+ observabilityIssues: unknown[];
111
+ state: GSDState;
112
+ mid: string | undefined;
113
+ midTitle: string | undefined;
114
+ isRetry: boolean;
115
+ previousTier: string | undefined;
116
+ }
117
+
77
118
  // ─── Per-unit one-shot promise state ────────────────────────────────────────
78
119
  //
79
120
  // A single module-level resolve function scoped to the current unit execution.
@@ -287,6 +328,20 @@ export async function runUnit(
287
328
  status: result.status,
288
329
  });
289
330
 
331
+ // Discard trailing follow-up messages (e.g. async_job_result notifications)
332
+ // from the completed unit. Without this, queued follow-ups trigger wasteful
333
+ // LLM turns before the next session can start (#1642).
334
+ // clearQueue() lives on AgentSession but isn't part of the typed
335
+ // ExtensionCommandContext interface — call it via runtime check.
336
+ try {
337
+ const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
338
+ if (typeof cmdCtxAny?.clearQueue === "function") {
339
+ (cmdCtxAny.clearQueue as () => unknown)();
340
+ }
341
+ } catch {
342
+ // Non-fatal — clearQueue may not be available in all contexts
343
+ }
344
+
290
345
  return result;
291
346
  }
292
347
 
@@ -642,204 +697,192 @@ async function closeoutAndStop(
642
697
  await deps.stopAuto(ctx, pi, reason);
643
698
  }
644
699
 
645
- // ─── autoLoop ────────────────────────────────────────────────────────────────
700
+ // ─── runPreDispatch ───────────────────────────────────────────────────────────
646
701
 
647
702
  /**
648
- * Main auto-mode execution loop. Iterates: derive dispatch → guards →
649
- * runUnit finalize → repeat. Exits when s.active becomes false or a
650
- * terminal condition is reached.
651
- *
652
- * This is the linear replacement for the recursive
653
- * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain.
703
+ * Phase 1: Pre-dispatch resource guard, health gate, state derivation,
704
+ * milestone transition, terminal conditions.
705
+ * Returns break to exit the loop, or next with PreDispatchData on success.
654
706
  */
655
- export async function autoLoop(
656
- ctx: ExtensionContext,
657
- pi: ExtensionAPI,
658
- s: AutoSession,
659
- deps: LoopDeps,
660
- ): Promise<void> {
661
- debugLog("autoLoop", { phase: "enter" });
662
- let iteration = 0;
663
- // ── Sliding-window stuck detection ──
664
- const recentUnits: Array<{ key: string; error?: string }> = [];
665
- const STUCK_WINDOW_SIZE = 6;
666
- let stuckRecoveryAttempts = 0;
707
+ async function runPreDispatch(
708
+ ic: IterationContext,
709
+ loopState: LoopState,
710
+ ): Promise<PhaseResult<PreDispatchData>> {
711
+ const { ctx, pi, s, deps, prefs } = ic;
667
712
 
668
- let consecutiveErrors = 0;
713
+ // Resource version guard
714
+ const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
715
+ if (staleMsg) {
716
+ await deps.stopAuto(ctx, pi, staleMsg);
717
+ debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
718
+ return { action: "break", reason: "resources-stale" };
719
+ }
669
720
 
670
- while (s.active) {
671
- iteration++;
672
- debugLog("autoLoop", { phase: "loop-top", iteration });
721
+ deps.invalidateAllCaches();
722
+ s.lastPromptCharCount = undefined;
723
+ s.lastBaselineCharCount = undefined;
673
724
 
674
- if (iteration > MAX_LOOP_ITERATIONS) {
675
- debugLog("autoLoop", {
676
- phase: "exit",
677
- reason: "max-iterations",
678
- iteration,
679
- });
680
- await deps.stopAuto(
681
- ctx,
682
- pi,
683
- `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
725
+ // Pre-dispatch health gate
726
+ try {
727
+ const healthGate = await deps.preDispatchHealthGate(s.basePath);
728
+ if (healthGate.fixesApplied.length > 0) {
729
+ ctx.ui.notify(
730
+ `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`,
731
+ "info",
684
732
  );
685
- break;
686
733
  }
687
-
688
- if (!s.cmdCtx) {
689
- debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
690
- break;
734
+ if (!healthGate.proceed) {
735
+ ctx.ui.notify(
736
+ healthGate.reason ?? "Pre-dispatch health check failed.",
737
+ "error",
738
+ );
739
+ await deps.pauseAuto(ctx, pi);
740
+ debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
741
+ return { action: "break", reason: "health-gate-failed" };
691
742
  }
743
+ } catch {
744
+ // Non-fatal
745
+ }
692
746
 
693
- try {
694
- // ── Blanket try/catch: one bad iteration must not kill the session
695
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
696
-
697
- // ── Check sidecar queue before deriveState ──
698
- let sidecarItem: SidecarItem | undefined;
699
- if (s.sidecarQueue.length > 0) {
700
- sidecarItem = s.sidecarQueue.shift()!;
701
- debugLog("autoLoop", {
702
- phase: "sidecar-dequeue",
703
- kind: sidecarItem.kind,
704
- unitType: sidecarItem.unitType,
705
- unitId: sidecarItem.unitId,
706
- });
707
- }
708
-
709
- const sessionLockBase = deps.lockBase();
710
- if (sessionLockBase) {
711
- const lockStatus = deps.validateSessionLock(sessionLockBase);
712
- if (!lockStatus.valid) {
713
- debugLog("autoLoop", {
714
- phase: "session-lock-invalid",
715
- reason: lockStatus.failureReason ?? "unknown",
716
- existingPid: lockStatus.existingPid,
717
- expectedPid: lockStatus.expectedPid,
718
- });
719
- deps.handleLostSessionLock(ctx, lockStatus);
720
- debugLog("autoLoop", {
721
- phase: "exit",
722
- reason: "session-lock-lost",
723
- detail: lockStatus.failureReason ?? "unknown",
724
- });
725
- break;
726
- }
727
- }
747
+ // Sync project root artifacts into worktree
748
+ if (
749
+ s.originalBasePath &&
750
+ s.basePath !== s.originalBasePath &&
751
+ s.currentMilestoneId
752
+ ) {
753
+ deps.syncProjectRootToWorktree(
754
+ s.originalBasePath,
755
+ s.basePath,
756
+ s.currentMilestoneId,
757
+ );
758
+ }
728
759
 
729
- // Variables shared between the sidecar and normal paths
730
- let unitType: string;
731
- let unitId: string;
732
- let prompt: string;
733
- let pauseAfterUatDispatch = false;
734
- let state: GSDState;
735
- let mid: string | undefined;
736
- let midTitle: string | undefined;
737
- let observabilityIssues: unknown[] = [];
760
+ // Derive state
761
+ let state = await deps.deriveState(s.basePath);
762
+ deps.syncCmuxSidebar(prefs, state);
763
+ let mid = state.activeMilestone?.id;
764
+ let midTitle = state.activeMilestone?.title;
765
+ debugLog("autoLoop", {
766
+ phase: "state-derived",
767
+ iteration: ic.iteration,
768
+ mid,
769
+ statePhase: state.phase,
770
+ });
738
771
 
739
- if (!sidecarItem) {
740
- // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
772
+ // ── Milestone transition ────────────────────────────────────────────
773
+ if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
774
+ ctx.ui.notify(
775
+ `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
776
+ "info",
777
+ );
778
+ deps.sendDesktopNotification(
779
+ "GSD",
780
+ `Milestone ${s.currentMilestoneId} complete!`,
781
+ "success",
782
+ "milestone",
783
+ );
784
+ deps.logCmuxEvent(
785
+ prefs,
786
+ `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
787
+ "success",
788
+ );
741
789
 
742
- // Resource version guard
743
- const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
744
- if (staleMsg) {
745
- await deps.stopAuto(ctx, pi, staleMsg);
746
- debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
747
- break;
790
+ const vizPrefs = prefs;
791
+ if (vizPrefs?.auto_visualize) {
792
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
793
+ }
794
+ if (vizPrefs?.auto_report !== false) {
795
+ try {
796
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
797
+ } catch (err) {
798
+ ctx.ui.notify(
799
+ `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
800
+ "warning",
801
+ );
748
802
  }
803
+ }
749
804
 
750
- deps.invalidateAllCaches();
751
- s.lastPromptCharCount = undefined;
752
- s.lastBaselineCharCount = undefined;
805
+ // Reset dispatch counters for new milestone
806
+ s.unitDispatchCount.clear();
807
+ s.unitRecoveryCount.clear();
808
+ s.unitLifetimeDispatches.clear();
809
+ loopState.recentUnits.length = 0;
810
+ loopState.stuckRecoveryAttempts = 0;
753
811
 
754
- // Pre-dispatch health gate
812
+ // Worktree lifecycle on milestone transition — merge current, enter next
813
+ deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
814
+
815
+ // Opt-in: create draft PR on milestone completion
816
+ if (prefs?.git?.auto_pr) {
755
817
  try {
756
- const healthGate = await deps.preDispatchHealthGate(s.basePath);
757
- if (healthGate.fixesApplied.length > 0) {
758
- ctx.ui.notify(
759
- `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`,
760
- "info",
761
- );
762
- }
763
- if (!healthGate.proceed) {
764
- ctx.ui.notify(
765
- healthGate.reason ?? "Pre-dispatch health check failed.",
766
- "error",
767
- );
768
- await deps.pauseAuto(ctx, pi);
769
- debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
770
- break;
818
+ const { createDraftPR } = await import("./git-service.js");
819
+ const prUrl = createDraftPR(
820
+ s.basePath,
821
+ s.currentMilestoneId!,
822
+ `[GSD] ${s.currentMilestoneId} complete`,
823
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
824
+ );
825
+ if (prUrl) {
826
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
771
827
  }
772
828
  } catch {
773
- // Non-fatal
829
+ // Non-fatal — PR creation is best-effort
774
830
  }
831
+ }
775
832
 
776
- // Sync project root artifacts into worktree
777
- if (
778
- s.originalBasePath &&
779
- s.basePath !== s.originalBasePath &&
780
- s.currentMilestoneId
781
- ) {
782
- deps.syncProjectRootToWorktree(
783
- s.originalBasePath,
784
- s.basePath,
785
- s.currentMilestoneId,
786
- );
833
+ deps.invalidateAllCaches();
834
+
835
+ state = await deps.deriveState(s.basePath);
836
+ mid = state.activeMilestone?.id;
837
+ midTitle = state.activeMilestone?.title;
838
+
839
+ if (mid) {
840
+ if (deps.getIsolationMode() !== "none") {
841
+ deps.captureIntegrationBranch(s.basePath, mid, {
842
+ commitDocs: prefs?.git?.commit_docs,
843
+ });
787
844
  }
845
+ deps.resolver.enterMilestone(mid, ctx.ui);
846
+ } else {
847
+ // mid is undefined — no milestone to capture integration branch for
848
+ }
788
849
 
789
- // Derive state
790
- state = await deps.deriveState(s.basePath);
791
- deps.syncCmuxSidebar(prefs, state);
792
- mid = state.activeMilestone?.id;
793
- midTitle = state.activeMilestone?.title;
794
- debugLog("autoLoop", {
795
- phase: "state-derived",
796
- iteration,
797
- mid,
798
- statePhase: state.phase,
799
- });
850
+ const pendingIds = state.registry
851
+ .filter(
852
+ (m: { status: string }) =>
853
+ m.status !== "complete" && m.status !== "parked",
854
+ )
855
+ .map((m: { id: string }) => m.id);
856
+ deps.pruneQueueOrder(s.basePath, pendingIds);
857
+ }
800
858
 
801
- // ── Milestone transition ────────────────────────────────────────────
802
- if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
803
- ctx.ui.notify(
804
- `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
805
- "info",
806
- );
807
- deps.sendDesktopNotification(
808
- "GSD",
809
- `Milestone ${s.currentMilestoneId} complete!`,
810
- "success",
811
- "milestone",
812
- );
813
- deps.logCmuxEvent(
814
- prefs,
815
- `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
816
- "success",
817
- );
859
+ if (mid) {
860
+ s.currentMilestoneId = mid;
861
+ deps.setActiveMilestoneId(s.basePath, mid);
862
+ }
818
863
 
819
- const vizPrefs = prefs;
820
- if (vizPrefs?.auto_visualize) {
821
- ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
822
- }
823
- if (vizPrefs?.auto_report !== false) {
824
- try {
825
- await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
826
- } catch (err) {
827
- ctx.ui.notify(
828
- `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
829
- "warning",
830
- );
831
- }
832
- }
864
+ // ── Terminal conditions ──────────────────────────────────────────────
833
865
 
834
- // Reset dispatch counters for new milestone
835
- s.unitDispatchCount.clear();
836
- s.unitRecoveryCount.clear();
837
- s.unitLifetimeDispatches.clear();
838
- recentUnits.length = 0;
839
- stuckRecoveryAttempts = 0;
866
+ if (!mid) {
867
+ if (s.currentUnit) {
868
+ await deps.closeoutUnit(
869
+ ctx,
870
+ s.basePath,
871
+ s.currentUnit.type,
872
+ s.currentUnit.id,
873
+ s.currentUnit.startedAt,
874
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
875
+ );
876
+ }
840
877
 
841
- // Worktree lifecycle on milestone transition — merge current, enter next
842
- deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
878
+ const incomplete = state.registry.filter(
879
+ (m: { status: string }) =>
880
+ m.status !== "complete" && m.status !== "parked",
881
+ );
882
+ if (incomplete.length === 0 && state.registry.length > 0) {
883
+ // All milestones complete — merge milestone branch before stopping
884
+ if (s.currentMilestoneId) {
885
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
843
886
 
844
887
  // Opt-in: create draft PR on milestone completion
845
888
  if (prefs?.git?.auto_pr) {
@@ -847,7 +890,7 @@ export async function autoLoop(
847
890
  const { createDraftPR } = await import("./git-service.js");
848
891
  const prUrl = createDraftPR(
849
892
  s.basePath,
850
- s.currentMilestoneId!,
893
+ s.currentMilestoneId,
851
894
  `[GSD] ${s.currentMilestoneId} complete`,
852
895
  `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
853
896
  );
@@ -858,841 +901,952 @@ export async function autoLoop(
858
901
  // Non-fatal — PR creation is best-effort
859
902
  }
860
903
  }
904
+ }
905
+ deps.sendDesktopNotification(
906
+ "GSD",
907
+ "All milestones complete!",
908
+ "success",
909
+ "milestone",
910
+ );
911
+ deps.logCmuxEvent(
912
+ prefs,
913
+ "All milestones complete.",
914
+ "success",
915
+ );
916
+ await deps.stopAuto(ctx, pi, "All milestones complete");
917
+ } else if (incomplete.length === 0 && state.registry.length === 0) {
918
+ // Empty registry — no milestones visible, likely a path resolution bug
919
+ const diag = `basePath=${s.basePath}, phase=${state.phase}`;
920
+ ctx.ui.notify(
921
+ `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`,
922
+ "error",
923
+ );
924
+ await deps.stopAuto(
925
+ ctx,
926
+ pi,
927
+ `No milestones found — check basePath resolution`,
928
+ );
929
+ } else if (state.phase === "blocked") {
930
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
931
+ await deps.stopAuto(ctx, pi, blockerMsg);
932
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
933
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
934
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
935
+ } else {
936
+ const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
937
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
938
+ ctx.ui.notify(
939
+ `Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`,
940
+ "error",
941
+ );
942
+ await deps.stopAuto(
943
+ ctx,
944
+ pi,
945
+ `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`,
946
+ );
947
+ }
948
+ debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
949
+ return { action: "break", reason: "no-active-milestone" };
950
+ }
861
951
 
862
- deps.invalidateAllCaches();
952
+ if (!midTitle) {
953
+ midTitle = mid;
954
+ ctx.ui.notify(
955
+ `Milestone ${mid} has no title in roadmap — using ID as fallback.`,
956
+ "warning",
957
+ );
958
+ }
863
959
 
864
- state = await deps.deriveState(s.basePath);
865
- mid = state.activeMilestone?.id;
866
- midTitle = state.activeMilestone?.title;
960
+ // Mid-merge safety check
961
+ if (deps.reconcileMergeState(s.basePath, ctx)) {
962
+ deps.invalidateAllCaches();
963
+ state = await deps.deriveState(s.basePath);
964
+ mid = state.activeMilestone?.id;
965
+ midTitle = state.activeMilestone?.title;
966
+ }
967
+
968
+ if (!mid || !midTitle) {
969
+ const noMilestoneReason = !mid
970
+ ? "No active milestone after merge reconciliation"
971
+ : `Milestone ${mid} has no title after reconciliation`;
972
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
973
+ debugLog("autoLoop", {
974
+ phase: "exit",
975
+ reason: "no-milestone-after-reconciliation",
976
+ });
977
+ return { action: "break", reason: "no-milestone-after-reconciliation" };
978
+ }
867
979
 
868
- if (mid) {
869
- if (deps.getIsolationMode() !== "none") {
870
- deps.captureIntegrationBranch(s.basePath, mid, {
871
- commitDocs: prefs?.git?.commit_docs,
872
- });
980
+ // Terminal: complete
981
+ if (state.phase === "complete") {
982
+ // Milestone merge on complete (before closeout so branch state is clean)
983
+ if (s.currentMilestoneId) {
984
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
985
+
986
+ // Opt-in: create draft PR on milestone completion
987
+ if (prefs?.git?.auto_pr) {
988
+ try {
989
+ const { createDraftPR } = await import("./git-service.js");
990
+ const prUrl = createDraftPR(
991
+ s.basePath,
992
+ s.currentMilestoneId,
993
+ `[GSD] ${s.currentMilestoneId} complete`,
994
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
995
+ );
996
+ if (prUrl) {
997
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
873
998
  }
874
- deps.resolver.enterMilestone(mid, ctx.ui);
875
- } else {
876
- // mid is undefined — no milestone to capture integration branch for
999
+ } catch {
1000
+ // Non-fatal — PR creation is best-effort
877
1001
  }
878
-
879
- const pendingIds = state.registry
880
- .filter(
881
- (m: { status: string }) =>
882
- m.status !== "complete" && m.status !== "parked",
883
- )
884
- .map((m: { id: string }) => m.id);
885
- deps.pruneQueueOrder(s.basePath, pendingIds);
886
1002
  }
1003
+ }
1004
+ deps.sendDesktopNotification(
1005
+ "GSD",
1006
+ `Milestone ${mid} complete!`,
1007
+ "success",
1008
+ "milestone",
1009
+ );
1010
+ deps.logCmuxEvent(
1011
+ prefs,
1012
+ `Milestone ${mid} complete.`,
1013
+ "success",
1014
+ );
1015
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
1016
+ debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
1017
+ return { action: "break", reason: "milestone-complete" };
1018
+ }
887
1019
 
888
- if (mid) {
889
- s.currentMilestoneId = mid;
890
- deps.setActiveMilestoneId(s.basePath, mid);
891
- }
1020
+ // Terminal: blocked
1021
+ if (state.phase === "blocked") {
1022
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
1023
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
1024
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
1025
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
1026
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
1027
+ debugLog("autoLoop", { phase: "exit", reason: "blocked" });
1028
+ return { action: "break", reason: "blocked" };
1029
+ }
892
1030
 
893
- // ── Terminal conditions ──────────────────────────────────────────────
1031
+ return { action: "next", data: { state, mid, midTitle } };
1032
+ }
894
1033
 
895
- if (!mid) {
896
- if (s.currentUnit) {
897
- await deps.closeoutUnit(
898
- ctx,
899
- s.basePath,
900
- s.currentUnit.type,
901
- s.currentUnit.id,
902
- s.currentUnit.startedAt,
903
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
904
- );
905
- }
1034
+ // ─── runDispatch ──────────────────────────────────────────────────────────────
1035
+
1036
+ /**
1037
+ * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks.
1038
+ * Returns break/continue to control the loop, or next with IterationData on success.
1039
+ */
1040
+ async function runDispatch(
1041
+ ic: IterationContext,
1042
+ preData: PreDispatchData,
1043
+ loopState: LoopState,
1044
+ ): Promise<PhaseResult<IterationData>> {
1045
+ const { ctx, pi, s, deps, prefs } = ic;
1046
+ const { state, mid, midTitle } = preData;
1047
+ const STUCK_WINDOW_SIZE = 6;
906
1048
 
907
- const incomplete = state.registry.filter(
908
- (m: { status: string }) =>
909
- m.status !== "complete" && m.status !== "parked",
1049
+ debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration });
1050
+ const dispatchResult = await deps.resolveDispatch({
1051
+ basePath: s.basePath,
1052
+ mid,
1053
+ midTitle,
1054
+ state,
1055
+ prefs,
1056
+ session: s,
1057
+ });
1058
+
1059
+ if (dispatchResult.action === "stop") {
1060
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1061
+ debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1062
+ return { action: "break", reason: "dispatch-stop" };
1063
+ }
1064
+
1065
+ if (dispatchResult.action !== "dispatch") {
1066
+ // Non-dispatch action (e.g. "skip") — re-derive state
1067
+ await new Promise((r) => setImmediate(r));
1068
+ return { action: "continue" };
1069
+ }
1070
+
1071
+ let unitType = dispatchResult.unitType;
1072
+ let unitId = dispatchResult.unitId;
1073
+ let prompt = dispatchResult.prompt;
1074
+ const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1075
+
1076
+ // ── Sliding-window stuck detection with graduated recovery ──
1077
+ const derivedKey = `${unitType}/${unitId}`;
1078
+
1079
+ if (!s.pendingVerificationRetry) {
1080
+ loopState.recentUnits.push({ key: derivedKey });
1081
+ if (loopState.recentUnits.length > STUCK_WINDOW_SIZE) loopState.recentUnits.shift();
1082
+
1083
+ const stuckSignal = detectStuck(loopState.recentUnits);
1084
+ if (stuckSignal) {
1085
+ debugLog("autoLoop", {
1086
+ phase: "stuck-check",
1087
+ unitType,
1088
+ unitId,
1089
+ reason: stuckSignal.reason,
1090
+ recoveryAttempts: loopState.stuckRecoveryAttempts,
1091
+ });
1092
+
1093
+ if (loopState.stuckRecoveryAttempts === 0) {
1094
+ // Level 1: try verifying the artifact, then cache invalidation + retry
1095
+ loopState.stuckRecoveryAttempts++;
1096
+ const artifactExists = deps.verifyExpectedArtifact(
1097
+ unitType,
1098
+ unitId,
1099
+ s.basePath,
910
1100
  );
911
- if (incomplete.length === 0 && state.registry.length > 0) {
912
- // All milestones complete — merge milestone branch before stopping
913
- if (s.currentMilestoneId) {
914
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
915
-
916
- // Opt-in: create draft PR on milestone completion
917
- if (prefs?.git?.auto_pr) {
918
- try {
919
- const { createDraftPR } = await import("./git-service.js");
920
- const prUrl = createDraftPR(
921
- s.basePath,
922
- s.currentMilestoneId,
923
- `[GSD] ${s.currentMilestoneId} complete`,
924
- `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
925
- );
926
- if (prUrl) {
927
- ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
928
- }
929
- } catch {
930
- // Non-fatal — PR creation is best-effort
931
- }
932
- }
933
- }
934
- deps.sendDesktopNotification(
935
- "GSD",
936
- "All milestones complete!",
937
- "success",
938
- "milestone",
939
- );
940
- deps.logCmuxEvent(
941
- prefs,
942
- "All milestones complete.",
943
- "success",
944
- );
945
- await deps.stopAuto(ctx, pi, "All milestones complete");
946
- } else if (incomplete.length === 0 && state.registry.length === 0) {
947
- // Empty registry — no milestones visible, likely a path resolution bug
948
- const diag = `basePath=${s.basePath}, phase=${state.phase}`;
949
- ctx.ui.notify(
950
- `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`,
951
- "error",
952
- );
953
- await deps.stopAuto(
954
- ctx,
955
- pi,
956
- `No milestones found — check basePath resolution`,
957
- );
958
- } else if (state.phase === "blocked") {
959
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
960
- await deps.stopAuto(ctx, pi, blockerMsg);
961
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
962
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
963
- deps.logCmuxEvent(prefs, blockerMsg, "error");
964
- } else {
965
- const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
966
- const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1101
+ if (artifactExists) {
1102
+ debugLog("autoLoop", {
1103
+ phase: "stuck-recovery",
1104
+ level: 1,
1105
+ action: "artifact-found",
1106
+ });
967
1107
  ctx.ui.notify(
968
- `Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`,
969
- "error",
970
- );
971
- await deps.stopAuto(
972
- ctx,
973
- pi,
974
- `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`,
1108
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1109
+ "info",
975
1110
  );
1111
+ deps.invalidateAllCaches();
1112
+ return { action: "continue" };
976
1113
  }
977
- debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
978
- break;
979
- }
980
-
981
- if (!midTitle) {
982
- midTitle = mid;
983
1114
  ctx.ui.notify(
984
- `Milestone ${mid} has no title in roadmap — using ID as fallback.`,
1115
+ `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
985
1116
  "warning",
986
1117
  );
987
- }
988
-
989
- // Mid-merge safety check
990
- if (deps.reconcileMergeState(s.basePath, ctx)) {
991
1118
  deps.invalidateAllCaches();
992
- state = await deps.deriveState(s.basePath);
993
- mid = state.activeMilestone?.id;
994
- midTitle = state.activeMilestone?.title;
995
- }
996
-
997
- if (!mid || !midTitle) {
998
- const noMilestoneReason = !mid
999
- ? "No active milestone after merge reconciliation"
1000
- : `Milestone ${mid} has no title after reconciliation`;
1001
- await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
1119
+ } else {
1120
+ // Level 2: hard stop — genuinely stuck
1002
1121
  debugLog("autoLoop", {
1003
- phase: "exit",
1004
- reason: "no-milestone-after-reconciliation",
1122
+ phase: "stuck-detected",
1123
+ unitType,
1124
+ unitId,
1125
+ reason: stuckSignal.reason,
1005
1126
  });
1006
- break;
1007
- }
1008
-
1009
- // Terminal: complete
1010
- if (state.phase === "complete") {
1011
- // Milestone merge on complete (before closeout so branch state is clean)
1012
- if (s.currentMilestoneId) {
1013
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
1014
-
1015
- // Opt-in: create draft PR on milestone completion
1016
- if (prefs?.git?.auto_pr) {
1017
- try {
1018
- const { createDraftPR } = await import("./git-service.js");
1019
- const prUrl = createDraftPR(
1020
- s.basePath,
1021
- s.currentMilestoneId,
1022
- `[GSD] ${s.currentMilestoneId} complete`,
1023
- `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
1024
- );
1025
- if (prUrl) {
1026
- ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
1027
- }
1028
- } catch {
1029
- // Non-fatal — PR creation is best-effort
1030
- }
1031
- }
1032
- }
1033
- deps.sendDesktopNotification(
1034
- "GSD",
1035
- `Milestone ${mid} complete!`,
1036
- "success",
1037
- "milestone",
1127
+ await deps.stopAuto(
1128
+ ctx,
1129
+ pi,
1130
+ `Stuck: ${stuckSignal.reason}`,
1038
1131
  );
1039
- deps.logCmuxEvent(
1040
- prefs,
1041
- `Milestone ${mid} complete.`,
1042
- "success",
1132
+ ctx.ui.notify(
1133
+ `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`,
1134
+ "error",
1043
1135
  );
1044
- await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
1045
- debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
1046
- break;
1136
+ return { action: "break", reason: "stuck-detected" };
1047
1137
  }
1048
-
1049
- // Terminal: blocked
1050
- if (state.phase === "blocked") {
1051
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
1052
- await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
1053
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
1054
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
1055
- deps.logCmuxEvent(prefs, blockerMsg, "error");
1056
- debugLog("autoLoop", { phase: "exit", reason: "blocked" });
1057
- break;
1138
+ } else {
1139
+ // Progress detected — reset recovery counter
1140
+ if (loopState.stuckRecoveryAttempts > 0) {
1141
+ debugLog("autoLoop", {
1142
+ phase: "stuck-counter-reset",
1143
+ from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "",
1144
+ to: derivedKey,
1145
+ });
1146
+ loopState.stuckRecoveryAttempts = 0;
1058
1147
  }
1148
+ }
1149
+ }
1059
1150
 
1060
- // ── Phase 2: Guards ─────────────────────────────────────────────────
1061
-
1062
- // Budget ceiling guard
1063
- const budgetCeiling = prefs?.budget_ceiling;
1064
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
1065
- const currentLedger = deps.getLedger() as { units: unknown } | null;
1066
- const totalCost = currentLedger
1067
- ? deps.getProjectTotals(currentLedger.units).cost
1068
- : 0;
1069
- const budgetPct = totalCost / budgetCeiling;
1070
- const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
1071
- const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(
1072
- s.lastBudgetAlertLevel,
1073
- budgetPct,
1074
- );
1075
- const enforcement = prefs?.budget_enforcement ?? "pause";
1076
- const budgetEnforcementAction = deps.getBudgetEnforcementAction(
1077
- enforcement,
1078
- budgetPct,
1079
- );
1080
-
1081
- // Data-driven threshold check — loop descending, fire first match
1082
- const threshold = BUDGET_THRESHOLDS.find(
1083
- (t) => newBudgetAlertLevel >= t.pct,
1084
- );
1085
- if (threshold) {
1086
- s.lastBudgetAlertLevel =
1087
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1088
-
1089
- if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
1090
- // 100% — special enforcement logic (halt/pause/warn)
1091
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1092
- if (budgetEnforcementAction === "halt") {
1093
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
1094
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1095
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1096
- break;
1097
- }
1098
- if (budgetEnforcementAction === "pause") {
1099
- ctx.ui.notify(
1100
- `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1101
- "warning",
1102
- );
1103
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1104
- deps.logCmuxEvent(prefs, msg, "warning");
1105
- await deps.pauseAuto(ctx, pi);
1106
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1107
- break;
1108
- }
1109
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
1110
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1111
- deps.logCmuxEvent(prefs, msg, "warning");
1112
- } else if (threshold.pct < 100) {
1113
- // Sub-100% — simple notification
1114
- const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1115
- ctx.ui.notify(msg, threshold.notifyLevel);
1116
- deps.sendDesktopNotification(
1117
- "GSD",
1118
- msg,
1119
- threshold.notifyLevel,
1120
- "budget",
1121
- );
1122
- deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
1123
- }
1124
- } else if (budgetAlertLevel === 0) {
1125
- s.lastBudgetAlertLevel = 0;
1126
- }
1127
- } else {
1128
- s.lastBudgetAlertLevel = 0;
1129
- }
1151
+ // Pre-dispatch hooks
1152
+ const preDispatchResult = deps.runPreDispatchHooks(
1153
+ unitType,
1154
+ unitId,
1155
+ prompt,
1156
+ s.basePath,
1157
+ );
1158
+ if (preDispatchResult.firedHooks.length > 0) {
1159
+ ctx.ui.notify(
1160
+ `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
1161
+ "info",
1162
+ );
1163
+ }
1164
+ if (preDispatchResult.action === "skip") {
1165
+ ctx.ui.notify(
1166
+ `Skipping ${unitType} ${unitId} (pre-dispatch hook).`,
1167
+ "info",
1168
+ );
1169
+ await new Promise((r) => setImmediate(r));
1170
+ return { action: "continue" };
1171
+ }
1172
+ if (preDispatchResult.action === "replace") {
1173
+ prompt = preDispatchResult.prompt ?? prompt;
1174
+ if (preDispatchResult.unitType) unitType = preDispatchResult.unitType;
1175
+ } else if (preDispatchResult.prompt) {
1176
+ prompt = preDispatchResult.prompt;
1177
+ }
1130
1178
 
1131
- // Context window guard
1132
- const contextThreshold = prefs?.context_pause_threshold ?? 0;
1133
- if (contextThreshold > 0 && s.cmdCtx) {
1134
- const contextUsage = s.cmdCtx.getContextUsage();
1135
- if (
1136
- contextUsage &&
1137
- contextUsage.percent !== null &&
1138
- contextUsage.percent >= contextThreshold
1139
- ) {
1140
- const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
1179
+ const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(
1180
+ s.basePath,
1181
+ deps.getMainBranch(s.basePath),
1182
+ unitType,
1183
+ unitId,
1184
+ );
1185
+ if (priorSliceBlocker) {
1186
+ await deps.stopAuto(ctx, pi, priorSliceBlocker);
1187
+ debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
1188
+ return { action: "break", reason: "prior-slice-blocker" };
1189
+ }
1190
+
1191
+ const observabilityIssues = await deps.collectObservabilityWarnings(
1192
+ ctx,
1193
+ s.basePath,
1194
+ unitType,
1195
+ unitId,
1196
+ );
1197
+
1198
+ return {
1199
+ action: "next",
1200
+ data: {
1201
+ unitType, unitId, prompt, finalPrompt: prompt,
1202
+ pauseAfterUatDispatch, observabilityIssues,
1203
+ state, mid, midTitle,
1204
+ isRetry: false, previousTier: undefined,
1205
+ },
1206
+ };
1207
+ }
1208
+
1209
+ // ─── runGuards ────────────────────────────────────────────────────────────────
1210
+
1211
+ /**
1212
+ * Phase 2: Guards — budget ceiling, context window, secrets re-check.
1213
+ * Returns break to exit the loop, or next to proceed to dispatch.
1214
+ */
1215
+ async function runGuards(
1216
+ ic: IterationContext,
1217
+ mid: string,
1218
+ ): Promise<PhaseResult> {
1219
+ const { ctx, pi, s, deps, prefs } = ic;
1220
+
1221
+ // Budget ceiling guard
1222
+ const budgetCeiling = prefs?.budget_ceiling;
1223
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
1224
+ const currentLedger = deps.getLedger() as { units: unknown } | null;
1225
+ const totalCost = currentLedger
1226
+ ? deps.getProjectTotals(currentLedger.units).cost
1227
+ : 0;
1228
+ const budgetPct = totalCost / budgetCeiling;
1229
+ const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
1230
+ const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(
1231
+ s.lastBudgetAlertLevel,
1232
+ budgetPct,
1233
+ );
1234
+ const enforcement = prefs?.budget_enforcement ?? "pause";
1235
+ const budgetEnforcementAction = deps.getBudgetEnforcementAction(
1236
+ enforcement,
1237
+ budgetPct,
1238
+ );
1239
+
1240
+ // Data-driven threshold check — loop descending, fire first match
1241
+ const threshold = BUDGET_THRESHOLDS.find(
1242
+ (t) => newBudgetAlertLevel >= t.pct,
1243
+ );
1244
+ if (threshold) {
1245
+ s.lastBudgetAlertLevel =
1246
+ newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1247
+
1248
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
1249
+ // 100% — special enforcement logic (halt/pause/warn)
1250
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1251
+ if (budgetEnforcementAction === "halt") {
1252
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
1253
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1254
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1255
+ return { action: "break", reason: "budget-halt" };
1256
+ }
1257
+ if (budgetEnforcementAction === "pause") {
1141
1258
  ctx.ui.notify(
1142
- `${msg} Run /gsd auto to continue (will start fresh session).`,
1143
- "warning",
1144
- );
1145
- deps.sendDesktopNotification(
1146
- "GSD",
1147
- `Context ${contextUsage.percent}% — paused`,
1259
+ `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1148
1260
  "warning",
1149
- "attention",
1150
1261
  );
1262
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1263
+ deps.logCmuxEvent(prefs, msg, "warning");
1151
1264
  await deps.pauseAuto(ctx, pi);
1152
- debugLog("autoLoop", { phase: "exit", reason: "context-window" });
1153
- break;
1265
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1266
+ return { action: "break", reason: "budget-pause" };
1154
1267
  }
1268
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
1269
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1270
+ deps.logCmuxEvent(prefs, msg, "warning");
1271
+ } else if (threshold.pct < 100) {
1272
+ // Sub-100% — simple notification
1273
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1274
+ ctx.ui.notify(msg, threshold.notifyLevel);
1275
+ deps.sendDesktopNotification(
1276
+ "GSD",
1277
+ msg,
1278
+ threshold.notifyLevel,
1279
+ "budget",
1280
+ );
1281
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
1155
1282
  }
1283
+ } else if (budgetAlertLevel === 0) {
1284
+ s.lastBudgetAlertLevel = 0;
1285
+ }
1286
+ } else {
1287
+ s.lastBudgetAlertLevel = 0;
1288
+ }
1156
1289
 
1157
- // Secrets re-check gate
1158
- try {
1159
- const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
1160
- if (manifestStatus && manifestStatus.pending.length > 0) {
1161
- const result = await deps.collectSecretsFromManifest(
1162
- s.basePath,
1163
- mid,
1164
- ctx,
1165
- );
1166
- if (
1167
- result &&
1168
- result.applied &&
1169
- result.skipped &&
1170
- result.existingSkipped
1171
- ) {
1172
- ctx.ui.notify(
1173
- `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
1174
- "info",
1175
- );
1176
- } else {
1177
- ctx.ui.notify("Secrets collection skipped.", "info");
1178
- }
1179
- }
1180
- } catch (err) {
1290
+ // Context window guard
1291
+ const contextThreshold = prefs?.context_pause_threshold ?? 0;
1292
+ if (contextThreshold > 0 && s.cmdCtx) {
1293
+ const contextUsage = s.cmdCtx.getContextUsage();
1294
+ if (
1295
+ contextUsage &&
1296
+ contextUsage.percent !== null &&
1297
+ contextUsage.percent >= contextThreshold
1298
+ ) {
1299
+ const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
1300
+ ctx.ui.notify(
1301
+ `${msg} Run /gsd auto to continue (will start fresh session).`,
1302
+ "warning",
1303
+ );
1304
+ deps.sendDesktopNotification(
1305
+ "GSD",
1306
+ `Context ${contextUsage.percent}% paused`,
1307
+ "warning",
1308
+ "attention",
1309
+ );
1310
+ await deps.pauseAuto(ctx, pi);
1311
+ debugLog("autoLoop", { phase: "exit", reason: "context-window" });
1312
+ return { action: "break", reason: "context-window" };
1313
+ }
1314
+ }
1315
+
1316
+ // Secrets re-check gate
1317
+ try {
1318
+ const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
1319
+ if (manifestStatus && manifestStatus.pending.length > 0) {
1320
+ const result = await deps.collectSecretsFromManifest(
1321
+ s.basePath,
1322
+ mid,
1323
+ ctx,
1324
+ );
1325
+ if (
1326
+ result &&
1327
+ result.applied &&
1328
+ result.skipped &&
1329
+ result.existingSkipped
1330
+ ) {
1181
1331
  ctx.ui.notify(
1182
- `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
1183
- "warning",
1332
+ `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
1333
+ "info",
1184
1334
  );
1335
+ } else {
1336
+ ctx.ui.notify("Secrets collection skipped.", "info");
1185
1337
  }
1338
+ }
1339
+ } catch (err) {
1340
+ ctx.ui.notify(
1341
+ `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
1342
+ "warning",
1343
+ );
1344
+ }
1186
1345
 
1187
- // ── Phase 3: Dispatch resolution ────────────────────────────────────
1346
+ return { action: "next", data: undefined as void };
1347
+ }
1188
1348
 
1189
- debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
1190
- const dispatchResult = await deps.resolveDispatch({
1191
- basePath: s.basePath,
1192
- mid,
1193
- midTitle: midTitle!,
1194
- state,
1195
- prefs,
1196
- session: s,
1197
- });
1349
+ // ─── runUnitPhase ─────────────────────────────────────────────────────────────
1198
1350
 
1199
- if (dispatchResult.action === "stop") {
1200
- await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1201
- debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1202
- break;
1203
- }
1351
+ /**
1352
+ * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify.
1353
+ * Returns break or next with unitStartedAt for downstream phases.
1354
+ */
1355
+ async function runUnitPhase(
1356
+ ic: IterationContext,
1357
+ iterData: IterationData,
1358
+ loopState: LoopState,
1359
+ sidecarItem?: SidecarItem,
1360
+ ): Promise<PhaseResult<{ unitStartedAt: number }>> {
1361
+ const { ctx, pi, s, deps, prefs } = ic;
1362
+ const { unitType, unitId, prompt, observabilityIssues, state, mid } = iterData;
1363
+
1364
+ debugLog("autoLoop", {
1365
+ phase: "unit-execution",
1366
+ iteration: ic.iteration,
1367
+ unitType,
1368
+ unitId,
1369
+ });
1204
1370
 
1205
- if (dispatchResult.action !== "dispatch") {
1206
- // Non-dispatch action (e.g. "skip") — re-derive state
1207
- await new Promise((r) => setImmediate(r));
1208
- continue;
1209
- }
1371
+ // Detect retry and capture previous tier for escalation
1372
+ const isRetry = !!(
1373
+ s.currentUnit &&
1374
+ s.currentUnit.type === unitType &&
1375
+ s.currentUnit.id === unitId
1376
+ );
1377
+ const previousTier = s.currentUnitRouting?.tier;
1210
1378
 
1211
- unitType = dispatchResult.unitType;
1212
- unitId = dispatchResult.unitId;
1213
- prompt = dispatchResult.prompt;
1214
- pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1379
+ s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1380
+ deps.captureAvailableSkills();
1381
+ deps.writeUnitRuntimeRecord(
1382
+ s.basePath,
1383
+ unitType,
1384
+ unitId,
1385
+ s.currentUnit.startedAt,
1386
+ {
1387
+ phase: "dispatched",
1388
+ wrapupWarningSent: false,
1389
+ timeoutAt: null,
1390
+ lastProgressAt: s.currentUnit.startedAt,
1391
+ progressCount: 0,
1392
+ lastProgressKind: "dispatch",
1393
+ },
1394
+ );
1215
1395
 
1216
- // ── Sliding-window stuck detection with graduated recovery ──
1217
- const derivedKey = `${unitType}/${unitId}`;
1396
+ // Status bar + progress widget
1397
+ ctx.ui.setStatus("gsd-auto", "auto");
1398
+ if (mid)
1399
+ deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
1400
+ deps.updateProgressWidget(ctx, unitType, unitId, state);
1401
+
1402
+ deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1403
+
1404
+ // Prompt injection
1405
+ let finalPrompt = prompt;
1406
+
1407
+ if (s.pendingVerificationRetry) {
1408
+ const retryCtx = s.pendingVerificationRetry;
1409
+ s.pendingVerificationRetry = null;
1410
+ const capped =
1411
+ retryCtx.failureContext.length > MAX_RECOVERY_CHARS
1412
+ ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
1413
+ "\n\n[...failure context truncated]"
1414
+ : retryCtx.failureContext;
1415
+ 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}`;
1416
+ }
1218
1417
 
1219
- if (!s.pendingVerificationRetry) {
1220
- recentUnits.push({ key: derivedKey });
1221
- if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift();
1418
+ if (s.pendingCrashRecovery) {
1419
+ const capped =
1420
+ s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
1421
+ ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
1422
+ "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
1423
+ : s.pendingCrashRecovery;
1424
+ finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
1425
+ s.pendingCrashRecovery = null;
1426
+ } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
1427
+ const diagnostic = deps.getDeepDiagnostic(s.basePath);
1428
+ if (diagnostic) {
1429
+ const cappedDiag =
1430
+ diagnostic.length > MAX_RECOVERY_CHARS
1431
+ ? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
1432
+ "\n\n[...diagnostic truncated to prevent memory exhaustion]"
1433
+ : diagnostic;
1434
+ 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}`;
1435
+ }
1436
+ }
1222
1437
 
1223
- const stuckSignal = detectStuck(recentUnits);
1224
- if (stuckSignal) {
1225
- debugLog("autoLoop", {
1226
- phase: "stuck-check",
1227
- unitType,
1228
- unitId,
1229
- reason: stuckSignal.reason,
1230
- recoveryAttempts: stuckRecoveryAttempts,
1231
- });
1438
+ const repairBlock =
1439
+ deps.buildObservabilityRepairBlock(observabilityIssues);
1440
+ if (repairBlock) {
1441
+ finalPrompt = `${finalPrompt}${repairBlock}`;
1442
+ }
1232
1443
 
1233
- if (stuckRecoveryAttempts === 0) {
1234
- // Level 1: try verifying the artifact, then cache invalidation + retry
1235
- stuckRecoveryAttempts++;
1236
- const artifactExists = deps.verifyExpectedArtifact(
1237
- unitType,
1238
- unitId,
1239
- s.basePath,
1240
- );
1241
- if (artifactExists) {
1242
- debugLog("autoLoop", {
1243
- phase: "stuck-recovery",
1244
- level: 1,
1245
- action: "artifact-found",
1246
- });
1247
- ctx.ui.notify(
1248
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1249
- "info",
1250
- );
1251
- deps.invalidateAllCaches();
1252
- continue;
1253
- }
1254
- ctx.ui.notify(
1255
- `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
1256
- "warning",
1257
- );
1258
- deps.invalidateAllCaches();
1259
- } else {
1260
- // Level 2: hard stop — genuinely stuck
1261
- debugLog("autoLoop", {
1262
- phase: "stuck-detected",
1263
- unitType,
1264
- unitId,
1265
- reason: stuckSignal.reason,
1266
- });
1267
- await deps.stopAuto(
1268
- ctx,
1269
- pi,
1270
- `Stuck: ${stuckSignal.reason}`,
1271
- );
1272
- ctx.ui.notify(
1273
- `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`,
1274
- "error",
1275
- );
1276
- break;
1277
- }
1278
- } else {
1279
- // Progress detected — reset recovery counter
1280
- if (stuckRecoveryAttempts > 0) {
1281
- debugLog("autoLoop", {
1282
- phase: "stuck-counter-reset",
1283
- from: recentUnits[recentUnits.length - 2]?.key ?? "",
1284
- to: derivedKey,
1285
- });
1286
- stuckRecoveryAttempts = 0;
1287
- }
1288
- }
1289
- }
1444
+ // Prompt char measurement
1445
+ s.lastPromptCharCount = finalPrompt.length;
1446
+ s.lastBaselineCharCount = undefined;
1447
+ if (deps.isDbAvailable()) {
1448
+ try {
1449
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1450
+ const [decisionsContent, requirementsContent, projectContent] =
1451
+ await Promise.all([
1452
+ inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
1453
+ inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
1454
+ inlineGsdRootFile(s.basePath, "project.md", "Project"),
1455
+ ]);
1456
+ s.lastBaselineCharCount =
1457
+ (decisionsContent?.length ?? 0) +
1458
+ (requirementsContent?.length ?? 0) +
1459
+ (projectContent?.length ?? 0);
1460
+ } catch {
1461
+ // Non-fatal
1462
+ }
1463
+ }
1290
1464
 
1291
- // Pre-dispatch hooks
1292
- const preDispatchResult = deps.runPreDispatchHooks(
1293
- unitType,
1294
- unitId,
1295
- prompt,
1296
- s.basePath,
1297
- );
1298
- if (preDispatchResult.firedHooks.length > 0) {
1299
- ctx.ui.notify(
1300
- `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
1301
- "info",
1302
- );
1303
- }
1304
- if (preDispatchResult.action === "skip") {
1305
- ctx.ui.notify(
1306
- `Skipping ${unitType} ${unitId} (pre-dispatch hook).`,
1307
- "info",
1308
- );
1309
- await new Promise((r) => setImmediate(r));
1310
- continue;
1311
- }
1312
- if (preDispatchResult.action === "replace") {
1313
- prompt = preDispatchResult.prompt ?? prompt;
1314
- if (preDispatchResult.unitType) unitType = preDispatchResult.unitType;
1315
- } else if (preDispatchResult.prompt) {
1316
- prompt = preDispatchResult.prompt;
1317
- }
1465
+ // Cache-optimize prompt section ordering
1466
+ try {
1467
+ finalPrompt = deps.reorderForCaching(finalPrompt);
1468
+ } catch (reorderErr) {
1469
+ const msg =
1470
+ reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
1471
+ process.stderr.write(
1472
+ `[gsd] prompt reorder failed (non-fatal): ${msg}\n`,
1473
+ );
1474
+ }
1318
1475
 
1319
- const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(
1320
- s.basePath,
1321
- deps.getMainBranch(s.basePath),
1322
- unitType,
1323
- unitId,
1324
- );
1325
- if (priorSliceBlocker) {
1326
- await deps.stopAuto(ctx, pi, priorSliceBlocker);
1327
- debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
1328
- break;
1329
- }
1476
+ // Select and apply model (with tier escalation on retry — normal units only)
1477
+ const modelResult = await deps.selectAndApplyModel(
1478
+ ctx,
1479
+ pi,
1480
+ unitType,
1481
+ unitId,
1482
+ s.basePath,
1483
+ prefs,
1484
+ s.verbose,
1485
+ s.autoModeStartModel,
1486
+ sidecarItem ? undefined : { isRetry, previousTier },
1487
+ );
1488
+ s.currentUnitRouting =
1489
+ modelResult.routing as AutoSession["currentUnitRouting"];
1490
+
1491
+ // Start unit supervision
1492
+ deps.clearUnitTimeout();
1493
+ deps.startUnitSupervision({
1494
+ s,
1495
+ ctx,
1496
+ pi,
1497
+ unitType,
1498
+ unitId,
1499
+ prefs,
1500
+ buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
1501
+ buildRecoveryContext: () => ({}),
1502
+ pauseAuto: deps.pauseAuto,
1503
+ });
1330
1504
 
1331
- observabilityIssues = await deps.collectObservabilityWarnings(
1332
- ctx,
1333
- s.basePath,
1334
- unitType,
1335
- unitId,
1336
- );
1505
+ // Session + send + await
1506
+ const sessionFile = deps.getSessionFile(ctx);
1507
+ deps.updateSessionLock(
1508
+ deps.lockBase(),
1509
+ unitType,
1510
+ unitId,
1511
+ s.completedUnits.length,
1512
+ sessionFile,
1513
+ );
1514
+ deps.writeLock(
1515
+ deps.lockBase(),
1516
+ unitType,
1517
+ unitId,
1518
+ s.completedUnits.length,
1519
+ sessionFile,
1520
+ );
1337
1521
 
1338
- // Derive state for shared use in execution phase
1339
- // (state, mid, midTitle already set above)
1522
+ debugLog("autoLoop", {
1523
+ phase: "runUnit-start",
1524
+ iteration: ic.iteration,
1525
+ unitType,
1526
+ unitId,
1527
+ });
1528
+ const unitResult = await runUnit(
1529
+ ctx,
1530
+ pi,
1531
+ s,
1532
+ unitType,
1533
+ unitId,
1534
+ finalPrompt,
1535
+ );
1536
+ debugLog("autoLoop", {
1537
+ phase: "runUnit-end",
1538
+ iteration: ic.iteration,
1539
+ unitType,
1540
+ unitId,
1541
+ status: unitResult.status,
1542
+ });
1340
1543
 
1341
- } else {
1342
- // ── Sidecar path: use values from the sidecar item directly ──
1343
- unitType = sidecarItem.unitType;
1344
- unitId = sidecarItem.unitId;
1345
- prompt = sidecarItem.prompt;
1346
- // Derive minimal state for progress widget / execution context
1347
- state = await deps.deriveState(s.basePath);
1348
- mid = state.activeMilestone?.id;
1349
- midTitle = state.activeMilestone?.title;
1544
+ // Tag the most recent window entry with error info for stuck detection
1545
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
1546
+ const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
1547
+ if (lastEntry) {
1548
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
1549
+ }
1550
+ } else if (unitResult.event?.messages?.length) {
1551
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
1552
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
1553
+ if (/error|fail|exception/i.test(msgStr)) {
1554
+ const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
1555
+ if (lastEntry) {
1556
+ lastEntry.error = msgStr.slice(0, 200);
1350
1557
  }
1558
+ }
1559
+ }
1351
1560
 
1352
- // ── Phase 4: Unit execution ─────────────────────────────────────────
1353
-
1354
- debugLog("autoLoop", {
1355
- phase: "unit-execution",
1356
- iteration,
1357
- unitType,
1358
- unitId,
1359
- });
1360
-
1361
- // Detect retry and capture previous tier for escalation
1362
- const isRetry = !!(
1363
- s.currentUnit &&
1364
- s.currentUnit.type === unitType &&
1365
- s.currentUnit.id === unitId
1366
- );
1367
- const previousTier = s.currentUnitRouting?.tier;
1561
+ if (unitResult.status === "cancelled") {
1562
+ ctx.ui.notify(
1563
+ `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
1564
+ "warning",
1565
+ );
1566
+ await deps.stopAuto(ctx, pi, "Session creation failed");
1567
+ debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
1568
+ return { action: "break", reason: "session-failed" };
1569
+ }
1368
1570
 
1369
- s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1370
- deps.captureAvailableSkills();
1371
- deps.writeUnitRuntimeRecord(
1372
- s.basePath,
1373
- unitType,
1374
- unitId,
1375
- s.currentUnit.startedAt,
1376
- {
1377
- phase: "dispatched",
1378
- wrapupWarningSent: false,
1379
- timeoutAt: null,
1380
- lastProgressAt: s.currentUnit.startedAt,
1381
- progressCount: 0,
1382
- lastProgressKind: "dispatch",
1383
- },
1384
- );
1571
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
1572
+ // Run right after runUnit() returns so telemetry is never lost to a
1573
+ // crash between iterations.
1574
+ await deps.closeoutUnit(
1575
+ ctx,
1576
+ s.basePath,
1577
+ unitType,
1578
+ unitId,
1579
+ s.currentUnit.startedAt,
1580
+ deps.buildSnapshotOpts(unitType, unitId),
1581
+ );
1385
1582
 
1386
- // Status bar + progress widget
1387
- ctx.ui.setStatus("gsd-auto", "auto");
1388
- if (mid)
1389
- deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
1390
- deps.updateProgressWidget(ctx, unitType, unitId, state);
1391
-
1392
- deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1393
-
1394
- // Prompt injection
1395
- let finalPrompt = prompt;
1396
-
1397
- if (s.pendingVerificationRetry) {
1398
- const retryCtx = s.pendingVerificationRetry;
1399
- s.pendingVerificationRetry = null;
1400
- const capped =
1401
- retryCtx.failureContext.length > MAX_RECOVERY_CHARS
1402
- ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
1403
- "\n\n[...failure context truncated]"
1404
- : retryCtx.failureContext;
1405
- 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}`;
1406
- }
1583
+ if (s.currentUnitRouting) {
1584
+ deps.recordOutcome(
1585
+ unitType,
1586
+ s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1587
+ true, // success assumed; dispatch will re-dispatch if artifact missing
1588
+ );
1589
+ }
1407
1590
 
1408
- if (s.pendingCrashRecovery) {
1409
- const capped =
1410
- s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
1411
- ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
1412
- "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
1413
- : s.pendingCrashRecovery;
1414
- finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
1415
- s.pendingCrashRecovery = null;
1416
- } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
1417
- const diagnostic = deps.getDeepDiagnostic(s.basePath);
1418
- if (diagnostic) {
1419
- const cappedDiag =
1420
- diagnostic.length > MAX_RECOVERY_CHARS
1421
- ? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
1422
- "\n\n[...diagnostic truncated to prevent memory exhaustion]"
1423
- : diagnostic;
1424
- 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}`;
1425
- }
1426
- }
1591
+ const isHookUnit = unitType.startsWith("hook/");
1592
+ const artifactVerified =
1593
+ isHookUnit ||
1594
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
1595
+ if (artifactVerified) {
1596
+ s.completedUnits.push({
1597
+ type: unitType,
1598
+ id: unitId,
1599
+ startedAt: s.currentUnit.startedAt,
1600
+ finishedAt: Date.now(),
1601
+ });
1602
+ if (s.completedUnits.length > 200) {
1603
+ s.completedUnits = s.completedUnits.slice(-200);
1604
+ }
1605
+ // Flush completed-units to disk so the record survives crashes
1606
+ try {
1607
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
1608
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
1609
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
1610
+ } catch { /* non-fatal: disk flush failure */ }
1611
+
1612
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
1613
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
1614
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
1615
+ }
1427
1616
 
1428
- const repairBlock =
1429
- deps.buildObservabilityRepairBlock(observabilityIssues);
1430
- if (repairBlock) {
1431
- finalPrompt = `${finalPrompt}${repairBlock}`;
1432
- }
1617
+ return { action: "next", data: { unitStartedAt: s.currentUnit.startedAt } };
1618
+ }
1433
1619
 
1434
- // Prompt char measurement
1435
- s.lastPromptCharCount = finalPrompt.length;
1436
- s.lastBaselineCharCount = undefined;
1437
- if (deps.isDbAvailable()) {
1438
- try {
1439
- const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1440
- const [decisionsContent, requirementsContent, projectContent] =
1441
- await Promise.all([
1442
- inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
1443
- inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
1444
- inlineGsdRootFile(s.basePath, "project.md", "Project"),
1445
- ]);
1446
- s.lastBaselineCharCount =
1447
- (decisionsContent?.length ?? 0) +
1448
- (requirementsContent?.length ?? 0) +
1449
- (projectContent?.length ?? 0);
1450
- } catch {
1451
- // Non-fatal
1452
- }
1453
- }
1620
+ // ─── runFinalize ──────────────────────────────────────────────────────────────
1454
1621
 
1455
- // Cache-optimize prompt section ordering
1456
- try {
1457
- finalPrompt = deps.reorderForCaching(finalPrompt);
1458
- } catch (reorderErr) {
1459
- const msg =
1460
- reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
1461
- process.stderr.write(
1462
- `[gsd] prompt reorder failed (non-fatal): ${msg}\n`,
1463
- );
1464
- }
1622
+ /**
1623
+ * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard.
1624
+ * Returns break/continue/next to control the outer loop.
1625
+ */
1626
+ async function runFinalize(
1627
+ ic: IterationContext,
1628
+ iterData: IterationData,
1629
+ sidecarItem?: SidecarItem,
1630
+ ): Promise<PhaseResult> {
1631
+ const { ctx, pi, s, deps } = ic;
1632
+ const { pauseAfterUatDispatch } = iterData;
1633
+
1634
+ debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration });
1635
+
1636
+ // Clear unit timeout (unit completed)
1637
+ deps.clearUnitTimeout();
1638
+
1639
+ // Post-unit context for pre/post verification
1640
+ const postUnitCtx: PostUnitContext = {
1641
+ s,
1642
+ ctx,
1643
+ pi,
1644
+ buildSnapshotOpts: deps.buildSnapshotOpts,
1645
+ lockBase: deps.lockBase,
1646
+ stopAuto: deps.stopAuto,
1647
+ pauseAuto: deps.pauseAuto,
1648
+ updateProgressWidget: deps.updateProgressWidget,
1649
+ };
1465
1650
 
1466
- // Select and apply model (with tier escalation on retry — normal units only)
1467
- const modelResult = await deps.selectAndApplyModel(
1468
- ctx,
1469
- pi,
1470
- unitType,
1471
- unitId,
1472
- s.basePath,
1473
- prefs,
1474
- s.verbose,
1475
- s.autoModeStartModel,
1476
- sidecarItem ? undefined : { isRetry, previousTier },
1477
- );
1478
- s.currentUnitRouting =
1479
- modelResult.routing as AutoSession["currentUnitRouting"];
1651
+ // Pre-verification processing (commit, doctor, state rebuild, etc.)
1652
+ // Sidecar items use lightweight pre-verification opts
1653
+ const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem
1654
+ ? sidecarItem.kind === "hook"
1655
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1656
+ : { skipSettleDelay: true, skipStateRebuild: true }
1657
+ : undefined;
1658
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
1659
+ if (preResult === "dispatched") {
1660
+ debugLog("autoLoop", {
1661
+ phase: "exit",
1662
+ reason: "pre-verification-dispatched",
1663
+ });
1664
+ return { action: "break", reason: "pre-verification-dispatched" };
1665
+ }
1480
1666
 
1481
- // Start unit supervision
1482
- deps.clearUnitTimeout();
1483
- deps.startUnitSupervision({
1484
- s,
1485
- ctx,
1486
- pi,
1487
- unitType,
1488
- unitId,
1489
- prefs,
1490
- buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
1491
- buildRecoveryContext: () => ({}),
1492
- pauseAuto: deps.pauseAuto,
1493
- });
1667
+ if (pauseAfterUatDispatch) {
1668
+ ctx.ui.notify(
1669
+ "UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
1670
+ "info",
1671
+ );
1672
+ await deps.pauseAuto(ctx, pi);
1673
+ debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
1674
+ return { action: "break", reason: "uat-pause" };
1675
+ }
1494
1676
 
1495
- // Session + send + await
1496
- const sessionFile = deps.getSessionFile(ctx);
1497
- deps.updateSessionLock(
1498
- deps.lockBase(),
1499
- unitType,
1500
- unitId,
1501
- s.completedUnits.length,
1502
- sessionFile,
1503
- );
1504
- deps.writeLock(
1505
- deps.lockBase(),
1506
- unitType,
1507
- unitId,
1508
- s.completedUnits.length,
1509
- sessionFile,
1510
- );
1677
+ // Verification gate
1678
+ // Hook sidecar items skip verification entirely.
1679
+ // Non-hook sidecar items run verification but skip retries (just continue).
1680
+ const skipVerification = sidecarItem?.kind === "hook";
1681
+ if (!skipVerification) {
1682
+ const verificationResult = await deps.runPostUnitVerification(
1683
+ { s, ctx, pi },
1684
+ deps.pauseAuto,
1685
+ );
1511
1686
 
1512
- debugLog("autoLoop", {
1513
- phase: "runUnit-start",
1514
- iteration,
1515
- unitType,
1516
- unitId,
1517
- });
1518
- const unitResult = await runUnit(
1519
- ctx,
1520
- pi,
1521
- s,
1522
- unitType,
1523
- unitId,
1524
- finalPrompt,
1525
- );
1526
- debugLog("autoLoop", {
1527
- phase: "runUnit-end",
1528
- iteration,
1529
- unitType,
1530
- unitId,
1531
- status: unitResult.status,
1532
- });
1687
+ if (verificationResult === "pause") {
1688
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1689
+ return { action: "break", reason: "verification-pause" };
1690
+ }
1533
1691
 
1534
- // Tag the most recent window entry with error info for stuck detection
1535
- if (unitResult.status === "error" || unitResult.status === "cancelled") {
1536
- const lastEntry = recentUnits[recentUnits.length - 1];
1537
- if (lastEntry) {
1538
- lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
1539
- }
1540
- } else if (unitResult.event?.messages?.length) {
1541
- const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
1542
- const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
1543
- if (/error|fail|exception/i.test(msgStr)) {
1544
- const lastEntry = recentUnits[recentUnits.length - 1];
1545
- if (lastEntry) {
1546
- lastEntry.error = msgStr.slice(0, 200);
1547
- }
1548
- }
1692
+ if (verificationResult === "retry") {
1693
+ if (sidecarItem) {
1694
+ // Sidecar verification retries are skipped — just continue
1695
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration: ic.iteration });
1696
+ } else {
1697
+ // s.pendingVerificationRetry was set by runPostUnitVerification.
1698
+ // Continue the loop — next iteration will inject the retry context into the prompt.
1699
+ debugLog("autoLoop", { phase: "verification-retry", iteration: ic.iteration });
1700
+ return { action: "continue" };
1549
1701
  }
1702
+ }
1703
+ }
1550
1704
 
1551
- if (unitResult.status === "cancelled") {
1552
- ctx.ui.notify(
1553
- `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
1554
- "warning",
1555
- );
1556
- await deps.stopAuto(ctx, pi, "Session creation failed");
1557
- debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
1558
- break;
1559
- }
1705
+ // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
1706
+ const postResult = await deps.postUnitPostVerification(postUnitCtx);
1560
1707
 
1561
- // ── Immediate unit closeout (metrics, activity log, memory) ────────
1562
- // Run right after runUnit() returns so telemetry is never lost to a
1563
- // crash between iterations.
1564
- await deps.closeoutUnit(
1565
- ctx,
1566
- s.basePath,
1567
- unitType,
1568
- unitId,
1569
- s.currentUnit.startedAt,
1570
- deps.buildSnapshotOpts(unitType, unitId),
1571
- );
1708
+ if (postResult === "stopped") {
1709
+ debugLog("autoLoop", {
1710
+ phase: "exit",
1711
+ reason: "post-verification-stopped",
1712
+ });
1713
+ return { action: "break", reason: "post-verification-stopped" };
1714
+ }
1572
1715
 
1573
- if (s.currentUnitRouting) {
1574
- deps.recordOutcome(
1575
- unitType,
1576
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1577
- true, // success assumed; dispatch will re-dispatch if artifact missing
1578
- );
1579
- }
1716
+ if (postResult === "step-wizard") {
1717
+ // Step mode — exit the loop (caller handles wizard)
1718
+ debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
1719
+ return { action: "break", reason: "step-wizard" };
1720
+ }
1580
1721
 
1581
- const isHookUnit = unitType.startsWith("hook/");
1582
- const artifactVerified =
1583
- isHookUnit ||
1584
- deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
1585
- if (artifactVerified) {
1586
- s.completedUnits.push({
1587
- type: unitType,
1588
- id: unitId,
1589
- startedAt: s.currentUnit.startedAt,
1590
- finishedAt: Date.now(),
1591
- });
1592
- if (s.completedUnits.length > 200) {
1593
- s.completedUnits = s.completedUnits.slice(-200);
1594
- }
1595
- // Flush completed-units to disk so the record survives crashes
1596
- try {
1597
- const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
1598
- const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
1599
- atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
1600
- } catch { /* non-fatal: disk flush failure */ }
1601
-
1602
- deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
1603
- s.unitDispatchCount.delete(`${unitType}/${unitId}`);
1604
- s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
1605
- }
1722
+ return { action: "next", data: undefined as void };
1723
+ }
1606
1724
 
1607
- // ── Phase 5: Finalize ───────────────────────────────────────────────
1725
+ // ─── autoLoop ────────────────────────────────────────────────────────────────
1608
1726
 
1609
- debugLog("autoLoop", { phase: "finalize", iteration });
1727
+ /**
1728
+ * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
1729
+ * runUnit → finalize → repeat. Exits when s.active becomes false or a
1730
+ * terminal condition is reached.
1731
+ *
1732
+ * This is the linear replacement for the recursive
1733
+ * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain.
1734
+ */
1735
+ export async function autoLoop(
1736
+ ctx: ExtensionContext,
1737
+ pi: ExtensionAPI,
1738
+ s: AutoSession,
1739
+ deps: LoopDeps,
1740
+ ): Promise<void> {
1741
+ debugLog("autoLoop", { phase: "enter" });
1742
+ let iteration = 0;
1743
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
1744
+ let consecutiveErrors = 0;
1610
1745
 
1611
- // Clear unit timeout (unit completed)
1612
- deps.clearUnitTimeout();
1746
+ while (s.active) {
1747
+ iteration++;
1748
+ debugLog("autoLoop", { phase: "loop-top", iteration });
1613
1749
 
1614
- // Post-unit context for pre/post verification
1615
- const postUnitCtx: PostUnitContext = {
1616
- s,
1750
+ if (iteration > MAX_LOOP_ITERATIONS) {
1751
+ debugLog("autoLoop", {
1752
+ phase: "exit",
1753
+ reason: "max-iterations",
1754
+ iteration,
1755
+ });
1756
+ await deps.stopAuto(
1617
1757
  ctx,
1618
1758
  pi,
1619
- buildSnapshotOpts: deps.buildSnapshotOpts,
1620
- lockBase: deps.lockBase,
1621
- stopAuto: deps.stopAuto,
1622
- pauseAuto: deps.pauseAuto,
1623
- updateProgressWidget: deps.updateProgressWidget,
1624
- };
1759
+ `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
1760
+ );
1761
+ break;
1762
+ }
1763
+
1764
+ if (!s.cmdCtx) {
1765
+ debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
1766
+ break;
1767
+ }
1768
+
1769
+ try {
1770
+ // ── Blanket try/catch: one bad iteration must not kill the session
1771
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
1625
1772
 
1626
- // Pre-verification processing (commit, doctor, state rebuild, etc.)
1627
- // Sidecar items use lightweight pre-verification opts
1628
- const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem
1629
- ? sidecarItem.kind === "hook"
1630
- ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1631
- : { skipSettleDelay: true, skipStateRebuild: true }
1632
- : undefined;
1633
- const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
1634
- if (preResult === "dispatched") {
1773
+ // ── Check sidecar queue before deriveState ──
1774
+ let sidecarItem: SidecarItem | undefined;
1775
+ if (s.sidecarQueue.length > 0) {
1776
+ sidecarItem = s.sidecarQueue.shift()!;
1635
1777
  debugLog("autoLoop", {
1636
- phase: "exit",
1637
- reason: "pre-verification-dispatched",
1778
+ phase: "sidecar-dequeue",
1779
+ kind: sidecarItem.kind,
1780
+ unitType: sidecarItem.unitType,
1781
+ unitId: sidecarItem.unitId,
1638
1782
  });
1639
- break;
1640
1783
  }
1641
1784
 
1642
- if (pauseAfterUatDispatch) {
1643
- ctx.ui.notify(
1644
- "UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
1645
- "info",
1646
- );
1647
- await deps.pauseAuto(ctx, pi);
1648
- debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
1649
- break;
1650
- }
1651
-
1652
- // Verification gate
1653
- // Hook sidecar items skip verification entirely.
1654
- // Non-hook sidecar items run verification but skip retries (just continue).
1655
- const skipVerification = sidecarItem?.kind === "hook";
1656
- if (!skipVerification) {
1657
- const verificationResult = await deps.runPostUnitVerification(
1658
- { s, ctx, pi },
1659
- deps.pauseAuto,
1660
- );
1661
-
1662
- if (verificationResult === "pause") {
1663
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1785
+ const sessionLockBase = deps.lockBase();
1786
+ if (sessionLockBase) {
1787
+ const lockStatus = deps.validateSessionLock(sessionLockBase);
1788
+ if (!lockStatus.valid) {
1789
+ debugLog("autoLoop", {
1790
+ phase: "session-lock-invalid",
1791
+ reason: lockStatus.failureReason ?? "unknown",
1792
+ existingPid: lockStatus.existingPid,
1793
+ expectedPid: lockStatus.expectedPid,
1794
+ });
1795
+ deps.handleLostSessionLock(ctx, lockStatus);
1796
+ debugLog("autoLoop", {
1797
+ phase: "exit",
1798
+ reason: "session-lock-lost",
1799
+ detail: lockStatus.failureReason ?? "unknown",
1800
+ });
1664
1801
  break;
1665
1802
  }
1666
-
1667
- if (verificationResult === "retry") {
1668
- if (sidecarItem) {
1669
- // Sidecar verification retries are skipped — just continue
1670
- debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration });
1671
- } else {
1672
- // s.pendingVerificationRetry was set by runPostUnitVerification.
1673
- // Continue the loop — next iteration will inject the retry context into the prompt.
1674
- debugLog("autoLoop", { phase: "verification-retry", iteration });
1675
- continue;
1676
- }
1677
- }
1678
1803
  }
1679
1804
 
1680
- // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
1681
- const postResult = await deps.postUnitPostVerification(postUnitCtx);
1805
+ const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration };
1806
+ let iterData: IterationData;
1682
1807
 
1683
- if (postResult === "stopped") {
1684
- debugLog("autoLoop", {
1685
- phase: "exit",
1686
- reason: "post-verification-stopped",
1687
- });
1688
- break;
1808
+ if (!sidecarItem) {
1809
+ // ── Phase 1: Pre-dispatch ─────────────────────────────────────────
1810
+ const preDispatchResult = await runPreDispatch(ic, loopState);
1811
+ if (preDispatchResult.action === "break") break;
1812
+ if (preDispatchResult.action === "continue") continue;
1813
+
1814
+ const preData = preDispatchResult.data;
1815
+
1816
+ // ── Phase 2: Guards ───────────────────────────────────────────────
1817
+ const guardsResult = await runGuards(ic, preData.mid);
1818
+ if (guardsResult.action === "break") break;
1819
+
1820
+ // ── Phase 3: Dispatch ─────────────────────────────────────────────
1821
+ const dispatchResult = await runDispatch(ic, preData, loopState);
1822
+ if (dispatchResult.action === "break") break;
1823
+ if (dispatchResult.action === "continue") continue;
1824
+ iterData = dispatchResult.data;
1825
+ } else {
1826
+ // ── Sidecar path: use values from the sidecar item directly ──
1827
+ const sidecarState = await deps.deriveState(s.basePath);
1828
+ iterData = {
1829
+ unitType: sidecarItem.unitType,
1830
+ unitId: sidecarItem.unitId,
1831
+ prompt: sidecarItem.prompt,
1832
+ finalPrompt: sidecarItem.prompt,
1833
+ pauseAfterUatDispatch: false,
1834
+ observabilityIssues: [],
1835
+ state: sidecarState,
1836
+ mid: sidecarState.activeMilestone?.id,
1837
+ midTitle: sidecarState.activeMilestone?.title,
1838
+ isRetry: false, previousTier: undefined,
1839
+ };
1689
1840
  }
1690
1841
 
1691
- if (postResult === "step-wizard") {
1692
- // Step mode — exit the loop (caller handles wizard)
1693
- debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
1694
- break;
1695
- }
1842
+ const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem);
1843
+ if (unitPhaseResult.action === "break") break;
1844
+
1845
+ // ── Phase 5: Finalize ───────────────────────────────────────────────
1846
+
1847
+ const finalizeResult = await runFinalize(ic, iterData, sidecarItem);
1848
+ if (finalizeResult.action === "break") break;
1849
+ if (finalizeResult.action === "continue") continue;
1696
1850
 
1697
1851
  consecutiveErrors = 0; // Iteration completed successfully
1698
1852
  debugLog("autoLoop", { phase: "iteration-complete", iteration });