gsd-pi 2.3.8 → 2.3.9

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/README.md +5 -2
  2. package/dist/cli.js +32 -2
  3. package/dist/logo.d.ts +16 -0
  4. package/dist/logo.js +25 -0
  5. package/dist/onboarding.d.ts +43 -0
  6. package/dist/onboarding.js +425 -0
  7. package/dist/wizard.js +8 -0
  8. package/package.json +1 -1
  9. package/scripts/postinstall.js +38 -9
  10. package/src/resources/GSD-WORKFLOW.md +2 -2
  11. package/src/resources/extensions/google-search/index.ts +1 -1
  12. package/src/resources/extensions/gsd/auto.ts +353 -144
  13. package/src/resources/extensions/gsd/files.ts +9 -7
  14. package/src/resources/extensions/gsd/index.ts +3 -1
  15. package/src/resources/extensions/gsd/metrics.ts +7 -5
  16. package/src/resources/extensions/gsd/migrate/command.ts +4 -1
  17. package/src/resources/extensions/gsd/migrate/validator.ts +5 -3
  18. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  19. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +5 -5
  20. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +3 -3
  21. package/src/resources/extensions/gsd/tests/parsers.test.ts +94 -0
  22. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +23 -6
  23. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  24. package/src/resources/extensions/gsd/tests/worktree.test.ts +116 -1
  25. package/src/resources/extensions/gsd/unit-runtime.ts +22 -1
  26. package/src/resources/extensions/gsd/workspace-index.ts +2 -2
  27. package/src/resources/extensions/gsd/worktree-command.ts +147 -41
  28. package/src/resources/extensions/gsd/worktree.ts +105 -8
  29. package/src/resources/extensions/mcporter/index.ts +21 -2
  30. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  31. package/src/resources/extensions/search-the-web/http.ts +1 -1
  32. package/src/resources/extensions/search-the-web/index.ts +9 -3
  33. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  34. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  35. package/src/resources/extensions/search-the-web/tool-llm-context.ts +265 -108
  36. package/src/resources/extensions/search-the-web/tool-search.ts +161 -88
  37. package/src/resources/extensions/subagent/index.ts +1 -1
@@ -61,7 +61,9 @@ import {
61
61
  autoCommitCurrentBranch,
62
62
  ensureSliceBranch,
63
63
  getCurrentBranch,
64
+ getMainBranch,
64
65
  getSliceBranchName,
66
+ parseSliceBranch,
65
67
  switchToMain,
66
68
  mergeSliceToMain,
67
69
  } from "./worktree.ts";
@@ -69,6 +71,39 @@ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
69
71
  import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
70
72
  import { showNextAction } from "../shared/next-action-ui.js";
71
73
 
74
+ // ─── Disk-backed completed-unit helpers ───────────────────────────────────────
75
+
76
+ /** Path to the persisted completed-unit keys file. */
77
+ function completedKeysPath(base: string): string {
78
+ return join(base, ".gsd", "completed-units.json");
79
+ }
80
+
81
+ /** Write a completed unit key to disk (read-modify-write append to set). */
82
+ function persistCompletedKey(base: string, key: string): void {
83
+ const file = completedKeysPath(base);
84
+ let keys: string[] = [];
85
+ try {
86
+ if (existsSync(file)) {
87
+ keys = JSON.parse(readFileSync(file, "utf-8"));
88
+ }
89
+ } catch { /* corrupt file — start fresh */ }
90
+ if (!keys.includes(key)) {
91
+ keys.push(key);
92
+ writeFileSync(file, JSON.stringify(keys), "utf-8");
93
+ }
94
+ }
95
+
96
+ /** Load all completed unit keys from disk into the in-memory set. */
97
+ function loadPersistedKeys(base: string, target: Set<string>): void {
98
+ const file = completedKeysPath(base);
99
+ try {
100
+ if (existsSync(file)) {
101
+ const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
102
+ for (const k of keys) target.add(k);
103
+ }
104
+ } catch { /* non-fatal */ }
105
+ }
106
+
72
107
  // ─── State ────────────────────────────────────────────────────────────────────
73
108
 
74
109
  let active = false;
@@ -78,10 +113,15 @@ let verbose = false;
78
113
  let cmdCtx: ExtensionCommandContext | null = null;
79
114
  let basePath = "";
80
115
 
81
- /** Track last dispatched unit to detect stuck loops */
82
- let lastUnit: { type: string; id: string } | null = null;
83
- let retryCount = 0;
84
- const MAX_RETRIES = 1;
116
+ /** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
117
+ const unitDispatchCount = new Map<string, number>();
118
+ const MAX_UNIT_DISPATCHES = 3;
119
+
120
+ /** Tracks recovery attempt count per unit for backoff and diagnostics. */
121
+ const unitRecoveryCount = new Map<string, number>();
122
+
123
+ /** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */
124
+ const completedKeySet = new Set<string>();
85
125
 
86
126
  /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */
87
127
  let pendingCrashRecovery: string | null = null;
@@ -102,6 +142,26 @@ let unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
102
142
  let wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
103
143
  let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
104
144
 
145
+ /** Format token counts for compact display */
146
+ function formatWidgetTokens(count: number): string {
147
+ if (count < 1000) return count.toString();
148
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
149
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
150
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
151
+ return `${Math.round(count / 1000000)}M`;
152
+ }
153
+
154
+ /**
155
+ * Footer factory that renders zero lines — hides the built-in footer entirely.
156
+ * All footer info (pwd, branch, tokens, cost, model) is shown inside the
157
+ * progress widget instead, so there's no gap or redundancy.
158
+ */
159
+ const hideFooter = () => ({
160
+ render(_width: number): string[] { return []; },
161
+ invalidate() {},
162
+ dispose() {},
163
+ });
164
+
105
165
  /** Dashboard data for the overlay */
106
166
  export interface AutoDashboardData {
107
167
  active: boolean;
@@ -185,13 +245,15 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
185
245
  active = false;
186
246
  paused = false;
187
247
  stepMode = false;
188
- lastUnit = null;
248
+ unitDispatchCount.clear();
249
+ unitRecoveryCount.clear();
189
250
  currentUnit = null;
190
251
  currentMilestoneId = null;
191
252
  cachedSliceProgress = null;
192
253
  pendingCrashRecovery = null;
193
254
  ctx?.ui.setStatus("gsd-auto", undefined);
194
255
  ctx?.ui.setWidget("gsd-progress", undefined);
256
+ ctx?.ui.setFooter(undefined);
195
257
 
196
258
  // Restore the user's original model
197
259
  if (pi && ctx && originalModelId) {
@@ -214,11 +276,12 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro
214
276
  if (basePath) clearLock(basePath);
215
277
  active = false;
216
278
  paused = true;
217
- // Preserve: lastUnit, currentUnit, basePath, verbose, cmdCtx,
279
+ // Preserve: unitDispatchCount, currentUnit, basePath, verbose, cmdCtx,
218
280
  // completedUnits, autoStartTime, currentMilestoneId, originalModelId
219
281
  // — all needed for resume and dashboard display
220
282
  ctx?.ui.setStatus("gsd-auto", "paused");
221
283
  ctx?.ui.setWidget("gsd-progress", undefined);
284
+ ctx?.ui.setFooter(undefined);
222
285
  const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
223
286
  ctx?.ui.notify(
224
287
  `${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
@@ -226,6 +289,33 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro
226
289
  );
227
290
  }
228
291
 
292
+ /**
293
+ * Self-heal: scan runtime records in .gsd/ and clear any where the expected
294
+ * artifact already exists on disk. This repairs incomplete closeouts from
295
+ * prior crashes — preventing spurious re-dispatch of already-completed units.
296
+ */
297
+ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Promise<void> {
298
+ try {
299
+ const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
300
+ const records = listUnitRuntimeRecords(base);
301
+ let healed = 0;
302
+ for (const record of records) {
303
+ const { unitType, unitId } = record;
304
+ const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
305
+ if (artifactPath && existsSync(artifactPath)) {
306
+ // Artifact exists — unit completed but closeout didn't finish.
307
+ clearUnitRuntimeRecord(base, unitType, unitId);
308
+ healed++;
309
+ }
310
+ }
311
+ if (healed > 0) {
312
+ ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s) with completed artifacts.`, "info");
313
+ }
314
+ } catch {
315
+ // Non-fatal — self-heal should never block auto-mode start
316
+ }
317
+ }
318
+
229
319
  export async function startAuto(
230
320
  ctx: ExtensionCommandContext,
231
321
  pi: ExtensionAPI,
@@ -245,9 +335,11 @@ export async function startAuto(
245
335
  stepMode = requestedStepMode;
246
336
  cmdCtx = ctx;
247
337
  basePath = base;
338
+ unitDispatchCount.clear();
248
339
  // Re-initialize metrics in case ledger was lost during pause
249
340
  if (!getLedger()) initMetrics(base);
250
341
  ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
342
+ ctx.ui.setFooter(hideFooter);
251
343
  ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
252
344
  // Rebuild disk state before resuming — user interaction during pause may have changed files
253
345
  try { await rebuildState(base); } catch { /* non-fatal */ }
@@ -257,6 +349,8 @@ export async function startAuto(
257
349
  ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
258
350
  }
259
351
  } catch { /* non-fatal */ }
352
+ // Self-heal: clear stale runtime records where artifacts already exist
353
+ await selfHealRuntimeRecords(base, ctx);
260
354
  await dispatchNextUnit(ctx, pi);
261
355
  return;
262
356
  }
@@ -335,8 +429,10 @@ export async function startAuto(
335
429
  verbose = verboseMode;
336
430
  cmdCtx = ctx;
337
431
  basePath = base;
338
- lastUnit = null;
339
- retryCount = 0;
432
+ unitDispatchCount.clear();
433
+ unitRecoveryCount.clear();
434
+ completedKeySet.clear();
435
+ loadPersistedKeys(base, completedKeySet);
340
436
  autoStartTime = Date.now();
341
437
  completedUnits = [];
342
438
  currentUnit = null;
@@ -352,6 +448,7 @@ export async function startAuto(
352
448
  }
353
449
 
354
450
  ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
451
+ ctx.ui.setFooter(hideFooter);
355
452
  const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
356
453
  const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
357
454
  const scopeMsg = pendingCount > 1
@@ -359,6 +456,9 @@ export async function startAuto(
359
456
  : "Will loop until milestone complete.";
360
457
  ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
361
458
 
459
+ // Self-heal: clear stale runtime records where artifacts already exist
460
+ await selfHealRuntimeRecords(base, ctx);
461
+
362
462
  // Dispatch the first unit
363
463
  await dispatchNextUnit(ctx, pi);
364
464
  }
@@ -594,7 +694,18 @@ function updateProgressWidget(
594
694
  const slice = state.activeSlice;
595
695
  const task = state.activeTask;
596
696
  const next = peekNext(unitType, state);
597
- const preferredModel = resolveModelForUnit(unitType);
697
+
698
+ // Cache git branch at widget creation time (not per render)
699
+ let cachedBranch: string | null = null;
700
+ try { cachedBranch = getCurrentBranch(basePath); } catch { /* not in git repo */ }
701
+
702
+ // Cache pwd with ~ substitution
703
+ let widgetPwd = process.cwd();
704
+ const widgetHome = process.env.HOME || process.env.USERPROFILE;
705
+ if (widgetHome && widgetPwd.startsWith(widgetHome)) {
706
+ widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`;
707
+ }
708
+ if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
598
709
 
599
710
  ctx.ui.setWidget("gsd-progress", (tui, theme) => {
600
711
  let pulseBright = true;
@@ -677,8 +788,63 @@ function updateProgressWidget(
677
788
  ));
678
789
  }
679
790
 
791
+ // ── Footer info (pwd, tokens, cost, context, model) ──────────────
792
+ lines.push("");
793
+ lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…")));
794
+
795
+ // Token stats from current unit session + cumulative cost from metrics
796
+ {
797
+ let totalInput = 0, totalOutput = 0;
798
+ let totalCacheRead = 0, totalCacheWrite = 0;
799
+ if (cmdCtx) {
800
+ for (const entry of cmdCtx.sessionManager.getEntries()) {
801
+ if (entry.type === "message" && (entry as any).message?.role === "assistant") {
802
+ const u = (entry as any).message.usage;
803
+ if (u) {
804
+ totalInput += u.input || 0;
805
+ totalOutput += u.output || 0;
806
+ totalCacheRead += u.cacheRead || 0;
807
+ totalCacheWrite += u.cacheWrite || 0;
808
+ }
809
+ }
810
+ }
811
+ }
812
+ const mLedger = getLedger();
813
+ const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
814
+ const cumulativeCost = autoTotals?.cost ?? 0;
815
+
816
+ const cxUsage = cmdCtx?.getContextUsage?.();
817
+ const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
818
+ const cxPctVal = cxUsage?.percent ?? 0;
819
+ const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
820
+
821
+ const sp: string[] = [];
822
+ if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`);
823
+ if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`);
824
+ if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`);
825
+ if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`);
826
+ if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
827
+
828
+ const cxDisplay = cxPct === "?"
829
+ ? `?/${formatWidgetTokens(cxWindow)}`
830
+ : `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
831
+ if (cxPctVal > 90) {
832
+ sp.push(theme.fg("error", cxDisplay));
833
+ } else if (cxPctVal > 70) {
834
+ sp.push(theme.fg("warning", cxDisplay));
835
+ } else {
836
+ sp.push(cxDisplay);
837
+ }
838
+
839
+ const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
840
+ .join(theme.fg("dim", " "));
841
+
842
+ const modelId = cmdCtx?.model?.id ?? "";
843
+ const sRight = modelId ? theme.fg("dim", modelId) : "";
844
+ lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
845
+ }
846
+
680
847
  const hintParts: string[] = [];
681
- if (preferredModel) hintParts.push(preferredModel);
682
848
  hintParts.push("esc pause");
683
849
  hintParts.push("Ctrl+Alt+G dashboard");
684
850
  lines.push(...ui.hints(hintParts));
@@ -786,8 +952,8 @@ async function dispatchNextUnit(
786
952
  "info",
787
953
  );
788
954
  // Reset stuck detection for new milestone
789
- lastUnit = null;
790
- retryCount = 0;
955
+ unitDispatchCount.clear();
956
+ unitRecoveryCount.clear();
791
957
  }
792
958
  if (mid) currentMilestoneId = mid;
793
959
 
@@ -811,10 +977,10 @@ async function dispatchNextUnit(
811
977
  // - complete-milestone runs on a slice branch (last slice bypass)
812
978
  {
813
979
  const currentBranch = getCurrentBranch(basePath);
814
- const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/);
815
- if (branchMatch) {
816
- const branchMid = branchMatch[1]!;
817
- const branchSid = branchMatch[2]!;
980
+ const parsedBranch = parseSliceBranch(currentBranch);
981
+ if (parsedBranch) {
982
+ const branchMid = parsedBranch.milestoneId;
983
+ const branchSid = parsedBranch.sliceId;
818
984
  // Check if this slice is marked done in the roadmap
819
985
  const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
820
986
  const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
@@ -828,8 +994,9 @@ async function dispatchNextUnit(
828
994
  const mergeResult = mergeSliceToMain(
829
995
  basePath, branchMid, branchSid, sliceTitleForMerge,
830
996
  );
997
+ const targetBranch = getMainBranch(basePath);
831
998
  ctx.ui.notify(
832
- `Merged ${mergeResult.branch} → main.`,
999
+ `Merged ${mergeResult.branch} → ${targetBranch}.`,
833
1000
  "info",
834
1001
  );
835
1002
  // Re-derive state from main so downstream logic sees merged state
@@ -863,6 +1030,12 @@ async function dispatchNextUnit(
863
1030
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
864
1031
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
865
1032
  }
1033
+ // Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
1034
+ try {
1035
+ const file = completedKeysPath(basePath);
1036
+ if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
1037
+ completedKeySet.clear();
1038
+ } catch { /* non-fatal */ }
866
1039
  await stopAuto(ctx, pi);
867
1040
  return;
868
1041
  }
@@ -902,144 +1075,157 @@ async function dispatchNextUnit(
902
1075
  // can perform the UAT manually. On next resume, result file will exist → skip.
903
1076
  let pauseAfterUatDispatch = false;
904
1077
 
905
- // ── Adaptive Replanning: check if last completed slice needs reassessment ──
906
- // After a slice completes, we reassess the roadmap before moving to the next slice.
907
- // Skip reassessment for the final slice (milestone complete) or if already assessed.
908
- const needsReassess = await checkNeedsReassessment(basePath, mid, state);
909
- if (needsRunUat) {
910
- const { sliceId, uatType } = needsRunUat;
911
- unitType = "run-uat";
912
- unitId = `${mid}/${sliceId}`;
913
- const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
914
- const uatContent = await loadFile(uatFile);
915
- prompt = await buildRunUatPrompt(
916
- mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
917
- );
918
- // For non-artifact-driven UAT types, pause after the prompt is dispatched.
919
- // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
920
- // then auto-mode pauses for human execution. On resume, result file exists → skip.
921
- if (uatType !== "artifact-driven") {
922
- pauseAfterUatDispatch = true;
923
- }
924
- } else if (needsReassess) {
925
- unitType = "reassess-roadmap";
926
- unitId = `${mid}/${needsReassess.sliceId}`;
927
- prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
928
- } else if (state.phase === "pre-planning") {
929
- // Need roadmap — check if context exists
930
- const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
931
- const hasContext = !!(contextFile && await loadFile(contextFile));
932
-
933
- if (!hasContext) {
934
- await stopAuto(ctx, pi);
935
- ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
936
- return;
937
- }
938
-
939
- // Research before roadmap if no research exists
940
- const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
941
- const hasResearch = !!(researchFile && await loadFile(researchFile));
942
-
943
- if (!hasResearch) {
944
- unitType = "research-milestone";
945
- unitId = mid;
946
- prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
947
- } else {
948
- unitType = "plan-milestone";
949
- unitId = mid;
950
- prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
951
- }
952
-
953
- } else if (state.phase === "planning") {
954
- // Slice needs planning — but research first if no research exists
955
- const sid = state.activeSlice!.id;
956
- const sTitle = state.activeSlice!.title;
957
- const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
958
- const hasResearch = !!(researchFile && await loadFile(researchFile));
959
-
960
- if (!hasResearch) {
961
- unitType = "research-slice";
962
- unitId = `${mid}/${sid}`;
963
- prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
964
- } else {
965
- unitType = "plan-slice";
966
- unitId = `${mid}/${sid}`;
967
- prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
968
- }
969
-
970
- } else if (state.phase === "replanning-slice") {
971
- // Blocker discovered — replan the slice before continuing
972
- const sid = state.activeSlice!.id;
973
- const sTitle = state.activeSlice!.title;
974
- unitType = "replan-slice";
975
- unitId = `${mid}/${sid}`;
976
- prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
977
-
978
- } else if (state.phase === "executing" && state.activeTask) {
979
- // Execute next task
980
- const sid = state.activeSlice!.id;
981
- const sTitle = state.activeSlice!.title;
982
- const tid = state.activeTask.id;
983
- const tTitle = state.activeTask.title;
984
- unitType = "execute-task";
985
- unitId = `${mid}/${sid}/${tid}`;
986
- prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
987
-
988
- } else if (state.phase === "summarizing") {
989
- // All tasks done — complete the slice
1078
+ // ── Phase-first dispatch: complete-slice MUST run before reassessment ──
1079
+ // If the current phase is "summarizing", complete-slice is responsible for
1080
+ // mergeSliceToMain. Reassessment must wait until the merge is done.
1081
+ if (state.phase === "summarizing") {
990
1082
  const sid = state.activeSlice!.id;
991
1083
  const sTitle = state.activeSlice!.title;
992
1084
  unitType = "complete-slice";
993
1085
  unitId = `${mid}/${sid}`;
994
1086
  prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1087
+ } else {
1088
+ // ── Adaptive Replanning: check if last completed slice needs reassessment ──
1089
+ // Computed here (after summarizing guard) so complete-slice always runs first.
1090
+ const needsReassess = await checkNeedsReassessment(basePath, mid, state);
1091
+ if (needsRunUat) {
1092
+ const { sliceId, uatType } = needsRunUat;
1093
+ unitType = "run-uat";
1094
+ unitId = `${mid}/${sliceId}`;
1095
+ const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
1096
+ const uatContent = await loadFile(uatFile);
1097
+ prompt = await buildRunUatPrompt(
1098
+ mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
1099
+ );
1100
+ // For non-artifact-driven UAT types, pause after the prompt is dispatched.
1101
+ // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
1102
+ // then auto-mode pauses for human execution. On resume, result file exists → skip.
1103
+ if (uatType !== "artifact-driven") {
1104
+ pauseAfterUatDispatch = true;
1105
+ }
1106
+ } else if (needsReassess) {
1107
+ unitType = "reassess-roadmap";
1108
+ unitId = `${mid}/${needsReassess.sliceId}`;
1109
+ prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
1110
+ } else if (state.phase === "pre-planning") {
1111
+ // Need roadmap — check if context exists
1112
+ const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
1113
+ const hasContext = !!(contextFile && await loadFile(contextFile));
1114
+
1115
+ if (!hasContext) {
1116
+ await stopAuto(ctx, pi);
1117
+ ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
1118
+ return;
1119
+ }
995
1120
 
996
- } else if (state.phase === "completing-milestone") {
997
- // All slices done complete the milestone
998
- unitType = "complete-milestone";
999
- unitId = mid;
1000
- prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
1121
+ // Research before roadmap if no research exists
1122
+ const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
1123
+ const hasResearch = !!(researchFile && await loadFile(researchFile));
1001
1124
 
1002
- } else {
1003
- if (currentUnit) {
1004
- const modelId = ctx.model?.id ?? "unknown";
1005
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1006
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1007
- }
1008
- await stopAuto(ctx, pi);
1009
- ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
1010
- return;
1011
- }
1125
+ if (!hasResearch) {
1126
+ unitType = "research-milestone";
1127
+ unitId = mid;
1128
+ prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
1129
+ } else {
1130
+ unitType = "plan-milestone";
1131
+ unitId = mid;
1132
+ prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
1133
+ }
1012
1134
 
1013
- await emitObservabilityWarnings(ctx, unitType, unitId);
1135
+ } else if (state.phase === "planning") {
1136
+ // Slice needs planning — but research first if no research exists
1137
+ const sid = state.activeSlice!.id;
1138
+ const sTitle = state.activeSlice!.title;
1139
+ const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
1140
+ const hasResearch = !!(researchFile && await loadFile(researchFile));
1141
+
1142
+ if (!hasResearch) {
1143
+ unitType = "research-slice";
1144
+ unitId = `${mid}/${sid}`;
1145
+ prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1146
+ } else {
1147
+ unitType = "plan-slice";
1148
+ unitId = `${mid}/${sid}`;
1149
+ prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1150
+ }
1014
1151
 
1015
- // Stuck detection same unit dispatched again means the LLM didn't produce
1016
- // the expected artifact. Retry once (the LLM may have hit an error or run out
1017
- // of context), then stop with a diagnostic.
1018
- if (lastUnit && lastUnit.type === unitType && lastUnit.id === unitId) {
1019
- retryCount++;
1152
+ } else if (state.phase === "replanning-slice") {
1153
+ // Blocker discovered replan the slice before continuing
1154
+ const sid = state.activeSlice!.id;
1155
+ const sTitle = state.activeSlice!.title;
1156
+ unitType = "replan-slice";
1157
+ unitId = `${mid}/${sid}`;
1158
+ prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1159
+
1160
+ } else if (state.phase === "executing" && state.activeTask) {
1161
+ // Execute next task
1162
+ const sid = state.activeSlice!.id;
1163
+ const sTitle = state.activeSlice!.title;
1164
+ const tid = state.activeTask.id;
1165
+ const tTitle = state.activeTask.title;
1166
+ unitType = "execute-task";
1167
+ unitId = `${mid}/${sid}/${tid}`;
1168
+ prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
1169
+
1170
+ } else if (state.phase === "completing-milestone") {
1171
+ // All slices done — complete the milestone
1172
+ unitType = "complete-milestone";
1173
+ unitId = mid;
1174
+ prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
1020
1175
 
1021
- if (retryCount > MAX_RETRIES) {
1176
+ } else {
1022
1177
  if (currentUnit) {
1023
1178
  const modelId = ctx.model?.id ?? "unknown";
1024
1179
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1180
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1025
1181
  }
1026
- saveActivityLog(ctx, basePath, lastUnit.type, lastUnit.id);
1027
-
1028
- // Diagnostic: what file was expected?
1029
- const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
1030
1182
  await stopAuto(ctx, pi);
1031
- ctx.ui.notify(
1032
- `Stuck: ${unitType} ${unitId} fired ${retryCount + 1} times. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}\n Check .gsd/ and activity logs.`,
1033
- "error",
1034
- );
1183
+ ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
1035
1184
  return;
1036
1185
  }
1186
+ }
1187
+
1188
+ await emitObservabilityWarnings(ctx, unitType, unitId);
1189
+
1190
+ // Idempotency: skip units already completed in a prior session.
1191
+ const idempotencyKey = `${unitType}/${unitId}`;
1192
+ if (completedKeySet.has(idempotencyKey)) {
1193
+ ctx.ui.notify(
1194
+ `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
1195
+ "info",
1196
+ );
1197
+ // Yield to the event loop before re-dispatching to avoid tight recursion
1198
+ // when many units are already completed (e.g., after crash recovery).
1199
+ await new Promise(r => setImmediate(r));
1200
+ await dispatchNextUnit(ctx, pi);
1201
+ return;
1202
+ }
1203
+
1204
+ // Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
1205
+ // Pattern A→B→A→B would reset retryCount every time; this map catches it.
1206
+ const dispatchKey = `${unitType}/${unitId}`;
1207
+ const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
1208
+ if (prevCount >= MAX_UNIT_DISPATCHES) {
1209
+ if (currentUnit) {
1210
+ const modelId = ctx.model?.id ?? "unknown";
1211
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1212
+ }
1213
+ saveActivityLog(ctx, basePath, unitType, unitId);
1214
+
1215
+ const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
1216
+ await stopAuto(ctx, pi);
1217
+ ctx.ui.notify(
1218
+ `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}\n Check branch state and .gsd/ artifacts.`,
1219
+ "error",
1220
+ );
1221
+ return;
1222
+ }
1223
+ unitDispatchCount.set(dispatchKey, prevCount + 1);
1224
+ if (prevCount > 0) {
1037
1225
  ctx.ui.notify(
1038
- `${unitType} ${unitId} didn't produce expected artifact. Retrying (${retryCount}/${MAX_RETRIES}).`,
1226
+ `${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`,
1039
1227
  "warning",
1040
1228
  );
1041
- } else {
1042
- retryCount = 0;
1043
1229
  }
1044
1230
  // Snapshot metrics + activity log for the PREVIOUS unit before we reassign.
1045
1231
  // The session still holds the previous unit's data (newSession hasn't fired yet).
@@ -1048,6 +1234,11 @@ async function dispatchNextUnit(
1048
1234
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1049
1235
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1050
1236
 
1237
+ // Persist completion to disk BEFORE updating memory — so a crash here is recoverable.
1238
+ const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
1239
+ persistCompletedKey(basePath, closeoutKey);
1240
+ completedKeySet.add(closeoutKey);
1241
+
1051
1242
  completedUnits.push({
1052
1243
  type: currentUnit.type,
1053
1244
  id: currentUnit.id,
@@ -1055,9 +1246,9 @@ async function dispatchNextUnit(
1055
1246
  finishedAt: Date.now(),
1056
1247
  });
1057
1248
  clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1249
+ unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1250
+ unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1058
1251
  }
1059
-
1060
- lastUnit = { type: unitType, id: unitId };
1061
1252
  currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1062
1253
  writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1063
1254
  phase: "dispatched",
@@ -1101,7 +1292,7 @@ async function dispatchNextUnit(
1101
1292
  if (pendingCrashRecovery) {
1102
1293
  finalPrompt = `${pendingCrashRecovery}\n\n---\n\n${finalPrompt}`;
1103
1294
  pendingCrashRecovery = null;
1104
- } else if (retryCount > 0) {
1295
+ } else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
1105
1296
  const diagnostic = getDeepDiagnostic(basePath);
1106
1297
  if (diagnostic) {
1107
1298
  finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${diagnostic}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
@@ -2079,6 +2270,20 @@ async function recoverTimedOutUnit(
2079
2270
  const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
2080
2271
  const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
2081
2272
 
2273
+ const recoveryKey = `${unitType}/${unitId}`;
2274
+ const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
2275
+ unitRecoveryCount.set(recoveryKey, attemptNumber);
2276
+
2277
+ if (attemptNumber > 1) {
2278
+ // Exponential backoff: 2^(n-1) seconds, capped at 30s
2279
+ const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
2280
+ ctx.ui.notify(
2281
+ `Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
2282
+ "info",
2283
+ );
2284
+ await new Promise(r => setTimeout(r, backoffMs));
2285
+ }
2286
+
2082
2287
  if (unitType === "execute-task") {
2083
2288
  const status = await inspectExecuteTaskDurability(basePath, unitId);
2084
2289
  if (!status) return "paused";
@@ -2094,9 +2299,10 @@ async function recoverTimedOutUnit(
2094
2299
  recovery: status,
2095
2300
  });
2096
2301
  ctx.ui.notify(
2097
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode.`,
2302
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
2098
2303
  "info",
2099
2304
  );
2305
+ unitRecoveryCount.delete(recoveryKey);
2100
2306
  await dispatchNextUnit(ctx, pi);
2101
2307
  return "recovered";
2102
2308
  }
@@ -2143,7 +2349,7 @@ async function recoverTimedOutUnit(
2143
2349
  { triggerTurn: true, deliverAs: "steer" },
2144
2350
  );
2145
2351
  ctx.ui.notify(
2146
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
2352
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
2147
2353
  "warning",
2148
2354
  );
2149
2355
  return "recovered";
@@ -2164,9 +2370,10 @@ async function recoverTimedOutUnit(
2164
2370
  lastRecoveryReason: reason,
2165
2371
  });
2166
2372
  ctx.ui.notify(
2167
- `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline.`,
2373
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
2168
2374
  "warning",
2169
2375
  );
2376
+ unitRecoveryCount.delete(recoveryKey);
2170
2377
  await dispatchNextUnit(ctx, pi);
2171
2378
  return "recovered";
2172
2379
  }
@@ -2197,9 +2404,10 @@ async function recoverTimedOutUnit(
2197
2404
  lastRecoveryReason: reason,
2198
2405
  });
2199
2406
  ctx.ui.notify(
2200
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing.`,
2407
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
2201
2408
  "info",
2202
2409
  );
2410
+ unitRecoveryCount.delete(recoveryKey);
2203
2411
  await dispatchNextUnit(ctx, pi);
2204
2412
  return "recovered";
2205
2413
  }
@@ -2245,7 +2453,7 @@ async function recoverTimedOutUnit(
2245
2453
  { triggerTurn: true, deliverAs: "steer" },
2246
2454
  );
2247
2455
  ctx.ui.notify(
2248
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
2456
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
2249
2457
  "warning",
2250
2458
  );
2251
2459
  return "recovered";
@@ -2265,9 +2473,10 @@ async function recoverTimedOutUnit(
2265
2473
  lastRecoveryReason: reason,
2266
2474
  });
2267
2475
  ctx.ui.notify(
2268
- `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline.`,
2476
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
2269
2477
  "warning",
2270
2478
  );
2479
+ unitRecoveryCount.delete(recoveryKey);
2271
2480
  await dispatchNextUnit(ctx, pi);
2272
2481
  return "recovered";
2273
2482
  }