dual-brain 3.5.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, standard alerts' },
44
- 'cost-saver': { emoji: '💸', uiLabel: 'Cost-saver', desc: 'Prefers cheaper models, tighter alerts' },
45
- 'quality-first': { emoji: '💎', uiLabel: 'Quality-first', desc: 'Uses best models, dual-brain for medium+ risk' },
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' },
45
+ 'cost-saver': { emoji: '🛡️', uiLabel: 'Conservative', desc: 'Fewer GPT dispatches, sticks to Claude for most work' },
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
 
@@ -245,14 +247,48 @@ function countRunning() {
245
247
  return { claude, codex };
246
248
  }
247
249
 
248
- // ─── Cost Alert Label ─────────────────────────────────────────────────────
250
+ // ─── Provider Balance ─────────────────────────────────────────────────────
249
251
 
250
- function costAlertLabel(profile) {
251
- if (profile.hasCustomBudget) return 'Custom';
252
- if (profile.name === 'balanced') return 'Default';
253
- if (profile.name === 'cost-saver') return 'Tight';
254
- if (profile.name === 'quality-first') return 'Relaxed';
255
- return 'Default';
252
+ function loadProviderBalance() {
253
+ const today = new Date().toISOString().slice(0, 10);
254
+ const logFile = join(__dirname, `usage-${today}.jsonl`);
255
+ if (!existsSync(logFile)) return { claude: 0, openai: 0, total: 0, label: 'No activity yet' };
256
+
257
+ let claude = 0, openai = 0;
258
+ try {
259
+ const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
260
+ for (const line of lines) {
261
+ try {
262
+ const e = JSON.parse(line);
263
+ if (e.provider === 'claude') claude++;
264
+ else if (e.provider === 'openai') openai++;
265
+ } catch {}
266
+ }
267
+ } catch {}
268
+
269
+ const total = claude + openai;
270
+ if (total === 0) return { claude: 0, openai: 0, total: 0, label: 'No activity yet' };
271
+
272
+ const claudePct = Math.round((claude / total) * 100);
273
+ const openaiPct = 100 - claudePct;
274
+
275
+ let label;
276
+ if (openaiPct === 0) label = 'Claude only — GPT subscription unused';
277
+ else if (claudePct === 0) label = 'GPT only — Claude subscription unused';
278
+ else if (Math.abs(claudePct - openaiPct) <= 20) label = 'Well balanced';
279
+ else if (claudePct > openaiPct) label = `Claude-heavy — GPT has capacity`;
280
+ else label = `GPT-heavy — Claude has capacity`;
281
+
282
+ return { claude: claudePct, openai: openaiPct, total, label };
283
+ }
284
+
285
+ function balanceBar(claudePct, openaiPct, width = 20) {
286
+ if (claudePct === 0 && openaiPct === 0) return dim('░'.repeat(width) + ' no activity');
287
+ const cFill = Math.round((claudePct / 100) * width);
288
+ const oFill = width - cFill;
289
+ const cBar = noColor ? '█'.repeat(cFill) : `\x1b[38;5;208m${'█'.repeat(cFill)}\x1b[0m`;
290
+ const oBar = noColor ? '▓'.repeat(oFill) : `\x1b[32m${'▓'.repeat(oFill)}\x1b[0m`;
291
+ return `${cBar}${oBar} ${orange(claudePct + '%')} Claude · ${green(openaiPct + '%')} GPT`;
256
292
  }
257
293
 
258
294
  // ─── Menu Renderers ───────────────────────────────────────────────────────
@@ -314,16 +350,33 @@ function renderReturningMenu(providers, sessions) {
314
350
  const profile = loadProfile();
315
351
  const pf = PROFILES[profile.name];
316
352
  const running = countRunning();
353
+ const balance = loadProviderBalance();
317
354
  const lines = [];
318
355
 
319
356
  lines.push('');
320
357
  lines.push(` 🧠 ${bold(`Dual-Brain v${VERSION}`)}`);
321
358
  lines.push('');
322
359
 
323
- // Compact provider + mode line
360
+ // Provider status
324
361
  const cStat = providers.claude.authed ? '✅' : '⚠️';
325
362
  const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
326
- 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)}`);
376
+
377
+ // Provider balance bar
378
+ lines.push(` ${balanceBar(balance.claude, balance.openai)}`);
379
+ if (balance.total > 0) lines.push(` ${dim(balance.label + ' · ' + balance.total + ' calls today')}`);
327
380
 
328
381
  // Recent sessions
329
382
  if (sessions.length > 0) {
@@ -350,7 +403,6 @@ function renderReturningMenu(providers, sessions) {
350
403
  if (sessions.length > 0) lines.push(` ${bold('[1-9]')} Resume numbered above`);
351
404
  lines.push(` ${bold('[n]')} New session`);
352
405
  lines.push(` ${bold('[p]')} Mode: ${dim(pf.uiLabel)}`);
353
- lines.push(` ${bold('[b]')} Cost alerts: ${dim(costAlertLabel(profile))}`);
354
406
 
355
407
  // Auth if needed
356
408
  if (!providers.claude.authed) lines.push(` ${bold('[j]')} Sign in to Claude`);
@@ -368,12 +420,17 @@ function renderReturningMenu(providers, sessions) {
368
420
  function showProfilePicker(rl) {
369
421
  return new Promise((resolve) => {
370
422
  const current = loadProfile();
423
+ const balance = loadProviderBalance();
371
424
  console.log('');
372
- console.log(` ${bold('Switch mode:')}`);
425
+ console.log(` ${bold('Switch routing mode:')}`);
426
+ if (balance.total > 0) {
427
+ console.log(` ${dim('Current balance: Claude ' + balance.claude + '% / GPT ' + balance.openai + '% · ' + balance.label)}`);
428
+ }
373
429
  console.log('');
374
430
  for (const [i, [name, pf]] of Object.entries(PROFILES).entries()) {
375
431
  const active = name === current.name ? ' ✅' : '';
376
- 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}`);
377
434
  }
378
435
  console.log(` ${bold('[q]')} Cancel`);
379
436
  console.log('');
@@ -396,49 +453,7 @@ function showProfilePicker(rl) {
396
453
  });
397
454
  }
398
455
 
399
- // ─── Cost Alert Editor ────────────────────────────────────────────────────
400
-
401
- function showCostAlertEditor(rl) {
402
- return new Promise((resolve) => {
403
- const profile = loadProfile();
404
- console.log('');
405
- console.log(` ${bold('Cost alerts')}`);
406
- console.log(` ${dim('Dual-brain estimates API costs from session activity.')}`);
407
- console.log(` ${dim('These are alerts, not billing caps.')}`);
408
- console.log('');
409
- console.log(` Current: warn at $${profile.budgets.session_warn_usd}/session, $${profile.budgets.daily_warn_usd}/day`);
410
- console.log(` limit at $${profile.budgets.session_limit_usd}/session, $${profile.budgets.daily_limit_usd}/day`);
411
- console.log('');
412
-
413
- rl.question(' Session alert limit ($, Enter = keep): ', (sessionStr) => {
414
- if (!sessionStr.trim()) return resolve();
415
- const session = parseFloat(sessionStr);
416
- if (isNaN(session) || session <= 0) { console.log(' Cancelled.'); return resolve(); }
417
-
418
- rl.question(' Daily alert limit ($, Enter = auto): ', (dailyStr) => {
419
- const daily = parseFloat(dailyStr);
420
- const finalDaily = (isNaN(daily) || daily <= 0) ? session * 3 : daily;
421
-
422
- let existing = {};
423
- try { existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8')); } catch {}
424
- const custom = existing.custom_overrides || {};
425
- custom.budgets = {
426
- session_warn_usd: +(session * 0.6).toFixed(2),
427
- session_limit_usd: session,
428
- daily_warn_usd: +(finalDaily * 0.6).toFixed(2),
429
- daily_limit_usd: finalDaily,
430
- };
431
- const data = { active: existing.active || 'balanced', switched_at: existing.switched_at || new Date().toISOString(), custom_overrides: custom };
432
- const tmp = PROFILE_FILE + '.tmp.' + process.pid;
433
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
434
- renameSync(tmp, PROFILE_FILE);
435
-
436
- console.log(` ✅ Cost alerts: $${session}/session · $${finalDaily}/day`);
437
- resolve();
438
- });
439
- });
440
- });
441
- }
456
+ // (Cost alert editor removed — replaced by provider balance + mode switching)
442
457
 
443
458
  // ─── Session Runner ───────────────────────────────────────────────────────
444
459
 
@@ -514,11 +529,6 @@ async function mainLoop() {
514
529
  continue;
515
530
  }
516
531
 
517
- if (choice === 'b') {
518
- await showCostAlertEditor(rl);
519
- continue;
520
- }
521
-
522
532
  if (choice === 'j') {
523
533
  console.log('');
524
534
  console.log(' Starting Claude login...');
@@ -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,8 +21,28 @@ 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
- description: 'Standard routing best model for each tier, normal budgets',
45
+ description: 'Auto-routes by complexity, uses both providers evenly',
26
46
  routing: {
27
47
  prefer_provider: 'auto',
28
48
  think_threshold: 'normal',
@@ -42,7 +62,7 @@ const PROFILES = {
42
62
  },
43
63
 
44
64
  'cost-saver': {
45
- description: 'Minimize spend prefer cheaper models, skip GPT for low risk',
65
+ description: 'Conservativefewer GPT dispatches, sticks to Claude',
46
66
  routing: {
47
67
  prefer_provider: 'cheapest',
48
68
  think_threshold: 'strict',
@@ -65,7 +85,7 @@ const PROFILES = {
65
85
  },
66
86
 
67
87
  'quality-first': {
68
- description: 'Maximum quality — dual-brain for medium+, stricter reviews',
88
+ description: 'Aggressivemaximizes both subscriptions, dual-brain for medium+',
69
89
  routing: {
70
90
  prefer_provider: 'most-capable',
71
91
  think_threshold: 'relaxed',
@@ -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
@@ -57,10 +57,10 @@ if (flag('--help') || flag('-h')) {
57
57
  --json Output detection as JSON
58
58
  --help Show this help
59
59
 
60
- 🎛️ Profiles:
61
- ⚖️ balanced Standard routing best model per tier
62
- 💸 cost-saver Minimize spend prefer cheaper models
63
- 💎 quality-first Maximum quality dual-brain for medium+
60
+ 🎛️ Routing modes:
61
+ ⚖️ Default Auto-routes, uses both providers evenly
62
+ 🛡️ Conservative Fewer GPT dispatches, sticks to Claude
63
+ 🚀 Aggressive Maximizes both subscriptions, dual-brain for medium+
64
64
 
65
65
  🚀 Examples:
66
66
  ${cmd('npx dual-brain')} # install or update
@@ -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`);
@@ -424,19 +425,19 @@ function profilePath(workspace) {
424
425
 
425
426
  const PROFILES = {
426
427
  balanced: {
427
- description: 'Standard routing best model for each tier, normal budgets',
428
+ description: 'Auto-routes by complexity, uses both providers evenly',
428
429
  routing: { prefer_provider: 'auto', think_threshold: 'normal', gpt_dispatch_bias: 0 },
429
430
  budgets: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
430
431
  quality_gate: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
431
432
  },
432
433
  'cost-saver': {
433
- description: 'Minimize spend prefer cheaper models, skip GPT for low risk',
434
+ description: 'Conservativefewer GPT dispatches, sticks to Claude',
434
435
  routing: { prefer_provider: 'cheapest', think_threshold: 'strict', gpt_dispatch_bias: -20 },
435
436
  budgets: { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
436
437
  quality_gate: { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
437
438
  },
438
439
  'quality-first': {
439
- description: 'Maximum quality — dual-brain for medium+, stricter reviews',
440
+ description: 'Aggressivemaximizes both subscriptions, dual-brain for medium+',
440
441
  routing: { prefer_provider: 'most-capable', think_threshold: 'relaxed', gpt_dispatch_bias: 10 },
441
442
  budgets: { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
442
443
  quality_gate: { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
@@ -490,16 +491,18 @@ function cmdMode() {
490
491
 
491
492
  if (!modeArg || modeArg === 'list') {
492
493
  const current = loadProfile(workspace);
493
- const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '💸', 'quality-first': '💎' };
494
+ const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
495
+ const UI_NAMES = { balanced: 'Default', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
494
496
  console.log('');
495
- console.log(' 🎛️ Profiles:');
497
+ console.log(' 🎛️ Routing modes:');
496
498
  console.log('');
497
499
  for (const [name, p] of Object.entries(PROFILES)) {
498
500
  const active = name === current.name ? ' ✅ active' : '';
499
- console.log(` ${PEMOJIS[name] || ' '} ${name.padEnd(15)} ${p.description}${active}`);
501
+ const label = UI_NAMES[name] || name;
502
+ console.log(` ${PEMOJIS[name] || ' '} ${label.padEnd(15)} ${p.description}${active}`);
500
503
  }
501
504
  console.log('');
502
- console.log(` Switch: ${cmd('npx dual-brain mode <profile>')}`);
505
+ console.log(` Switch: ${cmd('npx dual-brain mode <name>')}`);
503
506
  console.log('');
504
507
  return;
505
508
  }
@@ -522,9 +525,10 @@ function cmdMode() {
522
525
 
523
526
  saveProfile(workspace, modeArg, customOverrides);
524
527
 
525
- const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '💸', 'quality-first': '💎' };
528
+ const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
529
+ const UI_NAMES = { balanced: 'Default', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
526
530
  console.log('');
527
- console.log(` ✅ Profile switched: ${PEMOJIS[modeArg] || ''} ${modeArg}`);
531
+ console.log(` ✅ Mode switched: ${PEMOJIS[modeArg] || ''} ${UI_NAMES[modeArg] || modeArg}`);
528
532
  console.log(` ${profile.description}`);
529
533
  console.log('');
530
534
  console.log(' 🧭 Routing changes:');
@@ -547,12 +551,12 @@ function cmdBudget() {
547
551
  if (sessionArg == null) {
548
552
  const profile = loadProfile(workspace);
549
553
  console.log('');
550
- console.log(' 💵 Current budget:');
551
- console.log(` Session: ⚠️ $${profile.budgets.session_warn_usd} warn · 🛑 $${profile.budgets.session_limit_usd} limit`);
552
- console.log(` Daily: ⚠️ $${profile.budgets.daily_warn_usd} warn · 🛑 $${profile.budgets.daily_limit_usd} limit`);
554
+ console.log(' 📊 Usage alert thresholds (estimated, not billing caps):');
555
+ console.log(` Session: ⚠️ $${profile.budgets.session_warn_usd} warn · 🛑 $${profile.budgets.session_limit_usd} alert`);
556
+ console.log(` Daily: ⚠️ $${profile.budgets.daily_warn_usd} warn · 🛑 $${profile.budgets.daily_limit_usd} alert`);
553
557
  console.log('');
554
- console.log(` Set limits: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
555
- console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
558
+ console.log(` Adjust: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
559
+ console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
556
560
  console.log('');
557
561
  return;
558
562
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "3.5.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": {