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 +19 -0
- package/hooks/control-panel.mjs +74 -8
- package/hooks/dual-brain-review.mjs +3 -1
- package/hooks/dual-brain-think.mjs +3 -1
- package/hooks/enforce-tier.mjs +8 -4
- package/hooks/gpt-work-dispatcher.mjs +110 -42
- package/hooks/health-check.mjs +50 -3
- package/hooks/quality-gate.mjs +7 -0
- package/install.mjs +68 -5
- package/package.json +1 -1
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:
|
package/hooks/control-panel.mjs
CHANGED
|
@@ -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,
|
|
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 + ' ' +
|
|
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', ['
|
|
937
|
+
runSession('codex', ['resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
|
|
900
938
|
} else {
|
|
901
|
-
runSession('claude', ['-r', s.id
|
|
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', [
|
|
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', ['
|
|
960
|
+
runSession('codex', ['resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
|
|
923
961
|
} else {
|
|
924
|
-
runSession('claude', ['-r', s.id
|
|
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', [
|
|
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',
|
|
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',
|
|
123
|
+
'-s', SANDBOX,
|
|
122
124
|
prompt,
|
|
123
125
|
], {
|
|
124
126
|
encoding: 'utf8',
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
package/hooks/health-check.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
376
|
-
|
|
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;
|
package/hooks/quality-gate.mjs
CHANGED
|
@@ -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
|
-
|
|
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