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.
@@ -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
+ }