dual-brain 4.8.0 → 5.0.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
@@ -43,6 +43,25 @@ Dual-brain is a multi-round conversation between Claude and GPT — not a single
43
43
  3. High-risk decisions → dual-brain think
44
44
  4. When a task spans tiers: think > execute > search
45
45
 
46
+ ## Mandatory Workload Distribution
47
+
48
+ **Claude MUST follow these rules before implementing multi-file changes:**
49
+
50
+ 1. **Before starting any batch of 3+ file edits**: run `node .claude/hooks/budget-balancer.mjs` and `node .claude/hooks/vibe-router.mjs "description"` to check provider balance and classify tasks
51
+ 2. **When budget-balancer recommends GPT**: dispatch execution work via `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --tier execute`
52
+ 3. **Security/auth/credential changes**: always require dual-brain think flow before implementation
53
+ 4. **Audit remediation batches**: plan waves with dual-brain think, dispatch execution to GPT, Claude reviews
54
+ 5. **Claude's role in multi-task work**: define acceptance criteria, dispatch agents, review results — not solo-implement everything
55
+
56
+ **Triggers that require this workflow:**
57
+ - 3+ production files being edited in one session
58
+ - Any change touching auth, credentials, tokens, or secrets
59
+ - Any change to dispatcher, agent routing, or tier logic
60
+ - Audit or review remediation involving multiple subsystems
61
+ - When Claude's think capacity is above 60% per budget-balancer
62
+
63
+ **Failure to route is itself a bug.** If Claude implements a large batch solo when GPT has capacity, the user should treat this as a process failure and correct it.
64
+
46
65
  ## Quality Gate
47
66
 
48
67
  Before ending a session with code changes:
@@ -15,6 +15,7 @@ import { spawnSync } from 'child_process';
15
15
 
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
18
+ const PERMISSIONS_FILE = join(__dirname, '..', 'dual-brain.permissions.json');
18
19
  const LAUNCHED_MARKER = join(__dirname, '..', '.launched');
19
20
  const VERSION_STAMP_FILE = join(__dirname, '..', 'dual-brain.version.json');
20
21
  const UPDATE_CACHE_FILE = join(__dirname, '..', 'dual-brain.update-check.json');
@@ -56,6 +57,32 @@ function writeJsonFile(path, value) {
56
57
  writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
57
58
  }
58
59
 
60
+ function loadPermissions() {
61
+ const defaults = {
62
+ claude_skip_permissions: false,
63
+ codex_bypass_sandbox: false,
64
+ };
65
+ try {
66
+ const data = JSON.parse(readFileSync(PERMISSIONS_FILE, 'utf8'));
67
+ return {
68
+ claude_skip_permissions: !!data.claude_skip_permissions,
69
+ codex_bypass_sandbox: !!data.codex_bypass_sandbox,
70
+ };
71
+ } catch {
72
+ return defaults;
73
+ }
74
+ }
75
+
76
+ function savePermissions(perms) {
77
+ const next = {
78
+ claude_skip_permissions: !!perms.claude_skip_permissions,
79
+ codex_bypass_sandbox: !!perms.codex_bypass_sandbox,
80
+ };
81
+ const tmp = PERMISSIONS_FILE + '.tmp.' + process.pid;
82
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
83
+ renameSync(tmp, PERMISSIONS_FILE);
84
+ }
85
+
59
86
  function compareVersions(a, b) {
60
87
  const aParts = String(a || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
61
88
  const bParts = String(b || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
@@ -468,8 +495,14 @@ async function showAuthMenu(rl, providers) {
468
495
 
469
496
  if (choice === 'j') {
470
497
  console.log('');
471
- spawnSync('claude', ['login'], { stdio: 'inherit' });
472
- providers.claude.authed = true;
498
+ const r = spawnSync('claude', ['login'], { stdio: 'inherit' });
499
+ const fresh = detectProviders();
500
+ providers.claude = fresh.claude;
501
+ if (providers.claude.authed) {
502
+ console.log(` ${green('Claude authenticated.')}`);
503
+ } else {
504
+ console.log(` ${yellow('Claude login did not complete.')} Try again or check your subscription.`);
505
+ }
473
506
  continue;
474
507
  }
475
508
  if (choice === 'k' && providers.codex.installed) {
@@ -479,7 +512,13 @@ async function showAuthMenu(rl, providers) {
479
512
  console.log(` Open: ${cyan('https://auth.openai.com/codex/device')}`);
480
513
  console.log('');
481
514
  spawnSync(codexPath.stdout.trim(), ['login', '--device-auth'], { stdio: 'inherit' });
482
- providers.codex.authed = true;
515
+ const fresh = detectProviders();
516
+ providers.codex = fresh.codex;
517
+ if (providers.codex.authed) {
518
+ console.log(` ${green('Codex authenticated.')}`);
519
+ } else {
520
+ console.log(` ${yellow('Codex login did not complete.')} Try again.`);
521
+ }
483
522
  }
484
523
  continue;
485
524
  }
@@ -623,9 +662,9 @@ function renderFirstRunMenu(providers) {
623
662
  lines.push('');
624
663
 
625
664
  // Provider status
626
- const cStat = providers.claude.authed ? '✅' : providers.claude.installed ? '⚠️' : '❌';
627
- const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
628
- lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat}`);
665
+ const cStat = providers.claude.authed ? (noColor ? '[OK]' : '✅') : providers.claude.installed ? (noColor ? '[!]' : '⚠️') : (noColor ? '[X]' : '❌');
666
+ const xStat = providers.codex.authed ? (noColor ? '[OK]' : '✅') : providers.codex.installed ? (noColor ? '[!]' : '⚠️') : (noColor ? '[X]' : '❌');
667
+ lines.push(` ${noColor ? '' : '🟠 '}Claude ${cStat} ${noColor ? '' : '🟢 '}Codex ${xStat}`);
629
668
 
630
669
  if (providers.claude.authed && providers.codex.authed) {
631
670
  lines.push(` ${green('Both providers ready — full dual-brain mode')}`);
@@ -667,7 +706,8 @@ function renderFirstRunMenu(providers) {
667
706
  lines.push(` ${bold('[a]')} Auth management`);
668
707
  lines.push(` ${bold('[d]')} Dashboard & diagnostics`);
669
708
  lines.push(` ${bold('[s]')} Skip — just shell`);
670
- lines.push(` ${bold('[?]')} What is Dual Brain?`);
709
+ lines.push(` ${dim('Enter = new session · [?] help')}`);
710
+
671
711
  lines.push('');
672
712
 
673
713
  return lines;
@@ -675,6 +715,7 @@ function renderFirstRunMenu(providers) {
675
715
 
676
716
  function renderReturningMenu(providers, sessions) {
677
717
  const profile = loadProfile();
718
+ const permissions = loadPermissions();
678
719
  const pf = PROFILES[profile.name];
679
720
  const running = countRunning();
680
721
  const balance = loadProviderBalance();
@@ -688,8 +729,8 @@ function renderReturningMenu(providers, sessions) {
688
729
  lines.push('');
689
730
 
690
731
  // Provider status
691
- const cStat = providers.claude.authed ? '✅' : '⚠️';
692
- const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
732
+ const cStat = providers.claude.authed ? (noColor ? '[OK]' : '✅') : (noColor ? '[!]' : '⚠️');
733
+ const xStat = providers.codex.authed ? (noColor ? '[OK]' : '✅') : providers.codex.installed ? (noColor ? '[!]' : '⚠️') : (noColor ? '[X]' : '❌');
693
734
  let modeStatus = pf.uiLabel;
694
735
  if (profile.name === 'auto') {
695
736
  if (balance.total === 0) {
@@ -739,6 +780,7 @@ function renderReturningMenu(providers, sessions) {
739
780
  lines.push(` ${dim('─── Settings')}`);
740
781
  lines.push(` ${bold('[p]')} Mode: ${dim(pf.uiLabel)}`);
741
782
  lines.push(` ${bold('[b]')} Budget: ${dim('$' + profile.budgets.session_limit_usd + '/session, $' + profile.budgets.daily_limit_usd + '/day')}`);
783
+ lines.push(` ${bold('[x]')} Permissions: ${dim(permissions.claude_skip_permissions || permissions.codex_bypass_sandbox ? 'skip-permissions enabled' : 'safe mode')}`);
742
784
 
743
785
  // ── Auth
744
786
  lines.push('');
@@ -762,7 +804,12 @@ function renderReturningMenu(providers, sessions) {
762
804
  }
763
805
 
764
806
  lines.push('');
765
- lines.push(` ${bold('[s]')} Exit to shell ${dim('[?] help')}`);
807
+ lines.push(` ${bold('[s]')} Exit to shell`);
808
+ if (sessions.length > 0) {
809
+ lines.push(` ${dim('Enter = continue last · [?] help')}`);
810
+ } else {
811
+ lines.push(` ${dim('Enter = new session · [?] help')}`);
812
+ }
766
813
  lines.push('');
767
814
 
768
815
  return lines;
@@ -835,13 +882,25 @@ function showProfilePicker(rl) {
835
882
  // ─── Session Runner ───────────────────────────────────────────────────────
836
883
 
837
884
  function runSession(cmd, args, label) {
885
+ const permissions = loadPermissions();
886
+ const finalArgs = [...args];
887
+ if (cmd === 'claude' && permissions.claude_skip_permissions) {
888
+ finalArgs.push('--dangerously-skip-permissions');
889
+ }
890
+ if (cmd === 'codex' && permissions.codex_bypass_sandbox) {
891
+ finalArgs.push('--dangerously-bypass-approvals-and-sandbox');
892
+ }
893
+
838
894
  console.log('');
839
895
  console.log(` ${label}`);
840
896
  console.log(` ${dim('Inside Claude: press Ctrl+C twice to return here.')}`);
841
897
  console.log('');
842
898
  markLaunched();
843
- const result = spawnSync(cmd, args, { stdio: 'inherit' });
899
+ const result = spawnSync(cmd, finalArgs, { stdio: 'inherit' });
844
900
  console.log('');
901
+ if (result.status !== 0 && result.status !== null) {
902
+ console.log(` ${yellow('Session exited with code ' + result.status + '.')} ${dim('(' + cmd + ' ' + finalArgs.join(' ') + ')')}`);
903
+ }
845
904
  console.log(' Returned to Data Tools — Dual Brain.');
846
905
  return result.status || 0;
847
906
  }
@@ -875,12 +934,21 @@ async function mainLoop() {
875
934
  if (sessions.length > 0) {
876
935
  const s = sessions[0];
877
936
  if (s.tool === 'codex') {
878
- runSession('codex', ['--dangerously-bypass-approvals-and-sandbox', 'resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
937
+ runSession('codex', ['resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
879
938
  } else {
880
- runSession('claude', ['-r', s.id, '--dangerously-skip-permissions'], `Resuming session ${s.id.slice(0, 8)}...`);
939
+ runSession('claude', ['-r', s.id], `Resuming session ${s.id.slice(0, 8)}...`);
881
940
  }
941
+ } else if (!providers.claude.authed && !providers.claude.installed) {
942
+ console.log('');
943
+ console.log(` ${yellow('Claude is not installed.')} Install first:`);
944
+ console.log(` ${cyan('curl -fsSL https://claude.ai/install.sh | sh')}`);
945
+ console.log('');
946
+ } else if (!providers.claude.authed) {
947
+ console.log('');
948
+ console.log(` ${yellow('Claude is not authenticated.')} Press ${bold('[j]')} to sign in first.`);
949
+ console.log('');
882
950
  } else {
883
- runSession('claude', ['--dangerously-skip-permissions'], 'Starting new session...');
951
+ runSession('claude', [], 'Starting new session...');
884
952
  }
885
953
  continue;
886
954
  }
@@ -889,15 +957,21 @@ async function mainLoop() {
889
957
  if (num >= 1 && num <= 9 && sessions[num - 1]) {
890
958
  const s = sessions[num - 1];
891
959
  if (s.tool === 'codex') {
892
- runSession('codex', ['--dangerously-bypass-approvals-and-sandbox', 'resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
960
+ runSession('codex', ['resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
893
961
  } else {
894
- runSession('claude', ['-r', s.id, '--dangerously-skip-permissions'], `Resuming session ${s.id.slice(0, 8)}...`);
962
+ runSession('claude', ['-r', s.id], `Resuming session ${s.id.slice(0, 8)}...`);
895
963
  }
896
964
  continue;
897
965
  }
898
966
 
899
967
  if (choice === 'n') {
900
- runSession('claude', ['--dangerously-skip-permissions'], 'Starting new session...');
968
+ if (!providers.claude.authed) {
969
+ console.log('');
970
+ console.log(` ${yellow('Claude needs to be authenticated first.')} Press ${bold('[j]')} to sign in.`);
971
+ console.log('');
972
+ } else {
973
+ runSession('claude', [], 'Starting new session...');
974
+ }
901
975
  continue;
902
976
  }
903
977
 
@@ -916,6 +990,34 @@ async function mainLoop() {
916
990
  continue;
917
991
  }
918
992
 
993
+ if (choice === 'x' && !firstRun) {
994
+ const permissions = loadPermissions();
995
+ if (permissions.claude_skip_permissions || permissions.codex_bypass_sandbox) {
996
+ savePermissions({
997
+ claude_skip_permissions: false,
998
+ codex_bypass_sandbox: false,
999
+ });
1000
+ console.log('');
1001
+ console.log(` ${green('Permissions set to safe mode.')}`);
1002
+ console.log('');
1003
+ continue;
1004
+ }
1005
+
1006
+ console.log('');
1007
+ const confirm = await new Promise(resolve => rl.question(' WARNING: This enables skip-permissions mode for Claude sessions. Type YES to confirm: ', resolve));
1008
+ if (confirm.trim() === 'YES') {
1009
+ savePermissions({
1010
+ claude_skip_permissions: true,
1011
+ codex_bypass_sandbox: true,
1012
+ });
1013
+ console.log(` ${yellow('Skip-permissions mode enabled for Claude and Codex sessions.')}`);
1014
+ } else {
1015
+ console.log(' No changes made. Safe mode remains enabled.');
1016
+ }
1017
+ console.log('');
1018
+ continue;
1019
+ }
1020
+
919
1021
  if (choice === 'd') {
920
1022
  await showToolsMenu(rl);
921
1023
  continue;
@@ -923,9 +1025,14 @@ async function mainLoop() {
923
1025
 
924
1026
  if (choice === 'u') {
925
1027
  console.log('');
926
- console.log(' Updating dual-brain...');
1028
+ console.log(' Updating Dual Brain...');
927
1029
  console.log('');
928
- spawnSync('npx', ['-y', 'dual-brain', 'update'], { stdio: 'inherit', cwd: CWD });
1030
+ const upd = spawnSync('npx', ['-y', 'dual-brain', 'update'], { stdio: 'inherit', cwd: CWD });
1031
+ if (upd.status !== 0) {
1032
+ console.log('');
1033
+ console.log(` ${yellow('Update failed (exit ' + upd.status + ').')} Try manually: ${cyan('npx -y dual-brain@latest')}`);
1034
+ console.log('');
1035
+ }
929
1036
  continue;
930
1037
  }
931
1038
 
@@ -18,6 +18,8 @@ import { dirname, join, resolve } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
 
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
22
+ const SANDBOX = IS_REPLIT ? 'danger-full-access' : 'read-only';
21
23
 
22
24
  const REVIEW_PROMPT_R1 = `You are GPT-5.5 performing Round 1 of a dual-brain code review.
23
25
  Claude (Opus) will independently review the same changes, then send you their findings
@@ -189,7 +191,7 @@ function tryCodexReview(diff, { round = 1, claudeReview = null } = {}) {
189
191
  const proc = spawnSync(CODEX_BIN, [
190
192
  'exec', '--json', '--ephemeral',
191
193
  '-c', `model="${model}"`,
192
- '-s', 'danger-full-access',
194
+ '-s', SANDBOX,
193
195
  fullPrompt,
194
196
  ], {
195
197
  input: truncated,
@@ -25,6 +25,8 @@ import { dirname, join } from 'path';
25
25
  import { fileURLToPath } from 'url';
26
26
 
27
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
29
+ const SANDBOX = IS_REPLIT ? 'danger-full-access' : 'read-only';
28
30
 
29
31
  const CODEX_TIMEOUT_MS = 120_000;
30
32
  const MODEL = 'gpt-5.5';
@@ -118,7 +120,7 @@ function runGptAnalysis(codexBin, prompt) {
118
120
  const proc = spawnSync(codexBin, [
119
121
  'exec', '--json', '--ephemeral',
120
122
  '-m', MODEL,
121
- '-s', 'danger-full-access',
123
+ '-s', SANDBOX,
122
124
  prompt,
123
125
  ], {
124
126
  encoding: 'utf8',
@@ -14,10 +14,14 @@ const BURST_FILE = resolve(__dirname, '.burst-state');
14
14
  function detectBurst() {
15
15
  const now = Date.now();
16
16
  let state = { count: 0, window_start: now };
17
- try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
18
- if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
19
- state.count++;
20
- try { writeFileSync(BURST_FILE, JSON.stringify(state)); } catch {}
17
+ try {
18
+ try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
19
+ if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
20
+ state.count++;
21
+ const tmp = BURST_FILE + '.tmp.' + process.pid;
22
+ writeFileSync(tmp, JSON.stringify(state));
23
+ renameSync(tmp, BURST_FILE);
24
+ } catch {}
21
25
  return state.count >= 3;
22
26
  }
23
27
 
@@ -27,11 +27,10 @@ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
27
27
  const EXECUTE_WORDS = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i;
28
28
  const SEARCH_WORDS = /\b(explore|search|find|grep|locate|list\s+files|read[-\s]?only|lookup|scan)\b/i;
29
29
  const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
30
- const GPT_TIER_SANDBOX = {
31
- search: 'read-only',
32
- execute: 'danger-full-access',
33
- think: 'read-only',
34
- };
30
+ const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
31
+ const GPT_TIER_SANDBOX = IS_REPLIT
32
+ ? { search: 'danger-full-access', execute: 'danger-full-access', think: 'danger-full-access' }
33
+ : { search: 'read-only', execute: 'danger-full-access', think: 'read-only' };
35
34
  const GPT_TIER_PROMPTS = {
36
35
  search: 'You are a READ-ONLY search agent. Do NOT edit files.',
37
36
  execute: 'You are an execution agent. Edit files directly.',
@@ -145,10 +144,31 @@ Own this task completely.
145
144
  // Codex executor
146
145
  // ---------------------------------------------------------------------------
147
146
 
148
- function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access') {
149
- const startTime = Date.now();
147
+ function classifyCodexFailure(proc) {
148
+ let failureType = null;
149
+
150
+ if (proc.error?.code === 'ETIMEDOUT') {
151
+ failureType = 'timeout';
152
+ } else if (proc.error?.code === 'ENOENT') {
153
+ failureType = 'not_found';
154
+ } else if (proc.error) {
155
+ failureType = 'spawn_error';
156
+ }
157
+
158
+ const stderr = (proc.stderr || '').toLowerCase();
159
+ if (stderr.includes('unauthorized') || stderr.includes('401') || stderr.includes('not logged in')) {
160
+ failureType = 'auth';
161
+ } else if (stderr.includes('rate limit') || stderr.includes('429') || stderr.includes('too many')) {
162
+ failureType = 'rate_limit';
163
+ } else if (stderr.includes('timeout') || stderr.includes('timed out')) {
164
+ failureType = 'timeout';
165
+ }
150
166
 
151
- const proc = spawnSync(codexBin, [
167
+ return failureType;
168
+ }
169
+
170
+ function runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox) {
171
+ return spawnSync(codexBin, [
152
172
  'exec', '--json', '--ephemeral',
153
173
  '-m', model,
154
174
  '-s', sandbox,
@@ -159,48 +179,82 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger
159
179
  timeout: timeoutMs || 120000,
160
180
  cwd: cwd || process.cwd(),
161
181
  });
182
+ }
183
+
184
+ function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access') {
185
+ const startTime = Date.now();
186
+
187
+ function finalizeAttempt(proc, attemptStartTime, attemptCount) {
188
+ const durationMs = Date.now() - attemptStartTime;
189
+ const failureType = classifyCodexFailure(proc);
190
+
191
+ // Parse JSONL output
192
+ const messages = (proc.stdout || '')
193
+ .split('\n')
194
+ .filter(l => l.trim())
195
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
196
+ .filter(Boolean);
162
197
 
163
- const durationMs = Date.now() - startTime;
164
-
165
- // Parse JSONL output
166
- const messages = (proc.stdout || '')
167
- .split('\n')
168
- .filter(l => l.trim())
169
- .map(l => { try { return JSON.parse(l); } catch { return null; } })
170
- .filter(Boolean);
171
-
172
- const agentMessages = messages
173
- .filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
174
- .map(m => m.item.text);
175
-
176
- const usage = messages.find(m => m.type === 'turn.completed')?.usage;
177
- const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
178
-
179
- // Detect changed files from command_execution items
180
- const commands = messages
181
- .filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
182
- .map(m => m.item);
183
-
184
- // Estimate startup time: time to first agent message or completed item
185
- const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
186
- let startupMs = null;
187
- if (firstItemTs) {
188
- startupMs = Date.parse(firstItemTs) - startTime;
189
- if (startupMs < 0 || startupMs > durationMs) startupMs = null;
198
+ const agentMessages = messages
199
+ .filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
200
+ .map(m => m.item.text);
201
+
202
+ const usage = messages.find(m => m.type === 'turn.completed')?.usage;
203
+ const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
204
+ const errorMessages = errors.map(e => e.message || e.error?.message || 'unknown');
205
+
206
+ if (proc.error?.message) {
207
+ errorMessages.unshift(proc.error.message);
208
+ }
209
+ if (proc.stderr?.trim() && errorMessages.length === 0 && proc.status !== 0) {
210
+ errorMessages.push(proc.stderr.trim().slice(0, 200));
211
+ }
212
+
213
+ // Detect changed files from command_execution items
214
+ const commands = messages
215
+ .filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
216
+ .map(m => m.item);
217
+
218
+ // Estimate startup time: time to first agent message or completed item
219
+ const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
220
+ let startupMs = null;
221
+ if (firstItemTs) {
222
+ startupMs = Date.parse(firstItemTs) - attemptStartTime;
223
+ if (startupMs < 0 || startupMs > durationMs) startupMs = null;
224
+ }
225
+
226
+ return {
227
+ success: proc.status === 0 && errors.length === 0 && !failureType,
228
+ summary: agentMessages.join('\n\n'),
229
+ durationMs,
230
+ startupMs,
231
+ model,
232
+ usage: usage || null,
233
+ errors: errorMessages,
234
+ commands: commands.length,
235
+ exitCode: proc.status,
236
+ signal: proc.signal,
237
+ failureType: failureType || null,
238
+ stderrSummary: proc.stderr?.trim().slice(0, 200) || null,
239
+ spawnErrorMessage: proc.error?.message || null,
240
+ retryCount: attemptCount - 1,
241
+ };
190
242
  }
191
243
 
192
- return {
193
- success: proc.status === 0 && errors.length === 0,
194
- summary: agentMessages.join('\n\n'),
195
- durationMs,
196
- startupMs,
197
- model,
198
- usage: usage || null,
199
- errors: errors.map(e => e.message || e.error?.message || 'unknown'),
200
- commands: commands.length,
201
- exitCode: proc.status,
202
- signal: proc.signal,
203
- };
244
+ let attemptCount = 1;
245
+ let attemptStartTime = startTime;
246
+ let proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox);
247
+ let result = finalizeAttempt(proc, attemptStartTime, attemptCount);
248
+
249
+ if (!result.success && (result.failureType === 'rate_limit' || result.failureType === 'timeout')) {
250
+ spawnSync('sleep', ['3'], { stdio: 'ignore' });
251
+ attemptCount += 1;
252
+ attemptStartTime = Date.now();
253
+ proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox);
254
+ result = finalizeAttempt(proc, attemptStartTime, attemptCount);
255
+ }
256
+
257
+ return result;
204
258
  }
205
259
 
206
260
  // ---------------------------------------------------------------------------
@@ -431,6 +485,19 @@ if (import.meta.url === `file://${process.argv[1]}`) {
431
485
  console.log(`║ Model: ${result.model} Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
432
486
  console.log('╚══════════════════════════════════════════════════╝');
433
487
  } else {
488
+ if (result.failureType) {
489
+ const friendlyMessage = {
490
+ auth: 'Codex not authenticated. Run: codex login --device-auth',
491
+ rate_limit: 'Rate limited by OpenAI. Try again in a few minutes.',
492
+ timeout: 'Codex timed out. Try a simpler task or increase timeout.',
493
+ not_found: 'Codex CLI not found. Run: npm i -g @openai/codex',
494
+ spawn_error: `Failed to start Codex: ${result.spawnErrorMessage || 'unknown spawn error'}`,
495
+ }[result.failureType];
496
+
497
+ if (friendlyMessage) {
498
+ console.error(friendlyMessage);
499
+ }
500
+ }
434
501
  console.error('Task failed:', result.errors?.join(', ') || result.error);
435
502
  }
436
503
 
@@ -6,7 +6,8 @@
6
6
  * node .claude/hooks/health-check.mjs
7
7
  *
8
8
  * Validates that all hooks are wired, configs are valid, and the system
9
- * is functioning in a live session. Always exits 0 and outputs valid JSON.
9
+ * is functioning in a live session. Always exits 0. With --json flag, outputs
10
+ * only JSON to stdout. Without it, prints both table and JSON.
10
11
  *
11
12
  * Checks:
12
13
  * 1. orchestrator.json — exists and parses as valid JSON
@@ -29,9 +30,11 @@ import { spawnSync } from "child_process";
29
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
31
  const HOOKS_DIR = __dirname;
31
32
  const CONFIG_FILE = join(__dirname, "..", "orchestrator.json");
33
+ const SETTINGS_FILE = join(__dirname, "..", "settings.json");
32
34
  const USAGE_FILE_LEGACY = join(__dirname, "usage.jsonl");
33
35
  const USAGE_FILE_TODAY = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
34
36
  const WORKSPACE = join(__dirname, "..", "..");
37
+ const jsonOnly = process.argv.includes("--json");
35
38
 
36
39
  // ---------------------------------------------------------------------------
37
40
  // Status helpers
@@ -174,6 +177,39 @@ function checkHookScripts() {
174
177
  );
175
178
  }
176
179
 
180
+ /** 4b. Hook registration — verify required hooks are configured in settings.json */
181
+ function checkHookRegistration() {
182
+ if (!existsSync(SETTINGS_FILE)) {
183
+ return check("hook_registration", STATUS.fail, "settings.json not found");
184
+ }
185
+
186
+ let settings;
187
+ try {
188
+ settings = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
189
+ } catch (err) {
190
+ return check("hook_registration", STATUS.warn, `invalid JSON: ${err.message}`);
191
+ }
192
+
193
+ const preToolUse = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
194
+ const postToolUse = Array.isArray(settings?.hooks?.PostToolUse) ? settings.hooks.PostToolUse : [];
195
+
196
+ const expectedPre = "node .claude/hooks/enforce-tier.mjs";
197
+ const expectedPost = "node .claude/hooks/cost-logger.mjs";
198
+
199
+ const hasPre = preToolUse.includes(expectedPre);
200
+ const hasPost = postToolUse.includes(expectedPost);
201
+
202
+ if (hasPre && hasPost) {
203
+ return check("hook_registration", STATUS.pass, "required hooks registered");
204
+ }
205
+
206
+ const missing = [];
207
+ if (!hasPre) missing.push(`PreToolUse: ${expectedPre}`);
208
+ if (!hasPost) missing.push(`PostToolUse: ${expectedPost}`);
209
+
210
+ return check("hook_registration", STATUS.warn, `missing registrations: ${missing.join("; ")}`);
211
+ }
212
+
177
213
  /** 5. usage log active — check dated files and legacy for entries from last 15 minutes */
178
214
  function checkUsageJsonl() {
179
215
  const usageFile = existsSync(USAGE_FILE_TODAY) ? USAGE_FILE_TODAY
@@ -366,14 +402,21 @@ function main() {
366
402
  checkPricingVerified(),
367
403
  checkModelIntelligence(),
368
404
  checkHookScripts(),
405
+ checkHookRegistration(),
369
406
  checkUsageJsonl(),
370
407
  checkCodexCli(),
371
408
  checkGitRepo(),
372
409
  ];
373
410
 
374
411
  // Print formatted table
375
- console.log(renderTable(checks));
376
- console.log();
412
+ const tableOutput = renderTable(checks);
413
+ if (jsonOnly) {
414
+ console.error(tableOutput);
415
+ console.error();
416
+ } else {
417
+ console.log(tableOutput);
418
+ console.log();
419
+ }
377
420
 
378
421
  // Build JSON summary
379
422
  const passCount = checks.filter((c) => c.status === "pass").length;
@@ -163,6 +163,13 @@ function scoreSensitivity(files, config) {
163
163
  function matchesSkipPattern(filePath, patterns) {
164
164
  const segments = filePath.split('/');
165
165
  const basename = segments[segments.length - 1];
166
+ const isTestFile = /\.(test|spec)\.(js|ts|tsx|jsx|mjs)$/.test(basename);
167
+ const isTestDirectory = segments.some(seg => seg === '__tests__' || seg === '__mocks__');
168
+
169
+ if (isTestFile || isTestDirectory) {
170
+ return true;
171
+ }
172
+
166
173
  return patterns.some(p => {
167
174
  if (p.startsWith('.')) return basename.endsWith(p); // extension match
168
175
  return segments.some(seg => seg === p || seg.startsWith(p + '.')); // exact segment match
package/install.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * npx dual-brain --dry-run # detect only, don't install
10
10
  * npx dual-brain --help
11
11
  */
12
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
12
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
13
13
  import { createInterface } from 'readline';
14
14
  import { dirname, join, resolve } from 'path';
15
15
  import { fileURLToPath } from 'url';
@@ -34,6 +34,7 @@ const flag = (f) => argv.includes(f);
34
34
  const force = flag('--force');
35
35
  const dryRun = flag('--dry-run');
36
36
  const jsonOut = flag('--json');
37
+ const restoreNpmFlag = flag('--restore-npm');
37
38
  const positional = argv.filter(a => !a.startsWith('-'));
38
39
  const subcommand = positional[0] || null;
39
40
 
@@ -64,6 +65,7 @@ if (flag('--help') || flag('-h')) {
64
65
  --force Overwrite all existing config
65
66
  --dry-run Detect environment only
66
67
  --json Output detection as JSON
68
+ --restore-npm Restore persisted npm token before auth flows
67
69
  --help Show this help
68
70
 
69
71
  🎛️ Routing modes:
@@ -467,9 +469,26 @@ async function authGuidance(env) {
467
469
  const CODEX_HOME = join(process.env.HOME || '', '.codex');
468
470
  const CODEX_PERSIST = resolve(process.cwd(), '.replit-tools', '.codex-persistent');
469
471
 
472
+ function isGitignored(path) {
473
+ const result = run('git', ['check-ignore', '-q', path], { cwd: process.cwd() });
474
+ return result.status === 0;
475
+ }
476
+
477
+ function hasStrictFilePermissions(path) {
478
+ try {
479
+ return (statSync(path).mode & 0o777) === 0o600;
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
470
485
  function saveCodexCredentials() {
471
486
  const authFile = join(CODEX_HOME, 'auth.json');
472
487
  if (!existsSync(authFile)) return false;
488
+ if (!isGitignored('.replit-tools')) {
489
+ console.warn('WARNING: .replit-tools is not gitignored. Skipping credential persistence to avoid leaking secrets.');
490
+ return false;
491
+ }
473
492
  try {
474
493
  const auth = readFileSync(authFile, 'utf8');
475
494
  if (!auth.trim() || auth.trim() === '{}') return false;
@@ -477,6 +496,9 @@ function saveCodexCredentials() {
477
496
  const persisted = join(CODEX_PERSIST, 'auth.json');
478
497
  writeFileSync(persisted, auth, { mode: 0o600 });
479
498
  try { chmodSync(persisted, 0o600); } catch {}
499
+ if (!hasStrictFilePermissions(persisted)) {
500
+ console.warn(`WARNING: ${relPath(persisted)} permissions are not 0600.`);
501
+ }
480
502
  return true;
481
503
  } catch { return false; }
482
504
  }
@@ -486,6 +508,10 @@ function restoreCodexCredentials() {
486
508
  const targetAuth = join(CODEX_HOME, 'auth.json');
487
509
  if (existsSync(targetAuth)) return false;
488
510
  if (!existsSync(persistedAuth)) return false;
511
+ if (!hasStrictFilePermissions(persistedAuth)) {
512
+ console.warn(`WARNING: ${relPath(persistedAuth)} permissions are not 0600. Skipping restore.`);
513
+ return false;
514
+ }
489
515
  try {
490
516
  const auth = readFileSync(persistedAuth, 'utf8');
491
517
  if (!auth.trim() || auth.trim() === '{}') return false;
@@ -757,6 +783,41 @@ function generateClaudeMd(mode) {
757
783
  return md;
758
784
  }
759
785
 
786
+ const CLAUDE_MD_MANAGED_START = '<!-- dual-brain:start -->';
787
+ const CLAUDE_MD_MANAGED_END = '<!-- dual-brain:end -->';
788
+
789
+ function renderManagedClaudeSection(content) {
790
+ return `${CLAUDE_MD_MANAGED_START}\n${content.replace(/\s+$/, '')}\n${CLAUDE_MD_MANAGED_END}\n`;
791
+ }
792
+
793
+ function mergeClaudeMd(existingContent, managedContent) {
794
+ const managedSection = renderManagedClaudeSection(managedContent);
795
+ const startIndex = existingContent.indexOf(CLAUDE_MD_MANAGED_START);
796
+ const endIndex = existingContent.indexOf(CLAUDE_MD_MANAGED_END);
797
+
798
+ if (startIndex !== -1 && endIndex !== -1 && endIndex >= startIndex) {
799
+ const before = existingContent.slice(0, startIndex);
800
+ const after = existingContent.slice(endIndex + CLAUDE_MD_MANAGED_END.length);
801
+ const prefix = before.replace(/\s*$/, '');
802
+ const suffix = after.replace(/^\s*/, '');
803
+ return `${prefix}${prefix ? '\n\n' : ''}${managedSection}${suffix ? `\n${suffix}` : ''}`.replace(/\s+$/, '') + '\n';
804
+ }
805
+
806
+ const trimmed = existingContent.replace(/\s+$/, '');
807
+ return `${trimmed}${trimmed ? '\n\n' : ''}${managedSection}`;
808
+ }
809
+
810
+ function writeClaudeMd(targetPath, content) {
811
+ const managedContent = content.replace(/\s+$/, '');
812
+ if (force || !existsSync(targetPath)) {
813
+ writeFileSync(targetPath, renderManagedClaudeSection(managedContent));
814
+ return;
815
+ }
816
+
817
+ const existing = readFileSync(targetPath, 'utf8');
818
+ writeFileSync(targetPath, mergeClaudeMd(existing, managedContent));
819
+ }
820
+
760
821
  function generateGitignoreEntries(workspace) {
761
822
  const entries = [
762
823
  '.claude/hooks/usage-*.jsonl',
@@ -816,7 +877,7 @@ function install(workspace, env, mode) {
816
877
  actions.push('✓ settings.json (hooks registered)');
817
878
 
818
879
  const claudeMd = generateClaudeMd(mode);
819
- writeFileSync(join(target, 'CLAUDE.md'), claudeMd);
880
+ writeClaudeMd(join(target, 'CLAUDE.md'), claudeMd);
820
881
  actions.push('✓ CLAUDE.md (session instructions)');
821
882
 
822
883
  const rulesTarget = join(target, 'review-rules.md');
@@ -1284,6 +1345,10 @@ function cmdExplain() {
1284
1345
  // ─── Main ───────────────────────────────────────────────────────────────────
1285
1346
 
1286
1347
  async function main() {
1348
+ if (subcommand === 'auth' || restoreNpmFlag) {
1349
+ restoreNpmToken();
1350
+ }
1351
+
1287
1352
  if (subcommand === 'status') {
1288
1353
  launchPanel();
1289
1354
  return;
@@ -1293,9 +1358,6 @@ async function main() {
1293
1358
  if (subcommand === 'budget') { cmdBudget(); return; }
1294
1359
  if (subcommand === 'explain') { cmdExplain(); return; }
1295
1360
 
1296
- // Restore npm token if missing (for publish access)
1297
- restoreNpmToken();
1298
-
1299
1361
  let env = detectEnvironment();
1300
1362
  const startupUpdateInfo = (subcommand === 'update' || dryRun || jsonOut)
1301
1363
  ? null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "4.8.0",
3
+ "version": "5.0.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": {