gsd-pi 2.38.0-dev.4d4d14a → 2.38.0-dev.5492881

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 (37) hide show
  1. package/dist/resource-loader.js +34 -1
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  3. package/dist/resources/extensions/gsd/auto-loop.js +538 -469
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +9 -3
  5. package/dist/resources/extensions/gsd/auto-prompts.js +18 -14
  6. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  7. package/dist/resources/extensions/gsd/commands.js +2 -1
  8. package/dist/resources/extensions/gsd/doctor.js +20 -1
  9. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  10. package/dist/resources/extensions/gsd/files.js +4 -0
  11. package/dist/resources/extensions/gsd/git-service.js +22 -11
  12. package/dist/resources/extensions/gsd/guided-flow.js +82 -32
  13. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  14. package/dist/resources/extensions/gsd/prompts/run-uat.md +2 -0
  15. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  16. package/dist/resources/extensions/mcp-client/index.js +14 -1
  17. package/package.json +1 -1
  18. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  19. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  20. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  21. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  22. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  23. package/src/resources/extensions/gsd/auto-loop.ts +342 -304
  24. package/src/resources/extensions/gsd/auto-post-unit.ts +10 -3
  25. package/src/resources/extensions/gsd/auto-prompts.ts +20 -14
  26. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  27. package/src/resources/extensions/gsd/commands.ts +2 -2
  28. package/src/resources/extensions/gsd/doctor.ts +22 -1
  29. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  30. package/src/resources/extensions/gsd/files.ts +3 -1
  31. package/src/resources/extensions/gsd/git-service.ts +31 -9
  32. package/src/resources/extensions/gsd/guided-flow.ts +110 -38
  33. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  34. package/src/resources/extensions/gsd/prompts/run-uat.md +2 -0
  35. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  36. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +106 -31
  37. package/src/resources/extensions/mcp-client/index.ts +17 -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,6 +38,8 @@ 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
44
  /** Data-driven budget threshold notifications (descending). The 100% entry
40
45
  * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
@@ -130,6 +135,63 @@ export function _setActiveSession(_session: AutoSession | null): void {
130
135
  // No-op — kept for test backward compatibility
131
136
  }
132
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
+
133
195
  // ─── runUnit ─────────────────────────────────────────────────────────────────
134
196
 
135
197
  /**
@@ -501,9 +563,9 @@ async function generateMilestoneReport(
501
563
  ctx: ExtensionContext,
502
564
  milestoneId: string,
503
565
  ): Promise<void> {
504
- const { loadVisualizerData } = await import("./visualizer-data.js");
505
- const { generateHtmlReport } = await import("./export-html.js");
506
- const { writeReportSnapshot } = await import("./reports.js");
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");
507
569
  const { basename } = await import("node:path");
508
570
 
509
571
  const snapData = await loadVisualizerData(s.basePath);
@@ -598,8 +660,10 @@ export async function autoLoop(
598
660
  ): Promise<void> {
599
661
  debugLog("autoLoop", { phase: "enter" });
600
662
  let iteration = 0;
601
- let lastDerivedUnit = "";
602
- 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;
603
667
 
604
668
  let consecutiveErrors = 0;
605
669
 
@@ -628,6 +692,19 @@ export async function autoLoop(
628
692
 
629
693
  try {
630
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
+ }
631
708
 
632
709
  const sessionLockBase = deps.lockBase();
633
710
  if (sessionLockBase) {
@@ -649,6 +726,17 @@ export async function autoLoop(
649
726
  }
650
727
  }
651
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) {
652
740
  // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
653
741
 
654
742
  // Resource version guard
@@ -699,10 +787,10 @@ export async function autoLoop(
699
787
  }
700
788
 
701
789
  // Derive state
702
- let state = await deps.deriveState(s.basePath);
703
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
704
- let mid = state.activeMilestone?.id;
705
- 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;
706
794
  debugLog("autoLoop", {
707
795
  phase: "state-derived",
708
796
  iteration,
@@ -723,12 +811,12 @@ export async function autoLoop(
723
811
  "milestone",
724
812
  );
725
813
  deps.logCmuxEvent(
726
- deps.loadEffectiveGSDPreferences()?.preferences,
814
+ prefs,
727
815
  `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
728
816
  "success",
729
817
  );
730
818
 
731
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
819
+ const vizPrefs = prefs;
732
820
  if (vizPrefs?.auto_visualize) {
733
821
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
734
822
  }
@@ -747,11 +835,30 @@ export async function autoLoop(
747
835
  s.unitDispatchCount.clear();
748
836
  s.unitRecoveryCount.clear();
749
837
  s.unitLifetimeDispatches.clear();
750
- lastDerivedUnit = "";
751
- sameUnitCount = 0;
838
+ recentUnits.length = 0;
839
+ stuckRecoveryAttempts = 0;
752
840
 
753
841
  // Worktree lifecycle on milestone transition — merge current, enter next
754
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
+
755
862
  deps.invalidateAllCaches();
756
863
 
757
864
  state = await deps.deriveState(s.basePath);
@@ -761,9 +868,7 @@ export async function autoLoop(
761
868
  if (mid) {
762
869
  if (deps.getIsolationMode() !== "none") {
763
870
  deps.captureIntegrationBranch(s.basePath, mid, {
764
- commitDocs:
765
- deps.loadEffectiveGSDPreferences()?.preferences?.git
766
- ?.commit_docs,
871
+ commitDocs: prefs?.git?.commit_docs,
767
872
  });
768
873
  }
769
874
  deps.resolver.enterMilestone(mid, ctx.ui);
@@ -807,6 +912,24 @@ export async function autoLoop(
807
912
  // All milestones complete — merge milestone branch before stopping
808
913
  if (s.currentMilestoneId) {
809
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
+ }
810
933
  }
811
934
  deps.sendDesktopNotification(
812
935
  "GSD",
@@ -815,7 +938,7 @@ export async function autoLoop(
815
938
  "milestone",
816
939
  );
817
940
  deps.logCmuxEvent(
818
- deps.loadEffectiveGSDPreferences()?.preferences,
941
+ prefs,
819
942
  "All milestones complete.",
820
943
  "success",
821
944
  );
@@ -837,7 +960,7 @@ export async function autoLoop(
837
960
  await deps.stopAuto(ctx, pi, blockerMsg);
838
961
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
839
962
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
840
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
963
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
841
964
  } else {
842
965
  const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
843
966
  const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@@ -888,6 +1011,24 @@ export async function autoLoop(
888
1011
  // Milestone merge on complete (before closeout so branch state is clean)
889
1012
  if (s.currentMilestoneId) {
890
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
+ }
891
1032
  }
892
1033
  deps.sendDesktopNotification(
893
1034
  "GSD",
@@ -896,7 +1037,7 @@ export async function autoLoop(
896
1037
  "milestone",
897
1038
  );
898
1039
  deps.logCmuxEvent(
899
- deps.loadEffectiveGSDPreferences()?.preferences,
1040
+ prefs,
900
1041
  `Milestone ${mid} complete.`,
901
1042
  "success",
902
1043
  );
@@ -911,15 +1052,13 @@ export async function autoLoop(
911
1052
  await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
912
1053
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
913
1054
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
914
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
1055
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
915
1056
  debugLog("autoLoop", { phase: "exit", reason: "blocked" });
916
1057
  break;
917
1058
  }
918
1059
 
919
1060
  // ── Phase 2: Guards ─────────────────────────────────────────────────
920
1061
 
921
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
922
-
923
1062
  // Budget ceiling guard
924
1063
  const budgetCeiling = prefs?.budget_ceiling;
925
1064
  if (budgetCeiling !== undefined && budgetCeiling > 0) {
@@ -1069,76 +1208,84 @@ export async function autoLoop(
1069
1208
  continue;
1070
1209
  }
1071
1210
 
1072
- let unitType = dispatchResult.unitType;
1073
- let unitId = dispatchResult.unitId;
1074
- let prompt = dispatchResult.prompt;
1075
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1211
+ unitType = dispatchResult.unitType;
1212
+ unitId = dispatchResult.unitId;
1213
+ prompt = dispatchResult.prompt;
1214
+ pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1076
1215
 
1077
- // ── Same-unit stuck counter with graduated recovery ──
1216
+ // ── Sliding-window stuck detection with graduated recovery ──
1078
1217
  const derivedKey = `${unitType}/${unitId}`;
1079
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
1080
- sameUnitCount++;
1081
- debugLog("autoLoop", {
1082
- phase: "stuck-check",
1083
- unitType,
1084
- unitId,
1085
- sameUnitCount,
1086
- });
1087
1218
 
1088
- if (sameUnitCount === 3) {
1089
- // Level 1: try verifying the artifact — maybe it was written but not detected
1090
- 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",
1091
1227
  unitType,
1092
1228
  unitId,
1093
- s.basePath,
1094
- );
1095
- 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
1096
1261
  debugLog("autoLoop", {
1097
- phase: "stuck-recovery",
1098
- level: 1,
1099
- action: "artifact-found",
1262
+ phase: "stuck-detected",
1263
+ unitType,
1264
+ unitId,
1265
+ reason: stuckSignal.reason,
1100
1266
  });
1267
+ await deps.stopAuto(
1268
+ ctx,
1269
+ pi,
1270
+ `Stuck: ${stuckSignal.reason}`,
1271
+ );
1101
1272
  ctx.ui.notify(
1102
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1103
- "info",
1273
+ `Stuck on ${unitType} ${unitId} ${stuckSignal.reason}. The expected artifact was not written.`,
1274
+ "error",
1104
1275
  );
1105
- deps.invalidateAllCaches();
1106
- 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;
1107
1287
  }
1108
- ctx.ui.notify(
1109
- `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
1110
- "warning",
1111
- );
1112
- deps.invalidateAllCaches();
1113
- } else if (sameUnitCount === 5) {
1114
- // Level 2: hard stop — genuinely stuck
1115
- debugLog("autoLoop", {
1116
- phase: "stuck-detected",
1117
- unitType,
1118
- unitId,
1119
- sameUnitCount,
1120
- });
1121
- await deps.stopAuto(
1122
- ctx,
1123
- pi,
1124
- `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
1125
- );
1126
- ctx.ui.notify(
1127
- `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
1128
- "error",
1129
- );
1130
- break;
1131
- }
1132
- } else {
1133
- if (derivedKey !== lastDerivedUnit) {
1134
- debugLog("autoLoop", {
1135
- phase: "stuck-counter-reset",
1136
- from: lastDerivedUnit,
1137
- to: derivedKey,
1138
- });
1139
1288
  }
1140
- lastDerivedUnit = derivedKey;
1141
- sameUnitCount = 0;
1142
1289
  }
1143
1290
 
1144
1291
  // Pre-dispatch hooks
@@ -1181,13 +1328,27 @@ export async function autoLoop(
1181
1328
  break;
1182
1329
  }
1183
1330
 
1184
- const observabilityIssues = await deps.collectObservabilityWarnings(
1331
+ observabilityIssues = await deps.collectObservabilityWarnings(
1185
1332
  ctx,
1186
1333
  s.basePath,
1187
1334
  unitType,
1188
1335
  unitId,
1189
1336
  );
1190
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
+
1191
1352
  // ── Phase 4: Unit execution ─────────────────────────────────────────
1192
1353
 
1193
1354
  debugLog("autoLoop", {
@@ -1205,61 +1366,6 @@ export async function autoLoop(
1205
1366
  );
1206
1367
  const previousTier = s.currentUnitRouting?.tier;
1207
1368
 
1208
- // Closeout previous unit
1209
- if (s.currentUnit) {
1210
- await deps.closeoutUnit(
1211
- ctx,
1212
- s.basePath,
1213
- s.currentUnit.type,
1214
- s.currentUnit.id,
1215
- s.currentUnit.startedAt,
1216
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1217
- );
1218
-
1219
- if (s.currentUnitRouting) {
1220
- const isRetryForOutcome =
1221
- s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1222
- deps.recordOutcome(
1223
- s.currentUnit.type,
1224
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1225
- !isRetryForOutcome,
1226
- );
1227
- }
1228
-
1229
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1230
- const incomingKey = `${unitType}/${unitId}`;
1231
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
1232
- const artifactVerified =
1233
- isHookUnit ||
1234
- deps.verifyExpectedArtifact(
1235
- s.currentUnit.type,
1236
- s.currentUnit.id,
1237
- s.basePath,
1238
- );
1239
- if (closeoutKey !== incomingKey && artifactVerified) {
1240
- s.completedUnits.push({
1241
- type: s.currentUnit.type,
1242
- id: s.currentUnit.id,
1243
- startedAt: s.currentUnit.startedAt,
1244
- finishedAt: Date.now(),
1245
- });
1246
- if (s.completedUnits.length > 200) {
1247
- s.completedUnits = s.completedUnits.slice(-200);
1248
- }
1249
- deps.clearUnitRuntimeRecord(
1250
- s.basePath,
1251
- s.currentUnit.type,
1252
- s.currentUnit.id,
1253
- );
1254
- s.unitDispatchCount.delete(
1255
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1256
- );
1257
- s.unitRecoveryCount.delete(
1258
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1259
- );
1260
- }
1261
- }
1262
-
1263
1369
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1264
1370
  deps.captureAvailableSkills();
1265
1371
  deps.writeUnitRuntimeRecord(
@@ -1286,7 +1392,6 @@ export async function autoLoop(
1286
1392
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1287
1393
 
1288
1394
  // Prompt injection
1289
- const MAX_RECOVERY_CHARS = 50_000;
1290
1395
  let finalPrompt = prompt;
1291
1396
 
1292
1397
  if (s.pendingVerificationRetry) {
@@ -1331,7 +1436,7 @@ export async function autoLoop(
1331
1436
  s.lastBaselineCharCount = undefined;
1332
1437
  if (deps.isDbAvailable()) {
1333
1438
  try {
1334
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
1439
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1335
1440
  const [decisionsContent, requirementsContent, projectContent] =
1336
1441
  await Promise.all([
1337
1442
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
@@ -1358,7 +1463,7 @@ export async function autoLoop(
1358
1463
  );
1359
1464
  }
1360
1465
 
1361
- // Select and apply model (with tier escalation on retry)
1466
+ // Select and apply model (with tier escalation on retry — normal units only)
1362
1467
  const modelResult = await deps.selectAndApplyModel(
1363
1468
  ctx,
1364
1469
  pi,
@@ -1368,7 +1473,7 @@ export async function autoLoop(
1368
1473
  prefs,
1369
1474
  s.verbose,
1370
1475
  s.autoModeStartModel,
1371
- { isRetry, previousTier },
1476
+ sidecarItem ? undefined : { isRetry, previousTier },
1372
1477
  );
1373
1478
  s.currentUnitRouting =
1374
1479
  modelResult.routing as AutoSession["currentUnitRouting"];
@@ -1426,6 +1531,23 @@ export async function autoLoop(
1426
1531
  status: unitResult.status,
1427
1532
  });
1428
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
+
1429
1551
  if (unitResult.status === "cancelled") {
1430
1552
  ctx.ui.notify(
1431
1553
  `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
@@ -1436,6 +1558,52 @@ export async function autoLoop(
1436
1558
  break;
1437
1559
  }
1438
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
+
1439
1607
  // ── Phase 5: Finalize ───────────────────────────────────────────────
1440
1608
 
1441
1609
  debugLog("autoLoop", { phase: "finalize", iteration });
@@ -1456,7 +1624,13 @@ export async function autoLoop(
1456
1624
  };
1457
1625
 
1458
1626
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
1459
- 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);
1460
1634
  if (preResult === "dispatched") {
1461
1635
  debugLog("autoLoop", {
1462
1636
  phase: "exit",
@@ -1475,22 +1649,32 @@ export async function autoLoop(
1475
1649
  break;
1476
1650
  }
1477
1651
 
1478
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
1479
- const verificationResult = await deps.runPostUnitVerification(
1480
- { s, ctx, pi },
1481
- deps.pauseAuto,
1482
- );
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
+ );
1483
1661
 
1484
- if (verificationResult === "pause") {
1485
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1486
- break;
1487
- }
1662
+ if (verificationResult === "pause") {
1663
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1664
+ break;
1665
+ }
1488
1666
 
1489
- if (verificationResult === "retry") {
1490
- // s.pendingVerificationRetry was set by runPostUnitVerification.
1491
- // Continue the loop next iteration will inject the retry context into the prompt.
1492
- debugLog("autoLoop", { phase: "verification-retry", iteration });
1493
- 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
+ }
1494
1678
  }
1495
1679
 
1496
1680
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
@@ -1510,152 +1694,6 @@ export async function autoLoop(
1510
1694
  break;
1511
1695
  }
1512
1696
 
1513
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
1514
- let sidecarBroke = false;
1515
- while (s.sidecarQueue.length > 0 && s.active) {
1516
- const item = s.sidecarQueue.shift()!;
1517
- debugLog("autoLoop", {
1518
- phase: "sidecar-dequeue",
1519
- kind: item.kind,
1520
- unitType: item.unitType,
1521
- unitId: item.unitId,
1522
- });
1523
-
1524
- // Set up as current unit
1525
- const sidecarStartedAt = Date.now();
1526
- s.currentUnit = {
1527
- type: item.unitType,
1528
- id: item.unitId,
1529
- startedAt: sidecarStartedAt,
1530
- };
1531
- deps.writeUnitRuntimeRecord(
1532
- s.basePath,
1533
- item.unitType,
1534
- item.unitId,
1535
- sidecarStartedAt,
1536
- {
1537
- phase: "dispatched",
1538
- wrapupWarningSent: false,
1539
- timeoutAt: null,
1540
- lastProgressAt: sidecarStartedAt,
1541
- progressCount: 0,
1542
- lastProgressKind: "dispatch",
1543
- },
1544
- );
1545
-
1546
- // Model selection (handles hook model override)
1547
- await deps.selectAndApplyModel(
1548
- ctx,
1549
- pi,
1550
- item.unitType,
1551
- item.unitId,
1552
- s.basePath,
1553
- prefs,
1554
- s.verbose,
1555
- s.autoModeStartModel,
1556
- );
1557
-
1558
- // Supervision
1559
- deps.clearUnitTimeout();
1560
- deps.startUnitSupervision({
1561
- s,
1562
- ctx,
1563
- pi,
1564
- unitType: item.unitType,
1565
- unitId: item.unitId,
1566
- prefs,
1567
- buildSnapshotOpts: () =>
1568
- deps.buildSnapshotOpts(item.unitType, item.unitId),
1569
- buildRecoveryContext: () => ({}),
1570
- pauseAuto: deps.pauseAuto,
1571
- });
1572
-
1573
- // Write lock
1574
- const sidecarSessionFile = deps.getSessionFile(ctx);
1575
- deps.writeLock(
1576
- deps.lockBase(),
1577
- item.unitType,
1578
- item.unitId,
1579
- s.completedUnits.length,
1580
- sidecarSessionFile,
1581
- );
1582
-
1583
- // Execute via standard runUnit
1584
- const sidecarResult = await runUnit(
1585
- ctx,
1586
- pi,
1587
- s,
1588
- item.unitType,
1589
- item.unitId,
1590
- item.prompt,
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) {