agent-nuvira 3.3.12 → 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 (458) 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/agents/reasoner.d.ts.map +1 -1
  6. package/dist/agents/agents/reasoner.js +13 -0
  7. package/dist/agents/agents/reasoner.js.map +1 -1
  8. package/dist/agents/orchestrator.d.ts +31 -0
  9. package/dist/agents/orchestrator.d.ts.map +1 -1
  10. package/dist/agents/orchestrator.js +103 -135
  11. package/dist/agents/orchestrator.js.map +1 -1
  12. package/dist/cli/chat.d.ts +89 -0
  13. package/dist/cli/chat.d.ts.map +1 -1
  14. package/dist/cli/chat.js +798 -39
  15. package/dist/cli/chat.js.map +1 -1
  16. package/dist/cli/cli-program.d.ts.map +1 -1
  17. package/dist/cli/cli-program.js +20 -0
  18. package/dist/cli/cli-program.js.map +1 -1
  19. package/dist/cli/config.d.ts +12 -0
  20. package/dist/cli/config.d.ts.map +1 -1
  21. package/dist/cli/config.js +107 -3
  22. package/dist/cli/config.js.map +1 -1
  23. package/dist/cli/decisions.d.ts +28 -0
  24. package/dist/cli/decisions.d.ts.map +1 -0
  25. package/dist/cli/decisions.js +140 -0
  26. package/dist/cli/decisions.js.map +1 -0
  27. package/dist/cli/doctor.d.ts +6 -0
  28. package/dist/cli/doctor.d.ts.map +1 -1
  29. package/dist/cli/doctor.js +46 -0
  30. package/dist/cli/doctor.js.map +1 -1
  31. package/dist/cli/execute.d.ts.map +1 -1
  32. package/dist/cli/execute.js +6 -0
  33. package/dist/cli/execute.js.map +1 -1
  34. package/dist/cli/failover-runner.d.ts.map +1 -1
  35. package/dist/cli/failover-runner.js +19 -1
  36. package/dist/cli/failover-runner.js.map +1 -1
  37. package/dist/cli/feedback.d.ts.map +1 -1
  38. package/dist/cli/feedback.js +5 -1
  39. package/dist/cli/feedback.js.map +1 -1
  40. package/dist/cli/hooks.d.ts +30 -0
  41. package/dist/cli/hooks.d.ts.map +1 -0
  42. package/dist/cli/hooks.js +224 -0
  43. package/dist/cli/hooks.js.map +1 -0
  44. package/dist/cli/knowledge.d.ts +31 -0
  45. package/dist/cli/knowledge.d.ts.map +1 -0
  46. package/dist/cli/knowledge.js +223 -0
  47. package/dist/cli/knowledge.js.map +1 -0
  48. package/dist/cli/loop-executor.d.ts.map +1 -1
  49. package/dist/cli/loop-executor.js +92 -18
  50. package/dist/cli/loop-executor.js.map +1 -1
  51. package/dist/cli/model.d.ts +20 -0
  52. package/dist/cli/model.d.ts.map +1 -1
  53. package/dist/cli/model.js +294 -14
  54. package/dist/cli/model.js.map +1 -1
  55. package/dist/cli/omniroute-control.d.ts +88 -0
  56. package/dist/cli/omniroute-control.d.ts.map +1 -0
  57. package/dist/cli/omniroute-control.js +179 -0
  58. package/dist/cli/omniroute-control.js.map +1 -0
  59. package/dist/cli/omniroute.d.ts +21 -0
  60. package/dist/cli/omniroute.d.ts.map +1 -0
  61. package/dist/cli/omniroute.js +67 -0
  62. package/dist/cli/omniroute.js.map +1 -0
  63. package/dist/cli/rate.d.ts +78 -0
  64. package/dist/cli/rate.d.ts.map +1 -0
  65. package/dist/cli/rate.js +294 -0
  66. package/dist/cli/rate.js.map +1 -0
  67. package/dist/config/process-env.d.ts.map +1 -1
  68. package/dist/config/process-env.js +10 -0
  69. package/dist/config/process-env.js.map +1 -1
  70. package/dist/config/types.d.ts +8 -0
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/history.d.ts +21 -0
  73. package/dist/context/history.d.ts.map +1 -1
  74. package/dist/context/history.js +50 -0
  75. package/dist/context/history.js.map +1 -1
  76. package/dist/enterprise/vault.d.ts.map +1 -1
  77. package/dist/enterprise/vault.js +5 -21
  78. package/dist/enterprise/vault.js.map +1 -1
  79. package/dist/gateway/hook-contract.d.ts +206 -0
  80. package/dist/gateway/hook-contract.d.ts.map +1 -0
  81. package/dist/gateway/hook-contract.js +449 -0
  82. package/dist/gateway/hook-contract.js.map +1 -0
  83. package/dist/gateway/hooks.d.ts.map +1 -1
  84. package/dist/gateway/hooks.js +14 -0
  85. package/dist/gateway/hooks.js.map +1 -1
  86. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  87. package/dist/inference/gemini-adapter.js +4 -0
  88. package/dist/inference/gemini-adapter.js.map +1 -1
  89. package/dist/inference/interface.d.ts +54 -0
  90. package/dist/inference/interface.d.ts.map +1 -1
  91. package/dist/inference/model-id-validation.d.ts +57 -0
  92. package/dist/inference/model-id-validation.d.ts.map +1 -0
  93. package/dist/inference/model-id-validation.js +127 -0
  94. package/dist/inference/model-id-validation.js.map +1 -0
  95. package/dist/inference/native-tools.d.ts +16 -1
  96. package/dist/inference/native-tools.d.ts.map +1 -1
  97. package/dist/inference/native-tools.js +34 -10
  98. package/dist/inference/native-tools.js.map +1 -1
  99. package/dist/inference/openai-compat-adapter.d.ts.map +1 -1
  100. package/dist/inference/openai-compat-adapter.js +12 -1
  101. package/dist/inference/openai-compat-adapter.js.map +1 -1
  102. package/dist/inference/provider-catalog.d.ts +11 -0
  103. package/dist/inference/provider-catalog.d.ts.map +1 -1
  104. package/dist/inference/provider-catalog.js +80 -0
  105. package/dist/inference/provider-catalog.js.map +1 -1
  106. package/dist/inference/route-resolver.d.ts +32 -8
  107. package/dist/inference/route-resolver.d.ts.map +1 -1
  108. package/dist/inference/route-resolver.js +175 -1
  109. package/dist/inference/route-resolver.js.map +1 -1
  110. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  111. package/dist/inference/tool-call-utils.js +10 -8
  112. package/dist/inference/tool-call-utils.js.map +1 -1
  113. package/dist/inference/tools.d.ts +17 -1
  114. package/dist/inference/tools.d.ts.map +1 -1
  115. package/dist/inference/tools.js +69 -19
  116. package/dist/inference/tools.js.map +1 -1
  117. package/dist/learning/acceptance-model.d.ts +247 -0
  118. package/dist/learning/acceptance-model.d.ts.map +1 -0
  119. package/dist/learning/acceptance-model.js +708 -0
  120. package/dist/learning/acceptance-model.js.map +1 -0
  121. package/dist/learning/auto-router.d.ts +121 -24
  122. package/dist/learning/auto-router.d.ts.map +1 -1
  123. package/dist/learning/auto-router.js +374 -96
  124. package/dist/learning/auto-router.js.map +1 -1
  125. package/dist/learning/autonomy-policy.d.ts +12 -16
  126. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  127. package/dist/learning/autonomy-policy.js +50 -48
  128. package/dist/learning/autonomy-policy.js.map +1 -1
  129. package/dist/learning/behavioural-labels.d.ts +86 -0
  130. package/dist/learning/behavioural-labels.d.ts.map +1 -0
  131. package/dist/learning/behavioural-labels.js +166 -0
  132. package/dist/learning/behavioural-labels.js.map +1 -0
  133. package/dist/learning/capability-evidence.d.ts +167 -0
  134. package/dist/learning/capability-evidence.d.ts.map +1 -0
  135. package/dist/learning/capability-evidence.js +210 -0
  136. package/dist/learning/capability-evidence.js.map +1 -0
  137. package/dist/learning/capability-readiness.d.ts +64 -0
  138. package/dist/learning/capability-readiness.d.ts.map +1 -0
  139. package/dist/learning/capability-readiness.js +74 -0
  140. package/dist/learning/capability-readiness.js.map +1 -0
  141. package/dist/learning/capability-types.d.ts +169 -0
  142. package/dist/learning/capability-types.d.ts.map +1 -0
  143. package/dist/learning/capability-types.js +20 -0
  144. package/dist/learning/capability-types.js.map +1 -0
  145. package/dist/learning/catalog-feed.d.ts +89 -0
  146. package/dist/learning/catalog-feed.d.ts.map +1 -0
  147. package/dist/learning/catalog-feed.js +211 -0
  148. package/dist/learning/catalog-feed.js.map +1 -0
  149. package/dist/learning/cli-intent-effects.d.ts +60 -0
  150. package/dist/learning/cli-intent-effects.d.ts.map +1 -0
  151. package/dist/learning/cli-intent-effects.js +153 -0
  152. package/dist/learning/cli-intent-effects.js.map +1 -0
  153. package/dist/learning/command-adaptation.d.ts +41 -0
  154. package/dist/learning/command-adaptation.d.ts.map +1 -0
  155. package/dist/learning/command-adaptation.js +122 -0
  156. package/dist/learning/command-adaptation.js.map +1 -0
  157. package/dist/learning/consent-picture.d.ts +42 -0
  158. package/dist/learning/consent-picture.d.ts.map +1 -0
  159. package/dist/learning/consent-picture.js +53 -0
  160. package/dist/learning/consent-picture.js.map +1 -0
  161. package/dist/learning/context-budget.d.ts +37 -6
  162. package/dist/learning/context-budget.d.ts.map +1 -1
  163. package/dist/learning/context-budget.js +38 -9
  164. package/dist/learning/context-budget.js.map +1 -1
  165. package/dist/learning/decision-log.d.ts +114 -0
  166. package/dist/learning/decision-log.d.ts.map +1 -0
  167. package/dist/learning/decision-log.js +296 -0
  168. package/dist/learning/decision-log.js.map +1 -0
  169. package/dist/learning/deliverable-class.d.ts.map +1 -1
  170. package/dist/learning/deliverable-class.js +19 -0
  171. package/dist/learning/deliverable-class.js.map +1 -1
  172. package/dist/learning/deliverable-corpus.d.ts +117 -0
  173. package/dist/learning/deliverable-corpus.d.ts.map +1 -0
  174. package/dist/learning/deliverable-corpus.js +176 -0
  175. package/dist/learning/deliverable-corpus.js.map +1 -0
  176. package/dist/learning/error-repair.d.ts +45 -2
  177. package/dist/learning/error-repair.d.ts.map +1 -1
  178. package/dist/learning/error-repair.js +95 -0
  179. package/dist/learning/error-repair.js.map +1 -1
  180. package/dist/learning/exhaustion-report.d.ts +136 -0
  181. package/dist/learning/exhaustion-report.d.ts.map +1 -0
  182. package/dist/learning/exhaustion-report.js +239 -0
  183. package/dist/learning/exhaustion-report.js.map +1 -0
  184. package/dist/learning/failure-bookkeeping.d.ts +16 -0
  185. package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
  186. package/dist/learning/failure-bookkeeping.js +51 -5
  187. package/dist/learning/failure-bookkeeping.js.map +1 -1
  188. package/dist/learning/gate-friction.d.ts +39 -0
  189. package/dist/learning/gate-friction.d.ts.map +1 -0
  190. package/dist/learning/gate-friction.js +91 -0
  191. package/dist/learning/gate-friction.js.map +1 -0
  192. package/dist/learning/harness-narration.d.ts +34 -0
  193. package/dist/learning/harness-narration.d.ts.map +1 -0
  194. package/dist/learning/harness-narration.js +58 -0
  195. package/dist/learning/harness-narration.js.map +1 -0
  196. package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
  197. package/dist/learning/hub-skill-catalog.js +1 -2
  198. package/dist/learning/hub-skill-catalog.js.map +1 -1
  199. package/dist/learning/hybrid-router.d.ts +65 -0
  200. package/dist/learning/hybrid-router.d.ts.map +1 -1
  201. package/dist/learning/hybrid-router.js +186 -0
  202. package/dist/learning/hybrid-router.js.map +1 -1
  203. package/dist/learning/knowledge-base.d.ts +350 -0
  204. package/dist/learning/knowledge-base.d.ts.map +1 -0
  205. package/dist/learning/knowledge-base.js +906 -0
  206. package/dist/learning/knowledge-base.js.map +1 -0
  207. package/dist/learning/knowledge-eval.d.ts +171 -0
  208. package/dist/learning/knowledge-eval.d.ts.map +1 -0
  209. package/dist/learning/knowledge-eval.js +196 -0
  210. package/dist/learning/knowledge-eval.js.map +1 -0
  211. package/dist/learning/knowledge-turn.d.ts +92 -0
  212. package/dist/learning/knowledge-turn.d.ts.map +1 -0
  213. package/dist/learning/knowledge-turn.js +190 -0
  214. package/dist/learning/knowledge-turn.js.map +1 -0
  215. package/dist/learning/learned-commands.d.ts +64 -0
  216. package/dist/learning/learned-commands.d.ts.map +1 -0
  217. package/dist/learning/learned-commands.js +108 -0
  218. package/dist/learning/learned-commands.js.map +1 -0
  219. package/dist/learning/lexical-search.d.ts +50 -0
  220. package/dist/learning/lexical-search.d.ts.map +1 -0
  221. package/dist/learning/lexical-search.js +111 -0
  222. package/dist/learning/lexical-search.js.map +1 -0
  223. package/dist/learning/machine-facts.d.ts +71 -0
  224. package/dist/learning/machine-facts.d.ts.map +1 -0
  225. package/dist/learning/machine-facts.js +164 -0
  226. package/dist/learning/machine-facts.js.map +1 -0
  227. package/dist/learning/markdown-outline.d.ts +73 -0
  228. package/dist/learning/markdown-outline.d.ts.map +1 -0
  229. package/dist/learning/markdown-outline.js +136 -0
  230. package/dist/learning/markdown-outline.js.map +1 -0
  231. package/dist/learning/model-first-router.d.ts +33 -1
  232. package/dist/learning/model-first-router.d.ts.map +1 -1
  233. package/dist/learning/model-first-router.js +123 -9
  234. package/dist/learning/model-first-router.js.map +1 -1
  235. package/dist/learning/model-identity.d.ts +80 -0
  236. package/dist/learning/model-identity.d.ts.map +1 -0
  237. package/dist/learning/model-identity.js +115 -0
  238. package/dist/learning/model-identity.js.map +1 -0
  239. package/dist/learning/model-registry.d.ts +141 -2
  240. package/dist/learning/model-registry.d.ts.map +1 -1
  241. package/dist/learning/model-registry.js +324 -38
  242. package/dist/learning/model-registry.js.map +1 -1
  243. package/dist/learning/model-scoring.d.ts.map +1 -1
  244. package/dist/learning/model-scoring.js +8 -1
  245. package/dist/learning/model-scoring.js.map +1 -1
  246. package/dist/learning/model-selection.d.ts +2 -1
  247. package/dist/learning/model-selection.d.ts.map +1 -1
  248. package/dist/learning/model-selection.js +76 -2
  249. package/dist/learning/model-selection.js.map +1 -1
  250. package/dist/learning/outcome-observation.d.ts +49 -0
  251. package/dist/learning/outcome-observation.d.ts.map +1 -0
  252. package/dist/learning/outcome-observation.js +95 -0
  253. package/dist/learning/outcome-observation.js.map +1 -0
  254. package/dist/learning/pair-entitlement.d.ts +184 -0
  255. package/dist/learning/pair-entitlement.d.ts.map +1 -0
  256. package/dist/learning/pair-entitlement.js +214 -0
  257. package/dist/learning/pair-entitlement.js.map +1 -0
  258. package/dist/learning/project-docs.d.ts +83 -0
  259. package/dist/learning/project-docs.d.ts.map +1 -0
  260. package/dist/learning/project-docs.js +132 -0
  261. package/dist/learning/project-docs.js.map +1 -0
  262. package/dist/learning/provider-fallback.d.ts +23 -1
  263. package/dist/learning/provider-fallback.d.ts.map +1 -1
  264. package/dist/learning/provider-fallback.js +24 -0
  265. package/dist/learning/provider-fallback.js.map +1 -1
  266. package/dist/learning/quota-ledger.d.ts +13 -0
  267. package/dist/learning/quota-ledger.d.ts.map +1 -1
  268. package/dist/learning/quota-ledger.js +28 -0
  269. package/dist/learning/quota-ledger.js.map +1 -1
  270. package/dist/learning/reasoning-trace.d.ts +65 -1
  271. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  272. package/dist/learning/reasoning-trace.js +123 -1
  273. package/dist/learning/reasoning-trace.js.map +1 -1
  274. package/dist/learning/requirement-probe.d.ts +55 -0
  275. package/dist/learning/requirement-probe.d.ts.map +1 -0
  276. package/dist/learning/requirement-probe.js +77 -0
  277. package/dist/learning/requirement-probe.js.map +1 -0
  278. package/dist/learning/resilient-call.d.ts +20 -1
  279. package/dist/learning/resilient-call.d.ts.map +1 -1
  280. package/dist/learning/resilient-call.js +42 -4
  281. package/dist/learning/resilient-call.js.map +1 -1
  282. package/dist/learning/response-usability.d.ts +85 -0
  283. package/dist/learning/response-usability.d.ts.map +1 -0
  284. package/dist/learning/response-usability.js +135 -0
  285. package/dist/learning/response-usability.js.map +1 -0
  286. package/dist/learning/router-bandit.d.ts +132 -0
  287. package/dist/learning/router-bandit.d.ts.map +1 -1
  288. package/dist/learning/router-bandit.js +253 -0
  289. package/dist/learning/router-bandit.js.map +1 -1
  290. package/dist/learning/routing-history.d.ts +41 -2
  291. package/dist/learning/routing-history.d.ts.map +1 -1
  292. package/dist/learning/routing-history.js.map +1 -1
  293. package/dist/learning/run-trace.d.ts +21 -0
  294. package/dist/learning/run-trace.d.ts.map +1 -1
  295. package/dist/learning/run-trace.js +38 -0
  296. package/dist/learning/run-trace.js.map +1 -1
  297. package/dist/learning/seeded-benchmark.d.ts +14 -0
  298. package/dist/learning/seeded-benchmark.d.ts.map +1 -1
  299. package/dist/learning/seeded-benchmark.js +43 -4
  300. package/dist/learning/seeded-benchmark.js.map +1 -1
  301. package/dist/learning/session-grant.d.ts +75 -0
  302. package/dist/learning/session-grant.d.ts.map +1 -0
  303. package/dist/learning/session-grant.js +112 -0
  304. package/dist/learning/session-grant.js.map +1 -0
  305. package/dist/learning/task-model-continuity.d.ts +73 -0
  306. package/dist/learning/task-model-continuity.d.ts.map +1 -0
  307. package/dist/learning/task-model-continuity.js +87 -0
  308. package/dist/learning/task-model-continuity.js.map +1 -0
  309. package/dist/learning/turn-feedback.d.ts +93 -0
  310. package/dist/learning/turn-feedback.d.ts.map +1 -0
  311. package/dist/learning/turn-feedback.js +127 -0
  312. package/dist/learning/turn-feedback.js.map +1 -0
  313. package/dist/learning/turn-report.d.ts +104 -2
  314. package/dist/learning/turn-report.d.ts.map +1 -1
  315. package/dist/learning/turn-report.js +161 -6
  316. package/dist/learning/turn-report.js.map +1 -1
  317. package/dist/learning/verdict-routing.d.ts +51 -0
  318. package/dist/learning/verdict-routing.d.ts.map +1 -0
  319. package/dist/learning/verdict-routing.js +122 -0
  320. package/dist/learning/verdict-routing.js.map +1 -0
  321. package/dist/mcp/client.d.ts.map +1 -1
  322. package/dist/mcp/client.js +5 -0
  323. package/dist/mcp/client.js.map +1 -1
  324. package/dist/mcp/manager.d.ts +10 -0
  325. package/dist/mcp/manager.d.ts.map +1 -1
  326. package/dist/mcp/manager.js +20 -0
  327. package/dist/mcp/manager.js.map +1 -1
  328. package/dist/mcp/mcp-discovery.d.ts +34 -0
  329. package/dist/mcp/mcp-discovery.d.ts.map +1 -0
  330. package/dist/mcp/mcp-discovery.js +71 -0
  331. package/dist/mcp/mcp-discovery.js.map +1 -0
  332. package/dist/mcp/mcp-schema-cache.d.ts +17 -0
  333. package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
  334. package/dist/mcp/mcp-schema-cache.js +47 -3
  335. package/dist/mcp/mcp-schema-cache.js.map +1 -1
  336. package/dist/mcp/types.d.ts +28 -0
  337. package/dist/mcp/types.d.ts.map +1 -1
  338. package/dist/mcp/types.js.map +1 -1
  339. package/dist/security/secret-scan.d.ts +156 -0
  340. package/dist/security/secret-scan.d.ts.map +1 -0
  341. package/dist/security/secret-scan.js +525 -0
  342. package/dist/security/secret-scan.js.map +1 -0
  343. package/dist/tools/ask-user.d.ts.map +1 -1
  344. package/dist/tools/ask-user.js +4 -0
  345. package/dist/tools/ask-user.js.map +1 -1
  346. package/dist/tools/capability-registry.d.ts +150 -0
  347. package/dist/tools/capability-registry.d.ts.map +1 -0
  348. package/dist/tools/capability-registry.js +400 -0
  349. package/dist/tools/capability-registry.js.map +1 -0
  350. package/dist/tools/child-agent-runtime.d.ts.map +1 -1
  351. package/dist/tools/child-agent-runtime.js +19 -5
  352. package/dist/tools/child-agent-runtime.js.map +1 -1
  353. package/dist/tools/coding-tools.d.ts +17 -5
  354. package/dist/tools/coding-tools.d.ts.map +1 -1
  355. package/dist/tools/coding-tools.js +80 -11
  356. package/dist/tools/coding-tools.js.map +1 -1
  357. package/dist/tools/command-memo.d.ts +116 -0
  358. package/dist/tools/command-memo.d.ts.map +1 -0
  359. package/dist/tools/command-memo.js +202 -0
  360. package/dist/tools/command-memo.js.map +1 -0
  361. package/dist/tools/git-tool.d.ts.map +1 -1
  362. package/dist/tools/git-tool.js +13 -3
  363. package/dist/tools/git-tool.js.map +1 -1
  364. package/dist/tools/harness-directive.d.ts +42 -0
  365. package/dist/tools/harness-directive.d.ts.map +1 -0
  366. package/dist/tools/harness-directive.js +45 -0
  367. package/dist/tools/harness-directive.js.map +1 -0
  368. package/dist/tools/loop-skill-hint.d.ts +74 -174
  369. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  370. package/dist/tools/loop-skill-hint.js +110 -536
  371. package/dist/tools/loop-skill-hint.js.map +1 -1
  372. package/dist/tools/mcp-client-tool.d.ts +2 -0
  373. package/dist/tools/mcp-client-tool.d.ts.map +1 -1
  374. package/dist/tools/mcp-client-tool.js +3 -0
  375. package/dist/tools/mcp-client-tool.js.map +1 -1
  376. package/dist/tools/modality/shared.d.ts +1 -4
  377. package/dist/tools/modality/shared.d.ts.map +1 -1
  378. package/dist/tools/modality/shared.js +7 -22
  379. package/dist/tools/modality/shared.js.map +1 -1
  380. package/dist/tools/plan-store.d.ts +44 -4
  381. package/dist/tools/plan-store.d.ts.map +1 -1
  382. package/dist/tools/plan-store.js +93 -9
  383. package/dist/tools/plan-store.js.map +1 -1
  384. package/dist/tools/registry.d.ts +76 -3
  385. package/dist/tools/registry.d.ts.map +1 -1
  386. package/dist/tools/registry.js +623 -15
  387. package/dist/tools/registry.js.map +1 -1
  388. package/dist/tools/remediation-ladder.d.ts +91 -0
  389. package/dist/tools/remediation-ladder.d.ts.map +1 -0
  390. package/dist/tools/remediation-ladder.js +316 -0
  391. package/dist/tools/remediation-ladder.js.map +1 -0
  392. package/dist/tools/run-cli.d.ts.map +1 -1
  393. package/dist/tools/run-cli.js +29 -4
  394. package/dist/tools/run-cli.js.map +1 -1
  395. package/dist/tools/run-terminal.d.ts +11 -0
  396. package/dist/tools/run-terminal.d.ts.map +1 -1
  397. package/dist/tools/run-terminal.js +289 -10
  398. package/dist/tools/run-terminal.js.map +1 -1
  399. package/dist/tools/skills-hub.d.ts +7 -0
  400. package/dist/tools/skills-hub.d.ts.map +1 -1
  401. package/dist/tools/skills-hub.js +11 -2
  402. package/dist/tools/skills-hub.js.map +1 -1
  403. package/dist/tools/step-artifact.d.ts +35 -0
  404. package/dist/tools/step-artifact.d.ts.map +1 -0
  405. package/dist/tools/step-artifact.js +154 -0
  406. package/dist/tools/step-artifact.js.map +1 -0
  407. package/dist/tools/tool-loop.d.ts +327 -13
  408. package/dist/tools/tool-loop.d.ts.map +1 -1
  409. package/dist/tools/tool-loop.js +891 -66
  410. package/dist/tools/tool-loop.js.map +1 -1
  411. package/dist/tools/toolsets.js +4 -4
  412. package/dist/tools/toolsets.js.map +1 -1
  413. package/dist/tools/worktree.d.ts +11 -2
  414. package/dist/tools/worktree.d.ts.map +1 -1
  415. package/dist/tools/worktree.js +11 -2
  416. package/dist/tools/worktree.js.map +1 -1
  417. package/dist/utils/atomic-store.d.ts +53 -0
  418. package/dist/utils/atomic-store.d.ts.map +1 -0
  419. package/dist/utils/atomic-store.js +178 -0
  420. package/dist/utils/atomic-store.js.map +1 -0
  421. package/dist/utils/binary-probe.d.ts +25 -0
  422. package/dist/utils/binary-probe.d.ts.map +1 -0
  423. package/dist/utils/binary-probe.js +48 -0
  424. package/dist/utils/binary-probe.js.map +1 -0
  425. package/dist/utils/workspace-path.d.ts +50 -0
  426. package/dist/utils/workspace-path.d.ts.map +1 -0
  427. package/dist/utils/workspace-path.js +86 -0
  428. package/dist/utils/workspace-path.js.map +1 -0
  429. package/dist/web-dashboard/chat-console.d.ts +24 -0
  430. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  431. package/dist/web-dashboard/chat-console.js +38 -0
  432. package/dist/web-dashboard/chat-console.js.map +1 -1
  433. package/dist/web-dashboard/project-context.d.ts +27 -2
  434. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  435. package/dist/web-dashboard/project-context.js +32 -2
  436. package/dist/web-dashboard/project-context.js.map +1 -1
  437. package/dist/web-dashboard/server.d.ts +40 -0
  438. package/dist/web-dashboard/server.d.ts.map +1 -1
  439. package/dist/web-dashboard/server.js +733 -15
  440. package/dist/web-dashboard/server.js.map +1 -1
  441. package/dist/web-dashboard/src/types.d.ts +236 -1
  442. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  443. package/dist/web-dashboard/workspace-guard.d.ts +18 -2
  444. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  445. package/dist/web-dashboard/workspace-guard.js +94 -12
  446. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  447. package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
  448. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
  449. package/dist/web-dashboard/workspace-resolution.js +105 -0
  450. package/dist/web-dashboard/workspace-resolution.js.map +1 -0
  451. package/package.json +1 -1
  452. package/src/web-dashboard/public/assets/index-tK8Y2qHB.css +1 -0
  453. package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
  454. package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
  455. package/src/web-dashboard/public/index.html +2 -2
  456. package/src/web-dashboard/public/assets/index-BrZcYhy6.js +0 -207
  457. package/src/web-dashboard/public/assets/index-BrZcYhy6.js.map +0 -1
  458. package/src/web-dashboard/public/assets/index-DsWczTa6.css +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 } 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
- import { getAutoRouter, isAutoModel, isAutoProvider, governanceVerdict } from '../learning/auto-router.js';
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.
@@ -478,7 +524,10 @@ export class ChatCommand extends BaseCommand {
478
524
  // Best-effort — an unreadable config leaves the previous behavior.
479
525
  }
480
526
  }
481
- let autoMode = isAutoModel(mergedOpts.model) || isAutoProvider(mergedOpts.provider);
527
+ // A8 — a CONCRETE provider with `-m auto` means that provider's own auto
528
+ // (e.g. an OmniRoute combo), NOT our auto-route. isAgentAutoRoute excludes
529
+ // that case so the pin reaches the provider instead of being re-routed.
530
+ let autoMode = isAgentAutoRoute(mergedOpts.provider, mergedOpts.model);
482
531
  let { type, provider } = autoMode
483
532
  ? await this.getProvider({})
484
533
  : await this.getProvider(mergedOpts);
@@ -486,6 +535,11 @@ export class ChatCommand extends BaseCommand {
486
535
  // B — captured from the route so the post-route capability gate below can
487
536
  // judge the FINAL pair without re-resolving anything.
488
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();
489
543
  let routedText;
490
544
  if (autoMode) {
491
545
  // Intent-aware escalation: a bare "yes"/"do it" continuing software work
@@ -606,9 +660,27 @@ export class ChatCommand extends BaseCommand {
606
660
  recallBlock = undefined;
607
661
  }
608
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
+ }
609
681
  const parsed = parseRequestSync(message);
610
682
  const dispatchDecision = resolvePipelineDispatch(parsed, { dev: opts.dev, text: message });
611
- 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 });
612
684
  // No-model fallback: the tool loop could not generate a single response
613
685
  // AND the rules assessed a high-confidence pipeline intent — run the
614
686
  // pipeline directly (rules decide only when the model is unavailable; the
@@ -619,6 +691,15 @@ export class ChatCommand extends BaseCommand {
619
691
  // dispatching it here would run the ask on another engine entirely — the one
620
692
  // path that can run it UNISOLATED while the caller asked for isolation.
621
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'})`);
622
703
  const r = await runPipelineTool(message, this.configManager, { provider: type, model, board: false });
623
704
  // `success`, not `error`: a pipeline that RAN and failed reports its
624
705
  // outcome in `summary` and only sometimes sets `error`, so keying off
@@ -664,6 +745,16 @@ export class ChatCommand extends BaseCommand {
664
745
  successfulToolCalls: answer.successfulToolCalls,
665
746
  mutations: answer.runTrace?.mutations,
666
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
+ })(),
667
758
  flags: {
668
759
  unverifiedActionClaim: answer.unverifiedActionClaim,
669
760
  unverifiedEdit: answer.unverifiedEdit,
@@ -672,6 +763,15 @@ export class ChatCommand extends BaseCommand {
672
763
  undeliveredArtifact: answer.undeliveredArtifact,
673
764
  unfulfilledPromise: answer.unfulfilledPromise,
674
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),
675
775
  },
676
776
  });
677
777
  }
@@ -682,6 +782,109 @@ export class ChatCommand extends BaseCommand {
682
782
  // reviewable after the fact (the Trace tab renders it), not only in this
683
783
  // turn's return value. Best-effort: a trace write never breaks a turn.
684
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
+ }
685
888
  // E3 — surface a non-trivial report on the console. A plain answer (no
686
889
  // plan, nothing changed) produces no summary and stays silent.
687
890
  if (turnReport?.summary) {
@@ -701,6 +904,14 @@ export class ChatCommand extends BaseCommand {
701
904
  unfulfilledPromise: answer.unfulfilledPromise,
702
905
  undeliveredArtifact: answer.undeliveredArtifact,
703
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,
704
915
  // WS1 — the findings this turn recorded (empty when it recorded none).
705
916
  findings: answer.findings ?? [],
706
917
  provider: type,
@@ -739,8 +950,8 @@ export class ChatCommand extends BaseCommand {
739
950
  .description('Start an interactive chat session with AI')
740
951
  .argument('[prompt]', 'Optional initial prompt')
741
952
  .option('-f, --file <path>', 'Include file content as context')
742
- .option('-p, --provider <provider>', 'Inference provider')
743
- .option('-m, --model <model>', 'Model to use (if omitted, an interactive picker will appear)')
953
+ .option('-p, --provider <provider>', 'Inference provider to pin this chat to')
954
+ .option('-m, --model <model>', 'Model to pin this chat to (if omitted, an interactive picker will appear)')
744
955
  .option('--no-cache', 'Disable response caching')
745
956
  .option('-d, --dev', 'Always dispatch requests to the coding pipeline (no confirmation)', false)
746
957
  // WS5 (#27) — isolation and partial resume, as the two things an operator
@@ -756,6 +967,12 @@ export class ChatCommand extends BaseCommand {
756
967
  .option('--worktree', 'Run this turn in its own git worktree of the project and report the diff against the base commit. Refuses rather than running unisolated when the directory cannot be isolated (also asked for by NUVIRA_ISOLATE=1)')
757
968
  .option('--keep-worktree', 'Keep the isolated worktree after the turn instead of removing it')
758
969
  .option('--resume [id]', 'Replay the recorded steps of this ask whose input is unchanged instead of paying for them again (defaults to the record for this goal + directory; also asked for by NUVIRA_RESUME=1)')
970
+ .addHelpText('after', '\nModel routing:\n' +
971
+ ' Pinning -p/--provider (and -m/--model) does NOT stop the router from falling\n' +
972
+ ' over to another model if the pinned one is unavailable — auto routing takes\n' +
973
+ ' over and the turn says so. To work with the pinned model ONLY, set\n' +
974
+ ' NUVIRA_STRICT_MODEL=1: a dead pin then fails with a message naming the pair\n' +
975
+ ' instead of substituting another model.\n')
759
976
  .action(async (prompt, options) => {
760
977
  await this.execute(prompt, options || {});
761
978
  });
@@ -782,8 +999,44 @@ export class ChatCommand extends BaseCommand {
782
999
  // Apply the active model state from `nuvira model switch` as defaults
783
1000
  const activeOpts = applyActiveModel({ provider: options?.provider, model: options?.model });
784
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
+ }
785
1036
  // ── Auto routing mode: agent decides the best provider/model per message ──
786
- let autoMode = isAutoModel(mergedOpts.model) || isAutoProvider(mergedOpts.provider);
1037
+ // A8 — a pinned concrete provider with `-m auto` is the provider's own auto,
1038
+ // not ours. See isAgentAutoRoute.
1039
+ let autoMode = isAgentAutoRoute(mergedOpts.provider, mergedOpts.model);
787
1040
  let { type, provider } = autoMode
788
1041
  ? await this.getProvider({})
789
1042
  : await this.getProvider(mergedOpts);
@@ -879,6 +1132,10 @@ export class ChatCommand extends BaseCommand {
879
1132
  // WS5 — never on a REFUSED turn: the pipeline would run it in the real tree
880
1133
  // (see the guard in `answerOnce`).
881
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'})`);
882
1139
  await runDeveloperMode(prompt, this.configManager, { provider: type, model });
883
1140
  // After pipeline execution, show followups and continue conversation
884
1141
  // (don't just return — keep user engaged with next steps)
@@ -1128,7 +1385,20 @@ export class ChatCommand extends BaseCommand {
1128
1385
  ...(ctxOverrides?.debugSession ? { session: ctxOverrides.debugSession } : {}),
1129
1386
  backend: { engine: 'loop', provider: session.type, ...(session.model ? { model: session.model } : {}) },
1130
1387
  });
1131
- 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
+ });
1132
1402
  // WS3 (#25) — the turn's span root, when span export is on (else null). Same
1133
1403
  // identity as the log's: one surface, one conversation, one turn.
1134
1404
  const otelSpan = await startTurnSpan({
@@ -1303,26 +1573,36 @@ export class ChatCommand extends BaseCommand {
1303
1573
  // prompt, so on this surface the MODEL decides and the rules are invisible.
1304
1574
  const systemText = buildToolSystemPrompt(parsed);
1305
1575
  // Skill hint — MODE-DEPENDENT (see resolveSkillHintMode):
1306
- // - `match` (default) — the small keyword-matched hint: ONE skill, and
1307
- // only when the goal really matches; otherwise nothing. This is the
1308
- // 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.
1309
1580
  // - `catalog` (opt-in) — the full name+description catalog, which lets the
1310
- // MODEL pick a skill but costs ~24K chars on every turn, so it must be
1311
- // 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`).
1312
1584
  // - `off` — never inject one.
1313
1585
  // Best-effort: any failure returns '' and the turn proceeds byte-identically.
1314
1586
  let skillHint = '';
1315
1587
  try {
1316
- const { buildConfiguredSkillHint, markLoopSkillUsed } = await import('../tools/loop-skill-hint.js');
1317
- const injected = {
1318
- value: null,
1319
- };
1320
- skillHint = await buildConfiguredSkillHint(message, this.configManager, injected);
1321
- await markLoopSkillUsed(injected.value);
1588
+ const { buildConfiguredSkillHint } = await import('../tools/loop-skill-hint.js');
1589
+ skillHint = await buildConfiguredSkillHint(this.configManager);
1322
1590
  }
1323
1591
  catch {
1324
1592
  skillHint = ''; // best-effort — a hint failure never breaks the turn
1325
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
+ }
1326
1606
  // `-f/--file` file-context parity: the legacy generateWithContext loaded
1327
1607
  // + retrieval-reduced file context. Inject it as a context message before
1328
1608
  // the user's message so the tool-loop path keeps the flag working.
@@ -1358,6 +1638,25 @@ export class ChatCommand extends BaseCommand {
1358
1638
  ambientProjectContext = undefined;
1359
1639
  }
1360
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
+ }
1361
1660
  // G4 — carry THIS project's working state (files changed, verification debt,
1362
1661
  // user-reported regressions) into the turn, so the model does not re-derive
1363
1662
  // what previous turns already established. This is the fix for the
@@ -1379,7 +1678,7 @@ export class ChatCommand extends BaseCommand {
1379
1678
  // turn, where it used to occupy ~80% of the ask on WhatsApp turns.
1380
1679
  const systemPolicyBlock = ctxOverrides?.systemPolicy ? `\n\n${ctxOverrides.systemPolicy}` : '';
1381
1680
  const thread = [
1382
- { role: 'system', content: systemText + systemPolicyBlock + skillHint },
1681
+ { role: 'system', content: systemText + systemPolicyBlock + machineBlock + skillHint },
1383
1682
  // P3 — the attached project's bounded snapshot (path + file tree +
1384
1683
  // symbol map) rides in before the conversation, exactly like --file
1385
1684
  // context: the model knows what it is looking at without being told.
@@ -1390,11 +1689,22 @@ export class ChatCommand extends BaseCommand {
1390
1689
  : ambientProjectContext
1391
1690
  ? [{ role: 'user', content: `[Project context]\n${ambientProjectContext}` }]
1392
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 }] : []),
1393
1697
  // P4 — the recalled project context (prior sessions + facts) rides in
1394
1698
  // next, so the model starts from what this project was last doing.
1395
1699
  ...(ctxOverrides?.recallContext
1396
1700
  ? [{ role: 'user', content: ctxOverrides.recallContext }]
1397
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
+ : []),
1398
1708
  // G4 — the deterministic working-state ledger (never a summary).
1399
1709
  ...(workingStateBlock ? [{ role: 'user', content: workingStateBlock }] : []),
1400
1710
  ...(fileContext
@@ -1446,6 +1756,18 @@ export class ChatCommand extends BaseCommand {
1446
1756
  // P4 — when a project is attached, scope tools to its root so the
1447
1757
  // agent operates inside the project (not the dashboard server's cwd).
1448
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),
1449
1771
  emit: (event, data, source) => {
1450
1772
  // G3 — collect mutated file paths from `tool:started` (which carries
1451
1773
  // the arguments) so the working-state ledger knows what changed.
@@ -1613,7 +1935,11 @@ export class ChatCommand extends BaseCommand {
1613
1935
  { name: 'system:identity+tool-contract', chars: systemText.length },
1614
1936
  { name: 'system:channel-policy', chars: systemPolicyBlock.length },
1615
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 },
1616
1941
  { name: 'working-state', chars: workingStateBlock.length, dropPriority: 25 },
1942
+ { name: 'decisions', chars: ctxOverrides?.decisionContext?.length ?? 0, dropPriority: 28 },
1617
1943
  { name: 'recall', chars: ctxOverrides?.recallContext?.length ?? 0, dropPriority: 30 },
1618
1944
  {
1619
1945
  name: 'project-context',
@@ -1623,10 +1949,17 @@ export class ChatCommand extends BaseCommand {
1623
1949
  { name: 'file-context', chars: fileContext?.length ?? 0, dropPriority: 45 },
1624
1950
  { name: 'history+ask', chars: historyChars },
1625
1951
  ], { budget: contextBudget });
1626
- // Apply the FIRST ladder step (the documented, safe one): drop the skill
1627
- // hint from the assembled system message.
1628
- if (report.trims.includes('skill-hint') && skillHint) {
1629
- 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
+ };
1630
1963
  }
1631
1964
  if (report.level !== 'ok') {
1632
1965
  recordTraceEvent(chatTraceId, {
@@ -1643,6 +1976,9 @@ export class ChatCommand extends BaseCommand {
1643
1976
  // The instant this turn's model walk began. A failed generation records the
1644
1977
  // WALK (below) relative to this mark, so the trace can say what was tried.
1645
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 = [];
1646
1982
  const seenStepDigests = new Set();
1647
1983
  const digestPrompt = (p) => {
1648
1984
  try {
@@ -1706,6 +2042,146 @@ export class ChatCommand extends BaseCommand {
1706
2042
  throw err;
1707
2043
  }
1708
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
+ };
1709
2185
  try {
1710
2186
  // R1 — the harness follows the MODEL, not only the config: config says
1711
2187
  // what this deployment prefers, the profile decides what this model can
@@ -1747,6 +2223,13 @@ export class ChatCommand extends BaseCommand {
1747
2223
  onTraceEvent: (event) => recordTraceEvent(chatTraceId, event),
1748
2224
  deps: {
1749
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 }),
1750
2233
  executeTool: async (name, args, ctx) => {
1751
2234
  const tool = getTool(name);
1752
2235
  if (!tool)
@@ -1767,6 +2250,19 @@ export class ChatCommand extends BaseCommand {
1767
2250
  },
1768
2251
  },
1769
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
+ }
1770
2266
  }
1771
2267
  catch (err) {
1772
2268
  // The tool loop does not throw on its own; this catches the errors that
@@ -1823,6 +2319,71 @@ export class ChatCommand extends BaseCommand {
1823
2319
  generationFailed: true,
1824
2320
  };
1825
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
+ }
1826
2387
  // Record WHAT HAPPENED, not just "the model answered": a hallucinated
1827
2388
  // "I have sent it" (no tool ran) must be visible as an unverified claim in
1828
2389
  // the Trace tab instead of looking like a real delivery.
@@ -1839,6 +2400,10 @@ export class ChatCommand extends BaseCommand {
1839
2400
  unverifiedEditClaim: result.unverifiedEditClaim,
1840
2401
  undeliveredArtifact: result.undeliveredArtifact,
1841
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),
1842
2407
  });
1843
2408
  // A cancelled / failed / incomplete turn is NOT a success. `!generationFailed`
1844
2409
  // used to let a cancelled run record `success: true` while its outcome said
@@ -1902,6 +2467,31 @@ export class ChatCommand extends BaseCommand {
1902
2467
  if (result.unverifiedBuildClaim) {
1903
2468
  logger.warn(' ⚠️ A build command FAILED this turn and no later build succeeded, but the reply reports success — treat the build as UNVERIFIED.');
1904
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
+ }
1905
2495
  }
1906
2496
  catch {
1907
2497
  // Best-effort.
@@ -1968,11 +2558,18 @@ export class ChatCommand extends BaseCommand {
1968
2558
  // affect the answer.
1969
2559
  if (debugLog) {
1970
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;
1971
2566
  debugLog.backendOf({
1972
- provider: this.lastAttempt?.provider ?? session.type,
2567
+ provider: servedProvider,
1973
2568
  ...(servedModel ? { model: servedModel } : {}),
1974
2569
  transport: result.transport ?? null,
1975
2570
  });
2571
+ const requested = session.model ? `${session.type}/${session.model}` : session.type;
2572
+ const served = servedModel ? `${servedProvider}/${servedModel}` : servedProvider;
1976
2573
  debugLog.event('turn.end', {
1977
2574
  generationFailed: result.generationFailed === true,
1978
2575
  cancelled: result.cancelled === true,
@@ -1980,6 +2577,11 @@ export class ChatCommand extends BaseCommand {
1980
2577
  contentChars: result.content.length,
1981
2578
  toolCalls: result.toolCalls?.length ?? 0,
1982
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 }),
1983
2585
  });
1984
2586
  const notice = debugLogNotice(ctxOverrides?.debugSurface ?? 'cli-chat', debugLog.write());
1985
2587
  if (notice)
@@ -2014,6 +2616,11 @@ export class ChatCommand extends BaseCommand {
2014
2616
  unfulfilledPromise: result.unfulfilledPromise,
2015
2617
  undeliveredArtifact: result.undeliveredArtifact,
2016
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),
2017
2624
  });
2018
2625
  const chatFollowups = withContinuationFollowups(result.followups, {
2019
2626
  unfinished: !traceOutcomeSucceeded(chatOutcomeForFollowups),
@@ -2030,6 +2637,14 @@ export class ChatCommand extends BaseCommand {
2030
2637
  unfulfilledPromise: result.unfulfilledPromise,
2031
2638
  undeliveredArtifact: result.undeliveredArtifact,
2032
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,
2033
2648
  // R2 — the transport this turn travelled on (interactive REPL path).
2034
2649
  transport: result.transport,
2035
2650
  // WS1 — the findings this turn recorded, with their verdicts.
@@ -2143,6 +2758,41 @@ export class ChatCommand extends BaseCommand {
2143
2758
  // refused it). The reason names the provider and the rule.
2144
2759
  throw new Error(`Governance policy: ${verdict.reason}`);
2145
2760
  }
2761
+ // ADMIN BUDGET (pinned path). The pin still runs the model the user
2762
+ // chose — this only REFUSES it when the user's own declared budget
2763
+ // (routing.quota window, admin cost cap) is already exceeded, instead
2764
+ // of spending past the control they set in the dashboard. A no-op when
2765
+ // no budget is configured.
2766
+ const budget = adminBudgetVerdict(this.configManager, session.type, { model: session.model });
2767
+ if (!budget.allowed) {
2768
+ throw new Error(`Admin budget: ${budget.reason}`);
2769
+ }
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);
2146
2796
  }
2147
2797
  const resolveEffectiveModel = (providerType, requested) => {
2148
2798
  if (requested && requested !== 'default')
@@ -2286,6 +2936,16 @@ export class ChatCommand extends BaseCommand {
2286
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)}`));
2287
2937
  }
2288
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
+ });
2289
2949
  // Auto mode: fail over across the ranked candidates (never stuck).
2290
2950
  if (mode.auto) {
2291
2951
  const firstType = session.type;
@@ -2360,13 +3020,29 @@ export class ChatCommand extends BaseCommand {
2360
3020
  logger.success(`✅ Auto failover: answered from ${next.provider.name} (${next.model}) after ${firstType} failed`);
2361
3021
  return resp;
2362
3022
  }
2363
- 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
+ });
2364
3033
  // Next candidate.
2365
3034
  }
2366
3035
  }
2367
3036
  }
2368
- else if (isRetryableError(classifyFallbackError(err))) {
3037
+ else if (!strictModelMode() && isRetryableError(classifyFallbackError(err))) {
2369
3038
  // Non-auto: walk the shared fallback chain (retryable errors only).
3039
+ //
3040
+ // Under strict model mode the walk is exactly the substitution the
3041
+ // user forbade: `strictModelMode()` short-circuits this branch, so a
3042
+ // pinned model that cannot answer surfaces its own error instead of
3043
+ // quietly continuing on a provider the user did not choose (A2 — the
3044
+ // same defect the loop engine had, fixed for dashboard + CLI chat
3045
+ // here). The `tryGenerate` call above already reports the raw error.
2370
3046
  // Providers the admin policy rules out are collected here so the
2371
3047
  // failure can name POLICY as the reason instead of implying the model
2372
3048
  // was unreachable.
@@ -2391,6 +3067,10 @@ export class ChatCommand extends BaseCommand {
2391
3067
  catch {
2392
3068
  // Ordering is an optimization only.
2393
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;
2394
3074
  for (const fbType of ordered) {
2395
3075
  if (fbType === session.type)
2396
3076
  continue;
@@ -2428,7 +3108,40 @@ export class ChatCommand extends BaseCommand {
2428
3108
  // the dashboard console and the gateway chat engine - looked
2429
3109
  // dead.) Undefined = that provider's configured/adapter
2430
3110
  // default, which tryGenerate resolves per attempt.
2431
- 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;
2432
3145
  }
2433
3146
  catch {
2434
3147
  // Next fallback candidate.
@@ -2642,16 +3355,25 @@ export class ChatCommand extends BaseCommand {
2642
3355
  .sort()
2643
3356
  .join(','),
2644
3357
  ]);
2645
- const decision = withRoutingCache(cacheSignature, 30_000, () => getAutoRouter().resolve('chat', taskText, {
3358
+ const resolveFresh = () => getAutoRouter().resolve('chat', taskText, {
2646
3359
  ...buildAutoResolveOptions(this.configManager, {
2647
3360
  verbose: envBuff('DEBUG') === 'true',
2648
3361
  contextHintTokens: opts?.contextHintTokens,
2649
3362
  }),
2650
3363
  circuitBreakerStatus,
2651
3364
  ...(dispatch.taskIntentHint ? { taskIntentHint: dispatch.taskIntentHint } : {}),
2652
- }, this.configManager));
3365
+ }, this.configManager);
3366
+ const decision = opts?.noCache === true
3367
+ ? resolveFresh()
3368
+ : withRoutingCache(cacheSignature, 30_000, resolveFresh);
2653
3369
  // G5 — record the routing snapshot now (the winner + why), so the reasoning
2654
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));
2655
3377
  this.lastRouteSnapshot = {
2656
3378
  provider: decision.provider,
2657
3379
  model: decision.model,
@@ -2662,6 +3384,9 @@ export class ChatCommand extends BaseCommand {
2662
3384
  // and console can show WHY the pair was chosen, not just which pair.
2663
3385
  agenticCapable: decision.agenticCapable,
2664
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 } : {}),
2665
3390
  };
2666
3391
  // Walk the ranked candidates (winner first) and return the first available
2667
3392
  // provider — never a provider that lacks a key or endpoint. Providers that
@@ -2752,9 +3477,15 @@ export class ChatCommand extends BaseCommand {
2752
3477
  crossPipelineMemory: false,
2753
3478
  registryCheck: false,
2754
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).
2755
3486
  const chatCandidates = [];
2756
3487
  const seenPairs = new Set();
2757
- const pushCandidate = (prov, mdl) => {
3488
+ const pushCandidate = (prov, mdl, score, scoreBasis) => {
2758
3489
  if (!prov || excluded.has(prov) || registryBlocked.has(prov))
2759
3490
  return;
2760
3491
  const model = mdl && mdl !== 'default' ? mdl : 'default';
@@ -2772,7 +3503,13 @@ export class ChatCommand extends BaseCommand {
2772
3503
  // already ranks healthy models first, so the parked pick is only ever a
2773
3504
  // last resort that the repair then fixes.
2774
3505
  seenPairs.add(key);
2775
- 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
+ });
2776
3513
  };
2777
3514
  // The pool itself is the SAME one the orchestrator/tool/sub-agent path
2778
3515
  // walks: primary → model-first TIERED pool (same model on other providers,
@@ -2780,13 +3517,25 @@ export class ChatCommand extends BaseCommand {
2780
3517
  // reserve) → ranked placeholders → config fallback. Chat used to build a
2781
3518
  // shallower list here, which is why it reached strictly fewer models than
2782
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
+ }
2783
3532
  const pool = buildDeepFailoverPool(decision, {
2784
3533
  taskDescription: message,
2785
3534
  complexity: decision.complexity,
2786
3535
  configManager: this.configManager,
2787
3536
  });
2788
3537
  for (const c of pool)
2789
- pushCandidate(c.provider, c.model);
3538
+ pushCandidate(c.provider, c.model, c.score, c.scoreBasis);
2790
3539
  // Unique provider list (what callers use for their own failover) — derived
2791
3540
  // from the pair list so it stays consistent with what is actually tried.
2792
3541
  const candidates = [...new Set(chatCandidates.map((c) => c.provider))];
@@ -2811,7 +3560,12 @@ export class ChatCommand extends BaseCommand {
2811
3560
  task: message,
2812
3561
  verifyOnDemand: isMaxCapability(this.configManager),
2813
3562
  })).model;
2814
- // 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.
2815
3569
  recordRoutingDecision({
2816
3570
  source: 'chat',
2817
3571
  agentType: 'chat',
@@ -2819,7 +3573,9 @@ export class ChatCommand extends BaseCommand {
2819
3573
  complexity: decision.complexity,
2820
3574
  provider: candidate.provider,
2821
3575
  model,
2822
- score: decision.score,
3576
+ ...(candidate.scoreBasis
3577
+ ? { score: candidate.score, scoreBasis: candidate.scoreBasis }
3578
+ : {}),
2823
3579
  agenticCapable: isAgenticCapableModel(model, candidate.provider),
2824
3580
  overrideReason: decision.overrideReason,
2825
3581
  ...(opts?.fallbackFrom ? { fallbackFrom: opts.fallbackFrom } : {}),
@@ -2852,6 +3608,10 @@ export class ChatCommand extends BaseCommand {
2852
3608
  // over).
2853
3609
  const usableProvider = [decision.provider, ...decision.ranked.map((r) => r.provider)]
2854
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.
2855
3615
  recordRoutingDecision({
2856
3616
  source: 'chat',
2857
3617
  agentType: 'chat',
@@ -2859,7 +3619,6 @@ export class ChatCommand extends BaseCommand {
2859
3619
  complexity: decision.complexity,
2860
3620
  provider: usableProvider,
2861
3621
  model: decision.model,
2862
- score: decision.score,
2863
3622
  agenticCapable: isAgenticCapableModel(decision.model, usableProvider),
2864
3623
  overrideReason: decision.overrideReason,
2865
3624
  ...(opts?.fallbackFrom ? { fallbackFrom: opts.fallbackFrom } : {}),