dual-brain 7.1.4 → 7.1.6

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  This project uses dual-provider orchestration. Config: `.claude/orchestrator.json`.
4
4
 
5
- ## Core Architecture (v6)
5
+ ## Core Architecture (v7)
6
6
 
7
7
  Four modules in `src/` form the decision pipeline:
8
8
 
@@ -71,21 +71,21 @@ Dual-brain is a multi-round conversation between Claude and GPT — not a single
71
71
  ## Quality Gate
72
72
 
73
73
  Before ending a session with code changes:
74
- 1. Run `node .claude/hooks/session-report.mjs`
75
- 2. Run `node .claude/hooks/quality-gate.mjs`
74
+ 1. `node .claude/hooks/session-report.mjs` (allowed by head-guard for hook scripts)
75
+ 2. `node .claude/hooks/quality-gate.mjs`
76
76
 
77
77
  Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (GPT unavailable).
78
78
 
79
79
  ## Profiles
80
80
 
81
- Profile persists to `.claude/dual-brain.profile.json` (gitignored).
81
+ Profile persists to `.dualbrain/profile.json` (project-scoped, gitignored).
82
82
 
83
83
  - **auto** (default): Adapts routing based on task risk, provider health, and outcomes
84
84
  - **balanced**: Best model per tier, normal budgets, reviews at medium+ risk
85
85
  - **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
86
86
  - **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
87
87
 
88
- Switch: `dual-brain mode cost-saver` · Natural language aliases work: "be careful", "cheap mode", "thorough"
88
+ Switch via the interactive Profile screen in `dual-brain`, or set `bias` in `.dualbrain/profile.json`.
89
89
 
90
90
  ## Adaptive Routing (Auto Mode)
91
91
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ dual-brain detects the intent and risk of your task, picks the best model based
24
24
 
25
25
  ### `dual-brain init`
26
26
 
27
- First-time setup. Three questions: which providers you have, subscription tiers, and optimization preference.
27
+ First-time setup. Three questions: which providers you have, subscription tiers, and optimization preference. The actual flow auto-detects existing auth and adapts — you may see fewer prompts if credentials are already configured.
28
28
 
29
29
  ```
30
30
  Dual-Brain Orchestrator — First-time setup
@@ -121,7 +121,7 @@ Preferences are stored in `.dualbrain/profile.json` and applied on every `go` in
121
121
  import { orchestrate } from 'dual-brain';
122
122
 
123
123
  const result = await orchestrate({ prompt: "fix the bug", cwd: "." });
124
- console.log(result.summary);
124
+ console.log(result.result?.summary);
125
125
  ```
126
126
 
127
127
  Individual modules are also exported:
@@ -174,7 +174,7 @@ For Claude Code users, a hooks layer provides deeper integration. Hooks fire on
174
174
 
175
175
  ```bash
176
176
  # Install hooks into .claude/settings.json
177
- npx -y dual-brain
177
+ npx dual-brain install
178
178
  ```
179
179
 
180
180
  The installer auto-detects your environment (Claude CLI, Codex CLI, Replit), registers `enforce-tier.mjs` and `cost-logger.mjs` hooks, and writes `orchestrator.json` with your subscription config. Re-run anytime — it's idempotent.
@@ -65,10 +65,8 @@ Commands:
65
65
  forget "preference" Remove a preference by fuzzy match
66
66
 
67
67
  Interactive mode (entered with no args on a TTY):
68
- Shows dashboard screen with menu-driven navigation.
69
- [g] Go dispatch a task
70
- [s] Status, [p] Profile, [a] Auth, [d] Diagnostics
71
- [c] Command mode (REPL), [q] Exit
68
+ Session manager with recent sessions and routing.
69
+ [n] New session, [c] Continue last, [1-9] Resume, [s] Settings, [q] Exit
72
70
 
73
71
  Options:
74
72
  --version Print version
@@ -155,6 +153,9 @@ async function cmdInit(rl) {
155
153
  const profile = await runOnboarding({ interactive: true, detectedAuth: auth, rl });
156
154
  saveProfile(profile, { cwd });
157
155
 
156
+ // --- Step 2b: Install hooks so enforcement is active from first run ---
157
+ await cmdInstall(cwd);
158
+
158
159
  // --- Step 3: Show dashboard ---
159
160
  console.log('');
160
161
  const repo = loadRepoCache(cwd);
@@ -280,6 +281,27 @@ async function cmdGo(args) {
280
281
  }, cwd);
281
282
  } else {
282
283
  result = await dispatch({ decision, prompt, files, cwd });
284
+ if (result.status === 'completed' && result.type === 'native-agent') {
285
+ const nd = result.nativeDispatch || {};
286
+ const promptPreview = (nd.prompt || prompt).slice(0, 100);
287
+ const promptSuffix = (nd.prompt || prompt).length > 100 ? '...' : '';
288
+ console.log(`\nRouted: ${decision.provider}/${nd.model || decision.model} (${decision.tier})`);
289
+ console.log('To dispatch, use the Agent tool with:');
290
+ console.log(` model: ${nd.model || decision.model}`);
291
+ console.log(` prompt: ${promptPreview}${promptSuffix}`);
292
+ if (nd.isolation) console.log(` isolation: ${nd.isolation}`);
293
+ if (nd.maxTurns) console.log(` maxTurns: ${nd.maxTurns}`);
294
+ saveSession({
295
+ objective: prompt,
296
+ branch: null,
297
+ filesChanged: files,
298
+ commandsRun: [`dual-brain go "${prompt}"`],
299
+ lastResult: { status: 'success', summary: `native-agent routed to ${nd.model || decision.model}` },
300
+ provider: decision.provider,
301
+ nextAction: null,
302
+ }, cwd);
303
+ return;
304
+ }
283
305
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
284
306
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
285
307
  if (result.summary) console.log(result.summary);
@@ -443,7 +465,7 @@ async function cmdStatus(args = []) {
443
465
 
444
466
  const PROVIDER_MODEL_CLASSES = {
445
467
  claude: ['haiku', 'sonnet', 'opus'],
446
- openai: ['o4-mini', 'o3', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-5.4', 'gpt-5.5'],
468
+ openai: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5'],
447
469
  };
448
470
 
449
471
  function cmdHot(providerArg) {
@@ -466,8 +488,8 @@ function cmdCool(providerArg) {
466
488
  console.log(`Cleared hot state for all ${provider} model classes.`);
467
489
  }
468
490
 
469
- async function cmdInstall() {
470
- const cwd = process.cwd();
491
+ async function cmdInstall(cwd) {
492
+ if (!cwd) cwd = process.cwd();
471
493
 
472
494
  // Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
473
495
  const { spawnSync } = await import('child_process');
@@ -486,8 +508,6 @@ async function cmdInstall() {
486
508
  console.log(`Enforcement hooks already present (${skipped.length}):`);
487
509
  for (const item of skipped) console.log(` = ${item}`);
488
510
  }
489
-
490
- process.exit(0);
491
511
  }
492
512
 
493
513
  function cmdRemember(text) {
@@ -559,7 +579,8 @@ async function welcomeScreen(rl, ask) {
559
579
  } else {
560
580
  // Enter or anything else → save and go to dashboard
561
581
  saveProfile(setup.profile, { cwd });
562
- return { next: 'dashboard' };
582
+ await cmdInstall(cwd);
583
+ return { next: 'main' };
563
584
  }
564
585
  } else {
565
586
  // Not confident — show what's missing before falling through to wizard
@@ -685,109 +706,209 @@ async function welcomeScreen(rl, ask) {
685
706
  console.log(box('Setup Complete', summaryLines));
686
707
  console.log('');
687
708
 
688
- return { next: 'dashboard' };
709
+ await cmdInstall(cwd);
710
+
711
+ return { next: 'main' };
689
712
  }
690
713
 
691
- // ─── Screen: dashboardScreen ──────────────────────────────────────────────────
714
+ // ─── Screen: mainScreen ───────────────────────────────────────────────────────
692
715
 
693
- async function dashboardScreen(rl, ask) {
716
+ async function mainScreen(rl, ask) {
694
717
  const cwd = process.cwd();
695
718
  const version = readVersion();
696
719
  const profile = loadProfile(cwd);
697
720
  const auth = await detectAuth();
698
- const env = detectEnvironment();
699
721
 
700
- // Build status lines for box
701
- // If auth is found but provider is disabled in profile, show warning instead of green
702
- const claudeProviderEnabled = profile?.providers?.claude?.enabled !== false;
703
- const openaiProviderEnabled = profile?.providers?.openai?.enabled !== false;
704
- const claudeStatus = auth.claude.found
705
- ? (claudeProviderEnabled ? `🟢 Claude ${badge('connected')}` : `⚠️ Claude ${badge('warning')} disabled`)
706
- : `🔴 Claude ${badge('missing')}`;
707
- const openaiStatus = auth.openai.found
708
- ? (openaiProviderEnabled ? `🟢 OpenAI ${badge('connected')}` : `⚠️ OpenAI ${badge('warning')} disabled`)
709
- : `🔴 OpenAI ${badge('missing')}`;
710
- const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : env.isReplit ? 'Replit' : 'local';
711
-
712
- // Enforcement check
722
+ const claudePlan = profile?.providers?.claude?.plan ?? 'Pro';
723
+ const openaiPlan = profile?.providers?.openai?.plan ?? 'Plus';
724
+ const claudeStatus = auth.claude.found ? `Claude: ${claudePlan} ✓` : `Claude: missing`;
725
+ const openaiStatus = auth.openai.found ? `OpenAI: ${openaiPlan} ✓` : `OpenAI: missing`;
726
+
727
+ console.log(`\ndual-brain v${version}`);
728
+ console.log(`${claudeStatus} · ${openaiStatus}\n`);
729
+
730
+ const recentSessions = importReplitSessions(cwd).slice(0, 5);
731
+
732
+ if (recentSessions.length > 0) {
733
+ console.log('Recent:');
734
+ recentSessions.forEach((sess, i) => {
735
+ const activeIndicator = sess.isActive ? ' ●' : '';
736
+ console.log(` [${i + 1}] ${sess.age.padEnd(6)} ${sess.name}${activeIndicator}`);
737
+ });
738
+ console.log('');
739
+ }
740
+
741
+ console.log(' [c] Continue last session');
742
+ console.log(' [n] New session');
743
+ if (recentSessions.length > 0) {
744
+ console.log(' [1-9] Resume numbered above');
745
+ }
746
+ console.log(' [d] Switch to data-tools');
747
+ if (!auth.claude.found) console.log(' [j] Login to Claude');
748
+ if (!auth.openai.found) console.log(' [k] Login to Codex');
749
+ console.log(' [s] Settings [q] Exit');
750
+ console.log('');
751
+
752
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
753
+
754
+ if (choice === 'n') { return { next: 'new-session' }; }
755
+
756
+ if (choice === 'c') {
757
+ const sessions = importReplitSessions(cwd);
758
+ if (sessions.length === 0) {
759
+ console.log('\n No recent sessions found.\n');
760
+ await ask(' Press Enter to continue...');
761
+ return { next: 'main' };
762
+ }
763
+ const { spawnSync } = await import('node:child_process');
764
+ console.log(`\n Launching: claude --resume ${sessions[0].id}\n`);
765
+ spawnSync('claude', ['--resume', sessions[0].id], { stdio: 'inherit' });
766
+ return { next: 'main' };
767
+ }
768
+
769
+ const numChoice = parseInt(choice, 10);
770
+ if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
771
+ const sess = recentSessions[numChoice - 1];
772
+ const { spawnSync } = await import('node:child_process');
773
+ console.log(`\n Launching: claude --resume ${sess.id}\n`);
774
+ spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
775
+ return { next: 'main' };
776
+ }
777
+
778
+ if (choice === 'd') {
779
+ const { spawnSync } = await import('node:child_process');
780
+ const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
781
+ if (which.status === 0) {
782
+ spawnSync('claude-menu', { stdio: 'inherit' });
783
+ } else {
784
+ console.log('\n data-tools not found — install with: npm i -g replit-tools\n');
785
+ await ask(' Press Enter to continue...');
786
+ }
787
+ return { next: 'main' };
788
+ }
789
+
790
+ if (choice === 'j') {
791
+ const { spawnSync } = await import('node:child_process');
792
+ spawnSync('claude', ['login'], { stdio: 'inherit' });
793
+ return { next: 'main' };
794
+ }
795
+
796
+ if (choice === 'k') {
797
+ const { spawnSync } = await import('node:child_process');
798
+ spawnSync('codex', ['login'], { stdio: 'inherit' });
799
+ return { next: 'main' };
800
+ }
801
+
802
+ if (choice === 's') { return { next: 'settings' }; }
803
+ if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
804
+
805
+ return { next: 'main' };
806
+ }
807
+
808
+ // ─── Screen: newSessionScreen ─────────────────────────────────────────────────
809
+
810
+ async function newSessionScreen(rl, ask) {
811
+ const cwd = process.cwd();
812
+ const input = (await ask('\n What do you want to do? ')).trim();
813
+ if (!input) { return { next: 'main' }; }
814
+
815
+ const profile = loadProfile(cwd);
816
+ const detection = detectTask({ prompt: input });
817
+ const decision = decideRoute({ profile, detection, cwd });
818
+
819
+ console.log(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})`);
820
+ console.log(` Reason: ${decision.explanation}\n`);
821
+
822
+ const { spawnSync } = await import('node:child_process');
823
+ if (decision.provider === 'openai') {
824
+ spawnSync('codex', [input], { stdio: 'inherit' });
825
+ } else {
826
+ spawnSync('claude', ['-p', input], { stdio: 'inherit' });
827
+ }
828
+
829
+ return { next: 'main' };
830
+ }
831
+
832
+ // ─── Screen: settingsScreen ───────────────────────────────────────────────────
833
+
834
+ async function settingsScreen(rl, ask) {
835
+ const cwd = process.cwd();
836
+ const profile = loadProfile(cwd);
837
+ const auth = await detectAuth();
838
+
713
839
  let guardCount = 0;
714
840
  try {
715
841
  const settingsFile = join(cwd, '.claude', 'settings.json');
716
842
  if (existsSync(settingsFile)) {
717
843
  const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
718
844
  const preToolUse = settings?.hooks?.PreToolUse ?? [];
719
- const guardCmd = 'node .claude/hooks/head-guard.mjs';
720
- const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
721
- const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
722
- const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
723
- const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
724
- const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
845
+ const guardCmd = 'node .claude/hooks/head-guard.mjs';
846
+ const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
847
+ const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
848
+ const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
849
+ const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
850
+ const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
725
851
  guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
726
852
  }
727
853
  } catch { /* ignore */ }
728
854
 
729
- const authSummary = (auth.claude.found && auth.openai.found)
730
- ? 'both providers connected'
731
- : auth.claude.found
732
- ? 'Claude connected, OpenAI missing'
733
- : auth.openai.found
734
- ? 'OpenAI connected, Claude missing'
735
- : 'no providers connected';
736
-
737
- const dashLines = [
738
- `${claudeStatus} ${openaiStatus}`,
739
- `🌀 ${envLabel}`,
855
+ const modeLabel = (m) => m === profile.mode ? `${m} (active)` : m;
856
+
857
+ const settingsLines = [
858
+ `Mode:`,
859
+ ` [1] ${modeLabel('cost-saver')}`,
860
+ ` [2] ${modeLabel('balanced')}`,
861
+ ` [3] ${modeLabel('quality-first')}`,
862
+ '',
863
+ `Auth:`,
864
+ ` Claude: ${auth.claude.found ? `connected (${auth.claude.source})` : 'missing'}`,
865
+ ` OpenAI: ${auth.openai.found ? `connected (${auth.openai.source})` : 'missing'}`,
740
866
  '',
741
- `✓ Profile: ${profile.mode} · ${profile.providers?.claude?.enabled && profile.providers?.openai?.enabled ? 'dual' : 'solo'} mode`,
742
- `✓ Enforcement: ${guardCount} guards active`,
743
- `✓ Auth: ${authSummary}`,
867
+ `Enforcement: ${guardCount}/4 guards active`,
744
868
  ];
745
869
 
746
- console.log(box(`🧠 Dual-Brain v${version}`, dashLines));
747
870
  console.log('');
748
-
749
- // ── Recent Sessions (replit-tools import) ──────────────────────────────────
750
- const recentSessions = importReplitSessions(cwd).slice(0, 5);
751
- if (recentSessions.length > 0) {
752
- console.log(separator('Recent Sessions'));
753
- recentSessions.forEach((sess, i) => {
754
- const activeIndicator = sess.isActive ? '●' : ' ';
755
- const promptsLabel = `(${sess.promptCount} prompt${sess.promptCount !== 1 ? 's' : ''})`;
756
- console.log(` [${i + 1}] ${sess.age.padEnd(6)} ${activeIndicator} ${sess.name} ${promptsLabel}`);
757
- });
758
- console.log('');
759
- }
760
-
871
+ console.log(box('Settings', settingsLines));
872
+ console.log('');
761
873
  console.log(menu([
762
- { key: 's', label: 'Status detailed provider info', section: 'Info' },
763
- { key: 'p', label: 'Profile & preferences', section: 'Settings' },
764
- { key: 'a', label: 'Auth management', section: 'Settings' },
765
- { key: 'd', label: 'Diagnostics & repair', section: 'Settings' },
766
- { key: 'q', label: 'Exit to shell', section: '' },
874
+ { key: '1', label: 'Switch to cost-saver', section: 'Mode' },
875
+ { key: '2', label: 'Switch to balanced', section: 'Mode' },
876
+ { key: '3', label: 'Switch to quality-first', section: 'Mode' },
877
+ { key: 'a', label: 'Add API key', section: 'Auth' },
878
+ { key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
879
+ { key: 'b', label: 'Back', section: '' },
767
880
  ]));
768
881
  console.log('');
769
882
 
770
883
  const choice = (await ask(' Choice: ')).trim().toLowerCase();
771
884
 
772
- // Numeric choice session detail
773
- const numChoice = parseInt(choice, 10);
774
- if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
775
- return { next: 'session-detail', session: recentSessions[numChoice - 1] };
885
+ if (choice === '1' || choice === '2' || choice === '3') {
886
+ const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
887
+ profile.mode = modeMap[choice];
888
+ saveProfile(profile, { cwd });
889
+ console.log(` Mode set to: ${profile.mode}`);
890
+ return { next: 'settings' };
776
891
  }
777
892
 
778
- if (choice === 's') {
779
- await cmdStatus([]);
780
- await ask('\n Press Enter to return to dashboard...');
781
- return { next: 'dashboard' };
893
+ if (choice === 'a') {
894
+ await setupAuth(rl);
895
+ return { next: 'settings' };
782
896
  }
783
897
 
784
- if (choice === 'p') { return { next: 'profile' }; }
785
- if (choice === 'a') { return { next: 'auth' }; }
786
- if (choice === 'd') { return { next: 'diagnostics' }; }
787
- if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
898
+ if (choice === 'i') {
899
+ await cmdInstall();
900
+ return { next: 'settings' };
901
+ }
788
902
 
789
- // Unknown choice stay on dashboard
790
- return { next: 'dashboard' };
903
+ if (choice === 'b' || choice === 'back') { return { next: 'main' }; }
904
+
905
+ return { next: 'settings' };
906
+ }
907
+
908
+ // ─── Screen: dashboardScreen (kept for internal reference, unreachable) ───────
909
+
910
+ async function dashboardScreen(rl, ask) {
911
+ return { next: 'main' };
791
912
  }
792
913
 
793
914
  // ─── Screen: authScreen ───────────────────────────────────────────────────────
@@ -1278,12 +1399,15 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
1278
1399
  // ─── Screen state machine ─────────────────────────────────────────────────────
1279
1400
 
1280
1401
  const SCREENS = {
1281
- welcome: welcomeScreen,
1282
- dashboard: dashboardScreen,
1283
- auth: authScreen,
1284
- profile: profileScreen,
1285
- diagnostics: diagnosticsScreen,
1286
- repl: replScreen,
1402
+ welcome: welcomeScreen,
1403
+ main: mainScreen,
1404
+ 'new-session': newSessionScreen,
1405
+ settings: settingsScreen,
1406
+ dashboard: dashboardScreen,
1407
+ auth: authScreen,
1408
+ profile: profileScreen,
1409
+ diagnostics: diagnosticsScreen,
1410
+ repl: replScreen,
1287
1411
  'session-detail': sessionDetailScreen,
1288
1412
  };
1289
1413
 
@@ -1303,7 +1427,7 @@ async function runScreens(startScreen = 'dashboard') {
1303
1427
  ctx = result?.session ? { session: result.session } : {};
1304
1428
  } catch (e) {
1305
1429
  console.error(`Error: ${e.message}`);
1306
- current = 'dashboard'; // recover to dashboard on error
1430
+ current = 'main';
1307
1431
  ctx = {};
1308
1432
  }
1309
1433
  }
@@ -1326,8 +1450,7 @@ async function main() {
1326
1450
  if (isInteractive) {
1327
1451
  const cwd = process.cwd();
1328
1452
  if (profileExists(cwd)) {
1329
- // Profile already exists → go straight to dashboard
1330
- await runScreens('dashboard');
1453
+ await runScreens('main');
1331
1454
  } else {
1332
1455
  // First run: welcomeScreen handles auto-setup detection internally,
1333
1456
  // then falls through to manual wizard if needed.
@@ -15,7 +15,15 @@
15
15
 
16
16
  import { readFileSync } from 'fs';
17
17
 
18
- const BLOCKED_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
18
+ const BLOCKED_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
19
+
20
+ // Patterns that indicate a Bash command is writing/mutating the filesystem.
21
+ // Anchored to avoid false positives on grep/find output containing these words.
22
+ const WRITE_BASH_RE = /\brm\b|\bmv\b|\bcp\b|\bmkdir\b|\btouch\b|\bchmod\b|\bchown\b|\bdd\b|\binstall\b|\btruncate\b|\btee\b|\bsed\s+-i\b|\bawk\s+-i\b|>>|(?<![><])>(?![>=])/;
23
+
24
+ function isBashWriteIntent(command) {
25
+ return WRITE_BASH_RE.test(command);
26
+ }
19
27
 
20
28
  // Read stdin JSON payload
21
29
  let input;
@@ -23,9 +31,16 @@ try {
23
31
  const raw = readFileSync('/dev/stdin', 'utf8');
24
32
  input = JSON.parse(raw);
25
33
  } catch {
26
- // If we can't read / parse input, fail open don't break sessions
27
- // that aren't using dual-brain at all.
28
- process.exit(0);
34
+ // Can't parse input fail closed to avoid guard bypass.
35
+ const output = {
36
+ hookSpecificOutput: {
37
+ hookEventName: 'PreToolUse',
38
+ permissionDecision: 'deny',
39
+ permissionDecisionReason: '[dual-brain] head-guard could not parse hook input — blocking as a safety measure.',
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(output));
43
+ process.exit(2);
29
44
  }
30
45
 
31
46
  const toolName = input.tool_name || '';
@@ -50,9 +65,30 @@ if (BLOCKED_TOOLS.has(toolName)) {
50
65
  process.exit(2);
51
66
  }
52
67
 
53
- // Also block MCP filesystem write tools (any mcp__ tool with write/create/
54
- // delete/remove/move/rename in the name).
55
- if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename/i.test(toolName)) {
68
+ // Bash: allow read-only commands; block write-intent ones.
69
+ // Always allow node .claude/hooks/ and node hooks/ CLAUDE.md instructs HEAD to run these.
70
+ if (toolName === 'Bash') {
71
+ const command = (input.tool_input && input.tool_input.command) || '';
72
+ if (/^node\s+\.?(?:\.claude\/)?hooks\//.test(command.trimStart())) {
73
+ process.exit(0);
74
+ }
75
+ if (isBashWriteIntent(command)) {
76
+ const output = {
77
+ hookSpecificOutput: {
78
+ hookEventName: 'PreToolUse',
79
+ permissionDecision: 'deny',
80
+ permissionDecisionReason:
81
+ '[dual-brain] HEAD cannot run write-intent Bash commands. Dispatch via: dual-brain go "task description"',
82
+ },
83
+ };
84
+ process.stdout.write(JSON.stringify(output));
85
+ process.exit(2);
86
+ }
87
+ process.exit(0);
88
+ }
89
+
90
+ // Block MCP filesystem write tools by name.
91
+ if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename|append|patch|truncate|copy|commit|push|stage|merge|update|overwrite/i.test(toolName)) {
56
92
  const output = {
57
93
  hookSpecificOutput: {
58
94
  hookEventName: 'PreToolUse',
@@ -445,7 +445,7 @@ test('enforce-tier: cost-saver demotes think', () => {
445
445
  // cost-saver's demote_think=true demotes think→execute when text lacks think words
446
446
  const payload = JSON.stringify({
447
447
  tool_name: 'Agent',
448
- tool_input: { prompt: 'edit the README file', model: 'opus' },
448
+ tool_input: { prompt: '<!-- dual-brain-dispatch: test23 -->edit the README file', model: 'opus' },
449
449
  });
450
450
  const { parsed, status } = run(ENFORCE_TIER, payload);
451
451
  if (status !== 0) return `non-zero exit: ${status}`;
@@ -494,7 +494,7 @@ test('enforce-tier: auto profile with high-risk file', () => {
494
494
  // Description with auth/credentials path → risk classifier detects critical risk → promote to think
495
495
  const payload = JSON.stringify({
496
496
  tool_name: 'Agent',
497
- tool_input: { description: 'update src/auth/credentials.mjs', prompt: 'change the token logic', model: 'sonnet' },
497
+ tool_input: { description: 'update src/auth/credentials.mjs', prompt: '<!-- dual-brain-dispatch: test25 -->change the token logic', model: 'sonnet' },
498
498
  });
499
499
  const { parsed, status } = run(ENFORCE_TIER, payload);
500
500
  if (status !== 0) return `non-zero exit: ${status}`;
@@ -740,9 +740,9 @@ test('install: preserves existing hooks', () => {
740
740
  if (!installSrc.includes('.filter'))
741
741
  return 'install.mjs missing .filter() call — may clobber non-dual-brain hooks';
742
742
 
743
- // The merge logic should spread existingEntries first, then add dual-brain hooks
744
- if (!installSrc.includes('existingEntries'))
745
- return 'install.mjs missing existingEntries variable — may not preserve other hooks';
743
+ // The merge logic should filter existing hooks before merging dual-brain hooks
744
+ if (!installSrc.includes('existingPre') && !installSrc.includes('existingEntries'))
745
+ return 'install.mjs missing existing hook preservation — may not preserve other hooks';
746
746
 
747
747
  // Verify it reads existing settings before overwriting
748
748
  if (!installSrc.includes('existing') || !installSrc.includes('settings.json'))
@@ -1017,7 +1017,7 @@ test('adaptive loop: end-to-end hash match', () => {
1017
1017
  writeFileSync(LEDGER, '', 'utf8');
1018
1018
 
1019
1019
  // Step 1: Define a specific Agent payload used consistently across all steps
1020
- const toolInput = { prompt: 'fix the auth bug', description: 'patch auth module' };
1020
+ const toolInput = { prompt: '<!-- dual-brain-dispatch: test40 -->fix the auth bug', description: 'patch auth module' };
1021
1021
  const agentPayload = JSON.stringify({ tool_name: 'Agent', tool_input: toolInput });
1022
1022
 
1023
1023
  // Step 2: Run enforce-tier with this payload (computes and may log a promptHash)
@@ -253,7 +253,7 @@ async function handleRequest(msg) {
253
253
  return respond(id, {
254
254
  protocolVersion: '2024-11-05',
255
255
  capabilities: { tools: {} },
256
- serverInfo: { name: 'dual-brain', version: '7.1.0' },
256
+ serverInfo: { name: 'dual-brain', version: '7.1.4' },
257
257
  });
258
258
 
259
259
  case 'initialized':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.4",
3
+ "version": "7.1.6",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.0",
3
+ "version": "7.1.4",
4
4
  "description": "Dual-provider AI orchestration — smart routing between Claude and OpenAI",
5
5
  "skills": [
6
6
  { "name": "go", "description": "Route and dispatch a task", "file": "skills/go.md" },
package/src/decide.mjs CHANGED
@@ -71,10 +71,37 @@ const MODEL_CAPABILITIES = {
71
71
  effortLevels: ['low', 'medium', 'high'],
72
72
  costTier: 'medium',
73
73
  },
74
+ 'gpt-5.2': {
75
+ provider: 'openai',
76
+ tierFit: ['search', 'execute'],
77
+ contextWindow: 200_000,
78
+ costTier: 'medium',
79
+ strengths: ['code-generation', 'analysis'],
80
+ weaknesses: [],
81
+ effortLevels: null,
82
+ },
83
+ 'gpt-5.4-mini': {
84
+ provider: 'openai',
85
+ tierFit: ['search'],
86
+ contextWindow: 200_000,
87
+ costTier: 'low',
88
+ strengths: ['quick-tasks', 'search'],
89
+ weaknesses: ['complex-edits', 'architecture'],
90
+ effortLevels: null,
91
+ },
92
+ 'gpt-5.3-codex': {
93
+ provider: 'openai',
94
+ tierFit: ['execute'],
95
+ contextWindow: 200_000,
96
+ costTier: 'medium',
97
+ strengths: ['code-generation', 'refactoring'],
98
+ weaknesses: ['architecture', 'security'],
99
+ effortLevels: null,
100
+ },
74
101
  'gpt-5.4': {
75
102
  provider: 'openai',
76
103
  tierFit: ['execute', 'think'],
77
- contextWindow: 200_000,
104
+ contextWindow: 1_050_000,
78
105
  strengths: ['refactor', 'debug', 'code-generation', 'test'],
79
106
  weaknesses: ['cost'],
80
107
  effortLevels: ['low', 'medium', 'high', 'xhigh'],
@@ -83,7 +110,7 @@ const MODEL_CAPABILITIES = {
83
110
  'gpt-5.5': {
84
111
  provider: 'openai',
85
112
  tierFit: ['think'],
86
- contextWindow: 200_000,
113
+ contextWindow: 1_000_000,
87
114
  strengths: ['architecture', 'security', 'review', 'planning', 'complex-debug'],
88
115
  weaknesses: ['cost', 'latency'],
89
116
  effortLevels: ['low', 'medium', 'high', 'xhigh'],
@@ -264,16 +291,19 @@ function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
264
291
  }
265
292
  }
266
293
 
267
- function applyProfileBias(model, profile, provider, available) {
294
+ function applyProfileBias(model, profile, provider, available, tier) {
268
295
  const mode = profile?.mode || profile?.profile || 'auto';
269
296
  if (mode === 'cost-saver') {
270
- // Prefer cheapest available
297
+ // Prefer cheapest available that also fits the required tier
271
298
  const ranks = {
272
299
  claude: ['haiku', 'sonnet', 'opus'],
273
300
  openai: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5'],
274
301
  };
275
302
  for (const m of ranks[provider]) {
276
- if (available.includes(m)) return m;
303
+ if (!available.includes(m)) continue;
304
+ const caps = MODEL_CAPABILITIES[m];
305
+ if (tier && caps && !caps.tierFit.includes(tier)) continue;
306
+ return m;
277
307
  }
278
308
  }
279
309
  if (mode === 'quality-first') {
@@ -535,7 +565,7 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
535
565
  model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
536
566
 
537
567
  // Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
538
- model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider]);
568
+ model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
539
569
 
540
570
  // Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
541
571
  model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
package/src/dispatch.mjs CHANGED
@@ -647,13 +647,14 @@ async function dispatch(input = {}) {
647
647
  }
648
648
  _recordDispatchBudget(prompt);
649
649
  return {
650
- status: 'native-agent',
650
+ status: 'completed',
651
+ type: 'native-agent',
651
652
  provider: effectiveProvider,
652
653
  model: effectiveModel,
653
654
  command: null,
654
655
  nativeDispatch: nativeDescriptor,
655
- exitCode: null,
656
- summary: `[native] ${nativeDescriptor.description}`,
656
+ exitCode: 0,
657
+ summary: `Routed to ${effectiveProvider}/${effectiveModel} (${effectiveDecision.tier})`,
657
658
  durationMs: 0,
658
659
  usage: null,
659
660
  error: null,
package/src/index.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * orchestrate() convenience function for programmatic use.
7
7
  */
8
8
 
9
- export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, setupAuth, getActiveKey, removeAuthKey, disableKey, rotateToNextKey } from './profile.mjs';
9
+ export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, setupAuth, getActiveKey } from './profile.mjs';
10
10
  export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths } from './detect.mjs';
11
11
  export { decideRoute, getModelCapabilities, getAvailableModels, shouldDualBrain, explainDecision } from './decide.mjs';
12
12
  export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain } from './dispatch.mjs';
package/src/profile.mjs CHANGED
@@ -269,46 +269,13 @@ async function detectAuth() {
269
269
  const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
270
270
 
271
271
  /**
272
- * Migrate old single-key format to array format transparently.
273
- * Old: { claude: { key, savedAt, expiresAt }, openai: { ... } }
274
- * New: { claude: [{ key, label, savedAt, expiresAt, priority, enabled }], openai: [...] }
275
- * @param {object} auth
276
- * @returns {object} migrated auth object
277
- */
278
- function _migrateAuthFormat(auth) {
279
- const migrated = {};
280
- for (const [provider, value] of Object.entries(auth)) {
281
- if (Array.isArray(value)) {
282
- // Already new format
283
- migrated[provider] = value;
284
- } else if (value && typeof value === 'object' && value.key) {
285
- // Old single-key format — wrap in array
286
- migrated[provider] = [
287
- {
288
- key: value.key,
289
- label: 'primary',
290
- savedAt: value.savedAt || new Date().toISOString(),
291
- expiresAt: value.expiresAt || null,
292
- priority: 1,
293
- enabled: true,
294
- },
295
- ];
296
- } else {
297
- migrated[provider] = value;
298
- }
299
- }
300
- return migrated;
301
- }
302
-
303
- /**
304
- * Load .dualbrain/auth.json, migrating old single-key format to array format.
272
+ * Load .dualbrain/auth.json.
305
273
  * @param {string} [cwd]
306
274
  * @returns {object} auth object with arrays per provider
307
275
  */
308
276
  function loadAuthKeys(cwd) {
309
277
  try {
310
- const raw = JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
311
- return _migrateAuthFormat(raw);
278
+ return JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
312
279
  } catch {
313
280
  return {};
314
281
  }
@@ -380,73 +347,6 @@ function saveAuthKey(provider, key, opts = {}) {
380
347
  }
381
348
  }
382
349
 
383
- /**
384
- * Remove a key by index from the provider's array.
385
- * @param {string} provider
386
- * @param {number} index
387
- * @param {string} [cwd]
388
- */
389
- function removeAuthKey(provider, index, cwd) {
390
- const authFile = AUTH_FILE(cwd);
391
- const dir = dirname(authFile);
392
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
393
-
394
- const auth = loadAuthKeys(cwd);
395
- if (!Array.isArray(auth[provider])) return;
396
-
397
- auth[provider].splice(index, 1);
398
- writeFileSync(authFile, JSON.stringify(auth, null, 2));
399
- chmodSync(authFile, 0o600);
400
- }
401
-
402
- /**
403
- * Mark a key as enabled:false (used during failover when a key hits rate limits).
404
- * @param {string} provider
405
- * @param {number} index
406
- * @param {string} [cwd]
407
- */
408
- function disableKey(provider, index, cwd) {
409
- const authFile = AUTH_FILE(cwd);
410
- const dir = dirname(authFile);
411
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
412
-
413
- const auth = loadAuthKeys(cwd);
414
- if (!Array.isArray(auth[provider]) || !auth[provider][index]) return;
415
-
416
- auth[provider][index].enabled = false;
417
- writeFileSync(authFile, JSON.stringify(auth, null, 2));
418
- chmodSync(authFile, 0o600);
419
- }
420
-
421
- /**
422
- * Called when the active key hits a rate limit. Disables the current active key
423
- * temporarily and returns the next valid key, or null if none available.
424
- * @param {string} provider
425
- * @param {string} [cwd]
426
- * @returns {{ key: string, label: string, priority: number, enabled: boolean, expiresAt: string|null }|null}
427
- */
428
- function rotateToNextKey(provider, cwd) {
429
- const auth = loadAuthKeys(cwd);
430
- const keys = auth[provider] || [];
431
- const now = new Date();
432
-
433
- // Find current active key index
434
- const sortedValid = keys
435
- .map((k, i) => ({ ...k, _idx: i }))
436
- .filter(k => k.enabled)
437
- .filter(k => !k.expiresAt || new Date(k.expiresAt) > now)
438
- .sort((a, b) => (a.priority || 99) - (b.priority || 99));
439
-
440
- if (sortedValid.length === 0) return null;
441
-
442
- // Disable the current active key
443
- const currentIdx = sortedValid[0]._idx;
444
- disableKey(provider, currentIdx, cwd);
445
-
446
- // Reload and get the next valid key
447
- return getActiveKey(provider, cwd);
448
- }
449
-
450
350
  /**
451
351
  * Interactive setup flow: walks user through entering API keys for missing providers.
452
352
  * Accepts an existing readline Interface (rl) — does NOT close it.
@@ -974,6 +874,6 @@ export {
974
874
  detectPlans, syncPreferencesToMemory,
975
875
  detectAuth, detectEnvironment,
976
876
  setupAuth, saveAuthKey, loadAuthKeys,
977
- getActiveKey, removeAuthKey, disableKey, rotateToNextKey,
877
+ getActiveKey,
978
878
  defaultProfile, autoSetup,
979
879
  };