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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +15 -11
  2. package/dist/resource-loader.js +34 -1
  3. package/dist/resources/extensions/browser-tools/index.js +3 -1
  4. package/dist/resources/extensions/browser-tools/tools/verify.js +97 -0
  5. package/dist/resources/extensions/github-sync/cli.js +284 -0
  6. package/dist/resources/extensions/github-sync/index.js +73 -0
  7. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  8. package/dist/resources/extensions/github-sync/sync.js +424 -0
  9. package/dist/resources/extensions/github-sync/templates.js +118 -0
  10. package/dist/resources/extensions/github-sync/types.js +7 -0
  11. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  12. package/dist/resources/extensions/gsd/auto-loop.js +593 -516
  13. package/dist/resources/extensions/gsd/auto-post-unit.js +28 -3
  14. package/dist/resources/extensions/gsd/auto-prompts.js +197 -19
  15. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  16. package/dist/resources/extensions/gsd/commands.js +2 -1
  17. package/dist/resources/extensions/gsd/doctor-providers.js +3 -0
  18. package/dist/resources/extensions/gsd/doctor.js +20 -1
  19. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  20. package/dist/resources/extensions/gsd/files.js +46 -7
  21. package/dist/resources/extensions/gsd/git-service.js +30 -12
  22. package/dist/resources/extensions/gsd/gitignore.js +16 -3
  23. package/dist/resources/extensions/gsd/guided-flow.js +149 -38
  24. package/dist/resources/extensions/gsd/health-widget-core.js +32 -70
  25. package/dist/resources/extensions/gsd/health-widget.js +3 -86
  26. package/dist/resources/extensions/gsd/index.js +22 -19
  27. package/dist/resources/extensions/gsd/migrate-external.js +18 -1
  28. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  29. package/dist/resources/extensions/gsd/paths.js +3 -0
  30. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  31. package/dist/resources/extensions/gsd/preferences-validation.js +58 -0
  32. package/dist/resources/extensions/gsd/preferences.js +20 -9
  33. package/dist/resources/extensions/gsd/prompt-loader.js +6 -2
  34. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  35. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -1
  37. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  38. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  39. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  40. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  41. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  42. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  43. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  44. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  45. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  46. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  47. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  48. package/dist/resources/extensions/gsd/prompts/run-uat.md +3 -1
  49. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  50. package/dist/resources/extensions/gsd/state.js +41 -22
  51. package/dist/resources/extensions/gsd/templates/runtime.md +21 -0
  52. package/dist/resources/extensions/gsd/templates/task-plan.md +3 -0
  53. package/dist/resources/extensions/mcp-client/index.js +14 -1
  54. package/dist/resources/extensions/remote-questions/status.js +4 -2
  55. package/dist/resources/extensions/remote-questions/store.js +4 -2
  56. package/dist/resources/extensions/shared/frontmatter.js +1 -1
  57. package/package.json +1 -1
  58. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  59. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  60. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  61. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  63. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/skills.d.ts +1 -0
  65. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/skills.js +6 -1
  67. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  69. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/index.js +1 -1
  71. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  72. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  73. package/packages/pi-coding-agent/src/core/skills.ts +9 -1
  74. package/packages/pi-coding-agent/src/index.ts +1 -0
  75. package/src/resources/extensions/browser-tools/index.ts +3 -0
  76. package/src/resources/extensions/browser-tools/tools/verify.ts +117 -0
  77. package/src/resources/extensions/github-sync/cli.ts +364 -0
  78. package/src/resources/extensions/github-sync/index.ts +93 -0
  79. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  80. package/src/resources/extensions/github-sync/sync.ts +556 -0
  81. package/src/resources/extensions/github-sync/templates.ts +183 -0
  82. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  83. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  84. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  85. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  86. package/src/resources/extensions/github-sync/types.ts +47 -0
  87. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  88. package/src/resources/extensions/gsd/auto-loop.ts +472 -434
  89. package/src/resources/extensions/gsd/auto-post-unit.ts +29 -3
  90. package/src/resources/extensions/gsd/auto-prompts.ts +242 -19
  91. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  92. package/src/resources/extensions/gsd/commands.ts +2 -2
  93. package/src/resources/extensions/gsd/doctor-providers.ts +4 -0
  94. package/src/resources/extensions/gsd/doctor.ts +22 -1
  95. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  96. package/src/resources/extensions/gsd/files.ts +49 -9
  97. package/src/resources/extensions/gsd/git-service.ts +44 -10
  98. package/src/resources/extensions/gsd/gitignore.ts +17 -3
  99. package/src/resources/extensions/gsd/guided-flow.ts +177 -44
  100. package/src/resources/extensions/gsd/health-widget-core.ts +28 -80
  101. package/src/resources/extensions/gsd/health-widget.ts +3 -89
  102. package/src/resources/extensions/gsd/index.ts +21 -16
  103. package/src/resources/extensions/gsd/migrate-external.ts +18 -1
  104. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  105. package/src/resources/extensions/gsd/paths.ts +4 -0
  106. package/src/resources/extensions/gsd/preferences-types.ts +4 -0
  107. package/src/resources/extensions/gsd/preferences-validation.ts +50 -0
  108. package/src/resources/extensions/gsd/preferences.ts +23 -9
  109. package/src/resources/extensions/gsd/prompt-loader.ts +7 -2
  110. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  111. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  112. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -1
  113. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  114. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  115. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  116. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  117. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  118. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  119. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  120. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  121. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  122. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  123. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  124. package/src/resources/extensions/gsd/prompts/run-uat.md +3 -1
  125. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  126. package/src/resources/extensions/gsd/state.ts +38 -20
  127. package/src/resources/extensions/gsd/templates/runtime.md +21 -0
  128. package/src/resources/extensions/gsd/templates/task-plan.md +3 -0
  129. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +111 -37
  130. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +4 -3
  131. package/src/resources/extensions/gsd/tests/derive-state.test.ts +43 -0
  132. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +50 -0
  133. package/src/resources/extensions/gsd/tests/health-widget.test.ts +16 -54
  134. package/src/resources/extensions/gsd/tests/parsers.test.ts +131 -14
  135. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +209 -0
  136. package/src/resources/extensions/gsd/tests/run-uat.test.ts +5 -1
  137. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +140 -0
  138. package/src/resources/extensions/gsd/types.ts +18 -0
  139. package/src/resources/extensions/gsd/verification-evidence.ts +16 -0
  140. package/src/resources/extensions/mcp-client/index.ts +17 -1
  141. package/src/resources/extensions/remote-questions/status.ts +4 -2
  142. package/src/resources/extensions/remote-questions/store.ts +4 -2
  143. package/src/resources/extensions/shared/frontmatter.ts +1 -1
@@ -10,9 +10,9 @@
10
10
  * session rotation). No queue — stale agent_end events are dropped.
11
11
  */
12
12
 
13
- import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
13
+ import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent";
14
14
 
15
- import type { AutoSession } from "./auto/session.js";
15
+ import type { AutoSession, SidecarItem } from "./auto/session.js";
16
16
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
17
17
  import type { GSDPreferences } from "./preferences.js";
18
18
  import type { SessionLockStatus } from "./session-lock.js";
@@ -26,6 +26,9 @@ import type {
26
26
  import type { DispatchAction } from "./auto-dispatch.js";
27
27
  import type { WorktreeResolver } from "./worktree-resolver.js";
28
28
  import { debugLog } from "./debug-logger.js";
29
+ import { gsdRoot } from "./paths.js";
30
+ import { atomicWriteSync } from "./atomic-write.js";
31
+ import { join } from "node:path";
29
32
  import type { CmuxLogLevel } from "../cmux/index.js";
30
33
 
31
34
  /**
@@ -35,15 +38,19 @@ import type { CmuxLogLevel } from "../cmux/index.js";
35
38
  * generous headroom including retries and sidecar work.
36
39
  */
37
40
  const MAX_LOOP_ITERATIONS = 500;
41
+ /** Maximum characters of failure/crash context included in recovery prompts. */
42
+ const MAX_RECOVERY_CHARS = 50_000;
38
43
 
39
- /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
40
- * handled inline because it requires break/pause/stop control flow. */
44
+ /** Data-driven budget threshold notifications (descending). The 100% entry
45
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
46
+ * a simple notification. */
41
47
  const BUDGET_THRESHOLDS: Array<{
42
48
  pct: number;
43
49
  label: string;
44
- notifyLevel: "info" | "warning";
45
- cmuxLevel: "progress" | "warning";
50
+ notifyLevel: "info" | "warning" | "error";
51
+ cmuxLevel: "progress" | "warning" | "error";
46
52
  }> = [
53
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
47
54
  { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
48
55
  { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
49
56
  { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
@@ -128,6 +135,63 @@ export function _setActiveSession(_session: AutoSession | null): void {
128
135
  // No-op — kept for test backward compatibility
129
136
  }
130
137
 
138
+ // ─── detectStuck ─────────────────────────────────────────────────────────────
139
+
140
+ type WindowEntry = { key: string; error?: string };
141
+
142
+ /**
143
+ * Analyze a sliding window of recent unit dispatches for stuck patterns.
144
+ * Returns a signal with reason if stuck, null otherwise.
145
+ *
146
+ * Rule 1: Same error string twice in a row → stuck immediately.
147
+ * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
148
+ * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
149
+ */
150
+ export function detectStuck(
151
+ window: readonly WindowEntry[],
152
+ ): { stuck: true; reason: string } | null {
153
+ if (window.length < 2) return null;
154
+
155
+ const last = window[window.length - 1];
156
+ const prev = window[window.length - 2];
157
+
158
+ // Rule 1: Same error repeated consecutively
159
+ if (last.error && prev.error && last.error === prev.error) {
160
+ return {
161
+ stuck: true,
162
+ reason: `Same error repeated: ${last.error.slice(0, 200)}`,
163
+ };
164
+ }
165
+
166
+ // Rule 2: Same unit 3+ consecutive times
167
+ if (window.length >= 3) {
168
+ const lastThree = window.slice(-3);
169
+ if (lastThree.every((u) => u.key === last.key)) {
170
+ return {
171
+ stuck: true,
172
+ reason: `${last.key} derived 3 consecutive times without progress`,
173
+ };
174
+ }
175
+ }
176
+
177
+ // Rule 3: Oscillation (A→B→A→B in last 4)
178
+ if (window.length >= 4) {
179
+ const w = window.slice(-4);
180
+ if (
181
+ w[0].key === w[2].key &&
182
+ w[1].key === w[3].key &&
183
+ w[0].key !== w[1].key
184
+ ) {
185
+ return {
186
+ stuck: true,
187
+ reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
188
+ };
189
+ }
190
+ }
191
+
192
+ return null;
193
+ }
194
+
131
195
  // ─── runUnit ─────────────────────────────────────────────────────────────────
132
196
 
133
197
  /**
@@ -145,7 +209,6 @@ export async function runUnit(
145
209
  unitType: string,
146
210
  unitId: string,
147
211
  prompt: string,
148
- _prefs: GSDPreferences | undefined,
149
212
  ): Promise<UnitResult> {
150
213
  debugLog("runUnit", { phase: "start", unitType, unitId });
151
214
 
@@ -489,6 +552,96 @@ export interface LoopDeps {
489
552
  getSessionFile: (ctx: ExtensionContext) => string;
490
553
  }
491
554
 
555
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
556
+
557
+ /**
558
+ * Generate and write an HTML milestone report snapshot.
559
+ * Extracted from the milestone-transition block in autoLoop.
560
+ */
561
+ async function generateMilestoneReport(
562
+ s: AutoSession,
563
+ ctx: ExtensionContext,
564
+ milestoneId: string,
565
+ ): Promise<void> {
566
+ const { loadVisualizerData } = await importExtensionModule<typeof import("./visualizer-data.js")>(import.meta.url, "./visualizer-data.js");
567
+ const { generateHtmlReport } = await importExtensionModule<typeof import("./export-html.js")>(import.meta.url, "./export-html.js");
568
+ const { writeReportSnapshot } = await importExtensionModule<typeof import("./reports.js")>(import.meta.url, "./reports.js");
569
+ const { basename } = await import("node:path");
570
+
571
+ const snapData = await loadVisualizerData(s.basePath);
572
+ const completedMs = snapData.milestones.find(
573
+ (m: { id: string }) => m.id === milestoneId,
574
+ );
575
+ const msTitle = completedMs?.title ?? milestoneId;
576
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
577
+ const projName = basename(s.basePath);
578
+ const doneSlices = snapData.milestones.reduce(
579
+ (acc: number, m: { slices: { done: boolean }[] }) =>
580
+ acc + m.slices.filter((sl: { done: boolean }) => sl.done).length,
581
+ 0,
582
+ );
583
+ const totalSlices = snapData.milestones.reduce(
584
+ (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
585
+ 0,
586
+ );
587
+ const outPath = writeReportSnapshot({
588
+ basePath: s.basePath,
589
+ html: generateHtmlReport(snapData, {
590
+ projectName: projName,
591
+ projectPath: s.basePath,
592
+ gsdVersion,
593
+ milestoneId,
594
+ indexRelPath: "index.html",
595
+ }),
596
+ milestoneId,
597
+ milestoneTitle: msTitle,
598
+ kind: "milestone",
599
+ projectName: projName,
600
+ projectPath: s.basePath,
601
+ gsdVersion,
602
+ totalCost: snapData.totals?.cost ?? 0,
603
+ totalTokens: snapData.totals?.tokens.total ?? 0,
604
+ totalDuration: snapData.totals?.duration ?? 0,
605
+ doneSlices,
606
+ totalSlices,
607
+ doneMilestones: snapData.milestones.filter(
608
+ (m: { status: string }) => m.status === "complete",
609
+ ).length,
610
+ totalMilestones: snapData.milestones.length,
611
+ phase: snapData.phase,
612
+ });
613
+ ctx.ui.notify(
614
+ `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
615
+ "info",
616
+ );
617
+ }
618
+
619
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
620
+
621
+ /**
622
+ * If a unit is in-flight, close it out, then stop auto-mode.
623
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
624
+ */
625
+ async function closeoutAndStop(
626
+ ctx: ExtensionContext,
627
+ pi: ExtensionAPI,
628
+ s: AutoSession,
629
+ deps: LoopDeps,
630
+ reason: string,
631
+ ): Promise<void> {
632
+ if (s.currentUnit) {
633
+ await deps.closeoutUnit(
634
+ ctx,
635
+ s.basePath,
636
+ s.currentUnit.type,
637
+ s.currentUnit.id,
638
+ s.currentUnit.startedAt,
639
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
640
+ );
641
+ }
642
+ await deps.stopAuto(ctx, pi, reason);
643
+ }
644
+
492
645
  // ─── autoLoop ────────────────────────────────────────────────────────────────
493
646
 
494
647
  /**
@@ -507,8 +660,10 @@ export async function autoLoop(
507
660
  ): Promise<void> {
508
661
  debugLog("autoLoop", { phase: "enter" });
509
662
  let iteration = 0;
510
- let lastDerivedUnit = "";
511
- let sameUnitCount = 0;
663
+ // ── Sliding-window stuck detection ──
664
+ const recentUnits: Array<{ key: string; error?: string }> = [];
665
+ const STUCK_WINDOW_SIZE = 6;
666
+ let stuckRecoveryAttempts = 0;
512
667
 
513
668
  let consecutiveErrors = 0;
514
669
 
@@ -537,6 +692,19 @@ export async function autoLoop(
537
692
 
538
693
  try {
539
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
+ }
540
708
 
541
709
  const sessionLockBase = deps.lockBase();
542
710
  if (sessionLockBase) {
@@ -558,6 +726,17 @@ export async function autoLoop(
558
726
  }
559
727
  }
560
728
 
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[] = [];
738
+
739
+ if (!sidecarItem) {
561
740
  // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
562
741
 
563
742
  // Resource version guard
@@ -608,10 +787,10 @@ export async function autoLoop(
608
787
  }
609
788
 
610
789
  // Derive state
611
- let state = await deps.deriveState(s.basePath);
612
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
613
- let mid = state.activeMilestone?.id;
614
- let midTitle = state.activeMilestone?.title;
790
+ state = await deps.deriveState(s.basePath);
791
+ deps.syncCmuxSidebar(prefs, state);
792
+ mid = state.activeMilestone?.id;
793
+ midTitle = state.activeMilestone?.title;
615
794
  debugLog("autoLoop", {
616
795
  phase: "state-derived",
617
796
  iteration,
@@ -632,68 +811,18 @@ export async function autoLoop(
632
811
  "milestone",
633
812
  );
634
813
  deps.logCmuxEvent(
635
- deps.loadEffectiveGSDPreferences()?.preferences,
814
+ prefs,
636
815
  `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
637
816
  "success",
638
817
  );
639
818
 
640
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
819
+ const vizPrefs = prefs;
641
820
  if (vizPrefs?.auto_visualize) {
642
821
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
643
822
  }
644
823
  if (vizPrefs?.auto_report !== false) {
645
824
  try {
646
- const { loadVisualizerData } = await import("./visualizer-data.js");
647
- const { generateHtmlReport } = await import("./export-html.js");
648
- const { writeReportSnapshot } = await import("./reports.js");
649
- const { basename } = await import("node:path");
650
- const snapData = await loadVisualizerData(s.basePath);
651
- const completedMs = snapData.milestones.find(
652
- (m: { id: string }) => m.id === s.currentMilestoneId,
653
- );
654
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
655
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
656
- const projName = basename(s.basePath);
657
- const doneSlices = snapData.milestones.reduce(
658
- (acc: number, m: { slices: { done: boolean }[] }) =>
659
- acc +
660
- m.slices.filter((sl: { done: boolean }) => sl.done).length,
661
- 0,
662
- );
663
- const totalSlices = snapData.milestones.reduce(
664
- (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
665
- 0,
666
- );
667
- const outPath = writeReportSnapshot({
668
- basePath: s.basePath,
669
- html: generateHtmlReport(snapData, {
670
- projectName: projName,
671
- projectPath: s.basePath,
672
- gsdVersion,
673
- milestoneId: s.currentMilestoneId,
674
- indexRelPath: "index.html",
675
- }),
676
- milestoneId: s.currentMilestoneId!,
677
- milestoneTitle: msTitle,
678
- kind: "milestone",
679
- projectName: projName,
680
- projectPath: s.basePath,
681
- gsdVersion,
682
- totalCost: snapData.totals?.cost ?? 0,
683
- totalTokens: snapData.totals?.tokens.total ?? 0,
684
- totalDuration: snapData.totals?.duration ?? 0,
685
- doneSlices,
686
- totalSlices,
687
- doneMilestones: snapData.milestones.filter(
688
- (m: { status: string }) => m.status === "complete",
689
- ).length,
690
- totalMilestones: snapData.milestones.length,
691
- phase: snapData.phase,
692
- });
693
- ctx.ui.notify(
694
- `Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`,
695
- "info",
696
- );
825
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
697
826
  } catch (err) {
698
827
  ctx.ui.notify(
699
828
  `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -706,11 +835,30 @@ export async function autoLoop(
706
835
  s.unitDispatchCount.clear();
707
836
  s.unitRecoveryCount.clear();
708
837
  s.unitLifetimeDispatches.clear();
709
- lastDerivedUnit = "";
710
- sameUnitCount = 0;
838
+ recentUnits.length = 0;
839
+ stuckRecoveryAttempts = 0;
711
840
 
712
841
  // Worktree lifecycle on milestone transition — merge current, enter next
713
842
  deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
843
+
844
+ // Opt-in: create draft PR on milestone completion
845
+ if (prefs?.git?.auto_pr) {
846
+ try {
847
+ const { createDraftPR } = await import("./git-service.js");
848
+ const prUrl = createDraftPR(
849
+ s.basePath,
850
+ s.currentMilestoneId!,
851
+ `[GSD] ${s.currentMilestoneId} complete`,
852
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
853
+ );
854
+ if (prUrl) {
855
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
856
+ }
857
+ } catch {
858
+ // Non-fatal — PR creation is best-effort
859
+ }
860
+ }
861
+
714
862
  deps.invalidateAllCaches();
715
863
 
716
864
  state = await deps.deriveState(s.basePath);
@@ -720,9 +868,7 @@ export async function autoLoop(
720
868
  if (mid) {
721
869
  if (deps.getIsolationMode() !== "none") {
722
870
  deps.captureIntegrationBranch(s.basePath, mid, {
723
- commitDocs:
724
- deps.loadEffectiveGSDPreferences()?.preferences?.git
725
- ?.commit_docs,
871
+ commitDocs: prefs?.git?.commit_docs,
726
872
  });
727
873
  }
728
874
  deps.resolver.enterMilestone(mid, ctx.ui);
@@ -766,6 +912,24 @@ export async function autoLoop(
766
912
  // All milestones complete — merge milestone branch before stopping
767
913
  if (s.currentMilestoneId) {
768
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
+ }
769
933
  }
770
934
  deps.sendDesktopNotification(
771
935
  "GSD",
@@ -774,7 +938,7 @@ export async function autoLoop(
774
938
  "milestone",
775
939
  );
776
940
  deps.logCmuxEvent(
777
- deps.loadEffectiveGSDPreferences()?.preferences,
941
+ prefs,
778
942
  "All milestones complete.",
779
943
  "success",
780
944
  );
@@ -796,7 +960,7 @@ export async function autoLoop(
796
960
  await deps.stopAuto(ctx, pi, blockerMsg);
797
961
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
798
962
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
799
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
963
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
800
964
  } else {
801
965
  const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
802
966
  const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@@ -831,20 +995,10 @@ export async function autoLoop(
831
995
  }
832
996
 
833
997
  if (!mid || !midTitle) {
834
- if (s.currentUnit) {
835
- await deps.closeoutUnit(
836
- ctx,
837
- s.basePath,
838
- s.currentUnit.type,
839
- s.currentUnit.id,
840
- s.currentUnit.startedAt,
841
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
842
- );
843
- }
844
998
  const noMilestoneReason = !mid
845
999
  ? "No active milestone after merge reconciliation"
846
1000
  : `Milestone ${mid} has no title after reconciliation`;
847
- await deps.stopAuto(ctx, pi, noMilestoneReason);
1001
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
848
1002
  debugLog("autoLoop", {
849
1003
  phase: "exit",
850
1004
  reason: "no-milestone-after-reconciliation",
@@ -854,19 +1008,27 @@ export async function autoLoop(
854
1008
 
855
1009
  // Terminal: complete
856
1010
  if (state.phase === "complete") {
857
- if (s.currentUnit) {
858
- await deps.closeoutUnit(
859
- ctx,
860
- s.basePath,
861
- s.currentUnit.type,
862
- s.currentUnit.id,
863
- s.currentUnit.startedAt,
864
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
865
- );
866
- }
867
- // Milestone merge on complete
1011
+ // Milestone merge on complete (before closeout so branch state is clean)
868
1012
  if (s.currentMilestoneId) {
869
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
+ }
870
1032
  }
871
1033
  deps.sendDesktopNotification(
872
1034
  "GSD",
@@ -875,40 +1037,28 @@ export async function autoLoop(
875
1037
  "milestone",
876
1038
  );
877
1039
  deps.logCmuxEvent(
878
- deps.loadEffectiveGSDPreferences()?.preferences,
1040
+ prefs,
879
1041
  `Milestone ${mid} complete.`,
880
1042
  "success",
881
1043
  );
882
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
1044
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
883
1045
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
884
1046
  break;
885
1047
  }
886
1048
 
887
1049
  // Terminal: blocked
888
1050
  if (state.phase === "blocked") {
889
- if (s.currentUnit) {
890
- await deps.closeoutUnit(
891
- ctx,
892
- s.basePath,
893
- s.currentUnit.type,
894
- s.currentUnit.id,
895
- s.currentUnit.startedAt,
896
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
897
- );
898
- }
899
1051
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
900
- await deps.stopAuto(ctx, pi, blockerMsg);
1052
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
901
1053
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
902
1054
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
903
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
1055
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
904
1056
  debugLog("autoLoop", { phase: "exit", reason: "blocked" });
905
1057
  break;
906
1058
  }
907
1059
 
908
1060
  // ── Phase 2: Guards ─────────────────────────────────────────────────
909
1061
 
910
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
911
-
912
1062
  // Budget ceiling guard
913
1063
  const budgetCeiling = prefs?.budget_ceiling;
914
1064
  if (budgetCeiling !== undefined && budgetCeiling > 0) {
@@ -928,38 +1078,39 @@ export async function autoLoop(
928
1078
  budgetPct,
929
1079
  );
930
1080
 
931
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
932
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
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) {
933
1086
  s.lastBudgetAlertLevel =
934
1087
  newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
935
- if (budgetEnforcementAction === "halt") {
936
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
937
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
938
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
939
- break;
940
- }
941
- if (budgetEnforcementAction === "pause") {
942
- ctx.ui.notify(
943
- `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
944
- "warning",
945
- );
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");
946
1110
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
947
1111
  deps.logCmuxEvent(prefs, msg, "warning");
948
- await deps.pauseAuto(ctx, pi);
949
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
950
- break;
951
- }
952
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
953
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
954
- deps.logCmuxEvent(prefs, msg, "warning");
955
- } else {
956
- // Data-driven 75/80/90% threshold notifications
957
- const threshold = BUDGET_THRESHOLDS.find(
958
- (t) => newBudgetAlertLevel === t.pct,
959
- );
960
- if (threshold) {
961
- s.lastBudgetAlertLevel =
962
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1112
+ } else if (threshold.pct < 100) {
1113
+ // Sub-100% simple notification
963
1114
  const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
964
1115
  ctx.ui.notify(msg, threshold.notifyLevel);
965
1116
  deps.sendDesktopNotification(
@@ -969,9 +1120,9 @@ export async function autoLoop(
969
1120
  "budget",
970
1121
  );
971
1122
  deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
972
- } else if (budgetAlertLevel === 0) {
973
- s.lastBudgetAlertLevel = 0;
974
1123
  }
1124
+ } else if (budgetAlertLevel === 0) {
1125
+ s.lastBudgetAlertLevel = 0;
975
1126
  }
976
1127
  } else {
977
1128
  s.lastBudgetAlertLevel = 0;
@@ -1046,17 +1197,7 @@ export async function autoLoop(
1046
1197
  });
1047
1198
 
1048
1199
  if (dispatchResult.action === "stop") {
1049
- if (s.currentUnit) {
1050
- await deps.closeoutUnit(
1051
- ctx,
1052
- s.basePath,
1053
- s.currentUnit.type,
1054
- s.currentUnit.id,
1055
- s.currentUnit.startedAt,
1056
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1057
- );
1058
- }
1059
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
1200
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1060
1201
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1061
1202
  break;
1062
1203
  }
@@ -1067,76 +1208,84 @@ export async function autoLoop(
1067
1208
  continue;
1068
1209
  }
1069
1210
 
1070
- let unitType = dispatchResult.unitType;
1071
- let unitId = dispatchResult.unitId;
1072
- let prompt = dispatchResult.prompt;
1073
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1211
+ unitType = dispatchResult.unitType;
1212
+ unitId = dispatchResult.unitId;
1213
+ prompt = dispatchResult.prompt;
1214
+ pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1074
1215
 
1075
- // ── Same-unit stuck counter with graduated recovery ──
1216
+ // ── Sliding-window stuck detection with graduated recovery ──
1076
1217
  const derivedKey = `${unitType}/${unitId}`;
1077
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
1078
- sameUnitCount++;
1079
- debugLog("autoLoop", {
1080
- phase: "stuck-check",
1081
- unitType,
1082
- unitId,
1083
- sameUnitCount,
1084
- });
1085
1218
 
1086
- if (sameUnitCount === 3) {
1087
- // Level 1: try verifying the artifact — maybe it was written but not detected
1088
- const artifactExists = deps.verifyExpectedArtifact(
1219
+ if (!s.pendingVerificationRetry) {
1220
+ recentUnits.push({ key: derivedKey });
1221
+ if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift();
1222
+
1223
+ const stuckSignal = detectStuck(recentUnits);
1224
+ if (stuckSignal) {
1225
+ debugLog("autoLoop", {
1226
+ phase: "stuck-check",
1089
1227
  unitType,
1090
1228
  unitId,
1091
- s.basePath,
1092
- );
1093
- if (artifactExists) {
1229
+ reason: stuckSignal.reason,
1230
+ recoveryAttempts: stuckRecoveryAttempts,
1231
+ });
1232
+
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
1094
1261
  debugLog("autoLoop", {
1095
- phase: "stuck-recovery",
1096
- level: 1,
1097
- action: "artifact-found",
1262
+ phase: "stuck-detected",
1263
+ unitType,
1264
+ unitId,
1265
+ reason: stuckSignal.reason,
1098
1266
  });
1267
+ await deps.stopAuto(
1268
+ ctx,
1269
+ pi,
1270
+ `Stuck: ${stuckSignal.reason}`,
1271
+ );
1099
1272
  ctx.ui.notify(
1100
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1101
- "info",
1273
+ `Stuck on ${unitType} ${unitId} ${stuckSignal.reason}. The expected artifact was not written.`,
1274
+ "error",
1102
1275
  );
1103
- deps.invalidateAllCaches();
1104
- continue;
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;
1105
1287
  }
1106
- ctx.ui.notify(
1107
- `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
1108
- "warning",
1109
- );
1110
- deps.invalidateAllCaches();
1111
- } else if (sameUnitCount === 5) {
1112
- // Level 2: hard stop — genuinely stuck
1113
- debugLog("autoLoop", {
1114
- phase: "stuck-detected",
1115
- unitType,
1116
- unitId,
1117
- sameUnitCount,
1118
- });
1119
- await deps.stopAuto(
1120
- ctx,
1121
- pi,
1122
- `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
1123
- );
1124
- ctx.ui.notify(
1125
- `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
1126
- "error",
1127
- );
1128
- break;
1129
- }
1130
- } else {
1131
- if (derivedKey !== lastDerivedUnit) {
1132
- debugLog("autoLoop", {
1133
- phase: "stuck-counter-reset",
1134
- from: lastDerivedUnit,
1135
- to: derivedKey,
1136
- });
1137
1288
  }
1138
- lastDerivedUnit = derivedKey;
1139
- sameUnitCount = 0;
1140
1289
  }
1141
1290
 
1142
1291
  // Pre-dispatch hooks
@@ -1179,13 +1328,27 @@ export async function autoLoop(
1179
1328
  break;
1180
1329
  }
1181
1330
 
1182
- const observabilityIssues = await deps.collectObservabilityWarnings(
1331
+ observabilityIssues = await deps.collectObservabilityWarnings(
1183
1332
  ctx,
1184
1333
  s.basePath,
1185
1334
  unitType,
1186
1335
  unitId,
1187
1336
  );
1188
1337
 
1338
+ // Derive state for shared use in execution phase
1339
+ // (state, mid, midTitle already set above)
1340
+
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;
1350
+ }
1351
+
1189
1352
  // ── Phase 4: Unit execution ─────────────────────────────────────────
1190
1353
 
1191
1354
  debugLog("autoLoop", {
@@ -1203,61 +1366,6 @@ export async function autoLoop(
1203
1366
  );
1204
1367
  const previousTier = s.currentUnitRouting?.tier;
1205
1368
 
1206
- // Closeout previous unit
1207
- if (s.currentUnit) {
1208
- await deps.closeoutUnit(
1209
- ctx,
1210
- s.basePath,
1211
- s.currentUnit.type,
1212
- s.currentUnit.id,
1213
- s.currentUnit.startedAt,
1214
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1215
- );
1216
-
1217
- if (s.currentUnitRouting) {
1218
- const isRetry =
1219
- s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1220
- deps.recordOutcome(
1221
- s.currentUnit.type,
1222
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1223
- !isRetry,
1224
- );
1225
- }
1226
-
1227
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1228
- const incomingKey = `${unitType}/${unitId}`;
1229
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
1230
- const artifactVerified =
1231
- isHookUnit ||
1232
- deps.verifyExpectedArtifact(
1233
- s.currentUnit.type,
1234
- s.currentUnit.id,
1235
- s.basePath,
1236
- );
1237
- if (closeoutKey !== incomingKey && artifactVerified) {
1238
- s.completedUnits.push({
1239
- type: s.currentUnit.type,
1240
- id: s.currentUnit.id,
1241
- startedAt: s.currentUnit.startedAt,
1242
- finishedAt: Date.now(),
1243
- });
1244
- if (s.completedUnits.length > 200) {
1245
- s.completedUnits = s.completedUnits.slice(-200);
1246
- }
1247
- deps.clearUnitRuntimeRecord(
1248
- s.basePath,
1249
- s.currentUnit.type,
1250
- s.currentUnit.id,
1251
- );
1252
- s.unitDispatchCount.delete(
1253
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1254
- );
1255
- s.unitRecoveryCount.delete(
1256
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1257
- );
1258
- }
1259
- }
1260
-
1261
1369
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1262
1370
  deps.captureAvailableSkills();
1263
1371
  deps.writeUnitRuntimeRecord(
@@ -1284,7 +1392,6 @@ export async function autoLoop(
1284
1392
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1285
1393
 
1286
1394
  // Prompt injection
1287
- const MAX_RECOVERY_CHARS = 50_000;
1288
1395
  let finalPrompt = prompt;
1289
1396
 
1290
1397
  if (s.pendingVerificationRetry) {
@@ -1329,7 +1436,7 @@ export async function autoLoop(
1329
1436
  s.lastBaselineCharCount = undefined;
1330
1437
  if (deps.isDbAvailable()) {
1331
1438
  try {
1332
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
1439
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1333
1440
  const [decisionsContent, requirementsContent, projectContent] =
1334
1441
  await Promise.all([
1335
1442
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
@@ -1356,7 +1463,7 @@ export async function autoLoop(
1356
1463
  );
1357
1464
  }
1358
1465
 
1359
- // Select and apply model (with tier escalation on retry)
1466
+ // Select and apply model (with tier escalation on retry — normal units only)
1360
1467
  const modelResult = await deps.selectAndApplyModel(
1361
1468
  ctx,
1362
1469
  pi,
@@ -1366,7 +1473,7 @@ export async function autoLoop(
1366
1473
  prefs,
1367
1474
  s.verbose,
1368
1475
  s.autoModeStartModel,
1369
- { isRetry, previousTier },
1476
+ sidecarItem ? undefined : { isRetry, previousTier },
1370
1477
  );
1371
1478
  s.currentUnitRouting =
1372
1479
  modelResult.routing as AutoSession["currentUnitRouting"];
@@ -1415,7 +1522,6 @@ export async function autoLoop(
1415
1522
  unitType,
1416
1523
  unitId,
1417
1524
  finalPrompt,
1418
- prefs,
1419
1525
  );
1420
1526
  debugLog("autoLoop", {
1421
1527
  phase: "runUnit-end",
@@ -1425,6 +1531,23 @@ export async function autoLoop(
1425
1531
  status: unitResult.status,
1426
1532
  });
1427
1533
 
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
+ }
1549
+ }
1550
+
1428
1551
  if (unitResult.status === "cancelled") {
1429
1552
  ctx.ui.notify(
1430
1553
  `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
@@ -1435,6 +1558,52 @@ export async function autoLoop(
1435
1558
  break;
1436
1559
  }
1437
1560
 
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
+ );
1572
+
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
+ }
1580
+
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
+ }
1606
+
1438
1607
  // ── Phase 5: Finalize ───────────────────────────────────────────────
1439
1608
 
1440
1609
  debugLog("autoLoop", { phase: "finalize", iteration });
@@ -1455,7 +1624,13 @@ export async function autoLoop(
1455
1624
  };
1456
1625
 
1457
1626
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
1458
- const preResult = await deps.postUnitPreVerification(postUnitCtx);
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);
1459
1634
  if (preResult === "dispatched") {
1460
1635
  debugLog("autoLoop", {
1461
1636
  phase: "exit",
@@ -1474,22 +1649,32 @@ export async function autoLoop(
1474
1649
  break;
1475
1650
  }
1476
1651
 
1477
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
1478
- const verificationResult = await deps.runPostUnitVerification(
1479
- { s, ctx, pi },
1480
- deps.pauseAuto,
1481
- );
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
+ );
1482
1661
 
1483
- if (verificationResult === "pause") {
1484
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1485
- break;
1486
- }
1662
+ if (verificationResult === "pause") {
1663
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1664
+ break;
1665
+ }
1487
1666
 
1488
- if (verificationResult === "retry") {
1489
- // s.pendingVerificationRetry was set by runPostUnitVerification.
1490
- // Continue the loop next iteration will inject the retry context into the prompt.
1491
- debugLog("autoLoop", { phase: "verification-retry", iteration });
1492
- continue;
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
+ }
1493
1678
  }
1494
1679
 
1495
1680
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
@@ -1509,153 +1694,6 @@ export async function autoLoop(
1509
1694
  break;
1510
1695
  }
1511
1696
 
1512
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
1513
- let sidecarBroke = false;
1514
- while (s.sidecarQueue.length > 0 && s.active) {
1515
- const item = s.sidecarQueue.shift()!;
1516
- debugLog("autoLoop", {
1517
- phase: "sidecar-dequeue",
1518
- kind: item.kind,
1519
- unitType: item.unitType,
1520
- unitId: item.unitId,
1521
- });
1522
-
1523
- // Set up as current unit
1524
- const sidecarStartedAt = Date.now();
1525
- s.currentUnit = {
1526
- type: item.unitType,
1527
- id: item.unitId,
1528
- startedAt: sidecarStartedAt,
1529
- };
1530
- deps.writeUnitRuntimeRecord(
1531
- s.basePath,
1532
- item.unitType,
1533
- item.unitId,
1534
- sidecarStartedAt,
1535
- {
1536
- phase: "dispatched",
1537
- wrapupWarningSent: false,
1538
- timeoutAt: null,
1539
- lastProgressAt: sidecarStartedAt,
1540
- progressCount: 0,
1541
- lastProgressKind: "dispatch",
1542
- },
1543
- );
1544
-
1545
- // Model selection (handles hook model override)
1546
- await deps.selectAndApplyModel(
1547
- ctx,
1548
- pi,
1549
- item.unitType,
1550
- item.unitId,
1551
- s.basePath,
1552
- prefs,
1553
- s.verbose,
1554
- s.autoModeStartModel,
1555
- );
1556
-
1557
- // Supervision
1558
- deps.clearUnitTimeout();
1559
- deps.startUnitSupervision({
1560
- s,
1561
- ctx,
1562
- pi,
1563
- unitType: item.unitType,
1564
- unitId: item.unitId,
1565
- prefs,
1566
- buildSnapshotOpts: () =>
1567
- deps.buildSnapshotOpts(item.unitType, item.unitId),
1568
- buildRecoveryContext: () => ({}),
1569
- pauseAuto: deps.pauseAuto,
1570
- });
1571
-
1572
- // Write lock
1573
- const sidecarSessionFile = deps.getSessionFile(ctx);
1574
- deps.writeLock(
1575
- deps.lockBase(),
1576
- item.unitType,
1577
- item.unitId,
1578
- s.completedUnits.length,
1579
- sidecarSessionFile,
1580
- );
1581
-
1582
- // Execute via standard runUnit
1583
- const sidecarResult = await runUnit(
1584
- ctx,
1585
- pi,
1586
- s,
1587
- item.unitType,
1588
- item.unitId,
1589
- item.prompt,
1590
- prefs,
1591
- );
1592
- deps.clearUnitTimeout();
1593
-
1594
- if (sidecarResult.status === "cancelled") {
1595
- ctx.ui.notify(
1596
- `Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`,
1597
- "warning",
1598
- );
1599
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
1600
- sidecarBroke = true;
1601
- break;
1602
- }
1603
-
1604
- // Run pre-verification for the sidecar unit (lightweight path)
1605
- const sidecarPreOpts: PreVerificationOpts = item.kind === "hook"
1606
- ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1607
- : { skipSettleDelay: true, skipStateRebuild: true };
1608
- const sidecarPreResult =
1609
- await deps.postUnitPreVerification(postUnitCtx, sidecarPreOpts);
1610
- if (sidecarPreResult === "dispatched") {
1611
- // Pre-verification caused stop/pause
1612
- debugLog("autoLoop", {
1613
- phase: "exit",
1614
- reason: "sidecar-pre-verification-stop",
1615
- });
1616
- sidecarBroke = true;
1617
- break;
1618
- }
1619
-
1620
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
1621
- // Hook units are lightweight and don't need verification.
1622
- if (item.kind !== "hook") {
1623
- const sidecarVerification = await deps.runPostUnitVerification(
1624
- { s, ctx, pi },
1625
- deps.pauseAuto,
1626
- );
1627
- if (sidecarVerification === "pause") {
1628
- debugLog("autoLoop", {
1629
- phase: "exit",
1630
- reason: "sidecar-verification-pause",
1631
- });
1632
- sidecarBroke = true;
1633
- break;
1634
- }
1635
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
1636
- }
1637
-
1638
- // Post-verification (may enqueue more sidecar items)
1639
- const sidecarPostResult =
1640
- await deps.postUnitPostVerification(postUnitCtx);
1641
- if (sidecarPostResult === "stopped") {
1642
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
1643
- sidecarBroke = true;
1644
- break;
1645
- }
1646
- if (sidecarPostResult === "step-wizard") {
1647
- debugLog("autoLoop", {
1648
- phase: "exit",
1649
- reason: "sidecar-step-wizard",
1650
- });
1651
- sidecarBroke = true;
1652
- break;
1653
- }
1654
- // "continue" — loop checks sidecarQueue again
1655
- }
1656
-
1657
- if (sidecarBroke) break;
1658
-
1659
1697
  consecutiveErrors = 0; // Iteration completed successfully
1660
1698
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
1661
1699
  } catch (loopErr) {