muonroi-cli 1.8.5 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/dist/packages/agent-harness-core/src/event-filter.js +11 -0
  2. package/dist/packages/agent-harness-core/src/event-redact.js +7 -0
  3. package/dist/packages/agent-harness-core/src/event-tee.d.ts +20 -4
  4. package/dist/packages/agent-harness-core/src/event-tee.js +32 -5
  5. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +14 -0
  6. package/dist/packages/agent-harness-core/src/mcp-server.js +86 -10
  7. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  8. package/dist/packages/agent-harness-core/src/protocol.d.ts +24 -2
  9. package/dist/src/agent-harness/mock-model.d.ts +10 -0
  10. package/dist/src/agent-harness/mock-model.js +6 -2
  11. package/dist/src/chat/chat-keychain.d.ts +7 -12
  12. package/dist/src/chat/chat-keychain.js +19 -86
  13. package/dist/src/cli/keys.d.ts +8 -45
  14. package/dist/src/cli/keys.js +18 -324
  15. package/dist/src/council/clarifier.d.ts +4 -2
  16. package/dist/src/council/clarifier.js +158 -36
  17. package/dist/src/council/debate-planner.js +3 -2
  18. package/dist/src/council/debate.js +59 -5
  19. package/dist/src/council/index.d.ts +23 -0
  20. package/dist/src/council/index.js +37 -2
  21. package/dist/src/council/llm.d.ts +62 -0
  22. package/dist/src/council/llm.js +123 -23
  23. package/dist/src/council/panel-select.js +13 -3
  24. package/dist/src/council/planner.js +17 -0
  25. package/dist/src/council/preflight.d.ts +10 -0
  26. package/dist/src/council/preflight.js +36 -0
  27. package/dist/src/council/prompts.d.ts +9 -1
  28. package/dist/src/council/prompts.js +23 -6
  29. package/dist/src/council/types.d.ts +10 -0
  30. package/dist/src/ee/auth.d.ts +19 -0
  31. package/dist/src/ee/auth.js +39 -0
  32. package/dist/src/ee/client.js +28 -3
  33. package/dist/src/ee/ee-onboarding.js +6 -26
  34. package/dist/src/flow/compaction/compress.d.ts +2 -2
  35. package/dist/src/flow/compaction/compress.js +21 -8
  36. package/dist/src/flow/compaction/extract.d.ts +3 -3
  37. package/dist/src/flow/compaction/extract.js +6 -6
  38. package/dist/src/flow/compaction/index.d.ts +2 -1
  39. package/dist/src/flow/compaction/index.js +29 -3
  40. package/dist/src/flow/compaction/progress.d.ts +35 -0
  41. package/dist/src/flow/compaction/progress.js +35 -0
  42. package/dist/src/generated/version.d.ts +1 -1
  43. package/dist/src/generated/version.js +1 -1
  44. package/dist/src/gsd/flags.d.ts +11 -0
  45. package/dist/src/gsd/flags.js +19 -0
  46. package/dist/src/gsd/plan-council.js +104 -72
  47. package/dist/src/gsd/verdict-schema.d.ts +1 -1
  48. package/dist/src/headless/council-answers.js +4 -0
  49. package/dist/src/index.js +129 -260
  50. package/dist/src/lsp/builtins.js +3 -1
  51. package/dist/src/lsp/manager.d.ts +5 -1
  52. package/dist/src/lsp/manager.js +249 -3
  53. package/dist/src/lsp/npm-cache.d.ts +11 -1
  54. package/dist/src/lsp/npm-cache.js +17 -1
  55. package/dist/src/lsp/runtime.d.ts +6 -1
  56. package/dist/src/lsp/runtime.js +17 -1
  57. package/dist/src/lsp/types.d.ts +83 -1
  58. package/dist/src/lsp/types.js +10 -0
  59. package/dist/src/mcp/client-pool.js +43 -15
  60. package/dist/src/mcp/lsp-tools.d.ts +5 -1
  61. package/dist/src/mcp/lsp-tools.js +93 -2
  62. package/dist/src/mcp/mcp-keychain.d.ts +3 -5
  63. package/dist/src/mcp/mcp-keychain.js +9 -49
  64. package/dist/src/mcp/setup-guide-text.d.ts +1 -1
  65. package/dist/src/mcp/setup-guide-text.js +22 -2
  66. package/dist/src/mcp/tools-server.d.ts +10 -0
  67. package/dist/src/mcp/tools-server.js +10 -2
  68. package/dist/src/models/catalog.json +19 -19
  69. package/dist/src/orchestrator/ask-user.d.ts +61 -0
  70. package/dist/src/orchestrator/ask-user.js +65 -0
  71. package/dist/src/orchestrator/compaction.d.ts +2 -3
  72. package/dist/src/orchestrator/compaction.js +8 -8
  73. package/dist/src/orchestrator/council-manager.js +9 -8
  74. package/dist/src/orchestrator/council-request.d.ts +49 -0
  75. package/dist/src/orchestrator/council-request.js +62 -0
  76. package/dist/src/orchestrator/interactive-pause.d.ts +26 -0
  77. package/dist/src/orchestrator/interactive-pause.js +36 -0
  78. package/dist/src/orchestrator/message-processor.d.ts +4 -0
  79. package/dist/src/orchestrator/message-processor.js +26 -8
  80. package/dist/src/orchestrator/orchestrator.d.ts +25 -0
  81. package/dist/src/orchestrator/orchestrator.js +204 -50
  82. package/dist/src/orchestrator/preprocessor.js +2 -2
  83. package/dist/src/orchestrator/safety-askcard.d.ts +1 -1
  84. package/dist/src/orchestrator/safety-askcard.js +5 -2
  85. package/dist/src/orchestrator/safety-intercept.d.ts +5 -0
  86. package/dist/src/orchestrator/safety-intercept.js +7 -0
  87. package/dist/src/orchestrator/stall-watchdog.d.ts +8 -1
  88. package/dist/src/orchestrator/stall-watchdog.js +24 -3
  89. package/dist/src/orchestrator/stream-runner.d.ts +13 -3
  90. package/dist/src/orchestrator/stream-runner.js +54 -21
  91. package/dist/src/orchestrator/tool-engine.d.ts +19 -0
  92. package/dist/src/orchestrator/tool-engine.js +241 -25
  93. package/dist/src/orchestrator/turn-watchdog.d.ts +7 -0
  94. package/dist/src/orchestrator/turn-watchdog.js +38 -9
  95. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  96. package/dist/src/pil/agent-operating-contract.js +6 -4
  97. package/dist/src/pil/discovery.d.ts +1 -1
  98. package/dist/src/pil/discovery.js +2 -2
  99. package/dist/src/pil/layer1_5-complexity-size.d.ts +7 -0
  100. package/dist/src/pil/layer1_5-complexity-size.js +31 -5
  101. package/dist/src/pil/llm-classify.d.ts +78 -3
  102. package/dist/src/pil/llm-classify.js +351 -111
  103. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  104. package/dist/src/pil/native-capabilities-workbook.js +7 -0
  105. package/dist/src/pil/pipeline.js +2 -0
  106. package/dist/src/pil/repo-grounding-probe.d.ts +15 -0
  107. package/dist/src/pil/repo-grounding-probe.js +136 -0
  108. package/dist/src/pil/repo-structure-hints.d.ts +7 -0
  109. package/dist/src/pil/repo-structure-hints.js +45 -0
  110. package/dist/src/product-loop/artifact-io.js +4 -0
  111. package/dist/src/product-loop/criteria-seed.d.ts +51 -0
  112. package/dist/src/product-loop/criteria-seed.js +200 -0
  113. package/dist/src/product-loop/discovery-interview.d.ts +9 -0
  114. package/dist/src/product-loop/discovery-interview.js +37 -18
  115. package/dist/src/product-loop/discovery-recommender.js +2 -1
  116. package/dist/src/product-loop/discovery-schema.js +14 -1
  117. package/dist/src/product-loop/discovery-triage.d.ts +23 -0
  118. package/dist/src/product-loop/discovery-triage.js +109 -0
  119. package/dist/src/product-loop/gather.js +150 -2
  120. package/dist/src/product-loop/index.js +7 -0
  121. package/dist/src/product-loop/loop-driver.js +21 -8
  122. package/dist/src/product-loop/phase-plan.d.ts +16 -0
  123. package/dist/src/product-loop/phase-plan.js +42 -4
  124. package/dist/src/product-loop/phase-rituals.d.ts +3 -0
  125. package/dist/src/product-loop/phase-rituals.js +8 -3
  126. package/dist/src/product-loop/phase-runner.js +30 -11
  127. package/dist/src/product-loop/plan-adherence-review.d.ts +26 -0
  128. package/dist/src/product-loop/plan-adherence-review.js +144 -0
  129. package/dist/src/product-loop/sprint-runner.d.ts +62 -0
  130. package/dist/src/product-loop/sprint-runner.js +309 -8
  131. package/dist/src/product-loop/types.d.ts +25 -0
  132. package/dist/src/providers/anthropic.d.ts +9 -8
  133. package/dist/src/providers/anthropic.js +13 -47
  134. package/dist/src/providers/auth/grok-oauth.d.ts +1 -0
  135. package/dist/src/providers/auth/grok-oauth.js +30 -5
  136. package/dist/src/providers/auth/openai-oauth.d.ts +1 -0
  137. package/dist/src/providers/auth/openai-oauth.js +14 -0
  138. package/dist/src/providers/auth/token-store.d.ts +9 -9
  139. package/dist/src/providers/auth/token-store.js +8 -70
  140. package/dist/src/providers/auth/types.d.ts +8 -0
  141. package/dist/src/providers/env-store.d.ts +17 -0
  142. package/dist/src/providers/env-store.js +228 -0
  143. package/dist/src/providers/keychain.d.ts +21 -17
  144. package/dist/src/providers/keychain.js +124 -135
  145. package/dist/src/providers/runtime.d.ts +24 -9
  146. package/dist/src/providers/runtime.js +48 -37
  147. package/dist/src/providers/strategies/thinking-mode.js +9 -1
  148. package/dist/src/providers/strategies/xai.strategy.js +27 -0
  149. package/dist/src/providers/warm.d.ts +65 -0
  150. package/dist/src/providers/warm.js +145 -0
  151. package/dist/src/self-qa/agentic-loop.js +3 -2
  152. package/dist/src/storage/transcript.js +56 -2
  153. package/dist/src/tools/git-safety.d.ts +19 -0
  154. package/dist/src/tools/git-safety.js +168 -0
  155. package/dist/src/tools/native-tools.d.ts +1 -1
  156. package/dist/src/tools/native-tools.js +76 -1
  157. package/dist/src/tools/registry.d.ts +17 -0
  158. package/dist/src/tools/registry.js +116 -1
  159. package/dist/src/types/index.d.ts +29 -1
  160. package/dist/src/ui/app.js +67 -4
  161. package/dist/src/ui/components/agent-rail-activities.d.ts +26 -0
  162. package/dist/src/ui/components/agent-rail-activities.js +47 -0
  163. package/dist/src/ui/components/compact-progress-card.d.ts +24 -0
  164. package/dist/src/ui/components/compact-progress-card.js +42 -0
  165. package/dist/src/ui/components/council-phase-timeline.js +17 -2
  166. package/dist/src/ui/components/council-question-card.js +1 -0
  167. package/dist/src/ui/components/message-view.d.ts +15 -0
  168. package/dist/src/ui/components/message-view.js +50 -1
  169. package/dist/src/ui/components/tool-group.d.ts +15 -3
  170. package/dist/src/ui/components/tool-group.js +69 -11
  171. package/dist/src/ui/containers/modals-layer.d.ts +2 -1
  172. package/dist/src/ui/containers/modals-layer.js +2 -2
  173. package/dist/src/ui/council-harness-event.d.ts +57 -0
  174. package/dist/src/ui/council-harness-event.js +46 -0
  175. package/dist/src/ui/heartbeat-debug.d.ts +29 -0
  176. package/dist/src/ui/heartbeat-debug.js +45 -0
  177. package/dist/src/ui/modals/api-key-modal.js +1 -1
  178. package/dist/src/ui/modals/model-picker-modal.d.ts +8 -18
  179. package/dist/src/ui/modals/model-picker-modal.js +8 -10
  180. package/dist/src/ui/slash/ee.js +81 -0
  181. package/dist/src/ui/slash/menu-items.js +11 -2
  182. package/dist/src/ui/use-app-logic.js +354 -224
  183. package/dist/src/ui/utils/agent-activities.d.ts +39 -0
  184. package/dist/src/ui/utils/agent-activities.js +96 -0
  185. package/dist/src/ui/utils/group-tool-entries.d.ts +26 -0
  186. package/dist/src/ui/utils/group-tool-entries.js +111 -0
  187. package/dist/src/ui/utils/tool-summary.d.ts +21 -0
  188. package/dist/src/ui/utils/tool-summary.js +91 -0
  189. package/dist/src/utils/event-loop-monitor.d.ts +85 -0
  190. package/dist/src/utils/event-loop-monitor.js +107 -0
  191. package/dist/src/utils/llm-deadline.d.ts +14 -0
  192. package/dist/src/utils/llm-deadline.js +19 -0
  193. package/dist/src/utils/loop-profiler.d.ts +102 -0
  194. package/dist/src/utils/loop-profiler.js +202 -0
  195. package/dist/src/utils/settings.d.ts +27 -0
  196. package/dist/src/utils/settings.js +38 -2
  197. package/dist/src/utils/side-question.d.ts +1 -2
  198. package/dist/src/utils/side-question.js +2 -2
  199. package/dist/src/verify/entrypoint.js +51 -16
  200. package/dist/src/verify/orchestrator.d.ts +1 -1
  201. package/dist/src/verify/orchestrator.js +20 -3
  202. package/package.json +1 -2
  203. package/dist/src/cli/bw-vault.d.ts +0 -55
  204. package/dist/src/cli/bw-vault.js +0 -133
  205. package/dist/src/mcp/ee-tools.d.ts +0 -46
  206. package/dist/src/mcp/ee-tools.js +0 -194
@@ -1,5 +1,8 @@
1
+ import { getMcpKey } from "../mcp/mcp-keychain.js";
2
+ import { getWebResearchModel } from "../models/registry.js";
3
+ import { getCouncilLanguage } from "../utils/settings.js";
1
4
  import { pickCouncilTaskModel } from "./leader.js";
2
- import { tracedGenerate, tracedGenerateWithFallback } from "./llm.js";
5
+ import { tracedAsync, tracedGenerate, tracedGenerateWithFallback } from "./llm.js";
3
6
  import { phaseDone, phaseError, phaseStart } from "./phase-events.js";
4
7
  import { buildClarificationPrompt, buildReadinessJudgePrompt, buildSpecSynthesisPrompt } from "./prompts.js";
5
8
  /** P5: Hard cap on clarification rounds regardless of judge verdict. */
@@ -97,6 +100,51 @@ function grayAreaToRoundQuestion(g) {
97
100
  isRequired: true,
98
101
  };
99
102
  }
103
+ /**
104
+ * Resolve the concrete interview language from the council language setting +
105
+ * the topic. Pinned locale → that locale; "english" → English; "auto" (default)
106
+ * → detect from the topic (Vietnamese diacritics, mirroring
107
+ * `detectReplyLanguageHint`). Returned as a concrete name so the clarify prompt
108
+ * FORCES it (immune to an English "## Scope Research" block) and the escape-hatch
109
+ * labels can be localized.
110
+ */
111
+ const VI_DIACRITIC_RE = /[à-ỹÀ-Ỹ]/;
112
+ export function resolveInterviewLanguage(topic) {
113
+ const setting = getCouncilLanguage();
114
+ if (setting && setting !== "auto" && setting !== "english")
115
+ return setting;
116
+ if (setting === "english")
117
+ return "english";
118
+ return VI_DIACRITIC_RE.test(topic ?? "") ? "vietnamese" : "english";
119
+ }
120
+ /**
121
+ * The two escape-hatch options appended to every clarification card, localized to
122
+ * the interview language so a Vietnamese interview doesn't show English buttons.
123
+ * `lang` is a concrete language name; anything not recognized as Vietnamese falls
124
+ * back to English labels (the LLM-authored question itself is still localized).
125
+ * When `lang` is undefined the historical English-label pair is kept byte-
126
+ * identical so existing callers/tests are unaffected.
127
+ */
128
+ function escapeHatchOptions(lang) {
129
+ const isVi = typeof lang === "string" && /vietnamese|tiếng việt/i.test(lang.trim());
130
+ if (isVi) {
131
+ return [
132
+ { label: "Nhập câu trả lời", description: "Nhập câu trả lời tự do", value: "", kind: "freetext" },
133
+ { label: "Thảo luận thêm", description: "Thảo luận thêm trước khi trả lời", value: "", kind: "chat" },
134
+ ];
135
+ }
136
+ if (lang === undefined) {
137
+ // Historical default preserved byte-identical (EN label + VN description).
138
+ return [
139
+ { label: "Type something", description: "Nhập câu trả lời tự do", value: "", kind: "freetext" },
140
+ { label: "Chat about this", description: "Thảo luận thêm trước khi trả lời", value: "", kind: "chat" },
141
+ ];
142
+ }
143
+ return [
144
+ { label: "Type something", description: "Type a free-form answer", value: "", kind: "freetext" },
145
+ { label: "Chat about this", description: "Discuss before answering", value: "", kind: "chat" },
146
+ ];
147
+ }
100
148
  /**
101
149
  * Convert legacy `suggestions: string[]` into the new options schema with
102
150
  * standard "Type something" / "Chat about this" escape-hatches appended.
@@ -110,25 +158,11 @@ function grayAreaToRoundQuestion(g) {
110
158
  * returned `defaultIndex` points to it. Otherwise `defaultIndex` is omitted
111
159
  * so the UI knows to suppress the "(Recommended)" tag.
112
160
  */
113
- export function buildClarifyOptions(suggestions, recommended) {
161
+ export function buildClarifyOptions(suggestions, recommended, lang) {
114
162
  const choices = (suggestions ?? [])
115
163
  .filter((s) => typeof s === "string" && s.trim().length > 0)
116
164
  .map((s) => ({ label: s.trim(), value: s.trim(), kind: "choice" }));
117
- const options = [
118
- ...choices,
119
- {
120
- label: "Type something",
121
- description: "Nhập câu trả lời tự do",
122
- value: "",
123
- kind: "freetext",
124
- },
125
- {
126
- label: "Chat about this",
127
- description: "Thảo luận thêm trước khi trả lời",
128
- value: "",
129
- kind: "chat",
130
- },
131
- ];
165
+ const options = [...choices, ...escapeHatchOptions(lang)];
132
166
  let defaultIndex;
133
167
  if (typeof recommended === "string" && recommended.trim().length > 0) {
134
168
  const target = recommended.trim().toLowerCase();
@@ -146,7 +180,7 @@ export function buildClarifyOptions(suggestions, recommended) {
146
180
  * "Chat about this" escape-hatches are appended as in `buildClarifyOptions`.
147
181
  * Pure + exported for unit testing.
148
182
  */
149
- export function buildClarifyOptionsRich(specs) {
183
+ export function buildClarifyOptionsRich(specs, lang) {
150
184
  const cleaned = (specs ?? []).filter((s) => !!s && typeof s.label === "string" && s.label.trim().length > 0);
151
185
  const choices = cleaned.map((s) => ({
152
186
  label: s.label.trim(),
@@ -156,27 +190,105 @@ export function buildClarifyOptionsRich(specs) {
156
190
  ? { description: s.description.trim() }
157
191
  : {}),
158
192
  }));
159
- const options = [
160
- ...choices,
161
- {
162
- label: "Type something",
163
- description: "Nhập câu trả lời tự do",
164
- value: "",
165
- kind: "freetext",
166
- },
167
- {
168
- label: "Chat about this",
169
- description: "Thảo luận thêm trước khi trả lời",
170
- value: "",
171
- kind: "chat",
172
- },
173
- ];
193
+ const options = [...choices, ...escapeHatchOptions(lang)];
174
194
  let defaultIndex;
175
195
  const recIdx = cleaned.findIndex((s) => s.recommended === true);
176
196
  if (recIdx !== -1)
177
197
  defaultIndex = recIdx;
178
198
  return { options, defaultIndex };
179
199
  }
200
+ /**
201
+ * Pre-clarify scope research (research-first grounding).
202
+ *
203
+ * Before asking ANY clarification question, research the topic — codebase for an
204
+ * existing repo (the research method reads the tree) plus web — so the
205
+ * question-generator surfaces the REAL gray areas the evidence exposes instead
206
+ * of guessing blind ("hỏi tào lao"). The returned brief is appended to the
207
+ * `conversationContext` that `buildClarificationPrompt` already reads, so both
208
+ * `/council` and `/ideal` (which reuses this clarifier as its gather engine)
209
+ * inherit evidence-grounded questions from one shared code path.
210
+ *
211
+ * Web-capability policy (owner's Part E rule): PREFER a model with native web
212
+ * research integrated (catalog `nativeWebResearch`), switching the research call
213
+ * to it when one is reachable. Only when no native-web model is reachable fall
214
+ * back to Tavily (via the research method's builtin web tools). When NEITHER a
215
+ * native-web model NOR a Tavily key exists, warn the user before continuing —
216
+ * research proceeds on the codebase + model knowledge only, never silently blind.
217
+ * (Codebase evidence is always gathered by the research tool regardless of the
218
+ * web tier, so an existing repo is still grounded.)
219
+ *
220
+ * Never throws and never blocks the interview: if the flag is off, the llm has
221
+ * no `research` method (most unit-test mocks), the run is aborted, or the call
222
+ * fails, it yields nothing and returns "". Default ON; opt out with
223
+ * MUONROI_CLARIFY_RESEARCH_FIRST=0.
224
+ */
225
+ async function hasTavilyKey() {
226
+ try {
227
+ const k = ((await getMcpKey("tavily")) || process.env.TAVILY_API_KEY || "").trim();
228
+ return k.length >= 10;
229
+ }
230
+ catch {
231
+ return (process.env.TAVILY_API_KEY ?? "").trim().length >= 10;
232
+ }
233
+ }
234
+ export async function* researchScopeForClarification(topic, conversationContext, leaderModelId, llm, signal, reachableModels) {
235
+ if (process.env.MUONROI_CLARIFY_RESEARCH_FIRST === "0")
236
+ return "";
237
+ if (signal?.aborted)
238
+ return "";
239
+ // Skip cleanly when the injected llm can't research (generate-only test mocks)
240
+ // — keeps every existing clarifier test on its current, research-free path.
241
+ if (typeof llm.research !== "function")
242
+ return "";
243
+ // Choose the research model + web tier. Prefer a reachable native-web model.
244
+ const reachable = new Set([leaderModelId, ...reachableModels].filter(Boolean));
245
+ const nativeModel = getWebResearchModel(reachable);
246
+ let researchModel = leaderModelId;
247
+ let webTier;
248
+ if (nativeModel) {
249
+ researchModel = nativeModel.id;
250
+ webTier = "native";
251
+ }
252
+ else {
253
+ webTier = (await hasTavilyKey()) ? "tavily" : "none";
254
+ }
255
+ const phaseId = "phase:clarification-scope-research";
256
+ const startedAt = Date.now();
257
+ yield phaseStart({ phaseId, kind: "clarification", label: "Scope research" });
258
+ // Owner's Part E principle: never research the web fully blind without telling
259
+ // the user. Codebase + model-knowledge research still runs, but web findings
260
+ // will be absent — surface that so they can wire a native-web model or Tavily.
261
+ if (webTier === "none") {
262
+ yield {
263
+ type: "content",
264
+ content: `\n> ⚠ Scope research: no web-research-native model is reachable and no Tavily key is configured. ` +
265
+ `Researching from the codebase + model knowledge only — no live web findings. ` +
266
+ `Configure a native-web model or a Tavily API key for grounded web research.\n`,
267
+ };
268
+ }
269
+ try {
270
+ const goal = `${topic}\n\n(Research goal: narrow the scope of this request. Report verified findings, and — ` +
271
+ `critically — END with an explicit "## Open Questions / Assumptions" section listing what you could ` +
272
+ `NOT verify and had to ASSUME (the real goal/pain, the concrete task, the system of record, the ` +
273
+ `target user, hard constraints). Those unresolved items are exactly what the user must clarify next, ` +
274
+ `so name them plainly rather than papering over them with a plausible guess.)`;
275
+ const brief = yield* tracedAsync(() => llm.research(researchModel, goal, conversationContext, signal, undefined, {
276
+ internetFirst: webTier !== "none",
277
+ }), { phase: "clarify", label: "Scope research" });
278
+ yield phaseDone({ phaseId, kind: "clarification", label: "Scope research", startedAt });
279
+ return typeof brief === "string" ? brief.trim() : "";
280
+ }
281
+ catch (err) {
282
+ yield phaseError({
283
+ phaseId,
284
+ kind: "clarification",
285
+ label: "Scope research",
286
+ startedAt,
287
+ errorMessage: err instanceof Error ? err.message : String(err),
288
+ });
289
+ return "";
290
+ }
291
+ }
180
292
  export async function* runClarification(topic, leaderModelId, conversationContext, respondToQuestion, llm, signal, seedQuestions, maxRounds,
181
293
  /**
182
294
  * Optional pre-filled answers keyed by seed-question id. When the loop's
@@ -201,12 +313,22 @@ fallbackModels = []) {
201
313
  // P5: use MAX_CLARIFY_ROUNDS (12) as the hard cap; respect explicit override
202
314
  // from callers that pass maxRounds (e.g. tests that want old 3-round behavior).
203
315
  const max = typeof maxRounds === "number" && maxRounds > 0 ? maxRounds : MAX_CLARIFY_ROUNDS;
316
+ // Interview language (the user's conversation language, not English-by-default).
317
+ // Resolved once and forced into the clarify prompt + escape-hatch labels.
318
+ const uiLang = resolveInterviewLanguage(topic);
204
319
  const allQA = [];
205
320
  // P5: full Q&A history with timestamps for clarifyHistory field
206
321
  const clarifyHistory = [];
207
322
  const phaseStartedAt = Date.now();
208
323
  yield phaseStart({ phaseId: "phase:clarification", kind: "clarification", label: "Clarification" });
209
324
  const seeded = (seedQuestions ?? []).map(grayAreaToRoundQuestion);
325
+ // Research-first: ground the interview in evidence BEFORE asking anything, so
326
+ // the question-generator targets the real gray areas the research surfaced
327
+ // rather than guessing. Appended to the context every round's prompt reads.
328
+ const scopeBrief = yield* researchScopeForClarification(topic, conversationContext, leaderModelId, llm, signal, fallbackModels);
329
+ if (scopeBrief) {
330
+ conversationContext = `${conversationContext}${conversationContext ? "\n\n" : ""}## Scope Research\n${scopeBrief}`;
331
+ }
210
332
  // P5: track ready-gate state across rounds
211
333
  let gateReady = false;
212
334
  let gateConfidence = 0;
@@ -240,7 +362,7 @@ fallbackModels = []) {
240
362
  const gapHint = pendingGaps.length > 0
241
363
  ? `\n\n## Known Gaps (target these with follow-up questions)\n${pendingGaps.map((g) => `- ${g}`).join("\n")}`
242
364
  : "";
243
- const { system, prompt } = buildClarificationPrompt(topic, conversationContext, allQA.length > 0 ? allQA : undefined);
365
+ const { system, prompt } = buildClarificationPrompt(topic, conversationContext, allQA.length > 0 ? allQA : undefined, uiLang);
244
366
  let questionsRaw;
245
367
  try {
246
368
  const clarifyModel = pickCouncilTaskModel("clarify_questions", leaderModelId, costAware);
@@ -334,8 +456,8 @@ fallbackModels = []) {
334
456
  // pre-selected. Fall back to the legacy string `suggestions` shape (PIL
335
457
  // gray-area seeds and any model still emitting the old format).
336
458
  const { options, defaultIndex } = q.options && q.options.length > 0
337
- ? buildClarifyOptionsRich(q.options)
338
- : buildClarifyOptions(q.suggestions, q.recommended);
459
+ ? buildClarifyOptionsRich(q.options, uiLang)
460
+ : buildClarifyOptions(q.suggestions, q.recommended, uiLang);
339
461
  // Keep the deprecated `suggestions` mirror populated for consumers that
340
462
  // still read it (audit/replay), deriving labels from rich options.
341
463
  const suggestionLabels = q.suggestions ?? (q.options ? q.options.map((o) => o.label).filter((l) => typeof l === "string") : undefined);
@@ -177,8 +177,9 @@ signal) {
177
177
  try {
178
178
  const providerId = detectProviderForModel(leaderModelId);
179
179
  const key = await loadKeyForProvider(providerId);
180
- const { factory } = createProviderFactory(providerId, { apiKey: key });
181
- const runtime = resolveModelRuntime(factory, leaderModelId);
180
+ // Registers the leader provider's factory so resolveModelRuntime can derive it.
181
+ createProviderFactory(providerId, { apiKey: key });
182
+ const runtime = resolveModelRuntime(leaderModelId);
182
183
  // Bound attempt-1: a wedged provider response here would freeze the whole
183
184
  // council/loop silently (no streamText stall watchdog covers generateObject).
184
185
  // On timeout this rejects → caught below → retry (guarded) → fallback plan.
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getModelInfo } from "../models/registry.js";
3
3
  import { detectProviderForModel } from "../providers/runtime.js";
4
+ import { getIsolatedTaskDeadlineMs, withDeadlineRace } from "../utils/llm-deadline.js";
4
5
  import { getCouncilLanguage } from "../utils/settings.js";
5
6
  import { buildDebateCheckpoint, checkpointMatches, deleteDebateCheckpoint, restoreExchangeLogs, writeDebateCheckpoint, } from "./debate-checkpoint.js";
6
7
  import { resolveDebateSummary } from "./debate-summary.js";
7
8
  import { pickCouncilTaskModel } from "./leader.js";
8
- import { tracedAsync, tracedGenerate } from "./llm.js";
9
+ import { councilStreamLivenessReader, tracedAsync, tracedGenerate } from "./llm.js";
9
10
  import { phaseDone, phaseStart } from "./phase-events.js";
10
11
  import { buildFollowupPrompt, buildLeaderEvaluationPrompt, buildOpeningPrompt, buildResponsePrompt, buildRoundSummaryPrompt, } from "./prompts.js";
11
12
  /**
@@ -301,13 +302,13 @@ async function runResearchIsolated(runIsolatedTask, model, topic, conversationCo
301
302
  `Return a compact "## Research Findings" section: only the concrete facts that bear on the question, each with its source (file:line or URL). ` +
302
303
  `Be terse and factual — no opinions, no recommendations. If you cannot find solid evidence, say so in one line.`;
303
304
  try {
304
- const result = await runIsolatedTask({
305
+ const result = await withDeadlineRace(() => runIsolatedTask({
305
306
  agent: "explore",
306
307
  description: `Council research: ${topic.slice(0, 60)}`,
307
308
  prompt,
308
309
  // Pin the research-role model; bypasses the parent-tier cap in StreamRunner.
309
310
  modelId: model,
310
- });
311
+ }), getIsolatedTaskDeadlineMs(), "council-research");
311
312
  if (result.success && result.output?.trim())
312
313
  return result.output.trim();
313
314
  traceCb(`[research] isolated sub-agent produced nothing: ${result.error ?? "no output"}`);
@@ -352,7 +353,22 @@ export async function researchWithFallback(llm, primaryModel, topic, conversatio
352
353
  return primary;
353
354
  }
354
355
  }
356
+ /**
357
+ * Times every `debateWithRetryInner` exit path — success, retry, fallback and
358
+ * total failure all cost wall-clock, and a slow FAILING speaker is exactly the
359
+ * case worth seeing. Wrapping is why `durationMs` cannot drift out of sync with
360
+ * the four separate returns inside.
361
+ *
362
+ * The number is per-SPEAKER. The only timed council rows today are
363
+ * `debate_complete` (whole debate: 87s..1128s measured) and `council_summary`
364
+ * — aggregates that cannot name the model responsible.
365
+ */
355
366
  async function debateWithRetry(llm, model, system, prompt, signal, traceCb, toolBudget, fallbackPool = []) {
367
+ const startedAt = Date.now();
368
+ const r = await debateWithRetryInner(llm, model, system, prompt, signal, traceCb, toolBudget, fallbackPool);
369
+ return { ...r, durationMs: Date.now() - startedAt };
370
+ }
371
+ async function debateWithRetryInner(llm, model, system, prompt, signal, traceCb, toolBudget, fallbackPool = []) {
356
372
  // Respect the circuit breaker — once a model has tripped, it stays
357
373
  // tool-disabled for the rest of this council run.
358
374
  const allowTools = debateAllowsTools(model) && !toolBudget.disabled.has(model);
@@ -514,11 +530,16 @@ export async function* runDebate(spec, config, llm) {
514
530
  detail: `via ${researchCandidate.model}`,
515
531
  });
516
532
  const researchTraces = [];
533
+ const researchStartedAt = Date.now();
517
534
  researchFindings = yield* tracedAsync(() => researchWithFallback(llm, researchCandidate.model, spec.problemStatement, conversationContext, signal, (t) => researchTraces.push(t), { internetFirst }, fallbackPool, config.runIsolatedTask), {
518
535
  phase: "research",
519
536
  label: internetFirst ? "Researching (internet-first)" : "Researching codebase",
520
537
  detail: spec.problemStatement.slice(0, 80),
521
538
  role: "research",
539
+ // Research is a single long generateText (up to ~10 min / 15 tool steps)
540
+ // with a quiet main stream — the phase most easily misread as hung, and
541
+ // the one whose cost the per-round numbers could not account for.
542
+ liveness: councilStreamLivenessReader(),
522
543
  });
523
544
  // CQ-22: emit research tool traces as council_status
524
545
  for (const trace of researchTraces) {
@@ -545,6 +566,10 @@ export async function* runDebate(spec, config, llm) {
545
566
  text: researchFailed
546
567
  ? "⚠ Research unavailable — both providers failed. The council proceeds on the panel's own knowledge (no external findings injected)."
547
568
  : (researchFindings ?? ""),
569
+ // Spans researchWithFallback including any fallback provider attempt.
570
+ // Measured per-speaker debate turns summed to only ~3.7 min against a
571
+ // ~507s historical `debate_complete`, so the balance is here.
572
+ durationMs: Date.now() - researchStartedAt,
548
573
  },
549
574
  };
550
575
  }
@@ -595,6 +620,7 @@ export async function* runDebate(spec, config, llm) {
595
620
  conversationContext: selfContext,
596
621
  language: debateLanguage,
597
622
  });
623
+ const startedAt = Date.now();
598
624
  return openingWithRetry(llm, self.model, system, prompt).then((r) => ({
599
625
  role: self.role,
600
626
  model: self.model,
@@ -602,6 +628,7 @@ export async function* runDebate(spec, config, llm) {
602
628
  position: r.text,
603
629
  error: r.text ? null : (r.error ?? "empty completion after retries"),
604
630
  attempts: r.attempts,
631
+ durationMs: Date.now() - startedAt,
605
632
  }));
606
633
  });
607
634
  const openings = yield* tracedAsync(() => Promise.all(openingPromises), {
@@ -611,6 +638,9 @@ export async function* runDebate(spec, config, llm) {
611
638
  // each speaker is tasked to argue (A: live debate preview) instead of a bare
612
639
  // spinner during the atomic generateText window.
613
640
  detail: formatSpeakerRoster(participants),
641
+ // Promise.all does not pump this generator, so `elapsedMs` freezes while
642
+ // the openings run. Push-based stream counters keep the heartbeat honest.
643
+ liveness: councilStreamLivenessReader(),
614
644
  });
615
645
  yield { type: "content", content: "\n── Opening Analysis ──\n" };
616
646
  for (const o of openings) {
@@ -625,6 +655,7 @@ export async function* runDebate(spec, config, llm) {
625
655
  text: `[Error: ${o.error}]`,
626
656
  attempts: o.attempts,
627
657
  failureReason: o.error,
658
+ durationMs: o.durationMs,
628
659
  },
629
660
  };
630
661
  }
@@ -645,6 +676,7 @@ export async function* runDebate(spec, config, llm) {
645
676
  round: 0,
646
677
  text: o.position,
647
678
  attempts: o.attempts,
679
+ durationMs: o.durationMs,
648
680
  },
649
681
  };
650
682
  emitCouncilTurnLength({
@@ -757,6 +789,14 @@ export async function* runDebate(spec, config, llm) {
757
789
  // not yet escalated, criteria unmet).
758
790
  async function* escalateStop(stuck, pinnedUnmet, openList) {
759
791
  escalated = true;
792
+ // convene_council path: no interactive user is answering mid-tool-call, so a
793
+ // blocking escalation card would hang the council. Auto-accept (= conclude
794
+ // with the best synthesis so far) without emitting the card. Mirrors the
795
+ // "empty/failed answer → accept" fallback runEscalationPrompt already uses.
796
+ if (config.convenePath) {
797
+ escalation = { action: "accept" };
798
+ return "stop";
799
+ }
760
800
  const dec = yield* runEscalationPrompt({
761
801
  respondToQuestion: config.respondToQuestion,
762
802
  openCriteria: openList,
@@ -902,6 +942,7 @@ export async function* runDebate(spec, config, llm) {
902
942
  traces: aTraces,
903
943
  failureReason: aResult.failureReason,
904
944
  attempts: aResult.attempts,
945
+ durationMs: aResult.durationMs,
905
946
  });
906
947
  const bPrompt = buildResponsePrompt({
907
948
  speakerRole: b.role,
@@ -925,6 +966,7 @@ export async function* runDebate(spec, config, llm) {
925
966
  traces: bTraces,
926
967
  failureReason: bResult.failureReason,
927
968
  attempts: bResult.attempts,
969
+ durationMs: bResult.durationMs,
928
970
  });
929
971
  }
930
972
  else {
@@ -956,6 +998,7 @@ export async function* runDebate(spec, config, llm) {
956
998
  traces: aTraces,
957
999
  failureReason: aResult.failureReason,
958
1000
  attempts: aResult.attempts,
1001
+ durationMs: aResult.durationMs,
959
1002
  });
960
1003
  const bPrompt = buildFollowupPrompt({
961
1004
  speakerRole: b.role,
@@ -981,6 +1024,7 @@ export async function* runDebate(spec, config, llm) {
981
1024
  traces: bTraces,
982
1025
  failureReason: bResult.failureReason,
983
1026
  attempts: bResult.attempts,
1027
+ durationMs: bResult.durationMs,
984
1028
  });
985
1029
  }
986
1030
  // Only update positions when response is non-empty (avoid clearing on LLM error)
@@ -999,6 +1043,11 @@ export async function* runDebate(spec, config, llm) {
999
1043
  // Distinct speakers with their lens (A: live debate preview) — dedup by
1000
1044
  // formatted line so a speaker appearing in two pairs shows once.
1001
1045
  detail: formatSpeakerRoster(pairs.flatMap((p) => [p.a, p.b])),
1046
+ // THE case this exists for: each pair turn can run to the 5-min council
1047
+ // ceiling on a reasoning model, and Promise.all never pumps this
1048
+ // generator — `elapsedMs` freezes and a healthy 15-min round looks hung.
1049
+ // These counters come off the token stream, so they keep advancing.
1050
+ liveness: councilStreamLivenessReader(),
1002
1051
  });
1003
1052
  yield { type: "content", content: `\n── Round ${round} ──\n` };
1004
1053
  // Track failures so the circuit breaker can fire after this round.
@@ -1063,6 +1112,7 @@ export async function* runDebate(spec, config, llm) {
1063
1112
  text: chunk.text.trim(),
1064
1113
  toolCalls: chunk.toolCalls?.map((t) => ({ name: t.toolName })),
1065
1114
  attempts: chunk.attempts,
1115
+ durationMs: chunk.durationMs,
1066
1116
  },
1067
1117
  };
1068
1118
  emitCouncilTurnLength({
@@ -1236,6 +1286,10 @@ export async function* runDebate(spec, config, llm) {
1236
1286
  speaker: { role: "Leader", model: leaderModelId },
1237
1287
  round,
1238
1288
  text: verdictText,
1289
+ // Spans every evaluateDebate attempt including fallback models, so a
1290
+ // leader that ate the round is attributable rather than hidden inside
1291
+ // the `debate_complete` aggregate.
1292
+ durationMs: Date.now() - evalStart,
1239
1293
  },
1240
1294
  };
1241
1295
  // P5 — guaranteed done record for this round. Decision reflects the
@@ -1912,12 +1966,12 @@ export async function runGroundingVerify(runIsolatedTask, model, problemStatemen
1912
1966
  `words followed by its tag. Do NOT restate the whole debate or add opinions. Prefer [CONFIRMED]/[REFUTED] ` +
1913
1967
  `with a real citation over [UNVERIFIED].`;
1914
1968
  try {
1915
- const result = await runIsolatedTask({
1969
+ const result = await withDeadlineRace(() => runIsolatedTask({
1916
1970
  agent: "explore",
1917
1971
  description: `Council grounding-verify: ${problemStatement.slice(0, 50)}`,
1918
1972
  prompt,
1919
1973
  modelId: model,
1920
- });
1974
+ }), getIsolatedTaskDeadlineMs(), "council-grounding-verify");
1921
1975
  if (result.success && result.output?.trim())
1922
1976
  return result.output.trim();
1923
1977
  traceCb(`[grounding-verify] produced nothing: ${result.error ?? "no output"}`);
@@ -88,6 +88,19 @@ export interface RunCouncilOptions {
88
88
  * decisions.lock artifact actually get written for sprint-runner injection.
89
89
  */
90
90
  sprintPlanningMode?: boolean;
91
+ /**
92
+ * Convene-tool path (the `convene_council` builtin). When true, render
93
+ * clarify(optional)+debate+synthesis exactly as normal, then RETURN the
94
+ * synthesis string WITHOUT any post-debate decision surface: NO
95
+ * `pickPostDebateRecommendation`, NO option set, NO `council_question`
96
+ * post-debate card, NO `onPostDebateAction`, NO `postDebateContinuation`
97
+ * routing. The calling AGENT decides what happens next (continue silently,
98
+ * ask the user via `ask_user`, or hand off to `/ideal`) — the CLI hardcodes
99
+ * none of it (user directive: no CLI-hardcoded post-council branch). The
100
+ * persistence block (decisions.lock, judge, council record) still runs — it
101
+ * is an audit trail, not a decision.
102
+ */
103
+ convenePath?: boolean;
91
104
  /**
92
105
  * #2 — isolated sub-agent bridge (orchestrator.runTaskRequest). When wired,
93
106
  * the debate's research phase runs in a budget-capped explore sub-agent
@@ -144,6 +157,16 @@ export declare function pickPostDebateRecommendation(input: {
144
157
  reason: string;
145
158
  };
146
159
  export declare function postDebateContinuation(action: string | undefined, synthesis: string, outputKind?: string): string | null;
160
+ /**
161
+ * Neutral post-council continuation. Used by the auto-council path (tool-engine)
162
+ * and the `/council` slash path (runCouncilV2) once they run with
163
+ * `convenePath: true` — the hardcoded post-debate option card is suppressed, so
164
+ * there is no `chosenAction` to branch on. Instead of the CLI deciding the next
165
+ * step, we hand the synthesis back to a normal agent turn with a NON-BINDING
166
+ * nudge and let the agent's own intent drive the follow-up (respond / ask_user /
167
+ * implement). Returns "" for an empty synthesis so the caller skips re-entry.
168
+ */
169
+ export declare function buildNeutralPostCouncilContinuation(synthesis: string): string;
147
170
  export declare function runCouncil(topic: string, sessionModelId: string, messages: Array<{
148
171
  role: string;
149
172
  content: string | unknown;
@@ -179,6 +179,28 @@ export function postDebateContinuation(action, synthesis, outputKind) {
179
179
  }
180
180
  return null;
181
181
  }
182
+ /**
183
+ * Neutral post-council continuation. Used by the auto-council path (tool-engine)
184
+ * and the `/council` slash path (runCouncilV2) once they run with
185
+ * `convenePath: true` — the hardcoded post-debate option card is suppressed, so
186
+ * there is no `chosenAction` to branch on. Instead of the CLI deciding the next
187
+ * step, we hand the synthesis back to a normal agent turn with a NON-BINDING
188
+ * nudge and let the agent's own intent drive the follow-up (respond / ask_user /
189
+ * implement). Returns "" for an empty synthesis so the caller skips re-entry.
190
+ */
191
+ export function buildNeutralPostCouncilContinuation(synthesis) {
192
+ if (!synthesis || !synthesis.trim())
193
+ return "";
194
+ return (`Council debate completed. Conclusion:\n\n${synthesis}\n\n` +
195
+ `You now decide the next step based on the user's original request — do not ` +
196
+ `stop without doing one of these:\n` +
197
+ ` • If the conclusion IS the deliverable (analysis/evaluation/decision), ` +
198
+ `respond to the user with it.\n` +
199
+ ` • If a choice genuinely needs the human before proceeding, call ask_user.\n` +
200
+ ` • If the task calls for building and the conclusion is a sufficient spec, ` +
201
+ `implement it now through your normal workflow — do NOT re-litigate the ` +
202
+ `decision or expand scope beyond it.`);
203
+ }
182
204
  export async function* runCouncil(topic, sessionModelId, messages, sessionId, rawLlm, respondToQuestion, respondToPreflight, processMessageFn, options) {
183
205
  const stats = options?.councilStats ?? { calls: 0, startMs: Date.now(), phases: [] };
184
206
  const costAware = isCouncilCostAware();
@@ -349,7 +371,11 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ra
349
371
  const preflightGen = runPreflight(spec, participants, researchNeeded, respondToPreflight, {
350
372
  repoEmpty: internetFirst,
351
373
  researchOverridable: true,
352
- autoApprove: spec.ready === true || options?.autoApprovePreflight === true,
374
+ // convenePath auto-approves the pre-debate plan card too: the agent
375
+ // already decided to convene, so re-gating the discussion plan is a
376
+ // redundant interruption of the autonomous tool call (same rationale as
377
+ // sprintPlanningMode). The brief is still shown; it just isn't blocking.
378
+ autoApprove: spec.ready === true || options?.autoApprovePreflight === true || options?.convenePath === true,
353
379
  });
354
380
  let preflightResult;
355
381
  do {
@@ -503,6 +529,9 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ra
503
529
  // unmet, runDebate asks the user (extend / accept / rescope) instead of
504
530
  // silently synthesizing a partial outcome.
505
531
  respondToQuestion,
532
+ // convene_council path — auto-accept escalation (no blocking card) since
533
+ // the council runs autonomously mid-agent-turn with no interactive user.
534
+ convenePath: options?.convenePath,
506
535
  }, llm);
507
536
  let debateResult;
508
537
  do {
@@ -568,7 +597,13 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ra
568
597
  data: { topic, roundCount: debateState.roundCount, participantCount: debateState.active.length },
569
598
  });
570
599
  // ── Post-Debate AskCard: What next? ─────────────────────────────────────────
571
- if (sessionId) {
600
+ // convenePath skips this ENTIRE interactive block (recommendation, option set,
601
+ // card, respondToQuestion, postDebateAction, onPostDebateAction, and the whole
602
+ // routing tree). On that path the agent that called `convene_council` decides
603
+ // what happens next — the CLI must not hardcode a post-council pick. The
604
+ // persistence block below still runs (audit trail, not a decision), and the
605
+ // function returns synthesisText as usual.
606
+ if (sessionId && !options?.convenePath) {
572
607
  try {
573
608
  const { randomUUID } = await import("crypto");
574
609
  const refinementTopics = [];