dual-brain 0.2.30 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/.dual-brain/docs/claude-code-extension-points.md +32 -0
  2. package/.dual-brain/docs/data-tools-capabilities.md +181 -0
  3. package/.dual-brain/docs/ecosystem-tools.md +91 -0
  4. package/.dual-brain/docs/panel-handoff.md +124 -0
  5. package/.dual-brain/docs/ruflo-analysis.md +48 -0
  6. package/bin/dual-brain.mjs +56 -56
  7. package/dist/mcp-server/index.d.ts +27 -0
  8. package/dist/mcp-server/index.js +359 -0
  9. package/dist/mcp-server/index.js.map +1 -0
  10. package/dist/src/agent-protocol.d.ts +163 -0
  11. package/dist/src/agent-protocol.js +368 -0
  12. package/dist/src/agent-protocol.js.map +1 -0
  13. package/dist/src/agents/registry.d.ts +52 -0
  14. package/dist/src/agents/registry.js +393 -0
  15. package/dist/src/agents/registry.js.map +1 -0
  16. package/dist/src/awareness.d.ts +93 -0
  17. package/dist/src/awareness.js +406 -0
  18. package/dist/src/awareness.js.map +1 -0
  19. package/dist/src/brief.d.ts +48 -0
  20. package/dist/src/brief.js +179 -0
  21. package/dist/src/brief.js.map +1 -0
  22. package/dist/src/calibration.d.ts +32 -0
  23. package/dist/src/calibration.js +133 -0
  24. package/dist/src/calibration.js.map +1 -0
  25. package/dist/src/checkpoint.d.ts +33 -0
  26. package/dist/src/checkpoint.js +99 -0
  27. package/dist/src/checkpoint.js.map +1 -0
  28. package/dist/src/ci-triage.d.ts +33 -0
  29. package/dist/src/ci-triage.js +193 -0
  30. package/dist/src/ci-triage.js.map +1 -0
  31. package/dist/src/cognitive-loop.d.ts +56 -0
  32. package/dist/src/cognitive-loop.js +495 -0
  33. package/dist/src/cognitive-loop.js.map +1 -0
  34. package/dist/src/collaboration.d.ts +147 -0
  35. package/dist/src/collaboration.js +438 -0
  36. package/dist/src/collaboration.js.map +1 -0
  37. package/dist/src/context-intel.d.ts +47 -0
  38. package/dist/src/context-intel.js +156 -0
  39. package/dist/src/context-intel.js.map +1 -0
  40. package/dist/src/context.d.ts +53 -0
  41. package/dist/src/context.js +332 -0
  42. package/dist/src/context.js.map +1 -0
  43. package/dist/src/continuity.d.ts +89 -0
  44. package/dist/src/continuity.js +230 -0
  45. package/dist/src/continuity.js.map +1 -0
  46. package/dist/src/cost-tracker.d.ts +47 -0
  47. package/dist/src/cost-tracker.js +170 -0
  48. package/dist/src/cost-tracker.js.map +1 -0
  49. package/dist/src/debrief.d.ts +53 -0
  50. package/dist/src/debrief.js +222 -0
  51. package/dist/src/debrief.js.map +1 -0
  52. package/dist/src/decide.d.ts +96 -0
  53. package/dist/src/decide.js +744 -0
  54. package/dist/src/decide.js.map +1 -0
  55. package/dist/src/decompose.d.ts +39 -0
  56. package/dist/src/decompose.js +218 -0
  57. package/dist/src/decompose.js.map +1 -0
  58. package/dist/src/detect.d.ts +91 -0
  59. package/dist/src/detect.js +544 -0
  60. package/dist/src/detect.js.map +1 -0
  61. package/dist/src/dispatch.d.ts +154 -0
  62. package/dist/src/dispatch.js +1306 -0
  63. package/dist/src/dispatch.js.map +1 -0
  64. package/dist/src/doctor.d.ts +421 -0
  65. package/dist/src/doctor.js +1689 -0
  66. package/dist/src/doctor.js.map +1 -0
  67. package/dist/src/engine.d.ts +70 -0
  68. package/dist/src/engine.js +155 -0
  69. package/dist/src/engine.js.map +1 -0
  70. package/dist/src/envelope.d.ts +36 -0
  71. package/dist/src/envelope.js +80 -0
  72. package/dist/src/envelope.js.map +1 -0
  73. package/dist/src/failure-memory.d.ts +55 -0
  74. package/dist/src/failure-memory.js +175 -0
  75. package/dist/src/failure-memory.js.map +1 -0
  76. package/dist/src/fx.d.ts +87 -0
  77. package/dist/src/fx.js +272 -0
  78. package/dist/src/fx.js.map +1 -0
  79. package/dist/src/governance.d.ts +93 -0
  80. package/dist/src/governance.js +261 -0
  81. package/dist/src/governance.js.map +1 -0
  82. package/dist/src/handoff.d.ts +11 -0
  83. package/dist/src/handoff.js +90 -0
  84. package/dist/src/handoff.js.map +1 -0
  85. package/dist/src/head-protocol.d.ts +76 -0
  86. package/dist/src/head-protocol.js +109 -0
  87. package/dist/src/head-protocol.js.map +1 -0
  88. package/dist/src/head.d.ts +222 -0
  89. package/dist/src/head.js +765 -0
  90. package/dist/src/head.js.map +1 -0
  91. package/dist/src/health.d.ts +132 -0
  92. package/dist/src/health.js +435 -0
  93. package/dist/src/health.js.map +1 -0
  94. package/dist/src/inbox.d.ts +70 -0
  95. package/dist/src/inbox.js +218 -0
  96. package/dist/src/inbox.js.map +1 -0
  97. package/dist/src/index.d.ts +33 -0
  98. package/dist/src/index.js +38 -0
  99. package/dist/src/index.js.map +1 -0
  100. package/dist/src/install-hooks.d.ts +13 -0
  101. package/dist/src/install-hooks.js +88 -0
  102. package/dist/src/install-hooks.js.map +1 -0
  103. package/dist/src/integrity.d.ts +59 -0
  104. package/dist/src/integrity.js +206 -0
  105. package/dist/src/integrity.js.map +1 -0
  106. package/dist/src/intelligence.d.ts +104 -0
  107. package/dist/src/intelligence.js +391 -0
  108. package/dist/src/intelligence.js.map +1 -0
  109. package/dist/src/ledger.d.ts +54 -0
  110. package/dist/src/ledger.js +179 -0
  111. package/dist/src/ledger.js.map +1 -0
  112. package/dist/src/living-docs.d.ts +14 -0
  113. package/dist/src/living-docs.js +197 -0
  114. package/dist/src/living-docs.js.map +1 -0
  115. package/dist/src/memory-tiers.d.ts +37 -0
  116. package/dist/src/memory-tiers.js +160 -0
  117. package/dist/src/memory-tiers.js.map +1 -0
  118. package/dist/src/model-profiles.d.ts +65 -0
  119. package/dist/src/model-profiles.js +568 -0
  120. package/dist/src/model-profiles.js.map +1 -0
  121. package/dist/src/models.d.ts +58 -0
  122. package/dist/src/models.js +327 -0
  123. package/dist/src/models.js.map +1 -0
  124. package/dist/src/narrative.d.ts +54 -0
  125. package/dist/src/narrative.js +163 -0
  126. package/dist/src/narrative.js.map +1 -0
  127. package/dist/src/nextstep.d.ts +16 -0
  128. package/dist/src/nextstep.js +103 -0
  129. package/dist/src/nextstep.js.map +1 -0
  130. package/dist/src/observer.d.ts +18 -0
  131. package/dist/src/observer.js +251 -0
  132. package/dist/src/observer.js.map +1 -0
  133. package/dist/src/outcome.d.ts +110 -0
  134. package/dist/src/outcome.js +377 -0
  135. package/dist/src/outcome.js.map +1 -0
  136. package/dist/src/pipeline.d.ts +167 -0
  137. package/dist/src/pipeline.js +1503 -0
  138. package/dist/src/pipeline.js.map +1 -0
  139. package/dist/src/playbook.d.ts +59 -0
  140. package/dist/src/playbook.js +238 -0
  141. package/dist/src/playbook.js.map +1 -0
  142. package/dist/src/pr-agent.d.ts +97 -0
  143. package/dist/src/pr-agent.js +195 -0
  144. package/dist/src/pr-agent.js.map +1 -0
  145. package/dist/src/predictive.d.ts +57 -0
  146. package/dist/src/predictive.js +230 -0
  147. package/dist/src/predictive.js.map +1 -0
  148. package/dist/src/profile.d.ts +294 -0
  149. package/dist/src/profile.js +1347 -0
  150. package/dist/src/profile.js.map +1 -0
  151. package/dist/src/prompt-audit.d.ts +22 -0
  152. package/dist/src/prompt-audit.js +194 -0
  153. package/dist/src/prompt-audit.js.map +1 -0
  154. package/dist/src/prompt-intel.d.ts +12 -0
  155. package/dist/src/prompt-intel.js +321 -0
  156. package/dist/src/prompt-intel.js.map +1 -0
  157. package/dist/src/provider-context.d.ts +121 -0
  158. package/dist/src/provider-context.js +222 -0
  159. package/dist/src/provider-context.js.map +1 -0
  160. package/dist/src/provider-manager.d.ts +92 -0
  161. package/dist/src/provider-manager.js +428 -0
  162. package/dist/src/provider-manager.js.map +1 -0
  163. package/dist/src/receipt.d.ts +87 -0
  164. package/dist/src/receipt.js +326 -0
  165. package/dist/src/receipt.js.map +1 -0
  166. package/dist/src/recommendations.d.ts +13 -0
  167. package/dist/src/recommendations.js +291 -0
  168. package/dist/src/recommendations.js.map +1 -0
  169. package/dist/src/redact.d.ts +15 -0
  170. package/dist/src/redact.js +129 -0
  171. package/dist/src/redact.js.map +1 -0
  172. package/dist/src/replit.d.ts +397 -0
  173. package/dist/src/replit.js +1160 -0
  174. package/dist/src/replit.js.map +1 -0
  175. package/dist/src/repo.d.ts +149 -0
  176. package/dist/src/repo.js +416 -0
  177. package/dist/src/repo.js.map +1 -0
  178. package/dist/src/revert.d.ts +30 -0
  179. package/dist/src/revert.js +166 -0
  180. package/dist/src/revert.js.map +1 -0
  181. package/dist/src/room.d.ts +102 -0
  182. package/dist/src/room.js +212 -0
  183. package/dist/src/room.js.map +1 -0
  184. package/dist/src/routing-advisor.d.ts +57 -0
  185. package/dist/src/routing-advisor.js +221 -0
  186. package/dist/src/routing-advisor.js.map +1 -0
  187. package/dist/src/self-correct.d.ts +40 -0
  188. package/dist/src/self-correct.js +137 -0
  189. package/dist/src/self-correct.js.map +1 -0
  190. package/dist/src/session-lock.d.ts +35 -0
  191. package/dist/src/session-lock.js +134 -0
  192. package/dist/src/session-lock.js.map +1 -0
  193. package/dist/src/session.d.ts +267 -0
  194. package/dist/src/session.js +1660 -0
  195. package/dist/src/session.js.map +1 -0
  196. package/dist/src/settings-tui.d.ts +5 -0
  197. package/dist/src/settings-tui.js +422 -0
  198. package/dist/src/settings-tui.js.map +1 -0
  199. package/dist/src/setup-flow.d.ts +63 -0
  200. package/dist/src/setup-flow.js +233 -0
  201. package/dist/src/setup-flow.js.map +1 -0
  202. package/dist/src/signal.d.ts +19 -0
  203. package/dist/src/signal.js +122 -0
  204. package/dist/src/signal.js.map +1 -0
  205. package/dist/src/simmer.d.ts +85 -0
  206. package/dist/src/simmer.js +224 -0
  207. package/dist/src/simmer.js.map +1 -0
  208. package/dist/src/state-export.d.ts +129 -0
  209. package/dist/src/state-export.js +233 -0
  210. package/dist/src/state-export.js.map +1 -0
  211. package/dist/src/strategy.d.ts +54 -0
  212. package/dist/src/strategy.js +95 -0
  213. package/dist/src/strategy.js.map +1 -0
  214. package/dist/src/subscription.d.ts +40 -0
  215. package/dist/src/subscription.js +189 -0
  216. package/dist/src/subscription.js.map +1 -0
  217. package/dist/src/templates.d.ts +208 -0
  218. package/dist/src/templates.js +238 -0
  219. package/dist/src/templates.js.map +1 -0
  220. package/dist/src/test.d.ts +9 -0
  221. package/dist/src/test.js +1173 -0
  222. package/dist/src/test.js.map +1 -0
  223. package/dist/src/think-engine.d.ts +67 -0
  224. package/dist/src/think-engine.js +412 -0
  225. package/dist/src/think-engine.js.map +1 -0
  226. package/dist/src/tui.d.ts +71 -0
  227. package/dist/src/tui.js +242 -0
  228. package/dist/src/tui.js.map +1 -0
  229. package/dist/src/types.d.ts +177 -0
  230. package/dist/src/types.js +6 -0
  231. package/dist/src/types.js.map +1 -0
  232. package/dist/src/update-check.d.ts +7 -0
  233. package/dist/src/update-check.js +36 -0
  234. package/dist/src/update-check.js.map +1 -0
  235. package/dist/src/wave-planner.d.ts +30 -0
  236. package/dist/src/wave-planner.js +281 -0
  237. package/dist/src/wave-planner.js.map +1 -0
  238. package/hooks/head-guard.sh +41 -0
  239. package/hooks/task-classifier.mjs +328 -0
  240. package/hooks/vibe-router.mjs +387 -0
  241. package/package.json +29 -153
  242. package/src/agents/registry.mjs +0 -405
  243. package/src/awareness.mjs +0 -425
  244. package/src/brief.mjs +0 -266
  245. package/src/calibration.mjs +0 -148
  246. package/src/checkpoint.mjs +0 -109
  247. package/src/ci-triage.mjs +0 -191
  248. package/src/cognitive-loop.mjs +0 -562
  249. package/src/collaboration.mjs +0 -545
  250. package/src/context-intel.mjs +0 -158
  251. package/src/context.mjs +0 -389
  252. package/src/continuity.mjs +0 -298
  253. package/src/cost-tracker.mjs +0 -184
  254. package/src/debrief.mjs +0 -228
  255. package/src/decide.mjs +0 -1099
  256. package/src/decompose.mjs +0 -331
  257. package/src/detect.mjs +0 -702
  258. package/src/dispatch.mjs +0 -1447
  259. package/src/doctor.mjs +0 -1607
  260. package/src/envelope.mjs +0 -139
  261. package/src/failure-memory.mjs +0 -178
  262. package/src/fx.mjs +0 -276
  263. package/src/governance.mjs +0 -279
  264. package/src/handoff.mjs +0 -87
  265. package/src/head-protocol.mjs +0 -128
  266. package/src/head.mjs +0 -952
  267. package/src/health.mjs +0 -528
  268. package/src/inbox.mjs +0 -195
  269. package/src/index.mjs +0 -44
  270. package/src/install-hooks.mjs +0 -100
  271. package/src/integrity.mjs +0 -245
  272. package/src/intelligence.mjs +0 -447
  273. package/src/ledger.mjs +0 -196
  274. package/src/living-docs.mjs +0 -210
  275. package/src/memory-tiers.mjs +0 -193
  276. package/src/models.mjs +0 -363
  277. package/src/narrative.mjs +0 -169
  278. package/src/nextstep.mjs +0 -100
  279. package/src/observer.mjs +0 -241
  280. package/src/outcome.mjs +0 -400
  281. package/src/pipeline.mjs +0 -1711
  282. package/src/playbook.mjs +0 -257
  283. package/src/pr-agent.mjs +0 -214
  284. package/src/predictive.mjs +0 -250
  285. package/src/profile.mjs +0 -1411
  286. package/src/prompt-audit.mjs +0 -231
  287. package/src/prompt-intel.mjs +0 -325
  288. package/src/provider-context.mjs +0 -257
  289. package/src/receipt.mjs +0 -344
  290. package/src/recommendations.mjs +0 -296
  291. package/src/redact.mjs +0 -192
  292. package/src/replit.mjs +0 -1210
  293. package/src/repo.mjs +0 -445
  294. package/src/revert.mjs +0 -149
  295. package/src/routing-advisor.mjs +0 -204
  296. package/src/self-correct.mjs +0 -147
  297. package/src/session-lock.mjs +0 -160
  298. package/src/session.mjs +0 -1655
  299. package/src/settings-tui.mjs +0 -373
  300. package/src/setup-flow.mjs +0 -223
  301. package/src/signal.mjs +0 -115
  302. package/src/simmer.mjs +0 -241
  303. package/src/strategy.mjs +0 -235
  304. package/src/subscription.mjs +0 -212
  305. package/src/templates.mjs +0 -260
  306. package/src/think-engine.mjs +0 -428
  307. package/src/tui.mjs +0 -276
  308. package/src/update-check.mjs +0 -35
  309. package/src/wave-planner.mjs +0 -294
package/src/decide.mjs DELETED
@@ -1,1099 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * decide.mjs — Routing decision module for the Dual-Brain Orchestrator.
4
- *
5
- * Given a task detection + user profile, decides which provider/model/effort/mode
6
- * to use and explains why in one sentence.
7
- *
8
- * Exports: decideRoute, getModelCapabilities, getAvailableModels,
9
- * WORK_STYLES, getWorkStyle, estimateBudgetPressure,
10
- * shouldDualBrain, explainDecision, getFailoverOrder
11
- *
12
- * CLI: node src/decide.mjs --profile /path/to/profile.json \
13
- * --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
14
- */
15
-
16
- import { readFileSync } from 'fs';
17
- import { join, dirname } from 'path';
18
- import { fileURLToPath } from 'url';
19
- import { getProviderScore, checkCooldown } from './health.mjs';
20
-
21
- const __dirname = dirname(fileURLToPath(import.meta.url));
22
- const WORKSPACE = join(__dirname, '..');
23
-
24
- // ─── Model Registry (optional, lazy-loaded) ───────────────────────────────────
25
-
26
- /**
27
- * Cached reference to models.mjs exports. Populated on first successful import.
28
- * Remains null if models.mjs is unavailable — all callers fall back to
29
- * the existing hardcoded model selection logic in that case.
30
- */
31
- let modelRegistry = null;
32
- let _registryLoadAttempted = false;
33
-
34
- /**
35
- * Attempt to load models.mjs once. Subsequent calls return immediately.
36
- * This is intentionally fire-and-forget: decideRoute stays synchronous and
37
- * reads `modelRegistry` after the Promise resolves.
38
- */
39
- function _loadModelRegistry() {
40
- if (_registryLoadAttempted) return;
41
- _registryLoadAttempted = true;
42
- import('./models.mjs').then(mod => {
43
- modelRegistry = mod;
44
- }).catch(() => {
45
- // models.mjs unavailable — fall back to hardcoded logic
46
- });
47
- }
48
-
49
- // Kick off the load immediately so it is ready before the first routing call.
50
- _loadModelRegistry();
51
-
52
- // ─── Routing Advisor (optional, lazy-loaded) ──────────────────────────────────
53
-
54
- /**
55
- * Cached reference to routing-advisor.mjs exports. Populated on first import.
56
- * Remains null if unavailable — decideRoute skips advisor consultation in that case.
57
- */
58
- let routingAdvisor = null;
59
- let _advisorLoadAttempted = false;
60
-
61
- function _loadRoutingAdvisor() {
62
- if (_advisorLoadAttempted) return;
63
- _advisorLoadAttempted = true;
64
- import('./routing-advisor.mjs').then(mod => {
65
- routingAdvisor = mod;
66
- }).catch(() => {
67
- // routing-advisor.mjs unavailable — skip learned routing
68
- });
69
- }
70
-
71
- // Kick off the load immediately so it is ready before the first routing call.
72
- _loadRoutingAdvisor();
73
-
74
- // ─── Work Styles ─────────────────────────────────────────────────────────────
75
-
76
- /**
77
- * Work styles control how aggressively the router uses stronger models,
78
- * challenger (dual-brain) reviews, and checkpoints.
79
- * The user picks a style regardless of provider or plan — no price gating.
80
- */
81
- export const WORK_STYLES = {
82
- fast: {
83
- label: 'Fast',
84
- defaultWorker: 'claude-sonnet-4-6',
85
- complexWorker: 'claude-sonnet-4-6',
86
- challengerPolicy: 'never',
87
- checkpointPolicy: 'never',
88
- reviewPolicy: 'skip',
89
- description: 'Quick answers, single model, minimal reviews',
90
- },
91
- balanced: {
92
- label: 'Balanced',
93
- defaultWorker: 'claude-sonnet-4-6',
94
- complexWorker: 'claude-opus-4-6',
95
- challengerPolicy: 'high-risk', // only on high/critical risk
96
- checkpointPolicy: 'risky-ops', // before risky operations
97
- reviewPolicy: 'important', // important changes only
98
- description: 'Smart routing, reviews on important changes',
99
- },
100
- fullpower: {
101
- label: 'Full Power',
102
- defaultWorker: 'claude-sonnet-4-6',
103
- complexWorker: 'claude-opus-4-6',
104
- challengerPolicy: 'medium-risk', // medium+ risk
105
- checkpointPolicy: 'all-edits', // before all edits
106
- reviewPolicy: 'non-trivial', // everything non-trivial
107
- description: 'Deep reasoning, dual-brain on everything that matters',
108
- },
109
- };
110
-
111
- /**
112
- * Read the active work style from the profile.
113
- * Falls back to 'balanced' if not set or unrecognized.
114
- * @param {object} profile
115
- * @returns {object} The matching WORK_STYLES entry, with a `key` property added.
116
- */
117
- export function getWorkStyle(profile) {
118
- const key = profile?.workStyle || profile?.work_style || 'balanced';
119
- const style = WORK_STYLES[key] ?? WORK_STYLES.balanced;
120
- return { ...style, key: WORK_STYLES[key] ? key : 'balanced' };
121
- }
122
-
123
- // ─── Slim Model Capabilities (routing-relevant only) ─────────────────────────
124
-
125
- /** @type {Record<string, {provider, tierFit, contextWindow, strengths, weaknesses, effortLevels, costTier}>} */
126
- const MODEL_CAPABILITIES = {
127
- haiku: {
128
- provider: 'claude',
129
- tierFit: ['search'],
130
- contextWindow: 200_000,
131
- strengths: ['search', 'format', 'lookup', 'classification', 'grep-analysis'],
132
- weaknesses: ['complex-edits', 'architecture', 'security', 'multi-file-refactor'],
133
- effortLevels: null,
134
- costTier: 'cheap',
135
- },
136
- sonnet: {
137
- provider: 'claude',
138
- tierFit: ['execute', 'search'],
139
- contextWindow: 200_000,
140
- strengths: ['edit', 'refactor', 'test', 'debug', 'code-generation', 'tool-use'],
141
- weaknesses: ['deep-architecture', 'ambiguous-requirements', 'frontier-reasoning'],
142
- effortLevels: ['low', 'medium', 'high', 'xhigh'],
143
- costTier: 'medium',
144
- },
145
- opus: {
146
- provider: 'claude',
147
- tierFit: ['think', 'execute'],
148
- contextWindow: 200_000,
149
- strengths: ['architecture', 'security', 'complex-debug', 'review', 'planning', 'threat-modeling'],
150
- weaknesses: ['cost', 'overkill-for-simple-tasks'],
151
- effortLevels: ['low', 'medium', 'high', 'xhigh'],
152
- costTier: 'expensive',
153
- },
154
- 'gpt-4.1-mini': {
155
- provider: 'openai',
156
- tierFit: ['search'],
157
- contextWindow: 1_047_576,
158
- strengths: ['search', 'format', 'classification', 'fast-lookups'],
159
- weaknesses: ['complex-refactors', 'architecture', 'multi-file-edits'],
160
- effortLevels: ['low', 'medium', 'high'],
161
- costTier: 'cheap',
162
- },
163
- 'gpt-4.1': {
164
- provider: 'openai',
165
- tierFit: ['execute', 'search'],
166
- contextWindow: 1_047_576,
167
- strengths: ['edit', 'code-generation', 'simple-refactor'],
168
- weaknesses: ['architecture', 'security', 'complex-debug'],
169
- effortLevels: ['low', 'medium', 'high'],
170
- costTier: 'medium',
171
- },
172
- 'gpt-4o': {
173
- provider: 'openai',
174
- tierFit: ['execute', 'think'],
175
- contextWindow: 128_000,
176
- strengths: ['refactor', 'debug', 'code-generation', 'test', 'multimodal'],
177
- weaknesses: ['cost vs mini'],
178
- effortLevels: ['low', 'medium', 'high'],
179
- costTier: 'medium',
180
- },
181
- 'gpt-4o-mini': {
182
- provider: 'openai',
183
- tierFit: ['search'],
184
- contextWindow: 128_000,
185
- costTier: 'cheap',
186
- strengths: ['quick-tasks', 'search', 'classification'],
187
- weaknesses: ['complex-edits', 'architecture'],
188
- effortLevels: null,
189
- },
190
- 'o3': {
191
- provider: 'openai',
192
- tierFit: ['think'],
193
- contextWindow: 200_000,
194
- strengths: ['architecture', 'security', 'review', 'planning', 'complex-debug', 'deep-reasoning'],
195
- weaknesses: ['cost', 'latency'],
196
- effortLevels: ['low', 'medium', 'high'],
197
- costTier: 'expensive',
198
- },
199
- };
200
-
201
- // ─── Canonical Work Model Names ──────────────────────────────────────────────
202
-
203
- /**
204
- * These are the authoritative model IDs used when dispatching work.
205
- * The session model (what the user runs Claude Code with) is separate and
206
- * does not need to be changed — the router assigns work models independently.
207
- *
208
- * Role → model mapping:
209
- * execute → claude-sonnet-4-6 (native tool use, reliable workhorse)
210
- * think → claude-opus-4-6 (deep reasoning, complex single-brain tasks)
211
- * search → claude-haiku-4-5-20251001 / gpt-4o-mini (cheap, fast, disposable)
212
- * challenger → o3 or gpt-4o (independence — different training = different blind spots)
213
- */
214
- const WORK_MODELS = {
215
- execute: 'claude-sonnet-4-6',
216
- think: 'claude-opus-4-6',
217
- search: 'claude-haiku-4-5-20251001',
218
- challengerGpt: 'o3', // preferred challenger; falls back to gpt-4o when o3 unavailable
219
- challengerGptFallback: 'gpt-4o',
220
- searchGpt: 'gpt-4o-mini', // GPT-side search/classify
221
- };
222
-
223
- /** Always recommend Sonnet as the session model. */
224
- const RECOMMENDED_SESSION_MODEL = 'claude-sonnet-4-6';
225
- const RECOMMENDED_SESSION_REASON =
226
- 'Sonnet has native tool use and is the most cost-effective session model for orchestrating work agents.';
227
-
228
- // ─── Exported: getModelCapabilities ──────────────────────────────────────────
229
-
230
- /**
231
- * Look up a model's routing-relevant capabilities.
232
- * @param {string} model
233
- * @returns {object|null}
234
- */
235
- export function getModelCapabilities(model) {
236
- return MODEL_CAPABILITIES[model] ?? null;
237
- }
238
-
239
- // ─── Exported: getAvailableModels ─────────────────────────────────────────────
240
-
241
- /**
242
- * Return which models the user can access.
243
- * All known models are available by default; providers can explicitly restrict
244
- * via profile.providers.<provider>.models (array of allowed model short names).
245
- * This does NOT gate on price or configured plan — we cannot verify those from here.
246
- * @param {{ providers?: { claude?: { enabled?: boolean, models?: string[] }, openai?: { enabled?: boolean, models?: string[] } } }} profile
247
- * @returns {{ claude: string[], openai: string[] }}
248
- */
249
- export function getAvailableModels(profile) {
250
- const ALL_CLAUDE = ['haiku', 'sonnet', 'opus'];
251
- const ALL_OPENAI = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
252
-
253
- const claudeModels = profile?.providers?.claude?.models;
254
- const openaiModels = profile?.providers?.openai?.models;
255
-
256
- return {
257
- claude: Array.isArray(claudeModels) ? claudeModels : ALL_CLAUDE,
258
- openai: Array.isArray(openaiModels) ? openaiModels : ALL_OPENAI,
259
- };
260
- }
261
-
262
- // ─── Internal: challenger model selection ────────────────────────────────────
263
-
264
- /**
265
- * Pick the best challenger model from the opposing provider.
266
- * Claude primary → GPT challenger (o3 preferred, gpt-4o fallback).
267
- * GPT primary → Claude Opus challenger (Sonnet fallback).
268
- * Falls back gracefully when the other provider is not available.
269
- *
270
- * @param {string} primaryProvider 'claude'|'openai'
271
- * @param {object} available Result of getAvailableModels()
272
- * @returns {string|null}
273
- */
274
- function pickChallengerModel(primaryProvider, available) {
275
- if (primaryProvider === 'claude') {
276
- // Claude is primary → use GPT as challenger
277
- if (available.openai.includes(WORK_MODELS.challengerGpt)) return WORK_MODELS.challengerGpt;
278
- if (available.openai.includes(WORK_MODELS.challengerGptFallback)) return WORK_MODELS.challengerGptFallback;
279
- return null; // OpenAI not available
280
- } else {
281
- // OpenAI is primary → use Claude Opus as challenger
282
- if (available.claude.includes('opus')) return WORK_MODELS.think;
283
- if (available.claude.includes('sonnet')) return WORK_MODELS.execute;
284
- return null; // Claude not available
285
- }
286
- }
287
-
288
- /**
289
- * Decide whether to trigger a challenger based on the work style policy and task risk.
290
- * When only one provider is available, challenger is never triggered (no cross-provider review possible).
291
- * @param {string} challengerPolicy 'never'|'high-risk'|'medium-risk'
292
- * @param {'low'|'medium'|'high'|'critical'} risk
293
- * @param {boolean} hasBothProviders
294
- * @returns {boolean}
295
- */
296
- function shouldTriggerChallenger(challengerPolicy, risk, hasBothProviders) {
297
- if (challengerPolicy === 'never' || !hasBothProviders) return false;
298
- if (challengerPolicy === 'high-risk') return ['high', 'critical'].includes(risk);
299
- if (challengerPolicy === 'medium-risk') return ['medium', 'high', 'critical'].includes(risk);
300
- return false;
301
- }
302
-
303
- // ─── Exported: estimateBudgetPressure (deprecated stub) ──────────────────────
304
-
305
- /**
306
- * @deprecated Replaced by the health-based router in health.mjs.
307
- * Returns an empty object so callers that still import this don't crash.
308
- * The budget-balancer.mjs hook file is separate and can keep using usage logs.
309
- * @returns {{ claude: number, openai: number }}
310
- */
311
- export function estimateBudgetPressure(_profile, _cwd) {
312
- return { claude: 0, openai: 0 };
313
- }
314
-
315
- // ─── Internal: health-based provider scoring ──────────────────────────────────
316
-
317
- /**
318
- * Return a 0-100 routing score for each provider using health.mjs state.
319
- * For each provider we check its primary model class for the given tier.
320
- * @param {'search'|'execute'|'think'} tier
321
- * @param {string} [cwd]
322
- * @returns {{ claude: number, openai: number }}
323
- */
324
- function getHealthScores(tier, cwd) {
325
- // Map tier to representative model class per provider
326
- const claudeClass = tier === 'search' ? 'haiku'
327
- : tier === 'think' ? 'opus'
328
- : 'sonnet';
329
- const openaiClass = tier === 'search' ? 'gpt-4o-mini'
330
- : tier === 'think' ? 'o3'
331
- : 'gpt-4o';
332
-
333
- // Trigger cooldown expiry check (transitions hot→probing automatically)
334
- checkCooldown('claude', claudeClass, cwd);
335
- checkCooldown('openai', openaiClass, cwd);
336
-
337
- return {
338
- claude: getProviderScore('claude', claudeClass, cwd),
339
- openai: getProviderScore('openai', openaiClass, cwd),
340
- };
341
- }
342
-
343
- // ─── Exported: shouldDualBrain ────────────────────────────────────────────────
344
-
345
- /**
346
- * Return true if both providers should analyze this task.
347
- * Requires: (critical risk OR architecture/security intent OR complex+high-risk)
348
- * AND profile has both providers available with dual mode enabled.
349
- *
350
- * designImpact bypasses the hasBothProviders check — it is a mandatory review
351
- * gate, not optional collaboration. When only one provider is available the
352
- * caller should check degradedDualBrain on the decision output.
353
- * @param {{ intent?: string, risk?: string, complexity?: string, designImpact?: boolean }} detection
354
- * @param {object} profile
355
- * @returns {boolean}
356
- */
357
- export function shouldDualBrain(detection, profile) {
358
- const { intent = '', risk = 'low', complexity = 'simple', designImpact = false } = detection;
359
- const dualEnabled = profile?.dual_brain_enabled !== false;
360
- if (!dualEnabled) return false;
361
-
362
- const hasBothProviders = !!(
363
- profile?.providers?.claude?.enabled &&
364
- profile?.providers?.claude?.plan &&
365
- profile?.providers?.openai?.enabled &&
366
- profile?.providers?.openai?.plan
367
- );
368
-
369
- if (designImpact) return true;
370
-
371
- if (!hasBothProviders) return false;
372
-
373
- const criticalRisk = risk === 'critical';
374
- const archOrSecurity = ['architecture', 'security'].includes(intent);
375
- const complexHighRisk = complexity === 'complex' && risk === 'high';
376
-
377
- return criticalRisk || archOrSecurity || complexHighRisk;
378
- }
379
-
380
- // ─── Internal: select model for provider ─────────────────────────────────────
381
-
382
- const THINK_INTENTS = ['architecture', 'security', 'review', 'planning', 'compare'];
383
- const SEARCH_INTENTS = ['search', 'format', 'explain', 'lookup'];
384
-
385
- function pickClaudeModel(detection, available) {
386
- const { intent = '', risk = 'low', effort = 'medium' } = detection;
387
- const needsOpus = THINK_INTENTS.includes(intent) || risk === 'critical' || effort === 'xhigh';
388
- const needsHaiku = SEARCH_INTENTS.includes(intent) && !['high', 'critical'].includes(risk);
389
-
390
- if (needsOpus && available.includes('opus')) return 'opus';
391
- if (needsHaiku && available.includes('haiku')) return 'haiku';
392
- return available.includes('sonnet') ? 'sonnet' : available[available.length - 1];
393
- }
394
-
395
- function pickOpenAIModel(detection, available) {
396
- const { intent = '', risk = 'low', complexity = 'simple', effort = 'medium' } = detection;
397
- const needsTop = THINK_INTENTS.includes(intent) || risk === 'critical' || effort === 'xhigh';
398
- const needsMini = SEARCH_INTENTS.includes(intent) && effort === 'low';
399
- const needsCodex = ['refactor', 'debug'].includes(intent) && complexity !== 'trivial';
400
-
401
- const pref = needsTop ? 'o3'
402
- : needsMini ? 'gpt-4o-mini'
403
- : needsCodex ? 'gpt-4o'
404
- : 'gpt-4o';
405
-
406
- // Walk down rank until we find an available model
407
- const rank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
408
- const idx = rank.indexOf(pref);
409
- for (let i = idx; i >= 0; i--) {
410
- if (available.includes(rank[i])) return rank[i];
411
- }
412
- return available[0] ?? 'gpt-4o-mini';
413
- }
414
-
415
- /**
416
- * Normalize a full model ID (e.g. 'claude-sonnet-4-6') to the short name used
417
- * by the internal ranking arrays (e.g. 'sonnet'). Pass-through for names already
418
- * in short form or OpenAI model IDs that don't need normalization.
419
- * @param {string} model
420
- * @param {string} provider 'claude'|'openai'
421
- * @returns {string}
422
- */
423
- function toShortName(model, provider) {
424
- if (!model) return model;
425
- const m = model.toLowerCase();
426
- if (provider === 'claude') {
427
- if (m.includes('haiku')) return 'haiku';
428
- if (m.includes('opus')) return 'opus';
429
- if (m.includes('sonnet')) return 'sonnet';
430
- }
431
- // OpenAI and already-short names pass through unchanged
432
- return model;
433
- }
434
-
435
- /**
436
- * Resolve a short model name back to the best full model ID from the registry.
437
- * Used after the internal pipeline (health downgrade, profile bias, etc.) finalizes
438
- * the short name, to restore the full ID when the registry is available.
439
- * @param {string} shortName e.g. 'sonnet', 'opus', 'haiku'
440
- * @param {string} provider 'claude'|'openai'
441
- * @param {string} tier 'search'|'execute'|'think'
442
- * @returns {string} Full model ID, or shortName if registry unavailable
443
- */
444
- function toFullModelId(shortName, provider, tier) {
445
- if (!modelRegistry) return shortName;
446
- const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
447
- // Map short name back to a taskType for the registry lookup
448
- const taskType = tier === 'search' ? 'search' : tier === 'think' ? 'think' : 'execute';
449
- const candidates = modelRegistry.getModelsForTask(taskType, registryProvider);
450
- // Find the registry entry whose name substring matches the short name
451
- const match = candidates.find(m => m.id.toLowerCase().includes(shortName.toLowerCase()));
452
- return match ? match.id : shortName;
453
- }
454
-
455
- function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
456
- // score=100 healthy, score=50 degraded, score=25 probing, score=0 hot
457
- // If score is 0 (hot) and this isn't high-stakes, downgrade one tier
458
- if (score >= 50 || isHighStakes) return model;
459
-
460
- if (provider === 'claude') {
461
- const claudeRank = ['haiku', 'sonnet', 'opus'];
462
- const idx = claudeRank.indexOf(model);
463
- const steps = score === 0 ? 2 : 1;
464
- const downIdx = Math.max(0, idx - steps);
465
- for (let i = downIdx; i <= idx; i++) {
466
- if (available.includes(claudeRank[i])) return claudeRank[i];
467
- }
468
- return available[0] ?? 'haiku';
469
- } else {
470
- const oaiRank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
471
- const idx = oaiRank.indexOf(model);
472
- const steps = score === 0 ? 2 : 1;
473
- const downIdx = Math.max(0, idx - steps);
474
- for (let i = downIdx; i <= idx; i++) {
475
- if (available.includes(oaiRank[i])) return oaiRank[i];
476
- }
477
- return available[0] ?? 'gpt-4o-mini';
478
- }
479
- }
480
-
481
- function applyProfileBias(model, profile, provider, available, tier) {
482
- const mode = profile?.mode || profile?.profile || 'auto';
483
- if (mode === 'cost-saver') {
484
- // Prefer cheapest available that also fits the required tier
485
- const ranks = {
486
- claude: ['haiku', 'sonnet', 'opus'],
487
- openai: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'],
488
- };
489
- for (const m of ranks[provider]) {
490
- if (!available.includes(m)) continue;
491
- const caps = MODEL_CAPABILITIES[m];
492
- if (tier && caps && !caps.tierFit.includes(tier)) continue;
493
- return m;
494
- }
495
- }
496
- if (mode === 'quality-first') {
497
- // Prefer best available, keep current if already best
498
- const ranks = {
499
- claude: ['opus', 'sonnet', 'haiku'],
500
- openai: ['o3', 'o4-mini', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o-mini'],
501
- };
502
- for (const m of ranks[provider]) {
503
- if (available.includes(m)) return m;
504
- }
505
- }
506
- // Check user preferences (e.g. { prefer: 'opus', for: 'security' })
507
- const prefs = profile?.preferences || [];
508
- for (const pref of prefs) {
509
- if (pref.model && available.includes(pref.model) &&
510
- pref.for && MODEL_CAPABILITIES[pref.model]?.strengths?.includes(pref.for)) {
511
- return pref.model;
512
- }
513
- }
514
- return model;
515
- }
516
-
517
- function pickEffort(model, detection) {
518
- const caps = MODEL_CAPABILITIES[model];
519
- if (!caps?.effortLevels) return null;
520
- const { risk = 'low', complexity = 'simple', effort } = detection;
521
- if (effort && caps.effortLevels.includes(effort)) return effort;
522
- if (risk === 'critical' || complexity === 'complex') return 'xhigh';
523
- if (risk === 'high' || complexity === 'moderate') return 'high';
524
- if (risk === 'low' && complexity === 'trivial') return 'low';
525
- return 'medium';
526
- }
527
-
528
- function pickModes(model, detection) {
529
- const { intent = '', complexity = 'simple' } = detection;
530
- const caps = MODEL_CAPABILITIES[model] ?? {};
531
- const thinkingModels = ['sonnet', 'opus', 'o3', 'gpt-4o'];
532
- const lightIntents = ['search', 'format', 'explain', 'lookup'];
533
-
534
- return {
535
- extendedThinking: thinkingModels.includes(model)
536
- && ['moderate', 'complex'].includes(complexity)
537
- && !lightIntents.includes(intent),
538
- fastMode: model === 'opus',
539
- extendedContext: ['sonnet', 'opus'].includes(model),
540
- webSearch: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o'].includes(model),
541
- };
542
- }
543
-
544
- function pickSandbox(model, detection) {
545
- const { tier = 'execute' } = detection;
546
- if (tier === 'search') return 'read-only';
547
- if (MODEL_CAPABILITIES[model]?.provider === 'openai') return 'danger-full-access';
548
- return 'workspace-write';
549
- }
550
-
551
- function chooseProvider(detection, profile, healthScores) {
552
- const { tier = 'execute', intent = '' } = detection;
553
- const claudeScore = healthScores.claude;
554
- const openaiScore = healthScores.openai;
555
-
556
- // OpenAI not configured or not enabled → always use Claude
557
- if (!profile?.providers?.openai?.enabled) return 'claude';
558
-
559
- // Both hot (score=0) → pick the one with the higher score; if tied, prefer Claude
560
- if (claudeScore === 0 && openaiScore === 0) {
561
- return claudeScore >= openaiScore ? 'claude' : 'openai';
562
- }
563
-
564
- // Think-tier strongly prefers Claude (session context coupling), unless Claude is hot
565
- if (THINK_INTENTS.includes(intent) && claudeScore > 0) return 'claude';
566
-
567
- // Claude hot → route to OpenAI if available
568
- if (claudeScore === 0 && openaiScore > 0) return 'openai';
569
-
570
- // Isolated execute tasks: route to OpenAI if Claude is degraded/probing but OpenAI is healthy
571
- if (tier === 'execute' && !THINK_INTENTS.includes(intent)) {
572
- if (claudeScore < 100 && openaiScore > claudeScore) return 'openai';
573
- }
574
-
575
- // Default: Claude (lower session-context overhead, higher score wins)
576
- return claudeScore >= openaiScore ? 'claude' : 'openai';
577
- }
578
-
579
- // ─── Exported: explainDecision ────────────────────────────────────────────────
580
-
581
- /**
582
- * Generate a one-sentence explanation for the routing decision.
583
- * @param {object} decision
584
- * @param {object} detection
585
- * @param {object} profile
586
- * @returns {string}
587
- */
588
- export function explainDecision(decision, detection, profile) {
589
- const { provider, model, effort, dualBrain, workStyle, challengerModel } = decision;
590
- const { intent = 'task', risk = 'low', complexity = 'simple', tier = 'execute' } = detection;
591
- const healthScores = decision._healthScores || {};
592
- const mode = profile?.mode || profile?.profile || 'auto';
593
-
594
- const ws = decision._workStyle ?? getWorkStyle(profile);
595
- const wsLabel = ws.label ?? workStyle ?? 'Balanced';
596
- const modelLabel = effort ? `${model} ${effort}` : model;
597
-
598
- if (dualBrain && challengerModel) {
599
- return `${wsLabel} mode: ${modelLabel} for ${intent}, ${challengerModel} challenger on ${risk}-risk changes.`;
600
- }
601
- if (dualBrain) {
602
- return `${wsLabel} mode: ${modelLabel} with dual-brain review because this ${intent} change is ${risk} risk.`;
603
- }
604
- // Health-based explanations
605
- const claudeScore = healthScores.claude ?? 100;
606
- const providerScore = healthScores[provider] ?? 100;
607
- if (claudeScore === 0 && provider === 'openai') {
608
- return `${wsLabel} mode: using ${modelLabel} because Claude is rate-limited and this is an isolated ${tier} task.`;
609
- }
610
- if (providerScore < 50) {
611
- return `${wsLabel} mode: using ${modelLabel} (downgraded due to rate-limit cooldown) for this ${complexity} ${intent}.`;
612
- }
613
- if (mode === 'cost-saver') {
614
- return `${wsLabel} mode: using ${modelLabel} (cost-saver bias) for ${risk}-risk ${intent}.`;
615
- }
616
- if (mode === 'quality-first') {
617
- return `${wsLabel} mode: using ${modelLabel} (quality-first bias) for ${intent}.`;
618
- }
619
- if (THINK_INTENTS.includes(intent)) {
620
- return `${wsLabel} mode: ${modelLabel} for ${intent} — deep reasoning needed.`;
621
- }
622
- if (tier === 'search' || SEARCH_INTENTS.includes(intent)) {
623
- return `${wsLabel} mode: ${modelLabel} for lightweight ${intent} lookup.`;
624
- }
625
- return `${wsLabel} mode: ${modelLabel} for ${intent} (${risk} risk, ${provider} healthy).`;
626
- }
627
-
628
- // ─── Exported: parsePreferences ──────────────────────────────────────────────
629
-
630
- /**
631
- * Parse free-text user preferences into routing-relevant signals.
632
- * @param {Array<{text: string, enabled: boolean, scope: string}>} preferences
633
- * @returns {{
634
- * biasOverride: 'cost-saver'|'quality-first'|null,
635
- * preferProvider: 'claude'|'openai'|null,
636
- * avoidProvider: 'claude'|'openai'|null,
637
- * alwaysDualBrain: boolean,
638
- * neverDualBrain: boolean,
639
- * preferModel: 'opus'|'sonnet'|'haiku'|null,
640
- * }}
641
- */
642
- export function parsePreferences(preferences) {
643
- const active = (preferences || []).filter(p => p.enabled);
644
- const signals = {
645
- biasOverride: null,
646
- preferProvider: null,
647
- avoidProvider: null,
648
- alwaysDualBrain: false,
649
- neverDualBrain: false,
650
- preferModel: null,
651
- };
652
-
653
- for (const pref of active) {
654
- const t = pref.text.toLowerCase();
655
- // Cost/quality bias signals
656
- if (/cheap|save|budget|frugal|economical|cost/i.test(t)) signals.biasOverride = 'cost-saver';
657
- if (/quality|best|thorough|careful|premium/i.test(t)) signals.biasOverride = 'quality-first';
658
- // Provider preference signals
659
- if (/prefer claude|use claude|claude first/i.test(t)) signals.preferProvider = 'claude';
660
- if (/prefer (openai|gpt|chatgpt)|use (openai|gpt)/i.test(t)) signals.preferProvider = 'openai';
661
- if (/avoid claude|no claude/i.test(t)) signals.avoidProvider = 'claude';
662
- if (/avoid (openai|gpt)|no (openai|gpt)/i.test(t)) signals.avoidProvider = 'openai';
663
- // Dual-brain signals
664
- if (/always/.test(t) && /(consensus|dual.brain|two.brain|dual)/i.test(t)) signals.alwaysDualBrain = true;
665
- if (/never (consensus|dual)|skip (review|consensus)|solo/i.test(t)) signals.neverDualBrain = true;
666
- // Model preference signals
667
- if (/prefer opus|use opus/i.test(t)) signals.preferModel = 'opus';
668
- if (/prefer sonnet|use sonnet/i.test(t)) signals.preferModel = 'sonnet';
669
- if (/prefer haiku|use haiku/i.test(t)) signals.preferModel = 'haiku';
670
- }
671
- return signals;
672
- }
673
-
674
- // ─── Internal: safety floor for critical-risk tasks ───────────────────────────
675
-
676
- /**
677
- * Ensure critical-risk tasks are never handled by the cheapest (haiku/gpt-4.1-mini) model.
678
- * Cost-saver mode is the main culprit; escalate silently but emit a stderr warning.
679
- * @param {string} model
680
- * @param {string} provider
681
- * @param {string[]} available
682
- * @param {'low'|'medium'|'high'|'critical'} risk
683
- * @returns {string}
684
- */
685
- function applyCriticalRiskFloor(model, provider, available, risk) {
686
- if (risk !== 'critical') return model;
687
-
688
- const cheapModels = { claude: 'haiku', openai: 'gpt-4.1-mini' };
689
- const floorModels = { claude: 'sonnet', openai: 'gpt-4.1' };
690
-
691
- if (model === cheapModels[provider]) {
692
- const floor = floorModels[provider];
693
- const escalated = available.includes(floor) ? floor : available[available.length - 1] ?? model;
694
- process.stderr.write(
695
- `[dual-brain] Warning: cost-saver selected ${model} for a critical-risk task. ` +
696
- `Escalating to ${escalated} (safety floor).\n`
697
- );
698
- return escalated;
699
- }
700
- return model;
701
- }
702
-
703
- // ─── Exported: decideRoute ────────────────────────────────────────────────────
704
-
705
- /**
706
- * Main routing decision function.
707
- * @param {{ profile: object, detection: object, cwd?: string, thinkResult?: object, sessionContext?: object }} input
708
- * @returns {object} Routing decision
709
- */
710
- export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, sessionContext = null } = {}) {
711
- const available = getAvailableModels(profile);
712
-
713
- // Resolve active work style
714
- const workStyle = getWorkStyle(profile);
715
-
716
- // Parse free-text user preferences into routing signals
717
- const prefSignals = parsePreferences(profile.preferences);
718
-
719
- // Apply bias override from preferences (takes precedence over profile.bias)
720
- const profileWithEffectiveBias = prefSignals.biasOverride
721
- ? { ...profile, mode: prefSignals.biasOverride }
722
- : profile;
723
-
724
- const { tier = 'execute', risk = 'low', complexity = 'simple', effort: detectionEffort } = detection;
725
- const isHighStakes = ['critical', 'high'].includes(risk);
726
-
727
- // Determine whether to use the complexWorker (Opus) or defaultWorker (Sonnet).
728
- // "High reasoning depth" means: think-tier intent, high/critical risk, or complex+high-risk.
729
- const needsDeepReasoning =
730
- THINK_INTENTS.includes(detection.intent || '') ||
731
- risk === 'critical' ||
732
- (complexity === 'complex' && ['high', 'critical'].includes(risk)) ||
733
- detectionEffort === 'xhigh';
734
-
735
- // Get health scores for current tier
736
- const healthScores = getHealthScores(tier, cwd);
737
-
738
- // Choose provider (using the bias-patched profile so chooseProvider sees the right mode)
739
- let provider = chooseProvider(detection, profileWithEffectiveBias, healthScores);
740
-
741
- // Apply preferProvider / avoidProvider signals from preferences
742
- if (prefSignals.preferProvider) {
743
- const preferred = prefSignals.preferProvider;
744
- const prefEnabled = profile?.providers?.[preferred]?.enabled;
745
- const prefScore = healthScores[preferred] ?? 0;
746
- if (prefEnabled && prefScore > 0) provider = preferred;
747
- }
748
- if (prefSignals.avoidProvider && provider === prefSignals.avoidProvider) {
749
- const other = prefSignals.avoidProvider === 'claude' ? 'openai' : 'claude';
750
- const otherEnabled = profile?.providers?.[other]?.enabled;
751
- const otherScore = healthScores[other] ?? 0;
752
- if (otherEnabled && otherScore > 0) provider = other;
753
- }
754
-
755
- // Select base model using work style worker assignments.
756
- // For Claude primary: use complexWorker (opus) on deep reasoning, defaultWorker (sonnet) otherwise.
757
- // For OpenAI primary: mirror the same logic using GPT equivalents.
758
- //
759
- // Hardcoded fallback models (used when model registry is unavailable):
760
- const _fallbackClaude = (() => {
761
- const wantOpus = needsDeepReasoning && workStyle.key !== 'fast';
762
- const fb = wantOpus && available.claude.includes('opus') ? 'opus' : 'sonnet';
763
- return available.claude.includes(fb) ? fb : (available.claude[available.claude.length - 1] ?? 'sonnet');
764
- })();
765
- const _fallbackOpenAI = (() => {
766
- const wantO3 = needsDeepReasoning && workStyle.key === 'fullpower';
767
- const fb = wantO3 && available.openai.includes('o3') ? 'o3' : 'gpt-4o';
768
- return available.openai.includes(fb) ? fb : (available.openai[available.openai.length - 1] ?? 'gpt-4o');
769
- })();
770
-
771
- let model;
772
- if (modelRegistry) {
773
- // Use registry to pick best model for the tier/provider.
774
- // Map decide.mjs tier to registry taskType and constraints.
775
- const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
776
- const taskType = tier === 'search' ? 'search'
777
- : tier === 'think' ? 'think'
778
- : 'execute';
779
- const constraints = {
780
- provider: registryProvider,
781
- ...(tier === 'search' && { preferSpeed: true }),
782
- ...(tier === 'think' && { requireReasoning: true }),
783
- ...(!needsDeepReasoning && workStyle.key === 'fast' && { maxCost: 'medium' }),
784
- };
785
- const registryResult = modelRegistry.getBestModel(taskType, constraints);
786
- if (registryResult) {
787
- // Registry returns full model IDs (e.g. 'claude-sonnet-4-6').
788
- // dispatch.mjs mapToAgentModel handles both short names and full IDs.
789
- model = registryResult.id;
790
- } else {
791
- // Registry found no match — use hardcoded fallback
792
- model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
793
- }
794
- } else {
795
- // Registry unavailable — use existing hardcoded selection
796
- model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
797
- }
798
-
799
- // The internal pipeline (health downgrade, profile bias, safety floor) operates on
800
- // short model names ('haiku', 'sonnet', 'opus', 'gpt-4o', etc.) and the available[]
801
- // arrays use the same short names. Normalize a full model ID to short name first so
802
- // that rank lookups work correctly, then restore the full ID at the end.
803
- model = toShortName(model, provider);
804
-
805
- // Apply health-based downgrade (only if score < 50 and not high-stakes)
806
- model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
807
-
808
- // Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
809
- model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
810
-
811
- // Think-engine tier hint: use as a HINT to allow cheaper model when think-engine
812
- // classifies the task as recall/quick. Never escalate — only downgrade when safe to do so.
813
- let thinkTier = null;
814
- try {
815
- if (thinkResult?.tier) thinkTier = thinkResult.tier;
816
- } catch (e) {}
817
-
818
- if (thinkTier && !isHighStakes) {
819
- const claudeRankAsc = ['haiku', 'sonnet', 'opus'];
820
- const openaiRankAsc = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
821
-
822
- if (thinkTier === 'recall' && provider === 'claude') {
823
- // recall → haiku is fine if available
824
- const target = 'haiku';
825
- const currentIdx = claudeRankAsc.indexOf(model);
826
- const targetIdx = claudeRankAsc.indexOf(target);
827
- if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
828
- model = target;
829
- }
830
- } else if (thinkTier === 'recall' && provider === 'openai') {
831
- const target = 'gpt-4o-mini';
832
- const currentIdx = openaiRankAsc.indexOf(model);
833
- const targetIdx = openaiRankAsc.indexOf(target);
834
- if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
835
- model = target;
836
- }
837
- } else if (thinkTier === 'quick' && provider === 'claude') {
838
- // quick → sonnet is sufficient
839
- const target = 'sonnet';
840
- const currentIdx = claudeRankAsc.indexOf(model);
841
- const targetIdx = claudeRankAsc.indexOf(target);
842
- if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
843
- model = target;
844
- }
845
- } else if (thinkTier === 'quick' && provider === 'openai') {
846
- const target = 'gpt-4o';
847
- const currentIdx = openaiRankAsc.indexOf(model);
848
- const targetIdx = openaiRankAsc.indexOf(target);
849
- if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
850
- model = target;
851
- }
852
- }
853
- // 'standard', 'deep', 'ultra' — leave model unchanged; existing routing already picked correctly
854
- }
855
-
856
- // Session context: escalate or prefer model based on cross-session history
857
- if (sessionContext) {
858
- const sessionAttempts = Array.isArray(sessionContext.priorAttempts) ? sessionContext.priorAttempts : [];
859
- const sessionFailures = sessionAttempts.filter(a => a && (a.failed || a.status === 'failed'));
860
- const sessionSuccesses = sessionAttempts.filter(a => a && !a.failed && a.status !== 'failed');
861
-
862
- // Prior failures on similar work → escalate from sonnet to opus (Claude) or gpt-4o to o3 (OpenAI)
863
- if (sessionFailures.length >= 2 && !isHighStakes) {
864
- if (provider === 'claude') {
865
- const claudeRank = ['haiku', 'sonnet', 'opus'];
866
- const currentIdx = claudeRank.indexOf(toShortName(model, 'claude'));
867
- if (currentIdx !== -1 && currentIdx < claudeRank.length - 1) {
868
- const escalated = claudeRank[currentIdx + 1];
869
- if (available.claude.includes(escalated)) model = escalated;
870
- }
871
- } else {
872
- const oaiRank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
873
- const currentIdx = oaiRank.indexOf(model);
874
- if (currentIdx !== -1 && currentIdx < oaiRank.length - 1) {
875
- const escalated = oaiRank[currentIdx + 1];
876
- if (available.openai.includes(escalated)) model = escalated;
877
- }
878
- }
879
- }
880
-
881
- // Prior successful approach → prefer same provider/model that worked before
882
- if (sessionSuccesses.length > 0) {
883
- const lastSuccess = sessionSuccesses[sessionSuccesses.length - 1];
884
- if (lastSuccess.provider && lastSuccess.model && !isHighStakes) {
885
- const successProvider = lastSuccess.provider;
886
- const successModel = lastSuccess.model;
887
- const providerEnabled = profile?.providers?.[successProvider]?.enabled;
888
- const providerHealthy = (healthScores[successProvider] ?? 0) > 0;
889
- if (providerEnabled && providerHealthy) {
890
- const shortSuccess = toShortName(successModel, successProvider);
891
- if (available[successProvider]?.includes(shortSuccess)) {
892
- provider = successProvider;
893
- model = shortSuccess;
894
- }
895
- }
896
- }
897
- }
898
- }
899
-
900
- // Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
901
- model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
902
-
903
- // Apply preferModel signal from preferences (override after all other picks)
904
- if (prefSignals.preferModel) {
905
- const wantedModel = prefSignals.preferModel;
906
- if (available[provider]?.includes(wantedModel)) {
907
- model = wantedModel;
908
- }
909
- }
910
-
911
- // Restore full model ID from registry if the pipeline kept the same short name it started with.
912
- // If the pipeline changed the model (downgrade/bias/floor), resolve the new short name to a full ID.
913
- model = toFullModelId(model, provider, tier);
914
-
915
- // ── Routing advisor: consult learned EMA model for this task type ─────────
916
- // Non-blocking: only overrides when advisor has enough observations (confidence > 0.3).
917
- // Uses short model names; advisor only covers Claude models (haiku/sonnet/opus).
918
- let _advisorOverride = null;
919
- if (routingAdvisor && provider === 'claude') {
920
- try {
921
- const advice = routingAdvisor.adviseModel(
922
- { intent: detection.intent, tier, risk: detection.risk },
923
- cwd
924
- );
925
- if (advice.confidence > 0.3 && advice.model) {
926
- const advisorShort = advice.model; // advisor returns short names
927
- const previousModel = toShortName(model, 'claude');
928
- if (advisorShort !== previousModel && available.claude.includes(advisorShort)) {
929
- const overrideFullId = toFullModelId(advisorShort, 'claude', tier);
930
- _advisorOverride = { from: model, to: overrideFullId, reason: advice.reason, explored: advice.explored };
931
- model = overrideFullId;
932
- }
933
- }
934
- } catch { /* non-blocking */ }
935
- }
936
-
937
- // ── Challenger / dual-brain decision ─────────────────────────────────────
938
- const hasBothProviders = !!(
939
- profile?.providers?.claude?.enabled &&
940
- profile?.providers?.openai?.enabled
941
- );
942
-
943
- // Work-style challenger: triggered by challengerPolicy + risk level
944
- const challengerTriggered = shouldTriggerChallenger(
945
- workStyle.challengerPolicy,
946
- risk,
947
- hasBothProviders,
948
- );
949
-
950
- // Legacy designImpact dual-brain gate (mandatory review, bypass hasBothProviders check)
951
- const legacyDualBrain = !!(detection.designImpact && profile?.dual_brain_enabled !== false);
952
-
953
- // Preference overrides
954
- let dual = challengerTriggered || legacyDualBrain || shouldDualBrain(detection, profile);
955
- if (prefSignals.alwaysDualBrain) dual = true;
956
- if (prefSignals.neverDualBrain) dual = false;
957
-
958
- // When only one provider available and challenger was the reason, downgrade to single-brain
959
- if (dual && !hasBothProviders && !legacyDualBrain) dual = false;
960
-
961
- const degradedDualBrain = !!(legacyDualBrain && !hasBothProviders);
962
-
963
- // Pick challenger model (from the opposing provider)
964
- const challengerModel = dual ? pickChallengerModel(provider, available) : null;
965
-
966
- // Determine effort, modes, sandbox
967
- const effort = pickEffort(model, detection);
968
- const modes = pickModes(model, detection);
969
- const sandbox = pickSandbox(model, detection);
970
-
971
- const decision = {
972
- provider,
973
- model,
974
- effort,
975
- tier,
976
- dualBrain: dual,
977
- ...(degradedDualBrain && { degradedDualBrain: true }),
978
- ...(challengerModel && { challengerModel }),
979
- workStyle: workStyle.key,
980
- modes,
981
- sandbox,
982
- explanation: '',
983
- _healthScores: healthScores,
984
- _workStyle: workStyle,
985
- ...(_advisorOverride && { _advisorOverride }),
986
- };
987
-
988
- decision.explanation = explainDecision(decision, detection, profileWithEffectiveBias);
989
-
990
- // Remove internal fields from public output
991
- const { _healthScores, _workStyle, ...result } = decision;
992
- return result;
993
- }
994
-
995
- // ─── Exported: getFailoverOrder ──────────────────────────────────────────────
996
-
997
- /**
998
- * Given a failed routing decision and the active profile, return an ordered list
999
- * of fallback options to try next.
1000
- *
1001
- * Priority order:
1002
- * 1. Other subscriptions of the same provider (e.g. Claude Max #2 before Claude Pro)
1003
- * 2. Other provider (OpenAI or Claude, whichever wasn't tried)
1004
- *
1005
- * Within each group, options are ordered by capability match for the tier
1006
- * (best fit first, cheapest last).
1007
- *
1008
- * @param {object} decision The routing decision that just failed (provider, model, tier)
1009
- * @param {object} profile Active profile with providers/subscriptions info
1010
- * @returns {Array<{ provider: string, model: string, plan: string, label: string }>}
1011
- */
1012
- export function getFailoverOrder(decision, profile) {
1013
- const { provider: failedProvider, model: failedModel, tier = 'execute' } = decision;
1014
- const available = getAvailableModels(profile);
1015
-
1016
- // Build a ranked model list for Claude (best capability for tier → cheapest)
1017
- const claudeRankByTier = {
1018
- think: ['opus', 'sonnet', 'haiku'],
1019
- execute: ['sonnet', 'opus', 'haiku'],
1020
- search: ['haiku', 'sonnet', 'opus'],
1021
- };
1022
- const openaiRankByTier = {
1023
- think: ['o3', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o-mini'],
1024
- execute: ['gpt-4o', 'gpt-4.1', 'o3', 'gpt-4.1-mini', 'gpt-4o-mini'],
1025
- search: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o3'],
1026
- };
1027
-
1028
- const claudeRank = claudeRankByTier[tier] ?? claudeRankByTier.execute;
1029
- const openaiRank = openaiRankByTier[tier] ?? openaiRankByTier.execute;
1030
-
1031
- const claudeEnabled = !!(profile?.providers?.claude?.enabled);
1032
- const openaiEnabled = !!(profile?.providers?.openai?.enabled);
1033
-
1034
- const fallbacks = [];
1035
-
1036
- if (failedProvider === 'claude') {
1037
- // Same-provider fallbacks: other Claude models (skip the one that just failed)
1038
- for (const m of claudeRank) {
1039
- if (m === failedModel) continue;
1040
- if (!available.claude.includes(m)) continue;
1041
- fallbacks.push({ provider: 'claude', model: m, label: `Claude ${m}` });
1042
- }
1043
- // Cross-provider fallbacks: OpenAI models
1044
- if (openaiEnabled) {
1045
- for (const m of openaiRank) {
1046
- if (!available.openai.includes(m)) continue;
1047
- fallbacks.push({ provider: 'openai', model: m, label: `OpenAI ${m}` });
1048
- }
1049
- }
1050
- } else {
1051
- // Same-provider fallbacks: other OpenAI models (skip the one that just failed)
1052
- for (const m of openaiRank) {
1053
- if (m === failedModel) continue;
1054
- if (!available.openai.includes(m)) continue;
1055
- fallbacks.push({ provider: 'openai', model: m, label: `OpenAI ${m}` });
1056
- }
1057
- // Cross-provider fallbacks: Claude models
1058
- if (claudeEnabled) {
1059
- for (const m of claudeRank) {
1060
- if (!available.claude.includes(m)) continue;
1061
- fallbacks.push({ provider: 'claude', model: m, label: `Claude ${m}` });
1062
- }
1063
- }
1064
- }
1065
-
1066
- return fallbacks;
1067
- }
1068
-
1069
- // ─── CLI ──────────────────────────────────────────────────────────────────────
1070
-
1071
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1072
- const args = process.argv.slice(2);
1073
- let profilePath, detectionJson, cwd;
1074
-
1075
- for (let i = 0; i < args.length; i++) {
1076
- if (args[i] === '--profile' && args[i + 1]) { profilePath = args[++i]; }
1077
- if (args[i] === '--detection' && args[i + 1]) { detectionJson = args[++i]; }
1078
- if (args[i] === '--cwd' && args[i + 1]) { cwd = args[++i]; }
1079
- }
1080
-
1081
- let profile = {};
1082
- let detection = {};
1083
-
1084
- if (profilePath) {
1085
- try { profile = JSON.parse(readFileSync(profilePath, 'utf8')); } catch (e) {
1086
- console.error(`Failed to load profile: ${e.message}`);
1087
- process.exit(1);
1088
- }
1089
- }
1090
- if (detectionJson) {
1091
- try { detection = JSON.parse(detectionJson); } catch (e) {
1092
- console.error(`Failed to parse detection JSON: ${e.message}`);
1093
- process.exit(1);
1094
- }
1095
- }
1096
-
1097
- const result = decideRoute({ profile, detection, cwd });
1098
- console.log(JSON.stringify(result, null, 2));
1099
- }