dual-brain 7.1.16 → 7.1.17

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // dual-brain — CLI entry point. Commands: init, go, status, remember, forget
3
3
 
4
- import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
4
+ import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
@@ -169,6 +169,14 @@ Commands:
169
169
  remember "preference" Save a project-scoped preference
170
170
  forget "preference" Remove a preference by fuzzy match
171
171
  search "keyword" Search across all sessions
172
+ specialists List available specialist agents with descriptions
173
+ python "task" Force Python specialist for the task
174
+ typescript "task" Force TypeScript specialist for the task
175
+ html "task" Force HTML/CSS specialist for the task
176
+ linux "task" Force Linux/DevOps specialist for the task
177
+ security "task" Force Security specialist for the task
178
+ --dry-run (specialist commands) Show routing without executing
179
+ --files a,b (specialist commands) Provide file context
172
180
  shell-hook Output bash snippet to add dual-brain to your shell
173
181
  Usage: dual-brain shell-hook >> ~/.bashrc
174
182
 
@@ -611,6 +619,53 @@ function cmdForget(text) {
611
619
  console.log('Preference removed (if matched).');
612
620
  }
613
621
 
622
+ function cmdBreakGlass(reason) {
623
+ if (!reason) err('Usage: dual-brain break-glass "reason"');
624
+ const cwd = process.cwd();
625
+ const dualbrain = join(cwd, '.dualbrain');
626
+ const tokenPath = join(dualbrain, 'break-glass.json');
627
+ const auditDir = join(dualbrain, 'audit');
628
+ const auditFile = join(auditDir, 'head-audit.jsonl');
629
+ const TTL_MINUTES = 5;
630
+
631
+ mkdirSync(dualbrain, { recursive: true });
632
+ mkdirSync(auditDir, { recursive: true });
633
+
634
+ const token = {
635
+ createdAt: Date.now(),
636
+ ttlMinutes: TTL_MINUTES,
637
+ reason,
638
+ };
639
+ writeFileSync(tokenPath, JSON.stringify(token, null, 2));
640
+
641
+ // Audit entry
642
+ const auditEntry = {
643
+ ts: new Date().toISOString(),
644
+ event: 'break-glass-activated',
645
+ reason,
646
+ ttlMinutes: TTL_MINUTES,
647
+ expiresAt: new Date(token.createdAt + TTL_MINUTES * 60 * 1000).toISOString(),
648
+ };
649
+ try {
650
+ appendFileSync(auditFile, JSON.stringify(auditEntry) + '\n');
651
+ } catch { /* non-fatal */ }
652
+
653
+ const width = 51;
654
+ const inner = width - 2;
655
+ const pad = (s) => ' ' + s + ' '.repeat(inner - 1 - s.length);
656
+ const reasonLine = `Reason: ${reason}`;
657
+ const expiresLine = `Expires: ${TTL_MINUTES} minutes`;
658
+ const auditLine = 'All tool calls logged to audit.';
659
+
660
+ console.log('┌' + '─'.repeat(inner) + '┐');
661
+ console.log('│' + pad('🔓 Break-Glass Activated') + '│');
662
+ console.log('├' + '─'.repeat(inner) + '┤');
663
+ console.log('│' + pad(reasonLine) + '│');
664
+ console.log('│' + pad(expiresLine) + '│');
665
+ console.log('│' + pad(auditLine) + '│');
666
+ console.log('└' + '─'.repeat(inner) + '┘');
667
+ }
668
+
614
669
  // ─── Screen helpers ───────────────────────────────────────────────────────────
615
670
 
616
671
  /**
@@ -1582,6 +1637,282 @@ async function subscriptionsScreen(rl, ask) {
1582
1637
  return { next: 'main' };
1583
1638
  }
1584
1639
 
1640
+ // ─── Onboarding Wizard ───────────────────────────────────────────────────────
1641
+
1642
+ /**
1643
+ * 5-step onboarding wizard shown on first run (no .dualbrain/profile.json).
1644
+ * Matches the rounded ┌─┐ box style used in mainScreen / renderHeader.
1645
+ * @param {{ auth, plans, existingSessions }} detection
1646
+ * @param {string} cwd
1647
+ * @param {object} rl readline interface
1648
+ * @returns {object|null} profile object to save, or null if cancelled/skipped
1649
+ */
1650
+ async function runOnboardingWizard(detection, cwd, rl) {
1651
+ const ask = (q) => new Promise(res => rl.question(q, res));
1652
+ const version = readVersion();
1653
+
1654
+ // ── Rounded box helpers (matching mainScreen style) ────────────────────────
1655
+ const W = 51;
1656
+ const wTop = ` ┌${'─'.repeat(W)}┐`;
1657
+ const wSep = ` ├${'─'.repeat(W)}┤`;
1658
+ const wBottom = ` └${'─'.repeat(W)}┘`;
1659
+ const wPad = (s) => {
1660
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
1661
+ let vlen = 0;
1662
+ for (const ch of plain) {
1663
+ const cp = ch.codePointAt(0);
1664
+ if (
1665
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
1666
+ (cp >= 0x2600 && cp <= 0x27bf) ||
1667
+ cp === 0xfe0f || cp === 0x20e3
1668
+ ) { vlen += 2; } else { vlen += 1; }
1669
+ }
1670
+ return s + ' '.repeat(Math.max(0, W - vlen));
1671
+ };
1672
+ const wRow = (s) => ` │ ${wPad(s)}│`;
1673
+
1674
+ // ── Collected wizard state ─────────────────────────────────────────────────
1675
+ const state = {
1676
+ claudePlan: null,
1677
+ openaiPlan: null,
1678
+ headModel: null,
1679
+ importSessions: false,
1680
+ profile: 'auto',
1681
+ };
1682
+
1683
+ const { auth, plans, existingSessions } = detection;
1684
+ const claudeReady = auth.claude.found;
1685
+ const openaiReady = auth.openai.found;
1686
+
1687
+ // ══════════════════════════════════════════════════════════════════════════
1688
+ // Step 1 — Welcome & provider detection
1689
+ // ══════════════════════════════════════════════════════════════════════════
1690
+ console.log('');
1691
+ console.log(wTop);
1692
+ console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1693
+ console.log(wSep);
1694
+ console.log(wRow(`Step 1 of 5: Detected providers`));
1695
+ console.log(wSep);
1696
+
1697
+ const claudePlanLabel = claudeReady
1698
+ ? (CLAUDE_PLAN_LABELS[plans.claude] ?? plans.claude ?? 'plan unknown')
1699
+ : null;
1700
+ const openaiPlanLabel = openaiReady
1701
+ ? (OPENAI_PLAN_LABELS[plans.openai] ?? plans.openai ?? 'plan unknown')
1702
+ : null;
1703
+
1704
+ console.log(wRow(claudeReady
1705
+ ? `✓ Claude CLI ${claudePlanLabel ? `(${claudePlanLabel})` : ''}`
1706
+ : `✗ Claude CLI not logged in`));
1707
+ console.log(wRow(openaiReady
1708
+ ? `✓ Codex CLI ${openaiPlanLabel ? `(${openaiPlanLabel})` : ''}`
1709
+ : `✗ Codex CLI not logged in`));
1710
+ if (existingSessions.length > 0) {
1711
+ console.log(wRow(`✓ ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''} found`));
1712
+ }
1713
+ console.log(wSep);
1714
+ console.log(wRow(`[Enter] Continue setup [s] Skip wizard`));
1715
+ console.log(wBottom);
1716
+ console.log('');
1717
+
1718
+ if (!claudeReady && !openaiReady) {
1719
+ console.log(' No AI provider found. Log in first:');
1720
+ console.log(' claude auth login — for Claude');
1721
+ console.log(' codex login — for OpenAI/Codex');
1722
+ console.log(' Then re-run: dual-brain init\n');
1723
+ return null;
1724
+ }
1725
+
1726
+ const step1 = (await ask(' > ')).trim().toLowerCase();
1727
+ if (step1 === 's') {
1728
+ // Skip: auto-save detected plans and proceed directly
1729
+ const skippedProfile = loadProfile(cwd);
1730
+ if (claudeReady) skippedProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
1731
+ if (openaiReady) skippedProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
1732
+ const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
1733
+ skippedProfile.mode = enabledCount >= 2 ? 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
1734
+ return skippedProfile;
1735
+ }
1736
+
1737
+ // ══════════════════════════════════════════════════════════════════════════
1738
+ // Step 2 — Budget / plan selection
1739
+ // ══════════════════════════════════════════════════════════════════════════
1740
+ console.log('');
1741
+ console.log(wTop);
1742
+ console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1743
+ console.log(wSep);
1744
+ console.log(wRow(`Step 2 of 5: Subscription plans`));
1745
+ console.log(wSep);
1746
+
1747
+ if (claudeReady) {
1748
+ const detectedClaudePlan = plans.claude || 'pro';
1749
+ const detectedClaudeLabel = CLAUDE_PLAN_LABELS[detectedClaudePlan] ?? detectedClaudePlan;
1750
+ console.log(wRow(`Claude — detected: ${detectedClaudeLabel}`));
1751
+ console.log(wRow(` [1] Pro ($20/mo)`));
1752
+ console.log(wRow(` [2] Max x5 ($100/mo)`));
1753
+ console.log(wRow(` [3] Max x20 ($200/mo)`));
1754
+ console.log(wRow(` [Enter] Keep detected (${detectedClaudeLabel})`));
1755
+ console.log(wSep);
1756
+ const claudeChoice = (await ask(' Claude plan [1/2/3/Enter]: ')).trim();
1757
+ const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
1758
+ state.claudePlan = claudePlanMap[claudeChoice] || detectedClaudePlan;
1759
+ }
1760
+
1761
+ if (openaiReady) {
1762
+ const detectedOpenaiPlan = plans.openai || 'plus';
1763
+ const detectedOpenaiLabel = OPENAI_PLAN_LABELS[detectedOpenaiPlan] ?? detectedOpenaiPlan;
1764
+ console.log(wRow(`OpenAI — detected: ${detectedOpenaiLabel}`));
1765
+ console.log(wRow(` [1] Plus ($20/mo)`));
1766
+ console.log(wRow(` [2] Pro ($100/mo)`));
1767
+ console.log(wRow(` [3] Pro ($200/mo higher limits)`));
1768
+ console.log(wRow(` [Enter] Keep detected (${detectedOpenaiLabel})`));
1769
+ console.log(wSep);
1770
+ const openaiChoice = (await ask(' OpenAI plan [1/2/3/Enter]: ')).trim();
1771
+ const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
1772
+ state.openaiPlan = openaiPlanMap[openaiChoice] || detectedOpenaiPlan;
1773
+ }
1774
+
1775
+ console.log(wBottom);
1776
+
1777
+ // ══════════════════════════════════════════════════════════════════════════
1778
+ // Step 3 — HEAD model selection
1779
+ // ══════════════════════════════════════════════════════════════════════════
1780
+ const hasBigPlan = state.claudePlan === 'max5' || state.claudePlan === 'max20';
1781
+ const recommendedModel = hasBigPlan ? 'claude-opus-4-5' : 'claude-sonnet-4-5';
1782
+ const recommendedLabel = hasBigPlan
1783
+ ? 'Opus (Max plan — best quality)'
1784
+ : 'Sonnet (Pro plan — balanced speed/quality)';
1785
+
1786
+ console.log('');
1787
+ console.log(wTop);
1788
+ console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1789
+ console.log(wSep);
1790
+ console.log(wRow(`Step 3 of 5: HEAD model (think-tier)`));
1791
+ console.log(wSep);
1792
+ console.log(wRow(`Recommended: ${recommendedLabel}`));
1793
+ console.log(wSep);
1794
+ console.log(wRow(` [1] Haiku — fastest, lowest cost`));
1795
+ console.log(wRow(` [2] Sonnet — balanced (recommended for Pro)`));
1796
+ console.log(wRow(` [3] Opus — best quality (recommended for Max)`));
1797
+ console.log(wRow(` [Enter] Use recommended`));
1798
+ console.log(wBottom);
1799
+ console.log('');
1800
+
1801
+ const step3 = (await ask(' HEAD model [1/2/3/Enter]: ')).trim();
1802
+ const modelMap = {
1803
+ '1': 'claude-haiku-4-5',
1804
+ '2': 'claude-sonnet-4-5',
1805
+ '3': 'claude-opus-4-5',
1806
+ };
1807
+ state.headModel = modelMap[step3] || recommendedModel;
1808
+
1809
+ // ══════════════════════════════════════════════════════════════════════════
1810
+ // Step 4 — Import sessions + profile selection
1811
+ // ══════════════════════════════════════════════════════════════════════════
1812
+ console.log('');
1813
+ console.log(wTop);
1814
+ console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1815
+ console.log(wSep);
1816
+ console.log(wRow(`Step 4 of 5: Sessions & routing profile`));
1817
+ console.log(wSep);
1818
+
1819
+ if (existingSessions.length > 0) {
1820
+ console.log(wRow(`Import ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''}?`));
1821
+ console.log(wRow(` [y] Yes [Enter/n] Skip`));
1822
+ console.log(wSep);
1823
+ const importChoice = (await ask(' Import sessions [y/Enter]: ')).trim().toLowerCase();
1824
+ state.importSessions = importChoice === 'y';
1825
+ if (state.importSessions) {
1826
+ console.log('');
1827
+ console.log(` Importing ${existingSessions.length} sessions...`);
1828
+ const recent = existingSessions.slice(0, 5);
1829
+ for (const sess of recent) {
1830
+ console.log(` ${sess.age.padEnd(6)} ${sess.name}`);
1831
+ }
1832
+ if (existingSessions.length > 5) {
1833
+ console.log(` ... and ${existingSessions.length - 5} more`);
1834
+ }
1835
+ }
1836
+ console.log(wSep);
1837
+ }
1838
+
1839
+ console.log(wRow(`Routing profile:`));
1840
+ console.log(wRow(` [1] auto — adapts based on task risk & outcomes`));
1841
+ console.log(wRow(` [2] balanced — best model per tier, normal budgets`));
1842
+ console.log(wRow(` [3] cost-saver — prefer cheaper models, skip GPT`));
1843
+ console.log(wRow(` [4] quality-first — dual-brain for medium+ risk`));
1844
+ console.log(wRow(` [Enter] auto (recommended)`));
1845
+ console.log(wBottom);
1846
+ console.log('');
1847
+
1848
+ const step4 = (await ask(' Profile [1/2/3/4/Enter]: ')).trim();
1849
+ const profileMap = { '1': 'auto', '2': 'balanced', '3': 'cost-saver', '4': 'quality-first' };
1850
+ state.profile = profileMap[step4] || 'auto';
1851
+
1852
+ // ══════════════════════════════════════════════════════════════════════════
1853
+ // Step 5 — Summary & confirm
1854
+ // ══════════════════════════════════════════════════════════════════════════
1855
+ const claudeSummary = state.claudePlan
1856
+ ? `Claude: ${CLAUDE_PLAN_LABELS[state.claudePlan] ?? state.claudePlan}`
1857
+ : `Claude: not configured`;
1858
+ const openaiSummary = state.openaiPlan
1859
+ ? `OpenAI: ${OPENAI_PLAN_LABELS[state.openaiPlan] ?? state.openaiPlan}`
1860
+ : `OpenAI: not configured`;
1861
+ const modelSummary = `HEAD model: ${state.headModel}`;
1862
+ const profileSummary = `Profile: ${state.profile}`;
1863
+ const sessionSummary = existingSessions.length > 0
1864
+ ? `Sessions: ${state.importSessions ? `${existingSessions.length} imported` : 'skipped'}`
1865
+ : null;
1866
+
1867
+ console.log('');
1868
+ console.log(wTop);
1869
+ console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1870
+ console.log(wSep);
1871
+ console.log(wRow(`Step 5 of 5: Summary`));
1872
+ console.log(wSep);
1873
+ console.log(wRow(claudeSummary));
1874
+ console.log(wRow(openaiSummary));
1875
+ console.log(wRow(modelSummary));
1876
+ console.log(wRow(profileSummary));
1877
+ if (sessionSummary) console.log(wRow(sessionSummary));
1878
+ console.log(wSep);
1879
+ console.log(wRow(`[Enter] Save and start [q] Quit without saving`));
1880
+ console.log(wBottom);
1881
+ console.log('');
1882
+
1883
+ const step5 = (await ask(' > ')).trim().toLowerCase();
1884
+ if (step5 === 'q') {
1885
+ console.log('\n Setup cancelled.\n');
1886
+ return null;
1887
+ }
1888
+
1889
+ // ── Build and return the profile object ────────────────────────────────────
1890
+ const finalProfile = loadProfile(cwd);
1891
+
1892
+ if (state.claudePlan) {
1893
+ finalProfile.providers.claude = { enabled: true, plan: state.claudePlan };
1894
+ } else if (claudeReady) {
1895
+ finalProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
1896
+ }
1897
+
1898
+ if (state.openaiPlan) {
1899
+ finalProfile.providers.openai = { enabled: true, plan: state.openaiPlan };
1900
+ } else if (openaiReady) {
1901
+ finalProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
1902
+ }
1903
+
1904
+ const enabledCount = [
1905
+ finalProfile.providers?.claude?.enabled,
1906
+ finalProfile.providers?.openai?.enabled,
1907
+ ].filter(Boolean).length;
1908
+
1909
+ finalProfile.mode = enabledCount >= 2 ? state.profile : claudeReady ? 'solo-claude' : 'solo-openai';
1910
+ finalProfile.headModel = state.headModel;
1911
+ finalProfile.bias = state.profile;
1912
+
1913
+ return finalProfile;
1914
+ }
1915
+
1585
1916
  // ─── Screen: dashboardScreen (kept for internal reference, unreachable) ───────
1586
1917
 
1587
1918
  async function dashboardScreen(rl, ask) {
@@ -2223,6 +2554,151 @@ async function runScreens(startScreen = 'dashboard') {
2223
2554
  rl.close();
2224
2555
  }
2225
2556
 
2557
+ // ─── Specialist commands ──────────────────────────────────────────────────────
2558
+
2559
+ const SPECIALIST_DEFAULTS = {
2560
+ python: { name: 'Python', description: 'Python stdlib, typing, async' },
2561
+ typescript: { name: 'TypeScript', description: 'TS type system, React, Node' },
2562
+ html: { name: 'HTML/CSS', description: 'Semantic HTML, CSS, accessibility' },
2563
+ linux: { name: 'Linux/DevOps', description: 'Sysadmin, Docker, nginx, shell' },
2564
+ security: { name: 'Security', description: 'Auth, crypto, OWASP, threat model' },
2565
+ };
2566
+
2567
+ function loadSpecialistRegistry() {
2568
+ const regPath = join(__dirname, '..', 'agents', 'specialists', 'registry.json');
2569
+ try {
2570
+ const raw = JSON.parse(readFileSync(regPath, 'utf8'));
2571
+ const out = {};
2572
+ for (const [key, val] of Object.entries(raw.specialists || {})) {
2573
+ out[key] = { name: val.name || key, description: val.description || '' };
2574
+ }
2575
+ return out;
2576
+ } catch {
2577
+ return SPECIALIST_DEFAULTS;
2578
+ }
2579
+ }
2580
+
2581
+ function cmdSpecialists() {
2582
+ const registry = loadSpecialistRegistry();
2583
+ const entries = Object.entries(registry);
2584
+
2585
+ // Build padded rows
2586
+ const rows = entries.map(([key, val]) => {
2587
+ const k = key.padEnd(12);
2588
+ const d = val.description;
2589
+ return `│ ${k}${d}`;
2590
+ });
2591
+
2592
+ // Find longest row for width
2593
+ const inner = Math.max(
2594
+ ...rows.map(r => r.length),
2595
+ '│ Usage: dual-brain python "task description" │'.length - 2,
2596
+ );
2597
+ const width = inner + 1; // account for trailing │
2598
+
2599
+ function pad(str) {
2600
+ return str + ' '.repeat(Math.max(0, width - str.length - 1)) + '│';
2601
+ }
2602
+
2603
+ const top = '┌' + '─'.repeat(width - 1) + '┐';
2604
+ const title = pad('│ 🎯 Available Specialists');
2605
+ const divTop = '├' + '─'.repeat(width - 1) + '┤';
2606
+ const divBot = '├' + '─'.repeat(width - 1) + '┤';
2607
+ const bot = '└' + '─'.repeat(width - 1) + '┘';
2608
+
2609
+ console.log(top);
2610
+ console.log(title);
2611
+ console.log(divTop);
2612
+ for (const row of rows) console.log(pad(row));
2613
+ console.log(divBot);
2614
+ console.log(pad('│ Usage: dual-brain python "task description"'));
2615
+ console.log(pad('│ Auto-routing: off (use dual-brain go for auto)'));
2616
+ console.log(bot);
2617
+ }
2618
+
2619
+ async function cmdSpecialistGo(specialist, args) {
2620
+ const dryRun = args.includes('--dry-run');
2621
+ const verbose = args.includes('--verbose') || args.includes('-v');
2622
+ const filesRaw = flag(args, '--files');
2623
+ const files = filesRaw && typeof filesRaw === 'string'
2624
+ ? filesRaw.split(',').map(f => f.trim()).filter(Boolean)
2625
+ : [];
2626
+
2627
+ const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
2628
+ if (!prompt) err(`Usage: dual-brain ${specialist} "task description" [--dry-run] [--files a,b]`);
2629
+
2630
+ const cwd = process.cwd();
2631
+ const profile = await ensureProfile(cwd);
2632
+ const detection = detectTask({ prompt, files });
2633
+
2634
+ // Override specialist, preserve everything else
2635
+ detection.specialist = specialist;
2636
+
2637
+ console.log(`[specialist: ${specialist}] ${detection.explanation}`);
2638
+
2639
+ if (verbose) {
2640
+ vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
2641
+ vtrace(`Tier: ${detection.tier} | Specialist override: ${specialist}`);
2642
+ }
2643
+
2644
+ const decision = decideRoute({ profile, detection, cwd });
2645
+
2646
+ if (verbose) {
2647
+ const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
2648
+ vtrace(`Model selection: ${modelLabel}`);
2649
+ vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
2650
+ }
2651
+
2652
+ console.log(` specialist : ${specialist}`);
2653
+ console.log(` provider : ${decision.provider}`);
2654
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
2655
+ console.log(` tier : ${decision.tier}`);
2656
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
2657
+ console.log(` reason : ${decision.explanation}`);
2658
+
2659
+ if (dryRun) {
2660
+ console.log('\n(dry-run — not executing)');
2661
+ return;
2662
+ }
2663
+
2664
+ console.log('\nDispatching...');
2665
+ let result;
2666
+ if (decision.dualBrain) {
2667
+ result = await dispatchDualBrain({ decision, prompt, files, cwd });
2668
+ console.log(`\nConsensus: ${result.consensus}`);
2669
+ if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
2670
+ if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
2671
+ saveSession({
2672
+ objective: prompt,
2673
+ branch: null,
2674
+ filesChanged: files,
2675
+ commandsRun: [`dual-brain ${specialist} "${prompt}"`],
2676
+ lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
2677
+ provider: decision.provider,
2678
+ nextAction: null,
2679
+ }, cwd);
2680
+ } else {
2681
+ result = await dispatch({ decision, prompt, files, cwd });
2682
+ const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
2683
+ console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
2684
+ if (result.summary) console.log(result.summary);
2685
+ if (result.error) process.stderr.write(`${result.error}\n`);
2686
+ saveSession({
2687
+ objective: prompt,
2688
+ branch: null,
2689
+ filesChanged: files,
2690
+ commandsRun: [`dual-brain ${specialist} "${prompt}"`],
2691
+ lastResult: {
2692
+ status: result.status === 'completed' ? 'success' : 'failure',
2693
+ summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
2694
+ },
2695
+ provider: decision.provider,
2696
+ nextAction: null,
2697
+ }, cwd);
2698
+ if (result.status !== 'completed') process.exit(1);
2699
+ }
2700
+ }
2701
+
2226
2702
  // ─── Entry point ─────────────────────────────────────────────────────────────
2227
2703
 
2228
2704
  async function main() {
@@ -2246,9 +2722,20 @@ async function main() {
2246
2722
  if (profileExists(cwd)) {
2247
2723
  await runScreens('main');
2248
2724
  } else {
2249
- // First run: welcomeScreen handles auto-setup detection internally,
2250
- // then falls through to manual wizard if needed.
2251
- await runScreens('welcome');
2725
+ // First run: run the 5-step onboarding wizard, then go to main.
2726
+ process.stdout.write(`\ndual-brain v${readVersion()} Setup\n\nDetecting your setup...\n`);
2727
+ const auth = await detectAuth();
2728
+ const plans = detectPlans();
2729
+ const existingSessions = importReplitSessions(cwd);
2730
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2731
+ const wizardProfile = await runOnboardingWizard({ auth, plans, existingSessions }, cwd, rl);
2732
+ if (wizardProfile) {
2733
+ saveProfile(wizardProfile, { cwd });
2734
+ await cmdInstall(cwd);
2735
+ console.log('\n ✅ Setup complete! Starting dual-brain...\n');
2736
+ }
2737
+ rl.close();
2738
+ await runScreens('main');
2252
2739
  }
2253
2740
  } else {
2254
2741
  // Non-TTY: print status card and exit
@@ -2264,8 +2751,21 @@ async function main() {
2264
2751
 
2265
2752
  if (cmd === 'init') {
2266
2753
  if (isInteractive) {
2267
- // Run welcome wizard then dashboard
2268
- await runScreens('welcome');
2754
+ // Run 5-step onboarding wizard then main screen
2755
+ const cwd = process.cwd();
2756
+ process.stdout.write(`\ndual-brain v${readVersion()} — Setup\n\nDetecting your setup...\n`);
2757
+ const auth = await detectAuth();
2758
+ const plans = detectPlans();
2759
+ const existingSessions = importReplitSessions(cwd);
2760
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2761
+ const wizardProfile = await runOnboardingWizard({ auth, plans, existingSessions }, cwd, rl);
2762
+ if (wizardProfile) {
2763
+ saveProfile(wizardProfile, { cwd });
2764
+ await cmdInstall(cwd);
2765
+ console.log('\n ✅ Setup complete! Starting dual-brain...\n');
2766
+ }
2767
+ rl.close();
2768
+ await runScreens('main');
2269
2769
  } else {
2270
2770
  await cmdInit();
2271
2771
  }
@@ -2282,8 +2782,14 @@ async function main() {
2282
2782
  if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
2283
2783
  if (cmd === 'hot') { cmdHot(args[1]); return; }
2284
2784
  if (cmd === 'cool') { cmdCool(args[1]); return; }
2285
- if (cmd === 'remember') { cmdRemember(args[1]); return; }
2286
- if (cmd === 'forget') { cmdForget(args[1]); return; }
2785
+ if (cmd === 'remember') { cmdRemember(args[1]); return; }
2786
+ if (cmd === 'forget') { cmdForget(args[1]); return; }
2787
+ if (cmd === 'break-glass') { cmdBreakGlass(args.slice(1).join(' ')); return; }
2788
+
2789
+ if (cmd === 'specialists') { cmdSpecialists(); return; }
2790
+
2791
+ const SPECIALIST_CMDS = new Set(Object.keys(loadSpecialistRegistry()));
2792
+ if (SPECIALIST_CMDS.has(cmd)) { await cmdSpecialistGo(cmd, args.slice(1)); return; }
2287
2793
 
2288
2794
  if (cmd === 'search') {
2289
2795
  const query = args.slice(1).filter(a => !a.startsWith('--')).join(' ');
package/install.mjs CHANGED
@@ -14,6 +14,7 @@ import { createInterface } from 'readline';
14
14
  import { dirname, join, resolve } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { spawnSync } from 'child_process';
17
+ import { createHash } from 'crypto';
17
18
 
18
19
  // Skip hook installation during global npm install — hooks are installed
19
20
  // when the user runs 'dual-brain install' in their project directory.
@@ -863,6 +864,39 @@ function generateGitignoreEntries(workspace) {
863
864
  return { existing, needed };
864
865
  }
865
866
 
867
+ // ─── Hook Manifest ──────────────────────────────────────────────────────────
868
+
869
+ function hashString(s) {
870
+ return createHash('sha256').update(s).digest('hex');
871
+ }
872
+
873
+ function generateHookManifest(settings) {
874
+ const hooks = settings.hooks || {};
875
+ const preHooks = (hooks.PreToolUse || []).flatMap(entry =>
876
+ (entry.hooks || []).map(h => hashString(h.command || ''))
877
+ );
878
+ const postHooks = (hooks.PostToolUse || []).flatMap(entry =>
879
+ (entry.hooks || []).map(h => hashString(h.command || ''))
880
+ );
881
+ const settingsHash = hashString(JSON.stringify(hooks));
882
+ return {
883
+ generatedAt: new Date().toISOString(),
884
+ expectedHooks: {
885
+ PreToolUse: preHooks,
886
+ PostToolUse: postHooks,
887
+ },
888
+ settingsHash,
889
+ };
890
+ }
891
+
892
+ function writeHookManifest(workspace, settings) {
893
+ const dualbrain = join(workspace, '.dualbrain');
894
+ mkdirSync(dualbrain, { recursive: true });
895
+ const manifest = generateHookManifest(settings);
896
+ writeFileSync(join(dualbrain, 'hook-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
897
+ return manifest;
898
+ }
899
+
866
900
  // ─── Installation ───────────────────────────────────────────────────────────
867
901
 
868
902
  function install(workspace, env, mode) {
@@ -915,6 +949,9 @@ function install(workspace, env, mode) {
915
949
  writeFileSync(join(target, 'settings.json'), JSON.stringify(settings, null, 2) + '\n');
916
950
  actions.push('✓ settings.json (hooks registered)');
917
951
 
952
+ writeHookManifest(workspace, settings);
953
+ actions.push('✓ .dualbrain/hook-manifest.json (integrity manifest)');
954
+
918
955
  const claudeMd = generateClaudeMd(mode);
919
956
  writeClaudeMd(join(target, 'CLAUDE.md'), claudeMd);
920
957
  actions.push('✓ CLAUDE.md (session instructions)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.16",
3
+ "version": "7.1.17",
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/src/detect.mjs CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // detect.mjs — Task detection for dual-brain. Self-contained, no internal imports.
3
- // Exports: detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths
3
+ // Exports: detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths, classifySpecialist
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { resolve, dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
4
10
 
5
11
  // ─── Intent definitions ────────────────────────────────────────────────────────
6
12
 
@@ -118,10 +124,13 @@ function estimateComplexity({ prompt, fileCount = 0, risk = 'low', intent = 'edi
118
124
  }
119
125
 
120
126
  /** Map intent + risk + complexity → tier (think / search / execute). */
121
- function inferTier({ intent, risk, complexity, effort }) {
127
+ function inferTier({ intent, risk, complexity, effort, specialistTierBias }) {
122
128
  const thinkIntents = ['architecture', 'security', 'planning', 'compare', 'review'];
123
129
  if (thinkIntents.includes(intent) || risk === 'critical') return 'think';
124
130
 
131
+ // Specialist tier_bias can elevate to think before general tier logic
132
+ if (specialistTierBias === 'think') return 'think';
133
+
125
134
  const searchIntents = ['search', 'explain', 'format'];
126
135
  if (searchIntents.includes(intent) && effort === 'low') return 'search';
127
136
 
@@ -193,10 +202,15 @@ function detectTask(input) {
193
202
  effort = 'high';
194
203
  }
195
204
 
196
- // 6. Tier
197
- const tier = inferTier({ intent, risk, complexity, effort });
205
+ // 6. Specialist
206
+ const specialistResult = classifySpecialist(prompt, files);
207
+ const specialistDef = SPECIALIST_DEFS[specialistResult.specialist] || null;
208
+ const specialistTierBias = specialistDef?.tier_bias || null;
209
+
210
+ // 7. Tier
211
+ const tier = inferTier({ intent, risk, complexity, effort, specialistTierBias });
198
212
 
199
- // 7. Explanation
213
+ // 8. Explanation
200
214
  const explanation = buildExplanation({ intent, risk, complexity, fileCount, priorFailures });
201
215
 
202
216
  return {
@@ -210,9 +224,99 @@ function detectTask(input) {
210
224
  designImpact,
211
225
  requiresWrite: requiresWrite(intent),
212
226
  explanation,
227
+ specialist: specialistResult,
213
228
  };
214
229
  }
215
230
 
231
+ // ─── Specialist registry ──────────────────────────────────────────────────────
232
+
233
+ const SPECIALIST_REGISTRY_PATH = resolve(__dirname, '../agents/specialists/registry.json');
234
+
235
+ const DEFAULT_SPECIALISTS = {
236
+ python: { triggers: { extensions: ['.py', '.pyx', '.pyi'], keywords: ['python', 'pip', 'pytest', 'django', 'flask', 'asyncio'] } },
237
+ typescript: { triggers: { extensions: ['.ts', '.tsx', '.mts'], keywords: ['typescript', 'tsc', 'generics', 'react', 'next', 'node'] } },
238
+ html: { triggers: { extensions: ['.html', '.css', '.scss', '.svg'], keywords: ['html', 'css', 'accessibility', 'a11y', 'aria', 'responsive', 'tailwind'] } },
239
+ linux: { triggers: { extensions: ['.sh', '.bash', '.conf', '.service', '.dockerfile'], keywords: ['linux', 'bash', 'shell', 'systemd', 'nginx', 'docker', 'ssh', 'deploy'] } },
240
+ security: { triggers: { extensions: [], keywords: ['auth', 'oauth', 'jwt', 'credential', 'secret', 'encrypt', 'vulnerability', 'owasp', 'xss', 'csrf'] }, tier_bias: 'think' },
241
+ };
242
+
243
+ function loadSpecialistRegistry() {
244
+ try {
245
+ const raw = readFileSync(SPECIALIST_REGISTRY_PATH, 'utf8');
246
+ const parsed = JSON.parse(raw);
247
+ return parsed.specialists || DEFAULT_SPECIALISTS;
248
+ } catch {
249
+ return DEFAULT_SPECIALISTS;
250
+ }
251
+ }
252
+
253
+ const SPECIALIST_DEFS = loadSpecialistRegistry();
254
+
255
+ /**
256
+ * Classify which specialist domain best matches the prompt and file list.
257
+ * Returns { specialist, confidence, triggers }.
258
+ */
259
+ function classifySpecialist(prompt = '', files = []) {
260
+ const promptLower = prompt.toLowerCase();
261
+ const scores = {};
262
+ const matchedTriggers = {};
263
+
264
+ for (const [name, def] of Object.entries(SPECIALIST_DEFS)) {
265
+ const { extensions = [], keywords = [] } = def.triggers || {};
266
+ let score = 0;
267
+ const hits = [];
268
+
269
+ // +2 per matching file extension
270
+ for (const file of files) {
271
+ for (const ext of extensions) {
272
+ if (file.endsWith(ext)) {
273
+ score += 2;
274
+ hits.push(ext);
275
+ break; // count each file once per specialist
276
+ }
277
+ }
278
+ }
279
+
280
+ // +1 per matching keyword in prompt
281
+ for (const kw of keywords) {
282
+ // Use word-boundary-aware match where possible
283
+ const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
284
+ if (re.test(promptLower)) {
285
+ score += 1;
286
+ hits.push(kw);
287
+ }
288
+ }
289
+
290
+ scores[name] = score;
291
+ matchedTriggers[name] = hits;
292
+ }
293
+
294
+ // Find highest score
295
+ let best = null;
296
+ let bestScore = 0;
297
+ let bestExtCount = 0;
298
+
299
+ for (const [name, score] of Object.entries(scores)) {
300
+ if (score < 2) continue;
301
+ const extCount = matchedTriggers[name].filter(t => t.startsWith('.')).length;
302
+ if (
303
+ score > bestScore ||
304
+ (score === bestScore && extCount > bestExtCount)
305
+ ) {
306
+ best = name;
307
+ bestScore = score;
308
+ bestExtCount = extCount;
309
+ }
310
+ }
311
+
312
+ if (!best) {
313
+ return { specialist: 'generic', confidence: 'low', triggers: [] };
314
+ }
315
+
316
+ const confidence = bestScore >= 4 ? 'high' : 'medium';
317
+ return { specialist: best, confidence, triggers: matchedTriggers[best] };
318
+ }
319
+
216
320
  // ─── CLI ──────────────────────────────────────────────────────────────────────
217
321
 
218
322
  if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
@@ -238,4 +342,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
238
342
  console.log(JSON.stringify(result, null, 2));
239
343
  }
240
344
 
241
- export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths };
345
+ export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths, classifySpecialist };
package/src/dispatch.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // isInsideClaude, buildNativeDispatch, normalizeResult
9
9
 
10
10
  import { spawn } from 'node:child_process';
11
- import { mkdirSync, appendFileSync, existsSync } from 'node:fs';
11
+ import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
12
12
  import { join, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { createHash } from 'node:crypto';
@@ -20,6 +20,52 @@ const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
20
20
  const TIER_TIMEOUT_MS = { search: 60_000, execute: 120_000, think: 180_000 };
21
21
  const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
22
22
 
23
+ // ─── Specialist prompt loader ─────────────────────────────────────────────────
24
+
25
+ const SPECIALISTS_DIR = join(__dirname, '..', 'agents', 'specialists');
26
+
27
+ /**
28
+ * Load specialist registry from agents/specialists/registry.json.
29
+ * Returns null if registry is missing or malformed.
30
+ * @returns {object|null}
31
+ */
32
+ function _loadSpecialistRegistry() {
33
+ try {
34
+ const raw = readFileSync(join(SPECIALISTS_DIR, 'registry.json'), 'utf8');
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Read agents/specialists/_base.md and agents/specialists/{specialist}.md,
43
+ * concatenate them (base first, specialist second). Falls back gracefully:
44
+ * - If base is missing, only specialist content is returned.
45
+ * - If specialist file is missing, only base content is returned.
46
+ * - If both are missing, returns an empty string.
47
+ *
48
+ * @param {string} specialist Specialist key (e.g. 'python', 'security')
49
+ * @returns {string}
50
+ */
51
+ function loadSpecialistPrompt(specialist) {
52
+ if (!specialist || specialist === 'generic') return '';
53
+
54
+ const tryRead = (filePath) => {
55
+ try { return readFileSync(filePath, 'utf8').trim(); } catch { return ''; }
56
+ };
57
+
58
+ const registry = _loadSpecialistRegistry();
59
+ const entry = registry?.specialists?.[specialist];
60
+ const promptFile = entry?.prompt_file ?? `${specialist}.md`;
61
+
62
+ const base = tryRead(join(SPECIALISTS_DIR, '_base.md'));
63
+ const specific = tryRead(join(SPECIALISTS_DIR, promptFile));
64
+
65
+ const parts = [base, specific].filter(Boolean);
66
+ return parts.join('\n\n');
67
+ }
68
+
23
69
  // ─── Median dispatch time tracker (in-process, for slow-response detection) ──
24
70
  // Rolling window of recent dispatch durations keyed by "provider:modelClass"
25
71
  const _durationHistory = new Map();
@@ -561,7 +607,8 @@ function _prependDispatchMarker(prompt) {
561
607
 
562
608
  // ─── Main dispatch ────────────────────────────────────────────────────────────
563
609
  async function dispatch(input = {}) {
564
- const { decision = {}, files = [], cwd = process.cwd(), dryRun = false } = input;
610
+ const { files = [], cwd = process.cwd(), dryRun = false } = input;
611
+ let decision = input.decision ?? {};
565
612
  let { prompt } = input;
566
613
 
567
614
  if (!prompt) throw new Error('prompt is required');
@@ -573,6 +620,30 @@ async function dispatch(input = {}) {
573
620
  // that this agent call came through the official pipeline.
574
621
  prompt = _prependDispatchMarker(prompt);
575
622
 
623
+ // ── Specialist prompt injection ──────────────────────────────────────────────
624
+ const specialist = decision.specialist && decision.specialist !== 'generic'
625
+ ? decision.specialist
626
+ : null;
627
+
628
+ if (specialist) {
629
+ const specialistPrompt = loadSpecialistPrompt(specialist);
630
+ if (specialistPrompt) {
631
+ prompt = `${specialistPrompt}\n\n---\n\n${prompt}`;
632
+ process.stderr.write(`[dual-brain] specialist: ${specialist}\n`);
633
+ }
634
+
635
+ // Apply tier_bias from registry if decision didn't already pin a tier
636
+ if (!decision.tier) {
637
+ const registry = _loadSpecialistRegistry();
638
+ const tierBias = registry?.specialists?.[specialist]?.tier_bias;
639
+ if (tierBias) {
640
+ decision = { ...decision, tier: tierBias };
641
+ process.stderr.write(`[dual-brain] specialist tier_bias applied: ${tierBias}\n`);
642
+ }
643
+ }
644
+ }
645
+ // ── End specialist injection ─────────────────────────────────────────────────
646
+
576
647
  const tier = decision.tier ?? 'execute';
577
648
  const timeoutMs = TIER_TIMEOUT_MS[tier] ?? 120_000;
578
649
 
@@ -651,6 +722,7 @@ async function dispatch(input = {}) {
651
722
  status: 'dry-run',
652
723
  provider: effectiveProvider,
653
724
  model: effectiveModel,
725
+ specialist: specialist ?? 'generic',
654
726
  command,
655
727
  nativeDispatch: nativeDescriptor,
656
728
  exitCode: null,
@@ -712,6 +784,7 @@ async function dispatch(input = {}) {
712
784
  type: 'native-agent',
713
785
  provider: effectiveProvider,
714
786
  model: effectiveModel,
787
+ specialist: specialist ?? 'generic',
715
788
  command,
716
789
  nativeDispatch: nativeDescriptor,
717
790
  exitCode,
@@ -725,7 +798,7 @@ async function dispatch(input = {}) {
725
798
  const command = buildCommand(effectiveDecision, prompt, files, cwd);
726
799
 
727
800
  if (dryRun) {
728
- return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
801
+ return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
729
802
  }
730
803
 
731
804
  // Record this dispatch against the budget
@@ -778,6 +851,7 @@ async function dispatch(input = {}) {
778
851
  status: success ? 'completed' : 'failed',
779
852
  provider: effectiveProvider,
780
853
  model: effectiveModel,
854
+ specialist: specialist ?? 'generic',
781
855
  command,
782
856
  exitCode,
783
857
  summary,
@@ -865,4 +939,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
865
939
  }
866
940
  }
867
941
 
868
- export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult };
942
+ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt };
package/src/profile.mjs CHANGED
@@ -26,6 +26,7 @@ import { createInterface } from 'readline';
26
26
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
27
  import { homedir } from 'os';
28
28
  import { join } from 'path';
29
+ import { execFile } from 'child_process';
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // Claude Code memory integration
@@ -752,6 +753,230 @@ async function autoRefreshToken(cwd) {
752
753
  }
753
754
  }
754
755
 
756
+ // ---------------------------------------------------------------------------
757
+ // detectExistingAuth — silent onboarding scan
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /**
761
+ * Run a CLI command with a timeout, returning stdout as a string.
762
+ * Resolves with null on timeout, error, or non-zero exit.
763
+ * @param {string} cmd
764
+ * @param {string[]} args
765
+ * @param {number} timeoutMs
766
+ * @returns {Promise<string|null>}
767
+ */
768
+ function _runWithTimeout(cmd, args, timeoutMs) {
769
+ return new Promise(resolve => {
770
+ let settled = false;
771
+ const done = (val) => { if (!settled) { settled = true; resolve(val); } };
772
+
773
+ let child;
774
+ try {
775
+ child = execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
776
+ done(err ? null : (stdout || '').trim());
777
+ });
778
+ } catch {
779
+ done(null);
780
+ return;
781
+ }
782
+
783
+ // Belt-and-suspenders timeout fallback
784
+ const timer = setTimeout(() => {
785
+ try { child.kill('SIGTERM'); } catch {}
786
+ done(null);
787
+ }, timeoutMs + 500);
788
+
789
+ if (child?.on) {
790
+ child.on('close', () => clearTimeout(timer));
791
+ }
792
+ });
793
+ }
794
+
795
+ /**
796
+ * Derive a human-readable plan label from a plan tier string.
797
+ * @param {'claude'|'openai'} provider
798
+ * @param {string} plan e.g. '$20' | '$100' | '$200'
799
+ */
800
+ function _planLabel(provider, plan) {
801
+ const labels = {
802
+ claude: { '$20': 'Claude Pro ($20)', '$100': 'Claude Max x5 ($100)', '$200': 'Claude Max x20 ($200)' },
803
+ openai: { '$20': 'ChatGPT Plus ($20)', '$100': 'ChatGPT Pro ($100)', '$200': 'ChatGPT Pro ($200)' },
804
+ };
805
+ return labels[provider]?.[plan] ?? `${provider} ${plan}`;
806
+ }
807
+
808
+ /**
809
+ * Silently scan for existing auth from all known sources and return what was
810
+ * found, together with smart setup recommendations.
811
+ *
812
+ * Checks (in order, all non-throwing):
813
+ * 1. data-tools / replit-tools — ~/.claude/credentials.json or
814
+ * .replit-tools/.claude-persistent/.credentials.json for a session key
815
+ * 2. Claude CLI — `claude auth status` with 3 s timeout
816
+ * 3. Codex CLI — `codex auth status` with 3 s timeout or
817
+ * ~/.codex/ config files
818
+ * 4. Existing dual-brain config — .dualbrain/profile.json
819
+ *
820
+ * Returns:
821
+ * {
822
+ * claude: { found: boolean, source: string|null, plan: string|null, expiresAt: string|null },
823
+ * openai: { found: boolean, source: string|null, plan: string|null },
824
+ * existingProfile: boolean,
825
+ * recommendations: { headModel: string, budget: string, profile: string },
826
+ * }
827
+ *
828
+ * @param {string} [cwd]
829
+ */
830
+ async function detectExistingAuth(cwd) {
831
+ const home = homedir();
832
+ const root = cwd || process.cwd();
833
+
834
+ // -------------------------------------------------------------------------
835
+ // Result skeleton
836
+ // -------------------------------------------------------------------------
837
+ const result = {
838
+ claude: { found: false, source: null, plan: null, expiresAt: null },
839
+ openai: { found: false, source: null, plan: null },
840
+ existingProfile: false,
841
+ recommendations: { headModel: 'claude-sonnet-4-6', budget: '$20', profile: 'balanced' },
842
+ };
843
+
844
+ // -------------------------------------------------------------------------
845
+ // 1. data-tools / replit-tools — credentials.json session key
846
+ // -------------------------------------------------------------------------
847
+ const credPaths = [
848
+ join(root, '.replit-tools', '.claude-persistent', '.credentials.json'),
849
+ join(home, '.claude', '.credentials.json'),
850
+ // legacy replit persistent path
851
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.credentials.json',
852
+ ];
853
+ for (const credPath of credPaths) {
854
+ try {
855
+ const creds = JSON.parse(readFileSync(credPath, 'utf8'));
856
+ const oauth = creds?.claudeAiOauth;
857
+ if (oauth?.accessToken || oauth?.sessionKey) {
858
+ result.claude.found = true;
859
+ result.claude.source = credPath.includes('.replit-tools') ? 'data-tools' : 'credentials.json';
860
+ // Expiry
861
+ if (oauth.expiresAt) {
862
+ try { result.claude.expiresAt = new Date(oauth.expiresAt).toISOString(); } catch {}
863
+ }
864
+ break;
865
+ }
866
+ } catch { /* non-fatal */ }
867
+ }
868
+
869
+ // -------------------------------------------------------------------------
870
+ // 2. Claude CLI auth detection (config files + `claude auth status`)
871
+ // -------------------------------------------------------------------------
872
+ if (!result.claude.found) {
873
+ // Config-file scan (same paths as detectAuth)
874
+ const claudeConfigPaths = [
875
+ join(root, '.replit-tools', '.claude-persistent', '.claude.json'),
876
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
877
+ join(home, '.claude', '.claude.json'),
878
+ ];
879
+ for (const p of claudeConfigPaths) {
880
+ try {
881
+ const data = JSON.parse(readFileSync(p, 'utf8'));
882
+ if (data?.oauthAccount || (data?.apiKey && typeof data.apiKey === 'string')) {
883
+ result.claude.found = true;
884
+ result.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
885
+ break;
886
+ }
887
+ } catch { /* non-fatal */ }
888
+ }
889
+
890
+ // CLI fallback: `claude auth status`
891
+ if (!result.claude.found) {
892
+ const out = await _runWithTimeout('claude', ['auth', 'status'], 3000);
893
+ if (out && /logged.in|authenticated|signed.in/i.test(out)) {
894
+ result.claude.found = true;
895
+ result.claude.source = 'claude CLI (auth status)';
896
+ }
897
+ }
898
+ }
899
+
900
+ // -------------------------------------------------------------------------
901
+ // 3. Codex CLI / OpenAI auth detection
902
+ // -------------------------------------------------------------------------
903
+ const codexConfigPaths = [
904
+ join(root, '.replit-tools', '.codex-persistent', 'auth.json'),
905
+ '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
906
+ join(home, '.codex', 'auth.json'),
907
+ ];
908
+ for (const p of codexConfigPaths) {
909
+ try {
910
+ const data = JSON.parse(readFileSync(p, 'utf8'));
911
+ const accessToken = data?.tokens?.access_token || data?.access_token;
912
+ const idToken = data?.tokens?.id_token || data?.id_token;
913
+ if (accessToken || idToken) {
914
+ result.openai.found = true;
915
+ result.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
916
+ break;
917
+ }
918
+ } catch { /* non-fatal */ }
919
+ }
920
+
921
+ // CLI fallback: `codex auth status`
922
+ if (!result.openai.found) {
923
+ const out = await _runWithTimeout('codex', ['auth', 'status'], 3000);
924
+ if (out && /logged.in|authenticated|signed.in/i.test(out)) {
925
+ result.openai.found = true;
926
+ result.openai.source = 'codex CLI (auth status)';
927
+ }
928
+ }
929
+
930
+ // -------------------------------------------------------------------------
931
+ // 4. Existing dual-brain profile
932
+ // -------------------------------------------------------------------------
933
+ for (const p of [projectPath(root), GLOBAL_PATH]) {
934
+ if (existsSync(p)) {
935
+ result.existingProfile = true;
936
+ break;
937
+ }
938
+ }
939
+
940
+ // -------------------------------------------------------------------------
941
+ // Plan detection (re-use detectPlans which reads the same config files)
942
+ // -------------------------------------------------------------------------
943
+ const plans = detectPlans();
944
+ if (result.claude.found && plans.claude) result.claude.plan = plans.claude;
945
+ if (result.openai.found && plans.openai) result.openai.plan = plans.openai;
946
+
947
+ // -------------------------------------------------------------------------
948
+ // Smart recommendations
949
+ // -------------------------------------------------------------------------
950
+ const claudeRank = PLAN_RANK[result.claude.plan] || 0;
951
+ const openaiRank = PLAN_RANK[result.openai.plan] || 0;
952
+
953
+ if (result.claude.found && !result.openai.found) {
954
+ // Solo Claude
955
+ result.recommendations.headModel = 'claude-sonnet-4-6';
956
+ result.recommendations.budget = result.claude.plan || '$20';
957
+ result.recommendations.profile = claudeRank >= 2 ? 'quality-first' : 'balanced';
958
+ } else if (result.openai.found && !result.claude.found) {
959
+ // Solo OpenAI
960
+ result.recommendations.headModel = 'gpt-4o';
961
+ result.recommendations.budget = result.openai.plan || '$20';
962
+ result.recommendations.profile = openaiRank >= 2 ? 'quality-first' : 'balanced';
963
+ } else if (result.claude.found && result.openai.found) {
964
+ // Both available — higher-ranked provider drives HEAD model
965
+ if (openaiRank > claudeRank) {
966
+ result.recommendations.headModel = 'gpt-4o';
967
+ } else {
968
+ result.recommendations.headModel = 'claude-sonnet-4-6';
969
+ }
970
+ const topPlan = openaiRank >= claudeRank ? result.openai.plan : result.claude.plan;
971
+ result.recommendations.budget = topPlan || '$20';
972
+ const topRank = Math.max(claudeRank, openaiRank);
973
+ result.recommendations.profile = topRank >= 2 ? 'quality-first' : 'balanced';
974
+ }
975
+ // else: no auth found — defaults remain (claude-sonnet-4-6 / $20 / balanced)
976
+
977
+ return result;
978
+ }
979
+
755
980
  export {
756
981
  loadProfile, saveProfile, ensureProfile, runOnboarding,
757
982
  rememberPreference, forgetPreference, getActivePreferences,
@@ -760,4 +985,5 @@ export {
760
985
  detectAuth, detectEnvironment,
761
986
  saveSubscription, listSubscriptions,
762
987
  defaultProfile, autoSetup, autoRefreshToken,
988
+ detectExistingAuth,
763
989
  };