dual-brain 4.8.1 → 5.0.1

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);
@@ -688,6 +715,7 @@ function renderFirstRunMenu(providers) {
688
715
 
689
716
  function renderReturningMenu(providers, sessions) {
690
717
  const profile = loadProfile();
718
+ const permissions = loadPermissions();
691
719
  const pf = PROFILES[profile.name];
692
720
  const running = countRunning();
693
721
  const balance = loadProviderBalance();
@@ -752,6 +780,7 @@ function renderReturningMenu(providers, sessions) {
752
780
  lines.push(` ${dim('─── Settings')}`);
753
781
  lines.push(` ${bold('[p]')} Mode: ${dim(pf.uiLabel)}`);
754
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')}`);
755
784
 
756
785
  // ── Auth
757
786
  lines.push('');
@@ -853,15 +882,24 @@ function showProfilePicker(rl) {
853
882
  // ─── Session Runner ───────────────────────────────────────────────────────
854
883
 
855
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
+
856
894
  console.log('');
857
895
  console.log(` ${label}`);
858
896
  console.log(` ${dim('Inside Claude: press Ctrl+C twice to return here.')}`);
859
897
  console.log('');
860
898
  markLaunched();
861
- const result = spawnSync(cmd, args, { stdio: 'inherit' });
899
+ const result = spawnSync(cmd, finalArgs, { stdio: 'inherit' });
862
900
  console.log('');
863
901
  if (result.status !== 0 && result.status !== null) {
864
- console.log(` ${yellow('Session exited with code ' + result.status + '.')} ${dim('(' + cmd + ' ' + args.join(' ') + ')')}`);
902
+ console.log(` ${yellow('Session exited with code ' + result.status + '.')} ${dim('(' + cmd + ' ' + finalArgs.join(' ') + ')')}`);
865
903
  }
866
904
  console.log(' Returned to Data Tools — Dual Brain.');
867
905
  return result.status || 0;
@@ -896,9 +934,9 @@ async function mainLoop() {
896
934
  if (sessions.length > 0) {
897
935
  const s = sessions[0];
898
936
  if (s.tool === 'codex') {
899
- 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)}...`);
900
938
  } else {
901
- 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)}...`);
902
940
  }
903
941
  } else if (!providers.claude.authed && !providers.claude.installed) {
904
942
  console.log('');
@@ -910,7 +948,7 @@ async function mainLoop() {
910
948
  console.log(` ${yellow('Claude is not authenticated.')} Press ${bold('[j]')} to sign in first.`);
911
949
  console.log('');
912
950
  } else {
913
- runSession('claude', ['--dangerously-skip-permissions'], 'Starting new session...');
951
+ runSession('claude', [], 'Starting new session...');
914
952
  }
915
953
  continue;
916
954
  }
@@ -919,9 +957,9 @@ async function mainLoop() {
919
957
  if (num >= 1 && num <= 9 && sessions[num - 1]) {
920
958
  const s = sessions[num - 1];
921
959
  if (s.tool === 'codex') {
922
- 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)}...`);
923
961
  } else {
924
- 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)}...`);
925
963
  }
926
964
  continue;
927
965
  }
@@ -932,7 +970,7 @@ async function mainLoop() {
932
970
  console.log(` ${yellow('Claude needs to be authenticated first.')} Press ${bold('[j]')} to sign in.`);
933
971
  console.log('');
934
972
  } else {
935
- runSession('claude', ['--dangerously-skip-permissions'], 'Starting new session...');
973
+ runSession('claude', [], 'Starting new session...');
936
974
  }
937
975
  continue;
938
976
  }
@@ -952,6 +990,34 @@ async function mainLoop() {
952
990
  continue;
953
991
  }
954
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
+
955
1021
  if (choice === 'd') {
956
1022
  await showToolsMenu(rl);
957
1023
  continue;
@@ -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
 
@@ -144,10 +144,31 @@ Own this task completely.
144
144
  // Codex executor
145
145
  // ---------------------------------------------------------------------------
146
146
 
147
- function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access') {
148
- 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
+ }
166
+
167
+ return failureType;
168
+ }
149
169
 
150
- const proc = spawnSync(codexBin, [
170
+ function runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox) {
171
+ return spawnSync(codexBin, [
151
172
  'exec', '--json', '--ephemeral',
152
173
  '-m', model,
153
174
  '-s', sandbox,
@@ -158,48 +179,82 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger
158
179
  timeout: timeoutMs || 120000,
159
180
  cwd: cwd || process.cwd(),
160
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);
197
+
198
+ const agentMessages = messages
199
+ .filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
200
+ .map(m => m.item.text);
161
201
 
162
- const durationMs = Date.now() - startTime;
163
-
164
- // Parse JSONL output
165
- const messages = (proc.stdout || '')
166
- .split('\n')
167
- .filter(l => l.trim())
168
- .map(l => { try { return JSON.parse(l); } catch { return null; } })
169
- .filter(Boolean);
170
-
171
- const agentMessages = messages
172
- .filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
173
- .map(m => m.item.text);
174
-
175
- const usage = messages.find(m => m.type === 'turn.completed')?.usage;
176
- const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
177
-
178
- // Detect changed files from command_execution items
179
- const commands = messages
180
- .filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
181
- .map(m => m.item);
182
-
183
- // Estimate startup time: time to first agent message or completed item
184
- const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
185
- let startupMs = null;
186
- if (firstItemTs) {
187
- startupMs = Date.parse(firstItemTs) - startTime;
188
- if (startupMs < 0 || startupMs > durationMs) startupMs = null;
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
+ };
189
242
  }
190
243
 
191
- return {
192
- success: proc.status === 0 && errors.length === 0,
193
- summary: agentMessages.join('\n\n'),
194
- durationMs,
195
- startupMs,
196
- model,
197
- usage: usage || null,
198
- errors: errors.map(e => e.message || e.error?.message || 'unknown'),
199
- commands: commands.length,
200
- exitCode: proc.status,
201
- signal: proc.signal,
202
- };
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;
203
258
  }
204
259
 
205
260
  // ---------------------------------------------------------------------------
@@ -430,6 +485,19 @@ if (import.meta.url === `file://${process.argv[1]}`) {
430
485
  console.log(`║ Model: ${result.model} Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
431
486
  console.log('╚══════════════════════════════════════════════════╝');
432
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
+ }
433
501
  console.error('Task failed:', result.errors?.join(', ') || result.error);
434
502
  }
435
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,43 @@ 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 hasCommand = (entries, cmd) => entries.some(e =>
200
+ e === cmd || e?.command === cmd || e?.hooks?.some(h => h.command === cmd)
201
+ );
202
+
203
+ const hasPre = hasCommand(preToolUse, expectedPre);
204
+ const hasPost = hasCommand(postToolUse, expectedPost);
205
+
206
+ if (hasPre && hasPost) {
207
+ return check("hook_registration", STATUS.pass, "required hooks registered");
208
+ }
209
+
210
+ const missing = [];
211
+ if (!hasPre) missing.push(`PreToolUse: ${expectedPre}`);
212
+ if (!hasPost) missing.push(`PostToolUse: ${expectedPost}`);
213
+
214
+ return check("hook_registration", STATUS.warn, `missing registrations: ${missing.join("; ")}`);
215
+ }
216
+
177
217
  /** 5. usage log active — check dated files and legacy for entries from last 15 minutes */
178
218
  function checkUsageJsonl() {
179
219
  const usageFile = existsSync(USAGE_FILE_TODAY) ? USAGE_FILE_TODAY
@@ -366,14 +406,21 @@ function main() {
366
406
  checkPricingVerified(),
367
407
  checkModelIntelligence(),
368
408
  checkHookScripts(),
409
+ checkHookRegistration(),
369
410
  checkUsageJsonl(),
370
411
  checkCodexCli(),
371
412
  checkGitRepo(),
372
413
  ];
373
414
 
374
415
  // Print formatted table
375
- console.log(renderTable(checks));
376
- console.log();
416
+ const tableOutput = renderTable(checks);
417
+ if (jsonOnly) {
418
+ console.error(tableOutput);
419
+ console.error();
420
+ } else {
421
+ console.log(tableOutput);
422
+ console.log();
423
+ }
377
424
 
378
425
  // Build JSON summary
379
426
  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',
@@ -771,6 +832,7 @@ function generateGitignoreEntries(workspace) {
771
832
  '.claude/dual-brain.memory.json',
772
833
  '.claude/dual-brain.version.json',
773
834
  '.claude/dual-brain.update-check.json',
835
+ '.claude/dual-brain.permissions.json',
774
836
  ];
775
837
  let existing = '';
776
838
  try { existing = readFileSync(join(workspace, '.gitignore'), 'utf8'); } catch {}
@@ -816,7 +878,7 @@ function install(workspace, env, mode) {
816
878
  actions.push('✓ settings.json (hooks registered)');
817
879
 
818
880
  const claudeMd = generateClaudeMd(mode);
819
- writeFileSync(join(target, 'CLAUDE.md'), claudeMd);
881
+ writeClaudeMd(join(target, 'CLAUDE.md'), claudeMd);
820
882
  actions.push('✓ CLAUDE.md (session instructions)');
821
883
 
822
884
  const rulesTarget = join(target, 'review-rules.md');
@@ -1284,6 +1346,10 @@ function cmdExplain() {
1284
1346
  // ─── Main ───────────────────────────────────────────────────────────────────
1285
1347
 
1286
1348
  async function main() {
1349
+ if (subcommand === 'auth' || restoreNpmFlag) {
1350
+ restoreNpmToken();
1351
+ }
1352
+
1287
1353
  if (subcommand === 'status') {
1288
1354
  launchPanel();
1289
1355
  return;
@@ -1293,9 +1359,6 @@ async function main() {
1293
1359
  if (subcommand === 'budget') { cmdBudget(); return; }
1294
1360
  if (subcommand === 'explain') { cmdExplain(); return; }
1295
1361
 
1296
- // Restore npm token if missing (for publish access)
1297
- restoreNpmToken();
1298
-
1299
1362
  let env = detectEnvironment();
1300
1363
  const startupUpdateInfo = (subcommand === 'update' || dryRun || jsonOut)
1301
1364
  ? null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "4.8.1",
3
+ "version": "5.0.1",
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": {