dual-brain 4.6.0 → 4.7.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.
@@ -5,27 +5,14 @@
5
5
  * Sends git diffs to GPT for independent code review using the Codex CLI
6
6
  * (uses your ChatGPT subscription — no API key needed).
7
7
  *
8
- * Auto mode (default — no --round flag):
9
- * Runs the full 2-round review collaboration automatically.
10
- * node .claude/hooks/dual-brain-review.mjs
11
- *
12
- * Manual Round 1:
13
- * node .claude/hooks/dual-brain-review.mjs --round 1
14
- *
15
- * Manual Round 2:
16
- * node .claude/hooks/dual-brain-review.mjs --round 2 --claude-review "<findings>"
17
- *
18
- * Force manual mode:
19
- * node .claude/hooks/dual-brain-review.mjs --manual
20
- *
21
8
  * Falls back to direct OpenAI API if OPENAI_API_KEY is set.
22
9
  * Falls back to "no GPT available" if neither works.
23
10
  *
24
- * Output: JSON to stdout — always valid, never crashes (manual/round mode).
25
- * Human-readable output in auto mode.
11
+ * Usage: node .claude/hooks/dual-brain-review.mjs
12
+ * Output: JSON to stdout — always valid, never crashes.
26
13
  */
27
14
 
28
- import { execSync, spawnSync } from 'child_process';
15
+ import { spawnSync } from 'child_process';
29
16
  import { readFileSync } from 'fs';
30
17
  import { dirname, join, resolve } from 'path';
31
18
  import { fileURLToPath } from 'url';
@@ -69,16 +56,6 @@ Be direct. If Claude found something real that you missed, say so.
69
56
  If Claude flagged something that isn't actually a problem, explain why with evidence.
70
57
  The goal is the most accurate review, not defending your initial take.`;
71
58
 
72
- const CLAUDE_REVIEW_PROMPT = `Review the current git diff for bugs, security issues, and code quality problems.
73
-
74
- Look for:
75
- 1. Correctness — logic errors, null/undefined risks, off-by-one
76
- 2. Security — injection, auth bypass, data exposure
77
- 3. Edge cases — what breaks under unusual input
78
- 4. Quality — naming issues, unnecessary complexity
79
-
80
- Be concise — under 300 words. List findings ordered by severity. If the code looks good, say LGTM.`;
81
-
82
59
  function loadReviewRules() {
83
60
  const rulesFile = resolve(__dirname, '..', 'review-rules.md');
84
61
  try {
@@ -93,7 +70,6 @@ function loadReviewRules() {
93
70
  const MAX_DIFF_CHARS = 15000;
94
71
  const MIN_DIFF_LINES = 5;
95
72
  const CODEX_TIMEOUT = 90;
96
- const CLAUDE_TIMEOUT_MS = 60_000;
97
73
 
98
74
  function findCodex() {
99
75
  const candidates = [
@@ -118,34 +94,26 @@ function findCodex() {
118
94
  return null;
119
95
  }
120
96
 
121
- function findClaude() {
122
- try {
123
- const which = spawnSync('which', ['claude'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
124
- if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
125
- } catch {}
126
- const home = process.env.HOME || process.env.USERPROFILE || '';
127
- const fallbacks = [
128
- join(home, '.local', 'bin', 'claude'),
129
- join(home, 'bin', 'claude'),
130
- '/usr/local/bin/claude',
131
- ];
132
- for (const p of fallbacks) {
133
- try {
134
- const res = spawnSync(p, ['--version'], { stdio: 'pipe', timeout: 3000 });
135
- if (res.status === 0) return p;
136
- } catch {}
137
- }
138
- return null;
139
- }
140
-
141
97
  const CODEX_BIN = findCodex();
142
98
 
143
- function runGit(cmd) {
99
+ function runGit(args) {
144
100
  try {
145
- return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
101
+ const proc = spawnSync('git', args, {
102
+ encoding: 'utf8',
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ timeout: 10_000,
105
+ });
106
+ return proc.status === 0 ? proc.stdout : null;
146
107
  } catch { return null; }
147
108
  }
148
109
 
110
+ function isCodexAuthenticated(result) {
111
+ const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
112
+ if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
113
+ return result?.status === 0 ||
114
+ /\b(logged\s+in|authenticated|signed\s+in)\b/.test(out);
115
+ }
116
+
149
117
  function countLines(str) {
150
118
  return (str || '').split('\n').filter(l => l.trim().length > 0).length;
151
119
  }
@@ -186,58 +154,6 @@ function hasIssues(text) {
186
154
  return true;
187
155
  }
188
156
 
189
- function buildReviewSynthesis(gptR1Text, claudeText, gptR2Text) {
190
- const lines = [];
191
-
192
- lines.push('REVIEW SYNTHESIS');
193
- lines.push('─'.repeat(50));
194
-
195
- // CONFIRMED findings
196
- const confirmedMatch = gptR2Text.match(/CONFIRMED[:\s\n]+([\s\S]*?)(?=\n\s*(?:MISSED|DISAGREE|ESCALATED|VERDICT|[0-9]+\.)|$)/i);
197
- if (confirmedMatch && confirmedMatch[1].trim().length > 5) {
198
- lines.push('');
199
- lines.push('HIGH-CONFIDENCE FINDINGS (both found):');
200
- lines.push(confirmedMatch[1].trim().split('\n').slice(0, 6).join('\n'));
201
- }
202
-
203
- // MISSED findings (Claude caught, GPT missed)
204
- const missedMatch = gptR2Text.match(/MISSED[:\s\n]+([\s\S]*?)(?=\n\s*(?:DISAGREE|ESCALATED|VERDICT|[0-9]+\.)|$)/i);
205
- if (missedMatch && missedMatch[1].trim().length > 5) {
206
- lines.push('');
207
- lines.push('ADDITIONAL FINDINGS (Claude caught):');
208
- lines.push(missedMatch[1].trim().split('\n').slice(0, 4).join('\n'));
209
- }
210
-
211
- // ESCALATED
212
- const escalatedMatch = gptR2Text.match(/ESCALATED[:\s\n]+([\s\S]*?)(?=\n\s*(?:VERDICT|[0-9]+\.)|$)/i);
213
- if (escalatedMatch && escalatedMatch[1].trim().length > 5) {
214
- lines.push('');
215
- lines.push('ESCALATED SEVERITY:');
216
- lines.push(escalatedMatch[1].trim().split('\n').slice(0, 3).join('\n'));
217
- }
218
-
219
- // DISAGREE
220
- const disagreeMatch = gptR2Text.match(/DISAGREE[:\s\n]+([\s\S]*?)(?=\n\s*(?:ESCALATED|VERDICT|[0-9]+\.)|$)/i);
221
- if (disagreeMatch && disagreeMatch[1].trim().length > 5) {
222
- lines.push('');
223
- lines.push('DISPUTED (possible false positives):');
224
- lines.push(disagreeMatch[1].trim().split('\n').slice(0, 3).join('\n'));
225
- }
226
-
227
- // VERDICT
228
- const verdictMatch = gptR2Text.match(/VERDICT[:\s\n]+([\s\S]*?)(?=\n\s*[0-9]+\.|$)/i);
229
- if (verdictMatch) {
230
- lines.push('');
231
- lines.push('VERDICT:');
232
- lines.push(verdictMatch[1].trim().split('\n').slice(0, 2).join('\n'));
233
- }
234
-
235
- lines.push('');
236
- lines.push('─'.repeat(50));
237
-
238
- return lines.join('\n');
239
- }
240
-
241
157
  function exit(obj) {
242
158
  process.stdout.write(JSON.stringify(obj) + '\n');
243
159
  process.exit(0);
@@ -249,11 +165,10 @@ function exit(obj) {
249
165
  */
250
166
  function tryCodexReview(diff, { round = 1, claudeReview = null } = {}) {
251
167
  if (!CODEX_BIN) return null;
252
- try {
253
- spawnSync(CODEX_BIN, ['login', 'status'], {
254
- encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
255
- });
256
- } catch {
168
+ const login = spawnSync(CODEX_BIN, ['login', 'status'], {
169
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
170
+ });
171
+ if (!isCodexAuthenticated(login)) {
257
172
  return null;
258
173
  }
259
174
 
@@ -327,34 +242,6 @@ function tryCodexReview(diff, { round = 1, claudeReview = null } = {}) {
327
242
  }
328
243
  }
329
244
 
330
- /**
331
- * Try Claude CLI review.
332
- */
333
- function tryClaudeReview(diff) {
334
- const claudeBin = findClaude();
335
- if (!claudeBin) return null;
336
-
337
- const truncated = diff.length > MAX_DIFF_CHARS
338
- ? diff.slice(0, MAX_DIFF_CHARS) + '\n[truncated]'
339
- : diff;
340
-
341
- const prompt = `${CLAUDE_REVIEW_PROMPT}\n\nDiff to review:\n\`\`\`diff\n${truncated}\n\`\`\``;
342
-
343
- try {
344
- const proc = spawnSync(claudeBin, ['-p', prompt], {
345
- encoding: 'utf8',
346
- stdio: ['pipe', 'pipe', 'pipe'],
347
- timeout: CLAUDE_TIMEOUT_MS,
348
- });
349
-
350
- if (proc.status === 0 && proc.stdout && proc.stdout.trim()) {
351
- return proc.stdout.trim();
352
- }
353
- } catch {}
354
-
355
- return null;
356
- }
357
-
358
245
  /**
359
246
  * Try GPT review via direct API call (needs OPENAI_API_KEY).
360
247
  */
@@ -442,152 +329,29 @@ function parseArgs(argv) {
442
329
  return args;
443
330
  }
444
331
 
445
- // ---------------------------------------------------------------------------
446
- // Auto mode — full 2-round review collaboration in one shot
447
- // ---------------------------------------------------------------------------
448
-
449
- async function runAutoReviewMode(diff) {
450
- const BAR = '╠══════════════════════════════════════════════════╣';
451
- const TOP = '╔══════════════════════════════════════════════════╗';
452
- const BOT = '╚══════════════════════════════════════════════════╝';
453
- const WIDE = '║';
454
-
455
- const lineCount = countLines(diff);
456
-
457
- console.log(TOP);
458
- console.log(`${WIDE} Dual-Brain Review — Auto Mode`.padEnd(51) + WIDE);
459
- console.log(`${WIDE} ${lineCount} diff lines to review`.padEnd(51) + WIDE);
460
- console.log(BOT);
461
- console.log('');
462
-
463
- if (!CODEX_BIN) {
464
- console.log('[Auto mode] Codex CLI not found — falling back to manual mode.');
465
- console.log('');
466
- console.log('Manual steps:');
467
- console.log(' 1. Run: node hooks/dual-brain-review.mjs --round 1');
468
- console.log(' 2. Review independently');
469
- console.log(' 3. Run: node hooks/dual-brain-review.mjs --round 2 --claude-review "<findings>"');
470
- return;
471
- }
472
-
473
- // Step 1: GPT Round 1
474
- console.log('[ 1/4 ] Sending diff to GPT for Round 1 review...');
475
- const r1Result = tryCodexReview(diff, { round: 1 });
476
-
477
- if (!r1Result || r1Result.error) {
478
- const errMsg = r1Result?.review || 'Codex unavailable or not authenticated';
479
- console.log(`[Auto mode] GPT Round 1 failed: ${errMsg}`);
480
-
481
- // Try API fallback
482
- const apiR1 = await tryApiReview(diff, { round: 1 });
483
- if (!apiR1) {
484
- console.log('[Auto mode] No GPT available. Falling back to manual mode.');
485
- return;
486
- }
487
- console.log('');
488
- console.log(TOP);
489
- console.log(`${WIDE} Round 1 — GPT Review (API fallback)`.padEnd(51) + WIDE);
490
- console.log(BOT);
491
- console.log('');
492
- console.log(apiR1.review);
493
- console.log('');
494
- // Can't continue with auto Round 2 via API easily — prompt manual
495
- console.log('[Auto mode] API fallback: review Claude perspective manually, then run Round 2.');
496
- return;
497
- }
498
-
499
- console.log('');
500
- console.log(TOP);
501
- console.log(`${WIDE} Round 1 — GPT Review`.padEnd(51) + WIDE);
502
- console.log(BOT);
503
- console.log('');
504
- console.log(r1Result.review);
505
- console.log('');
506
-
507
- // Step 2: Claude's independent review
508
- console.log('[ 2/4 ] Generating Claude independent review...');
509
- const claudeReviewText = tryClaudeReview(diff);
510
-
511
- if (!claudeReviewText) {
512
- console.log('[Auto mode] Claude CLI not available — skipping Claude review step.');
513
- console.log('Set your PATH to include the `claude` binary to enable full auto mode.');
514
- console.log('');
515
- } else {
516
- console.log('');
517
- console.log(TOP);
518
- console.log(`${WIDE} Claude Independent Review`.padEnd(51) + WIDE);
519
- console.log(BOT);
520
- console.log('');
521
- console.log(claudeReviewText);
522
- console.log('');
523
- }
524
-
525
- // Step 3: GPT Round 2
526
- const claudeFindings = claudeReviewText || '(Claude review unavailable — assess independently)';
527
- console.log('[ 3/4 ] Sending Round 2 to GPT with Claude findings...');
528
- const r2Result = tryCodexReview(diff, { round: 2, claudeReview: claudeFindings });
529
-
530
- if (!r2Result || r2Result.error) {
531
- console.log('[Auto mode] GPT Round 2 failed. Synthesis skipped.');
532
- console.log('Review Round 1 and Claude findings above for your assessment.');
533
- return;
534
- }
535
-
536
- console.log('');
537
- console.log(TOP);
538
- console.log(`${WIDE} Round 2 — GPT Cross-Validation`.padEnd(51) + WIDE);
539
- console.log(BOT);
540
- console.log('');
541
- console.log(r2Result.review);
542
- console.log('');
543
-
544
- // Step 4: Synthesis
545
- console.log('[ 4/4 ] Building review synthesis...');
546
- console.log('');
547
- console.log(TOP);
548
- console.log(`${WIDE} Final Review Synthesis`.padEnd(51) + WIDE);
549
- console.log(BOT);
550
- console.log('');
551
- console.log(buildReviewSynthesis(r1Result.review, claudeReviewText || '', r2Result.review));
552
- console.log('');
553
- }
554
-
555
332
  async function main() {
556
333
  const args = parseArgs(process.argv.slice(2));
557
- const hasExplicitRound = args.round !== undefined;
558
- const isManual = args.manual === true || hasExplicitRound;
559
-
560
334
  const round = args.round ? parseInt(args.round, 10) : 1;
561
335
  const claudeReview = args['claude-review'] || null;
562
336
  const opts = { round, claudeReview };
563
337
 
564
338
  // 1. Get diff
565
- let diff = runGit('git diff --staged') || '';
339
+ let diff = runGit(['diff', '--staged']) || '';
566
340
  if (countLines(diff) < MIN_DIFF_LINES) {
567
- const headDiff = runGit('git diff HEAD') || '';
341
+ const headDiff = runGit(['diff', 'HEAD']) || '';
568
342
  if (countLines(headDiff) > countLines(diff)) diff = headDiff;
569
343
  }
570
344
 
571
345
  try {
572
- const untracked = runGit('git ls-files --others --exclude-standard') || '';
346
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard']) || '';
573
347
  const sourceExts = /\.(ts|tsx|js|jsx|py|rs|go|java|rb|swift|kt|mjs|cjs)$/;
574
348
  const untrackedSrc = untracked.split('\n').filter(f => f && sourceExts.test(f));
575
349
  for (const f of untrackedSrc.slice(0, 10)) {
576
- const content = runGit(`git diff --no-index /dev/null "${f}"`);
350
+ const content = runGit(['diff', '--no-index', '/dev/null', f]);
577
351
  if (content) diff += '\n' + content;
578
352
  }
579
353
  } catch {}
580
354
 
581
- if (!isManual) {
582
- // Auto mode — human-readable output when there are changes
583
- if (countLines(diff) >= MIN_DIFF_LINES) {
584
- await runAutoReviewMode(diff);
585
- return;
586
- }
587
- // No changes: fall through to JSON output for programmatic callers
588
- }
589
-
590
- // Manual / round mode — JSON output (backward compat)
591
355
  if (countLines(diff) < MIN_DIFF_LINES) {
592
356
  exit({ review: 'No significant changes to review' });
593
357
  }