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