gsd-pi 2.38.0-dev.63ad7e5 → 2.38.0-dev.8f5c161

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.
@@ -18,9 +18,11 @@ import { debugLog } from "./debug-logger.js";
18
18
  * generous headroom including retries and sidecar work.
19
19
  */
20
20
  const MAX_LOOP_ITERATIONS = 500;
21
- /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
22
- * handled inline because it requires break/pause/stop control flow. */
21
+ /** Data-driven budget threshold notifications (descending). The 100% entry
22
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
23
+ * a simple notification. */
23
24
  const BUDGET_THRESHOLDS = [
25
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
24
26
  { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
25
27
  { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
26
28
  { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
@@ -88,7 +90,7 @@ export function _setActiveSession(_session) {
88
90
  * On session creation failure or timeout, returns { status: 'cancelled' }
89
91
  * without awaiting the promise.
90
92
  */
91
- export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
93
+ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
92
94
  debugLog("runUnit", { phase: "start", unitType, unitId });
93
95
  // ── Session creation with timeout ──
94
96
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
@@ -155,6 +157,60 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
155
157
  });
156
158
  return result;
157
159
  }
160
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
161
+ /**
162
+ * Generate and write an HTML milestone report snapshot.
163
+ * Extracted from the milestone-transition block in autoLoop.
164
+ */
165
+ 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");
169
+ const { basename } = await import("node:path");
170
+ const snapData = await loadVisualizerData(s.basePath);
171
+ const completedMs = snapData.milestones.find((m) => m.id === milestoneId);
172
+ const msTitle = completedMs?.title ?? milestoneId;
173
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
174
+ const projName = basename(s.basePath);
175
+ const doneSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.filter((sl) => sl.done).length, 0);
176
+ const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
177
+ const outPath = writeReportSnapshot({
178
+ basePath: s.basePath,
179
+ html: generateHtmlReport(snapData, {
180
+ projectName: projName,
181
+ projectPath: s.basePath,
182
+ gsdVersion,
183
+ milestoneId,
184
+ indexRelPath: "index.html",
185
+ }),
186
+ milestoneId,
187
+ milestoneTitle: msTitle,
188
+ kind: "milestone",
189
+ projectName: projName,
190
+ projectPath: s.basePath,
191
+ gsdVersion,
192
+ totalCost: snapData.totals?.cost ?? 0,
193
+ totalTokens: snapData.totals?.tokens.total ?? 0,
194
+ totalDuration: snapData.totals?.duration ?? 0,
195
+ doneSlices,
196
+ totalSlices,
197
+ doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
198
+ totalMilestones: snapData.milestones.length,
199
+ phase: snapData.phase,
200
+ });
201
+ ctx.ui.notify(`Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, "info");
202
+ }
203
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
204
+ /**
205
+ * If a unit is in-flight, close it out, then stop auto-mode.
206
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
207
+ */
208
+ async function closeoutAndStop(ctx, pi, s, deps, reason) {
209
+ if (s.currentUnit) {
210
+ await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
211
+ }
212
+ await deps.stopAuto(ctx, pi, reason);
213
+ }
158
214
  // ─── autoLoop ────────────────────────────────────────────────────────────────
159
215
  /**
160
216
  * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
@@ -262,43 +318,7 @@ export async function autoLoop(ctx, pi, s, deps) {
262
318
  }
263
319
  if (vizPrefs?.auto_report !== false) {
264
320
  try {
265
- const { loadVisualizerData } = await import("./visualizer-data.js");
266
- const { generateHtmlReport } = await import("./export-html.js");
267
- const { writeReportSnapshot } = await import("./reports.js");
268
- const { basename } = await import("node:path");
269
- const snapData = await loadVisualizerData(s.basePath);
270
- const completedMs = snapData.milestones.find((m) => m.id === s.currentMilestoneId);
271
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
272
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
273
- const projName = basename(s.basePath);
274
- const doneSlices = snapData.milestones.reduce((acc, m) => acc +
275
- m.slices.filter((sl) => sl.done).length, 0);
276
- const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
277
- const outPath = writeReportSnapshot({
278
- basePath: s.basePath,
279
- html: generateHtmlReport(snapData, {
280
- projectName: projName,
281
- projectPath: s.basePath,
282
- gsdVersion,
283
- milestoneId: s.currentMilestoneId,
284
- indexRelPath: "index.html",
285
- }),
286
- milestoneId: s.currentMilestoneId,
287
- milestoneTitle: msTitle,
288
- kind: "milestone",
289
- projectName: projName,
290
- projectPath: s.basePath,
291
- gsdVersion,
292
- totalCost: snapData.totals?.cost ?? 0,
293
- totalTokens: snapData.totals?.tokens.total ?? 0,
294
- totalDuration: snapData.totals?.duration ?? 0,
295
- doneSlices,
296
- totalSlices,
297
- doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
298
- totalMilestones: snapData.milestones.length,
299
- phase: snapData.phase,
300
- });
301
- ctx.ui.notify(`Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`, "info");
321
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId);
302
322
  }
303
323
  catch (err) {
304
324
  ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
@@ -386,13 +406,10 @@ export async function autoLoop(ctx, pi, s, deps) {
386
406
  midTitle = state.activeMilestone?.title;
387
407
  }
388
408
  if (!mid || !midTitle) {
389
- if (s.currentUnit) {
390
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
391
- }
392
409
  const noMilestoneReason = !mid
393
410
  ? "No active milestone after merge reconciliation"
394
411
  : `Milestone ${mid} has no title after reconciliation`;
395
- await deps.stopAuto(ctx, pi, noMilestoneReason);
412
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
396
413
  debugLog("autoLoop", {
397
414
  phase: "exit",
398
415
  reason: "no-milestone-after-reconciliation",
@@ -401,26 +418,20 @@ export async function autoLoop(ctx, pi, s, deps) {
401
418
  }
402
419
  // Terminal: complete
403
420
  if (state.phase === "complete") {
404
- if (s.currentUnit) {
405
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
406
- }
407
- // Milestone merge on complete
421
+ // Milestone merge on complete (before closeout so branch state is clean)
408
422
  if (s.currentMilestoneId) {
409
423
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
410
424
  }
411
425
  deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
412
426
  deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${mid} complete.`, "success");
413
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
427
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
414
428
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
415
429
  break;
416
430
  }
417
431
  // Terminal: blocked
418
432
  if (state.phase === "blocked") {
419
- if (s.currentUnit) {
420
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
421
- }
422
433
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
423
- await deps.stopAuto(ctx, pi, blockerMsg);
434
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
424
435
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
425
436
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
426
437
  deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
@@ -441,42 +452,42 @@ export async function autoLoop(ctx, pi, s, deps) {
441
452
  const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
442
453
  const enforcement = prefs?.budget_enforcement ?? "pause";
443
454
  const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
444
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
445
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
455
+ // Data-driven threshold check loop descending, fire first match
456
+ const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
457
+ if (threshold) {
446
458
  s.lastBudgetAlertLevel =
447
459
  newBudgetAlertLevel;
448
- if (budgetEnforcementAction === "halt") {
449
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
450
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
451
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
452
- break;
453
- }
454
- if (budgetEnforcementAction === "pause") {
455
- ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
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");
471
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
472
+ deps.logCmuxEvent(prefs, msg, "warning");
473
+ await deps.pauseAuto(ctx, pi);
474
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
475
+ break;
476
+ }
477
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
456
478
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
457
479
  deps.logCmuxEvent(prefs, msg, "warning");
458
- await deps.pauseAuto(ctx, pi);
459
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
460
- break;
461
480
  }
462
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
463
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
464
- deps.logCmuxEvent(prefs, msg, "warning");
465
- }
466
- else {
467
- // Data-driven 75/80/90% threshold notifications
468
- const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel === t.pct);
469
- if (threshold) {
470
- s.lastBudgetAlertLevel =
471
- newBudgetAlertLevel;
481
+ else if (threshold.pct < 100) {
482
+ // Sub-100% simple notification
472
483
  const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
473
484
  ctx.ui.notify(msg, threshold.notifyLevel);
474
485
  deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
475
486
  deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
476
487
  }
477
- else if (budgetAlertLevel === 0) {
478
- s.lastBudgetAlertLevel = 0;
479
- }
488
+ }
489
+ else if (budgetAlertLevel === 0) {
490
+ s.lastBudgetAlertLevel = 0;
480
491
  }
481
492
  }
482
493
  else {
@@ -527,10 +538,7 @@ export async function autoLoop(ctx, pi, s, deps) {
527
538
  session: s,
528
539
  });
529
540
  if (dispatchResult.action === "stop") {
530
- if (s.currentUnit) {
531
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
532
- }
533
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
541
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
534
542
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
535
543
  break;
536
544
  }
@@ -634,8 +642,8 @@ export async function autoLoop(ctx, pi, s, deps) {
634
642
  if (s.currentUnit) {
635
643
  await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
636
644
  if (s.currentUnitRouting) {
637
- const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
638
- deps.recordOutcome(s.currentUnit.type, s.currentUnitRouting.tier, !isRetry);
645
+ const isRetryForOutcome = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
646
+ deps.recordOutcome(s.currentUnit.type, s.currentUnitRouting.tier, !isRetryForOutcome);
639
647
  }
640
648
  const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
641
649
  const incomingKey = `${unitType}/${unitId}`;
@@ -762,7 +770,7 @@ export async function autoLoop(ctx, pi, s, deps) {
762
770
  unitType,
763
771
  unitId,
764
772
  });
765
- const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt, prefs);
773
+ const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt);
766
774
  debugLog("autoLoop", {
767
775
  phase: "runUnit-end",
768
776
  iteration,
@@ -876,7 +884,7 @@ export async function autoLoop(ctx, pi, s, deps) {
876
884
  const sidecarSessionFile = deps.getSessionFile(ctx);
877
885
  deps.writeLock(deps.lockBase(), item.unitType, item.unitId, s.completedUnits.length, sidecarSessionFile);
878
886
  // Execute via standard runUnit
879
- const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt, prefs);
887
+ const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt);
880
888
  deps.clearUnitTimeout();
881
889
  if (sidecarResult.status === "cancelled") {
882
890
  ctx.ui.notify(`Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`, "warning");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.38.0-dev.63ad7e5",
3
+ "version": "2.38.0-dev.8f5c161",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,14 +36,16 @@ import type { CmuxLogLevel } from "../cmux/index.js";
36
36
  */
37
37
  const MAX_LOOP_ITERATIONS = 500;
38
38
 
39
- /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
40
- * handled inline because it requires break/pause/stop control flow. */
39
+ /** Data-driven budget threshold notifications (descending). The 100% entry
40
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
41
+ * a simple notification. */
41
42
  const BUDGET_THRESHOLDS: Array<{
42
43
  pct: number;
43
44
  label: string;
44
- notifyLevel: "info" | "warning";
45
- cmuxLevel: "progress" | "warning";
45
+ notifyLevel: "info" | "warning" | "error";
46
+ cmuxLevel: "progress" | "warning" | "error";
46
47
  }> = [
48
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
47
49
  { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
48
50
  { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
49
51
  { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
@@ -145,7 +147,6 @@ export async function runUnit(
145
147
  unitType: string,
146
148
  unitId: string,
147
149
  prompt: string,
148
- _prefs: GSDPreferences | undefined,
149
150
  ): Promise<UnitResult> {
150
151
  debugLog("runUnit", { phase: "start", unitType, unitId });
151
152
 
@@ -489,6 +490,96 @@ export interface LoopDeps {
489
490
  getSessionFile: (ctx: ExtensionContext) => string;
490
491
  }
491
492
 
493
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
494
+
495
+ /**
496
+ * Generate and write an HTML milestone report snapshot.
497
+ * Extracted from the milestone-transition block in autoLoop.
498
+ */
499
+ async function generateMilestoneReport(
500
+ s: AutoSession,
501
+ ctx: ExtensionContext,
502
+ milestoneId: string,
503
+ ): 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");
507
+ const { basename } = await import("node:path");
508
+
509
+ const snapData = await loadVisualizerData(s.basePath);
510
+ const completedMs = snapData.milestones.find(
511
+ (m: { id: string }) => m.id === milestoneId,
512
+ );
513
+ const msTitle = completedMs?.title ?? milestoneId;
514
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
515
+ const projName = basename(s.basePath);
516
+ const doneSlices = snapData.milestones.reduce(
517
+ (acc: number, m: { slices: { done: boolean }[] }) =>
518
+ acc + m.slices.filter((sl: { done: boolean }) => sl.done).length,
519
+ 0,
520
+ );
521
+ const totalSlices = snapData.milestones.reduce(
522
+ (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
523
+ 0,
524
+ );
525
+ const outPath = writeReportSnapshot({
526
+ basePath: s.basePath,
527
+ html: generateHtmlReport(snapData, {
528
+ projectName: projName,
529
+ projectPath: s.basePath,
530
+ gsdVersion,
531
+ milestoneId,
532
+ indexRelPath: "index.html",
533
+ }),
534
+ milestoneId,
535
+ milestoneTitle: msTitle,
536
+ kind: "milestone",
537
+ projectName: projName,
538
+ projectPath: s.basePath,
539
+ gsdVersion,
540
+ totalCost: snapData.totals?.cost ?? 0,
541
+ totalTokens: snapData.totals?.tokens.total ?? 0,
542
+ totalDuration: snapData.totals?.duration ?? 0,
543
+ doneSlices,
544
+ totalSlices,
545
+ doneMilestones: snapData.milestones.filter(
546
+ (m: { status: string }) => m.status === "complete",
547
+ ).length,
548
+ totalMilestones: snapData.milestones.length,
549
+ phase: snapData.phase,
550
+ });
551
+ ctx.ui.notify(
552
+ `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
553
+ "info",
554
+ );
555
+ }
556
+
557
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * If a unit is in-flight, close it out, then stop auto-mode.
561
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
562
+ */
563
+ async function closeoutAndStop(
564
+ ctx: ExtensionContext,
565
+ pi: ExtensionAPI,
566
+ s: AutoSession,
567
+ deps: LoopDeps,
568
+ reason: string,
569
+ ): Promise<void> {
570
+ if (s.currentUnit) {
571
+ await deps.closeoutUnit(
572
+ ctx,
573
+ s.basePath,
574
+ s.currentUnit.type,
575
+ s.currentUnit.id,
576
+ s.currentUnit.startedAt,
577
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
578
+ );
579
+ }
580
+ await deps.stopAuto(ctx, pi, reason);
581
+ }
582
+
492
583
  // ─── autoLoop ────────────────────────────────────────────────────────────────
493
584
 
494
585
  /**
@@ -643,57 +734,7 @@ export async function autoLoop(
643
734
  }
644
735
  if (vizPrefs?.auto_report !== false) {
645
736
  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
- );
737
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
697
738
  } catch (err) {
698
739
  ctx.ui.notify(
699
740
  `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -831,20 +872,10 @@ export async function autoLoop(
831
872
  }
832
873
 
833
874
  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
875
  const noMilestoneReason = !mid
845
876
  ? "No active milestone after merge reconciliation"
846
877
  : `Milestone ${mid} has no title after reconciliation`;
847
- await deps.stopAuto(ctx, pi, noMilestoneReason);
878
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
848
879
  debugLog("autoLoop", {
849
880
  phase: "exit",
850
881
  reason: "no-milestone-after-reconciliation",
@@ -854,17 +885,7 @@ export async function autoLoop(
854
885
 
855
886
  // Terminal: complete
856
887
  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
888
+ // Milestone merge on complete (before closeout so branch state is clean)
868
889
  if (s.currentMilestoneId) {
869
890
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
870
891
  }
@@ -879,25 +900,15 @@ export async function autoLoop(
879
900
  `Milestone ${mid} complete.`,
880
901
  "success",
881
902
  );
882
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
903
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
883
904
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
884
905
  break;
885
906
  }
886
907
 
887
908
  // Terminal: blocked
888
909
  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
910
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
900
- await deps.stopAuto(ctx, pi, blockerMsg);
911
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
901
912
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
902
913
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
903
914
  deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
@@ -928,38 +939,39 @@ export async function autoLoop(
928
939
  budgetPct,
929
940
  );
930
941
 
931
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
932
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
942
+ // Data-driven threshold check loop descending, fire first match
943
+ const threshold = BUDGET_THRESHOLDS.find(
944
+ (t) => newBudgetAlertLevel >= t.pct,
945
+ );
946
+ if (threshold) {
933
947
  s.lastBudgetAlertLevel =
934
948
  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
- );
949
+
950
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
951
+ // 100% special enforcement logic (halt/pause/warn)
952
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
953
+ if (budgetEnforcementAction === "halt") {
954
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
955
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
956
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
957
+ break;
958
+ }
959
+ if (budgetEnforcementAction === "pause") {
960
+ ctx.ui.notify(
961
+ `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
962
+ "warning",
963
+ );
964
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
965
+ deps.logCmuxEvent(prefs, msg, "warning");
966
+ await deps.pauseAuto(ctx, pi);
967
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
968
+ break;
969
+ }
970
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
946
971
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
947
972
  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"];
973
+ } else if (threshold.pct < 100) {
974
+ // Sub-100% simple notification
963
975
  const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
964
976
  ctx.ui.notify(msg, threshold.notifyLevel);
965
977
  deps.sendDesktopNotification(
@@ -969,9 +981,9 @@ export async function autoLoop(
969
981
  "budget",
970
982
  );
971
983
  deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
972
- } else if (budgetAlertLevel === 0) {
973
- s.lastBudgetAlertLevel = 0;
974
984
  }
985
+ } else if (budgetAlertLevel === 0) {
986
+ s.lastBudgetAlertLevel = 0;
975
987
  }
976
988
  } else {
977
989
  s.lastBudgetAlertLevel = 0;
@@ -1046,17 +1058,7 @@ export async function autoLoop(
1046
1058
  });
1047
1059
 
1048
1060
  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);
1061
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1060
1062
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1061
1063
  break;
1062
1064
  }
@@ -1215,12 +1217,12 @@ export async function autoLoop(
1215
1217
  );
1216
1218
 
1217
1219
  if (s.currentUnitRouting) {
1218
- const isRetry =
1220
+ const isRetryForOutcome =
1219
1221
  s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1220
1222
  deps.recordOutcome(
1221
1223
  s.currentUnit.type,
1222
1224
  s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1223
- !isRetry,
1225
+ !isRetryForOutcome,
1224
1226
  );
1225
1227
  }
1226
1228
 
@@ -1415,7 +1417,6 @@ export async function autoLoop(
1415
1417
  unitType,
1416
1418
  unitId,
1417
1419
  finalPrompt,
1418
- prefs,
1419
1420
  );
1420
1421
  debugLog("autoLoop", {
1421
1422
  phase: "runUnit-end",
@@ -1587,7 +1588,6 @@ export async function autoLoop(
1587
1588
  item.unitType,
1588
1589
  item.unitId,
1589
1590
  item.prompt,
1590
- prefs,
1591
1591
  );
1592
1592
  deps.clearUnitTimeout();
1593
1593
 
@@ -104,7 +104,6 @@ test("resolveAgentEnd resolves a pending runUnit promise", async () => {
104
104
  "task",
105
105
  "T01",
106
106
  "do stuff",
107
- undefined,
108
107
  );
109
108
 
110
109
  // Give the microtask queue a tick so runUnit reaches the await
@@ -136,7 +135,7 @@ test("double resolveAgentEnd only resolves once (second is dropped)", async () =
136
135
  const event1 = makeEvent([{ id: 1 }]);
137
136
  const event2 = makeEvent([{ id: 2 }]);
138
137
 
139
- const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
138
+ const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt");
140
139
 
141
140
  await new Promise((r) => setTimeout(r, 10));
142
141
 
@@ -161,7 +160,7 @@ test("runUnit returns cancelled when session creation fails", async () => {
161
160
  const pi = makeMockPi();
162
161
  const s = makeMockSession({ newSessionThrows: "connection refused" });
163
162
 
164
- const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
163
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
165
164
 
166
165
  assert.equal(result.status, "cancelled");
167
166
  assert.equal(result.event, undefined);
@@ -177,7 +176,7 @@ test("runUnit returns cancelled when session creation times out", async () => {
177
176
  // Session returns cancelled: true (simulates the timeout race outcome)
178
177
  const s = makeMockSession({ newSessionResult: { cancelled: true } });
179
178
 
180
- const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
179
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
181
180
 
182
181
  assert.equal(result.status, "cancelled");
183
182
  assert.equal(result.event, undefined);
@@ -192,7 +191,7 @@ test("runUnit returns cancelled when s.active is false before sendMessage", asyn
192
191
  const s = makeMockSession();
193
192
  s.active = false;
194
193
 
195
- const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
194
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
196
195
 
197
196
  assert.equal(result.status, "cancelled");
198
197
  assert.equal(pi.calls.length, 0);
@@ -212,7 +211,7 @@ test("runUnit only arms resolve after newSession completes", async () => {
212
211
  },
213
212
  });
214
213
 
215
- const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
214
+ const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt");
216
215
 
217
216
  await new Promise((r) => setTimeout(r, 30));
218
217