gsd-pi 2.12.0 → 2.13.0

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 (62) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/resource-loader.d.ts +2 -0
  3. package/dist/resource-loader.js +36 -1
  4. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  5. package/dist/resources/extensions/gsd/auto.ts +222 -11
  6. package/dist/resources/extensions/gsd/doctor.ts +195 -1
  7. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  8. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  9. package/dist/resources/extensions/gsd/preferences.ts +17 -1
  10. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  11. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  12. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  13. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  14. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  15. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  16. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  17. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  18. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  19. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  20. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  21. package/package.json +1 -1
  22. package/packages/pi-coding-agent/dist/cli/args.js +1 -1
  23. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  27. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  29. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  32. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  35. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  36. package/packages/pi-coding-agent/src/cli/args.ts +1 -1
  37. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  38. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  39. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  40. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  41. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  42. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  43. package/packages/pi-tui/dist/components/editor.js +64 -6
  44. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  45. package/packages/pi-tui/src/components/editor.ts +71 -6
  46. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  47. package/src/resources/extensions/gsd/auto.ts +222 -11
  48. package/src/resources/extensions/gsd/doctor.ts +195 -1
  49. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  50. package/src/resources/extensions/gsd/git-service.ts +11 -0
  51. package/src/resources/extensions/gsd/preferences.ts +17 -1
  52. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  53. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  54. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  55. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  56. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  57. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  58. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  59. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  60. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  61. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  62. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
@@ -86,6 +86,19 @@ import {
86
86
  import { GitServiceImpl, runGit } from "./git-service.js";
87
87
  import { nativeCommitCountBetween } from "./native-git-bridge.js";
88
88
  import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
89
+ import { formatGitError } from "./git-self-heal.js";
90
+ import {
91
+ createAutoWorktree,
92
+ enterAutoWorktree,
93
+ teardownAutoWorktree,
94
+ isInAutoWorktree,
95
+ getAutoWorktreePath,
96
+ getAutoWorktreeOriginalBase,
97
+ mergeSliceToMilestone,
98
+ mergeMilestoneToMain,
99
+ shouldUseWorktreeIsolation,
100
+ getMergeToMainMode,
101
+ } from "./auto-worktree.js";
89
102
  import type { GitPreferences } from "./git-service.js";
90
103
  import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
91
104
  import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
@@ -144,6 +157,7 @@ let stepMode = false;
144
157
  let verbose = false;
145
158
  let cmdCtx: ExtensionCommandContext | null = null;
146
159
  let basePath = "";
160
+ let originalBasePath = "";
147
161
  let gitService: GitServiceImpl | null = null;
148
162
 
149
163
  /** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
@@ -151,6 +165,12 @@ const unitDispatchCount = new Map<string, number>();
151
165
  const MAX_UNIT_DISPATCHES = 3;
152
166
  /** Retry index at which a stub summary placeholder is written when the summary is still absent. */
153
167
  const STUB_RECOVERY_THRESHOLD = 2;
168
+ /** Hard cap on total dispatches per unit across ALL reconciliation cycles.
169
+ * unitDispatchCount can be reset by loop-recovery/self-repair paths, but this
170
+ * counter is never reset — it catches infinite reconciliation loops where
171
+ * artifacts exist but deriveState keeps returning the same unit. */
172
+ const unitLifetimeDispatches = new Map<string, number>();
173
+ const MAX_LIFETIME_DISPATCHES = 6;
154
174
 
155
175
  /** Tracks recovery attempt count per unit for backoff and diagnostics. */
156
176
  const unitRecoveryCount = new Map<string, number>();
@@ -343,6 +363,27 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
343
363
 
344
364
  // Remove SIGTERM handler registered at auto-mode start
345
365
  deregisterSigtermHandler();
366
+
367
+ // ── Auto-worktree: exit worktree and reset basePath on stop ──
368
+ if (currentMilestoneId && isInAutoWorktree(basePath)) {
369
+ try {
370
+ teardownAutoWorktree(originalBasePath, currentMilestoneId);
371
+ basePath = originalBasePath;
372
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
373
+ ctx?.ui.notify("Exited auto-worktree.", "info");
374
+ } catch (err) {
375
+ ctx?.ui.notify(
376
+ `Auto-worktree teardown failed: ${err instanceof Error ? err.message : String(err)}`,
377
+ "warning",
378
+ );
379
+ // Force basePath back to original even if teardown failed
380
+ if (originalBasePath) {
381
+ basePath = originalBasePath;
382
+ try { process.chdir(basePath); } catch { /* best-effort */ }
383
+ }
384
+ }
385
+ }
386
+
346
387
  const ledger = getLedger();
347
388
  if (ledger && ledger.units.length > 0) {
348
389
  const totals = getProjectTotals(ledger.units);
@@ -367,8 +408,10 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
367
408
  stepMode = false;
368
409
  unitDispatchCount.clear();
369
410
  unitRecoveryCount.clear();
411
+ unitLifetimeDispatches.clear();
370
412
  currentUnit = null;
371
413
  currentMilestoneId = null;
414
+ originalBasePath = "";
372
415
  cachedSliceProgress = null;
373
416
  pendingCrashRecovery = null;
374
417
  _handlingAgentEnd = false;
@@ -518,10 +561,17 @@ async function mergeOrphanedSliceBranches(
518
561
  "info",
519
562
  );
520
563
  try {
521
- switchToMain(base);
522
- const mergeResult = mergeSliceToMain(
523
- base, milestoneId, sliceId, sliceEntry.title || sliceId,
524
- );
564
+ let mergeResult;
565
+ if (isInAutoWorktree(base) && getMergeToMainMode() !== "slice") {
566
+ mergeResult = mergeSliceToMilestone(
567
+ base, milestoneId, sliceId, sliceEntry.title || sliceId,
568
+ );
569
+ } else {
570
+ switchToMain(base);
571
+ mergeResult = mergeSliceToMain(
572
+ base, milestoneId, sliceId, sliceEntry.title || sliceId,
573
+ );
574
+ }
525
575
  ctx.ui.notify(
526
576
  `Merged orphaned branch ${mergeResult.branch} → ${mainBranch}.`,
527
577
  "info",
@@ -568,13 +618,38 @@ export async function startAuto(
568
618
  cmdCtx = ctx;
569
619
  basePath = base;
570
620
  unitDispatchCount.clear();
621
+ unitLifetimeDispatches.clear();
571
622
  // Re-initialize metrics in case ledger was lost during pause
572
623
  if (!getLedger()) initMetrics(base);
573
624
  // Ensure milestone ID is set on git service for integration branch resolution
574
625
  if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
575
626
 
627
+ // ── Auto-worktree: re-enter worktree on resume if not already inside ──
628
+ if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && shouldUseWorktreeIsolation(originalBasePath)) {
629
+ try {
630
+ const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
631
+ if (existingWtPath) {
632
+ const wtPath = enterAutoWorktree(originalBasePath, currentMilestoneId);
633
+ basePath = wtPath;
634
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
635
+ ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info");
636
+ } else {
637
+ // Worktree was deleted while paused — recreate it.
638
+ const wtPath = createAutoWorktree(originalBasePath, currentMilestoneId);
639
+ basePath = wtPath;
640
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
641
+ ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info");
642
+ }
643
+ } catch (err) {
644
+ ctx.ui.notify(
645
+ `Auto-worktree re-entry failed: ${err instanceof Error ? err.message : String(err)}. Continuing at current path.`,
646
+ "warning",
647
+ );
648
+ }
649
+ }
650
+
576
651
  // Re-register SIGTERM handler for the resumed session
577
- registerSigtermHandler(base);
652
+ registerSigtermHandler(basePath);
578
653
 
579
654
  ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
580
655
  ctx.ui.setFooter(hideFooter);
@@ -687,6 +762,7 @@ export async function startAuto(
687
762
  basePath = base;
688
763
  unitDispatchCount.clear();
689
764
  unitRecoveryCount.clear();
765
+ unitLifetimeDispatches.clear();
690
766
  completedKeySet.clear();
691
767
  loadPersistedKeys(base, completedKeySet);
692
768
  resetHookState();
@@ -710,6 +786,36 @@ export async function startAuto(
710
786
  setActiveMilestoneId(base, currentMilestoneId);
711
787
  }
712
788
 
789
+ // ── Auto-worktree: create or enter worktree for the active milestone ──
790
+ // Store the original project root before any chdir so we can restore on stop.
791
+ originalBasePath = base;
792
+ if (currentMilestoneId && shouldUseWorktreeIsolation(base)) {
793
+ try {
794
+ const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
795
+ if (existingWtPath) {
796
+ // Worktree already exists (e.g., previous session created it) — enter it.
797
+ const wtPath = enterAutoWorktree(base, currentMilestoneId);
798
+ basePath = wtPath;
799
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
800
+ ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info");
801
+ } else {
802
+ // Fresh start — create worktree and enter it.
803
+ const wtPath = createAutoWorktree(base, currentMilestoneId);
804
+ basePath = wtPath;
805
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
806
+ ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
807
+ }
808
+ // Re-register SIGTERM handler with the new basePath
809
+ registerSigtermHandler(basePath);
810
+ } catch (err) {
811
+ // Worktree creation is non-fatal — continue in the project root.
812
+ ctx.ui.notify(
813
+ `Auto-worktree setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
814
+ "warning",
815
+ );
816
+ }
817
+ }
818
+
713
819
  // Initialize metrics — loads existing ledger from disk
714
820
  initMetrics(base);
715
821
 
@@ -827,6 +933,24 @@ export async function handleAgentEnd(
827
933
  } catch {
828
934
  // Non-fatal
829
935
  }
936
+
937
+ // ── Path A fix: verify artifact and persist completion before re-entering dispatch ──
938
+ // After doctor + rebuildState, check whether the just-completed unit actually
939
+ // produced its expected artifact. If so, persist the completion key now so the
940
+ // idempotency check at the top of dispatchNextUnit() skips it — even if
941
+ // deriveState() still returns this unit as active (e.g. branch mismatch).
942
+ try {
943
+ if (verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath)) {
944
+ const completionKey = `${currentUnit.type}/${currentUnit.id}`;
945
+ if (!completedKeySet.has(completionKey)) {
946
+ persistCompletedKey(basePath, completionKey);
947
+ completedKeySet.add(completionKey);
948
+ }
949
+ invalidateStateCache();
950
+ }
951
+ } catch {
952
+ // Non-fatal — worst case we fall through to normal dispatch which has its own checks
953
+ }
830
954
  }
831
955
 
832
956
  // ── Post-unit hooks: check if a configured hook should run before normal dispatch ──
@@ -1382,6 +1506,9 @@ async function dispatchNextUnit(
1382
1506
 
1383
1507
  // Clear stale directory listing cache so deriveState sees fresh disk state (#431)
1384
1508
  clearPathCache();
1509
+ // Clear parsed roadmap/plan cache — doctor may have re-populated it with
1510
+ // stale data between handleAgentEnd and this dispatch call (Path B fix).
1511
+ clearParseCache();
1385
1512
 
1386
1513
  let state = await deriveState(basePath);
1387
1514
  let mid = state.activeMilestone?.id;
@@ -1396,8 +1523,9 @@ async function dispatchNextUnit(
1396
1523
  // Reset stuck detection for new milestone
1397
1524
  unitDispatchCount.clear();
1398
1525
  unitRecoveryCount.clear();
1526
+ unitLifetimeDispatches.clear();
1399
1527
  // Capture integration branch for the new milestone and update git service
1400
- captureIntegrationBranch(basePath, mid);
1528
+ captureIntegrationBranch(originalBasePath || basePath, mid);
1401
1529
  }
1402
1530
  if (mid) {
1403
1531
  currentMilestoneId = mid;
@@ -1502,10 +1630,17 @@ async function dispatchNextUnit(
1502
1630
  if (sliceEntry?.done) {
1503
1631
  try {
1504
1632
  const sliceTitleForMerge = sliceEntry.title || branchSid;
1505
- switchToMain(basePath);
1506
- const mergeResult = mergeSliceToMain(
1507
- basePath, branchMid, branchSid, sliceTitleForMerge,
1508
- );
1633
+ let mergeResult;
1634
+ if (isInAutoWorktree(basePath) && getMergeToMainMode() !== "slice") {
1635
+ mergeResult = mergeSliceToMilestone(
1636
+ basePath, branchMid, branchSid, sliceTitleForMerge,
1637
+ );
1638
+ } else {
1639
+ switchToMain(basePath);
1640
+ mergeResult = mergeSliceToMain(
1641
+ basePath, branchMid, branchSid, sliceTitleForMerge,
1642
+ );
1643
+ }
1509
1644
  const targetBranch = getMainBranch(basePath);
1510
1645
  ctx.ui.notify(
1511
1646
  `Merged ${mergeResult.branch} → ${targetBranch}.`,
@@ -1569,7 +1704,7 @@ async function dispatchNextUnit(
1569
1704
  }
1570
1705
 
1571
1706
  // Non-conflict errors: reset and stop
1572
- const message = error instanceof Error ? error.message : String(error);
1707
+ const message = formatGitError(error instanceof Error ? error : String(error));
1573
1708
  try {
1574
1709
  const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
1575
1710
  if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
@@ -1626,6 +1761,27 @@ async function dispatchNextUnit(
1626
1761
  if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
1627
1762
  completedKeySet.clear();
1628
1763
  } catch { /* non-fatal */ }
1764
+
1765
+ // ── Milestone merge: squash-merge milestone branch to main before stopping ──
1766
+ if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath && getMergeToMainMode() === "milestone") {
1767
+ try {
1768
+ const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
1769
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
1770
+ const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
1771
+ basePath = originalBasePath;
1772
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1773
+ ctx.ui.notify(
1774
+ `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1775
+ "info",
1776
+ );
1777
+ } catch (err) {
1778
+ ctx.ui.notify(
1779
+ `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
1780
+ "warning",
1781
+ );
1782
+ }
1783
+ }
1784
+
1629
1785
  await stopAuto(ctx, pi);
1630
1786
  return;
1631
1787
  }
@@ -1888,6 +2044,26 @@ async function dispatchNextUnit(
1888
2044
  // Pattern A→B→A→B would reset retryCount every time; this map catches it.
1889
2045
  const dispatchKey = `${unitType}/${unitId}`;
1890
2046
  const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
2047
+
2048
+ // Hard lifetime cap — survives counter resets from loop-recovery/self-repair.
2049
+ // Catches the case where reconciliation "succeeds" (artifacts exist) but
2050
+ // deriveState keeps returning the same unit, creating an infinite cycle.
2051
+ const lifetimeCount = (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
2052
+ unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
2053
+ if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
2054
+ if (currentUnit) {
2055
+ const modelId = ctx.model?.id ?? "unknown";
2056
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2057
+ }
2058
+ saveActivityLog(ctx, basePath, unitType, unitId);
2059
+ const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2060
+ await stopAuto(ctx, pi);
2061
+ ctx.ui.notify(
2062
+ `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles). Stopping.${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
2063
+ "error",
2064
+ );
2065
+ return;
2066
+ }
1891
2067
  if (prevCount >= MAX_UNIT_DISPATCHES) {
1892
2068
  if (currentUnit) {
1893
2069
  const modelId = ctx.model?.id ?? "unknown";
@@ -1912,7 +2088,13 @@ async function dispatchNextUnit(
1912
2088
  `Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`,
1913
2089
  "warning",
1914
2090
  );
2091
+ // Persist completion so idempotency check prevents re-dispatch
2092
+ // if deriveState keeps returning this unit (#462).
2093
+ const reconciledKey = `${unitType}/${unitId}`;
2094
+ persistCompletedKey(basePath, reconciledKey);
2095
+ completedKeySet.add(reconciledKey);
1915
2096
  unitDispatchCount.delete(dispatchKey);
2097
+ invalidateStateCache();
1916
2098
  await new Promise(r => setImmediate(r));
1917
2099
  await dispatchNextUnit(ctx, pi);
1918
2100
  return;
@@ -1921,6 +2103,30 @@ async function dispatchNextUnit(
1921
2103
  }
1922
2104
  }
1923
2105
 
2106
+ // General reconciliation: if the last attempt DID produce the expected
2107
+ // artifact on disk, clear the counter and advance instead of stopping.
2108
+ // The execute-task path above handles its special case (writing placeholder
2109
+ // summaries). This catch-all covers complete-slice, plan-slice,
2110
+ // research-slice, and all other unit types where the Nth attempt at the
2111
+ // dispatch limit succeeded but the counter check fires before anyone
2112
+ // verifies disk state. Without this, a successful final attempt is
2113
+ // indistinguishable from a failed one.
2114
+ if (verifyExpectedArtifact(unitType, unitId, basePath)) {
2115
+ ctx.ui.notify(
2116
+ `Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`,
2117
+ "info",
2118
+ );
2119
+ // Persist completion so the idempotency check prevents re-dispatch
2120
+ // if deriveState keeps returning this unit (see #462).
2121
+ persistCompletedKey(basePath, dispatchKey);
2122
+ completedKeySet.add(dispatchKey);
2123
+ unitDispatchCount.delete(dispatchKey);
2124
+ invalidateStateCache();
2125
+ await new Promise(r => setImmediate(r));
2126
+ await dispatchNextUnit(ctx, pi);
2127
+ return;
2128
+ }
2129
+
1924
2130
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
1925
2131
  const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
1926
2132
  await stopAuto(ctx, pi);
@@ -1947,7 +2153,12 @@ async function dispatchNextUnit(
1947
2153
  `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
1948
2154
  "warning",
1949
2155
  );
2156
+ // Persist completion so idempotency check prevents re-dispatch (#462).
2157
+ const repairedKey = `${unitType}/${unitId}`;
2158
+ persistCompletedKey(basePath, repairedKey);
2159
+ completedKeySet.add(repairedKey);
1950
2160
  unitDispatchCount.delete(dispatchKey);
2161
+ invalidateStateCache();
1951
2162
  await new Promise(r => setImmediate(r));
1952
2163
  await dispatchNextUnit(ctx, pi);
1953
2164
  return;
@@ -1,3 +1,4 @@
1
+ import { execSync } from "node:child_process";
1
2
  import { existsSync, mkdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
 
@@ -5,6 +6,9 @@ import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPla
5
6
  import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
6
7
  import { deriveState, isMilestoneComplete } from "./state.js";
7
8
  import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
9
+ import { listWorktrees } from "./worktree-manager.js";
10
+ import { abortAndReset } from "./git-self-heal.js";
11
+ import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
8
12
 
9
13
  export type DoctorSeverity = "info" | "warning" | "error";
10
14
  export type DoctorIssueCode =
@@ -23,7 +27,11 @@ export type DoctorIssueCode =
23
27
  | "active_requirement_missing_owner"
24
28
  | "blocked_requirement_missing_reason"
25
29
  | "blocker_discovered_no_replan"
26
- | "delimiter_in_title";
30
+ | "delimiter_in_title"
31
+ | "orphaned_auto_worktree"
32
+ | "stale_milestone_branch"
33
+ | "corrupt_merge_state"
34
+ | "tracked_runtime_files";
27
35
 
28
36
  export interface DoctorIssue {
29
37
  severity: DoctorSeverity;
@@ -451,6 +459,189 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
451
459
  }).join("\n");
452
460
  }
453
461
 
462
+ async function checkGitHealth(
463
+ basePath: string,
464
+ issues: DoctorIssue[],
465
+ fixesApplied: string[],
466
+ shouldFix: (code: DoctorIssueCode) => boolean,
467
+ ): Promise<void> {
468
+ // Degrade gracefully if not a git repo
469
+ try {
470
+ execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
471
+ } catch {
472
+ return; // Not a git repo — skip all git health checks
473
+ }
474
+
475
+ const gitDir = join(basePath, ".git");
476
+
477
+ // ── Orphaned auto-worktrees ──────────────────────────────────────────
478
+ try {
479
+ const worktrees = listWorktrees(basePath);
480
+ const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
481
+
482
+ // Load roadmap state once for cross-referencing
483
+ const state = await deriveState(basePath);
484
+
485
+ for (const wt of milestoneWorktrees) {
486
+ // Extract milestone ID from branch name "milestone/M001" → "M001"
487
+ const milestoneId = wt.branch.replace(/^milestone\//, "");
488
+ const milestoneEntry = state.registry.find(m => m.id === milestoneId);
489
+
490
+ // Check if milestone is complete via roadmap
491
+ let isComplete = false;
492
+ if (milestoneEntry) {
493
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
494
+ const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
495
+ if (roadmapContent) {
496
+ const roadmap = parseRoadmap(roadmapContent);
497
+ isComplete = isMilestoneComplete(roadmap);
498
+ }
499
+ }
500
+
501
+ if (isComplete) {
502
+ issues.push({
503
+ severity: "warning",
504
+ code: "orphaned_auto_worktree",
505
+ scope: "milestone",
506
+ unitId: milestoneId,
507
+ message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
508
+ fixable: true,
509
+ });
510
+
511
+ if (shouldFix("orphaned_auto_worktree")) {
512
+ // Never remove a worktree matching current working directory
513
+ const cwd = process.cwd();
514
+ if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
515
+ fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
516
+ } else {
517
+ try {
518
+ execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" });
519
+ fixesApplied.push(`removed orphaned worktree ${wt.path}`);
520
+ } catch {
521
+ fixesApplied.push(`failed to remove worktree ${wt.path}`);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ }
527
+
528
+ // ── Stale milestone branches ─────────────────────────────────────────
529
+ try {
530
+ const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
531
+ if (branchOutput) {
532
+ const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
533
+ const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
534
+
535
+ for (const branch of branches) {
536
+ // Skip branches that have a worktree (handled above)
537
+ if (worktreeBranches.has(branch)) continue;
538
+
539
+ const milestoneId = branch.replace(/^milestone\//, "");
540
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
541
+ const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
542
+ if (!roadmapContent) continue;
543
+
544
+ const roadmap = parseRoadmap(roadmapContent);
545
+ if (isMilestoneComplete(roadmap)) {
546
+ issues.push({
547
+ severity: "info",
548
+ code: "stale_milestone_branch",
549
+ scope: "milestone",
550
+ unitId: milestoneId,
551
+ message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
552
+ fixable: true,
553
+ });
554
+
555
+ if (shouldFix("stale_milestone_branch")) {
556
+ try {
557
+ execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" });
558
+ fixesApplied.push(`deleted stale branch ${branch}`);
559
+ } catch {
560
+ fixesApplied.push(`failed to delete branch ${branch}`);
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }
566
+ } catch {
567
+ // git branch list failed — skip stale branch check
568
+ }
569
+ } catch {
570
+ // listWorktrees or deriveState failed — skip worktree/branch checks
571
+ }
572
+
573
+ // ── Corrupt merge state ────────────────────────────────────────────────
574
+ try {
575
+ const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
576
+ const mergeStateDirs = ["rebase-apply", "rebase-merge"];
577
+ const found: string[] = [];
578
+
579
+ for (const f of mergeStateFiles) {
580
+ if (existsSync(join(gitDir, f))) found.push(f);
581
+ }
582
+ for (const d of mergeStateDirs) {
583
+ if (existsSync(join(gitDir, d))) found.push(d);
584
+ }
585
+
586
+ if (found.length > 0) {
587
+ issues.push({
588
+ severity: "error",
589
+ code: "corrupt_merge_state",
590
+ scope: "project",
591
+ unitId: "project",
592
+ message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
593
+ fixable: true,
594
+ });
595
+
596
+ if (shouldFix("corrupt_merge_state")) {
597
+ const result = abortAndReset(basePath);
598
+ fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
599
+ }
600
+ }
601
+ } catch {
602
+ // Can't check .git dir — skip
603
+ }
604
+
605
+ // ── Tracked runtime files ──────────────────────────────────────────────
606
+ try {
607
+ const trackedPaths: string[] = [];
608
+ for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
609
+ try {
610
+ const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim();
611
+ if (output) {
612
+ trackedPaths.push(...output.split("\n").filter(Boolean));
613
+ }
614
+ } catch {
615
+ // Individual ls-files can fail — continue
616
+ }
617
+ }
618
+
619
+ if (trackedPaths.length > 0) {
620
+ issues.push({
621
+ severity: "warning",
622
+ code: "tracked_runtime_files",
623
+ scope: "project",
624
+ unitId: "project",
625
+ message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
626
+ fixable: true,
627
+ });
628
+
629
+ if (shouldFix("tracked_runtime_files")) {
630
+ try {
631
+ for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
632
+ execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" });
633
+ }
634
+ fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
635
+ } catch {
636
+ fixesApplied.push("failed to untrack runtime files");
637
+ }
638
+ }
639
+ }
640
+ } catch {
641
+ // git ls-files failed — skip
642
+ }
643
+ }
644
+
454
645
  export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
455
646
  const issues: DoctorIssue[] = [];
456
647
  const fixesApplied: string[] = [];
@@ -491,6 +682,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
491
682
  }
492
683
  }
493
684
 
685
+ // Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files)
686
+ await checkGitHealth(basePath, issues, fixesApplied, shouldFix);
687
+
494
688
  const milestonesPath = milestonesDir(basePath);
495
689
  if (!existsSync(milestonesPath)) {
496
690
  return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };