dual-brain 0.1.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.
- package/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- package/src/update-check.mjs +35 -0
package/src/decide.mjs
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
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
|
+
* estimateBudgetPressure, shouldDualBrain, explainDecision
|
|
10
|
+
*
|
|
11
|
+
* CLI: node src/decide.mjs --profile /path/to/profile.json \
|
|
12
|
+
* --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'fs';
|
|
16
|
+
import { join, dirname } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { getProviderScore, checkCooldown } from './health.mjs';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const WORKSPACE = join(__dirname, '..');
|
|
22
|
+
const USAGE_DIR = join(WORKSPACE, '.dualbrain', 'usage');
|
|
23
|
+
const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
// ─── Slim Model Capabilities (routing-relevant only) ─────────────────────────
|
|
26
|
+
|
|
27
|
+
/** @type {Record<string, {provider, tierFit, contextWindow, strengths, weaknesses, effortLevels, costTier}>} */
|
|
28
|
+
const MODEL_CAPABILITIES = {
|
|
29
|
+
haiku: {
|
|
30
|
+
provider: 'claude',
|
|
31
|
+
tierFit: ['search'],
|
|
32
|
+
contextWindow: 200_000,
|
|
33
|
+
strengths: ['search', 'format', 'lookup', 'classification', 'grep-analysis'],
|
|
34
|
+
weaknesses: ['complex-edits', 'architecture', 'security', 'multi-file-refactor'],
|
|
35
|
+
effortLevels: null,
|
|
36
|
+
costTier: 'cheap',
|
|
37
|
+
},
|
|
38
|
+
sonnet: {
|
|
39
|
+
provider: 'claude',
|
|
40
|
+
tierFit: ['execute', 'search'],
|
|
41
|
+
contextWindow: 200_000,
|
|
42
|
+
strengths: ['edit', 'refactor', 'test', 'debug', 'code-generation', 'tool-use'],
|
|
43
|
+
weaknesses: ['deep-architecture', 'ambiguous-requirements', 'frontier-reasoning'],
|
|
44
|
+
effortLevels: ['low', 'medium', 'high', 'xhigh'],
|
|
45
|
+
costTier: 'medium',
|
|
46
|
+
},
|
|
47
|
+
opus: {
|
|
48
|
+
provider: 'claude',
|
|
49
|
+
tierFit: ['think', 'execute'],
|
|
50
|
+
contextWindow: 200_000,
|
|
51
|
+
strengths: ['architecture', 'security', 'complex-debug', 'review', 'planning', 'threat-modeling'],
|
|
52
|
+
weaknesses: ['cost', 'overkill-for-simple-tasks'],
|
|
53
|
+
effortLevels: ['low', 'medium', 'high', 'xhigh'],
|
|
54
|
+
costTier: 'expensive',
|
|
55
|
+
},
|
|
56
|
+
'gpt-4.1-mini': {
|
|
57
|
+
provider: 'openai',
|
|
58
|
+
tierFit: ['search'],
|
|
59
|
+
contextWindow: 1_047_576,
|
|
60
|
+
strengths: ['search', 'format', 'classification', 'fast-lookups'],
|
|
61
|
+
weaknesses: ['complex-refactors', 'architecture', 'multi-file-edits'],
|
|
62
|
+
effortLevels: ['low', 'medium', 'high'],
|
|
63
|
+
costTier: 'cheap',
|
|
64
|
+
},
|
|
65
|
+
'gpt-4.1': {
|
|
66
|
+
provider: 'openai',
|
|
67
|
+
tierFit: ['execute', 'search'],
|
|
68
|
+
contextWindow: 1_047_576,
|
|
69
|
+
strengths: ['edit', 'code-generation', 'simple-refactor'],
|
|
70
|
+
weaknesses: ['architecture', 'security', 'complex-debug'],
|
|
71
|
+
effortLevels: ['low', 'medium', 'high'],
|
|
72
|
+
costTier: 'medium',
|
|
73
|
+
},
|
|
74
|
+
'gpt-4o': {
|
|
75
|
+
provider: 'openai',
|
|
76
|
+
tierFit: ['execute', 'think'],
|
|
77
|
+
contextWindow: 128_000,
|
|
78
|
+
strengths: ['refactor', 'debug', 'code-generation', 'test', 'multimodal'],
|
|
79
|
+
weaknesses: ['cost vs mini'],
|
|
80
|
+
effortLevels: ['low', 'medium', 'high'],
|
|
81
|
+
costTier: 'medium',
|
|
82
|
+
},
|
|
83
|
+
'gpt-4o-mini': {
|
|
84
|
+
provider: 'openai',
|
|
85
|
+
tierFit: ['search'],
|
|
86
|
+
contextWindow: 128_000,
|
|
87
|
+
costTier: 'cheap',
|
|
88
|
+
strengths: ['quick-tasks', 'search', 'classification'],
|
|
89
|
+
weaknesses: ['complex-edits', 'architecture'],
|
|
90
|
+
effortLevels: null,
|
|
91
|
+
},
|
|
92
|
+
'o3': {
|
|
93
|
+
provider: 'openai',
|
|
94
|
+
tierFit: ['think'],
|
|
95
|
+
contextWindow: 200_000,
|
|
96
|
+
strengths: ['architecture', 'security', 'review', 'planning', 'complex-debug', 'deep-reasoning'],
|
|
97
|
+
weaknesses: ['cost', 'latency'],
|
|
98
|
+
effortLevels: ['low', 'medium', 'high'],
|
|
99
|
+
costTier: 'expensive',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ─── Subscription Model Access ────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const CLAUDE_MODELS_BY_PLAN = {
|
|
106
|
+
'$20': ['haiku', 'sonnet'],
|
|
107
|
+
'$100': ['haiku', 'sonnet', 'opus'],
|
|
108
|
+
'$200': ['haiku', 'sonnet', 'opus'],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const OPENAI_MODELS_BY_PLAN = {
|
|
112
|
+
'$20': ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o'],
|
|
113
|
+
'$100': ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'],
|
|
114
|
+
'$200': ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Token fallback estimates per tier (no real usage data)
|
|
118
|
+
const TOKEN_FALLBACK = { search: 2_500, execute: 8_000, think: 15_000 };
|
|
119
|
+
|
|
120
|
+
// ─── Exported: getModelCapabilities ──────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Look up a model's routing-relevant capabilities.
|
|
124
|
+
* @param {string} model
|
|
125
|
+
* @returns {object|null}
|
|
126
|
+
*/
|
|
127
|
+
export function getModelCapabilities(model) {
|
|
128
|
+
return MODEL_CAPABILITIES[model] ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Exported: getAvailableModels ─────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Return which models the user can access given their profile's provider plans.
|
|
135
|
+
* @param {{ providers?: { claude?: { plan?: string, enabled?: boolean }, openai?: { plan?: string, enabled?: boolean } } }} profile
|
|
136
|
+
* @returns {{ claude: string[], openai: string[] }}
|
|
137
|
+
*/
|
|
138
|
+
export function getAvailableModels(profile) {
|
|
139
|
+
const claudePlan = profile?.providers?.claude?.plan || '$100';
|
|
140
|
+
const openaiPlan = profile?.providers?.openai?.plan || '$20';
|
|
141
|
+
return {
|
|
142
|
+
claude: CLAUDE_MODELS_BY_PLAN[claudePlan] ?? CLAUDE_MODELS_BY_PLAN['$100'],
|
|
143
|
+
openai: OPENAI_MODELS_BY_PLAN[openaiPlan] ?? OPENAI_MODELS_BY_PLAN['$20'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Exported: estimateBudgetPressure (deprecated stub) ──────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @deprecated Replaced by the health-based router in health.mjs.
|
|
151
|
+
* Returns an empty object so callers that still import this don't crash.
|
|
152
|
+
* The budget-balancer.mjs hook file is separate and can keep using usage logs.
|
|
153
|
+
* @returns {{ claude: number, openai: number }}
|
|
154
|
+
*/
|
|
155
|
+
export function estimateBudgetPressure(_profile, _cwd) {
|
|
156
|
+
return { claude: 0, openai: 0 };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Internal: health-based provider scoring ──────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Return a 0-100 routing score for each provider using health.mjs state.
|
|
163
|
+
* For each provider we check its primary model class for the given tier.
|
|
164
|
+
* @param {'search'|'execute'|'think'} tier
|
|
165
|
+
* @param {string} [cwd]
|
|
166
|
+
* @returns {{ claude: number, openai: number }}
|
|
167
|
+
*/
|
|
168
|
+
function getHealthScores(tier, cwd) {
|
|
169
|
+
// Map tier to representative model class per provider
|
|
170
|
+
const claudeClass = tier === 'search' ? 'haiku'
|
|
171
|
+
: tier === 'think' ? 'opus'
|
|
172
|
+
: 'sonnet';
|
|
173
|
+
const openaiClass = tier === 'search' ? 'gpt-4o-mini'
|
|
174
|
+
: tier === 'think' ? 'o3'
|
|
175
|
+
: 'gpt-4o';
|
|
176
|
+
|
|
177
|
+
// Trigger cooldown expiry check (transitions hot→probing automatically)
|
|
178
|
+
checkCooldown('claude', claudeClass, cwd);
|
|
179
|
+
checkCooldown('openai', openaiClass, cwd);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
claude: getProviderScore('claude', claudeClass, cwd),
|
|
183
|
+
openai: getProviderScore('openai', openaiClass, cwd),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Exported: shouldDualBrain ────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Return true if both providers should analyze this task.
|
|
191
|
+
* Requires: (critical risk OR architecture/security intent OR complex+high-risk)
|
|
192
|
+
* AND profile has both providers available with dual mode enabled.
|
|
193
|
+
*
|
|
194
|
+
* designImpact bypasses the hasBothProviders check — it is a mandatory review
|
|
195
|
+
* gate, not optional collaboration. When only one provider is available the
|
|
196
|
+
* caller should check degradedDualBrain on the decision output.
|
|
197
|
+
* @param {{ intent?: string, risk?: string, complexity?: string, designImpact?: boolean }} detection
|
|
198
|
+
* @param {object} profile
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
201
|
+
export function shouldDualBrain(detection, profile) {
|
|
202
|
+
const { intent = '', risk = 'low', complexity = 'simple', designImpact = false } = detection;
|
|
203
|
+
const dualEnabled = profile?.dual_brain_enabled !== false;
|
|
204
|
+
if (!dualEnabled) return false;
|
|
205
|
+
|
|
206
|
+
const hasBothProviders = !!(
|
|
207
|
+
profile?.providers?.claude?.enabled &&
|
|
208
|
+
profile?.providers?.claude?.plan &&
|
|
209
|
+
profile?.providers?.openai?.enabled &&
|
|
210
|
+
profile?.providers?.openai?.plan
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (designImpact) return true;
|
|
214
|
+
|
|
215
|
+
if (!hasBothProviders) return false;
|
|
216
|
+
|
|
217
|
+
const criticalRisk = risk === 'critical';
|
|
218
|
+
const archOrSecurity = ['architecture', 'security'].includes(intent);
|
|
219
|
+
const complexHighRisk = complexity === 'complex' && risk === 'high';
|
|
220
|
+
|
|
221
|
+
return criticalRisk || archOrSecurity || complexHighRisk;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Internal: select model for provider ─────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
const THINK_INTENTS = ['architecture', 'security', 'review', 'planning', 'compare'];
|
|
227
|
+
const SEARCH_INTENTS = ['search', 'format', 'explain', 'lookup'];
|
|
228
|
+
|
|
229
|
+
function pickClaudeModel(detection, available) {
|
|
230
|
+
const { intent = '', risk = 'low', effort = 'medium' } = detection;
|
|
231
|
+
const needsOpus = THINK_INTENTS.includes(intent) || risk === 'critical' || effort === 'xhigh';
|
|
232
|
+
const needsHaiku = SEARCH_INTENTS.includes(intent) && !['high', 'critical'].includes(risk);
|
|
233
|
+
|
|
234
|
+
if (needsOpus && available.includes('opus')) return 'opus';
|
|
235
|
+
if (needsHaiku && available.includes('haiku')) return 'haiku';
|
|
236
|
+
return available.includes('sonnet') ? 'sonnet' : available[available.length - 1];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function pickOpenAIModel(detection, available) {
|
|
240
|
+
const { intent = '', risk = 'low', complexity = 'simple', effort = 'medium' } = detection;
|
|
241
|
+
const needsTop = THINK_INTENTS.includes(intent) || risk === 'critical' || effort === 'xhigh';
|
|
242
|
+
const needsMini = SEARCH_INTENTS.includes(intent) && effort === 'low';
|
|
243
|
+
const needsCodex = ['refactor', 'debug'].includes(intent) && complexity !== 'trivial';
|
|
244
|
+
|
|
245
|
+
const pref = needsTop ? 'o3'
|
|
246
|
+
: needsMini ? 'gpt-4o-mini'
|
|
247
|
+
: needsCodex ? 'gpt-4o'
|
|
248
|
+
: 'gpt-4o';
|
|
249
|
+
|
|
250
|
+
// Walk down rank until we find an available model
|
|
251
|
+
const rank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
252
|
+
const idx = rank.indexOf(pref);
|
|
253
|
+
for (let i = idx; i >= 0; i--) {
|
|
254
|
+
if (available.includes(rank[i])) return rank[i];
|
|
255
|
+
}
|
|
256
|
+
return available[0] ?? 'gpt-4o-mini';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
|
|
260
|
+
// score=100 healthy, score=50 degraded, score=25 probing, score=0 hot
|
|
261
|
+
// If score is 0 (hot) and this isn't high-stakes, downgrade one tier
|
|
262
|
+
if (score >= 50 || isHighStakes) return model;
|
|
263
|
+
|
|
264
|
+
if (provider === 'claude') {
|
|
265
|
+
const claudeRank = ['haiku', 'sonnet', 'opus'];
|
|
266
|
+
const idx = claudeRank.indexOf(model);
|
|
267
|
+
const steps = score === 0 ? 2 : 1;
|
|
268
|
+
const downIdx = Math.max(0, idx - steps);
|
|
269
|
+
for (let i = downIdx; i <= idx; i++) {
|
|
270
|
+
if (available.includes(claudeRank[i])) return claudeRank[i];
|
|
271
|
+
}
|
|
272
|
+
return available[0] ?? 'haiku';
|
|
273
|
+
} else {
|
|
274
|
+
const oaiRank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
275
|
+
const idx = oaiRank.indexOf(model);
|
|
276
|
+
const steps = score === 0 ? 2 : 1;
|
|
277
|
+
const downIdx = Math.max(0, idx - steps);
|
|
278
|
+
for (let i = downIdx; i <= idx; i++) {
|
|
279
|
+
if (available.includes(oaiRank[i])) return oaiRank[i];
|
|
280
|
+
}
|
|
281
|
+
return available[0] ?? 'gpt-4o-mini';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function applyProfileBias(model, profile, provider, available, tier) {
|
|
286
|
+
const mode = profile?.mode || profile?.profile || 'auto';
|
|
287
|
+
if (mode === 'cost-saver') {
|
|
288
|
+
// Prefer cheapest available that also fits the required tier
|
|
289
|
+
const ranks = {
|
|
290
|
+
claude: ['haiku', 'sonnet', 'opus'],
|
|
291
|
+
openai: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'],
|
|
292
|
+
};
|
|
293
|
+
for (const m of ranks[provider]) {
|
|
294
|
+
if (!available.includes(m)) continue;
|
|
295
|
+
const caps = MODEL_CAPABILITIES[m];
|
|
296
|
+
if (tier && caps && !caps.tierFit.includes(tier)) continue;
|
|
297
|
+
return m;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (mode === 'quality-first') {
|
|
301
|
+
// Prefer best available, keep current if already best
|
|
302
|
+
const ranks = {
|
|
303
|
+
claude: ['opus', 'sonnet', 'haiku'],
|
|
304
|
+
openai: ['o3', 'o4-mini', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o-mini'],
|
|
305
|
+
};
|
|
306
|
+
for (const m of ranks[provider]) {
|
|
307
|
+
if (available.includes(m)) return m;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Check user preferences (e.g. { prefer: 'opus', for: 'security' })
|
|
311
|
+
const prefs = profile?.preferences || [];
|
|
312
|
+
for (const pref of prefs) {
|
|
313
|
+
if (pref.model && available.includes(pref.model) &&
|
|
314
|
+
pref.for && MODEL_CAPABILITIES[pref.model]?.strengths?.includes(pref.for)) {
|
|
315
|
+
return pref.model;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return model;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function pickEffort(model, detection) {
|
|
322
|
+
const caps = MODEL_CAPABILITIES[model];
|
|
323
|
+
if (!caps?.effortLevels) return null;
|
|
324
|
+
const { risk = 'low', complexity = 'simple', effort } = detection;
|
|
325
|
+
if (effort && caps.effortLevels.includes(effort)) return effort;
|
|
326
|
+
if (risk === 'critical' || complexity === 'complex') return 'xhigh';
|
|
327
|
+
if (risk === 'high' || complexity === 'moderate') return 'high';
|
|
328
|
+
if (risk === 'low' && complexity === 'trivial') return 'low';
|
|
329
|
+
return 'medium';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function pickModes(model, detection) {
|
|
333
|
+
const { intent = '', complexity = 'simple' } = detection;
|
|
334
|
+
const caps = MODEL_CAPABILITIES[model] ?? {};
|
|
335
|
+
const thinkingModels = ['sonnet', 'opus', 'o3', 'gpt-4o'];
|
|
336
|
+
const lightIntents = ['search', 'format', 'explain', 'lookup'];
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
extendedThinking: thinkingModels.includes(model)
|
|
340
|
+
&& ['moderate', 'complex'].includes(complexity)
|
|
341
|
+
&& !lightIntents.includes(intent),
|
|
342
|
+
fastMode: model === 'opus',
|
|
343
|
+
extendedContext: ['sonnet', 'opus'].includes(model),
|
|
344
|
+
webSearch: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o'].includes(model),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function pickSandbox(model, detection) {
|
|
349
|
+
const { tier = 'execute' } = detection;
|
|
350
|
+
if (tier === 'search') return 'read-only';
|
|
351
|
+
if (MODEL_CAPABILITIES[model]?.provider === 'openai') return 'danger-full-access';
|
|
352
|
+
return 'workspace-write';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function chooseProvider(detection, profile, healthScores) {
|
|
356
|
+
const { tier = 'execute', intent = '' } = detection;
|
|
357
|
+
const claudeScore = healthScores.claude;
|
|
358
|
+
const openaiScore = healthScores.openai;
|
|
359
|
+
|
|
360
|
+
// OpenAI not configured or not enabled → always use Claude
|
|
361
|
+
if (!profile?.providers?.openai?.enabled || !profile?.providers?.openai?.plan) return 'claude';
|
|
362
|
+
|
|
363
|
+
// Both hot (score=0) → pick the one with the higher score; if tied, prefer Claude
|
|
364
|
+
if (claudeScore === 0 && openaiScore === 0) {
|
|
365
|
+
return claudeScore >= openaiScore ? 'claude' : 'openai';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Think-tier strongly prefers Claude (session context coupling), unless Claude is hot
|
|
369
|
+
if (THINK_INTENTS.includes(intent) && claudeScore > 0) return 'claude';
|
|
370
|
+
|
|
371
|
+
// Claude hot → route to OpenAI if available
|
|
372
|
+
if (claudeScore === 0 && openaiScore > 0) return 'openai';
|
|
373
|
+
|
|
374
|
+
// Isolated execute tasks: route to OpenAI if Claude is degraded/probing but OpenAI is healthy
|
|
375
|
+
if (tier === 'execute' && !THINK_INTENTS.includes(intent)) {
|
|
376
|
+
if (claudeScore < 100 && openaiScore > claudeScore) return 'openai';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Default: Claude (lower session-context overhead, higher score wins)
|
|
380
|
+
return claudeScore >= openaiScore ? 'claude' : 'openai';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Exported: explainDecision ────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Generate a one-sentence explanation for the routing decision.
|
|
387
|
+
* @param {object} decision
|
|
388
|
+
* @param {object} detection
|
|
389
|
+
* @param {object} profile
|
|
390
|
+
* @returns {string}
|
|
391
|
+
*/
|
|
392
|
+
export function explainDecision(decision, detection, profile) {
|
|
393
|
+
const { provider, model, effort, dualBrain } = decision;
|
|
394
|
+
const { intent = 'task', risk = 'low', complexity = 'simple', tier = 'execute' } = detection;
|
|
395
|
+
const healthScores = decision._healthScores || {};
|
|
396
|
+
const mode = profile?.mode || profile?.profile || 'auto';
|
|
397
|
+
|
|
398
|
+
const modelLabel = effort ? `${model} ${effort}` : model;
|
|
399
|
+
|
|
400
|
+
if (dualBrain) {
|
|
401
|
+
return `Using ${modelLabel} with dual-brain review because this ${intent} change is ${risk} risk.`;
|
|
402
|
+
}
|
|
403
|
+
// Health-based explanations
|
|
404
|
+
const claudeScore = healthScores.claude ?? 100;
|
|
405
|
+
const providerScore = healthScores[provider] ?? 100;
|
|
406
|
+
if (claudeScore === 0 && provider === 'openai') {
|
|
407
|
+
return `Using ${modelLabel} because Claude is rate-limited and this is an isolated ${tier} task.`;
|
|
408
|
+
}
|
|
409
|
+
if (providerScore < 50) {
|
|
410
|
+
return `Using ${modelLabel} (downgraded due to rate-limit cooldown) for this ${complexity} ${intent}.`;
|
|
411
|
+
}
|
|
412
|
+
if (mode === 'cost-saver') {
|
|
413
|
+
return `Using ${modelLabel} because cost-saver mode prefers cheaper models for ${risk}-risk work.`;
|
|
414
|
+
}
|
|
415
|
+
if (mode === 'quality-first') {
|
|
416
|
+
return `Using ${modelLabel} because quality-first mode prefers stronger models for ${intent}.`;
|
|
417
|
+
}
|
|
418
|
+
if (THINK_INTENTS.includes(intent)) {
|
|
419
|
+
return `Using ${modelLabel} because ${intent} tasks need deep reasoning and Claude is healthy.`;
|
|
420
|
+
}
|
|
421
|
+
if (tier === 'search' || SEARCH_INTENTS.includes(intent)) {
|
|
422
|
+
return `Using ${modelLabel} because this is a simple ${intent} with low risk.`;
|
|
423
|
+
}
|
|
424
|
+
return `Using ${modelLabel} because ${provider} is healthy and this is a routine ${intent}.`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── Exported: parsePreferences ──────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Parse free-text user preferences into routing-relevant signals.
|
|
431
|
+
* @param {Array<{text: string, enabled: boolean, scope: string}>} preferences
|
|
432
|
+
* @returns {{
|
|
433
|
+
* biasOverride: 'cost-saver'|'quality-first'|null,
|
|
434
|
+
* preferProvider: 'claude'|'openai'|null,
|
|
435
|
+
* avoidProvider: 'claude'|'openai'|null,
|
|
436
|
+
* alwaysDualBrain: boolean,
|
|
437
|
+
* neverDualBrain: boolean,
|
|
438
|
+
* preferModel: 'opus'|'sonnet'|'haiku'|null,
|
|
439
|
+
* }}
|
|
440
|
+
*/
|
|
441
|
+
export function parsePreferences(preferences) {
|
|
442
|
+
const active = (preferences || []).filter(p => p.enabled);
|
|
443
|
+
const signals = {
|
|
444
|
+
biasOverride: null,
|
|
445
|
+
preferProvider: null,
|
|
446
|
+
avoidProvider: null,
|
|
447
|
+
alwaysDualBrain: false,
|
|
448
|
+
neverDualBrain: false,
|
|
449
|
+
preferModel: null,
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
for (const pref of active) {
|
|
453
|
+
const t = pref.text.toLowerCase();
|
|
454
|
+
// Cost/quality bias signals
|
|
455
|
+
if (/cheap|save|budget|frugal|economical|cost/i.test(t)) signals.biasOverride = 'cost-saver';
|
|
456
|
+
if (/quality|best|thorough|careful|premium/i.test(t)) signals.biasOverride = 'quality-first';
|
|
457
|
+
// Provider preference signals
|
|
458
|
+
if (/prefer claude|use claude|claude first/i.test(t)) signals.preferProvider = 'claude';
|
|
459
|
+
if (/prefer (openai|gpt|chatgpt)|use (openai|gpt)/i.test(t)) signals.preferProvider = 'openai';
|
|
460
|
+
if (/avoid claude|no claude/i.test(t)) signals.avoidProvider = 'claude';
|
|
461
|
+
if (/avoid (openai|gpt)|no (openai|gpt)/i.test(t)) signals.avoidProvider = 'openai';
|
|
462
|
+
// Dual-brain signals
|
|
463
|
+
if (/always/.test(t) && /(consensus|dual.brain|two.brain|dual)/i.test(t)) signals.alwaysDualBrain = true;
|
|
464
|
+
if (/never (consensus|dual)|skip (review|consensus)|solo/i.test(t)) signals.neverDualBrain = true;
|
|
465
|
+
// Model preference signals
|
|
466
|
+
if (/prefer opus|use opus/i.test(t)) signals.preferModel = 'opus';
|
|
467
|
+
if (/prefer sonnet|use sonnet/i.test(t)) signals.preferModel = 'sonnet';
|
|
468
|
+
if (/prefer haiku|use haiku/i.test(t)) signals.preferModel = 'haiku';
|
|
469
|
+
}
|
|
470
|
+
return signals;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Internal: safety floor for critical-risk tasks ───────────────────────────
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Ensure critical-risk tasks are never handled by the cheapest (haiku/gpt-4.1-mini) model.
|
|
477
|
+
* Cost-saver mode is the main culprit; escalate silently but emit a stderr warning.
|
|
478
|
+
* @param {string} model
|
|
479
|
+
* @param {string} provider
|
|
480
|
+
* @param {string[]} available
|
|
481
|
+
* @param {'low'|'medium'|'high'|'critical'} risk
|
|
482
|
+
* @returns {string}
|
|
483
|
+
*/
|
|
484
|
+
function applyCriticalRiskFloor(model, provider, available, risk) {
|
|
485
|
+
if (risk !== 'critical') return model;
|
|
486
|
+
|
|
487
|
+
const cheapModels = { claude: 'haiku', openai: 'gpt-4.1-mini' };
|
|
488
|
+
const floorModels = { claude: 'sonnet', openai: 'gpt-4.1' };
|
|
489
|
+
|
|
490
|
+
if (model === cheapModels[provider]) {
|
|
491
|
+
const floor = floorModels[provider];
|
|
492
|
+
const escalated = available.includes(floor) ? floor : available[available.length - 1] ?? model;
|
|
493
|
+
process.stderr.write(
|
|
494
|
+
`[dual-brain] Warning: cost-saver selected ${model} for a critical-risk task. ` +
|
|
495
|
+
`Escalating to ${escalated} (safety floor).\n`
|
|
496
|
+
);
|
|
497
|
+
return escalated;
|
|
498
|
+
}
|
|
499
|
+
return model;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ─── Exported: decideRoute ────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Main routing decision function.
|
|
506
|
+
* @param {{ profile: object, detection: object, cwd?: string }} input
|
|
507
|
+
* @returns {object} Routing decision
|
|
508
|
+
*/
|
|
509
|
+
export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
510
|
+
const available = getAvailableModels(profile);
|
|
511
|
+
|
|
512
|
+
// Parse free-text user preferences into routing signals
|
|
513
|
+
const prefSignals = parsePreferences(profile.preferences);
|
|
514
|
+
|
|
515
|
+
// Apply bias override from preferences (takes precedence over profile.bias)
|
|
516
|
+
const profileWithEffectiveBias = prefSignals.biasOverride
|
|
517
|
+
? { ...profile, mode: prefSignals.biasOverride }
|
|
518
|
+
: profile;
|
|
519
|
+
|
|
520
|
+
// dual-brain: start with the natural shouldDualBrain result, then apply preference overrides
|
|
521
|
+
let dual = shouldDualBrain(detection, profile);
|
|
522
|
+
if (prefSignals.alwaysDualBrain) dual = true;
|
|
523
|
+
if (prefSignals.neverDualBrain) dual = false;
|
|
524
|
+
|
|
525
|
+
const { tier = 'execute', risk = 'low' } = detection;
|
|
526
|
+
const isHighStakes = ['critical', 'high'].includes(risk);
|
|
527
|
+
|
|
528
|
+
// Get health scores for current tier
|
|
529
|
+
const healthScores = getHealthScores(tier, cwd);
|
|
530
|
+
|
|
531
|
+
// Choose provider (using the bias-patched profile so chooseProvider sees the right mode)
|
|
532
|
+
let provider = chooseProvider(detection, profileWithEffectiveBias, healthScores);
|
|
533
|
+
|
|
534
|
+
// Apply preferProvider / avoidProvider signals from preferences
|
|
535
|
+
if (prefSignals.preferProvider) {
|
|
536
|
+
const preferred = prefSignals.preferProvider;
|
|
537
|
+
const prefEnabled = profile?.providers?.[preferred]?.enabled && profile?.providers?.[preferred]?.plan;
|
|
538
|
+
const prefScore = healthScores[preferred] ?? 0;
|
|
539
|
+
// Use preferred provider if it is configured and has any health score (even degraded)
|
|
540
|
+
if (prefEnabled && prefScore > 0) provider = preferred;
|
|
541
|
+
}
|
|
542
|
+
if (prefSignals.avoidProvider && provider === prefSignals.avoidProvider) {
|
|
543
|
+
// Switch to the other provider only if it is configured and healthy
|
|
544
|
+
const other = prefSignals.avoidProvider === 'claude' ? 'openai' : 'claude';
|
|
545
|
+
const otherEnabled = profile?.providers?.[other]?.enabled && profile?.providers?.[other]?.plan;
|
|
546
|
+
const otherScore = healthScores[other] ?? 0;
|
|
547
|
+
if (otherEnabled && otherScore > 0) provider = other;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Select base model (use bias-patched profile for model selection too)
|
|
551
|
+
let model = provider === 'claude'
|
|
552
|
+
? pickClaudeModel(detection, available.claude)
|
|
553
|
+
: pickOpenAIModel(detection, available.openai);
|
|
554
|
+
|
|
555
|
+
// Apply health-based downgrade (only if score < 50 and not high-stakes)
|
|
556
|
+
model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
|
|
557
|
+
|
|
558
|
+
// Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
|
|
559
|
+
model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
|
|
560
|
+
|
|
561
|
+
// Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
|
|
562
|
+
model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
|
|
563
|
+
|
|
564
|
+
// Apply preferModel signal from preferences (override after all other picks)
|
|
565
|
+
if (prefSignals.preferModel) {
|
|
566
|
+
const wantedModel = prefSignals.preferModel;
|
|
567
|
+
if (available[provider]?.includes(wantedModel)) {
|
|
568
|
+
model = wantedModel;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Determine effort, modes, sandbox
|
|
573
|
+
const effort = pickEffort(model, detection);
|
|
574
|
+
const modes = pickModes(model, detection);
|
|
575
|
+
const sandbox = pickSandbox(model, detection);
|
|
576
|
+
|
|
577
|
+
const hasBothProviders = !!(
|
|
578
|
+
profile?.providers?.claude?.enabled &&
|
|
579
|
+
profile?.providers?.claude?.plan &&
|
|
580
|
+
profile?.providers?.openai?.enabled &&
|
|
581
|
+
profile?.providers?.openai?.plan
|
|
582
|
+
);
|
|
583
|
+
const degradedDualBrain = !!(dual && detection.designImpact && !hasBothProviders);
|
|
584
|
+
|
|
585
|
+
const decision = {
|
|
586
|
+
provider,
|
|
587
|
+
model,
|
|
588
|
+
effort,
|
|
589
|
+
tier,
|
|
590
|
+
dualBrain: dual,
|
|
591
|
+
...(degradedDualBrain && { degradedDualBrain: true }),
|
|
592
|
+
modes,
|
|
593
|
+
sandbox,
|
|
594
|
+
explanation: '',
|
|
595
|
+
_healthScores: healthScores,
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
decision.explanation = explainDecision(decision, detection, profileWithEffectiveBias);
|
|
599
|
+
|
|
600
|
+
// Remove internal field from public output
|
|
601
|
+
const { _healthScores, ...result } = decision;
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
608
|
+
const args = process.argv.slice(2);
|
|
609
|
+
let profilePath, detectionJson, cwd;
|
|
610
|
+
|
|
611
|
+
for (let i = 0; i < args.length; i++) {
|
|
612
|
+
if (args[i] === '--profile' && args[i + 1]) { profilePath = args[++i]; }
|
|
613
|
+
if (args[i] === '--detection' && args[i + 1]) { detectionJson = args[++i]; }
|
|
614
|
+
if (args[i] === '--cwd' && args[i + 1]) { cwd = args[++i]; }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let profile = {};
|
|
618
|
+
let detection = {};
|
|
619
|
+
|
|
620
|
+
if (profilePath) {
|
|
621
|
+
try { profile = JSON.parse(readFileSync(profilePath, 'utf8')); } catch (e) {
|
|
622
|
+
console.error(`Failed to load profile: ${e.message}`);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (detectionJson) {
|
|
627
|
+
try { detection = JSON.parse(detectionJson); } catch (e) {
|
|
628
|
+
console.error(`Failed to parse detection JSON: ${e.message}`);
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const result = decideRoute({ profile, detection, cwd });
|
|
634
|
+
console.log(JSON.stringify(result, null, 2));
|
|
635
|
+
}
|