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
@@ -9,8 +9,12 @@
9
9
  * (per-unit one-shot resolver) and `_sessionSwitchInFlight` (guard for
10
10
  * session rotation). No queue — stale agent_end events are dropped.
11
11
  */
12
+ import { importExtensionModule } from "@gsd/pi-coding-agent";
12
13
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
13
14
  import { debugLog } from "./debug-logger.js";
15
+ import { gsdRoot } from "./paths.js";
16
+ import { atomicWriteSync } from "./atomic-write.js";
17
+ import { join } from "node:path";
14
18
  /**
15
19
  * Maximum total loop iterations before forced stop. Prevents runaway loops
16
20
  * when units alternate IDs (bypassing the same-unit stuck detector).
@@ -18,6 +22,8 @@ import { debugLog } from "./debug-logger.js";
18
22
  * generous headroom including retries and sidecar work.
19
23
  */
20
24
  const MAX_LOOP_ITERATIONS = 500;
25
+ /** Maximum characters of failure/crash context included in recovery prompts. */
26
+ const MAX_RECOVERY_CHARS = 50_000;
21
27
  /** Data-driven budget threshold notifications (descending). The 100% entry
22
28
  * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
23
29
  * a simple notification. */
@@ -81,6 +87,50 @@ export function _resetPendingResolve() {
81
87
  export function _setActiveSession(_session) {
82
88
  // No-op — kept for test backward compatibility
83
89
  }
90
+ /**
91
+ * Analyze a sliding window of recent unit dispatches for stuck patterns.
92
+ * Returns a signal with reason if stuck, null otherwise.
93
+ *
94
+ * Rule 1: Same error string twice in a row → stuck immediately.
95
+ * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
96
+ * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
97
+ */
98
+ export function detectStuck(window) {
99
+ if (window.length < 2)
100
+ return null;
101
+ const last = window[window.length - 1];
102
+ const prev = window[window.length - 2];
103
+ // Rule 1: Same error repeated consecutively
104
+ if (last.error && prev.error && last.error === prev.error) {
105
+ return {
106
+ stuck: true,
107
+ reason: `Same error repeated: ${last.error.slice(0, 200)}`,
108
+ };
109
+ }
110
+ // Rule 2: Same unit 3+ consecutive times
111
+ if (window.length >= 3) {
112
+ const lastThree = window.slice(-3);
113
+ if (lastThree.every((u) => u.key === last.key)) {
114
+ return {
115
+ stuck: true,
116
+ reason: `${last.key} derived 3 consecutive times without progress`,
117
+ };
118
+ }
119
+ }
120
+ // Rule 3: Oscillation (A→B→A→B in last 4)
121
+ if (window.length >= 4) {
122
+ const w = window.slice(-4);
123
+ if (w[0].key === w[2].key &&
124
+ w[1].key === w[3].key &&
125
+ w[0].key !== w[1].key) {
126
+ return {
127
+ stuck: true,
128
+ reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
129
+ };
130
+ }
131
+ }
132
+ return null;
133
+ }
84
134
  // ─── runUnit ─────────────────────────────────────────────────────────────────
85
135
  /**
86
136
  * Execute a single unit: create a new session, send the prompt, and await
@@ -163,9 +213,9 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
163
213
  * Extracted from the milestone-transition block in autoLoop.
164
214
  */
165
215
  async function generateMilestoneReport(s, ctx, milestoneId) {
166
- const { loadVisualizerData } = await import("./visualizer-data.js");
167
- const { generateHtmlReport } = await import("./export-html.js");
168
- const { writeReportSnapshot } = await import("./reports.js");
216
+ const { loadVisualizerData } = await importExtensionModule(import.meta.url, "./visualizer-data.js");
217
+ const { generateHtmlReport } = await importExtensionModule(import.meta.url, "./export-html.js");
218
+ const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "./reports.js");
169
219
  const { basename } = await import("node:path");
170
220
  const snapData = await loadVisualizerData(s.basePath);
171
221
  const completedMs = snapData.milestones.find((m) => m.id === milestoneId);
@@ -223,8 +273,10 @@ async function closeoutAndStop(ctx, pi, s, deps, reason) {
223
273
  export async function autoLoop(ctx, pi, s, deps) {
224
274
  debugLog("autoLoop", { phase: "enter" });
225
275
  let iteration = 0;
226
- let lastDerivedUnit = "";
227
- let sameUnitCount = 0;
276
+ // ── Sliding-window stuck detection ──
277
+ const recentUnits = [];
278
+ const STUCK_WINDOW_SIZE = 6;
279
+ let stuckRecoveryAttempts = 0;
228
280
  let consecutiveErrors = 0;
229
281
  while (s.active) {
230
282
  iteration++;
@@ -244,6 +296,18 @@ export async function autoLoop(ctx, pi, s, deps) {
244
296
  }
245
297
  try {
246
298
  // ── Blanket try/catch: one bad iteration must not kill the session
299
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
300
+ // ── Check sidecar queue before deriveState ──
301
+ let sidecarItem;
302
+ if (s.sidecarQueue.length > 0) {
303
+ sidecarItem = s.sidecarQueue.shift();
304
+ debugLog("autoLoop", {
305
+ phase: "sidecar-dequeue",
306
+ kind: sidecarItem.kind,
307
+ unitType: sidecarItem.unitType,
308
+ unitId: sidecarItem.unitId,
309
+ });
310
+ }
247
311
  const sessionLockBase = deps.lockBase();
248
312
  if (sessionLockBase) {
249
313
  const lockStatus = deps.validateSessionLock(sessionLockBase);
@@ -263,369 +327,436 @@ export async function autoLoop(ctx, pi, s, deps) {
263
327
  break;
264
328
  }
265
329
  }
266
- // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
267
- // Resource version guard
268
- const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
269
- if (staleMsg) {
270
- await deps.stopAuto(ctx, pi, staleMsg);
271
- debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
272
- break;
273
- }
274
- deps.invalidateAllCaches();
275
- s.lastPromptCharCount = undefined;
276
- s.lastBaselineCharCount = undefined;
277
- // Pre-dispatch health gate
278
- try {
279
- const healthGate = await deps.preDispatchHealthGate(s.basePath);
280
- if (healthGate.fixesApplied.length > 0) {
281
- ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
282
- }
283
- if (!healthGate.proceed) {
284
- ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
285
- await deps.pauseAuto(ctx, pi);
286
- debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
330
+ // Variables shared between the sidecar and normal paths
331
+ let unitType;
332
+ let unitId;
333
+ let prompt;
334
+ let pauseAfterUatDispatch = false;
335
+ let state;
336
+ let mid;
337
+ let midTitle;
338
+ let observabilityIssues = [];
339
+ if (!sidecarItem) {
340
+ // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
341
+ // Resource version guard
342
+ const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
343
+ if (staleMsg) {
344
+ await deps.stopAuto(ctx, pi, staleMsg);
345
+ debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
287
346
  break;
288
347
  }
289
- }
290
- catch {
291
- // Non-fatal
292
- }
293
- // Sync project root artifacts into worktree
294
- if (s.originalBasePath &&
295
- s.basePath !== s.originalBasePath &&
296
- s.currentMilestoneId) {
297
- deps.syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
298
- }
299
- // Derive state
300
- let state = await deps.deriveState(s.basePath);
301
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
302
- let mid = state.activeMilestone?.id;
303
- let midTitle = state.activeMilestone?.title;
304
- debugLog("autoLoop", {
305
- phase: "state-derived",
306
- iteration,
307
- mid,
308
- statePhase: state.phase,
309
- });
310
- // ── Milestone transition ────────────────────────────────────────────
311
- if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
312
- ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
313
- deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
314
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
315
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
316
- if (vizPrefs?.auto_visualize) {
317
- ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
318
- }
319
- if (vizPrefs?.auto_report !== false) {
320
- try {
321
- await generateMilestoneReport(s, ctx, s.currentMilestoneId);
348
+ deps.invalidateAllCaches();
349
+ s.lastPromptCharCount = undefined;
350
+ s.lastBaselineCharCount = undefined;
351
+ // Pre-dispatch health gate
352
+ try {
353
+ const healthGate = await deps.preDispatchHealthGate(s.basePath);
354
+ if (healthGate.fixesApplied.length > 0) {
355
+ ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
322
356
  }
323
- catch (err) {
324
- ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
357
+ if (!healthGate.proceed) {
358
+ ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
359
+ await deps.pauseAuto(ctx, pi);
360
+ debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
361
+ break;
325
362
  }
326
363
  }
327
- // Reset dispatch counters for new milestone
328
- s.unitDispatchCount.clear();
329
- s.unitRecoveryCount.clear();
330
- s.unitLifetimeDispatches.clear();
331
- lastDerivedUnit = "";
332
- sameUnitCount = 0;
333
- // Worktree lifecycle on milestone transition — merge current, enter next
334
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
335
- deps.invalidateAllCaches();
364
+ catch {
365
+ // Non-fatal
366
+ }
367
+ // Sync project root artifacts into worktree
368
+ if (s.originalBasePath &&
369
+ s.basePath !== s.originalBasePath &&
370
+ s.currentMilestoneId) {
371
+ deps.syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
372
+ }
373
+ // Derive state
336
374
  state = await deps.deriveState(s.basePath);
375
+ deps.syncCmuxSidebar(prefs, state);
337
376
  mid = state.activeMilestone?.id;
338
377
  midTitle = state.activeMilestone?.title;
378
+ debugLog("autoLoop", {
379
+ phase: "state-derived",
380
+ iteration,
381
+ mid,
382
+ statePhase: state.phase,
383
+ });
384
+ // ── Milestone transition ────────────────────────────────────────────
385
+ if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
386
+ ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
387
+ deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
388
+ deps.logCmuxEvent(prefs, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
389
+ const vizPrefs = prefs;
390
+ if (vizPrefs?.auto_visualize) {
391
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
392
+ }
393
+ if (vizPrefs?.auto_report !== false) {
394
+ try {
395
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId);
396
+ }
397
+ catch (err) {
398
+ ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
399
+ }
400
+ }
401
+ // Reset dispatch counters for new milestone
402
+ s.unitDispatchCount.clear();
403
+ s.unitRecoveryCount.clear();
404
+ s.unitLifetimeDispatches.clear();
405
+ recentUnits.length = 0;
406
+ stuckRecoveryAttempts = 0;
407
+ // Worktree lifecycle on milestone transition — merge current, enter next
408
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
409
+ // Opt-in: create draft PR on milestone completion
410
+ if (prefs?.git?.auto_pr) {
411
+ try {
412
+ const { createDraftPR } = await import("./git-service.js");
413
+ const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
414
+ if (prUrl) {
415
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
416
+ }
417
+ }
418
+ catch {
419
+ // Non-fatal — PR creation is best-effort
420
+ }
421
+ }
422
+ deps.invalidateAllCaches();
423
+ state = await deps.deriveState(s.basePath);
424
+ mid = state.activeMilestone?.id;
425
+ midTitle = state.activeMilestone?.title;
426
+ if (mid) {
427
+ if (deps.getIsolationMode() !== "none") {
428
+ deps.captureIntegrationBranch(s.basePath, mid, {
429
+ commitDocs: prefs?.git?.commit_docs,
430
+ });
431
+ }
432
+ deps.resolver.enterMilestone(mid, ctx.ui);
433
+ }
434
+ else {
435
+ // mid is undefined — no milestone to capture integration branch for
436
+ }
437
+ const pendingIds = state.registry
438
+ .filter((m) => m.status !== "complete" && m.status !== "parked")
439
+ .map((m) => m.id);
440
+ deps.pruneQueueOrder(s.basePath, pendingIds);
441
+ }
339
442
  if (mid) {
340
- if (deps.getIsolationMode() !== "none") {
341
- deps.captureIntegrationBranch(s.basePath, mid, {
342
- commitDocs: deps.loadEffectiveGSDPreferences()?.preferences?.git
343
- ?.commit_docs,
344
- });
443
+ s.currentMilestoneId = mid;
444
+ deps.setActiveMilestoneId(s.basePath, mid);
445
+ }
446
+ // ── Terminal conditions ──────────────────────────────────────────────
447
+ if (!mid) {
448
+ if (s.currentUnit) {
449
+ await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
345
450
  }
346
- deps.resolver.enterMilestone(mid, ctx.ui);
451
+ const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
452
+ if (incomplete.length === 0 && state.registry.length > 0) {
453
+ // All milestones complete — merge milestone branch before stopping
454
+ if (s.currentMilestoneId) {
455
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
456
+ // Opt-in: create draft PR on milestone completion
457
+ if (prefs?.git?.auto_pr) {
458
+ try {
459
+ const { createDraftPR } = await import("./git-service.js");
460
+ const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
461
+ if (prUrl) {
462
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
463
+ }
464
+ }
465
+ catch {
466
+ // Non-fatal — PR creation is best-effort
467
+ }
468
+ }
469
+ }
470
+ deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
471
+ deps.logCmuxEvent(prefs, "All milestones complete.", "success");
472
+ await deps.stopAuto(ctx, pi, "All milestones complete");
473
+ }
474
+ else if (incomplete.length === 0 && state.registry.length === 0) {
475
+ // Empty registry — no milestones visible, likely a path resolution bug
476
+ const diag = `basePath=${s.basePath}, phase=${state.phase}`;
477
+ ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
478
+ await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
479
+ }
480
+ else if (state.phase === "blocked") {
481
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
482
+ await deps.stopAuto(ctx, pi, blockerMsg);
483
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
484
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
485
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
486
+ }
487
+ else {
488
+ const ids = incomplete.map((m) => m.id).join(", ");
489
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
490
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
491
+ await deps.stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
492
+ }
493
+ debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
494
+ break;
347
495
  }
348
- else {
349
- // mid is undefined — no milestone to capture integration branch for
496
+ if (!midTitle) {
497
+ midTitle = mid;
498
+ ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
350
499
  }
351
- const pendingIds = state.registry
352
- .filter((m) => m.status !== "complete" && m.status !== "parked")
353
- .map((m) => m.id);
354
- deps.pruneQueueOrder(s.basePath, pendingIds);
355
- }
356
- if (mid) {
357
- s.currentMilestoneId = mid;
358
- deps.setActiveMilestoneId(s.basePath, mid);
359
- }
360
- // ── Terminal conditions ──────────────────────────────────────────────
361
- if (!mid) {
362
- if (s.currentUnit) {
363
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
364
- }
365
- const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
366
- if (incomplete.length === 0 && state.registry.length > 0) {
367
- // All milestones complete — merge milestone branch before stopping
500
+ // Mid-merge safety check
501
+ if (deps.reconcileMergeState(s.basePath, ctx)) {
502
+ deps.invalidateAllCaches();
503
+ state = await deps.deriveState(s.basePath);
504
+ mid = state.activeMilestone?.id;
505
+ midTitle = state.activeMilestone?.title;
506
+ }
507
+ if (!mid || !midTitle) {
508
+ const noMilestoneReason = !mid
509
+ ? "No active milestone after merge reconciliation"
510
+ : `Milestone ${mid} has no title after reconciliation`;
511
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
512
+ debugLog("autoLoop", {
513
+ phase: "exit",
514
+ reason: "no-milestone-after-reconciliation",
515
+ });
516
+ break;
517
+ }
518
+ // Terminal: complete
519
+ if (state.phase === "complete") {
520
+ // Milestone merge on complete (before closeout so branch state is clean)
368
521
  if (s.currentMilestoneId) {
369
522
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
523
+ // Opt-in: create draft PR on milestone completion
524
+ if (prefs?.git?.auto_pr) {
525
+ try {
526
+ const { createDraftPR } = await import("./git-service.js");
527
+ const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
528
+ if (prUrl) {
529
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
530
+ }
531
+ }
532
+ catch {
533
+ // Non-fatal — PR creation is best-effort
534
+ }
535
+ }
370
536
  }
371
- deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
372
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, "All milestones complete.", "success");
373
- await deps.stopAuto(ctx, pi, "All milestones complete");
374
- }
375
- else if (incomplete.length === 0 && state.registry.length === 0) {
376
- // Empty registry — no milestones visible, likely a path resolution bug
377
- const diag = `basePath=${s.basePath}, phase=${state.phase}`;
378
- ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
379
- await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
537
+ deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
538
+ deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success");
539
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
540
+ debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
541
+ break;
380
542
  }
381
- else if (state.phase === "blocked") {
543
+ // Terminal: blocked
544
+ if (state.phase === "blocked") {
382
545
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
383
- await deps.stopAuto(ctx, pi, blockerMsg);
546
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
384
547
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
385
548
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
386
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
387
- }
388
- else {
389
- const ids = incomplete.map((m) => m.id).join(", ");
390
- const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
391
- ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
392
- await deps.stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
393
- }
394
- debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
395
- break;
396
- }
397
- if (!midTitle) {
398
- midTitle = mid;
399
- ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
400
- }
401
- // Mid-merge safety check
402
- if (deps.reconcileMergeState(s.basePath, ctx)) {
403
- deps.invalidateAllCaches();
404
- state = await deps.deriveState(s.basePath);
405
- mid = state.activeMilestone?.id;
406
- midTitle = state.activeMilestone?.title;
407
- }
408
- if (!mid || !midTitle) {
409
- const noMilestoneReason = !mid
410
- ? "No active milestone after merge reconciliation"
411
- : `Milestone ${mid} has no title after reconciliation`;
412
- await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
413
- debugLog("autoLoop", {
414
- phase: "exit",
415
- reason: "no-milestone-after-reconciliation",
416
- });
417
- break;
418
- }
419
- // Terminal: complete
420
- if (state.phase === "complete") {
421
- // Milestone merge on complete (before closeout so branch state is clean)
422
- if (s.currentMilestoneId) {
423
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
549
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
550
+ debugLog("autoLoop", { phase: "exit", reason: "blocked" });
551
+ break;
424
552
  }
425
- deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
426
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${mid} complete.`, "success");
427
- await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
428
- debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
429
- break;
430
- }
431
- // Terminal: blocked
432
- if (state.phase === "blocked") {
433
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
434
- await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
435
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
436
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
437
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
438
- debugLog("autoLoop", { phase: "exit", reason: "blocked" });
439
- break;
440
- }
441
- // ── Phase 2: Guards ─────────────────────────────────────────────────
442
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
443
- // Budget ceiling guard
444
- const budgetCeiling = prefs?.budget_ceiling;
445
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
446
- const currentLedger = deps.getLedger();
447
- const totalCost = currentLedger
448
- ? deps.getProjectTotals(currentLedger.units).cost
449
- : 0;
450
- const budgetPct = totalCost / budgetCeiling;
451
- const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
452
- const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
453
- const enforcement = prefs?.budget_enforcement ?? "pause";
454
- const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
455
- // Data-driven threshold check — loop descending, fire first match
456
- const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
457
- if (threshold) {
458
- s.lastBudgetAlertLevel =
459
- newBudgetAlertLevel;
460
- if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
461
- // 100% — special enforcement logic (halt/pause/warn)
462
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
463
- if (budgetEnforcementAction === "halt") {
464
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
465
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
466
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
467
- break;
468
- }
469
- if (budgetEnforcementAction === "pause") {
470
- ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
553
+ // ── Phase 2: Guards ─────────────────────────────────────────────────
554
+ // Budget ceiling guard
555
+ const budgetCeiling = prefs?.budget_ceiling;
556
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
557
+ const currentLedger = deps.getLedger();
558
+ const totalCost = currentLedger
559
+ ? deps.getProjectTotals(currentLedger.units).cost
560
+ : 0;
561
+ const budgetPct = totalCost / budgetCeiling;
562
+ const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
563
+ const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
564
+ const enforcement = prefs?.budget_enforcement ?? "pause";
565
+ const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
566
+ // Data-driven threshold check — loop descending, fire first match
567
+ const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
568
+ if (threshold) {
569
+ s.lastBudgetAlertLevel =
570
+ newBudgetAlertLevel;
571
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
572
+ // 100% special enforcement logic (halt/pause/warn)
573
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
574
+ if (budgetEnforcementAction === "halt") {
575
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
576
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
577
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
578
+ break;
579
+ }
580
+ if (budgetEnforcementAction === "pause") {
581
+ ctx.ui.notify(`${msg} Pausing auto-mode /gsd auto to override and continue.`, "warning");
582
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
583
+ deps.logCmuxEvent(prefs, msg, "warning");
584
+ await deps.pauseAuto(ctx, pi);
585
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
586
+ break;
587
+ }
588
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
471
589
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
472
590
  deps.logCmuxEvent(prefs, msg, "warning");
473
- await deps.pauseAuto(ctx, pi);
474
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
475
- break;
476
591
  }
477
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
478
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
479
- deps.logCmuxEvent(prefs, msg, "warning");
592
+ else if (threshold.pct < 100) {
593
+ // Sub-100% simple notification
594
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
595
+ ctx.ui.notify(msg, threshold.notifyLevel);
596
+ deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
597
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
598
+ }
480
599
  }
481
- else if (threshold.pct < 100) {
482
- // Sub-100% — simple notification
483
- const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
484
- ctx.ui.notify(msg, threshold.notifyLevel);
485
- deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
486
- deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
600
+ else if (budgetAlertLevel === 0) {
601
+ s.lastBudgetAlertLevel = 0;
487
602
  }
488
603
  }
489
- else if (budgetAlertLevel === 0) {
604
+ else {
490
605
  s.lastBudgetAlertLevel = 0;
491
606
  }
492
- }
493
- else {
494
- s.lastBudgetAlertLevel = 0;
495
- }
496
- // Context window guard
497
- const contextThreshold = prefs?.context_pause_threshold ?? 0;
498
- if (contextThreshold > 0 && s.cmdCtx) {
499
- const contextUsage = s.cmdCtx.getContextUsage();
500
- if (contextUsage &&
501
- contextUsage.percent !== null &&
502
- contextUsage.percent >= contextThreshold) {
503
- const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
504
- ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
505
- deps.sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention");
506
- await deps.pauseAuto(ctx, pi);
507
- debugLog("autoLoop", { phase: "exit", reason: "context-window" });
508
- break;
509
- }
510
- }
511
- // Secrets re-check gate
512
- try {
513
- const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
514
- if (manifestStatus && manifestStatus.pending.length > 0) {
515
- const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
516
- if (result &&
517
- result.applied &&
518
- result.skipped &&
519
- result.existingSkipped) {
520
- ctx.ui.notify(`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, "info");
607
+ // Context window guard
608
+ const contextThreshold = prefs?.context_pause_threshold ?? 0;
609
+ if (contextThreshold > 0 && s.cmdCtx) {
610
+ const contextUsage = s.cmdCtx.getContextUsage();
611
+ if (contextUsage &&
612
+ contextUsage.percent !== null &&
613
+ contextUsage.percent >= contextThreshold) {
614
+ const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
615
+ ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
616
+ deps.sendDesktopNotification("GSD", `Context ${contextUsage.percent}% paused`, "warning", "attention");
617
+ await deps.pauseAuto(ctx, pi);
618
+ debugLog("autoLoop", { phase: "exit", reason: "context-window" });
619
+ break;
521
620
  }
522
- else {
523
- ctx.ui.notify("Secrets collection skipped.", "info");
621
+ }
622
+ // Secrets re-check gate
623
+ try {
624
+ const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
625
+ if (manifestStatus && manifestStatus.pending.length > 0) {
626
+ const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
627
+ if (result &&
628
+ result.applied &&
629
+ result.skipped &&
630
+ result.existingSkipped) {
631
+ ctx.ui.notify(`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, "info");
632
+ }
633
+ else {
634
+ ctx.ui.notify("Secrets collection skipped.", "info");
635
+ }
524
636
  }
525
637
  }
526
- }
527
- catch (err) {
528
- ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
529
- }
530
- // ── Phase 3: Dispatch resolution ────────────────────────────────────
531
- debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
532
- const dispatchResult = await deps.resolveDispatch({
533
- basePath: s.basePath,
534
- mid,
535
- midTitle: midTitle,
536
- state,
537
- prefs,
538
- session: s,
539
- });
540
- if (dispatchResult.action === "stop") {
541
- await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
542
- debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
543
- break;
544
- }
545
- if (dispatchResult.action !== "dispatch") {
546
- // Non-dispatch action (e.g. "skip") — re-derive state
547
- await new Promise((r) => setImmediate(r));
548
- continue;
549
- }
550
- let unitType = dispatchResult.unitType;
551
- let unitId = dispatchResult.unitId;
552
- let prompt = dispatchResult.prompt;
553
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
554
- // ── Same-unit stuck counter with graduated recovery ──
555
- const derivedKey = `${unitType}/${unitId}`;
556
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
557
- sameUnitCount++;
558
- debugLog("autoLoop", {
559
- phase: "stuck-check",
560
- unitType,
561
- unitId,
562
- sameUnitCount,
638
+ catch (err) {
639
+ ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
640
+ }
641
+ // ── Phase 3: Dispatch resolution ────────────────────────────────────
642
+ debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
643
+ const dispatchResult = await deps.resolveDispatch({
644
+ basePath: s.basePath,
645
+ mid,
646
+ midTitle: midTitle,
647
+ state,
648
+ prefs,
649
+ session: s,
563
650
  });
564
- if (sameUnitCount === 3) {
565
- // Level 1: try verifying the artifact — maybe it was written but not detected
566
- const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
567
- if (artifactExists) {
651
+ if (dispatchResult.action === "stop") {
652
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
653
+ debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
654
+ break;
655
+ }
656
+ if (dispatchResult.action !== "dispatch") {
657
+ // Non-dispatch action (e.g. "skip") — re-derive state
658
+ await new Promise((r) => setImmediate(r));
659
+ continue;
660
+ }
661
+ unitType = dispatchResult.unitType;
662
+ unitId = dispatchResult.unitId;
663
+ prompt = dispatchResult.prompt;
664
+ pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
665
+ // ── Sliding-window stuck detection with graduated recovery ──
666
+ const derivedKey = `${unitType}/${unitId}`;
667
+ if (!s.pendingVerificationRetry) {
668
+ recentUnits.push({ key: derivedKey });
669
+ if (recentUnits.length > STUCK_WINDOW_SIZE)
670
+ recentUnits.shift();
671
+ const stuckSignal = detectStuck(recentUnits);
672
+ if (stuckSignal) {
568
673
  debugLog("autoLoop", {
569
- phase: "stuck-recovery",
570
- level: 1,
571
- action: "artifact-found",
674
+ phase: "stuck-check",
675
+ unitType,
676
+ unitId,
677
+ reason: stuckSignal.reason,
678
+ recoveryAttempts: stuckRecoveryAttempts,
572
679
  });
573
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
574
- deps.invalidateAllCaches();
575
- continue;
680
+ if (stuckRecoveryAttempts === 0) {
681
+ // Level 1: try verifying the artifact, then cache invalidation + retry
682
+ stuckRecoveryAttempts++;
683
+ const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
684
+ if (artifactExists) {
685
+ debugLog("autoLoop", {
686
+ phase: "stuck-recovery",
687
+ level: 1,
688
+ action: "artifact-found",
689
+ });
690
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
691
+ deps.invalidateAllCaches();
692
+ continue;
693
+ }
694
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, "warning");
695
+ deps.invalidateAllCaches();
696
+ }
697
+ else {
698
+ // Level 2: hard stop — genuinely stuck
699
+ debugLog("autoLoop", {
700
+ phase: "stuck-detected",
701
+ unitType,
702
+ unitId,
703
+ reason: stuckSignal.reason,
704
+ });
705
+ await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
706
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, "error");
707
+ break;
708
+ }
709
+ }
710
+ else {
711
+ // Progress detected — reset recovery counter
712
+ if (stuckRecoveryAttempts > 0) {
713
+ debugLog("autoLoop", {
714
+ phase: "stuck-counter-reset",
715
+ from: recentUnits[recentUnits.length - 2]?.key ?? "",
716
+ to: derivedKey,
717
+ });
718
+ stuckRecoveryAttempts = 0;
719
+ }
576
720
  }
577
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`, "warning");
578
- deps.invalidateAllCaches();
579
721
  }
580
- else if (sameUnitCount === 5) {
581
- // Level 2: hard stop genuinely stuck
582
- debugLog("autoLoop", {
583
- phase: "stuck-detected",
584
- unitType,
585
- unitId,
586
- sameUnitCount,
587
- });
588
- await deps.stopAuto(ctx, pi, `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`);
589
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`, "error");
722
+ // Pre-dispatch hooks
723
+ const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
724
+ if (preDispatchResult.firedHooks.length > 0) {
725
+ ctx.ui.notify(`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info");
726
+ }
727
+ if (preDispatchResult.action === "skip") {
728
+ ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
729
+ await new Promise((r) => setImmediate(r));
730
+ continue;
731
+ }
732
+ if (preDispatchResult.action === "replace") {
733
+ prompt = preDispatchResult.prompt ?? prompt;
734
+ if (preDispatchResult.unitType)
735
+ unitType = preDispatchResult.unitType;
736
+ }
737
+ else if (preDispatchResult.prompt) {
738
+ prompt = preDispatchResult.prompt;
739
+ }
740
+ const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(s.basePath, deps.getMainBranch(s.basePath), unitType, unitId);
741
+ if (priorSliceBlocker) {
742
+ await deps.stopAuto(ctx, pi, priorSliceBlocker);
743
+ debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
590
744
  break;
591
745
  }
746
+ observabilityIssues = await deps.collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
747
+ // Derive state for shared use in execution phase
748
+ // (state, mid, midTitle already set above)
592
749
  }
593
750
  else {
594
- if (derivedKey !== lastDerivedUnit) {
595
- debugLog("autoLoop", {
596
- phase: "stuck-counter-reset",
597
- from: lastDerivedUnit,
598
- to: derivedKey,
599
- });
600
- }
601
- lastDerivedUnit = derivedKey;
602
- sameUnitCount = 0;
603
- }
604
- // Pre-dispatch hooks
605
- const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
606
- if (preDispatchResult.firedHooks.length > 0) {
607
- ctx.ui.notify(`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info");
608
- }
609
- if (preDispatchResult.action === "skip") {
610
- ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
611
- await new Promise((r) => setImmediate(r));
612
- continue;
613
- }
614
- if (preDispatchResult.action === "replace") {
615
- prompt = preDispatchResult.prompt ?? prompt;
616
- if (preDispatchResult.unitType)
617
- unitType = preDispatchResult.unitType;
618
- }
619
- else if (preDispatchResult.prompt) {
620
- prompt = preDispatchResult.prompt;
621
- }
622
- const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(s.basePath, deps.getMainBranch(s.basePath), unitType, unitId);
623
- if (priorSliceBlocker) {
624
- await deps.stopAuto(ctx, pi, priorSliceBlocker);
625
- debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
626
- break;
751
+ // ── Sidecar path: use values from the sidecar item directly ──
752
+ unitType = sidecarItem.unitType;
753
+ unitId = sidecarItem.unitId;
754
+ prompt = sidecarItem.prompt;
755
+ // Derive minimal state for progress widget / execution context
756
+ state = await deps.deriveState(s.basePath);
757
+ mid = state.activeMilestone?.id;
758
+ midTitle = state.activeMilestone?.title;
627
759
  }
628
- const observabilityIssues = await deps.collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
629
760
  // ── Phase 4: Unit execution ─────────────────────────────────────────
630
761
  debugLog("autoLoop", {
631
762
  phase: "unit-execution",
@@ -638,33 +769,6 @@ export async function autoLoop(ctx, pi, s, deps) {
638
769
  s.currentUnit.type === unitType &&
639
770
  s.currentUnit.id === unitId);
640
771
  const previousTier = s.currentUnitRouting?.tier;
641
- // Closeout previous unit
642
- if (s.currentUnit) {
643
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
644
- if (s.currentUnitRouting) {
645
- const isRetryForOutcome = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
646
- deps.recordOutcome(s.currentUnit.type, s.currentUnitRouting.tier, !isRetryForOutcome);
647
- }
648
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
649
- const incomingKey = `${unitType}/${unitId}`;
650
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
651
- const artifactVerified = isHookUnit ||
652
- deps.verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
653
- if (closeoutKey !== incomingKey && artifactVerified) {
654
- s.completedUnits.push({
655
- type: s.currentUnit.type,
656
- id: s.currentUnit.id,
657
- startedAt: s.currentUnit.startedAt,
658
- finishedAt: Date.now(),
659
- });
660
- if (s.completedUnits.length > 200) {
661
- s.completedUnits = s.completedUnits.slice(-200);
662
- }
663
- deps.clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
664
- s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
665
- s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
666
- }
667
- }
668
772
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
669
773
  deps.captureAvailableSkills();
670
774
  deps.writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
@@ -682,7 +786,6 @@ export async function autoLoop(ctx, pi, s, deps) {
682
786
  deps.updateProgressWidget(ctx, unitType, unitId, state);
683
787
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
684
788
  // Prompt injection
685
- const MAX_RECOVERY_CHARS = 50_000;
686
789
  let finalPrompt = prompt;
687
790
  if (s.pendingVerificationRetry) {
688
791
  const retryCtx = s.pendingVerificationRetry;
@@ -720,7 +823,7 @@ export async function autoLoop(ctx, pi, s, deps) {
720
823
  s.lastBaselineCharCount = undefined;
721
824
  if (deps.isDbAvailable()) {
722
825
  try {
723
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
826
+ const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js");
724
827
  const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
725
828
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
726
829
  inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
@@ -743,8 +846,8 @@ export async function autoLoop(ctx, pi, s, deps) {
743
846
  const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
744
847
  process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
745
848
  }
746
- // Select and apply model (with tier escalation on retry)
747
- const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel, { isRetry, previousTier });
849
+ // Select and apply model (with tier escalation on retry — normal units only)
850
+ const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel, sidecarItem ? undefined : { isRetry, previousTier });
748
851
  s.currentUnitRouting =
749
852
  modelResult.routing;
750
853
  // Start unit supervision
@@ -778,12 +881,60 @@ export async function autoLoop(ctx, pi, s, deps) {
778
881
  unitId,
779
882
  status: unitResult.status,
780
883
  });
884
+ // Tag the most recent window entry with error info for stuck detection
885
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
886
+ const lastEntry = recentUnits[recentUnits.length - 1];
887
+ if (lastEntry) {
888
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
889
+ }
890
+ }
891
+ else if (unitResult.event?.messages?.length) {
892
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
893
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
894
+ if (/error|fail|exception/i.test(msgStr)) {
895
+ const lastEntry = recentUnits[recentUnits.length - 1];
896
+ if (lastEntry) {
897
+ lastEntry.error = msgStr.slice(0, 200);
898
+ }
899
+ }
900
+ }
781
901
  if (unitResult.status === "cancelled") {
782
902
  ctx.ui.notify(`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, "warning");
783
903
  await deps.stopAuto(ctx, pi, "Session creation failed");
784
904
  debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
785
905
  break;
786
906
  }
907
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
908
+ // Run right after runUnit() returns so telemetry is never lost to a
909
+ // crash between iterations.
910
+ await deps.closeoutUnit(ctx, s.basePath, unitType, unitId, s.currentUnit.startedAt, deps.buildSnapshotOpts(unitType, unitId));
911
+ if (s.currentUnitRouting) {
912
+ deps.recordOutcome(unitType, s.currentUnitRouting.tier, true);
913
+ }
914
+ const isHookUnit = unitType.startsWith("hook/");
915
+ const artifactVerified = isHookUnit ||
916
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
917
+ if (artifactVerified) {
918
+ s.completedUnits.push({
919
+ type: unitType,
920
+ id: unitId,
921
+ startedAt: s.currentUnit.startedAt,
922
+ finishedAt: Date.now(),
923
+ });
924
+ if (s.completedUnits.length > 200) {
925
+ s.completedUnits = s.completedUnits.slice(-200);
926
+ }
927
+ // Flush completed-units to disk so the record survives crashes
928
+ try {
929
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
930
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
931
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
932
+ }
933
+ catch { /* non-fatal: disk flush failure */ }
934
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
935
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
936
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
937
+ }
787
938
  // ── Phase 5: Finalize ───────────────────────────────────────────────
788
939
  debugLog("autoLoop", { phase: "finalize", iteration });
789
940
  // Clear unit timeout (unit completed)
@@ -800,7 +951,13 @@ export async function autoLoop(ctx, pi, s, deps) {
800
951
  updateProgressWidget: deps.updateProgressWidget,
801
952
  };
802
953
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
803
- const preResult = await deps.postUnitPreVerification(postUnitCtx);
954
+ // Sidecar items use lightweight pre-verification opts
955
+ const preVerificationOpts = sidecarItem
956
+ ? sidecarItem.kind === "hook"
957
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
958
+ : { skipSettleDelay: true, skipStateRebuild: true }
959
+ : undefined;
960
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
804
961
  if (preResult === "dispatched") {
805
962
  debugLog("autoLoop", {
806
963
  phase: "exit",
@@ -814,17 +971,28 @@ export async function autoLoop(ctx, pi, s, deps) {
814
971
  debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
815
972
  break;
816
973
  }
817
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
818
- const verificationResult = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
819
- if (verificationResult === "pause") {
820
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
821
- break;
822
- }
823
- if (verificationResult === "retry") {
824
- // s.pendingVerificationRetry was set by runPostUnitVerification.
825
- // Continue the loop — next iteration will inject the retry context into the prompt.
826
- debugLog("autoLoop", { phase: "verification-retry", iteration });
827
- continue;
974
+ // Verification gate
975
+ // Hook sidecar items skip verification entirely.
976
+ // Non-hook sidecar items run verification but skip retries (just continue).
977
+ const skipVerification = sidecarItem?.kind === "hook";
978
+ if (!skipVerification) {
979
+ const verificationResult = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
980
+ if (verificationResult === "pause") {
981
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
982
+ break;
983
+ }
984
+ if (verificationResult === "retry") {
985
+ if (sidecarItem) {
986
+ // Sidecar verification retries are skipped — just continue
987
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration });
988
+ }
989
+ else {
990
+ // s.pendingVerificationRetry was set by runPostUnitVerification.
991
+ // Continue the loop — next iteration will inject the retry context into the prompt.
992
+ debugLog("autoLoop", { phase: "verification-retry", iteration });
993
+ continue;
994
+ }
995
+ }
828
996
  }
829
997
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
830
998
  const postResult = await deps.postUnitPostVerification(postUnitCtx);
@@ -840,105 +1008,6 @@ export async function autoLoop(ctx, pi, s, deps) {
840
1008
  debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
841
1009
  break;
842
1010
  }
843
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
844
- let sidecarBroke = false;
845
- while (s.sidecarQueue.length > 0 && s.active) {
846
- const item = s.sidecarQueue.shift();
847
- debugLog("autoLoop", {
848
- phase: "sidecar-dequeue",
849
- kind: item.kind,
850
- unitType: item.unitType,
851
- unitId: item.unitId,
852
- });
853
- // Set up as current unit
854
- const sidecarStartedAt = Date.now();
855
- s.currentUnit = {
856
- type: item.unitType,
857
- id: item.unitId,
858
- startedAt: sidecarStartedAt,
859
- };
860
- deps.writeUnitRuntimeRecord(s.basePath, item.unitType, item.unitId, sidecarStartedAt, {
861
- phase: "dispatched",
862
- wrapupWarningSent: false,
863
- timeoutAt: null,
864
- lastProgressAt: sidecarStartedAt,
865
- progressCount: 0,
866
- lastProgressKind: "dispatch",
867
- });
868
- // Model selection (handles hook model override)
869
- await deps.selectAndApplyModel(ctx, pi, item.unitType, item.unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel);
870
- // Supervision
871
- deps.clearUnitTimeout();
872
- deps.startUnitSupervision({
873
- s,
874
- ctx,
875
- pi,
876
- unitType: item.unitType,
877
- unitId: item.unitId,
878
- prefs,
879
- buildSnapshotOpts: () => deps.buildSnapshotOpts(item.unitType, item.unitId),
880
- buildRecoveryContext: () => ({}),
881
- pauseAuto: deps.pauseAuto,
882
- });
883
- // Write lock
884
- const sidecarSessionFile = deps.getSessionFile(ctx);
885
- deps.writeLock(deps.lockBase(), item.unitType, item.unitId, s.completedUnits.length, sidecarSessionFile);
886
- // Execute via standard runUnit
887
- const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt);
888
- deps.clearUnitTimeout();
889
- if (sidecarResult.status === "cancelled") {
890
- ctx.ui.notify(`Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`, "warning");
891
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
892
- sidecarBroke = true;
893
- break;
894
- }
895
- // Run pre-verification for the sidecar unit (lightweight path)
896
- const sidecarPreOpts = item.kind === "hook"
897
- ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
898
- : { skipSettleDelay: true, skipStateRebuild: true };
899
- const sidecarPreResult = await deps.postUnitPreVerification(postUnitCtx, sidecarPreOpts);
900
- if (sidecarPreResult === "dispatched") {
901
- // Pre-verification caused stop/pause
902
- debugLog("autoLoop", {
903
- phase: "exit",
904
- reason: "sidecar-pre-verification-stop",
905
- });
906
- sidecarBroke = true;
907
- break;
908
- }
909
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
910
- // Hook units are lightweight and don't need verification.
911
- if (item.kind !== "hook") {
912
- const sidecarVerification = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
913
- if (sidecarVerification === "pause") {
914
- debugLog("autoLoop", {
915
- phase: "exit",
916
- reason: "sidecar-verification-pause",
917
- });
918
- sidecarBroke = true;
919
- break;
920
- }
921
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
922
- }
923
- // Post-verification (may enqueue more sidecar items)
924
- const sidecarPostResult = await deps.postUnitPostVerification(postUnitCtx);
925
- if (sidecarPostResult === "stopped") {
926
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
927
- sidecarBroke = true;
928
- break;
929
- }
930
- if (sidecarPostResult === "step-wizard") {
931
- debugLog("autoLoop", {
932
- phase: "exit",
933
- reason: "sidecar-step-wizard",
934
- });
935
- sidecarBroke = true;
936
- break;
937
- }
938
- // "continue" — loop checks sidecarQueue again
939
- }
940
- if (sidecarBroke)
941
- break;
942
1011
  consecutiveErrors = 0; // Iteration completed successfully
943
1012
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
944
1013
  }