gsd-pi 2.38.0-dev.eeb3520 → 2.39.0-dev.20aba06

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 (346) hide show
  1. package/README.md +15 -11
  2. package/dist/app-paths.js +1 -1
  3. package/dist/cli.js +9 -0
  4. package/dist/extension-discovery.d.ts +5 -3
  5. package/dist/extension-discovery.js +14 -9
  6. package/dist/extension-registry.js +2 -2
  7. package/dist/remote-questions-config.js +2 -2
  8. package/dist/resource-loader.js +100 -3
  9. package/dist/resources/extensions/async-jobs/index.js +10 -0
  10. package/dist/resources/extensions/browser-tools/index.js +3 -1
  11. package/dist/resources/extensions/browser-tools/package.json +3 -1
  12. package/dist/resources/extensions/browser-tools/tools/verify.js +97 -0
  13. package/dist/resources/extensions/cmux/index.js +55 -1
  14. package/dist/resources/extensions/context7/package.json +1 -1
  15. package/dist/resources/extensions/get-secrets-from-user.js +5 -24
  16. package/dist/resources/extensions/github-sync/cli.js +284 -0
  17. package/dist/resources/extensions/github-sync/index.js +73 -0
  18. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  19. package/dist/resources/extensions/github-sync/sync.js +424 -0
  20. package/dist/resources/extensions/github-sync/templates.js +118 -0
  21. package/dist/resources/extensions/github-sync/types.js +7 -0
  22. package/dist/resources/extensions/google-search/package.json +3 -1
  23. package/dist/resources/extensions/gsd/auto/session.js +6 -23
  24. package/dist/resources/extensions/gsd/auto-dashboard.js +7 -0
  25. package/dist/resources/extensions/gsd/auto-dispatch.js +8 -9
  26. package/dist/resources/extensions/gsd/auto-loop.js +923 -787
  27. package/dist/resources/extensions/gsd/auto-post-unit.js +107 -70
  28. package/dist/resources/extensions/gsd/auto-prompts.js +205 -51
  29. package/dist/resources/extensions/gsd/auto-start.js +19 -3
  30. package/dist/resources/extensions/gsd/auto-worktree-sync.js +13 -5
  31. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  32. package/dist/resources/extensions/gsd/auto.js +149 -100
  33. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +126 -0
  34. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +233 -0
  35. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +59 -0
  36. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +38 -0
  37. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +156 -0
  38. package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +46 -0
  39. package/dist/resources/extensions/gsd/bootstrap/system-context.js +300 -0
  40. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +38 -0
  41. package/dist/resources/extensions/gsd/captures.js +9 -1
  42. package/dist/resources/extensions/gsd/commands/catalog.js +278 -0
  43. package/dist/resources/extensions/gsd/commands/context.js +84 -0
  44. package/dist/resources/extensions/gsd/commands/dispatcher.js +21 -0
  45. package/dist/resources/extensions/gsd/commands/handlers/auto.js +72 -0
  46. package/dist/resources/extensions/gsd/commands/handlers/core.js +246 -0
  47. package/dist/resources/extensions/gsd/commands/handlers/ops.js +166 -0
  48. package/dist/resources/extensions/gsd/commands/handlers/parallel.js +94 -0
  49. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +102 -0
  50. package/dist/resources/extensions/gsd/commands/index.js +11 -0
  51. package/dist/resources/extensions/gsd/commands-extensions.js +3 -2
  52. package/dist/resources/extensions/gsd/commands-handlers.js +17 -4
  53. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  54. package/dist/resources/extensions/gsd/commands.js +8 -1169
  55. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  56. package/dist/resources/extensions/gsd/dashboard-overlay.js +9 -0
  57. package/dist/resources/extensions/gsd/detection.js +1 -2
  58. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  59. package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
  60. package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
  61. package/dist/resources/extensions/gsd/doctor-format.js +15 -0
  62. package/dist/resources/extensions/gsd/doctor-proactive.js +80 -10
  63. package/dist/resources/extensions/gsd/doctor-providers.js +30 -11
  64. package/dist/resources/extensions/gsd/doctor.js +234 -12
  65. package/dist/resources/extensions/gsd/env-utils.js +29 -0
  66. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  67. package/dist/resources/extensions/gsd/export-html.js +46 -0
  68. package/dist/resources/extensions/gsd/export.js +1 -1
  69. package/dist/resources/extensions/gsd/files.js +48 -9
  70. package/dist/resources/extensions/gsd/forensics.js +1 -1
  71. package/dist/resources/extensions/gsd/git-service.js +30 -12
  72. package/dist/resources/extensions/gsd/gitignore.js +16 -3
  73. package/dist/resources/extensions/gsd/guided-flow.js +149 -38
  74. package/dist/resources/extensions/gsd/health-widget-core.js +32 -70
  75. package/dist/resources/extensions/gsd/health-widget.js +4 -87
  76. package/dist/resources/extensions/gsd/index.js +4 -1111
  77. package/dist/resources/extensions/gsd/migrate/parsers.js +1 -1
  78. package/dist/resources/extensions/gsd/migrate-external.js +18 -1
  79. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  80. package/dist/resources/extensions/gsd/package.json +1 -1
  81. package/dist/resources/extensions/gsd/paths.js +3 -0
  82. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  83. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  84. package/dist/resources/extensions/gsd/preferences-validation.js +59 -11
  85. package/dist/resources/extensions/gsd/preferences.js +22 -11
  86. package/dist/resources/extensions/gsd/progress-score.js +20 -1
  87. package/dist/resources/extensions/gsd/prompt-loader.js +6 -2
  88. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  89. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  90. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  91. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -3
  92. package/dist/resources/extensions/gsd/prompts/forensics.md +121 -46
  93. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  94. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  95. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  96. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  97. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  98. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  99. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  100. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  101. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  102. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  103. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  104. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  105. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  106. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  107. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  108. package/dist/resources/extensions/gsd/prompts/run-uat.md +28 -11
  109. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  110. package/dist/resources/extensions/gsd/repo-identity.js +21 -4
  111. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  112. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  113. package/dist/resources/extensions/gsd/state.js +42 -23
  114. package/dist/resources/extensions/gsd/templates/runtime.md +21 -0
  115. package/dist/resources/extensions/gsd/templates/task-plan.md +3 -0
  116. package/dist/resources/extensions/gsd/visualizer-data.js +27 -2
  117. package/dist/resources/extensions/gsd/visualizer-views.js +52 -0
  118. package/dist/resources/extensions/gsd/worktree.js +35 -16
  119. package/dist/resources/extensions/mcp-client/index.js +14 -1
  120. package/dist/resources/extensions/remote-questions/status.js +4 -1
  121. package/dist/resources/extensions/remote-questions/store.js +4 -1
  122. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  123. package/dist/resources/extensions/shared/frontmatter.js +1 -1
  124. package/dist/resources/extensions/subagent/index.js +12 -3
  125. package/dist/resources/extensions/subagent/isolation.js +2 -1
  126. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  127. package/dist/resources/extensions/universal-config/package.json +1 -1
  128. package/dist/welcome-screen.d.ts +13 -0
  129. package/dist/welcome-screen.js +97 -0
  130. package/package.json +1 -1
  131. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  132. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  133. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  134. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +12 -0
  135. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  136. package/packages/pi-coding-agent/dist/core/agent-session.js +107 -24
  137. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  138. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  139. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  140. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  141. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  142. package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
  143. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  144. package/packages/pi-coding-agent/dist/core/skill-tool.test.d.ts +2 -0
  145. package/packages/pi-coding-agent/dist/core/skill-tool.test.d.ts.map +1 -0
  146. package/packages/pi-coding-agent/dist/core/skill-tool.test.js +70 -0
  147. package/packages/pi-coding-agent/dist/core/skill-tool.test.js.map +1 -0
  148. package/packages/pi-coding-agent/dist/core/skills.d.ts +1 -0
  149. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  150. package/packages/pi-coding-agent/dist/core/skills.js +8 -2
  151. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  152. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  153. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  154. package/packages/pi-coding-agent/dist/index.js +1 -1
  155. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  156. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts +17 -0
  157. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -0
  158. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +244 -0
  159. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -0
  160. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts +3 -0
  161. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -0
  162. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +58 -0
  163. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -0
  164. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts +12 -0
  165. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts.map +1 -0
  166. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js +54 -0
  167. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js.map +1 -0
  168. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.d.ts +6 -0
  169. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.d.ts.map +1 -0
  170. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js +63 -0
  171. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js.map +1 -0
  172. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +38 -0
  173. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -0
  174. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js +2 -0
  175. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -0
  176. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -1
  177. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  178. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +15 -457
  179. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  180. package/packages/pi-coding-agent/package.json +1 -1
  181. package/packages/pi-coding-agent/src/core/agent-session.ts +122 -23
  182. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  183. package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
  184. package/packages/pi-coding-agent/src/core/skill-tool.test.ts +89 -0
  185. package/packages/pi-coding-agent/src/core/skills.ts +11 -2
  186. package/packages/pi-coding-agent/src/index.ts +1 -0
  187. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +302 -0
  188. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +59 -0
  189. package/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +68 -0
  190. package/packages/pi-coding-agent/src/modes/interactive/controllers/model-controller.ts +71 -0
  191. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +37 -0
  192. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +18 -510
  193. package/pkg/package.json +1 -1
  194. package/src/resources/extensions/async-jobs/index.ts +11 -0
  195. package/src/resources/extensions/browser-tools/index.ts +3 -0
  196. package/src/resources/extensions/browser-tools/tools/verify.ts +117 -0
  197. package/src/resources/extensions/cmux/index.ts +57 -1
  198. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  199. package/src/resources/extensions/github-sync/cli.ts +364 -0
  200. package/src/resources/extensions/github-sync/index.ts +93 -0
  201. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  202. package/src/resources/extensions/github-sync/sync.ts +556 -0
  203. package/src/resources/extensions/github-sync/templates.ts +183 -0
  204. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  205. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  206. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  207. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  208. package/src/resources/extensions/github-sync/types.ts +47 -0
  209. package/src/resources/extensions/gsd/auto/session.ts +7 -25
  210. package/src/resources/extensions/gsd/auto-dashboard.ts +10 -0
  211. package/src/resources/extensions/gsd/auto-dispatch.ts +7 -9
  212. package/src/resources/extensions/gsd/auto-loop.ts +1285 -1138
  213. package/src/resources/extensions/gsd/auto-post-unit.ts +90 -46
  214. package/src/resources/extensions/gsd/auto-prompts.ts +250 -53
  215. package/src/resources/extensions/gsd/auto-start.ts +24 -3
  216. package/src/resources/extensions/gsd/auto-worktree-sync.ts +15 -4
  217. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  218. package/src/resources/extensions/gsd/auto.ts +152 -111
  219. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +142 -0
  220. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +238 -0
  221. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +90 -0
  222. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +46 -0
  223. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +167 -0
  224. package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +55 -0
  225. package/src/resources/extensions/gsd/bootstrap/system-context.ts +340 -0
  226. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +51 -0
  227. package/src/resources/extensions/gsd/captures.ts +10 -1
  228. package/src/resources/extensions/gsd/commands/catalog.ts +301 -0
  229. package/src/resources/extensions/gsd/commands/context.ts +101 -0
  230. package/src/resources/extensions/gsd/commands/dispatcher.ts +32 -0
  231. package/src/resources/extensions/gsd/commands/handlers/auto.ts +74 -0
  232. package/src/resources/extensions/gsd/commands/handlers/core.ts +274 -0
  233. package/src/resources/extensions/gsd/commands/handlers/ops.ts +169 -0
  234. package/src/resources/extensions/gsd/commands/handlers/parallel.ts +118 -0
  235. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +109 -0
  236. package/src/resources/extensions/gsd/commands/index.ts +14 -0
  237. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  238. package/src/resources/extensions/gsd/commands-handlers.ts +18 -3
  239. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  240. package/src/resources/extensions/gsd/commands.ts +10 -1307
  241. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  242. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  243. package/src/resources/extensions/gsd/detection.ts +2 -2
  244. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  245. package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
  246. package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
  247. package/src/resources/extensions/gsd/doctor-format.ts +20 -0
  248. package/src/resources/extensions/gsd/doctor-proactive.ts +106 -10
  249. package/src/resources/extensions/gsd/doctor-providers.ts +30 -9
  250. package/src/resources/extensions/gsd/doctor-types.ts +16 -1
  251. package/src/resources/extensions/gsd/doctor.ts +243 -14
  252. package/src/resources/extensions/gsd/env-utils.ts +31 -0
  253. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  254. package/src/resources/extensions/gsd/export-html.ts +51 -0
  255. package/src/resources/extensions/gsd/export.ts +1 -1
  256. package/src/resources/extensions/gsd/files.ts +51 -11
  257. package/src/resources/extensions/gsd/forensics.ts +1 -1
  258. package/src/resources/extensions/gsd/git-service.ts +44 -10
  259. package/src/resources/extensions/gsd/gitignore.ts +17 -3
  260. package/src/resources/extensions/gsd/guided-flow.ts +177 -44
  261. package/src/resources/extensions/gsd/health-widget-core.ts +28 -80
  262. package/src/resources/extensions/gsd/health-widget.ts +4 -89
  263. package/src/resources/extensions/gsd/index.ts +12 -1307
  264. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  265. package/src/resources/extensions/gsd/migrate-external.ts +18 -1
  266. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  267. package/src/resources/extensions/gsd/paths.ts +4 -0
  268. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  269. package/src/resources/extensions/gsd/preferences-types.ts +4 -4
  270. package/src/resources/extensions/gsd/preferences-validation.ts +51 -11
  271. package/src/resources/extensions/gsd/preferences.ts +25 -11
  272. package/src/resources/extensions/gsd/progress-score.ts +23 -0
  273. package/src/resources/extensions/gsd/prompt-loader.ts +7 -2
  274. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  275. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  276. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  277. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -3
  278. package/src/resources/extensions/gsd/prompts/forensics.md +121 -46
  279. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  280. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  281. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  282. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  283. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  284. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  285. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  286. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  287. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  288. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  289. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  290. package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  291. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  292. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  293. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  294. package/src/resources/extensions/gsd/prompts/run-uat.md +28 -11
  295. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  296. package/src/resources/extensions/gsd/repo-identity.ts +23 -4
  297. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  298. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  299. package/src/resources/extensions/gsd/state.ts +39 -21
  300. package/src/resources/extensions/gsd/templates/runtime.md +21 -0
  301. package/src/resources/extensions/gsd/templates/task-plan.md +3 -0
  302. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  303. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -77
  304. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +4 -3
  305. package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
  306. package/src/resources/extensions/gsd/tests/derive-state.test.ts +43 -0
  307. package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
  308. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +3 -3
  309. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
  310. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +50 -0
  311. package/src/resources/extensions/gsd/tests/health-widget.test.ts +16 -54
  312. package/src/resources/extensions/gsd/tests/parsers.test.ts +131 -14
  313. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +209 -0
  314. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  315. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  316. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +16 -16
  317. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  318. package/src/resources/extensions/gsd/tests/run-uat.test.ts +16 -4
  319. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +140 -0
  320. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +10 -10
  321. package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
  322. package/src/resources/extensions/gsd/types.ts +18 -1
  323. package/src/resources/extensions/gsd/verification-evidence.ts +16 -0
  324. package/src/resources/extensions/gsd/visualizer-data.ts +52 -2
  325. package/src/resources/extensions/gsd/visualizer-views.ts +58 -0
  326. package/src/resources/extensions/gsd/worktree.ts +35 -15
  327. package/src/resources/extensions/mcp-client/index.ts +17 -1
  328. package/src/resources/extensions/remote-questions/status.ts +5 -1
  329. package/src/resources/extensions/remote-questions/store.ts +5 -1
  330. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  331. package/src/resources/extensions/shared/frontmatter.ts +1 -1
  332. package/src/resources/extensions/subagent/index.ts +12 -3
  333. package/src/resources/extensions/subagent/isolation.ts +3 -1
  334. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
  335. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  336. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  337. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  338. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  339. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  340. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  341. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  342. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  343. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  344. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  345. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  346. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -5,20 +5,20 @@
5
5
  * pattern with a while loop. The agent_end event resolves a promise instead
6
6
  * of recursing.
7
7
  *
8
- * MAINTENANCE RULE: The only module-level mutable state here is `_activeSession`,
9
- * used by the agent_end bridge. Promise state itself lives on AutoSession so
10
- * concurrent auto sessions cannot corrupt each other.
8
+ * MAINTENANCE RULE: Module-level mutable state is limited to `_currentResolve`
9
+ * (per-unit one-shot resolver) and `_sessionSwitchInFlight` (guard for
10
+ * session rotation). No queue stale agent_end events are dropped.
11
11
  */
12
12
 
13
- import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
13
+ import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent";
14
14
 
15
- import type { AutoSession } from "./auto/session.js";
15
+ import type { AutoSession, SidecarItem } from "./auto/session.js";
16
16
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
17
17
  import type { GSDPreferences } from "./preferences.js";
18
18
  import type { SessionLockStatus } from "./session-lock.js";
19
19
  import type { GSDState } from "./types.js";
20
20
  import type { CloseoutOptions } from "./auto-unit-closeout.js";
21
- import type { PostUnitContext } from "./auto-post-unit.js";
21
+ import type { PostUnitContext, PreVerificationOpts } from "./auto-post-unit.js";
22
22
  import type {
23
23
  VerificationContext,
24
24
  VerificationResult,
@@ -26,6 +26,9 @@ import type {
26
26
  import type { DispatchAction } from "./auto-dispatch.js";
27
27
  import type { WorktreeResolver } from "./worktree-resolver.js";
28
28
  import { debugLog } from "./debug-logger.js";
29
+ import { gsdRoot } from "./paths.js";
30
+ import { atomicWriteSync } from "./atomic-write.js";
31
+ import { join } from "node:path";
29
32
  import type { CmuxLogLevel } from "../cmux/index.js";
30
33
 
31
34
  /**
@@ -35,6 +38,23 @@ import type { CmuxLogLevel } from "../cmux/index.js";
35
38
  * generous headroom including retries and sidecar work.
36
39
  */
37
40
  const MAX_LOOP_ITERATIONS = 500;
41
+ /** Maximum characters of failure/crash context included in recovery prompts. */
42
+ const MAX_RECOVERY_CHARS = 50_000;
43
+
44
+ /** Data-driven budget threshold notifications (descending). The 100% entry
45
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
46
+ * a simple notification. */
47
+ const BUDGET_THRESHOLDS: Array<{
48
+ pct: number;
49
+ label: string;
50
+ notifyLevel: "info" | "warning" | "error";
51
+ cmuxLevel: "progress" | "warning" | "error";
52
+ }> = [
53
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
54
+ { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
55
+ { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
56
+ { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
57
+ ];
38
58
 
39
59
  // ─── Types ───────────────────────────────────────────────────────────────────
40
60
 
@@ -54,17 +74,56 @@ export interface UnitResult {
54
74
  event?: AgentEndEvent;
55
75
  }
56
76
 
57
- // ─── Session-scoped promise state ───────────────────────────────────────────
77
+ // ─── Phase pipeline types ────────────────────────────────────────────────────
78
+
79
+ type PhaseResult<T = void> =
80
+ | { action: "continue" }
81
+ | { action: "break"; reason: string }
82
+ | { action: "next"; data: T }
83
+
84
+ interface IterationContext {
85
+ ctx: ExtensionContext;
86
+ pi: ExtensionAPI;
87
+ s: AutoSession;
88
+ deps: LoopDeps;
89
+ prefs: GSDPreferences | undefined;
90
+ iteration: number;
91
+ }
92
+
93
+ interface LoopState {
94
+ recentUnits: Array<{ key: string; error?: string }>;
95
+ stuckRecoveryAttempts: number;
96
+ }
97
+
98
+ interface PreDispatchData {
99
+ state: GSDState;
100
+ mid: string;
101
+ midTitle: string;
102
+ }
103
+
104
+ interface IterationData {
105
+ unitType: string;
106
+ unitId: string;
107
+ prompt: string;
108
+ finalPrompt: string;
109
+ pauseAfterUatDispatch: boolean;
110
+ observabilityIssues: unknown[];
111
+ state: GSDState;
112
+ mid: string | undefined;
113
+ midTitle: string | undefined;
114
+ isRetry: boolean;
115
+ previousTier: string | undefined;
116
+ }
117
+
118
+ // ─── Per-unit one-shot promise state ────────────────────────────────────────
58
119
  //
59
- // pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level)
60
- // so concurrent sessions cannot corrupt each other's promises.
120
+ // A single module-level resolve function scoped to the current unit execution.
121
+ // No queue if an agent_end arrives with no pending resolver, it is dropped
122
+ // (logged as warning). This is simpler and safer than the previous session-
123
+ // scoped pendingResolve + pendingAgentEndQueue pattern.
61
124
 
62
- /**
63
- * The singleton session reference used by resolveAgentEnd. Set by autoLoop
64
- * on entry so that the agent_end handler in index.ts can resolve the correct
65
- * session's promise without needing a direct reference to `s`.
66
- */
67
- let _activeSession: AutoSession | null = null;
125
+ let _currentResolve: ((result: UnitResult) => void) | null = null;
126
+ let _sessionSwitchInFlight = false;
68
127
 
69
128
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
70
129
 
@@ -73,60 +132,105 @@ let _activeSession: AutoSession | null = null;
73
132
  * in-flight unit promise. One-shot: the resolver is nulled before calling
74
133
  * to prevent double-resolution from model fallback retries.
75
134
  *
76
- * If no pendingResolve exists (event arrived between loop iterations),
77
- * the event is queued on the session so the next runUnit can drain it.
135
+ * If no resolver exists (event arrived between loop iterations or during
136
+ * session switch), the event is dropped with a debug warning.
78
137
  */
79
138
  export function resolveAgentEnd(event: AgentEndEvent): void {
80
- const s = _activeSession;
81
- if (!s) {
82
- debugLog("resolveAgentEnd", {
83
- status: "no-active-session",
84
- warning: "agent_end with no active loop session",
85
- });
139
+ if (_sessionSwitchInFlight) {
140
+ debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
86
141
  return;
87
142
  }
88
-
89
- if (s.pendingResolve) {
143
+ if (_currentResolve) {
90
144
  debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
91
- const r = s.pendingResolve;
92
- s.pendingResolve = null;
145
+ const r = _currentResolve;
146
+ _currentResolve = null;
93
147
  r({ status: "completed", event });
94
148
  } else {
95
- // Queue the event so the next runUnit picks it up immediately
96
149
  debugLog("resolveAgentEnd", {
97
- status: "queued",
98
- queueLength: s.pendingAgentEndQueue.length + 1,
99
- warning:
100
- "agent_end arrived between loop iterations — queued for next runUnit",
150
+ status: "no-pending-resolve",
151
+ warning: "agent_end with no pending unit",
101
152
  });
102
- s.pendingAgentEndQueue.push(event);
103
153
  }
104
154
  }
105
155
 
106
156
  export function isSessionSwitchInFlight(): boolean {
107
- return _activeSession?.sessionSwitchInFlight ?? false;
157
+ return _sessionSwitchInFlight;
108
158
  }
109
159
 
110
160
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
111
161
 
112
162
  /**
113
- * Reset session promise state. Only exported for test cleanup — production code
114
- * should never call this.
163
+ * Reset module-level promise state. Only exported for test cleanup —
164
+ * production code should never call this.
115
165
  */
116
166
  export function _resetPendingResolve(): void {
117
- if (_activeSession) {
118
- _activeSession.pendingResolve = null;
119
- _activeSession.pendingAgentEndQueue = [];
120
- }
121
- _activeSession = null;
167
+ _currentResolve = null;
168
+ _sessionSwitchInFlight = false;
122
169
  }
123
170
 
124
171
  /**
125
- * Set the active session for resolveAgentEnd. Only exported for test setup —
126
- * production code sets this via autoLoop entry.
172
+ * No-op for backward compatibility with tests that previously set the
173
+ * active session. The module no longer holds a session reference.
127
174
  */
128
- export function _setActiveSession(session: AutoSession | null): void {
129
- _activeSession = session;
175
+ export function _setActiveSession(_session: AutoSession | null): void {
176
+ // No-op — kept for test backward compatibility
177
+ }
178
+
179
+ // ─── detectStuck ─────────────────────────────────────────────────────────────
180
+
181
+ type WindowEntry = { key: string; error?: string };
182
+
183
+ /**
184
+ * Analyze a sliding window of recent unit dispatches for stuck patterns.
185
+ * Returns a signal with reason if stuck, null otherwise.
186
+ *
187
+ * Rule 1: Same error string twice in a row → stuck immediately.
188
+ * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
189
+ * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
190
+ */
191
+ export function detectStuck(
192
+ window: readonly WindowEntry[],
193
+ ): { stuck: true; reason: string } | null {
194
+ if (window.length < 2) return null;
195
+
196
+ const last = window[window.length - 1];
197
+ const prev = window[window.length - 2];
198
+
199
+ // Rule 1: Same error repeated consecutively
200
+ if (last.error && prev.error && last.error === prev.error) {
201
+ return {
202
+ stuck: true,
203
+ reason: `Same error repeated: ${last.error.slice(0, 200)}`,
204
+ };
205
+ }
206
+
207
+ // Rule 2: Same unit 3+ consecutive times
208
+ if (window.length >= 3) {
209
+ const lastThree = window.slice(-3);
210
+ if (lastThree.every((u) => u.key === last.key)) {
211
+ return {
212
+ stuck: true,
213
+ reason: `${last.key} derived 3 consecutive times without progress`,
214
+ };
215
+ }
216
+ }
217
+
218
+ // Rule 3: Oscillation (A→B→A→B in last 4)
219
+ if (window.length >= 4) {
220
+ const w = window.slice(-4);
221
+ if (
222
+ w[0].key === w[2].key &&
223
+ w[1].key === w[3].key &&
224
+ w[0].key !== w[1].key
225
+ ) {
226
+ return {
227
+ stuck: true,
228
+ reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
229
+ };
230
+ }
231
+ }
232
+
233
+ return null;
130
234
  }
131
235
 
132
236
  // ─── runUnit ─────────────────────────────────────────────────────────────────
@@ -146,45 +250,18 @@ export async function runUnit(
146
250
  unitType: string,
147
251
  unitId: string,
148
252
  prompt: string,
149
- _prefs: GSDPreferences | undefined,
150
253
  ): Promise<UnitResult> {
151
254
  debugLog("runUnit", { phase: "start", unitType, unitId });
152
255
 
153
- // ── Drain queued events from error-recovery retries ──
154
- // If an agent_end arrived between iterations (e.g. from a model fallback
155
- // sendMessage retry), consume it immediately instead of creating a new promise.
156
- // Cap queue to 3 entries to prevent unbounded growth from stale events.
157
- if (s.pendingAgentEndQueue.length > 3) {
158
- debugLog("runUnit", {
159
- phase: "queue-overflow",
160
- dropped: s.pendingAgentEndQueue.length - 1,
161
- unitType,
162
- unitId,
163
- });
164
- s.pendingAgentEndQueue = [
165
- s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1]!,
166
- ];
167
- }
168
- if (s.pendingAgentEndQueue.length > 0) {
169
- const queued = s.pendingAgentEndQueue.shift()!;
170
- debugLog("runUnit", {
171
- phase: "drained-queued-event",
172
- unitType,
173
- unitId,
174
- queueRemaining: s.pendingAgentEndQueue.length,
175
- });
176
- return { status: "completed", event: queued };
177
- }
178
-
179
256
  // ── Session creation with timeout ──
180
257
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
181
258
 
182
259
  let sessionResult: { cancelled: boolean };
183
260
  let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
184
- s.sessionSwitchInFlight = true;
261
+ _sessionSwitchInFlight = true;
185
262
  try {
186
263
  const sessionPromise = s.cmdCtx!.newSession().finally(() => {
187
- s.sessionSwitchInFlight = false;
264
+ _sessionSwitchInFlight = false;
188
265
  });
189
266
  const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
190
267
  sessionTimeoutHandle = setTimeout(
@@ -216,11 +293,12 @@ export async function runUnit(
216
293
  return { status: "cancelled" };
217
294
  }
218
295
 
219
- // ── Create the agent_end promise (session-scoped) ──
296
+ // ── Create the agent_end promise (per-unit one-shot) ──
220
297
  // This happens after newSession completes so session-switch agent_end events
221
298
  // from the previous session cannot resolve the new unit.
299
+ _sessionSwitchInFlight = false;
222
300
  const unitPromise = new Promise<UnitResult>((resolve) => {
223
- s.pendingResolve = resolve;
301
+ _currentResolve = resolve;
224
302
  });
225
303
 
226
304
  // Ensure cwd matches basePath before dispatch (#1389).
@@ -250,6 +328,20 @@ export async function runUnit(
250
328
  status: result.status,
251
329
  });
252
330
 
331
+ // Discard trailing follow-up messages (e.g. async_job_result notifications)
332
+ // from the completed unit. Without this, queued follow-ups trigger wasteful
333
+ // LLM turns before the next session can start (#1642).
334
+ // clearQueue() lives on AgentSession but isn't part of the typed
335
+ // ExtensionCommandContext interface — call it via runtime check.
336
+ try {
337
+ const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
338
+ if (typeof cmdCtxAny?.clearQueue === "function") {
339
+ (cmdCtxAny.clearQueue as () => unknown)();
340
+ }
341
+ } catch {
342
+ // Non-fatal — clearQueue may not be available in all contexts
343
+ }
344
+
253
345
  return result;
254
346
  }
255
347
 
@@ -383,6 +475,7 @@ export interface LoopDeps {
383
475
  midTitle: string;
384
476
  state: GSDState;
385
477
  prefs: GSDPreferences | undefined;
478
+ session?: AutoSession;
386
479
  }) => Promise<DispatchAction>;
387
480
  runPreDispatchHooks: (
388
481
  unitType: string,
@@ -500,6 +593,7 @@ export interface LoopDeps {
500
593
  // Post-unit processing
501
594
  postUnitPreVerification: (
502
595
  pctx: PostUnitContext,
596
+ opts?: PreVerificationOpts,
503
597
  ) => Promise<"dispatched" | "continue">;
504
598
  runPostUnitVerification: (
505
599
  vctx: VerificationContext,
@@ -513,1193 +607,1246 @@ export interface LoopDeps {
513
607
  getSessionFile: (ctx: ExtensionContext) => string;
514
608
  }
515
609
 
516
- // ─── autoLoop ────────────────────────────────────────────────────────────────
610
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
517
611
 
518
612
  /**
519
- * Main auto-mode execution loop. Iterates: derive dispatch → guards →
520
- * runUnit finalize repeat. Exits when s.active becomes false or a
521
- * terminal condition is reached.
522
- *
523
- * This is the linear replacement for the recursive
524
- * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain.
613
+ * Generate and write an HTML milestone report snapshot.
614
+ * Extracted from the milestone-transition block in autoLoop.
525
615
  */
526
- export async function autoLoop(
616
+ async function generateMilestoneReport(
617
+ s: AutoSession,
618
+ ctx: ExtensionContext,
619
+ milestoneId: string,
620
+ ): Promise<void> {
621
+ const { loadVisualizerData } = await importExtensionModule<typeof import("./visualizer-data.js")>(import.meta.url, "./visualizer-data.js");
622
+ const { generateHtmlReport } = await importExtensionModule<typeof import("./export-html.js")>(import.meta.url, "./export-html.js");
623
+ const { writeReportSnapshot } = await importExtensionModule<typeof import("./reports.js")>(import.meta.url, "./reports.js");
624
+ const { basename } = await import("node:path");
625
+
626
+ const snapData = await loadVisualizerData(s.basePath);
627
+ const completedMs = snapData.milestones.find(
628
+ (m: { id: string }) => m.id === milestoneId,
629
+ );
630
+ const msTitle = completedMs?.title ?? milestoneId;
631
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
632
+ const projName = basename(s.basePath);
633
+ const doneSlices = snapData.milestones.reduce(
634
+ (acc: number, m: { slices: { done: boolean }[] }) =>
635
+ acc + m.slices.filter((sl: { done: boolean }) => sl.done).length,
636
+ 0,
637
+ );
638
+ const totalSlices = snapData.milestones.reduce(
639
+ (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
640
+ 0,
641
+ );
642
+ const outPath = writeReportSnapshot({
643
+ basePath: s.basePath,
644
+ html: generateHtmlReport(snapData, {
645
+ projectName: projName,
646
+ projectPath: s.basePath,
647
+ gsdVersion,
648
+ milestoneId,
649
+ indexRelPath: "index.html",
650
+ }),
651
+ milestoneId,
652
+ milestoneTitle: msTitle,
653
+ kind: "milestone",
654
+ projectName: projName,
655
+ projectPath: s.basePath,
656
+ gsdVersion,
657
+ totalCost: snapData.totals?.cost ?? 0,
658
+ totalTokens: snapData.totals?.tokens.total ?? 0,
659
+ totalDuration: snapData.totals?.duration ?? 0,
660
+ doneSlices,
661
+ totalSlices,
662
+ doneMilestones: snapData.milestones.filter(
663
+ (m: { status: string }) => m.status === "complete",
664
+ ).length,
665
+ totalMilestones: snapData.milestones.length,
666
+ phase: snapData.phase,
667
+ });
668
+ ctx.ui.notify(
669
+ `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
670
+ "info",
671
+ );
672
+ }
673
+
674
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
675
+
676
+ /**
677
+ * If a unit is in-flight, close it out, then stop auto-mode.
678
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
679
+ */
680
+ async function closeoutAndStop(
527
681
  ctx: ExtensionContext,
528
682
  pi: ExtensionAPI,
529
683
  s: AutoSession,
530
684
  deps: LoopDeps,
685
+ reason: string,
531
686
  ): Promise<void> {
532
- debugLog("autoLoop", { phase: "enter" });
533
- _activeSession = s;
534
- let iteration = 0;
535
- let lastDerivedUnit = "";
536
- let sameUnitCount = 0;
687
+ if (s.currentUnit) {
688
+ await deps.closeoutUnit(
689
+ ctx,
690
+ s.basePath,
691
+ s.currentUnit.type,
692
+ s.currentUnit.id,
693
+ s.currentUnit.startedAt,
694
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
695
+ );
696
+ }
697
+ await deps.stopAuto(ctx, pi, reason);
698
+ }
537
699
 
538
- let consecutiveErrors = 0;
700
+ // ─── runPreDispatch ───────────────────────────────────────────────────────────
539
701
 
540
- while (s.active) {
541
- iteration++;
542
- debugLog("autoLoop", { phase: "loop-top", iteration });
702
+ /**
703
+ * Phase 1: Pre-dispatch — resource guard, health gate, state derivation,
704
+ * milestone transition, terminal conditions.
705
+ * Returns break to exit the loop, or next with PreDispatchData on success.
706
+ */
707
+ async function runPreDispatch(
708
+ ic: IterationContext,
709
+ loopState: LoopState,
710
+ ): Promise<PhaseResult<PreDispatchData>> {
711
+ const { ctx, pi, s, deps, prefs } = ic;
543
712
 
544
- if (iteration > MAX_LOOP_ITERATIONS) {
545
- debugLog("autoLoop", {
546
- phase: "exit",
547
- reason: "max-iterations",
548
- iteration,
549
- });
550
- await deps.stopAuto(
551
- ctx,
552
- pi,
553
- `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
713
+ // Resource version guard
714
+ const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
715
+ if (staleMsg) {
716
+ await deps.stopAuto(ctx, pi, staleMsg);
717
+ debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
718
+ return { action: "break", reason: "resources-stale" };
719
+ }
720
+
721
+ deps.invalidateAllCaches();
722
+ s.lastPromptCharCount = undefined;
723
+ s.lastBaselineCharCount = undefined;
724
+
725
+ // Pre-dispatch health gate
726
+ try {
727
+ const healthGate = await deps.preDispatchHealthGate(s.basePath);
728
+ if (healthGate.fixesApplied.length > 0) {
729
+ ctx.ui.notify(
730
+ `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`,
731
+ "info",
554
732
  );
555
- break;
556
733
  }
557
-
558
- if (!s.cmdCtx) {
559
- debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
560
- break;
734
+ if (!healthGate.proceed) {
735
+ ctx.ui.notify(
736
+ healthGate.reason ?? "Pre-dispatch health check failed.",
737
+ "error",
738
+ );
739
+ await deps.pauseAuto(ctx, pi);
740
+ debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
741
+ return { action: "break", reason: "health-gate-failed" };
561
742
  }
743
+ } catch {
744
+ // Non-fatal
745
+ }
562
746
 
563
- try {
564
- // ── Blanket try/catch: one bad iteration must not kill the session
565
-
566
- const sessionLockBase = deps.lockBase();
567
- if (sessionLockBase) {
568
- const lockStatus = deps.validateSessionLock(sessionLockBase);
569
- if (!lockStatus.valid) {
570
- debugLog("autoLoop", {
571
- phase: "session-lock-invalid",
572
- reason: lockStatus.failureReason ?? "unknown",
573
- existingPid: lockStatus.existingPid,
574
- expectedPid: lockStatus.expectedPid,
575
- });
576
- deps.handleLostSessionLock(ctx, lockStatus);
577
- debugLog("autoLoop", {
578
- phase: "exit",
579
- reason: "session-lock-lost",
580
- detail: lockStatus.failureReason ?? "unknown",
581
- });
582
- break;
583
- }
584
- }
747
+ // Sync project root artifacts into worktree
748
+ if (
749
+ s.originalBasePath &&
750
+ s.basePath !== s.originalBasePath &&
751
+ s.currentMilestoneId
752
+ ) {
753
+ deps.syncProjectRootToWorktree(
754
+ s.originalBasePath,
755
+ s.basePath,
756
+ s.currentMilestoneId,
757
+ );
758
+ }
585
759
 
586
- // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
760
+ // Derive state
761
+ let state = await deps.deriveState(s.basePath);
762
+ deps.syncCmuxSidebar(prefs, state);
763
+ let mid = state.activeMilestone?.id;
764
+ let midTitle = state.activeMilestone?.title;
765
+ debugLog("autoLoop", {
766
+ phase: "state-derived",
767
+ iteration: ic.iteration,
768
+ mid,
769
+ statePhase: state.phase,
770
+ });
587
771
 
588
- // Resource version guard
589
- const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
590
- if (staleMsg) {
591
- await deps.stopAuto(ctx, pi, staleMsg);
592
- debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
593
- break;
772
+ // ── Milestone transition ────────────────────────────────────────────
773
+ if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
774
+ ctx.ui.notify(
775
+ `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
776
+ "info",
777
+ );
778
+ deps.sendDesktopNotification(
779
+ "GSD",
780
+ `Milestone ${s.currentMilestoneId} complete!`,
781
+ "success",
782
+ "milestone",
783
+ );
784
+ deps.logCmuxEvent(
785
+ prefs,
786
+ `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
787
+ "success",
788
+ );
789
+
790
+ const vizPrefs = prefs;
791
+ if (vizPrefs?.auto_visualize) {
792
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
793
+ }
794
+ if (vizPrefs?.auto_report !== false) {
795
+ try {
796
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
797
+ } catch (err) {
798
+ ctx.ui.notify(
799
+ `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
800
+ "warning",
801
+ );
594
802
  }
803
+ }
595
804
 
596
- deps.invalidateAllCaches();
597
- s.lastPromptCharCount = undefined;
598
- s.lastBaselineCharCount = undefined;
805
+ // Reset dispatch counters for new milestone
806
+ s.unitDispatchCount.clear();
807
+ s.unitRecoveryCount.clear();
808
+ s.unitLifetimeDispatches.clear();
809
+ loopState.recentUnits.length = 0;
810
+ loopState.stuckRecoveryAttempts = 0;
599
811
 
600
- // Pre-dispatch health gate
812
+ // Worktree lifecycle on milestone transition — merge current, enter next
813
+ deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
814
+
815
+ // Opt-in: create draft PR on milestone completion
816
+ if (prefs?.git?.auto_pr) {
601
817
  try {
602
- const healthGate = await deps.preDispatchHealthGate(s.basePath);
603
- if (healthGate.fixesApplied.length > 0) {
604
- ctx.ui.notify(
605
- `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`,
606
- "info",
607
- );
608
- }
609
- if (!healthGate.proceed) {
610
- ctx.ui.notify(
611
- healthGate.reason ?? "Pre-dispatch health check failed.",
612
- "error",
613
- );
614
- await deps.pauseAuto(ctx, pi);
615
- debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
616
- break;
818
+ const { createDraftPR } = await import("./git-service.js");
819
+ const prUrl = createDraftPR(
820
+ s.basePath,
821
+ s.currentMilestoneId!,
822
+ `[GSD] ${s.currentMilestoneId} complete`,
823
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
824
+ );
825
+ if (prUrl) {
826
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
617
827
  }
618
828
  } catch {
619
- // Non-fatal
829
+ // Non-fatal — PR creation is best-effort
620
830
  }
831
+ }
621
832
 
622
- // Sync project root artifacts into worktree
623
- if (
624
- s.originalBasePath &&
625
- s.basePath !== s.originalBasePath &&
626
- s.currentMilestoneId
627
- ) {
628
- deps.syncProjectRootToWorktree(
629
- s.originalBasePath,
630
- s.basePath,
631
- s.currentMilestoneId,
632
- );
833
+ deps.invalidateAllCaches();
834
+
835
+ state = await deps.deriveState(s.basePath);
836
+ mid = state.activeMilestone?.id;
837
+ midTitle = state.activeMilestone?.title;
838
+
839
+ if (mid) {
840
+ if (deps.getIsolationMode() !== "none") {
841
+ deps.captureIntegrationBranch(s.basePath, mid, {
842
+ commitDocs: prefs?.git?.commit_docs,
843
+ });
633
844
  }
845
+ deps.resolver.enterMilestone(mid, ctx.ui);
846
+ } else {
847
+ // mid is undefined — no milestone to capture integration branch for
848
+ }
634
849
 
635
- // Derive state
636
- let state = await deps.deriveState(s.basePath);
637
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
638
- let mid = state.activeMilestone?.id;
639
- let midTitle = state.activeMilestone?.title;
640
- debugLog("autoLoop", {
641
- phase: "state-derived",
642
- iteration,
643
- mid,
644
- statePhase: state.phase,
645
- });
850
+ const pendingIds = state.registry
851
+ .filter(
852
+ (m: { status: string }) =>
853
+ m.status !== "complete" && m.status !== "parked",
854
+ )
855
+ .map((m: { id: string }) => m.id);
856
+ deps.pruneQueueOrder(s.basePath, pendingIds);
857
+ }
646
858
 
647
- // ── Milestone transition ────────────────────────────────────────────
648
- if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
649
- ctx.ui.notify(
650
- `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
651
- "info",
652
- );
653
- deps.sendDesktopNotification(
654
- "GSD",
655
- `Milestone ${s.currentMilestoneId} complete!`,
656
- "success",
657
- "milestone",
658
- );
659
- deps.logCmuxEvent(
660
- deps.loadEffectiveGSDPreferences()?.preferences,
661
- `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
662
- "success",
663
- );
859
+ if (mid) {
860
+ s.currentMilestoneId = mid;
861
+ deps.setActiveMilestoneId(s.basePath, mid);
862
+ }
664
863
 
665
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
666
- if (vizPrefs?.auto_visualize) {
667
- ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
668
- }
669
- if (vizPrefs?.auto_report !== false) {
864
+ // ── Terminal conditions ──────────────────────────────────────────────
865
+
866
+ if (!mid) {
867
+ if (s.currentUnit) {
868
+ await deps.closeoutUnit(
869
+ ctx,
870
+ s.basePath,
871
+ s.currentUnit.type,
872
+ s.currentUnit.id,
873
+ s.currentUnit.startedAt,
874
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
875
+ );
876
+ }
877
+
878
+ const incomplete = state.registry.filter(
879
+ (m: { status: string }) =>
880
+ m.status !== "complete" && m.status !== "parked",
881
+ );
882
+ if (incomplete.length === 0 && state.registry.length > 0) {
883
+ // All milestones complete — merge milestone branch before stopping
884
+ if (s.currentMilestoneId) {
885
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
886
+
887
+ // Opt-in: create draft PR on milestone completion
888
+ if (prefs?.git?.auto_pr) {
670
889
  try {
671
- const { loadVisualizerData } = await import("./visualizer-data.js");
672
- const { generateHtmlReport } = await import("./export-html.js");
673
- const { writeReportSnapshot } = await import("./reports.js");
674
- const { basename } = await import("node:path");
675
- const snapData = await loadVisualizerData(s.basePath);
676
- const completedMs = snapData.milestones.find(
677
- (m: { id: string }) => m.id === s.currentMilestoneId,
678
- );
679
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
680
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
681
- const projName = basename(s.basePath);
682
- const doneSlices = snapData.milestones.reduce(
683
- (acc: number, m: { slices: { done: boolean }[] }) =>
684
- acc +
685
- m.slices.filter((sl: { done: boolean }) => sl.done).length,
686
- 0,
687
- );
688
- const totalSlices = snapData.milestones.reduce(
689
- (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
690
- 0,
691
- );
692
- const outPath = writeReportSnapshot({
693
- basePath: s.basePath,
694
- html: generateHtmlReport(snapData, {
695
- projectName: projName,
696
- projectPath: s.basePath,
697
- gsdVersion,
698
- milestoneId: s.currentMilestoneId,
699
- indexRelPath: "index.html",
700
- }),
701
- milestoneId: s.currentMilestoneId!,
702
- milestoneTitle: msTitle,
703
- kind: "milestone",
704
- projectName: projName,
705
- projectPath: s.basePath,
706
- gsdVersion,
707
- totalCost: snapData.totals?.cost ?? 0,
708
- totalTokens: snapData.totals?.tokens.total ?? 0,
709
- totalDuration: snapData.totals?.duration ?? 0,
710
- doneSlices,
711
- totalSlices,
712
- doneMilestones: snapData.milestones.filter(
713
- (m: { status: string }) => m.status === "complete",
714
- ).length,
715
- totalMilestones: snapData.milestones.length,
716
- phase: snapData.phase,
717
- });
718
- ctx.ui.notify(
719
- `Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`,
720
- "info",
721
- );
722
- } catch (err) {
723
- ctx.ui.notify(
724
- `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
725
- "warning",
890
+ const { createDraftPR } = await import("./git-service.js");
891
+ const prUrl = createDraftPR(
892
+ s.basePath,
893
+ s.currentMilestoneId,
894
+ `[GSD] ${s.currentMilestoneId} complete`,
895
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
726
896
  );
897
+ if (prUrl) {
898
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
899
+ }
900
+ } catch {
901
+ // Non-fatal — PR creation is best-effort
727
902
  }
728
903
  }
904
+ }
905
+ deps.sendDesktopNotification(
906
+ "GSD",
907
+ "All milestones complete!",
908
+ "success",
909
+ "milestone",
910
+ );
911
+ deps.logCmuxEvent(
912
+ prefs,
913
+ "All milestones complete.",
914
+ "success",
915
+ );
916
+ await deps.stopAuto(ctx, pi, "All milestones complete");
917
+ } else if (incomplete.length === 0 && state.registry.length === 0) {
918
+ // Empty registry — no milestones visible, likely a path resolution bug
919
+ const diag = `basePath=${s.basePath}, phase=${state.phase}`;
920
+ ctx.ui.notify(
921
+ `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`,
922
+ "error",
923
+ );
924
+ await deps.stopAuto(
925
+ ctx,
926
+ pi,
927
+ `No milestones found — check basePath resolution`,
928
+ );
929
+ } else if (state.phase === "blocked") {
930
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
931
+ await deps.stopAuto(ctx, pi, blockerMsg);
932
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
933
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
934
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
935
+ } else {
936
+ const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
937
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
938
+ ctx.ui.notify(
939
+ `Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`,
940
+ "error",
941
+ );
942
+ await deps.stopAuto(
943
+ ctx,
944
+ pi,
945
+ `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`,
946
+ );
947
+ }
948
+ debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
949
+ return { action: "break", reason: "no-active-milestone" };
950
+ }
951
+
952
+ if (!midTitle) {
953
+ midTitle = mid;
954
+ ctx.ui.notify(
955
+ `Milestone ${mid} has no title in roadmap — using ID as fallback.`,
956
+ "warning",
957
+ );
958
+ }
729
959
 
730
- // Reset dispatch counters for new milestone
731
- s.unitDispatchCount.clear();
732
- s.unitRecoveryCount.clear();
733
- s.unitLifetimeDispatches.clear();
734
- lastDerivedUnit = "";
735
- sameUnitCount = 0;
960
+ // Mid-merge safety check
961
+ if (deps.reconcileMergeState(s.basePath, ctx)) {
962
+ deps.invalidateAllCaches();
963
+ state = await deps.deriveState(s.basePath);
964
+ mid = state.activeMilestone?.id;
965
+ midTitle = state.activeMilestone?.title;
966
+ }
736
967
 
737
- // Worktree lifecycle on milestone transition — merge current, enter next
738
- deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
739
- deps.invalidateAllCaches();
968
+ if (!mid || !midTitle) {
969
+ const noMilestoneReason = !mid
970
+ ? "No active milestone after merge reconciliation"
971
+ : `Milestone ${mid} has no title after reconciliation`;
972
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
973
+ debugLog("autoLoop", {
974
+ phase: "exit",
975
+ reason: "no-milestone-after-reconciliation",
976
+ });
977
+ return { action: "break", reason: "no-milestone-after-reconciliation" };
978
+ }
979
+
980
+ // Terminal: complete
981
+ if (state.phase === "complete") {
982
+ // Milestone merge on complete (before closeout so branch state is clean)
983
+ if (s.currentMilestoneId) {
984
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
740
985
 
741
- state = await deps.deriveState(s.basePath);
742
- mid = state.activeMilestone?.id;
743
- midTitle = state.activeMilestone?.title;
744
-
745
- if (mid) {
746
- if (deps.getIsolationMode() !== "none") {
747
- deps.captureIntegrationBranch(s.basePath, mid, {
748
- commitDocs:
749
- deps.loadEffectiveGSDPreferences()?.preferences?.git
750
- ?.commit_docs,
751
- });
986
+ // Opt-in: create draft PR on milestone completion
987
+ if (prefs?.git?.auto_pr) {
988
+ try {
989
+ const { createDraftPR } = await import("./git-service.js");
990
+ const prUrl = createDraftPR(
991
+ s.basePath,
992
+ s.currentMilestoneId,
993
+ `[GSD] ${s.currentMilestoneId} complete`,
994
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
995
+ );
996
+ if (prUrl) {
997
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
752
998
  }
753
- deps.resolver.enterMilestone(mid, ctx.ui);
754
- } else {
755
- // mid is undefined — no milestone to capture integration branch for
999
+ } catch {
1000
+ // Non-fatal — PR creation is best-effort
756
1001
  }
757
-
758
- const pendingIds = state.registry
759
- .filter(
760
- (m: { status: string }) =>
761
- m.status !== "complete" && m.status !== "parked",
762
- )
763
- .map((m: { id: string }) => m.id);
764
- deps.pruneQueueOrder(s.basePath, pendingIds);
765
1002
  }
1003
+ }
1004
+ deps.sendDesktopNotification(
1005
+ "GSD",
1006
+ `Milestone ${mid} complete!`,
1007
+ "success",
1008
+ "milestone",
1009
+ );
1010
+ deps.logCmuxEvent(
1011
+ prefs,
1012
+ `Milestone ${mid} complete.`,
1013
+ "success",
1014
+ );
1015
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
1016
+ debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
1017
+ return { action: "break", reason: "milestone-complete" };
1018
+ }
766
1019
 
767
- if (mid) {
768
- s.currentMilestoneId = mid;
769
- deps.setActiveMilestoneId(s.basePath, mid);
770
- }
1020
+ // Terminal: blocked
1021
+ if (state.phase === "blocked") {
1022
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
1023
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
1024
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
1025
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
1026
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
1027
+ debugLog("autoLoop", { phase: "exit", reason: "blocked" });
1028
+ return { action: "break", reason: "blocked" };
1029
+ }
1030
+
1031
+ return { action: "next", data: { state, mid, midTitle } };
1032
+ }
771
1033
 
772
- // ── Terminal conditions ──────────────────────────────────────────────
1034
+ // ─── runDispatch ──────────────────────────────────────────────────────────────
773
1035
 
774
- if (!mid) {
775
- if (s.currentUnit) {
776
- await deps.closeoutUnit(
777
- ctx,
778
- s.basePath,
779
- s.currentUnit.type,
780
- s.currentUnit.id,
781
- s.currentUnit.startedAt,
782
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
783
- );
784
- }
1036
+ /**
1037
+ * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks.
1038
+ * Returns break/continue to control the loop, or next with IterationData on success.
1039
+ */
1040
+ async function runDispatch(
1041
+ ic: IterationContext,
1042
+ preData: PreDispatchData,
1043
+ loopState: LoopState,
1044
+ ): Promise<PhaseResult<IterationData>> {
1045
+ const { ctx, pi, s, deps, prefs } = ic;
1046
+ const { state, mid, midTitle } = preData;
1047
+ const STUCK_WINDOW_SIZE = 6;
1048
+
1049
+ debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration });
1050
+ const dispatchResult = await deps.resolveDispatch({
1051
+ basePath: s.basePath,
1052
+ mid,
1053
+ midTitle,
1054
+ state,
1055
+ prefs,
1056
+ session: s,
1057
+ });
1058
+
1059
+ if (dispatchResult.action === "stop") {
1060
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1061
+ debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1062
+ return { action: "break", reason: "dispatch-stop" };
1063
+ }
1064
+
1065
+ if (dispatchResult.action !== "dispatch") {
1066
+ // Non-dispatch action (e.g. "skip") — re-derive state
1067
+ await new Promise((r) => setImmediate(r));
1068
+ return { action: "continue" };
1069
+ }
1070
+
1071
+ let unitType = dispatchResult.unitType;
1072
+ let unitId = dispatchResult.unitId;
1073
+ let prompt = dispatchResult.prompt;
1074
+ const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
785
1075
 
786
- const incomplete = state.registry.filter(
787
- (m: { status: string }) =>
788
- m.status !== "complete" && m.status !== "parked",
1076
+ // ── Sliding-window stuck detection with graduated recovery ──
1077
+ const derivedKey = `${unitType}/${unitId}`;
1078
+
1079
+ if (!s.pendingVerificationRetry) {
1080
+ loopState.recentUnits.push({ key: derivedKey });
1081
+ if (loopState.recentUnits.length > STUCK_WINDOW_SIZE) loopState.recentUnits.shift();
1082
+
1083
+ const stuckSignal = detectStuck(loopState.recentUnits);
1084
+ if (stuckSignal) {
1085
+ debugLog("autoLoop", {
1086
+ phase: "stuck-check",
1087
+ unitType,
1088
+ unitId,
1089
+ reason: stuckSignal.reason,
1090
+ recoveryAttempts: loopState.stuckRecoveryAttempts,
1091
+ });
1092
+
1093
+ if (loopState.stuckRecoveryAttempts === 0) {
1094
+ // Level 1: try verifying the artifact, then cache invalidation + retry
1095
+ loopState.stuckRecoveryAttempts++;
1096
+ const artifactExists = deps.verifyExpectedArtifact(
1097
+ unitType,
1098
+ unitId,
1099
+ s.basePath,
789
1100
  );
790
- if (incomplete.length === 0) {
791
- // All milestones complete — merge milestone branch before stopping
792
- if (s.currentMilestoneId) {
793
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
794
- }
795
- deps.sendDesktopNotification(
796
- "GSD",
797
- "All milestones complete!",
798
- "success",
799
- "milestone",
800
- );
801
- deps.logCmuxEvent(
802
- deps.loadEffectiveGSDPreferences()?.preferences,
803
- "All milestones complete.",
804
- "success",
805
- );
806
- await deps.stopAuto(ctx, pi, "All milestones complete");
807
- } else if (state.phase === "blocked") {
808
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
809
- await deps.stopAuto(ctx, pi, blockerMsg);
810
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
811
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
812
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
813
- } else {
814
- const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
815
- const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1101
+ if (artifactExists) {
1102
+ debugLog("autoLoop", {
1103
+ phase: "stuck-recovery",
1104
+ level: 1,
1105
+ action: "artifact-found",
1106
+ });
816
1107
  ctx.ui.notify(
817
- `Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`,
818
- "error",
819
- );
820
- await deps.stopAuto(
821
- ctx,
822
- pi,
823
- `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`,
1108
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1109
+ "info",
824
1110
  );
1111
+ deps.invalidateAllCaches();
1112
+ return { action: "continue" };
825
1113
  }
826
- debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
827
- break;
828
- }
829
-
830
- if (!midTitle) {
831
- midTitle = mid;
832
1114
  ctx.ui.notify(
833
- `Milestone ${mid} has no title in roadmap — using ID as fallback.`,
1115
+ `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
834
1116
  "warning",
835
1117
  );
836
- }
837
-
838
- // Mid-merge safety check
839
- if (deps.reconcileMergeState(s.basePath, ctx)) {
840
1118
  deps.invalidateAllCaches();
841
- state = await deps.deriveState(s.basePath);
842
- mid = state.activeMilestone?.id;
843
- midTitle = state.activeMilestone?.title;
844
- }
845
-
846
- if (!mid || !midTitle) {
847
- if (s.currentUnit) {
848
- await deps.closeoutUnit(
849
- ctx,
850
- s.basePath,
851
- s.currentUnit.type,
852
- s.currentUnit.id,
853
- s.currentUnit.startedAt,
854
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
855
- );
856
- }
857
- const noMilestoneReason = !mid
858
- ? "No active milestone after merge reconciliation"
859
- : `Milestone ${mid} has no title after reconciliation`;
860
- await deps.stopAuto(ctx, pi, noMilestoneReason);
1119
+ } else {
1120
+ // Level 2: hard stop — genuinely stuck
861
1121
  debugLog("autoLoop", {
862
- phase: "exit",
863
- reason: "no-milestone-after-reconciliation",
1122
+ phase: "stuck-detected",
1123
+ unitType,
1124
+ unitId,
1125
+ reason: stuckSignal.reason,
864
1126
  });
865
- break;
866
- }
867
-
868
- // Terminal: complete
869
- if (state.phase === "complete") {
870
- if (s.currentUnit) {
871
- await deps.closeoutUnit(
872
- ctx,
873
- s.basePath,
874
- s.currentUnit.type,
875
- s.currentUnit.id,
876
- s.currentUnit.startedAt,
877
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
878
- );
879
- }
880
- // Milestone merge on complete
881
- if (s.currentMilestoneId) {
882
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
883
- }
884
- deps.sendDesktopNotification(
885
- "GSD",
886
- `Milestone ${mid} complete!`,
887
- "success",
888
- "milestone",
1127
+ await deps.stopAuto(
1128
+ ctx,
1129
+ pi,
1130
+ `Stuck: ${stuckSignal.reason}`,
889
1131
  );
890
- deps.logCmuxEvent(
891
- deps.loadEffectiveGSDPreferences()?.preferences,
892
- `Milestone ${mid} complete.`,
893
- "success",
1132
+ ctx.ui.notify(
1133
+ `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`,
1134
+ "error",
894
1135
  );
895
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
896
- debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
897
- break;
1136
+ return { action: "break", reason: "stuck-detected" };
898
1137
  }
899
-
900
- // Terminal: blocked
901
- if (state.phase === "blocked") {
902
- if (s.currentUnit) {
903
- await deps.closeoutUnit(
904
- ctx,
905
- s.basePath,
906
- s.currentUnit.type,
907
- s.currentUnit.id,
908
- s.currentUnit.startedAt,
909
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
910
- );
911
- }
912
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
913
- await deps.stopAuto(ctx, pi, blockerMsg);
914
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
915
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
916
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
917
- debugLog("autoLoop", { phase: "exit", reason: "blocked" });
918
- break;
1138
+ } else {
1139
+ // Progress detected — reset recovery counter
1140
+ if (loopState.stuckRecoveryAttempts > 0) {
1141
+ debugLog("autoLoop", {
1142
+ phase: "stuck-counter-reset",
1143
+ from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "",
1144
+ to: derivedKey,
1145
+ });
1146
+ loopState.stuckRecoveryAttempts = 0;
919
1147
  }
1148
+ }
1149
+ }
920
1150
 
921
- // ── Phase 2: Guards ─────────────────────────────────────────────────
1151
+ // Pre-dispatch hooks
1152
+ const preDispatchResult = deps.runPreDispatchHooks(
1153
+ unitType,
1154
+ unitId,
1155
+ prompt,
1156
+ s.basePath,
1157
+ );
1158
+ if (preDispatchResult.firedHooks.length > 0) {
1159
+ ctx.ui.notify(
1160
+ `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
1161
+ "info",
1162
+ );
1163
+ }
1164
+ if (preDispatchResult.action === "skip") {
1165
+ ctx.ui.notify(
1166
+ `Skipping ${unitType} ${unitId} (pre-dispatch hook).`,
1167
+ "info",
1168
+ );
1169
+ await new Promise((r) => setImmediate(r));
1170
+ return { action: "continue" };
1171
+ }
1172
+ if (preDispatchResult.action === "replace") {
1173
+ prompt = preDispatchResult.prompt ?? prompt;
1174
+ if (preDispatchResult.unitType) unitType = preDispatchResult.unitType;
1175
+ } else if (preDispatchResult.prompt) {
1176
+ prompt = preDispatchResult.prompt;
1177
+ }
922
1178
 
923
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
1179
+ const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(
1180
+ s.basePath,
1181
+ deps.getMainBranch(s.basePath),
1182
+ unitType,
1183
+ unitId,
1184
+ );
1185
+ if (priorSliceBlocker) {
1186
+ await deps.stopAuto(ctx, pi, priorSliceBlocker);
1187
+ debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
1188
+ return { action: "break", reason: "prior-slice-blocker" };
1189
+ }
924
1190
 
925
- // Budget ceiling guard
926
- const budgetCeiling = prefs?.budget_ceiling;
927
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
928
- const currentLedger = deps.getLedger() as { units: unknown } | null;
929
- const totalCost = currentLedger
930
- ? deps.getProjectTotals(currentLedger.units).cost
931
- : 0;
932
- const budgetPct = totalCost / budgetCeiling;
933
- const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
934
- const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(
935
- s.lastBudgetAlertLevel,
936
- budgetPct,
937
- );
938
- const enforcement = prefs?.budget_enforcement ?? "pause";
939
- const budgetEnforcementAction = deps.getBudgetEnforcementAction(
940
- enforcement,
941
- budgetPct,
942
- );
1191
+ const observabilityIssues = await deps.collectObservabilityWarnings(
1192
+ ctx,
1193
+ s.basePath,
1194
+ unitType,
1195
+ unitId,
1196
+ );
943
1197
 
944
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
945
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
946
- s.lastBudgetAlertLevel =
947
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
948
- if (budgetEnforcementAction === "halt") {
949
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
950
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
951
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
952
- break;
953
- }
954
- if (budgetEnforcementAction === "pause") {
955
- ctx.ui.notify(
956
- `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
957
- "warning",
958
- );
959
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
960
- deps.logCmuxEvent(prefs, msg, "warning");
961
- await deps.pauseAuto(ctx, pi);
962
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
963
- break;
964
- }
965
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
966
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
967
- deps.logCmuxEvent(prefs, msg, "warning");
968
- } else if (newBudgetAlertLevel === 90) {
969
- s.lastBudgetAlertLevel =
970
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
971
- ctx.ui.notify(
972
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
973
- "warning",
974
- );
975
- deps.sendDesktopNotification(
976
- "GSD",
977
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
978
- "warning",
979
- "budget",
980
- );
981
- deps.logCmuxEvent(
982
- prefs,
983
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
984
- "warning",
985
- );
986
- } else if (newBudgetAlertLevel === 80) {
987
- s.lastBudgetAlertLevel =
988
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
989
- ctx.ui.notify(
990
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
991
- "warning",
992
- );
993
- deps.sendDesktopNotification(
994
- "GSD",
995
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
996
- "warning",
997
- "budget",
998
- );
999
- deps.logCmuxEvent(
1000
- prefs,
1001
- `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1002
- "warning",
1003
- );
1004
- } else if (newBudgetAlertLevel === 75) {
1005
- s.lastBudgetAlertLevel =
1006
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1007
- ctx.ui.notify(
1008
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1009
- "info",
1010
- );
1011
- deps.sendDesktopNotification(
1012
- "GSD",
1013
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1014
- "info",
1015
- "budget",
1016
- );
1017
- deps.logCmuxEvent(
1018
- prefs,
1019
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1020
- "progress",
1021
- );
1022
- } else if (budgetAlertLevel === 0) {
1023
- s.lastBudgetAlertLevel = 0;
1024
- }
1025
- } else {
1026
- s.lastBudgetAlertLevel = 0;
1027
- }
1198
+ return {
1199
+ action: "next",
1200
+ data: {
1201
+ unitType, unitId, prompt, finalPrompt: prompt,
1202
+ pauseAfterUatDispatch, observabilityIssues,
1203
+ state, mid, midTitle,
1204
+ isRetry: false, previousTier: undefined,
1205
+ },
1206
+ };
1207
+ }
1028
1208
 
1029
- // Context window guard
1030
- const contextThreshold = prefs?.context_pause_threshold ?? 0;
1031
- if (contextThreshold > 0 && s.cmdCtx) {
1032
- const contextUsage = s.cmdCtx.getContextUsage();
1033
- if (
1034
- contextUsage &&
1035
- contextUsage.percent !== null &&
1036
- contextUsage.percent >= contextThreshold
1037
- ) {
1038
- const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
1209
+ // ─── runGuards ────────────────────────────────────────────────────────────────
1210
+
1211
+ /**
1212
+ * Phase 2: Guards — budget ceiling, context window, secrets re-check.
1213
+ * Returns break to exit the loop, or next to proceed to dispatch.
1214
+ */
1215
+ async function runGuards(
1216
+ ic: IterationContext,
1217
+ mid: string,
1218
+ ): Promise<PhaseResult> {
1219
+ const { ctx, pi, s, deps, prefs } = ic;
1220
+
1221
+ // Budget ceiling guard
1222
+ const budgetCeiling = prefs?.budget_ceiling;
1223
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
1224
+ const currentLedger = deps.getLedger() as { units: unknown } | null;
1225
+ const totalCost = currentLedger
1226
+ ? deps.getProjectTotals(currentLedger.units).cost
1227
+ : 0;
1228
+ const budgetPct = totalCost / budgetCeiling;
1229
+ const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
1230
+ const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(
1231
+ s.lastBudgetAlertLevel,
1232
+ budgetPct,
1233
+ );
1234
+ const enforcement = prefs?.budget_enforcement ?? "pause";
1235
+ const budgetEnforcementAction = deps.getBudgetEnforcementAction(
1236
+ enforcement,
1237
+ budgetPct,
1238
+ );
1239
+
1240
+ // Data-driven threshold check — loop descending, fire first match
1241
+ const threshold = BUDGET_THRESHOLDS.find(
1242
+ (t) => newBudgetAlertLevel >= t.pct,
1243
+ );
1244
+ if (threshold) {
1245
+ s.lastBudgetAlertLevel =
1246
+ newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1247
+
1248
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
1249
+ // 100% — special enforcement logic (halt/pause/warn)
1250
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1251
+ if (budgetEnforcementAction === "halt") {
1252
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
1253
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1254
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1255
+ return { action: "break", reason: "budget-halt" };
1256
+ }
1257
+ if (budgetEnforcementAction === "pause") {
1039
1258
  ctx.ui.notify(
1040
- `${msg} Run /gsd auto to continue (will start fresh session).`,
1259
+ `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1041
1260
  "warning",
1042
1261
  );
1043
- deps.sendDesktopNotification(
1044
- "GSD",
1045
- `Context ${contextUsage.percent}% — paused`,
1046
- "warning",
1047
- "attention",
1048
- );
1262
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1263
+ deps.logCmuxEvent(prefs, msg, "warning");
1049
1264
  await deps.pauseAuto(ctx, pi);
1050
- debugLog("autoLoop", { phase: "exit", reason: "context-window" });
1051
- break;
1052
- }
1053
- }
1054
-
1055
- // Secrets re-check gate
1056
- try {
1057
- const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
1058
- if (manifestStatus && manifestStatus.pending.length > 0) {
1059
- const result = await deps.collectSecretsFromManifest(
1060
- s.basePath,
1061
- mid,
1062
- ctx,
1063
- );
1064
- if (
1065
- result &&
1066
- result.applied &&
1067
- result.skipped &&
1068
- result.existingSkipped
1069
- ) {
1070
- ctx.ui.notify(
1071
- `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
1072
- "info",
1073
- );
1074
- } else {
1075
- ctx.ui.notify("Secrets collection skipped.", "info");
1076
- }
1265
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1266
+ return { action: "break", reason: "budget-pause" };
1077
1267
  }
1078
- } catch (err) {
1079
- ctx.ui.notify(
1080
- `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
1081
- "warning",
1268
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
1269
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1270
+ deps.logCmuxEvent(prefs, msg, "warning");
1271
+ } else if (threshold.pct < 100) {
1272
+ // Sub-100% — simple notification
1273
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1274
+ ctx.ui.notify(msg, threshold.notifyLevel);
1275
+ deps.sendDesktopNotification(
1276
+ "GSD",
1277
+ msg,
1278
+ threshold.notifyLevel,
1279
+ "budget",
1082
1280
  );
1281
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
1083
1282
  }
1283
+ } else if (budgetAlertLevel === 0) {
1284
+ s.lastBudgetAlertLevel = 0;
1285
+ }
1286
+ } else {
1287
+ s.lastBudgetAlertLevel = 0;
1288
+ }
1084
1289
 
1085
- // ── Phase 3: Dispatch resolution ────────────────────────────────────
1086
-
1087
- debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
1088
- const dispatchResult = await deps.resolveDispatch({
1089
- basePath: s.basePath,
1090
- mid,
1091
- midTitle: midTitle!,
1092
- state,
1093
- prefs,
1094
- });
1095
-
1096
- if (dispatchResult.action === "stop") {
1097
- if (s.currentUnit) {
1098
- await deps.closeoutUnit(
1099
- ctx,
1100
- s.basePath,
1101
- s.currentUnit.type,
1102
- s.currentUnit.id,
1103
- s.currentUnit.startedAt,
1104
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1105
- );
1106
- }
1107
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
1108
- debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1109
- break;
1110
- }
1111
-
1112
- if (dispatchResult.action !== "dispatch") {
1113
- // Non-dispatch action (e.g. "skip") — re-derive state
1114
- await new Promise((r) => setImmediate(r));
1115
- continue;
1116
- }
1117
-
1118
- let unitType = dispatchResult.unitType;
1119
- let unitId = dispatchResult.unitId;
1120
- let prompt = dispatchResult.prompt;
1121
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1122
-
1123
- // ── Same-unit stuck counter with graduated recovery ──
1124
- const derivedKey = `${unitType}/${unitId}`;
1125
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
1126
- sameUnitCount++;
1127
- debugLog("autoLoop", {
1128
- phase: "stuck-check",
1129
- unitType,
1130
- unitId,
1131
- sameUnitCount,
1132
- });
1133
-
1134
- if (sameUnitCount === 3) {
1135
- // Level 1: try verifying the artifact — maybe it was written but not detected
1136
- const artifactExists = deps.verifyExpectedArtifact(
1137
- unitType,
1138
- unitId,
1139
- s.basePath,
1140
- );
1141
- if (artifactExists) {
1142
- debugLog("autoLoop", {
1143
- phase: "stuck-recovery",
1144
- level: 1,
1145
- action: "artifact-found",
1146
- });
1147
- ctx.ui.notify(
1148
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1149
- "info",
1150
- );
1151
- deps.invalidateAllCaches();
1152
- continue;
1153
- }
1154
- ctx.ui.notify(
1155
- `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
1156
- "warning",
1157
- );
1158
- deps.invalidateAllCaches();
1159
- } else if (sameUnitCount === 5) {
1160
- // Level 2: hard stop — genuinely stuck
1161
- debugLog("autoLoop", {
1162
- phase: "stuck-detected",
1163
- unitType,
1164
- unitId,
1165
- sameUnitCount,
1166
- });
1167
- await deps.stopAuto(
1168
- ctx,
1169
- pi,
1170
- `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
1171
- );
1172
- ctx.ui.notify(
1173
- `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
1174
- "error",
1175
- );
1176
- break;
1177
- }
1178
- } else {
1179
- if (derivedKey !== lastDerivedUnit) {
1180
- debugLog("autoLoop", {
1181
- phase: "stuck-counter-reset",
1182
- from: lastDerivedUnit,
1183
- to: derivedKey,
1184
- });
1185
- }
1186
- lastDerivedUnit = derivedKey;
1187
- sameUnitCount = 0;
1188
- }
1290
+ // Context window guard
1291
+ const contextThreshold = prefs?.context_pause_threshold ?? 0;
1292
+ if (contextThreshold > 0 && s.cmdCtx) {
1293
+ const contextUsage = s.cmdCtx.getContextUsage();
1294
+ if (
1295
+ contextUsage &&
1296
+ contextUsage.percent !== null &&
1297
+ contextUsage.percent >= contextThreshold
1298
+ ) {
1299
+ const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
1300
+ ctx.ui.notify(
1301
+ `${msg} Run /gsd auto to continue (will start fresh session).`,
1302
+ "warning",
1303
+ );
1304
+ deps.sendDesktopNotification(
1305
+ "GSD",
1306
+ `Context ${contextUsage.percent}% — paused`,
1307
+ "warning",
1308
+ "attention",
1309
+ );
1310
+ await deps.pauseAuto(ctx, pi);
1311
+ debugLog("autoLoop", { phase: "exit", reason: "context-window" });
1312
+ return { action: "break", reason: "context-window" };
1313
+ }
1314
+ }
1189
1315
 
1190
- // Pre-dispatch hooks
1191
- const preDispatchResult = deps.runPreDispatchHooks(
1192
- unitType,
1193
- unitId,
1194
- prompt,
1316
+ // Secrets re-check gate
1317
+ try {
1318
+ const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
1319
+ if (manifestStatus && manifestStatus.pending.length > 0) {
1320
+ const result = await deps.collectSecretsFromManifest(
1195
1321
  s.basePath,
1322
+ mid,
1323
+ ctx,
1196
1324
  );
1197
- if (preDispatchResult.firedHooks.length > 0) {
1198
- ctx.ui.notify(
1199
- `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
1200
- "info",
1201
- );
1202
- }
1203
- if (preDispatchResult.action === "skip") {
1325
+ if (
1326
+ result &&
1327
+ result.applied &&
1328
+ result.skipped &&
1329
+ result.existingSkipped
1330
+ ) {
1204
1331
  ctx.ui.notify(
1205
- `Skipping ${unitType} ${unitId} (pre-dispatch hook).`,
1332
+ `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
1206
1333
  "info",
1207
1334
  );
1208
- await new Promise((r) => setImmediate(r));
1209
- continue;
1210
- }
1211
- if (preDispatchResult.action === "replace") {
1212
- prompt = preDispatchResult.prompt ?? prompt;
1213
- if (preDispatchResult.unitType) unitType = preDispatchResult.unitType;
1214
- } else if (preDispatchResult.prompt) {
1215
- prompt = preDispatchResult.prompt;
1335
+ } else {
1336
+ ctx.ui.notify("Secrets collection skipped.", "info");
1216
1337
  }
1338
+ }
1339
+ } catch (err) {
1340
+ ctx.ui.notify(
1341
+ `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
1342
+ "warning",
1343
+ );
1344
+ }
1217
1345
 
1218
- const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(
1219
- s.basePath,
1220
- deps.getMainBranch(s.basePath),
1221
- unitType,
1222
- unitId,
1223
- );
1224
- if (priorSliceBlocker) {
1225
- await deps.stopAuto(ctx, pi, priorSliceBlocker);
1226
- debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
1227
- break;
1228
- }
1346
+ return { action: "next", data: undefined as void };
1347
+ }
1229
1348
 
1230
- const observabilityIssues = await deps.collectObservabilityWarnings(
1231
- ctx,
1232
- s.basePath,
1233
- unitType,
1234
- unitId,
1235
- );
1349
+ // ─── runUnitPhase ─────────────────────────────────────────────────────────────
1236
1350
 
1237
- // ── Phase 4: Unit execution ─────────────────────────────────────────
1351
+ /**
1352
+ * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify.
1353
+ * Returns break or next with unitStartedAt for downstream phases.
1354
+ */
1355
+ async function runUnitPhase(
1356
+ ic: IterationContext,
1357
+ iterData: IterationData,
1358
+ loopState: LoopState,
1359
+ sidecarItem?: SidecarItem,
1360
+ ): Promise<PhaseResult<{ unitStartedAt: number }>> {
1361
+ const { ctx, pi, s, deps, prefs } = ic;
1362
+ const { unitType, unitId, prompt, observabilityIssues, state, mid } = iterData;
1363
+
1364
+ debugLog("autoLoop", {
1365
+ phase: "unit-execution",
1366
+ iteration: ic.iteration,
1367
+ unitType,
1368
+ unitId,
1369
+ });
1238
1370
 
1239
- debugLog("autoLoop", {
1240
- phase: "unit-execution",
1241
- iteration,
1242
- unitType,
1243
- unitId,
1244
- });
1371
+ // Detect retry and capture previous tier for escalation
1372
+ const isRetry = !!(
1373
+ s.currentUnit &&
1374
+ s.currentUnit.type === unitType &&
1375
+ s.currentUnit.id === unitId
1376
+ );
1377
+ const previousTier = s.currentUnitRouting?.tier;
1245
1378
 
1246
- // Detect retry and capture previous tier for escalation
1247
- const isRetry = !!(
1248
- s.currentUnit &&
1249
- s.currentUnit.type === unitType &&
1250
- s.currentUnit.id === unitId
1251
- );
1252
- const previousTier = s.currentUnitRouting?.tier;
1379
+ s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1380
+ deps.captureAvailableSkills();
1381
+ deps.writeUnitRuntimeRecord(
1382
+ s.basePath,
1383
+ unitType,
1384
+ unitId,
1385
+ s.currentUnit.startedAt,
1386
+ {
1387
+ phase: "dispatched",
1388
+ wrapupWarningSent: false,
1389
+ timeoutAt: null,
1390
+ lastProgressAt: s.currentUnit.startedAt,
1391
+ progressCount: 0,
1392
+ lastProgressKind: "dispatch",
1393
+ },
1394
+ );
1253
1395
 
1254
- // Closeout previous unit
1255
- if (s.currentUnit) {
1256
- await deps.closeoutUnit(
1257
- ctx,
1258
- s.basePath,
1259
- s.currentUnit.type,
1260
- s.currentUnit.id,
1261
- s.currentUnit.startedAt,
1262
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1263
- );
1396
+ // Status bar + progress widget
1397
+ ctx.ui.setStatus("gsd-auto", "auto");
1398
+ if (mid)
1399
+ deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
1400
+ deps.updateProgressWidget(ctx, unitType, unitId, state);
1401
+
1402
+ deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1403
+
1404
+ // Prompt injection
1405
+ let finalPrompt = prompt;
1406
+
1407
+ if (s.pendingVerificationRetry) {
1408
+ const retryCtx = s.pendingVerificationRetry;
1409
+ s.pendingVerificationRetry = null;
1410
+ const capped =
1411
+ retryCtx.failureContext.length > MAX_RECOVERY_CHARS
1412
+ ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
1413
+ "\n\n[...failure context truncated]"
1414
+ : retryCtx.failureContext;
1415
+ 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}`;
1416
+ }
1264
1417
 
1265
- if (s.currentUnitRouting) {
1266
- const isRetry =
1267
- s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1268
- deps.recordOutcome(
1269
- s.currentUnit.type,
1270
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1271
- !isRetry,
1272
- );
1273
- }
1418
+ if (s.pendingCrashRecovery) {
1419
+ const capped =
1420
+ s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
1421
+ ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
1422
+ "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
1423
+ : s.pendingCrashRecovery;
1424
+ finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
1425
+ s.pendingCrashRecovery = null;
1426
+ } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
1427
+ const diagnostic = deps.getDeepDiagnostic(s.basePath);
1428
+ if (diagnostic) {
1429
+ const cappedDiag =
1430
+ diagnostic.length > MAX_RECOVERY_CHARS
1431
+ ? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
1432
+ "\n\n[...diagnostic truncated to prevent memory exhaustion]"
1433
+ : diagnostic;
1434
+ finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
1435
+ }
1436
+ }
1274
1437
 
1275
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1276
- const incomingKey = `${unitType}/${unitId}`;
1277
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
1278
- const artifactVerified =
1279
- isHookUnit ||
1280
- deps.verifyExpectedArtifact(
1281
- s.currentUnit.type,
1282
- s.currentUnit.id,
1283
- s.basePath,
1284
- );
1285
- if (closeoutKey !== incomingKey && artifactVerified) {
1286
- s.completedUnits.push({
1287
- type: s.currentUnit.type,
1288
- id: s.currentUnit.id,
1289
- startedAt: s.currentUnit.startedAt,
1290
- finishedAt: Date.now(),
1291
- });
1292
- if (s.completedUnits.length > 200) {
1293
- s.completedUnits = s.completedUnits.slice(-200);
1294
- }
1295
- deps.clearUnitRuntimeRecord(
1296
- s.basePath,
1297
- s.currentUnit.type,
1298
- s.currentUnit.id,
1299
- );
1300
- s.unitDispatchCount.delete(
1301
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1302
- );
1303
- s.unitRecoveryCount.delete(
1304
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1305
- );
1306
- }
1307
- }
1438
+ const repairBlock =
1439
+ deps.buildObservabilityRepairBlock(observabilityIssues);
1440
+ if (repairBlock) {
1441
+ finalPrompt = `${finalPrompt}${repairBlock}`;
1442
+ }
1308
1443
 
1309
- s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1310
- deps.captureAvailableSkills();
1311
- deps.writeUnitRuntimeRecord(
1312
- s.basePath,
1313
- unitType,
1314
- unitId,
1315
- s.currentUnit.startedAt,
1316
- {
1317
- phase: "dispatched",
1318
- wrapupWarningSent: false,
1319
- timeoutAt: null,
1320
- lastProgressAt: s.currentUnit.startedAt,
1321
- progressCount: 0,
1322
- lastProgressKind: "dispatch",
1323
- },
1324
- );
1444
+ // Prompt char measurement
1445
+ s.lastPromptCharCount = finalPrompt.length;
1446
+ s.lastBaselineCharCount = undefined;
1447
+ if (deps.isDbAvailable()) {
1448
+ try {
1449
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1450
+ const [decisionsContent, requirementsContent, projectContent] =
1451
+ await Promise.all([
1452
+ inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
1453
+ inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
1454
+ inlineGsdRootFile(s.basePath, "project.md", "Project"),
1455
+ ]);
1456
+ s.lastBaselineCharCount =
1457
+ (decisionsContent?.length ?? 0) +
1458
+ (requirementsContent?.length ?? 0) +
1459
+ (projectContent?.length ?? 0);
1460
+ } catch {
1461
+ // Non-fatal
1462
+ }
1463
+ }
1325
1464
 
1326
- // Status bar + progress widget
1327
- ctx.ui.setStatus("gsd-auto", "auto");
1328
- if (mid)
1329
- deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
1330
- deps.updateProgressWidget(ctx, unitType, unitId, state);
1331
-
1332
- deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1333
-
1334
- // Prompt injection
1335
- const MAX_RECOVERY_CHARS = 50_000;
1336
- let finalPrompt = prompt;
1337
-
1338
- if (s.pendingVerificationRetry) {
1339
- const retryCtx = s.pendingVerificationRetry;
1340
- s.pendingVerificationRetry = null;
1341
- const capped =
1342
- retryCtx.failureContext.length > MAX_RECOVERY_CHARS
1343
- ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
1344
- "\n\n[...failure context truncated]"
1345
- : retryCtx.failureContext;
1346
- 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}`;
1347
- }
1465
+ // Cache-optimize prompt section ordering
1466
+ try {
1467
+ finalPrompt = deps.reorderForCaching(finalPrompt);
1468
+ } catch (reorderErr) {
1469
+ const msg =
1470
+ reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
1471
+ process.stderr.write(
1472
+ `[gsd] prompt reorder failed (non-fatal): ${msg}\n`,
1473
+ );
1474
+ }
1348
1475
 
1349
- if (s.pendingCrashRecovery) {
1350
- const capped =
1351
- s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
1352
- ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
1353
- "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
1354
- : s.pendingCrashRecovery;
1355
- finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
1356
- s.pendingCrashRecovery = null;
1357
- } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
1358
- const diagnostic = deps.getDeepDiagnostic(s.basePath);
1359
- if (diagnostic) {
1360
- const cappedDiag =
1361
- diagnostic.length > MAX_RECOVERY_CHARS
1362
- ? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
1363
- "\n\n[...diagnostic truncated to prevent memory exhaustion]"
1364
- : diagnostic;
1365
- finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
1366
- }
1367
- }
1476
+ // Select and apply model (with tier escalation on retry — normal units only)
1477
+ const modelResult = await deps.selectAndApplyModel(
1478
+ ctx,
1479
+ pi,
1480
+ unitType,
1481
+ unitId,
1482
+ s.basePath,
1483
+ prefs,
1484
+ s.verbose,
1485
+ s.autoModeStartModel,
1486
+ sidecarItem ? undefined : { isRetry, previousTier },
1487
+ );
1488
+ s.currentUnitRouting =
1489
+ modelResult.routing as AutoSession["currentUnitRouting"];
1490
+
1491
+ // Start unit supervision
1492
+ deps.clearUnitTimeout();
1493
+ deps.startUnitSupervision({
1494
+ s,
1495
+ ctx,
1496
+ pi,
1497
+ unitType,
1498
+ unitId,
1499
+ prefs,
1500
+ buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
1501
+ buildRecoveryContext: () => ({}),
1502
+ pauseAuto: deps.pauseAuto,
1503
+ });
1368
1504
 
1369
- const repairBlock =
1370
- deps.buildObservabilityRepairBlock(observabilityIssues);
1371
- if (repairBlock) {
1372
- finalPrompt = `${finalPrompt}${repairBlock}`;
1373
- }
1505
+ // Session + send + await
1506
+ const sessionFile = deps.getSessionFile(ctx);
1507
+ deps.updateSessionLock(
1508
+ deps.lockBase(),
1509
+ unitType,
1510
+ unitId,
1511
+ s.completedUnits.length,
1512
+ sessionFile,
1513
+ );
1514
+ deps.writeLock(
1515
+ deps.lockBase(),
1516
+ unitType,
1517
+ unitId,
1518
+ s.completedUnits.length,
1519
+ sessionFile,
1520
+ );
1374
1521
 
1375
- // Prompt char measurement
1376
- s.lastPromptCharCount = finalPrompt.length;
1377
- s.lastBaselineCharCount = undefined;
1378
- if (deps.isDbAvailable()) {
1379
- try {
1380
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
1381
- const [decisionsContent, requirementsContent, projectContent] =
1382
- await Promise.all([
1383
- inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
1384
- inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
1385
- inlineGsdRootFile(s.basePath, "project.md", "Project"),
1386
- ]);
1387
- s.lastBaselineCharCount =
1388
- (decisionsContent?.length ?? 0) +
1389
- (requirementsContent?.length ?? 0) +
1390
- (projectContent?.length ?? 0);
1391
- } catch {
1392
- // Non-fatal
1393
- }
1394
- }
1522
+ debugLog("autoLoop", {
1523
+ phase: "runUnit-start",
1524
+ iteration: ic.iteration,
1525
+ unitType,
1526
+ unitId,
1527
+ });
1528
+ const unitResult = await runUnit(
1529
+ ctx,
1530
+ pi,
1531
+ s,
1532
+ unitType,
1533
+ unitId,
1534
+ finalPrompt,
1535
+ );
1536
+ debugLog("autoLoop", {
1537
+ phase: "runUnit-end",
1538
+ iteration: ic.iteration,
1539
+ unitType,
1540
+ unitId,
1541
+ status: unitResult.status,
1542
+ });
1395
1543
 
1396
- // Cache-optimize prompt section ordering
1397
- try {
1398
- finalPrompt = deps.reorderForCaching(finalPrompt);
1399
- } catch (reorderErr) {
1400
- const msg =
1401
- reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
1402
- process.stderr.write(
1403
- `[gsd] prompt reorder failed (non-fatal): ${msg}\n`,
1404
- );
1544
+ // Tag the most recent window entry with error info for stuck detection
1545
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
1546
+ const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
1547
+ if (lastEntry) {
1548
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
1549
+ }
1550
+ } else if (unitResult.event?.messages?.length) {
1551
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
1552
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
1553
+ if (/error|fail|exception/i.test(msgStr)) {
1554
+ const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
1555
+ if (lastEntry) {
1556
+ lastEntry.error = msgStr.slice(0, 200);
1405
1557
  }
1558
+ }
1559
+ }
1406
1560
 
1407
- // Select and apply model (with tier escalation on retry)
1408
- const modelResult = await deps.selectAndApplyModel(
1409
- ctx,
1410
- pi,
1411
- unitType,
1412
- unitId,
1413
- s.basePath,
1414
- prefs,
1415
- s.verbose,
1416
- s.autoModeStartModel,
1417
- { isRetry, previousTier },
1418
- );
1419
- s.currentUnitRouting =
1420
- modelResult.routing as AutoSession["currentUnitRouting"];
1421
-
1422
- // Start unit supervision
1423
- deps.clearUnitTimeout();
1424
- deps.startUnitSupervision({
1425
- s,
1426
- ctx,
1427
- pi,
1428
- unitType,
1429
- unitId,
1430
- prefs,
1431
- buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
1432
- buildRecoveryContext: () => ({}),
1433
- pauseAuto: deps.pauseAuto,
1434
- });
1435
-
1436
- // Session + send + await
1437
- const sessionFile = deps.getSessionFile(ctx);
1438
- deps.updateSessionLock(
1439
- deps.lockBase(),
1440
- unitType,
1441
- unitId,
1442
- s.completedUnits.length,
1443
- sessionFile,
1444
- );
1445
- deps.writeLock(
1446
- deps.lockBase(),
1447
- unitType,
1448
- unitId,
1449
- s.completedUnits.length,
1450
- sessionFile,
1451
- );
1452
-
1453
- debugLog("autoLoop", {
1454
- phase: "runUnit-start",
1455
- iteration,
1456
- unitType,
1457
- unitId,
1458
- });
1459
- const unitResult = await runUnit(
1460
- ctx,
1461
- pi,
1462
- s,
1463
- unitType,
1464
- unitId,
1465
- finalPrompt,
1466
- prefs,
1467
- );
1468
- debugLog("autoLoop", {
1469
- phase: "runUnit-end",
1470
- iteration,
1471
- unitType,
1472
- unitId,
1473
- status: unitResult.status,
1474
- });
1561
+ if (unitResult.status === "cancelled") {
1562
+ ctx.ui.notify(
1563
+ `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
1564
+ "warning",
1565
+ );
1566
+ await deps.stopAuto(ctx, pi, "Session creation failed");
1567
+ debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
1568
+ return { action: "break", reason: "session-failed" };
1569
+ }
1475
1570
 
1476
- if (unitResult.status === "cancelled") {
1477
- ctx.ui.notify(
1478
- `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
1479
- "warning",
1480
- );
1481
- await deps.stopAuto(ctx, pi, "Session creation failed");
1482
- debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
1483
- break;
1484
- }
1571
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
1572
+ // Run right after runUnit() returns so telemetry is never lost to a
1573
+ // crash between iterations.
1574
+ await deps.closeoutUnit(
1575
+ ctx,
1576
+ s.basePath,
1577
+ unitType,
1578
+ unitId,
1579
+ s.currentUnit.startedAt,
1580
+ deps.buildSnapshotOpts(unitType, unitId),
1581
+ );
1485
1582
 
1486
- // ── Phase 5: Finalize ───────────────────────────────────────────────
1583
+ if (s.currentUnitRouting) {
1584
+ deps.recordOutcome(
1585
+ unitType,
1586
+ s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1587
+ true, // success assumed; dispatch will re-dispatch if artifact missing
1588
+ );
1589
+ }
1487
1590
 
1488
- debugLog("autoLoop", { phase: "finalize", iteration });
1591
+ const isHookUnit = unitType.startsWith("hook/");
1592
+ const artifactVerified =
1593
+ isHookUnit ||
1594
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
1595
+ if (artifactVerified) {
1596
+ s.completedUnits.push({
1597
+ type: unitType,
1598
+ id: unitId,
1599
+ startedAt: s.currentUnit.startedAt,
1600
+ finishedAt: Date.now(),
1601
+ });
1602
+ if (s.completedUnits.length > 200) {
1603
+ s.completedUnits = s.completedUnits.slice(-200);
1604
+ }
1605
+ // Flush completed-units to disk so the record survives crashes
1606
+ try {
1607
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
1608
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
1609
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
1610
+ } catch { /* non-fatal: disk flush failure */ }
1611
+
1612
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
1613
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
1614
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
1615
+ }
1489
1616
 
1490
- // Clear unit timeout (unit completed)
1491
- deps.clearUnitTimeout();
1617
+ return { action: "next", data: { unitStartedAt: s.currentUnit.startedAt } };
1618
+ }
1492
1619
 
1493
- // Post-unit context for pre/post verification
1494
- const postUnitCtx: PostUnitContext = {
1495
- s,
1496
- ctx,
1497
- pi,
1498
- buildSnapshotOpts: deps.buildSnapshotOpts,
1499
- lockBase: deps.lockBase,
1500
- stopAuto: deps.stopAuto,
1501
- pauseAuto: deps.pauseAuto,
1502
- updateProgressWidget: deps.updateProgressWidget,
1503
- };
1620
+ // ─── runFinalize ──────────────────────────────────────────────────────────────
1504
1621
 
1505
- // Pre-verification processing (commit, doctor, state rebuild, etc.)
1506
- const preResult = await deps.postUnitPreVerification(postUnitCtx);
1507
- if (preResult === "dispatched") {
1508
- debugLog("autoLoop", {
1509
- phase: "exit",
1510
- reason: "pre-verification-dispatched",
1511
- });
1512
- break;
1513
- }
1622
+ /**
1623
+ * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard.
1624
+ * Returns break/continue/next to control the outer loop.
1625
+ */
1626
+ async function runFinalize(
1627
+ ic: IterationContext,
1628
+ iterData: IterationData,
1629
+ sidecarItem?: SidecarItem,
1630
+ ): Promise<PhaseResult> {
1631
+ const { ctx, pi, s, deps } = ic;
1632
+ const { pauseAfterUatDispatch } = iterData;
1633
+
1634
+ debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration });
1635
+
1636
+ // Clear unit timeout (unit completed)
1637
+ deps.clearUnitTimeout();
1638
+
1639
+ // Post-unit context for pre/post verification
1640
+ const postUnitCtx: PostUnitContext = {
1641
+ s,
1642
+ ctx,
1643
+ pi,
1644
+ buildSnapshotOpts: deps.buildSnapshotOpts,
1645
+ lockBase: deps.lockBase,
1646
+ stopAuto: deps.stopAuto,
1647
+ pauseAuto: deps.pauseAuto,
1648
+ updateProgressWidget: deps.updateProgressWidget,
1649
+ };
1514
1650
 
1515
- if (pauseAfterUatDispatch) {
1516
- ctx.ui.notify(
1517
- "UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
1518
- "info",
1519
- );
1520
- await deps.pauseAuto(ctx, pi);
1521
- debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
1522
- break;
1523
- }
1651
+ // Pre-verification processing (commit, doctor, state rebuild, etc.)
1652
+ // Sidecar items use lightweight pre-verification opts
1653
+ const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem
1654
+ ? sidecarItem.kind === "hook"
1655
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1656
+ : { skipSettleDelay: true, skipStateRebuild: true }
1657
+ : undefined;
1658
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
1659
+ if (preResult === "dispatched") {
1660
+ debugLog("autoLoop", {
1661
+ phase: "exit",
1662
+ reason: "pre-verification-dispatched",
1663
+ });
1664
+ return { action: "break", reason: "pre-verification-dispatched" };
1665
+ }
1524
1666
 
1525
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
1526
- const verificationResult = await deps.runPostUnitVerification(
1527
- { s, ctx, pi },
1528
- deps.pauseAuto,
1529
- );
1667
+ if (pauseAfterUatDispatch) {
1668
+ ctx.ui.notify(
1669
+ "UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
1670
+ "info",
1671
+ );
1672
+ await deps.pauseAuto(ctx, pi);
1673
+ debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
1674
+ return { action: "break", reason: "uat-pause" };
1675
+ }
1530
1676
 
1531
- if (verificationResult === "pause") {
1532
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1533
- break;
1534
- }
1677
+ // Verification gate
1678
+ // Hook sidecar items skip verification entirely.
1679
+ // Non-hook sidecar items run verification but skip retries (just continue).
1680
+ const skipVerification = sidecarItem?.kind === "hook";
1681
+ if (!skipVerification) {
1682
+ const verificationResult = await deps.runPostUnitVerification(
1683
+ { s, ctx, pi },
1684
+ deps.pauseAuto,
1685
+ );
1686
+
1687
+ if (verificationResult === "pause") {
1688
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1689
+ return { action: "break", reason: "verification-pause" };
1690
+ }
1535
1691
 
1536
- if (verificationResult === "retry") {
1692
+ if (verificationResult === "retry") {
1693
+ if (sidecarItem) {
1694
+ // Sidecar verification retries are skipped — just continue
1695
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration: ic.iteration });
1696
+ } else {
1537
1697
  // s.pendingVerificationRetry was set by runPostUnitVerification.
1538
1698
  // Continue the loop — next iteration will inject the retry context into the prompt.
1539
- debugLog("autoLoop", { phase: "verification-retry", iteration });
1540
- continue;
1699
+ debugLog("autoLoop", { phase: "verification-retry", iteration: ic.iteration });
1700
+ return { action: "continue" };
1541
1701
  }
1702
+ }
1703
+ }
1542
1704
 
1543
- // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
1544
- const postResult = await deps.postUnitPostVerification(postUnitCtx);
1705
+ // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
1706
+ const postResult = await deps.postUnitPostVerification(postUnitCtx);
1545
1707
 
1546
- if (postResult === "stopped") {
1547
- debugLog("autoLoop", {
1548
- phase: "exit",
1549
- reason: "post-verification-stopped",
1550
- });
1551
- break;
1552
- }
1708
+ if (postResult === "stopped") {
1709
+ debugLog("autoLoop", {
1710
+ phase: "exit",
1711
+ reason: "post-verification-stopped",
1712
+ });
1713
+ return { action: "break", reason: "post-verification-stopped" };
1714
+ }
1553
1715
 
1554
- if (postResult === "step-wizard") {
1555
- // Step mode — exit the loop (caller handles wizard)
1556
- debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
1557
- break;
1558
- }
1716
+ if (postResult === "step-wizard") {
1717
+ // Step mode — exit the loop (caller handles wizard)
1718
+ debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
1719
+ return { action: "break", reason: "step-wizard" };
1720
+ }
1559
1721
 
1560
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
1561
- let sidecarBroke = false;
1562
- while (s.sidecarQueue.length > 0 && s.active) {
1563
- const item = s.sidecarQueue.shift()!;
1564
- debugLog("autoLoop", {
1565
- phase: "sidecar-dequeue",
1566
- kind: item.kind,
1567
- unitType: item.unitType,
1568
- unitId: item.unitId,
1569
- });
1722
+ return { action: "next", data: undefined as void };
1723
+ }
1570
1724
 
1571
- // Set up as current unit
1572
- const sidecarStartedAt = Date.now();
1573
- s.currentUnit = {
1574
- type: item.unitType,
1575
- id: item.unitId,
1576
- startedAt: sidecarStartedAt,
1577
- };
1578
- deps.writeUnitRuntimeRecord(
1579
- s.basePath,
1580
- item.unitType,
1581
- item.unitId,
1582
- sidecarStartedAt,
1583
- {
1584
- phase: "dispatched",
1585
- wrapupWarningSent: false,
1586
- timeoutAt: null,
1587
- lastProgressAt: sidecarStartedAt,
1588
- progressCount: 0,
1589
- lastProgressKind: "dispatch",
1590
- },
1591
- );
1725
+ // ─── autoLoop ────────────────────────────────────────────────────────────────
1592
1726
 
1593
- // Model selection (handles hook model override)
1594
- await deps.selectAndApplyModel(
1595
- ctx,
1596
- pi,
1597
- item.unitType,
1598
- item.unitId,
1599
- s.basePath,
1600
- prefs,
1601
- s.verbose,
1602
- s.autoModeStartModel,
1603
- );
1727
+ /**
1728
+ * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
1729
+ * runUnit → finalize → repeat. Exits when s.active becomes false or a
1730
+ * terminal condition is reached.
1731
+ *
1732
+ * This is the linear replacement for the recursive
1733
+ * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain.
1734
+ */
1735
+ export async function autoLoop(
1736
+ ctx: ExtensionContext,
1737
+ pi: ExtensionAPI,
1738
+ s: AutoSession,
1739
+ deps: LoopDeps,
1740
+ ): Promise<void> {
1741
+ debugLog("autoLoop", { phase: "enter" });
1742
+ let iteration = 0;
1743
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
1744
+ let consecutiveErrors = 0;
1604
1745
 
1605
- // Supervision
1606
- deps.clearUnitTimeout();
1607
- deps.startUnitSupervision({
1608
- s,
1609
- ctx,
1610
- pi,
1611
- unitType: item.unitType,
1612
- unitId: item.unitId,
1613
- prefs,
1614
- buildSnapshotOpts: () =>
1615
- deps.buildSnapshotOpts(item.unitType, item.unitId),
1616
- buildRecoveryContext: () => ({}),
1617
- pauseAuto: deps.pauseAuto,
1618
- });
1746
+ while (s.active) {
1747
+ iteration++;
1748
+ debugLog("autoLoop", { phase: "loop-top", iteration });
1619
1749
 
1620
- // Write lock
1621
- const sidecarSessionFile = deps.getSessionFile(ctx);
1622
- deps.writeLock(
1623
- deps.lockBase(),
1624
- item.unitType,
1625
- item.unitId,
1626
- s.completedUnits.length,
1627
- sidecarSessionFile,
1628
- );
1750
+ if (iteration > MAX_LOOP_ITERATIONS) {
1751
+ debugLog("autoLoop", {
1752
+ phase: "exit",
1753
+ reason: "max-iterations",
1754
+ iteration,
1755
+ });
1756
+ await deps.stopAuto(
1757
+ ctx,
1758
+ pi,
1759
+ `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
1760
+ );
1761
+ break;
1762
+ }
1629
1763
 
1630
- // Execute via standard runUnit
1631
- const sidecarResult = await runUnit(
1632
- ctx,
1633
- pi,
1634
- s,
1635
- item.unitType,
1636
- item.unitId,
1637
- item.prompt,
1638
- prefs,
1639
- );
1640
- deps.clearUnitTimeout();
1764
+ if (!s.cmdCtx) {
1765
+ debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
1766
+ break;
1767
+ }
1641
1768
 
1642
- if (sidecarResult.status === "cancelled") {
1643
- ctx.ui.notify(
1644
- `Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`,
1645
- "warning",
1646
- );
1647
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
1648
- sidecarBroke = true;
1649
- break;
1650
- }
1769
+ try {
1770
+ // ── Blanket try/catch: one bad iteration must not kill the session
1771
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
1651
1772
 
1652
- // Run pre-verification for the sidecar unit
1653
- const sidecarPreResult =
1654
- await deps.postUnitPreVerification(postUnitCtx);
1655
- if (sidecarPreResult === "dispatched") {
1656
- // Pre-verification caused stop/pause
1773
+ // ── Check sidecar queue before deriveState ──
1774
+ let sidecarItem: SidecarItem | undefined;
1775
+ if (s.sidecarQueue.length > 0) {
1776
+ sidecarItem = s.sidecarQueue.shift()!;
1777
+ debugLog("autoLoop", {
1778
+ phase: "sidecar-dequeue",
1779
+ kind: sidecarItem.kind,
1780
+ unitType: sidecarItem.unitType,
1781
+ unitId: sidecarItem.unitId,
1782
+ });
1783
+ }
1784
+
1785
+ const sessionLockBase = deps.lockBase();
1786
+ if (sessionLockBase) {
1787
+ const lockStatus = deps.validateSessionLock(sessionLockBase);
1788
+ if (!lockStatus.valid) {
1657
1789
  debugLog("autoLoop", {
1658
- phase: "exit",
1659
- reason: "sidecar-pre-verification-stop",
1790
+ phase: "session-lock-invalid",
1791
+ reason: lockStatus.failureReason ?? "unknown",
1792
+ existingPid: lockStatus.existingPid,
1793
+ expectedPid: lockStatus.expectedPid,
1660
1794
  });
1661
- sidecarBroke = true;
1662
- break;
1663
- }
1664
-
1665
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
1666
- // Hook units are lightweight and don't need verification.
1667
- if (item.kind !== "hook") {
1668
- const sidecarVerification = await deps.runPostUnitVerification(
1669
- { s, ctx, pi },
1670
- deps.pauseAuto,
1671
- );
1672
- if (sidecarVerification === "pause") {
1673
- debugLog("autoLoop", {
1674
- phase: "exit",
1675
- reason: "sidecar-verification-pause",
1676
- });
1677
- sidecarBroke = true;
1678
- break;
1679
- }
1680
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
1681
- }
1682
-
1683
- // Post-verification (may enqueue more sidecar items)
1684
- const sidecarPostResult =
1685
- await deps.postUnitPostVerification(postUnitCtx);
1686
- if (sidecarPostResult === "stopped") {
1687
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
1688
- sidecarBroke = true;
1689
- break;
1690
- }
1691
- if (sidecarPostResult === "step-wizard") {
1795
+ deps.handleLostSessionLock(ctx, lockStatus);
1692
1796
  debugLog("autoLoop", {
1693
1797
  phase: "exit",
1694
- reason: "sidecar-step-wizard",
1798
+ reason: "session-lock-lost",
1799
+ detail: lockStatus.failureReason ?? "unknown",
1695
1800
  });
1696
- sidecarBroke = true;
1697
1801
  break;
1698
1802
  }
1699
- // "continue" — loop checks sidecarQueue again
1700
1803
  }
1701
1804
 
1702
- if (sidecarBroke) break;
1805
+ const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration };
1806
+ let iterData: IterationData;
1807
+
1808
+ if (!sidecarItem) {
1809
+ // ── Phase 1: Pre-dispatch ─────────────────────────────────────────
1810
+ const preDispatchResult = await runPreDispatch(ic, loopState);
1811
+ if (preDispatchResult.action === "break") break;
1812
+ if (preDispatchResult.action === "continue") continue;
1813
+
1814
+ const preData = preDispatchResult.data;
1815
+
1816
+ // ── Phase 2: Guards ───────────────────────────────────────────────
1817
+ const guardsResult = await runGuards(ic, preData.mid);
1818
+ if (guardsResult.action === "break") break;
1819
+
1820
+ // ── Phase 3: Dispatch ─────────────────────────────────────────────
1821
+ const dispatchResult = await runDispatch(ic, preData, loopState);
1822
+ if (dispatchResult.action === "break") break;
1823
+ if (dispatchResult.action === "continue") continue;
1824
+ iterData = dispatchResult.data;
1825
+ } else {
1826
+ // ── Sidecar path: use values from the sidecar item directly ──
1827
+ const sidecarState = await deps.deriveState(s.basePath);
1828
+ iterData = {
1829
+ unitType: sidecarItem.unitType,
1830
+ unitId: sidecarItem.unitId,
1831
+ prompt: sidecarItem.prompt,
1832
+ finalPrompt: sidecarItem.prompt,
1833
+ pauseAfterUatDispatch: false,
1834
+ observabilityIssues: [],
1835
+ state: sidecarState,
1836
+ mid: sidecarState.activeMilestone?.id,
1837
+ midTitle: sidecarState.activeMilestone?.title,
1838
+ isRetry: false, previousTier: undefined,
1839
+ };
1840
+ }
1841
+
1842
+ const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem);
1843
+ if (unitPhaseResult.action === "break") break;
1844
+
1845
+ // ── Phase 5: Finalize ───────────────────────────────────────────────
1846
+
1847
+ const finalizeResult = await runFinalize(ic, iterData, sidecarItem);
1848
+ if (finalizeResult.action === "break") break;
1849
+ if (finalizeResult.action === "continue") continue;
1703
1850
 
1704
1851
  consecutiveErrors = 0; // Iteration completed successfully
1705
1852
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
@@ -1740,6 +1887,6 @@ export async function autoLoop(
1740
1887
  }
1741
1888
  }
1742
1889
 
1743
- _activeSession = null;
1890
+ _currentResolve = null;
1744
1891
  debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
1745
1892
  }