gsd-pi 2.17.0 → 2.19.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 (217) hide show
  1. package/README.md +39 -0
  2. package/dist/onboarding.js +2 -2
  3. package/dist/remote-questions-config.d.ts +10 -0
  4. package/dist/remote-questions-config.js +36 -0
  5. package/dist/resources/extensions/gsd/activity-log.ts +37 -7
  6. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +65 -16
  8. package/dist/resources/extensions/gsd/auto-worktree.ts +33 -4
  9. package/dist/resources/extensions/gsd/auto.ts +399 -29
  10. package/dist/resources/extensions/gsd/captures.ts +384 -0
  11. package/dist/resources/extensions/gsd/commands.ts +382 -23
  12. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  14. package/dist/resources/extensions/gsd/dispatch-guard.ts +7 -19
  15. package/dist/resources/extensions/gsd/docs/preferences-reference.md +201 -2
  16. package/dist/resources/extensions/gsd/files.ts +123 -1
  17. package/dist/resources/extensions/gsd/guided-flow.ts +237 -4
  18. package/dist/resources/extensions/gsd/index.ts +47 -3
  19. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  20. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  21. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  22. package/dist/resources/extensions/gsd/paths.ts +9 -0
  23. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  24. package/dist/resources/extensions/gsd/preferences.ts +132 -1
  25. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  26. package/dist/resources/extensions/gsd/prompts/execute-task.md +6 -5
  27. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  28. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  29. package/dist/resources/extensions/gsd/prompts/system.md +2 -0
  30. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  31. package/dist/resources/extensions/gsd/queue-order.ts +231 -0
  32. package/dist/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  33. package/dist/resources/extensions/gsd/state.ts +15 -3
  34. package/dist/resources/extensions/gsd/templates/knowledge.md +19 -0
  35. package/dist/resources/extensions/gsd/templates/preferences.md +14 -0
  36. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  37. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  38. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  39. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  40. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  41. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  42. package/dist/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  43. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  44. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  45. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  46. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  47. package/dist/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  48. package/dist/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  49. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  50. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  51. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  52. package/dist/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  53. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  54. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  55. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  56. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  57. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  58. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  59. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  60. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  61. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  62. package/dist/resources/extensions/gsd/worktree-manager.ts +8 -5
  63. package/dist/resources/extensions/gsd/worktree.ts +22 -0
  64. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  65. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  66. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  67. package/dist/resources/extensions/shared/next-action-ui.ts +16 -1
  68. package/package.json +1 -1
  69. package/packages/pi-coding-agent/dist/cli/args.d.ts +5 -0
  70. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  71. package/packages/pi-coding-agent/dist/cli/args.js +21 -0
  72. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  73. package/packages/pi-coding-agent/dist/cli/list-models.d.ts +14 -3
  74. package/packages/pi-coding-agent/dist/cli/list-models.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/cli/list-models.js +52 -17
  76. package/packages/pi-coding-agent/dist/cli/list-models.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts +27 -0
  78. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts.map +1 -0
  79. package/packages/pi-coding-agent/dist/core/discovery-cache.js +79 -0
  80. package/packages/pi-coding-agent/dist/core/discovery-cache.js.map +1 -0
  81. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts +2 -0
  82. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts.map +1 -0
  83. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js +140 -0
  84. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts +35 -0
  86. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-discovery.js +162 -0
  88. package/packages/pi-coding-agent/dist/core/model-discovery.js.map +1 -0
  89. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts +2 -0
  90. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts.map +1 -0
  91. package/packages/pi-coding-agent/dist/core/model-discovery.test.js +100 -0
  92. package/packages/pi-coding-agent/dist/core/model-discovery.test.js.map +1 -0
  93. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts +2 -0
  94. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts.map +1 -0
  95. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js +113 -0
  96. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +26 -0
  98. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/model-registry.js +98 -0
  100. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts +62 -0
  102. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/models-json-writer.js +145 -0
  104. package/packages/pi-coding-agent/dist/core/models-json-writer.js.map +1 -0
  105. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts +2 -0
  106. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts.map +1 -0
  107. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js +118 -0
  108. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js.map +1 -0
  109. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +9 -0
  110. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/core/settings-manager.js +11 -0
  112. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  113. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  115. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  116. package/packages/pi-coding-agent/dist/index.d.ts +5 -1
  117. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/index.js +4 -1
  119. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/main.js +17 -2
  122. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts +1 -0
  124. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js +1 -0
  126. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +1 -1
  128. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  129. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts +25 -0
  130. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts.map +1 -0
  131. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js +121 -0
  132. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js.map +1 -0
  133. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  134. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  135. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +32 -0
  136. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  137. package/packages/pi-coding-agent/src/cli/args.ts +21 -0
  138. package/packages/pi-coding-agent/src/cli/list-models.ts +70 -17
  139. package/packages/pi-coding-agent/src/core/discovery-cache.test.ts +170 -0
  140. package/packages/pi-coding-agent/src/core/discovery-cache.ts +97 -0
  141. package/packages/pi-coding-agent/src/core/model-discovery.test.ts +125 -0
  142. package/packages/pi-coding-agent/src/core/model-discovery.ts +231 -0
  143. package/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts +135 -0
  144. package/packages/pi-coding-agent/src/core/model-registry.ts +107 -0
  145. package/packages/pi-coding-agent/src/core/models-json-writer.test.ts +145 -0
  146. package/packages/pi-coding-agent/src/core/models-json-writer.ts +188 -0
  147. package/packages/pi-coding-agent/src/core/settings-manager.ts +21 -0
  148. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  149. package/packages/pi-coding-agent/src/index.ts +5 -0
  150. package/packages/pi-coding-agent/src/main.ts +19 -2
  151. package/packages/pi-coding-agent/src/modes/interactive/components/index.ts +1 -0
  152. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +1 -1
  153. package/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts +163 -0
  154. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +37 -0
  155. package/src/resources/extensions/gsd/activity-log.ts +37 -7
  156. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  157. package/src/resources/extensions/gsd/auto-prompts.ts +65 -16
  158. package/src/resources/extensions/gsd/auto-worktree.ts +33 -4
  159. package/src/resources/extensions/gsd/auto.ts +399 -29
  160. package/src/resources/extensions/gsd/captures.ts +384 -0
  161. package/src/resources/extensions/gsd/commands.ts +382 -23
  162. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  163. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  164. package/src/resources/extensions/gsd/dispatch-guard.ts +7 -19
  165. package/src/resources/extensions/gsd/docs/preferences-reference.md +201 -2
  166. package/src/resources/extensions/gsd/files.ts +123 -1
  167. package/src/resources/extensions/gsd/guided-flow.ts +237 -4
  168. package/src/resources/extensions/gsd/index.ts +47 -3
  169. package/src/resources/extensions/gsd/metrics.ts +48 -0
  170. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  171. package/src/resources/extensions/gsd/model-router.ts +256 -0
  172. package/src/resources/extensions/gsd/paths.ts +9 -0
  173. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  174. package/src/resources/extensions/gsd/preferences.ts +132 -1
  175. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  176. package/src/resources/extensions/gsd/prompts/execute-task.md +6 -5
  177. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  178. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  179. package/src/resources/extensions/gsd/prompts/system.md +2 -0
  180. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  181. package/src/resources/extensions/gsd/queue-order.ts +231 -0
  182. package/src/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  183. package/src/resources/extensions/gsd/state.ts +15 -3
  184. package/src/resources/extensions/gsd/templates/knowledge.md +19 -0
  185. package/src/resources/extensions/gsd/templates/preferences.md +14 -0
  186. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  187. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  188. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  189. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  190. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  192. package/src/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  193. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  194. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  195. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  196. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  197. package/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  198. package/src/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  199. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  200. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  201. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  202. package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  203. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  204. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  205. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  206. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  207. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  208. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  209. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  210. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  211. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  212. package/src/resources/extensions/gsd/worktree-manager.ts +8 -5
  213. package/src/resources/extensions/gsd/worktree.ts +22 -0
  214. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  215. package/src/resources/extensions/remote-questions/format.ts +12 -6
  216. package/src/resources/extensions/remote-questions/manager.ts +8 -0
  217. package/src/resources/extensions/shared/next-action-ui.ts +16 -1
@@ -19,6 +19,7 @@ import type {
19
19
  import { deriveState, invalidateStateCache } from "./state.js";
20
20
  import type { BudgetEnforcementMode, GSDState } from "./types.js";
21
21
  import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js";
22
+ import { loadPrompt } from "./prompt-loader.js";
22
23
  export { inlinePriorMilestoneSummary } from "./files.js";
23
24
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
24
25
  import {
@@ -29,7 +30,7 @@ import {
29
30
  buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
30
31
  } from "./paths.js";
31
32
  import { invalidateAllCaches } from "./cache.js";
32
- import { saveActivityLog } from "./activity-log.js";
33
+ import { saveActivityLog, clearActivityLogState } from "./activity-log.js";
33
34
  import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js";
34
35
  import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js";
35
36
  import {
@@ -39,9 +40,12 @@ import {
39
40
  readUnitRuntimeRecord,
40
41
  writeUnitRuntimeRecord,
41
42
  } from "./unit-runtime.js";
42
- import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode } from "./preferences.js";
43
+ import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig } from "./preferences.js";
43
44
  import { sendDesktopNotification } from "./notifications.js";
44
45
  import type { GSDPreferences } from "./preferences.js";
46
+ import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
47
+ import { resolveModelForComplexity } from "./model-router.js";
48
+ import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js";
45
49
  import {
46
50
  checkPostUnitHooks,
47
51
  getActiveHook,
@@ -92,7 +96,9 @@ import {
92
96
  getAutoWorktreePath,
93
97
  getAutoWorktreeOriginalBase,
94
98
  mergeMilestoneToMain,
99
+ autoWorktreeBranch,
95
100
  } from "./auto-worktree.js";
101
+ import { pruneQueueOrder } from "./queue-order.js";
96
102
  import { showNextAction } from "../shared/next-action-ui.js";
97
103
  import {
98
104
  resolveExpectedArtifactPath,
@@ -127,6 +133,7 @@ import {
127
133
  deregisterSigtermHandler as _deregisterSigtermHandler,
128
134
  detectWorkingTreeActivity,
129
135
  } from "./auto-supervisor.js";
136
+ import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
130
137
 
131
138
  // ─── State ────────────────────────────────────────────────────────────────────
132
139
 
@@ -196,6 +203,33 @@ function shouldUseWorktreeIsolation(): boolean {
196
203
  return true; // default: worktree
197
204
  }
198
205
 
206
+ /**
207
+ * Detect and escape a stale worktree cwd (#608).
208
+ *
209
+ * After milestone completion + merge, the worktree directory is removed but
210
+ * the process cwd may still point inside `.gsd/worktrees/<MID>/`.
211
+ * When a new session starts, `process.cwd()` is passed as `base` to startAuto
212
+ * and all subsequent writes land in the wrong directory. This function detects
213
+ * that scenario and chdir back to the project root.
214
+ *
215
+ * Returns the corrected base path.
216
+ */
217
+ function escapeStaleWorktree(base: string): string {
218
+ const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
219
+ const idx = base.indexOf(marker);
220
+ if (idx === -1) return base;
221
+
222
+ // base is inside .gsd/worktrees/<something> — extract the project root
223
+ const projectRoot = base.slice(0, idx);
224
+ try {
225
+ process.chdir(projectRoot);
226
+ } catch {
227
+ // If chdir fails, return the original — caller will handle errors downstream
228
+ return base;
229
+ }
230
+ return projectRoot;
231
+ }
232
+
199
233
  /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */
200
234
  let pendingCrashRecovery: string | null = null;
201
235
 
@@ -204,6 +238,9 @@ let autoStartTime: number = 0;
204
238
  let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = [];
205
239
  let currentUnit: { type: string; id: string; startedAt: number } | null = null;
206
240
 
241
+ /** Track dynamic routing decision for the current unit (for metrics) */
242
+ let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
243
+
207
244
  /** Track current milestone to detect transitions */
208
245
  let currentMilestoneId: string | null = null;
209
246
  let lastBudgetAlertLevel: BudgetAlertLevel = 0;
@@ -228,6 +265,9 @@ const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds
228
265
  /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
229
266
  let _sigtermHandler: (() => void) | null = null;
230
267
 
268
+ /** Tool calls currently being executed — prevents false idle detection during long-running tools. */
269
+ const inFlightTools = new Set<string>();
270
+
231
271
  type BudgetAlertLevel = 0 | 75 | 90 | 100;
232
272
 
233
273
  export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
@@ -269,6 +309,15 @@ export { type AutoDashboardData } from "./auto-dashboard.js";
269
309
  export function getAutoDashboardData(): AutoDashboardData {
270
310
  const ledger = getLedger();
271
311
  const totals = ledger ? getProjectTotals(ledger.units) : null;
312
+ // Pending capture count — lazy check, non-fatal
313
+ let pendingCaptureCount = 0;
314
+ try {
315
+ if (basePath) {
316
+ pendingCaptureCount = countPendingCaptures(basePath);
317
+ }
318
+ } catch {
319
+ // Non-fatal — captures module may not be loaded
320
+ }
272
321
  return {
273
322
  active,
274
323
  paused,
@@ -280,6 +329,7 @@ export function getAutoDashboardData(): AutoDashboardData {
280
329
  basePath,
281
330
  totalCost: totals?.cost ?? 0,
282
331
  totalTokens: totals?.tokens.total ?? 0,
332
+ pendingCaptureCount,
283
333
  };
284
334
  }
285
335
 
@@ -293,6 +343,22 @@ export function isAutoPaused(): boolean {
293
343
  return paused;
294
344
  }
295
345
 
346
+ /**
347
+ * Mark a tool execution as in-flight. Called from index.ts on tool_execution_start.
348
+ * Prevents the idle watchdog from declaring the agent idle while tools are executing.
349
+ */
350
+ export function markToolStart(toolCallId: string): void {
351
+ if (!active) return;
352
+ inFlightTools.add(toolCallId);
353
+ }
354
+
355
+ /**
356
+ * Mark a tool execution as completed. Called from index.ts on tool_execution_end.
357
+ */
358
+ export function markToolEnd(toolCallId: string): void {
359
+ inFlightTools.delete(toolCallId);
360
+ }
361
+
296
362
  /**
297
363
  * Return the base path to use for the auto.lock file.
298
364
  * Always uses the original project root (not the worktree) so that
@@ -345,6 +411,7 @@ function clearUnitTimeout(): void {
345
411
  clearInterval(idleWatchdogHandle);
346
412
  idleWatchdogHandle = null;
347
413
  }
414
+ inFlightTools.clear();
348
415
  clearDispatchGapWatchdog();
349
416
  }
350
417
 
@@ -426,14 +493,18 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
426
493
  `Auto-worktree teardown failed: ${err instanceof Error ? err.message : String(err)}`,
427
494
  "warning",
428
495
  );
429
- // Force basePath back to original even if teardown failed
430
- if (originalBasePath) {
431
- basePath = originalBasePath;
432
- try { process.chdir(basePath); } catch { /* best-effort */ }
433
- }
434
496
  }
435
497
  }
436
498
 
499
+ // Always restore cwd to project root on stop (#608).
500
+ // Even if isInAutoWorktree returned false (e.g., module state was already
501
+ // cleared by mergeMilestoneToMain), the process cwd may still be inside
502
+ // the worktree directory. Force it back to originalBasePath.
503
+ if (originalBasePath) {
504
+ basePath = originalBasePath;
505
+ try { process.chdir(basePath); } catch { /* best-effort */ }
506
+ }
507
+
437
508
  const ledger = getLedger();
438
509
  if (ledger && ledger.units.length > 0) {
439
510
  const totals = getProjectTotals(ledger.units);
@@ -451,6 +522,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
451
522
  }
452
523
 
453
524
  resetMetrics();
525
+ resetRoutingHistory();
454
526
  resetHookState();
455
527
  if (basePath) clearPersistedHookState(basePath);
456
528
  active = false;
@@ -458,12 +530,15 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
458
530
  stepMode = false;
459
531
  unitDispatchCount.clear();
460
532
  unitRecoveryCount.clear();
533
+ inFlightTools.clear();
461
534
  lastBudgetAlertLevel = 0;
462
535
  unitLifetimeDispatches.clear();
463
536
  currentUnit = null;
464
537
  currentMilestoneId = null;
465
538
  originalBasePath = "";
539
+ completedUnits = [];
466
540
  clearSliceProgressCache();
541
+ clearActivityLogState();
467
542
  pendingCrashRecovery = null;
468
543
  _handlingAgentEnd = false;
469
544
  ctx?.ui.setStatus("gsd-auto", undefined);
@@ -519,6 +594,11 @@ export async function startAuto(
519
594
  ): Promise<void> {
520
595
  const requestedStepMode = options?.step ?? false;
521
596
 
597
+ // Escape stale worktree cwd from a previous milestone (#608).
598
+ // After milestone merge + worktree removal, the process cwd may still point
599
+ // inside .gsd/worktrees/<MID>/ — detect and chdir back to project root.
600
+ base = escapeStaleWorktree(base);
601
+
522
602
  // If resuming from paused state, just re-activate and dispatch next unit.
523
603
  // The conversation is still intact — no need to reinitialize everything.
524
604
  if (paused) {
@@ -569,17 +649,17 @@ export async function startAuto(
569
649
  ctx.ui.setFooter(hideFooter);
570
650
  ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
571
651
  // Restore hook state from disk in case session was interrupted
572
- restoreHookState(base);
652
+ restoreHookState(basePath);
573
653
  // Rebuild disk state before resuming — user interaction during pause may have changed files
574
- try { await rebuildState(base); } catch { /* non-fatal */ }
654
+ try { await rebuildState(basePath); } catch { /* non-fatal */ }
575
655
  try {
576
- const report = await runGSDDoctor(base, { fix: true });
656
+ const report = await runGSDDoctor(basePath, { fix: true });
577
657
  if (report.fixesApplied.length > 0) {
578
658
  ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
579
659
  }
580
660
  } catch { /* non-fatal */ }
581
661
  // Self-heal: clear stale runtime records where artifacts already exist
582
- await selfHealRuntimeRecords(base, ctx, completedKeySet);
662
+ await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
583
663
  invalidateAllCaches();
584
664
  await dispatchNextUnit(ctx, pi);
585
665
  return;
@@ -748,6 +828,9 @@ export async function startAuto(
748
828
  // Initialize metrics — loads existing ledger from disk
749
829
  initMetrics(base);
750
830
 
831
+ // Initialize routing history for adaptive learning
832
+ initRoutingHistory(base);
833
+
751
834
  // Snapshot installed skills so we can detect new ones after research
752
835
  if (resolveSkillDiscoveryMode() !== "off") {
753
836
  snapshotSkills();
@@ -950,7 +1033,7 @@ export async function handleAgentEnd(
950
1033
  const hookStartedAt = Date.now();
951
1034
  if (currentUnit) {
952
1035
  const modelId = ctx.model?.id ?? "unknown";
953
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1036
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
954
1037
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
955
1038
  }
956
1039
  currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
@@ -1045,6 +1128,108 @@ export async function handleAgentEnd(
1045
1128
  }
1046
1129
  }
1047
1130
 
1131
+ // ── Triage check: dispatch triage unit if pending captures exist ──────────
1132
+ // Fires after hooks complete, before normal dispatch. Follows the same
1133
+ // early-dispatch-and-return pattern as hooks and fix-merge.
1134
+ // Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage),
1135
+ // hook units (hooks run before triage conceptually).
1136
+ if (
1137
+ !stepMode &&
1138
+ currentUnit &&
1139
+ !currentUnit.type.startsWith("hook/") &&
1140
+ currentUnit.type !== "triage-captures" &&
1141
+ currentUnit.type !== "quick-task"
1142
+ ) {
1143
+ try {
1144
+ if (hasPendingCaptures(basePath)) {
1145
+ const pending = loadPendingCaptures(basePath);
1146
+ if (pending.length > 0) {
1147
+ const state = await deriveState(basePath);
1148
+ const mid = state.activeMilestone?.id;
1149
+ const sid = state.activeSlice?.id;
1150
+
1151
+ if (mid && sid) {
1152
+ // Build triage prompt with current context
1153
+ let currentPlan = "";
1154
+ let roadmapContext = "";
1155
+ const planFile = resolveSliceFile(basePath, mid, sid, "PLAN");
1156
+ if (planFile) currentPlan = (await loadFile(planFile)) ?? "";
1157
+ const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
1158
+ if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? "";
1159
+
1160
+ const capturesList = pending.map(c =>
1161
+ `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})`
1162
+ ).join("\n");
1163
+
1164
+ const prompt = loadPrompt("triage-captures", {
1165
+ pendingCaptures: capturesList,
1166
+ currentPlan: currentPlan || "(no active slice plan)",
1167
+ roadmapContext: roadmapContext || "(no active roadmap)",
1168
+ });
1169
+
1170
+ ctx.ui.notify(
1171
+ `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`,
1172
+ "info",
1173
+ );
1174
+
1175
+ // Close out previous unit metrics
1176
+ if (currentUnit) {
1177
+ const modelId = ctx.model?.id ?? "unknown";
1178
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1179
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1180
+ }
1181
+
1182
+ // Dispatch triage as a new unit (early-dispatch-and-return)
1183
+ const triageUnitType = "triage-captures";
1184
+ const triageUnitId = `${mid}/${sid}/triage`;
1185
+ const triageStartedAt = Date.now();
1186
+ currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
1187
+ writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, {
1188
+ phase: "dispatched",
1189
+ wrapupWarningSent: false,
1190
+ timeoutAt: null,
1191
+ lastProgressAt: triageStartedAt,
1192
+ progressCount: 0,
1193
+ lastProgressKind: "dispatch",
1194
+ });
1195
+ updateProgressWidget(ctx, triageUnitType, triageUnitId, state);
1196
+
1197
+ const result = await cmdCtx!.newSession();
1198
+ if (result.cancelled) {
1199
+ await stopAuto(ctx, pi);
1200
+ return;
1201
+ }
1202
+ const sessionFile = ctx.sessionManager.getSessionFile();
1203
+ writeLock(basePath, triageUnitType, triageUnitId, completedUnits.length, sessionFile);
1204
+
1205
+ // Start unit timeout for triage (use same supervisor config as hooks)
1206
+ clearUnitTimeout();
1207
+ const supervisor = resolveAutoSupervisorConfig();
1208
+ const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
1209
+ unitTimeoutHandle = setTimeout(async () => {
1210
+ unitTimeoutHandle = null;
1211
+ if (!active) return;
1212
+ ctx.ui.notify(
1213
+ `Triage unit exceeded timeout. Pausing auto-mode.`,
1214
+ "warning",
1215
+ );
1216
+ await pauseAuto(ctx, pi);
1217
+ }, triageTimeoutMs);
1218
+
1219
+ if (!active) return;
1220
+ pi.sendMessage(
1221
+ { customType: "gsd-auto", content: prompt, display: verbose },
1222
+ { triggerTurn: true },
1223
+ );
1224
+ return; // handleAgentEnd will fire again when triage session completes
1225
+ }
1226
+ }
1227
+ }
1228
+ } catch {
1229
+ // Triage check failure is non-fatal — proceed to normal dispatch
1230
+ }
1231
+ }
1232
+
1048
1233
  // In step mode, pause and show a wizard instead of immediately dispatching
1049
1234
  if (stepMode) {
1050
1235
  await showStepWizard(ctx, pi);
@@ -1166,7 +1351,10 @@ function updateProgressWidget(
1166
1351
  unitId: string,
1167
1352
  state: GSDState,
1168
1353
  ): void {
1169
- _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors);
1354
+ const badge = currentUnitRouting?.tier
1355
+ ? ({ light: "L", standard: "S", heavy: "H" }[currentUnitRouting.tier] ?? undefined)
1356
+ : undefined;
1357
+ _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge);
1170
1358
  }
1171
1359
 
1172
1360
  /** State accessors for the widget — closures over module globals. */
@@ -1245,12 +1433,90 @@ async function dispatchNextUnit(
1245
1433
  "info",
1246
1434
  );
1247
1435
  sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
1436
+ // Hint: visualizer available after milestone transition
1437
+ const vizPrefs = loadEffectiveGSDPreferences()?.preferences;
1438
+ if (vizPrefs?.auto_visualize) {
1439
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
1440
+ }
1248
1441
  // Reset stuck detection for new milestone
1249
1442
  unitDispatchCount.clear();
1250
1443
  unitRecoveryCount.clear();
1251
1444
  unitLifetimeDispatches.clear();
1252
- // Capture integration branch for the new milestone and update git service
1253
- captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1445
+ // Clear completed-units.json for the finished milestone
1446
+ try {
1447
+ const file = completedKeysPath(basePath);
1448
+ if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
1449
+ completedKeySet.clear();
1450
+ } catch { /* non-fatal */ }
1451
+
1452
+ // ── Worktree lifecycle on milestone transition (#616) ──────────────
1453
+ // When transitioning from M_old to M_new inside a worktree, we must:
1454
+ // 1. Merge the completed milestone's worktree back to main
1455
+ // 2. Re-derive state from the project root
1456
+ // 3. Create a new worktree for the incoming milestone
1457
+ // Without this, M_new runs inside M_old's worktree on the wrong branch,
1458
+ // and artifact paths resolve against the wrong .gsd/ directory.
1459
+ if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) {
1460
+ try {
1461
+ const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
1462
+ if (roadmapPath) {
1463
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
1464
+ const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
1465
+ ctx.ui.notify(
1466
+ `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1467
+ "info",
1468
+ );
1469
+ } else {
1470
+ // No roadmap found — teardown worktree without merge
1471
+ teardownAutoWorktree(originalBasePath, currentMilestoneId);
1472
+ ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info");
1473
+ }
1474
+ } catch (err) {
1475
+ ctx.ui.notify(
1476
+ `Milestone merge failed during transition: ${err instanceof Error ? err.message : String(err)}`,
1477
+ "warning",
1478
+ );
1479
+ // Force cwd back to project root even if merge failed
1480
+ if (originalBasePath) {
1481
+ try { process.chdir(originalBasePath); } catch { /* best-effort */ }
1482
+ }
1483
+ }
1484
+
1485
+ // Update basePath to project root (mergeMilestoneToMain already chdir'd)
1486
+ basePath = originalBasePath;
1487
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1488
+ invalidateAllCaches();
1489
+
1490
+ // Re-derive state from project root before creating new worktree
1491
+ state = await deriveState(basePath);
1492
+ mid = state.activeMilestone?.id;
1493
+ midTitle = state.activeMilestone?.title;
1494
+
1495
+ // Create new worktree for the incoming milestone
1496
+ if (mid) {
1497
+ captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1498
+ try {
1499
+ const wtPath = createAutoWorktree(basePath, mid);
1500
+ basePath = wtPath;
1501
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1502
+ ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
1503
+ } catch (err) {
1504
+ ctx.ui.notify(
1505
+ `Auto-worktree creation for ${mid} failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
1506
+ "warning",
1507
+ );
1508
+ }
1509
+ }
1510
+ } else {
1511
+ // Not in worktree — just capture integration branch for the new milestone
1512
+ captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1513
+ }
1514
+
1515
+ // Prune completed milestone from queue order file
1516
+ const pendingIds = state.registry
1517
+ .filter(m => m.status !== "complete")
1518
+ .map(m => m.id);
1519
+ pruneQueueOrder(basePath, pendingIds);
1254
1520
  }
1255
1521
  if (mid) {
1256
1522
  currentMilestoneId = mid;
@@ -1261,7 +1527,7 @@ async function dispatchNextUnit(
1261
1527
  // Save final session before stopping
1262
1528
  if (currentUnit) {
1263
1529
  const modelId = ctx.model?.id ?? "unknown";
1264
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1530
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1265
1531
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1266
1532
  }
1267
1533
  sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
@@ -1289,7 +1555,7 @@ async function dispatchNextUnit(
1289
1555
  if (!mid || !midTitle) {
1290
1556
  if (currentUnit) {
1291
1557
  const modelId = ctx.model?.id ?? "unknown";
1292
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1558
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1293
1559
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1294
1560
  }
1295
1561
  await stopAuto(ctx, pi);
@@ -1304,7 +1570,7 @@ async function dispatchNextUnit(
1304
1570
  if (state.phase === "complete") {
1305
1571
  if (currentUnit) {
1306
1572
  const modelId = ctx.model?.id ?? "unknown";
1307
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1573
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1308
1574
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1309
1575
  }
1310
1576
  // Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
@@ -1331,6 +1597,39 @@ async function dispatchNextUnit(
1331
1597
  `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
1332
1598
  "warning",
1333
1599
  );
1600
+ // Ensure cwd is restored even if merge failed partway through (#608).
1601
+ // mergeMilestoneToMain may have chdir'd but then thrown, leaving us
1602
+ // in an indeterminate location.
1603
+ if (originalBasePath) {
1604
+ basePath = originalBasePath;
1605
+ try { process.chdir(basePath); } catch { /* best-effort */ }
1606
+ }
1607
+ }
1608
+ } else if (currentMilestoneId && !isInAutoWorktree(basePath)) {
1609
+ // Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch.
1610
+ // Squash-merge back to the integration branch (or main) before stopping.
1611
+ try {
1612
+ const currentBranch = getCurrentBranch(basePath);
1613
+ const milestoneBranch = autoWorktreeBranch(currentMilestoneId);
1614
+ if (currentBranch === milestoneBranch) {
1615
+ const roadmapPath = resolveMilestoneFile(basePath, currentMilestoneId, "ROADMAP");
1616
+ if (roadmapPath) {
1617
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
1618
+ // mergeMilestoneToMain handles: auto-commit, checkout integration branch,
1619
+ // squash merge, commit, optional push, branch deletion.
1620
+ const mergeResult = mergeMilestoneToMain(basePath, currentMilestoneId, roadmapContent);
1621
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1622
+ ctx.ui.notify(
1623
+ `Milestone ${currentMilestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1624
+ "info",
1625
+ );
1626
+ }
1627
+ }
1628
+ } catch (err) {
1629
+ ctx.ui.notify(
1630
+ `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
1631
+ "warning",
1632
+ );
1334
1633
  }
1335
1634
  }
1336
1635
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
@@ -1341,7 +1640,7 @@ async function dispatchNextUnit(
1341
1640
  if (state.phase === "blocked") {
1342
1641
  if (currentUnit) {
1343
1642
  const modelId = ctx.model?.id ?? "unknown";
1344
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1643
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1345
1644
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1346
1645
  }
1347
1646
  await stopAuto(ctx, pi);
@@ -1449,7 +1748,7 @@ async function dispatchNextUnit(
1449
1748
  if (dispatchResult.action === "stop") {
1450
1749
  if (currentUnit) {
1451
1750
  const modelId = ctx.model?.id ?? "unknown";
1452
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1751
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1453
1752
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1454
1753
  }
1455
1754
  await stopAuto(ctx, pi);
@@ -1559,7 +1858,7 @@ async function dispatchNextUnit(
1559
1858
  if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
1560
1859
  if (currentUnit) {
1561
1860
  const modelId = ctx.model?.id ?? "unknown";
1562
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1861
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1563
1862
  }
1564
1863
  saveActivityLog(ctx, basePath, unitType, unitId);
1565
1864
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
@@ -1573,7 +1872,7 @@ async function dispatchNextUnit(
1573
1872
  if (prevCount >= MAX_UNIT_DISPATCHES) {
1574
1873
  if (currentUnit) {
1575
1874
  const modelId = ctx.model?.id ?? "unknown";
1576
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1875
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1577
1876
  }
1578
1877
  saveActivityLog(ctx, basePath, unitType, unitId);
1579
1878
 
@@ -1731,9 +2030,19 @@ async function dispatchNextUnit(
1731
2030
  // The session still holds the previous unit's data (newSession hasn't fired yet).
1732
2031
  if (currentUnit) {
1733
2032
  const modelId = ctx.model?.id ?? "unknown";
1734
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2033
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1735
2034
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1736
2035
 
2036
+ // Record routing outcome for adaptive learning
2037
+ if (currentUnitRouting) {
2038
+ const isRetry = currentUnit.type === unitType && currentUnit.id === unitId;
2039
+ recordOutcome(
2040
+ currentUnit.type,
2041
+ currentUnitRouting.tier as "light" | "standard" | "heavy",
2042
+ !isRetry, // success = not being retried
2043
+ );
2044
+ }
2045
+
1737
2046
  // Only mark the previous unit as completed if:
1738
2047
  // 1. We're not about to re-dispatch the same unit (retry scenario)
1739
2048
  // 2. The expected artifact actually exists on disk
@@ -1757,6 +2066,10 @@ async function dispatchNextUnit(
1757
2066
  startedAt: currentUnit.startedAt,
1758
2067
  finishedAt: Date.now(),
1759
2068
  });
2069
+ // Cap to last 200 entries to prevent unbounded growth (#611)
2070
+ if (completedUnits.length > 200) {
2071
+ completedUnits = completedUnits.slice(-200);
2072
+ }
1760
2073
  clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1761
2074
  unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1762
2075
  unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
@@ -1832,7 +2145,54 @@ async function dispatchNextUnit(
1832
2145
  const modelConfig = resolveModelWithFallbacksForUnit(unitType);
1833
2146
  if (modelConfig) {
1834
2147
  const availableModels = ctx.modelRegistry.getAvailable();
1835
- const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
2148
+
2149
+ // ─── Dynamic Model Routing ─────────────────────────────────────────
2150
+ // If enabled, classify unit complexity and potentially downgrade to a
2151
+ // cheaper model. The user's configured model is the ceiling.
2152
+ const routingConfig = resolveDynamicRoutingConfig();
2153
+ let effectiveModelConfig = modelConfig;
2154
+ let routingTierLabel = "";
2155
+ currentUnitRouting = null;
2156
+
2157
+ if (routingConfig.enabled) {
2158
+ // Compute budget pressure if budget ceiling is set
2159
+ let budgetPct: number | undefined;
2160
+ if (routingConfig.budget_pressure !== false) {
2161
+ const budgetCeiling = prefs?.budget_ceiling;
2162
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
2163
+ const currentLedger = getLedger();
2164
+ const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
2165
+ budgetPct = totalCost / budgetCeiling;
2166
+ }
2167
+ }
2168
+
2169
+ // Classify complexity (hook routing controlled by config.hooks)
2170
+ const isHook = unitType.startsWith("hook/");
2171
+ const shouldClassify = !isHook || routingConfig.hooks !== false;
2172
+
2173
+ if (shouldClassify) {
2174
+ const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
2175
+ const availableModelIds = availableModels.map(m => m.id);
2176
+ const routing = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
2177
+
2178
+ if (routing.wasDowngraded) {
2179
+ effectiveModelConfig = {
2180
+ primary: routing.modelId,
2181
+ fallbacks: routing.fallbacks,
2182
+ };
2183
+ if (verbose) {
2184
+ ctx.ui.notify(
2185
+ `Dynamic routing [${tierLabel(classification.tier)}]: ${routing.modelId} (${classification.reason})`,
2186
+ "info",
2187
+ );
2188
+ }
2189
+ }
2190
+ routingTierLabel = ` [${tierLabel(classification.tier)}]`;
2191
+ currentUnitRouting = { tier: classification.tier, modelDowngraded: routing.wasDowngraded };
2192
+ }
2193
+ }
2194
+
2195
+ const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks];
1836
2196
  let modelSet = false;
1837
2197
 
1838
2198
  for (const modelId of modelsToTry) {
@@ -1897,11 +2257,11 @@ async function dispatchNextUnit(
1897
2257
 
1898
2258
  const ok = await pi.setModel(model, { persist: false });
1899
2259
  if (ok) {
1900
- const fallbackNote = modelId === modelConfig.primary
2260
+ const fallbackNote = modelId === effectiveModelConfig.primary
1901
2261
  ? ""
1902
- : ` (fallback from ${modelConfig.primary})`;
2262
+ : ` (fallback from ${effectiveModelConfig.primary})`;
1903
2263
  const phase = unitPhaseLabel(unitType);
1904
- ctx.ui.notify(`Model [${phase}]: ${model.provider}/${model.id}${fallbackNote}`, "info");
2264
+ ctx.ui.notify(`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`, "info");
1905
2265
  modelSet = true;
1906
2266
  break;
1907
2267
  } else {
@@ -1957,6 +2317,16 @@ async function dispatchNextUnit(
1957
2317
  if (!runtime) return;
1958
2318
  if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
1959
2319
 
2320
+ // Agent has tool calls currently executing (await_job, long bash, etc.) —
2321
+ // not idle, just waiting for tool completion.
2322
+ if (inFlightTools.size > 0) {
2323
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2324
+ lastProgressAt: Date.now(),
2325
+ lastProgressKind: "tool-in-flight",
2326
+ });
2327
+ return;
2328
+ }
2329
+
1960
2330
  // Before triggering recovery, check if the agent is actually producing
1961
2331
  // work on disk. `git status --porcelain` is cheap and catches any
1962
2332
  // staged/unstaged/untracked changes the agent made since lastProgressAt.
@@ -1970,7 +2340,7 @@ async function dispatchNextUnit(
1970
2340
 
1971
2341
  if (currentUnit) {
1972
2342
  const modelId = ctx.model?.id ?? "unknown";
1973
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2343
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1974
2344
  }
1975
2345
  saveActivityLog(ctx, basePath, unitType, unitId);
1976
2346
 
@@ -1996,7 +2366,7 @@ async function dispatchNextUnit(
1996
2366
  timeoutAt: Date.now(),
1997
2367
  });
1998
2368
  const modelId = ctx.model?.id ?? "unknown";
1999
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2369
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
2000
2370
  }
2001
2371
  saveActivityLog(ctx, basePath, unitType, unitId);
2002
2372