dual-brain 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -15,7 +15,26 @@ Route subagents by task complexity:
15
15
  For isolated or parallel work, dispatch to GPT via Codex CLI:
16
16
 
17
17
  - `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model gpt-5.4` — execution tasks
18
- - `node .claude/hooks/dual-brain-think.mjs --question "..."` — dual-perspective decisions
18
+
19
+ ## Dual-Brain Collaboration
20
+
21
+ Dual-brain is a multi-round conversation between Claude and GPT — not a single-shot dispatch.
22
+
23
+ **Think flow** (architecture decisions):
24
+ 1. Round 1: `node .claude/hooks/dual-brain-think.mjs --question "..."`
25
+ → GPT gives independent analysis
26
+ 2. You analyze the same question independently
27
+ 3. Round 2: `node .claude/hooks/dual-brain-think.mjs --question "..." --round 2 --claude-says "<your analysis>"`
28
+ → GPT responds to your points: agreements, pushback, refined recommendation
29
+ 4. You synthesize both rounds into a final decision
30
+
31
+ **Review flow** (code review):
32
+ 1. Round 1: `node .claude/hooks/dual-brain-review.mjs`
33
+ → GPT reviews the diff independently
34
+ 2. You review the same diff independently
35
+ 3. Round 2: `node .claude/hooks/dual-brain-review.mjs --round 2 --claude-review "<your findings>"`
36
+ → GPT confirms shared findings, acknowledges misses, disputes false positives
37
+ 4. You synthesize into a final review verdict
19
38
 
20
39
  ## Routing Rules
21
40
 
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * control-panel.mjs — Interactive TUI control panel for Dual-Brain Orchestrator.
4
+ *
5
+ * Keyboard-driven dashboard with live-updating pressure, profile switching,
6
+ * inline budget editing, and routing decision viewer.
7
+ *
8
+ * Falls back to static emoji output when not in a TTY.
9
+ */
10
+
11
+ import readline from 'readline';
12
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
13
+ import { dirname, join } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { spawnSync } from 'child_process';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
19
+ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
20
+ const VERSION = (() => {
21
+ try { return JSON.parse(readFileSync(join(__dirname, '..', '..', 'dual-brain', 'package.json'), 'utf8')).version; } catch {}
22
+ try { return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version; } catch {}
23
+ return '?';
24
+ })();
25
+
26
+ // ─── ANSI ──────────────────────────────────────────────────────────────────
27
+
28
+ const color = !process.env.NO_COLOR;
29
+ const A = {
30
+ altOn: '\x1b[?1049h', altOff: '\x1b[?1049l',
31
+ clear: '\x1b[2J', home: '\x1b[H',
32
+ hide: '\x1b[?25l', show: '\x1b[?25h',
33
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
34
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
35
+ blue: '\x1b[34m', cyan: '\x1b[36m', gray: '\x1b[90m',
36
+ white: '\x1b[37m',
37
+ };
38
+ const c = (code, s) => color ? `${code}${s}${A.reset}` : s;
39
+
40
+ // ─── Profiles ──────────────────────────────────────────────────────────────
41
+
42
+ const PROFILES = {
43
+ balanced: { emoji: '⚖️', label: 'Balanced', desc: 'Standard routing — best model per tier' },
44
+ 'cost-saver': { emoji: '💸', label: 'Cost-saver', desc: 'Minimize spend — prefer cheaper models' },
45
+ 'quality-first': { emoji: '💎', label: 'Quality-first', desc: 'Maximum quality — dual-brain for medium+' },
46
+ };
47
+
48
+ const PROFILE_BUDGETS = {
49
+ balanced: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
50
+ 'cost-saver': { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
51
+ 'quality-first': { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
52
+ };
53
+
54
+ const PROFILE_GATE = {
55
+ balanced: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
56
+ 'cost-saver': { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
57
+ 'quality-first': { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
58
+ };
59
+
60
+ // ─── Data Loaders ──────────────────────────────────────────────────────────
61
+
62
+ function loadConfig() {
63
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
64
+ }
65
+
66
+ function loadProfile() {
67
+ try {
68
+ const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
69
+ const name = data.active && PROFILES[data.active] ? data.active : 'balanced';
70
+ const custom = data.custom_overrides || {};
71
+ return {
72
+ name,
73
+ budgets: { ...PROFILE_BUDGETS[name], ...custom.budgets },
74
+ gate: PROFILE_GATE[name],
75
+ switched_at: data.switched_at || null,
76
+ };
77
+ } catch {
78
+ return { name: 'balanced', budgets: PROFILE_BUDGETS.balanced, gate: PROFILE_GATE.balanced, switched_at: null };
79
+ }
80
+ }
81
+
82
+ function saveProfile(name, customOverrides) {
83
+ const data = { active: name, switched_at: new Date().toISOString() };
84
+ if (customOverrides) data.custom_overrides = customOverrides;
85
+ const tmp = PROFILE_FILE + '.tmp.' + process.pid;
86
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
87
+ renameSync(tmp, PROFILE_FILE);
88
+ }
89
+
90
+ function saveBudget(sessionLimit, dailyLimit) {
91
+ let existing = {};
92
+ try { existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8')); } catch {}
93
+ const custom = existing.custom_overrides || {};
94
+ custom.budgets = {
95
+ session_warn_usd: +(sessionLimit * 0.6).toFixed(2),
96
+ session_limit_usd: sessionLimit,
97
+ daily_warn_usd: +(dailyLimit * 0.6).toFixed(2),
98
+ daily_limit_usd: dailyLimit,
99
+ };
100
+ const data = { active: existing.active || 'balanced', switched_at: existing.switched_at || new Date().toISOString(), custom_overrides: custom };
101
+ const tmp = PROFILE_FILE + '.tmp.' + process.pid;
102
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
103
+ renameSync(tmp, PROFILE_FILE);
104
+ }
105
+
106
+ function detectProviders() {
107
+ const claude = { authed: false, models: 'opus / sonnet / haiku' };
108
+ const codex = { authed: false, installed: false, models: 'gpt-5.5 / gpt-5.4 / gpt-4.1-mini' };
109
+
110
+ const credPaths = [
111
+ join(process.env.HOME || '', '.claude', '.credentials.json'),
112
+ join(process.env.HOME || '', '.claude', 'credentials.json'),
113
+ ];
114
+ for (const p of credPaths) {
115
+ try {
116
+ const cred = JSON.parse(readFileSync(p, 'utf8'));
117
+ if (cred.claudeAiOauth || cred.apiKey || cred.oauth_token) { claude.authed = true; break; }
118
+ } catch {}
119
+ }
120
+ if (!claude.authed) {
121
+ const r = spawnSync('claude', ['auth', 'status'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
122
+ const out = ((r.stdout || '') + (r.stderr || '')).toLowerCase();
123
+ if (out.includes('logged in') || out.includes('authenticated')) claude.authed = true;
124
+ }
125
+
126
+ const which = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
127
+ if (which.status === 0 && which.stdout.trim()) {
128
+ codex.installed = true;
129
+ const login = spawnSync(which.stdout.trim(), ['login', 'status'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
130
+ const out = ((login.stdout || '') + (login.stderr || '')).toLowerCase();
131
+ if (login.status === 0 || out.includes('logged in') || out.includes('authenticated')) codex.authed = true;
132
+ }
133
+
134
+ return { claude, codex };
135
+ }
136
+
137
+ function loadPressure() {
138
+ try {
139
+ const today = new Date().toISOString().slice(0, 10);
140
+ const summaryPath = join(__dirname, `usage-summary-${today}.json`);
141
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
142
+ const cutoff = Date.now() - 5 * 60 * 60 * 1000;
143
+ const result = {};
144
+ for (const provider of ['claude', 'openai']) {
145
+ result[provider] = {};
146
+ for (const tier of ['think', 'execute', 'search']) {
147
+ const ts = (summary.pressure?.[provider]?.[tier] || []).filter(t => Date.parse(t) >= cutoff);
148
+ const BUDGETS = { think: 45, execute: 364, search: 2000 };
149
+ const calls = ts.length;
150
+ const pressure = Math.min(1, calls / (BUDGETS[tier] || 364));
151
+ result[provider][tier] = { calls, pressure };
152
+ }
153
+ }
154
+ return result;
155
+ } catch {
156
+ return {
157
+ claude: { think: { calls: 0, pressure: 0 }, execute: { calls: 0, pressure: 0 }, search: { calls: 0, pressure: 0 } },
158
+ openai: { think: { calls: 0, pressure: 0 }, execute: { calls: 0, pressure: 0 }, search: { calls: 0, pressure: 0 } },
159
+ };
160
+ }
161
+ }
162
+
163
+ function loadTodayCost() {
164
+ try {
165
+ const today = new Date().toISOString().slice(0, 10);
166
+ const summary = JSON.parse(readFileSync(join(__dirname, `usage-summary-${today}.json`), 'utf8'));
167
+ return summary.totals?.cost_estimate || 0;
168
+ } catch { return 0; }
169
+ }
170
+
171
+ function loadLastDecision() {
172
+ const today = new Date().toISOString().slice(0, 10);
173
+ const logFile = join(__dirname, `usage-${today}.jsonl`);
174
+ if (!existsSync(logFile)) return null;
175
+ try {
176
+ const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
177
+ for (let i = lines.length - 1; i >= 0; i--) {
178
+ try {
179
+ const e = JSON.parse(lines[i]);
180
+ if (e.type === 'tier_recommendation') return e;
181
+ } catch {}
182
+ }
183
+ } catch {}
184
+ return null;
185
+ }
186
+
187
+ // ─── Rendering ─────────────────────────────────────────────────────────────
188
+
189
+ function pressureBar(p, w = 10) {
190
+ const filled = Math.min(w, Math.round(p * w));
191
+ const bar = '▓'.repeat(filled) + '░'.repeat(w - filled);
192
+ const pct = String(Math.round(p * 100)).padStart(3) + '%';
193
+ let stateEmoji, stateLabel;
194
+ if (p >= 0.95) { stateEmoji = '🛑'; stateLabel = c(A.red + A.bold, 'throttled'); }
195
+ else if (p >= 0.82) { stateEmoji = '🔥'; stateLabel = c(A.red, 'hot'); }
196
+ else if (p >= 0.65) { stateEmoji = '🟡'; stateLabel = c(A.yellow, 'warm'); }
197
+ else { stateEmoji = '🟢'; stateLabel = c(A.green, 'healthy'); }
198
+ const barColored = p >= 0.82 ? c(A.red, bar) : p >= 0.65 ? c(A.yellow, bar) : c(A.green, bar);
199
+ return `${barColored} ${pct} ${stateEmoji} ${stateLabel}`;
200
+ }
201
+
202
+ function renderDashboard(state) {
203
+ const { profile, providers, pressure, cost, flash } = state;
204
+ const pf = PROFILES[profile.name];
205
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false });
206
+ const mode = (providers.claude.authed && providers.codex.authed) ? '🧠 Dual brain active' :
207
+ providers.claude.authed ? '🟠 Claude only' :
208
+ providers.codex.authed ? '🟢 OpenAI only' : '🔎 No providers';
209
+
210
+ const lines = [];
211
+ lines.push('');
212
+ lines.push(c(A.bold, ` 🧠 Dual-Brain Control Panel v${VERSION}`) + ` ${c(A.green, '🟢 Live')} ${c(A.dim, time)}`);
213
+ lines.push('');
214
+ lines.push(` ${mode}`);
215
+ lines.push(` 🎛️ Profile ${pf.emoji} ${c(A.bold, pf.label)} ${c(A.dim, pf.desc)}`);
216
+ lines.push(` 💵 Budget Session $${cost.toFixed(2)} / $${profile.budgets.session_limit_usd} Daily / $${profile.budgets.daily_limit_usd}`);
217
+ lines.push(` 🛡️ Gate Reviews ${profile.gate.sensitivity_floor}+ Dual-brain ${profile.gate.dual_brain_minimum}+`);
218
+ lines.push('');
219
+
220
+ lines.push(` 🔌 ${c(A.bold, 'Providers')}`);
221
+ const cStatus = providers.claude.authed ? '✅ authenticated' : '⚠️ not authenticated';
222
+ const xStatus = providers.codex.authed ? '✅ authenticated' : providers.codex.installed ? '⚠️ login needed' : '❌ not found';
223
+ lines.push(` 🟠 Claude ${cStatus} ${c(A.dim, providers.claude.models)}`);
224
+ lines.push(` 🟢 Codex ${xStatus} ${c(A.dim, providers.codex.models)}`);
225
+ lines.push('');
226
+
227
+ lines.push(` 🌡️ ${c(A.bold, 'Pressure')} ${c(A.dim, '— rolling 5h')}`);
228
+ for (const [label, emoji, key] of [['Claude', '🟠', 'claude'], ['OpenAI', '🟢', 'openai']]) {
229
+ lines.push(` ${emoji} ${label}`);
230
+ for (const tier of ['think', 'execute', 'search']) {
231
+ const p = pressure[key]?.[tier] || { pressure: 0 };
232
+ const tierLabel = (tier.charAt(0).toUpperCase() + tier.slice(1)).padEnd(8);
233
+ lines.push(` ${c(A.dim, tierLabel)} ${pressureBar(p.pressure)}`);
234
+ }
235
+ if (key === 'claude') lines.push('');
236
+ }
237
+ lines.push('');
238
+
239
+ if (flash) {
240
+ lines.push(` ${flash}`);
241
+ lines.push('');
242
+ }
243
+
244
+ lines.push(c(A.dim, ' ─'.repeat(30)));
245
+ lines.push(` ⌨️ ${c(A.bold, '1')} Balanced ${c(A.bold, '2')} Cost-saver ${c(A.bold, '3')} Quality-first ${c(A.bold, 'b')} Budget ${c(A.bold, 'e')} Explain ${c(A.bold, 'q')} Quit`);
246
+ lines.push('');
247
+
248
+ return lines.join('\n');
249
+ }
250
+
251
+ function renderExplain(decision, profile) {
252
+ const lines = [];
253
+ lines.push('');
254
+ lines.push(c(A.bold, ' 🧭 Last Routing Decision'));
255
+ lines.push(c(A.dim, ' ' + '─'.repeat(40)));
256
+
257
+ if (!decision) {
258
+ lines.push(' 💤 No routing decisions recorded today.');
259
+ lines.push('');
260
+ lines.push(c(A.dim, ' Press any key to go back'));
261
+ return lines.join('\n');
262
+ }
263
+
264
+ const time = decision.timestamp?.slice(11, 19) || '??:??:??';
265
+ const followed = decision.followed;
266
+ lines.push(` 🕐 Time ${time}`);
267
+ lines.push(` 🔎 Detected ${decision.detected_tier || 'unknown'} tier`);
268
+ lines.push(` 🧠 Recommended ${decision.recommended_model || 'unknown'}`);
269
+ lines.push(` 🎯 Actual ${decision.actual_model || 'unknown'}`);
270
+ lines.push(` ${followed ? '✅' : '⚠️'} Followed ${followed ? 'yes' : 'no'}`);
271
+ lines.push(` 🎛️ Profile ${profile.name}`);
272
+ lines.push('');
273
+
274
+ if (followed) {
275
+ lines.push(' ✅ Routing matched the recommendation.');
276
+ } else {
277
+ lines.push(' ⚠️ Recommendation was overridden.');
278
+ }
279
+
280
+ lines.push('');
281
+ lines.push(c(A.dim, ' Press any key to go back'));
282
+ return lines.join('\n');
283
+ }
284
+
285
+ function renderBudgetEditor(sessionVal, dailyVal, field, flash) {
286
+ const lines = [];
287
+ lines.push('');
288
+ lines.push(c(A.bold, ' 💵 Edit Budget'));
289
+ lines.push(c(A.dim, ' ' + '─'.repeat(40)));
290
+ lines.push('');
291
+
292
+ const sCursor = field === 'session' ? '_' : '';
293
+ const dCursor = field === 'daily' ? '_' : '';
294
+ lines.push(` Session limit: $${sessionVal}${sCursor}${field === 'session' ? c(A.dim, ' ← editing') : ''}`);
295
+ lines.push(` Daily limit: $${dailyVal}${dCursor}${field === 'daily' ? c(A.dim, ' ← editing') : ''}`);
296
+ lines.push('');
297
+
298
+ if (flash) {
299
+ lines.push(` ${flash}`);
300
+ lines.push('');
301
+ }
302
+
303
+ lines.push(c(A.dim, ' Type numbers · Tab next · Enter save · Esc cancel'));
304
+ return lines.join('\n');
305
+ }
306
+
307
+ // ─── Static (non-TTY) Output ───────────────────────────────────────────────
308
+
309
+ function renderStatic() {
310
+ const profile = loadProfile();
311
+ const providers = detectProviders();
312
+ const pressure = loadPressure();
313
+ const cost = loadTodayCost();
314
+ const state = { profile, providers, pressure, cost, flash: null };
315
+ console.log(renderDashboard(state));
316
+ }
317
+
318
+ // ─── Interactive TUI ───────────────────────────────────────────────────────
319
+
320
+ function startTUI() {
321
+ let view = 'dashboard';
322
+ let flash = null;
323
+ let flashTimeout = null;
324
+ let refreshTimer = null;
325
+
326
+ // Budget editor state
327
+ let budgetSession = '';
328
+ let budgetDaily = '';
329
+ let budgetField = 'session';
330
+
331
+ function setFlash(msg, ms = 3000) {
332
+ flash = msg;
333
+ clearTimeout(flashTimeout);
334
+ flashTimeout = setTimeout(() => { flash = null; render(); }, ms);
335
+ }
336
+
337
+ function loadState() {
338
+ return {
339
+ profile: loadProfile(),
340
+ providers: detectProviders(),
341
+ pressure: loadPressure(),
342
+ cost: loadTodayCost(),
343
+ flash,
344
+ };
345
+ }
346
+
347
+ function render() {
348
+ let screen;
349
+ if (view === 'dashboard') {
350
+ screen = renderDashboard(loadState());
351
+ } else if (view === 'explain') {
352
+ const decision = loadLastDecision();
353
+ const profile = loadProfile();
354
+ screen = renderExplain(decision, profile);
355
+ } else if (view === 'budget') {
356
+ screen = renderBudgetEditor(budgetSession, budgetDaily, budgetField, flash);
357
+ }
358
+ process.stdout.write(A.home + A.clear + screen);
359
+ }
360
+
361
+ function startRefresh() {
362
+ stopRefresh();
363
+ refreshTimer = setInterval(render, 2000);
364
+ }
365
+
366
+ function stopRefresh() {
367
+ if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
368
+ }
369
+
370
+ function cleanup() {
371
+ stopRefresh();
372
+ clearTimeout(flashTimeout);
373
+ process.stdin.setRawMode(false);
374
+ process.stdout.write(A.reset + A.show + A.altOff);
375
+ process.exit(0);
376
+ }
377
+
378
+ function switchProfile(name) {
379
+ let customOverrides = null;
380
+ try {
381
+ const existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
382
+ if (existing.custom_overrides?.budgets) customOverrides = { budgets: existing.custom_overrides.budgets };
383
+ } catch {}
384
+ saveProfile(name, customOverrides);
385
+ const pf = PROFILES[name];
386
+ setFlash(`✅ Profile switched: ${pf.emoji} ${pf.label}`);
387
+ render();
388
+ }
389
+
390
+ // Setup
391
+ process.stdout.write(A.altOn + A.hide);
392
+ readline.emitKeypressEvents(process.stdin);
393
+ process.stdin.setRawMode(true);
394
+ process.stdin.resume();
395
+
396
+ process.on('SIGINT', cleanup);
397
+ process.on('SIGTERM', cleanup);
398
+ process.on('uncaughtException', (err) => {
399
+ cleanup();
400
+ console.error(err);
401
+ });
402
+
403
+ render();
404
+ startRefresh();
405
+
406
+ process.stdin.on('keypress', (str, key) => {
407
+ if (key?.ctrl && key?.name === 'c') return cleanup();
408
+
409
+ if (view === 'budget') {
410
+ if (key?.name === 'escape') {
411
+ view = 'dashboard';
412
+ startRefresh();
413
+ render();
414
+ return;
415
+ }
416
+ if (key?.name === 'tab') {
417
+ budgetField = budgetField === 'session' ? 'daily' : 'session';
418
+ render();
419
+ return;
420
+ }
421
+ if (key?.name === 'return') {
422
+ const s = parseFloat(budgetSession);
423
+ const d = parseFloat(budgetDaily);
424
+ if (isNaN(s) || s <= 0) { setFlash('❌ Invalid session limit'); render(); return; }
425
+ const daily = (isNaN(d) || d <= 0) ? s * 3 : d;
426
+ saveBudget(s, daily);
427
+ view = 'dashboard';
428
+ startRefresh();
429
+ setFlash(`✅ Budget updated: Session $${s} · Daily $${daily}`);
430
+ render();
431
+ return;
432
+ }
433
+ if (key?.name === 'backspace') {
434
+ if (budgetField === 'session') budgetSession = budgetSession.slice(0, -1);
435
+ else budgetDaily = budgetDaily.slice(0, -1);
436
+ render();
437
+ return;
438
+ }
439
+ if (str && /[0-9.]/.test(str)) {
440
+ if (budgetField === 'session') budgetSession += str;
441
+ else budgetDaily += str;
442
+ render();
443
+ return;
444
+ }
445
+ return;
446
+ }
447
+
448
+ if (view === 'explain') {
449
+ view = 'dashboard';
450
+ startRefresh();
451
+ render();
452
+ return;
453
+ }
454
+
455
+ // Dashboard keys
456
+ if (key?.name === 'q' || key?.name === 'escape') return cleanup();
457
+ if (str === '1') return switchProfile('balanced');
458
+ if (str === '2') return switchProfile('cost-saver');
459
+ if (str === '3') return switchProfile('quality-first');
460
+ if (str === 'r') { render(); return; }
461
+ if (str === 'e') {
462
+ view = 'explain';
463
+ stopRefresh();
464
+ render();
465
+ return;
466
+ }
467
+ if (str === 'b') {
468
+ view = 'budget';
469
+ stopRefresh();
470
+ const profile = loadProfile();
471
+ budgetSession = String(profile.budgets.session_limit_usd);
472
+ budgetDaily = String(profile.budgets.daily_limit_usd);
473
+ budgetField = 'session';
474
+ flash = null;
475
+ render();
476
+ return;
477
+ }
478
+ });
479
+ }
480
+
481
+ // ─── Entry ─────────────────────────────────────────────────────────────────
482
+
483
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
484
+
485
+ if (interactive) {
486
+ startTUI();
487
+ } else {
488
+ renderStatic();
489
+ }
@@ -19,7 +19,11 @@ import { fileURLToPath } from 'url';
19
19
 
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
21
 
22
- const REVIEW_PROMPT = `Review the current uncommitted changes in this repo for:
22
+ const REVIEW_PROMPT_R1 = `You are GPT-5.5 performing Round 1 of a dual-brain code review.
23
+ Claude (Opus) will independently review the same changes, then send you their findings
24
+ for a collaborative Round 2 discussion.
25
+
26
+ Review the current uncommitted changes for:
23
27
  1. Correctness — logic errors, off-by-one, null/undefined risks
24
28
  2. Security — injection, auth bypass, data exposure
25
29
  3. Edge cases — what could break under unusual input
@@ -34,6 +38,24 @@ Required output:
34
38
 
35
39
  Be concise. Flag only real issues, not style preferences. If the code looks good, say "LGTM" and note any minor suggestions. Output your review as plain text, not JSON.`;
36
40
 
41
+ const REVIEW_PROMPT_R2 = `You are GPT-5.5 in Round 2 of a collaborative code review with Claude (Opus).
42
+ You already reviewed this diff in Round 1. Claude has now independently reviewed the same changes.
43
+ This is a professional peer review dialogue — two senior engineers refining their assessment together.
44
+
45
+ Claude's review findings:
46
+ ---CLAUDE_REVIEW---
47
+
48
+ Now respond as a peer reviewer:
49
+ 1. CONFIRMED: Issues you both found — these are high-confidence findings
50
+ 2. MISSED: Issues Claude caught that you missed — acknowledge them
51
+ 3. DISAGREE: Claude's findings you think are false positives — explain why
52
+ 4. ESCALATED: Issues that are MORE severe than either of you initially rated
53
+ 5. VERDICT: Combined assessment — LGTM, minor issues, or blocks merge
54
+
55
+ Be direct. If Claude found something real that you missed, say so.
56
+ If Claude flagged something that isn't actually a problem, explain why with evidence.
57
+ The goal is the most accurate review, not defending your initial take.`;
58
+
37
59
  function loadReviewRules() {
38
60
  const rulesFile = resolve(__dirname, '..', 'review-rules.md');
39
61
  try {
@@ -127,9 +149,9 @@ function exit(obj) {
127
149
 
128
150
  /**
129
151
  * Try GPT review via Codex CLI (uses ChatGPT subscription auth).
130
- * Returns review text or null if codex isn't available.
152
+ * Round 1: independent review. Round 2: respond to Claude's review.
131
153
  */
132
- function tryCodexReview(diff) {
154
+ function tryCodexReview(diff, { round = 1, claudeReview = null } = {}) {
133
155
  if (!CODEX_BIN) return null;
134
156
  try {
135
157
  spawnSync(CODEX_BIN, ['login', 'status'], {
@@ -145,7 +167,14 @@ function tryCodexReview(diff) {
145
167
  ? diff.slice(0, MAX_DIFF_CHARS) + '\n[truncated]'
146
168
  : diff;
147
169
 
148
- const fullPrompt = REVIEW_PROMPT + loadReviewRules();
170
+ let basePrompt;
171
+ if (round === 2 && claudeReview) {
172
+ basePrompt = REVIEW_PROMPT_R2.replace('---CLAUDE_REVIEW---', claudeReview);
173
+ } else {
174
+ basePrompt = REVIEW_PROMPT_R1;
175
+ }
176
+ const fullPrompt = basePrompt + loadReviewRules();
177
+
149
178
  const proc = spawnSync(CODEX_BIN, [
150
179
  'exec', '--json', '--ephemeral',
151
180
  '-c', `model="${model}"`,
@@ -159,7 +188,6 @@ function tryCodexReview(diff) {
159
188
  });
160
189
  const result = proc.stdout || '';
161
190
 
162
- // Parse JSONL output, find agent_message items
163
191
  const messages = result
164
192
  .split('\n')
165
193
  .filter(l => l.trim())
@@ -173,16 +201,17 @@ function tryCodexReview(diff) {
173
201
  const usage = messages.find(m => m.type === 'turn.completed')?.usage;
174
202
 
175
203
  if (agentMessages.length > 0) {
204
+ const reviewText = agentMessages.join('\n\n');
176
205
  return {
177
- review: agentMessages.join('\n\n'),
206
+ round,
207
+ review: reviewText,
178
208
  model,
179
209
  auth_type: 'codex_subscription',
180
- issues_found: hasIssues(agentMessages.join(' ')),
210
+ issues_found: hasIssues(reviewText),
181
211
  tokens: usage || null,
182
212
  };
183
213
  }
184
214
 
185
- // Check for errors
186
215
  const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
187
216
  if (errors.length > 0) {
188
217
  return {
@@ -205,7 +234,7 @@ function tryCodexReview(diff) {
205
234
  /**
206
235
  * Try GPT review via direct API call (needs OPENAI_API_KEY).
207
236
  */
208
- async function tryApiReview(diff) {
237
+ async function tryApiReview(diff, { round = 1, claudeReview = null } = {}) {
209
238
  const apiKey = process.env.OPENAI_API_KEY;
210
239
  if (!apiKey) return null;
211
240
 
@@ -214,7 +243,14 @@ async function tryApiReview(diff) {
214
243
  ? diff.slice(0, MAX_DIFF_CHARS) + '\n[truncated]'
215
244
  : diff;
216
245
 
217
- const fullPrompt = REVIEW_PROMPT + loadReviewRules();
246
+ let basePrompt;
247
+ if (round === 2 && claudeReview) {
248
+ basePrompt = REVIEW_PROMPT_R2.replace('---CLAUDE_REVIEW---', claudeReview);
249
+ } else {
250
+ basePrompt = REVIEW_PROMPT_R1;
251
+ }
252
+ const fullPrompt = basePrompt + loadReviewRules();
253
+
218
254
  const controller = new AbortController();
219
255
  const timer = setTimeout(() => controller.abort(), 30_000);
220
256
 
@@ -245,6 +281,7 @@ async function tryApiReview(diff) {
245
281
  if (!text) return null;
246
282
 
247
283
  return {
284
+ round,
248
285
  review: text,
249
286
  model,
250
287
  auth_type: 'api_key',
@@ -256,7 +293,37 @@ async function tryApiReview(diff) {
256
293
  }
257
294
  }
258
295
 
296
+ function parseArgs(argv) {
297
+ const args = {};
298
+ let i = 0;
299
+ while (i < argv.length) {
300
+ const arg = argv[i];
301
+ if (arg.startsWith('--')) {
302
+ const eqIdx = arg.indexOf('=');
303
+ if (eqIdx !== -1) {
304
+ args[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
305
+ } else {
306
+ const key = arg.slice(2);
307
+ const next = argv[i + 1];
308
+ if (next !== undefined && !next.startsWith('--')) {
309
+ args[key] = next;
310
+ i++;
311
+ } else {
312
+ args[key] = true;
313
+ }
314
+ }
315
+ }
316
+ i++;
317
+ }
318
+ return args;
319
+ }
320
+
259
321
  async function main() {
322
+ const args = parseArgs(process.argv.slice(2));
323
+ const round = args.round ? parseInt(args.round, 10) : 1;
324
+ const claudeReview = args['claude-review'] || null;
325
+ const opts = { round, claudeReview };
326
+
260
327
  // 1. Get diff
261
328
  let diff = runGit('git diff --staged') || '';
262
329
  if (countLines(diff) < MIN_DIFF_LINES) {
@@ -264,12 +331,11 @@ async function main() {
264
331
  if (countLines(headDiff) > countLines(diff)) diff = headDiff;
265
332
  }
266
333
 
267
- // Also gather content of untracked source files
268
334
  try {
269
335
  const untracked = runGit('git ls-files --others --exclude-standard') || '';
270
336
  const sourceExts = /\.(ts|tsx|js|jsx|py|rs|go|java|rb|swift|kt|mjs|cjs)$/;
271
337
  const untrackedSrc = untracked.split('\n').filter(f => f && sourceExts.test(f));
272
- for (const f of untrackedSrc.slice(0, 10)) { // cap at 10 files
338
+ for (const f of untrackedSrc.slice(0, 10)) {
273
339
  const content = runGit(`git diff --no-index /dev/null "${f}"`);
274
340
  if (content) diff += '\n' + content;
275
341
  }
@@ -279,13 +345,36 @@ async function main() {
279
345
  exit({ review: 'No significant changes to review' });
280
346
  }
281
347
 
282
- // 2. Try Codex CLI first (uses ChatGPT subscription)
283
- const codexResult = tryCodexReview(diff);
284
- if (codexResult) exit(codexResult);
348
+ // 2. Try Codex CLI first
349
+ const codexResult = tryCodexReview(diff, opts);
350
+ if (codexResult) {
351
+ if (round === 1) {
352
+ codexResult.instructions = `Round 1 complete. Now:
353
+ 1. Provide YOUR independent code review of the same changes
354
+ 2. Then call Round 2 to send your findings back to GPT:
355
+ node .claude/hooks/dual-brain-review.mjs --round 2 --claude-review "<your findings>"
356
+ 3. GPT will respond — confirming shared findings, acknowledging misses, and pushing back on false positives
357
+ 4. You then synthesize both rounds into the final review verdict`;
358
+ } else {
359
+ codexResult.instructions = `GPT has responded to your review. Synthesize into a FINAL REVIEW:
360
+ - CONFIRMED findings (both found) → high confidence, must fix
361
+ - GPT-only findings you agree with → add to your list
362
+ - Your findings GPT disputed → re-evaluate honestly
363
+ - Final verdict: LGTM, minor issues, or blocks merge`;
364
+ }
365
+ exit(codexResult);
366
+ }
285
367
 
286
368
  // 3. Try direct API
287
- const apiResult = await tryApiReview(diff);
288
- if (apiResult) exit(apiResult);
369
+ const apiResult = await tryApiReview(diff, opts);
370
+ if (apiResult) {
371
+ if (round === 1) {
372
+ apiResult.instructions = `Round 1 complete. Provide YOUR independent review, then call Round 2 with --round 2 --claude-review "<findings>"`;
373
+ } else {
374
+ apiResult.instructions = `Synthesize both rounds into a final review verdict.`;
375
+ }
376
+ exit(apiResult);
377
+ }
289
378
 
290
379
  // 4. No GPT available
291
380
  exit({
@@ -60,8 +60,33 @@ function findCodex() {
60
60
  // Prompt builder
61
61
  // ---------------------------------------------------------------------------
62
62
 
63
- function buildGptPrompt({ question, context, files }) {
63
+ function buildGptPrompt({ question, context, files, round, claudePerspective }) {
64
+ if (round === 2 && claudePerspective) {
65
+ return `You are GPT-5.5 in a collaborative architectural discussion with Claude (Opus).
66
+ You gave your initial analysis on a question. Claude has now provided its independent perspective.
67
+ This is a professional dialogue — two experts refining a decision together.
68
+
69
+ Original question: ${question}
70
+ ${context ? `\nContext: ${context}` : ''}
71
+
72
+ Claude's perspective:
73
+ ${claudePerspective}
74
+
75
+ Now respond as a colleague, not a critic. Structure your response:
76
+ 1. AGREEMENTS: Where Claude's analysis strengthens or confirms your thinking
77
+ 2. PUSHBACK: Where you disagree — be specific about WHY with evidence or reasoning
78
+ 3. NEW INSIGHTS: Anything Claude's perspective surfaced that you missed
79
+ 4. REFINED RECOMMENDATION: Your updated recommendation incorporating both perspectives
80
+ 5. REMAINING CONCERNS: Open questions neither of you fully resolved
81
+ 6. CONFIDENCE DELTA: Has your confidence changed? Why?
82
+
83
+ Be direct and substantive. If Claude is right about something you got wrong, say so.
84
+ If you still disagree after considering their points, explain what specific evidence would change your mind.`;
85
+ }
86
+
64
87
  return `You are GPT-5.5, providing an independent architectural perspective.
88
+ This is Round 1 of a dual-brain analysis — Claude (Opus) will independently analyze the same question,
89
+ then send you their perspective for a collaborative discussion in Round 2.
65
90
 
66
91
  Question: ${question}
67
92
  ${context ? `\nContext: ${context}` : ''}
@@ -165,7 +190,7 @@ function logUsage({ durationMs, usage, success }) {
165
190
  // Core exported function
166
191
  // ---------------------------------------------------------------------------
167
192
 
168
- export async function dualThink({ question, context, files } = {}) {
193
+ export async function dualThink({ question, context, files, round, claudePerspective } = {}) {
169
194
  if (!question) {
170
195
  return {
171
196
  gpt: null,
@@ -174,6 +199,8 @@ export async function dualThink({ question, context, files } = {}) {
174
199
  };
175
200
  }
176
201
 
202
+ const effectiveRound = (round === 2 && claudePerspective) ? 2 : 1;
203
+
177
204
  const codexBin = findCodex();
178
205
  if (!codexBin) {
179
206
  return {
@@ -183,7 +210,6 @@ export async function dualThink({ question, context, files } = {}) {
183
210
  };
184
211
  }
185
212
 
186
- // Check Codex auth before running
187
213
  try {
188
214
  execSync(`${codexBin} login status`, {
189
215
  encoding: 'utf8',
@@ -198,7 +224,7 @@ export async function dualThink({ question, context, files } = {}) {
198
224
  };
199
225
  }
200
226
 
201
- const prompt = buildGptPrompt({ question, context, files });
227
+ const prompt = buildGptPrompt({ question, context, files, round: effectiveRound, claudePerspective });
202
228
  const raw = runGptAnalysis(codexBin, prompt);
203
229
 
204
230
  logUsage({ durationMs: raw.durationMs, usage: raw.usage, success: raw.success });
@@ -207,18 +233,44 @@ export async function dualThink({ question, context, files } = {}) {
207
233
  return {
208
234
  gpt: null,
209
235
  error: raw.error || 'GPT analysis failed',
210
- fallback: 'Proceed with single-brain analysis on Claude Opus',
236
+ fallback: effectiveRound === 2
237
+ ? 'GPT rebuttal unavailable — synthesize from Round 1 analysis alone'
238
+ : 'Proceed with single-brain analysis on Claude Opus',
239
+ };
240
+ }
241
+
242
+ if (effectiveRound === 2) {
243
+ return {
244
+ round: 2,
245
+ gpt: {
246
+ rebuttal: raw.text,
247
+ model: MODEL,
248
+ durationMs: raw.durationMs,
249
+ tokens: raw.usage,
250
+ },
251
+ instructions: `GPT has responded to your analysis. Now synthesize both rounds into a FINAL DECISION:
252
+ 1. Where you both agree → high confidence, proceed
253
+ 2. Where GPT pushed back on your points → re-evaluate honestly
254
+ 3. Where you still disagree → state why and what evidence would resolve it
255
+ 4. Final recommendation with combined confidence level`,
256
+ question,
211
257
  };
212
258
  }
213
259
 
214
260
  return {
261
+ round: 1,
215
262
  gpt: {
216
263
  recommendation: raw.text,
217
264
  model: MODEL,
218
265
  durationMs: raw.durationMs,
219
266
  tokens: raw.usage,
220
267
  },
221
- instructions: 'Now provide YOUR independent analysis of the same question. Then compare both perspectives and make a final decision. If you disagree with GPT, explain why with evidence.',
268
+ instructions: `Round 1 complete. Now:
269
+ 1. Provide YOUR independent analysis of the same question (same structure: recommendation, rationale, alternatives, risks, confidence, verification)
270
+ 2. Then call Round 2 to send your perspective back to GPT:
271
+ node .claude/hooks/dual-brain-think.mjs --question "<same question>" --round 2 --claude-says "<your analysis summary>"
272
+ 3. GPT will respond to your specific points — agreements, pushback, and refined recommendation
273
+ 4. You then synthesize both rounds into the final decision`,
222
274
  question,
223
275
  context: context || null,
224
276
  };
@@ -268,32 +320,41 @@ function printResult(result, question) {
268
320
  const TOP = '╔══════════════════════════════════════════════════╗';
269
321
  const BOT = '╚══════════════════════════════════════════════════╝';
270
322
 
323
+ const roundLabel = result.round === 2 ? 'Round 2 — Rebuttal' : 'Round 1 — Initial';
324
+
271
325
  console.log(TOP);
272
- console.log('║ Dual-Brain Think ║');
326
+ console.log(`║ 🧠 Dual-Brain Think · ${roundLabel}`.padEnd(51) + '║');
273
327
  console.log(BAR);
274
- // Truncate question to fit the box
275
328
  const q = question.length > 44 ? question.slice(0, 41) + '...' : question;
276
329
  console.log(`║ Question: ${q.padEnd(38)} ║`);
277
330
  console.log(BAR);
278
331
 
279
332
  if (!result.gpt) {
280
- // Failure path
281
- console.log(`║ ERROR: ${(result.error || 'Unknown error').padEnd(41)} ║`);
333
+ console.log(`║ ${(result.error || 'Unknown error').padEnd(45)} ║`);
282
334
  console.log(BAR);
283
- console.log(`║ Fallback: ${(result.fallback || '').padEnd(39)} ║`);
335
+ console.log(`║ ↩️ ${(result.fallback || '').padEnd(45)} ║`);
284
336
  console.log(BOT);
285
337
  return;
286
338
  }
287
339
 
288
- const durSec = (result.gpt.durationMs / 1000).toFixed(1);
289
- console.log(`║ GPT-5.5 Perspective (${MODEL}, ${durSec}s):`.padEnd(51) + '║');
340
+ const gptData = result.gpt;
341
+ const durSec = (gptData.durationMs / 1000).toFixed(1);
342
+ console.log(`║ 🤖 GPT-5.5 (${durSec}s):`.padEnd(51) + '║');
290
343
  console.log(BAR);
291
344
  console.log('');
292
- console.log(result.gpt.recommendation);
345
+ console.log(gptData.recommendation || gptData.rebuttal);
293
346
  console.log('');
294
347
  console.log(BAR);
295
- console.log('║ Now: Provide YOUR analysis and compare. ║');
296
- console.log('║ If you disagree, explain why with evidence. ║');
348
+
349
+ if (result.round === 2) {
350
+ console.log('║ 🔄 Synthesize both rounds into final decision. ║');
351
+ console.log('║ Where you agree → high confidence. ║');
352
+ console.log('║ Where you disagree → state what would resolve it.║');
353
+ } else {
354
+ console.log('║ 📝 Your turn: analyze independently, then call ║');
355
+ console.log('║ Round 2 with --round 2 --claude-says "..." ║');
356
+ console.log('║ for GPT\'s rebuttal to your analysis. ║');
357
+ }
297
358
  console.log(BOT);
298
359
  }
299
360
 
@@ -306,7 +367,8 @@ if (import.meta.url === `file://${process.argv[1]}`) {
306
367
 
307
368
  if (!args.question) {
308
369
  console.error(
309
- 'Usage: node dual-brain-think.mjs --question "<question>" [--context "<context>"] [--files file1,file2]'
370
+ 'Usage: node dual-brain-think.mjs --question "<question>" [--context "<ctx>"] [--files f1,f2]\n' +
371
+ ' node dual-brain-think.mjs --question "<question>" --round 2 --claude-says "<analysis>"'
310
372
  );
311
373
  process.exit(1);
312
374
  }
@@ -315,6 +377,8 @@ if (import.meta.url === `file://${process.argv[1]}`) {
315
377
  question: args.question,
316
378
  context: args.context,
317
379
  files: args.files,
380
+ round: args.round ? parseInt(args.round, 10) : 1,
381
+ claudePerspective: args['claude-says'] || null,
318
382
  });
319
383
 
320
384
  printResult(result, args.question);
package/install.mjs CHANGED
@@ -39,34 +39,34 @@ if (flag('--version') || flag('-v')) {
39
39
 
40
40
  if (flag('--help') || flag('-h')) {
41
41
  console.log(`
42
- dual-brain v${VERSION} — Dual-provider orchestrator for Claude Code
42
+ 🧠 dual-brain v${VERSION} — Dual-provider orchestrator for Claude Code
43
43
 
44
44
  Usage: npx -y dual-brain [command] [options]
45
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)
46
+ ⌨️ Commands:
47
+ (none) 🧠 Auto-detect and install/update orchestrator
48
+ status 🟢 Open live control panel
49
+ mode 🎛️ Show or switch profile
50
+ budget 💵 Set session/daily spend limits
51
+ explain 🧭 Explain last routing decision
52
+ init Alias for default install
53
53
 
54
54
  Options:
55
- --force Overwrite all existing config (keeps review-rules.md)
56
- --dry-run Detect environment only, don't install
57
- --json Output detection as JSON (implies --dry-run)
55
+ --force Overwrite all existing config
56
+ --dry-run Detect environment only
57
+ --json Output detection as JSON
58
58
  --help Show this help
59
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
60
+ 🎛️ Profiles:
61
+ ⚖️ balanced Standard routing — best model per tier
62
+ 💸 cost-saver Minimize spend — prefer cheaper models
63
+ 💎 quality-first Maximum quality — dual-brain for medium+
64
64
 
65
- Examples:
65
+ 🚀 Examples:
66
66
  ${cmd('npx dual-brain')} # install or update
67
- ${cmd('npx dual-brain status')} # live dashboard
67
+ ${cmd('npx dual-brain status')} # open control panel
68
68
  ${cmd('npx dual-brain mode cost-saver')} # switch profile
69
- ${cmd('npx dual-brain budget 8 25')} # $8 session / $25 daily
69
+ ${cmd('npx dual-brain budget 8 25')} # \$8 session / \$25 daily
70
70
  ${cmd('npx dual-brain explain')} # last routing decision
71
71
  `);
72
72
  process.exit(0);
@@ -381,43 +381,50 @@ function install(workspace, env, mode) {
381
381
 
382
382
  // ─── Status Report ──────────────────────────────────────────────────────────
383
383
 
384
- function statusIcon(val) { return val ? '' : ''; }
384
+ function statusIcon(val) { return val ? '' : ''; }
385
+
386
+ const MODE_EMOJIS = {
387
+ 'dual': '🧠',
388
+ 'claude-only': '🟠',
389
+ 'openai-only': '🟢',
390
+ 'detect-only': '🔎',
391
+ };
385
392
 
386
393
  function printReport(env, mode, actions) {
387
394
  const lines = [];
388
395
 
389
396
  lines.push(br('╔', '╗'));
390
- lines.push(ln(`Dual-Brain Orchestrator v${VERSION}`));
397
+ lines.push(ln(`🧠 Dual-Brain Orchestrator v${VERSION}`));
391
398
  lines.push(sep());
392
399
 
393
- lines.push(ln('Environment'));
400
+ lines.push(ln('🌎 Environment'));
394
401
  if (env.isReplit) {
395
- lines.push(ln(` Platform: Replit${env.hasReplitTools ? ' (replit-tools detected)' : ''}`));
402
+ lines.push(ln(` 🌀 Platform: Replit${env.hasReplitTools ? ' + replit-tools' : ''}`));
396
403
  } else {
397
404
  lines.push(ln(' Platform: standalone'));
398
405
  }
399
406
 
400
407
  const cVer = env.claude.version ? ` ${env.claude.version}` : '';
401
- const cAuth = env.claude.authed ? 'authenticated' : env.claude.installed ? 'not authenticated' : 'not found';
402
- lines.push(ln(` Claude CLI: ${statusIcon(env.claude.authed)} ${cAuth}${cVer}`));
408
+ const cAuth = env.claude.authed ? 'authenticated' : env.claude.installed ? '⚠️ login needed' : 'not found';
409
+ lines.push(ln(` 🟠 Claude: ${cAuth}${cVer}`));
403
410
 
404
411
  const xVer = env.codex.version ? ` ${env.codex.version}` : '';
405
- const xAuth = env.codex.authed ? 'authenticated' : env.codex.installed ? 'not authenticated' : 'not found';
406
- lines.push(ln(` Codex CLI: ${statusIcon(env.codex.authed)} ${xAuth}${xVer}`));
412
+ const xAuth = env.codex.authed ? 'authenticated' : env.codex.installed ? '⚠️ login needed' : 'not found';
413
+ lines.push(ln(` 🟢 Codex: ${xAuth}${xVer}`));
407
414
 
408
415
  lines.push(sep());
409
- lines.push(ln(`Mode: ${MODE_LABELS[mode.mode]}`));
416
+ lines.push(ln(`${MODE_EMOJIS[mode.mode] || '🧠'} Mode: ${MODE_LABELS[mode.mode]}`));
410
417
 
411
418
  if (actions) {
412
419
  lines.push(sep());
413
- lines.push(ln('Installed'));
420
+ lines.push(ln('📝 Installed'));
414
421
  for (const a of actions) lines.push(ln(` ${a}`));
415
422
  }
416
423
 
417
424
  const needsAction = !env.claude.authed || !env.codex.authed;
418
425
  if (needsAction && mode.mode !== 'dual') {
419
426
  lines.push(sep());
420
- lines.push(ln('To unlock full features:'));
427
+ lines.push(ln('🔓 Unlock full power:'));
421
428
  if (!env.claude.installed) {
422
429
  lines.push(ln(' curl -fsSL https://claude.ai/install.sh | sh'));
423
430
  }
@@ -436,8 +443,8 @@ function printReport(env, mode, actions) {
436
443
  lines.push(sep());
437
444
  if (actions) {
438
445
  lines.push(ln(mode.mode === 'dual'
439
- ? 'Ready both providers active, no restart needed'
440
- : 'Ready hooks active, run commands above for full power'));
446
+ ? 'Ready: both providers active, no restart needed'
447
+ : 'Ready: hooks active, run commands above for full power'));
441
448
  } else {
442
449
  lines.push(ln('Dry run — no files written'));
443
450
  }
@@ -448,35 +455,26 @@ function printReport(env, mode, actions) {
448
455
  console.log('');
449
456
 
450
457
  if (actions) {
451
- console.log(' What just happened:');
452
- console.log(' Every Claude Code session in this project now auto-routes');
453
- console.log(' agent work by complexity — cheap models for search, mid-tier');
454
- console.log(' for execution, best models for thinking. Cost is tracked.');
458
+ console.log(' 🧭 What changed:');
459
+ console.log(' Every Claude Code session now auto-routes agent work by');
460
+ console.log(' complexity — cheap models for search, mid-tier for execution,');
461
+ console.log(' best models for thinking. Cost is tracked automatically.');
455
462
  if (mode.mode === 'dual') {
456
- console.log(' Both Claude and GPT are available as work providers.');
463
+ console.log(' 🧠 Both Claude and GPT are available as work providers.');
457
464
  }
458
465
  console.log('');
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
- }
466
+ console.log(' ⌨️ Open the control panel:');
467
+ console.log(` ${cmd('npx dual-brain status')}`);
470
468
  console.log('');
471
- console.log(' In-session tools (ask Claude to run these):');
469
+ console.log(' 🩺 In-session tools (ask Claude to run):');
472
470
  console.log(' node .claude/hooks/health-check.mjs # verify setup');
473
471
  console.log(' node .claude/hooks/cost-report.mjs # see activity');
474
- console.log(' node .claude/hooks/budget-balancer.mjs # provider balance');
472
+ console.log(' node .claude/hooks/decision-ledger.mjs # routing insights');
475
473
  if (mode.openaiEnabled) {
476
474
  console.log(' node .claude/hooks/dual-brain-review.mjs # GPT code review');
477
475
  }
478
476
  console.log('');
479
- console.log(' Customize:');
477
+ console.log(' ⚙️ Customize:');
480
478
  console.log(' .claude/review-rules.md # your project\'s review rules');
481
479
  console.log(' .claude/orchestrator.json # routing, budgets, tiers');
482
480
  console.log('');
@@ -616,12 +614,13 @@ function cmdMode() {
616
614
 
617
615
  if (!modeArg || modeArg === 'list') {
618
616
  const current = loadProfile(workspace);
617
+ const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '💸', 'quality-first': '💎' };
619
618
  console.log('');
620
- console.log(' Available profiles:');
619
+ console.log(' 🎛️ Profiles:');
621
620
  console.log('');
622
621
  for (const [name, p] of Object.entries(PROFILES)) {
623
- const active = name === current.name ? ' active' : '';
624
- console.log(` ${name.padEnd(15)} ${p.description}${active}`);
622
+ const active = name === current.name ? ' active' : '';
623
+ console.log(` ${PEMOJIS[name] || ' '} ${name.padEnd(15)} ${p.description}${active}`);
625
624
  }
626
625
  console.log('');
627
626
  console.log(` Switch: ${cmd('npx dual-brain mode <profile>')}`);
@@ -647,17 +646,18 @@ function cmdMode() {
647
646
 
648
647
  saveProfile(workspace, modeArg, customOverrides);
649
648
 
649
+ const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '💸', 'quality-first': '💎' };
650
650
  console.log('');
651
- console.log(` Profile switched to: ${modeArg}`);
651
+ console.log(` Profile switched: ${PEMOJIS[modeArg] || ''} ${modeArg}`);
652
652
  console.log(` ${profile.description}`);
653
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+`);
654
+ console.log(' 🧭 Routing changes:');
655
+ console.log(` Provider: ${profile.routing.prefer_provider}`);
656
+ console.log(` 💵 Budget: $${profile.budgets.session_limit_usd}/session, $${profile.budgets.daily_limit_usd}/day`);
657
+ console.log(` 🛡️ Reviews: ${profile.quality_gate.sensitivity_floor} risk+`);
658
+ console.log(` 🧠 Dual-brain: ${profile.quality_gate.dual_brain_minimum} risk+`);
659
659
  console.log('');
660
- console.log(' Active immediately no restart needed.');
660
+ console.log(' 🟢 Active immediately, no restart needed.');
661
661
  console.log('');
662
662
  }
663
663
 
@@ -671,9 +671,9 @@ function cmdBudget() {
671
671
  if (sessionArg == null) {
672
672
  const profile = loadProfile(workspace);
673
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}`);
674
+ console.log(' 💵 Current budget:');
675
+ console.log(` Session: ⚠️ $${profile.budgets.session_warn_usd} warn · 🛑 $${profile.budgets.session_limit_usd} limit`);
676
+ console.log(` Daily: ⚠️ $${profile.budgets.daily_warn_usd} warn · 🛑 $${profile.budgets.daily_limit_usd} limit`);
677
677
  console.log('');
678
678
  console.log(` Set limits: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
679
679
  console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
@@ -710,11 +710,11 @@ function cmdBudget() {
710
710
  renameSync(budgetTmp, budgetTarget);
711
711
 
712
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}`);
713
+ console.log(' Budget updated:');
714
+ console.log(` Session: ⚠️ $${customOverrides.budgets.session_warn_usd} warn · 🛑 $${sessionArg} limit`);
715
+ console.log(` Daily: ⚠️ $${customOverrides.budgets.daily_warn_usd} warn · 🛑 $${daily} limit`);
716
716
  console.log('');
717
- console.log(' Active immediately no restart needed.');
717
+ console.log(' 🟢 Active immediately, no restart needed.');
718
718
  console.log('');
719
719
  }
720
720
 
@@ -728,7 +728,7 @@ function cmdExplain() {
728
728
 
729
729
  if (!existsSync(logFile)) {
730
730
  console.log('');
731
- console.log(' No routing decisions recorded today.');
731
+ console.log(' 💤 No routing decisions recorded today.');
732
732
  console.log(' Start a Claude Code session and the tier enforcer will log decisions.');
733
733
  console.log('');
734
734
  return;
@@ -752,7 +752,7 @@ function cmdExplain() {
752
752
 
753
753
  if (!lastRec) {
754
754
  console.log('');
755
- console.log(' No routing decisions found in today\'s log.');
755
+ console.log(' 💤 No routing decisions found in today\'s log.');
756
756
  console.log(' The tier enforcer logs decisions when Agent tool is used.');
757
757
  console.log('');
758
758
  return;
@@ -761,23 +761,23 @@ function cmdExplain() {
761
761
  const profile = loadProfile(workspace);
762
762
 
763
763
  console.log('');
764
- console.log(' Last Routing Decision');
764
+ console.log(' 🧭 Last Routing Decision');
765
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}`);
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(` ${lastRec.followed ? '✅' : '⚠️'} Followed: ${lastRec.followed ? 'yes' : 'no'}`);
771
+ console.log(` 🎛️ Profile: ${profile.name}`);
772
772
  console.log('');
773
773
 
774
774
  if (!lastRec.followed) {
775
- console.log(' The recommendation was not followed. This may mean:');
775
+ console.log(' ⚠️ Recommendation was overridden. This may mean:');
776
776
  console.log(' - The task needed a different model (valid override)');
777
777
  console.log(' - The subagent_type forced a specific tier');
778
778
  console.log(` - Profile "${profile.name}" adjusted the threshold`);
779
779
  } else {
780
- console.log(' The recommendation was followed — routing worked as expected.');
780
+ console.log(' Routing matched the recommendation.');
781
781
  }
782
782
 
783
783
  let total = 0, followed = 0;
@@ -796,7 +796,18 @@ function cmdExplain() {
796
796
  // ─── Main ───────────────────────────────────────────────────────────────────
797
797
 
798
798
  function main() {
799
- if (subcommand === 'status') { cmdStatus(); return; }
799
+ if (subcommand === 'status') {
800
+ // Launch interactive TUI if available and TTY
801
+ const panelPath = join(resolve(process.cwd()), '.claude', 'hooks', 'control-panel.mjs');
802
+ const pkgPanel = join(__dirname, 'hooks', 'control-panel.mjs');
803
+ const panel = existsSync(panelPath) ? panelPath : existsSync(pkgPanel) ? pkgPanel : null;
804
+ if (panel && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
805
+ const { status } = spawnSync(process.execPath, [panel], { stdio: 'inherit' });
806
+ process.exit(status || 0);
807
+ }
808
+ cmdStatus();
809
+ return;
810
+ }
800
811
  if (subcommand === 'mode') { cmdMode(); return; }
801
812
  if (subcommand === 'budget') { cmdBudget(); return; }
802
813
  if (subcommand === 'explain') { cmdExplain(); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "3.2.0",
3
+ "version": "3.3.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": {