dual-brain 2.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/hookify.orchestrator-cost.local.md +16 -0
- package/hookify.orchestrator-gate.local.md +19 -0
- package/hookify.orchestrator-route.local.md +23 -0
- package/hooks/budget-balancer.mjs +463 -0
- package/hooks/cost-logger.mjs +250 -0
- package/hooks/cost-report.mjs +344 -0
- package/hooks/dual-brain-review.mjs +302 -0
- package/hooks/dual-brain-think.mjs +321 -0
- package/hooks/enforce-tier.mjs +282 -0
- package/hooks/gpt-work-dispatcher.mjs +254 -0
- package/hooks/health-check.mjs +390 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/quality-gate.mjs +283 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/test-orchestrator.mjs +316 -0
- package/install.mjs +153 -0
- package/orchestrator.json +215 -0
- package/package.json +38 -0
- package/review-rules.md +17 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dual-brain-review.mjs
|
|
4
|
+
*
|
|
5
|
+
* Sends git diffs to GPT for independent code review using the Codex CLI
|
|
6
|
+
* (uses your ChatGPT subscription — no API key needed).
|
|
7
|
+
*
|
|
8
|
+
* Falls back to direct OpenAI API if OPENAI_API_KEY is set.
|
|
9
|
+
* Falls back to "no GPT available" if neither works.
|
|
10
|
+
*
|
|
11
|
+
* Usage: node .claude/hooks/dual-brain-review.mjs
|
|
12
|
+
* Output: JSON to stdout — always valid, never crashes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync, spawnSync } from 'child_process';
|
|
16
|
+
import { readFileSync } from 'fs';
|
|
17
|
+
import { dirname, resolve } from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
const REVIEW_PROMPT = `Review the current uncommitted changes in this repo for:
|
|
23
|
+
1. Correctness — logic errors, off-by-one, null/undefined risks
|
|
24
|
+
2. Security — injection, auth bypass, data exposure
|
|
25
|
+
3. Edge cases — what could break under unusual input
|
|
26
|
+
4. Quality — naming, structure, unnecessary complexity
|
|
27
|
+
|
|
28
|
+
Required output:
|
|
29
|
+
- Findings only, ordered by severity
|
|
30
|
+
- File/line references when possible
|
|
31
|
+
- Whether tests cover the changed behavior
|
|
32
|
+
- Whether the change follows existing repo patterns
|
|
33
|
+
- Whether any issue should block merge
|
|
34
|
+
|
|
35
|
+
Be concise. Flag only real issues, not style preferences. If the code looks good, say "LGTM" and note any minor suggestions. Output your review as plain text, not JSON.`;
|
|
36
|
+
|
|
37
|
+
function loadReviewRules() {
|
|
38
|
+
const rulesFile = resolve(__dirname, '..', 'review-rules.md');
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(rulesFile, 'utf8').trim();
|
|
41
|
+
if (!content) return '';
|
|
42
|
+
return '\n\nAlso enforce these project-specific rules:\n' + content;
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_DIFF_CHARS = 15000;
|
|
49
|
+
const MIN_DIFF_LINES = 5;
|
|
50
|
+
const CODEX_TIMEOUT = 90;
|
|
51
|
+
|
|
52
|
+
function findCodex() {
|
|
53
|
+
const candidates = [
|
|
54
|
+
process.env.CODEX_BIN,
|
|
55
|
+
].filter(Boolean);
|
|
56
|
+
for (const c of candidates) {
|
|
57
|
+
try { spawnSync(c, ['--version'], { stdio: 'pipe', timeout: 3000 }); return c; } catch {}
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const which = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
|
|
61
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
62
|
+
} catch {}
|
|
63
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
64
|
+
const fallbacks = [
|
|
65
|
+
join(home, '.local', 'bin', 'codex'),
|
|
66
|
+
join(home, 'bin', 'codex'),
|
|
67
|
+
'/usr/local/bin/codex',
|
|
68
|
+
];
|
|
69
|
+
for (const p of fallbacks) {
|
|
70
|
+
try { spawnSync(p, ['--version'], { stdio: 'pipe', timeout: 3000 }); return p; } catch {}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const CODEX_BIN = findCodex();
|
|
76
|
+
|
|
77
|
+
function runGit(cmd) {
|
|
78
|
+
try {
|
|
79
|
+
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
80
|
+
} catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function countLines(str) {
|
|
84
|
+
return (str || '').split('\n').filter(l => l.trim().length > 0).length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getThinkModel() {
|
|
88
|
+
try {
|
|
89
|
+
const config = JSON.parse(readFileSync(resolve(__dirname, '..', 'orchestrator.json'), 'utf8'));
|
|
90
|
+
const models = config?.subscriptions?.openai?.models ?? {};
|
|
91
|
+
for (const [name, info] of Object.entries(models)) {
|
|
92
|
+
if (info?.tier === 'think') return name;
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
return 'gpt-5.5';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasIssues(text) {
|
|
99
|
+
const lower = text.toLowerCase();
|
|
100
|
+
|
|
101
|
+
// Check for concrete issue indicators first
|
|
102
|
+
const issuePatterns = [
|
|
103
|
+
/\b(bug|crash|vulnerability|incorrect|broken|dangerous|unsafe|injection|leak)\b/i,
|
|
104
|
+
/\bshould\s+(fix|change|update|remove|replace|refactor)\b/i,
|
|
105
|
+
/\bmust\s+(fix|change|update|remove|replace|refactor)\b/i,
|
|
106
|
+
/\b(will\s+break|could\s+break|might\s+break|can\s+crash|could\s+crash)\b/i,
|
|
107
|
+
/\b(missing\s+(check|validation|guard|null|error|handling))\b/i,
|
|
108
|
+
/\b(race\s+condition|deadlock|overflow|underflow|out\s+of\s+bounds)\b/i,
|
|
109
|
+
];
|
|
110
|
+
const hasIssueIndicators = issuePatterns.some(p => p.test(text));
|
|
111
|
+
|
|
112
|
+
// If concrete issues found, always flag — even if "LGTM" also appears
|
|
113
|
+
if (hasIssueIndicators) return true;
|
|
114
|
+
|
|
115
|
+
// No concrete issues — check if review explicitly says it's clean
|
|
116
|
+
const good = ['lgtm', 'looks good', 'no issues', 'no problems', 'no concerns', 'all good', 'clean'];
|
|
117
|
+
if (good.some(g => lower.includes(g))) return false;
|
|
118
|
+
|
|
119
|
+
// Ambiguous — default to flagging for human review
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function exit(obj) {
|
|
124
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Try GPT review via Codex CLI (uses ChatGPT subscription auth).
|
|
130
|
+
* Returns review text or null if codex isn't available.
|
|
131
|
+
*/
|
|
132
|
+
function tryCodexReview(diff) {
|
|
133
|
+
if (!CODEX_BIN) return null;
|
|
134
|
+
try {
|
|
135
|
+
spawnSync(CODEX_BIN, ['login', 'status'], {
|
|
136
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const model = getThinkModel();
|
|
144
|
+
const truncated = diff.length > MAX_DIFF_CHARS
|
|
145
|
+
? diff.slice(0, MAX_DIFF_CHARS) + '\n[truncated]'
|
|
146
|
+
: diff;
|
|
147
|
+
|
|
148
|
+
const fullPrompt = REVIEW_PROMPT + loadReviewRules();
|
|
149
|
+
const proc = spawnSync(CODEX_BIN, [
|
|
150
|
+
'exec', '--json', '--ephemeral',
|
|
151
|
+
'-c', `model="${model}"`,
|
|
152
|
+
'-s', 'danger-full-access',
|
|
153
|
+
fullPrompt,
|
|
154
|
+
], {
|
|
155
|
+
input: truncated,
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
158
|
+
timeout: CODEX_TIMEOUT * 1000,
|
|
159
|
+
});
|
|
160
|
+
const result = proc.stdout || '';
|
|
161
|
+
|
|
162
|
+
// Parse JSONL output, find agent_message items
|
|
163
|
+
const messages = result
|
|
164
|
+
.split('\n')
|
|
165
|
+
.filter(l => l.trim())
|
|
166
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
|
|
169
|
+
const agentMessages = messages
|
|
170
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
|
|
171
|
+
.map(m => m.item.text);
|
|
172
|
+
|
|
173
|
+
const usage = messages.find(m => m.type === 'turn.completed')?.usage;
|
|
174
|
+
|
|
175
|
+
if (agentMessages.length > 0) {
|
|
176
|
+
return {
|
|
177
|
+
review: agentMessages.join('\n\n'),
|
|
178
|
+
model,
|
|
179
|
+
auth_type: 'codex_subscription',
|
|
180
|
+
issues_found: hasIssues(agentMessages.join(' ')),
|
|
181
|
+
tokens: usage || null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check for errors
|
|
186
|
+
const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
|
|
187
|
+
if (errors.length > 0) {
|
|
188
|
+
return {
|
|
189
|
+
review: `Codex error: ${errors[0].message || errors[0].error?.message || 'unknown'}`,
|
|
190
|
+
error: true,
|
|
191
|
+
auth_type: 'codex_subscription',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
review: `Codex exec failed: ${err.message?.slice(0, 200) || 'unknown error'}`,
|
|
199
|
+
error: true,
|
|
200
|
+
auth_type: 'codex_subscription',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Try GPT review via direct API call (needs OPENAI_API_KEY).
|
|
207
|
+
*/
|
|
208
|
+
async function tryApiReview(diff) {
|
|
209
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
210
|
+
if (!apiKey) return null;
|
|
211
|
+
|
|
212
|
+
const model = getThinkModel();
|
|
213
|
+
const truncated = diff.length > MAX_DIFF_CHARS
|
|
214
|
+
? diff.slice(0, MAX_DIFF_CHARS) + '\n[truncated]'
|
|
215
|
+
: diff;
|
|
216
|
+
|
|
217
|
+
const fullPrompt = REVIEW_PROMPT + loadReviewRules();
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
signal: controller.signal,
|
|
225
|
+
headers: {
|
|
226
|
+
'Content-Type': 'application/json',
|
|
227
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
228
|
+
},
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
model,
|
|
231
|
+
messages: [
|
|
232
|
+
{ role: 'system', content: fullPrompt },
|
|
233
|
+
{ role: 'user', content: `Review this diff:\n\n\`\`\`diff\n${truncated}\n\`\`\`` },
|
|
234
|
+
],
|
|
235
|
+
temperature: 0,
|
|
236
|
+
max_tokens: 1000,
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
if (!response.ok) return null;
|
|
242
|
+
|
|
243
|
+
const data = await response.json();
|
|
244
|
+
const text = data?.choices?.[0]?.message?.content ?? '';
|
|
245
|
+
if (!text) return null;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
review: text,
|
|
249
|
+
model,
|
|
250
|
+
auth_type: 'api_key',
|
|
251
|
+
issues_found: hasIssues(text),
|
|
252
|
+
};
|
|
253
|
+
} catch {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function main() {
|
|
260
|
+
// 1. Get diff
|
|
261
|
+
let diff = runGit('git diff --staged') || '';
|
|
262
|
+
if (countLines(diff) < MIN_DIFF_LINES) {
|
|
263
|
+
const headDiff = runGit('git diff HEAD') || '';
|
|
264
|
+
if (countLines(headDiff) > countLines(diff)) diff = headDiff;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Also gather content of untracked source files
|
|
268
|
+
try {
|
|
269
|
+
const untracked = runGit('git ls-files --others --exclude-standard') || '';
|
|
270
|
+
const sourceExts = /\.(ts|tsx|js|jsx|py|rs|go|java|rb|swift|kt|mjs|cjs)$/;
|
|
271
|
+
const untrackedSrc = untracked.split('\n').filter(f => f && sourceExts.test(f));
|
|
272
|
+
for (const f of untrackedSrc.slice(0, 10)) { // cap at 10 files
|
|
273
|
+
const content = runGit(`git diff --no-index /dev/null "${f}"`);
|
|
274
|
+
if (content) diff += '\n' + content;
|
|
275
|
+
}
|
|
276
|
+
} catch {}
|
|
277
|
+
|
|
278
|
+
if (countLines(diff) < MIN_DIFF_LINES) {
|
|
279
|
+
exit({ review: 'No significant changes to review' });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 2. Try Codex CLI first (uses ChatGPT subscription)
|
|
283
|
+
const codexResult = tryCodexReview(diff);
|
|
284
|
+
if (codexResult) exit(codexResult);
|
|
285
|
+
|
|
286
|
+
// 3. Try direct API
|
|
287
|
+
const apiResult = await tryApiReview(diff);
|
|
288
|
+
if (apiResult) exit(apiResult);
|
|
289
|
+
|
|
290
|
+
// 4. No GPT available
|
|
291
|
+
exit({
|
|
292
|
+
review: 'No GPT review available. Install Codex CLI and login with your ChatGPT subscription, or set OPENAI_API_KEY.',
|
|
293
|
+
skip_reason: 'no_gpt_auth',
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
main().catch(err => {
|
|
298
|
+
process.stdout.write(
|
|
299
|
+
JSON.stringify({ review: `Unexpected error: ${err?.message ?? String(err)}`, error: true }) + '\n'
|
|
300
|
+
);
|
|
301
|
+
process.exit(0);
|
|
302
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dual-brain-think.mjs
|
|
4
|
+
*
|
|
5
|
+
* Runs a dual-perspective thinking process — GPT-5.5 (via Codex CLI) independently
|
|
6
|
+
* analyzes a question, then emits its output along with instructions for Claude
|
|
7
|
+
* (the main session) to provide its own independent analysis and compare both.
|
|
8
|
+
*
|
|
9
|
+
* Usage as CLI:
|
|
10
|
+
* node .claude/hooks/dual-brain-think.mjs \
|
|
11
|
+
* --question "Should we use queues or direct API calls for the notification system?"
|
|
12
|
+
*
|
|
13
|
+
* Usage as module:
|
|
14
|
+
* import { dualThink } from './dual-brain-think.mjs';
|
|
15
|
+
* const result = await dualThink({
|
|
16
|
+
* question: "Should we use queues or direct calls?",
|
|
17
|
+
* context: "Building a notification system that handles ~1000 events/min",
|
|
18
|
+
* files: ['src/notifications/'],
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execSync, spawnSync } from 'child_process';
|
|
23
|
+
import { appendFileSync } from 'fs';
|
|
24
|
+
import { dirname, join } from 'path';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
const CODEX_TIMEOUT_MS = 120_000;
|
|
30
|
+
const MODEL = 'gpt-5.5';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Codex discovery — same pattern as dual-brain-review.mjs
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function findCodex() {
|
|
37
|
+
const candidates = [
|
|
38
|
+
process.env.CODEX_BIN,
|
|
39
|
+
].filter(Boolean);
|
|
40
|
+
for (const c of candidates) {
|
|
41
|
+
try { spawnSync(c, ['--version'], { stdio: 'pipe', timeout: 3000 }); return c; } catch {}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const which = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
|
|
45
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
46
|
+
} catch {}
|
|
47
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
48
|
+
const fallbacks = [
|
|
49
|
+
join(home, '.local', 'bin', 'codex'),
|
|
50
|
+
join(home, 'bin', 'codex'),
|
|
51
|
+
'/usr/local/bin/codex',
|
|
52
|
+
];
|
|
53
|
+
for (const p of fallbacks) {
|
|
54
|
+
try { spawnSync(p, ['--version'], { stdio: 'pipe', timeout: 3000 }); return p; } catch {}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Prompt builder
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function buildGptPrompt({ question, context, files }) {
|
|
64
|
+
return `You are GPT-5.5, providing an independent architectural perspective.
|
|
65
|
+
|
|
66
|
+
Question: ${question}
|
|
67
|
+
${context ? `\nContext: ${context}` : ''}
|
|
68
|
+
${files?.length ? `\nRelevant files: ${files.join(', ')}` : ''}
|
|
69
|
+
|
|
70
|
+
Provide your analysis in this structure:
|
|
71
|
+
1. RECOMMENDATION: Your clear recommendation (1-2 sentences)
|
|
72
|
+
2. RATIONALE: Why this is the best approach (3-5 points)
|
|
73
|
+
3. ALTERNATIVES: What you considered and rejected
|
|
74
|
+
4. RISKS: What could go wrong with your recommendation
|
|
75
|
+
5. CONFIDENCE: low/medium/high and why
|
|
76
|
+
6. VERIFICATION: How to validate this decision is correct`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Codex executor
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
function runGptAnalysis(codexBin, prompt) {
|
|
84
|
+
const startTime = Date.now();
|
|
85
|
+
|
|
86
|
+
const proc = spawnSync(codexBin, [
|
|
87
|
+
'exec', '--json', '--ephemeral',
|
|
88
|
+
'-m', MODEL,
|
|
89
|
+
'-s', 'danger-full-access',
|
|
90
|
+
prompt,
|
|
91
|
+
], {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
94
|
+
timeout: CODEX_TIMEOUT_MS,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const durationMs = Date.now() - startTime;
|
|
98
|
+
|
|
99
|
+
// Parse JSONL output
|
|
100
|
+
const messages = (proc.stdout || '')
|
|
101
|
+
.split('\n')
|
|
102
|
+
.filter(l => l.trim())
|
|
103
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
|
|
106
|
+
const agentMessages = messages
|
|
107
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
|
|
108
|
+
.map(m => m.item.text);
|
|
109
|
+
|
|
110
|
+
const usage = messages.find(m => m.type === 'turn.completed')?.usage ?? null;
|
|
111
|
+
const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
|
|
112
|
+
|
|
113
|
+
if (agentMessages.length > 0) {
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
text: agentMessages.join('\n\n'),
|
|
117
|
+
durationMs,
|
|
118
|
+
usage,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
error: errors[0].message || errors[0].error?.message || 'unknown codex error',
|
|
126
|
+
durationMs,
|
|
127
|
+
usage: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
success: false,
|
|
133
|
+
error: 'No agent messages returned from Codex',
|
|
134
|
+
durationMs,
|
|
135
|
+
usage: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Usage logger — matches schema_version: 2 used across the orchestrator
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function logUsage({ durationMs, usage, success }) {
|
|
144
|
+
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
145
|
+
const entry = JSON.stringify({
|
|
146
|
+
schema_version: 2,
|
|
147
|
+
timestamp: new Date().toISOString(),
|
|
148
|
+
provider: 'openai',
|
|
149
|
+
tier: 'think',
|
|
150
|
+
tool: 'dual-brain-think',
|
|
151
|
+
model: MODEL,
|
|
152
|
+
dispatcher: 'dual-brain-think',
|
|
153
|
+
status: success ? 'ok' : 'error',
|
|
154
|
+
durationMs: durationMs ?? null,
|
|
155
|
+
input_tokens: usage?.input_tokens ?? null,
|
|
156
|
+
output_tokens: usage?.output_tokens ?? null,
|
|
157
|
+
session_id: process.env.CLAUDE_SESSION_ID || null,
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
appendFileSync(logFile, entry + '\n');
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Core exported function
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export async function dualThink({ question, context, files } = {}) {
|
|
169
|
+
if (!question) {
|
|
170
|
+
return {
|
|
171
|
+
gpt: null,
|
|
172
|
+
error: 'No question provided',
|
|
173
|
+
fallback: 'Proceed with single-brain analysis on Claude Opus',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const codexBin = findCodex();
|
|
178
|
+
if (!codexBin) {
|
|
179
|
+
return {
|
|
180
|
+
gpt: null,
|
|
181
|
+
error: 'Codex CLI not available',
|
|
182
|
+
fallback: 'Proceed with single-brain analysis on Claude Opus',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check Codex auth before running
|
|
187
|
+
try {
|
|
188
|
+
execSync(`${codexBin} login status`, {
|
|
189
|
+
encoding: 'utf8',
|
|
190
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
191
|
+
timeout: 5000,
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
return {
|
|
195
|
+
gpt: null,
|
|
196
|
+
error: 'Codex CLI not authenticated — run `codex login`',
|
|
197
|
+
fallback: 'Proceed with single-brain analysis on Claude Opus',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const prompt = buildGptPrompt({ question, context, files });
|
|
202
|
+
const raw = runGptAnalysis(codexBin, prompt);
|
|
203
|
+
|
|
204
|
+
logUsage({ durationMs: raw.durationMs, usage: raw.usage, success: raw.success });
|
|
205
|
+
|
|
206
|
+
if (!raw.success) {
|
|
207
|
+
return {
|
|
208
|
+
gpt: null,
|
|
209
|
+
error: raw.error || 'GPT analysis failed',
|
|
210
|
+
fallback: 'Proceed with single-brain analysis on Claude Opus',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
gpt: {
|
|
216
|
+
recommendation: raw.text,
|
|
217
|
+
model: MODEL,
|
|
218
|
+
durationMs: raw.durationMs,
|
|
219
|
+
tokens: raw.usage,
|
|
220
|
+
},
|
|
221
|
+
instructions: 'Now provide YOUR independent analysis of the same question. Then compare both perspectives and make a final decision. If you disagree with GPT, explain why with evidence.',
|
|
222
|
+
question,
|
|
223
|
+
context: context || null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// CLI argument parser
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function parseArgs(argv) {
|
|
232
|
+
const args = {};
|
|
233
|
+
let i = 0;
|
|
234
|
+
while (i < argv.length) {
|
|
235
|
+
const arg = argv[i];
|
|
236
|
+
if (arg.startsWith('--')) {
|
|
237
|
+
const eqIdx = arg.indexOf('=');
|
|
238
|
+
if (eqIdx !== -1) {
|
|
239
|
+
args[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
|
|
240
|
+
} else {
|
|
241
|
+
const key = arg.slice(2);
|
|
242
|
+
const next = argv[i + 1];
|
|
243
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
244
|
+
args[key] = next;
|
|
245
|
+
i++;
|
|
246
|
+
} else {
|
|
247
|
+
args[key] = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
i++;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Normalize files to an array
|
|
255
|
+
if (typeof args.files === 'string') {
|
|
256
|
+
args.files = args.files.split(',').map(f => f.trim()).filter(Boolean);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return args;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// CLI output formatter
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function printResult(result, question) {
|
|
267
|
+
const BAR = '╠══════════════════════════════════════════════════╣';
|
|
268
|
+
const TOP = '╔══════════════════════════════════════════════════╗';
|
|
269
|
+
const BOT = '╚══════════════════════════════════════════════════╝';
|
|
270
|
+
|
|
271
|
+
console.log(TOP);
|
|
272
|
+
console.log('║ Dual-Brain Think ║');
|
|
273
|
+
console.log(BAR);
|
|
274
|
+
// Truncate question to fit the box
|
|
275
|
+
const q = question.length > 44 ? question.slice(0, 41) + '...' : question;
|
|
276
|
+
console.log(`║ Question: ${q.padEnd(38)} ║`);
|
|
277
|
+
console.log(BAR);
|
|
278
|
+
|
|
279
|
+
if (!result.gpt) {
|
|
280
|
+
// Failure path
|
|
281
|
+
console.log(`║ ERROR: ${(result.error || 'Unknown error').padEnd(41)} ║`);
|
|
282
|
+
console.log(BAR);
|
|
283
|
+
console.log(`║ Fallback: ${(result.fallback || '').padEnd(39)} ║`);
|
|
284
|
+
console.log(BOT);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const durSec = (result.gpt.durationMs / 1000).toFixed(1);
|
|
289
|
+
console.log(`║ GPT-5.5 Perspective (${MODEL}, ${durSec}s):`.padEnd(51) + '║');
|
|
290
|
+
console.log(BAR);
|
|
291
|
+
console.log('');
|
|
292
|
+
console.log(result.gpt.recommendation);
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(BAR);
|
|
295
|
+
console.log('║ Now: Provide YOUR analysis and compare. ║');
|
|
296
|
+
console.log('║ If you disagree, explain why with evidence. ║');
|
|
297
|
+
console.log(BOT);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// CLI entry point
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
305
|
+
const args = parseArgs(process.argv.slice(2));
|
|
306
|
+
|
|
307
|
+
if (!args.question) {
|
|
308
|
+
console.error(
|
|
309
|
+
'Usage: node dual-brain-think.mjs --question "<question>" [--context "<context>"] [--files file1,file2]'
|
|
310
|
+
);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const result = await dualThink({
|
|
315
|
+
question: args.question,
|
|
316
|
+
context: args.context,
|
|
317
|
+
files: args.files,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
printResult(result, args.question);
|
|
321
|
+
}
|