gsd-pi 2.26.0 → 2.27.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 (171) hide show
  1. package/README.md +43 -6
  2. package/dist/cli.js +4 -2
  3. package/dist/headless.d.ts +3 -0
  4. package/dist/headless.js +136 -8
  5. package/dist/help-text.js +3 -0
  6. package/dist/loader.js +33 -4
  7. package/dist/resources/extensions/bg-shell/index.ts +19 -2
  8. package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
  9. package/dist/resources/extensions/bg-shell/types.ts +21 -1
  10. package/dist/resources/extensions/gsd/auto/session.ts +224 -0
  11. package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
  12. package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
  13. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
  15. package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
  16. package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
  17. package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
  18. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  19. package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  20. package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  21. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  22. package/dist/resources/extensions/gsd/auto.ts +977 -1551
  23. package/dist/resources/extensions/gsd/commands.ts +3 -3
  24. package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  25. package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
  26. package/dist/resources/extensions/gsd/export-html.ts +1001 -0
  27. package/dist/resources/extensions/gsd/export.ts +49 -1
  28. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  29. package/dist/resources/extensions/gsd/gitignore.ts +4 -1
  30. package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
  31. package/dist/resources/extensions/gsd/index.ts +54 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
  33. package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  35. package/dist/resources/extensions/gsd/preferences.ts +62 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
  37. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  38. package/dist/resources/extensions/gsd/reports.ts +510 -0
  39. package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
  40. package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  41. package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  42. package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  43. package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  44. package/dist/resources/extensions/gsd/state.ts +30 -0
  45. package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
  46. package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  47. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  48. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  49. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  51. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  52. package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  54. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  55. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  57. package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  58. package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  59. package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  60. package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  61. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  62. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  63. package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  64. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  65. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  66. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  67. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  68. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  69. package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  70. package/dist/resources/extensions/gsd/types.ts +38 -0
  71. package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
  72. package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
  73. package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
  74. package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  75. package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
  76. package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
  77. package/dist/resources/extensions/shared/format-utils.ts +85 -0
  78. package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  79. package/dist/resources/extensions/subagent/index.ts +46 -1
  80. package/dist/resources/extensions/subagent/isolation.ts +9 -6
  81. package/package.json +1 -1
  82. package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
  83. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  84. package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
  85. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
  87. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
  90. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  91. package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
  92. package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
  93. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  94. package/packages/pi-tui/dist/components/editor.js +1 -1
  95. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  96. package/packages/pi-tui/src/components/editor.ts +3 -1
  97. package/scripts/link-workspace-packages.cjs +22 -6
  98. package/src/resources/extensions/bg-shell/index.ts +19 -2
  99. package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
  100. package/src/resources/extensions/bg-shell/types.ts +21 -1
  101. package/src/resources/extensions/gsd/auto/session.ts +224 -0
  102. package/src/resources/extensions/gsd/auto-budget.ts +32 -0
  103. package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
  104. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  105. package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
  107. package/src/resources/extensions/gsd/auto-observability.ts +74 -0
  108. package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
  109. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  110. package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  111. package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  112. package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  113. package/src/resources/extensions/gsd/auto.ts +977 -1551
  114. package/src/resources/extensions/gsd/commands.ts +3 -3
  115. package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  116. package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
  117. package/src/resources/extensions/gsd/export-html.ts +1001 -0
  118. package/src/resources/extensions/gsd/export.ts +49 -1
  119. package/src/resources/extensions/gsd/git-service.ts +6 -0
  120. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  121. package/src/resources/extensions/gsd/guided-flow.ts +24 -5
  122. package/src/resources/extensions/gsd/index.ts +54 -1
  123. package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
  124. package/src/resources/extensions/gsd/observability-validator.ts +21 -0
  125. package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  126. package/src/resources/extensions/gsd/preferences.ts +62 -1
  127. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
  128. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  129. package/src/resources/extensions/gsd/reports.ts +510 -0
  130. package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
  131. package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  132. package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  133. package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  134. package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  135. package/src/resources/extensions/gsd/state.ts +30 -0
  136. package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
  137. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  138. package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  139. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  140. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  141. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  142. package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  143. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  144. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  146. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  147. package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  148. package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  149. package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  150. package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  151. package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  152. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  153. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  154. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  155. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  156. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  157. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  158. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  159. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  160. package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  161. package/src/resources/extensions/gsd/types.ts +38 -0
  162. package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
  163. package/src/resources/extensions/gsd/verification-gate.ts +567 -0
  164. package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
  165. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  166. package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
  167. package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
  168. package/src/resources/extensions/shared/format-utils.ts +85 -0
  169. package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  170. package/src/resources/extensions/subagent/index.ts +46 -1
  171. package/src/resources/extensions/subagent/isolation.ts +9 -6
@@ -17,17 +17,17 @@ import type {
17
17
  } from "@gsd/pi-coding-agent";
18
18
 
19
19
  import { deriveState } from "./state.js";
20
- import type { BudgetEnforcementMode, GSDState } from "./types.js";
21
- import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides, parseSummary } from "./files.js";
20
+ import type { GSDState } from "./types.js";
21
+ import { loadFile, getManifestStatus, resolveAllOverrides, parsePlan, parseSummary } from "./files.js";
22
22
  import { loadPrompt } from "./prompt-loader.js";
23
+ import { runVerificationGate, formatFailureContext, captureRuntimeErrors, runDependencyAudit } from "./verification-gate.js";
24
+ import { writeVerificationJSON } from "./verification-evidence.js";
23
25
  export { inlinePriorMilestoneSummary } from "./files.js";
24
26
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
25
27
  import {
26
28
  gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
27
29
  resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFile,
28
- relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath,
29
- milestonesDir,
30
- buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
30
+ milestonesDir, buildTaskFileName,
31
31
  } from "./paths.js";
32
32
  import { invalidateAllCaches } from "./cache.js";
33
33
  import { saveActivityLog, clearActivityLogState } from "./activity-log.js";
@@ -35,16 +35,42 @@ import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.
35
35
  import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js";
36
36
  import {
37
37
  clearUnitRuntimeRecord,
38
- formatExecuteTaskRecoveryStatus,
39
38
  inspectExecuteTaskDurability,
40
39
  readUnitRuntimeRecord,
41
40
  writeUnitRuntimeRecord,
42
41
  } from "./unit-runtime.js";
43
- import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig, getIsolationMode } from "./preferences.js";
42
+ import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js";
44
43
  import { sendDesktopNotification } from "./notifications.js";
45
44
  import type { GSDPreferences } from "./preferences.js";
46
- import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
47
- import { resolveModelForComplexity } from "./model-router.js";
45
+ import {
46
+ type BudgetAlertLevel,
47
+ getBudgetAlertLevel,
48
+ getNewBudgetAlertLevel,
49
+ getBudgetEnforcementAction,
50
+ } from "./auto-budget.js";
51
+ import {
52
+ markToolStart as _markToolStart,
53
+ markToolEnd as _markToolEnd,
54
+ getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs,
55
+ getInFlightToolCount,
56
+ getOldestInFlightToolStart,
57
+ clearInFlightTools,
58
+ } from "./auto-tool-tracking.js";
59
+ import {
60
+ collectObservabilityWarnings as _collectObservabilityWarnings,
61
+ buildObservabilityRepairBlock,
62
+ } from "./auto-observability.js";
63
+ import { closeoutUnit } from "./auto-unit-closeout.js";
64
+ import { recoverTimedOutUnit } from "./auto-timeout-recovery.js";
65
+ import { selectAndApplyModel } from "./auto-model-selection.js";
66
+ import {
67
+ syncProjectRootToWorktree,
68
+ syncStateToProjectRoot,
69
+ readResourceVersion,
70
+ checkResourcesStale,
71
+ escapeStaleWorktree,
72
+ } from "./auto-worktree-sync.js";
73
+ // complexity-classifier + model-router imports moved to auto-model-selection.ts
48
74
  import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js";
49
75
  import {
50
76
  checkPostUnitHooks,
@@ -57,12 +83,7 @@ import {
57
83
  restoreHookState,
58
84
  clearPersistedHookState,
59
85
  } from "./post-unit-hooks.js";
60
- import {
61
- validatePlanBoundary,
62
- validateExecuteBoundary,
63
- validateCompleteBoundary,
64
- formatValidationIssues,
65
- } from "./observability-validator.js";
86
+ // observability-validator imports moved to auto-observability.ts
66
87
  import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
67
88
  import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
68
89
  import {
@@ -76,13 +97,13 @@ import {
76
97
  import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js";
77
98
  import { captureAvailableSkills, getAndClearSkills, resetSkillTelemetry } from "./skill-telemetry.js";
78
99
  import {
79
- initMetrics, resetMetrics, snapshotUnitMetrics, getLedger,
100
+ initMetrics, resetMetrics, getLedger,
80
101
  getProjectTotals, formatCost, formatTokenCount,
81
102
  } from "./metrics.js";
103
+ import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js";
82
104
  import { join } from "node:path";
83
105
  import { sep as pathSep } from "node:path";
84
- import { homedir } from "node:os";
85
- import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync, cpSync } from "node:fs";
106
+ import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
86
107
  import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
87
108
  import {
88
109
  autoCommitCurrentBranch,
@@ -126,18 +147,7 @@ import {
126
147
  reconcileMergeState,
127
148
  } from "./auto-recovery.js";
128
149
  import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js";
129
- import {
130
- buildResearchSlicePrompt,
131
- buildResearchMilestonePrompt,
132
- buildPlanSlicePrompt,
133
- buildPlanMilestonePrompt,
134
- buildExecuteTaskPrompt,
135
- buildCompleteSlicePrompt,
136
- buildCompleteMilestonePrompt,
137
- buildReassessRoadmapPrompt,
138
- buildRunUatPrompt,
139
- buildReplanSlicePrompt,
140
- } from "./auto-prompts.js";
150
+ // Prompt builders moved to auto-direct-dispatch.ts (only used there now)
141
151
  import {
142
152
  type AutoDashboardData,
143
153
  updateProgressWidget as _updateProgressWidget,
@@ -145,7 +155,6 @@ import {
145
155
  clearSliceProgressCache,
146
156
  describeNextUnit as _describeNextUnit,
147
157
  unitVerb,
148
- unitPhaseLabel,
149
158
  formatAutoElapsed as _formatAutoElapsed,
150
159
  formatWidgetTokens,
151
160
  hideFooter,
@@ -159,180 +168,27 @@ import {
159
168
  import { isDbAvailable } from "./gsd-db.js";
160
169
  import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
161
170
 
162
- // ─── Worktree Project Root State Sync ───────────────────────────────────────
163
- // When running in an auto-worktree, dispatch state (.gsd/ metadata) diverges
164
- // between the worktree (where work happens) and the project root (where
165
- // startAutoMode reads initial state on restart). Without syncing, restarting
166
- // auto-mode reads stale state from the project root and re-dispatches
167
- // already-completed units.
168
-
169
- /**
170
- * Sync milestone artifacts from project root INTO worktree before deriveState.
171
- * Covers the case where the LLM wrote artifacts to the main repo filesystem
172
- * (e.g. via absolute paths) but the worktree has stale data. Also deletes
173
- * gsd.db in the worktree so it rebuilds from fresh disk state (#853).
174
- * Non-fatal — sync failure should never block dispatch.
175
- */
176
- function syncProjectRootToWorktree(projectRoot: string, worktreePath: string, milestoneId: string | null): void {
177
- if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
178
- if (!milestoneId) return;
179
-
180
- const prGsd = join(projectRoot, ".gsd");
181
- const wtGsd = join(worktreePath, ".gsd");
182
-
183
- // Copy milestone directory from project root to worktree if the project root
184
- // has newer artifacts (e.g. slices that don't exist in the worktree yet)
185
- try {
186
- const srcMilestone = join(prGsd, "milestones", milestoneId);
187
- const dstMilestone = join(wtGsd, "milestones", milestoneId);
188
- if (existsSync(srcMilestone)) {
189
- mkdirSync(dstMilestone, { recursive: true });
190
- cpSync(srcMilestone, dstMilestone, { recursive: true, force: false });
191
- }
192
- } catch { /* non-fatal */ }
193
-
194
- // Delete worktree gsd.db so it rebuilds from the freshly synced files.
195
- // Stale DB rows are the root cause of the infinite skip loop (#853).
196
- try {
197
- const wtDb = join(wtGsd, "gsd.db");
198
- if (existsSync(wtDb)) {
199
- unlinkSync(wtDb);
200
- }
201
- } catch { /* non-fatal */ }
202
- }
203
-
204
- /**
205
- * Sync dispatch-critical .gsd/ state files from worktree to project root.
206
- * Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
207
- * Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
208
- * Non-fatal — sync failure should never block dispatch.
209
- */
210
- function syncStateToProjectRoot(worktreePath: string, projectRoot: string, milestoneId: string | null): void {
211
- if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
212
- if (!milestoneId) return;
213
-
214
- const wtGsd = join(worktreePath, ".gsd");
215
- const prGsd = join(projectRoot, ".gsd");
216
-
217
- // 1. STATE.md — the quick-glance status used by initial deriveState()
218
- try {
219
- const src = join(wtGsd, "STATE.md");
220
- const dst = join(prGsd, "STATE.md");
221
- if (existsSync(src)) cpSync(src, dst, { force: true });
222
- } catch { /* non-fatal */ }
223
-
224
- // 2. Milestone directory — ROADMAP, slice PLANs, task summaries
225
- // Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
226
- try {
227
- const srcMilestone = join(wtGsd, "milestones", milestoneId);
228
- const dstMilestone = join(prGsd, "milestones", milestoneId);
229
- if (existsSync(srcMilestone)) {
230
- mkdirSync(dstMilestone, { recursive: true });
231
- cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
232
- }
233
- } catch { /* non-fatal */ }
171
+ // Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts
234
172
 
235
- // 3. Merge completed-units.json (set-union of both locations)
236
- // Prevents already-completed units from being re-dispatched after crash/restart.
237
- const srcKeysFile = join(wtGsd, "completed-units.json");
238
- const dstKeysFile = join(prGsd, "completed-units.json");
239
- if (existsSync(srcKeysFile)) {
240
- try {
241
- const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
242
- let dstKeys: string[] = [];
243
- if (existsSync(dstKeysFile)) {
244
- try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
245
- }
246
- const merged = [...new Set([...dstKeys, ...srcKeys])];
247
- writeFileSync(dstKeysFile, JSON.stringify(merged, null, 2));
248
- } catch { /* non-fatal */ }
249
- }
173
+ // ─── Session State ─────────────────────────────────────────────────────────
250
174
 
251
- // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
252
- // Without this, a crash during a unit leaves the runtime record only in the
253
- // worktree. If the next session resolves basePath before worktree re-entry,
254
- // selfHeal can't find or clear the stale record (#769).
255
- try {
256
- const srcRuntime = join(wtGsd, "runtime", "units");
257
- const dstRuntime = join(prGsd, "runtime", "units");
258
- if (existsSync(srcRuntime)) {
259
- mkdirSync(dstRuntime, { recursive: true });
260
- cpSync(srcRuntime, dstRuntime, { recursive: true, force: true });
261
- }
262
- } catch { /* non-fatal */ }
263
- }
175
+ import {
176
+ AutoSession,
177
+ MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES,
178
+ MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH,
179
+ } from "./auto/session.js";
180
+ import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerificationRetry } from "./auto/session.js";
181
+ export {
182
+ MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES,
183
+ MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH,
184
+ } from "./auto/session.js";
185
+ export type { CompletedUnit, CurrentUnit, UnitRouting, StartModel } from "./auto/session.js";
264
186
 
265
- // ─── State ────────────────────────────────────────────────────────────────────
266
-
267
- let active = false;
268
- let paused = false;
269
- let stepMode = false;
270
- let verbose = false;
271
- let cmdCtx: ExtensionCommandContext | null = null;
272
- let basePath = "";
273
- let originalBasePath = "";
274
- let gitService: GitServiceImpl | null = null;
275
-
276
- /** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
277
- const unitDispatchCount = new Map<string, number>();
278
- const MAX_UNIT_DISPATCHES = 3;
279
- /** Retry index at which a stub summary placeholder is written when the summary is still absent. */
280
- const STUB_RECOVERY_THRESHOLD = 2;
281
- /** Hard cap on total dispatches per unit across ALL reconciliation cycles.
282
- * unitDispatchCount can be reset by loop-recovery/self-repair paths, but this
283
- * counter is never reset — it catches infinite reconciliation loops where
284
- * artifacts exist but deriveState keeps returning the same unit. */
285
- const unitLifetimeDispatches = new Map<string, number>();
286
- const MAX_LIFETIME_DISPATCHES = 6;
287
-
288
- /** Tracks recovery attempt count per unit for backoff and diagnostics. */
289
- const unitRecoveryCount = new Map<string, number>();
290
-
291
- /** Track consecutive skips per unit — catches infinite skip loops where deriveState
292
- * keeps returning the same already-completed unit. Reset on any real dispatch. */
293
- const unitConsecutiveSkips = new Map<string, number>();
294
- const MAX_CONSECUTIVE_SKIPS = 3;
295
-
296
- /** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */
297
- const completedKeySet = new Set<string>();
298
-
299
- /** Resource version captured at auto-mode start. If the managed-resources
300
- * manifest version changes mid-session (e.g. npm update -g gsd-pi),
301
- * templates on disk may expect variables the in-memory code doesn't provide.
302
- * Detect this and stop gracefully instead of crashing.
303
- * Uses gsdVersion (semver) instead of syncedAt (timestamp) so that
304
- * launching a second session doesn't falsely trigger staleness (#804). */
305
- let resourceVersionOnStart: string | null = null;
306
-
307
- function readResourceVersion(): string | null {
308
- const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
309
- const manifestPath = join(agentDir, "managed-resources.json");
310
- try {
311
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
312
- return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null;
313
- } catch {
314
- return null;
315
- }
316
- }
187
+ const s = new AutoSession();
317
188
 
318
- function checkResourcesStale(): string | null {
319
- if (resourceVersionOnStart === null) return null;
320
- const current = readResourceVersion();
321
- if (current === null) return null;
322
- if (current !== resourceVersionOnStart) {
323
- return "GSD resources were updated since this session started. Restart gsd to load the new code.";
324
- }
325
- return null;
326
- }
189
+ /** Throttle STATE.md rebuilds at most once per 30 seconds */
190
+ const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
327
191
 
328
- /**
329
- * Resolve whether auto-mode should use worktree isolation.
330
- * Returns true for worktree mode (default), false for branch and none modes.
331
- * Branch mode works directly in the project root — useful for repos
332
- * with git submodules where worktrees don't work well (#531).
333
- * None mode skips all worktree and milestone-branch logic — commits
334
- * land on the current branch with no isolation (#M001-S02).
335
- */
336
192
  export function shouldUseWorktreeIsolation(): boolean {
337
193
  const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
338
194
  if (prefs?.isolation === "none") return false;
@@ -340,49 +196,19 @@ export function shouldUseWorktreeIsolation(): boolean {
340
196
  return true; // default: worktree
341
197
  }
342
198
 
343
- /**
344
- * Detect and escape a stale worktree cwd (#608).
345
- *
346
- * After milestone completion + merge, the worktree directory is removed but
347
- * the process cwd may still point inside `.gsd/worktrees/<MID>/`.
348
- * When a new session starts, `process.cwd()` is passed as `base` to startAuto
349
- * and all subsequent writes land in the wrong directory. This function detects
350
- * that scenario and chdir back to the project root.
351
- *
352
- * Returns the corrected base path.
353
- */
354
- function escapeStaleWorktree(base: string): string {
355
- const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
356
- const idx = base.indexOf(marker);
357
- if (idx === -1) return base;
199
+ /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */
358
200
 
359
- // base is inside .gsd/worktrees/<something> extract the project root
360
- const projectRoot = base.slice(0, idx);
361
- try {
362
- process.chdir(projectRoot);
363
- } catch {
364
- // If chdir fails, return the original — caller will handle errors downstream
365
- return base;
366
- }
367
- return projectRoot;
368
- }
201
+ /** Pending verification retryset when gate fails with retries remaining, consumed by dispatchNextUnit */
369
202
 
370
- /** Crash recovery promptset by startAuto, consumed by first dispatchNextUnit */
371
- let pendingCrashRecovery: string | null = null;
203
+ /** Verification retry count per unitId separate from s.unitDispatchCount which tracks artifact-missing retries */
372
204
 
373
205
  /** Session file path captured at pause — used to synthesize recovery briefing on resume */
374
- let pausedSessionFile: string | null = null;
375
206
 
376
207
  /** Dashboard tracking */
377
- let autoStartTime: number = 0;
378
- let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = [];
379
- let currentUnit: { type: string; id: string; startedAt: number } | null = null;
380
208
 
381
209
  /** Track dynamic routing decision for the current unit (for metrics) */
382
- let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
383
210
 
384
211
  /** Queue of quick-task captures awaiting dispatch after triage resolution */
385
- let pendingQuickTasks: import("./captures.js").CaptureEntry[] = [];
386
212
 
387
213
  /**
388
214
  * Model captured at auto-mode start. Used to prevent model bleed between
@@ -391,78 +217,42 @@ let pendingQuickTasks: import("./captures.js").CaptureEntry[] = [];
391
217
  * the session's original model is re-applied instead of reading from
392
218
  * the shared global settings (which another instance may have overwritten).
393
219
  */
394
- let autoModeStartModel: { provider: string; id: string } | null = null;
395
220
 
396
221
  /** Track current milestone to detect transitions */
397
- let currentMilestoneId: string | null = null;
398
- let lastBudgetAlertLevel: BudgetAlertLevel = 0;
399
222
 
400
223
  /** Model the user had selected before auto-mode started */
401
- let originalModelId: string | null = null;
402
- let originalModelProvider: string | null = null;
403
224
 
404
225
  /** Progress-aware timeout supervision */
405
- let unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
406
- let wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
407
- let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
226
+
227
+ /** Context-pressure continue-here monitor fires once when context usage >= 70% */
408
228
 
409
229
  /** Dispatch gap watchdog — detects when the state machine stalls between units.
410
230
  * After handleAgentEnd completes, if auto-mode is still active but no new unit
411
231
  * has been dispatched (sendMessage not called), this timer fires to force a
412
232
  * re-evaluation. Covers the case where dispatchNextUnit silently fails or
413
233
  * an unhandled error kills the dispatch chain. */
414
- let dispatchGapHandle: ReturnType<typeof setTimeout> | null = null;
415
- const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds
416
234
 
417
235
  /** Prompt character measurement for token savings analysis (R051). */
418
- let lastPromptCharCount: number | undefined;
419
- let lastBaselineCharCount: number | undefined;
420
236
 
421
237
  /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
422
- let _sigtermHandler: (() => void) | null = null;
423
238
 
424
239
  /**
425
240
  * Tool calls currently being executed — prevents false idle detection during long-running tools.
426
241
  * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
427
242
  * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
428
243
  */
429
- const inFlightTools = new Map<string, number>();
430
-
431
- type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100;
432
-
433
- export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
434
- if (budgetPct >= 1.0) return 100;
435
- if (budgetPct >= 0.90) return 90;
436
- if (budgetPct >= 0.80) return 80;
437
- if (budgetPct >= 0.75) return 75;
438
- return 0;
439
- }
440
-
441
- export function getNewBudgetAlertLevel(previousLevel: BudgetAlertLevel, budgetPct: number): BudgetAlertLevel | null {
442
- const currentLevel = getBudgetAlertLevel(budgetPct);
443
- if (currentLevel === 0 || currentLevel <= previousLevel) return null;
444
- return currentLevel;
445
- }
446
-
447
- export function getBudgetEnforcementAction(
448
- enforcement: BudgetEnforcementMode,
449
- budgetPct: number,
450
- ): "none" | "warn" | "pause" | "halt" {
451
- if (budgetPct < 1.0) return "none";
452
- if (enforcement === "halt") return "halt";
453
- if (enforcement === "pause") return "pause";
454
- return "warn";
455
- }
244
+ // Re-export budget utilities for external consumers
245
+ export { getBudgetAlertLevel, getNewBudgetAlertLevel, getBudgetEnforcementAction } from "./auto-budget.js";
456
246
 
457
247
  /** Wrapper: register SIGTERM handler and store reference. */
458
248
  function registerSigtermHandler(currentBasePath: string): void {
459
- _sigtermHandler = _registerSigtermHandler(currentBasePath, _sigtermHandler);
249
+ s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
460
250
  }
461
251
 
462
252
  /** Wrapper: deregister SIGTERM handler and clear reference. */
463
253
  function deregisterSigtermHandler(): void {
464
- _deregisterSigtermHandler(_sigtermHandler);
465
- _sigtermHandler = null;
254
+ _deregisterSigtermHandler(s.sigtermHandler);
255
+ s.sigtermHandler = null;
466
256
  }
467
257
 
468
258
  export { type AutoDashboardData } from "./auto-dashboard.js";
@@ -473,21 +263,18 @@ export function getAutoDashboardData(): AutoDashboardData {
473
263
  // Pending capture count — lazy check, non-fatal
474
264
  let pendingCaptureCount = 0;
475
265
  try {
476
- if (basePath) {
477
- pendingCaptureCount = countPendingCaptures(basePath);
266
+ if (s.basePath) {
267
+ pendingCaptureCount = countPendingCaptures(s.basePath);
478
268
  }
479
269
  } catch {
480
270
  // Non-fatal — captures module may not be loaded
481
271
  }
482
- return {
483
- active,
484
- paused,
485
- stepMode,
486
- startTime: autoStartTime,
487
- elapsed: (active || paused) ? Date.now() - autoStartTime : 0,
488
- currentUnit: currentUnit ? { ...currentUnit } : null,
489
- completedUnits: [...completedUnits],
490
- basePath,
272
+ return { active: s.active, paused: s.paused,
273
+ stepMode: s.stepMode,
274
+ startTime: s.autoStartTime,
275
+ elapsed: (s.active || s.paused) ? Date.now() - s.autoStartTime : 0,
276
+ currentUnit: s.currentUnit ? { ...s.currentUnit } : null,
277
+ completedUnits: [...s.completedUnits], basePath: s.basePath,
491
278
  totalCost: totals?.cost ?? 0,
492
279
  totalTokens: totals?.tokens.total ?? 0,
493
280
  pendingCaptureCount,
@@ -497,37 +284,24 @@ export function getAutoDashboardData(): AutoDashboardData {
497
284
  // ─── Public API ───────────────────────────────────────────────────────────────
498
285
 
499
286
  export function isAutoActive(): boolean {
500
- return active;
287
+ return s.active;
501
288
  }
502
289
 
503
290
  export function isAutoPaused(): boolean {
504
- return paused;
291
+ return s.paused;
505
292
  }
506
293
 
507
- /**
508
- * Mark a tool execution as in-flight. Called from index.ts on tool_execution_start.
509
- * Records start time so the idle watchdog can detect tools hung longer than the idle timeout.
510
- */
294
+ // Tool tracking — delegates to auto-tool-tracking.ts
511
295
  export function markToolStart(toolCallId: string): void {
512
- if (!active) return;
513
- inFlightTools.set(toolCallId, Date.now());
296
+ _markToolStart(toolCallId, s.active);
514
297
  }
515
298
 
516
- /**
517
- * Mark a tool execution as completed. Called from index.ts on tool_execution_end.
518
- */
519
299
  export function markToolEnd(toolCallId: string): void {
520
- inFlightTools.delete(toolCallId);
300
+ _markToolEnd(toolCallId);
521
301
  }
522
302
 
523
- /**
524
- * Returns the age (ms) of the oldest currently in-flight tool, or 0 if none.
525
- * Exported for testing.
526
- */
527
303
  export function getOldestInFlightToolAgeMs(): number {
528
- if (inFlightTools.size === 0) return 0;
529
- const oldestStart = Math.min(...inFlightTools.values());
530
- return Date.now() - oldestStart;
304
+ return _getOldestInFlightToolAgeMs();
531
305
  }
532
306
 
533
307
  /**
@@ -536,7 +310,7 @@ export function getOldestInFlightToolAgeMs(): number {
536
310
  * a second terminal can discover and stop a running auto-mode session.
537
311
  */
538
312
  function lockBase(): string {
539
- return originalBasePath || basePath;
313
+ return s.originalBasePath || s.basePath;
540
314
  }
541
315
 
542
316
  /**
@@ -566,50 +340,65 @@ export function stopAutoRemote(projectRoot: string): { found: boolean; pid?: num
566
340
  }
567
341
 
568
342
  export function isStepMode(): boolean {
569
- return stepMode;
343
+ return s.stepMode;
570
344
  }
571
345
 
572
346
  function clearUnitTimeout(): void {
573
- if (unitTimeoutHandle) {
574
- clearTimeout(unitTimeoutHandle);
575
- unitTimeoutHandle = null;
347
+ if (s.unitTimeoutHandle) {
348
+ clearTimeout(s.unitTimeoutHandle);
349
+ s.unitTimeoutHandle = null;
350
+ }
351
+ if (s.wrapupWarningHandle) {
352
+ clearTimeout(s.wrapupWarningHandle);
353
+ s.wrapupWarningHandle = null;
576
354
  }
577
- if (wrapupWarningHandle) {
578
- clearTimeout(wrapupWarningHandle);
579
- wrapupWarningHandle = null;
355
+ if (s.idleWatchdogHandle) {
356
+ clearInterval(s.idleWatchdogHandle);
357
+ s.idleWatchdogHandle = null;
580
358
  }
581
- if (idleWatchdogHandle) {
582
- clearInterval(idleWatchdogHandle);
583
- idleWatchdogHandle = null;
359
+ if (s.continueHereHandle) {
360
+ clearInterval(s.continueHereHandle);
361
+ s.continueHereHandle = null;
584
362
  }
585
- inFlightTools.clear();
363
+ clearInFlightTools();
586
364
  clearDispatchGapWatchdog();
587
365
  }
588
366
 
589
367
  function clearDispatchGapWatchdog(): void {
590
- if (dispatchGapHandle) {
591
- clearTimeout(dispatchGapHandle);
592
- dispatchGapHandle = null;
368
+ if (s.dispatchGapHandle) {
369
+ clearTimeout(s.dispatchGapHandle);
370
+ s.dispatchGapHandle = null;
593
371
  }
594
372
  }
595
373
 
374
+ /** Build snapshot metric opts, enriching with continueHereFired from the runtime record. */
375
+ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number } & Record<string, unknown> {
376
+ const runtime = s.currentUnit ? readUnitRuntimeRecord(s.basePath, unitType, unitId) : null;
377
+ return {
378
+ promptCharCount: s.lastPromptCharCount,
379
+ baselineCharCount: s.lastBaselineCharCount,
380
+ ...(s.currentUnitRouting ?? {}),
381
+ ...(runtime?.continueHereFired ? { continueHereFired: true } : {}),
382
+ };
383
+ }
384
+
596
385
  /**
597
386
  * Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
598
387
  * after handleAgentEnd completes. This catches the case where the dispatch chain silently
599
- * breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left active but idle.
388
+ * breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left s.active but idle.
600
389
  *
601
390
  * The watchdog is cleared on the next successful unit dispatch (clearUnitTimeout is called
602
391
  * at the start of handleAgentEnd, which calls clearDispatchGapWatchdog).
603
392
  */
604
393
  function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void {
605
394
  clearDispatchGapWatchdog();
606
- dispatchGapHandle = setTimeout(async () => {
607
- dispatchGapHandle = null;
608
- if (!active || !cmdCtx) return;
395
+ s.dispatchGapHandle = setTimeout(async () => {
396
+ s.dispatchGapHandle = null;
397
+ if (!s.active || !s.cmdCtx) return;
609
398
 
610
399
  // Auto-mode is active but no unit was dispatched — the state machine stalled.
611
400
  // Re-derive state and attempt a fresh dispatch.
612
- if (verbose) {
401
+ if (s.verbose) {
613
402
  ctx.ui.notify(
614
403
  "Dispatch gap detected — re-evaluating state.",
615
404
  "info",
@@ -626,37 +415,37 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
626
415
 
627
416
  // If dispatchNextUnit returned normally but still didn't dispatch a unit
628
417
  // (no sendMessage called → no timeout set), auto-mode is permanently
629
- // stalled. Stop cleanly instead of leaving it active but idle (#537).
630
- if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
418
+ // stalled. Stop cleanly instead of leaving it s.active but idle (#537).
419
+ if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) {
631
420
  await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry");
632
421
  }
633
422
  }, DISPATCH_GAP_TIMEOUT_MS);
634
423
  }
635
424
 
636
425
  export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise<void> {
637
- if (!active && !paused) return;
426
+ if (!s.active && !s.paused) return;
638
427
  const reasonSuffix = reason ? ` — ${reason}` : "";
639
428
  clearUnitTimeout();
640
429
  if (lockBase()) clearLock(lockBase());
641
430
  clearSkillSnapshot();
642
431
  resetSkillTelemetry();
643
- _dispatching = false;
644
- _skipDepth = 0;
432
+ s.dispatching = false;
433
+ s.skipDepth = 0;
645
434
 
646
435
  // Remove SIGTERM handler registered at auto-mode start
647
436
  deregisterSigtermHandler();
648
437
 
649
- // ── Auto-worktree: exit worktree and reset basePath on stop ──
438
+ // ── Auto-worktree: exit worktree and reset s.basePath on stop ──
650
439
  // Preserve the milestone branch so the next /gsd auto can re-enter
651
440
  // where it left off. The branch is only deleted during milestone
652
441
  // completion (mergeMilestoneToMain) after the work has been squash-merged.
653
- if (currentMilestoneId && isInAutoWorktree(basePath)) {
442
+ if (s.currentMilestoneId && isInAutoWorktree(s.basePath)) {
654
443
  try {
655
444
  // Auto-commit any dirty state before leaving so work isn't lost
656
- try { autoCommitCurrentBranch(basePath, "stop", currentMilestoneId); } catch { /* non-fatal */ }
657
- teardownAutoWorktree(originalBasePath, currentMilestoneId, { preserveBranch: true });
658
- basePath = originalBasePath;
659
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
445
+ try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch { /* non-fatal */ }
446
+ teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true });
447
+ s.basePath = s.originalBasePath;
448
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
660
449
  ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info");
661
450
  } catch (err) {
662
451
  ctx?.ui.notify(
@@ -677,10 +466,10 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
677
466
  // Always restore cwd to project root on stop (#608).
678
467
  // Even if isInAutoWorktree returned false (e.g., module state was already
679
468
  // cleared by mergeMilestoneToMain), the process cwd may still be inside
680
- // the worktree directory. Force it back to originalBasePath.
681
- if (originalBasePath) {
682
- basePath = originalBasePath;
683
- try { process.chdir(basePath); } catch { /* best-effort */ }
469
+ // the worktree directory. Force it back to s.originalBasePath.
470
+ if (s.originalBasePath) {
471
+ s.basePath = s.originalBasePath;
472
+ try { process.chdir(s.basePath); } catch { /* best-effort */ }
684
473
  }
685
474
 
686
475
  const ledger = getLedger();
@@ -695,8 +484,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
695
484
  }
696
485
 
697
486
  // Sync disk state so next resume starts from accurate state
698
- if (basePath) {
699
- try { await rebuildState(basePath); } catch { /* non-fatal */ }
487
+ if (s.basePath) {
488
+ try { await rebuildState(s.basePath); } catch { /* non-fatal */ }
700
489
  }
701
490
 
702
491
  // Write debug summary before resetting state
@@ -710,41 +499,45 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
710
499
  resetMetrics();
711
500
  resetRoutingHistory();
712
501
  resetHookState();
713
- if (basePath) clearPersistedHookState(basePath);
714
- active = false;
715
- paused = false;
716
- stepMode = false;
717
- unitDispatchCount.clear();
718
- unitRecoveryCount.clear();
719
- unitConsecutiveSkips.clear();
720
- inFlightTools.clear();
721
- lastBudgetAlertLevel = 0;
722
- unitLifetimeDispatches.clear();
723
- currentUnit = null;
724
- autoModeStartModel = null;
725
- currentMilestoneId = null;
726
- originalBasePath = "";
727
- completedUnits = [];
728
- pendingQuickTasks = [];
502
+ if (s.basePath) clearPersistedHookState(s.basePath);
503
+ s.active = false;
504
+ s.paused = false;
505
+ s.stepMode = false;
506
+ s.unitDispatchCount.clear();
507
+ s.unitRecoveryCount.clear();
508
+ s.unitConsecutiveSkips.clear();
509
+ clearInFlightTools();
510
+ s.lastBudgetAlertLevel = 0;
511
+ s.lastStateRebuildAt = 0;
512
+ s.unitLifetimeDispatches.clear();
513
+ s.currentUnit = null;
514
+ s.autoModeStartModel = null;
515
+ s.currentMilestoneId = null;
516
+ s.originalBasePath = "";
517
+ s.completedUnits = [];
518
+ s.pendingQuickTasks = [];
729
519
  clearSliceProgressCache();
730
520
  clearActivityLogState();
731
521
  resetProactiveHealing();
732
- pendingCrashRecovery = null;
733
- pausedSessionFile = null;
734
- _handlingAgentEnd = false;
522
+ s.recentlyEvictedKeys.clear();
523
+ s.pendingCrashRecovery = null;
524
+ s.pendingVerificationRetry = null;
525
+ s.verificationRetryCount.clear();
526
+ s.pausedSessionFile = null;
527
+ s.handlingAgentEnd = false;
735
528
  ctx?.ui.setStatus("gsd-auto", undefined);
736
529
  ctx?.ui.setWidget("gsd-progress", undefined);
737
530
  ctx?.ui.setFooter(undefined);
738
531
 
739
532
  // Restore the user's original model
740
- if (pi && ctx && originalModelId && originalModelProvider) {
741
- const original = ctx.modelRegistry.find(originalModelProvider, originalModelId);
533
+ if (pi && ctx && s.originalModelId && s.originalModelProvider) {
534
+ const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId);
742
535
  if (original) await pi.setModel(original);
743
- originalModelId = null;
744
- originalModelProvider = null;
536
+ s.originalModelId = null;
537
+ s.originalModelProvider = null;
745
538
  }
746
539
 
747
- cmdCtx = null;
540
+ s.cmdCtx = null;
748
541
  }
749
542
 
750
543
  /**
@@ -753,29 +546,31 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
753
546
  * from disk state. Called when the user presses Escape during auto-mode.
754
547
  */
755
548
  export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise<void> {
756
- if (!active) return;
549
+ if (!s.active) return;
757
550
  clearUnitTimeout();
758
551
 
759
552
  // Capture the current session file before clearing state — used for
760
553
  // recovery briefing on resume so the next agent knows what already happened.
761
- pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
554
+ s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
762
555
 
763
556
  if (lockBase()) clearLock(lockBase());
764
557
 
765
558
  // Remove SIGTERM handler registered at auto-mode start
766
559
  deregisterSigtermHandler();
767
560
 
768
- active = false;
769
- paused = true;
770
- // Preserve: unitDispatchCount, currentUnit, basePath, verbose, cmdCtx,
771
- // completedUnits, autoStartTime, currentMilestoneId, originalModelId
561
+ s.active = false;
562
+ s.paused = true;
563
+ s.pendingVerificationRetry = null;
564
+ s.verificationRetryCount.clear();
565
+ // Preserve: s.unitDispatchCount, s.currentUnit, s.basePath, s.verbose, s.cmdCtx,
566
+ // s.completedUnits, s.autoStartTime, s.currentMilestoneId, s.originalModelId
772
567
  // — all needed for resume and dashboard display
773
568
  ctx?.ui.setStatus("gsd-auto", "paused");
774
569
  ctx?.ui.setWidget("gsd-progress", undefined);
775
570
  ctx?.ui.setFooter(undefined);
776
- const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
571
+ const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto";
777
572
  ctx?.ui.notify(
778
- `${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
573
+ `${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
779
574
  "info",
780
575
  );
781
576
  }
@@ -797,38 +592,38 @@ export async function startAuto(
797
592
 
798
593
  // If resuming from paused state, just re-activate and dispatch next unit.
799
594
  // The conversation is still intact — no need to reinitialize everything.
800
- if (paused) {
801
- paused = false;
802
- active = true;
803
- verbose = verboseMode;
595
+ if (s.paused) {
596
+ s.paused = false;
597
+ s.active = true;
598
+ s.verbose = verboseMode;
804
599
  // Allow switching between step/auto on resume
805
- stepMode = requestedStepMode;
806
- cmdCtx = ctx;
807
- basePath = base;
808
- unitDispatchCount.clear();
809
- unitLifetimeDispatches.clear();
810
- unitConsecutiveSkips.clear();
600
+ s.stepMode = requestedStepMode;
601
+ s.cmdCtx = ctx;
602
+ s.basePath = base;
603
+ s.unitDispatchCount.clear();
604
+ s.unitLifetimeDispatches.clear();
605
+ s.unitConsecutiveSkips.clear();
811
606
  // Re-initialize metrics in case ledger was lost during pause
812
607
  if (!getLedger()) initMetrics(base);
813
608
  // Ensure milestone ID is set on git service for integration branch resolution
814
- if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
609
+ if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId);
815
610
 
816
611
  // ── Auto-worktree: re-enter worktree on resume if not already inside ──
817
612
  // Skip if already inside a worktree (manual /worktree) to prevent nesting.
818
613
  // Skip entirely in branch or none isolation mode (#531).
819
- if (currentMilestoneId && shouldUseWorktreeIsolation() && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) {
614
+ if (s.currentMilestoneId && shouldUseWorktreeIsolation() && s.originalBasePath && !isInAutoWorktree(s.basePath) && !detectWorktreeName(s.basePath) && !detectWorktreeName(s.originalBasePath)) {
820
615
  try {
821
- const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
616
+ const existingWtPath = getAutoWorktreePath(s.originalBasePath, s.currentMilestoneId);
822
617
  if (existingWtPath) {
823
- const wtPath = enterAutoWorktree(originalBasePath, currentMilestoneId);
824
- basePath = wtPath;
825
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
618
+ const wtPath = enterAutoWorktree(s.originalBasePath, s.currentMilestoneId);
619
+ s.basePath = wtPath;
620
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
826
621
  ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info");
827
622
  } else {
828
623
  // Worktree was deleted while paused — recreate it.
829
- const wtPath = createAutoWorktree(originalBasePath, currentMilestoneId);
830
- basePath = wtPath;
831
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
624
+ const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId);
625
+ s.basePath = wtPath;
626
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
832
627
  ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info");
833
628
  }
834
629
  } catch (err) {
@@ -842,46 +637,45 @@ export async function startAuto(
842
637
  // Re-register SIGTERM handler for the resumed session (use original base for lock)
843
638
  registerSigtermHandler(lockBase());
844
639
 
845
- ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
640
+ ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
846
641
  ctx.ui.setFooter(hideFooter);
847
- ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
642
+ ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
848
643
  // Restore hook state from disk in case session was interrupted
849
- restoreHookState(basePath);
644
+ restoreHookState(s.basePath);
850
645
  // Rebuild disk state before resuming — user interaction during pause may have changed files
851
- try { await rebuildState(basePath); } catch { /* non-fatal */ }
646
+ try { await rebuildState(s.basePath); } catch { /* non-fatal */ }
852
647
  try {
853
- const report = await runGSDDoctor(basePath, { fix: true });
648
+ const report = await runGSDDoctor(s.basePath, { fix: true });
854
649
  if (report.fixesApplied.length > 0) {
855
650
  ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
856
651
  }
857
652
  } catch { /* non-fatal */ }
858
653
  // Self-heal: clear stale runtime records where artifacts already exist
859
- await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
654
+ await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet);
860
655
  invalidateAllCaches();
861
656
 
862
657
  // Synthesize recovery briefing from the paused session so the next agent
863
658
  // knows what already happened (reuses crash recovery infrastructure).
864
- if (pausedSessionFile) {
865
- const activityDir = join(gsdRoot(basePath), "activity");
659
+ if (s.pausedSessionFile) {
660
+ const activityDir = join(gsdRoot(s.basePath), "activity");
866
661
  const recovery = synthesizeCrashRecovery(
867
- basePath,
868
- currentUnit?.type ?? "unknown",
869
- currentUnit?.id ?? "unknown",
870
- pausedSessionFile,
662
+ s.basePath,
663
+ s.currentUnit?.type ?? "unknown",
664
+ s.currentUnit?.id ?? "unknown", s.pausedSessionFile ?? undefined,
871
665
  activityDir,
872
666
  );
873
667
  if (recovery && recovery.trace.toolCallCount > 0) {
874
- pendingCrashRecovery = recovery.prompt;
668
+ s.pendingCrashRecovery = recovery.prompt;
875
669
  ctx.ui.notify(
876
670
  `Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`,
877
671
  "info",
878
672
  );
879
673
  }
880
- pausedSessionFile = null;
674
+ s.pausedSessionFile = null;
881
675
  }
882
676
 
883
677
  // Write lock on resume so cross-process status detection works (#723).
884
- writeLock(lockBase(), "resuming", currentMilestoneId ?? "unknown", completedUnits.length);
678
+ writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length);
885
679
 
886
680
  await dispatchNextUnit(ctx, pi);
887
681
  return;
@@ -894,9 +688,11 @@ export async function startAuto(
894
688
  }
895
689
 
896
690
  // Ensure .gitignore has baseline patterns
897
- const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs;
898
- ensureGitignore(base, { commitDocs });
899
- untrackRuntimeFiles(base);
691
+ const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git;
692
+ const commitDocs = gitPrefs?.commit_docs;
693
+ const manageGitignore = gitPrefs?.manage_gitignore;
694
+ ensureGitignore(base, { commitDocs, manageGitignore });
695
+ if (manageGitignore !== false) untrackRuntimeFiles(base);
900
696
 
901
697
  // Bootstrap .gsd/ if it doesn't exist
902
698
  const gsdDir = join(base, ".gsd");
@@ -911,8 +707,8 @@ export async function startAuto(
911
707
  }
912
708
  }
913
709
 
914
- // Initialize GitServiceImpl — basePath is set and git repo confirmed
915
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
710
+ // Initialize GitServiceImpl — s.basePath is set and git repo confirmed
711
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
916
712
 
917
713
  // Check for crash from previous session
918
714
  const crashLock = readCrashLock(base);
@@ -946,7 +742,7 @@ export async function startAuto(
946
742
  crashLock.sessionFile, activityDir,
947
743
  );
948
744
  if (recovery && recovery.trace.toolCallCount > 0) {
949
- pendingCrashRecovery = recovery.prompt;
745
+ s.pendingCrashRecovery = recovery.prompt;
950
746
  ctx.ui.notify(
951
747
  `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
952
748
  "warning",
@@ -984,6 +780,26 @@ export async function startAuto(
984
780
  // after a discussion that wrote new artifacts) may cause deriveState to
985
781
  // return pre-planning when the roadmap already exists (#800).
986
782
  invalidateAllCaches();
783
+
784
+ // ── Clean stale runtime unit files for completed milestones (#887) ───────
785
+ // After resource-update restart, stale runtime/units/*.json files from
786
+ // previously completed milestones can cause deriveState to resume the wrong
787
+ // milestone. If a milestone has a SUMMARY file, its unit files are stale.
788
+ try {
789
+ const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units");
790
+ if (existsSync(runtimeUnitsDir)) {
791
+ for (const file of readdirSync(runtimeUnitsDir)) {
792
+ if (!file.endsWith(".json")) continue;
793
+ const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
794
+ if (!midMatch) continue;
795
+ const mid = midMatch[1];
796
+ if (resolveMilestoneFile(base, mid, "SUMMARY")) {
797
+ try { unlinkSync(join(runtimeUnitsDir, file)); } catch { /* non-fatal */ }
798
+ }
799
+ }
800
+ }
801
+ } catch { /* non-fatal — don't block startup */ }
802
+
987
803
  let state = await deriveState(base);
988
804
 
989
805
  // ── Stale worktree state recovery (#654) ─────────────────────────────────
@@ -1101,29 +917,29 @@ export async function startAuto(
1101
917
  return;
1102
918
  }
1103
919
 
1104
- active = true;
1105
- stepMode = requestedStepMode;
1106
- verbose = verboseMode;
1107
- cmdCtx = ctx;
1108
- basePath = base;
1109
- unitDispatchCount.clear();
1110
- unitRecoveryCount.clear();
1111
- unitConsecutiveSkips.clear();
1112
- lastBudgetAlertLevel = 0;
1113
- unitLifetimeDispatches.clear();
1114
- completedKeySet.clear();
1115
- loadPersistedKeys(base, completedKeySet);
920
+ s.active = true;
921
+ s.stepMode = requestedStepMode;
922
+ s.verbose = verboseMode;
923
+ s.cmdCtx = ctx;
924
+ s.basePath = base;
925
+ s.unitDispatchCount.clear();
926
+ s.unitRecoveryCount.clear();
927
+ s.unitConsecutiveSkips.clear();
928
+ s.lastBudgetAlertLevel = 0;
929
+ s.unitLifetimeDispatches.clear();
930
+ s.completedKeySet.clear();
931
+ loadPersistedKeys(base, s.completedKeySet);
1116
932
  resetHookState();
1117
933
  restoreHookState(base);
1118
934
  resetProactiveHealing();
1119
- autoStartTime = Date.now();
1120
- resourceVersionOnStart = readResourceVersion();
1121
- completedUnits = [];
1122
- pendingQuickTasks = [];
1123
- currentUnit = null;
1124
- currentMilestoneId = state.activeMilestone?.id ?? null;
1125
- originalModelId = ctx.model?.id ?? null;
1126
- originalModelProvider = ctx.model?.provider ?? null;
935
+ s.autoStartTime = Date.now();
936
+ s.resourceVersionOnStart = readResourceVersion();
937
+ s.completedUnits = [];
938
+ s.pendingQuickTasks = [];
939
+ s.currentUnit = null;
940
+ s.currentMilestoneId = state.activeMilestone?.id ?? null;
941
+ s.originalModelId = ctx.model?.id ?? null;
942
+ s.originalModelProvider = ctx.model?.provider ?? null;
1127
943
 
1128
944
  // Register a SIGTERM handler so `kill <pid>` cleans up the lock and exits.
1129
945
  registerSigtermHandler(base);
@@ -1132,18 +948,18 @@ export async function startAuto(
1132
948
  // auto-mode started. Slice branches will merge back to this branch instead
1133
949
  // of the repo's default (main/master). Idempotent when the branch is the
1134
950
  // same; updates the record when started from a different branch (#300).
1135
- if (currentMilestoneId) {
951
+ if (s.currentMilestoneId) {
1136
952
  if (getIsolationMode() !== "none") {
1137
- captureIntegrationBranch(base, currentMilestoneId, { commitDocs });
953
+ captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs });
1138
954
  }
1139
- setActiveMilestoneId(base, currentMilestoneId);
955
+ setActiveMilestoneId(base, s.currentMilestoneId);
1140
956
  }
1141
957
 
1142
958
  // ── Auto-worktree: create or enter worktree for the active milestone ──
1143
959
  // Store the original project root before any chdir so we can restore on stop.
1144
960
  // Skip if already inside a worktree (manual /worktree or another auto-worktree)
1145
961
  // to prevent nested worktree creation.
1146
- originalBasePath = base;
962
+ s.originalBasePath = base;
1147
963
 
1148
964
  const isUnderGsdWorktrees = (p: string): boolean => {
1149
965
  // Prevent creating nested auto-worktrees when running from within any
@@ -1156,30 +972,30 @@ export async function startAuto(
1156
972
  return p.endsWith(worktreesSuffix);
1157
973
  };
1158
974
 
1159
- if (currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
975
+ if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
1160
976
  try {
1161
- const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
977
+ const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId);
1162
978
  if (existingWtPath) {
1163
979
  // Worktree already exists (e.g., previous session created it) — enter it.
1164
- const wtPath = enterAutoWorktree(base, currentMilestoneId);
1165
- basePath = wtPath;
1166
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
980
+ const wtPath = enterAutoWorktree(base, s.currentMilestoneId);
981
+ s.basePath = wtPath;
982
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1167
983
  ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info");
1168
984
  } else {
1169
985
  // Fresh start — create worktree and enter it.
1170
- const wtPath = createAutoWorktree(base, currentMilestoneId);
1171
- basePath = wtPath;
1172
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
986
+ const wtPath = createAutoWorktree(base, s.currentMilestoneId);
987
+ s.basePath = wtPath;
988
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1173
989
  ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
1174
990
  }
1175
- // Re-register SIGTERM handler with the original basePath (lock lives there)
1176
- registerSigtermHandler(originalBasePath);
991
+ // Re-register SIGTERM handler with the original s.basePath (lock lives there)
992
+ registerSigtermHandler(s.originalBasePath);
1177
993
 
1178
994
  // After worktree entry, load completed keys from BOTH locations (project root
1179
995
  // + worktree) so the in-memory set is the union. Prevents re-dispatch of units
1180
996
  // completed in either location after crash/restart (#769).
1181
- if (basePath !== originalBasePath) {
1182
- loadPersistedKeys(basePath, completedKeySet);
997
+ if (s.basePath !== s.originalBasePath) {
998
+ loadPersistedKeys(s.basePath, s.completedKeySet);
1183
999
  }
1184
1000
  } catch (err) {
1185
1001
  // Worktree creation is non-fatal — continue in the project root.
@@ -1191,8 +1007,8 @@ export async function startAuto(
1191
1007
  }
1192
1008
 
1193
1009
  // ── DB lifecycle: auto-migrate or open existing database ──
1194
- const gsdDbPath = join(basePath, ".gsd", "gsd.db");
1195
- const gsdDirPath = join(basePath, ".gsd");
1010
+ const gsdDbPath = join(s.basePath, ".gsd", "gsd.db");
1011
+ const gsdDirPath = join(s.basePath, ".gsd");
1196
1012
  if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) {
1197
1013
  const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md"));
1198
1014
  const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md"));
@@ -1202,7 +1018,7 @@ export async function startAuto(
1202
1018
  const { openDatabase: openDb } = await import("./gsd-db.js");
1203
1019
  const { migrateFromMarkdown } = await import("./md-importer.js");
1204
1020
  openDb(gsdDbPath);
1205
- migrateFromMarkdown(basePath);
1021
+ migrateFromMarkdown(s.basePath);
1206
1022
  } catch (err) {
1207
1023
  process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`);
1208
1024
  }
@@ -1218,18 +1034,18 @@ export async function startAuto(
1218
1034
  }
1219
1035
 
1220
1036
  // Initialize metrics — loads existing ledger from disk.
1221
- // Use basePath (not base) so worktree-mode reads the worktree ledger (#769).
1222
- initMetrics(basePath);
1037
+ // Use s.basePath (not base) so worktree-mode reads the worktree ledger (#769).
1038
+ initMetrics(s.basePath);
1223
1039
 
1224
1040
  // Initialize routing history for adaptive learning
1225
- initRoutingHistory(basePath);
1041
+ initRoutingHistory(s.basePath);
1226
1042
 
1227
1043
  // Capture the session's current model at auto-mode start (#650).
1228
1044
  // This prevents model bleed when multiple GSD instances share the
1229
1045
  // same global settings.json — each instance remembers its own model.
1230
1046
  const currentModel = ctx.model;
1231
1047
  if (currentModel) {
1232
- autoModeStartModel = { provider: currentModel.provider, id: currentModel.id };
1048
+ s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id };
1233
1049
  }
1234
1050
 
1235
1051
  // Snapshot installed skills so we can detect new ones after research
@@ -1237,9 +1053,9 @@ export async function startAuto(
1237
1053
  snapshotSkills();
1238
1054
  }
1239
1055
 
1240
- ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
1056
+ ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
1241
1057
  ctx.ui.setFooter(hideFooter);
1242
- const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
1058
+ const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
1243
1059
  const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
1244
1060
  const scopeMsg = pendingCount > 1
1245
1061
  ? `Will loop through ${pendingCount} milestones.`
@@ -1249,7 +1065,7 @@ export async function startAuto(
1249
1065
  // Write initial lock file immediately so cross-process status detection
1250
1066
  // works even before the first unit is dispatched (#723).
1251
1067
  // The lock is updated with unit-specific info on each dispatch and cleared on stop.
1252
- writeLock(lockBase(), "starting", currentMilestoneId ?? "unknown", 0);
1068
+ writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
1253
1069
 
1254
1070
  // Secrets collection gate — collect pending secrets before first dispatch
1255
1071
  const mid = state.activeMilestone!.id;
@@ -1274,9 +1090,9 @@ export async function startAuto(
1274
1090
  }
1275
1091
 
1276
1092
  // Self-heal: clear stale runtime records where artifacts already exist.
1277
- // Use basePath (not base) — in worktree mode, basePath points to the worktree
1093
+ // Use s.basePath (not base) — in worktree mode, s.basePath points to the worktree
1278
1094
  // where runtime records and artifacts actually live (#769).
1279
- await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
1095
+ await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet);
1280
1096
 
1281
1097
  // Self-heal: remove stale .git/index.lock from prior crash.
1282
1098
  // A stale lock file blocks all git operations (commit, merge, checkout).
@@ -1327,15 +1143,14 @@ export async function startAuto(
1327
1143
  * await). Without this guard, concurrent dispatchNextUnit calls race on
1328
1144
  * newSession(), causing one to cancel the other and silently stopping
1329
1145
  * auto-mode. */
1330
- let _handlingAgentEnd = false;
1331
1146
 
1332
1147
  export async function handleAgentEnd(
1333
1148
  ctx: ExtensionContext,
1334
1149
  pi: ExtensionAPI,
1335
1150
  ): Promise<void> {
1336
- if (!active || !cmdCtx) return;
1337
- if (_handlingAgentEnd) return;
1338
- _handlingAgentEnd = true;
1151
+ if (!s.active || !s.cmdCtx) return;
1152
+ if (s.handlingAgentEnd) return;
1153
+ s.handlingAgentEnd = true;
1339
1154
 
1340
1155
  try {
1341
1156
 
@@ -1347,15 +1162,15 @@ export async function handleAgentEnd(
1347
1162
  // coordinator signals before dispatching the next unit.
1348
1163
  const milestoneLock = process.env.GSD_MILESTONE_LOCK;
1349
1164
  if (milestoneLock) {
1350
- const signal = consumeSignal(basePath, milestoneLock);
1165
+ const signal = consumeSignal(s.basePath, milestoneLock);
1351
1166
  if (signal) {
1352
1167
  if (signal.signal === "stop") {
1353
- _handlingAgentEnd = false;
1168
+ s.handlingAgentEnd = false;
1354
1169
  await stopAuto(ctx, pi);
1355
1170
  return;
1356
1171
  }
1357
1172
  if (signal.signal === "pause") {
1358
- _handlingAgentEnd = false;
1173
+ s.handlingAgentEnd = false;
1359
1174
  await pauseAuto(ctx, pi);
1360
1175
  return;
1361
1176
  }
@@ -1374,15 +1189,15 @@ export async function handleAgentEnd(
1374
1189
  // For execute-task units, build a meaningful commit message from the
1375
1190
  // task summary (one-liner, key_files, inferred type). For other unit
1376
1191
  // types, fall back to the generic chore() message.
1377
- if (currentUnit) {
1192
+ if (s.currentUnit) {
1378
1193
  try {
1379
1194
  let taskContext: TaskCommitContext | undefined;
1380
1195
 
1381
- if (currentUnit.type === "execute-task") {
1382
- const parts = currentUnit.id.split("/");
1196
+ if (s.currentUnit.type === "execute-task") {
1197
+ const parts = s.currentUnit.id.split("/");
1383
1198
  const [mid, sid, tid] = parts;
1384
1199
  if (mid && sid && tid) {
1385
- const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY");
1200
+ const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
1386
1201
  if (summaryPath) {
1387
1202
  try {
1388
1203
  const summaryContent = await loadFile(summaryPath);
@@ -1402,7 +1217,7 @@ export async function handleAgentEnd(
1402
1217
  }
1403
1218
  }
1404
1219
 
1405
- const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id, taskContext);
1220
+ const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext);
1406
1221
  if (commitMsg) {
1407
1222
  ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info");
1408
1223
  }
@@ -1419,10 +1234,10 @@ export async function handleAgentEnd(
1419
1234
  // Exception: after complete-slice itself, use fixLevel:"all" so roadmap
1420
1235
  // checkboxes get fixed even if complete-slice crashed (#839).
1421
1236
  try {
1422
- const scopeParts = currentUnit.id.split("/").slice(0, 2);
1237
+ const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
1423
1238
  const doctorScope = scopeParts.join("/");
1424
- const effectiveFixLevel = currentUnit.type === "complete-slice" ? "all" as const : "task" as const;
1425
- const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
1239
+ const effectiveFixLevel = s.currentUnit.type === "complete-slice" ? "all" as const : "task" as const;
1240
+ const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
1426
1241
  if (report.fixesApplied.length > 0) {
1427
1242
  ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
1428
1243
  }
@@ -1458,29 +1273,46 @@ export async function handleAgentEnd(
1458
1273
  } catch {
1459
1274
  // Non-fatal — doctor failure should never block dispatch
1460
1275
  }
1276
+ // Throttle STATE.md rebuilds to reduce I/O spikes on long sessions.
1277
+ // STATE.md is a derived diagnostic artifact — skipping a rebuild is safe;
1278
+ // the next unit or stop/pause will rebuild it.
1279
+ const now = Date.now();
1280
+ if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) {
1281
+ try {
1282
+ await rebuildState(s.basePath);
1283
+ s.lastStateRebuildAt = now;
1284
+ // State rebuild commit is bookkeeping — generic message is appropriate
1285
+ autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id);
1286
+ } catch {
1287
+ // Non-fatal
1288
+ }
1289
+ }
1290
+
1291
+ // ── Prune dead bg-shell processes ──────────────────────────────────────
1292
+ // Dead processes retain ~500KB-1MB of output buffers each. Without pruning,
1293
+ // they accumulate during long auto-mode sessions causing memory pressure.
1461
1294
  try {
1462
- await rebuildState(basePath);
1463
- // State rebuild commit is bookkeeping — generic message is appropriate
1464
- autoCommitCurrentBranch(basePath, "state-rebuild", currentUnit.id);
1295
+ const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js");
1296
+ pruneDeadProcesses();
1465
1297
  } catch {
1466
- // Non-fatal
1298
+ // Non-fatal — bg-shell may not be available
1467
1299
  }
1468
1300
 
1469
1301
  // ── Sync worktree state back to project root ──────────────────────────
1470
1302
  // Ensures that if auto-mode restarts, deriveState(projectRoot) reads
1471
1303
  // current milestone progress instead of stale pre-worktree state (#654).
1472
- if (originalBasePath && originalBasePath !== basePath) {
1304
+ if (s.originalBasePath && s.originalBasePath !== s.basePath) {
1473
1305
  try {
1474
- syncStateToProjectRoot(basePath, originalBasePath, currentMilestoneId);
1306
+ syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId);
1475
1307
  } catch {
1476
1308
  // Non-fatal — stale state is the existing behavior, sync is an improvement
1477
1309
  }
1478
1310
  }
1479
1311
 
1480
1312
  // ── Rewrite-docs completion: resolve overrides and reset circuit breaker ──
1481
- if (currentUnit.type === "rewrite-docs") {
1313
+ if (s.currentUnit.type === "rewrite-docs") {
1482
1314
  try {
1483
- await resolveAllOverrides(basePath);
1315
+ await resolveAllOverrides(s.basePath);
1484
1316
  resetRewriteCircuitBreaker();
1485
1317
  ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info");
1486
1318
  } catch {
@@ -1492,15 +1324,15 @@ export async function handleAgentEnd(
1492
1324
  // After a triage-captures unit completes, the LLM has classified captures and
1493
1325
  // updated CAPTURES.md. Now we execute those classifications: inject tasks into
1494
1326
  // the plan, write replan triggers, and queue quick-tasks for dispatch.
1495
- if (currentUnit.type === "triage-captures") {
1327
+ if (s.currentUnit.type === "triage-captures") {
1496
1328
  try {
1497
1329
  const { executeTriageResolutions } = await import("./triage-resolution.js");
1498
- const state = await deriveState(basePath);
1330
+ const state = await deriveState(s.basePath);
1499
1331
  const mid = state.activeMilestone?.id;
1500
1332
  const sid = state.activeSlice?.id;
1501
1333
 
1502
1334
  if (mid && sid) {
1503
- const triageResult = executeTriageResolutions(basePath, mid, sid);
1335
+ const triageResult = executeTriageResolutions(s.basePath, mid, sid);
1504
1336
 
1505
1337
  if (triageResult.injected > 0) {
1506
1338
  ctx.ui.notify(
@@ -1518,7 +1350,7 @@ export async function handleAgentEnd(
1518
1350
  // Queue quick-tasks for dispatch. They'll be picked up by the
1519
1351
  // quick-task dispatch block below the triage check.
1520
1352
  for (const qt of triageResult.quickTasks) {
1521
- pendingQuickTasks.push(qt);
1353
+ s.pendingQuickTasks.push(qt);
1522
1354
  }
1523
1355
  ctx.ui.notify(
1524
1356
  `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`,
@@ -1539,20 +1371,20 @@ export async function handleAgentEnd(
1539
1371
  // After doctor + rebuildState, check whether the just-completed unit actually
1540
1372
  // produced its expected artifact. If so, persist the completion key now so the
1541
1373
  // idempotency check at the top of dispatchNextUnit() skips it — even if
1542
- // deriveState() still returns this unit as active (e.g. branch mismatch).
1374
+ // deriveState() still returns this unit as s.active (e.g. branch mismatch).
1543
1375
  //
1544
1376
  // IMPORTANT: For non-hook units, defer persistence until after the hook check.
1545
1377
  // If a post-unit hook requests a retry, we need to remove the completion key
1546
1378
  // so dispatchNextUnit re-dispatches the trigger unit.
1547
1379
  let triggerArtifactVerified = false;
1548
- if (!currentUnit.type.startsWith("hook/")) {
1380
+ if (!s.currentUnit.type.startsWith("hook/")) {
1549
1381
  try {
1550
- triggerArtifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
1382
+ triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
1551
1383
  if (triggerArtifactVerified) {
1552
- const completionKey = `${currentUnit.type}/${currentUnit.id}`;
1553
- if (!completedKeySet.has(completionKey)) {
1554
- persistCompletedKey(basePath, completionKey);
1555
- completedKeySet.add(completionKey);
1384
+ const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1385
+ if (!s.completedKeySet.has(completionKey)) {
1386
+ persistCompletedKey(s.basePath, completionKey);
1387
+ s.completedKeySet.add(completionKey);
1556
1388
  }
1557
1389
  invalidateAllCaches();
1558
1390
  }
@@ -1562,50 +1394,177 @@ export async function handleAgentEnd(
1562
1394
  } else {
1563
1395
  // Hook unit completed — finalize its runtime record and clear it
1564
1396
  try {
1565
- writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
1397
+ writeUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, {
1566
1398
  phase: "finalized",
1567
1399
  progressCount: 1,
1568
1400
  lastProgressKind: "hook-completed",
1569
1401
  });
1570
- clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1402
+ clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
1571
1403
  } catch {
1572
1404
  // Non-fatal
1573
1405
  }
1574
1406
  }
1575
1407
  }
1576
1408
 
1409
+ // ── Verification gate: run typecheck/lint/test after execute-task ──
1410
+ if (s.currentUnit && s.currentUnit.type === "execute-task") {
1411
+ try {
1412
+ const effectivePrefs = loadEffectiveGSDPreferences();
1413
+ const prefs = effectivePrefs?.preferences;
1414
+
1415
+ // Read task plan verify field from the current task's slice plan
1416
+ // unitId format is "M001/S01/T03" — extract mid, sid, tid
1417
+ const parts = s.currentUnit.id.split("/");
1418
+ let taskPlanVerify: string | undefined;
1419
+ if (parts.length >= 3) {
1420
+ const [mid, sid, tid] = parts;
1421
+ const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
1422
+ if (planFile) {
1423
+ const planContent = await loadFile(planFile);
1424
+ if (planContent) {
1425
+ const slicePlan = parsePlan(planContent);
1426
+ const taskEntry = slicePlan?.tasks?.find(t => t.id === tid);
1427
+ taskPlanVerify = taskEntry?.verify;
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ const result = runVerificationGate({ basePath: s.basePath,
1433
+ unitId: s.currentUnit.id,
1434
+ cwd: s.basePath,
1435
+ preferenceCommands: prefs?.verification_commands,
1436
+ taskPlanVerify,
1437
+ });
1438
+
1439
+ // Capture runtime errors from bg-shell and browser console
1440
+ const runtimeErrors = await captureRuntimeErrors();
1441
+ if (runtimeErrors.length > 0) {
1442
+ result.runtimeErrors = runtimeErrors;
1443
+ // Blocking runtime errors override gate pass
1444
+ if (runtimeErrors.some(e => e.blocking)) {
1445
+ result.passed = false;
1446
+ }
1447
+ }
1448
+
1449
+ // Conditional dependency audit (R008)
1450
+ const auditWarnings = runDependencyAudit(s.basePath);
1451
+ if (auditWarnings.length > 0) {
1452
+ result.auditWarnings = auditWarnings;
1453
+ process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`);
1454
+ for (const w of auditWarnings) {
1455
+ process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`);
1456
+ }
1457
+ }
1458
+
1459
+ // Auto-fix retry preferences (R005 / D005)
1460
+ const autoFixEnabled = prefs?.verification_auto_fix !== false; // default true
1461
+ const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2;
1462
+ const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1463
+
1464
+ if (result.checks.length > 0) {
1465
+ const passCount = result.checks.filter(c => c.exitCode === 0).length;
1466
+ const total = result.checks.length;
1467
+ if (result.passed) {
1468
+ ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`);
1469
+ } else {
1470
+ const failures = result.checks.filter(c => c.exitCode !== 0);
1471
+ const failNames = failures.map(f => f.command).join(", ");
1472
+ ctx.ui.notify(`Verification gate: FAILED — ${failNames}`);
1473
+ process.stderr.write(`verification-gate: ${total - passCount}/${total} checks failed\n`);
1474
+ for (const f of failures) {
1475
+ process.stderr.write(` ${f.command} exited ${f.exitCode}\n`);
1476
+ if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`);
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ // Log blocking runtime errors to stderr
1482
+ if (result.runtimeErrors?.some(e => e.blocking)) {
1483
+ const blockingErrors = result.runtimeErrors.filter(e => e.blocking);
1484
+ process.stderr.write(`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`);
1485
+ for (const err of blockingErrors) {
1486
+ process.stderr.write(` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`);
1487
+ }
1488
+ }
1489
+
1490
+ // Write verification evidence JSON artifact
1491
+ const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
1492
+ if (parts.length >= 3) {
1493
+ try {
1494
+ const [mid, sid, tid] = parts;
1495
+ const sDir = resolveSlicePath(s.basePath, mid, sid);
1496
+ if (sDir) {
1497
+ const tasksDir = join(sDir, "tasks");
1498
+ if (result.passed) {
1499
+ writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id);
1500
+ } else {
1501
+ const nextAttempt = attempt + 1;
1502
+ writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries);
1503
+ }
1504
+ }
1505
+ } catch (evidenceErr) {
1506
+ process.stderr.write(`verification-evidence: write error — ${(evidenceErr as Error).message}\n`);
1507
+ }
1508
+ }
1509
+
1510
+ // ── Auto-fix retry logic ──
1511
+ if (result.passed) {
1512
+ // Gate passed — clear retry state and continue normal flow
1513
+ s.verificationRetryCount.delete(s.currentUnit.id);
1514
+ s.pendingVerificationRetry = null;
1515
+ } else if (autoFixEnabled && attempt + 1 <= maxRetries) {
1516
+ // Gate failed, retries remaining — set up retry and return early
1517
+ const nextAttempt = attempt + 1;
1518
+ s.verificationRetryCount.set(s.currentUnit.id, nextAttempt);
1519
+ s.pendingVerificationRetry = {
1520
+ unitId: s.currentUnit.id,
1521
+ failureContext: formatFailureContext(result),
1522
+ attempt: nextAttempt,
1523
+ };
1524
+ ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning");
1525
+ // Remove completion key so dispatchNextUnit re-dispatches this unit
1526
+ s.completedKeySet.delete(completionKey);
1527
+ removePersistedKey(s.basePath, completionKey);
1528
+ return; // ← Critical: exit before DB dual-write and post-unit hooks
1529
+ } else {
1530
+ // Gate failed, retries exhausted (or auto-fix disabled) — pause for human review
1531
+ const exhaustedAttempt = attempt + 1;
1532
+ s.verificationRetryCount.delete(s.currentUnit.id);
1533
+ s.pendingVerificationRetry = null;
1534
+ ctx.ui.notify(
1535
+ `Verification gate FAILED after ${exhaustedAttempt > maxRetries ? exhaustedAttempt - 1 : exhaustedAttempt} retries — pausing for human review`,
1536
+ "error",
1537
+ );
1538
+ await pauseAuto(ctx, pi);
1539
+ return;
1540
+ }
1541
+ } catch (err) {
1542
+ // Gate errors are non-fatal — log and continue
1543
+ process.stderr.write(`verification-gate: error — ${(err as Error).message}\n`);
1544
+ }
1545
+ }
1546
+
1577
1547
  // ── DB dual-write: re-import changed markdown files so next unit's prompts use fresh data ──
1578
1548
  if (isDbAvailable()) {
1579
1549
  try {
1580
1550
  const { migrateFromMarkdown } = await import("./md-importer.js");
1581
- migrateFromMarkdown(basePath);
1551
+ migrateFromMarkdown(s.basePath);
1582
1552
  } catch (err) {
1583
1553
  process.stderr.write(`gsd-db: re-import failed: ${(err as Error).message}\n`);
1584
1554
  }
1585
1555
  }
1586
1556
 
1587
1557
  // ── Post-unit hooks: check if a configured hook should run before normal dispatch ──
1588
- if (currentUnit && !stepMode) {
1589
- const hookUnit = checkPostUnitHooks(currentUnit.type, currentUnit.id, basePath);
1558
+ if (s.currentUnit && !s.stepMode) {
1559
+ const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath);
1590
1560
  if (hookUnit) {
1591
1561
  // Dispatch the hook unit instead of normal flow
1592
1562
  const hookStartedAt = Date.now();
1593
- if (currentUnit) {
1594
- const modelId = ctx.model?.id ?? "unknown";
1595
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
1596
- const hookActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1597
- if (hookActivityFile) {
1598
- try {
1599
- const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1600
- const llmCallFn = buildMemoryLLMCall(ctx);
1601
- if (llmCallFn) {
1602
- extractMemoriesFromUnit(hookActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1603
- }
1604
- } catch { /* non-fatal */ }
1605
- }
1563
+ if (s.currentUnit) {
1564
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
1606
1565
  }
1607
- currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
1608
- writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
1566
+ s.currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
1567
+ writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
1609
1568
  phase: "dispatched",
1610
1569
  wrapupWarningSent: false,
1611
1570
  timeoutAt: null,
@@ -1614,7 +1573,7 @@ export async function handleAgentEnd(
1614
1573
  lastProgressKind: "dispatch",
1615
1574
  });
1616
1575
 
1617
- const state = await deriveState(basePath);
1576
+ const state = await deriveState(s.basePath);
1618
1577
  updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state);
1619
1578
  const hookState = getActiveHook();
1620
1579
  ctx.ui.notify(
@@ -1635,27 +1594,27 @@ export async function handleAgentEnd(
1635
1594
  }
1636
1595
  }
1637
1596
 
1638
- const result = await cmdCtx!.newSession();
1597
+ const result = await s.cmdCtx!.newSession();
1639
1598
  if (result.cancelled) {
1640
1599
  resetHookState();
1641
1600
  await stopAuto(ctx, pi, "Hook session cancelled");
1642
1601
  return;
1643
1602
  }
1644
1603
  const sessionFile = ctx.sessionManager.getSessionFile();
1645
- writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
1604
+ writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile);
1646
1605
  // Persist hook state so cycle counts survive crashes
1647
- persistHookState(basePath);
1606
+ persistHookState(s.basePath);
1648
1607
 
1649
1608
  // Start supervision timers for hook units — hooks can get stuck just
1650
1609
  // like normal units, and without a watchdog auto-mode would hang forever.
1651
1610
  clearUnitTimeout();
1652
1611
  const supervisor = resolveAutoSupervisorConfig();
1653
1612
  const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
1654
- unitTimeoutHandle = setTimeout(async () => {
1655
- unitTimeoutHandle = null;
1656
- if (!active) return;
1657
- if (currentUnit) {
1658
- writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, currentUnit.startedAt, {
1613
+ s.unitTimeoutHandle = setTimeout(async () => {
1614
+ s.unitTimeoutHandle = null;
1615
+ if (!s.active) return;
1616
+ if (s.currentUnit) {
1617
+ writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, s.currentUnit.startedAt, {
1659
1618
  phase: "timeout",
1660
1619
  timeoutAt: Date.now(),
1661
1620
  });
@@ -1669,9 +1628,9 @@ export async function handleAgentEnd(
1669
1628
  }, hookHardTimeoutMs);
1670
1629
 
1671
1630
  // Guard against race with timeout/pause before sending
1672
- if (!active) return;
1631
+ if (!s.active) return;
1673
1632
  pi.sendMessage(
1674
- { customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
1633
+ { customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose },
1675
1634
  { triggerTurn: true },
1676
1635
  );
1677
1636
  return; // handleAgentEnd will fire again when hook session completes
@@ -1684,8 +1643,8 @@ export async function handleAgentEnd(
1684
1643
  // Remove the trigger unit's completion key so dispatchNextUnit
1685
1644
  // will re-dispatch it instead of skipping it as already-complete.
1686
1645
  const triggerKey = `${trigger.unitType}/${trigger.unitId}`;
1687
- completedKeySet.delete(triggerKey);
1688
- removePersistedKey(basePath, triggerKey);
1646
+ s.completedKeySet.delete(triggerKey);
1647
+ removePersistedKey(s.basePath, triggerKey);
1689
1648
  ctx.ui.notify(
1690
1649
  `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
1691
1650
  "info",
@@ -1702,17 +1661,17 @@ export async function handleAgentEnd(
1702
1661
  // Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage),
1703
1662
  // hook units (hooks run before triage conceptually).
1704
1663
  if (
1705
- !stepMode &&
1706
- currentUnit &&
1707
- !currentUnit.type.startsWith("hook/") &&
1708
- currentUnit.type !== "triage-captures" &&
1709
- currentUnit.type !== "quick-task"
1664
+ !s.stepMode &&
1665
+ s.currentUnit &&
1666
+ !s.currentUnit.type.startsWith("hook/") &&
1667
+ s.currentUnit.type !== "triage-captures" &&
1668
+ s.currentUnit.type !== "quick-task"
1710
1669
  ) {
1711
1670
  try {
1712
- if (hasPendingCaptures(basePath)) {
1713
- const pending = loadPendingCaptures(basePath);
1671
+ if (hasPendingCaptures(s.basePath)) {
1672
+ const pending = loadPendingCaptures(s.basePath);
1714
1673
  if (pending.length > 0) {
1715
- const state = await deriveState(basePath);
1674
+ const state = await deriveState(s.basePath);
1716
1675
  const mid = state.activeMilestone?.id;
1717
1676
  const sid = state.activeSlice?.id;
1718
1677
 
@@ -1720,9 +1679,9 @@ export async function handleAgentEnd(
1720
1679
  // Build triage prompt with current context
1721
1680
  let currentPlan = "";
1722
1681
  let roadmapContext = "";
1723
- const planFile = resolveSliceFile(basePath, mid, sid, "PLAN");
1682
+ const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
1724
1683
  if (planFile) currentPlan = (await loadFile(planFile)) ?? "";
1725
- const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
1684
+ const roadmapFile = resolveMilestoneFile(s.basePath, mid, "ROADMAP");
1726
1685
  if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? "";
1727
1686
 
1728
1687
  const capturesList = pending.map(c =>
@@ -1741,27 +1700,16 @@ export async function handleAgentEnd(
1741
1700
  );
1742
1701
 
1743
1702
  // Close out previous unit metrics
1744
- if (currentUnit) {
1745
- const modelId = ctx.model?.id ?? "unknown";
1746
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1747
- const triageActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1748
- if (triageActivityFile) {
1749
- try {
1750
- const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1751
- const llmCallFn = buildMemoryLLMCall(ctx);
1752
- if (llmCallFn) {
1753
- extractMemoriesFromUnit(triageActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1754
- }
1755
- } catch { /* non-fatal */ }
1756
- }
1703
+ if (s.currentUnit) {
1704
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
1757
1705
  }
1758
1706
 
1759
1707
  // Dispatch triage as a new unit (early-dispatch-and-return)
1760
1708
  const triageUnitType = "triage-captures";
1761
1709
  const triageUnitId = `${mid}/${sid}/triage`;
1762
1710
  const triageStartedAt = Date.now();
1763
- currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
1764
- writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, {
1711
+ s.currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
1712
+ writeUnitRuntimeRecord(s.basePath, triageUnitType, triageUnitId, triageStartedAt, {
1765
1713
  phase: "dispatched",
1766
1714
  wrapupWarningSent: false,
1767
1715
  timeoutAt: null,
@@ -1771,21 +1719,21 @@ export async function handleAgentEnd(
1771
1719
  });
1772
1720
  updateProgressWidget(ctx, triageUnitType, triageUnitId, state);
1773
1721
 
1774
- const result = await cmdCtx!.newSession();
1722
+ const result = await s.cmdCtx!.newSession();
1775
1723
  if (result.cancelled) {
1776
1724
  await stopAuto(ctx, pi);
1777
1725
  return;
1778
1726
  }
1779
1727
  const sessionFile = ctx.sessionManager.getSessionFile();
1780
- writeLock(lockBase(), triageUnitType, triageUnitId, completedUnits.length, sessionFile);
1728
+ writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile);
1781
1729
 
1782
1730
  // Start unit timeout for triage (use same supervisor config as hooks)
1783
1731
  clearUnitTimeout();
1784
1732
  const supervisor = resolveAutoSupervisorConfig();
1785
1733
  const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
1786
- unitTimeoutHandle = setTimeout(async () => {
1787
- unitTimeoutHandle = null;
1788
- if (!active) return;
1734
+ s.unitTimeoutHandle = setTimeout(async () => {
1735
+ s.unitTimeoutHandle = null;
1736
+ if (!s.active) return;
1789
1737
  ctx.ui.notify(
1790
1738
  `Triage unit exceeded timeout. Pausing auto-mode.`,
1791
1739
  "warning",
@@ -1793,9 +1741,9 @@ export async function handleAgentEnd(
1793
1741
  await pauseAuto(ctx, pi);
1794
1742
  }, triageTimeoutMs);
1795
1743
 
1796
- if (!active) return;
1744
+ if (!s.active) return;
1797
1745
  pi.sendMessage(
1798
- { customType: "gsd-auto", content: prompt, display: verbose },
1746
+ { customType: "gsd-auto", content: prompt, display: s.verbose },
1799
1747
  { triggerTurn: true },
1800
1748
  );
1801
1749
  return; // handleAgentEnd will fire again when triage session completes
@@ -1811,13 +1759,13 @@ export async function handleAgentEnd(
1811
1759
  // Quick-tasks are self-contained one-off tasks that don't modify the plan.
1812
1760
  // They're queued during post-triage resolution and dispatched here one at a time.
1813
1761
  if (
1814
- !stepMode &&
1815
- pendingQuickTasks.length > 0 &&
1816
- currentUnit &&
1817
- currentUnit.type !== "quick-task"
1762
+ !s.stepMode &&
1763
+ s.pendingQuickTasks.length > 0 &&
1764
+ s.currentUnit &&
1765
+ s.currentUnit.type !== "quick-task"
1818
1766
  ) {
1819
1767
  try {
1820
- const capture = pendingQuickTasks.shift()!;
1768
+ const capture = s.pendingQuickTasks.shift()!;
1821
1769
  const { buildQuickTaskPrompt } = await import("./triage-resolution.js");
1822
1770
  const { markCaptureExecuted } = await import("./captures.js");
1823
1771
  const prompt = buildQuickTaskPrompt(capture);
@@ -1828,27 +1776,16 @@ export async function handleAgentEnd(
1828
1776
  );
1829
1777
 
1830
1778
  // Close out previous unit metrics
1831
- if (currentUnit) {
1832
- const modelId = ctx.model?.id ?? "unknown";
1833
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1834
- const qtActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1835
- if (qtActivityFile) {
1836
- try {
1837
- const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1838
- const llmCallFn = buildMemoryLLMCall(ctx);
1839
- if (llmCallFn) {
1840
- extractMemoriesFromUnit(qtActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1841
- }
1842
- } catch { /* non-fatal */ }
1843
- }
1779
+ if (s.currentUnit) {
1780
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
1844
1781
  }
1845
1782
 
1846
1783
  // Dispatch quick-task as a new unit
1847
1784
  const qtUnitType = "quick-task";
1848
- const qtUnitId = `${currentMilestoneId}/${capture.id}`;
1785
+ const qtUnitId = `${ s.currentMilestoneId }/${capture.id}`;
1849
1786
  const qtStartedAt = Date.now();
1850
- currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt };
1851
- writeUnitRuntimeRecord(basePath, qtUnitType, qtUnitId, qtStartedAt, {
1787
+ s.currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt };
1788
+ writeUnitRuntimeRecord(s.basePath, qtUnitType, qtUnitId, qtStartedAt, {
1852
1789
  phase: "dispatched",
1853
1790
  wrapupWarningSent: false,
1854
1791
  timeoutAt: null,
@@ -1856,27 +1793,27 @@ export async function handleAgentEnd(
1856
1793
  progressCount: 0,
1857
1794
  lastProgressKind: "dispatch",
1858
1795
  });
1859
- const state = await deriveState(basePath);
1796
+ const state = await deriveState(s.basePath);
1860
1797
  updateProgressWidget(ctx, qtUnitType, qtUnitId, state);
1861
1798
 
1862
- const result = await cmdCtx!.newSession();
1799
+ const result = await s.cmdCtx!.newSession();
1863
1800
  if (result.cancelled) {
1864
1801
  await stopAuto(ctx, pi);
1865
1802
  return;
1866
1803
  }
1867
1804
  const sessionFile = ctx.sessionManager.getSessionFile();
1868
- writeLock(lockBase(), qtUnitType, qtUnitId, completedUnits.length, sessionFile);
1805
+ writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile);
1869
1806
 
1870
1807
  // Mark capture as executed now that the unit is dispatched
1871
- markCaptureExecuted(basePath, capture.id);
1808
+ markCaptureExecuted(s.basePath, capture.id);
1872
1809
 
1873
1810
  // Start unit timeout for quick-task
1874
1811
  clearUnitTimeout();
1875
1812
  const supervisor = resolveAutoSupervisorConfig();
1876
1813
  const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
1877
- unitTimeoutHandle = setTimeout(async () => {
1878
- unitTimeoutHandle = null;
1879
- if (!active) return;
1814
+ s.unitTimeoutHandle = setTimeout(async () => {
1815
+ s.unitTimeoutHandle = null;
1816
+ if (!s.active) return;
1880
1817
  ctx.ui.notify(
1881
1818
  `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`,
1882
1819
  "warning",
@@ -1884,9 +1821,9 @@ export async function handleAgentEnd(
1884
1821
  await pauseAuto(ctx, pi);
1885
1822
  }, qtTimeoutMs);
1886
1823
 
1887
- if (!active) return;
1824
+ if (!s.active) return;
1888
1825
  pi.sendMessage(
1889
- { customType: "gsd-auto", content: prompt, display: verbose },
1826
+ { customType: "gsd-auto", content: prompt, display: s.verbose },
1890
1827
  { triggerTurn: true },
1891
1828
  );
1892
1829
  return; // handleAgentEnd will fire again when quick-task session completes
@@ -1896,7 +1833,7 @@ export async function handleAgentEnd(
1896
1833
  }
1897
1834
 
1898
1835
  // In step mode, pause and show a wizard instead of immediately dispatching
1899
- if (stepMode) {
1836
+ if (s.stepMode) {
1900
1837
  await showStepWizard(ctx, pi);
1901
1838
  return;
1902
1839
  }
@@ -1906,7 +1843,7 @@ export async function handleAgentEnd(
1906
1843
  } catch (dispatchErr) {
1907
1844
  // dispatchNextUnit threw — without this catch the error would propagate
1908
1845
  // to the pi event emitter which may silently swallow async rejections,
1909
- // leaving auto-mode active but permanently stalled (see #381).
1846
+ // leaving auto-mode s.active but permanently stalled (see #381).
1910
1847
  const message = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
1911
1848
  ctx.ui.notify(
1912
1849
  `Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`,
@@ -1919,15 +1856,15 @@ export async function handleAgentEnd(
1919
1856
  return;
1920
1857
  }
1921
1858
 
1922
- // If dispatchNextUnit returned normally but auto-mode is still active and
1859
+ // If dispatchNextUnit returned normally but auto-mode is still s.active and
1923
1860
  // no new unit timeout was set (meaning sendMessage was never called), start
1924
1861
  // the dispatch gap watchdog as a safety net.
1925
- if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
1862
+ if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) {
1926
1863
  startDispatchGapWatchdog(ctx, pi);
1927
1864
  }
1928
1865
 
1929
1866
  } finally {
1930
- _handlingAgentEnd = false;
1867
+ s.handlingAgentEnd = false;
1931
1868
  }
1932
1869
  }
1933
1870
 
@@ -1942,14 +1879,14 @@ async function showStepWizard(
1942
1879
  ctx: ExtensionContext,
1943
1880
  pi: ExtensionAPI,
1944
1881
  ): Promise<void> {
1945
- if (!cmdCtx) return;
1882
+ if (!s.cmdCtx) return;
1946
1883
 
1947
- const state = await deriveState(basePath);
1884
+ const state = await deriveState(s.basePath);
1948
1885
  const mid = state.activeMilestone?.id;
1949
1886
 
1950
1887
  // Build summary of what just completed
1951
- const justFinished = currentUnit
1952
- ? `${unitVerb(currentUnit.type)} ${currentUnit.id}`
1888
+ const justFinished = s.currentUnit
1889
+ ? `${unitVerb(s.currentUnit.type)} ${s.currentUnit.id}`
1953
1890
  : "previous unit";
1954
1891
 
1955
1892
  // If no active milestone or everything is complete, stop
@@ -1957,7 +1894,7 @@ async function showStepWizard(
1957
1894
  const incomplete = state.registry.filter(m => m.status !== "complete");
1958
1895
  if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") {
1959
1896
  const ids = incomplete.map(m => m.id).join(", ");
1960
- const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1897
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1961
1898
  ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
1962
1899
  await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`);
1963
1900
  } else {
@@ -1969,7 +1906,7 @@ async function showStepWizard(
1969
1906
  // Peek at what's next by examining state
1970
1907
  const nextDesc = _describeNextUnit(state);
1971
1908
 
1972
- const choice = await showNextAction(cmdCtx, {
1909
+ const choice = await showNextAction(s.cmdCtx, {
1973
1910
  title: `GSD — ${justFinished} complete`,
1974
1911
  summary: [
1975
1912
  `${mid}: ${state.activeMilestone?.title ?? mid}`,
@@ -1999,7 +1936,7 @@ async function showStepWizard(
1999
1936
  if (choice === "continue") {
2000
1937
  await dispatchNextUnit(ctx, pi);
2001
1938
  } else if (choice === "auto") {
2002
- stepMode = false;
1939
+ s.stepMode = false;
2003
1940
  ctx.ui.setStatus("gsd-auto", "auto");
2004
1941
  ctx.ui.notify("Switched to auto-mode.", "info");
2005
1942
  await dispatchNextUnit(ctx, pi);
@@ -2024,58 +1961,57 @@ function updateProgressWidget(
2024
1961
  unitId: string,
2025
1962
  state: GSDState,
2026
1963
  ): void {
2027
- const badge = currentUnitRouting?.tier
2028
- ? ({ light: "L", standard: "S", heavy: "H" }[currentUnitRouting.tier] ?? undefined)
1964
+ const badge = s.currentUnitRouting?.tier
1965
+ ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? undefined)
2029
1966
  : undefined;
2030
1967
  _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge);
2031
1968
  }
2032
1969
 
2033
1970
  /** State accessors for the widget — closures over module globals. */
2034
1971
  const widgetStateAccessors: WidgetStateAccessors = {
2035
- getAutoStartTime: () => autoStartTime,
2036
- isStepMode: () => stepMode,
2037
- getCmdCtx: () => cmdCtx,
2038
- getBasePath: () => basePath,
2039
- isVerbose: () => verbose,
1972
+ getAutoStartTime: () => s.autoStartTime,
1973
+ isStepMode: () => s.stepMode,
1974
+ getCmdCtx: () => s.cmdCtx,
1975
+ getBasePath: () => s.basePath,
1976
+ isVerbose: () => s.verbose,
2040
1977
  };
2041
1978
 
2042
1979
  // ─── Core Loop ────────────────────────────────────────────────────────────────
2043
1980
 
2044
1981
  /** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */
2045
- let _skipDepth = 0;
2046
- const MAX_SKIP_DEPTH = 20;
2047
1982
 
2048
1983
  /** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd).
2049
1984
  * Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls
2050
- * that bypass the _handlingAgentEnd guard. Recursive calls (from skip paths) are
2051
- * allowed via _skipDepth > 0. */
2052
- let _dispatching = false;
1985
+ * that bypass the s.handlingAgentEnd guard. Recursive calls (from skip paths) are
1986
+ * allowed via s.skipDepth > 0. */
1987
+
1988
+ /** Keys recently evicted by skip-loop breaker — prevents re-persistence in the fallback path (#912). */
2053
1989
 
2054
1990
  async function dispatchNextUnit(
2055
1991
  ctx: ExtensionContext,
2056
1992
  pi: ExtensionAPI,
2057
1993
  ): Promise<void> {
2058
- if (!active || !cmdCtx) {
2059
- debugLog(`dispatchNextUnit early return — active=${active}, cmdCtx=${!!cmdCtx}`);
2060
- if (active && !cmdCtx) {
1994
+ if (!s.active || !s.cmdCtx) {
1995
+ debugLog(`dispatchNextUnit early return — active=${s.active}, cmdCtx=${!!s.cmdCtx}`);
1996
+ if (s.active && !s.cmdCtx) {
2061
1997
  ctx.ui.notify("Auto-mode session expired. Run /gsd auto to restart.", "info");
2062
1998
  }
2063
1999
  return;
2064
2000
  }
2065
2001
 
2066
- // Reentrancy guard: allow recursive calls from skip paths (_skipDepth > 0)
2002
+ // Reentrancy guard: allow recursive calls from skip paths (s.skipDepth > 0)
2067
2003
  // but block concurrent external calls (watchdog, step wizard, etc.)
2068
- if (_dispatching && _skipDepth === 0) {
2004
+ if (s.dispatching && s.skipDepth === 0) {
2069
2005
  debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing");
2070
2006
  return; // Another dispatch is in progress — bail silently
2071
2007
  }
2072
- _dispatching = true;
2008
+ s.dispatching = true;
2073
2009
  try {
2074
2010
  // Recursion depth guard: when many units are skipped in sequence (e.g., after
2075
2011
  // crash recovery with 10+ completed units), recursive dispatchNextUnit calls
2076
2012
  // can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH.
2077
- if (_skipDepth > MAX_SKIP_DEPTH) {
2078
- _skipDepth = 0;
2013
+ if (s.skipDepth > MAX_SKIP_DEPTH) {
2014
+ s.skipDepth = 0;
2079
2015
  ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info");
2080
2016
  await new Promise(r => setTimeout(r, 200));
2081
2017
  }
@@ -2085,7 +2021,7 @@ async function dispatchNextUnit(
2085
2021
  // once at startup. If resources were re-synced (e.g. /gsd:update, npm update,
2086
2022
  // or dev copy-resources), templates may expect variables the in-memory code
2087
2023
  // doesn't provide. Stop gracefully instead of crashing.
2088
- const staleMsg = checkResourcesStale();
2024
+ const staleMsg = checkResourcesStale(s.resourceVersionOnStart);
2089
2025
  if (staleMsg) {
2090
2026
  await stopAuto(ctx, pi, staleMsg);
2091
2027
  return;
@@ -2095,14 +2031,14 @@ async function dispatchNextUnit(
2095
2031
  // Parse cache is also cleared — doctor may have re-populated it with
2096
2032
  // stale data between handleAgentEnd and this dispatch call (Path B fix).
2097
2033
  invalidateAllCaches();
2098
- lastPromptCharCount = undefined;
2099
- lastBaselineCharCount = undefined;
2034
+ s.lastPromptCharCount = undefined;
2035
+ s.lastBaselineCharCount = undefined;
2100
2036
 
2101
2037
  // ── Pre-dispatch health gate ──────────────────────────────────────────
2102
2038
  // Lightweight check for critical issues that would cause the next unit
2103
2039
  // to fail or corrupt state. Auto-heals what it can, blocks on the rest.
2104
2040
  try {
2105
- const healthGate = await preDispatchHealthGate(basePath);
2041
+ const healthGate = await preDispatchHealthGate(s.basePath);
2106
2042
  if (healthGate.fixesApplied.length > 0) {
2107
2043
  ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
2108
2044
  }
@@ -2119,12 +2055,12 @@ async function dispatchNextUnit(
2119
2055
  // When the LLM writes artifacts to the main repo filesystem instead of
2120
2056
  // the worktree, the worktree's gsd.db becomes stale. Sync before
2121
2057
  // deriveState to ensure the worktree has the latest artifacts.
2122
- if (originalBasePath && basePath !== originalBasePath && currentMilestoneId) {
2123
- syncProjectRootToWorktree(originalBasePath, basePath, currentMilestoneId);
2058
+ if (s.originalBasePath && s.basePath !== s.originalBasePath && s.currentMilestoneId) {
2059
+ syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
2124
2060
  }
2125
2061
 
2126
2062
  const stopDeriveTimer = debugTime("derive-state");
2127
- let state = await deriveState(basePath);
2063
+ let state = await deriveState(s.basePath);
2128
2064
  stopDeriveTimer({
2129
2065
  phase: state.phase,
2130
2066
  milestone: state.activeMilestone?.id,
@@ -2135,27 +2071,75 @@ async function dispatchNextUnit(
2135
2071
  let midTitle = state.activeMilestone?.title;
2136
2072
 
2137
2073
  // Detect milestone transition
2138
- if (mid && currentMilestoneId && mid !== currentMilestoneId) {
2074
+ if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
2139
2075
  ctx.ui.notify(
2140
- `Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
2076
+ `Milestone ${ s.currentMilestoneId } complete. Advancing to ${mid}: ${midTitle}.`,
2141
2077
  "info",
2142
2078
  );
2143
- sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
2079
+ sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
2144
2080
  // Hint: visualizer available after milestone transition
2145
2081
  const vizPrefs = loadEffectiveGSDPreferences()?.preferences;
2146
2082
  if (vizPrefs?.auto_visualize) {
2147
2083
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
2148
2084
  }
2085
+ // Auto-generate HTML report snapshot on milestone completion (default: on, disable with auto_report: false)
2086
+ if (vizPrefs?.auto_report !== false) {
2087
+ try {
2088
+ const { loadVisualizerData } = await import("./visualizer-data.js");
2089
+ const { generateHtmlReport } = await import("./export-html.js");
2090
+ const { writeReportSnapshot, reportsDir } = await import("./reports.js");
2091
+ const { basename } = await import("node:path");
2092
+ const snapData = await loadVisualizerData(s.basePath);
2093
+ const completedMs = snapData.milestones.find(m => m.id === s.currentMilestoneId);
2094
+ const msTitle = completedMs?.title ?? s.currentMilestoneId;
2095
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
2096
+ const projName = basename(s.basePath);
2097
+ const doneSlices = snapData.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
2098
+ const totalSlices = snapData.milestones.reduce((s, m) => s + m.slices.length, 0);
2099
+ const outPath = writeReportSnapshot({ basePath: s.basePath,
2100
+ html: generateHtmlReport(snapData, {
2101
+ projectName: projName,
2102
+ projectPath: s.basePath,
2103
+ gsdVersion,
2104
+ milestoneId: s.currentMilestoneId,
2105
+ indexRelPath: "index.html",
2106
+ }),
2107
+ milestoneId: s.currentMilestoneId,
2108
+ milestoneTitle: msTitle,
2109
+ kind: "milestone",
2110
+ projectName: projName,
2111
+ projectPath: s.basePath,
2112
+ gsdVersion,
2113
+ totalCost: snapData.totals?.cost ?? 0,
2114
+ totalTokens: snapData.totals?.tokens.total ?? 0,
2115
+ totalDuration: snapData.totals?.duration ?? 0,
2116
+ doneSlices,
2117
+ totalSlices,
2118
+ doneMilestones: snapData.milestones.filter(m => m.status === "complete").length,
2119
+ totalMilestones: snapData.milestones.length,
2120
+ phase: snapData.phase,
2121
+ });
2122
+ ctx.ui.notify(
2123
+ `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
2124
+ "info",
2125
+ );
2126
+ } catch (err) {
2127
+ ctx.ui.notify(
2128
+ `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
2129
+ "warning",
2130
+ );
2131
+ }
2132
+ }
2149
2133
  // Reset stuck detection for new milestone
2150
- unitDispatchCount.clear();
2151
- unitRecoveryCount.clear();
2152
- unitConsecutiveSkips.clear();
2153
- unitLifetimeDispatches.clear();
2134
+ s.unitDispatchCount.clear();
2135
+ s.unitRecoveryCount.clear();
2136
+ s.unitConsecutiveSkips.clear();
2137
+ s.unitLifetimeDispatches.clear();
2154
2138
  // Clear completed-units.json for the finished milestone
2155
2139
  try {
2156
- const file = completedKeysPath(basePath);
2140
+ const file = completedKeysPath(s.basePath);
2157
2141
  if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
2158
- completedKeySet.clear();
2142
+ s.completedKeySet.clear();
2159
2143
  } catch { /* non-fatal */ }
2160
2144
 
2161
2145
  // ── Worktree lifecycle on milestone transition (#616) ──────────────
@@ -2165,20 +2149,20 @@ async function dispatchNextUnit(
2165
2149
  // 3. Create a new worktree for the incoming milestone
2166
2150
  // Without this, M_new runs inside M_old's worktree on the wrong branch,
2167
2151
  // and artifact paths resolve against the wrong .gsd/ directory.
2168
- if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) {
2152
+ if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) {
2169
2153
  try {
2170
- const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
2154
+ const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
2171
2155
  if (roadmapPath) {
2172
2156
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
2173
- const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
2157
+ const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
2174
2158
  ctx.ui.notify(
2175
- `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
2159
+ `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
2176
2160
  "info",
2177
2161
  );
2178
2162
  } else {
2179
2163
  // No roadmap found — teardown worktree without merge
2180
- teardownAutoWorktree(originalBasePath, currentMilestoneId);
2181
- ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info");
2164
+ teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId);
2165
+ ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info");
2182
2166
  }
2183
2167
  } catch (err) {
2184
2168
  ctx.ui.notify(
@@ -2186,28 +2170,28 @@ async function dispatchNextUnit(
2186
2170
  "warning",
2187
2171
  );
2188
2172
  // Force cwd back to project root even if merge failed
2189
- if (originalBasePath) {
2190
- try { process.chdir(originalBasePath); } catch { /* best-effort */ }
2173
+ if (s.originalBasePath) {
2174
+ try { process.chdir(s.originalBasePath); } catch { /* best-effort */ }
2191
2175
  }
2192
2176
  }
2193
2177
 
2194
- // Update basePath to project root (mergeMilestoneToMain already chdir'd)
2195
- basePath = originalBasePath;
2196
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2178
+ // Update s.basePath to project root (mergeMilestoneToMain already chdir'd)
2179
+ s.basePath = s.originalBasePath;
2180
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2197
2181
  invalidateAllCaches();
2198
2182
 
2199
2183
  // Re-derive state from project root before creating new worktree
2200
- state = await deriveState(basePath);
2184
+ state = await deriveState(s.basePath);
2201
2185
  mid = state.activeMilestone?.id;
2202
2186
  midTitle = state.activeMilestone?.title;
2203
2187
 
2204
2188
  // Create new worktree for the incoming milestone
2205
2189
  if (mid) {
2206
- captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
2190
+ captureIntegrationBranch(s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
2207
2191
  try {
2208
- const wtPath = createAutoWorktree(basePath, mid);
2209
- basePath = wtPath;
2210
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2192
+ const wtPath = createAutoWorktree(s.basePath, mid);
2193
+ s.basePath = wtPath;
2194
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2211
2195
  ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
2212
2196
  } catch (err) {
2213
2197
  ctx.ui.notify(
@@ -2220,7 +2204,7 @@ async function dispatchNextUnit(
2220
2204
  // Not in worktree — capture integration branch for the new milestone (branch mode only).
2221
2205
  // In none mode there's no milestone branch to merge back to, so skip.
2222
2206
  if (getIsolationMode() !== "none") {
2223
- captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
2207
+ captureIntegrationBranch(s.originalBasePath || s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
2224
2208
  }
2225
2209
  }
2226
2210
 
@@ -2228,19 +2212,17 @@ async function dispatchNextUnit(
2228
2212
  const pendingIds = state.registry
2229
2213
  .filter(m => m.status !== "complete")
2230
2214
  .map(m => m.id);
2231
- pruneQueueOrder(basePath, pendingIds);
2215
+ pruneQueueOrder(s.basePath, pendingIds);
2232
2216
  }
2233
2217
  if (mid) {
2234
- currentMilestoneId = mid;
2235
- setActiveMilestoneId(basePath, mid);
2218
+ s.currentMilestoneId = mid;
2219
+ setActiveMilestoneId(s.basePath, mid);
2236
2220
  }
2237
2221
 
2238
2222
  if (!mid) {
2239
2223
  // Save final session before stopping
2240
- if (currentUnit) {
2241
- const modelId = ctx.model?.id ?? "unknown";
2242
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2243
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2224
+ if (s.currentUnit) {
2225
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2244
2226
  }
2245
2227
 
2246
2228
  const incomplete = state.registry.filter(m => m.status !== "complete");
@@ -2255,9 +2237,9 @@ async function dispatchNextUnit(
2255
2237
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
2256
2238
  sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2257
2239
  } else {
2258
- // Milestones with remaining work exist but none became active — unexpected
2240
+ // Milestones with remaining work exist but none became s.active — unexpected
2259
2241
  const ids = incomplete.map(m => m.id).join(", ");
2260
- const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
2242
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
2261
2243
  ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
2262
2244
  await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
2263
2245
  }
@@ -2273,19 +2255,17 @@ async function dispatchNextUnit(
2273
2255
  }
2274
2256
 
2275
2257
  // ── Mid-merge safety check: detect leftover merge state from a prior session ──
2276
- if (reconcileMergeState(basePath, ctx)) {
2258
+ if (reconcileMergeState(s.basePath, ctx)) {
2277
2259
  invalidateAllCaches();
2278
- state = await deriveState(basePath);
2260
+ state = await deriveState(s.basePath);
2279
2261
  mid = state.activeMilestone?.id;
2280
2262
  midTitle = state.activeMilestone?.title;
2281
2263
  }
2282
2264
 
2283
2265
  // After merge guard removal (branchless architecture), mid/midTitle could be undefined
2284
2266
  if (!mid || !midTitle) {
2285
- if (currentUnit) {
2286
- const modelId = ctx.model?.id ?? "unknown";
2287
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2288
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2267
+ if (s.currentUnit) {
2268
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2289
2269
  }
2290
2270
  const noMilestoneReason = !mid
2291
2271
  ? "No active milestone after merge reconciliation"
@@ -2300,28 +2280,26 @@ async function dispatchNextUnit(
2300
2280
  let prompt: string;
2301
2281
 
2302
2282
  if (state.phase === "complete") {
2303
- if (currentUnit) {
2304
- const modelId = ctx.model?.id ?? "unknown";
2305
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2306
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2283
+ if (s.currentUnit) {
2284
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2307
2285
  }
2308
2286
  // Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
2309
2287
  try {
2310
- const file = completedKeysPath(basePath);
2288
+ const file = completedKeysPath(s.basePath);
2311
2289
  if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
2312
- completedKeySet.clear();
2290
+ s.completedKeySet.clear();
2313
2291
  } catch { /* non-fatal */ }
2314
2292
  // ── Milestone merge: squash-merge milestone branch to main before stopping ──
2315
- if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
2293
+ if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
2316
2294
  try {
2317
- const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
2318
- if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${currentMilestoneId}`);
2295
+ const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
2296
+ if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
2319
2297
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
2320
- const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
2321
- basePath = originalBasePath;
2322
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2298
+ const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
2299
+ s.basePath = s.originalBasePath;
2300
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2323
2301
  ctx.ui.notify(
2324
- `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
2302
+ `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
2325
2303
  "info",
2326
2304
  );
2327
2305
  } catch (err) {
@@ -2332,27 +2310,27 @@ async function dispatchNextUnit(
2332
2310
  // Ensure cwd is restored even if merge failed partway through (#608).
2333
2311
  // mergeMilestoneToMain may have chdir'd but then thrown, leaving us
2334
2312
  // in an indeterminate location.
2335
- if (originalBasePath) {
2336
- basePath = originalBasePath;
2337
- try { process.chdir(basePath); } catch { /* best-effort */ }
2313
+ if (s.originalBasePath) {
2314
+ s.basePath = s.originalBasePath;
2315
+ try { process.chdir(s.basePath); } catch { /* best-effort */ }
2338
2316
  }
2339
2317
  }
2340
- } else if (currentMilestoneId && !isInAutoWorktree(basePath) && getIsolationMode() !== "none") {
2318
+ } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") {
2341
2319
  // Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch.
2342
2320
  // Squash-merge back to the integration branch (or main) before stopping.
2343
2321
  try {
2344
- const currentBranch = getCurrentBranch(basePath);
2345
- const milestoneBranch = autoWorktreeBranch(currentMilestoneId);
2322
+ const currentBranch = getCurrentBranch(s.basePath);
2323
+ const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
2346
2324
  if (currentBranch === milestoneBranch) {
2347
- const roadmapPath = resolveMilestoneFile(basePath, currentMilestoneId, "ROADMAP");
2325
+ const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
2348
2326
  if (roadmapPath) {
2349
2327
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
2350
2328
  // mergeMilestoneToMain handles: auto-commit, checkout integration branch,
2351
2329
  // squash merge, commit, optional push, branch deletion.
2352
- const mergeResult = mergeMilestoneToMain(basePath, currentMilestoneId, roadmapContent);
2353
- gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2330
+ const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
2331
+ s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
2354
2332
  ctx.ui.notify(
2355
- `Milestone ${currentMilestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
2333
+ `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
2356
2334
  "info",
2357
2335
  );
2358
2336
  }
@@ -2370,10 +2348,8 @@ async function dispatchNextUnit(
2370
2348
  }
2371
2349
 
2372
2350
  if (state.phase === "blocked") {
2373
- if (currentUnit) {
2374
- const modelId = ctx.model?.id ?? "unknown";
2375
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2376
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2351
+ if (s.currentUnit) {
2352
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2377
2353
  }
2378
2354
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2379
2355
  await stopAuto(ctx, pi, blockerMsg);
@@ -2393,14 +2369,14 @@ async function dispatchNextUnit(
2393
2369
  const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
2394
2370
  const budgetPct = totalCost / budgetCeiling;
2395
2371
  const budgetAlertLevel = getBudgetAlertLevel(budgetPct);
2396
- const newBudgetAlertLevel = getNewBudgetAlertLevel(lastBudgetAlertLevel, budgetPct);
2372
+ const newBudgetAlertLevel = getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
2397
2373
  const enforcement = prefs?.budget_enforcement ?? "pause";
2398
2374
 
2399
2375
  const budgetEnforcementAction = getBudgetEnforcementAction(enforcement, budgetPct);
2400
2376
 
2401
2377
  if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
2402
2378
  const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
2403
- lastBudgetAlertLevel = newBudgetAlertLevel;
2379
+ s.lastBudgetAlertLevel = newBudgetAlertLevel;
2404
2380
  if (budgetEnforcementAction === "halt") {
2405
2381
  sendDesktopNotification("GSD", msg, "error", "budget");
2406
2382
  await stopAuto(ctx, pi, "Budget ceiling reached");
@@ -2415,28 +2391,28 @@ async function dispatchNextUnit(
2415
2391
  ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
2416
2392
  sendDesktopNotification("GSD", msg, "warning", "budget");
2417
2393
  } else if (newBudgetAlertLevel === 90) {
2418
- lastBudgetAlertLevel = newBudgetAlertLevel;
2394
+ s.lastBudgetAlertLevel = newBudgetAlertLevel;
2419
2395
  ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2420
2396
  sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2421
2397
  } else if (newBudgetAlertLevel === 80) {
2422
- lastBudgetAlertLevel = newBudgetAlertLevel;
2398
+ s.lastBudgetAlertLevel = newBudgetAlertLevel;
2423
2399
  ctx.ui.notify(`Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2424
2400
  sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2425
2401
  } else if (newBudgetAlertLevel === 75) {
2426
- lastBudgetAlertLevel = newBudgetAlertLevel;
2402
+ s.lastBudgetAlertLevel = newBudgetAlertLevel;
2427
2403
  ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
2428
2404
  sendDesktopNotification("GSD", `Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info", "budget");
2429
2405
  } else if (budgetAlertLevel === 0) {
2430
- lastBudgetAlertLevel = 0;
2406
+ s.lastBudgetAlertLevel = 0;
2431
2407
  }
2432
2408
  } else {
2433
- lastBudgetAlertLevel = 0;
2409
+ s.lastBudgetAlertLevel = 0;
2434
2410
  }
2435
2411
 
2436
2412
  // Context window guard — pause if approaching context limits
2437
2413
  const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default
2438
- if (contextThreshold > 0 && cmdCtx) {
2439
- const contextUsage = cmdCtx.getContextUsage();
2414
+ if (contextThreshold > 0 && s.cmdCtx) {
2415
+ const contextUsage = s.cmdCtx.getContextUsage();
2440
2416
  if (contextUsage && contextUsage.percent !== null && contextUsage.percent >= contextThreshold) {
2441
2417
  const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
2442
2418
  ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
@@ -2453,9 +2429,9 @@ async function dispatchNextUnit(
2453
2429
  // into plan-slice / execute-task with no real credentials and mock everything.
2454
2430
  const runSecretsGate = async () => {
2455
2431
  try {
2456
- const manifestStatus = await getManifestStatus(basePath, mid);
2432
+ const manifestStatus = await getManifestStatus(s.basePath, mid);
2457
2433
  if (manifestStatus && manifestStatus.pending.length > 0) {
2458
- const result = await collectSecretsFromManifest(basePath, mid, ctx);
2434
+ const result = await collectSecretsFromManifest(s.basePath, mid, ctx);
2459
2435
  if (result && result.applied && result.skipped && result.existingSkipped) {
2460
2436
  ctx.ui.notify(
2461
2437
  `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
@@ -2476,15 +2452,12 @@ async function dispatchNextUnit(
2476
2452
  await runSecretsGate();
2477
2453
 
2478
2454
  // ── Dispatch table: resolve phase → unit type + prompt ──
2479
- const dispatchResult = await resolveDispatch({
2480
- basePath, mid, midTitle: midTitle!, state, prefs,
2455
+ const dispatchResult = await resolveDispatch({ basePath: s.basePath, mid, midTitle: midTitle!, state, prefs,
2481
2456
  });
2482
2457
 
2483
2458
  if (dispatchResult.action === "stop") {
2484
- if (currentUnit) {
2485
- const modelId = ctx.model?.id ?? "unknown";
2486
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2487
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2459
+ if (s.currentUnit) {
2460
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2488
2461
  }
2489
2462
  await stopAuto(ctx, pi, dispatchResult.reason);
2490
2463
  return;
@@ -2503,7 +2476,7 @@ async function dispatchNextUnit(
2503
2476
  let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
2504
2477
 
2505
2478
  // ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ──
2506
- const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath);
2479
+ const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
2507
2480
  if (preDispatchResult.firedHooks.length > 0) {
2508
2481
  ctx.ui.notify(
2509
2482
  `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
@@ -2524,66 +2497,67 @@ async function dispatchNextUnit(
2524
2497
  prompt = preDispatchResult.prompt;
2525
2498
  }
2526
2499
 
2527
- const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
2500
+ const priorSliceBlocker = getPriorSliceCompletionBlocker(s.basePath, getMainBranch(s.basePath), unitType, unitId);
2528
2501
  if (priorSliceBlocker) {
2529
2502
  await stopAuto(ctx, pi, priorSliceBlocker);
2530
2503
  return;
2531
2504
  }
2532
2505
 
2533
- const observabilityIssues = await collectObservabilityWarnings(ctx, unitType, unitId);
2506
+ const observabilityIssues = await _collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
2534
2507
 
2535
2508
  // Idempotency: skip units already completed in a prior session.
2536
2509
  const idempotencyKey = `${unitType}/${unitId}`;
2537
- if (completedKeySet.has(idempotencyKey)) {
2510
+ if (s.completedKeySet.has(idempotencyKey)) {
2538
2511
  // Cross-validate: does the expected artifact actually exist?
2539
- const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
2512
+ const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath);
2540
2513
  if (artifactExists) {
2541
2514
  // Guard against infinite skip loops: if deriveState keeps returning the
2542
2515
  // same completed unit, consecutive skips will trip this breaker. Evict the
2543
2516
  // key so the next dispatch forces full reconciliation instead of looping.
2544
- const skipCount = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2545
- unitConsecutiveSkips.set(idempotencyKey, skipCount);
2517
+ const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2518
+ s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
2546
2519
  if (skipCount > MAX_CONSECUTIVE_SKIPS) {
2547
2520
  // Cross-check: verify deriveState actually returns this unit (#790).
2548
2521
  // If the unit's milestone is already complete, this is a phantom skip
2549
2522
  // loop from stale crash recovery context — don't evict.
2550
2523
  const skippedMid = unitId.split("/")[0];
2551
2524
  const skippedMilestoneComplete = skippedMid
2552
- ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
2525
+ ? !!resolveMilestoneFile(s.basePath, skippedMid, "SUMMARY")
2553
2526
  : false;
2554
2527
  if (skippedMilestoneComplete) {
2555
2528
  // Milestone is complete — evicting this key would fight self-heal.
2556
2529
  // Clear skip counter and re-dispatch from fresh state.
2557
- unitConsecutiveSkips.delete(idempotencyKey);
2530
+ s.unitConsecutiveSkips.delete(idempotencyKey);
2558
2531
  invalidateAllCaches();
2559
2532
  ctx.ui.notify(
2560
2533
  `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`,
2561
2534
  "info",
2562
2535
  );
2563
- _skipDepth++;
2536
+ s.skipDepth++;
2564
2537
  await new Promise(r => setTimeout(r, 50));
2565
2538
  await dispatchNextUnit(ctx, pi);
2566
- _skipDepth = Math.max(0, _skipDepth - 1);
2539
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
2567
2540
  return;
2568
2541
  }
2569
- unitConsecutiveSkips.delete(idempotencyKey);
2570
- completedKeySet.delete(idempotencyKey);
2571
- removePersistedKey(basePath, idempotencyKey);
2542
+ s.unitConsecutiveSkips.delete(idempotencyKey);
2543
+ s.completedKeySet.delete(idempotencyKey);
2544
+ s.recentlyEvictedKeys.add(idempotencyKey);
2545
+ removePersistedKey(s.basePath, idempotencyKey);
2572
2546
  invalidateAllCaches();
2573
2547
  ctx.ui.notify(
2574
2548
  `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`,
2575
2549
  "warning",
2576
2550
  );
2577
- if (!active) return;
2578
- _skipDepth++;
2551
+ if (!s.active) return;
2552
+ s.skipDepth++;
2579
2553
  await new Promise(r => setTimeout(r, 150));
2580
2554
  await dispatchNextUnit(ctx, pi);
2581
- _skipDepth = Math.max(0, _skipDepth - 1);
2555
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
2582
2556
  return;
2583
2557
  }
2584
2558
  // Count toward lifetime cap so hard-stop fires during skip loops (#792)
2585
- const lifeSkip = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2586
- unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
2559
+ const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2560
+ s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
2587
2561
  if (lifeSkip > MAX_LIFETIME_DISPATCHES) {
2588
2562
  await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
2589
2563
  ctx.ui.notify(
@@ -2596,16 +2570,16 @@ async function dispatchNextUnit(
2596
2570
  `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
2597
2571
  "info",
2598
2572
  );
2599
- if (!active) return;
2600
- _skipDepth++;
2573
+ if (!s.active) return;
2574
+ s.skipDepth++;
2601
2575
  await new Promise(r => setTimeout(r, 150));
2602
2576
  await dispatchNextUnit(ctx, pi);
2603
- _skipDepth = Math.max(0, _skipDepth - 1);
2577
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
2604
2578
  return;
2605
2579
  } else {
2606
2580
  // Stale completion record — artifact missing. Remove and re-run.
2607
- completedKeySet.delete(idempotencyKey);
2608
- removePersistedKey(basePath, idempotencyKey);
2581
+ s.completedKeySet.delete(idempotencyKey);
2582
+ removePersistedKey(s.basePath, idempotencyKey);
2609
2583
  ctx.ui.notify(
2610
2584
  `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
2611
2585
  "warning",
@@ -2616,52 +2590,54 @@ async function dispatchNextUnit(
2616
2590
  // Fallback: if the idempotency key is missing but the expected artifact already
2617
2591
  // exists on disk, the task completed in a prior session without persisting the key.
2618
2592
  // Persist it now and skip re-dispatch. This prevents infinite loops where a task
2619
- // completes successfully but the completion key was never written (e.g., completed
2620
- // on the first attempt before hitting the retry-threshold persistence logic).
2621
- if (verifyExpectedArtifact(unitType, unitId, basePath)) {
2622
- persistCompletedKey(basePath, idempotencyKey);
2623
- completedKeySet.add(idempotencyKey);
2593
+ // completes successfully but the completion key was never written.
2594
+ //
2595
+ // EXCEPTION: if the key was just evicted by the skip-loop breaker above, do NOT
2596
+ // re-persist — that would recreate the exact loop the breaker was trying to break (#912).
2597
+ if (verifyExpectedArtifact(unitType, unitId, s.basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) {
2598
+ persistCompletedKey(s.basePath, idempotencyKey);
2599
+ s.completedKeySet.add(idempotencyKey);
2624
2600
  invalidateAllCaches();
2625
2601
  // Same consecutive-skip guard as the idempotency path above.
2626
- const skipCount2 = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2627
- unitConsecutiveSkips.set(idempotencyKey, skipCount2);
2602
+ const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2603
+ s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
2628
2604
  if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
2629
2605
  // Cross-check: verify the unit's milestone is still active (#790).
2630
2606
  const skippedMid2 = unitId.split("/")[0];
2631
2607
  const skippedMilestoneComplete2 = skippedMid2
2632
- ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
2608
+ ? !!resolveMilestoneFile(s.basePath, skippedMid2, "SUMMARY")
2633
2609
  : false;
2634
2610
  if (skippedMilestoneComplete2) {
2635
- unitConsecutiveSkips.delete(idempotencyKey);
2611
+ s.unitConsecutiveSkips.delete(idempotencyKey);
2636
2612
  invalidateAllCaches();
2637
2613
  ctx.ui.notify(
2638
2614
  `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`,
2639
2615
  "info",
2640
2616
  );
2641
- _skipDepth++;
2617
+ s.skipDepth++;
2642
2618
  await new Promise(r => setTimeout(r, 50));
2643
2619
  await dispatchNextUnit(ctx, pi);
2644
- _skipDepth = Math.max(0, _skipDepth - 1);
2620
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
2645
2621
  return;
2646
2622
  }
2647
- unitConsecutiveSkips.delete(idempotencyKey);
2648
- completedKeySet.delete(idempotencyKey);
2649
- removePersistedKey(basePath, idempotencyKey);
2623
+ s.unitConsecutiveSkips.delete(idempotencyKey);
2624
+ s.completedKeySet.delete(idempotencyKey);
2625
+ removePersistedKey(s.basePath, idempotencyKey);
2650
2626
  invalidateAllCaches();
2651
2627
  ctx.ui.notify(
2652
2628
  `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`,
2653
2629
  "warning",
2654
2630
  );
2655
- if (!active) return;
2656
- _skipDepth++;
2631
+ if (!s.active) return;
2632
+ s.skipDepth++;
2657
2633
  await new Promise(r => setTimeout(r, 150));
2658
2634
  await dispatchNextUnit(ctx, pi);
2659
- _skipDepth = Math.max(0, _skipDepth - 1);
2635
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
2660
2636
  return;
2661
2637
  }
2662
2638
  // Count toward lifetime cap so hard-stop fires during skip loops (#792)
2663
- const lifeSkip2 = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2664
- unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
2639
+ const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2640
+ s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
2665
2641
  if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) {
2666
2642
  await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
2667
2643
  ctx.ui.notify(
@@ -2674,41 +2650,41 @@ async function dispatchNextUnit(
2674
2650
  `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
2675
2651
  "info",
2676
2652
  );
2677
- if (!active) return;
2678
- _skipDepth++;
2653
+ if (!s.active) return;
2654
+ s.skipDepth++;
2679
2655
  await new Promise(r => setTimeout(r, 150));
2680
2656
  await dispatchNextUnit(ctx, pi);
2681
- _skipDepth = Math.max(0, _skipDepth - 1);
2657
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
2682
2658
  return;
2683
2659
  }
2684
2660
 
2685
2661
  // Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
2686
2662
  // Pattern A→B→A→B would reset retryCount every time; this map catches it.
2687
2663
  const dispatchKey = `${unitType}/${unitId}`;
2688
- const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
2664
+ const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0;
2689
2665
  // Real dispatch reached — clear the consecutive-skip counter for this unit.
2690
- unitConsecutiveSkips.delete(dispatchKey);
2666
+ s.unitConsecutiveSkips.delete(dispatchKey);
2691
2667
 
2692
2668
  debugLog("dispatch-unit", {
2693
2669
  type: unitType,
2694
2670
  id: unitId,
2695
2671
  cycle: prevCount + 1,
2696
- lifetime: (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1,
2672
+ lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1,
2697
2673
  });
2698
2674
  debugCount("dispatches");
2699
2675
 
2700
2676
  // Hard lifetime cap — survives counter resets from loop-recovery/self-repair.
2701
2677
  // Catches the case where reconciliation "succeeds" (artifacts exist) but
2702
2678
  // deriveState keeps returning the same unit, creating an infinite cycle.
2703
- const lifetimeCount = (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
2704
- unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
2679
+ const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
2680
+ s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
2705
2681
  if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
2706
- if (currentUnit) {
2707
- const modelId = ctx.model?.id ?? "unknown";
2708
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2682
+ if (s.currentUnit) {
2683
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2684
+ } else {
2685
+ saveActivityLog(ctx, s.basePath, unitType, unitId);
2709
2686
  }
2710
- saveActivityLog(ctx, basePath, unitType, unitId);
2711
- const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2687
+ const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
2712
2688
  await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`);
2713
2689
  ctx.ui.notify(
2714
2690
  `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.`,
@@ -2717,11 +2693,11 @@ async function dispatchNextUnit(
2717
2693
  return;
2718
2694
  }
2719
2695
  if (prevCount >= MAX_UNIT_DISPATCHES) {
2720
- if (currentUnit) {
2721
- const modelId = ctx.model?.id ?? "unknown";
2722
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2696
+ if (s.currentUnit) {
2697
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2698
+ } else {
2699
+ saveActivityLog(ctx, s.basePath, unitType, unitId);
2723
2700
  }
2724
- saveActivityLog(ctx, basePath, unitType, unitId);
2725
2701
 
2726
2702
  // Final reconciliation pass for execute-task: write any missing durable
2727
2703
  // artifacts (summary placeholder + [x] checkbox) so the pipeline can
@@ -2729,13 +2705,13 @@ async function dispatchNextUnit(
2729
2705
  if (unitType === "execute-task") {
2730
2706
  const [mid, sid, tid] = unitId.split("/");
2731
2707
  if (mid && sid && tid) {
2732
- const status = await inspectExecuteTaskDurability(basePath, unitId);
2708
+ const status = await inspectExecuteTaskDurability(s.basePath, unitId);
2733
2709
  if (status) {
2734
- const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount);
2710
+ const reconciled = skipExecuteTask(s.basePath, mid, sid, tid, status, "loop-recovery", prevCount);
2735
2711
  // reconciled: skipExecuteTask attempted to write missing artifacts.
2736
2712
  // verifyExpectedArtifact: confirms physical artifacts (summary + [x]) now exist on disk.
2737
2713
  // Both must pass before we clear the dispatch counter and advance.
2738
- if (reconciled && verifyExpectedArtifact(unitType, unitId, basePath)) {
2714
+ if (reconciled && verifyExpectedArtifact(unitType, unitId, s.basePath)) {
2739
2715
  ctx.ui.notify(
2740
2716
  `Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`,
2741
2717
  "warning",
@@ -2743,9 +2719,9 @@ async function dispatchNextUnit(
2743
2719
  // Persist completion so idempotency check prevents re-dispatch
2744
2720
  // if deriveState keeps returning this unit (#462).
2745
2721
  const reconciledKey = `${unitType}/${unitId}`;
2746
- persistCompletedKey(basePath, reconciledKey);
2747
- completedKeySet.add(reconciledKey);
2748
- unitDispatchCount.delete(dispatchKey);
2722
+ persistCompletedKey(s.basePath, reconciledKey);
2723
+ s.completedKeySet.add(reconciledKey);
2724
+ s.unitDispatchCount.delete(dispatchKey);
2749
2725
  invalidateAllCaches();
2750
2726
  await new Promise(r => setImmediate(r));
2751
2727
  await dispatchNextUnit(ctx, pi);
@@ -2763,16 +2739,16 @@ async function dispatchNextUnit(
2763
2739
  // dispatch limit succeeded but the counter check fires before anyone
2764
2740
  // verifies disk state. Without this, a successful final attempt is
2765
2741
  // indistinguishable from a failed one.
2766
- if (verifyExpectedArtifact(unitType, unitId, basePath)) {
2742
+ if (verifyExpectedArtifact(unitType, unitId, s.basePath)) {
2767
2743
  ctx.ui.notify(
2768
2744
  `Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`,
2769
2745
  "info",
2770
2746
  );
2771
2747
  // Persist completion so the idempotency check prevents re-dispatch
2772
2748
  // if deriveState keeps returning this unit (see #462).
2773
- persistCompletedKey(basePath, dispatchKey);
2774
- completedKeySet.add(dispatchKey);
2775
- unitDispatchCount.delete(dispatchKey);
2749
+ persistCompletedKey(s.basePath, dispatchKey);
2750
+ s.completedKeySet.add(dispatchKey);
2751
+ s.unitDispatchCount.delete(dispatchKey);
2776
2752
  invalidateAllCaches();
2777
2753
  await new Promise(r => setImmediate(r));
2778
2754
  await dispatchNextUnit(ctx, pi);
@@ -2784,15 +2760,15 @@ async function dispatchNextUnit(
2784
2760
  // but the LLM failed to write the summary N times. A stub lets the pipeline advance.
2785
2761
  if (unitType === "complete-milestone") {
2786
2762
  try {
2787
- const mPath = resolveMilestonePath(basePath, unitId);
2763
+ const mPath = resolveMilestonePath(s.basePath, unitId);
2788
2764
  if (mPath) {
2789
2765
  const stubPath = join(mPath, `${unitId}-SUMMARY.md`);
2790
2766
  if (!existsSync(stubPath)) {
2791
2767
  writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`);
2792
2768
  ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning");
2793
- persistCompletedKey(basePath, dispatchKey);
2794
- completedKeySet.add(dispatchKey);
2795
- unitDispatchCount.delete(dispatchKey);
2769
+ persistCompletedKey(s.basePath, dispatchKey);
2770
+ s.completedKeySet.add(dispatchKey);
2771
+ s.unitDispatchCount.delete(dispatchKey);
2796
2772
  invalidateAllCaches();
2797
2773
  await new Promise(r => setImmediate(r));
2798
2774
  await dispatchNextUnit(ctx, pi);
@@ -2802,8 +2778,8 @@ async function dispatchNextUnit(
2802
2778
  } catch { /* non-fatal — fall through to normal stop */ }
2803
2779
  }
2804
2780
 
2805
- const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2806
- const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
2781
+ const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
2782
+ const remediation = buildLoopRemediationSteps(unitType, unitId, s.basePath);
2807
2783
  await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`);
2808
2784
  sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
2809
2785
  ctx.ui.notify(
@@ -2812,28 +2788,28 @@ async function dispatchNextUnit(
2812
2788
  );
2813
2789
  return;
2814
2790
  }
2815
- unitDispatchCount.set(dispatchKey, prevCount + 1);
2791
+ s.unitDispatchCount.set(dispatchKey, prevCount + 1);
2816
2792
  if (prevCount > 0) {
2817
2793
  // Adaptive self-repair: each retry attempts a different remediation step.
2818
2794
  if (unitType === "execute-task") {
2819
- const status = await inspectExecuteTaskDurability(basePath, unitId);
2795
+ const status = await inspectExecuteTaskDurability(s.basePath, unitId);
2820
2796
  const [mid, sid, tid] = unitId.split("/");
2821
2797
  if (status && mid && sid && tid) {
2822
2798
  if (status.summaryExists && !status.taskChecked) {
2823
2799
  // Retry 1+: summary exists but checkbox not marked — mark [x] and advance.
2824
- const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
2800
+ const repaired = skipExecuteTask(s.basePath, mid, sid, tid, status, "self-repair", 0);
2825
2801
  // repaired: skipExecuteTask updated metadata (returned early-true even if regex missed).
2826
2802
  // verifyExpectedArtifact: confirms the physical artifact (summary + [x]) now exists.
2827
- if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) {
2803
+ if (repaired && verifyExpectedArtifact(unitType, unitId, s.basePath)) {
2828
2804
  ctx.ui.notify(
2829
2805
  `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
2830
2806
  "warning",
2831
2807
  );
2832
2808
  // Persist completion so idempotency check prevents re-dispatch (#462).
2833
2809
  const repairedKey = `${unitType}/${unitId}`;
2834
- persistCompletedKey(basePath, repairedKey);
2835
- completedKeySet.add(repairedKey);
2836
- unitDispatchCount.delete(dispatchKey);
2810
+ persistCompletedKey(s.basePath, repairedKey);
2811
+ s.completedKeySet.add(repairedKey);
2812
+ s.unitDispatchCount.delete(dispatchKey);
2837
2813
  invalidateAllCaches();
2838
2814
  await new Promise(r => setImmediate(r));
2839
2815
  await dispatchNextUnit(ctx, pi);
@@ -2843,8 +2819,8 @@ async function dispatchNextUnit(
2843
2819
  // Retry STUB_RECOVERY_THRESHOLD+: summary still missing after multiple attempts.
2844
2820
  // Write a minimal stub summary so the next agent session has a recovery artifact
2845
2821
  // to overwrite, rather than starting from scratch again.
2846
- const tasksDir = resolveTasksDir(basePath, mid, sid);
2847
- const sDir = resolveSlicePath(basePath, mid, sid);
2822
+ const tasksDir = resolveTasksDir(s.basePath, mid, sid);
2823
+ const sDir = resolveSlicePath(s.basePath, mid, sid);
2848
2824
  const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
2849
2825
  if (targetDir) {
2850
2826
  if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
@@ -2875,28 +2851,15 @@ async function dispatchNextUnit(
2875
2851
  }
2876
2852
  // Snapshot metrics + activity log for the PREVIOUS unit before we reassign.
2877
2853
  // The session still holds the previous unit's data (newSession hasn't fired yet).
2878
- if (currentUnit) {
2879
- const modelId = ctx.model?.id ?? "unknown";
2880
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2881
- const activityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2882
-
2883
- // Fire-and-forget memory extraction from completed unit
2884
- if (activityFile) {
2885
- try {
2886
- const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
2887
- const llmCallFn = buildMemoryLLMCall(ctx);
2888
- if (llmCallFn) {
2889
- extractMemoriesFromUnit(activityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
2890
- }
2891
- } catch { /* non-fatal */ }
2892
- }
2854
+ if (s.currentUnit) {
2855
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
2893
2856
 
2894
2857
  // Record routing outcome for adaptive learning
2895
- if (currentUnitRouting) {
2896
- const isRetry = currentUnit.type === unitType && currentUnit.id === unitId;
2858
+ if (s.currentUnitRouting) {
2859
+ const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
2897
2860
  recordOutcome(
2898
- currentUnit.type,
2899
- currentUnitRouting.tier as "light" | "standard" | "heavy",
2861
+ s.currentUnit.type,
2862
+ s.currentUnitRouting.tier as "light" | "standard" | "heavy",
2900
2863
  !isRetry, // success = not being retried
2901
2864
  );
2902
2865
  }
@@ -2906,55 +2869,55 @@ async function dispatchNextUnit(
2906
2869
  // 2. The expected artifact actually exists on disk
2907
2870
  // For hook units, skip artifact verification — hooks don't produce standard
2908
2871
  // artifacts and their runtime records were already finalized in handleAgentEnd.
2909
- const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
2872
+ const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
2910
2873
  const incomingKey = `${unitType}/${unitId}`;
2911
- const isHookUnit = currentUnit.type.startsWith("hook/");
2912
- const artifactVerified = isHookUnit || verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
2874
+ const isHookUnit = s.currentUnit.type.startsWith("hook/");
2875
+ const artifactVerified = isHookUnit || verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
2913
2876
  if (closeoutKey !== incomingKey && artifactVerified) {
2914
2877
  if (!isHookUnit) {
2915
2878
  // Only persist completion keys for real units — hook keys are
2916
2879
  // ephemeral and should not pollute the idempotency set.
2917
- persistCompletedKey(basePath, closeoutKey);
2918
- completedKeySet.add(closeoutKey);
2880
+ persistCompletedKey(s.basePath, closeoutKey);
2881
+ s.completedKeySet.add(closeoutKey);
2919
2882
  }
2920
2883
 
2921
- completedUnits.push({
2922
- type: currentUnit.type,
2923
- id: currentUnit.id,
2924
- startedAt: currentUnit.startedAt,
2884
+ s.completedUnits.push({
2885
+ type: s.currentUnit.type,
2886
+ id: s.currentUnit.id,
2887
+ startedAt: s.currentUnit.startedAt,
2925
2888
  finishedAt: Date.now(),
2926
2889
  });
2927
2890
  // Cap to last 200 entries to prevent unbounded growth (#611)
2928
- if (completedUnits.length > 200) {
2929
- completedUnits = completedUnits.slice(-200);
2891
+ if (s.completedUnits.length > 200) {
2892
+ s.completedUnits = s.completedUnits.slice(-200);
2930
2893
  }
2931
- clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
2932
- unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
2933
- unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
2894
+ clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
2895
+ s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
2896
+ s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
2934
2897
  }
2935
2898
  }
2936
- currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
2899
+ s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
2937
2900
  captureAvailableSkills(); // Capture skill telemetry at dispatch time (#599)
2938
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2901
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
2939
2902
  phase: "dispatched",
2940
2903
  wrapupWarningSent: false,
2941
2904
  timeoutAt: null,
2942
- lastProgressAt: currentUnit.startedAt,
2905
+ lastProgressAt: s.currentUnit.startedAt,
2943
2906
  progressCount: 0,
2944
2907
  lastProgressKind: "dispatch",
2945
2908
  });
2946
2909
 
2947
2910
  // Status bar + progress widget
2948
2911
  ctx.ui.setStatus("gsd-auto", "auto");
2949
- if (mid) updateSliceProgressCache(basePath, mid, state.activeSlice?.id);
2912
+ if (mid) updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
2950
2913
  updateProgressWidget(ctx, unitType, unitId, state);
2951
2914
 
2952
2915
  // Ensure preconditions — create directories, branches, etc.
2953
2916
  // so the LLM doesn't have to get these right
2954
- ensurePreconditions(unitType, unitId, basePath, state);
2917
+ ensurePreconditions(unitType, unitId, s.basePath, state);
2955
2918
 
2956
2919
  // Fresh session
2957
- const result = await cmdCtx!.newSession();
2920
+ const result = await s.cmdCtx!.newSession();
2958
2921
  if (result.cancelled) {
2959
2922
  await stopAuto(ctx, pi, "Session cancelled");
2960
2923
  return;
@@ -2968,21 +2931,32 @@ async function dispatchNextUnit(
2968
2931
  // Pi appends entries incrementally via appendFileSync, so on crash the
2969
2932
  // session file survives with every tool call up to the crash point.
2970
2933
  const sessionFile = ctx.sessionManager.getSessionFile();
2971
- writeLock(lockBase(), unitType, unitId, completedUnits.length, sessionFile);
2934
+ writeLock(lockBase(), unitType, unitId, s.completedUnits.length, sessionFile);
2972
2935
 
2973
2936
  // On crash recovery, prepend the full recovery briefing
2974
2937
  // On retry (stuck detection), prepend deep diagnostic from last attempt
2975
2938
  // Cap injected content to prevent unbounded prompt growth → OOM
2976
2939
  const MAX_RECOVERY_CHARS = 50_000;
2977
2940
  let finalPrompt = prompt;
2978
- if (pendingCrashRecovery) {
2979
- const capped = pendingCrashRecovery.length > MAX_RECOVERY_CHARS
2980
- ? pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
2981
- : pendingCrashRecovery;
2941
+
2942
+ // Verification retry inject failure context so the agent can auto-fix
2943
+ if (s.pendingVerificationRetry) {
2944
+ const retryCtx = s.pendingVerificationRetry;
2945
+ s.pendingVerificationRetry = null;
2946
+ const capped = retryCtx.failureContext.length > MAX_RECOVERY_CHARS
2947
+ ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...failure context truncated]"
2948
+ : retryCtx.failureContext;
2949
+ finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`;
2950
+ }
2951
+
2952
+ if (s.pendingCrashRecovery) {
2953
+ const capped = s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
2954
+ ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
2955
+ : s.pendingCrashRecovery;
2982
2956
  finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
2983
- pendingCrashRecovery = null;
2984
- } else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
2985
- const diagnostic = getDeepDiagnostic(basePath);
2957
+ s.pendingCrashRecovery = null;
2958
+ } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
2959
+ const diagnostic = getDeepDiagnostic(s.basePath);
2986
2960
  if (diagnostic) {
2987
2961
  const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS
2988
2962
  ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...diagnostic truncated to prevent memory exhaustion]"
@@ -2999,17 +2973,17 @@ async function dispatchNextUnit(
2999
2973
  }
3000
2974
 
3001
2975
  // ── Prompt char measurement (R051) ──
3002
- lastPromptCharCount = finalPrompt.length;
3003
- lastBaselineCharCount = undefined;
2976
+ s.lastPromptCharCount = finalPrompt.length;
2977
+ s.lastBaselineCharCount = undefined;
3004
2978
  if (isDbAvailable()) {
3005
2979
  try {
3006
2980
  const { inlineGsdRootFile } = await import("./auto-prompts.js");
3007
2981
  const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
3008
- inlineGsdRootFile(basePath, "decisions.md", "Decisions"),
3009
- inlineGsdRootFile(basePath, "requirements.md", "Requirements"),
3010
- inlineGsdRootFile(basePath, "project.md", "Project"),
2982
+ inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
2983
+ inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
2984
+ inlineGsdRootFile(s.basePath, "project.md", "Project"),
3011
2985
  ]);
3012
- lastBaselineCharCount =
2986
+ s.lastBaselineCharCount =
3013
2987
  (decisionsContent?.length ?? 0) +
3014
2988
  (requirementsContent?.length ?? 0) +
3015
2989
  (projectContent?.length ?? 0);
@@ -3018,158 +2992,9 @@ async function dispatchNextUnit(
3018
2992
  }
3019
2993
  }
3020
2994
 
3021
- // Switch model if preferences specify one for this unit type
3022
- // Try primary model, then fallbacks in order if setting fails
3023
- const modelConfig = resolveModelWithFallbacksForUnit(unitType);
3024
- if (modelConfig) {
3025
- const availableModels = ctx.modelRegistry.getAvailable();
3026
-
3027
- // ─── Dynamic Model Routing ─────────────────────────────────────────
3028
- // If enabled, classify unit complexity and potentially downgrade to a
3029
- // cheaper model. The user's configured model is the ceiling.
3030
- const routingConfig = resolveDynamicRoutingConfig();
3031
- let effectiveModelConfig = modelConfig;
3032
- let routingTierLabel = "";
3033
- currentUnitRouting = null;
3034
-
3035
- if (routingConfig.enabled) {
3036
- // Compute budget pressure if budget ceiling is set
3037
- let budgetPct: number | undefined;
3038
- if (routingConfig.budget_pressure !== false) {
3039
- const budgetCeiling = prefs?.budget_ceiling;
3040
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
3041
- const currentLedger = getLedger();
3042
- const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
3043
- budgetPct = totalCost / budgetCeiling;
3044
- }
3045
- }
3046
-
3047
- // Classify complexity (hook routing controlled by config.hooks)
3048
- const isHook = unitType.startsWith("hook/");
3049
- const shouldClassify = !isHook || routingConfig.hooks !== false;
3050
-
3051
- if (shouldClassify) {
3052
- const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
3053
- const availableModelIds = availableModels.map(m => m.id);
3054
- const routing = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
3055
-
3056
- if (routing.wasDowngraded) {
3057
- effectiveModelConfig = {
3058
- primary: routing.modelId,
3059
- fallbacks: routing.fallbacks,
3060
- };
3061
- if (verbose) {
3062
- ctx.ui.notify(
3063
- `Dynamic routing [${tierLabel(classification.tier)}]: ${routing.modelId} (${classification.reason})`,
3064
- "info",
3065
- );
3066
- }
3067
- }
3068
- routingTierLabel = ` [${tierLabel(classification.tier)}]`;
3069
- currentUnitRouting = { tier: classification.tier, modelDowngraded: routing.wasDowngraded };
3070
- }
3071
- }
3072
-
3073
- const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks];
3074
- let modelSet = false;
3075
-
3076
- for (const modelId of modelsToTry) {
3077
- // Resolve model from available models.
3078
- // Handles multiple formats:
3079
- // "provider/model" → explicit provider targeting (e.g. "anthropic/claude-opus-4-6")
3080
- // "bare-id" → match by ID across providers
3081
- // "org/model-name" → OpenRouter-style IDs where the full string is the model ID
3082
- // "openrouter/org/model" → explicit provider + OpenRouter model ID
3083
- const slashIdx = modelId.indexOf("/");
3084
- let model;
3085
- if (slashIdx !== -1) {
3086
- const maybeProvider = modelId.substring(0, slashIdx);
3087
- const id = modelId.substring(slashIdx + 1);
3088
-
3089
- // Check if the prefix before the first slash is a known provider
3090
- const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
3091
- if (knownProviders.has(maybeProvider.toLowerCase())) {
3092
- // Explicit "provider/model" format (handles "openrouter/org/model" too)
3093
- model = availableModels.find(
3094
- m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
3095
- && m.id.toLowerCase() === id.toLowerCase(),
3096
- );
3097
- }
3098
-
3099
- // If the prefix wasn't a known provider, or no match was found within that provider,
3100
- // try matching the full string as a model ID (OpenRouter-style IDs like "org/model-name")
3101
- if (!model) {
3102
- const lower = modelId.toLowerCase();
3103
- model = availableModels.find(
3104
- m => m.id.toLowerCase() === lower
3105
- || `${m.provider}/${m.id}`.toLowerCase() === lower,
3106
- );
3107
- }
3108
- } else {
3109
- // For bare IDs, prefer the current session's provider, then first available match
3110
- const currentProvider = ctx.model?.provider;
3111
- const exactProviderMatch = availableModels.find(
3112
- m => m.id === modelId && m.provider === currentProvider,
3113
- );
3114
- const anyMatch = availableModels.find(m => m.id === modelId);
3115
- model = exactProviderMatch ?? anyMatch;
3116
-
3117
- // Warn if the ID is ambiguous across providers
3118
- if (anyMatch && !exactProviderMatch) {
3119
- const providers = availableModels
3120
- .filter(m => m.id === modelId)
3121
- .map(m => m.provider);
3122
- if (providers.length > 1) {
3123
- ctx.ui.notify(
3124
- `Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` +
3125
- `Resolved to ${anyMatch.provider}. Use "provider/model" format for explicit targeting.`,
3126
- "warning",
3127
- );
3128
- }
3129
- }
3130
- }
3131
- if (!model) {
3132
- if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info");
3133
- continue;
3134
- }
3135
-
3136
- const ok = await pi.setModel(model, { persist: false });
3137
- if (ok) {
3138
- const fallbackNote = modelId === effectiveModelConfig.primary
3139
- ? ""
3140
- : ` (fallback from ${effectiveModelConfig.primary})`;
3141
- const phase = unitPhaseLabel(unitType);
3142
- ctx.ui.notify(`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`, "info");
3143
- modelSet = true;
3144
- break;
3145
- } else {
3146
- const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1];
3147
- if (nextModel) {
3148
- if (verbose) ctx.ui.notify(`Failed to set model ${modelId}, trying ${nextModel}...`, "info");
3149
- } else {
3150
- ctx.ui.notify(`All preferred models unavailable for ${unitType}. Using default.`, "warning");
3151
- }
3152
- }
3153
- }
3154
-
3155
- // modelSet=false is already handled by the "all fallbacks exhausted" warning above
3156
- } else if (autoModeStartModel) {
3157
- // No model preference for this unit type — re-apply the model captured
3158
- // at auto-mode start to prevent bleed from the shared global settings.json
3159
- // when multiple GSD instances run concurrently (#650).
3160
- const availableModels = ctx.modelRegistry.getAvailable();
3161
- const startModel = availableModels.find(
3162
- m => m.provider === autoModeStartModel!.provider && m.id === autoModeStartModel!.id,
3163
- );
3164
- if (startModel) {
3165
- const ok = await pi.setModel(startModel, { persist: false });
3166
- if (!ok) {
3167
- // Fallback: try matching just by ID across providers
3168
- const byId = availableModels.find(m => m.id === autoModeStartModel!.id);
3169
- if (byId) await pi.setModel(byId, { persist: false });
3170
- }
3171
- }
3172
- }
2995
+ // Select and apply model for this unit (dynamic routing, fallback chains, etc.)
2996
+ const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel);
2997
+ s.currentUnitRouting = modelResult.routing;
3173
2998
 
3174
2999
  // Start progress-aware supervision: a soft warning, an idle watchdog, and
3175
3000
  // a larger hard ceiling. Productive long-running tasks may continue past the
@@ -3180,17 +3005,17 @@ async function dispatchNextUnit(
3180
3005
  const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000;
3181
3006
  const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000;
3182
3007
 
3183
- wrapupWarningHandle = setTimeout(() => {
3184
- wrapupWarningHandle = null;
3185
- if (!active || !currentUnit) return;
3186
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3008
+ s.wrapupWarningHandle = setTimeout(() => {
3009
+ s.wrapupWarningHandle = null;
3010
+ if (!s.active || !s.currentUnit) return;
3011
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
3187
3012
  phase: "wrapup-warning-sent",
3188
3013
  wrapupWarningSent: true,
3189
3014
  });
3190
3015
  pi.sendMessage(
3191
3016
  {
3192
3017
  customType: "gsd-auto-wrapup",
3193
- display: verbose,
3018
+ display: s.verbose,
3194
3019
  content: [
3195
3020
  "**TIME BUDGET WARNING — keep going only if progress is real.**",
3196
3021
  "This unit crossed the soft time budget.",
@@ -3205,9 +3030,10 @@ async function dispatchNextUnit(
3205
3030
  );
3206
3031
  }, softTimeoutMs);
3207
3032
 
3208
- idleWatchdogHandle = setInterval(async () => {
3209
- if (!active || !currentUnit) return;
3210
- const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
3033
+ s.idleWatchdogHandle = setInterval(async () => {
3034
+ try {
3035
+ if (!s.active || !s.currentUnit) return;
3036
+ const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId);
3211
3037
  if (!runtime) return;
3212
3038
  if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
3213
3039
 
@@ -3216,11 +3042,11 @@ async function dispatchNextUnit(
3216
3042
  // if the tool started recently. A tool in-flight for longer than the idle
3217
3043
  // timeout is likely stuck — e.g., `python -m http.server 8080 &` keeps the
3218
3044
  // shell's stdout/stderr open, causing the Bash tool to hang indefinitely.
3219
- if (inFlightTools.size > 0) {
3220
- const oldestStart = Math.min(...inFlightTools.values());
3045
+ if (getInFlightToolCount() > 0) {
3046
+ const oldestStart = getOldestInFlightToolStart()!;
3221
3047
  const toolAgeMs = Date.now() - oldestStart;
3222
3048
  if (toolAgeMs < idleTimeoutMs) {
3223
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3049
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
3224
3050
  lastProgressAt: Date.now(),
3225
3051
  lastProgressKind: "tool-in-flight",
3226
3052
  });
@@ -3238,24 +3064,24 @@ async function dispatchNextUnit(
3238
3064
  // Before triggering recovery, check if the agent is actually producing
3239
3065
  // work on disk. `git status --porcelain` is cheap and catches any
3240
3066
  // staged/unstaged/untracked changes the agent made since lastProgressAt.
3241
- if (detectWorkingTreeActivity(basePath)) {
3242
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3067
+ if (detectWorkingTreeActivity(s.basePath)) {
3068
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
3243
3069
  lastProgressAt: Date.now(),
3244
3070
  lastProgressKind: "filesystem-activity",
3245
3071
  });
3246
3072
  return;
3247
3073
  }
3248
3074
 
3249
- if (currentUnit) {
3250
- const modelId = ctx.model?.id ?? "unknown";
3251
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
3075
+ if (s.currentUnit) {
3076
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
3077
+ } else {
3078
+ saveActivityLog(ctx, s.basePath, unitType, unitId);
3252
3079
  }
3253
- saveActivityLog(ctx, basePath, unitType, unitId);
3254
3080
 
3255
- const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle");
3081
+ const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle", buildRecoveryContext());
3256
3082
  if (recovery === "recovered") return;
3257
3083
 
3258
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3084
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
3259
3085
  phase: "paused",
3260
3086
  });
3261
3087
  ctx.ui.notify(
@@ -3263,22 +3089,33 @@ async function dispatchNextUnit(
3263
3089
  "warning",
3264
3090
  );
3265
3091
  await pauseAuto(ctx, pi);
3092
+ } catch (err) {
3093
+ // Guard against unhandled rejections in the async interval callback.
3094
+ // Without this, a thrown error leaves the interval running forever
3095
+ // while the auto-mode state becomes inconsistent.
3096
+ const message = err instanceof Error ? err.message : String(err);
3097
+ console.error(`[idle-watchdog] Unhandled error: ${message}`);
3098
+ try {
3099
+ ctx.ui.notify(`Idle watchdog error: ${message}`, "warning");
3100
+ } catch { /* best effort */ }
3101
+ }
3266
3102
  }, 15000);
3267
3103
 
3268
- unitTimeoutHandle = setTimeout(async () => {
3269
- unitTimeoutHandle = null;
3270
- if (!active) return;
3271
- if (currentUnit) {
3272
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3104
+ s.unitTimeoutHandle = setTimeout(async () => {
3105
+ try {
3106
+ s.unitTimeoutHandle = null;
3107
+ if (!s.active) return;
3108
+ if (s.currentUnit) {
3109
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
3273
3110
  phase: "timeout",
3274
3111
  timeoutAt: Date.now(),
3275
3112
  });
3276
- const modelId = ctx.model?.id ?? "unknown";
3277
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
3113
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
3114
+ } else {
3115
+ saveActivityLog(ctx, s.basePath, unitType, unitId);
3278
3116
  }
3279
- saveActivityLog(ctx, basePath, unitType, unitId);
3280
3117
 
3281
- const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard");
3118
+ const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard", buildRecoveryContext());
3282
3119
  if (recovery === "recovered") return;
3283
3120
 
3284
3121
  ctx.ui.notify(
@@ -3286,12 +3123,80 @@ async function dispatchNextUnit(
3286
3123
  "warning",
3287
3124
  );
3288
3125
  await pauseAuto(ctx, pi);
3126
+ } catch (err) {
3127
+ const message = err instanceof Error ? err.message : String(err);
3128
+ console.error(`[hard-timeout] Unhandled error: ${message}`);
3129
+ try {
3130
+ ctx.ui.notify(`Hard timeout error: ${message}`, "warning");
3131
+ } catch { /* best effort */ }
3132
+ }
3289
3133
  }, hardTimeoutMs);
3290
3134
 
3291
- // Inject prompt — verify auto-mode still active (guards against race with timeout/pause)
3292
- if (!active) return;
3135
+ // ── Continue-here context-pressure monitor ────────────────────────────
3136
+ // Polls context usage every 15s. When usage hits the continue-here
3137
+ // threshold (70%), sends a one-shot wrap-up signal so the agent finishes
3138
+ // gracefully and the next unit gets a fresh session. This is softer than
3139
+ // context_pause_threshold which hard-pauses auto-mode entirely.
3140
+ if (s.continueHereHandle) {
3141
+ clearInterval(s.continueHereHandle);
3142
+ s.continueHereHandle = null;
3143
+ }
3144
+ const executorContextWindow = resolveExecutorContextWindow(
3145
+ ctx.modelRegistry as Parameters<typeof resolveExecutorContextWindow>[0],
3146
+ prefs as Parameters<typeof resolveExecutorContextWindow>[1],
3147
+ ctx.model?.contextWindow,
3148
+ );
3149
+ const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent;
3150
+ s.continueHereHandle = setInterval(() => {
3151
+ if (!s.active || !s.currentUnit || !s.cmdCtx) return;
3152
+ // One-shot guard: skip if already fired for this unit
3153
+ const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId);
3154
+ if (runtime?.continueHereFired) return;
3155
+
3156
+ const contextUsage = s.cmdCtx.getContextUsage();
3157
+ if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return;
3158
+
3159
+ // Fire once — mark runtime record and send wrap-up message
3160
+ writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit!.startedAt, {
3161
+ continueHereFired: true,
3162
+ });
3163
+
3164
+ if (s.verbose) {
3165
+ ctx.ui.notify(
3166
+ `Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`,
3167
+ "info",
3168
+ );
3169
+ }
3170
+
3171
+ pi.sendMessage(
3172
+ {
3173
+ customType: "gsd-auto-wrapup",
3174
+ display: s.verbose,
3175
+ content: [
3176
+ "**CONTEXT BUDGET WARNING — wrap up this unit now.**",
3177
+ `Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`,
3178
+ "The next unit needs a fresh context to work effectively. Wrap up now:",
3179
+ "1. Finish any in-progress file writes",
3180
+ "2. Write or update the required durable artifacts (summary, checkboxes)",
3181
+ "3. Mark task state on disk correctly",
3182
+ "4. Leave precise resume notes if anything remains unfinished",
3183
+ "Do NOT start new sub-tasks or investigations.",
3184
+ ].join("\n"),
3185
+ },
3186
+ { triggerTurn: true },
3187
+ );
3188
+
3189
+ // Clear the interval after firing — no need to keep polling
3190
+ if (s.continueHereHandle) {
3191
+ clearInterval(s.continueHereHandle);
3192
+ s.continueHereHandle = null;
3193
+ }
3194
+ }, 15_000);
3195
+
3196
+ // Inject prompt — verify auto-mode still s.active (guards against race with timeout/pause)
3197
+ if (!s.active) return;
3293
3198
  pi.sendMessage(
3294
- { customType: "gsd-auto", content: finalPrompt, display: verbose },
3199
+ { customType: "gsd-auto", content: finalPrompt, display: s.verbose },
3295
3200
  { triggerTurn: true },
3296
3201
  );
3297
3202
 
@@ -3306,7 +3211,7 @@ async function dispatchNextUnit(
3306
3211
  await pauseAuto(ctx, pi);
3307
3212
  }
3308
3213
  } finally {
3309
- _dispatching = false;
3214
+ s.dispatching = false;
3310
3215
  }
3311
3216
  }
3312
3217
 
@@ -3356,296 +3261,16 @@ function ensurePreconditions(
3356
3261
 
3357
3262
  // ─── Diagnostics ──────────────────────────────────────────────────────────────
3358
3263
 
3359
- async function collectObservabilityWarnings(
3360
- ctx: ExtensionContext,
3361
- unitType: string,
3362
- unitId: string,
3363
- ): Promise<import("./observability-validator.ts").ValidationIssue[]> {
3364
- // Hook units have custom artifacts — skip standard observability checks
3365
- if (unitType.startsWith("hook/")) return [];
3366
-
3367
- const parts = unitId.split("/");
3368
- const mid = parts[0];
3369
- const sid = parts[1];
3370
- const tid = parts[2];
3371
-
3372
- if (!mid || !sid) return [];
3373
-
3374
- let issues = [] as Awaited<ReturnType<typeof validatePlanBoundary>>;
3375
-
3376
- if (unitType === "plan-slice") {
3377
- issues = await validatePlanBoundary(basePath, mid, sid);
3378
- } else if (unitType === "execute-task" && tid) {
3379
- issues = await validateExecuteBoundary(basePath, mid, sid, tid);
3380
- } else if (unitType === "complete-slice") {
3381
- issues = await validateCompleteBoundary(basePath, mid, sid);
3382
- }
3383
-
3384
- if (issues.length > 0) {
3385
- ctx.ui.notify(
3386
- `Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`,
3387
- "warning",
3388
- );
3389
- }
3390
-
3391
- return issues;
3392
- }
3393
-
3394
- function buildObservabilityRepairBlock(issues: import("./observability-validator.ts").ValidationIssue[]): string {
3395
- if (issues.length === 0) return "";
3396
- const items = issues.map(issue => {
3397
- const fileName = issue.file.split("/").pop() || issue.file;
3398
- let line = `- **${fileName}**: ${issue.message}`;
3399
- if (issue.suggestion) line += ` → ${issue.suggestion}`;
3400
- return line;
3401
- });
3402
- return [
3403
- "",
3404
- "---",
3405
- "",
3406
- "## Pre-flight: Observability gaps to fix FIRST",
3407
- "",
3408
- "The following issues were detected in plan/summary files for this unit.",
3409
- "**Read each flagged file, apply the fix described, then proceed with the unit.**",
3410
- "",
3411
- ...items,
3412
- "",
3413
- "---",
3414
- "",
3415
- ].join("\n");
3416
- }
3417
-
3418
- async function recoverTimedOutUnit(
3419
- ctx: ExtensionContext,
3420
- pi: ExtensionAPI,
3421
- unitType: string,
3422
- unitId: string,
3423
- reason: "idle" | "hard",
3424
- ): Promise<"recovered" | "paused"> {
3425
- if (!currentUnit) return "paused";
3426
-
3427
- const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
3428
- const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
3429
- const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
3430
-
3431
- const recoveryKey = `${unitType}/${unitId}`;
3432
- const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
3433
- unitRecoveryCount.set(recoveryKey, attemptNumber);
3434
-
3435
- if (attemptNumber > 1) {
3436
- // Exponential backoff: 2^(n-1) seconds, capped at 30s
3437
- const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
3438
- ctx.ui.notify(
3439
- `Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
3440
- "info",
3441
- );
3442
- await new Promise(r => setTimeout(r, backoffMs));
3443
- }
3444
-
3445
- if (unitType === "execute-task") {
3446
- const status = await inspectExecuteTaskDurability(basePath, unitId);
3447
- if (!status) return "paused";
3448
-
3449
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3450
- recovery: status,
3451
- });
3452
-
3453
- const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
3454
- if (durableComplete) {
3455
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3456
- phase: "finalized",
3457
- recovery: status,
3458
- });
3459
- ctx.ui.notify(
3460
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
3461
- "info",
3462
- );
3463
- unitRecoveryCount.delete(recoveryKey);
3464
- await dispatchNextUnit(ctx, pi);
3465
- return "recovered";
3466
- }
3467
-
3468
- if (recoveryAttempts < maxRecoveryAttempts) {
3469
- const isEscalation = recoveryAttempts > 0;
3470
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3471
- phase: "recovered",
3472
- recovery: status,
3473
- recoveryAttempts: recoveryAttempts + 1,
3474
- lastRecoveryReason: reason,
3475
- lastProgressAt: Date.now(),
3476
- progressCount: (runtime?.progressCount ?? 0) + 1,
3477
- lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
3478
- });
3479
-
3480
- const steeringLines = isEscalation
3481
- ? [
3482
- `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
3483
- `You are still executing ${unitType} ${unitId}.`,
3484
- `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
3485
- `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
3486
- "You MUST finish the durable output NOW, even if incomplete.",
3487
- "Write the task summary with whatever you have accomplished so far.",
3488
- "Mark the task [x] in the plan. Commit your work.",
3489
- "A partial summary is infinitely better than no summary.",
3490
- ]
3491
- : [
3492
- `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
3493
- `You are still executing ${unitType} ${unitId}.`,
3494
- `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
3495
- `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
3496
- "Do not keep exploring.",
3497
- "Immediately finish the required durable output for this unit.",
3498
- "If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
3499
- ];
3500
-
3501
- pi.sendMessage(
3502
- {
3503
- customType: "gsd-auto-timeout-recovery",
3504
- display: verbose,
3505
- content: steeringLines.join("\n"),
3506
- },
3507
- { triggerTurn: true, deliverAs: "steer" },
3508
- );
3509
- ctx.ui.notify(
3510
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
3511
- "warning",
3512
- );
3513
- return "recovered";
3514
- }
3515
-
3516
- // Retries exhausted — write missing durable artifacts and advance.
3517
- const diagnostic = formatExecuteTaskRecoveryStatus(status);
3518
- const [mid, sid, tid] = unitId.split("/");
3519
- const skipped = mid && sid && tid
3520
- ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
3521
- : false;
3522
-
3523
- if (skipped) {
3524
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3525
- phase: "skipped",
3526
- recovery: status,
3527
- recoveryAttempts: recoveryAttempts + 1,
3528
- lastRecoveryReason: reason,
3529
- });
3530
- ctx.ui.notify(
3531
- `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
3532
- "warning",
3533
- );
3534
- unitRecoveryCount.delete(recoveryKey);
3535
- await dispatchNextUnit(ctx, pi);
3536
- return "recovered";
3537
- }
3538
-
3539
- // Fallback: couldn't write skip artifacts — pause as before.
3540
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3541
- phase: "paused",
3542
- recovery: status,
3543
- recoveryAttempts: recoveryAttempts + 1,
3544
- lastRecoveryReason: reason,
3545
- });
3546
- ctx.ui.notify(
3547
- `${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`,
3548
- "warning",
3549
- );
3550
- return "paused";
3551
- }
3552
-
3553
- const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
3554
-
3555
- // Check if the artifact already exists on disk — agent may have written it
3556
- // without signaling completion.
3557
- const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
3558
- if (artifactPath && existsSync(artifactPath)) {
3559
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3560
- phase: "finalized",
3561
- recoveryAttempts: recoveryAttempts + 1,
3562
- lastRecoveryReason: reason,
3563
- });
3564
- ctx.ui.notify(
3565
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
3566
- "info",
3567
- );
3568
- unitRecoveryCount.delete(recoveryKey);
3569
- await dispatchNextUnit(ctx, pi);
3570
- return "recovered";
3571
- }
3572
-
3573
- if (recoveryAttempts < maxRecoveryAttempts) {
3574
- const isEscalation = recoveryAttempts > 0;
3575
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3576
- phase: "recovered",
3577
- recoveryAttempts: recoveryAttempts + 1,
3578
- lastRecoveryReason: reason,
3579
- lastProgressAt: Date.now(),
3580
- progressCount: (runtime?.progressCount ?? 0) + 1,
3581
- lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
3582
- });
3583
-
3584
- const steeringLines = isEscalation
3585
- ? [
3586
- `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
3587
- `You are still executing ${unitType} ${unitId}.`,
3588
- `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
3589
- `Expected durable output: ${expected}.`,
3590
- "You MUST write the artifact file NOW, even if incomplete.",
3591
- "Write whatever you have — partial research, preliminary findings, best-effort analysis.",
3592
- "A partial artifact is infinitely better than no artifact.",
3593
- "If you are truly blocked, write the file with a BLOCKER section explaining why.",
3594
- ]
3595
- : [
3596
- `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
3597
- `You are still executing ${unitType} ${unitId}.`,
3598
- `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
3599
- `Expected durable output: ${expected}.`,
3600
- "Stop broad exploration.",
3601
- "Write the required artifact now.",
3602
- "If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
3603
- ];
3264
+ // collectObservabilityWarnings + buildObservabilityRepairBlock → auto-observability.ts
3604
3265
 
3605
- pi.sendMessage(
3606
- {
3607
- customType: "gsd-auto-timeout-recovery",
3608
- display: verbose,
3609
- content: steeringLines.join("\n"),
3610
- },
3611
- { triggerTurn: true, deliverAs: "steer" },
3612
- );
3613
- ctx.ui.notify(
3614
- `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
3615
- "warning",
3616
- );
3617
- return "recovered";
3618
- }
3266
+ // recoverTimedOutUnit → auto-timeout-recovery.ts
3619
3267
 
3620
- // Retries exhausted write a blocker placeholder and advance the pipeline
3621
- // instead of silently stalling.
3622
- const placeholder = writeBlockerPlaceholder(
3623
- unitType, unitId, basePath,
3624
- `${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
3625
- );
3626
-
3627
- if (placeholder) {
3628
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3629
- phase: "skipped",
3630
- recoveryAttempts: recoveryAttempts + 1,
3631
- lastRecoveryReason: reason,
3632
- });
3633
- ctx.ui.notify(
3634
- `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
3635
- "warning",
3636
- );
3637
- unitRecoveryCount.delete(recoveryKey);
3638
- await dispatchNextUnit(ctx, pi);
3639
- return "recovered";
3640
- }
3641
-
3642
- // Fallback: couldn't resolve artifact path — pause as before.
3643
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3644
- phase: "paused",
3645
- recoveryAttempts: recoveryAttempts + 1,
3646
- lastRecoveryReason: reason,
3647
- });
3648
- return "paused";
3268
+ /** Build recovery context from module state for recoverTimedOutUnit */
3269
+ function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryContext {
3270
+ return { basePath: s.basePath, verbose: s.verbose,
3271
+ currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), unitRecoveryCount: s.unitRecoveryCount,
3272
+ dispatchNextUnit,
3273
+ };
3649
3274
  }
3650
3275
 
3651
3276
  // Re-export recovery functions for external consumers
@@ -3661,9 +3286,9 @@ export {
3661
3286
  * Test-only: expose skip-loop state for unit tests.
3662
3287
  * Not part of the public API.
3663
3288
  */
3664
- export function _getUnitConsecutiveSkips(): Map<string, number> { return unitConsecutiveSkips; }
3665
- export function _resetUnitConsecutiveSkips(): void { unitConsecutiveSkips.clear(); }
3666
- export { MAX_CONSECUTIVE_SKIPS };
3289
+ export function _getUnitConsecutiveSkips(): Map<string, number> { return s.unitConsecutiveSkips; }
3290
+ export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); }
3291
+ // MAX_CONSECUTIVE_SKIPS re-exported from auto/session.ts at top of file
3667
3292
 
3668
3293
  /**
3669
3294
  * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
@@ -3679,37 +3304,37 @@ export async function dispatchHookUnit(
3679
3304
  hookModel: string | undefined,
3680
3305
  targetBasePath: string,
3681
3306
  ): Promise<boolean> {
3682
- // Ensure auto-mode is active
3683
- if (!active) {
3307
+ // Ensure auto-mode is s.active
3308
+ if (!s.active) {
3684
3309
  // Initialize auto-mode state minimally
3685
- active = true;
3686
- stepMode = true;
3687
- cmdCtx = ctx as ExtensionCommandContext;
3688
- basePath = targetBasePath;
3689
- autoStartTime = Date.now();
3690
- currentUnit = null;
3691
- completedUnits = [];
3692
- pendingQuickTasks = [];
3310
+ s.active = true;
3311
+ s.stepMode = true;
3312
+ s.cmdCtx = ctx as ExtensionCommandContext;
3313
+ s.basePath = targetBasePath;
3314
+ s.autoStartTime = Date.now();
3315
+ s.currentUnit = null;
3316
+ s.completedUnits = [];
3317
+ s.pendingQuickTasks = [];
3693
3318
  }
3694
3319
 
3695
3320
  const hookUnitType = `hook/${hookName}`;
3696
3321
  const hookStartedAt = Date.now();
3697
3322
 
3698
3323
  // Set up the trigger unit as the "current" unit so post-unit hooks can reference it
3699
- currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt };
3324
+ s.currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt };
3700
3325
 
3701
3326
  // Create a new session for the hook
3702
- const result = await cmdCtx!.newSession();
3327
+ const result = await s.cmdCtx!.newSession();
3703
3328
  if (result.cancelled) {
3704
3329
  await stopAuto(ctx, pi);
3705
3330
  return false;
3706
3331
  }
3707
3332
 
3708
3333
  // Update current unit to the hook unit
3709
- currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt };
3334
+ s.currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt };
3710
3335
 
3711
3336
  // Write runtime record
3712
- writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, {
3337
+ writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, {
3713
3338
  phase: "dispatched",
3714
3339
  wrapupWarningSent: false,
3715
3340
  timeoutAt: null,
@@ -3733,17 +3358,17 @@ export async function dispatchHookUnit(
3733
3358
 
3734
3359
  // Write lock
3735
3360
  const sessionFile = ctx.sessionManager.getSessionFile();
3736
- writeLock(lockBase(), hookUnitType, triggerUnitId, completedUnits.length, sessionFile);
3361
+ writeLock(lockBase(), hookUnitType, triggerUnitId, s.completedUnits.length, sessionFile);
3737
3362
 
3738
3363
  // Set up timeout
3739
3364
  clearUnitTimeout();
3740
3365
  const supervisor = resolveAutoSupervisorConfig();
3741
3366
  const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
3742
- unitTimeoutHandle = setTimeout(async () => {
3743
- unitTimeoutHandle = null;
3744
- if (!active) return;
3745
- if (currentUnit) {
3746
- writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, {
3367
+ s.unitTimeoutHandle = setTimeout(async () => {
3368
+ s.unitTimeoutHandle = null;
3369
+ if (!s.active) return;
3370
+ if (s.currentUnit) {
3371
+ writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, {
3747
3372
  phase: "timeout",
3748
3373
  timeoutAt: Date.now(),
3749
3374
  });
@@ -3757,7 +3382,7 @@ export async function dispatchHookUnit(
3757
3382
  }, hookHardTimeoutMs);
3758
3383
 
3759
3384
  // Update status
3760
- ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
3385
+ ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
3761
3386
  ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
3762
3387
 
3763
3388
  // Send the hook prompt
@@ -3772,204 +3397,5 @@ export async function dispatchHookUnit(
3772
3397
  }
3773
3398
 
3774
3399
 
3775
- // ─── Direct Phase Dispatch ────────────────────────────────────────────────────
3776
-
3777
- export async function dispatchDirectPhase(
3778
- ctx: ExtensionCommandContext,
3779
- pi: ExtensionAPI,
3780
- phase: string,
3781
- base: string,
3782
- ): Promise<void> {
3783
- const state = await deriveState(base);
3784
- const mid = state.activeMilestone?.id;
3785
- const midTitle = state.activeMilestone?.title ?? "";
3786
-
3787
- if (!mid) {
3788
- ctx.ui.notify("Cannot dispatch: no active milestone.", "warning");
3789
- return;
3790
- }
3791
-
3792
- const normalized = phase.toLowerCase();
3793
- let unitType: string;
3794
- let unitId: string;
3795
- let prompt: string;
3796
-
3797
- switch (normalized) {
3798
- case "research":
3799
- case "research-milestone":
3800
- case "research-slice": {
3801
- const isSlice = normalized === "research-slice" || (normalized === "research" && state.phase !== "pre-planning");
3802
- if (isSlice) {
3803
- const sid = state.activeSlice?.id;
3804
- const sTitle = state.activeSlice?.title ?? "";
3805
- if (!sid) {
3806
- ctx.ui.notify("Cannot dispatch research-slice: no active slice.", "warning");
3807
- return;
3808
- }
3809
-
3810
- // When require_slice_discussion is enabled, pause auto-mode before
3811
- // each new slice so the user can discuss requirements first (#789).
3812
- const sliceContextFile = resolveSliceFile(base, mid, sid, "CONTEXT");
3813
- const requireDiscussion = loadEffectiveGSDPreferences()?.preferences?.phases?.require_slice_discussion;
3814
- if (requireDiscussion && !sliceContextFile) {
3815
- ctx.ui.notify(
3816
- `Slice ${sid} requires discussion before planning. Run /gsd discuss to discuss this slice, then /gsd auto to resume.`,
3817
- "info",
3818
- );
3819
- await pauseAuto(ctx, pi);
3820
- return;
3821
- }
3822
-
3823
- unitType = "research-slice";
3824
- unitId = `${mid}/${sid}`;
3825
- prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, base);
3826
- } else {
3827
- unitType = "research-milestone";
3828
- unitId = mid;
3829
- prompt = await buildResearchMilestonePrompt(mid, midTitle, base);
3830
- }
3831
- break;
3832
- }
3833
-
3834
- case "plan":
3835
- case "plan-milestone":
3836
- case "plan-slice": {
3837
- const isSlice = normalized === "plan-slice" || (normalized === "plan" && state.phase !== "pre-planning");
3838
- if (isSlice) {
3839
- const sid = state.activeSlice?.id;
3840
- const sTitle = state.activeSlice?.title ?? "";
3841
- if (!sid) {
3842
- ctx.ui.notify("Cannot dispatch plan-slice: no active slice.", "warning");
3843
- return;
3844
- }
3845
- unitType = "plan-slice";
3846
- unitId = `${mid}/${sid}`;
3847
- prompt = await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, base);
3848
- } else {
3849
- unitType = "plan-milestone";
3850
- unitId = mid;
3851
- prompt = await buildPlanMilestonePrompt(mid, midTitle, base);
3852
- }
3853
- break;
3854
- }
3855
-
3856
- case "execute":
3857
- case "execute-task": {
3858
- const sid = state.activeSlice?.id;
3859
- const sTitle = state.activeSlice?.title ?? "";
3860
- const tid = state.activeTask?.id;
3861
- const tTitle = state.activeTask?.title ?? "";
3862
- if (!sid) {
3863
- ctx.ui.notify("Cannot dispatch execute-task: no active slice.", "warning");
3864
- return;
3865
- }
3866
- if (!tid) {
3867
- ctx.ui.notify("Cannot dispatch execute-task: no active task.", "warning");
3868
- return;
3869
- }
3870
- unitType = "execute-task";
3871
- unitId = `${mid}/${sid}/${tid}`;
3872
- prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
3873
- break;
3874
- }
3875
-
3876
- case "complete":
3877
- case "complete-slice":
3878
- case "complete-milestone": {
3879
- const isSlice = normalized === "complete-slice" || (normalized === "complete" && state.phase === "summarizing");
3880
- if (isSlice) {
3881
- const sid = state.activeSlice?.id;
3882
- const sTitle = state.activeSlice?.title ?? "";
3883
- if (!sid) {
3884
- ctx.ui.notify("Cannot dispatch complete-slice: no active slice.", "warning");
3885
- return;
3886
- }
3887
- unitType = "complete-slice";
3888
- unitId = `${mid}/${sid}`;
3889
- prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, base);
3890
- } else {
3891
- unitType = "complete-milestone";
3892
- unitId = mid;
3893
- prompt = await buildCompleteMilestonePrompt(mid, midTitle, base);
3894
- }
3895
- break;
3896
- }
3897
-
3898
- case "reassess":
3899
- case "reassess-roadmap": {
3900
- const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
3901
- const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
3902
- if (!roadmapContent) {
3903
- ctx.ui.notify("Cannot dispatch reassess-roadmap: no roadmap found.", "warning");
3904
- return;
3905
- }
3906
- const roadmap = parseRoadmap(roadmapContent);
3907
- const completedSlices = roadmap.slices.filter(s => s.done);
3908
- if (completedSlices.length === 0) {
3909
- ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning");
3910
- return;
3911
- }
3912
- const completedSliceId = completedSlices[completedSlices.length - 1].id;
3913
- unitType = "reassess-roadmap";
3914
- unitId = `${mid}/${completedSliceId}`;
3915
- prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, base);
3916
- break;
3917
- }
3918
-
3919
- case "uat":
3920
- case "run-uat": {
3921
- const sid = state.activeSlice?.id;
3922
- if (!sid) {
3923
- ctx.ui.notify("Cannot dispatch run-uat: no active slice.", "warning");
3924
- return;
3925
- }
3926
- const uatFile = resolveSliceFile(base, mid, sid, "UAT");
3927
- if (!uatFile) {
3928
- ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");
3929
- return;
3930
- }
3931
- const uatContent = await loadFile(uatFile);
3932
- if (!uatContent) {
3933
- ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning");
3934
- return;
3935
- }
3936
- const uatPath = relSliceFile(base, mid, sid, "UAT");
3937
- unitType = "run-uat";
3938
- unitId = `${mid}/${sid}`;
3939
- prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base);
3940
- break;
3941
- }
3942
-
3943
- case "replan":
3944
- case "replan-slice": {
3945
- const sid = state.activeSlice?.id;
3946
- const sTitle = state.activeSlice?.title ?? "";
3947
- if (!sid) {
3948
- ctx.ui.notify("Cannot dispatch replan-slice: no active slice.", "warning");
3949
- return;
3950
- }
3951
- unitType = "replan-slice";
3952
- unitId = `${mid}/${sid}`;
3953
- prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base);
3954
- break;
3955
- }
3956
-
3957
- default:
3958
- ctx.ui.notify(
3959
- `Unknown phase "${phase}". Valid phases: research, plan, execute, complete, reassess, uat, replan.`,
3960
- "warning",
3961
- );
3962
- return;
3963
- }
3964
-
3965
- ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info");
3966
- const result = await ctx.newSession();
3967
- if (result.cancelled) {
3968
- ctx.ui.notify("Session creation cancelled.", "warning");
3969
- return;
3970
- }
3971
- pi.sendMessage(
3972
- { customType: "gsd-dispatch", content: prompt, display: false },
3973
- { triggerTurn: true },
3974
- );
3975
- }
3400
+ // Direct phase dispatch → auto-direct-dispatch.ts
3401
+ export { dispatchDirectPhase } from "./auto-direct-dispatch.js";