muonroi-cli 1.8.4 → 1.9.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 (562) hide show
  1. package/LICENSE +17 -5
  2. package/dist/packages/agent-harness-core/src/driver.d.ts +27 -1
  3. package/dist/packages/agent-harness-core/src/driver.js +46 -0
  4. package/dist/packages/agent-harness-core/src/event-filter.js +11 -0
  5. package/dist/packages/agent-harness-core/src/event-redact.js +7 -0
  6. package/dist/packages/agent-harness-core/src/event-tee.d.ts +64 -0
  7. package/dist/packages/agent-harness-core/src/event-tee.js +104 -0
  8. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +25 -0
  9. package/dist/packages/agent-harness-core/src/mcp-server.js +169 -21
  10. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  11. package/dist/packages/agent-harness-core/src/protocol.d.ts +90 -4
  12. package/dist/packages/agent-harness-core/src/protocol.js +15 -0
  13. package/dist/packages/agent-harness-core/src/visual-quality.d.ts +58 -0
  14. package/dist/packages/agent-harness-core/src/visual-quality.js +141 -0
  15. package/dist/packages/agent-harness-opentui/src/agent-mode.d.ts +6 -0
  16. package/dist/packages/agent-harness-opentui/src/agent-mode.js +14 -1
  17. package/dist/packages/agent-harness-opentui/src/input-bridge.d.ts +2 -10
  18. package/dist/packages/agent-harness-opentui/src/input-bridge.js +103 -16
  19. package/dist/packages/agent-harness-opentui/src/install.d.ts +8 -0
  20. package/dist/packages/agent-harness-opentui/src/install.js +10 -0
  21. package/dist/packages/agent-harness-opentui/src/semantic.js +12 -10
  22. package/dist/packages/agent-harness-opentui/src/visual-capture.d.ts +56 -0
  23. package/dist/packages/agent-harness-opentui/src/visual-capture.js +103 -0
  24. package/dist/src/agent-harness/mock-model.d.ts +38 -0
  25. package/dist/src/agent-harness/mock-model.js +69 -3
  26. package/dist/src/agent-harness/test-spawn.js +31 -0
  27. package/dist/src/chat/chat-keychain.d.ts +7 -12
  28. package/dist/src/chat/chat-keychain.js +19 -86
  29. package/dist/src/cli/config/screen-providers.js +1 -1
  30. package/dist/src/cli/cost-forensics.d.ts +10 -0
  31. package/dist/src/cli/cost-forensics.js +18 -3
  32. package/dist/src/cli/keys-bundle.d.ts +1 -1
  33. package/dist/src/cli/keys-bundle.js +1 -1
  34. package/dist/src/cli/keys.d.ts +10 -47
  35. package/dist/src/cli/keys.js +31 -399
  36. package/dist/src/council/clarifier.d.ts +31 -3
  37. package/dist/src/council/clarifier.js +220 -32
  38. package/dist/src/council/context.js +49 -15
  39. package/dist/src/council/debate-checkpoint.d.ts +129 -0
  40. package/dist/src/council/debate-checkpoint.js +176 -0
  41. package/dist/src/council/debate-planner.js +54 -5
  42. package/dist/src/council/debate-summary.d.ts +25 -0
  43. package/dist/src/council/debate-summary.js +85 -0
  44. package/dist/src/council/debate.d.ts +169 -2
  45. package/dist/src/council/debate.js +1265 -135
  46. package/dist/src/council/index.d.ts +108 -1
  47. package/dist/src/council/index.js +670 -197
  48. package/dist/src/council/leader.d.ts +26 -0
  49. package/dist/src/council/leader.js +150 -9
  50. package/dist/src/council/llm.d.ts +94 -0
  51. package/dist/src/council/llm.js +348 -55
  52. package/dist/src/council/panel-select.d.ts +30 -0
  53. package/dist/src/council/panel-select.js +82 -0
  54. package/dist/src/council/planner.js +40 -0
  55. package/dist/src/council/preflight.d.ts +17 -0
  56. package/dist/src/council/preflight.js +50 -2
  57. package/dist/src/council/prompts.d.ts +39 -4
  58. package/dist/src/council/prompts.js +256 -69
  59. package/dist/src/council/stance-recall.d.ts +42 -0
  60. package/dist/src/council/stance-recall.js +57 -0
  61. package/dist/src/council/strip-think.d.ts +17 -0
  62. package/dist/src/council/strip-think.js +33 -0
  63. package/dist/src/council/types.d.ts +138 -0
  64. package/dist/src/ee/artifact-cache.d.ts +16 -0
  65. package/dist/src/ee/artifact-cache.js +32 -0
  66. package/dist/src/ee/auth.d.ts +20 -0
  67. package/dist/src/ee/auth.js +54 -2
  68. package/dist/src/ee/bridge.d.ts +10 -0
  69. package/dist/src/ee/bridge.js +58 -0
  70. package/dist/src/ee/client.js +109 -21
  71. package/dist/src/ee/ee-onboarding.js +6 -26
  72. package/dist/src/ee/export-transcripts.d.ts +1 -0
  73. package/dist/src/ee/export-transcripts.js +8 -10
  74. package/dist/src/ee/extract-session.js +29 -0
  75. package/dist/src/ee/extract-style.d.ts +58 -0
  76. package/dist/src/ee/extract-style.js +270 -0
  77. package/dist/src/ee/recall-ledger.d.ts +9 -0
  78. package/dist/src/ee/recall-ledger.js +3 -0
  79. package/dist/src/ee/scope.d.ts +1 -0
  80. package/dist/src/ee/scope.js +26 -1
  81. package/dist/src/ee/search.d.ts +7 -0
  82. package/dist/src/ee/search.js +24 -0
  83. package/dist/src/ee/transcript-emit.js +2 -0
  84. package/dist/src/ee/types.d.ts +22 -0
  85. package/dist/src/ee/who-am-i-brain.d.ts +35 -0
  86. package/dist/src/ee/who-am-i-brain.js +220 -0
  87. package/dist/src/ee/who-am-i.d.ts +10 -3
  88. package/dist/src/ee/who-am-i.js +12 -0
  89. package/dist/src/ee/workflow-event.d.ts +48 -0
  90. package/dist/src/ee/workflow-event.js +81 -0
  91. package/dist/src/flow/compaction/compress.d.ts +3 -3
  92. package/dist/src/flow/compaction/compress.js +58 -8
  93. package/dist/src/flow/compaction/extract.d.ts +4 -7
  94. package/dist/src/flow/compaction/extract.js +50 -10
  95. package/dist/src/flow/compaction/index.d.ts +14 -1
  96. package/dist/src/flow/compaction/index.js +96 -3
  97. package/dist/src/flow/compaction/input-guard.d.ts +24 -0
  98. package/dist/src/flow/compaction/input-guard.js +43 -0
  99. package/dist/src/flow/compaction/progress.d.ts +35 -0
  100. package/dist/src/flow/compaction/progress.js +35 -0
  101. package/dist/src/flow/fold-planning.d.ts +36 -0
  102. package/dist/src/flow/fold-planning.js +83 -0
  103. package/dist/src/flow/hierarchy.d.ts +146 -0
  104. package/dist/src/flow/hierarchy.js +427 -0
  105. package/dist/src/flow/index.d.ts +1 -0
  106. package/dist/src/flow/index.js +2 -0
  107. package/dist/src/flow/run-artifacts.d.ts +102 -0
  108. package/dist/src/flow/run-artifacts.js +208 -0
  109. package/dist/src/generated/version.d.ts +1 -1
  110. package/dist/src/generated/version.js +1 -1
  111. package/dist/src/gsd/assessment-schema.d.ts +44 -0
  112. package/dist/src/gsd/assessment-schema.js +134 -0
  113. package/dist/src/gsd/capability-registry.d.ts +45 -0
  114. package/dist/src/gsd/capability-registry.js +337 -0
  115. package/dist/src/gsd/complexity-assessor.d.ts +39 -0
  116. package/dist/src/gsd/complexity-assessor.js +152 -0
  117. package/dist/src/gsd/config-bridge.d.ts +7 -0
  118. package/dist/src/gsd/config-bridge.js +114 -0
  119. package/dist/src/gsd/config-loader.d.ts +27 -0
  120. package/dist/src/gsd/config-loader.js +50 -0
  121. package/dist/src/gsd/council-context.d.ts +44 -0
  122. package/dist/src/gsd/council-context.js +114 -0
  123. package/dist/src/gsd/ee-closure.d.ts +28 -0
  124. package/dist/src/gsd/ee-closure.js +49 -0
  125. package/dist/src/gsd/flags.d.ts +66 -0
  126. package/dist/src/gsd/flags.js +102 -0
  127. package/dist/src/gsd/gsd-dispatch.d.ts +58 -0
  128. package/dist/src/gsd/gsd-dispatch.js +131 -0
  129. package/dist/src/gsd/gsd-runtime.d.ts +22 -0
  130. package/dist/src/gsd/gsd-runtime.js +37 -0
  131. package/dist/src/gsd/host-adapter.d.ts +11 -0
  132. package/dist/src/gsd/host-adapter.js +29 -0
  133. package/dist/src/gsd/index.d.ts +24 -1
  134. package/dist/src/gsd/index.js +27 -0
  135. package/dist/src/gsd/loop-host-contract.d.ts +21 -0
  136. package/dist/src/gsd/loop-host-contract.js +39 -0
  137. package/dist/src/gsd/loop-host.d.ts +69 -0
  138. package/dist/src/gsd/loop-host.js +245 -0
  139. package/dist/src/gsd/loop-resolver.d.ts +36 -0
  140. package/dist/src/gsd/loop-resolver.js +79 -0
  141. package/dist/src/gsd/model-tier.d.ts +13 -0
  142. package/dist/src/gsd/model-tier.js +45 -0
  143. package/dist/src/gsd/mutation-gate.d.ts +16 -0
  144. package/dist/src/gsd/mutation-gate.js +41 -0
  145. package/dist/src/gsd/native-roadmap.d.ts +89 -0
  146. package/dist/src/gsd/native-roadmap.js +343 -0
  147. package/dist/src/gsd/native-state.d.ts +47 -0
  148. package/dist/src/gsd/native-state.js +220 -0
  149. package/dist/src/gsd/paths.d.ts +23 -0
  150. package/dist/src/gsd/paths.js +66 -0
  151. package/dist/src/gsd/phase-dag.d.ts +12 -0
  152. package/dist/src/gsd/phase-dag.js +94 -0
  153. package/dist/src/gsd/phase-sync.d.ts +42 -0
  154. package/dist/src/gsd/phase-sync.js +321 -0
  155. package/dist/src/gsd/pil-gate-context.d.ts +13 -0
  156. package/dist/src/gsd/pil-gate-context.js +64 -0
  157. package/dist/src/gsd/pil-gate-critic.d.ts +19 -0
  158. package/dist/src/gsd/pil-gate-critic.js +74 -0
  159. package/dist/src/gsd/plan-council-prompts.d.ts +25 -0
  160. package/dist/src/gsd/plan-council-prompts.js +79 -0
  161. package/dist/src/gsd/plan-council.d.ts +44 -0
  162. package/dist/src/gsd/plan-council.js +283 -0
  163. package/dist/src/gsd/plan-gate-vocabulary.d.ts +40 -0
  164. package/dist/src/gsd/plan-gate-vocabulary.js +64 -0
  165. package/dist/src/gsd/product-workspace.d.ts +13 -0
  166. package/dist/src/gsd/product-workspace.js +124 -0
  167. package/dist/src/gsd/ship-bridge.d.ts +25 -0
  168. package/dist/src/gsd/ship-bridge.js +65 -0
  169. package/dist/src/gsd/state-document.d.ts +40 -0
  170. package/dist/src/gsd/state-document.js +163 -0
  171. package/dist/src/gsd/verdict-schema.d.ts +39 -0
  172. package/dist/src/gsd/verdict-schema.js +144 -0
  173. package/dist/src/gsd/verify-context.d.ts +22 -0
  174. package/dist/src/gsd/verify-context.js +27 -0
  175. package/dist/src/gsd/verify-council-prompts.d.ts +19 -0
  176. package/dist/src/gsd/verify-council-prompts.js +85 -0
  177. package/dist/src/gsd/verify-council.d.ts +25 -0
  178. package/dist/src/gsd/verify-council.js +119 -0
  179. package/dist/src/gsd/verify-gate-vocabulary.d.ts +25 -0
  180. package/dist/src/gsd/verify-gate-vocabulary.js +46 -0
  181. package/dist/src/gsd/workflow-engine.d.ts +60 -0
  182. package/dist/src/gsd/workflow-engine.js +207 -0
  183. package/dist/src/gsd/workflow-tools.d.ts +13 -0
  184. package/dist/src/gsd/workflow-tools.js +277 -0
  185. package/dist/src/headless/council-answers.js +4 -0
  186. package/dist/src/hooks/index.js +1 -1
  187. package/dist/src/index.js +172 -270
  188. package/dist/src/lsp/builtins.js +3 -1
  189. package/dist/src/lsp/manager.d.ts +5 -1
  190. package/dist/src/lsp/manager.js +249 -3
  191. package/dist/src/lsp/npm-cache.d.ts +11 -1
  192. package/dist/src/lsp/npm-cache.js +17 -1
  193. package/dist/src/lsp/runtime.d.ts +6 -1
  194. package/dist/src/lsp/runtime.js +17 -1
  195. package/dist/src/lsp/types.d.ts +83 -1
  196. package/dist/src/lsp/types.js +10 -0
  197. package/dist/src/maintain/pr-builder.js +23 -13
  198. package/dist/src/mcp/auto-setup.js +57 -32
  199. package/dist/src/mcp/client-pool.js +44 -16
  200. package/dist/src/mcp/lsp-tools.d.ts +5 -1
  201. package/dist/src/mcp/lsp-tools.js +93 -2
  202. package/dist/src/mcp/mcp-keychain.d.ts +3 -5
  203. package/dist/src/mcp/mcp-keychain.js +9 -49
  204. package/dist/src/mcp/research-onboarding.js +8 -7
  205. package/dist/src/mcp/runtime.js +34 -2
  206. package/dist/src/mcp/setup-guide-text.d.ts +1 -1
  207. package/dist/src/mcp/setup-guide-text.js +22 -2
  208. package/dist/src/mcp/tools-server.d.ts +10 -0
  209. package/dist/src/mcp/tools-server.js +10 -2
  210. package/dist/src/models/catalog-client.d.ts +87 -0
  211. package/dist/src/models/catalog-client.js +105 -38
  212. package/dist/src/models/catalog.json +528 -265
  213. package/dist/src/models/registry.d.ts +22 -7
  214. package/dist/src/models/registry.js +73 -10
  215. package/dist/src/ops/doctor.js +1 -1
  216. package/dist/src/orchestrator/ask-user.d.ts +61 -0
  217. package/dist/src/orchestrator/ask-user.js +65 -0
  218. package/dist/src/orchestrator/auto-commit.js +1 -1
  219. package/dist/src/orchestrator/batch-turn-runner.js +2 -2
  220. package/dist/src/orchestrator/cache-prefix.d.ts +67 -0
  221. package/dist/src/orchestrator/cache-prefix.js +83 -0
  222. package/dist/src/orchestrator/compact-request.d.ts +32 -0
  223. package/dist/src/orchestrator/compact-request.js +41 -0
  224. package/dist/src/orchestrator/compaction.d.ts +12 -3
  225. package/dist/src/orchestrator/compaction.js +35 -15
  226. package/dist/src/orchestrator/council-manager.d.ts +12 -3
  227. package/dist/src/orchestrator/council-manager.js +74 -32
  228. package/dist/src/orchestrator/council-request.d.ts +49 -0
  229. package/dist/src/orchestrator/council-request.js +62 -0
  230. package/dist/src/orchestrator/cross-turn-dedup.d.ts +6 -0
  231. package/dist/src/orchestrator/cross-turn-dedup.js +22 -1
  232. package/dist/src/orchestrator/error-utils.d.ts +29 -0
  233. package/dist/src/orchestrator/error-utils.js +132 -24
  234. package/dist/src/orchestrator/grounding-check.js +39 -1
  235. package/dist/src/orchestrator/interactive-pause.d.ts +26 -0
  236. package/dist/src/orchestrator/interactive-pause.js +36 -0
  237. package/dist/src/orchestrator/message-processor.d.ts +4 -0
  238. package/dist/src/orchestrator/message-processor.js +268 -41
  239. package/dist/src/orchestrator/orchestrator.d.ts +64 -3
  240. package/dist/src/orchestrator/orchestrator.js +823 -120
  241. package/dist/src/orchestrator/preprocessor.js +3 -3
  242. package/dist/src/orchestrator/proactive-compact-detector.d.ts +26 -0
  243. package/dist/src/orchestrator/proactive-compact-detector.js +36 -0
  244. package/dist/src/orchestrator/prompts.js +17 -17
  245. package/dist/src/orchestrator/reactive-delegation.d.ts +39 -0
  246. package/dist/src/orchestrator/reactive-delegation.js +59 -0
  247. package/dist/src/orchestrator/retry-classifier.d.ts +2 -1
  248. package/dist/src/orchestrator/retry-classifier.js +46 -2
  249. package/dist/src/orchestrator/safety-askcard.d.ts +1 -1
  250. package/dist/src/orchestrator/safety-askcard.js +5 -2
  251. package/dist/src/orchestrator/safety-intercept.d.ts +50 -0
  252. package/dist/src/orchestrator/safety-intercept.js +62 -0
  253. package/dist/src/orchestrator/scope-reminder.js +1 -1
  254. package/dist/src/orchestrator/session-experience.d.ts +2 -1
  255. package/dist/src/orchestrator/session-experience.js +2 -1
  256. package/dist/src/orchestrator/should-run-gate.d.ts +5 -0
  257. package/dist/src/orchestrator/should-run-gate.js +18 -0
  258. package/dist/src/orchestrator/stall-watchdog.d.ts +31 -3
  259. package/dist/src/orchestrator/stall-watchdog.js +65 -10
  260. package/dist/src/orchestrator/stream-runner.d.ts +13 -3
  261. package/dist/src/orchestrator/stream-runner.js +115 -49
  262. package/dist/src/orchestrator/sub-agent-cap.d.ts +13 -1
  263. package/dist/src/orchestrator/sub-agent-cap.js +16 -1
  264. package/dist/src/orchestrator/sub-agent-model-tier.d.ts +13 -1
  265. package/dist/src/orchestrator/sub-agent-model-tier.js +16 -2
  266. package/dist/src/orchestrator/subagent-compactor.d.ts +53 -1
  267. package/dist/src/orchestrator/subagent-compactor.js +126 -15
  268. package/dist/src/orchestrator/tool-engine.d.ts +41 -0
  269. package/dist/src/orchestrator/tool-engine.js +846 -66
  270. package/dist/src/orchestrator/tool-limit-auto-recover.d.ts +22 -0
  271. package/dist/src/orchestrator/tool-limit-auto-recover.js +30 -0
  272. package/dist/src/orchestrator/turn-runner-deps.d.ts +19 -0
  273. package/dist/src/orchestrator/turn-watchdog.d.ts +44 -0
  274. package/dist/src/orchestrator/turn-watchdog.js +84 -0
  275. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  276. package/dist/src/pil/agent-operating-contract.js +6 -4
  277. package/dist/src/pil/cheap-model-playbook.d.ts +1 -1
  278. package/dist/src/pil/cheap-model-playbook.js +5 -1
  279. package/dist/src/pil/cheap-model-workbooks.d.ts +1 -1
  280. package/dist/src/pil/cheap-model-workbooks.js +1 -1
  281. package/dist/src/pil/discovery-types.d.ts +1 -0
  282. package/dist/src/pil/discovery.d.ts +1 -1
  283. package/dist/src/pil/discovery.js +18 -13
  284. package/dist/src/pil/layer1-intent.d.ts +18 -6
  285. package/dist/src/pil/layer1-intent.js +66 -757
  286. package/dist/src/pil/layer15-context-scan.js +15 -1
  287. package/dist/src/pil/layer1_5-complexity-size.d.ts +7 -0
  288. package/dist/src/pil/layer1_5-complexity-size.js +31 -5
  289. package/dist/src/pil/layer3-ee-injection.js +23 -8
  290. package/dist/src/pil/layer4-gsd.js +69 -16
  291. package/dist/src/pil/layer5-context.js +7 -3
  292. package/dist/src/pil/layer6-output.d.ts +23 -0
  293. package/dist/src/pil/layer6-output.js +5 -1
  294. package/dist/src/pil/llm-classify.d.ts +111 -5
  295. package/dist/src/pil/llm-classify.js +421 -189
  296. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  297. package/dist/src/pil/native-capabilities-workbook.js +8 -0
  298. package/dist/src/pil/pipeline.js +36 -2
  299. package/dist/src/pil/repo-grounding-probe.d.ts +15 -0
  300. package/dist/src/pil/repo-grounding-probe.js +136 -0
  301. package/dist/src/pil/repo-structure-hints.d.ts +7 -0
  302. package/dist/src/pil/repo-structure-hints.js +45 -0
  303. package/dist/src/pil/response-tools.js +5 -3
  304. package/dist/src/pil/schema.d.ts +1 -0
  305. package/dist/src/pil/schema.js +2 -0
  306. package/dist/src/pil/types.d.ts +18 -0
  307. package/dist/src/playbook/directives.d.ts +4 -0
  308. package/dist/src/playbook/directives.js +17 -5
  309. package/dist/src/product-loop/artifact-io.js +4 -0
  310. package/dist/src/product-loop/backlog-builder.d.ts +14 -1
  311. package/dist/src/product-loop/backlog-builder.js +30 -6
  312. package/dist/src/product-loop/criteria-seed.d.ts +51 -0
  313. package/dist/src/product-loop/criteria-seed.js +200 -0
  314. package/dist/src/product-loop/discovery-context-format.js +3 -1
  315. package/dist/src/product-loop/discovery-ecosystem.js +4 -1
  316. package/dist/src/product-loop/discovery-interview.d.ts +9 -0
  317. package/dist/src/product-loop/discovery-interview.js +60 -12
  318. package/dist/src/product-loop/discovery-recommender.js +2 -1
  319. package/dist/src/product-loop/discovery-schema.js +19 -2
  320. package/dist/src/product-loop/discovery-triage.d.ts +23 -0
  321. package/dist/src/product-loop/discovery-triage.js +109 -0
  322. package/dist/src/product-loop/gather.js +150 -2
  323. package/dist/src/product-loop/ideal-trace.d.ts +7 -0
  324. package/dist/src/product-loop/ideal-trace.js +64 -0
  325. package/dist/src/product-loop/index.d.ts +13 -1
  326. package/dist/src/product-loop/index.js +340 -52
  327. package/dist/src/product-loop/loop-driver.d.ts +7 -0
  328. package/dist/src/product-loop/loop-driver.js +330 -106
  329. package/dist/src/product-loop/phase-plan.d.ts +21 -0
  330. package/dist/src/product-loop/phase-plan.js +81 -6
  331. package/dist/src/product-loop/phase-rituals.d.ts +3 -0
  332. package/dist/src/product-loop/phase-rituals.js +8 -3
  333. package/dist/src/product-loop/phase-runner.js +39 -12
  334. package/dist/src/product-loop/plan-adherence-review.d.ts +26 -0
  335. package/dist/src/product-loop/plan-adherence-review.js +144 -0
  336. package/dist/src/product-loop/sprint-runner.d.ts +173 -0
  337. package/dist/src/product-loop/sprint-runner.js +863 -19
  338. package/dist/src/product-loop/types.d.ts +61 -5
  339. package/dist/src/providers/adapter.d.ts +1 -1
  340. package/dist/src/providers/adapter.js +3 -4
  341. package/dist/src/providers/anthropic.d.ts +9 -8
  342. package/dist/src/providers/anthropic.js +13 -47
  343. package/dist/src/providers/auth/browser-flow.d.ts +1 -1
  344. package/dist/src/providers/auth/browser-flow.js +1 -1
  345. package/dist/src/providers/auth/grok-oauth.d.ts +1 -0
  346. package/dist/src/providers/auth/grok-oauth.js +30 -5
  347. package/dist/src/providers/auth/openai-oauth.d.ts +1 -0
  348. package/dist/src/providers/auth/openai-oauth.js +15 -1
  349. package/dist/src/providers/auth/registry.js +0 -34
  350. package/dist/src/providers/auth/token-store.d.ts +9 -9
  351. package/dist/src/providers/auth/token-store.js +8 -67
  352. package/dist/src/providers/auth/types.d.ts +9 -1
  353. package/dist/src/providers/auth/types.js +1 -1
  354. package/dist/src/providers/capabilities.d.ts +24 -5
  355. package/dist/src/providers/capabilities.js +42 -24
  356. package/dist/src/providers/endpoints.d.ts +2 -2
  357. package/dist/src/providers/endpoints.js +11 -10
  358. package/dist/src/providers/env-store.d.ts +17 -0
  359. package/dist/src/providers/env-store.js +228 -0
  360. package/dist/src/providers/keychain.d.ts +22 -18
  361. package/dist/src/providers/keychain.js +127 -140
  362. package/dist/src/providers/mcp-vision-bridge.js +56 -146
  363. package/dist/src/providers/openai-compatible.js +8 -1
  364. package/dist/src/providers/pricing.d.ts +2 -2
  365. package/dist/src/providers/pricing.js +3 -13
  366. package/dist/src/providers/runtime.d.ts +43 -3
  367. package/dist/src/providers/runtime.js +88 -14
  368. package/dist/src/providers/strategies/base.strategy.d.ts +16 -0
  369. package/dist/src/providers/strategies/base.strategy.js +24 -1
  370. package/dist/src/providers/strategies/{siliconflow.strategy.d.ts → opencode-go.strategy.d.ts} +3 -3
  371. package/dist/src/providers/strategies/opencode-go.strategy.js +83 -0
  372. package/dist/src/providers/strategies/registry.js +4 -4
  373. package/dist/src/providers/strategies/thinking-mode.d.ts +109 -0
  374. package/dist/src/providers/strategies/thinking-mode.js +288 -1
  375. package/dist/src/providers/strategies/xai.strategy.js +27 -0
  376. package/dist/src/providers/strategies/zai.strategy.d.ts +14 -0
  377. package/dist/src/providers/strategies/zai.strategy.js +44 -0
  378. package/dist/src/providers/types.d.ts +5 -6
  379. package/dist/src/providers/types.js +2 -2
  380. package/dist/src/providers/vision-backend.d.ts +47 -0
  381. package/dist/src/providers/vision-backend.js +258 -0
  382. package/dist/src/providers/vision-proxy.d.ts +22 -9
  383. package/dist/src/providers/vision-proxy.js +63 -132
  384. package/dist/src/providers/warm.d.ts +65 -0
  385. package/dist/src/providers/warm.js +145 -0
  386. package/dist/src/providers/wire-debug.js +95 -0
  387. package/dist/src/router/decide.d.ts +13 -0
  388. package/dist/src/router/decide.js +138 -36
  389. package/dist/src/router/peak-hour.d.ts +38 -0
  390. package/dist/src/router/peak-hour.js +107 -0
  391. package/dist/src/router/step-router.js +3 -2
  392. package/dist/src/router/warm.js +4 -5
  393. package/dist/src/scaffold/continuation-prompt.d.ts +11 -0
  394. package/dist/src/scaffold/continuation-prompt.js +26 -0
  395. package/dist/src/scaffold/point-to-existing.d.ts +21 -0
  396. package/dist/src/scaffold/point-to-existing.js +25 -0
  397. package/dist/src/self-qa/agentic-loop.js +6 -5
  398. package/dist/src/{ui/state → state}/active-run.d.ts +19 -0
  399. package/dist/src/{ui/state → state}/active-run.js +21 -0
  400. package/dist/src/{ui/status-bar/store.d.ts → state/status-bar-store.d.ts} +4 -1
  401. package/dist/src/{ui/status-bar/store.js → state/status-bar-store.js} +12 -9
  402. package/dist/src/state/turn-trace.d.ts +43 -0
  403. package/dist/src/state/turn-trace.js +32 -0
  404. package/dist/src/storage/db.js +2 -1
  405. package/dist/src/storage/index.d.ts +1 -1
  406. package/dist/src/storage/index.js +1 -1
  407. package/dist/src/storage/interaction-log.d.ts +1 -1
  408. package/dist/src/storage/migrations.js +71 -1
  409. package/dist/src/storage/sessions.d.ts +28 -10
  410. package/dist/src/storage/sessions.js +78 -21
  411. package/dist/src/storage/transcript-view.js +1 -1
  412. package/dist/src/storage/transcript.d.ts +51 -0
  413. package/dist/src/storage/transcript.js +340 -15
  414. package/dist/src/tools/file.d.ts +15 -0
  415. package/dist/src/tools/file.js +32 -0
  416. package/dist/src/tools/git-safety.d.ts +19 -0
  417. package/dist/src/tools/git-safety.js +168 -0
  418. package/dist/src/tools/native-tools.d.ts +1 -1
  419. package/dist/src/tools/native-tools.js +81 -1
  420. package/dist/src/tools/registry.d.ts +20 -0
  421. package/dist/src/tools/registry.js +576 -23
  422. package/dist/src/tools/research.d.ts +29 -0
  423. package/dist/src/tools/research.js +233 -0
  424. package/dist/src/types/index.d.ts +147 -4
  425. package/dist/src/ui/app.js +0 -0
  426. package/dist/src/ui/cards/product-status-card.js +1 -1
  427. package/dist/src/ui/components/agent-rail-activities.d.ts +26 -0
  428. package/dist/src/ui/components/agent-rail-activities.js +47 -0
  429. package/dist/src/ui/components/bubble-body-guard.d.ts +27 -0
  430. package/dist/src/ui/components/bubble-body-guard.js +50 -0
  431. package/dist/src/ui/components/compact-progress-card.d.ts +24 -0
  432. package/dist/src/ui/components/compact-progress-card.js +42 -0
  433. package/dist/src/ui/components/context-rail.d.ts +26 -0
  434. package/dist/src/ui/components/context-rail.js +33 -0
  435. package/dist/src/ui/components/council-conclusion-card.d.ts +73 -0
  436. package/dist/src/ui/components/council-conclusion-card.js +420 -0
  437. package/dist/src/ui/components/council-debate-pill.d.ts +36 -0
  438. package/dist/src/ui/components/council-debate-pill.js +34 -0
  439. package/dist/src/ui/components/council-info-card.js +2 -2
  440. package/dist/src/ui/components/council-leader-bubble.d.ts +10 -3
  441. package/dist/src/ui/components/council-leader-bubble.js +21 -11
  442. package/dist/src/ui/components/council-message-bubble.d.ts +15 -2
  443. package/dist/src/ui/components/council-message-bubble.js +16 -15
  444. package/dist/src/ui/components/council-phase-timeline.d.ts +9 -1
  445. package/dist/src/ui/components/council-phase-timeline.js +66 -17
  446. package/dist/src/ui/components/council-placeholder-bubble.d.ts +16 -9
  447. package/dist/src/ui/components/council-placeholder-bubble.js +32 -29
  448. package/dist/src/ui/components/council-question-card.js +13 -12
  449. package/dist/src/ui/components/council-rail-rounds.d.ts +26 -0
  450. package/dist/src/ui/components/council-rail-rounds.js +57 -0
  451. package/dist/src/ui/components/council-round-group.d.ts +38 -0
  452. package/dist/src/ui/components/council-round-group.js +88 -0
  453. package/dist/src/ui/components/council-status-list.d.ts +3 -1
  454. package/dist/src/ui/components/council-status-list.js +36 -24
  455. package/dist/src/ui/components/council-synthesis-banner.d.ts +7 -2
  456. package/dist/src/ui/components/council-synthesis-banner.js +20 -5
  457. package/dist/src/ui/components/halt-recovery-card.js +9 -5
  458. package/dist/src/ui/components/jump-to-latest-pill.d.ts +11 -0
  459. package/dist/src/ui/components/jump-to-latest-pill.js +14 -0
  460. package/dist/src/ui/components/message-view.d.ts +15 -0
  461. package/dist/src/ui/components/message-view.js +50 -1
  462. package/dist/src/ui/components/prompt-box.js +18 -16
  463. package/dist/src/ui/components/session-tree-card.d.ts +14 -0
  464. package/dist/src/ui/components/session-tree-card.js +46 -0
  465. package/dist/src/ui/components/slash-inline-menu.d.ts +12 -0
  466. package/dist/src/ui/components/slash-inline-menu.js +26 -5
  467. package/dist/src/ui/components/task-list-panel.d.ts +14 -1
  468. package/dist/src/ui/components/task-list-panel.js +22 -2
  469. package/dist/src/ui/components/tool-group.d.ts +15 -3
  470. package/dist/src/ui/components/tool-group.js +69 -11
  471. package/dist/src/ui/containers/modals-layer.d.ts +4 -2
  472. package/dist/src/ui/containers/modals-layer.js +2 -2
  473. package/dist/src/ui/council-harness-event.d.ts +57 -0
  474. package/dist/src/ui/council-harness-event.js +46 -0
  475. package/dist/src/ui/heartbeat-debug.d.ts +29 -0
  476. package/dist/src/ui/heartbeat-debug.js +45 -0
  477. package/dist/src/ui/hooks/use-session-picker.d.ts +3 -3
  478. package/dist/src/ui/mcp-modal.js +2 -4
  479. package/dist/src/ui/modals/api-key-modal.js +1 -1
  480. package/dist/src/ui/modals/connect-modal.js +4 -3
  481. package/dist/src/ui/modals/model-picker-modal.d.ts +8 -18
  482. package/dist/src/ui/modals/model-picker-modal.js +8 -10
  483. package/dist/src/ui/modals/session-picker-modal.d.ts +2 -2
  484. package/dist/src/ui/modals/session-picker-modal.js +3 -5
  485. package/dist/src/ui/picker-providers.d.ts +1 -1
  486. package/dist/src/ui/picker-providers.js +1 -1
  487. package/dist/src/ui/primitives/index.d.ts +1 -0
  488. package/dist/src/ui/primitives/index.js +2 -0
  489. package/dist/src/ui/primitives/semantic-primitives.d.ts +76 -0
  490. package/dist/src/ui/primitives/semantic-primitives.js +81 -0
  491. package/dist/src/ui/slash/compact.js +5 -7
  492. package/dist/src/ui/slash/cost.js +1 -1
  493. package/dist/src/ui/slash/council.js +19 -1
  494. package/dist/src/ui/slash/debug.d.ts +3 -31
  495. package/dist/src/ui/slash/debug.js +9 -20
  496. package/dist/src/ui/slash/ee.js +81 -0
  497. package/dist/src/ui/slash/ideal.d.ts +6 -2
  498. package/dist/src/ui/slash/ideal.js +97 -7
  499. package/dist/src/ui/slash/menu-items.d.ts +7 -0
  500. package/dist/src/ui/slash/menu-items.js +23 -20
  501. package/dist/src/ui/slash/registry.d.ts +2 -0
  502. package/dist/src/ui/slash/registry.js +4 -0
  503. package/dist/src/ui/status-bar/cache-hit.d.ts +6 -0
  504. package/dist/src/ui/status-bar/cache-hit.js +9 -0
  505. package/dist/src/ui/status-bar/index.d.ts +1 -1
  506. package/dist/src/ui/status-bar/index.js +7 -3
  507. package/dist/src/ui/status-bar/usd-meter.d.ts +5 -4
  508. package/dist/src/ui/status-bar/usd-meter.js +6 -4
  509. package/dist/src/ui/theme.d.ts +1 -0
  510. package/dist/src/ui/theme.js +2 -0
  511. package/dist/src/ui/types.d.ts +7 -0
  512. package/dist/src/ui/use-app-logic.js +0 -0
  513. package/dist/src/ui/utils/agent-activities.d.ts +39 -0
  514. package/dist/src/ui/utils/agent-activities.js +96 -0
  515. package/dist/src/ui/utils/format.d.ts +14 -0
  516. package/dist/src/ui/utils/format.js +23 -3
  517. package/dist/src/ui/utils/group-tool-entries.d.ts +26 -0
  518. package/dist/src/ui/utils/group-tool-entries.js +111 -0
  519. package/dist/src/ui/utils/tool-summary.d.ts +21 -0
  520. package/dist/src/ui/utils/tool-summary.js +91 -0
  521. package/dist/src/usage/downgrade.js +2 -2
  522. package/dist/src/usage/product-ledger.js +2 -2
  523. package/dist/src/utils/event-loop-monitor.d.ts +85 -0
  524. package/dist/src/utils/event-loop-monitor.js +107 -0
  525. package/dist/src/utils/install-manager.js +2 -1
  526. package/dist/src/utils/llm-deadline.d.ts +14 -0
  527. package/dist/src/utils/llm-deadline.js +19 -0
  528. package/dist/src/utils/logger.js +2 -2
  529. package/dist/src/utils/loop-profiler.d.ts +102 -0
  530. package/dist/src/utils/loop-profiler.js +202 -0
  531. package/dist/src/utils/permission-mode.js +5 -3
  532. package/dist/src/utils/redactor.js +1 -1
  533. package/dist/src/utils/settings.d.ts +180 -5
  534. package/dist/src/utils/settings.js +271 -31
  535. package/dist/src/utils/side-question.d.ts +1 -2
  536. package/dist/src/utils/side-question.js +2 -2
  537. package/dist/src/utils/visible-retry.d.ts +11 -0
  538. package/dist/src/utils/visible-retry.js +10 -1
  539. package/dist/src/verify/entrypoint.d.ts +1 -1
  540. package/dist/src/verify/entrypoint.js +52 -17
  541. package/dist/src/verify/orchestrator.d.ts +1 -1
  542. package/dist/src/verify/orchestrator.js +20 -3
  543. package/dist/src/verify/recipes.d.ts +13 -0
  544. package/dist/src/verify/recipes.js +15 -0
  545. package/package.json +134 -132
  546. package/dist/src/cli/bw-vault.d.ts +0 -55
  547. package/dist/src/cli/bw-vault.js +0 -133
  548. package/dist/src/mcp/ee-tools.d.ts +0 -46
  549. package/dist/src/mcp/ee-tools.js +0 -193
  550. package/dist/src/providers/auth/gcloud.d.ts +0 -28
  551. package/dist/src/providers/auth/gcloud.js +0 -102
  552. package/dist/src/providers/auth/gemini-oauth.d.ts +0 -82
  553. package/dist/src/providers/auth/gemini-oauth.js +0 -472
  554. package/dist/src/providers/gemini.d.ts +0 -11
  555. package/dist/src/providers/gemini.js +0 -45
  556. package/dist/src/providers/siliconflow-sse-repair.d.ts +0 -58
  557. package/dist/src/providers/siliconflow-sse-repair.js +0 -177
  558. package/dist/src/providers/strategies/google.strategy.d.ts +0 -22
  559. package/dist/src/providers/strategies/google.strategy.js +0 -174
  560. package/dist/src/providers/strategies/siliconflow.strategy.js +0 -29
  561. package/dist/src/ui/containers/chat-feed.d.ts +0 -40
  562. package/dist/src/ui/containers/chat-feed.js +0 -66
@@ -1,6 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { getModelInfo } from "../models/registry.js";
3
+ import { detectProviderForModel } from "../providers/runtime.js";
4
+ import { getIsolatedTaskDeadlineMs, withDeadlineRace } from "../utils/llm-deadline.js";
5
+ import { getCouncilLanguage } from "../utils/settings.js";
6
+ import { buildDebateCheckpoint, checkpointMatches, deleteDebateCheckpoint, restoreExchangeLogs, writeDebateCheckpoint, } from "./debate-checkpoint.js";
7
+ import { resolveDebateSummary } from "./debate-summary.js";
2
8
  import { pickCouncilTaskModel } from "./leader.js";
3
- import { tracedAsync, tracedGenerate } from "./llm.js";
9
+ import { councilStreamLivenessReader, tracedAsync, tracedGenerate } from "./llm.js";
4
10
  import { phaseDone, phaseStart } from "./phase-events.js";
5
11
  import { buildFollowupPrompt, buildLeaderEvaluationPrompt, buildOpeningPrompt, buildResponsePrompt, buildRoundSummaryPrompt, } from "./prompts.js";
6
12
  /**
@@ -220,7 +226,149 @@ async function openingWithRetry(llm, model, system, prompt) {
220
226
  }
221
227
  return { text: "", attempts: MAX_OPENING_ATTEMPTS, error: lastError };
222
228
  }
223
- async function debateWithRetry(llm, model, system, prompt, signal, traceCb, toolBudget) {
229
+ /**
230
+ * Pick a debate fallback model on a DIFFERENT provider than `failedModel`.
231
+ *
232
+ * When a participant's model fails BOTH same-model attempts (e.g. opencode-go
233
+ * proxy overload "Upstream request failed", or a param-reject that survives the
234
+ * adapter-level degrade), retrying the same endpoint just fails again — live
235
+ * session renders showed the same speaker dropped 3× in a row, silently
236
+ * shrinking the debate. Falling back to any pooled council model on another
237
+ * provider keeps that voice in the discussion. Returns undefined when every
238
+ * pooled model resolves to the same provider (nothing to fall back to).
239
+ */
240
+ function pickDebateFallbackModel(failedModel, pool) {
241
+ let failedProvider;
242
+ try {
243
+ failedProvider = detectProviderForModel(failedModel);
244
+ }
245
+ catch {
246
+ failedProvider = undefined;
247
+ }
248
+ for (const candidate of pool) {
249
+ if (candidate === failedModel)
250
+ continue;
251
+ let candidateProvider;
252
+ try {
253
+ candidateProvider = detectProviderForModel(candidate);
254
+ }
255
+ catch {
256
+ continue;
257
+ }
258
+ if (candidateProvider && candidateProvider !== failedProvider)
259
+ return candidate;
260
+ }
261
+ return undefined;
262
+ }
263
+ /**
264
+ * Marker the CouncilLLM.research catch-block embeds in its return string when a
265
+ * provider crashes (it returns a string, never throws — see llm.ts:737). Used
266
+ * to detect a failed research pass so we can fall back to another provider.
267
+ */
268
+ const RESEARCH_FAILED_MARKER = "[Research failed:";
269
+ /**
270
+ * Runs council research on `primaryModel`; if the provider crashes (the
271
+ * `[Research failed: …]` marker), retries ONCE on a pooled model resolving to a
272
+ * DIFFERENT provider before giving up. Mirrors {@link pickDebateFallbackModel}.
273
+ *
274
+ * Motivating evidence: session de4bafe5ecb7 routed research to opencode-go
275
+ * (Console Go "Upstream request failed") with no fallback. Research returned
276
+ * only the failure marker → participants had zero citations to tag → evidence
277
+ * density hard-zeroed → the debate scored "Low confidence 0%" despite being
278
+ * substantive. debateWithRetry already had cross-provider fallback; research
279
+ * did not.
280
+ */
281
+ /**
282
+ * #2 — run the research phase in an ISOLATED explore sub-agent (near-empty
283
+ * context, budget-capped, independent compaction) instead of the in-process
284
+ * 15-step generateText that accretes tool clutter into the council thread.
285
+ * Returns the findings string on success; a `[Research failed: …]` marker string
286
+ * on failure so `researchWithFallback` transparently falls back to `llm.research`.
287
+ */
288
+ async function runResearchIsolated(runIsolatedTask, model, topic, conversationContext, traceCb, options) {
289
+ traceCb(`[research] isolated explore sub-agent via ${model}`);
290
+ // Keep the child near-empty — pass only the question + a bounded context slice
291
+ // (the whole point is to NOT inherit the council's growing transcript).
292
+ const ctxSlice = conversationContext.length > 4000
293
+ ? `${conversationContext.slice(0, 4000)}\n…[context truncated]`
294
+ : conversationContext;
295
+ const sourcePref = options.internetFirst
296
+ ? "This is a greenfield task with little/no local source: PREFER web + documentation sources (use web_search / fetch tools if available), then any local files."
297
+ : "PREFER grounding every claim in THIS repo's code — cite concrete file:line. Use web sources only to fill genuine gaps.";
298
+ const prompt = `You are grounding a council debate with EVIDENCE. Research the question below and return concise, sourced findings.\n\n` +
299
+ `## Question\n${topic}\n\n` +
300
+ `## Debate context\n${ctxSlice}\n\n` +
301
+ `## Instructions\n${sourcePref}\n` +
302
+ `Return a compact "## Research Findings" section: only the concrete facts that bear on the question, each with its source (file:line or URL). ` +
303
+ `Be terse and factual — no opinions, no recommendations. If you cannot find solid evidence, say so in one line.`;
304
+ try {
305
+ const result = await withDeadlineRace(() => runIsolatedTask({
306
+ agent: "explore",
307
+ description: `Council research: ${topic.slice(0, 60)}`,
308
+ prompt,
309
+ // Pin the research-role model; bypasses the parent-tier cap in StreamRunner.
310
+ modelId: model,
311
+ }), getIsolatedTaskDeadlineMs(), "council-research");
312
+ if (result.success && result.output?.trim())
313
+ return result.output.trim();
314
+ traceCb(`[research] isolated sub-agent produced nothing: ${result.error ?? "no output"}`);
315
+ return `[Research failed: ${result.error ?? "isolated sub-agent produced no output"}]`;
316
+ }
317
+ catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err);
319
+ traceCb(`[research] isolated sub-agent threw: ${msg}`);
320
+ return `[Research failed: ${msg}]`;
321
+ }
322
+ }
323
+ export async function researchWithFallback(llm, primaryModel, topic, conversationContext, signal, traceCb, options, fallbackPool, runIsolatedTask) {
324
+ // #2 — prefer the isolated explore sub-agent when the orchestrator wired one.
325
+ // On any failure (marker) we transparently fall through to the legacy
326
+ // in-process research below, preserving resilience for headless/degraded runs.
327
+ if (runIsolatedTask && !signal?.aborted) {
328
+ const iso = await runResearchIsolated(runIsolatedTask, primaryModel, topic, conversationContext, traceCb, options);
329
+ if (!iso.includes(RESEARCH_FAILED_MARKER))
330
+ return iso;
331
+ traceCb("[research] isolated sub-agent unavailable — falling back to in-process research");
332
+ }
333
+ const primary = await llm.research(primaryModel, topic, conversationContext, signal, traceCb, options);
334
+ if (!primary.includes(RESEARCH_FAILED_MARKER) || signal?.aborted)
335
+ return primary;
336
+ const fallbackModel = pickDebateFallbackModel(primaryModel, fallbackPool);
337
+ if (!fallbackModel)
338
+ return primary;
339
+ traceCb(`[research] ${primaryModel} failed; retrying via ${fallbackModel} (different provider)`);
340
+ try {
341
+ const fb = await llm.research(fallbackModel, topic, conversationContext, signal, traceCb, options);
342
+ if (!fb.includes(RESEARCH_FAILED_MARKER)) {
343
+ traceCb(`[research] recovered via fallback ${fallbackModel}`);
344
+ return fb;
345
+ }
346
+ // Both providers failed — surface the primary's marker so the existing
347
+ // "research produced nothing" rendering still fires.
348
+ return primary;
349
+ }
350
+ catch (err) {
351
+ const msg = err instanceof Error ? err.message : String(err);
352
+ traceCb(`[research] fallback ${fallbackModel} also failed: ${msg}`);
353
+ return primary;
354
+ }
355
+ }
356
+ /**
357
+ * Times every `debateWithRetryInner` exit path — success, retry, fallback and
358
+ * total failure all cost wall-clock, and a slow FAILING speaker is exactly the
359
+ * case worth seeing. Wrapping is why `durationMs` cannot drift out of sync with
360
+ * the four separate returns inside.
361
+ *
362
+ * The number is per-SPEAKER. The only timed council rows today are
363
+ * `debate_complete` (whole debate: 87s..1128s measured) and `council_summary`
364
+ * — aggregates that cannot name the model responsible.
365
+ */
366
+ async function debateWithRetry(llm, model, system, prompt, signal, traceCb, toolBudget, fallbackPool = []) {
367
+ const startedAt = Date.now();
368
+ const r = await debateWithRetryInner(llm, model, system, prompt, signal, traceCb, toolBudget, fallbackPool);
369
+ return { ...r, durationMs: Date.now() - startedAt };
370
+ }
371
+ async function debateWithRetryInner(llm, model, system, prompt, signal, traceCb, toolBudget, fallbackPool = []) {
224
372
  // Respect the circuit breaker — once a model has tripped, it stays
225
373
  // tool-disabled for the rest of this council run.
226
374
  const allowTools = debateAllowsTools(model) && !toolBudget.disabled.has(model);
@@ -250,31 +398,69 @@ async function debateWithRetry(llm, model, system, prompt, signal, traceCb, tool
250
398
  // Retry once — if first attempt was tool-enabled and came back empty, the
251
399
  // reasoning model likely hit the step cap on a tool call without producing
252
400
  // text. Retry WITHOUT tools to guarantee analytical output.
401
+ let retryError;
402
+ let retryToolCalls = [];
253
403
  try {
254
404
  const retry = await llm.debate(model, system, prompt, signal, traceCb, { enableVerificationTools: false });
255
405
  const text = (retry.text ?? "").trim();
256
406
  if (text.length > 0) {
257
407
  return { text: retry.text, toolCalls: retry.toolCalls ?? [], attempts: 2 };
258
408
  }
259
- return {
260
- text: "",
261
- toolCalls: retry.toolCalls ?? [],
262
- failureReason: `provider returned empty completion on both attempts (initial: ${firstError})`,
263
- attempts: 2,
264
- };
409
+ retryToolCalls = retry.toolCalls ?? [];
410
+ retryError = "provider returned empty completion";
265
411
  }
266
412
  catch (err) {
267
- const retryMsg = err instanceof Error ? err.message : String(err);
268
- return {
269
- text: "",
270
- toolCalls: [],
271
- failureReason: `both attempts failed initial: ${firstError}; retry: ${retryMsg}`,
272
- attempts: 2,
273
- };
413
+ retryError = err instanceof Error ? err.message : String(err);
414
+ }
415
+ // Both same-model attempts failed. Before dropping this speaker from the
416
+ // debate, try ONE cross-provider fallback — the primary endpoint is either
417
+ // overloaded (opencode-go "Upstream request failed") or param-rejecting, and
418
+ // a third same-model hit would just fail identically. Skip when the caller
419
+ // aborted (user cancellation must not be papered over by a fallback call).
420
+ if (!signal?.aborted) {
421
+ const fallbackModel = pickDebateFallbackModel(model, fallbackPool);
422
+ if (fallbackModel) {
423
+ try {
424
+ const fb = await llm.debate(fallbackModel, system, prompt, signal, traceCb, {
425
+ enableVerificationTools: false,
426
+ });
427
+ const text = (fb.text ?? "").trim();
428
+ if (text.length > 0) {
429
+ traceCb(`[debate] ${model} failed both attempts; recovered via fallback ${fallbackModel}`);
430
+ return { text: fb.text, toolCalls: fb.toolCalls ?? [], attempts: 3 };
431
+ }
432
+ }
433
+ catch (err) {
434
+ const fbMsg = err instanceof Error ? err.message : String(err);
435
+ traceCb(`[debate] fallback ${fallbackModel} for ${model} also failed: ${fbMsg}`);
436
+ }
437
+ }
274
438
  }
439
+ return {
440
+ text: "",
441
+ toolCalls: retryToolCalls,
442
+ failureReason: `both attempts failed — initial: ${firstError}; retry: ${retryError}`,
443
+ attempts: 2,
444
+ };
275
445
  }
276
446
  export async function* runDebate(spec, config, llm) {
277
447
  const { leaderModelId, participants, conversationContext, signal, debatePlan } = config;
448
+ // C — resume from a prior per-round checkpoint when it matches this debate
449
+ // (same problem statement + panel). On a match we skip research + openings and
450
+ // the already-completed rounds, restoring the accumulated transcript. On any
451
+ // mismatch (spec changed, panel re-resolved) we run fresh and let the stale
452
+ // checkpoint be overwritten by the first new round.
453
+ const resumeCp = config.resumeCheckpoint &&
454
+ checkpointMatches(config.resumeCheckpoint, spec.problemStatement, participants.map((p) => p.model))
455
+ ? config.resumeCheckpoint
456
+ : undefined;
457
+ const resumed = resumeCp !== undefined;
458
+ // Cross-provider fallback pool for debateWithRetry: leader + every
459
+ // participant model, deduped. When a speaker's model fails both same-model
460
+ // attempts, debateWithRetry retries once on the first pooled model whose
461
+ // provider differs, so an overloaded provider (opencode-go) can't silently
462
+ // drop a voice from the debate.
463
+ const fallbackPool = Array.from(new Set([leaderModelId, ...participants.map((p) => p.model)]));
278
464
  // Correlation id for the observe-only council-turn-length telemetry (groups
279
465
  // per-turn length samples by run). sessionId in production; a stable literal
280
466
  // for direct callers/tests that omit runId.
@@ -283,6 +469,12 @@ export async function* runDebate(spec, config, llm) {
283
469
  const leaderNeedsResearch = config.leaderNeedsResearch;
284
470
  const internetFirst = config.internetFirst === true;
285
471
  const costAware = config.costAware === true;
472
+ // Feature B — resolved council debate language. The chosen language IS the
473
+ // debate language (no translate-back pass). "auto" (default) follows the
474
+ // user's brief; "english" is the historical behavior; any other value pins
475
+ // the debate prose to that locale. Threaded into every debate/eval/summary
476
+ // prompt below. config.debateLanguage lets a caller/test override the setting.
477
+ const debateLanguage = config.debateLanguage ?? getCouncilLanguage();
286
478
  const active = [];
287
479
  const exchangeLogs = new Map();
288
480
  const archive = [];
@@ -296,13 +488,32 @@ export async function* runDebate(spec, config, llm) {
296
488
  // Track which models we've already announced as tool-disabled so we don't
297
489
  // emit the same "circuit breaker tripped" message every round.
298
490
  const announcedDisabled = new Set();
491
+ // C — restore accumulated state before any phase runs when resuming.
492
+ if (resumeCp) {
493
+ for (const p of resumeCp.active)
494
+ active.push(p);
495
+ for (const [k, v] of restoreExchangeLogs(resumeCp))
496
+ exchangeLogs.set(k, v);
497
+ for (const a of resumeCp.archive)
498
+ archive.push(a);
499
+ runningSummary = resumeCp.runningSummary;
500
+ researchFindings = resumeCp.researchFindings;
501
+ yield {
502
+ type: "content",
503
+ content: `\n> Resuming debate from round ${resumeCp.roundCount + 1} — restored ${resumeCp.roundCount} completed round(s), ${active.length} participant(s), skipping research + opening statements.\n`,
504
+ };
505
+ }
299
506
  // ── Leader decides: research needed? (skipped if user overrode upstream) ──
300
507
  // Reuse the leader's upstream research decision (computed once in runCouncil)
301
508
  // when available; only run the classifier here for direct callers that did not
302
509
  // pre-compute it. Avoids a duplicate leader-tier LLM call per council run.
303
- const needsResearch = researchSkipOverride
510
+ // On resume the research phase already ran — never re-run it.
511
+ const needsResearch = resumed
304
512
  ? false
305
- : (leaderNeedsResearch ?? (yield* evaluateResearchNeed(spec, leaderModelId, conversationContext, llm, costAware)));
513
+ : researchSkipOverride
514
+ ? false
515
+ : (leaderNeedsResearch ??
516
+ (yield* evaluateResearchNeed(spec, leaderModelId, conversationContext, llm, costAware)));
306
517
  if (researchSkipOverride) {
307
518
  yield {
308
519
  type: "content",
@@ -319,11 +530,16 @@ export async function* runDebate(spec, config, llm) {
319
530
  detail: `via ${researchCandidate.model}`,
320
531
  });
321
532
  const researchTraces = [];
322
- researchFindings = yield* tracedAsync(() => llm.research(researchCandidate.model, spec.problemStatement, conversationContext, signal, (t) => researchTraces.push(t), { internetFirst }), {
533
+ const researchStartedAt = Date.now();
534
+ researchFindings = yield* tracedAsync(() => researchWithFallback(llm, researchCandidate.model, spec.problemStatement, conversationContext, signal, (t) => researchTraces.push(t), { internetFirst }, fallbackPool, config.runIsolatedTask), {
323
535
  phase: "research",
324
536
  label: internetFirst ? "Researching (internet-first)" : "Researching codebase",
325
537
  detail: spec.problemStatement.slice(0, 80),
326
538
  role: "research",
539
+ // Research is a single long generateText (up to ~10 min / 15 tool steps)
540
+ // with a quiet main stream — the phase most easily misread as hung, and
541
+ // the one whose cost the per-round numbers could not account for.
542
+ liveness: councilStreamLivenessReader(),
327
543
  });
328
544
  // CQ-22: emit research tool traces as council_status
329
545
  for (const trace of researchTraces) {
@@ -336,105 +552,163 @@ export async function* runDebate(spec, config, llm) {
336
552
  startedAt: p0Start,
337
553
  detail: `via ${researchCandidate.model}`,
338
554
  });
555
+ // A failed research call returns a "[Research failed: …]" placeholder once
556
+ // both primary and fallback providers are exhausted. It is a truthy string,
557
+ // so injecting it verbatim (below) would plant a fake "Research Findings"
558
+ // block into every opening/round prompt. Detect it and show a clean status
559
+ // note instead of the raw placeholder.
560
+ const researchFailed = !!researchFindings && researchFindings.includes(RESEARCH_FAILED_MARKER);
339
561
  yield {
340
562
  type: "council_message",
341
563
  councilMessage: {
342
564
  kind: "research",
343
565
  speaker: { role: researchCandidate.role, model: researchCandidate.model },
344
- text: researchFindings ?? "",
566
+ text: researchFailed
567
+ ? "⚠ Research unavailable — both providers failed. The council proceeds on the panel's own knowledge (no external findings injected)."
568
+ : (researchFindings ?? ""),
569
+ // Spans researchWithFallback including any fallback provider attempt.
570
+ // Measured per-speaker debate turns summed to only ~3.7 min against a
571
+ // ~507s historical `debate_complete`, so the balance is here.
572
+ durationMs: Date.now() - researchStartedAt,
345
573
  },
346
574
  };
347
575
  }
348
- const enrichedContext = researchFindings
576
+ // Only enrich when research actually produced findings — never fold the
577
+ // "[Research failed: …]" placeholder into the debate context.
578
+ const enrichedContext = researchFindings && !researchFindings.includes(RESEARCH_FAILED_MARKER)
349
579
  ? `${conversationContext}\n\n---\n\n## Research Findings\n${researchFindings}`
350
580
  : conversationContext;
351
581
  // ── Phase 1: Parallel opening statements ───────────────────────────────────
352
- const p1Start = Date.now();
353
- yield phaseStart({
354
- phaseId: "phase:opening",
355
- kind: "opening",
356
- label: "Opening analysis",
357
- detail: `${participants.length} participants in parallel`,
358
- });
359
- const openingPromises = participants.map((self) => {
360
- const partner = participants.find((c) => c.role !== self.role) ?? participants[0];
361
- const { system, prompt } = buildOpeningPrompt({
362
- speakerRole: self.role,
363
- partnerRole: partner.role,
364
- speakerStance: self.stance,
365
- partnerStance: partner.stance,
366
- spec,
367
- outputShape: debatePlan?.outputShape,
368
- conversationContext: enrichedContext,
582
+ // Skipped entirely on resume — `active` was restored from the checkpoint so
583
+ // re-running openings would both waste tokens and reset the debated positions.
584
+ if (!resumed) {
585
+ const p1Start = Date.now();
586
+ yield phaseStart({
587
+ phaseId: "phase:opening",
588
+ kind: "opening",
589
+ label: "Opening analysis",
590
+ detail: `${participants.length} participants in parallel`,
369
591
  });
370
- return openingWithRetry(llm, self.model, system, prompt).then((r) => ({
371
- role: self.role,
372
- model: self.model,
373
- stance: self.stance,
374
- position: r.text,
375
- error: r.text ? null : (r.error ?? "empty completion after retries"),
376
- attempts: r.attempts,
377
- }));
378
- });
379
- const openings = yield* tracedAsync(() => Promise.all(openingPromises), {
380
- phase: "opening",
381
- label: `Generating opening statements (${participants.length} participants in parallel)`,
382
- detail: participants.map((p) => p.role).join(", "),
383
- });
384
- yield { type: "content", content: "\n── Opening Analysis ──\n" };
385
- for (const o of openings) {
386
- const speakerRole = o.stance?.name ?? o.role;
387
- if (o.error) {
388
- yield {
389
- type: "council_message",
390
- councilMessage: {
391
- kind: "debate",
392
- speaker: { role: speakerRole, model: o.model },
393
- round: 0,
394
- text: `[Error: ${o.error}]`,
395
- attempts: o.attempts,
396
- failureReason: o.error,
397
- },
398
- };
592
+ // Sprint-2 item 3 — per-stance recall. Fire one stance-weighted recall per
593
+ // unique role before openings; fold each role's seed into that participant's
594
+ // opening context so every stance opens grounded in the experience its lens
595
+ // cares about. Bounded + failure-tolerant inside the injected fn; a null/empty
596
+ // result leaves openings unchanged.
597
+ let stanceSeeds = null;
598
+ if (config.stanceRecall) {
599
+ try {
600
+ const roles = Array.from(new Set(participants.map((p) => p.role)));
601
+ stanceSeeds = await config.stanceRecall(roles, spec.problemStatement);
602
+ }
603
+ catch {
604
+ stanceSeeds = null;
605
+ }
399
606
  }
400
- else {
401
- active.push({ role: o.role, model: o.model, position: o.position, stance: o.stance });
402
- archive.push({
403
- round: 0,
404
- role: o.role,
405
- model: o.model,
406
- stanceName: o.stance?.name,
407
- ...makeExcerpt(o.position),
607
+ const openingPromises = participants.map((self) => {
608
+ const partner = participants.find((c) => c.role !== self.role) ?? participants[0];
609
+ const seed = stanceSeeds?.get(self.role)?.trim();
610
+ const selfContext = seed
611
+ ? `${enrichedContext}\n\n---\n\n## Experience recall — ${self.stance?.name ?? self.role} lens\n${seed}`
612
+ : enrichedContext;
613
+ const { system, prompt } = buildOpeningPrompt({
614
+ speakerRole: self.role,
615
+ partnerRole: partner.role,
616
+ speakerStance: self.stance,
617
+ partnerStance: partner.stance,
618
+ spec,
619
+ outputShape: debatePlan?.outputShape,
620
+ conversationContext: selfContext,
621
+ language: debateLanguage,
408
622
  });
409
- yield {
410
- type: "council_message",
411
- councilMessage: {
412
- kind: "debate",
413
- speaker: { role: speakerRole, model: o.model },
623
+ const startedAt = Date.now();
624
+ return openingWithRetry(llm, self.model, system, prompt).then((r) => ({
625
+ role: self.role,
626
+ model: self.model,
627
+ stance: self.stance,
628
+ position: r.text,
629
+ error: r.text ? null : (r.error ?? "empty completion after retries"),
630
+ attempts: r.attempts,
631
+ durationMs: Date.now() - startedAt,
632
+ }));
633
+ });
634
+ const openings = yield* tracedAsync(() => Promise.all(openingPromises), {
635
+ phase: "opening",
636
+ label: `Generating opening statements (${participants.length} participants in parallel)`,
637
+ // Newline-joined "Name — lens" roster so the composing placeholder shows WHAT
638
+ // each speaker is tasked to argue (A: live debate preview) instead of a bare
639
+ // spinner during the atomic generateText window.
640
+ detail: formatSpeakerRoster(participants),
641
+ // Promise.all does not pump this generator, so `elapsedMs` freezes while
642
+ // the openings run. Push-based stream counters keep the heartbeat honest.
643
+ liveness: councilStreamLivenessReader(),
644
+ });
645
+ yield { type: "content", content: "\n── Opening Analysis ──\n" };
646
+ for (const o of openings) {
647
+ const speakerRole = o.stance?.name ?? o.role;
648
+ if (o.error) {
649
+ yield {
650
+ type: "council_message",
651
+ councilMessage: {
652
+ kind: "debate",
653
+ speaker: { role: speakerRole, model: o.model },
654
+ round: 0,
655
+ text: `[Error: ${o.error}]`,
656
+ attempts: o.attempts,
657
+ failureReason: o.error,
658
+ durationMs: o.durationMs,
659
+ },
660
+ };
661
+ }
662
+ else {
663
+ active.push({ role: o.role, model: o.model, position: o.position, stance: o.stance });
664
+ archive.push({
665
+ round: 0,
666
+ role: o.role,
667
+ model: o.model,
668
+ stanceName: o.stance?.name,
669
+ ...makeExcerpt(o.position),
670
+ });
671
+ yield {
672
+ type: "council_message",
673
+ councilMessage: {
674
+ kind: "debate",
675
+ speaker: { role: speakerRole, model: o.model },
676
+ round: 0,
677
+ text: o.position,
678
+ attempts: o.attempts,
679
+ durationMs: o.durationMs,
680
+ },
681
+ };
682
+ emitCouncilTurnLength({
683
+ role: speakerRole,
414
684
  round: 0,
415
685
  text: o.position,
416
- attempts: o.attempts,
417
- },
418
- };
419
- emitCouncilTurnLength({
420
- role: speakerRole,
421
- round: 0,
422
- text: o.position,
423
- model: o.model,
424
- correlationId: turnCorrelationId,
425
- });
686
+ model: o.model,
687
+ correlationId: turnCorrelationId,
688
+ });
689
+ }
426
690
  }
691
+ yield phaseDone({
692
+ phaseId: "phase:opening",
693
+ kind: "opening",
694
+ label: "Opening analysis",
695
+ startedAt: p1Start,
696
+ detail: `${active.length}/${participants.length} participants succeeded`,
697
+ });
427
698
  }
428
- yield phaseDone({
429
- phaseId: "phase:opening",
430
- kind: "opening",
431
- label: "Opening analysis",
432
- startedAt: p1Start,
433
- detail: `${active.length}/${participants.length} participants succeeded`,
434
- });
435
699
  if (active.length < 2) {
436
700
  yield { type: "content", content: "\nNot enough successful openings for discussion.\n" };
437
- return { spec, exchangeLogs, runningSummary: "", roundCount: 0, researchFindings, active, archive };
701
+ // Even a single-opening debate is worth persisting (F9) a deterministic
702
+ // synthesis of whatever position survived beats an empty research artifact.
703
+ return {
704
+ spec,
705
+ exchangeLogs,
706
+ runningSummary: resolveDebateSummary({ runningSummary: "", active, archive }),
707
+ roundCount: 0,
708
+ researchFindings,
709
+ active,
710
+ archive,
711
+ };
438
712
  }
439
713
  // ── Phase 2: Dynamic discussion rounds ─────────────────────────────────────
440
714
  // Leader-decided round budget: planner proposes an initial value, leader
@@ -458,6 +732,16 @@ export async function* runDebate(spec, config, llm) {
458
732
  type: "content",
459
733
  content: `\n> Leader-proposed debate budget: ${maxRounds} round${maxRounds === 1 ? "" : "s"}${ceilingNote}.\n`,
460
734
  };
735
+ // P3 — structured budget/ceiling for the context rail. These are locals here,
736
+ // invisible to the council entrypoint, so they ride a separate council_meta
737
+ // patch that the UI upsert-merges with the leader/panel patch.
738
+ yield {
739
+ type: "council_meta",
740
+ councilMeta: {
741
+ roundBudget: maxRounds,
742
+ roundCeiling: kindCapped ? effectiveCeiling : ABSOLUTE_MAX_ROUNDS,
743
+ },
744
+ };
461
745
  // Pairs that fail twice in a row are dropped from subsequent rounds so the
462
746
  // remaining participants don't keep retrying a broken model and inflating
463
747
  // the persistent transcript with failure noise.
@@ -466,7 +750,74 @@ export async function* runDebate(spec, config, llm) {
466
750
  // Stop debate entirely after two consecutive rounds where ≥50% of pairs fail
467
751
  // — the LLM is clearly under provider stress and more rounds won't help.
468
752
  let consecutiveRoundFailures = 0;
469
- for (let round = 1; round <= maxRounds; round++) {
753
+ // P5 topic carried from the prior round's leader nextRoundFocus, shown as the
754
+ // next round's heading in the round-grouped transcript.
755
+ let nextTopic;
756
+ // B5 — prior round's aligned criteriaMet, so each round's directive/verdict
757
+ // and the post-debate unmet-flag know what is still open. Empty before round 1
758
+ // → the round-1 directive treats every criterion as unmet.
759
+ let lastCriteriaMet = [];
760
+ // B4 — leader auto-remedy progress tracking. `bestCriteriaMetCount` is the
761
+ // high-water mark of pinned criteria met; `roundsSinceProgress` counts
762
+ // consecutive evaluated rounds that produced no NEW met criterion. Auto-extend
763
+ // fires only while progress is being made; a stuck criterion (no progress for
764
+ // 2 rounds) stops the budget burn and drops to a diagnostic closing verdict.
765
+ let bestCriteriaMetCount = 0;
766
+ let roundsSinceProgress = 0;
767
+ // B4 interactive escalation — fires at most once per debate. `escalation`
768
+ // records the user's choice at a stop-with-unmet boundary for the DebateState.
769
+ let escalated = false;
770
+ let escalation;
771
+ // C — restore round-loop continuity from the checkpoint. `maxRounds` takes the
772
+ // checkpointed budget (which may have been extended past the plan). The within-
773
+ // run heuristics that reset safely (droppedPairKeys, consecutiveRoundFailures,
774
+ // consecutivePairFailures, escalated) are intentionally NOT restored — a fresh
775
+ // start for them at most re-tries a previously-dropped pair, never corrupts
776
+ // state. The loop start is derived below from `roundCount`.
777
+ if (resumeCp) {
778
+ roundCount = resumeCp.roundCount;
779
+ maxRounds = Math.max(maxRounds, resumeCp.maxRounds);
780
+ lastCriteriaMet = [...resumeCp.lastCriteriaMet];
781
+ bestCriteriaMetCount = resumeCp.bestCriteriaMetCount;
782
+ roundsSinceProgress = resumeCp.roundsSinceProgress;
783
+ nextTopic = resumeCp.nextTopic;
784
+ }
785
+ // Shared applier for the two stop-with-unmet boundaries (leader voluntarily
786
+ // stopped, or the budget exhausted while stuck/at ceiling). Reassigns the
787
+ // loop's round budget via closure; returns whether the debate should keep
788
+ // going. Caller must have already checked the gate (responder wired, flag on,
789
+ // not yet escalated, criteria unmet).
790
+ async function* escalateStop(stuck, pinnedUnmet, openList) {
791
+ escalated = true;
792
+ // convene_council path: no interactive user is answering mid-tool-call, so a
793
+ // blocking escalation card would hang the council. Auto-accept (= conclude
794
+ // with the best synthesis so far) without emitting the card. Mirrors the
795
+ // "empty/failed answer → accept" fallback runEscalationPrompt already uses.
796
+ if (config.convenePath) {
797
+ escalation = { action: "accept" };
798
+ return "stop";
799
+ }
800
+ const dec = yield* runEscalationPrompt({
801
+ respondToQuestion: config.respondToQuestion,
802
+ openCriteria: openList,
803
+ pinnedUnmet,
804
+ stuck,
805
+ atAbsoluteMax: maxRounds >= ABSOLUTE_MAX_ROUNDS,
806
+ currentMax: maxRounds,
807
+ });
808
+ escalation = { action: dec.action, grantedRounds: dec.grantedRounds || undefined };
809
+ if (dec.action === "extend" && dec.grantedRounds > 0) {
810
+ maxRounds += dec.grantedRounds;
811
+ roundsSinceProgress = 0;
812
+ if (!nextTopic)
813
+ nextTopic = `Close the unmet criteria: ${openList.join("; ")}`;
814
+ return "extend";
815
+ }
816
+ return "stop";
817
+ }
818
+ // C — on resume, `roundCount` was seeded to the last completed round so the
819
+ // loop continues at roundCount+1; a fresh run starts at 1 (roundCount=0).
820
+ for (let round = roundCount + 1; round <= maxRounds; round++) {
470
821
  // User cancelled mid-debate — stop before spending another round of
471
822
  // parallel pair LLM calls. The caller (runCouncil) re-checks the signal at
472
823
  // its next phase boundary and skips synthesis too.
@@ -475,6 +826,10 @@ export async function* runDebate(spec, config, llm) {
475
826
  break;
476
827
  }
477
828
  roundCount = round;
829
+ // Set when the user extends the debate at this round's stop boundary (B4
830
+ // escalation). Guards the convergence-exit below so a user "extend" isn't
831
+ // immediately overridden by a lock-phrase convergence break.
832
+ let userExtendedThisRound = false;
478
833
  const p2Start = Date.now();
479
834
  const roundPhaseId = `phase:round-${round}`;
480
835
  yield phaseStart({
@@ -510,6 +865,50 @@ export async function* runDebate(spec, config, llm) {
510
865
  roundCount = round - 1;
511
866
  break;
512
867
  }
868
+ // P5 — round lifecycle for the grouped transcript. `roundRec` closes over
869
+ // this round's participants/pairCount/emergent/topic; a running record now,
870
+ // a guaranteed done record on every exit below.
871
+ // Surface the task-adaptive persona (or model id), never the internal
872
+ // implement/verify/research cost-tier slot — that slot is a routing detail
873
+ // that misleads on analysis/decision topics (observed session dd34c59c63e9:
874
+ // an "evaluation" debate showed a bogus "implement" member).
875
+ const roundParticipants = active.map((p) => p.stance?.name ?? p.model);
876
+ const roundEmergent = round > plannedMaxRounds;
877
+ const roundTopic = nextTopic;
878
+ const roundRec = (state, patch = {}) => ({
879
+ type: "council_round",
880
+ councilRound: {
881
+ round,
882
+ state,
883
+ topic: roundTopic,
884
+ participants: roundParticipants,
885
+ pairCount: pairs.length,
886
+ emergent: roundEmergent,
887
+ ...patch,
888
+ },
889
+ });
890
+ yield roundRec("running");
891
+ // B5 — pre-round leader DIRECTIVE. Before the exchanges run, the leader
892
+ // states this round's goal and which pinned criteria are still unmet, so it
893
+ // visibly conducts each round instead of only grading afterwards. Gated on
894
+ // pinned criteria (nothing to steer toward otherwise) + the conductor flag.
895
+ // Captured in `roundDirective` so it also lands on the round record's
896
+ // `directive` field below — durable in the conclusion card, not only the
897
+ // ephemeral live bubble a user misses if they look away mid-debate.
898
+ let roundDirective;
899
+ if (leaderConductorEnabled() && spec.successCriteria.length > 0) {
900
+ roundDirective = buildLeaderDirective(round, spec.successCriteria, lastCriteriaMet, roundTopic);
901
+ yield {
902
+ type: "council_message",
903
+ councilMessage: {
904
+ kind: "leader",
905
+ phase: "directive",
906
+ speaker: { role: "Leader", model: leaderModelId },
907
+ round,
908
+ text: roundDirective,
909
+ },
910
+ };
911
+ }
513
912
  const pairResults = yield* tracedAsync(() => Promise.all(pairs.map(async ({ a, b, key }) => {
514
913
  const log = exchangeLogs.get(key);
515
914
  const chunks = [];
@@ -529,9 +928,10 @@ export async function* runDebate(spec, config, llm) {
529
928
  speakerPosition: a.position,
530
929
  partnerPosition: b.position,
531
930
  spec,
931
+ language: debateLanguage,
532
932
  });
533
933
  const aTraces = [];
534
- const aResult = await debateWithRetry(llm, a.model, aPrompt.system, aPrompt.prompt, signal, (t) => aTraces.push(t), toolBudget);
934
+ const aResult = await debateWithRetry(llm, a.model, aPrompt.system, aPrompt.prompt, signal, (t) => aTraces.push(t), toolBudget, fallbackPool);
535
935
  aResponse = aResult.text;
536
936
  aToolCalls = aResult.toolCalls;
537
937
  log.push(`[${aLabel}]: ${aResponse}`);
@@ -542,6 +942,7 @@ export async function* runDebate(spec, config, llm) {
542
942
  traces: aTraces,
543
943
  failureReason: aResult.failureReason,
544
944
  attempts: aResult.attempts,
945
+ durationMs: aResult.durationMs,
545
946
  });
546
947
  const bPrompt = buildResponsePrompt({
547
948
  speakerRole: b.role,
@@ -551,9 +952,10 @@ export async function* runDebate(spec, config, llm) {
551
952
  speakerPosition: b.position,
552
953
  partnerPosition: aResponse,
553
954
  spec,
955
+ language: debateLanguage,
554
956
  });
555
957
  const bTraces = [];
556
- const bResult = await debateWithRetry(llm, b.model, bPrompt.system, bPrompt.prompt, signal, (t) => bTraces.push(t), toolBudget);
958
+ const bResult = await debateWithRetry(llm, b.model, bPrompt.system, bPrompt.prompt, signal, (t) => bTraces.push(t), toolBudget, fallbackPool);
557
959
  bResponse = bResult.text;
558
960
  bToolCalls = bResult.toolCalls;
559
961
  log.push(`[${bLabel}]: ${bResponse}`);
@@ -564,6 +966,7 @@ export async function* runDebate(spec, config, llm) {
564
966
  traces: bTraces,
565
967
  failureReason: bResult.failureReason,
566
968
  attempts: bResult.attempts,
969
+ durationMs: bResult.durationMs,
567
970
  });
568
971
  }
569
972
  else {
@@ -581,9 +984,10 @@ export async function* runDebate(spec, config, llm) {
581
984
  round,
582
985
  runningSummary,
583
986
  spec,
987
+ language: debateLanguage,
584
988
  });
585
989
  const aTraces = [];
586
- const aResult = await debateWithRetry(llm, a.model, aPrompt.system, aPrompt.prompt, signal, (t) => aTraces.push(t), toolBudget);
990
+ const aResult = await debateWithRetry(llm, a.model, aPrompt.system, aPrompt.prompt, signal, (t) => aTraces.push(t), toolBudget, fallbackPool);
587
991
  aResponse = aResult.text;
588
992
  aToolCalls = aResult.toolCalls;
589
993
  log.push(`[${aLabel}] (round ${round}): ${aResponse}`);
@@ -594,6 +998,7 @@ export async function* runDebate(spec, config, llm) {
594
998
  traces: aTraces,
595
999
  failureReason: aResult.failureReason,
596
1000
  attempts: aResult.attempts,
1001
+ durationMs: aResult.durationMs,
597
1002
  });
598
1003
  const bPrompt = buildFollowupPrompt({
599
1004
  speakerRole: b.role,
@@ -605,9 +1010,10 @@ export async function* runDebate(spec, config, llm) {
605
1010
  round,
606
1011
  runningSummary,
607
1012
  spec,
1013
+ language: debateLanguage,
608
1014
  });
609
1015
  const bTraces = [];
610
- const bResult = await debateWithRetry(llm, b.model, bPrompt.system, bPrompt.prompt, signal, (t) => bTraces.push(t), toolBudget);
1016
+ const bResult = await debateWithRetry(llm, b.model, bPrompt.system, bPrompt.prompt, signal, (t) => bTraces.push(t), toolBudget, fallbackPool);
611
1017
  bResponse = bResult.text;
612
1018
  bToolCalls = bResult.toolCalls;
613
1019
  log.push(`[${bLabel}] (round ${round}): ${bResponse}`);
@@ -618,6 +1024,7 @@ export async function* runDebate(spec, config, llm) {
618
1024
  traces: bTraces,
619
1025
  failureReason: bResult.failureReason,
620
1026
  attempts: bResult.attempts,
1027
+ durationMs: bResult.durationMs,
621
1028
  });
622
1029
  }
623
1030
  // Only update positions when response is non-empty (avoid clearing on LLM error)
@@ -633,7 +1040,14 @@ export async function* runDebate(spec, config, llm) {
633
1040
  })), {
634
1041
  phase: "exchange",
635
1042
  label: `Discussion round ${round} (${pairs.length} pair${pairs.length === 1 ? "" : "s"})`,
636
- detail: pairs.map((p) => `${p.a.role}↔${p.b.role}`).join(", "),
1043
+ // Distinct speakers with their lens (A: live debate preview) — dedup by
1044
+ // formatted line so a speaker appearing in two pairs shows once.
1045
+ detail: formatSpeakerRoster(pairs.flatMap((p) => [p.a, p.b])),
1046
+ // THE case this exists for: each pair turn can run to the 5-min council
1047
+ // ceiling on a reasoning model, and Promise.all never pumps this
1048
+ // generator — `elapsedMs` freezes and a healthy 15-min round looks hung.
1049
+ // These counters come off the token stream, so they keep advancing.
1050
+ liveness: councilStreamLivenessReader(),
637
1051
  });
638
1052
  yield { type: "content", content: `\n── Round ${round} ──\n` };
639
1053
  // Track failures so the circuit breaker can fire after this round.
@@ -698,6 +1112,7 @@ export async function* runDebate(spec, config, llm) {
698
1112
  text: chunk.text.trim(),
699
1113
  toolCalls: chunk.toolCalls?.map((t) => ({ name: t.toolName })),
700
1114
  attempts: chunk.attempts,
1115
+ durationMs: chunk.durationMs,
701
1116
  },
702
1117
  };
703
1118
  emitCouncilTurnLength({
@@ -769,6 +1184,11 @@ export async function* runDebate(spec, config, llm) {
769
1184
  type: "content",
770
1185
  content: `\n> Circuit breaker: aborting debate after ${consecutiveRoundFailures} consecutive failure-heavy rounds — proceeding to synthesis with what we have.\n`,
771
1186
  };
1187
+ yield roundRec("done", {
1188
+ leaderDecision: "circuit-break",
1189
+ leaderReason: "provider stress — circuit breaker",
1190
+ directive: roundDirective,
1191
+ });
772
1192
  break;
773
1193
  }
774
1194
  }
@@ -784,13 +1204,67 @@ export async function* runDebate(spec, config, llm) {
784
1204
  label: `Leader evaluation (round ${round})`,
785
1205
  });
786
1206
  const allExchangeText = [...exchangeLogs.values()].flat().slice(-8).join("\n\n");
787
- const evaluation = yield* evaluateDebate(spec, allExchangeText, round, leaderModelId, llm, costAware);
1207
+ let evaluation = yield* evaluateDebate(spec, allExchangeText, round, leaderModelId, llm, costAware, undefined, debateLanguage);
1208
+ // Eval robustness: the leader's cost-tier eval model can be on a flaky proxy
1209
+ // (Console Go glm/kimi → "Upstream request failed") while panel models on
1210
+ // other providers stay healthy. Rather than surface "evaluation unavailable"
1211
+ // and lose the round outcome, retry the eval on each healthy model in the
1212
+ // fallback pool before giving up. Bounded (pool is small) and only runs on the
1213
+ // failure path, so successful evals pay nothing.
1214
+ if (!evaluation) {
1215
+ // Which model the primary eval already tried (skip re-hitting it). Defensive
1216
+ // against a partially-mocked leader module in tests — if we can't resolve
1217
+ // it, skip the fallback loop rather than throw.
1218
+ let firstTried = null;
1219
+ try {
1220
+ firstTried = pickCouncilTaskModel("evaluate_round", leaderModelId, costAware);
1221
+ }
1222
+ catch {
1223
+ firstTried = null;
1224
+ }
1225
+ if (firstTried) {
1226
+ for (const fallbackModel of fallbackPool) {
1227
+ if (fallbackModel === firstTried)
1228
+ continue;
1229
+ evaluation = yield* evaluateDebate(spec, allExchangeText, round, leaderModelId, llm, costAware, fallbackModel, debateLanguage);
1230
+ if (evaluation)
1231
+ break;
1232
+ }
1233
+ }
1234
+ }
788
1235
  if (evaluation) {
789
1236
  if (typeof evaluation.evidenceDensity === "number") {
790
1237
  lastEvidenceDensity = evaluation.evidenceDensity;
791
1238
  }
792
1239
  const metCount = evaluation.criteriaStatus.filter((c) => c.met).length;
793
1240
  const total = evaluation.criteriaStatus.length;
1241
+ // B2/B3: project this round's evaluation onto the PINNED spec criteria
1242
+ // (index-aligned, best-effort text match as a fallback) and push it to the
1243
+ // rail so the user sees live ✓/○ against the exact outcome they saw — not
1244
+ // an opaque "N/M". Only when the spec has real pinned criteria.
1245
+ const hasPinned = spec.successCriteria.length > 0;
1246
+ const aligned = hasPinned ? alignCriteriaMet(spec.successCriteria, evaluation.criteriaStatus) : [];
1247
+ // Count of pinned criteria still open this round — used by both auto-remedy
1248
+ // and the interactive escalation boundaries below.
1249
+ const pinnedUnmet = hasPinned ? aligned.filter((m) => !m).length : 0;
1250
+ if (hasPinned) {
1251
+ yield {
1252
+ type: "council_meta",
1253
+ councilMeta: { criteriaMet: aligned },
1254
+ };
1255
+ lastCriteriaMet = aligned; // B5: feed next round's directive + final unmet-flag
1256
+ // B4: track progress against the PINNED criteria. A round that meets a new
1257
+ // criterion resets the stuck counter; a round that meets nothing new
1258
+ // increments it. Auto-remedy reads these to decide extend-vs-give-up.
1259
+ const pinnedMetNow = aligned.filter(Boolean).length;
1260
+ if (pinnedMetNow > bestCriteriaMetCount) {
1261
+ bestCriteriaMetCount = pinnedMetNow;
1262
+ roundsSinceProgress = 0;
1263
+ }
1264
+ else {
1265
+ roundsSinceProgress++;
1266
+ }
1267
+ }
794
1268
  yield phaseDone({
795
1269
  phaseId: evalPhaseId,
796
1270
  kind: "evaluation",
@@ -798,15 +1272,45 @@ export async function* runDebate(spec, config, llm) {
798
1272
  startedAt: evalStart,
799
1273
  detail: `${metCount}/${total} criteria met · ${evaluation.reason.slice(0, 80)}`,
800
1274
  });
1275
+ // B5: post-round VERDICT. With pinned criteria + conductor on, list each
1276
+ // criterion's ✓/○ and the focus handed to the next round; otherwise fall
1277
+ // back to the plain one-line eval (pre-B5 behavior).
1278
+ const verdictText = leaderConductorEnabled() && hasPinned
1279
+ ? buildLeaderVerdict(spec.successCriteria, aligned, evaluation.reason, evaluation.nextRoundFocus)
1280
+ : `${metCount}/${total} criteria met — ${evaluation.reason}`;
801
1281
  yield {
802
1282
  type: "council_message",
803
1283
  councilMessage: {
804
1284
  kind: "leader",
1285
+ phase: leaderConductorEnabled() && hasPinned ? "verdict" : undefined,
805
1286
  speaker: { role: "Leader", model: leaderModelId },
806
1287
  round,
807
- text: `${metCount}/${total} criteria met — ${evaluation.reason}`,
1288
+ text: verdictText,
1289
+ // Spans every evaluateDebate attempt including fallback models, so a
1290
+ // leader that ate the round is attributable rather than hidden inside
1291
+ // the `debate_complete` aggregate.
1292
+ durationMs: Date.now() - evalStart,
808
1293
  },
809
1294
  };
1295
+ // P5 — guaranteed done record for this round. Decision reflects the
1296
+ // LEADER's intent (extend / continue / stop); a later code-side convergence
1297
+ // override that stops the loop is a separate mechanism and doesn't rewrite
1298
+ // the leader's stated call. Carry the leader's nextRoundFocus to the next
1299
+ // round's topic.
1300
+ const leaderDecision = typeof evaluation.extendRounds === "number" && evaluation.extendRounds > 0
1301
+ ? "extend"
1302
+ : evaluation.shouldContinue
1303
+ ? "continue"
1304
+ : "stop";
1305
+ yield roundRec("done", {
1306
+ criteriaMet: metCount,
1307
+ criteriaTotal: total,
1308
+ leaderReason: evaluation.reason,
1309
+ leaderDecision,
1310
+ nextRoundFocus: evaluation.nextRoundFocus,
1311
+ directive: roundDirective,
1312
+ });
1313
+ nextTopic = evaluation.nextRoundFocus;
810
1314
  if (evaluation.needsResearch && evaluation.researchQuery) {
811
1315
  const midPhaseId = `phase:mid-research-${round}`;
812
1316
  const midStart = Date.now();
@@ -818,7 +1322,7 @@ export async function* runDebate(spec, config, llm) {
818
1322
  });
819
1323
  const researchCandidate = participants.find((c) => c.role === "research") ?? participants[0];
820
1324
  const midTraces = [];
821
- const findings = yield* tracedAsync(() => llm.research(researchCandidate.model, evaluation.researchQuery, enrichedContext, signal, (t) => midTraces.push(t)), {
1325
+ const findings = yield* tracedAsync(() => researchWithFallback(llm, researchCandidate.model, evaluation.researchQuery, enrichedContext, signal, (t) => midTraces.push(t), {}, fallbackPool, config.runIsolatedTask), {
822
1326
  phase: "research",
823
1327
  label: "Mid-debate research",
824
1328
  detail: evaluation.researchQuery.slice(0, 80),
@@ -855,11 +1359,40 @@ export async function* runDebate(spec, config, llm) {
855
1359
  }
856
1360
  }
857
1361
  if (!evaluation.shouldContinue) {
858
- yield {
859
- type: "content",
860
- content: `\n> Leader decided: debate sufficient at round ${round}.\n`,
861
- };
862
- break;
1362
+ // B4 escalation site 1 — the leader is declaring the debate done. If
1363
+ // pinned criteria are still unmet and we have an interactive channel,
1364
+ // ask the user before accepting a partial outcome (the "3/5 → stop,
1365
+ // synthesize as if done" gap). An "extend" cancels the stop and runs
1366
+ // more rounds; accept/rescope confirm it.
1367
+ if (config.respondToQuestion &&
1368
+ leaderEscalationEnabled() &&
1369
+ !escalated &&
1370
+ hasPinned &&
1371
+ pinnedUnmet > 0 &&
1372
+ // Respect the leader's own verdict: if it declared every criterion met,
1373
+ // don't second-guess it with the fuzzy per-criterion alignment (which
1374
+ // can miss a match and show a false unmet). Escalate only on a genuine
1375
+ // stop-with-unmet the leader itself signalled.
1376
+ !evaluation.allCriteriaMet) {
1377
+ const openList = spec.successCriteria.filter((_, i) => !aligned[i]).map((c) => shortCriterion(c, 56));
1378
+ const outcome = yield* escalateStop(roundsSinceProgress >= 2, pinnedUnmet, openList);
1379
+ if (outcome === "extend") {
1380
+ userExtendedThisRound = true;
1381
+ // Fall through — the loop runs the user-granted rounds. Skip the
1382
+ // convergence + auto-remedy stop-logic below (both assume a natural
1383
+ // stop); the inter-round summary still runs.
1384
+ }
1385
+ else {
1386
+ break;
1387
+ }
1388
+ }
1389
+ else {
1390
+ yield {
1391
+ type: "content",
1392
+ content: `\n> Leader decided: debate sufficient at round ${round}.\n`,
1393
+ };
1394
+ break;
1395
+ }
863
1396
  }
864
1397
  // Code-side convergence override. When the latest round had ≥80% of
865
1398
  // pair-turns containing lock phrases ("everything locked", "fully
@@ -875,7 +1408,9 @@ export async function* runDebate(spec, config, llm) {
875
1408
  const lockRatio = convergenceRatio(lastRoundTurns);
876
1409
  const skepticClean = Array.isArray(evaluation.unresolvedPoints) && evaluation.unresolvedPoints.length === 0;
877
1410
  const canExitEarly = (round >= 2 && lockRatio >= 0.8) || (round === 1 && lockRatio >= 0.8 && skepticClean);
878
- if (canExitEarly) {
1411
+ // A user "extend" at this round's stop boundary overrides a convergence
1412
+ // break — the user explicitly asked for more rounds to close open criteria.
1413
+ if (canExitEarly && !userExtendedThisRound) {
879
1414
  const reason = round === 1
880
1415
  ? `round 1 converged early (lock=${Math.round(lockRatio * 100)}%, no unresolved points)`
881
1416
  : `${Math.round(lockRatio * 100)}% of round ${round} turns contained lock phrases`;
@@ -885,24 +1420,60 @@ export async function* runDebate(spec, config, llm) {
885
1420
  };
886
1421
  break;
887
1422
  }
888
- // Leader asked for more rounds and we still have ceiling headroom.
889
- // Both the absolute ceiling AND the kind-specific cap apply — leader
890
- // can't override an implementation_plan cap of 3 by asking for 4.
891
- if (round === maxRounds &&
892
- typeof evaluation.extendRounds === "number" &&
893
- evaluation.extendRounds > 0 &&
894
- maxRounds < effectiveCeiling) {
895
- const requested = Math.max(1, Math.floor(evaluation.extendRounds));
1423
+ // Budget-exhaustion remedy (B4). At the last planned round with ceiling
1424
+ // headroom, two triggers extend the debate:
1425
+ // 1. the leader explicitly asked (extendRounds > 0), OR
1426
+ // 2. auto-remedy pinned criteria are still unmet AND progress is being
1427
+ // made (a new criterion was met within the last 2 rounds).
1428
+ // "Done = all pinned criteria met"; the ceiling is a leader-managed budget,
1429
+ // not a give-up at the initial plan. A stuck criterion (no progress for 2
1430
+ // rounds) fails the guard so we don't burn the ceiling chasing it — the
1431
+ // closing verdict below then escalates it as stuck. Both the absolute
1432
+ // ceiling AND the kind cap still apply (leader can't push an
1433
+ // implementation_plan cap of 3 to 4).
1434
+ const leaderAskedExtend = typeof evaluation.extendRounds === "number" && evaluation.extendRounds > 0;
1435
+ const autoRemedy = leaderAutoRemedyEnabled() && autoRemedyWantsExtend(pinnedUnmet, roundsSinceProgress);
1436
+ if (round === maxRounds && maxRounds < effectiveCeiling && (leaderAskedExtend || autoRemedy)) {
1437
+ const requested = leaderAskedExtend ? Math.max(1, Math.floor(evaluation.extendRounds)) : 1;
896
1438
  const newMax = Math.min(effectiveCeiling, maxRounds + requested);
897
1439
  const grantedExtra = newMax - maxRounds;
898
1440
  if (grantedExtra > 0) {
1441
+ const why = leaderAskedExtend
1442
+ ? "unresolved points remain"
1443
+ : `${pinnedUnmet} pinned criteri${pinnedUnmet === 1 ? "on" : "a"} still unmet`;
899
1444
  yield {
900
1445
  type: "content",
901
- content: `\n> Leader extending debate by ${grantedExtra} round${grantedExtra === 1 ? "" : "s"} (now ${newMax}/${ABSOLUTE_MAX_ROUNDS}) — unresolved points remain.\n`,
1446
+ content: `\n> Leader extending debate by ${grantedExtra} round${grantedExtra === 1 ? "" : "s"} (now ${newMax}/${ABSOLUTE_MAX_ROUNDS}) — ${why}.\n`,
902
1447
  };
903
1448
  maxRounds = newMax;
1449
+ // Steer the extra round at the open criteria when auto-remedy fired and
1450
+ // the leader set no focus of its own.
1451
+ if (autoRemedy && !leaderAskedExtend && !nextTopic) {
1452
+ const openList = spec.successCriteria.filter((_, i) => !aligned[i]).map((c) => shortCriterion(c, 48));
1453
+ nextTopic = `Close the unmet criteria: ${openList.join("; ")}`;
1454
+ }
904
1455
  }
905
1456
  }
1457
+ // B4 escalation site 2 — the leader wanted to keep going but we're at the
1458
+ // last round and auto-remedy couldn't extend (stuck, or the ceiling is
1459
+ // reached). Rather than let the loop exit into a diagnostic-only synthesis,
1460
+ // ask the user. An "extend" bumps maxRounds so the loop continues; accept/
1461
+ // rescope let it exit naturally. `!escalated` already prevents a double-ask
1462
+ // if site 1 fired this round.
1463
+ if (round === maxRounds &&
1464
+ config.respondToQuestion &&
1465
+ leaderEscalationEnabled() &&
1466
+ !escalated &&
1467
+ hasPinned &&
1468
+ !evaluation.allCriteriaMet &&
1469
+ escalationWanted({
1470
+ pinnedUnmet,
1471
+ stuck: roundsSinceProgress >= 2,
1472
+ atCeiling: maxRounds >= effectiveCeiling,
1473
+ })) {
1474
+ const openList = spec.successCriteria.filter((_, i) => !aligned[i]).map((c) => shortCriterion(c, 56));
1475
+ yield* escalateStop(roundsSinceProgress >= 2, pinnedUnmet, openList);
1476
+ }
906
1477
  }
907
1478
  else {
908
1479
  yield phaseDone({
@@ -912,6 +1483,33 @@ export async function* runDebate(spec, config, llm) {
912
1483
  startedAt: evalStart,
913
1484
  detail: "evaluation unavailable — continuing",
914
1485
  });
1486
+ // P5 — eval parse failed: still close the round with a done record so the
1487
+ // grouped transcript never shows a round stuck "running". Do NOT set
1488
+ // leaderReason here — the "eval-unavailable" DECISION_LABEL already conveys
1489
+ // it, and a redundant reason line rendered the message twice on the card
1490
+ // (observed session dd34c59c63e9).
1491
+ yield roundRec("done", { leaderDecision: "eval-unavailable", directive: roundDirective });
1492
+ nextTopic = undefined;
1493
+ // F2 — final-round eval-unavailable must not silently drop an unmet
1494
+ // outcome. The eval failed to parse even after the cross-provider fallback
1495
+ // loop, so there is no fresh criteria status; fall back to the last
1496
+ // successful round's alignment (lastCriteriaMet), treating an empty history
1497
+ // as all-unmet. If this is the last round and we have an interactive
1498
+ // channel with criteria still open, consult the user (same B4 escalation)
1499
+ // instead of proceeding straight to synthesis. stuck=true: a broken eval
1500
+ // gives no progress signal, so the diagnostic frames the criteria as
1501
+ // needing evidence/rescope rather than "more debate".
1502
+ if (round === maxRounds &&
1503
+ config.respondToQuestion &&
1504
+ leaderEscalationEnabled() &&
1505
+ !escalated &&
1506
+ spec.successCriteria.length > 0) {
1507
+ const unmetIdx = spec.successCriteria.map((_, i) => i).filter((i) => lastCriteriaMet[i] !== true);
1508
+ if (unmetIdx.length > 0) {
1509
+ const openList = unmetIdx.map((i) => shortCriterion(spec.successCriteria[i], 56));
1510
+ yield* escalateStop(true, unmetIdx.length, openList);
1511
+ }
1512
+ }
915
1513
  }
916
1514
  // Generate inter-round summary
917
1515
  if (round < maxRounds) {
@@ -924,7 +1522,7 @@ export async function* runDebate(spec, config, llm) {
924
1522
  });
925
1523
  try {
926
1524
  const allEx = [...exchangeLogs.values()].flat().slice(-6).join("\n\n");
927
- const { system, prompt } = buildRoundSummaryPrompt(allEx, spec.problemStatement, round);
1525
+ const { system, prompt } = buildRoundSummaryPrompt(allEx, spec.problemStatement, round, debateLanguage);
928
1526
  // Round summary is mechanical condensation — drop to "fast" tier on the leader's
929
1527
  // provider when cost-aware. Fall back to the first participant's model otherwise
930
1528
  // (matches the pre-cost-aware behavior).
@@ -961,6 +1559,57 @@ export async function* runDebate(spec, config, llm) {
961
1559
  });
962
1560
  }
963
1561
  }
1562
+ // C — snapshot the fully-completed round (transcript + summary persisted) so
1563
+ // a break before synthesis resumes from round+1 instead of round 1. roundCount
1564
+ // was set to `round` at the top of this iteration. Non-fatal on write failure.
1565
+ if (config.checkpointDir) {
1566
+ await writeDebateCheckpoint(config.checkpointDir, buildDebateCheckpoint({
1567
+ problemStatement: spec.problemStatement,
1568
+ roundCount,
1569
+ maxRounds,
1570
+ exchangeLogs,
1571
+ runningSummary,
1572
+ researchFindings,
1573
+ active,
1574
+ archive,
1575
+ lastCriteriaMet,
1576
+ bestCriteriaMetCount,
1577
+ roundsSinceProgress,
1578
+ nextTopic,
1579
+ savedAt: new Date().toISOString(),
1580
+ }));
1581
+ }
1582
+ }
1583
+ // B4 leader remedy: the debate has ended — via leader stop, convergence, or
1584
+ // budget exhaustion (after auto-remedy exhausted the ceiling). If pinned
1585
+ // criteria remain unmet, the leader emits a visible closing verdict that
1586
+ // DIAGNOSES why it stopped and gives an actionable next move, instead of
1587
+ // letting synthesis proceed as if the outcome were fully achieved (the "3/5 →
1588
+ // stop, synthesize as if done" gap). When the user was consulted (B4
1589
+ // escalation) and chose to accept/rescope, the remedy reflects that decision
1590
+ // instead of a generic "re-run" shrug.
1591
+ if (leaderConductorEnabled() && spec.successCriteria.length > 0) {
1592
+ const unmet = spec.successCriteria.filter((_, i) => !lastCriteriaMet[i]);
1593
+ if (unmet.length > 0) {
1594
+ const atCeiling = maxRounds >= effectiveCeiling;
1595
+ const stuck = roundsSinceProgress >= 2;
1596
+ const remedy = escalation?.action === "accept"
1597
+ ? "you accepted these as open — synthesis proceeds with them noted as unresolved."
1598
+ : escalation?.action === "rescope"
1599
+ ? "you asked to narrow the scope — re-run the council on just these criteria with a tighter problem statement."
1600
+ : diagnoseUnmetRemedy({ stuck, atCeiling, effectiveCeiling, roundsSinceProgress });
1601
+ yield {
1602
+ type: "council_message",
1603
+ councilMessage: {
1604
+ kind: "leader",
1605
+ phase: "verdict",
1606
+ speaker: { role: "Leader", model: leaderModelId },
1607
+ text: `Debate ended with ${unmet.length} of ${spec.successCriteria.length} criteri` +
1608
+ `${unmet.length === 1 ? "on" : "a"} still unmet: ${unmet.map((c) => shortCriterion(c, 56)).join("; ")}. ` +
1609
+ `Synthesis notes these as open — ${remedy}`,
1610
+ },
1611
+ };
1612
+ }
964
1613
  }
965
1614
  // Compute cumulative evidence density across the WHOLE debate, not just
966
1615
  // the leader's last per-round evaluation. Citations are concentrated in
@@ -969,12 +1618,112 @@ export async function* runDebate(spec, config, llm) {
969
1618
  // slice (as lastEvidenceDensity does) reports 0.00 even when the debate
970
1619
  // actually produced [CONFIRMED via …] tags earlier — that's the bug
971
1620
  // session ea13da132dec hit despite 2 real web_fetch citations.
972
- const fullExchangeText = [...exchangeLogs.values()].flat().join("\n\n");
1621
+ // #3 grounding-verify. When the debate produced weak evidence density
1622
+ // (debaters barely tagged claims, since each is capped at stepCountIs(2)), run
1623
+ // an isolated explore sub-agent to fact-check the load-bearing claims and emit
1624
+ // authoritative [CONFIRMED]/[REFUTED] tags. Its output is folded into the
1625
+ // density INPUT below (NOT into exchangeLogs, keeping the persisted transcript
1626
+ // clean) and surfaced as a council_message. Fires only when a bridge is wired,
1627
+ // the flag is on, we're not aborting, and grounding is genuinely weak.
1628
+ let groundingVerifyText = "";
1629
+ {
1630
+ const preText = [...exchangeLogs.values()].flat().join("\n\n");
1631
+ if (config.runIsolatedTask &&
1632
+ groundingVerifyEnabled() &&
1633
+ !signal?.aborted &&
1634
+ computeEvidenceDensity(preText) < GROUNDING_VERIFY_THRESHOLD) {
1635
+ const gvStart = Date.now();
1636
+ yield phaseStart({
1637
+ phaseId: "phase:grounding-verify",
1638
+ kind: "grounding_verify",
1639
+ label: "Grounding verification",
1640
+ detail: "verifying claims against evidence",
1641
+ });
1642
+ const gvTraces = [];
1643
+ const gvOut = yield* tracedAsync(() => runGroundingVerify(config.runIsolatedTask, leaderModelId, spec.problemStatement, preText, (t) => gvTraces.push(t)), { phase: "research", label: "Verifying claims against evidence", role: "verify" });
1644
+ for (const t of gvTraces)
1645
+ yield { type: "council_status", content: t };
1646
+ yield phaseDone({
1647
+ phaseId: "phase:grounding-verify",
1648
+ kind: "grounding_verify",
1649
+ label: "Grounding verification",
1650
+ startedAt: gvStart,
1651
+ });
1652
+ // Only fold in when it actually produced verified citations — appending
1653
+ // pure [UNVERIFIED] output would LOWER density, defeating the purpose.
1654
+ if (gvOut && countCitations(gvOut) > 0) {
1655
+ groundingVerifyText = gvOut;
1656
+ yield {
1657
+ type: "council_message",
1658
+ councilMessage: {
1659
+ kind: "research",
1660
+ speaker: { role: "verify", model: leaderModelId },
1661
+ text: gvOut,
1662
+ },
1663
+ };
1664
+ }
1665
+ }
1666
+ }
1667
+ const fullExchangeText = [...exchangeLogs.values()].flat().join("\n\n") + (groundingVerifyText ? `\n\n${groundingVerifyText}` : "");
973
1668
  const cumulativeDensity = computeEvidenceDensity(fullExchangeText);
974
1669
  // Prefer cumulative when it exceeds the leader's last-round measurement —
975
1670
  // we don't want a converged final round (which has fewer fact-claims to
976
1671
  // tag) to wipe out evidence work done earlier in the debate.
977
1672
  const finalEvidenceDensity = Math.max(cumulativeDensity, lastEvidenceDensity ?? 0);
1673
+ // Count of claims participants explicitly TAGGED ([CONFIRMED]/[REFUTED]/
1674
+ // [UNVERIFIED]) across the whole debate. Lets the confidence badge tell
1675
+ // "measured 0% grounding" apart from "no tags emitted → not measurable"
1676
+ // (session de4bafe5ecb7: substantive debate, zero tags → misleading 0%).
1677
+ const finalTaggedClaims = countCitations(fullExchangeText) + countUnverified(fullExchangeText);
1678
+ // F9 root fix — `runningSummary` is only produced as an INTER-round summary
1679
+ // (`if (round < maxRounds)` above), so a debate that ends on its final/only
1680
+ // round, or stops early after round 1, returns an empty summary — and the
1681
+ // /ideal research artifacts (research.md, delegations.md) silently lose the
1682
+ // whole debate. Generate a real closing summary from the full exchange when
1683
+ // none exists; if there were no discussion rounds (openings-only) or the
1684
+ // model call fails, fall back to a deterministic synthesis from the
1685
+ // participants' positions so the returned state is never emptily summarized.
1686
+ if (!runningSummary.trim()) {
1687
+ const exchangeTurns = [...exchangeLogs.values()].flat();
1688
+ if (exchangeTurns.length > 0) {
1689
+ const closeId = "phase:summary-final";
1690
+ const closeStart = Date.now();
1691
+ yield phaseStart({ phaseId: closeId, kind: "summary", label: "Closing summary" });
1692
+ try {
1693
+ const { system, prompt } = buildRoundSummaryPrompt(exchangeTurns.slice(-6).join("\n\n"), spec.problemStatement, roundCount, debateLanguage);
1694
+ const summaryModel = pickCouncilTaskModel("round_summary", leaderModelId, costAware);
1695
+ runningSummary = yield* tracedGenerate(llm, {
1696
+ phase: "summary",
1697
+ label: "Summarizing the debate",
1698
+ modelId: costAware ? summaryModel : active[0].model,
1699
+ system,
1700
+ prompt,
1701
+ maxTokens: 512,
1702
+ });
1703
+ yield phaseDone({ phaseId: closeId, kind: "summary", label: "Closing summary", startedAt: closeStart });
1704
+ }
1705
+ catch {
1706
+ yield phaseDone({
1707
+ phaseId: closeId,
1708
+ kind: "summary",
1709
+ label: "Closing summary",
1710
+ startedAt: closeStart,
1711
+ detail: "skipped",
1712
+ });
1713
+ }
1714
+ }
1715
+ // Deterministic backstop: openings-only debates (no exchange turns) or a
1716
+ // failed/empty closing-summary call still yield a non-empty summary.
1717
+ if (!runningSummary.trim()) {
1718
+ runningSummary = resolveDebateSummary({ runningSummary, active, archive });
1719
+ }
1720
+ }
1721
+ // C — the debate reached synthesis (normal completion or user-cancel fall-
1722
+ // through), so the checkpoint is obsolete. A mid-round THROW never reaches
1723
+ // here, leaving the checkpoint for the loop-driver retry / resume path.
1724
+ if (config.checkpointDir) {
1725
+ await deleteDebateCheckpoint(config.checkpointDir);
1726
+ }
978
1727
  return {
979
1728
  spec,
980
1729
  exchangeLogs,
@@ -984,6 +1733,12 @@ export async function* runDebate(spec, config, llm) {
984
1733
  active,
985
1734
  archive,
986
1735
  finalEvidenceDensity,
1736
+ finalTaggedClaims,
1737
+ escalation,
1738
+ // F1 — expose the last successful round's criteria alignment so the
1739
+ // post-debate card can frame an unmet outcome as provisional. Undefined when
1740
+ // no round eval ever produced a criteria status (card treats as all-unmet).
1741
+ finalCriteriaMet: lastCriteriaMet.length > 0 ? lastCriteriaMet : undefined,
987
1742
  };
988
1743
  }
989
1744
  export async function* evaluateResearchNeed(spec, leaderModelId, conversationContext, llm, costAware = false) {
@@ -1011,21 +1766,39 @@ export async function* evaluateResearchNeed(spec, leaderModelId, conversationCon
1011
1766
  }
1012
1767
  return true;
1013
1768
  }
1014
- async function* evaluateDebate(spec, exchangeText, round, leaderModelId, llm, costAware = false) {
1769
+ async function* evaluateDebate(spec, exchangeText, round, leaderModelId, llm, costAware = false,
1770
+ // When set, evaluate with this exact model instead of the cost-tier pick — used
1771
+ // by the call-site fallback loop to re-run a round eval on a healthy panel model
1772
+ // after the leader's provider rejected the eval payload (Console Go "Upstream
1773
+ // request failed" on glm/kimi; observed session dd34c59c63e9).
1774
+ modelOverride,
1775
+ /** Feature B — resolved council debate language (undefined → English). */
1776
+ debateLanguage) {
1015
1777
  try {
1016
- const { system, prompt } = buildLeaderEvaluationPrompt({ spec, exchangeLogs: exchangeText, round });
1017
- const modelId = pickCouncilTaskModel("evaluate_round", leaderModelId, costAware);
1778
+ const { system, prompt } = buildLeaderEvaluationPrompt({
1779
+ spec,
1780
+ exchangeLogs: exchangeText,
1781
+ round,
1782
+ language: debateLanguage,
1783
+ });
1784
+ const modelId = modelOverride ?? pickCouncilTaskModel("evaluate_round", leaderModelId, costAware);
1018
1785
  const raw = yield* tracedGenerate(llm, {
1019
1786
  phase: "evaluate",
1020
- label: `Leader evaluating round ${round}`,
1787
+ label: modelOverride
1788
+ ? `Leader evaluating round ${round} (fallback: ${modelOverride})`
1789
+ : `Leader evaluating round ${round}`,
1021
1790
  modelId,
1022
1791
  system,
1023
1792
  prompt,
1024
- maxTokens: 1024,
1793
+ // Raised from 1024: nextRoundFocus is now the FIRST schema field, and the
1794
+ // whole eval is parsed by a single JSON.parse that returns null on any
1795
+ // truncation — a tight budget could clip the JSON and null the round's
1796
+ // outcome. 1536 keeps the focus line + criteria array intact.
1797
+ maxTokens: 1536,
1025
1798
  });
1026
- const match = raw.match(/\{[\s\S]*\}/);
1027
- if (match) {
1028
- const parsed = JSON.parse(match[0]);
1799
+ const jsonStr = extractEvalJson(raw);
1800
+ if (jsonStr) {
1801
+ const parsed = JSON.parse(jsonStr);
1029
1802
  const citationCount = countCitations(exchangeText);
1030
1803
  const evidenceDensity = computeEvidenceDensity(exchangeText);
1031
1804
  const disagreementResolved = citationCount;
@@ -1053,15 +1826,372 @@ async function* evaluateDebate(spec, exchangeText, round, leaderModelId, llm, co
1053
1826
  evidenceDensity,
1054
1827
  disagreementResolved,
1055
1828
  extendRounds,
1829
+ nextRoundFocus: typeof parsed.nextRoundFocus === "string" &&
1830
+ parsed.nextRoundFocus.trim()
1831
+ ? parsed.nextRoundFocus.trim()
1832
+ : undefined,
1056
1833
  };
1057
1834
  }
1835
+ // F3a — no JSON object found in the model output. Log a diagnosable snippet
1836
+ // (No-Silent-Catch) before falling through to null so eval-unavailable is
1837
+ // never a black box — earlier this returned null silently and the only signal
1838
+ // was the "evaluation unavailable" round label.
1839
+ console.warn(`[council] round-${round} eval: no JSON object in output from ${modelOverride ?? leaderModelId} ` +
1840
+ `(${raw.length} chars): ${raw.slice(0, 160).replace(/\s+/g, " ")}`);
1058
1841
  }
1059
- catch {
1060
- // Continue debate if evaluation fails
1842
+ catch (err) {
1843
+ // F3a parse or generate failure. Log with context instead of swallowing:
1844
+ // which model, which round, the message. The debate still continues (returns
1845
+ // null → eval-unavailable), but the failure is now diagnosable remotely.
1846
+ console.error(`[council] round-${round} eval failed on ${modelOverride ?? leaderModelId}: ${err?.message}`, { round, stack: err?.stack?.split("\n").slice(0, 3) });
1061
1847
  }
1062
1848
  return null;
1063
1849
  }
1064
- function countCitations(text) {
1850
+ /**
1851
+ * F3b — robustly extract the leader-evaluation JSON object from a model's raw
1852
+ * output. Replaces a greedy `/\{[\s\S]*\}/` match that could swallow prose,
1853
+ * multiple objects, or a trailing partial object into an unparseable span.
1854
+ *
1855
+ * Strategy: strip code fences, then brace-scan and return the LAST fully
1856
+ * balanced top-level `{…}` object (the eval schema is emitted last, after any
1857
+ * chain-of-thought prose). Returns null only when no balanced object exists.
1858
+ * Deterministic, no LLM call. Braces inside string values are rare in the
1859
+ * machine-emitted eval payload; a real tokenizer would be overkill here.
1860
+ */
1861
+ export function extractEvalJson(raw) {
1862
+ if (!raw)
1863
+ return null;
1864
+ const unfenced = raw.replace(/```(?:json)?/gi, "").replace(/```/g, "");
1865
+ let best = null;
1866
+ let depth = 0;
1867
+ let start = -1;
1868
+ for (let i = 0; i < unfenced.length; i++) {
1869
+ const ch = unfenced[i];
1870
+ if (ch === "{") {
1871
+ if (depth === 0)
1872
+ start = i;
1873
+ depth++;
1874
+ }
1875
+ else if (ch === "}") {
1876
+ if (depth > 0) {
1877
+ depth--;
1878
+ if (depth === 0 && start >= 0)
1879
+ best = unfenced.slice(start, i + 1);
1880
+ }
1881
+ }
1882
+ }
1883
+ return best;
1884
+ }
1885
+ /**
1886
+ * Format a speaker list as newline-joined "Name — lens" rows for the composing
1887
+ * placeholder (A: live debate preview). Prefers the concrete `focus`, falls back
1888
+ * to the one-sentence `lens`, then to bare name. Dedups identical rows so a
1889
+ * speaker in multiple pairs renders once. Returns undefined when nothing useful
1890
+ * is available so the UI falls back to label-only.
1891
+ */
1892
+ /**
1893
+ * Project a leader evaluation's `criteriaStatus` onto the PINNED spec criteria,
1894
+ * returning a boolean[] index-aligned to `pinned` (B2/B3). The eval prompt asks
1895
+ * for one entry per criterion in order, so index alignment is the primary path;
1896
+ * when the model drifts (wrong count/order) we fall back to a case-insensitive
1897
+ * substring match either direction, defaulting unmatched criteria to not-met so
1898
+ * a hallucinated "all met" never silently marks an untouched criterion done.
1899
+ */
1900
+ export function alignCriteriaMet(pinned, status) {
1901
+ const aligned = status.length === pinned.length;
1902
+ return pinned.map((crit, i) => {
1903
+ if (aligned)
1904
+ return status[i]?.met === true;
1905
+ const norm = crit.trim().toLowerCase();
1906
+ const hit = status.find((s) => {
1907
+ const sc = (s.criterion ?? "").trim().toLowerCase();
1908
+ return sc.length > 0 && (sc.includes(norm) || norm.includes(sc));
1909
+ });
1910
+ return hit?.met === true;
1911
+ });
1912
+ }
1913
+ /**
1914
+ * B5 leader-conductor visibility. Default ON; opt out with
1915
+ * MUONROI_LEADER_CONDUCTOR=0 (fallback = pre-B5 behavior, no directive/verdict
1916
+ * messages — keeps headless/legacy transcripts unchanged).
1917
+ */
1918
+ export function leaderConductorEnabled() {
1919
+ return process.env.MUONROI_LEADER_CONDUCTOR !== "0";
1920
+ }
1921
+ /**
1922
+ * B4 leader auto-remedy. When pinned criteria remain unmet at the round budget's
1923
+ * end, the leader auto-extends toward them (up to the hard ceiling) instead of
1924
+ * stopping at the initial plan — "done = all criteria met", ceiling is a managed
1925
+ * budget. Default ON under the conductor; opt out with
1926
+ * MUONROI_COUNCIL_AUTO_REMEDY=0 (fallback = only leader-requested extensions).
1927
+ */
1928
+ export function leaderAutoRemedyEnabled() {
1929
+ return leaderConductorEnabled() && process.env.MUONROI_COUNCIL_AUTO_REMEDY !== "0";
1930
+ }
1931
+ /**
1932
+ * #3 — grounding-verify pass. When a debate ends with weak evidence density
1933
+ * (< GROUNDING_VERIFY_THRESHOLD, i.e. debaters barely tagged any [CONFIRMED]/
1934
+ * [REFUTED] claims — the common case since each debater is capped at
1935
+ * stepCountIs(2)), an isolated explore sub-agent verifies the load-bearing
1936
+ * claims against the codebase and emits authoritative tags that raise the
1937
+ * density metric council uses for confidence. Default ON when an isolated-task
1938
+ * bridge is wired; opt out with MUONROI_COUNCIL_GROUNDING_VERIFY=0.
1939
+ */
1940
+ export function groundingVerifyEnabled() {
1941
+ return process.env.MUONROI_COUNCIL_GROUNDING_VERIFY !== "0";
1942
+ }
1943
+ /** Density below which the grounding-verify pass fires (weak grounding). */
1944
+ const GROUNDING_VERIFY_THRESHOLD = 0.3;
1945
+ /**
1946
+ * #3 — run an isolated explore sub-agent that fact-checks the debate's
1947
+ * load-bearing claims and emits `[CONFIRMED via file:line]` / `[REFUTED via …]`
1948
+ * tags (the exact shape `countCitations` recognises). Returns the tagged output
1949
+ * on success, or "" on any failure (caller only folds it in when it actually
1950
+ * raised the citation count).
1951
+ */
1952
+ export async function runGroundingVerify(runIsolatedTask, model, problemStatement, exchangeText, traceCb) {
1953
+ traceCb(`[grounding-verify] isolated explore sub-agent via ${model}`);
1954
+ const claims = exchangeText.length > 6000 ? `${exchangeText.slice(0, 6000)}\n…[truncated]` : exchangeText;
1955
+ const prompt = `You are grounding-checking a council debate — verify its load-bearing FACTUAL claims against evidence.\n\n` +
1956
+ `## Debate question\n${problemStatement}\n\n` +
1957
+ `## Debaters' claims\n${claims}\n\n` +
1958
+ `## Instructions\n` +
1959
+ `Pick the up-to-5 most decision-relevant factual claims and check EACH against THIS repository's code ` +
1960
+ `(grep/read files) and web sources if available. For every claim you check, emit exactly one inline tag in ` +
1961
+ `this precise format:\n` +
1962
+ ` [CONFIRMED via <file:line or URL>] — evidence supports the claim\n` +
1963
+ ` [REFUTED via <file:line or URL>] — evidence contradicts the claim\n` +
1964
+ ` [UNVERIFIED] — you genuinely could not find evidence\n` +
1965
+ `Return a compact "## Grounding Check" list: one line per checked claim, each restating the claim in ≤15 ` +
1966
+ `words followed by its tag. Do NOT restate the whole debate or add opinions. Prefer [CONFIRMED]/[REFUTED] ` +
1967
+ `with a real citation over [UNVERIFIED].`;
1968
+ try {
1969
+ const result = await withDeadlineRace(() => runIsolatedTask({
1970
+ agent: "explore",
1971
+ description: `Council grounding-verify: ${problemStatement.slice(0, 50)}`,
1972
+ prompt,
1973
+ modelId: model,
1974
+ }), getIsolatedTaskDeadlineMs(), "council-grounding-verify");
1975
+ if (result.success && result.output?.trim())
1976
+ return result.output.trim();
1977
+ traceCb(`[grounding-verify] produced nothing: ${result.error ?? "no output"}`);
1978
+ return "";
1979
+ }
1980
+ catch (err) {
1981
+ traceCb(`[grounding-verify] threw: ${err instanceof Error ? err.message : String(err)}`);
1982
+ return "";
1983
+ }
1984
+ }
1985
+ /**
1986
+ * B4: does auto-remedy want to extend the budget this round? True while pinned
1987
+ * criteria remain unmet AND progress is still being made (a new criterion was
1988
+ * met within the last 2 rounds). A stuck debate (roundsSinceProgress ≥ 2) returns
1989
+ * false so the ceiling isn't burned chasing a criterion that isn't moving.
1990
+ */
1991
+ export function autoRemedyWantsExtend(pinnedUnmet, roundsSinceProgress) {
1992
+ return pinnedUnmet > 0 && roundsSinceProgress < 2;
1993
+ }
1994
+ /**
1995
+ * B4: the diagnostic closing-remedy line for a debate that ended with unmet
1996
+ * pinned criteria — distinguishes a stuck criterion (needs evidence/rescope)
1997
+ * from a genuine ceiling hit (needs a higher budget) from an ordinary early
1998
+ * stop, so the leader's final word is an actionable next move, not a shrug.
1999
+ */
2000
+ export function diagnoseUnmetRemedy(opts) {
2001
+ if (opts.stuck) {
2002
+ return (`these made no progress across the last ${opts.roundsSinceProgress} rounds — ` +
2003
+ `they likely need external evidence (research) or a narrower scope, not more debate.`);
2004
+ }
2005
+ if (opts.atCeiling) {
2006
+ return (`the debate hit its ${opts.effectiveCeiling}-round ceiling with these open — ` +
2007
+ `re-run with a higher round budget or split the scope.`);
2008
+ }
2009
+ return `re-run with an extended round budget or a narrower scope to close them.`;
2010
+ }
2011
+ /** Extra rounds a user "extend" grants — can push past effectiveCeiling, never past ABSOLUTE_MAX_ROUNDS. */
2012
+ const ESCALATION_EXTEND_ROUNDS = 2;
2013
+ /**
2014
+ * B4 interactive escalation. When the debate is about to stop with pinned
2015
+ * criteria unmet AND auto-remedy can't help (stuck / at ceiling), hand the
2016
+ * decision to the user instead of silently synthesizing a partial outcome.
2017
+ * Default ON under the conductor; opt out with MUONROI_COUNCIL_ESCALATE=0
2018
+ * (fallback = diagnostic closing verdict only, no askcard).
2019
+ */
2020
+ export function leaderEscalationEnabled() {
2021
+ return leaderConductorEnabled() && process.env.MUONROI_COUNCIL_ESCALATE !== "0";
2022
+ }
2023
+ /**
2024
+ * B4: should we interrupt to ask the user at this stop boundary? True only when
2025
+ * pinned criteria remain unmet AND the leader can no longer self-remedy — it is
2026
+ * stuck (no progress for ≥2 rounds) or has hit the round ceiling. While progress
2027
+ * is still being made under the ceiling, auto-remedy handles it silently and we
2028
+ * don't nag the user.
2029
+ */
2030
+ export function escalationWanted(opts) {
2031
+ return opts.pinnedUnmet > 0 && (opts.stuck || opts.atCeiling);
2032
+ }
2033
+ /**
2034
+ * B4: the three escalation choices. When the debate is already at the absolute
2035
+ * safety ceiling, the "extend" option degrades to a disabled-looking accept
2036
+ * (label says so) — we never let the user push past ABSOLUTE_MAX_ROUNDS.
2037
+ */
2038
+ export function buildEscalationOptions(unmetCount, atAbsoluteMax) {
2039
+ const noun = `${unmetCount} unmet criteri${unmetCount === 1 ? "on" : "a"}`;
2040
+ return [
2041
+ atAbsoluteMax
2042
+ ? {
2043
+ label: "Extend (unavailable — at hard ceiling)",
2044
+ description: `The debate already reached the ${ABSOLUTE_MAX_ROUNDS}-round safety ceiling; more rounds aren't allowed. Picking this accepts the outcome as-is.`,
2045
+ value: "escalate_accept",
2046
+ kind: "choice",
2047
+ }
2048
+ : {
2049
+ label: `Extend ${ESCALATION_EXTEND_ROUNDS} more rounds`,
2050
+ description: `Push past the round budget to keep working the ${noun}.`,
2051
+ value: "escalate_extend",
2052
+ kind: "choice",
2053
+ },
2054
+ {
2055
+ label: "Accept as-is",
2056
+ description: `Proceed to synthesis with the ${noun} noted as open.`,
2057
+ value: "escalate_accept",
2058
+ kind: "choice",
2059
+ },
2060
+ {
2061
+ label: "Narrow the scope",
2062
+ description: "Stop and re-scope — synthesis notes the open criteria for a narrower follow-up.",
2063
+ value: "escalate_rescope",
2064
+ kind: "choice",
2065
+ },
2066
+ ];
2067
+ }
2068
+ /**
2069
+ * B4: emit the escalation askcard and resolve the user's choice. Yields the
2070
+ * council_question chunk (rendered by the same consumer as clarifier/post-debate
2071
+ * askcards), awaits the responder, echoes the choice, and returns the decision.
2072
+ * `grantedRounds` is pre-computed here (bounded by ABSOLUTE_MAX_ROUNDS) so the
2073
+ * caller only mutates loop state. Any unmatched / empty answer is treated as
2074
+ * "accept" — never a silent hang.
2075
+ */
2076
+ export async function* runEscalationPrompt(opts) {
2077
+ const { respondToQuestion, openCriteria, pinnedUnmet, stuck, atAbsoluteMax, currentMax } = opts;
2078
+ const noun = `${pinnedUnmet} criteri${pinnedUnmet === 1 ? "on" : "a"}`;
2079
+ const openList = openCriteria.join("; ");
2080
+ const questionId = randomUUID();
2081
+ yield {
2082
+ type: "council_question",
2083
+ content: `**Debate stalled with ${noun} still unmet.**\n> Open: ${openList}`,
2084
+ councilQuestion: {
2085
+ questionId,
2086
+ // Reuse the existing post-debate phase — same askcard renderer, no new UI.
2087
+ phase: "post-debate",
2088
+ question: `The debate reached its ${stuck ? "progress limit" : "round ceiling"} with ${noun} still unmet. ` +
2089
+ `How do you want to proceed?`,
2090
+ context: `Open criteria: ${openList}`,
2091
+ isRequired: false,
2092
+ options: buildEscalationOptions(pinnedUnmet, atAbsoluteMax),
2093
+ defaultIndex: 0,
2094
+ },
2095
+ };
2096
+ let answer = "";
2097
+ try {
2098
+ answer = (await respondToQuestion(questionId))?.trim() ?? "";
2099
+ }
2100
+ catch (err) {
2101
+ // A failed responder must not hang or crash the debate — treat as accept and
2102
+ // log so a broken UI channel is diagnosable (No-Silent-Catch).
2103
+ console.error(`[council] escalation responder failed — accepting outcome as-is: ${err?.message}`, {
2104
+ questionId,
2105
+ stack: err?.stack?.split("\n").slice(0, 3),
2106
+ });
2107
+ answer = "";
2108
+ }
2109
+ if (answer === "escalate_extend" && !atAbsoluteMax) {
2110
+ const newMax = Math.min(ABSOLUTE_MAX_ROUNDS, currentMax + ESCALATION_EXTEND_ROUNDS);
2111
+ const grantedRounds = Math.max(0, newMax - currentMax);
2112
+ if (grantedRounds > 0) {
2113
+ yield {
2114
+ type: "content",
2115
+ content: `\n> User extended debate by ${grantedRounds} round${grantedRounds === 1 ? "" : "s"} (now ${newMax}/${ABSOLUTE_MAX_ROUNDS}) — pushing past the budget to close the open criteria.\n`,
2116
+ };
2117
+ return { action: "extend", grantedRounds };
2118
+ }
2119
+ // No headroom left even though the option showed — fall through to accept.
2120
+ }
2121
+ if (answer === "escalate_rescope") {
2122
+ yield {
2123
+ type: "content",
2124
+ content: `\n ↳ Narrow the scope — ending the debate; synthesis will note the open criteria for a re-scoped follow-up.\n`,
2125
+ };
2126
+ return { action: "rescope", grantedRounds: 0 };
2127
+ }
2128
+ yield {
2129
+ type: "content",
2130
+ content: `\n ↳ Accepted the current outcome with ${noun} open.\n`,
2131
+ };
2132
+ return { action: "accept", grantedRounds: 0 };
2133
+ }
2134
+ /** One-line criterion label for directive/verdict bodies. */
2135
+ export function shortCriterion(c, max = 64) {
2136
+ const t = c.trim().replace(/\s+/g, " ");
2137
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
2138
+ }
2139
+ /**
2140
+ * B5: build the leader's pre-round DIRECTIVE body — the round goal plus the
2141
+ * criteria still unmet going into this round. `metSoFar` is the prior round's
2142
+ * aligned criteriaMet (empty before round 1 → everything is unmet).
2143
+ */
2144
+ export function buildLeaderDirective(round, criteria, metSoFar, focus) {
2145
+ const pending = criteria.filter((_, i) => !metSoFar[i]);
2146
+ const lines = [];
2147
+ const trimmedFocus = focus?.trim();
2148
+ lines.push(trimmedFocus
2149
+ ? `Focus: ${trimmedFocus}`
2150
+ : round === 1
2151
+ ? "Establish concrete evidence for every outcome criterion."
2152
+ : "Drive the remaining criteria to done.");
2153
+ if (pending.length > 0) {
2154
+ // One criterion per line (bulleted) rather than a single "; "-joined blob.
2155
+ // The joined form produced a ~300-char logical line (5 criteria × ~56 chars)
2156
+ // that word-wrapped into a dense, unreadable block overflowing the round-
2157
+ // summary card and the leader directive bubble — the "loạn/không rõ ràng"
2158
+ // the user reported. A vertical list reads cleanly and wraps per-criterion.
2159
+ lines.push(`Unmet (${pending.length}/${criteria.length}):`);
2160
+ for (const c of pending)
2161
+ lines.push(` • ${shortCriterion(c, 72)}`);
2162
+ }
2163
+ else {
2164
+ lines.push("All criteria met so far — pressure-test the weakest before closing.");
2165
+ }
2166
+ return lines.join("\n");
2167
+ }
2168
+ /**
2169
+ * B5: build the leader's post-round VERDICT body — per-criterion ✓/○ against the
2170
+ * pinned outcome, the leader's reason, and the focus it hands to the next round.
2171
+ */
2172
+ export function buildLeaderVerdict(criteria, met, reason, nextFocus) {
2173
+ const metCount = met.filter(Boolean).length;
2174
+ const lines = [`${metCount}/${criteria.length} criteria met — ${reason.trim()}`];
2175
+ criteria.forEach((c, i) => {
2176
+ lines.push(`${met[i] ? "✓" : "○"} ${shortCriterion(c, 56)}`);
2177
+ });
2178
+ const nf = nextFocus?.trim();
2179
+ if (nf && metCount < criteria.length)
2180
+ lines.push(`→ Next: ${nf}`);
2181
+ return lines.join("\n");
2182
+ }
2183
+ export function formatSpeakerRoster(list) {
2184
+ const rows = [];
2185
+ for (const s of list) {
2186
+ const name = s.stance?.name ?? s.model;
2187
+ const angle = s.stance?.focus?.trim() || s.stance?.lens?.trim();
2188
+ const row = angle ? `${name} — ${angle}` : name;
2189
+ if (row && !rows.includes(row))
2190
+ rows.push(row);
2191
+ }
2192
+ return rows.length > 0 ? rows.join("\n") : undefined;
2193
+ }
2194
+ export function countCitations(text) {
1065
2195
  const matches = text.match(/\[(REFUTED|CONFIRMED) via [^\]]+\]/g);
1066
2196
  return matches?.length ?? 0;
1067
2197
  }
@@ -1087,7 +2217,7 @@ function countUnverified(text) {
1087
2217
  * shown, low confidence is correct. This biases participants (via the
1088
2218
  * EVIDENCE_RULE prompt) to either verify or explicitly mark unverified.
1089
2219
  */
1090
- function computeEvidenceDensity(text) {
2220
+ export function computeEvidenceDensity(text) {
1091
2221
  const cited = countCitations(text);
1092
2222
  const unverified = countUnverified(text);
1093
2223
  const totalTagged = cited + unverified;