muonroi-cli 1.8.3 → 1.8.5

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