dual-brain 3.6.0 → 3.7.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/CLAUDE.md CHANGED
@@ -56,13 +56,22 @@ Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_re
56
56
  Active profile controls routing posture, budgets, and quality gate behavior.
57
57
  Profile persists to `.claude/dual-brain.profile.json` (gitignored).
58
58
 
59
- - **balanced** (default): Best model per tier, normal budgets, reviews at medium+ risk
59
+ - **auto** (default): Adapts routing based on task risk, provider health, and outcomes. Uses file-path risk classification and failure-loop detection to auto-escalate when needed.
60
+ - **balanced**: Best model per tier, normal budgets, reviews at medium+ risk
60
61
  - **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
61
62
  - **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
62
63
 
63
64
  Switch profiles: `npx dual-brain mode cost-saver`
64
65
  Check status: `npx dual-brain status`
65
66
 
67
+ ## Adaptive Routing (Auto Mode)
68
+
69
+ Auto mode classifies risk from file paths and adjusts routing in real-time:
70
+
71
+ - **Risk classification**: auth/secrets→critical, billing/migrations→high, tests/utils→medium, docs→low
72
+ - **Failure detection**: 2+ failures on same prompt in 2 hours → auto-escalate tier or trigger dual-brain
73
+ - **Provider balance**: Routes to underused provider when one subscription is hot
74
+
66
75
  ## Available Tools
67
76
 
68
77
  - `node .claude/hooks/cost-report.mjs` — activity and cost estimates
@@ -40,12 +40,14 @@ const blue = s => e('1;38;5;33', s);
40
40
  // ─── Profiles ──────────────────────────────────────────────────────────────
41
41
 
42
42
  const PROFILES = {
43
- balanced: { emoji: '⚖️', uiLabel: 'Default', desc: 'Auto-routes by complexity, uses both providers evenly' },
43
+ auto: { emoji: '🤖', uiLabel: 'Auto', desc: 'Adapts routing based on task risk, provider health, and outcomes' },
44
+ balanced: { emoji: '⚖️', uiLabel: 'Balanced', desc: 'Routes by complexity, uses both providers evenly' },
44
45
  'cost-saver': { emoji: '🛡️', uiLabel: 'Conservative', desc: 'Fewer GPT dispatches, sticks to Claude for most work' },
45
46
  'quality-first': { emoji: '🚀', uiLabel: 'Aggressive', desc: 'Maximizes both subscriptions, dual-brain for medium+ risk' },
46
47
  };
47
48
 
48
49
  const PROFILE_BUDGETS = {
50
+ auto: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
49
51
  balanced: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
50
52
  'cost-saver': { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
51
53
  'quality-first': { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
@@ -54,11 +56,11 @@ const PROFILE_BUDGETS = {
54
56
  function loadProfile() {
55
57
  try {
56
58
  const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
57
- const name = data.active && PROFILES[data.active] ? data.active : 'balanced';
59
+ const name = data.active && PROFILES[data.active] ? data.active : 'auto';
58
60
  const custom = data.custom_overrides || {};
59
61
  return { name, budgets: { ...PROFILE_BUDGETS[name], ...custom.budgets }, hasCustomBudget: !!custom.budgets };
60
62
  } catch {
61
- return { name: 'balanced', budgets: PROFILE_BUDGETS.balanced, hasCustomBudget: false };
63
+ return { name: 'auto', budgets: PROFILE_BUDGETS.auto, hasCustomBudget: false };
62
64
  }
63
65
  }
64
66
 
@@ -358,7 +360,19 @@ function renderReturningMenu(providers, sessions) {
358
360
  // Provider status
359
361
  const cStat = providers.claude.authed ? '✅' : '⚠️';
360
362
  const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
361
- lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat} ${pf.emoji} ${bold(pf.uiLabel)}`);
363
+ let modeStatus = pf.uiLabel;
364
+ if (profile.name === 'auto') {
365
+ if (balance.total === 0) {
366
+ modeStatus = 'Auto · learning your workflow';
367
+ } else if (balance.openai > balance.claude + 20) {
368
+ modeStatus = 'Auto · routing GPT for isolated work';
369
+ } else if (balance.claude > balance.openai + 20) {
370
+ modeStatus = 'Auto · Claude-primary, GPT available';
371
+ } else {
372
+ modeStatus = 'Auto · balanced routing active';
373
+ }
374
+ }
375
+ lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat} ${pf.emoji} ${bold(modeStatus)}`);
362
376
 
363
377
  // Provider balance bar
364
378
  lines.push(` ${balanceBar(balance.claude, balance.openai)}`);
@@ -415,7 +429,8 @@ function showProfilePicker(rl) {
415
429
  console.log('');
416
430
  for (const [i, [name, pf]] of Object.entries(PROFILES).entries()) {
417
431
  const active = name === current.name ? ' ✅' : '';
418
- console.log(` ${bold('[' + (i + 1) + ']')} ${pf.emoji} ${pf.uiLabel.padEnd(15)} ${dim(pf.desc)}${active}`);
432
+ const recommended = name === 'auto' && current.name !== 'auto' ? dim(' (recommended)') : '';
433
+ console.log(` ${bold('[' + (i + 1) + ']')} ${pf.emoji} ${pf.uiLabel.padEnd(15)} ${dim(pf.desc)}${active}${recommended}`);
419
434
  }
420
435
  console.log(` ${bold('[q]')} Cancel`);
421
436
  console.log('');
@@ -3,6 +3,8 @@ import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
3
3
  import { createHash } from 'crypto';
4
4
  import { dirname, resolve, join } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
+ import { classifyRisk, extractPaths } from './risk-classifier.mjs';
7
+ import { checkFailureLoop } from './failure-detector.mjs';
6
8
 
7
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
10
  const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
@@ -12,11 +14,12 @@ const DRIFT_STATE = resolve(__dirname, '.drift-warned');
12
14
  function loadProfile() {
13
15
  try {
14
16
  const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
15
- return data.active || 'balanced';
16
- } catch { return 'balanced'; }
17
+ return data.active || 'auto';
18
+ } catch { return 'auto'; }
17
19
  }
18
20
 
19
21
  const PROFILE_SETTINGS = {
22
+ auto: { demote_think: false, promote_execute: false, bias: 0 },
20
23
  balanced: { demote_think: false, promote_execute: false, bias: 0 },
21
24
  'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
22
25
  'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
@@ -231,9 +234,9 @@ try {
231
234
  // Balance hint — populated after tier is fully resolved
232
235
  let balanceHint = null;
233
236
 
234
- // Helper to prepend optional warnings (duplicate + drift + balance) before a message
237
+ // Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
235
238
  const prependWarnings = (msg) => {
236
- const parts = [duplicateWarning, driftWarning, msg, balanceHint].filter(Boolean);
239
+ const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
237
240
  return parts.join('\n\n');
238
241
  };
239
242
 
@@ -277,6 +280,32 @@ try {
277
280
  else tier = 'execute';
278
281
  }
279
282
 
283
+ // Risk classification from file paths in description
284
+ const filePaths = extractPaths(ti.description || '');
285
+ const riskResult = classifyRisk(filePaths);
286
+ let autoStatus = null;
287
+
288
+ // Bias high/critical risk toward think tier
289
+ if ((riskResult.level === 'critical' || riskResult.level === 'high') && tier !== 'think') {
290
+ tier = 'think';
291
+ autoStatus = riskResult.level === 'critical'
292
+ ? `Dual-brain: dual-brain review recommended — ${riskResult.reason.split(':')[0]} detected`
293
+ : `Dual-brain: promoting to think tier — ${riskResult.reason.split(':')[0]}`;
294
+ }
295
+
296
+ // Failure loop detection
297
+ const failureCheck = checkFailureLoop(promptHash);
298
+ let failureMessage = null;
299
+ if (failureCheck.isLoop) {
300
+ if (failureCheck.suggestion === 'promote_tier' && tier === 'execute') {
301
+ tier = 'think';
302
+ autoStatus = 'Dual-brain: escalating to think tier — previous attempt failed';
303
+ } else if (failureCheck.suggestion === 'escalate_to_dual_brain') {
304
+ autoStatus = 'Dual-brain: dual-brain review recommended — repeated failures detected';
305
+ }
306
+ failureMessage = `**[Failure Loop]** ${failureCheck.count} failed attempts in 2hrs. Consider: \`node .claude/hooks/dual-brain-think.mjs --question "why is this failing?"\``;
307
+ }
308
+
280
309
  // Apply profile-driven tier adjustments
281
310
  if (profileSettings.demote_think && tier === 'think' && !THINK_WORDS.test(text)) {
282
311
  tier = 'execute';
@@ -312,7 +341,7 @@ try {
312
341
  followed: true,
313
342
  profile: profileName,
314
343
  });
315
- const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
344
+ const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
316
345
  if (onlyWarnings) {
317
346
  process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
318
347
  } else {
@@ -344,7 +373,7 @@ try {
344
373
  followed: true,
345
374
  profile: profileName,
346
375
  });
347
- const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
376
+ const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
348
377
  if (onlyWarnings) {
349
378
  process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
350
379
  } else {
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * failure-detector.mjs — Detects repeated failure loops for adaptive routing.
4
+ *
5
+ * Exports:
6
+ * checkFailureLoop(promptHash) → { isLoop, count, suggestion }
7
+ * recordFailure(promptHash, tier, reason) → void
8
+ */
9
+
10
+ import { readFileSync, appendFileSync } from 'fs';
11
+ import { dirname, join } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
16
+
17
+ function checkFailureLoop(promptHash) {
18
+ if (!promptHash) return { isLoop: false, count: 0, suggestion: null };
19
+
20
+ const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
21
+ let failures = 0;
22
+ let lastTier = null;
23
+
24
+ try {
25
+ const lines = readFileSync(LEDGER_FILE, 'utf8').split('\n').filter(Boolean);
26
+ for (const line of lines) {
27
+ try {
28
+ const entry = JSON.parse(line);
29
+ if (entry.prompt_hash !== promptHash) continue;
30
+ if (Date.parse(entry.timestamp) < twoHoursAgo) continue;
31
+ if (entry.success === false || entry.followed === false) {
32
+ failures++;
33
+ lastTier = entry.tier;
34
+ }
35
+ } catch {}
36
+ }
37
+ } catch {}
38
+
39
+ if (failures < 2) return { isLoop: false, count: failures, suggestion: null };
40
+
41
+ const suggestion = lastTier === 'execute'
42
+ ? 'promote_tier'
43
+ : 'escalate_to_dual_brain';
44
+
45
+ return { isLoop: true, count: failures, suggestion };
46
+ }
47
+
48
+ function recordFailure(promptHash, tier, reason) {
49
+ const entry = JSON.stringify({
50
+ type: 'failure',
51
+ timestamp: new Date().toISOString(),
52
+ prompt_hash: promptHash,
53
+ tier,
54
+ reason: reason || 'unknown',
55
+ success: false,
56
+ });
57
+ try {
58
+ appendFileSync(LEDGER_FILE, entry + '\n');
59
+ } catch {}
60
+ }
61
+
62
+ export { checkFailureLoop, recordFailure };
@@ -21,6 +21,26 @@ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
21
21
  const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
22
22
 
23
23
  const PROFILES = {
24
+ auto: {
25
+ description: 'Adapts routing based on task risk, provider health, and outcomes',
26
+ routing: {
27
+ prefer_provider: 'auto',
28
+ think_threshold: 'adaptive',
29
+ gpt_dispatch_bias: 0,
30
+ },
31
+ budgets: {
32
+ session_warn_usd: 5.00,
33
+ session_limit_usd: 10.00,
34
+ daily_warn_usd: 20.00,
35
+ daily_limit_usd: 50.00,
36
+ },
37
+ quality_gate: {
38
+ sensitivity_floor: 'medium',
39
+ dual_brain_minimum: 'high',
40
+ },
41
+ tier_overrides: null,
42
+ },
43
+
24
44
  balanced: {
25
45
  description: 'Auto-routes by complexity, uses both providers evenly',
26
46
  routing: {
@@ -106,12 +126,12 @@ function loadConfig() {
106
126
 
107
127
  function getActiveProfile() {
108
128
  const saved = loadProfileFile();
109
- const name = saved?.active || 'balanced';
110
- const profile = PROFILES[name] || PROFILES.balanced;
129
+ const name = saved?.active || 'auto';
130
+ const profile = PROFILES[name] || PROFILES.auto;
111
131
  const customOverrides = saved?.custom_overrides || {};
112
132
 
113
133
  return {
114
- name: PROFILES[name] ? name : 'balanced',
134
+ name: PROFILES[name] ? name : 'auto',
115
135
  ...profile,
116
136
  budgets: { ...profile.budgets, ...customOverrides.budgets },
117
137
  routing: { ...profile.routing, ...customOverrides.routing },
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * risk-classifier.mjs — File-path risk classification for adaptive routing.
4
+ *
5
+ * Export: classifyRisk(paths) → { level, reason }
6
+ */
7
+
8
+ const PATTERNS = [
9
+ { level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate|cert[s]?|\.pem|\.key)\b/i, label: 'security-sensitive' },
10
+ { level: 'high', regex: /\b(billing|payment|migration|deploy|ci[-/]cd|\.github\/workflows|security|permission|policy|schema\.prisma|schema\.sql|api[-_]?contract|openapi|swagger)\b/i, label: 'high-impact infrastructure' },
11
+ { level: 'medium', regex: /\b(test|spec|\.test\.|\.spec\.|shared|util[s]?|lib\/|public[-_]?api|integrat|config|\.config\.)\b/i, label: 'shared/tested code' },
12
+ { level: 'low', regex: /\b(readme|\.md$|docs?\/|comment|format|lint|\.prettierrc|local[-_]?script|internal[-_]?only|changelog)\b/i, label: 'docs/formatting' },
13
+ ];
14
+
15
+ const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
16
+
17
+ function classifyRisk(paths) {
18
+ if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
19
+
20
+ let highest = { level: 'low', reason: 'no matching risk patterns' };
21
+
22
+ for (const p of paths) {
23
+ for (const pattern of PATTERNS) {
24
+ if (pattern.regex.test(p) && LEVEL_ORDER[pattern.level] > LEVEL_ORDER[highest.level]) {
25
+ highest = { level: pattern.level, reason: `${pattern.label}: ${p}` };
26
+ if (pattern.level === 'critical') return highest;
27
+ }
28
+ }
29
+ }
30
+
31
+ return highest;
32
+ }
33
+
34
+ function extractPaths(text) {
35
+ if (!text) return [];
36
+ const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
37
+ if (!matches) return [];
38
+ return matches.map(m => m.trim().replace(/^["'`]/, ''));
39
+ }
40
+
41
+ export { classifyRisk, extractPaths };
@@ -57,6 +57,14 @@ function emptySummary() {
57
57
  token_averages: {},
58
58
 
59
59
  codex_latencies: [],
60
+
61
+ session_insights: {
62
+ gpt_latency_status: 'normal',
63
+ provider_override_count: 0,
64
+ failure_domains: [],
65
+ dual_brain_useful: false,
66
+ balance_posture: 'no activity yet',
67
+ },
60
68
  };
61
69
  }
62
70
 
@@ -199,6 +207,16 @@ function getTokenAverages(date) {
199
207
  return summary.token_averages;
200
208
  }
201
209
 
210
+ function updateSessionInsight(key, value, date) {
211
+ const validKeys = ['gpt_latency_status', 'provider_override_count', 'failure_domains', 'dual_brain_useful', 'balance_posture'];
212
+ if (!validKeys.includes(key)) return;
213
+ const summary = readSummary(date);
214
+ if (!summary.session_insights) summary.session_insights = {};
215
+ summary.session_insights[key] = value;
216
+ summary.updated_at = new Date().toISOString();
217
+ atomicWrite(summaryPath(date), summary);
218
+ }
219
+
202
220
  function getAdaptiveCodexThreshold(date) {
203
221
  const summary = readSummary(date);
204
222
  const latencies = summary.codex_latencies || [];
@@ -227,5 +245,6 @@ export {
227
245
  getPressureBuckets,
228
246
  getTokenAverages,
229
247
  getAdaptiveCodexThreshold,
248
+ updateSessionInsight,
230
249
  atomicWrite,
231
250
  };
package/install.mjs CHANGED
@@ -336,6 +336,7 @@ function install(workspace, env, mode) {
336
336
  'install-git-hooks.mjs', 'session-report.mjs', 'budget-balancer.mjs',
337
337
  'gpt-work-dispatcher.mjs', 'profiles.mjs',
338
338
  'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
339
+ 'risk-classifier.mjs', 'failure-detector.mjs',
339
340
  ];
340
341
  for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
341
342
  actions.push(`✓ ${HOOKS.length} hook scripts`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Dual-provider orchestration for Claude Code — tiered routing, budget balancing, and GPT dual-brain review across Claude + OpenAI subscriptions",
5
5
  "type": "module",
6
6
  "bin": {