muonroi-cli 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/packages/agent-harness-core/src/event-filter.js +1 -0
  4. package/dist/packages/agent-harness-core/src/event-redact.js +8 -0
  5. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +16 -0
  6. package/dist/packages/agent-harness-core/src/mcp-server.js +88 -10
  7. package/dist/packages/agent-harness-core/src/protocol.d.ts +15 -0
  8. package/dist/packages/agent-harness-core/src/selector.js +2 -2
  9. package/dist/packages/agent-harness-core/src/transports/sidechannel.js +1 -1
  10. package/dist/packages/agent-harness-opentui/src/agent-mode.js +1 -1
  11. package/dist/packages/agent-harness-opentui/src/install.js +1 -1
  12. package/dist/src/__test-stubs__/vitest-setup.js +4 -0
  13. package/dist/src/agent-harness/__tests__/mock-model.spec.js +54 -0
  14. package/dist/src/agent-harness/mock-model.d.ts +1 -1
  15. package/dist/src/agent-harness/mock-model.js +32 -3
  16. package/dist/src/agent-harness/test-spawn.js +3 -1
  17. package/dist/src/chat/chat-keychain.js +9 -1
  18. package/dist/src/cli/cost-forensics.js +12 -12
  19. package/dist/src/cli/keys.d.ts +2 -2
  20. package/dist/src/cli/keys.js +107 -28
  21. package/dist/src/cli/usage-report.js +2 -2
  22. package/dist/src/council/__tests__/abort-threading.test.d.ts +1 -0
  23. package/dist/src/council/__tests__/abort-threading.test.js +193 -0
  24. package/dist/src/council/__tests__/clarification-prompt.test.js +21 -1
  25. package/dist/src/council/__tests__/council-turn-length-emit.test.d.ts +1 -0
  26. package/dist/src/council/__tests__/council-turn-length-emit.test.js +130 -0
  27. package/dist/src/council/__tests__/debate-round-budget.test.d.ts +1 -0
  28. package/dist/src/council/__tests__/debate-round-budget.test.js +45 -0
  29. package/dist/src/council/__tests__/evaluator-metrics.test.js +3 -0
  30. package/dist/src/council/__tests__/post-debate-recommendation.test.d.ts +1 -0
  31. package/dist/src/council/__tests__/post-debate-recommendation.test.js +58 -0
  32. package/dist/src/council/__tests__/research-tools.test.js +4 -0
  33. package/dist/src/council/__tests__/round-tools.test.js +6 -0
  34. package/dist/src/council/clarifier-question-cap.test.d.ts +12 -0
  35. package/dist/src/council/clarifier-question-cap.test.js +73 -0
  36. package/dist/src/council/clarifier.d.ts +29 -1
  37. package/dist/src/council/clarifier.js +64 -1
  38. package/dist/src/council/debate-planner.d.ts +2 -1
  39. package/dist/src/council/debate-planner.js +5 -3
  40. package/dist/src/council/debate.d.ts +9 -0
  41. package/dist/src/council/debate.js +88 -8
  42. package/dist/src/council/index.d.ts +46 -1
  43. package/dist/src/council/index.js +125 -19
  44. package/dist/src/council/llm.js +41 -13
  45. package/dist/src/council/prompts.js +56 -21
  46. package/dist/src/council/types.d.ts +14 -1
  47. package/dist/src/ee/__tests__/export-transcripts.test.js +1 -1
  48. package/dist/src/ee/bridge.d.ts +3 -1
  49. package/dist/src/ee/bridge.js +9 -3
  50. package/dist/src/ee/bridge.test.js +1 -2
  51. package/dist/src/ee/client.js +15 -2
  52. package/dist/src/ee/recall-ledger.d.ts +20 -0
  53. package/dist/src/ee/recall-ledger.js +40 -1
  54. package/dist/src/ee/search.d.ts +25 -0
  55. package/dist/src/ee/search.js +73 -0
  56. package/dist/src/ee/types.d.ts +15 -1
  57. package/dist/src/ee/who-am-i.d.ts +41 -0
  58. package/dist/src/ee/who-am-i.js +143 -0
  59. package/dist/src/ee/who-am-i.test.d.ts +1 -0
  60. package/dist/src/ee/who-am-i.test.js +101 -0
  61. package/dist/src/generated/version.d.ts +1 -1
  62. package/dist/src/generated/version.js +1 -1
  63. package/dist/src/headless/council-answers.js +1 -0
  64. package/dist/src/headless/output.d.ts +30 -1
  65. package/dist/src/headless/output.js +180 -0
  66. package/dist/src/headless/output.test.js +61 -1
  67. package/dist/src/hooks/index.js +48 -2
  68. package/dist/src/index.d.ts +2 -0
  69. package/dist/src/index.js +55 -60
  70. package/dist/src/lsp/client-normalize.test.d.ts +12 -0
  71. package/dist/src/lsp/client-normalize.test.js +34 -0
  72. package/dist/src/lsp/client.d.ts +1 -0
  73. package/dist/src/lsp/client.js +10 -2
  74. package/dist/src/lsp/manager.d.ts +1 -1
  75. package/dist/src/lsp/manager.js +2 -2
  76. package/dist/src/lsp/manager.test.js +2 -2
  77. package/dist/src/lsp/runtime.d.ts +1 -1
  78. package/dist/src/lsp/runtime.js +2 -2
  79. package/dist/src/maintain/__tests__/gh-create-pr.test.js +7 -3
  80. package/dist/src/maintain/__tests__/pr-builder.test.js +7 -3
  81. package/dist/src/mcp/__tests__/client-pool.spec.js +3 -3
  82. package/dist/src/mcp/__tests__/ee-tools.test.js +42 -14
  83. package/dist/src/mcp/__tests__/forensics-tools.test.js +3 -3
  84. package/dist/src/mcp/__tests__/lsp-tools.test.js +4 -4
  85. package/dist/src/mcp/client-pool.d.ts +1 -1
  86. package/dist/src/mcp/client-pool.js +25 -9
  87. package/dist/src/mcp/ee-tools.d.ts +10 -0
  88. package/dist/src/mcp/ee-tools.js +34 -0
  89. package/dist/src/mcp/oauth-callback.js +2 -2
  90. package/dist/src/mcp/parse-headers.test.js +14 -14
  91. package/dist/src/mcp/self-verify-jobs.js +1 -3
  92. package/dist/src/mcp/setup-guide-text.js +74 -74
  93. package/dist/src/mcp/smoke.test.js +43 -43
  94. package/dist/src/models/catalog-gemini.test.js +12 -10
  95. package/dist/src/models/catalog.json +106 -38
  96. package/dist/src/ops/doctor.js +7 -7
  97. package/dist/src/orchestrator/__tests__/auto-commit.test.d.ts +1 -0
  98. package/dist/src/orchestrator/__tests__/auto-commit.test.js +142 -0
  99. package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +2 -0
  100. package/dist/src/orchestrator/__tests__/delegations.test.d.ts +1 -0
  101. package/dist/src/orchestrator/__tests__/delegations.test.js +96 -0
  102. package/dist/src/orchestrator/__tests__/message-processor.test.js +1 -0
  103. package/dist/src/orchestrator/__tests__/stream-runner.test.js +29 -0
  104. package/dist/src/orchestrator/agent.test.js +5 -2
  105. package/dist/src/orchestrator/auto-commit.d.ts +110 -0
  106. package/dist/src/orchestrator/auto-commit.js +364 -0
  107. package/dist/src/orchestrator/batch-turn-runner.d.ts +3 -2
  108. package/dist/src/orchestrator/batch-turn-runner.js +2 -1
  109. package/dist/src/orchestrator/compaction-proposer-prompt.d.ts +8 -0
  110. package/dist/src/orchestrator/compaction-proposer-prompt.js +43 -0
  111. package/dist/src/orchestrator/compaction.d.ts +22 -0
  112. package/dist/src/orchestrator/compaction.js +42 -0
  113. package/dist/src/orchestrator/delegations.d.ts +1 -0
  114. package/dist/src/orchestrator/delegations.js +41 -0
  115. package/dist/src/orchestrator/error-utils.js +27 -2
  116. package/dist/src/orchestrator/message-processor.d.ts +21 -5
  117. package/dist/src/orchestrator/message-processor.js +219 -2573
  118. package/dist/src/orchestrator/orchestrator.d.ts +8 -2
  119. package/dist/src/orchestrator/orchestrator.js +187 -34
  120. package/dist/src/orchestrator/preprocessor.d.ts +13 -0
  121. package/dist/src/orchestrator/preprocessor.js +145 -0
  122. package/dist/src/orchestrator/prompts.d.ts +8 -0
  123. package/dist/src/orchestrator/prompts.js +76 -60
  124. package/dist/src/orchestrator/safety-askcard.d.ts +66 -0
  125. package/dist/src/orchestrator/safety-askcard.js +74 -0
  126. package/dist/src/orchestrator/sandbox.test.js +8 -5
  127. package/dist/src/orchestrator/scope-ceiling.js +1 -1
  128. package/dist/src/orchestrator/stall-watchdog.d.ts +47 -0
  129. package/dist/src/orchestrator/stall-watchdog.js +23 -0
  130. package/dist/src/orchestrator/stall-watchdog.test.js +36 -1
  131. package/dist/src/orchestrator/stream-runner.js +52 -30
  132. package/dist/src/orchestrator/sub-agent-model-tier.js +1 -1
  133. package/dist/src/orchestrator/subagent-compactor.d.ts +14 -0
  134. package/dist/src/orchestrator/subagent-compactor.js +83 -10
  135. package/dist/src/orchestrator/subagent-compactor.spec.js +132 -0
  136. package/dist/src/orchestrator/text-tool-call-detector.d.ts +9 -5
  137. package/dist/src/orchestrator/text-tool-call-detector.js +16 -12
  138. package/dist/src/orchestrator/text-tool-call-detector.test.js +66 -8
  139. package/dist/src/orchestrator/tool-args-repair.js +1 -1
  140. package/dist/src/orchestrator/tool-engine.d.ts +151 -0
  141. package/dist/src/orchestrator/tool-engine.js +3079 -0
  142. package/dist/src/orchestrator/turn-runner-deps.d.ts +1 -0
  143. package/dist/src/pil/__tests__/discovery-types.test.js +15 -4
  144. package/dist/src/pil/__tests__/discovery.test.js +89 -145
  145. package/dist/src/pil/__tests__/dual-run.test.js +2 -0
  146. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +3 -0
  147. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  148. package/dist/src/pil/__tests__/layer16-clarity.test.js +35 -32
  149. package/dist/src/pil/__tests__/layer18-acceptance.test.js +1 -78
  150. package/dist/src/pil/__tests__/layer2_5-ponytail.test.d.ts +1 -0
  151. package/dist/src/pil/__tests__/layer2_5-ponytail.test.js +53 -0
  152. package/dist/src/pil/__tests__/layer3-ee-injection.test.js +67 -4
  153. package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +14 -2
  154. package/dist/src/pil/__tests__/layer4-gsd.test.js +30 -0
  155. package/dist/src/pil/__tests__/layer5-context.test.js +39 -1
  156. package/dist/src/pil/__tests__/layer6-output.test.js +30 -2
  157. package/dist/src/pil/__tests__/orchestrator-integration.test.js +3 -1
  158. package/dist/src/pil/__tests__/pipeline.test.js +11 -8
  159. package/dist/src/pil/__tests__/response-tools.test.js +52 -0
  160. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  161. package/dist/src/pil/agent-operating-contract.js +5 -3
  162. package/dist/src/pil/agent-operating-contract.test.js +2 -2
  163. package/dist/src/pil/cheap-model-playbook.d.ts +1 -1
  164. package/dist/src/pil/cheap-model-playbook.js +1 -1
  165. package/dist/src/pil/cheap-model-workbooks.d.ts +1 -1
  166. package/dist/src/pil/cheap-model-workbooks.js +1 -1
  167. package/dist/src/pil/config.d.ts +20 -0
  168. package/dist/src/pil/config.js +35 -0
  169. package/dist/src/pil/discovery-types.d.ts +32 -9
  170. package/dist/src/pil/discovery.d.ts +3 -3
  171. package/dist/src/pil/discovery.js +138 -244
  172. package/dist/src/pil/index.d.ts +1 -1
  173. package/dist/src/pil/index.js +1 -1
  174. package/dist/src/pil/layer1-intent.d.ts +9 -1
  175. package/dist/src/pil/layer1-intent.js +99 -44
  176. package/dist/src/pil/layer1-intent.test.js +112 -2
  177. package/dist/src/pil/layer15-context-scan.js +1 -1
  178. package/dist/src/pil/layer16-clarity.d.ts +21 -25
  179. package/dist/src/pil/layer16-clarity.js +52 -88
  180. package/dist/src/pil/layer18-acceptance.d.ts +13 -3
  181. package/dist/src/pil/layer18-acceptance.js +1 -75
  182. package/dist/src/pil/layer2-personality.js +7 -8
  183. package/dist/src/pil/layer2_5-ponytail.d.ts +2 -0
  184. package/dist/src/pil/layer2_5-ponytail.js +41 -0
  185. package/dist/src/pil/layer3-ee-injection.js +180 -14
  186. package/dist/src/pil/layer4-gsd.js +22 -8
  187. package/dist/src/pil/layer5-context.d.ts +1 -0
  188. package/dist/src/pil/layer5-context.js +26 -11
  189. package/dist/src/pil/layer6-output.js +23 -5
  190. package/dist/src/pil/llm-classify.js +3 -3
  191. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  192. package/dist/src/pil/native-capabilities-workbook.js +4 -3
  193. package/dist/src/pil/pipeline.js +15 -1
  194. package/dist/src/pil/response-tools.d.ts +16 -0
  195. package/dist/src/pil/response-tools.js +41 -0
  196. package/dist/src/pil/schema.d.ts +8 -0
  197. package/dist/src/pil/schema.js +24 -3
  198. package/dist/src/pil/task-tier-map.js +7 -7
  199. package/dist/src/pil/types.d.ts +9 -3
  200. package/dist/src/product-loop/__tests__/discovery-interview.test.js +103 -1
  201. package/dist/src/product-loop/__tests__/gather-selectable-alts.test.js +34 -0
  202. package/dist/src/product-loop/__tests__/sprint-self-verify.test.js +12 -12
  203. package/dist/src/product-loop/discovery-interview.d.ts +20 -0
  204. package/dist/src/product-loop/discovery-interview.js +121 -7
  205. package/dist/src/product-loop/done-gate.js +3 -3
  206. package/dist/src/product-loop/gather.js +19 -5
  207. package/dist/src/product-loop/loop-driver.js +20 -20
  208. package/dist/src/product-loop/progress-snapshot.js +4 -4
  209. package/dist/src/product-loop/sprint-self-verify.js +2 -2
  210. package/dist/src/providers/__tests__/reasoning-roundtrip.test.js +12 -0
  211. package/dist/src/providers/auth/__tests__/gemini-oauth.test.d.ts +1 -5
  212. package/dist/src/providers/auth/__tests__/gemini-oauth.test.js +1 -5
  213. package/dist/src/providers/auth/gcloud.d.ts +28 -0
  214. package/dist/src/providers/auth/gcloud.js +102 -0
  215. package/dist/src/providers/auth/gemini-oauth.d.ts +46 -19
  216. package/dist/src/providers/auth/gemini-oauth.js +292 -105
  217. package/dist/src/providers/auth/grok-oauth.d.ts +1 -1
  218. package/dist/src/providers/auth/grok-oauth.js +1 -1
  219. package/dist/src/providers/auth/registry.js +28 -6
  220. package/dist/src/providers/gemini.d.ts +1 -1
  221. package/dist/src/providers/gemini.js +10 -3
  222. package/dist/src/providers/keychain.d.ts +2 -2
  223. package/dist/src/providers/keychain.js +20 -2
  224. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  225. package/dist/src/providers/openai.js +5 -1
  226. package/dist/src/providers/runtime.d.ts +1 -1
  227. package/dist/src/providers/runtime.js +11 -2
  228. package/dist/src/providers/siliconflow-sse-repair.js +1 -1
  229. package/dist/src/providers/strategies/google.strategy.d.ts +9 -2
  230. package/dist/src/providers/strategies/google.strategy.js +159 -9
  231. package/dist/src/providers/strategies/thinking-mode.js +19 -6
  232. package/dist/src/providers/types.d.ts +1 -1
  233. package/dist/src/reporter/index.js +1 -1
  234. package/dist/src/router/decide.test.js +40 -8
  235. package/dist/src/router/step-router.d.ts +20 -0
  236. package/dist/src/router/step-router.js +73 -0
  237. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  238. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  239. package/dist/src/scaffold/continuation-prompt.js +60 -60
  240. package/dist/src/scaffold/init-new.js +453 -453
  241. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  242. package/dist/src/self-qa/agentic-loop.js +19 -19
  243. package/dist/src/self-qa/orchestrator.js +5 -5
  244. package/dist/src/self-qa/scenario-planner.js +1 -1
  245. package/dist/src/self-qa/spec-emitter.js +4 -4
  246. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  247. package/dist/src/storage/index.d.ts +1 -1
  248. package/dist/src/storage/index.js +1 -1
  249. package/dist/src/storage/interaction-log.js +5 -5
  250. package/dist/src/storage/migrations.js +122 -122
  251. package/dist/src/storage/session-experience-store.js +4 -4
  252. package/dist/src/storage/sessions.js +43 -43
  253. package/dist/src/storage/transcript-view.js +7 -1
  254. package/dist/src/storage/transcript.d.ts +1 -0
  255. package/dist/src/storage/transcript.js +55 -0
  256. package/dist/src/storage/ui-interaction-log.d.ts +17 -0
  257. package/dist/src/storage/usage.js +14 -14
  258. package/dist/src/storage/workspaces.js +12 -12
  259. package/dist/src/tools/__tests__/native-tools.test.js +6 -0
  260. package/dist/src/tools/bash.d.ts +36 -23
  261. package/dist/src/tools/bash.js +59 -301
  262. package/dist/src/tools/bash.test.js +2 -257
  263. package/dist/src/tools/file-tracker.d.ts +5 -0
  264. package/dist/src/tools/file-tracker.js +14 -0
  265. package/dist/src/tools/file.js +2 -0
  266. package/dist/src/tools/git-safety.d.ts +38 -1
  267. package/dist/src/tools/git-safety.js +94 -4
  268. package/dist/src/tools/git-safety.test.js +45 -1
  269. package/dist/src/tools/native-tools.d.ts +1 -1
  270. package/dist/src/tools/native-tools.js +56 -0
  271. package/dist/src/tools/registry-bash-empty-command.test.js +4 -3
  272. package/dist/src/tools/registry-git-safety.test.js +17 -6
  273. package/dist/src/tools/registry.d.ts +5 -0
  274. package/dist/src/tools/registry.js +185 -11
  275. package/dist/src/types/index.d.ts +7 -1
  276. package/dist/src/ui/app.d.ts +1 -0
  277. package/dist/src/ui/app.js +0 -0
  278. package/dist/src/ui/components/council-question-card.js +1 -0
  279. package/dist/src/ui/components/message-view.js +37 -3
  280. package/dist/src/ui/components/structured-response-view.js +7 -0
  281. package/dist/src/ui/components/task-list-panel.js +3 -12
  282. package/dist/src/ui/containers/chat-feed.d.ts +40 -0
  283. package/dist/src/ui/containers/chat-feed.js +66 -0
  284. package/dist/src/ui/containers/modals-layer.d.ts +87 -0
  285. package/dist/src/ui/containers/modals-layer.js +18 -0
  286. package/dist/src/ui/slash/__tests__/menu-parity.test.d.ts +1 -0
  287. package/dist/src/ui/slash/__tests__/menu-parity.test.js +1 -0
  288. package/dist/src/ui/slash/__tests__/ponytail.test.d.ts +1 -0
  289. package/dist/src/ui/slash/__tests__/ponytail.test.js +37 -0
  290. package/dist/src/ui/slash/council-inspect.js +4 -4
  291. package/dist/src/ui/slash/export.js +16 -1
  292. package/dist/src/ui/slash/menu-items.js +1 -0
  293. package/dist/src/ui/slash/ponytail.d.ts +2 -0
  294. package/dist/src/ui/slash/ponytail.js +19 -0
  295. package/dist/src/ui/status-bar/store.js +0 -1
  296. package/dist/src/ui/status-bar/store.test.js +2 -2
  297. package/dist/src/ui/use-app-logic.d.ts +26 -0
  298. package/dist/src/ui/use-app-logic.js +0 -0
  299. package/dist/src/ui/utils/relaunch.js +1 -1
  300. package/dist/src/ui/utils/text.d.ts +11 -0
  301. package/dist/src/ui/utils/text.js +21 -1
  302. package/dist/src/ui/utils/text.test.js +25 -1
  303. package/dist/src/ui/utils/tools.js +3 -1
  304. package/dist/src/usage/cost-log.js +2 -2
  305. package/dist/src/usage/estimator.d.ts +14 -0
  306. package/dist/src/usage/estimator.js +22 -0
  307. package/dist/src/utils/__tests__/compaction-caps.test.d.ts +21 -0
  308. package/dist/src/utils/__tests__/compaction-caps.test.js +187 -0
  309. package/dist/src/utils/__tests__/llm-deadline-abort.test.d.ts +1 -0
  310. package/dist/src/utils/__tests__/llm-deadline-abort.test.js +33 -0
  311. package/dist/src/utils/clipboard-image.js +23 -23
  312. package/dist/src/utils/llm-deadline.d.ts +9 -1
  313. package/dist/src/utils/llm-deadline.js +31 -2
  314. package/dist/src/utils/permission-mode.d.ts +33 -0
  315. package/dist/src/utils/permission-mode.js +164 -3
  316. package/dist/src/utils/permission-mode.test.js +92 -1
  317. package/dist/src/utils/settings.d.ts +27 -31
  318. package/dist/src/utils/settings.js +100 -109
  319. package/dist/src/utils/side-question.js +2 -2
  320. package/dist/src/utils/skills.js +3 -3
  321. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  322. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ /**
2
+ * council-turn-length emit contract (G2-b).
3
+ *
4
+ * Drives the REAL `runDebate` generator with a recording CouncilLLM and a
5
+ * capturing `globalThis.__muonroiAgentRuntime`, then asserts the observe-only
6
+ * `council-turn-length` harness event fires for each fully-assembled speaker
7
+ * turn — opening statements (round 0) and discussion turns (round 1+) — carrying
8
+ * the right charCount / wordCount / round / model / correlationId.
9
+ *
10
+ * This exercises the production emit path in src/council/debate.ts end-to-end
11
+ * (not a mocked re-implementation). The MCP harness tool surface only exposes
12
+ * toast/stream.delta via tui_last_event, so this generator-level drive is the
13
+ * faithful wire-level proof that the event fires during a real council run.
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
+ import { runDebate } from "../debate.js";
17
+ const OPENING = "Opening alpha beta gamma."; // 25 chars, 4 words
18
+ const ROUND = "Round response one two three."; // round-turn text
19
+ function recordingLlm() {
20
+ return {
21
+ // openingWithRetry + leader-eval + summaries all go through generate. Opening
22
+ // statements use the returned text verbatim → that drives the round-0 emit.
23
+ generate: async () => OPENING,
24
+ // debateWithRetry (discussion turns) is the ONLY caller of debate → drives the
25
+ // round-1+ emit with a known, non-failure string.
26
+ debate: async () => ({ text: ROUND, toolCalls: [] }),
27
+ research: async () => "findings",
28
+ };
29
+ }
30
+ function makeParticipants() {
31
+ return [
32
+ { role: "architect", model: "model-arch", position: "", stance: { name: "architect", lens: "design" } },
33
+ { role: "qa", model: "model-qa", position: "", stance: { name: "qa", lens: "risk" } },
34
+ ];
35
+ }
36
+ function makeSpec() {
37
+ return {
38
+ problemStatement: "Decide X vs Y for a small service.",
39
+ constraints: [],
40
+ successCriteria: [],
41
+ scope: "",
42
+ rawQA: [],
43
+ };
44
+ }
45
+ function makeConfig(participants) {
46
+ return {
47
+ topic: "X vs Y",
48
+ conversationContext: "",
49
+ leaderModelId: "model-leader",
50
+ participants,
51
+ debatePlan: {
52
+ intentSummary: "Pick the better option.",
53
+ stances: [
54
+ { name: "architect", lens: "design" },
55
+ { name: "qa", lens: "risk" },
56
+ ],
57
+ outputShape: {
58
+ kind: "decision",
59
+ sections: [{ key: "rec", heading: "Recommendation", shape: "list" }],
60
+ guardrails: [],
61
+ },
62
+ plannedRounds: 1,
63
+ },
64
+ // Skip research so the flow goes straight to opening statements deterministically.
65
+ researchSkipOverride: true,
66
+ runId: "sess-turnlen-test",
67
+ };
68
+ }
69
+ describe("council-turn-length emit (real runDebate)", () => {
70
+ let captured;
71
+ let prevRuntime;
72
+ beforeEach(() => {
73
+ captured = [];
74
+ const g = globalThis;
75
+ prevRuntime = g.__muonroiAgentRuntime;
76
+ g.__muonroiAgentRuntime = { emitEvent: (e) => captured.push(e) };
77
+ });
78
+ afterEach(() => {
79
+ globalThis.__muonroiAgentRuntime = prevRuntime;
80
+ });
81
+ function turnLengthEvents() {
82
+ return captured.filter((e) => e.t === "event" && e.kind === "council-turn-length");
83
+ }
84
+ it("emits a round-0 event per opening speaker with correct counts + correlationId", async () => {
85
+ const participants = makeParticipants();
86
+ const gen = runDebate(makeSpec(), makeConfig(participants), recordingLlm());
87
+ let step = await gen.next();
88
+ while (!step.done)
89
+ step = await gen.next();
90
+ const opening = turnLengthEvents().filter((e) => e.round === 0);
91
+ // One opening statement per participant.
92
+ expect(opening.length).toBe(2);
93
+ for (const e of opening) {
94
+ expect(e.charCount).toBe(OPENING.length);
95
+ expect(e.wordCount).toBe(4);
96
+ expect(e.correlationId).toBe("sess-turnlen-test");
97
+ expect(typeof e.model).toBe("string");
98
+ expect(e.model.length).toBeGreaterThan(0);
99
+ }
100
+ // Roles are the participant stance names / roles.
101
+ const roles = opening.map((e) => e.role);
102
+ expect(roles).toContain("architect");
103
+ expect(roles).toContain("qa");
104
+ });
105
+ it("emits round-1 discussion-turn events with the discussion text's counts", async () => {
106
+ const gen = runDebate(makeSpec(), makeConfig(makeParticipants()), recordingLlm());
107
+ let step = await gen.next();
108
+ while (!step.done)
109
+ step = await gen.next();
110
+ const round1 = turnLengthEvents().filter((e) => e.round === 1);
111
+ expect(round1.length).toBeGreaterThanOrEqual(1);
112
+ for (const e of round1) {
113
+ expect(e.charCount).toBe(ROUND.trim().length);
114
+ expect(e.wordCount).toBe(ROUND.trim().split(/\s+/).filter(Boolean).length);
115
+ expect(e.correlationId).toBe("sess-turnlen-test");
116
+ }
117
+ });
118
+ it("does not emit when no agent runtime is installed (normal user mode)", async () => {
119
+ globalThis.__muonroiAgentRuntime = undefined;
120
+ const localCaptured = [];
121
+ // Re-install a capturer AFTER clearing to prove the helper truly no-ops when
122
+ // the runtime is absent: here it's absent, so localCaptured stays empty.
123
+ const gen = runDebate(makeSpec(), makeConfig(makeParticipants()), recordingLlm());
124
+ let step = await gen.next();
125
+ while (!step.done)
126
+ step = await gen.next();
127
+ expect(localCaptured.length).toBe(0);
128
+ });
129
+ });
130
+ //# sourceMappingURL=council-turn-length-emit.test.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * C (latency UX) — per-kind debate round cap. Before this, only implementation_plan
3
+ * was capped (3); a `decision` debate inherited the absolute ceiling (8) and ran as
4
+ * many rounds as the planner proposed. Live, a Redis-vs-in-memory decision ran 5
5
+ * rounds / ~10 min with the leader itself calling rounds 4-5 "minor disagreements".
6
+ * resolveDebateRoundBudget now caps the discussion kinds at 3 (exploration 5).
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { resolveDebateRoundBudget } from "../debate.js";
10
+ describe("resolveDebateRoundBudget — per-kind round cap (issue C)", () => {
11
+ it("caps a decision at 3 rounds even when the planner proposes 5", () => {
12
+ const r = resolveDebateRoundBudget("decision", 5);
13
+ expect(r.maxRounds).toBe(3);
14
+ expect(r.effectiveCeiling).toBe(3);
15
+ expect(r.kindCapped).toBe(true);
16
+ });
17
+ it("caps evaluation and investigation at 3", () => {
18
+ expect(resolveDebateRoundBudget("evaluation", 8).maxRounds).toBe(3);
19
+ expect(resolveDebateRoundBudget("investigation", 8).maxRounds).toBe(3);
20
+ });
21
+ it("keeps implementation_plan at its existing 3-round cap", () => {
22
+ expect(resolveDebateRoundBudget("implementation_plan", 5).maxRounds).toBe(3);
23
+ });
24
+ it("allows exploration more breadth (5)", () => {
25
+ const r = resolveDebateRoundBudget("exploration", 8);
26
+ expect(r.maxRounds).toBe(5);
27
+ expect(r.effectiveCeiling).toBe(5);
28
+ });
29
+ it("falls back to the absolute ceiling (8) for an unknown kind", () => {
30
+ const r = resolveDebateRoundBudget("something-else", 7);
31
+ expect(r.maxRounds).toBe(7);
32
+ expect(r.effectiveCeiling).toBe(8);
33
+ expect(r.kindCapped).toBe(false);
34
+ });
35
+ it("uses the default budget (3) when the planner proposes nothing", () => {
36
+ expect(resolveDebateRoundBudget("decision", undefined).maxRounds).toBe(3);
37
+ expect(resolveDebateRoundBudget(undefined, undefined).maxRounds).toBe(3);
38
+ expect(resolveDebateRoundBudget(undefined, 0).maxRounds).toBe(3);
39
+ });
40
+ it("respects a planner proposal lower than the cap", () => {
41
+ expect(resolveDebateRoundBudget("decision", 1).maxRounds).toBe(1);
42
+ expect(resolveDebateRoundBudget("implementation_plan", 2).maxRounds).toBe(2);
43
+ });
44
+ });
45
+ //# sourceMappingURL=debate-round-budget.test.js.map
@@ -359,6 +359,7 @@ describe("CQ-10: planDebate returns FALLBACK_PLAN after double failure", () => {
359
359
  vi.doMock("../../providers/runtime.js", () => ({
360
360
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
361
361
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
362
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
362
363
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
363
364
  }));
364
365
  vi.doMock("../prompts.js", () => ({
@@ -401,6 +402,7 @@ describe("CQ-10: planDebate returns FALLBACK_PLAN after double failure", () => {
401
402
  vi.doMock("../../providers/runtime.js", () => ({
402
403
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
403
404
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
405
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
404
406
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
405
407
  }));
406
408
  vi.doMock("../prompts.js", () => ({
@@ -433,6 +435,7 @@ describe("CQ-10: planDebate returns FALLBACK_PLAN after double failure", () => {
433
435
  vi.doMock("../../providers/runtime.js", () => ({
434
436
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
435
437
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
438
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
436
439
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
437
440
  }));
438
441
  vi.doMock("../prompts.js", () => ({
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Issue #3 — post-debate default mismatch.
3
+ *
4
+ * Before the fix, `runCouncil`'s post-debate AskCard recommended "generate_plan"
5
+ * (Lock plan & execute Sprint 1) for ANY successful synthesis with no plan yet —
6
+ * ignoring `debatePlan.outputShape.kind`. For a pure decision/evaluation topic the
7
+ * user wanted a decision, not to build, so defaulting to "kick off a sprint" was
8
+ * the wrong next step. `pickPostDebateRecommendation` now only defaults to
9
+ * generate_plan for `implementation_plan`-shaped debates; everything else defaults
10
+ * to `save_exit` (the synthesis IS the deliverable). The generate_plan OPTION is
11
+ * still offered — only the pre-selected default changed.
12
+ */
13
+ import { describe, expect, it } from "vitest";
14
+ import { pickPostDebateRecommendation } from "../index.js";
15
+ const base = {
16
+ synthesisFailed: false,
17
+ hasEmptySections: false,
18
+ refinementTopics: [],
19
+ confidenceLevel: "high",
20
+ hasPlan: false,
21
+ };
22
+ describe("pickPostDebateRecommendation — issue #3 default", () => {
23
+ it("defaults implementation_plan (no plan yet) to generate_plan", () => {
24
+ const r = pickPostDebateRecommendation({ ...base, outputKind: "implementation_plan" });
25
+ expect(r.value).toBe("generate_plan");
26
+ });
27
+ for (const kind of ["decision", "evaluation", "investigation", "exploration", "other"]) {
28
+ it(`defaults ${kind} (no plan) to save_exit, not generate_plan`, () => {
29
+ const r = pickPostDebateRecommendation({ ...base, outputKind: kind });
30
+ expect(r.value).toBe("save_exit");
31
+ // Reason names the shape so the card explains WHY save is the default.
32
+ expect(r.reason).toContain(kind);
33
+ });
34
+ }
35
+ it("retry_synthesis wins on synthesis failure regardless of kind", () => {
36
+ const r = pickPostDebateRecommendation({ ...base, synthesisFailed: true, outputKind: "decision" });
37
+ expect(r.value).toBe("retry_synthesis");
38
+ });
39
+ it("refine wins when the debate left sections empty", () => {
40
+ const r = pickPostDebateRecommendation({
41
+ ...base,
42
+ hasEmptySections: true,
43
+ refinementTopics: ["Risks", "Trade-offs"],
44
+ outputKind: "implementation_plan",
45
+ });
46
+ expect(r.value).toBe("refine");
47
+ expect(r.reason).toContain("2");
48
+ });
49
+ it("low confidence routes to ask_followup before the kind split", () => {
50
+ const r = pickPostDebateRecommendation({ ...base, confidenceLevel: "low", outputKind: "implementation_plan" });
51
+ expect(r.value).toBe("ask_followup");
52
+ });
53
+ it("an existing plan always defaults to save_exit", () => {
54
+ const r = pickPostDebateRecommendation({ ...base, hasPlan: true, outputKind: "implementation_plan" });
55
+ expect(r.value).toBe("save_exit");
56
+ });
57
+ });
58
+ //# sourceMappingURL=post-debate-recommendation.test.js.map
@@ -51,6 +51,7 @@ describe("CQ-04: research() URL detection and gap annotation", () => {
51
51
  createProviderFactory: vi.fn().mockReturnValue({
52
52
  factory: {},
53
53
  }),
54
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
54
55
  resolveModelRuntime: vi.fn().mockReturnValue({
55
56
  model: {},
56
57
  providerOptions: undefined,
@@ -93,6 +94,7 @@ describe("CQ-04: research() URL detection and gap annotation", () => {
93
94
  vi.doMock("../../providers/runtime.js", () => ({
94
95
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
95
96
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
97
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
96
98
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
97
99
  }));
98
100
  vi.doMock("../../tools/registry.js", () => ({
@@ -141,6 +143,7 @@ describe("CQ-03: research() MCP tool merge", () => {
141
143
  vi.doMock("../../providers/runtime.js", () => ({
142
144
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
143
145
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
146
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
144
147
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
145
148
  }));
146
149
  vi.doMock("../../tools/registry.js", () => ({
@@ -185,6 +188,7 @@ describe("CQ-03: research() MCP tool merge", () => {
185
188
  vi.doMock("../../providers/runtime.js", () => ({
186
189
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
187
190
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
191
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
188
192
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
189
193
  }));
190
194
  vi.doMock("../../tools/registry.js", () => ({
@@ -33,6 +33,7 @@ describe("debate() call shape — tools off by default, on with explicit opt-in"
33
33
  vi.doMock("../../providers/runtime.js", () => ({
34
34
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
35
35
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
36
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
36
37
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
37
38
  }));
38
39
  vi.doMock("../prompts.js", () => ({
@@ -63,6 +64,7 @@ describe("debate() call shape — tools off by default, on with explicit opt-in"
63
64
  vi.doMock("../../providers/runtime.js", () => ({
64
65
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
65
66
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
67
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
66
68
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
67
69
  }));
68
70
  // Builtin tools — must contain grep+read_file so the filter keeps something
@@ -113,6 +115,7 @@ describe("debate() call shape — tools off by default, on with explicit opt-in"
113
115
  vi.doMock("../../providers/runtime.js", () => ({
114
116
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
115
117
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
118
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
116
119
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
117
120
  }));
118
121
  vi.doMock("../../tools/registry.js", () => ({
@@ -162,6 +165,7 @@ describe("CQ-07: debate() returns { text, toolCalls } — not bare string", () =
162
165
  vi.doMock("../../providers/runtime.js", () => ({
163
166
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
164
167
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
168
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
165
169
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
166
170
  }));
167
171
  vi.doMock("../../tools/registry.js", () => ({
@@ -209,6 +213,7 @@ describe("CQ-07: debate() returns { text, toolCalls } — not bare string", () =
209
213
  vi.doMock("../../providers/runtime.js", () => ({
210
214
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
211
215
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
216
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
212
217
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
213
218
  }));
214
219
  vi.doMock("../../tools/registry.js", () => ({
@@ -244,6 +249,7 @@ describe("CQ-07: debate() returns { text, toolCalls } — not bare string", () =
244
249
  vi.doMock("../../providers/runtime.js", () => ({
245
250
  detectProviderForModel: vi.fn().mockReturnValue("openai"),
246
251
  createProviderFactory: vi.fn().mockReturnValue({ factory: {} }),
252
+ createProviderFactoryAsync: vi.fn().mockResolvedValue({ factory: {} }),
247
253
  resolveModelRuntime: vi.fn().mockReturnValue({ model: {}, providerOptions: undefined }),
248
254
  }));
249
255
  vi.doMock("../../tools/registry.js", () => ({
@@ -0,0 +1,12 @@
1
+ /**
2
+ * src/council/clarifier-question-cap.test.ts
3
+ *
4
+ * G2-a regression backstop. The clarifier prompt asks for "typically 0-2"
5
+ * questions and tells the model NOT to ask generic greenfield questions in an
6
+ * existing repo, but the model has repeatedly ignored both (session
7
+ * cfc711c57df0: 6 generic greenfield questions on "improve council quality").
8
+ * capClarifierQuestions enforces this deterministically in code. These tests
9
+ * pin that behaviour; the live council surfacing is verified via the MCP
10
+ * harness separately.
11
+ */
12
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * src/council/clarifier-question-cap.test.ts
3
+ *
4
+ * G2-a regression backstop. The clarifier prompt asks for "typically 0-2"
5
+ * questions and tells the model NOT to ask generic greenfield questions in an
6
+ * existing repo, but the model has repeatedly ignored both (session
7
+ * cfc711c57df0: 6 generic greenfield questions on "improve council quality").
8
+ * capClarifierQuestions enforces this deterministically in code. These tests
9
+ * pin that behaviour; the live council surfacing is verified via the MCP
10
+ * harness separately.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import { capClarifierQuestions, isGenericGreenfieldQuestion, MAX_CLARIFY_QUESTIONS_PER_ROUND } from "./clarifier.js";
14
+ const scoped = (n) => Array.from({ length: n }, (_, i) => ({ question: `Scoped decision ${i} for this change?`, why: "scope" }));
15
+ describe("isGenericGreenfieldQuestion", () => {
16
+ it("matches generic greenfield questions (English)", () => {
17
+ expect(isGenericGreenfieldQuestion({ question: "Who is the target audience?" })).toBe(true);
18
+ expect(isGenericGreenfieldQuestion({ question: "Which programming language should we use?" })).toBe(true);
19
+ expect(isGenericGreenfieldQuestion({ question: "Which database do you want?" })).toBe(true);
20
+ expect(isGenericGreenfieldQuestion({ question: "What is the deployment target?" })).toBe(true);
21
+ expect(isGenericGreenfieldQuestion({ question: "What kind of product is this?" })).toBe(true);
22
+ });
23
+ it("matches generic greenfield questions (Vietnamese)", () => {
24
+ expect(isGenericGreenfieldQuestion({ question: "Đối tượng người dùng là ai?" })).toBe(true);
25
+ expect(isGenericGreenfieldQuestion({ question: "Dùng ngôn ngữ lập trình nào?" })).toBe(true);
26
+ expect(isGenericGreenfieldQuestion({ question: "Cơ sở dữ liệu nào phù hợp?" })).toBe(true);
27
+ });
28
+ it("does NOT match scoped, change-specific questions", () => {
29
+ expect(isGenericGreenfieldQuestion({ question: "Should the clarifier cap be configurable?" })).toBe(false);
30
+ expect(isGenericGreenfieldQuestion({ question: "Phạm vi của thay đổi này là gì?", why: "scope" })).toBe(false);
31
+ expect(isGenericGreenfieldQuestion({ question: "Do we keep backward compat for the old flag?" })).toBe(false);
32
+ });
33
+ });
34
+ describe("capClarifierQuestions", () => {
35
+ it("hard-caps to MAX_CLARIFY_QUESTIONS_PER_ROUND when the model over-asks", () => {
36
+ const { kept, dropped } = capClarifierQuestions(scoped(6), false);
37
+ expect(kept.length).toBe(MAX_CLARIFY_QUESTIONS_PER_ROUND);
38
+ expect(dropped).toBe(6 - MAX_CLARIFY_QUESTIONS_PER_ROUND);
39
+ });
40
+ it("leaves a compliant 0-2 round untouched", () => {
41
+ expect(capClarifierQuestions(scoped(2), false)).toEqual({ kept: scoped(2), dropped: 0 });
42
+ expect(capClarifierQuestions([], false)).toEqual({ kept: [], dropped: 0 });
43
+ });
44
+ it("existing repo: drops generic greenfield questions then caps the rest", () => {
45
+ const qs = [
46
+ { question: "Who is the target audience?" },
47
+ { question: "Which framework should we use?" },
48
+ { question: "Should we preserve the legacy --flag for this change?" },
49
+ { question: "Is the cap value configurable via settings?" },
50
+ ];
51
+ const { kept, dropped } = capClarifierQuestions(qs, true);
52
+ expect(kept.every((q) => !isGenericGreenfieldQuestion(q))).toBe(true);
53
+ expect(kept.length).toBe(2); // 2 generic dropped → 2 scoped remain, within cap
54
+ expect(dropped).toBe(2);
55
+ });
56
+ it("existing repo: never zeroes out a round — keeps the top pick if all look generic", () => {
57
+ const allGeneric = [
58
+ { question: "Who is the target audience?" },
59
+ { question: "Which database?" },
60
+ { question: "What kind of product is this?" },
61
+ ];
62
+ const { kept, dropped } = capClarifierQuestions(allGeneric, true);
63
+ expect(kept.length).toBe(1);
64
+ expect(dropped).toBe(2);
65
+ });
66
+ it("greenfield topic (no Current Project snapshot): does NOT drop generics, only caps", () => {
67
+ const qs = [{ question: "Who is the target audience?" }, { question: "Which language?" }];
68
+ const { kept, dropped } = capClarifierQuestions(qs, false);
69
+ expect(kept.length).toBe(2); // legitimate for a greenfield build
70
+ expect(dropped).toBe(0);
71
+ });
72
+ });
73
+ //# sourceMappingURL=clarifier-question-cap.test.js.map
@@ -3,6 +3,34 @@ import type { CouncilQuestionOption, StreamChunk } from "../types/index.js";
3
3
  import type { ClarifiedSpec, CouncilLLM, QuestionResponder } from "./types.js";
4
4
  /** P5: Hard cap on clarification rounds regardless of judge verdict. */
5
5
  export declare const MAX_CLARIFY_ROUNDS = 12;
6
+ /**
7
+ * G2-a: Hard cap on clarifier questions SURFACED per round, regardless of how
8
+ * many the model emits. buildClarificationPrompt asks for "typically 0-2", but
9
+ * that soft hint has repeatedly been ignored (e.g. 6 generic greenfield
10
+ * questions on a scoped "improve council quality" topic). This deterministic
11
+ * cap enforces the documented target so the user is never carpet-bombed.
12
+ */
13
+ export declare const MAX_CLARIFY_QUESTIONS_PER_ROUND = 2;
14
+ export declare function isGenericGreenfieldQuestion(q: {
15
+ question: string;
16
+ why?: string;
17
+ }): boolean;
18
+ /**
19
+ * G2-a: deterministically enforce the clarifier's "typically 0-2 questions"
20
+ * rule. In an existing repo (`existingRepo` — a "## Current Project" snapshot
21
+ * is present) first drop generic greenfield questions the prompt already told
22
+ * the model not to ask; then hard-cap the remainder at
23
+ * MAX_CLARIFY_QUESTIONS_PER_ROUND. Never zero out a round that had questions —
24
+ * if every one looked generic, keep the model's top pick rather than asking
25
+ * nothing. Pure + exported so it is unit-testable in isolation.
26
+ */
27
+ export declare function capClarifierQuestions<T extends {
28
+ question: string;
29
+ why?: string;
30
+ }>(questions: T[], existingRepo: boolean): {
31
+ kept: T[];
32
+ dropped: number;
33
+ };
6
34
  /**
7
35
  * P5: Call the readiness judge to determine whether the current spec + Q&A is
8
36
  * sufficient to start a productive debate, or whether critical gaps remain.
@@ -41,7 +69,7 @@ export interface ClarifyOptionsResult {
41
69
  * so the UI knows to suppress the "(Recommended)" tag.
42
70
  */
43
71
  export declare function buildClarifyOptions(suggestions: string[] | undefined, recommended?: string): ClarifyOptionsResult;
44
- export declare function runClarification(topic: string, leaderModelId: string, conversationContext: string, respondToQuestion: QuestionResponder, llm: CouncilLLM, _signal?: AbortSignal, seedQuestions?: GrayAreaQuestion[], maxRounds?: number,
72
+ export declare function runClarification(topic: string, leaderModelId: string, conversationContext: string, respondToQuestion: QuestionResponder, llm: CouncilLLM, signal?: AbortSignal, seedQuestions?: GrayAreaQuestion[], maxRounds?: number,
45
73
  /**
46
74
  * Optional pre-filled answers keyed by seed-question id. When the loop's
47
75
  * discover phase has already proven a dimension from the project (e.g.
@@ -4,6 +4,47 @@ import { phaseDone, phaseError, phaseStart } from "./phase-events.js";
4
4
  import { buildClarificationPrompt, buildReadinessJudgePrompt, buildSpecSynthesisPrompt } from "./prompts.js";
5
5
  /** P5: Hard cap on clarification rounds regardless of judge verdict. */
6
6
  export const MAX_CLARIFY_ROUNDS = 12;
7
+ /**
8
+ * G2-a: Hard cap on clarifier questions SURFACED per round, regardless of how
9
+ * many the model emits. buildClarificationPrompt asks for "typically 0-2", but
10
+ * that soft hint has repeatedly been ignored (e.g. 6 generic greenfield
11
+ * questions on a scoped "improve council quality" topic). This deterministic
12
+ * cap enforces the documented target so the user is never carpet-bombed.
13
+ */
14
+ export const MAX_CLARIFY_QUESTIONS_PER_ROUND = 2;
15
+ /**
16
+ * G2-a: Generic "greenfield" questions that the prompt already tells the model
17
+ * NOT to ask when a "## Current Project" snapshot is present (existing repo) —
18
+ * product type, target audience, which language/framework, which database,
19
+ * hosting/deploy target. Matched in both English and Vietnamese on the
20
+ * question + why text. Used only as an existing-repo filter; never applied to
21
+ * greenfield topics where these questions are legitimate.
22
+ */
23
+ const GREENFIELD_QUESTION_RE = /\b(target audience|product type|what (kind|type) of (product|app|application)|which (programming )?language|which framework|which database|which db|hosting|deploy(ment)? target|tech stack)\b|đối tượng (người dùng|sử dụng)|loại (sản phẩm|ứng dụng)|ngôn ngữ (lập trình|nào)|framework nào|cơ sở dữ liệu|database nào|triển khai ở đâu/i;
24
+ export function isGenericGreenfieldQuestion(q) {
25
+ return GREENFIELD_QUESTION_RE.test(`${q.question} ${q.why ?? ""}`);
26
+ }
27
+ /**
28
+ * G2-a: deterministically enforce the clarifier's "typically 0-2 questions"
29
+ * rule. In an existing repo (`existingRepo` — a "## Current Project" snapshot
30
+ * is present) first drop generic greenfield questions the prompt already told
31
+ * the model not to ask; then hard-cap the remainder at
32
+ * MAX_CLARIFY_QUESTIONS_PER_ROUND. Never zero out a round that had questions —
33
+ * if every one looked generic, keep the model's top pick rather than asking
34
+ * nothing. Pure + exported so it is unit-testable in isolation.
35
+ */
36
+ export function capClarifierQuestions(questions, existingRepo) {
37
+ const before = questions.length;
38
+ let kept = questions;
39
+ if (existingRepo) {
40
+ const filtered = kept.filter((q) => !isGenericGreenfieldQuestion(q));
41
+ kept = filtered.length > 0 ? filtered : kept.slice(0, 1);
42
+ }
43
+ if (kept.length > MAX_CLARIFY_QUESTIONS_PER_ROUND) {
44
+ kept = kept.slice(0, MAX_CLARIFY_QUESTIONS_PER_ROUND);
45
+ }
46
+ return { kept, dropped: before - kept.length };
47
+ }
7
48
  /**
8
49
  * P5: Call the readiness judge to determine whether the current spec + Q&A is
9
50
  * sufficient to start a productive debate, or whether critical gaps remain.
@@ -97,7 +138,7 @@ export function buildClarifyOptions(suggestions, recommended) {
97
138
  }
98
139
  return { options, defaultIndex };
99
140
  }
100
- export async function* runClarification(topic, leaderModelId, conversationContext, respondToQuestion, llm, _signal, seedQuestions, maxRounds,
141
+ export async function* runClarification(topic, leaderModelId, conversationContext, respondToQuestion, llm, signal, seedQuestions, maxRounds,
101
142
  /**
102
143
  * Optional pre-filled answers keyed by seed-question id. When the loop's
103
144
  * discover phase has already proven a dimension from the project (e.g.
@@ -128,6 +169,11 @@ costAware = false) {
128
169
  // gaps from the previous readiness verdict, passed to the next question-generator
129
170
  let pendingGaps = [];
130
171
  for (let round = 0; round < max; round++) {
172
+ // User cancelled during clarification — stop asking further rounds. The
173
+ // generate calls themselves are already abort-aware (wrapped llm); this
174
+ // guard prevents starting a fresh round after a mid-round cancel.
175
+ if (signal?.aborted)
176
+ break;
131
177
  const useSeed = round === 0 && seeded.length > 0;
132
178
  const roundId = `phase:clarification-round-${round + 1}`;
133
179
  const roundStart = Date.now();
@@ -182,6 +228,23 @@ costAware = false) {
182
228
  questions = [];
183
229
  }
184
230
  }
231
+ // G2-a: enforce the "typically 0-2 questions per round" rule the prompt asks
232
+ // for but the model/seeds repeatedly exceed. Applies to BOTH the LLM path
233
+ // AND the PIL gray-area SEED path — live drive (greenfield "/ideal") showed
234
+ // round-0 seeds (productType/targetPlatform/audience) surfacing uncapped,
235
+ // which a model-path-only cap missed. In an existing repo (a
236
+ // "## Current Project" snapshot is present) generic greenfield questions are
237
+ // also dropped — the documented EE-3589e10d failure. A non-silent content
238
+ // line surfaces whatever was trimmed.
239
+ const existingRepo = conversationContext.includes("## Current Project");
240
+ const capped = capClarifierQuestions(questions, existingRepo);
241
+ questions = capped.kept;
242
+ if (capped.dropped > 0) {
243
+ yield {
244
+ type: "content",
245
+ content: `\n_(clarifier: trimmed ${capped.dropped} question${capped.dropped === 1 ? "" : "s"} — cap ${MAX_CLARIFY_QUESTIONS_PER_ROUND}/round${existingRepo ? " + dropped generic greenfield questions for an existing repo" : ""})_\n`,
246
+ };
247
+ }
185
248
  if (questions.length === 0) {
186
249
  // The clarifier asking nothing IS the readiness signal — the leader already
187
250
  // decided no gaps remain. Mark the spec ready directly rather than leaving the
@@ -5,4 +5,5 @@ import type { ClarifiedSpec, CouncilLLM, DebatePlan } from "./types.js";
5
5
  export declare function planDebate(spec: ClarifiedSpec, leaderModelId: string, llm: CouncilLLM, eeWarnings?: CouncilWarning[], // CQ-13: experience snippets to seed prompt
6
6
  experienceMode?: CouncilExperienceMode, // CQ-14: controls Experience Auditor injection
7
7
  taskType?: string, // CQ-11: task type from PIL (e.g. "architecture", "bugfix")
8
- complexityTier?: string): AsyncGenerator<StreamChunk, DebatePlan, unknown>;
8
+ complexityTier?: string, // CQ-11: complexity tier from PIL (e.g. "heavy", "medium", "light")
9
+ signal?: AbortSignal): AsyncGenerator<StreamChunk, DebatePlan, unknown>;
@@ -97,7 +97,8 @@ function injectAuditorStance(plan, eeWarnings, experienceMode) {
97
97
  export async function* planDebate(spec, leaderModelId, llm, eeWarnings, // CQ-13: experience snippets to seed prompt
98
98
  experienceMode, // CQ-14: controls Experience Auditor injection
99
99
  taskType, // CQ-11: task type from PIL (e.g. "architecture", "bugfix")
100
- complexityTier) {
100
+ complexityTier, // CQ-11: complexity tier from PIL (e.g. "heavy", "medium", "light")
101
+ signal) {
101
102
  const eeSnippets = eeWarnings?.map((w) => w.text).filter(Boolean) ?? [];
102
103
  const { system: baseSystem, prompt } = buildDebatePlanPrompt(spec);
103
104
  // Build calibration context from PIL metadata
@@ -133,7 +134,8 @@ complexityTier) {
133
134
  // Bound attempt-1: a wedged provider response here would freeze the whole
134
135
  // council/loop silently (no streamText stall watchdog covers generateObject).
135
136
  // On timeout this rejects → caught below → retry (guarded) → fallback plan.
136
- const { signal: timedSignal, cleanup: cleanupTimeout } = withTimeoutSignal(undefined, getProviderStallTimeoutMs());
137
+ // The user-abort signal is combined in so an Esc during planning aborts it.
138
+ const { signal: timedSignal, cleanup: cleanupTimeout } = withTimeoutSignal(signal, getProviderStallTimeoutMs());
137
139
  const { object } = await withDeadlineRace(() => generateObject({
138
140
  model: runtime.model,
139
141
  schema: DebatePlanSchema,
@@ -141,7 +143,7 @@ complexityTier) {
141
143
  prompt,
142
144
  abortSignal: timedSignal,
143
145
  ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
144
- }), getProviderStallTimeoutMs() + 5_000, "plan_debate").finally(() => cleanupTimeout());
146
+ }), getProviderStallTimeoutMs() + 5_000, "plan_debate", signal).finally(() => cleanupTimeout());
145
147
  // Validate with existing sanitize helpers for normalization
146
148
  const stances = sanitizeStances(object.stances);
147
149
  const outputShape = sanitizeShape(object.outputShape);
@@ -1,4 +1,13 @@
1
1
  import type { StreamChunk } from "../types/index.js";
2
2
  import type { ClarifiedSpec, CouncilConfig, CouncilLLM, DebateState } from "./types.js";
3
+ /**
4
+ * Resolve the initial round budget + hard ceiling from the plan's output-shape kind
5
+ * and the planner-proposed round count. Pure + exported for unit testing the cap.
6
+ */
7
+ export declare function resolveDebateRoundBudget(planKind: string | undefined, plannedRounds: number | undefined): {
8
+ maxRounds: number;
9
+ effectiveCeiling: number;
10
+ kindCapped: boolean;
11
+ };
3
12
  export declare function runDebate(spec: ClarifiedSpec, config: CouncilConfig, llm: CouncilLLM): AsyncGenerator<StreamChunk, DebateState, unknown>;
4
13
  export declare function evaluateResearchNeed(spec: ClarifiedSpec, leaderModelId: string, conversationContext: string, llm: CouncilLLM, costAware?: boolean): AsyncGenerator<StreamChunk, boolean, unknown>;