gsd-pi 2.23.0 → 2.25.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 (212) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.js +12 -3
  3. package/dist/headless.d.ts +4 -0
  4. package/dist/headless.js +118 -10
  5. package/dist/help-text.js +22 -7
  6. package/dist/models-resolver.d.ts +0 -11
  7. package/dist/models-resolver.js +0 -15
  8. package/dist/resource-loader.d.ts +0 -1
  9. package/dist/resource-loader.js +64 -18
  10. package/dist/resources/GSD-WORKFLOW.md +12 -9
  11. package/dist/resources/extensions/bg-shell/overlay.ts +18 -17
  12. package/dist/resources/extensions/get-secrets-from-user.ts +5 -23
  13. package/dist/resources/extensions/gsd/activity-log.ts +5 -3
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  15. package/dist/resources/extensions/gsd/auto-prompts.ts +87 -0
  16. package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
  17. package/dist/resources/extensions/gsd/auto-worktree.ts +134 -4
  18. package/dist/resources/extensions/gsd/auto.ts +307 -77
  19. package/dist/resources/extensions/gsd/cache.ts +3 -1
  20. package/dist/resources/extensions/gsd/commands.ts +176 -10
  21. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  22. package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  23. package/dist/resources/extensions/gsd/doctor.ts +58 -11
  24. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  25. package/dist/resources/extensions/gsd/git-service.ts +74 -14
  26. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  27. package/dist/resources/extensions/gsd/gsd-db.ts +78 -1
  28. package/dist/resources/extensions/gsd/guided-flow.ts +109 -12
  29. package/dist/resources/extensions/gsd/index.ts +48 -2
  30. package/dist/resources/extensions/gsd/memory-extractor.ts +352 -0
  31. package/dist/resources/extensions/gsd/memory-store.ts +441 -0
  32. package/dist/resources/extensions/gsd/migrate/command.ts +2 -2
  33. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  34. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  35. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  36. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  37. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  38. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  39. package/dist/resources/extensions/gsd/prompts/discuss.md +4 -4
  40. package/dist/resources/extensions/gsd/prompts/execute-task.md +1 -1
  41. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  42. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  43. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  44. package/dist/resources/extensions/gsd/prompts/queue.md +1 -1
  45. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  46. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  47. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  48. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  49. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  50. package/dist/resources/extensions/gsd/state.ts +72 -30
  51. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  52. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  53. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  54. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +256 -2
  55. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  56. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
  57. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  58. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  59. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  60. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  61. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  62. package/dist/resources/extensions/gsd/tests/git-service.test.ts +70 -4
  63. package/dist/resources/extensions/gsd/tests/gsd-db.test.ts +2 -2
  64. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  65. package/dist/resources/extensions/gsd/tests/md-importer.test.ts +2 -3
  66. package/dist/resources/extensions/gsd/tests/memory-extractor.test.ts +180 -0
  67. package/dist/resources/extensions/gsd/tests/memory-store.test.ts +345 -0
  68. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  69. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  70. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  71. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  72. package/dist/resources/extensions/gsd/tests/smart-entry-draft.test.ts +1 -1
  73. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  74. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +147 -2
  75. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +88 -10
  76. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +314 -87
  77. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  78. package/dist/resources/extensions/gsd/triage-ui.ts +1 -1
  79. package/dist/resources/extensions/gsd/types.ts +15 -1
  80. package/dist/resources/extensions/gsd/visualizer-data.ts +291 -10
  81. package/dist/resources/extensions/gsd/visualizer-overlay.ts +237 -28
  82. package/dist/resources/extensions/gsd/visualizer-views.ts +462 -48
  83. package/dist/resources/extensions/gsd/worktree.ts +9 -2
  84. package/dist/resources/extensions/search-the-web/native-search.ts +15 -5
  85. package/dist/resources/extensions/subagent/index.ts +5 -0
  86. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  87. package/dist/update-check.d.ts +9 -0
  88. package/dist/update-check.js +97 -0
  89. package/package.json +6 -1
  90. package/packages/pi-agent-core/dist/agent-loop.js +2 -0
  91. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  92. package/packages/pi-agent-core/src/agent-loop.ts +2 -0
  93. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  94. package/packages/pi-ai/dist/providers/anthropic.js +55 -7
  95. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  96. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  97. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  98. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  99. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  100. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  101. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  102. package/packages/pi-ai/dist/providers/mistral.js +3 -0
  103. package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
  104. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  105. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  106. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  107. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  108. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  109. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  110. package/packages/pi-ai/dist/types.d.ts +23 -1
  111. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  112. package/packages/pi-ai/dist/types.js.map +1 -1
  113. package/packages/pi-ai/src/providers/anthropic.ts +59 -9
  114. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  115. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  116. package/packages/pi-ai/src/providers/mistral.ts +3 -0
  117. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  118. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  119. package/packages/pi-ai/src/types.ts +19 -1
  120. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  121. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  122. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  123. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  124. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -0
  126. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  128. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  129. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +72 -0
  130. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  131. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  132. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  133. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -0
  134. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +84 -0
  135. package/scripts/postinstall.js +7 -109
  136. package/src/resources/GSD-WORKFLOW.md +12 -9
  137. package/src/resources/extensions/bg-shell/overlay.ts +18 -17
  138. package/src/resources/extensions/get-secrets-from-user.ts +5 -23
  139. package/src/resources/extensions/gsd/activity-log.ts +5 -3
  140. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  141. package/src/resources/extensions/gsd/auto-prompts.ts +87 -0
  142. package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
  143. package/src/resources/extensions/gsd/auto-worktree.ts +134 -4
  144. package/src/resources/extensions/gsd/auto.ts +307 -77
  145. package/src/resources/extensions/gsd/cache.ts +3 -1
  146. package/src/resources/extensions/gsd/commands.ts +176 -10
  147. package/src/resources/extensions/gsd/complexity.ts +1 -0
  148. package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  149. package/src/resources/extensions/gsd/doctor.ts +58 -11
  150. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  151. package/src/resources/extensions/gsd/git-service.ts +74 -14
  152. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  153. package/src/resources/extensions/gsd/gsd-db.ts +78 -1
  154. package/src/resources/extensions/gsd/guided-flow.ts +109 -12
  155. package/src/resources/extensions/gsd/index.ts +48 -2
  156. package/src/resources/extensions/gsd/memory-extractor.ts +352 -0
  157. package/src/resources/extensions/gsd/memory-store.ts +441 -0
  158. package/src/resources/extensions/gsd/migrate/command.ts +2 -2
  159. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  160. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  161. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  162. package/src/resources/extensions/gsd/preferences.ts +65 -1
  163. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  164. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  165. package/src/resources/extensions/gsd/prompts/discuss.md +4 -4
  166. package/src/resources/extensions/gsd/prompts/execute-task.md +1 -1
  167. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  168. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  169. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  170. package/src/resources/extensions/gsd/prompts/queue.md +1 -1
  171. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  172. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  173. package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  174. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  175. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  176. package/src/resources/extensions/gsd/state.ts +72 -30
  177. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  178. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  179. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  180. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +256 -2
  181. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  182. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
  183. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  184. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  185. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  186. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  187. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  188. package/src/resources/extensions/gsd/tests/git-service.test.ts +70 -4
  189. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +2 -2
  190. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  191. package/src/resources/extensions/gsd/tests/md-importer.test.ts +2 -3
  192. package/src/resources/extensions/gsd/tests/memory-extractor.test.ts +180 -0
  193. package/src/resources/extensions/gsd/tests/memory-store.test.ts +345 -0
  194. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  195. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  196. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  197. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  198. package/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +1 -1
  199. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  200. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +147 -2
  201. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +88 -10
  202. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +314 -87
  203. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  204. package/src/resources/extensions/gsd/triage-ui.ts +1 -1
  205. package/src/resources/extensions/gsd/types.ts +15 -1
  206. package/src/resources/extensions/gsd/visualizer-data.ts +291 -10
  207. package/src/resources/extensions/gsd/visualizer-overlay.ts +237 -28
  208. package/src/resources/extensions/gsd/visualizer-views.ts +462 -48
  209. package/src/resources/extensions/gsd/worktree.ts +9 -2
  210. package/src/resources/extensions/search-the-web/native-search.ts +15 -5
  211. package/src/resources/extensions/subagent/index.ts +5 -0
  212. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -16,9 +16,9 @@ import type {
16
16
  ExtensionCommandContext,
17
17
  } from "@gsd/pi-coding-agent";
18
18
 
19
- import { deriveState, invalidateStateCache } from "./state.js";
19
+ import { deriveState } from "./state.js";
20
20
  import type { BudgetEnforcementMode, GSDState } from "./types.js";
21
- import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js";
21
+ import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides, parseSummary } from "./files.js";
22
22
  import { loadPrompt } from "./prompt-loader.js";
23
23
  export { inlinePriorMilestoneSummary } from "./files.js";
24
24
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
@@ -94,7 +94,7 @@ import {
94
94
  parseSliceBranch,
95
95
  setActiveMilestoneId,
96
96
  } from "./worktree.js";
97
- import { GitServiceImpl } from "./git-service.js";
97
+ import { GitServiceImpl, type TaskCommitContext } from "./git-service.js";
98
98
  import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
99
99
  import { formatGitError } from "./git-self-heal.js";
100
100
  import {
@@ -108,6 +108,7 @@ import {
108
108
  autoWorktreeBranch,
109
109
  } from "./auto-worktree.js";
110
110
  import { pruneQueueOrder } from "./queue-order.js";
111
+ import { consumeSignal } from "./session-status-io.js";
111
112
  import { showNextAction } from "../shared/next-action-ui.js";
112
113
  import { debugLog, debugTime, debugCount, debugPeak, enableDebug, isDebugEnabled, writeDebugSummary, getDebugLogPath } from "./debug-logger.js";
113
114
  import {
@@ -195,6 +196,35 @@ function syncStateToProjectRoot(worktreePath: string, projectRoot: string, miles
195
196
  cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
196
197
  }
197
198
  } catch { /* non-fatal */ }
199
+
200
+ // 3. Merge completed-units.json (set-union of both locations)
201
+ // Prevents already-completed units from being re-dispatched after crash/restart.
202
+ const srcKeysFile = join(wtGsd, "completed-units.json");
203
+ const dstKeysFile = join(prGsd, "completed-units.json");
204
+ if (existsSync(srcKeysFile)) {
205
+ try {
206
+ const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
207
+ let dstKeys: string[] = [];
208
+ if (existsSync(dstKeysFile)) {
209
+ try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
210
+ }
211
+ const merged = [...new Set([...dstKeys, ...srcKeys])];
212
+ writeFileSync(dstKeysFile, JSON.stringify(merged, null, 2));
213
+ } catch { /* non-fatal */ }
214
+ }
215
+
216
+ // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
217
+ // Without this, a crash during a unit leaves the runtime record only in the
218
+ // worktree. If the next session resolves basePath before worktree re-entry,
219
+ // selfHeal can't find or clear the stale record (#769).
220
+ try {
221
+ const srcRuntime = join(wtGsd, "runtime", "units");
222
+ const dstRuntime = join(prGsd, "runtime", "units");
223
+ if (existsSync(srcRuntime)) {
224
+ mkdirSync(dstRuntime, { recursive: true });
225
+ cpSync(srcRuntime, dstRuntime, { recursive: true, force: true });
226
+ }
227
+ } catch { /* non-fatal */ }
198
228
  }
199
229
 
200
230
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -361,11 +391,12 @@ let _sigtermHandler: (() => void) | null = null;
361
391
  */
362
392
  const inFlightTools = new Map<string, number>();
363
393
 
364
- type BudgetAlertLevel = 0 | 75 | 90 | 100;
394
+ type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100;
365
395
 
366
396
  export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
367
397
  if (budgetPct >= 1.0) return 100;
368
398
  if (budgetPct >= 0.90) return 90;
399
+ if (budgetPct >= 0.80) return 80;
369
400
  if (budgetPct >= 0.75) return 75;
370
401
  return 0;
371
402
  }
@@ -552,11 +583,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
552
583
  await dispatchNextUnit(ctx, pi);
553
584
  } catch (retryErr) {
554
585
  const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
555
- ctx.ui.notify(
556
- `Dispatch gap recovery failed: ${message}. Stopping auto-mode.`,
557
- "error",
558
- );
559
- await stopAuto(ctx, pi);
586
+ await stopAuto(ctx, pi, `Dispatch gap recovery failed: ${message}`);
560
587
  return;
561
588
  }
562
589
 
@@ -564,17 +591,14 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
564
591
  // (no sendMessage called → no timeout set), auto-mode is permanently
565
592
  // stalled. Stop cleanly instead of leaving it active but idle (#537).
566
593
  if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
567
- ctx.ui.notify(
568
- "Auto-mode stalled — no dispatchable unit found after retry. Stopping. Run /gsd auto to restart.",
569
- "warning",
570
- );
571
- await stopAuto(ctx, pi);
594
+ await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry");
572
595
  }
573
596
  }, DISPATCH_GAP_TIMEOUT_MS);
574
597
  }
575
598
 
576
- export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise<void> {
599
+ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise<void> {
577
600
  if (!active && !paused) return;
601
+ const reasonSuffix = reason ? ` — ${reason}` : "";
578
602
  clearUnitTimeout();
579
603
  if (lockBase()) clearLock(lockBase());
580
604
  clearSkillSnapshot();
@@ -626,11 +650,11 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
626
650
  if (ledger && ledger.units.length > 0) {
627
651
  const totals = getProjectTotals(ledger.units);
628
652
  ctx?.ui.notify(
629
- `Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
653
+ `Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
630
654
  "info",
631
655
  );
632
656
  } else {
633
- ctx?.ui.notify("Auto-mode stopped.", "info");
657
+ ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info");
634
658
  }
635
659
 
636
660
  // Sync disk state so next resume starts from accurate state
@@ -865,23 +889,37 @@ export async function startAuto(
865
889
  );
866
890
  return;
867
891
  }
868
- // Stale lock from a dead process — synthesize crash recovery context.
869
- const activityDir = join(gsdRoot(base), "activity");
870
- const recovery = synthesizeCrashRecovery(
871
- base, crashLock.unitType, crashLock.unitId,
872
- crashLock.sessionFile, activityDir,
873
- );
874
- if (recovery && recovery.trace.toolCallCount > 0) {
875
- pendingCrashRecovery = recovery.prompt;
892
+ // Stale lock from a dead process — validate before synthesizing recovery context.
893
+ // If the recovered unit belongs to a fully-completed milestone (SUMMARY exists),
894
+ // discard recovery context to prevent phantom skip loops (#790).
895
+ const recoveredMid = crashLock.unitId.split("/")[0];
896
+ const milestoneAlreadyComplete = recoveredMid
897
+ ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
898
+ : false;
899
+
900
+ if (milestoneAlreadyComplete) {
876
901
  ctx.ui.notify(
877
- `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
878
- "warning",
902
+ `Crash recovery: discarding stale context for ${crashLock.unitId} milestone ${recoveredMid} is already complete.`,
903
+ "info",
879
904
  );
880
905
  } else {
881
- ctx.ui.notify(
882
- `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
883
- "warning",
906
+ const activityDir = join(gsdRoot(base), "activity");
907
+ const recovery = synthesizeCrashRecovery(
908
+ base, crashLock.unitType, crashLock.unitId,
909
+ crashLock.sessionFile, activityDir,
884
910
  );
911
+ if (recovery && recovery.trace.toolCallCount > 0) {
912
+ pendingCrashRecovery = recovery.prompt;
913
+ ctx.ui.notify(
914
+ `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
915
+ "warning",
916
+ );
917
+ } else {
918
+ ctx.ui.notify(
919
+ `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
920
+ "warning",
921
+ );
922
+ }
885
923
  }
886
924
  clearLock(base);
887
925
  }
@@ -1094,6 +1132,13 @@ export async function startAuto(
1094
1132
  }
1095
1133
  // Re-register SIGTERM handler with the original basePath (lock lives there)
1096
1134
  registerSigtermHandler(originalBasePath);
1135
+
1136
+ // After worktree entry, load completed keys from BOTH locations (project root
1137
+ // + worktree) so the in-memory set is the union. Prevents re-dispatch of units
1138
+ // completed in either location after crash/restart (#769).
1139
+ if (basePath !== originalBasePath) {
1140
+ loadPersistedKeys(basePath, completedKeySet);
1141
+ }
1097
1142
  } catch (err) {
1098
1143
  // Worktree creation is non-fatal — continue in the project root.
1099
1144
  ctx.ui.notify(
@@ -1130,11 +1175,12 @@ export async function startAuto(
1130
1175
  }
1131
1176
  }
1132
1177
 
1133
- // Initialize metrics — loads existing ledger from disk
1134
- initMetrics(base);
1178
+ // Initialize metrics — loads existing ledger from disk.
1179
+ // Use basePath (not base) so worktree-mode reads the worktree ledger (#769).
1180
+ initMetrics(basePath);
1135
1181
 
1136
1182
  // Initialize routing history for adaptive learning
1137
- initRoutingHistory(base);
1183
+ initRoutingHistory(basePath);
1138
1184
 
1139
1185
  // Capture the session's current model at auto-mode start (#650).
1140
1186
  // This prevents model bleed when multiple GSD instances share the
@@ -1185,8 +1231,10 @@ export async function startAuto(
1185
1231
  );
1186
1232
  }
1187
1233
 
1188
- // Self-heal: clear stale runtime records where artifacts already exist
1189
- await selfHealRuntimeRecords(base, ctx, completedKeySet);
1234
+ // Self-heal: clear stale runtime records where artifacts already exist.
1235
+ // Use basePath (not base) — in worktree mode, basePath points to the worktree
1236
+ // where runtime records and artifacts actually live (#769).
1237
+ await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
1190
1238
 
1191
1239
  // Self-heal: remove stale .git/index.lock from prior crash.
1192
1240
  // A stale lock file blocks all git operations (commit, merge, checkout).
@@ -1252,6 +1300,27 @@ export async function handleAgentEnd(
1252
1300
  // Unit completed — clear its timeout
1253
1301
  clearUnitTimeout();
1254
1302
 
1303
+ // ── Parallel worker signal check ─────────────────────────────────────
1304
+ // When running as a parallel worker (GSD_MILESTONE_LOCK set), check for
1305
+ // coordinator signals before dispatching the next unit.
1306
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
1307
+ if (milestoneLock) {
1308
+ const signal = consumeSignal(basePath, milestoneLock);
1309
+ if (signal) {
1310
+ if (signal.signal === "stop") {
1311
+ _handlingAgentEnd = false;
1312
+ await stopAuto(ctx, pi);
1313
+ return;
1314
+ }
1315
+ if (signal.signal === "pause") {
1316
+ _handlingAgentEnd = false;
1317
+ await pauseAuto(ctx, pi);
1318
+ return;
1319
+ }
1320
+ // "resume" and "rebase" signals are handled elsewhere or no-op here
1321
+ }
1322
+ }
1323
+
1255
1324
  // Invalidate all caches — the unit just completed and may have
1256
1325
  // written planning files (task summaries, roadmap checkboxes, etc.)
1257
1326
  invalidateAllCaches();
@@ -1259,12 +1328,41 @@ export async function handleAgentEnd(
1259
1328
  // Small delay to let files settle (git commits, file writes)
1260
1329
  await new Promise(r => setTimeout(r, 500));
1261
1330
 
1262
- // Auto-commit any dirty files the LLM left behind on the current branch.
1331
+ // Commit any dirty files the LLM left behind on the current branch.
1332
+ // For execute-task units, build a meaningful commit message from the
1333
+ // task summary (one-liner, key_files, inferred type). For other unit
1334
+ // types, fall back to the generic chore() message.
1263
1335
  if (currentUnit) {
1264
1336
  try {
1265
- const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
1337
+ let taskContext: TaskCommitContext | undefined;
1338
+
1339
+ if (currentUnit.type === "execute-task") {
1340
+ const parts = currentUnit.id.split("/");
1341
+ const [mid, sid, tid] = parts;
1342
+ if (mid && sid && tid) {
1343
+ const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY");
1344
+ if (summaryPath) {
1345
+ try {
1346
+ const summaryContent = await loadFile(summaryPath);
1347
+ if (summaryContent) {
1348
+ const summary = parseSummary(summaryContent);
1349
+ taskContext = {
1350
+ taskId: `${sid}/${tid}`,
1351
+ taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid,
1352
+ oneLiner: summary.oneLiner || undefined,
1353
+ keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined,
1354
+ };
1355
+ }
1356
+ } catch {
1357
+ // Non-fatal — fall back to generic message
1358
+ }
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id, taskContext);
1266
1364
  if (commitMsg) {
1267
- ctx.ui.notify(`Auto-committed uncommitted changes.`, "info");
1365
+ ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info");
1268
1366
  }
1269
1367
  } catch {
1270
1368
  // Non-fatal
@@ -1317,7 +1415,8 @@ export async function handleAgentEnd(
1317
1415
  }
1318
1416
  try {
1319
1417
  await rebuildState(basePath);
1320
- autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
1418
+ // State rebuild commit is bookkeeping — generic message is appropriate
1419
+ autoCommitCurrentBranch(basePath, "state-rebuild", currentUnit.id);
1321
1420
  } catch {
1322
1421
  // Non-fatal
1323
1422
  }
@@ -1410,7 +1509,7 @@ export async function handleAgentEnd(
1410
1509
  persistCompletedKey(basePath, completionKey);
1411
1510
  completedKeySet.add(completionKey);
1412
1511
  }
1413
- invalidateStateCache();
1512
+ invalidateAllCaches();
1414
1513
  }
1415
1514
  } catch {
1416
1515
  // Non-fatal — worst case we fall through to normal dispatch which has its own checks
@@ -1449,7 +1548,16 @@ export async function handleAgentEnd(
1449
1548
  if (currentUnit) {
1450
1549
  const modelId = ctx.model?.id ?? "unknown";
1451
1550
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
1452
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1551
+ const hookActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1552
+ if (hookActivityFile) {
1553
+ try {
1554
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1555
+ const llmCallFn = buildMemoryLLMCall(ctx);
1556
+ if (llmCallFn) {
1557
+ extractMemoriesFromUnit(hookActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1558
+ }
1559
+ } catch { /* non-fatal */ }
1560
+ }
1453
1561
  }
1454
1562
  currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
1455
1563
  writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
@@ -1485,7 +1593,7 @@ export async function handleAgentEnd(
1485
1593
  const result = await cmdCtx!.newSession();
1486
1594
  if (result.cancelled) {
1487
1595
  resetHookState();
1488
- await stopAuto(ctx, pi);
1596
+ await stopAuto(ctx, pi, "Hook session cancelled");
1489
1597
  return;
1490
1598
  }
1491
1599
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -1591,7 +1699,16 @@ export async function handleAgentEnd(
1591
1699
  if (currentUnit) {
1592
1700
  const modelId = ctx.model?.id ?? "unknown";
1593
1701
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1594
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1702
+ const triageActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1703
+ if (triageActivityFile) {
1704
+ try {
1705
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1706
+ const llmCallFn = buildMemoryLLMCall(ctx);
1707
+ if (llmCallFn) {
1708
+ extractMemoriesFromUnit(triageActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1709
+ }
1710
+ } catch { /* non-fatal */ }
1711
+ }
1595
1712
  }
1596
1713
 
1597
1714
  // Dispatch triage as a new unit (early-dispatch-and-return)
@@ -1669,7 +1786,16 @@ export async function handleAgentEnd(
1669
1786
  if (currentUnit) {
1670
1787
  const modelId = ctx.model?.id ?? "unknown";
1671
1788
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1672
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1789
+ const qtActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1790
+ if (qtActivityFile) {
1791
+ try {
1792
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1793
+ const llmCallFn = buildMemoryLLMCall(ctx);
1794
+ if (llmCallFn) {
1795
+ extractMemoriesFromUnit(qtActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1796
+ }
1797
+ } catch { /* non-fatal */ }
1798
+ }
1673
1799
  }
1674
1800
 
1675
1801
  // Dispatch quick-task as a new unit
@@ -1783,7 +1909,15 @@ async function showStepWizard(
1783
1909
 
1784
1910
  // If no active milestone or everything is complete, stop
1785
1911
  if (!mid || state.phase === "complete") {
1786
- await stopAuto(ctx, pi);
1912
+ const incomplete = state.registry.filter(m => m.status !== "complete");
1913
+ if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") {
1914
+ const ids = incomplete.map(m => m.id).join(", ");
1915
+ const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1916
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
1917
+ await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`);
1918
+ } else {
1919
+ await stopAuto(ctx, pi, state.phase === "complete" ? "All work complete" : "No active milestone");
1920
+ }
1787
1921
  return;
1788
1922
  }
1789
1923
 
@@ -1906,8 +2040,7 @@ async function dispatchNextUnit(
1906
2040
  // doesn't provide. Stop gracefully instead of crashing.
1907
2041
  const staleMsg = checkResourcesStale();
1908
2042
  if (staleMsg) {
1909
- await stopAuto(ctx, pi);
1910
- ctx.ui.notify(staleMsg, "error");
2043
+ await stopAuto(ctx, pi, staleMsg);
1911
2044
  return;
1912
2045
  }
1913
2046
 
@@ -2054,8 +2187,25 @@ async function dispatchNextUnit(
2054
2187
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2055
2188
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2056
2189
  }
2057
- sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
2058
- await stopAuto(ctx, pi);
2190
+
2191
+ const incomplete = state.registry.filter(m => m.status !== "complete");
2192
+ if (incomplete.length === 0) {
2193
+ // Genuinely all complete
2194
+ sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
2195
+ await stopAuto(ctx, pi, "All milestones complete");
2196
+ } else if (state.phase === "blocked") {
2197
+ // Milestones exist but are dependency-blocked
2198
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2199
+ await stopAuto(ctx, pi, blockerMsg);
2200
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
2201
+ sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2202
+ } else {
2203
+ // Milestones with remaining work exist but none became active — unexpected
2204
+ const ids = incomplete.map(m => m.id).join(", ");
2205
+ const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
2206
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
2207
+ await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
2208
+ }
2059
2209
  return;
2060
2210
  }
2061
2211
 
@@ -2063,8 +2213,8 @@ async function dispatchNextUnit(
2063
2213
  // The !mid check above returns early if mid is falsy; midTitle comes from
2064
2214
  // the same object so it should always be present when mid is.
2065
2215
  if (!midTitle) {
2066
- await stopAuto(ctx, pi);
2067
- return;
2216
+ midTitle = mid; // Defensive fallback: use milestone ID as title
2217
+ ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
2068
2218
  }
2069
2219
 
2070
2220
  // ── Mid-merge safety check: detect leftover merge state from a prior session ──
@@ -2082,7 +2232,10 @@ async function dispatchNextUnit(
2082
2232
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2083
2233
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2084
2234
  }
2085
- await stopAuto(ctx, pi);
2235
+ const noMilestoneReason = !mid
2236
+ ? "No active milestone after merge reconciliation"
2237
+ : `Milestone ${mid} has no title after reconciliation`;
2238
+ await stopAuto(ctx, pi, noMilestoneReason);
2086
2239
  return;
2087
2240
  }
2088
2241
 
@@ -2157,7 +2310,7 @@ async function dispatchNextUnit(
2157
2310
  }
2158
2311
  }
2159
2312
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
2160
- await stopAuto(ctx, pi);
2313
+ await stopAuto(ctx, pi, `Milestone ${mid} complete`);
2161
2314
  return;
2162
2315
  }
2163
2316
 
@@ -2167,8 +2320,8 @@ async function dispatchNextUnit(
2167
2320
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2168
2321
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2169
2322
  }
2170
- await stopAuto(ctx, pi);
2171
2323
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2324
+ await stopAuto(ctx, pi, blockerMsg);
2172
2325
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
2173
2326
  sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2174
2327
  return;
@@ -2194,9 +2347,8 @@ async function dispatchNextUnit(
2194
2347
  const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
2195
2348
  lastBudgetAlertLevel = newBudgetAlertLevel;
2196
2349
  if (budgetEnforcementAction === "halt") {
2197
- ctx.ui.notify(`${msg} Stopping auto-mode.`, "error");
2198
2350
  sendDesktopNotification("GSD", msg, "error", "budget");
2199
- await stopAuto(ctx, pi);
2351
+ await stopAuto(ctx, pi, "Budget ceiling reached");
2200
2352
  return;
2201
2353
  }
2202
2354
  if (budgetEnforcementAction === "pause") {
@@ -2211,6 +2363,10 @@ async function dispatchNextUnit(
2211
2363
  lastBudgetAlertLevel = newBudgetAlertLevel;
2212
2364
  ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2213
2365
  sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2366
+ } else if (newBudgetAlertLevel === 80) {
2367
+ lastBudgetAlertLevel = newBudgetAlertLevel;
2368
+ ctx.ui.notify(`Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2369
+ sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2214
2370
  } else if (newBudgetAlertLevel === 75) {
2215
2371
  lastBudgetAlertLevel = newBudgetAlertLevel;
2216
2372
  ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
@@ -2275,8 +2431,7 @@ async function dispatchNextUnit(
2275
2431
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2276
2432
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2277
2433
  }
2278
- await stopAuto(ctx, pi);
2279
- ctx.ui.notify(dispatchResult.reason, dispatchResult.level);
2434
+ await stopAuto(ctx, pi, dispatchResult.reason);
2280
2435
  return;
2281
2436
  }
2282
2437
 
@@ -2316,8 +2471,7 @@ async function dispatchNextUnit(
2316
2471
 
2317
2472
  const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
2318
2473
  if (priorSliceBlocker) {
2319
- await stopAuto(ctx, pi);
2320
- ctx.ui.notify(priorSliceBlocker, "error");
2474
+ await stopAuto(ctx, pi, priorSliceBlocker);
2321
2475
  return;
2322
2476
  }
2323
2477
 
@@ -2335,26 +2489,61 @@ async function dispatchNextUnit(
2335
2489
  const skipCount = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2336
2490
  unitConsecutiveSkips.set(idempotencyKey, skipCount);
2337
2491
  if (skipCount > MAX_CONSECUTIVE_SKIPS) {
2492
+ // Cross-check: verify deriveState actually returns this unit (#790).
2493
+ // If the unit's milestone is already complete, this is a phantom skip
2494
+ // loop from stale crash recovery context — don't evict.
2495
+ const skippedMid = unitId.split("/")[0];
2496
+ const skippedMilestoneComplete = skippedMid
2497
+ ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
2498
+ : false;
2499
+ if (skippedMilestoneComplete) {
2500
+ // Milestone is complete — evicting this key would fight self-heal.
2501
+ // Clear skip counter and re-dispatch from fresh state.
2502
+ unitConsecutiveSkips.delete(idempotencyKey);
2503
+ invalidateAllCaches();
2504
+ ctx.ui.notify(
2505
+ `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`,
2506
+ "info",
2507
+ );
2508
+ _skipDepth++;
2509
+ await new Promise(r => setTimeout(r, 50));
2510
+ await dispatchNextUnit(ctx, pi);
2511
+ _skipDepth = Math.max(0, _skipDepth - 1);
2512
+ return;
2513
+ }
2338
2514
  unitConsecutiveSkips.delete(idempotencyKey);
2339
2515
  completedKeySet.delete(idempotencyKey);
2340
2516
  removePersistedKey(basePath, idempotencyKey);
2341
- invalidateStateCache();
2517
+ invalidateAllCaches();
2342
2518
  ctx.ui.notify(
2343
2519
  `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`,
2344
2520
  "warning",
2345
2521
  );
2522
+ if (!active) return;
2346
2523
  _skipDepth++;
2347
- await new Promise(r => setTimeout(r, 50));
2524
+ await new Promise(r => setTimeout(r, 150));
2348
2525
  await dispatchNextUnit(ctx, pi);
2349
2526
  _skipDepth = Math.max(0, _skipDepth - 1);
2350
2527
  return;
2351
2528
  }
2529
+ // Count toward lifetime cap so hard-stop fires during skip loops (#792)
2530
+ const lifeSkip = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2531
+ unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
2532
+ if (lifeSkip > MAX_LIFETIME_DISPATCHES) {
2533
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
2534
+ ctx.ui.notify(
2535
+ `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle (${lifeSkip} iterations).`,
2536
+ "error",
2537
+ );
2538
+ return;
2539
+ }
2352
2540
  ctx.ui.notify(
2353
2541
  `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
2354
2542
  "info",
2355
2543
  );
2544
+ if (!active) return;
2356
2545
  _skipDepth++;
2357
- await new Promise(r => setTimeout(r, 50));
2546
+ await new Promise(r => setTimeout(r, 150));
2358
2547
  await dispatchNextUnit(ctx, pi);
2359
2548
  _skipDepth = Math.max(0, _skipDepth - 1);
2360
2549
  return;
@@ -2377,31 +2566,62 @@ async function dispatchNextUnit(
2377
2566
  if (verifyExpectedArtifact(unitType, unitId, basePath)) {
2378
2567
  persistCompletedKey(basePath, idempotencyKey);
2379
2568
  completedKeySet.add(idempotencyKey);
2380
- invalidateStateCache();
2569
+ invalidateAllCaches();
2381
2570
  // Same consecutive-skip guard as the idempotency path above.
2382
2571
  const skipCount2 = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2383
2572
  unitConsecutiveSkips.set(idempotencyKey, skipCount2);
2384
2573
  if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
2574
+ // Cross-check: verify the unit's milestone is still active (#790).
2575
+ const skippedMid2 = unitId.split("/")[0];
2576
+ const skippedMilestoneComplete2 = skippedMid2
2577
+ ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
2578
+ : false;
2579
+ if (skippedMilestoneComplete2) {
2580
+ unitConsecutiveSkips.delete(idempotencyKey);
2581
+ invalidateAllCaches();
2582
+ ctx.ui.notify(
2583
+ `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`,
2584
+ "info",
2585
+ );
2586
+ _skipDepth++;
2587
+ await new Promise(r => setTimeout(r, 50));
2588
+ await dispatchNextUnit(ctx, pi);
2589
+ _skipDepth = Math.max(0, _skipDepth - 1);
2590
+ return;
2591
+ }
2385
2592
  unitConsecutiveSkips.delete(idempotencyKey);
2386
2593
  completedKeySet.delete(idempotencyKey);
2387
2594
  removePersistedKey(basePath, idempotencyKey);
2388
- invalidateStateCache();
2595
+ invalidateAllCaches();
2389
2596
  ctx.ui.notify(
2390
2597
  `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`,
2391
2598
  "warning",
2392
2599
  );
2600
+ if (!active) return;
2393
2601
  _skipDepth++;
2394
- await new Promise(r => setTimeout(r, 50));
2602
+ await new Promise(r => setTimeout(r, 150));
2395
2603
  await dispatchNextUnit(ctx, pi);
2396
2604
  _skipDepth = Math.max(0, _skipDepth - 1);
2397
2605
  return;
2398
2606
  }
2607
+ // Count toward lifetime cap so hard-stop fires during skip loops (#792)
2608
+ const lifeSkip2 = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2609
+ unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
2610
+ if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) {
2611
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
2612
+ ctx.ui.notify(
2613
+ `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle (${lifeSkip2} iterations).`,
2614
+ "error",
2615
+ );
2616
+ return;
2617
+ }
2399
2618
  ctx.ui.notify(
2400
2619
  `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
2401
2620
  "info",
2402
2621
  );
2622
+ if (!active) return;
2403
2623
  _skipDepth++;
2404
- await new Promise(r => setTimeout(r, 50));
2624
+ await new Promise(r => setTimeout(r, 150));
2405
2625
  await dispatchNextUnit(ctx, pi);
2406
2626
  _skipDepth = Math.max(0, _skipDepth - 1);
2407
2627
  return;
@@ -2434,9 +2654,9 @@ async function dispatchNextUnit(
2434
2654
  }
2435
2655
  saveActivityLog(ctx, basePath, unitType, unitId);
2436
2656
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2437
- await stopAuto(ctx, pi);
2657
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`);
2438
2658
  ctx.ui.notify(
2439
- `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.`,
2659
+ `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${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.`,
2440
2660
  "error",
2441
2661
  );
2442
2662
  return;
@@ -2471,7 +2691,7 @@ async function dispatchNextUnit(
2471
2691
  persistCompletedKey(basePath, reconciledKey);
2472
2692
  completedKeySet.add(reconciledKey);
2473
2693
  unitDispatchCount.delete(dispatchKey);
2474
- invalidateStateCache();
2694
+ invalidateAllCaches();
2475
2695
  await new Promise(r => setImmediate(r));
2476
2696
  await dispatchNextUnit(ctx, pi);
2477
2697
  return;
@@ -2498,7 +2718,7 @@ async function dispatchNextUnit(
2498
2718
  persistCompletedKey(basePath, dispatchKey);
2499
2719
  completedKeySet.add(dispatchKey);
2500
2720
  unitDispatchCount.delete(dispatchKey);
2501
- invalidateStateCache();
2721
+ invalidateAllCaches();
2502
2722
  await new Promise(r => setImmediate(r));
2503
2723
  await dispatchNextUnit(ctx, pi);
2504
2724
  return;
@@ -2518,7 +2738,7 @@ async function dispatchNextUnit(
2518
2738
  persistCompletedKey(basePath, dispatchKey);
2519
2739
  completedKeySet.add(dispatchKey);
2520
2740
  unitDispatchCount.delete(dispatchKey);
2521
- invalidateStateCache();
2741
+ invalidateAllCaches();
2522
2742
  await new Promise(r => setImmediate(r));
2523
2743
  await dispatchNextUnit(ctx, pi);
2524
2744
  return;
@@ -2529,7 +2749,7 @@ async function dispatchNextUnit(
2529
2749
 
2530
2750
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2531
2751
  const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
2532
- await stopAuto(ctx, pi);
2752
+ await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`);
2533
2753
  sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
2534
2754
  ctx.ui.notify(
2535
2755
  `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
@@ -2559,7 +2779,7 @@ async function dispatchNextUnit(
2559
2779
  persistCompletedKey(basePath, repairedKey);
2560
2780
  completedKeySet.add(repairedKey);
2561
2781
  unitDispatchCount.delete(dispatchKey);
2562
- invalidateStateCache();
2782
+ invalidateAllCaches();
2563
2783
  await new Promise(r => setImmediate(r));
2564
2784
  await dispatchNextUnit(ctx, pi);
2565
2785
  return;
@@ -2603,7 +2823,18 @@ async function dispatchNextUnit(
2603
2823
  if (currentUnit) {
2604
2824
  const modelId = ctx.model?.id ?? "unknown";
2605
2825
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2606
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2826
+ const activityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2827
+
2828
+ // Fire-and-forget memory extraction from completed unit
2829
+ if (activityFile) {
2830
+ try {
2831
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
2832
+ const llmCallFn = buildMemoryLLMCall(ctx);
2833
+ if (llmCallFn) {
2834
+ extractMemoriesFromUnit(activityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
2835
+ }
2836
+ } catch { /* non-fatal */ }
2837
+ }
2607
2838
 
2608
2839
  // Record routing outcome for adaptive learning
2609
2840
  if (currentUnitRouting) {
@@ -2670,8 +2901,7 @@ async function dispatchNextUnit(
2670
2901
  // Fresh session
2671
2902
  const result = await cmdCtx!.newSession();
2672
2903
  if (result.cancelled) {
2673
- await stopAuto(ctx, pi);
2674
- ctx.ui.notify("Auto-mode stopped.", "info");
2904
+ await stopAuto(ctx, pi, "Session cancelled");
2675
2905
  return;
2676
2906
  }
2677
2907