@opengsd/gsd-pi 1.1.1-dev.b2556262 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/dist/project-sessions.js +4 -2
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +17 -9
  4. package/dist/resources/extensions/gsd/auto/contracts.js +8 -1
  5. package/dist/resources/extensions/gsd/auto/orchestrator.js +659 -57
  6. package/dist/resources/extensions/gsd/auto-prompts.js +110 -1
  7. package/dist/resources/extensions/gsd/auto-runtime-state.js +3 -0
  8. package/dist/resources/extensions/gsd/auto-tool-tracking.js +5 -0
  9. package/dist/resources/extensions/gsd/auto-unit-tool-scope.js +29 -0
  10. package/dist/resources/extensions/gsd/auto.js +62 -464
  11. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -1
  12. package/dist/resources/extensions/gsd/debug-logger.js +10 -0
  13. package/dist/resources/extensions/gsd/doctor-proactive.js +7 -2
  14. package/dist/resources/extensions/gsd/guided-flow.js +2 -2
  15. package/dist/resources/extensions/gsd/markdown-renderer.js +31 -32
  16. package/dist/resources/extensions/gsd/mcp-filter.js +6 -0
  17. package/dist/resources/extensions/gsd/native-git-bridge.js +9 -0
  18. package/dist/resources/extensions/gsd/prompts/discuss.md +6 -7
  19. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +5 -7
  20. package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +3 -5
  21. package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +1 -2
  22. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +5 -6
  23. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  24. package/dist/resources/extensions/gsd/prompts/research-milestone.md +2 -2
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +5 -3
  26. package/dist/resources/extensions/gsd/schemas/parsers.js +6 -1
  27. package/dist/resources/extensions/gsd/state-reconciliation/drift/artifact-db.js +21 -1
  28. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +169 -20
  29. package/dist/resources/extensions/gsd/user-input-boundary.js +42 -4
  30. package/dist/web/standalone/.next/BUILD_ID +1 -1
  31. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  32. package/dist/web/standalone/.next/build-manifest.json +3 -3
  33. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  34. package/dist/web/standalone/.next/required-server-files.json +3 -3
  35. package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
  36. package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
  46. package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  56. package/dist/web/standalone/.next/server/app/api/boot/route.js.nft.json +1 -1
  57. package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
  58. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  59. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js.nft.json +1 -1
  60. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
  61. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  62. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js.nft.json +1 -1
  63. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
  64. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
  65. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js.nft.json +1 -1
  66. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
  68. package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
  69. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  70. package/dist/web/standalone/.next/server/app/api/captures/route.js.nft.json +1 -1
  71. package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/cleanup/route.js.nft.json +1 -1
  74. package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
  76. package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  78. package/dist/web/standalone/.next/server/app/api/doctor/route.js.nft.json +1 -1
  79. package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
  80. package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
  81. package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
  82. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/export-data/route.js.nft.json +1 -1
  84. package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
  85. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  86. package/dist/web/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
  87. package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
  88. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/forensics/route.js.nft.json +1 -1
  90. package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
  91. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  92. package/dist/web/standalone/.next/server/app/api/git/route.js.nft.json +1 -1
  93. package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
  94. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/history/route.js.nft.json +1 -1
  96. package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
  97. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  98. package/dist/web/standalone/.next/server/app/api/hooks/route.js.nft.json +1 -1
  99. package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
  100. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/inspect/route.js.nft.json +1 -1
  102. package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
  103. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  104. package/dist/web/standalone/.next/server/app/api/knowledge/route.js.nft.json +1 -1
  105. package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
  106. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  107. package/dist/web/standalone/.next/server/app/api/live-state/route.js.nft.json +1 -1
  108. package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
  109. package/dist/web/standalone/.next/server/app/api/mcp-connections/route.js +1 -1
  110. package/dist/web/standalone/.next/server/app/api/mcp-connections/route.js.nft.json +1 -1
  111. package/dist/web/standalone/.next/server/app/api/mcp-connections/route_client-reference-manifest.js +1 -1
  112. package/dist/web/standalone/.next/server/app/api/notifications/route.js +2 -2
  113. package/dist/web/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  114. package/dist/web/standalone/.next/server/app/api/notifications/route_client-reference-manifest.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  116. package/dist/web/standalone/.next/server/app/api/onboarding/route.js.nft.json +1 -1
  117. package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
  118. package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
  119. package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
  120. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  121. package/dist/web/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  122. package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  123. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  124. package/dist/web/standalone/.next/server/app/api/recovery/route.js.nft.json +1 -1
  125. package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
  126. package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
  127. package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
  128. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  129. package/dist/web/standalone/.next/server/app/api/session/browser/route.js.nft.json +1 -1
  130. package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
  131. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  132. package/dist/web/standalone/.next/server/app/api/session/command/route.js.nft.json +1 -1
  133. package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
  134. package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
  135. package/dist/web/standalone/.next/server/app/api/session/events/route.js.nft.json +1 -1
  136. package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
  137. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  138. package/dist/web/standalone/.next/server/app/api/session/manage/route.js.nft.json +1 -1
  139. package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
  140. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  141. package/dist/web/standalone/.next/server/app/api/settings-data/route.js.nft.json +1 -1
  142. package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
  143. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  144. package/dist/web/standalone/.next/server/app/api/shutdown/route.js.nft.json +1 -1
  145. package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
  146. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  147. package/dist/web/standalone/.next/server/app/api/skill-health/route.js.nft.json +1 -1
  148. package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
  149. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  150. package/dist/web/standalone/.next/server/app/api/steer/route.js.nft.json +1 -1
  151. package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
  152. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  153. package/dist/web/standalone/.next/server/app/api/switch-root/route.js.nft.json +1 -1
  154. package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
  155. package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +1 -1
  156. package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
  157. package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
  158. package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
  159. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +1 -1
  160. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js.nft.json +1 -1
  161. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
  162. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
  163. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js.nft.json +1 -1
  164. package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
  165. package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
  166. package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
  167. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  168. package/dist/web/standalone/.next/server/app/api/undo/route.js.nft.json +1 -1
  169. package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
  170. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  171. package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  172. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  173. package/dist/web/standalone/.next/server/app/api/visualizer/route.js.nft.json +1 -1
  174. package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
  175. package/dist/web/standalone/.next/server/app/index.html +1 -1
  176. package/dist/web/standalone/.next/server/app/index.rsc +4 -4
  177. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  178. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
  179. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  180. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
  181. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  182. package/dist/web/standalone/.next/server/app/page.js +2 -2
  183. package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  184. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  185. package/dist/web/standalone/.next/server/chunks/2842.js +4 -4
  186. package/dist/web/standalone/.next/server/chunks/5047.js +2 -0
  187. package/dist/web/standalone/.next/server/chunks/5124.js +1 -0
  188. package/dist/web/standalone/.next/server/chunks/8357.js +3 -3
  189. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  190. package/dist/web/standalone/.next/server/middleware.js +3 -3
  191. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  192. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  193. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  194. package/dist/web/standalone/.next/static/chunks/app/_not-found/page-49f565245e1e4afe.js +1 -0
  195. package/dist/web/standalone/.next/static/chunks/app/{layout-4ae2d68984392bbf.js → layout-b35cbfff38aaf4cf.js} +1 -1
  196. package/dist/web/standalone/.next/static/chunks/app/page-a48b7c48333b31c8.js +1 -0
  197. package/dist/web/standalone/.next/static/chunks/{main-app-90d1d8d5e5d2dc6b.js → main-app-590a74400e35f685.js} +1 -1
  198. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ec3eaffc9785ba48.js +1 -0
  199. package/dist/web/standalone/node_modules/@gsd/native/package.json +1 -1
  200. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  201. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  202. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  203. package/dist/web/standalone/server.js +1 -1
  204. package/package.json +8 -6
  205. package/packages/cloud-mcp-gateway/package.json +2 -2
  206. package/packages/contracts/package.json +1 -1
  207. package/packages/daemon/package.json +4 -4
  208. package/packages/gsd-agent-core/package.json +5 -5
  209. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +1 -1
  210. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
  211. package/packages/gsd-agent-modes/package.json +7 -7
  212. package/packages/mcp-server/package.json +3 -3
  213. package/packages/native/package.json +1 -1
  214. package/packages/pi-agent-core/package.json +1 -1
  215. package/packages/pi-ai/dist/models.generated.d.ts +0 -34
  216. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  217. package/packages/pi-ai/dist/models.generated.js +0 -34
  218. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  219. package/packages/pi-ai/package.json +1 -1
  220. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  221. package/packages/pi-coding-agent/dist/core/auth-storage.js +11 -3
  222. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  223. package/packages/pi-coding-agent/package.json +7 -7
  224. package/packages/pi-tui/package.json +2 -2
  225. package/packages/rpc-client/package.json +2 -2
  226. package/pkg/package.json +1 -1
  227. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +18 -8
  228. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +2 -2
  229. package/src/resources/extensions/gsd/auto/contracts.ts +8 -119
  230. package/src/resources/extensions/gsd/auto/orchestrator.ts +794 -58
  231. package/src/resources/extensions/gsd/auto-prompts.ts +114 -1
  232. package/src/resources/extensions/gsd/auto-runtime-state.ts +4 -0
  233. package/src/resources/extensions/gsd/auto-tool-tracking.ts +5 -0
  234. package/src/resources/extensions/gsd/auto-unit-tool-scope.ts +33 -0
  235. package/src/resources/extensions/gsd/auto.ts +81 -500
  236. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
  237. package/src/resources/extensions/gsd/debug-logger.ts +11 -0
  238. package/src/resources/extensions/gsd/doctor-proactive.ts +8 -2
  239. package/src/resources/extensions/gsd/guided-flow.ts +2 -2
  240. package/src/resources/extensions/gsd/markdown-renderer.ts +38 -19
  241. package/src/resources/extensions/gsd/mcp-filter.ts +7 -0
  242. package/src/resources/extensions/gsd/native-git-bridge.ts +9 -0
  243. package/src/resources/extensions/gsd/prompts/discuss.md +6 -7
  244. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +5 -7
  245. package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +3 -5
  246. package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +1 -2
  247. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +5 -6
  248. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  249. package/src/resources/extensions/gsd/prompts/research-milestone.md +2 -2
  250. package/src/resources/extensions/gsd/prompts/validate-milestone.md +5 -3
  251. package/src/resources/extensions/gsd/schemas/parsers.ts +6 -1
  252. package/src/resources/extensions/gsd/state-reconciliation/drift/artifact-db.ts +31 -10
  253. package/src/resources/extensions/gsd/tests/artifact-db-drift-memo.test.ts +66 -0
  254. package/src/resources/extensions/gsd/tests/auto-dispatch-baseline-harness.test.ts +53 -0
  255. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +590 -855
  256. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +38 -10
  257. package/src/resources/extensions/gsd/tests/debug-logger.test.ts +15 -0
  258. package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +64 -1
  259. package/src/resources/extensions/gsd/tests/markdown-renderer-parse-cache.test.ts +75 -0
  260. package/src/resources/extensions/gsd/tests/orchestrator-legacy-parity.test.ts +127 -0
  261. package/src/resources/extensions/gsd/tests/parse-project-milestone-bridge.test.ts +77 -0
  262. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +4 -2
  263. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +29 -2
  264. package/src/resources/extensions/gsd/tests/research-milestone-composer.test.ts +65 -0
  265. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +19 -5
  266. package/src/resources/extensions/gsd/tests/token-tool-gating.test.ts +38 -0
  267. package/src/resources/extensions/gsd/tests/user-input-boundary.test.ts +62 -0
  268. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +24 -0
  269. package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +15 -3
  270. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +183 -21
  271. package/src/resources/extensions/gsd/user-input-boundary.ts +37 -5
  272. package/dist/web/standalone/.next/server/chunks/678.js +0 -2
  273. package/dist/web/standalone/.next/static/chunks/app/_not-found/page-a6fb1847f67f167c.js +0 -1
  274. package/dist/web/standalone/.next/static/chunks/app/page-6644fc6ee8ca1247.js +0 -1
  275. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-1115d46ac61b9823.js +0 -1
  276. /package/dist/web/standalone/.next/static/{tJOKQbQRO-9MiFDO8DIDS → TA5o9SHAnCdK6Umm1MYxb}/_buildManifest.js +0 -0
  277. /package/dist/web/standalone/.next/static/{tJOKQbQRO-9MiFDO8DIDS → TA5o9SHAnCdK6Umm1MYxb}/_ssgManifest.js +0 -0
@@ -1,26 +1,201 @@
1
1
  // Project/App: gsd-pi
2
2
  // File Purpose: Auto Orchestration module contract and ADR-015 invariant sequence tests.
3
+ //
4
+ // Phase 2 of #442 collapsed the nine adapter seams into AutoOrchestrator. These
5
+ // tests therefore drive the REAL collapsed orchestrator against real temp
6
+ // SQLite + git fixtures (fixture builder modelled on
7
+ // state-reconciliation-drift.test.ts) and inject dispatch decisions through the
8
+ // real unified rule registry (setRegistry) rather than mock adapters. Decision
9
+ // logic is asserted on observable advance() outcomes and journal events instead
10
+ // of an internal calls[] array. Dispatch-decision parity (formerly the
11
+ // createWiredDispatchAdapter tests) is asserted against the exported pure
12
+ // decideOrchestratorDispatch helper.
3
13
 
4
14
  import test from "node:test";
5
15
  import assert from "node:assert/strict";
16
+ import { execFileSync } from "node:child_process";
6
17
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
18
  import { tmpdir } from "node:os";
8
19
  import { join } from "node:path";
9
20
 
10
- import { createAutoOrchestrator, STUCK_WINDOW_SIZE } from "../auto/orchestrator.js";
11
- import type { AutoOrchestratorDeps } from "../auto/contracts.js";
21
+ import {
22
+ createAutoOrchestrator,
23
+ decideOrchestratorDispatch,
24
+ resolveLiveOrchestratorBasePath,
25
+ STUCK_WINDOW_SIZE,
26
+ } from "../auto/orchestrator.js";
27
+ import type { OrchestratorContext } from "../auto/orchestrator.js";
28
+ import type { AutoOrchestrationModule, AutoSessionContext } from "../auto/contracts.js";
12
29
  import type { GSDState } from "../types.js";
13
- import { createWiredDispatchAdapter, resolveLiveOrchestratorBasePath } from "../auto.js";
14
30
  import { resolveDispatch, type DispatchContext } from "../auto-dispatch.js";
15
31
  import { RuleRegistry, setRegistry, resetRegistry } from "../rule-registry.js";
16
32
  import type { UnifiedRule } from "../rule-types.js";
17
33
  import { supportsStructuredQuestions } from "../workflow-mcp.js";
18
- import { closeDatabase, insertMilestone, insertSlice, insertTask, openDatabase } from "../gsd-db.js";
34
+ import {
35
+ closeDatabase,
36
+ insertMilestone,
37
+ insertSlice,
38
+ insertTask,
39
+ openDatabase,
40
+ } from "../gsd-db.js";
41
+ import { AutoSession } from "../auto/session.js";
42
+ import { acquireSessionLock, releaseSessionLock } from "../session-lock.js";
43
+ import { queryJournal } from "../journal.js";
44
+ import { invalidateAllCaches } from "../cache.js";
45
+ import { invalidateStateCache } from "../state.js";
46
+
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ // Fixture builder
49
+ //
50
+ // Builds a real, isolated project: a git repo (so the pre-dispatch health gate
51
+ // and merge-state reconciliation have something real to probe), a SQLite DB
52
+ // seeded with one active milestone/slice/task, and the matching ROADMAP/PLAN
53
+ // markdown projection. A real session lock is acquired so the orchestrator's
54
+ // ensureLockOwnership passes. A fresh AutoSession is wired to the base path. A
55
+ // dispatch rule is installed in the real unified registry so resolveDispatch
56
+ // yields a deterministic decision — this is the only "injection", and it is the
57
+ // same public seam (setRegistry) the dispatch engine already exposes.
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ type DispatchRuleResult =
61
+ | { action: "dispatch"; unitType: string; unitId: string; prompt: string; pauseAfterDispatch?: boolean }
62
+ | { action: "stop"; reason: string; level: "info" | "warning" | "error" }
63
+ | { action: "skip"; matchedRule?: string };
64
+
65
+ interface FixtureOptions {
66
+ /** When provided, the rule returns this result. Defaults to dispatching M001/S01/T01. */
67
+ dispatch?: () => DispatchRuleResult | Promise<DispatchRuleResult>;
68
+ /** Rule name (becomes the dispatch `reason`/`matchedRule`). */
69
+ ruleName?: string;
70
+ /** Skip seeding a ready task (used for the "no remaining units" / complete scenarios). */
71
+ noTask?: boolean;
72
+ /** Mark the seeded milestone complete (drives the completion → stopped path). */
73
+ complete?: boolean;
74
+ }
19
75
 
20
- function assertBlockedResult(
21
- result: Awaited<ReturnType<ReturnType<typeof createAutoOrchestrator>["advance"]>>,
22
- ): asserts result is Extract<typeof result, { kind: "blocked" }> {
23
- assert.equal(result.kind, "blocked");
76
+ interface Fixture {
77
+ base: string;
78
+ session: AutoSession;
79
+ ctx: OrchestratorContext;
80
+ orchestrator: AutoOrchestrationModule;
81
+ /** Names emitted to the journal by the orchestrator (data.name), in order. */
82
+ journalNames(): string[];
83
+ cleanup(): void;
84
+ }
85
+
86
+ const DEFAULT_DISPATCH: DispatchRuleResult = {
87
+ action: "dispatch",
88
+ unitType: "execute-task",
89
+ unitId: "M001/S01/T01",
90
+ prompt: "fixture-prompt",
91
+ };
92
+
93
+ function gitInit(base: string): void {
94
+ execFileSync("git", ["init", "--initial-branch=main"], { cwd: base, stdio: "ignore" });
95
+ execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: base, stdio: "ignore" });
96
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: base, stdio: "ignore" });
97
+ writeFileSync(join(base, ".gitkeep"), "");
98
+ execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
99
+ execFileSync("git", ["commit", "-m", "initial"], { cwd: base, stdio: "ignore" });
100
+ }
101
+
102
+ function makeFixture(opts: FixtureOptions = {}): Fixture {
103
+ const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-"));
104
+ gitInit(base);
105
+
106
+ const milestoneDir = join(base, ".gsd", "milestones", "M001");
107
+ const sliceDir = join(milestoneDir, "slices", "S01");
108
+ mkdirSync(join(sliceDir, "tasks"), { recursive: true });
109
+
110
+ invalidateAllCaches();
111
+ invalidateStateCache();
112
+ openDatabase(join(base, ".gsd", "gsd.db"));
113
+ insertMilestone({ id: "M001", title: "Milestone", status: opts.complete ? "complete" : "active" });
114
+ if (!opts.noTask && !opts.complete) {
115
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active", risk: "low", depends: [], demo: "", sequence: 1 });
116
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Task", status: "active" });
117
+ }
118
+
119
+ writeFileSync(
120
+ join(milestoneDir, "M001-ROADMAP.md"),
121
+ [
122
+ "# M001: Milestone",
123
+ "",
124
+ "**Vision:** Fixture milestone",
125
+ "",
126
+ "## Slices",
127
+ "",
128
+ "- [ ] **S01: Slice** `risk:low` `depends:[]`",
129
+ "",
130
+ ].join("\n"),
131
+ );
132
+ if (!opts.noTask && !opts.complete) {
133
+ writeFileSync(
134
+ join(sliceDir, "S01-PLAN.md"),
135
+ [
136
+ "# S01: Slice",
137
+ "",
138
+ "**Goal:** Fixture goal",
139
+ "**Demo:** Fixture demo",
140
+ "",
141
+ "## Must-Haves",
142
+ "",
143
+ "- Everything works",
144
+ "",
145
+ "## Tasks",
146
+ "",
147
+ "- [ ] **T01: Task** `est:1h`",
148
+ "",
149
+ ].join("\n"),
150
+ );
151
+ }
152
+
153
+ acquireSessionLock(base);
154
+
155
+ const session = new AutoSession();
156
+ session.basePath = base;
157
+ session.originalBasePath = base;
158
+ session.currentMilestoneId = "M001";
159
+ session.resourceVersionOnStart = null;
160
+
161
+ const ctx: OrchestratorContext = {
162
+ ctx: { model: {}, modelRegistry: { getAll: () => [] }, ui: { notify() {} } } as never,
163
+ pi: { getActiveTools: () => [] } as never,
164
+ dispatchBasePath: base,
165
+ runtimeBasePath: base,
166
+ session,
167
+ };
168
+
169
+ const ruleName = opts.ruleName ?? "fixture-dispatch";
170
+ const decide = opts.dispatch ?? (() => DEFAULT_DISPATCH);
171
+ const rule: UnifiedRule = {
172
+ name: ruleName,
173
+ when: "dispatch",
174
+ evaluation: "first-match",
175
+ where: async () => decide(),
176
+ then: (r: unknown) => r,
177
+ };
178
+ setRegistry(new RuleRegistry([rule]));
179
+
180
+ const orchestrator = createAutoOrchestrator(ctx);
181
+
182
+ return {
183
+ base,
184
+ session,
185
+ ctx,
186
+ orchestrator,
187
+ journalNames() {
188
+ return queryJournal(base)
189
+ .map((e) => (e.data as Record<string, unknown> | undefined)?.name)
190
+ .filter((n): n is string => typeof n === "string");
191
+ },
192
+ cleanup() {
193
+ resetRegistry();
194
+ try { releaseSessionLock(base); } catch { /* */ }
195
+ try { closeDatabase(); } catch { /* */ }
196
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
197
+ },
198
+ };
24
199
  }
25
200
 
26
201
  function makeState(): GSDState {
@@ -38,637 +213,370 @@ function makeState(): GSDState {
38
213
  };
39
214
  }
40
215
 
41
- function makeDeps(overrides: Partial<AutoOrchestratorDeps> = {}): { deps: AutoOrchestratorDeps; calls: string[] } {
42
- const calls: string[] = [];
43
- const stateSnapshot = makeState();
44
-
45
- const deps: AutoOrchestratorDeps = {
46
- stateReconciliation: {
47
- async reconcileBeforeDispatch() {
48
- calls.push("state.reconcile");
49
- return { ok: true, stateSnapshot };
50
- },
51
- },
52
- dispatch: {
53
- async decideNextUnit(input) {
54
- calls.push("dispatch.decide");
55
- assert.equal(input.stateSnapshot, stateSnapshot);
56
- return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
57
- },
58
- },
59
- toolContract: {
60
- async compileUnitToolContract() {
61
- calls.push("tool.compile");
62
- return { ok: true };
63
- },
64
- },
65
- recovery: {
66
- async classifyAndRecover() {
67
- calls.push("recovery.classify");
68
- return { action: "stop", reason: "fatal" };
69
- },
70
- },
71
- worktree: {
72
- async prepareForUnit() {
73
- calls.push("worktree.prepare");
74
- return { ok: true };
75
- },
76
- async syncAfterUnit() { calls.push("worktree.sync"); },
77
- async cleanupOnStop() { calls.push("worktree.cleanup"); },
78
- },
79
- health: {
80
- checkResourcesStale() {
81
- calls.push("health.stale");
82
- return null;
83
- },
84
- async preAdvanceGate() {
85
- calls.push("health.pre");
86
- return { kind: "pass" };
87
- },
88
- async postAdvanceRecord() { calls.push("health.post"); },
89
- },
90
- runtime: {
91
- async ensureLockOwnership() { calls.push("runtime.lock"); },
92
- async journalTransition(event) { calls.push(`journal:${event.name}`); },
93
- },
94
- notifications: {
95
- async notifyLifecycle(event) { calls.push(`notify:${event.name}`); },
96
- },
97
- uokGate: {
98
- async emit(input) { calls.push(`gate:${input.gateId}:${input.outcome}`); },
99
- },
100
- };
216
+ const SESSION_CONTEXT: AutoSessionContext = { basePath: "/tmp/project", trigger: "manual" };
101
217
 
102
- return { deps: { ...deps, ...overrides }, calls };
103
- }
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+ // Lifecycle: start / resume / stop
220
+ // ─────────────────────────────────────────────────────────────────────────────
104
221
 
105
- test("start() enters running phase without dispatching", async () => {
106
- const { deps, calls } = makeDeps();
107
- const orchestrator = createAutoOrchestrator(deps);
222
+ test("start() enters running phase without dispatching", async (t) => {
223
+ const f = makeFixture();
224
+ t.after(() => f.cleanup());
108
225
 
109
- const result = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
226
+ const result = await f.orchestrator.start(SESSION_CONTEXT);
110
227
 
111
228
  assert.equal(result.kind, "started");
112
- const status = orchestrator.getStatus();
229
+ const status = f.orchestrator.getStatus();
113
230
  assert.equal(status.phase, "running");
114
231
  assert.equal(status.activeUnit, undefined);
115
- assert.ok(calls.includes("journal:start"));
116
- assert.ok(!calls.includes("journal:advance"));
232
+ assert.ok(f.journalNames().includes("start"));
233
+ assert.ok(!f.journalNames().includes("advance"));
117
234
  });
118
235
 
119
- test("advance() returns blocked when health gate denies", async () => {
120
- const { deps, calls } = makeDeps({
121
- health: {
122
- checkResourcesStale: () => null,
123
- async preAdvanceGate() { return { kind: "fail", reason: "doctor-block" }; },
124
- async postAdvanceRecord() {},
125
- },
126
- });
127
- const orchestrator = createAutoOrchestrator(deps);
236
+ test("resume() enters running phase without dispatching", async (t) => {
237
+ const f = makeFixture();
238
+ t.after(() => f.cleanup());
128
239
 
129
- const result = await orchestrator.advance();
240
+ const result = await f.orchestrator.resume();
130
241
 
131
- assertBlockedResult(result);
132
- assert.equal(result.reason, "doctor-block");
133
- assert.equal(result.action, "pause");
134
- assert.ok(calls.includes("gate:pre-dispatch-health-gate:manual-attention"));
242
+ assert.equal(result.kind, "resumed");
243
+ assert.equal(f.orchestrator.getStatus().phase, "running");
244
+ assert.ok(!f.journalNames().includes("advance"));
135
245
  });
136
246
 
137
- test("advance() stops auto when health gate reports unrecoverable git probe", async () => {
138
- const { deps } = makeDeps({
139
- health: {
140
- checkResourcesStale: () => null,
141
- async preAdvanceGate() {
142
- return { kind: "fail", reason: "Could not verify git conflict state", action: "stop" };
143
- },
144
- async postAdvanceRecord() {},
145
- },
146
- });
147
- const orchestrator = createAutoOrchestrator(deps);
247
+ test("transitionCount increases across lifecycle transitions", async (t) => {
248
+ const f = makeFixture();
249
+ t.after(() => f.cleanup());
148
250
 
149
- const result = await orchestrator.advance();
251
+ const before = f.orchestrator.getStatus().transitionCount;
252
+ await f.orchestrator.start(SESSION_CONTEXT);
253
+ const afterStart = f.orchestrator.getStatus().transitionCount;
254
+ await f.orchestrator.stop("done");
255
+ const afterStop = f.orchestrator.getStatus().transitionCount;
150
256
 
151
- assertBlockedResult(result);
152
- assert.equal(result.action, "stop");
257
+ assert.ok(afterStart > before);
258
+ assert.ok(afterStop > afterStart);
153
259
  });
154
260
 
155
- test("advance() returns blocked pause when resources are stale", async () => {
156
- const { deps, calls } = makeDeps({
157
- health: {
158
- checkResourcesStale: () => "resources changed since session start",
159
- async preAdvanceGate() { return { kind: "pass" }; },
160
- async postAdvanceRecord() {},
161
- },
162
- });
163
- const orchestrator = createAutoOrchestrator(deps);
261
+ test("stop() transitions to stopped and journals stop", async (t) => {
262
+ const f = makeFixture();
263
+ t.after(() => f.cleanup());
164
264
 
165
- const result = await orchestrator.advance();
265
+ const result = await f.orchestrator.stop("user-request");
166
266
 
167
- assertBlockedResult(result);
168
- assert.equal(result.reason, "resources changed since session start");
169
- assert.equal(result.action, "pause");
170
- assert.ok(calls.includes("gate:resource-version-guard:fail"));
171
- assert.ok(!calls.includes("health.pre"));
172
- assert.ok(!calls.includes("state.reconcile"));
267
+ assert.equal(result.kind, "stopped");
268
+ assert.equal(f.orchestrator.getStatus().phase, "stopped");
269
+ assert.ok(f.journalNames().includes("stop"));
173
270
  });
174
271
 
175
- test("advance() pre-dispatch parity: gate emissions and control-flow action match legacy branches", async () => {
176
- type Scenario = {
177
- name: string;
178
- staleMsg: string | null;
179
- gateResult: Awaited<ReturnType<AutoOrchestratorDeps["health"]["preAdvanceGate"]>>;
180
- expectedKind: "advanced" | "blocked";
181
- expectedAction?: "pause" | "stop";
182
- expectedReason?: string;
183
- expectedGates: string[];
184
- };
185
- const scenarios: Scenario[] = [
186
- {
187
- name: "pass",
188
- staleMsg: null,
189
- gateResult: { kind: "pass" },
190
- expectedKind: "advanced",
191
- expectedGates: [
192
- "resource-version-guard:policy:pass:none:resource version guard passed:",
193
- "pre-dispatch-health-gate:execution:pass:none:pre-dispatch health gate passed:",
194
- ],
195
- },
196
- {
197
- name: "resource-stale",
198
- staleMsg: "resources changed since session start",
199
- gateResult: { kind: "pass" },
200
- expectedKind: "blocked",
201
- expectedAction: "pause",
202
- expectedReason: "resources changed since session start",
203
- expectedGates: [
204
- "resource-version-guard:policy:fail:policy:resource version guard blocked dispatch:resources changed since session start",
205
- ],
206
- },
207
- {
208
- name: "health-gate-fail",
209
- staleMsg: null,
210
- gateResult: { kind: "fail", reason: "doctor-block" },
211
- expectedKind: "blocked",
212
- expectedAction: "pause",
213
- expectedReason: "doctor-block",
214
- expectedGates: [
215
- "resource-version-guard:policy:pass:none:resource version guard passed:",
216
- "pre-dispatch-health-gate:execution:manual-attention:manual-attention:pre-dispatch health gate blocked dispatch:doctor-block",
217
- ],
218
- },
219
- ];
220
-
221
- for (const scenario of scenarios) {
222
- const gateEvents: string[] = [];
223
- const { deps } = makeDeps({
224
- health: {
225
- checkResourcesStale: () => scenario.staleMsg,
226
- async preAdvanceGate() { return scenario.gateResult; },
227
- async postAdvanceRecord() {},
228
- },
229
- uokGate: {
230
- async emit(input) {
231
- gateEvents.push(
232
- `${input.gateId}:${input.gateType}:${input.outcome}:${input.failureClass}:${input.rationale}:${input.findings ?? ""}`,
233
- );
234
- },
235
- },
236
- });
237
- const orchestrator = createAutoOrchestrator(deps);
238
- const result = await orchestrator.advance();
239
-
240
- assert.equal(result.kind, scenario.expectedKind, `${scenario.name} result kind`);
241
- if (scenario.expectedKind === "blocked") {
242
- assertBlockedResult(result);
243
- assert.equal(result.action, scenario.expectedAction, `${scenario.name} blocked action`);
244
- assert.equal(result.reason, scenario.expectedReason, `${scenario.name} blocked reason`);
245
- }
246
- assert.deepEqual(gateEvents, scenario.expectedGates, `${scenario.name} gate parity`);
247
- }
248
- });
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // advance(): happy path + ADR-015 invariant sequence
274
+ // ─────────────────────────────────────────────────────────────────────────────
249
275
 
250
- test("advance() continues past pre-dispatch health gate when it throws", async () => {
251
- const { deps, calls } = makeDeps({
252
- health: {
253
- checkResourcesStale: () => null,
254
- async preAdvanceGate() { return { kind: "threw", error: new Error("boom") }; },
255
- async postAdvanceRecord() {},
256
- },
257
- });
258
- const orchestrator = createAutoOrchestrator(deps);
276
+ test("advance() dispatches the resolved unit and journals advance", async (t) => {
277
+ const f = makeFixture();
278
+ t.after(() => f.cleanup());
259
279
 
260
- const result = await orchestrator.advance();
280
+ const result = await f.orchestrator.advance();
261
281
 
262
282
  assert.equal(result.kind, "advanced");
263
- assert.ok(calls.includes("gate:pre-dispatch-health-gate:manual-attention"));
264
- assert.ok(calls.includes("state.reconcile"));
265
- assert.ok(calls.includes("dispatch.decide"));
283
+ if (result.kind !== "advanced") return;
284
+ assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "M001/S01/T01" });
285
+ assert.equal(f.orchestrator.getStatus().phase, "running");
286
+ // Journal records the advance AFTER the invariant gates (lock, health,
287
+ // reconcile, dispatch, tool-contract, worktree) — i.e. no advance-blocked.
288
+ const names = f.journalNames();
289
+ assert.ok(names.includes("advance"));
290
+ assert.ok(!names.includes("advance-blocked"));
266
291
  });
267
292
 
268
- test("advance() forwards fixesApplied into pre-dispatch-health-gate pass findings", async () => {
269
- let observed = "";
270
- const { deps } = makeDeps({
271
- health: {
272
- checkResourcesStale: () => null,
273
- async preAdvanceGate() { return { kind: "pass", fixesApplied: ["fix-a", "fix-b"] }; },
274
- async postAdvanceRecord() {},
275
- },
276
- uokGate: {
277
- async emit(input) {
278
- if (input.gateId === "pre-dispatch-health-gate" && input.outcome === "pass") {
279
- observed = input.findings ?? "";
280
- }
281
- },
282
- },
283
- });
284
- const orchestrator = createAutoOrchestrator(deps);
293
+ test("advance() sets active unit and is reflected in status", async (t) => {
294
+ const f = makeFixture();
295
+ t.after(() => f.cleanup());
285
296
 
286
- await orchestrator.advance();
297
+ await f.orchestrator.advance();
287
298
 
288
- assert.equal(observed, "fix-a, fix-b");
299
+ assert.deepEqual(f.orchestrator.getStatus().activeUnit, {
300
+ unitType: "execute-task",
301
+ unitId: "M001/S01/T01",
302
+ });
289
303
  });
290
304
 
291
- test("advance() follows the ADR-015 invariant sequence before journaling advance", async () => {
292
- const { deps, calls } = makeDeps();
293
- const orchestrator = createAutoOrchestrator(deps);
305
+ test("getStatus() returns defensive copy of activeUnit", async (t) => {
306
+ const f = makeFixture();
307
+ t.after(() => f.cleanup());
294
308
 
295
- const result = await orchestrator.advance();
309
+ await f.orchestrator.advance();
310
+ const snap1 = f.orchestrator.getStatus();
311
+ if (snap1.activeUnit) snap1.activeUnit.unitId = "MUTATED";
312
+ const snap2 = f.orchestrator.getStatus();
296
313
 
297
- assert.equal(result.kind, "advanced");
298
- assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "T01" });
299
- assert.deepEqual(calls, [
300
- "runtime.lock",
301
- "health.stale",
302
- "gate:resource-version-guard:pass",
303
- "health.pre",
304
- "gate:pre-dispatch-health-gate:pass",
305
- "state.reconcile",
306
- "dispatch.decide",
307
- "tool.compile",
308
- "worktree.prepare",
309
- "journal:advance",
310
- "worktree.sync",
311
- "health.post",
312
- ]);
314
+ assert.equal(snap2.activeUnit?.unitId, "M001/S01/T01");
313
315
  });
314
316
 
315
- test("advance() blocks before dispatch when State Reconciliation blocks", async () => {
316
- const { deps, calls } = makeDeps({
317
- stateReconciliation: {
318
- async reconcileBeforeDispatch() {
319
- calls.push("state.reconcile");
320
- return { ok: false, reason: "state drift blocked", stateSnapshot: makeState() };
321
- },
322
- },
323
- });
324
- const orchestrator = createAutoOrchestrator(deps);
325
-
326
- const result = await orchestrator.advance();
317
+ // ─────────────────────────────────────────────────────────────────────────────
318
+ // Dispatch passthrough decisions (skip / blocked / no-remaining-units)
319
+ // ─────────────────────────────────────────────────────────────────────────────
327
320
 
328
- assertBlockedResult(result);
329
- assert.equal(result.reason, "state drift blocked");
330
- assert.equal(result.action, "pause");
331
- assert.ok(!calls.includes("dispatch.decide"));
332
- assert.ok(calls.includes("journal:advance-blocked"));
333
- });
334
-
335
- test("advance() blocks before Runtime persistence when Tool Contract fails", async () => {
336
- const { deps, calls } = makeDeps({
337
- toolContract: {
338
- async compileUnitToolContract() {
339
- calls.push("tool.compile");
340
- return { ok: false, reason: "unknown Unit" };
341
- },
342
- },
321
+ test("advance() keeps running when dispatch intentionally skips a phase", async (t) => {
322
+ const f = makeFixture({
323
+ dispatch: () => ({ action: "skip", matchedRule: "evaluating-gates skipped after marking gates omitted" }),
343
324
  });
344
- const orchestrator = createAutoOrchestrator(deps);
325
+ t.after(() => f.cleanup());
345
326
 
346
- const result = await orchestrator.advance();
327
+ const result = await f.orchestrator.advance();
347
328
 
348
- assertBlockedResult(result);
349
- assert.equal(result.reason, "unknown Unit");
350
- assert.equal(result.action, "pause");
351
- assert.ok(!calls.includes("worktree.prepare"));
352
- assert.ok(!calls.includes("journal:advance"));
353
- assert.ok(calls.includes("journal:advance-blocked"));
329
+ assert.equal(result.kind, "skipped");
330
+ if (result.kind !== "skipped") return;
331
+ assert.equal(result.reason, "evaluating-gates skipped after marking gates omitted");
332
+ assert.equal(f.orchestrator.getStatus().phase, "running");
333
+ const names = f.journalNames();
334
+ assert.ok(names.includes("advance-skipped"));
335
+ assert.ok(!names.includes("advance-stopped"));
354
336
  });
355
337
 
356
- test("advance() blocks before Runtime persistence when Worktree Safety fails", async () => {
357
- const { deps, calls } = makeDeps({
358
- worktree: {
359
- async prepareForUnit() {
360
- calls.push("worktree.prepare");
361
- return { ok: false, reason: "worktree invalid" };
362
- },
363
- async syncAfterUnit() { calls.push("worktree.sync"); },
364
- async cleanupOnStop() { calls.push("worktree.cleanup"); },
365
- },
338
+ test("advance() surfaces dispatch blocker reason instead of generic no remaining units", async (t) => {
339
+ const reason = "Milestone M001 validation verdict is needs-remediation but all slices are complete.";
340
+ const f = makeFixture({
341
+ dispatch: () => ({ action: "stop", reason, level: "warning" }),
366
342
  });
367
- const orchestrator = createAutoOrchestrator(deps);
343
+ t.after(() => f.cleanup());
368
344
 
369
- const result = await orchestrator.advance();
345
+ const result = await f.orchestrator.advance();
370
346
 
371
- assertBlockedResult(result);
372
- assert.equal(result.reason, "worktree invalid");
347
+ assert.equal(result.kind, "blocked");
348
+ if (result.kind !== "blocked") return;
349
+ assert.equal(result.reason, reason);
373
350
  assert.equal(result.action, "pause");
374
- assert.ok(!calls.includes("journal:advance"));
375
- assert.ok(!calls.includes("worktree.sync"));
376
- assert.ok(calls.includes("journal:advance-blocked"));
351
+ const names = f.journalNames();
352
+ assert.ok(names.includes("advance-blocked"));
353
+ assert.ok(!names.includes("advance-stopped"));
377
354
  });
378
355
 
379
- test("advance() allows non-worktree isolation prepare result", async () => {
380
- const { deps, calls } = makeDeps({
381
- worktree: {
382
- async prepareForUnit() {
383
- calls.push("worktree.prepare");
384
- return { ok: true, reason: "isolation-not-worktree" };
385
- },
386
- async syncAfterUnit() { calls.push("worktree.sync"); },
387
- async cleanupOnStop() { calls.push("worktree.cleanup"); },
388
- },
356
+ test("advance() stop level=error blocks with action stop", async (t) => {
357
+ const f = makeFixture({
358
+ dispatch: () => ({ action: "stop", reason: "hard blocker", level: "error" }),
389
359
  });
390
- const orchestrator = createAutoOrchestrator(deps);
360
+ t.after(() => f.cleanup());
391
361
 
392
- const result = await orchestrator.advance();
362
+ const result = await f.orchestrator.advance();
393
363
 
394
- assert.equal(result.kind, "advanced");
395
- assert.ok(calls.includes("journal:advance"));
396
- assert.ok(calls.includes("worktree.sync"));
397
- });
398
-
399
- test("advance() stops when dispatch has no next unit", async () => {
400
- const { deps } = makeDeps({
401
- dispatch: {
402
- async decideNextUnit() { return null; },
403
- },
404
- });
405
- const orchestrator = createAutoOrchestrator(deps);
406
-
407
- const result = await orchestrator.advance();
408
-
409
- assert.equal(result.kind, "stopped");
410
- assert.equal(orchestrator.getStatus().phase, "stopped");
364
+ assert.equal(result.kind, "blocked");
365
+ if (result.kind !== "blocked") return;
366
+ assert.equal(result.action, "stop");
411
367
  });
412
368
 
413
- test("advance() reports completion when complete state has no next unit", async () => {
414
- const completeState: GSDState = {
415
- ...makeState(),
416
- activeMilestone: null,
417
- phase: "complete",
418
- lastCompletedMilestone: { id: "M001", title: "Milestone" },
419
- nextAction: "All milestones complete.",
420
- };
421
- const { deps } = makeDeps({
422
- stateReconciliation: {
423
- async reconcileBeforeDispatch() {
424
- return { ok: true, stateSnapshot: completeState };
425
- },
426
- },
427
- dispatch: {
428
- async decideNextUnit() { return null; },
429
- },
430
- });
431
- const orchestrator = createAutoOrchestrator(deps);
369
+ test("advance() reports completion when complete state has no next unit", async (t) => {
370
+ const f = makeFixture({ complete: true, noTask: true });
371
+ t.after(() => f.cleanup());
432
372
 
433
- const result = await orchestrator.advance();
373
+ const result = await f.orchestrator.advance();
434
374
 
435
375
  assert.equal(result.kind, "stopped");
376
+ if (result.kind !== "stopped") return;
436
377
  assert.equal(result.reason, "all milestones complete");
378
+ assert.equal(f.orchestrator.getStatus().phase, "stopped");
437
379
  });
438
380
 
439
- test("advance() keeps running when dispatch intentionally skips a phase", async () => {
440
- const { deps, calls } = makeDeps({
441
- dispatch: {
442
- async decideNextUnit() {
443
- return { kind: "skipped", reason: "evaluating-gates skipped after marking gates omitted" };
444
- },
445
- },
446
- });
447
- const orchestrator = createAutoOrchestrator(deps);
448
-
449
- const result = await orchestrator.advance();
450
-
451
- assert.equal(result.kind, "skipped");
452
- if (result.kind !== "skipped") return;
453
- assert.equal(result.reason, "evaluating-gates skipped after marking gates omitted");
454
- assert.equal(orchestrator.getStatus().phase, "running");
455
- assert.ok(calls.includes("journal:advance-skipped"));
456
- assert.ok(!calls.includes("journal:advance-stopped"));
457
- });
458
-
459
- test("advance() surfaces dispatch blocker reason instead of generic no remaining units", async () => {
460
- const { deps, calls } = makeDeps({
461
- dispatch: {
462
- async decideNextUnit() {
463
- return {
464
- kind: "blocked",
465
- reason: "Milestone M001 validation verdict is needs-remediation but all slices are complete.",
466
- action: "pause",
467
- };
468
- },
381
+ test("advance() stopped clears previous activeUnit and resets idempotent lock", async (t) => {
382
+ // First advance dispatches; then we make the milestone resolve to no unit by
383
+ // closing it on disk + DB and re-deriving. Simpler: drive a fixture that
384
+ // dispatches once, finalize externally, then the next decision is complete.
385
+ let dispatchOnce = true;
386
+ const f = makeFixture({
387
+ dispatch: () => {
388
+ if (dispatchOnce) {
389
+ dispatchOnce = false;
390
+ return DEFAULT_DISPATCH;
391
+ }
392
+ // After the first advance, signal completion via a benign skip → still
393
+ // exercises the running/active-unit transition. For the stopped path we
394
+ // rely on the complete-state test above.
395
+ return { action: "skip", matchedRule: "done" };
469
396
  },
470
397
  });
471
- const orchestrator = createAutoOrchestrator(deps);
398
+ t.after(() => f.cleanup());
472
399
 
473
- const result = await orchestrator.advance();
400
+ const first = await f.orchestrator.advance();
401
+ assert.equal(first.kind, "advanced");
474
402
 
475
- assert.equal(result.kind, "blocked");
476
- if (result.kind !== "blocked") return;
477
- assert.equal(result.reason, "Milestone M001 validation verdict is needs-remediation but all slices are complete.");
478
- assert.equal(result.action, "pause");
479
- assert.ok(calls.includes("journal:advance-blocked"));
480
- assert.ok(!calls.includes("journal:advance-stopped"));
403
+ const second = await f.orchestrator.advance();
404
+ assert.equal(second.kind, "skipped");
405
+ // skip clears activeUnit
406
+ assert.equal(f.orchestrator.getStatus().activeUnit, undefined);
481
407
  });
482
408
 
483
- test("resume() enters running phase without dispatching", async () => {
484
- const { deps, calls } = makeDeps();
485
- const orchestrator = createAutoOrchestrator(deps);
486
-
487
- const result = await orchestrator.resume();
488
-
489
- assert.equal(result.kind, "resumed");
490
- assert.equal(orchestrator.getStatus().phase, "running");
491
- assert.ok(!calls.includes("journal:advance"));
492
- assert.ok(!calls.includes("dispatch.decide"));
493
- });
409
+ // ─────────────────────────────────────────────────────────────────────────────
410
+ // Idempotency + finalized guard + stuck-loop ring (issues #5786 / #5787 / #415)
411
+ // ─────────────────────────────────────────────────────────────────────────────
494
412
 
495
- test("advance() uses recovery on error", async () => {
496
- const { deps, calls } = makeDeps({
497
- runtime: {
498
- async ensureLockOwnership() { throw new Error("lock lost"); },
499
- async journalTransition(event) { calls.push(`journal:${event.name}`); },
500
- },
501
- recovery: {
502
- async classifyAndRecover() { return { action: "escalate", reason: "needs manual" }; },
503
- },
504
- });
505
- const orchestrator = createAutoOrchestrator(deps);
413
+ test("advance() is idempotent for the same active unit", async (t) => {
414
+ const f = makeFixture();
415
+ t.after(() => f.cleanup());
506
416
 
507
- const result = await orchestrator.advance();
417
+ const first = await f.orchestrator.advance();
418
+ const second = await f.orchestrator.advance();
508
419
 
509
- assert.equal(result.kind, "error");
510
- assert.equal(result.reason, "needs manual");
511
- assert.equal(orchestrator.getStatus().phase, "error");
512
- assert.ok(calls.includes("journal:advance-error"));
420
+ assert.equal(first.kind, "advanced");
421
+ if (first.kind === "advanced") {
422
+ assert.deepEqual(first.unit, { unitType: "execute-task", unitId: "M001/S01/T01" });
423
+ }
424
+ assert.equal(second.kind, "blocked");
425
+ if (second.kind !== "blocked") return;
426
+ assert.equal(second.reason, "idempotent advance: unit already active");
427
+ assert.equal(second.action, "pause");
513
428
  });
514
429
 
515
- test("advance() is idempotent for the same active unit", async () => {
516
- const { deps, calls } = makeDeps();
517
- const orchestrator = createAutoOrchestrator(deps);
430
+ test("idempotency block fires with its own reason before saturation", async (t) => {
431
+ const f = makeFixture();
432
+ t.after(() => f.cleanup());
518
433
 
519
- const first = await orchestrator.advance();
520
- const second = await orchestrator.advance();
434
+ const first = await f.orchestrator.advance();
435
+ const second = await f.orchestrator.advance();
521
436
 
522
437
  assert.equal(first.kind, "advanced");
523
- assert.deepEqual(first.unit, { unitType: "execute-task", unitId: "T01" });
524
438
  assert.equal(second.kind, "blocked");
439
+ if (second.kind !== "blocked") return;
525
440
  assert.equal(second.reason, "idempotent advance: unit already active");
526
441
  assert.equal(second.action, "pause");
527
-
528
- const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
529
- assert.equal(prepareCalls, 1);
530
442
  });
531
443
 
532
- test("completeActiveUnit clears in-flight idempotency and stops stale same-unit advance", async () => {
533
- const { deps, calls } = makeDeps();
534
- const orchestrator = createAutoOrchestrator(deps);
444
+ test("completeActiveUnit clears in-flight idempotency and stops stale same-unit advance", async (t) => {
445
+ const f = makeFixture();
446
+ t.after(() => f.cleanup());
535
447
 
536
- const first = await orchestrator.advance();
448
+ const first = await f.orchestrator.advance();
537
449
  assert.equal(first.kind, "advanced");
538
450
  if (first.kind !== "advanced") throw new Error("expected first advance");
539
451
 
540
- await orchestrator.completeActiveUnit(first.unit);
541
- const second = await orchestrator.advance();
452
+ await f.orchestrator.completeActiveUnit(first.unit);
453
+ const second = await f.orchestrator.advance();
542
454
 
543
- assert.equal(orchestrator.getStatus().activeUnit, undefined);
455
+ assert.equal(f.orchestrator.getStatus().activeUnit, undefined);
544
456
  assert.equal(second.kind, "blocked");
545
457
  if (second.kind !== "blocked") throw new Error("expected stale same-unit block");
546
458
  assert.equal(second.action, "stop");
547
- assert.equal(second.reason, "state did not advance after finalized execute-task T01");
548
- assert.ok(calls.includes("journal:unit-finalized"));
549
- const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
550
- assert.equal(prepareCalls, 1, "stale same-unit advance must not prepare or redispatch");
459
+ assert.equal(second.reason, "state did not advance after finalized execute-task M001/S01/T01");
460
+ assert.ok(f.journalNames().includes("unit-finalized"));
551
461
  });
552
462
 
553
- test("completeActiveUnit allows a different next unit to advance", async () => {
554
- let nextTaskId = "T01";
555
- const { deps } = makeDeps({
556
- dispatch: {
557
- async decideNextUnit() {
558
- return { unitType: "execute-task", unitId: nextTaskId, reason: "ready", preconditions: [] };
559
- },
560
- },
463
+ test("#442: finalized-repeat recovers (skipped) when the unit's artifact already exists on disk", async (t) => {
464
+ // plan-milestone's expected artifact is the ROADMAP, which the fixture
465
+ // already writes so verifyExpectedArtifact returns true. This is the legacy
466
+ // stuck-recovery scenario (unit completed on disk, DB row stale): instead of
467
+ // the finalized-repeat HARD-STOP, #442 verify-and-recover should refresh +
468
+ // skip so the loop can progress. plan-milestone is deliberately NOT one of
469
+ // the DB-refreshing unit types, so the recovery stays side-effect-light.
470
+ const f = makeFixture({
471
+ dispatch: () => ({ action: "dispatch", unitType: "plan-milestone", unitId: "M001", prompt: "p" }),
561
472
  });
562
- const orchestrator = createAutoOrchestrator(deps);
473
+ t.after(() => f.cleanup());
474
+
475
+ const first = await f.orchestrator.advance();
476
+ if (first.kind !== "advanced") {
477
+ throw new Error(`expected advanced, got ${first.kind}: ${(first as { reason?: string }).reason ?? ""}`);
478
+ }
479
+ await f.orchestrator.completeActiveUnit(first.unit);
563
480
 
564
- const first = await orchestrator.advance();
481
+ const second = await f.orchestrator.advance();
482
+ assert.equal(second.kind, "skipped", "should recover via artifact verification, not hard-stop");
483
+ if (second.kind !== "skipped") throw new Error("expected skipped recovery");
484
+ assert.match(second.reason, /stuck-recovery/);
485
+ assert.ok(f.journalNames().includes("advance-skipped"));
486
+ });
487
+
488
+ test("completeActiveUnit allows a different next unit to advance", async (t) => {
489
+ let nextTaskId = "M001/S01/T01";
490
+ const f = makeFixture({
491
+ dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: nextTaskId, prompt: "p" }),
492
+ });
493
+ t.after(() => f.cleanup());
494
+
495
+ const first = await f.orchestrator.advance();
565
496
  assert.equal(first.kind, "advanced");
566
497
  if (first.kind !== "advanced") throw new Error("expected first advance");
567
498
 
568
- await orchestrator.completeActiveUnit(first.unit);
569
- nextTaskId = "T02";
570
- const second = await orchestrator.advance();
499
+ await f.orchestrator.completeActiveUnit(first.unit);
500
+ nextTaskId = "M001/S01/T02";
501
+ const second = await f.orchestrator.advance();
571
502
 
572
503
  assert.equal(second.kind, "advanced");
573
504
  if (second.kind !== "advanced") throw new Error("expected second advance");
574
- assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "T02" });
505
+ assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "M001/S01/T02" });
575
506
  });
576
507
 
577
- test("completeActiveUnit guard survives an intervening advance and blocks X→Y→X re-dispatch", async () => {
578
- // Regression test for issue #415: lastFinalizedUnitKey was wiped on every advance(),
579
- // allowing completed units to be re-dispatched after any interleaving unit (X→Y→X).
580
- let nextTaskId = "T01";
581
- const { deps } = makeDeps({
582
- dispatch: {
583
- async decideNextUnit() {
584
- return { unitType: "execute-task", unitId: nextTaskId, reason: "ready", preconditions: [] };
585
- },
586
- },
508
+ test("completeActiveUnit guard survives an intervening advance and blocks X→Y→X re-dispatch (#415)", async (t) => {
509
+ let nextTaskId = "M001/S01/T01";
510
+ const f = makeFixture({
511
+ dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: nextTaskId, prompt: "p" }),
587
512
  });
588
- const orchestrator = createAutoOrchestrator(deps);
513
+ t.after(() => f.cleanup());
589
514
 
590
- // Step 1: advance X (T01)
591
- const first = await orchestrator.advance();
515
+ const first = await f.orchestrator.advance();
592
516
  assert.equal(first.kind, "advanced");
593
517
  if (first.kind !== "advanced") throw new Error("expected first advance");
594
518
 
595
- // Step 2: complete X (T01) — sets lastFinalizedUnitKey = 'execute-task:T01'
596
- await orchestrator.completeActiveUnit(first.unit);
519
+ await f.orchestrator.completeActiveUnit(first.unit);
597
520
 
598
- // Step 3: advance Y (T02) — must NOT clear lastFinalizedUnitKey
599
- nextTaskId = "T02";
600
- const second = await orchestrator.advance();
521
+ nextTaskId = "M001/S01/T02";
522
+ const second = await f.orchestrator.advance();
601
523
  assert.equal(second.kind, "advanced");
602
524
  if (second.kind !== "advanced") throw new Error("expected second advance (T02)");
603
- assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "T02" });
525
+ assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "M001/S01/T02" });
604
526
 
605
- // Step 4: re-select X (T01) — must be blocked because T01 was finalized
606
- nextTaskId = "T01";
607
- const third = await orchestrator.advance();
527
+ nextTaskId = "M001/S01/T01";
528
+ const third = await f.orchestrator.advance();
608
529
  assert.equal(third.kind, "blocked");
609
530
  if (third.kind !== "blocked") throw new Error("expected X→Y→X re-dispatch to be blocked");
610
531
  assert.equal(third.action, "stop");
611
- assert.equal(third.reason, "state did not advance after finalized execute-task T01");
532
+ assert.equal(third.reason, "state did not advance after finalized execute-task M001/S01/T01");
612
533
  });
613
534
 
614
- test("retryActiveUnit clears in-flight idempotency without marking the unit finalized", async () => {
615
- const { deps, calls } = makeDeps();
616
- const orchestrator = createAutoOrchestrator(deps);
535
+ test("retryActiveUnit clears in-flight idempotency without marking the unit finalized", async (t) => {
536
+ const f = makeFixture();
537
+ t.after(() => f.cleanup());
617
538
 
618
- const first = await orchestrator.advance();
539
+ const first = await f.orchestrator.advance();
619
540
  assert.equal(first.kind, "advanced");
620
541
  if (first.kind !== "advanced") throw new Error("expected first advance");
621
542
 
622
- await orchestrator.retryActiveUnit(first.unit);
623
- const second = await orchestrator.advance();
543
+ await f.orchestrator.retryActiveUnit(first.unit);
544
+ const second = await f.orchestrator.advance();
624
545
 
625
546
  assert.equal(second.kind, "advanced");
626
547
  if (second.kind !== "advanced") throw new Error("expected retry advance");
627
548
  assert.deepEqual(second.unit, first.unit);
628
- assert.ok(calls.includes("journal:unit-retry"));
629
- const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
630
- assert.equal(prepareCalls, 2, "retry should intentionally redispatch the same unit");
549
+ assert.ok(f.journalNames().includes("unit-retry"));
631
550
  });
632
551
 
633
- test("retryActiveUnit clears finalized same-unit guard for post-hook retries", async () => {
634
- const { deps, calls } = makeDeps();
635
- const orchestrator = createAutoOrchestrator(deps);
552
+ test("retryActiveUnit clears finalized same-unit guard for post-hook retries", async (t) => {
553
+ const f = makeFixture();
554
+ t.after(() => f.cleanup());
636
555
 
637
- const first = await orchestrator.advance();
556
+ const first = await f.orchestrator.advance();
638
557
  assert.equal(first.kind, "advanced");
639
558
  if (first.kind !== "advanced") throw new Error("expected first advance");
640
559
 
641
- await orchestrator.completeActiveUnit(first.unit);
642
- await orchestrator.retryActiveUnit(first.unit);
643
- const second = await orchestrator.advance();
560
+ await f.orchestrator.completeActiveUnit(first.unit);
561
+ await f.orchestrator.retryActiveUnit(first.unit);
562
+ const second = await f.orchestrator.advance();
644
563
 
645
564
  assert.equal(second.kind, "advanced");
646
565
  if (second.kind !== "advanced") throw new Error("expected retry advance");
647
566
  assert.deepEqual(second.unit, first.unit);
648
- assert.ok(calls.includes("journal:unit-finalized"));
649
- assert.ok(calls.includes("journal:unit-retry"));
650
- const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
651
- assert.equal(prepareCalls, 2, "post-hook retry should redispatch the finalized unit");
652
- });
653
-
654
- test("resume() re-enters running phase", async () => {
655
- const { deps } = makeDeps();
656
- const orchestrator = createAutoOrchestrator(deps);
657
-
658
- const result = await orchestrator.resume();
659
-
660
- assert.equal(result.kind, "resumed");
661
- assert.equal(orchestrator.getStatus().phase, "running");
567
+ const names = f.journalNames();
568
+ assert.ok(names.includes("unit-finalized"));
569
+ assert.ok(names.includes("unit-retry"));
662
570
  });
663
571
 
664
- test("resume() clears idempotent lock and allows re-advance", async () => {
665
- const { deps } = makeDeps();
666
- const orchestrator = createAutoOrchestrator(deps);
572
+ test("resume() clears idempotent lock and allows re-advance", async (t) => {
573
+ const f = makeFixture();
574
+ t.after(() => f.cleanup());
667
575
 
668
- const first = await orchestrator.advance();
669
- const blocked = await orchestrator.advance();
670
- const resumed = await orchestrator.resume();
671
- const next = await orchestrator.advance();
576
+ const first = await f.orchestrator.advance();
577
+ const blocked = await f.orchestrator.advance();
578
+ const resumed = await f.orchestrator.resume();
579
+ const next = await f.orchestrator.advance();
672
580
 
673
581
  assert.equal(first.kind, "advanced");
674
582
  assert.equal(blocked.kind, "blocked");
@@ -676,263 +584,81 @@ test("resume() clears idempotent lock and allows re-advance", async () => {
676
584
  assert.equal(next.kind, "advanced");
677
585
  });
678
586
 
679
- test("transitionCount increases across lifecycle transitions", async () => {
680
- const { deps } = makeDeps();
681
- const orchestrator = createAutoOrchestrator(deps);
682
-
683
- const before = orchestrator.getStatus().transitionCount;
684
- await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
685
- const afterStart = orchestrator.getStatus().transitionCount;
686
- await orchestrator.stop("done");
687
- const afterStop = orchestrator.getStatus().transitionCount;
688
-
689
- assert.ok(afterStart > before);
690
- assert.ok(afterStop > afterStart);
691
- });
692
-
693
- test("stop() clears idempotent unit lock so advance can run again", async () => {
694
- const { deps } = makeDeps();
695
- const orchestrator = createAutoOrchestrator(deps);
587
+ test("start() clears prior idempotent lock", async (t) => {
588
+ const f = makeFixture();
589
+ t.after(() => f.cleanup());
696
590
 
697
- const first = await orchestrator.advance();
698
- const blocked = await orchestrator.advance();
699
- const stopped = await orchestrator.stop("reset");
700
- const second = await orchestrator.advance();
701
-
702
- assert.equal(first.kind, "advanced");
703
- assert.equal(blocked.kind, "blocked");
704
- assert.equal(stopped.kind, "stopped");
705
- assert.equal(second.kind, "advanced");
706
- });
707
-
708
- test("advance() stopped clears previous activeUnit", async () => {
709
- let first = true;
710
- const { deps } = makeDeps({
711
- dispatch: {
712
- async decideNextUnit() {
713
- if (first) {
714
- first = false;
715
- return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
716
- }
717
- return null;
718
- },
719
- },
720
- });
721
- const orchestrator = createAutoOrchestrator(deps);
722
-
723
- await orchestrator.advance();
724
- const stopped = await orchestrator.advance();
725
-
726
- assert.equal(stopped.kind, "stopped");
727
- assert.equal(orchestrator.getStatus().activeUnit, undefined);
728
- });
729
-
730
- test("recovery stop clears activeUnit", async () => {
731
- const { deps, calls } = makeDeps({
732
- runtime: {
733
- async ensureLockOwnership() { throw new Error("boom"); },
734
- async journalTransition(event) { calls.push(`journal:${event.name}`); },
735
- },
736
- recovery: {
737
- async classifyAndRecover() { return { action: "stop", reason: "fatal" }; },
738
- },
739
- });
740
- const orchestrator = createAutoOrchestrator(deps);
741
-
742
- const result = await orchestrator.advance();
743
-
744
- assert.equal(result.kind, "stopped");
745
- assert.equal(orchestrator.getStatus().activeUnit, undefined);
746
- assert.ok(calls.includes("journal:advance-stopped"));
747
- assert.ok(calls.includes("notify:stopped"));
748
- assert.ok(!calls.includes("notify:error"));
749
- });
750
-
751
- test("recovery retry maps to paused result", async () => {
752
- const { deps, calls } = makeDeps({
753
- runtime: {
754
- async ensureLockOwnership() { throw new Error("boom"); },
755
- async journalTransition(event) { calls.push(`journal:${event.name}`); },
756
- },
757
- recovery: {
758
- async classifyAndRecover() { return { action: "retry", reason: "transient" }; },
759
- },
760
- });
761
- const orchestrator = createAutoOrchestrator(deps);
762
-
763
- const result = await orchestrator.advance();
764
-
765
- assert.equal(result.kind, "paused");
766
- assert.equal(result.reason, "transient");
767
- assert.equal(orchestrator.getStatus().phase, "paused");
768
- assert.ok(calls.includes("journal:advance-paused"));
769
- assert.ok(calls.includes("notify:pause"));
770
- });
771
-
772
- test("getStatus() returns defensive copy of activeUnit", async () => {
773
- const { deps } = makeDeps();
774
- const orchestrator = createAutoOrchestrator(deps);
775
-
776
- await orchestrator.advance();
777
- const snap1 = orchestrator.getStatus();
778
- if (snap1.activeUnit) snap1.activeUnit.unitId = "MUTATED";
779
- const snap2 = orchestrator.getStatus();
780
-
781
- assert.equal(snap2.activeUnit?.unitId, "T01");
782
- });
783
-
784
- test("start() clears prior idempotent lock", async () => {
785
- const { deps } = makeDeps();
786
- const orchestrator = createAutoOrchestrator(deps);
787
-
788
- await orchestrator.advance();
789
- const blocked = await orchestrator.advance();
790
- const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
791
- const next = await orchestrator.advance();
591
+ await f.orchestrator.advance();
592
+ const blocked = await f.orchestrator.advance();
593
+ const restarted = await f.orchestrator.start(SESSION_CONTEXT);
594
+ const next = await f.orchestrator.advance();
792
595
 
793
596
  assert.equal(blocked.kind, "blocked");
794
597
  assert.equal(restarted.kind, "started");
795
598
  assert.equal(next.kind, "advanced");
796
599
  });
797
600
 
798
- test("error path emits error notification", async () => {
799
- const { deps, calls } = makeDeps({
800
- runtime: {
801
- async ensureLockOwnership() { throw new Error("boom"); },
802
- async journalTransition(event) { calls.push(`journal:${event.name}`); },
803
- },
804
- recovery: {
805
- async classifyAndRecover() { return { action: "escalate", reason: "needs manual" }; },
806
- },
807
- });
808
- const orchestrator = createAutoOrchestrator(deps);
809
-
810
- await orchestrator.advance();
811
-
812
- assert.ok(calls.includes("notify:error"));
813
- });
814
-
815
- test("blocked path journals advance-blocked", async () => {
816
- const { deps, calls } = makeDeps();
817
- const orchestrator = createAutoOrchestrator(deps);
818
-
819
- await orchestrator.advance();
820
- await orchestrator.advance();
821
-
822
- assert.ok(calls.includes("journal:advance-blocked"));
823
- });
601
+ test("stop() clears idempotent unit lock so advance can run again", async (t) => {
602
+ const f = makeFixture();
603
+ t.after(() => f.cleanup());
824
604
 
825
- test("health post hook runs on blocked result", async () => {
826
- const { deps, calls } = makeDeps();
827
- const orchestrator = createAutoOrchestrator(deps);
828
-
829
- await orchestrator.advance();
830
- await orchestrator.advance();
831
-
832
- assert.ok(calls.includes("health.post"));
833
- });
834
-
835
- test("start() emits start notification", async () => {
836
- const { deps, calls } = makeDeps();
837
- const orchestrator = createAutoOrchestrator(deps);
838
-
839
- await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
840
-
841
- assert.ok(calls.includes("notify:start"));
842
- });
843
-
844
- test("resume() emits resume notification", async () => {
845
- const { deps, calls } = makeDeps();
846
- const orchestrator = createAutoOrchestrator(deps);
847
-
848
- await orchestrator.resume();
849
-
850
- assert.ok(calls.includes("notify:resume"));
851
- });
852
-
853
- test("stopped with no remaining units clears idempotent lock for next advance", async () => {
854
- let callCount = 0;
855
- const { deps } = makeDeps({
856
- dispatch: {
857
- async decideNextUnit() {
858
- callCount += 1;
859
- if (callCount === 2) return null;
860
- return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
861
- },
862
- },
863
- });
864
- const orchestrator = createAutoOrchestrator(deps);
865
-
866
- const first = await orchestrator.advance();
867
- const stopped = await orchestrator.advance();
868
- const after = await orchestrator.advance();
605
+ const first = await f.orchestrator.advance();
606
+ const blocked = await f.orchestrator.advance();
607
+ const stopped = await f.orchestrator.stop("reset");
608
+ const second = await f.orchestrator.advance();
869
609
 
870
610
  assert.equal(first.kind, "advanced");
611
+ assert.equal(blocked.kind, "blocked");
871
612
  assert.equal(stopped.kind, "stopped");
872
- assert.equal(after.kind, "advanced");
613
+ assert.equal(second.kind, "advanced");
873
614
  });
874
615
 
875
- test("stop() cleans up worktree and transitions to stopped", async () => {
876
- const { deps, calls } = makeDeps();
877
- const orchestrator = createAutoOrchestrator(deps);
616
+ test("blocked path journals advance-blocked and records a health snapshot", async (t) => {
617
+ const f = makeFixture();
618
+ t.after(() => f.cleanup());
878
619
 
879
- const result = await orchestrator.stop("user-request");
620
+ await f.orchestrator.advance();
621
+ await f.orchestrator.advance();
880
622
 
881
- assert.equal(result.kind, "stopped");
882
- assert.equal(orchestrator.getStatus().phase, "stopped");
883
- assert.ok(calls.includes("worktree.cleanup"));
884
- assert.ok(calls.includes("journal:stop"));
885
- assert.ok(calls.includes("notify:stop"));
623
+ assert.ok(f.journalNames().includes("advance-blocked"));
886
624
  });
887
625
 
888
- // ────────────────────────────────────────────────────────────────────────
889
- // Stuck-loop ring buffer (issue #5787)
890
- // ────────────────────────────────────────────────────────────────────────
626
+ // ─── Stuck-loop ring buffer (issue #5787) ──────────────────────────────────
891
627
 
892
628
  test("STUCK_WINDOW_SIZE matches the legacy auto/phases.ts constant", () => {
893
629
  assert.equal(STUCK_WINDOW_SIZE, 6);
894
630
  });
895
631
 
896
- test("stuck-loop: empty ring on a freshly constructed orchestrator advances normally", async () => {
897
- const { deps } = makeDeps();
898
- const orchestrator = createAutoOrchestrator(deps);
632
+ test("stuck-loop: empty ring on a freshly constructed orchestrator advances normally", async (t) => {
633
+ const f = makeFixture();
634
+ t.after(() => f.cleanup());
899
635
 
900
- const result = await orchestrator.advance();
636
+ const result = await f.orchestrator.advance();
901
637
 
902
638
  assert.equal(result.kind, "advanced");
903
639
  });
904
640
 
905
- test("stuck-loop: partial fill of mixed units does not block", async () => {
906
- // Alternate A/B for STUCK_WINDOW_SIZE rounds. No single key saturates the
907
- // window, so neither idempotency nor stuck-loop should fire.
641
+ test("stuck-loop: partial fill of mixed units does not block", async (t) => {
908
642
  let i = 0;
909
- const sequence = ["A", "B", "A", "B", "A", "B"];
910
- const { deps } = makeDeps({
911
- dispatch: {
912
- async decideNextUnit() {
913
- const id = sequence[i++ % sequence.length];
914
- return { unitType: "execute-task", unitId: id, reason: "ready", preconditions: [] };
915
- },
916
- },
643
+ const sequence = ["M001/S01/A", "M001/S01/B", "M001/S01/A", "M001/S01/B", "M001/S01/A", "M001/S01/B"];
644
+ const f = makeFixture({
645
+ dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: sequence[i++ % sequence.length], prompt: "p" }),
917
646
  });
918
- const orchestrator = createAutoOrchestrator(deps);
647
+ t.after(() => f.cleanup());
919
648
 
920
649
  for (let round = 0; round < STUCK_WINDOW_SIZE; round++) {
921
- const result = await orchestrator.advance();
650
+ const result = await f.orchestrator.advance();
922
651
  assert.equal(result.kind, "advanced", `round ${round} should advance, got ${result.kind}`);
923
652
  }
924
653
  });
925
654
 
926
- test("stuck-loop: ring saturated with same unit blocks with action 'stop' and stuck-loop reason", async () => {
927
- // Dispatch picks the same unit every time. The first advance succeeds.
928
- // Calls 2..STUCK_WINDOW_SIZE-1 are idempotency-blocked while the ring fills.
929
- // The STUCK_WINDOW_SIZE'th call sees a saturated ring and returns stuck-loop.
930
- const { deps } = makeDeps();
931
- const orchestrator = createAutoOrchestrator(deps);
655
+ test("stuck-loop: ring saturated with same unit blocks with action 'stop' and stuck-loop reason", async (t) => {
656
+ const f = makeFixture();
657
+ t.after(() => f.cleanup());
932
658
 
933
- const results: Awaited<ReturnType<typeof orchestrator.advance>>[] = [];
659
+ const results: Awaited<ReturnType<typeof f.orchestrator.advance>>[] = [];
934
660
  for (let i = 0; i < STUCK_WINDOW_SIZE; i++) {
935
- results.push(await orchestrator.advance());
661
+ results.push(await f.orchestrator.advance());
936
662
  }
937
663
 
938
664
  // First call advances.
@@ -952,88 +678,108 @@ test("stuck-loop: ring saturated with same unit blocks with action 'stop' and st
952
678
  assert.equal(last.kind, "blocked");
953
679
  if (last.kind !== "blocked") return;
954
680
  assert.equal(last.action, "stop");
955
- assert.equal(last.reason, `stuck-loop: execute-task:T01 picked ${STUCK_WINDOW_SIZE} times`);
956
- });
957
-
958
- test("stuck-loop: idempotency block continues to fire with its own reason before saturation", async () => {
959
- // Two identical calls should produce idempotent (not stuck-loop). Ensures the
960
- // existing idempotency block is not absorbed by the new check.
961
- const { deps } = makeDeps();
962
- const orchestrator = createAutoOrchestrator(deps);
963
-
964
- const first = await orchestrator.advance();
965
- const second = await orchestrator.advance();
966
-
967
- assert.equal(first.kind, "advanced");
968
- assert.equal(second.kind, "blocked");
969
- assert.equal(second.reason, "idempotent advance: unit already active");
970
- assert.equal(second.action, "pause");
681
+ assert.equal(last.reason, `stuck-loop: execute-task:M001/S01/T01 picked ${STUCK_WINDOW_SIZE} times`);
971
682
  });
972
683
 
973
- test("stuck-loop: start() resets the ring so a fresh saturation cycle is required", async () => {
974
- // Fill the ring to one short of saturation, then start() — the ring should
975
- // be cleared, and the next advance must succeed instead of going stuck.
976
- const { deps } = makeDeps();
977
- const orchestrator = createAutoOrchestrator(deps);
684
+ test("stuck-loop: start() resets the ring so a fresh saturation cycle is required", async (t) => {
685
+ const f = makeFixture();
686
+ t.after(() => f.cleanup());
978
687
 
979
688
  for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
980
- await orchestrator.advance();
689
+ await f.orchestrator.advance();
981
690
  }
982
691
 
983
- const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
692
+ const restarted = await f.orchestrator.start(SESSION_CONTEXT);
984
693
  assert.equal(restarted.kind, "started");
985
694
 
986
- // Immediately after start(), the next advance should succeed because start()
987
- // no longer pre-dispatches and the ring was reset.
988
- const next = await orchestrator.advance();
695
+ const next = await f.orchestrator.advance();
989
696
  assert.equal(next.kind, "advanced");
990
697
  });
991
698
 
992
- test("stuck-loop: resume() resets the ring", async () => {
993
- const { deps } = makeDeps();
994
- const orchestrator = createAutoOrchestrator(deps);
699
+ test("stuck-loop: resume() resets the ring", async (t) => {
700
+ const f = makeFixture();
701
+ t.after(() => f.cleanup());
995
702
 
996
703
  for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
997
- await orchestrator.advance();
704
+ await f.orchestrator.advance();
998
705
  }
999
706
 
1000
- const resumed = await orchestrator.resume();
707
+ const resumed = await f.orchestrator.resume();
1001
708
  assert.equal(resumed.kind, "resumed");
1002
709
 
1003
- const next = await orchestrator.advance();
710
+ const next = await f.orchestrator.advance();
1004
711
  assert.equal(next.kind, "advanced");
1005
712
  });
1006
713
 
1007
- test("stuck-loop: stop() resets the ring", async () => {
1008
- const { deps } = makeDeps();
1009
- const orchestrator = createAutoOrchestrator(deps);
714
+ test("stuck-loop: stop() resets the ring", async (t) => {
715
+ const f = makeFixture();
716
+ t.after(() => f.cleanup());
1010
717
 
1011
718
  for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
1012
- await orchestrator.advance();
719
+ await f.orchestrator.advance();
1013
720
  }
1014
721
 
1015
- const stopped = await orchestrator.stop("user-request");
722
+ const stopped = await f.orchestrator.stop("user-request");
1016
723
  assert.equal(stopped.kind, "stopped");
1017
724
 
1018
- // Ring is cleared by stop(). A subsequent advance is a fresh first-touch.
1019
- const next = await orchestrator.advance();
725
+ const next = await f.orchestrator.advance();
1020
726
  assert.equal(next.kind, "advanced");
1021
727
  });
1022
728
 
1023
- test("stuck-loop: journal records the stuck-loop reason on advance-blocked", async () => {
1024
- const { deps, calls } = makeDeps();
1025
- const orchestrator = createAutoOrchestrator(deps);
729
+ test("stuck-loop: journal records the stuck-loop reason on advance-blocked", async (t) => {
730
+ const f = makeFixture();
731
+ t.after(() => f.cleanup());
1026
732
 
1027
733
  for (let i = 0; i < STUCK_WINDOW_SIZE; i++) {
1028
- await orchestrator.advance();
734
+ await f.orchestrator.advance();
1029
735
  }
1030
736
 
1031
- assert.ok(calls.includes("journal:advance-blocked"));
737
+ const stuckEntry = queryJournal(f.base).find(
738
+ (e) => {
739
+ const reason = (e.data as Record<string, unknown> | undefined)?.reason;
740
+ return typeof reason === "string" && reason.startsWith("stuck-loop:");
741
+ },
742
+ );
743
+ assert.ok(stuckEntry, "journal must record an advance-blocked entry with the stuck-loop reason");
744
+ assert.ok(f.journalNames().includes("advance-blocked"));
745
+ });
746
+
747
+ // ─────────────────────────────────────────────────────────────────────────────
748
+ // Recovery path: a lock held by another process throws inside advance() and is
749
+ // routed through the REAL classifyFailure → result mapping + notifications.
750
+ // We force the throw by acquiring the lock under a different PID (writing a
751
+ // foreign-PID lockfile is not portable, so we drive the deterministic-stop
752
+ // classification via a fixture whose runtimeBasePath has no valid lock).
753
+ // ─────────────────────────────────────────────────────────────────────────────
754
+
755
+ test("advance() routes a lost-lock error through recovery and journals an outcome", async (t) => {
756
+ const f = makeFixture();
757
+ t.after(() => f.cleanup());
758
+
759
+ // Release the lock so ensureLockOwnership() sees missing-metadata and throws,
760
+ // exercising the catch → classifyAndRecover → result-mapping branch.
761
+ releaseSessionLock(f.base);
762
+ // Remove the lockfile artifact so getSessionLockStatus returns !valid.
763
+ try { rmSync(join(f.base, ".gsd", "auto.lock"), { force: true }); } catch { /* */ }
764
+ try { rmSync(join(f.base, ".gsd.lock"), { recursive: true, force: true }); } catch { /* */ }
765
+
766
+ const result = await f.orchestrator.advance();
767
+
768
+ // classifyFailure maps a generic Error to a recovery action; the orchestrator
769
+ // surfaces it as paused/stopped/error and journals the corresponding event.
770
+ assert.ok(["paused", "stopped", "error"].includes(result.kind), `unexpected kind ${result.kind}`);
771
+ const names = f.journalNames();
772
+ assert.ok(
773
+ names.includes("advance-paused") || names.includes("advance-stopped") || names.includes("advance-error"),
774
+ "recovery must journal an advance-paused/stopped/error event",
775
+ );
1032
776
  });
1033
777
 
1034
- // ─── closeout regression: wired orchestrator must not dispatch from a removed worktree ───
778
+ // ─────────────────────────────────────────────────────────────────────────────
779
+ // closeout regression: live-base resolver after worktree cleanup
780
+ // ─────────────────────────────────────────────────────────────────────────────
1035
781
 
1036
- test("wired orchestrator base resolver prefers live project root after worktree cleanup", (t) => {
782
+ test("live orchestrator base resolver prefers live project root after worktree cleanup", (t) => {
1037
783
  const projectRoot = mkdtempSync(join(tmpdir(), "gsd-orch-root-"));
1038
784
  const staleWorktreeRoot = join(projectRoot, ".gsd", "worktrees", "M002");
1039
785
  mkdirSync(join(staleWorktreeRoot, ".bg-shell"), { recursive: true });
@@ -1050,7 +796,7 @@ test("wired orchestrator base resolver prefers live project root after worktree
1050
796
  );
1051
797
  });
1052
798
 
1053
- test("wired orchestrator base resolver keeps a captured active git worktree", (t) => {
799
+ test("live orchestrator base resolver keeps a captured active git worktree", (t) => {
1054
800
  const projectRoot = mkdtempSync(join(tmpdir(), "gsd-orch-worktree-"));
1055
801
  const worktreeRoot = join(projectRoot, ".gsd", "worktrees", "M003");
1056
802
  mkdirSync(worktreeRoot, { recursive: true });
@@ -1066,14 +812,14 @@ test("wired orchestrator base resolver keeps a captured active git worktree", (t
1066
812
  );
1067
813
  });
1068
814
 
1069
- // ─── #5789 parity: wired dispatch adapter mirrors runDispatch's resolveDispatch call ───
815
+ // ─────────────────────────────────────────────────────────────────────────────
816
+ // Dispatch-decision parity (#5789) — formerly the createWiredDispatchAdapter
817
+ // tests. These exercise the exported pure decideOrchestratorDispatch helper.
818
+ // ─────────────────────────────────────────────────────────────────────────────
1070
819
 
1071
- test("wired DispatchAdapter forwards session-derived dispatch inputs identically to runDispatch", async () => {
820
+ test("decideOrchestratorDispatch forwards session-derived dispatch inputs identically to runDispatch", async () => {
1072
821
  const stateSnapshot = makeState();
1073
822
 
1074
- // Install a capturing registry so we observe the DispatchContext both code paths
1075
- // build, and force a deterministic dispatch action so the parity assertion is
1076
- // about *inputs*, not rule evaluation.
1077
823
  const captured: DispatchContext[] = [];
1078
824
  const captureRule: UnifiedRule = {
1079
825
  name: "test-capture",
@@ -1093,7 +839,6 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
1093
839
  setRegistry(new RuleRegistry([captureRule]));
1094
840
 
1095
841
  try {
1096
- // Mock ExtensionContext + ExtensionAPI with the surface the wired adapter touches.
1097
842
  const fakeModelRegistry = {
1098
843
  getAll: () => [],
1099
844
  getProviderAuthMode: (_provider: string) => "apiKey" as const,
@@ -1105,30 +850,28 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
1105
850
  contextWindow: 200_000,
1106
851
  },
1107
852
  modelRegistry: fakeModelRegistry,
1108
- } as any;
853
+ } as never;
1109
854
  const pi = {
1110
855
  getActiveTools: () => ["read_file", "write_file"],
1111
- } as any;
856
+ } as never;
1112
857
  const basePath = "/tmp/parity-fixture";
1113
858
 
1114
- // Path A — wired adapter (what createWiredAutoOrchestrationModule uses).
1115
- const adapter = createWiredDispatchAdapter(ctx, pi, basePath);
1116
- const adapterResult = await adapter.decideNextUnit({ stateSnapshot });
859
+ // Path A — the orchestrator's pure dispatch decision.
860
+ const adapterResult = await decideOrchestratorDispatch(ctx, pi, basePath, undefined, { stateSnapshot });
1117
861
 
1118
862
  // Path B — direct resolveDispatch call mirroring phases.ts:runDispatch.
1119
- // Inline the same derivations runDispatch uses so any drift here is a parity break.
1120
- const prefs = undefined; // loadEffectiveGSDPreferences returns null for /tmp/parity-fixture.
1121
- const provider = ctx.model?.provider;
1122
- const authMode = provider && typeof ctx.modelRegistry?.getProviderAuthMode === "function"
1123
- ? ctx.modelRegistry.getProviderAuthMode(provider)
863
+ const prefs = undefined;
864
+ const provider = (ctx as { model?: { provider?: string } }).model?.provider;
865
+ const authMode = provider && typeof fakeModelRegistry.getProviderAuthMode === "function"
866
+ ? fakeModelRegistry.getProviderAuthMode(provider)
1124
867
  : undefined;
1125
- const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
868
+ const activeTools = ["read_file", "write_file"];
1126
869
  const structuredQuestionsAvailable: "true" | "false" =
1127
870
  prefs !== undefined && (prefs as { planning_depth?: string }).planning_depth === "deep"
1128
871
  ? "false"
1129
872
  : supportsStructuredQuestions(activeTools, {
1130
873
  authMode,
1131
- baseUrl: ctx.model?.baseUrl,
874
+ baseUrl: (ctx as { model?: { baseUrl?: string } }).model?.baseUrl,
1132
875
  })
1133
876
  ? "true"
1134
877
  : "false";
@@ -1140,17 +883,15 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
1140
883
  state: stateSnapshot,
1141
884
  prefs,
1142
885
  structuredQuestionsAvailable,
1143
- sessionContextWindow: ctx.model?.contextWindow,
1144
- sessionProvider: ctx.model?.provider,
1145
- modelRegistry: ctx.modelRegistry,
886
+ sessionContextWindow: 200_000,
887
+ sessionProvider: "anthropic",
888
+ modelRegistry: fakeModelRegistry,
1146
889
  };
1147
890
  const directAction = await resolveDispatch(builtDirectCtx);
1148
891
 
1149
- // Two contexts captured: one per resolveDispatch call.
1150
892
  assert.equal(captured.length, 2, "expected two captured dispatch contexts");
1151
893
  const [adapterCtx, directCtx] = captured;
1152
894
 
1153
- // Parity assertion: session-derived fields are identical.
1154
895
  assert.equal(adapterCtx.structuredQuestionsAvailable, directCtx.structuredQuestionsAvailable);
1155
896
  assert.equal(adapterCtx.sessionContextWindow, directCtx.sessionContextWindow);
1156
897
  assert.equal(adapterCtx.sessionProvider, directCtx.sessionProvider);
@@ -1159,7 +900,6 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
1159
900
  assert.equal(adapterCtx.mid, directCtx.mid);
1160
901
  assert.equal(adapterCtx.midTitle, directCtx.midTitle);
1161
902
 
1162
- // Dispatch action equality: both flows reach the same dispatch decision.
1163
903
  if (!adapterResult || !("unitType" in adapterResult)) {
1164
904
  assert.fail("expected adapter result to be a dispatch decision");
1165
905
  }
@@ -1177,7 +917,7 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
1177
917
  }
1178
918
  });
1179
919
 
1180
- test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-derived values", async () => {
920
+ test("decideOrchestratorDispatch prefers caller-supplied dispatch inputs over ctx-derived values", async () => {
1181
921
  const stateSnapshot = makeState();
1182
922
  const captured: DispatchContext[] = [];
1183
923
  const captureRule: UnifiedRule = {
@@ -1213,14 +953,11 @@ test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-der
1213
953
  contextWindow: 200_000,
1214
954
  },
1215
955
  modelRegistry: ctxModelRegistry,
1216
- } as any;
1217
- const pi = {
1218
- getActiveTools: () => [],
1219
- } as any;
1220
- const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
1221
- const session = { basePath: "/tmp/session-fixture" } as any;
956
+ } as never;
957
+ const pi = { getActiveTools: () => [] } as never;
958
+ const session = { basePath: "/tmp/session-fixture" } as never;
1222
959
 
1223
- const result = await adapter.decideNextUnit({
960
+ const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/parity-fixture", undefined, {
1224
961
  stateSnapshot,
1225
962
  session,
1226
963
  structuredQuestionsAvailable: "true",
@@ -1242,7 +979,7 @@ test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-der
1242
979
  }
1243
980
  });
1244
981
 
1245
- test("wired DispatchAdapter forwards constructor session when advance input omits session", async () => {
982
+ test("decideOrchestratorDispatch forwards constructor session when advance input omits session", async () => {
1246
983
  const stateSnapshot = makeState();
1247
984
  const captured: DispatchContext[] = [];
1248
985
  const captureRule: UnifiedRule = {
@@ -1263,16 +1000,15 @@ test("wired DispatchAdapter forwards constructor session when advance input omit
1263
1000
  setRegistry(new RuleRegistry([captureRule]));
1264
1001
 
1265
1002
  try {
1266
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1267
- const pi = { getActiveTools: () => [] } as any;
1003
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1004
+ const pi = { getActiveTools: () => [] } as never;
1268
1005
  const session = {
1269
1006
  basePath: "/tmp/worktree-fixture",
1270
1007
  originalBasePath: "/tmp/project-fixture",
1271
1008
  currentMilestoneId: "M001",
1272
- } as any;
1273
- const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/project-fixture", session);
1009
+ } as never;
1274
1010
 
1275
- const result = await adapter.decideNextUnit({ stateSnapshot });
1011
+ const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/project-fixture", session, { stateSnapshot });
1276
1012
 
1277
1013
  assert.ok(result);
1278
1014
  assert.equal(captured.length, 1, "expected one captured dispatch context");
@@ -1283,7 +1019,7 @@ test("wired DispatchAdapter forwards constructor session when advance input omit
1283
1019
  }
1284
1020
  });
1285
1021
 
1286
- test("wired DispatchAdapter adopts next active milestone after the session milestone is closed", async (t) => {
1022
+ test("decideOrchestratorDispatch adopts next active milestone after the session milestone is closed", async (t) => {
1287
1023
  const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-milestone-adopt-"));
1288
1024
  t.after(() => rmSync(base, { recursive: true, force: true }));
1289
1025
 
@@ -1314,28 +1050,27 @@ test("wired DispatchAdapter adopts next active milestone after the session miles
1314
1050
  setRegistry(new RuleRegistry([captureRule]));
1315
1051
 
1316
1052
  try {
1317
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1318
- const pi = { getActiveTools: () => [] } as any;
1053
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1054
+ const pi = { getActiveTools: () => [] } as never;
1319
1055
  const session = {
1320
1056
  basePath: base,
1321
1057
  originalBasePath: base,
1322
1058
  currentMilestoneId: "M001",
1323
- } as any;
1324
- const adapter = createWiredDispatchAdapter(ctx, pi, base, session);
1059
+ } as never;
1325
1060
 
1326
- const result = await adapter.decideNextUnit({ stateSnapshot });
1061
+ const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
1327
1062
 
1328
1063
  assert.ok(result);
1329
- if (!("unitType" in result)) assert.fail(`expected dispatch decision, got ${JSON.stringify(result)}`);
1064
+ if (!result || !("unitType" in result)) assert.fail(`expected dispatch decision, got ${JSON.stringify(result)}`);
1330
1065
  assert.equal(result.unitId, "M002/S01/T01");
1331
- assert.equal(session.currentMilestoneId, "M002");
1066
+ assert.equal((session as { currentMilestoneId: string }).currentMilestoneId, "M002");
1332
1067
  assert.equal(captured[0]?.session?.currentMilestoneId, "M002");
1333
1068
  } finally {
1334
1069
  resetRegistry();
1335
1070
  }
1336
1071
  });
1337
1072
 
1338
- test("wired DispatchAdapter keeps blocking stale milestone worktree scope", async (t) => {
1073
+ test("decideOrchestratorDispatch keeps blocking stale milestone worktree scope", async (t) => {
1339
1074
  const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-worktree-block-"));
1340
1075
  t.after(() => rmSync(base, { recursive: true, force: true }));
1341
1076
 
@@ -1349,16 +1084,15 @@ test("wired DispatchAdapter keeps blocking stale milestone worktree scope", asyn
1349
1084
  };
1350
1085
  const worktreePath = join(base, ".gsd", "worktrees", "M001");
1351
1086
  mkdirSync(worktreePath, { recursive: true });
1352
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1353
- const pi = { getActiveTools: () => [] } as any;
1087
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1088
+ const pi = { getActiveTools: () => [] } as never;
1354
1089
  const session = {
1355
1090
  basePath: worktreePath,
1356
1091
  originalBasePath: base,
1357
1092
  currentMilestoneId: "M001",
1358
- } as any;
1359
- const adapter = createWiredDispatchAdapter(ctx, pi, base, session);
1093
+ } as never;
1360
1094
 
1361
- const result = await adapter.decideNextUnit({ stateSnapshot });
1095
+ const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
1362
1096
 
1363
1097
  assert.deepEqual(result, {
1364
1098
  kind: "blocked",
@@ -1366,13 +1100,13 @@ test("wired DispatchAdapter keeps blocking stale milestone worktree scope", asyn
1366
1100
  'Dispatch milestone mismatch: context mid "M002" does not match session.currentMilestoneId "M001". The active worktree/session and derived project state disagree; recover, park, or discard the stranded milestone before continuing.',
1367
1101
  action: "pause",
1368
1102
  });
1369
- assert.equal(session.currentMilestoneId, "M001");
1103
+ assert.equal((session as { currentMilestoneId: string }).currentMilestoneId, "M001");
1370
1104
  });
1371
1105
 
1372
- test("wired DispatchAdapter replays pending verification retry dispatch", async () => {
1106
+ test("decideOrchestratorDispatch replays pending verification retry dispatch", async () => {
1373
1107
  const stateSnapshot = makeState();
1374
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1375
- const pi = { getActiveTools: () => [] } as any;
1108
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1109
+ const pi = { getActiveTools: () => [] } as never;
1376
1110
  const session = {
1377
1111
  basePath: "/tmp/worktree-fixture",
1378
1112
  pendingOrchestrationDispatch: null,
@@ -1385,22 +1119,25 @@ test("wired DispatchAdapter replays pending verification retry dispatch", async
1385
1119
  mid: "M004",
1386
1120
  midTitle: "Milestone 4",
1387
1121
  },
1388
- } as any;
1389
- const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/project-fixture", session);
1122
+ } as never;
1390
1123
 
1391
- const result = await adapter.decideNextUnit({ stateSnapshot });
1124
+ const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/project-fixture", session, { stateSnapshot });
1392
1125
 
1393
1126
  assert.ok(result);
1394
- if (!("unitType" in result)) assert.fail("expected dispatch decision");
1127
+ if (!result || !("unitType" in result)) assert.fail("expected dispatch decision");
1395
1128
  assert.equal(result.unitType, "complete-slice");
1396
1129
  assert.equal(result.unitId, "M004/S01");
1397
1130
  assert.equal(result.reason, "verification-retry");
1398
- assert.equal(session.pendingVerificationRetryDispatch, null);
1399
- assert.equal(session.pendingOrchestrationDispatch?.prompt, "repair slice closeout");
1400
- assert.equal(session.pendingOrchestrationDispatch?.state, stateSnapshot);
1131
+ const sess = session as {
1132
+ pendingVerificationRetryDispatch: unknown;
1133
+ pendingOrchestrationDispatch: { prompt?: string; state?: unknown } | null;
1134
+ };
1135
+ assert.equal(sess.pendingVerificationRetryDispatch, null);
1136
+ assert.equal(sess.pendingOrchestrationDispatch?.prompt, "repair slice closeout");
1137
+ assert.equal(sess.pendingOrchestrationDispatch?.state, stateSnapshot);
1401
1138
  });
1402
1139
 
1403
- test("wired DispatchAdapter clears verification retry state when skipping an already closed retry dispatch", async () => {
1140
+ test("decideOrchestratorDispatch clears verification retry state when skipping an already closed retry dispatch", async () => {
1404
1141
  const stateSnapshot = makeState();
1405
1142
  const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-closed-retry-"));
1406
1143
 
@@ -1425,8 +1162,8 @@ test("wired DispatchAdapter clears verification retry state when skipping an alr
1425
1162
  };
1426
1163
  setRegistry(new RuleRegistry([retryRule]));
1427
1164
 
1428
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1429
- const pi = { getActiveTools: () => [] } as any;
1165
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1166
+ const pi = { getActiveTools: () => [] } as never;
1430
1167
  const session = {
1431
1168
  basePath: base,
1432
1169
  pendingOrchestrationDispatch: { stale: true },
@@ -1435,17 +1172,17 @@ test("wired DispatchAdapter clears verification retry state when skipping an alr
1435
1172
  failureContext: "artifact missing",
1436
1173
  attempt: 1,
1437
1174
  },
1438
- } as any;
1439
- const adapter = createWiredDispatchAdapter(ctx, pi, base, session);
1175
+ } as never;
1440
1176
 
1441
- const result = await adapter.decideNextUnit({ stateSnapshot });
1177
+ const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
1442
1178
 
1443
1179
  assert.deepEqual(result, {
1444
1180
  kind: "skipped",
1445
1181
  reason: "execute-task M001/S01/T01 is already complete",
1446
1182
  });
1447
- assert.equal(session.pendingVerificationRetry, null);
1448
- assert.equal(session.pendingOrchestrationDispatch, null);
1183
+ const sess = session as { pendingVerificationRetry: unknown; pendingOrchestrationDispatch: unknown };
1184
+ assert.equal(sess.pendingVerificationRetry, null);
1185
+ assert.equal(sess.pendingOrchestrationDispatch, null);
1449
1186
  } finally {
1450
1187
  resetRegistry();
1451
1188
  closeDatabase();
@@ -1453,7 +1190,7 @@ test("wired DispatchAdapter clears verification retry state when skipping an alr
1453
1190
  }
1454
1191
  });
1455
1192
 
1456
- test("wired DispatchAdapter preserves stop reason as a blocked decision", async () => {
1193
+ test("decideOrchestratorDispatch preserves stop reason as a blocked decision", async () => {
1457
1194
  const stateSnapshot = makeState();
1458
1195
  const stopRule: UnifiedRule = {
1459
1196
  name: "test-stop",
@@ -1469,11 +1206,10 @@ test("wired DispatchAdapter preserves stop reason as a blocked decision", async
1469
1206
  setRegistry(new RuleRegistry([stopRule]));
1470
1207
 
1471
1208
  try {
1472
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1473
- const pi = { getActiveTools: () => [] } as any;
1474
- const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
1209
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1210
+ const pi = { getActiveTools: () => [] } as never;
1475
1211
 
1476
- const result = await adapter.decideNextUnit({ stateSnapshot });
1212
+ const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/parity-fixture", undefined, { stateSnapshot });
1477
1213
 
1478
1214
  assert.deepEqual(result, {
1479
1215
  kind: "blocked",
@@ -1485,7 +1221,7 @@ test("wired DispatchAdapter preserves stop reason as a blocked decision", async
1485
1221
  }
1486
1222
  });
1487
1223
 
1488
- test("wired DispatchAdapter preserves dispatch skip instead of collapsing it to no remaining units", async () => {
1224
+ test("decideOrchestratorDispatch preserves dispatch skip instead of collapsing it to no remaining units", async () => {
1489
1225
  const stateSnapshot = makeState();
1490
1226
  const skipRule: UnifiedRule = {
1491
1227
  name: "test-skip-gate",
@@ -1500,11 +1236,10 @@ test("wired DispatchAdapter preserves dispatch skip instead of collapsing it to
1500
1236
  setRegistry(new RuleRegistry([skipRule]));
1501
1237
 
1502
1238
  try {
1503
- const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as any;
1504
- const pi = { getActiveTools: () => [] } as any;
1505
- const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
1239
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1240
+ const pi = { getActiveTools: () => [] } as never;
1506
1241
 
1507
- const result = await adapter.decideNextUnit({ stateSnapshot });
1242
+ const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/parity-fixture", undefined, { stateSnapshot });
1508
1243
 
1509
1244
  assert.deepEqual(result, {
1510
1245
  kind: "skipped",