agent-nuvira 3.3.13 → 3.3.14

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 (387) hide show
  1. package/README.md +67 -1
  2. package/dist/agents/agents/planner.d.ts.map +1 -1
  3. package/dist/agents/agents/planner.js +9 -0
  4. package/dist/agents/agents/planner.js.map +1 -1
  5. package/dist/agents/orchestrator.d.ts +31 -0
  6. package/dist/agents/orchestrator.d.ts.map +1 -1
  7. package/dist/agents/orchestrator.js +74 -126
  8. package/dist/agents/orchestrator.js.map +1 -1
  9. package/dist/cli/chat.d.ts +89 -0
  10. package/dist/cli/chat.d.ts.map +1 -1
  11. package/dist/cli/chat.js +765 -33
  12. package/dist/cli/chat.js.map +1 -1
  13. package/dist/cli/cli-program.d.ts.map +1 -1
  14. package/dist/cli/cli-program.js +12 -0
  15. package/dist/cli/cli-program.js.map +1 -1
  16. package/dist/cli/config.d.ts +12 -0
  17. package/dist/cli/config.d.ts.map +1 -1
  18. package/dist/cli/config.js +86 -0
  19. package/dist/cli/config.js.map +1 -1
  20. package/dist/cli/decisions.d.ts +28 -0
  21. package/dist/cli/decisions.d.ts.map +1 -0
  22. package/dist/cli/decisions.js +140 -0
  23. package/dist/cli/decisions.js.map +1 -0
  24. package/dist/cli/doctor.d.ts +6 -0
  25. package/dist/cli/doctor.d.ts.map +1 -1
  26. package/dist/cli/doctor.js +46 -0
  27. package/dist/cli/doctor.js.map +1 -1
  28. package/dist/cli/failover-runner.d.ts.map +1 -1
  29. package/dist/cli/failover-runner.js +19 -1
  30. package/dist/cli/failover-runner.js.map +1 -1
  31. package/dist/cli/feedback.d.ts.map +1 -1
  32. package/dist/cli/feedback.js +5 -1
  33. package/dist/cli/feedback.js.map +1 -1
  34. package/dist/cli/hooks.d.ts +30 -0
  35. package/dist/cli/hooks.d.ts.map +1 -0
  36. package/dist/cli/hooks.js +224 -0
  37. package/dist/cli/hooks.js.map +1 -0
  38. package/dist/cli/knowledge.d.ts +9 -1
  39. package/dist/cli/knowledge.d.ts.map +1 -1
  40. package/dist/cli/knowledge.js +93 -2
  41. package/dist/cli/knowledge.js.map +1 -1
  42. package/dist/cli/loop-executor.d.ts.map +1 -1
  43. package/dist/cli/loop-executor.js +47 -12
  44. package/dist/cli/loop-executor.js.map +1 -1
  45. package/dist/cli/model.d.ts +20 -0
  46. package/dist/cli/model.d.ts.map +1 -1
  47. package/dist/cli/model.js +294 -14
  48. package/dist/cli/model.js.map +1 -1
  49. package/dist/cli/rate.d.ts +78 -0
  50. package/dist/cli/rate.d.ts.map +1 -0
  51. package/dist/cli/rate.js +294 -0
  52. package/dist/cli/rate.js.map +1 -0
  53. package/dist/config/process-env.d.ts.map +1 -1
  54. package/dist/config/process-env.js +10 -0
  55. package/dist/config/process-env.js.map +1 -1
  56. package/dist/enterprise/vault.d.ts.map +1 -1
  57. package/dist/enterprise/vault.js +5 -21
  58. package/dist/enterprise/vault.js.map +1 -1
  59. package/dist/gateway/hook-contract.d.ts +33 -0
  60. package/dist/gateway/hook-contract.d.ts.map +1 -1
  61. package/dist/gateway/hook-contract.js +97 -0
  62. package/dist/gateway/hook-contract.js.map +1 -1
  63. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  64. package/dist/inference/gemini-adapter.js +4 -0
  65. package/dist/inference/gemini-adapter.js.map +1 -1
  66. package/dist/inference/interface.d.ts +37 -0
  67. package/dist/inference/interface.d.ts.map +1 -1
  68. package/dist/inference/native-tools.d.ts +16 -1
  69. package/dist/inference/native-tools.d.ts.map +1 -1
  70. package/dist/inference/native-tools.js +34 -10
  71. package/dist/inference/native-tools.js.map +1 -1
  72. package/dist/inference/route-resolver.d.ts +30 -0
  73. package/dist/inference/route-resolver.d.ts.map +1 -1
  74. package/dist/inference/route-resolver.js +158 -1
  75. package/dist/inference/route-resolver.js.map +1 -1
  76. package/dist/inference/tools.d.ts +16 -1
  77. package/dist/inference/tools.d.ts.map +1 -1
  78. package/dist/inference/tools.js +51 -18
  79. package/dist/inference/tools.js.map +1 -1
  80. package/dist/learning/acceptance-model.d.ts +247 -0
  81. package/dist/learning/acceptance-model.d.ts.map +1 -0
  82. package/dist/learning/acceptance-model.js +708 -0
  83. package/dist/learning/acceptance-model.js.map +1 -0
  84. package/dist/learning/auto-router.d.ts +68 -24
  85. package/dist/learning/auto-router.d.ts.map +1 -1
  86. package/dist/learning/auto-router.js +301 -96
  87. package/dist/learning/auto-router.js.map +1 -1
  88. package/dist/learning/autonomy-policy.d.ts +12 -16
  89. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  90. package/dist/learning/autonomy-policy.js +50 -48
  91. package/dist/learning/autonomy-policy.js.map +1 -1
  92. package/dist/learning/behavioural-labels.d.ts +86 -0
  93. package/dist/learning/behavioural-labels.d.ts.map +1 -0
  94. package/dist/learning/behavioural-labels.js +166 -0
  95. package/dist/learning/behavioural-labels.js.map +1 -0
  96. package/dist/learning/capability-evidence.d.ts +167 -0
  97. package/dist/learning/capability-evidence.d.ts.map +1 -0
  98. package/dist/learning/capability-evidence.js +210 -0
  99. package/dist/learning/capability-evidence.js.map +1 -0
  100. package/dist/learning/capability-readiness.d.ts +64 -0
  101. package/dist/learning/capability-readiness.d.ts.map +1 -0
  102. package/dist/learning/capability-readiness.js +74 -0
  103. package/dist/learning/capability-readiness.js.map +1 -0
  104. package/dist/learning/capability-types.d.ts +169 -0
  105. package/dist/learning/capability-types.d.ts.map +1 -0
  106. package/dist/learning/capability-types.js +20 -0
  107. package/dist/learning/capability-types.js.map +1 -0
  108. package/dist/learning/catalog-feed.d.ts +89 -0
  109. package/dist/learning/catalog-feed.d.ts.map +1 -0
  110. package/dist/learning/catalog-feed.js +211 -0
  111. package/dist/learning/catalog-feed.js.map +1 -0
  112. package/dist/learning/cli-intent-effects.d.ts +60 -0
  113. package/dist/learning/cli-intent-effects.d.ts.map +1 -0
  114. package/dist/learning/cli-intent-effects.js +153 -0
  115. package/dist/learning/cli-intent-effects.js.map +1 -0
  116. package/dist/learning/command-adaptation.d.ts +41 -0
  117. package/dist/learning/command-adaptation.d.ts.map +1 -0
  118. package/dist/learning/command-adaptation.js +122 -0
  119. package/dist/learning/command-adaptation.js.map +1 -0
  120. package/dist/learning/consent-picture.d.ts +42 -0
  121. package/dist/learning/consent-picture.d.ts.map +1 -0
  122. package/dist/learning/consent-picture.js +53 -0
  123. package/dist/learning/consent-picture.js.map +1 -0
  124. package/dist/learning/context-budget.d.ts +37 -6
  125. package/dist/learning/context-budget.d.ts.map +1 -1
  126. package/dist/learning/context-budget.js +38 -9
  127. package/dist/learning/context-budget.js.map +1 -1
  128. package/dist/learning/decision-log.d.ts +114 -0
  129. package/dist/learning/decision-log.d.ts.map +1 -0
  130. package/dist/learning/decision-log.js +296 -0
  131. package/dist/learning/decision-log.js.map +1 -0
  132. package/dist/learning/deliverable-class.d.ts.map +1 -1
  133. package/dist/learning/deliverable-class.js +19 -0
  134. package/dist/learning/deliverable-class.js.map +1 -1
  135. package/dist/learning/deliverable-corpus.d.ts +117 -0
  136. package/dist/learning/deliverable-corpus.d.ts.map +1 -0
  137. package/dist/learning/deliverable-corpus.js +176 -0
  138. package/dist/learning/deliverable-corpus.js.map +1 -0
  139. package/dist/learning/error-repair.d.ts +22 -1
  140. package/dist/learning/error-repair.d.ts.map +1 -1
  141. package/dist/learning/error-repair.js +37 -0
  142. package/dist/learning/error-repair.js.map +1 -1
  143. package/dist/learning/exhaustion-report.d.ts +136 -0
  144. package/dist/learning/exhaustion-report.d.ts.map +1 -0
  145. package/dist/learning/exhaustion-report.js +239 -0
  146. package/dist/learning/exhaustion-report.js.map +1 -0
  147. package/dist/learning/failure-bookkeeping.d.ts +16 -0
  148. package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
  149. package/dist/learning/failure-bookkeeping.js +51 -5
  150. package/dist/learning/failure-bookkeeping.js.map +1 -1
  151. package/dist/learning/gate-friction.d.ts +39 -0
  152. package/dist/learning/gate-friction.d.ts.map +1 -0
  153. package/dist/learning/gate-friction.js +91 -0
  154. package/dist/learning/gate-friction.js.map +1 -0
  155. package/dist/learning/harness-narration.d.ts +34 -0
  156. package/dist/learning/harness-narration.d.ts.map +1 -0
  157. package/dist/learning/harness-narration.js +58 -0
  158. package/dist/learning/harness-narration.js.map +1 -0
  159. package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
  160. package/dist/learning/hub-skill-catalog.js +1 -2
  161. package/dist/learning/hub-skill-catalog.js.map +1 -1
  162. package/dist/learning/hybrid-router.d.ts +65 -0
  163. package/dist/learning/hybrid-router.d.ts.map +1 -1
  164. package/dist/learning/hybrid-router.js +186 -0
  165. package/dist/learning/hybrid-router.js.map +1 -1
  166. package/dist/learning/knowledge-base.d.ts +229 -3
  167. package/dist/learning/knowledge-base.d.ts.map +1 -1
  168. package/dist/learning/knowledge-base.js +593 -24
  169. package/dist/learning/knowledge-base.js.map +1 -1
  170. package/dist/learning/knowledge-eval.d.ts +171 -0
  171. package/dist/learning/knowledge-eval.d.ts.map +1 -0
  172. package/dist/learning/knowledge-eval.js +196 -0
  173. package/dist/learning/knowledge-eval.js.map +1 -0
  174. package/dist/learning/knowledge-turn.d.ts +92 -0
  175. package/dist/learning/knowledge-turn.d.ts.map +1 -0
  176. package/dist/learning/knowledge-turn.js +190 -0
  177. package/dist/learning/knowledge-turn.js.map +1 -0
  178. package/dist/learning/learned-commands.d.ts +64 -0
  179. package/dist/learning/learned-commands.d.ts.map +1 -0
  180. package/dist/learning/learned-commands.js +108 -0
  181. package/dist/learning/learned-commands.js.map +1 -0
  182. package/dist/learning/lexical-search.d.ts +50 -0
  183. package/dist/learning/lexical-search.d.ts.map +1 -0
  184. package/dist/learning/lexical-search.js +111 -0
  185. package/dist/learning/lexical-search.js.map +1 -0
  186. package/dist/learning/machine-facts.d.ts +71 -0
  187. package/dist/learning/machine-facts.d.ts.map +1 -0
  188. package/dist/learning/machine-facts.js +164 -0
  189. package/dist/learning/machine-facts.js.map +1 -0
  190. package/dist/learning/markdown-outline.d.ts +73 -0
  191. package/dist/learning/markdown-outline.d.ts.map +1 -0
  192. package/dist/learning/markdown-outline.js +136 -0
  193. package/dist/learning/markdown-outline.js.map +1 -0
  194. package/dist/learning/model-first-router.d.ts +33 -1
  195. package/dist/learning/model-first-router.d.ts.map +1 -1
  196. package/dist/learning/model-first-router.js +123 -9
  197. package/dist/learning/model-first-router.js.map +1 -1
  198. package/dist/learning/model-identity.d.ts +80 -0
  199. package/dist/learning/model-identity.d.ts.map +1 -0
  200. package/dist/learning/model-identity.js +115 -0
  201. package/dist/learning/model-identity.js.map +1 -0
  202. package/dist/learning/model-registry.d.ts +141 -2
  203. package/dist/learning/model-registry.d.ts.map +1 -1
  204. package/dist/learning/model-registry.js +324 -38
  205. package/dist/learning/model-registry.js.map +1 -1
  206. package/dist/learning/model-scoring.d.ts.map +1 -1
  207. package/dist/learning/model-scoring.js +8 -1
  208. package/dist/learning/model-scoring.js.map +1 -1
  209. package/dist/learning/model-selection.d.ts.map +1 -1
  210. package/dist/learning/model-selection.js +41 -0
  211. package/dist/learning/model-selection.js.map +1 -1
  212. package/dist/learning/outcome-observation.d.ts +49 -0
  213. package/dist/learning/outcome-observation.d.ts.map +1 -0
  214. package/dist/learning/outcome-observation.js +95 -0
  215. package/dist/learning/outcome-observation.js.map +1 -0
  216. package/dist/learning/pair-entitlement.d.ts +184 -0
  217. package/dist/learning/pair-entitlement.d.ts.map +1 -0
  218. package/dist/learning/pair-entitlement.js +214 -0
  219. package/dist/learning/pair-entitlement.js.map +1 -0
  220. package/dist/learning/provider-fallback.d.ts +23 -1
  221. package/dist/learning/provider-fallback.d.ts.map +1 -1
  222. package/dist/learning/provider-fallback.js +24 -0
  223. package/dist/learning/provider-fallback.js.map +1 -1
  224. package/dist/learning/reasoning-trace.d.ts +65 -1
  225. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  226. package/dist/learning/reasoning-trace.js +123 -1
  227. package/dist/learning/reasoning-trace.js.map +1 -1
  228. package/dist/learning/requirement-probe.d.ts +55 -0
  229. package/dist/learning/requirement-probe.d.ts.map +1 -0
  230. package/dist/learning/requirement-probe.js +77 -0
  231. package/dist/learning/requirement-probe.js.map +1 -0
  232. package/dist/learning/resilient-call.d.ts +20 -1
  233. package/dist/learning/resilient-call.d.ts.map +1 -1
  234. package/dist/learning/resilient-call.js +42 -4
  235. package/dist/learning/resilient-call.js.map +1 -1
  236. package/dist/learning/response-usability.d.ts +85 -0
  237. package/dist/learning/response-usability.d.ts.map +1 -0
  238. package/dist/learning/response-usability.js +135 -0
  239. package/dist/learning/response-usability.js.map +1 -0
  240. package/dist/learning/router-bandit.d.ts +132 -0
  241. package/dist/learning/router-bandit.d.ts.map +1 -1
  242. package/dist/learning/router-bandit.js +253 -0
  243. package/dist/learning/router-bandit.js.map +1 -1
  244. package/dist/learning/routing-history.d.ts +41 -2
  245. package/dist/learning/routing-history.d.ts.map +1 -1
  246. package/dist/learning/routing-history.js.map +1 -1
  247. package/dist/learning/run-trace.d.ts +21 -0
  248. package/dist/learning/run-trace.d.ts.map +1 -1
  249. package/dist/learning/run-trace.js +38 -0
  250. package/dist/learning/run-trace.js.map +1 -1
  251. package/dist/learning/session-grant.d.ts +75 -0
  252. package/dist/learning/session-grant.d.ts.map +1 -0
  253. package/dist/learning/session-grant.js +112 -0
  254. package/dist/learning/session-grant.js.map +1 -0
  255. package/dist/learning/task-model-continuity.d.ts +73 -0
  256. package/dist/learning/task-model-continuity.d.ts.map +1 -0
  257. package/dist/learning/task-model-continuity.js +87 -0
  258. package/dist/learning/task-model-continuity.js.map +1 -0
  259. package/dist/learning/turn-feedback.d.ts +93 -0
  260. package/dist/learning/turn-feedback.d.ts.map +1 -0
  261. package/dist/learning/turn-feedback.js +127 -0
  262. package/dist/learning/turn-feedback.js.map +1 -0
  263. package/dist/learning/turn-report.d.ts +104 -2
  264. package/dist/learning/turn-report.d.ts.map +1 -1
  265. package/dist/learning/turn-report.js +161 -6
  266. package/dist/learning/turn-report.js.map +1 -1
  267. package/dist/learning/verdict-routing.d.ts +51 -0
  268. package/dist/learning/verdict-routing.d.ts.map +1 -0
  269. package/dist/learning/verdict-routing.js +122 -0
  270. package/dist/learning/verdict-routing.js.map +1 -0
  271. package/dist/mcp/client.d.ts.map +1 -1
  272. package/dist/mcp/client.js +5 -0
  273. package/dist/mcp/client.js.map +1 -1
  274. package/dist/mcp/manager.d.ts +10 -0
  275. package/dist/mcp/manager.d.ts.map +1 -1
  276. package/dist/mcp/manager.js +20 -0
  277. package/dist/mcp/manager.js.map +1 -1
  278. package/dist/mcp/mcp-discovery.d.ts +34 -0
  279. package/dist/mcp/mcp-discovery.d.ts.map +1 -0
  280. package/dist/mcp/mcp-discovery.js +71 -0
  281. package/dist/mcp/mcp-discovery.js.map +1 -0
  282. package/dist/mcp/mcp-schema-cache.d.ts +17 -0
  283. package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
  284. package/dist/mcp/mcp-schema-cache.js +47 -3
  285. package/dist/mcp/mcp-schema-cache.js.map +1 -1
  286. package/dist/mcp/types.d.ts +28 -0
  287. package/dist/mcp/types.d.ts.map +1 -1
  288. package/dist/mcp/types.js.map +1 -1
  289. package/dist/tools/ask-user.d.ts.map +1 -1
  290. package/dist/tools/ask-user.js +4 -0
  291. package/dist/tools/ask-user.js.map +1 -1
  292. package/dist/tools/capability-registry.d.ts +150 -0
  293. package/dist/tools/capability-registry.d.ts.map +1 -0
  294. package/dist/tools/capability-registry.js +400 -0
  295. package/dist/tools/capability-registry.js.map +1 -0
  296. package/dist/tools/child-agent-runtime.d.ts.map +1 -1
  297. package/dist/tools/child-agent-runtime.js +19 -5
  298. package/dist/tools/child-agent-runtime.js.map +1 -1
  299. package/dist/tools/coding-tools.d.ts +17 -5
  300. package/dist/tools/coding-tools.d.ts.map +1 -1
  301. package/dist/tools/coding-tools.js +80 -11
  302. package/dist/tools/coding-tools.js.map +1 -1
  303. package/dist/tools/command-memo.d.ts +116 -0
  304. package/dist/tools/command-memo.d.ts.map +1 -0
  305. package/dist/tools/command-memo.js +202 -0
  306. package/dist/tools/command-memo.js.map +1 -0
  307. package/dist/tools/git-tool.d.ts.map +1 -1
  308. package/dist/tools/git-tool.js +13 -3
  309. package/dist/tools/git-tool.js.map +1 -1
  310. package/dist/tools/harness-directive.d.ts +42 -0
  311. package/dist/tools/harness-directive.d.ts.map +1 -0
  312. package/dist/tools/harness-directive.js +45 -0
  313. package/dist/tools/harness-directive.js.map +1 -0
  314. package/dist/tools/loop-skill-hint.d.ts +74 -174
  315. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  316. package/dist/tools/loop-skill-hint.js +110 -536
  317. package/dist/tools/loop-skill-hint.js.map +1 -1
  318. package/dist/tools/mcp-client-tool.d.ts +2 -0
  319. package/dist/tools/mcp-client-tool.d.ts.map +1 -1
  320. package/dist/tools/mcp-client-tool.js +3 -0
  321. package/dist/tools/mcp-client-tool.js.map +1 -1
  322. package/dist/tools/modality/shared.d.ts +1 -4
  323. package/dist/tools/modality/shared.d.ts.map +1 -1
  324. package/dist/tools/modality/shared.js +7 -22
  325. package/dist/tools/modality/shared.js.map +1 -1
  326. package/dist/tools/plan-store.d.ts +20 -2
  327. package/dist/tools/plan-store.d.ts.map +1 -1
  328. package/dist/tools/plan-store.js +51 -7
  329. package/dist/tools/plan-store.js.map +1 -1
  330. package/dist/tools/registry.d.ts +76 -3
  331. package/dist/tools/registry.d.ts.map +1 -1
  332. package/dist/tools/registry.js +486 -26
  333. package/dist/tools/registry.js.map +1 -1
  334. package/dist/tools/remediation-ladder.d.ts.map +1 -1
  335. package/dist/tools/remediation-ladder.js +8 -0
  336. package/dist/tools/remediation-ladder.js.map +1 -1
  337. package/dist/tools/run-cli.d.ts.map +1 -1
  338. package/dist/tools/run-cli.js +29 -4
  339. package/dist/tools/run-cli.js.map +1 -1
  340. package/dist/tools/run-terminal.d.ts +11 -0
  341. package/dist/tools/run-terminal.d.ts.map +1 -1
  342. package/dist/tools/run-terminal.js +265 -10
  343. package/dist/tools/run-terminal.js.map +1 -1
  344. package/dist/tools/tool-loop.d.ts +321 -13
  345. package/dist/tools/tool-loop.d.ts.map +1 -1
  346. package/dist/tools/tool-loop.js +867 -66
  347. package/dist/tools/tool-loop.js.map +1 -1
  348. package/dist/utils/atomic-store.d.ts +53 -0
  349. package/dist/utils/atomic-store.d.ts.map +1 -0
  350. package/dist/utils/atomic-store.js +178 -0
  351. package/dist/utils/atomic-store.js.map +1 -0
  352. package/dist/utils/binary-probe.d.ts +25 -0
  353. package/dist/utils/binary-probe.d.ts.map +1 -0
  354. package/dist/utils/binary-probe.js +48 -0
  355. package/dist/utils/binary-probe.js.map +1 -0
  356. package/dist/utils/workspace-path.d.ts +50 -0
  357. package/dist/utils/workspace-path.d.ts.map +1 -0
  358. package/dist/utils/workspace-path.js +86 -0
  359. package/dist/utils/workspace-path.js.map +1 -0
  360. package/dist/web-dashboard/chat-console.d.ts +24 -0
  361. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  362. package/dist/web-dashboard/chat-console.js +38 -0
  363. package/dist/web-dashboard/chat-console.js.map +1 -1
  364. package/dist/web-dashboard/project-context.d.ts +27 -2
  365. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  366. package/dist/web-dashboard/project-context.js +32 -2
  367. package/dist/web-dashboard/project-context.js.map +1 -1
  368. package/dist/web-dashboard/server.d.ts +11 -0
  369. package/dist/web-dashboard/server.d.ts.map +1 -1
  370. package/dist/web-dashboard/server.js +362 -11
  371. package/dist/web-dashboard/server.js.map +1 -1
  372. package/dist/web-dashboard/src/types.d.ts +170 -1
  373. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  374. package/dist/web-dashboard/workspace-guard.d.ts +11 -2
  375. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  376. package/dist/web-dashboard/workspace-guard.js +24 -12
  377. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  378. package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
  379. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
  380. package/dist/web-dashboard/workspace-resolution.js +105 -0
  381. package/dist/web-dashboard/workspace-resolution.js.map +1 -0
  382. package/package.json +1 -1
  383. package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
  384. package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
  385. package/src/web-dashboard/public/index.html +1 -1
  386. package/src/web-dashboard/public/assets/index-DhiuR4S2.js +0 -210
  387. package/src/web-dashboard/public/assets/index-DhiuR4S2.js.map +0 -1
package/dist/cli/chat.js CHANGED
@@ -5,13 +5,14 @@ import { Command } from 'commander';
5
5
  import inquirer from 'inquirer';
6
6
  import { BaseCommand } from './commands.js';
7
7
  import { resolveProvider } from './router.js';
8
- import { resolveRoute, strictModelMode } from '../inference/route-resolver.js';
8
+ import { resolveRoute, strictModelMode, strictPinRefusal } from '../inference/route-resolver.js';
9
9
  import { showModelPicker } from './model-picker.js';
10
10
  import { ContextParser } from '../context/parser.js';
11
11
  import { getCache } from '../context/cache.js';
12
12
  import { assembleContext, retrievalOptionsFromConfig, recordRetrievalStats } from '../learning/retrieval.js';
13
13
  import { getChatHistory } from '../context/history.js';
14
14
  import { maybeAutoRecall, recallContextBlock } from '../context/session-recall.js';
15
+ import { decisionsRecordingEnabled, recallDecisionBlock } from '../learning/decision-log.js';
15
16
  import { getMemoryManager } from '../memory/manager.js';
16
17
  import { logger } from '../utils/logger.js';
17
18
  import { printOrchestrationResult } from './execute.js';
@@ -24,12 +25,17 @@ import { closeResume, openResume, resolveResumeRequest, } from '../learning/step
24
25
  import { applyActiveModel } from './model.js';
25
26
  import { capabilityReasoningEffort, isMaxCapability } from '../config/capability-mode.js';
26
27
  import { getProviderFallback, classifyFallbackError, isRetryableError, isTransientForRetry, recordRegistrySuccess } from '../learning/provider-fallback.js';
27
- import { recordActionFailure } from '../learning/failure-bookkeeping.js';
28
+ import { recordActionFailure, } from '../learning/failure-bookkeeping.js';
29
+ import { getRouterBandit } from '../learning/router-bandit.js';
30
+ import { UnusableModelResponseError } from '../learning/response-usability.js';
28
31
  import { resolveThreadBudgetChars } from '../learning/context-budget.js';
29
32
  import { getAutoRouter, isAgentAutoRoute, isAutoModel, isAutoProvider, governanceVerdict, adminBudgetVerdict } from '../learning/auto-router.js';
30
33
  import { continuationSoftwareText } from '../learning/continuation-intent.js';
34
+ import { TaskModelContinuity, taskSignature } from '../learning/task-model-continuity.js';
35
+ import { describeFailureKind, renderExhaustionReport, } from '../learning/exhaustion-report.js';
31
36
  import { estimateTokens } from '../learning/cost-tracker.js';
32
37
  import { getModelRegistry } from '../learning/model-registry.js';
38
+ import { costSince } from '../learning/cost-tracker.js';
33
39
  import { refreshModelRegistry } from '../inference/model-probe.js';
34
40
  import { startWarmupDaemon } from '../learning/model-warmup.js';
35
41
  import { recordRoutingDecision } from '../learning/routing-history.js';
@@ -47,12 +53,15 @@ import { runToolLoop, extractFallbackToolCalls } from '../tools/tool-loop.js';
47
53
  import { flushSpans, otelNoticeOnce, startTurnSpan } from '../observability/otel.js';
48
54
  import { detectAnswerQualityFailure, answerQualityError, toUserFacingGenerationError, isToolCallingUnsupported, stripToolCallArtifacts, } from '../inference/tool-call-utils.js';
49
55
  import { beginTrace, endTrace, recordStep, recordTraceEvent, recordTraceFindings, recordTurnReport, buildTraceOutcome, traceOutcomeSucceeded } from '../learning/reasoning-trace.js';
50
- import { recordWorkingState, getWorkingState, formatWorkingState, isProjectLedgerDir } from '../learning/working-state.js';
56
+ import { recordWorkingState, getWorkingState, formatWorkingState, isProjectLedgerDir, detectRegressionSignal } from '../learning/working-state.js';
57
+ import { recordDeliverableCandidate, markLastDeliverableRejected } from '../learning/deliverable-corpus.js';
58
+ import { deriveAndApplyBehaviouralLabel } from '../learning/behavioural-labels.js';
51
59
  import { getLoopExposureMode } from '../tools/toolsets.js';
52
60
  import { resolveModelHarnessProfile, shouldSkipNativeTools, isAgenticCapableModel } from '../learning/model-harness.js';
53
61
  import { assertAgenticRoute, setWeakModelConsent, resolveWeakModelPolicy, weakRouteNotice } from '../learning/agentic-route-gate.js';
54
62
  import { resolvePromptBudget, measurePromptBudget, formatPromptBudgetBreakdown } from '../learning/prompt-budget.js';
55
63
  import { buildTurnReport, formatTurnReport } from '../learning/turn-report.js';
64
+ import { turnOutcomeObservation } from '../learning/outcome-observation.js';
56
65
  import { resolveAdapterDefault, hasCredentials } from '../learning/model-selection.js';
57
66
  import { buildLoopProjectContext } from '../tools/loop-project-context.js';
58
67
  import { sweepTransientFailures, collectionRevivalStore } from '../learning/provider-revival.js';
@@ -360,7 +369,10 @@ export function turnCarriesHonestyFlag(result) {
360
369
  result.unverifiedBuildClaim ||
361
370
  result.unverifiedEdit ||
362
371
  result.unverifiedEditClaim ||
363
- result.noActionTaken);
372
+ result.noActionTaken ||
373
+ result.artifactIncomplete ||
374
+ result.unverifiedFileClaim ||
375
+ result.artifactShortfall);
364
376
  }
365
377
  // ─── ChatCommand ────────────────────────────────────────────────────────────
366
378
  export class ChatCommand extends BaseCommand {
@@ -391,6 +403,40 @@ export class ChatCommand extends BaseCommand {
391
403
  * rate-limited (a genuinely shared limit).
392
404
  */
393
405
  sessionFailedModels = new Map();
406
+ /**
407
+ * P3 (fix_model_routing) — task-level model CONTINUITY.
408
+ *
409
+ * The pair that produced usable work for a task is remembered per task
410
+ * signature, and the next turn of the SAME task (a "resume"/"continue" whose
411
+ * routing text resolves to the same ask) offers it FIRST. Routing re-decides
412
+ * every turn, and a signal-free continuation re-decided from scratch is how a
413
+ * working run degraded onto a model that then answered with nothing.
414
+ *
415
+ * A preference, never a pin: the recalled pair goes through the SAME
416
+ * credential/exclusion/registry gates as any other candidate, and the normal
417
+ * pool stands behind it — so a model that has since died costs one check.
418
+ */
419
+ taskContinuity = new TaskModelContinuity();
420
+ /** The task signature of the turn being routed (see routeMessageAuto). */
421
+ lastTaskSignature;
422
+ /**
423
+ * P4 (fix_model_routing) — what THIS turn actually tried, and why each
424
+ * candidate did not answer. Reset per turn; consumed by the exhaustion report
425
+ * when a turn ends without an answer. Chat's own failover walk is the
426
+ * authority here (the shared walk in `resilient-call.ts` keeps its own log for
427
+ * the gateway/orchestrator paths), so the report can name the models the user
428
+ * was actually served by — not a pool that was considered.
429
+ */
430
+ turnAttempts = [];
431
+ /** Record one failed/skipped attempt for this turn's exhaustion report. */
432
+ recordTurnAttempt(attempt) {
433
+ try {
434
+ this.turnAttempts.push(attempt);
435
+ }
436
+ catch {
437
+ // Best-effort — telemetry must never break a turn.
438
+ }
439
+ }
394
440
  // RATE_LIMIT_EXCLUSION_MS + TRANSIENT_FAILURE_EXCLUSION_MS now live in
395
441
  // src/learning/failure-bookkeeping.ts (shared with every action) — see
396
442
  // recordActionFailure. Behavior is identical: same values, same semantics.
@@ -489,6 +535,11 @@ export class ChatCommand extends BaseCommand {
489
535
  // B — captured from the route so the post-route capability gate below can
490
536
  // judge the FINAL pair without re-resolving anything.
491
537
  let routeVerdict;
538
+ // C6 — the turn's economy window, taken before the FIRST provider call. The
539
+ // turn report states what THIS turn cost from the persisted ledger; a
540
+ // continuation or a resumed turn runs in a fresh process, so a session
541
+ // counter would report zero for it (`costSince` reads timestamps instead).
542
+ const turnCostSince = Date.now();
492
543
  let routedText;
493
544
  if (autoMode) {
494
545
  // Intent-aware escalation: a bare "yes"/"do it" continuing software work
@@ -609,9 +660,27 @@ export class ChatCommand extends BaseCommand {
609
660
  recallBlock = undefined;
610
661
  }
611
662
  }
663
+ // Decision recall — the SAME attached project's recorded decisions
664
+ // (`.nuvira/decisions.jsonl`), when this ask actually mentions one. It is
665
+ // ADVISORY: it is injected as context and NEVER suppresses an `ask_user`, so
666
+ // a situation that has changed can still ask. Best-effort: an empty log (or
667
+ // no matching token) injects nothing, and a read failure never breaks the
668
+ // turn. Gated by `decisionsRecordingEnabled()` so a test-run turn is inert
669
+ // unless it explicitly opts in (NUVIRA_DECISION_LOG=on).
670
+ let decisionBlock;
671
+ if (opts.projectPath && decisionsRecordingEnabled()) {
672
+ try {
673
+ const block = recallDecisionBlock(opts.projectPath, message, 3);
674
+ if (block)
675
+ decisionBlock = block;
676
+ }
677
+ catch {
678
+ decisionBlock = undefined;
679
+ }
680
+ }
612
681
  const parsed = parseRequestSync(message);
613
682
  const dispatchDecision = resolvePipelineDispatch(parsed, { dev: opts.dev, text: message });
614
- const answer = await this.runChatAnswer(message, opts.history ?? [], { type, provider, model }, { provider: mergedOpts.provider, model: mergedOpts.model, dev: mergedOpts.dev, cache: true }, true, { auto: autoMode }, parsed, { askUser: opts.askUser, onProgress: opts.onProgress, onToolCall: opts.onToolCall, onPlanChange: opts.onPlanChange, onGitDiff: opts.onGitDiff, onSkillDraft: opts.onSkillDraft, onFinding: opts.onFinding, planStore: opts.planStore ?? this.planStore, gateway: opts.gateway, projectContext: opts.projectContext, recallContext: recallBlock, projectPath: opts.projectPath, onToken: opts.onToken, signal: opts.signal, continuation: opts.continuation, systemPolicy: opts.systemPolicy, debugSurface: opts.debugSurface ?? 'cli-chat', debugSession: opts.debugSession, worktree: opts.worktree, keepWorktree: opts.keepWorktree, resume: opts.resume });
683
+ const answer = await this.runChatAnswer(message, opts.history ?? [], { type, provider, model }, { provider: mergedOpts.provider, model: mergedOpts.model, dev: mergedOpts.dev, cache: true }, true, { auto: autoMode }, parsed, { askUser: opts.askUser, onProgress: opts.onProgress, onToolCall: opts.onToolCall, onPlanChange: opts.onPlanChange, onGitDiff: opts.onGitDiff, onSkillDraft: opts.onSkillDraft, onFinding: opts.onFinding, planStore: opts.planStore ?? this.planStore, gateway: opts.gateway, projectContext: opts.projectContext, recallContext: recallBlock, decisionContext: decisionBlock, projectPath: opts.projectPath, onToken: opts.onToken, signal: opts.signal, continuation: opts.continuation, systemPolicy: opts.systemPolicy, debugSurface: opts.debugSurface ?? 'cli-chat', debugSession: opts.debugSession, worktree: opts.worktree, keepWorktree: opts.keepWorktree, resume: opts.resume });
615
684
  // No-model fallback: the tool loop could not generate a single response
616
685
  // AND the rules assessed a high-confidence pipeline intent — run the
617
686
  // pipeline directly (rules decide only when the model is unavailable; the
@@ -622,6 +691,15 @@ export class ChatCommand extends BaseCommand {
622
691
  // dispatching it here would run the ask on another engine entirely — the one
623
692
  // path that can run it UNISOLATED while the caller asked for isolation.
624
693
  if (answer.generationFailed && !answer.refused && dispatchDecision.dispatch && !dispatchDecision.needConfirm) {
694
+ // E2 — this CHANGES THE EXECUTION MODEL: the ask leaves the single chat tool
695
+ // loop and runs as a multi-agent pipeline. Falling back is legitimate
696
+ // (nothing was generated), but it must never be silent. A pinned ask that
697
+ // flips engines without saying so is the same defect as an unattended
698
+ // auto-pick reported as the user's own choice: the user is owed the reason
699
+ // and the fact that their pin is the pair being carried over.
700
+ opts.onProgress?.(` ⤵️ the chat turn produced no answer on ${provider.name}${model ? ` / ${model}` : ''} — ` +
701
+ `running this ask in the multi-agent pipeline instead (a different execution model` +
702
+ `${autoMode ? '' : ', on the same pinned pair'})`);
625
703
  const r = await runPipelineTool(message, this.configManager, { provider: type, model, board: false });
626
704
  // `success`, not `error`: a pipeline that RAN and failed reports its
627
705
  // outcome in `summary` and only sometimes sets `error`, so keying off
@@ -667,6 +745,16 @@ export class ChatCommand extends BaseCommand {
667
745
  successfulToolCalls: answer.successfulToolCalls,
668
746
  mutations: answer.runTrace?.mutations,
669
747
  changedPaths: answer.runTrace?.paths,
748
+ // E1 — decisions the harness took on the user's behalf (an unattended
749
+ // ask_user default), so the report discloses them instead of letting the
750
+ // answer read as a choice the user made.
751
+ assumptions: answer.runTrace?.assumptions,
752
+ // C6 — what the turn cost, from the ledger, so an expensive turn is visible
753
+ // where the turn is described instead of only in a session total.
754
+ cost: (() => {
755
+ const spend = costSince(turnCostSince);
756
+ return { usd: spend.costUsd, tokens: spend.tokens, calls: spend.requests };
757
+ })(),
670
758
  flags: {
671
759
  unverifiedActionClaim: answer.unverifiedActionClaim,
672
760
  unverifiedEdit: answer.unverifiedEdit,
@@ -675,6 +763,15 @@ export class ChatCommand extends BaseCommand {
675
763
  undeliveredArtifact: answer.undeliveredArtifact,
676
764
  unfulfilledPromise: answer.unfulfilledPromise,
677
765
  noActionTaken: answer.noActionTaken,
766
+ // Bundle 19 — the artifact's own admission travels to the report, so the
767
+ // turn cannot read as finished work on any surface that shows it.
768
+ incompleteArtifactClaim: Boolean(answer.artifactIncomplete),
769
+ // Bundle 20 — a fabricated "I wrote the file" reaches the report for the
770
+ // same reason: no surface may read such a turn as finished work.
771
+ unverifiedFileClaim: Boolean(answer.unverifiedFileClaim),
772
+ // Bundle 23 — a file far short of the stated magnitude is the same:
773
+ // the delivered artifact is not what was asked for.
774
+ artifactShortfall: Boolean(answer.artifactShortfall),
678
775
  },
679
776
  });
680
777
  }
@@ -685,6 +782,109 @@ export class ChatCommand extends BaseCommand {
685
782
  // reviewable after the fact (the Trace tab renders it), not only in this
686
783
  // turn's return value. Best-effort: a trace write never breaks a turn.
687
784
  recordTurnReport(answer.traceId, turnReport);
785
+ // The user's message for THIS turn is also a verdict on the PREVIOUS one
786
+ // ("still broken", "no change", or a near-identical re-ask). It is resolved
787
+ // BEFORE this turn's own delivery is recorded, because these signals judge the
788
+ // PREVIOUS turn — run after the record below, they would label the row this very
789
+ // turn just created. Derived ONCE here, because two independent consumers need
790
+ // the same answer: the bandit's `userAccepted` and the deliverable corpus's
791
+ // label. Neither trusts the model's own account of itself.
792
+ const userReportsRegression = detectRegressionSignal(message);
793
+ if (userReportsRegression) {
794
+ // Label the previous authored delivery as a miss. Independent of routing:
795
+ // the corpus is collected whether or not the bandit is on.
796
+ try {
797
+ markLastDeliverableRejected();
798
+ }
799
+ catch {
800
+ // Collection is best-effort.
801
+ }
802
+ }
803
+ // Tier 3 — the verdict the harness can infer from BEHAVIOUR, when the user did
804
+ // not report a regression: a re-ask or a hand-edited artifact is a rejection, an
805
+ // untouched artifact the user then REFERENCES is a (weak) acceptance. Recorded
806
+ // `source: 'derived'` so a fit can weigh it below an explicit verdict. Stands
807
+ // aside when a regression was reported — that path already owns the label.
808
+ if (!userReportsRegression) {
809
+ try {
810
+ deriveAndApplyBehaviouralLabel({ newAsk: message, reportsRegression: false });
811
+ }
812
+ catch {
813
+ // Collection is best-effort.
814
+ }
815
+ }
816
+ // Item 13 (round 3) — COLLECT, never judge. An authored artifact that met the
817
+ // ask's stated magnitude and did not admit its own omission is the "fluent,
818
+ // on-length" delivery no rule can score; the corpus records it so that a
819
+ // LATER correction can label it. This is a dataset, not a detector: nothing
820
+ // reads it, and no score is derived from it. Recorded LAST so the verdict-on-
821
+ // previous above never sees this turn's own row.
822
+ if (answer.authoredDeliverable) {
823
+ try {
824
+ recordDeliverableCandidate({
825
+ ask: message,
826
+ path: answer.authoredDeliverable.path,
827
+ deliveredWords: answer.authoredDeliverable.deliveredWords,
828
+ ...(answer.authoredDeliverable.targetWords !== undefined
829
+ ? { targetWords: answer.authoredDeliverable.targetWords }
830
+ : {}),
831
+ ...(turnReport?.verification ? { verification: turnReport.verification } : {}),
832
+ // The trace link is what lets an EXPLICIT verdict (`nuvira rate`) label
833
+ // THIS delivery rather than "the most recent one".
834
+ ...(answer.traceId ? { traceId: answer.traceId } : {}),
835
+ excerpt: answer.authoredDeliverable.excerpt,
836
+ });
837
+ }
838
+ catch {
839
+ // Collection is best-effort — never break a turn on a corpus write.
840
+ }
841
+ }
842
+ // Feed the MEASURED outcome back into the bandit on the CHAT path too. The
843
+ // orchestrator has done this for pipeline tasks all along; the chat turn —
844
+ // the product's most common entry point — never did, so every turn's real
845
+ // result was thrown away and the router learned from only one of its two
846
+ // execution models. The signal is the turn report's derived VERIFICATION
847
+ // verdict, never the model's own account of itself.
848
+ //
849
+ // Guarded exactly like the orchestrator: only when this turn was AUTO-routed
850
+ // (otherwise `getLastProvider` could reward a stale provider noted by an
851
+ // earlier auto run in this process) and only when bandit learning is on.
852
+ if (autoMode && this.configManager.getAll().routing?.bandit !== false) {
853
+ // `userAccepted: false` for the turn that just ran, applied BEFORE this
854
+ // turn's own outcome is recorded (recording overwrites the attribution the
855
+ // correction refers to). It is walked up to the previous AUTO-routed turn
856
+ // only — `recordUserRejection` no-ops when nothing is pending.
857
+ if (userReportsRegression) {
858
+ try {
859
+ getAutoRouter().recordUserRejection('chat');
860
+ }
861
+ catch {
862
+ // Learning is best-effort — never break a turn on a bandit error.
863
+ }
864
+ }
865
+ const observation = turnOutcomeObservation(turnReport);
866
+ if (observation) {
867
+ try {
868
+ getAutoRouter().recordOutcome('chat', message, observation.outcome, this.configManager, observation.outcomeData);
869
+ }
870
+ catch {
871
+ // Learning is best-effort — never break a turn on a bandit error.
872
+ }
873
+ // Bundle 3b: the SAME derived verdict also feeds the capability
874
+ // scorecard's accuracy parameter for the pair that actually served the
875
+ // turn. One translation of "did the work verify", two consumers — rather
876
+ // than a second, parallel notion of success that could drift from it.
877
+ // `blocked`/`not-applicable` write nothing (see the registry method).
878
+ try {
879
+ if (type && model) {
880
+ getModelRegistry().recordCapabilityEvidence(type, model, turnReport?.verification ?? 'not-applicable');
881
+ }
882
+ }
883
+ catch {
884
+ // Learning is best-effort — never break a turn on a scorecard write.
885
+ }
886
+ }
887
+ }
688
888
  // E3 — surface a non-trivial report on the console. A plain answer (no
689
889
  // plan, nothing changed) produces no summary and stays silent.
690
890
  if (turnReport?.summary) {
@@ -704,6 +904,14 @@ export class ChatCommand extends BaseCommand {
704
904
  unfulfilledPromise: answer.unfulfilledPromise,
705
905
  undeliveredArtifact: answer.undeliveredArtifact,
706
906
  unverifiedBuildClaim: answer.unverifiedBuildClaim,
907
+ // Bundle 19/20 — the reliability flags must not be dropped at this
908
+ // boundary, or a caller re-deriving the verdict would see a clean turn.
909
+ artifactIncomplete: answer.artifactIncomplete,
910
+ unverifiedFileClaim: answer.unverifiedFileClaim,
911
+ artifactShortfall: answer.artifactShortfall,
912
+ // Item 13 (round 3) — the collectable "fluent, on-length" delivery travels to
913
+ // the caller, which records it. Facts only; no verdict is attached here.
914
+ authoredDeliverable: answer.authoredDeliverable,
707
915
  // WS1 — the findings this turn recorded (empty when it recorded none).
708
916
  findings: answer.findings ?? [],
709
917
  provider: type,
@@ -791,6 +999,40 @@ export class ChatCommand extends BaseCommand {
791
999
  // Apply the active model state from `nuvira model switch` as defaults
792
1000
  const activeOpts = applyActiveModel({ provider: options?.provider, model: options?.model });
793
1001
  const mergedOpts = { ...options, provider: activeOpts.provider, model: activeOpts.model };
1002
+ // ── The CONFIG's defaultProvider is a routing directive too ──────────────
1003
+ // When the operator supplies neither a provider nor a model — no `-p`, no
1004
+ // `-m`, no `nuvira model switch` state — the config decides, and the shipped
1005
+ // default is `defaultProvider: "auto"`. This is the CLI's counterpart to the
1006
+ // block in `answerOnce` (see `chat-answer-once-auto-parity.test.ts`, which
1007
+ // pinned the dashboard/gateway side of it in 2026-09-20).
1008
+ //
1009
+ // WHY IT IS LOAD-BEARING, measured 2026-10-06: `execute` — the whole CLI
1010
+ // entry (`-t/--task`, `chat "<task>"`, the REPL) — did NOT honour it. With
1011
+ // neither flag nor active-model state, `autoMode` was `false`, so the turn
1012
+ // took the NON-auto path and `resolveProvider(config, undefined)` resolved
1013
+ // `defaultProvider: "auto"` through `rankAvailableProviders()` to ONE
1014
+ // concrete provider. Everything downstream of the auto router was therefore
1015
+ // skipped on the product's most common entry point: no `routeMessageAuto`
1016
+ // (so no routing-history row to audit which model served the turn, no
1017
+ // routing cache, and no capability gate), no pin pre-flight, and
1018
+ // `model explain` — which DOES use the auto router — predicting a model the
1019
+ // runtime never used. Measured with `NUVIRA_TRACE_ROUTE=1`: a `-t` turn
1020
+ // logged `[TRACE] execute {"autoMode":false,"type":"groq"}` and appended
1021
+ // ZERO rows to routing-history.
1022
+ //
1023
+ // Only the AUTO default changes behaviour: an explicit `-p`/`-m` or a
1024
+ // `model switch` pin is a deliberate choice and keeps the non-auto path
1025
+ // exactly as it is (and is checked FIRST, so a pin can never be re-routed).
1026
+ if (!mergedOpts.provider && !mergedOpts.model) {
1027
+ try {
1028
+ const cfg = this.configManager.getAll();
1029
+ if (isAutoProvider(cfg.defaultProvider))
1030
+ mergedOpts.provider = cfg.defaultProvider;
1031
+ }
1032
+ catch {
1033
+ // Best-effort — an unreadable config leaves the previous behavior.
1034
+ }
1035
+ }
794
1036
  // ── Auto routing mode: agent decides the best provider/model per message ──
795
1037
  // A8 — a pinned concrete provider with `-m auto` is the provider's own auto,
796
1038
  // not ours. See isAgentAutoRoute.
@@ -890,6 +1132,10 @@ export class ChatCommand extends BaseCommand {
890
1132
  // WS5 — never on a REFUSED turn: the pipeline would run it in the real tree
891
1133
  // (see the guard in `answerOnce`).
892
1134
  if (answer.generationFailed && !answer.refused && dispatchDecision.dispatch && !dispatchDecision.needConfirm) {
1135
+ // E2 — same rule as `answerOnce`: the engine changes here, so say so.
1136
+ logger.info(` ⤵️ the chat turn produced no answer on ${provider.name}${model ? ` / ${model}` : ''} — ` +
1137
+ `running this ask in the multi-agent pipeline instead (a different execution model` +
1138
+ `${autoMode ? '' : ', on the same pinned pair'})`);
893
1139
  await runDeveloperMode(prompt, this.configManager, { provider: type, model });
894
1140
  // After pipeline execution, show followups and continue conversation
895
1141
  // (don't just return — keep user engaged with next steps)
@@ -1139,7 +1385,20 @@ export class ChatCommand extends BaseCommand {
1139
1385
  ...(ctxOverrides?.debugSession ? { session: ctxOverrides.debugSession } : {}),
1140
1386
  backend: { engine: 'loop', provider: session.type, ...(session.model ? { model: session.model } : {}) },
1141
1387
  });
1142
- debugLog?.event('turn.start', { provider: session.type });
1388
+ // ── The pair this surface RESOLVED is not the pair that served ───────────
1389
+ // MEASURED (2026-10-07): one log read `# backend.provider: local` /
1390
+ // `# backend.model: qwen2.5:0.5b` while its own first event said
1391
+ // `turn.start {"provider":"gemini"}`. Neither field was computed wrongly —
1392
+ // the header names the pair that ACTUALLY served (via `lastAttempt`, updated
1393
+ // by the provider walk and written at close) and this event named the pair
1394
+ // the turn was CONFIGURED with, before any walk. But a lone `provider` field
1395
+ // in an event called `turn.start` reads as "the turn started on gemini", so
1396
+ // the honest fix is to say which question each field answers: this one is the
1397
+ // REQUESTED route, and `turn.end` now carries the served one from the same
1398
+ // expression as the header, so the two can never disagree.
1399
+ debugLog?.event('turn.start', {
1400
+ requested: session.model ? `${session.type}/${session.model}` : session.type,
1401
+ });
1143
1402
  // WS3 (#25) — the turn's span root, when span export is on (else null). Same
1144
1403
  // identity as the log's: one surface, one conversation, one turn.
1145
1404
  const otelSpan = await startTurnSpan({
@@ -1314,26 +1573,36 @@ export class ChatCommand extends BaseCommand {
1314
1573
  // prompt, so on this surface the MODEL decides and the rules are invisible.
1315
1574
  const systemText = buildToolSystemPrompt(parsed);
1316
1575
  // Skill hint — MODE-DEPENDENT (see resolveSkillHintMode):
1317
- // - `match` (default) — the small keyword-matched hint: ONE skill, and
1318
- // only when the goal really matches; otherwise nothing. This is the
1319
- // 3.3.10 behaviour and keeps the prompt small.
1576
+ // - `pointer` (default) — a bounded discovery pointer: skills exist, here
1577
+ // are the two ways to find one (the `skill` tool and the capability
1578
+ // search). The MODEL picks; the harness no longer keyword-matches. This
1579
+ // keeps the prompt small and has no false positives to maintain.
1320
1580
  // - `catalog` (opt-in) — the full name+description catalog, which lets the
1321
- // MODEL pick a skill but costs ~24K chars on every turn, so it must be
1322
- // chosen (`NUVIRA_SKILL_CATALOG=catalog` or `skills.catalogHint`).
1581
+ // model read every skill in-context but costs ~24K chars on every turn,
1582
+ // so it must be chosen (`NUVIRA_SKILL_CATALOG=catalog` or
1583
+ // `skills.catalogHint`).
1323
1584
  // - `off` — never inject one.
1324
1585
  // Best-effort: any failure returns '' and the turn proceeds byte-identically.
1325
1586
  let skillHint = '';
1326
1587
  try {
1327
- const { buildConfiguredSkillHint, markLoopSkillUsed } = await import('../tools/loop-skill-hint.js');
1328
- const injected = {
1329
- value: null,
1330
- };
1331
- skillHint = await buildConfiguredSkillHint(message, this.configManager, injected);
1332
- await markLoopSkillUsed(injected.value);
1588
+ const { buildConfiguredSkillHint } = await import('../tools/loop-skill-hint.js');
1589
+ skillHint = await buildConfiguredSkillHint(this.configManager);
1333
1590
  }
1334
1591
  catch {
1335
1592
  skillHint = ''; // best-effort — a hint failure never breaks the turn
1336
1593
  }
1594
+ // Machine facts — the host stated ONCE (OS / arch / shell / installed package
1595
+ // managers), so the model chooses a command for THIS machine instead of
1596
+ // assuming one. Detected once per process; best-effort (a detection failure
1597
+ // injects nothing and the turn proceeds byte-identically).
1598
+ let machineBlock = '';
1599
+ try {
1600
+ const { buildMachineFactsBlock } = await import('../learning/machine-facts.js');
1601
+ machineBlock = buildMachineFactsBlock();
1602
+ }
1603
+ catch {
1604
+ machineBlock = '';
1605
+ }
1337
1606
  // `-f/--file` file-context parity: the legacy generateWithContext loaded
1338
1607
  // + retrieval-reduced file context. Inject it as a context message before
1339
1608
  // the user's message so the tool-loop path keeps the flag working.
@@ -1369,6 +1638,25 @@ export class ChatCommand extends BaseCommand {
1369
1638
  ambientProjectContext = undefined;
1370
1639
  }
1371
1640
  }
1641
+ // Phase 1 — deterministic, tag-scoped knowledge. A message that OPENS with
1642
+ // `#tag` retrieves that tag's relevant passages and puts them in front of
1643
+ // the model before the question. It is INERT otherwise: with no marker (or
1644
+ // with no tags defined at all) the builder returns an empty block WITHOUT
1645
+ // touching the embedder, so a turn that did not ask for a document is
1646
+ // byte-identical to one from a build without this feature — no extra cost,
1647
+ // no extra noise, and never a wrong corpus. Dynamically imported so the
1648
+ // retrieval modules are not loaded on a turn that will not use them, the
1649
+ // same best-effort contract as the ambient project context above. See
1650
+ // src/learning/knowledge-turn.ts for what the guarantee rests on.
1651
+ let knowledgeBlock = '';
1652
+ try {
1653
+ const { buildKnowledgeTurnContext } = await import('../learning/knowledge-turn.js');
1654
+ const knowledge = await buildKnowledgeTurnContext(message);
1655
+ knowledgeBlock = knowledge.block;
1656
+ }
1657
+ catch {
1658
+ knowledgeBlock = ''; // best-effort — a retrieval failure never breaks the turn
1659
+ }
1372
1660
  // G4 — carry THIS project's working state (files changed, verification debt,
1373
1661
  // user-reported regressions) into the turn, so the model does not re-derive
1374
1662
  // what previous turns already established. This is the fix for the
@@ -1390,7 +1678,7 @@ export class ChatCommand extends BaseCommand {
1390
1678
  // turn, where it used to occupy ~80% of the ask on WhatsApp turns.
1391
1679
  const systemPolicyBlock = ctxOverrides?.systemPolicy ? `\n\n${ctxOverrides.systemPolicy}` : '';
1392
1680
  const thread = [
1393
- { role: 'system', content: systemText + systemPolicyBlock + skillHint },
1681
+ { role: 'system', content: systemText + systemPolicyBlock + machineBlock + skillHint },
1394
1682
  // P3 — the attached project's bounded snapshot (path + file tree +
1395
1683
  // symbol map) rides in before the conversation, exactly like --file
1396
1684
  // context: the model knows what it is looking at without being told.
@@ -1401,11 +1689,22 @@ export class ChatCommand extends BaseCommand {
1401
1689
  : ambientProjectContext
1402
1690
  ? [{ role: 'user', content: `[Project context]\n${ambientProjectContext}` }]
1403
1691
  : []),
1692
+ // Phase 1 — the tagged knowledge this ask selected, when it selected one.
1693
+ // It sits with the other CONTEXT blocks (before the history) so the
1694
+ // conversation stays contiguous, and it is a USER-TURN message rather than
1695
+ // system text, so the system prompt stays byte-stable and cacheable.
1696
+ ...(knowledgeBlock ? [{ role: 'user', content: knowledgeBlock }] : []),
1404
1697
  // P4 — the recalled project context (prior sessions + facts) rides in
1405
1698
  // next, so the model starts from what this project was last doing.
1406
1699
  ...(ctxOverrides?.recallContext
1407
1700
  ? [{ role: 'user', content: ctxOverrides.recallContext }]
1408
1701
  : []),
1702
+ // Decision recall — the ADVISORY standing answers this project recorded
1703
+ // for related asks. Sits with the other context blocks, and is deliberately
1704
+ // never used to suppress an `ask_user`.
1705
+ ...(ctxOverrides?.decisionContext
1706
+ ? [{ role: 'user', content: ctxOverrides.decisionContext }]
1707
+ : []),
1409
1708
  // G4 — the deterministic working-state ledger (never a summary).
1410
1709
  ...(workingStateBlock ? [{ role: 'user', content: workingStateBlock }] : []),
1411
1710
  ...(fileContext
@@ -1457,6 +1756,18 @@ export class ChatCommand extends BaseCommand {
1457
1756
  // P4 — when a project is attached, scope tools to its root so the
1458
1757
  // agent operates inside the project (not the dashboard server's cwd).
1459
1758
  cwd: turnCwd,
1759
+ // Cluster G — the surface's own verdict on whether `turnCwd` is the
1760
+ // user's workspace. `false` means this is an unscoped turn (no attachment,
1761
+ // no folder named in the message, no configured default), so `turnCwd` is
1762
+ // the dashboard process's own directory — a place that belongs to nobody.
1763
+ // A write there must ask; see `unscopedWriteRefusal` in coding-tools.ts.
1764
+ ...(ctxOverrides?.unscopedWorkspace ? { workspaceUnscoped: true } : {}),
1765
+ // P7 — is a PERSON watching this turn? It decides `run_terminal`'s DEFAULT
1766
+ // command timeout (60s here vs 120s unattended): two minutes of silence in
1767
+ // front of someone is not patience, it is a bug — the live run spent four
1768
+ // minutes on back-to-back 120-second timeouts while the console showed
1769
+ // nothing, then gave up.
1770
+ interactive: Boolean(ctxOverrides?.askUser) || Boolean(process.stdin.isTTY),
1460
1771
  emit: (event, data, source) => {
1461
1772
  // G3 — collect mutated file paths from `tool:started` (which carries
1462
1773
  // the arguments) so the working-state ledger knows what changed.
@@ -1624,7 +1935,11 @@ export class ChatCommand extends BaseCommand {
1624
1935
  { name: 'system:identity+tool-contract', chars: systemText.length },
1625
1936
  { name: 'system:channel-policy', chars: systemPolicyBlock.length },
1626
1937
  { name: 'skill-hint', chars: skillHint.length, dropPriority: 10 },
1938
+ // Machine facts are FACTS about the host, not a recommendation, so they
1939
+ // drop LAST among the optional blocks (higher priority = kept longer).
1940
+ { name: 'machine-facts', chars: machineBlock.length, dropPriority: 15 },
1627
1941
  { name: 'working-state', chars: workingStateBlock.length, dropPriority: 25 },
1942
+ { name: 'decisions', chars: ctxOverrides?.decisionContext?.length ?? 0, dropPriority: 28 },
1628
1943
  { name: 'recall', chars: ctxOverrides?.recallContext?.length ?? 0, dropPriority: 30 },
1629
1944
  {
1630
1945
  name: 'project-context',
@@ -1634,10 +1949,17 @@ export class ChatCommand extends BaseCommand {
1634
1949
  { name: 'file-context', chars: fileContext?.length ?? 0, dropPriority: 45 },
1635
1950
  { name: 'history+ask', chars: historyChars },
1636
1951
  ], { budget: contextBudget });
1637
- // Apply the FIRST ladder step (the documented, safe one): drop the skill
1638
- // hint from the assembled system message.
1639
- if (report.trims.includes('skill-hint') && skillHint) {
1640
- thread[0] = { role: 'system', content: systemText + systemPolicyBlock };
1952
+ // Apply the ladder steps: drop the optional system blocks the budget names,
1953
+ // rebuilding the system message from the survivors.
1954
+ const trimmed = new Set(report.trims);
1955
+ if ((trimmed.has('skill-hint') || trimmed.has('machine-facts')) && (skillHint || machineBlock)) {
1956
+ thread[0] = {
1957
+ role: 'system',
1958
+ content: systemText +
1959
+ systemPolicyBlock +
1960
+ (trimmed.has('machine-facts') ? '' : machineBlock) +
1961
+ (trimmed.has('skill-hint') ? '' : skillHint),
1962
+ };
1641
1963
  }
1642
1964
  if (report.level !== 'ok') {
1643
1965
  recordTraceEvent(chatTraceId, {
@@ -1654,6 +1976,9 @@ export class ChatCommand extends BaseCommand {
1654
1976
  // The instant this turn's model walk began. A failed generation records the
1655
1977
  // WALK (below) relative to this mark, so the trace can say what was tried.
1656
1978
  const modelWalkMark = Date.now();
1979
+ // P4 — a fresh attempt log for this turn (the report is about what THIS turn
1980
+ // tried, never a stale list from an earlier one).
1981
+ this.turnAttempts = [];
1657
1982
  const seenStepDigests = new Set();
1658
1983
  const digestPrompt = (p) => {
1659
1984
  try {
@@ -1717,6 +2042,146 @@ export class ChatCommand extends BaseCommand {
1717
2042
  throw err;
1718
2043
  }
1719
2044
  };
2045
+ /**
2046
+ * P2 (fix_model_routing) — MID-TURN MODEL HANDOFF.
2047
+ *
2048
+ * The tool loop calls this when the model serving the turn resolved with
2049
+ * NOTHING usable (no answer text, no tool call) and has already had its one
2050
+ * retry. Until now that case re-asked the SAME model: a live dashboard run
2051
+ * took five consecutive empty responses from `local/gpt-oss:120b-cloud` and
2052
+ * ended as `bounded` with zero tool calls, while a healthy Gemini model sat
2053
+ * configured and unused — the user had to say "retry". The loop owns the
2054
+ * retry policy; this owns the POOL.
2055
+ *
2056
+ * Order of business, and why:
2057
+ * 1. Exclude the pair that returned nothing — MODEL-scoped, never the
2058
+ * provider. Free tiers meter per model, so the provider's next-best
2059
+ * model is usually perfectly healthy, and `pushCandidate` in the deep
2060
+ * pool already honours this model-scoped exclusion set.
2061
+ * 2. Auto turns re-resolve through the same deep pool the first route used,
2062
+ * first WITHOUT excluding the provider (its sibling models are the
2063
+ * cheapest, lowest-latency handoff) and then WITH it, so a provider
2064
+ * whose only models are dead is genuinely left behind.
2065
+ * 3. A pinned (non-auto) turn walks the configured fallback chain, and
2066
+ * honours `strictModelMode()` by refusing to substitute at all — the
2067
+ * same rule the thrown-error failover already follows.
2068
+ *
2069
+ * Resolving `false` is honest: it means no other candidate exists, and the
2070
+ * loop then falls back to its own bounded escalation instead of spinning.
2071
+ */
2072
+ const requestModelSwitch = async () => {
2073
+ const failedModel = session.model;
2074
+ // P4 — this pair is one of the models the turn actually tried, and it
2075
+ // failed by answering with nothing. Recorded here because the report has
2076
+ // to name it, and because "the provider returned an empty response" is a
2077
+ // cause the user should see instead of a generic apology.
2078
+ this.recordTurnAttempt({
2079
+ provider: session.type,
2080
+ model: failedModel ?? 'default',
2081
+ kind: 'empty-response',
2082
+ reason: describeFailureKind('empty-response'),
2083
+ });
2084
+ // ── RC2: the failure must be LEARNED, not just survived ───────────────
2085
+ // The session exclusion alone only lasts this turn: the model stayed
2086
+ // `verified` with `errorRate` 0 across sessions, so the very next chat
2087
+ // could route to it again. `recordAutoProviderFailure` runs the FULL
2088
+ // shared composition for the pair — session exclusion (model-scoped),
2089
+ // registry write-through (which parks it for EMPTY_RESPONSE_PARK_MS), the
2090
+ // quota-timeline event, and the shared breaker (deliberately skipped for
2091
+ // an empty response: the breaker is provider-scoped and the provider was
2092
+ // fine). One `empty-response` failure nobody wrote down was the whole
2093
+ // reason a dead model kept its "verified" badge.
2094
+ try {
2095
+ this.recordAutoProviderFailure(session.type, new UnusableModelResponseError('empty', 'the provider returned nothing usable for this step'), failedModel);
2096
+ }
2097
+ catch {
2098
+ // Best-effort — bookkeeping must never break the turn.
2099
+ }
2100
+ // P6 — and the bandit hears about it too: a step-level empty is NEGATIVE
2101
+ // evidence for this model in the arm the router samples (see
2102
+ // `RouterBandit.penalizeModel`), so the score that flipped a working run to
2103
+ // a broken model can actually decay.
2104
+ if (failedModel && failedModel !== 'default') {
2105
+ try {
2106
+ getRouterBandit().penalizeModel(failedModel, this.lastRouteSnapshot?.complexity ?? undefined, this.lastTaskSignature?.split('|')[0]);
2107
+ }
2108
+ catch {
2109
+ // Best-effort — learning must never break the turn.
2110
+ }
2111
+ }
2112
+ /** Install a candidate and report whether it is genuinely a NEW pair. */
2113
+ const install = (next) => {
2114
+ const current = `${session.type}|${session.model ?? 'default'}`;
2115
+ const pair = `${next.type}|${next.model ?? 'default'}`;
2116
+ if (pair === current)
2117
+ return false;
2118
+ session.type = next.type;
2119
+ session.provider = next.provider;
2120
+ if (next.model && next.model !== 'default')
2121
+ session.model = next.model;
2122
+ this.lastAttempt = { provider: next.type, model: next.model };
2123
+ logger.warn(` 🔀 ${current} returned nothing usable — handing the turn to ${pair}`);
2124
+ return true;
2125
+ };
2126
+ if (mode.auto) {
2127
+ // First keep the provider (its sibling models are the cheapest handoff),
2128
+ // then drop the provider entirely so a fully-dead provider is left behind.
2129
+ // The same intent hint the initial route and the failover walk use: a
2130
+ // bare continuation ("resume") must re-route on the ask it continues,
2131
+ // not on the signal-free word, or the handoff lands on a trivial model.
2132
+ const switchRoutingText = continuationSoftwareText(message, history) ?? undefined;
2133
+ for (const excludeProviders of [[], [session.type]]) {
2134
+ try {
2135
+ const next = await this.routeMessageAuto(message, excludeProviders, {
2136
+ ...(switchRoutingText ? { routingText: switchRoutingText } : {}),
2137
+ fallbackFrom: session.type,
2138
+ // A handoff must NEVER reuse a decision computed before the failure
2139
+ // it is reacting to. The 30-second routing cache is keyed on inputs
2140
+ // that do not include the pair we just excluded, so a cached
2141
+ // decision could hand back the SAME model — making the handoff a
2142
+ // no-op and ending a recoverable turn. Observed shape: two routing
2143
+ // decisions 17 seconds apart carrying an identical score, one of
2144
+ // them a failover the cache had never re-evaluated.
2145
+ noCache: true,
2146
+ });
2147
+ if (install(next))
2148
+ return true;
2149
+ }
2150
+ catch {
2151
+ // Unresolvable through this shape — try the next one.
2152
+ }
2153
+ }
2154
+ return false;
2155
+ }
2156
+ // A pinned turn: never substitute a model the user explicitly forbade.
2157
+ if (strictModelMode())
2158
+ return false;
2159
+ try {
2160
+ const chain = getProviderFallback(this.configManager, this.configManager.getAll().fallback)
2161
+ .getFallbackChain(session.type);
2162
+ for (const fbType of chain) {
2163
+ if (fbType === session.type)
2164
+ continue;
2165
+ if (!hasCredentials(this.configManager, fbType))
2166
+ continue;
2167
+ if (!governanceVerdict(this.configManager, fbType, { taskText: message }).allowed)
2168
+ continue;
2169
+ try {
2170
+ const resolved = resolveProvider(this.configManager, fbType);
2171
+ const model = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
2172
+ if (install({ type: resolved.type, provider: resolved.provider, model }))
2173
+ return true;
2174
+ }
2175
+ catch {
2176
+ // Next fallback candidate.
2177
+ }
2178
+ }
2179
+ }
2180
+ catch {
2181
+ // Fall through to `false` — the loop's bounded path still applies.
2182
+ }
2183
+ return false;
2184
+ };
1720
2185
  try {
1721
2186
  // R1 — the harness follows the MODEL, not only the config: config says
1722
2187
  // what this deployment prefers, the profile decides what this model can
@@ -1758,6 +2223,13 @@ export class ChatCommand extends BaseCommand {
1758
2223
  onTraceEvent: (event) => recordTraceEvent(chatTraceId, event),
1759
2224
  deps: {
1760
2225
  callModel: callModelWithTrace,
2226
+ // P2 — the empty-response handoff (see `requestModelSwitch` above).
2227
+ requestModelSwitch,
2228
+ // C5 — `requestModelSwitch` installs the replacement on `session`, so
2229
+ // re-resolve the thread budget from the session AFTER a handoff: a
2230
+ // handoff to a smaller window is the case that overflows, and the
2231
+ // budget in hand was computed from the model that just failed.
2232
+ threadBudgetAfterSwitch: () => resolveThreadBudgetChars({ provider: session.type, model: session.model }),
1761
2233
  executeTool: async (name, args, ctx) => {
1762
2234
  const tool = getTool(name);
1763
2235
  if (!tool)
@@ -1778,6 +2250,19 @@ export class ChatCommand extends BaseCommand {
1778
2250
  },
1779
2251
  },
1780
2252
  });
2253
+ // P3 — REMEMBER the worker that actually delivered this task, so the next
2254
+ // turn of the SAME task ("resume", "continue") prefers it instead of
2255
+ // re-deciding from a signal-free word. Only a DELIVERED turn is remembered:
2256
+ // a bounded, cancelled or failed one has taught us nothing about which
2257
+ // model can do this job, and remembering it would propagate the failure.
2258
+ if (!result.cancelled &&
2259
+ !result.generationFailed &&
2260
+ !result.bounded &&
2261
+ !result.termination?.includes('no-capable') &&
2262
+ result.content.trim() &&
2263
+ session.model) {
2264
+ this.taskContinuity.remember(this.lastTaskSignature ?? taskSignature(undefined, undefined), session.type, session.model);
2265
+ }
1781
2266
  }
1782
2267
  catch (err) {
1783
2268
  // The tool loop does not throw on its own; this catches the errors that
@@ -1834,6 +2319,71 @@ export class ChatCommand extends BaseCommand {
1834
2319
  generationFailed: true,
1835
2320
  };
1836
2321
  }
2322
+ // ── P4 — THE HONEST EXHAUSTION REPORT ────────────────────────────────
2323
+ // A turn that ends without an answer used to deliver a bare line ("the
2324
+ // language model was unavailable") or, worse, a QUESTION that made the user
2325
+ // the retry button. Instead, the user gets the measured facts: which models
2326
+ // were tried and why each did not answer, which targets are unavailable and
2327
+ // the WALL-CLOCK time each frees up, whether this was a routing gap or a real
2328
+ // shortage, and the levers only they can pull — with no request to retry.
2329
+ //
2330
+ // Gated on an actual ATTEMPT (or the loop's evidenced `no-capable-candidate`):
2331
+ // a governance refusal, an admin-budget stop or a bad pin fails with a
2332
+ // healthy pool and a message that is already the right answer, and dressing
2333
+ // those up as exhaustion would be a new lie in place of the old one.
2334
+ if (!result.cancelled &&
2335
+ (result.generationFailed || result.termination === 'no-capable-candidate')) {
2336
+ try {
2337
+ const breadth = modelBreadthReport(modelWalkMark, this.configManager);
2338
+ const seen = new Set(this.turnAttempts.map((a) => `${a.provider}|${a.model}|${a.kind}`));
2339
+ const attempts = [...this.turnAttempts];
2340
+ for (const a of breadth.tried) {
2341
+ const key = `${a.provider}|${a.model}|${a.kind}`;
2342
+ if (seen.has(key))
2343
+ continue;
2344
+ seen.add(key);
2345
+ attempts.push({
2346
+ provider: a.provider,
2347
+ model: a.model,
2348
+ kind: a.kind,
2349
+ reason: a.reason || describeFailureKind(a.kind),
2350
+ ...(a.skipped ? { skipped: true } : {}),
2351
+ });
2352
+ }
2353
+ if (attempts.length > 0) {
2354
+ const exclusions = breadth.parked
2355
+ .filter((r) => r.active)
2356
+ .map((r) => ({
2357
+ provider: r.provider,
2358
+ ...(r.model ? { model: r.model } : {}),
2359
+ kind: r.kind,
2360
+ ...(r.expiresAt ? { expiresAt: r.expiresAt } : {}),
2361
+ ...(r.source ? { source: r.source } : {}),
2362
+ }));
2363
+ const reportText = renderExhaustionReport({
2364
+ attempts,
2365
+ exclusions,
2366
+ ...(typeof breadth.poolSize === 'number' ? { poolSize: breadth.poolSize } : {}),
2367
+ ...(typeof breadth.poolProviders === 'number' ? { poolProviders: breadth.poolProviders } : {}),
2368
+ task: message,
2369
+ // The underlying line is kept as the REASON, so a specific,
2370
+ // actionable cause is never buried by the report.
2371
+ ...(result.generationFailed && result.content.trim() ? { cause: result.content } : {}),
2372
+ });
2373
+ if (reportText) {
2374
+ result.content = reportText;
2375
+ recordTraceEvent(chatTraceId, {
2376
+ kind: 'failover',
2377
+ summary: `exhaustion report delivered — ${attempts.filter((a) => !a.skipped).length} model(s) tried, ` +
2378
+ `${exclusions.length} unavailable, eligible pool ${breadth.poolSize ?? '?'}`,
2379
+ });
2380
+ }
2381
+ }
2382
+ }
2383
+ catch {
2384
+ // The report is a courtesy — the turn's own failure line still stands.
2385
+ }
2386
+ }
1837
2387
  // Record WHAT HAPPENED, not just "the model answered": a hallucinated
1838
2388
  // "I have sent it" (no tool ran) must be visible as an unverified claim in
1839
2389
  // the Trace tab instead of looking like a real delivery.
@@ -1850,6 +2400,10 @@ export class ChatCommand extends BaseCommand {
1850
2400
  unverifiedEditClaim: result.unverifiedEditClaim,
1851
2401
  undeliveredArtifact: result.undeliveredArtifact,
1852
2402
  unverifiedBuildClaim: result.unverifiedBuildClaim,
2403
+ // Bundle 19/20 — the artifact's own omission, and a file claim no write
2404
+ // backs, both make the turn `incomplete` on the trace (not `answered`). incompleteArtifactClaim: Boolean(result.artifactIncomplete),
2405
+ unverifiedFileClaim: result.unverifiedFileClaim,
2406
+ artifactShortfall: Boolean(result.artifactShortfall),
1853
2407
  });
1854
2408
  // A cancelled / failed / incomplete turn is NOT a success. `!generationFailed`
1855
2409
  // used to let a cancelled run record `success: true` while its outcome said
@@ -1913,6 +2467,31 @@ export class ChatCommand extends BaseCommand {
1913
2467
  if (result.unverifiedBuildClaim) {
1914
2468
  logger.warn(' ⚠️ A build command FAILED this turn and no later build succeeded, but the reply reports success — treat the build as UNVERIFIED.');
1915
2469
  }
2470
+ // Bundle 19 — the ARTIFACT-honesty warning. The file this turn wrote says its
2471
+ // own content was omitted, so the prose above describes a document that is not
2472
+ // on disk. The artifact's OWN WORDS are quoted, so the reader sees the evidence
2473
+ // (a specific line the file contains) rather than a verdict they must trust.
2474
+ // Without this the flag only changed the internal verdict; the user still saw
2475
+ // a confident "complete" reply with no sign of the admission inside it.
2476
+ if (result.artifactIncomplete) {
2477
+ logger.warn(` ⚠️ The file ${result.artifactIncomplete.path} says its own content was omitted ("${result.artifactIncomplete.statement}") — the artifact is INCOMPLETE, whatever the reply above claims.`);
2478
+ }
2479
+ // Bundle 20 — the FILE-CLAIM warning. The reply above says a file was
2480
+ // written and the turn's own record says none was. Found live: a turn wrote
2481
+ // nothing and replied "The guide has been written and saved as GUIDE.md …
2482
+ // contains a full ~5,000-word technical guide" — and the user saw no warning,
2483
+ // because the flag that exists for this failure is gated on the ASK and the
2484
+ // ask was not recognised as a deliverable. This one keys on the CLAIM.
2485
+ if (result.unverifiedFileClaim) {
2486
+ logger.warn(' ⚠️ This reply says a file was written, but NO file was written this turn — treat the deliverable as MISSING, not as done.');
2487
+ }
2488
+ // Bundle 23 (item 13) — the ARTIFACT-VS-ASK warning. The ask named a
2489
+ // magnitude and the file this turn wrote is far short of it; the numbers are
2490
+ // quoted so the reader sees the gap rather than a verdict.
2491
+ if (result.artifactShortfall) {
2492
+ const s = result.artifactShortfall;
2493
+ logger.warn(` ⚠️ ${s.path} has ~${s.deliveredWords} words, but the request asked for ~${s.targetWords} (${s.source}) — the deliverable is materially SHORT of what was asked.`);
2494
+ }
1916
2495
  }
1917
2496
  catch {
1918
2497
  // Best-effort.
@@ -1979,11 +2558,18 @@ export class ChatCommand extends BaseCommand {
1979
2558
  // affect the answer.
1980
2559
  if (debugLog) {
1981
2560
  const servedModel = this.lastAttempt?.model ?? session.model;
2561
+ // ONE expression for the served pair, used by BOTH the header and the
2562
+ // `turn.end` event — so "what the header says" and "what the events say"
2563
+ // is a single fact rather than two that can drift (the A5/D3 class of
2564
+ // defect, in its last remaining face).
2565
+ const servedProvider = this.lastAttempt?.provider ?? session.type;
1982
2566
  debugLog.backendOf({
1983
- provider: this.lastAttempt?.provider ?? session.type,
2567
+ provider: servedProvider,
1984
2568
  ...(servedModel ? { model: servedModel } : {}),
1985
2569
  transport: result.transport ?? null,
1986
2570
  });
2571
+ const requested = session.model ? `${session.type}/${session.model}` : session.type;
2572
+ const served = servedModel ? `${servedProvider}/${servedModel}` : servedProvider;
1987
2573
  debugLog.event('turn.end', {
1988
2574
  generationFailed: result.generationFailed === true,
1989
2575
  cancelled: result.cancelled === true,
@@ -1991,6 +2577,11 @@ export class ChatCommand extends BaseCommand {
1991
2577
  contentChars: result.content.length,
1992
2578
  toolCalls: result.toolCalls?.length ?? 0,
1993
2579
  findings: findings.length,
2580
+ // The pair that answered, and (only when it differs) the pair the turn
2581
+ // asked for — a reader can then tell a straight run from a failover
2582
+ // without cross-referencing the header.
2583
+ served,
2584
+ ...(served === requested ? {} : { requested }),
1994
2585
  });
1995
2586
  const notice = debugLogNotice(ctxOverrides?.debugSurface ?? 'cli-chat', debugLog.write());
1996
2587
  if (notice)
@@ -2025,6 +2616,11 @@ export class ChatCommand extends BaseCommand {
2025
2616
  unfulfilledPromise: result.unfulfilledPromise,
2026
2617
  undeliveredArtifact: result.undeliveredArtifact,
2027
2618
  unverifiedBuildClaim: result.unverifiedBuildClaim,
2619
+ // Bundle 20 — an unbacked file claim (or a self-admitted omission) means
2620
+ // the work did not conclude, so the continuation affordances must appear.
2621
+ incompleteArtifactClaim: Boolean(result.artifactIncomplete),
2622
+ unverifiedFileClaim: result.unverifiedFileClaim,
2623
+ artifactShortfall: Boolean(result.artifactShortfall),
2028
2624
  });
2029
2625
  const chatFollowups = withContinuationFollowups(result.followups, {
2030
2626
  unfinished: !traceOutcomeSucceeded(chatOutcomeForFollowups),
@@ -2041,6 +2637,14 @@ export class ChatCommand extends BaseCommand {
2041
2637
  unfulfilledPromise: result.unfulfilledPromise,
2042
2638
  undeliveredArtifact: result.undeliveredArtifact,
2043
2639
  unverifiedBuildClaim: result.unverifiedBuildClaim,
2640
+ // Bundle 19/20 — carried to the caller so the honesty flags survive the
2641
+ // return boundary instead of only living in the turn report.
2642
+ artifactIncomplete: result.artifactIncomplete,
2643
+ unverifiedFileClaim: result.unverifiedFileClaim,
2644
+ artifactShortfall: result.artifactShortfall,
2645
+ // Item 13 (round 3) — the collectable "fluent, on-length" delivery survives
2646
+ // this boundary too, so `answerOnce` can record it as corpus facts.
2647
+ authoredDeliverable: result.authoredDeliverable,
2044
2648
  // R2 — the transport this turn travelled on (interactive REPL path).
2045
2649
  transport: result.transport,
2046
2650
  // WS1 — the findings this turn recorded, with their verdicts.
@@ -2164,6 +2768,32 @@ export class ChatCommand extends BaseCommand {
2164
2768
  throw new Error(`Admin budget: ${budget.reason}`);
2165
2769
  }
2166
2770
  }
2771
+ /**
2772
+ * A2 — REACHABILITY PRE-FLIGHT (pinned path).
2773
+ *
2774
+ * The governance/admin checks above cover POLICY; this covers whether the
2775
+ * pin can work at all. Measured (Run D): `-p openrouter -m
2776
+ * deepseek/deepseek-v4.1-flash` on an unfunded account was sent, and the
2777
+ * operator learned the real cause from a raw 402 body — after a full round
2778
+ * trip, with a live 6-second failure that said nothing about what to do.
2779
+ *
2780
+ * `resolveRoute` refuses that pin, and every one of its callers therefore
2781
+ * does — but this method resolves its own model, so after the first fix the
2782
+ * same run still logged BOTH behaviours: the pipeline refused the pair
2783
+ * before any call ("Refusing to call …") while the chat tool loop sent the
2784
+ * request and got `402 Insufficient credits` back. Same gate, same wording,
2785
+ * same implementation (`strictPinRefusal`) — this is the one seam that was
2786
+ * still missing it.
2787
+ *
2788
+ * Strict mode only, exactly like `resolveRoute`: the refusal sentence says
2789
+ * strict mode forbids substituting, and on a non-strict turn the fallback
2790
+ * walk is the correct repair.
2791
+ */
2792
+ if (!mode.auto && strictModelMode()) {
2793
+ const refusal = strictPinRefusal(session.type, session.model);
2794
+ if (refusal)
2795
+ throw new Error(refusal);
2796
+ }
2167
2797
  const resolveEffectiveModel = (providerType, requested) => {
2168
2798
  if (requested && requested !== 'default')
2169
2799
  return requested;
@@ -2306,6 +2936,16 @@ export class ChatCommand extends BaseCommand {
2306
2936
  return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) => logger.warn(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`));
2307
2937
  }
2308
2938
  catch (err) {
2939
+ // P4 — the model this turn STARTED on failed. Recorded with a human
2940
+ // phrase (never the raw provider text, which is how a JSON quota dump
2941
+ // once reached a user verbatim) so the exhaustion report can name it.
2942
+ const primaryKind = classifyFallbackError(err);
2943
+ this.recordTurnAttempt({
2944
+ provider: session.type,
2945
+ model: session.model ?? 'default',
2946
+ kind: primaryKind,
2947
+ reason: describeFailureKind(primaryKind),
2948
+ });
2309
2949
  // Auto mode: fail over across the ranked candidates (never stuck).
2310
2950
  if (mode.auto) {
2311
2951
  const firstType = session.type;
@@ -2380,7 +3020,16 @@ export class ChatCommand extends BaseCommand {
2380
3020
  logger.success(`✅ Auto failover: answered from ${next.provider.name} (${next.model}) after ${firstType} failed`);
2381
3021
  return resp;
2382
3022
  }
2383
- catch {
3023
+ catch (nextErr) {
3024
+ // P4 — record THIS candidate's failure too, so the report lists
3025
+ // every model the turn was actually served by, in order.
3026
+ const nextKind = classifyFallbackError(nextErr);
3027
+ this.recordTurnAttempt({
3028
+ provider: next.type,
3029
+ model: next.model ?? 'default',
3030
+ kind: nextKind,
3031
+ reason: describeFailureKind(nextKind),
3032
+ });
2384
3033
  // Next candidate.
2385
3034
  }
2386
3035
  }
@@ -2418,6 +3067,10 @@ export class ChatCommand extends BaseCommand {
2418
3067
  catch {
2419
3068
  // Ordering is an optimization only.
2420
3069
  }
3070
+ // The pair the turn was PINNED to. Captured before the walk because
3071
+ // the walk now installs what it serves (see below), and the warning
3072
+ // must still name the provider that actually failed.
3073
+ const pinnedType = session.type;
2421
3074
  for (const fbType of ordered) {
2422
3075
  if (fbType === session.type)
2423
3076
  continue;
@@ -2455,7 +3108,40 @@ export class ChatCommand extends BaseCommand {
2455
3108
  // the dashboard console and the gateway chat engine - looked
2456
3109
  // dead.) Undefined = that provider's configured/adapter
2457
3110
  // default, which tryGenerate resolves per attempt.
2458
- return await tryGenerate(resolved.provider, resolved.type, resolveEffectiveModel(resolved.type, undefined));
3111
+ const fallbackModel = resolveEffectiveModel(resolved.type, undefined);
3112
+ const resp = await tryGenerate(resolved.provider, resolved.type, fallbackModel);
3113
+ // ── The pair that ANSWERED is the pair the turn ran on ───────
3114
+ // This walk used to serve the fallback and leave `session` still
3115
+ // naming the provider that had just failed. Everything the turn
3116
+ // then reported or learned read the WRONG provider against the
3117
+ // RIGHT model — one interlocking defect with four faces, all
3118
+ // measured:
3119
+ // • the session debug log's `turn.start` named the pinned
3120
+ // provider while its own header named the one that served
3121
+ // (`backend.provider: deepseek` with `turn.start {"provider":
3122
+ // "groq"}` — A5/D3);
3123
+ // • `servedRoute()` told the MODEL it was a different model
3124
+ // than the one answering;
3125
+ // • the response cache stored `provider: "groq"` beside
3126
+ // `model: "deepseek-flash"`;
3127
+ // • and worst, `recordRegistrySuccess(session.type,
3128
+ // session.model)` wrote a PAIR THAT CANNOT EXIST into the
3129
+ // model registry as `verified`. That is the self-poisoning:
3130
+ // `groq|deepseek-flash` and `groq|gemini-3.1-flash-lite` are
3131
+ // both sitting in the live registry as `telemetry`-verified,
3132
+ // and because `telemetry` outranks `spot-check` in
3133
+ // `preferredModelsFor`, the NEXT groq call picked the bogus
3134
+ // id as its adapter default, sent a foreign model name to
3135
+ // groq, and 404'd into the same fallback walk — each run
3136
+ // making the next one worse.
3137
+ // The auto branch above has always installed `next`; the pinned
3138
+ // branch is the one that drifted.
3139
+ session.type = resolved.type;
3140
+ session.provider = resolved.provider;
3141
+ if (fallbackModel)
3142
+ session.model = fallbackModel;
3143
+ logger.warn(` 🔀 ${pinnedType} was unavailable — answered from ${resolved.provider.name} (${fallbackModel ?? 'default'})`);
3144
+ return resp;
2459
3145
  }
2460
3146
  catch {
2461
3147
  // Next fallback candidate.
@@ -2669,16 +3355,25 @@ export class ChatCommand extends BaseCommand {
2669
3355
  .sort()
2670
3356
  .join(','),
2671
3357
  ]);
2672
- const decision = withRoutingCache(cacheSignature, 30_000, () => getAutoRouter().resolve('chat', taskText, {
3358
+ const resolveFresh = () => getAutoRouter().resolve('chat', taskText, {
2673
3359
  ...buildAutoResolveOptions(this.configManager, {
2674
3360
  verbose: envBuff('DEBUG') === 'true',
2675
3361
  contextHintTokens: opts?.contextHintTokens,
2676
3362
  }),
2677
3363
  circuitBreakerStatus,
2678
3364
  ...(dispatch.taskIntentHint ? { taskIntentHint: dispatch.taskIntentHint } : {}),
2679
- }, this.configManager));
3365
+ }, this.configManager);
3366
+ const decision = opts?.noCache === true
3367
+ ? resolveFresh()
3368
+ : withRoutingCache(cacheSignature, 30_000, resolveFresh);
2680
3369
  // G5 — record the routing snapshot now (the winner + why), so the reasoning
2681
3370
  // trace for this turn can show the decision instead of a blank column.
3371
+ // P3 — the TASK this turn belongs to, as far as model continuity is
3372
+ // concerned. Kept on the instance because the turn's SUCCESS path (the only
3373
+ // place that knows a model actually DELIVERED) does not hold the routing
3374
+ // decision; intent+complexity is the same key for the original ask and for a
3375
+ // continuation of it, which is exactly the pair that must match.
3376
+ this.lastTaskSignature = taskSignature(dispatch.taskIntentHint ?? decision.taskProfile?.intent, String(decision.complexity));
2682
3377
  this.lastRouteSnapshot = {
2683
3378
  provider: decision.provider,
2684
3379
  model: decision.model,
@@ -2689,6 +3384,9 @@ export class ChatCommand extends BaseCommand {
2689
3384
  // and console can show WHY the pair was chosen, not just which pair.
2690
3385
  agenticCapable: decision.agenticCapable,
2691
3386
  overrideReason: decision.overrideReason,
3387
+ // Bundle 36 — the intent the bandit will bucket this outcome under, so a
3388
+ // later `nuvira rate bad` can correct the EXACT arm (see `verdict-routing.ts`).
3389
+ ...(decision.taskProfile?.intent ? { taskIntent: decision.taskProfile.intent } : {}),
2692
3390
  };
2693
3391
  // Walk the ranked candidates (winner first) and return the first available
2694
3392
  // provider — never a provider that lacks a key or endpoint. Providers that
@@ -2779,9 +3477,15 @@ export class ChatCommand extends BaseCommand {
2779
3477
  crossPipelineMemory: false,
2780
3478
  registryCheck: false,
2781
3479
  });
3480
+ // B2-a — each candidate carries the score OF ITS OWN PAIR, when the pool had
3481
+ // a real measurement for it, plus the scale that score is on. It used to
3482
+ // carry nothing at all and every row was recorded with `decision.score`, so
3483
+ // three different pairs shared one provider-level number (measured live:
3484
+ // `0.43836864406779663` on gemini/gemma-4-26b-a4b-it,
3485
+ // openrouter/cohere/command-r7b-12-2024 and deepseek/deepseek-flash).
2782
3486
  const chatCandidates = [];
2783
3487
  const seenPairs = new Set();
2784
- const pushCandidate = (prov, mdl) => {
3488
+ const pushCandidate = (prov, mdl, score, scoreBasis) => {
2785
3489
  if (!prov || excluded.has(prov) || registryBlocked.has(prov))
2786
3490
  return;
2787
3491
  const model = mdl && mdl !== 'default' ? mdl : 'default';
@@ -2799,7 +3503,13 @@ export class ChatCommand extends BaseCommand {
2799
3503
  // already ranks healthy models first, so the parked pick is only ever a
2800
3504
  // last resort that the repair then fixes.
2801
3505
  seenPairs.add(key);
2802
- chatCandidates.push({ provider: prov, model });
3506
+ chatCandidates.push({
3507
+ provider: prov,
3508
+ model,
3509
+ // Only a labelled measurement travels: an ordering placeholder must not
3510
+ // reach the audit trail as if it described this pair.
3511
+ ...(scoreBasis ? { score, scoreBasis } : {}),
3512
+ });
2803
3513
  };
2804
3514
  // The pool itself is the SAME one the orchestrator/tool/sub-agent path
2805
3515
  // walks: primary → model-first TIERED pool (same model on other providers,
@@ -2807,13 +3517,25 @@ export class ChatCommand extends BaseCommand {
2807
3517
  // reserve) → ranked placeholders → config fallback. Chat used to build a
2808
3518
  // shallower list here, which is why it reached strictly fewer models than
2809
3519
  // the orchestrator could.
3520
+ // P3 — TASK-LEVEL CONTINUITY (RC7). A continuation carries no signal about
3521
+ // its own task, so the router re-decides from scratch and can land on a model
3522
+ // that has never served it (the live `resume` degraded from a working Gemini
3523
+ // model to one that returned nothing). Offer the pair that DID serve this
3524
+ // task FIRST. It is pushed through the same `pushCandidate` gate as every
3525
+ // other candidate, so a dead key, an active exclusion or a registry block
3526
+ // simply drops it and the normal pool is used instead.
3527
+ if (opts?.routingText) {
3528
+ const recalled = this.taskContinuity.recall(this.lastTaskSignature);
3529
+ if (recalled)
3530
+ pushCandidate(recalled.provider, recalled.model);
3531
+ }
2810
3532
  const pool = buildDeepFailoverPool(decision, {
2811
3533
  taskDescription: message,
2812
3534
  complexity: decision.complexity,
2813
3535
  configManager: this.configManager,
2814
3536
  });
2815
3537
  for (const c of pool)
2816
- pushCandidate(c.provider, c.model);
3538
+ pushCandidate(c.provider, c.model, c.score, c.scoreBasis);
2817
3539
  // Unique provider list (what callers use for their own failover) — derived
2818
3540
  // from the pair list so it stays consistent with what is actually tried.
2819
3541
  const candidates = [...new Set(chatCandidates.map((c) => c.provider))];
@@ -2838,7 +3560,12 @@ export class ChatCommand extends BaseCommand {
2838
3560
  task: message,
2839
3561
  verifyOnDemand: isMaxCapability(this.configManager),
2840
3562
  })).model;
2841
- // Record the actually-used route for the dashboard audit trail
3563
+ // Record the actually-used route for the dashboard audit trail.
3564
+ // B2-a — the score belongs to THE PAIR THIS ROW NAMES. `decision.score`
3565
+ // describes the router's own pick; a walked alternate has its own score
3566
+ // (`candidate.score`, a model-level measurement from the tiered pool) or
3567
+ // none at all (an ordering placeholder / a recalled pair). Writing the
3568
+ // decision's number here is what made a walk look like a tie.
2842
3569
  recordRoutingDecision({
2843
3570
  source: 'chat',
2844
3571
  agentType: 'chat',
@@ -2846,7 +3573,9 @@ export class ChatCommand extends BaseCommand {
2846
3573
  complexity: decision.complexity,
2847
3574
  provider: candidate.provider,
2848
3575
  model,
2849
- score: decision.score,
3576
+ ...(candidate.scoreBasis
3577
+ ? { score: candidate.score, scoreBasis: candidate.scoreBasis }
3578
+ : {}),
2850
3579
  agenticCapable: isAgenticCapableModel(model, candidate.provider),
2851
3580
  overrideReason: decision.overrideReason,
2852
3581
  ...(opts?.fallbackFrom ? { fallbackFrom: opts.fallbackFrom } : {}),
@@ -2879,6 +3608,10 @@ export class ChatCommand extends BaseCommand {
2879
3608
  // over).
2880
3609
  const usableProvider = [decision.provider, ...decision.ranked.map((r) => r.provider)]
2881
3610
  .find((p) => !isActiveExclusion(p) && !registryBlocked.has(p)) || decision.provider;
3611
+ // B2-a — this row names `decision.model` possibly on a DIFFERENT provider
3612
+ // than the decision's (`usableProvider`), so the decision's composite does
3613
+ // not describe it. The pair was never ranked as a whole; record it bare
3614
+ // rather than attaching a number that belongs to another provider.
2882
3615
  recordRoutingDecision({
2883
3616
  source: 'chat',
2884
3617
  agentType: 'chat',
@@ -2886,7 +3619,6 @@ export class ChatCommand extends BaseCommand {
2886
3619
  complexity: decision.complexity,
2887
3620
  provider: usableProvider,
2888
3621
  model: decision.model,
2889
- score: decision.score,
2890
3622
  agenticCapable: isAgenticCapableModel(decision.model, usableProvider),
2891
3623
  overrideReason: decision.overrideReason,
2892
3624
  ...(opts?.fallbackFrom ? { fallbackFrom: opts.fallbackFrom } : {}),