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

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