dual-brain 3.0.1 → 3.2.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/install.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * npx dual-brain --dry-run # detect only, don't install
9
9
  * npx dual-brain --help
10
10
  */
11
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
11
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
12
12
  import { dirname, join, resolve } from 'path';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { spawnSync } from 'child_process';
@@ -16,6 +16,12 @@ import { spawnSync } from 'child_process';
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  const VERSION = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version;
18
18
 
19
+ // ─── Replit Detection ──────────────────────────────────────────────────────
20
+
21
+ const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
22
+
23
+ function cmd(s) { return IS_REPLIT ? `! ${s}` : s; }
24
+
19
25
  // ─── CLI ────────────────────────────────────────────────────────────────────
20
26
 
21
27
  const argv = process.argv.slice(2);
@@ -23,27 +29,53 @@ const flag = (f) => argv.includes(f);
23
29
  const force = flag('--force');
24
30
  const dryRun = flag('--dry-run');
25
31
  const jsonOut = flag('--json');
32
+ const positional = argv.filter(a => !a.startsWith('-'));
33
+ const subcommand = positional[0] || null;
34
+
35
+ if (flag('--version') || flag('-v')) {
36
+ console.log(`dual-brain v${VERSION}`);
37
+ process.exit(0);
38
+ }
26
39
 
27
40
  if (flag('--help') || flag('-h')) {
28
41
  console.log(`
29
42
  dual-brain v${VERSION} — Dual-provider orchestrator for Claude Code
30
43
 
31
- Usage: npx -y dual-brain [options]
44
+ Usage: npx -y dual-brain [command] [options]
45
+
46
+ Commands:
47
+ (none) Auto-detect and install/update orchestrator
48
+ status Live view of mode, spend, pressure, profile
49
+ mode Show or switch profile (balanced, cost-saver, quality-first)
50
+ budget Set session/daily spend limits
51
+ explain Show why the last routing decision was made
52
+ init Alias for default install (backward compat)
32
53
 
33
54
  Options:
34
55
  --force Overwrite all existing config (keeps review-rules.md)
35
56
  --dry-run Detect environment only, don't install
36
57
  --json Output detection as JSON (implies --dry-run)
37
58
  --help Show this help
59
+
60
+ Profiles:
61
+ balanced Standard routing — best model for each tier
62
+ cost-saver Minimize spend — prefer cheaper models
63
+ quality-first Maximum quality — dual-brain for medium+ risk
64
+
65
+ Examples:
66
+ ${cmd('npx dual-brain')} # install or update
67
+ ${cmd('npx dual-brain status')} # live dashboard
68
+ ${cmd('npx dual-brain mode cost-saver')} # switch profile
69
+ ${cmd('npx dual-brain budget 8 25')} # $8 session / $25 daily
70
+ ${cmd('npx dual-brain explain')} # last routing decision
38
71
  `);
39
72
  process.exit(0);
40
73
  }
41
74
 
42
- // Silently accept 'init' for backward compat
43
- const positional = argv.filter(a => !a.startsWith('-'));
44
- if (positional.length > 0 && positional[0] !== 'init') {
45
- console.error(` Unknown command: ${positional[0]}`);
46
- console.error(' Run: npx dual-brain --help');
75
+ const SUBCOMMANDS = ['init', 'status', 'mode', 'budget', 'explain'];
76
+ if (subcommand && !SUBCOMMANDS.includes(subcommand)) {
77
+ console.error(` Unknown command: ${subcommand}`);
78
+ console.error(` Run: ${cmd('npx dual-brain --help')}`);
47
79
  process.exit(1);
48
80
  }
49
81
 
@@ -240,7 +272,20 @@ function generateSettings(workspace) {
240
272
  ],
241
273
  };
242
274
 
243
- return { ...existing, hooks };
275
+ const DUAL_BRAIN_CMDS = [
276
+ 'node .claude/hooks/enforce-tier.mjs',
277
+ 'node .claude/hooks/cost-logger.mjs',
278
+ ];
279
+
280
+ const merged = { ...(existing.hooks || {}) };
281
+ for (const [event, entries] of Object.entries(hooks)) {
282
+ const existingEntries = (merged[event] || []).filter(e =>
283
+ !e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
284
+ );
285
+ merged[event] = [...existingEntries, ...entries];
286
+ }
287
+
288
+ return { ...existing, hooks: merged };
244
289
  }
245
290
 
246
291
  function generateClaudeMd(mode) {
@@ -265,6 +310,9 @@ function generateGitignoreEntries(workspace) {
265
310
  '.claude/reviews/',
266
311
  '.claude/hooks/.drift-warned',
267
312
  '.claude/hooks/.budget-alerted',
313
+ '.claude/dual-brain.profile.json',
314
+ '.claude/hooks/usage-summary-*.json',
315
+ '.claude/hooks/decision-ledger.jsonl',
268
316
  ];
269
317
  let existing = '';
270
318
  try { existing = readFileSync(join(workspace, '.gitignore'), 'utf8'); } catch {}
@@ -285,7 +333,8 @@ function install(workspace, env, mode) {
285
333
  'dual-brain-review.mjs', 'dual-brain-think.mjs', 'quality-gate.mjs',
286
334
  'test-orchestrator.mjs', 'setup-wizard.mjs', 'health-check.mjs',
287
335
  'install-git-hooks.mjs', 'session-report.mjs', 'budget-balancer.mjs',
288
- 'gpt-work-dispatcher.mjs',
336
+ 'gpt-work-dispatcher.mjs', 'profiles.mjs',
337
+ 'summary-checkpoint.mjs', 'decision-ledger.mjs',
289
338
  ];
290
339
  for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
291
340
  actions.push(`✓ ${HOOKS.length} hook scripts`);
@@ -322,7 +371,7 @@ function install(workspace, env, mode) {
322
371
  if (needed.length > 0) {
323
372
  writeFileSync(
324
373
  join(workspace, '.gitignore'),
325
- gi + '\n# Dual-Brain Orchestrator\n' + needed.join('\n') + '\n'
374
+ (gi && !gi.endsWith('\n') ? gi + '\n' : gi) + '\n# Dual-Brain Orchestrator\n' + needed.join('\n') + '\n'
326
375
  );
327
376
  actions.push('✓ .gitignore updated');
328
377
  }
@@ -407,7 +456,19 @@ function printReport(env, mode, actions) {
407
456
  console.log(' Both Claude and GPT are available as work providers.');
408
457
  }
409
458
  console.log('');
410
- console.log(' Try these in your next Claude Code session:');
459
+ if (IS_REPLIT) {
460
+ console.log(' Try these in your Replit shell (paste with ! prefix):');
461
+ console.log(` ${cmd('npx dual-brain status')} # live dashboard`);
462
+ console.log(` ${cmd('npx dual-brain mode cost-saver')} # switch profile`);
463
+ console.log(` ${cmd('npx dual-brain budget 8 25')} # set limits`);
464
+ } else {
465
+ console.log(' Try these in your next Claude Code session:');
466
+ console.log(' npx dual-brain status # live dashboard');
467
+ console.log(' npx dual-brain mode cost-saver # switch profile');
468
+ console.log(' npx dual-brain budget 8 25 # set limits');
469
+ }
470
+ console.log('');
471
+ console.log(' In-session tools (ask Claude to run these):');
411
472
  console.log(' node .claude/hooks/health-check.mjs # verify setup');
412
473
  console.log(' node .claude/hooks/cost-report.mjs # see activity');
413
474
  console.log(' node .claude/hooks/budget-balancer.mjs # provider balance');
@@ -422,9 +483,324 @@ function printReport(env, mode, actions) {
422
483
  }
423
484
  }
424
485
 
486
+ // ─── Profile System ────────────────────────────────────────────────────────
487
+
488
+ const PROFILE_FILE_REL = '.claude/dual-brain.profile.json';
489
+
490
+ function profilePath(workspace) {
491
+ return join(workspace || process.cwd(), PROFILE_FILE_REL);
492
+ }
493
+
494
+ const PROFILES = {
495
+ balanced: {
496
+ description: 'Standard routing — best model for each tier, normal budgets',
497
+ routing: { prefer_provider: 'auto', think_threshold: 'normal', gpt_dispatch_bias: 0 },
498
+ budgets: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
499
+ quality_gate: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
500
+ },
501
+ 'cost-saver': {
502
+ description: 'Minimize spend — prefer cheaper models, skip GPT for low risk',
503
+ routing: { prefer_provider: 'cheapest', think_threshold: 'strict', gpt_dispatch_bias: -20 },
504
+ budgets: { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
505
+ quality_gate: { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
506
+ },
507
+ 'quality-first': {
508
+ description: 'Maximum quality — dual-brain for medium+, stricter reviews',
509
+ routing: { prefer_provider: 'most-capable', think_threshold: 'relaxed', gpt_dispatch_bias: 10 },
510
+ budgets: { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
511
+ quality_gate: { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
512
+ },
513
+ };
514
+
515
+ function loadProfile(workspace) {
516
+ try {
517
+ const data = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
518
+ const name = data.active && PROFILES[data.active] ? data.active : 'balanced';
519
+ const profile = PROFILES[name];
520
+ const custom = data.custom_overrides || {};
521
+ return {
522
+ name,
523
+ ...profile,
524
+ budgets: { ...profile.budgets, ...custom.budgets },
525
+ routing: { ...profile.routing, ...custom.routing },
526
+ switched_at: data.switched_at || null,
527
+ };
528
+ } catch {
529
+ return { name: 'balanced', ...PROFILES.balanced, switched_at: null };
530
+ }
531
+ }
532
+
533
+ function saveProfile(workspace, name, customOverrides) {
534
+ const data = { active: name, switched_at: new Date().toISOString() };
535
+ if (customOverrides) data.custom_overrides = customOverrides;
536
+ const target = profilePath(workspace);
537
+ const tmp = target + '.tmp.' + process.pid;
538
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
539
+ renameSync(tmp, target);
540
+ }
541
+
542
+ // ─── Subcommand: status ────────────────────────────────────────────────────
543
+
544
+ function cmdStatus() {
545
+ const workspace = resolve(process.cwd());
546
+ const env = detectEnvironment();
547
+ const mode = resolveMode(env);
548
+ const profile = loadProfile(workspace);
549
+
550
+ const lines = [];
551
+ lines.push(br('╔', '╗'));
552
+ lines.push(ln(`Dual-Brain Status — v${VERSION}`));
553
+ lines.push(sep());
554
+
555
+ lines.push(ln(`Mode: ${MODE_LABELS[mode.mode]}`));
556
+ lines.push(ln(`Profile: ${profile.name}`));
557
+ lines.push(ln(` ${PROFILES[profile.name]?.description || ''}`));
558
+ if (profile.switched_at) {
559
+ lines.push(ln(` Set: ${profile.switched_at.slice(0, 16).replace('T', ' ')}`));
560
+ }
561
+
562
+ lines.push(sep());
563
+
564
+ lines.push(ln('Budget Limits'));
565
+ lines.push(ln(` Session: warn $${profile.budgets.session_warn_usd} / limit $${profile.budgets.session_limit_usd}`));
566
+ lines.push(ln(` Daily: warn $${profile.budgets.daily_warn_usd} / limit $${profile.budgets.daily_limit_usd}`));
567
+
568
+ lines.push(sep());
569
+
570
+ lines.push(ln('Providers'));
571
+ const cAuth = env.claude.authed ? 'authenticated' : 'not authenticated';
572
+ const xAuth = env.codex.authed ? 'authenticated' : env.codex.installed ? 'not authenticated' : 'not found';
573
+ lines.push(ln(` Claude: ${statusIcon(env.claude.authed)} ${cAuth}`));
574
+ lines.push(ln(` Codex: ${statusIcon(env.codex.authed)} ${xAuth}`));
575
+
576
+ lines.push(sep());
577
+
578
+ lines.push(ln('Quality Gate'));
579
+ lines.push(ln(` Reviews from: ${profile.quality_gate.sensitivity_floor} risk+`));
580
+ lines.push(ln(` Dual-brain at: ${profile.quality_gate.dual_brain_minimum} risk+`));
581
+
582
+ const balancer = join(workspace, '.claude', 'hooks', 'budget-balancer.mjs');
583
+ if (existsSync(balancer)) {
584
+ const proc = run(process.execPath, [balancer]);
585
+ if (proc.status === 0 && proc.stdout.trim()) {
586
+ lines.push(sep());
587
+ lines.push(ln('Provider Pressure (5hr rolling)'));
588
+ for (const l of proc.stdout.trim().split('\n')) {
589
+ if (l.includes('█') || l.includes('░') || l.includes('Recommendation')) {
590
+ const cleaned = l.replace(/[║╔╗╠╣╚╝═]/g, '').trim();
591
+ if (cleaned) lines.push(ln(` ${cleaned}`));
592
+ }
593
+ }
594
+ }
595
+ }
596
+
597
+ lines.push(br('╚', '╝'));
598
+
599
+ console.log('');
600
+ for (const l of lines) console.log(` ${l}`);
601
+ console.log('');
602
+
603
+ if (IS_REPLIT) {
604
+ console.log(' Quick actions (paste into shell):');
605
+ console.log(` ${cmd('npx dual-brain mode cost-saver')} # switch profile`);
606
+ console.log(` ${cmd('npx dual-brain budget 8 25')} # set limits`);
607
+ console.log('');
608
+ }
609
+ }
610
+
611
+ // ─── Subcommand: mode ──────────────────────────────────────────────────────
612
+
613
+ function cmdMode() {
614
+ const workspace = resolve(process.cwd());
615
+ const modeArg = positional[1] || null;
616
+
617
+ if (!modeArg || modeArg === 'list') {
618
+ const current = loadProfile(workspace);
619
+ console.log('');
620
+ console.log(' Available profiles:');
621
+ console.log('');
622
+ for (const [name, p] of Object.entries(PROFILES)) {
623
+ const active = name === current.name ? ' ← active' : '';
624
+ console.log(` ${name.padEnd(15)} ${p.description}${active}`);
625
+ }
626
+ console.log('');
627
+ console.log(` Switch: ${cmd('npx dual-brain mode <profile>')}`);
628
+ console.log('');
629
+ return;
630
+ }
631
+
632
+ if (!PROFILES[modeArg]) {
633
+ console.error(` Unknown profile: ${modeArg}`);
634
+ console.error(` Available: ${Object.keys(PROFILES).join(', ')}`);
635
+ process.exit(1);
636
+ }
637
+
638
+ const profile = PROFILES[modeArg];
639
+
640
+ let customOverrides = null;
641
+ try {
642
+ const existing = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
643
+ if (existing.custom_overrides?.budgets) {
644
+ customOverrides = { budgets: existing.custom_overrides.budgets };
645
+ }
646
+ } catch {}
647
+
648
+ saveProfile(workspace, modeArg, customOverrides);
649
+
650
+ console.log('');
651
+ console.log(` Profile switched to: ${modeArg}`);
652
+ console.log(` ${profile.description}`);
653
+ console.log('');
654
+ console.log(' What changed:');
655
+ console.log(` Routing: ${profile.routing.prefer_provider}`);
656
+ console.log(` Budget: $${profile.budgets.session_limit_usd}/session, $${profile.budgets.daily_limit_usd}/day`);
657
+ console.log(` Reviews from: ${profile.quality_gate.sensitivity_floor} risk+`);
658
+ console.log(` Dual-brain: ${profile.quality_gate.dual_brain_minimum} risk+`);
659
+ console.log('');
660
+ console.log(' Active immediately — no restart needed.');
661
+ console.log('');
662
+ }
663
+
664
+ // ─── Subcommand: budget ────────────────────────────────────────────────────
665
+
666
+ function cmdBudget() {
667
+ const workspace = resolve(process.cwd());
668
+ const sessionArg = positional[1] ? parseFloat(positional[1]) : null;
669
+ const dailyArg = positional[2] ? parseFloat(positional[2]) : null;
670
+
671
+ if (sessionArg == null) {
672
+ const profile = loadProfile(workspace);
673
+ console.log('');
674
+ console.log(' Current budget limits:');
675
+ console.log(` Session: warn $${profile.budgets.session_warn_usd} / limit $${profile.budgets.session_limit_usd}`);
676
+ console.log(` Daily: warn $${profile.budgets.daily_warn_usd} / limit $${profile.budgets.daily_limit_usd}`);
677
+ console.log('');
678
+ console.log(` Set limits: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
679
+ console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
680
+ console.log('');
681
+ return;
682
+ }
683
+
684
+ if (isNaN(sessionArg) || sessionArg <= 0) {
685
+ console.error(' Session limit must be a positive number');
686
+ process.exit(1);
687
+ }
688
+
689
+ const daily = (dailyArg != null && !isNaN(dailyArg) && dailyArg > 0) ? dailyArg : sessionArg * 3;
690
+
691
+ let existing = {};
692
+ try { existing = JSON.parse(readFileSync(profilePath(workspace), 'utf8')); } catch {}
693
+
694
+ const customOverrides = existing.custom_overrides || {};
695
+ customOverrides.budgets = {
696
+ session_warn_usd: +(sessionArg * 0.6).toFixed(2),
697
+ session_limit_usd: sessionArg,
698
+ daily_warn_usd: +(daily * 0.6).toFixed(2),
699
+ daily_limit_usd: daily,
700
+ };
701
+
702
+ const data = {
703
+ active: existing.active || 'balanced',
704
+ switched_at: existing.switched_at || new Date().toISOString(),
705
+ custom_overrides: customOverrides,
706
+ };
707
+ const budgetTarget = profilePath(workspace);
708
+ const budgetTmp = budgetTarget + '.tmp.' + process.pid;
709
+ writeFileSync(budgetTmp, JSON.stringify(data, null, 2) + '\n');
710
+ renameSync(budgetTmp, budgetTarget);
711
+
712
+ console.log('');
713
+ console.log(' Budget limits updated:');
714
+ console.log(` Session: warn $${customOverrides.budgets.session_warn_usd} / limit $${sessionArg}`);
715
+ console.log(` Daily: warn $${customOverrides.budgets.daily_warn_usd} / limit $${daily}`);
716
+ console.log('');
717
+ console.log(' Active immediately — no restart needed.');
718
+ console.log('');
719
+ }
720
+
721
+ // ─── Subcommand: explain ───────────────────────────────────────────────────
722
+
723
+ function cmdExplain() {
724
+ const workspace = resolve(process.cwd());
725
+ const hooksDir = join(workspace, '.claude', 'hooks');
726
+ const today = new Date().toISOString().slice(0, 10);
727
+ const logFile = join(hooksDir, `usage-${today}.jsonl`);
728
+
729
+ if (!existsSync(logFile)) {
730
+ console.log('');
731
+ console.log(' No routing decisions recorded today.');
732
+ console.log(' Start a Claude Code session and the tier enforcer will log decisions.');
733
+ console.log('');
734
+ return;
735
+ }
736
+
737
+ let lines;
738
+ try {
739
+ lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
740
+ } catch {
741
+ console.log(' Could not read usage log.');
742
+ return;
743
+ }
744
+
745
+ let lastRec = null;
746
+ for (let i = lines.length - 1; i >= 0; i--) {
747
+ try {
748
+ const entry = JSON.parse(lines[i]);
749
+ if (entry.type === 'tier_recommendation') { lastRec = entry; break; }
750
+ } catch {}
751
+ }
752
+
753
+ if (!lastRec) {
754
+ console.log('');
755
+ console.log(' No routing decisions found in today\'s log.');
756
+ console.log(' The tier enforcer logs decisions when Agent tool is used.');
757
+ console.log('');
758
+ return;
759
+ }
760
+
761
+ const profile = loadProfile(workspace);
762
+
763
+ console.log('');
764
+ console.log(' Last Routing Decision');
765
+ console.log(' ' + '─'.repeat(40));
766
+ console.log(` Time: ${lastRec.timestamp?.slice(11, 19) || 'unknown'}`);
767
+ console.log(` Detected: ${lastRec.detected_tier || 'unknown'} tier`);
768
+ console.log(` Recommended: ${lastRec.recommended_model || 'unknown'}`);
769
+ console.log(` Actual: ${lastRec.actual_model || 'unknown'}`);
770
+ console.log(` Followed: ${lastRec.followed ? 'yes' : 'no'}`);
771
+ console.log(` Profile: ${profile.name}`);
772
+ console.log('');
773
+
774
+ if (!lastRec.followed) {
775
+ console.log(' The recommendation was not followed. This may mean:');
776
+ console.log(' - The task needed a different model (valid override)');
777
+ console.log(' - The subagent_type forced a specific tier');
778
+ console.log(` - Profile "${profile.name}" adjusted the threshold`);
779
+ } else {
780
+ console.log(' The recommendation was followed — routing worked as expected.');
781
+ }
782
+
783
+ let total = 0, followed = 0;
784
+ for (const line of lines) {
785
+ try {
786
+ const e = JSON.parse(line);
787
+ if (e.type === 'tier_recommendation') { total++; if (e.followed) followed++; }
788
+ } catch {}
789
+ }
790
+ const pct = total > 0 ? Math.round((followed / total) * 100) : 0;
791
+ console.log('');
792
+ console.log(` Today: ${followed}/${total} recommendations followed (${pct}%)`);
793
+ console.log('');
794
+ }
795
+
425
796
  // ─── Main ───────────────────────────────────────────────────────────────────
426
797
 
427
798
  function main() {
799
+ if (subcommand === 'status') { cmdStatus(); return; }
800
+ if (subcommand === 'mode') { cmdMode(); return; }
801
+ if (subcommand === 'budget') { cmdBudget(); return; }
802
+ if (subcommand === 'explain') { cmdExplain(); return; }
803
+
428
804
  const env = detectEnvironment();
429
805
  const mode = resolveMode(env);
430
806
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "3.0.1",
3
+ "version": "3.2.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": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "install.mjs",
30
- "hooks/",
30
+ "hooks/*.mjs",
31
31
  "orchestrator.json",
32
32
  "hookify.*.local.md",
33
33
  "review-rules.md",