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.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. 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
+ }