dual-brain 4.1.0 → 4.2.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/README.md CHANGED
@@ -124,6 +124,24 @@ npx dual-brain status # check current profile and provider health
124
124
  - **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical work.
125
125
  - **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews.
126
126
 
127
+ ## Troubleshooting
128
+
129
+ **Hooks not firing** -- Run `node .claude/hooks/health-check.mjs`. Check that `.claude/settings.json` has the hook entries. Re-run `npx dual-brain` to re-register.
130
+
131
+ **Codex/GPT features unavailable** -- Run `codex --version` and `codex login`. If Codex CLI isn't installed: `npm i -g @openai/codex`. Re-run `npx dual-brain` to detect.
132
+
133
+ **Auth expired** -- Run `claude login` for Claude, `codex login` for OpenAI. Re-run `npx dual-brain` to re-detect.
134
+
135
+ **Duplicate warnings every time** -- Normal during agent waves (3+ agents in 90s). The system auto-suppresses. If persistent with single agents, check for identical task descriptions.
136
+
137
+ **Budget warnings too aggressive/too lenient** -- Switch profile: `npx dual-brain mode cost-saver` or `npx dual-brain mode quality-first`. Or set custom limits with `npx dual-brain budget <session$> [daily$]`.
138
+
139
+ **Corrupt state / weird behavior** -- Remove state files and re-run: `rm .claude/dual-brain.profile.json .claude/hooks/dual-brain.*.json 2>/dev/null; npx dual-brain`
140
+
141
+ **Multiple Claude Code sessions** -- State files may have brief write conflicts. Each session tracks independently. Use a single session for best results.
142
+
143
+ **Uninstall** -- `npx dual-brain --uninstall` removes hooks from settings.json and cleans state files.
144
+
127
145
  ## Requirements
128
146
 
129
147
  - Node 20+
@@ -0,0 +1,107 @@
1
+ /**
2
+ * atomic-write.mjs — Atomic file operations for the dual-brain orchestrator.
3
+ *
4
+ * Prevents race conditions in read-modify-write patterns under multi-session use.
5
+ * No dependencies — uses only Node.js builtins.
6
+ *
7
+ * Exported API:
8
+ * atomicWriteJSON(filePath, data) → write JSON atomically via tmp+rename
9
+ * lockedReadModifyWrite(filePath, modifyFn, default) → locked read-modify-write cycle
10
+ */
11
+
12
+ import { openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
13
+ import { constants } from 'fs';
14
+
15
+ const LOCK_TIMEOUT_MS = 5_000;
16
+ const STALE_LOCK_MS = 10_000;
17
+
18
+ /**
19
+ * Atomically write JSON data to filePath using tmp-file + rename.
20
+ * Tmp file is in the same directory to avoid cross-device rename issues.
21
+ */
22
+ export function atomicWriteJSON(filePath, data) {
23
+ const tmp = filePath + '.tmp.' + process.pid;
24
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
25
+ renameSync(tmp, filePath);
26
+ }
27
+
28
+ /**
29
+ * Acquire a .lock file using O_EXCL for atomic creation.
30
+ * Returns true if lock acquired, false otherwise.
31
+ * Steals stale locks (older than STALE_LOCK_MS).
32
+ */
33
+ function acquireLock(lockPath) {
34
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
35
+
36
+ while (Date.now() < deadline) {
37
+ try {
38
+ const fd = openSync(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
39
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
40
+ closeSync(fd);
41
+ return true;
42
+ } catch (err) {
43
+ if (err.code !== 'EEXIST') throw err;
44
+
45
+ // Check for stale lock
46
+ try {
47
+ const stat = statSync(lockPath);
48
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
49
+ // Stale lock — process likely died, steal it
50
+ try { unlinkSync(lockPath); } catch {}
51
+ continue;
52
+ }
53
+ } catch {
54
+ // Lock disappeared between our check — retry
55
+ continue;
56
+ }
57
+
58
+ // Wait briefly before retrying
59
+ const waitMs = 10 + Math.floor(Math.random() * 20);
60
+ const end = Date.now() + waitMs;
61
+ while (Date.now() < end) { /* spin */ }
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ function releaseLock(lockPath) {
68
+ try { unlinkSync(lockPath); } catch {}
69
+ }
70
+
71
+ /**
72
+ * Locked read-modify-write cycle.
73
+ *
74
+ * 1. Acquire .lock file (O_EXCL atomic creation)
75
+ * 2. Read current JSON (or use defaultValue if missing/corrupt)
76
+ * 3. Call modifyFn(currentData) → newData
77
+ * 4. Atomic write newData via tmp+rename
78
+ * 5. Release lock
79
+ *
80
+ * @param {string} filePath — JSON file to modify
81
+ * @param {function} modifyFn — (currentData) => newData
82
+ * @param {*} defaultValue — used if file doesn't exist or is corrupt
83
+ */
84
+ export function lockedReadModifyWrite(filePath, modifyFn, defaultValue = {}) {
85
+ const lockPath = filePath + '.lock';
86
+ const locked = acquireLock(lockPath);
87
+
88
+ if (!locked) {
89
+ // Timeout — fall through without lock (better than hanging)
90
+ // This matches the previous unlocked behavior as a degraded fallback
91
+ }
92
+
93
+ try {
94
+ let current;
95
+ try {
96
+ current = JSON.parse(readFileSync(filePath, 'utf8'));
97
+ } catch {
98
+ current = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
99
+ }
100
+
101
+ const updated = modifyFn(current);
102
+ atomicWriteJSON(filePath, updated);
103
+ return updated;
104
+ } finally {
105
+ if (locked) releaseLock(lockPath);
106
+ }
107
+ }
@@ -272,6 +272,28 @@ async function main() {
272
272
  } catch {}
273
273
  }
274
274
 
275
+ // Record outcomes (success + failure) to decision ledger for routing feedback
276
+ if (toolName === 'Agent') {
277
+ try {
278
+ const { computePromptHash } = await import('./failure-detector.mjs');
279
+ const { recordDecision, recordOutcome } = await import('./decision-ledger.mjs');
280
+ const promptHash = computePromptHash(toolInput);
281
+ const decisionId = recordDecision({
282
+ tier,
283
+ provider: detectProvider(model),
284
+ model,
285
+ prompt_hash: promptHash,
286
+ profile: loadActiveProfile(),
287
+ session_id: SESSION_ID,
288
+ });
289
+ recordOutcome(decisionId, {
290
+ success: status !== 'error',
291
+ actual_input_tokens: inputTokens,
292
+ actual_output_tokens: outputTokens,
293
+ });
294
+ } catch {}
295
+ }
296
+
275
297
  const budgetMsg = await checkBudget();
276
298
 
277
299
  // PostToolUse hooks must emit a JSON object to stdout
@@ -204,6 +204,55 @@ function getInsights(opts = {}) {
204
204
  };
205
205
  }
206
206
 
207
+ /**
208
+ * getOutcomeStats — lightweight aggregation for the routing hot path.
209
+ *
210
+ * Returns success rates by tier and provider over the last 24 hours,
211
+ * plus flags for any tier with < 50% success (with ≥ 5 outcomes).
212
+ */
213
+ function getOutcomeStats() {
214
+ const { decisions, outcomes } = loadLedger();
215
+ const merged = mergeDecisionsWithOutcomes(decisions, outcomes);
216
+
217
+ const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
218
+ const recent = merged.filter(d => d.outcome && d.timestamp >= cutoff);
219
+
220
+ const byTier = {};
221
+ const byProvider = {};
222
+
223
+ for (const d of recent) {
224
+ // Tier stats
225
+ const t = d.tier || 'execute';
226
+ if (!byTier[t]) byTier[t] = { total: 0, success: 0 };
227
+ byTier[t].total++;
228
+ if (d.outcome.success) byTier[t].success++;
229
+
230
+ // Provider stats
231
+ const p = d.provider || 'claude';
232
+ if (!byProvider[p]) byProvider[p] = { total: 0, success: 0 };
233
+ byProvider[p].total++;
234
+ if (d.outcome.success) byProvider[p].success++;
235
+ }
236
+
237
+ // Flag underperforming tiers (< 50% success with ≥ 5 outcomes)
238
+ const underperforming = [];
239
+ for (const [tier, stats] of Object.entries(byTier)) {
240
+ if (stats.total >= 5) {
241
+ const rate = Math.round((stats.success / stats.total) * 100);
242
+ if (rate < 50) {
243
+ underperforming.push({ tier, rate, total: stats.total });
244
+ }
245
+ }
246
+ }
247
+
248
+ return {
249
+ by_tier: byTier,
250
+ by_provider: byProvider,
251
+ total_outcomes: recent.length,
252
+ underperforming,
253
+ };
254
+ }
255
+
207
256
  // ─── CLI ────────────────────────────────────────────────────────────────────
208
257
 
209
258
  function printInsights() {
@@ -296,4 +345,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
296
345
  }
297
346
  }
298
347
 
299
- export { recordDecision, recordOutcome, getInsights, loadLedger };
348
+ export { recordDecision, recordOutcome, getInsights, getOutcomeStats, loadLedger };
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
2
+ import { readFileSync, writeFileSync, appendFileSync } from 'fs';
3
3
  import { dirname, resolve, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { classifyRisk, extractPaths } from './risk-classifier.mjs';
6
6
  import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
7
+ import { getOutcomeStats } from './decision-ledger.mjs';
8
+ import { atomicWriteJSON } from './atomic-write.mjs';
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
11
  const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
@@ -17,7 +19,7 @@ function detectBurst() {
17
19
  try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
18
20
  if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
19
21
  state.count++;
20
- try { writeFileSync(BURST_FILE, JSON.stringify(state)); } catch {}
22
+ try { atomicWriteJSON(BURST_FILE, state); } catch {}
21
23
  return state.count >= 3;
22
24
  }
23
25
 
@@ -90,9 +92,7 @@ function logRecommendation(event) {
90
92
  summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
91
93
  }
92
94
  summary.updated_at = new Date().toISOString();
93
- const tmp = summaryFile + '.tmp.' + process.pid;
94
- writeFileSync(tmp, JSON.stringify(summary, null, 2) + '\n');
95
- renameSync(tmp, summaryFile);
95
+ atomicWriteJSON(summaryFile, summary);
96
96
  } catch {}
97
97
 
98
98
  // Sync ledger write (append-only, fast)
@@ -254,10 +254,12 @@ try {
254
254
 
255
255
  // Balance hint — populated after tier is fully resolved
256
256
  let balanceHint = null;
257
+ // Outcome advisory — populated after tier is fully resolved
258
+ let outcomeAdvisory = null;
257
259
 
258
- // Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
260
+ // Helper to prepend optional warnings (duplicate + drift + balance + outcome + auto) before a message
259
261
  const prependWarnings = (msg) => {
260
- const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
262
+ const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean);
261
263
  return parts.join('\n\n');
262
264
  };
263
265
 
@@ -349,6 +351,17 @@ try {
349
351
  }
350
352
  }
351
353
 
354
+ // Outcome stats advisory — best-effort, suppressed in burst mode
355
+ if (!burstMode) {
356
+ try {
357
+ const stats = getOutcomeStats();
358
+ const tierIssue = stats.underperforming.find(u => u.tier === tier);
359
+ if (tierIssue) {
360
+ outcomeAdvisory = `Heads up — ${tierIssue.tier} tasks have been struggling lately (${tierIssue.rate}% success over ${tierIssue.total} recent outcomes). Consider escalating to a higher tier.`;
361
+ }
362
+ } catch {}
363
+ }
364
+
352
365
  const expected = preferredModel(config, tier);
353
366
 
354
367
  if (tier === 'think') {
@@ -363,7 +376,7 @@ try {
363
376
  followed: true,
364
377
  profile: profileName,
365
378
  });
366
- const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
379
+ const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
367
380
  if (onlyWarnings) {
368
381
  process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
369
382
  } else {
@@ -394,7 +407,7 @@ try {
394
407
  followed: true,
395
408
  profile: profileName,
396
409
  });
397
- const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
410
+ const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
398
411
  if (onlyWarnings) {
399
412
  process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
400
413
  } else {
@@ -12,6 +12,7 @@ import { createHash } from 'crypto';
12
12
  import { readFileSync, appendFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
13
13
  import { dirname, join } from 'path';
14
14
  import { fileURLToPath } from 'url';
15
+ import { atomicWriteJSON } from './atomic-write.mjs';
15
16
 
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -17,9 +17,10 @@
17
17
  */
18
18
 
19
19
  import { execSync as _execSync } from 'child_process';
20
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
20
+ import { existsSync, readFileSync } from 'fs';
21
21
  import { dirname, join } from 'path';
22
22
  import { fileURLToPath } from 'url';
23
+ import { atomicWriteJSON } from './atomic-write.mjs';
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
26
 
@@ -80,10 +81,9 @@ function emptySummary() {
80
81
 
81
82
  const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
82
83
 
84
+ /** @deprecated Use atomicWriteJSON directly. Kept as re-export for backward compat. */
83
85
  function atomicWrite(path, data) {
84
- const tmp = path + '.tmp.' + process.pid;
85
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
86
- renameSync(tmp, path);
86
+ atomicWriteJSON(path, data);
87
87
  }
88
88
 
89
89
  function readSummary(date) {
@@ -313,31 +313,116 @@ test('orchestrator.json: dual_thinking configured', () => {
313
313
  return true;
314
314
  });
315
315
 
316
- // ─── Test 15: profile consistency across modules ────────────────────────────
316
+ // ─── Test 15: profile consistency (behavioral) ─────────────────────────────
317
317
  test('profiles: consistent across modules', () => {
318
- const profilesSrc = readFileSync(resolve(__dirname, 'profiles.mjs'), 'utf8');
319
- const profileNames = ['auto', 'balanced', 'cost-saver', 'quality-first'];
320
- for (const name of profileNames) {
321
- if (!profilesSrc.includes(`${name}:`) && !profilesSrc.includes(`'${name}':`)) return `profiles.mjs missing: ${name}`;
322
- }
318
+ const script = `
319
+ import { PROFILES, getActiveProfile } from './profiles.mjs';
320
+ const results = { errors: [] };
321
+
322
+ // 1. All 4 profiles exist
323
+ const expected = ['auto', 'balanced', 'cost-saver', 'quality-first'];
324
+ for (const name of expected) {
325
+ if (!PROFILES[name]) results.errors.push('missing profile: ' + name);
326
+ }
323
327
 
324
- const installSrc = readFileSync(resolve(__dirname, '..', 'install.mjs'), 'utf8');
325
- for (const name of profileNames) {
326
- if (!installSrc.includes(`${name}:`) && !installSrc.includes(`'${name}':`)) return `install.mjs missing profile: ${name}`;
327
- }
328
+ // 2. Each profile has required fields
329
+ const requiredFields = ['description', 'routing', 'budgets', 'quality_gate'];
330
+ const routingFields = ['prefer_provider', 'think_threshold', 'gpt_dispatch_bias'];
331
+ const budgetFields = ['session_warn_usd', 'session_limit_usd', 'daily_warn_usd', 'daily_limit_usd'];
328
332
 
329
- const enforceSrc = readFileSync(resolve(__dirname, 'enforce-tier.mjs'), 'utf8');
330
- if (!enforceSrc.includes('auto:')) return 'enforce-tier.mjs missing auto in PROFILE_SETTINGS';
333
+ for (const name of expected) {
334
+ const p = PROFILES[name];
335
+ if (!p) continue;
336
+ for (const f of requiredFields) {
337
+ if (!p[f]) results.errors.push(name + ' missing field: ' + f);
338
+ }
339
+ for (const f of routingFields) {
340
+ if (p.routing[f] === undefined) results.errors.push(name + ' routing missing: ' + f);
341
+ }
342
+ for (const f of budgetFields) {
343
+ if (typeof p.budgets[f] !== 'number' || p.budgets[f] <= 0)
344
+ results.errors.push(name + ' budget not positive number: ' + f + '=' + p.budgets[f]);
345
+ }
346
+ }
331
347
 
348
+ // 3. getActiveProfile returns a valid profile
349
+ const active = getActiveProfile();
350
+ if (!active.name) results.errors.push('getActiveProfile missing name');
351
+ if (!active.routing) results.errors.push('getActiveProfile missing routing');
352
+ if (!active.budgets) results.errors.push('getActiveProfile missing budgets');
353
+
354
+ process.stdout.write(JSON.stringify(results));
355
+ `;
356
+ const proc = spawnSync(process.execPath, [
357
+ '--input-type=module',
358
+ '-e', script,
359
+ ], { encoding: 'utf8', timeout: 5000, cwd: HOOKS });
360
+
361
+ if (proc.status !== 0) return `profiles script failed: ${proc.stderr}`;
362
+ let results;
363
+ try { results = JSON.parse(proc.stdout.trim()); } catch { return `output not JSON: ${proc.stdout}`; }
364
+ if (results.errors.length > 0) return results.errors.join('; ');
332
365
  return true;
333
366
  });
334
367
 
335
- // ─── Test 16: failure-detector only counts real failures ─────────────────────
336
- test('failure-detector: ignores followed=false', () => {
337
- const src = readFileSync(resolve(__dirname, 'failure-detector.mjs'), 'utf8');
338
- if (src.includes('followed === false')) return 'still conflates followed=false with failure';
339
- if (!src.includes('success === false') && !src.includes('success !== false')) return 'missing success check';
340
- return true;
368
+ // ─── Test 16: failure-detector API contract (behavioral) ────────────────────
369
+ test('failure-detector: API contract', () => {
370
+ const LEDGER = resolve(HOOKS, 'decision-ledger.jsonl');
371
+ const backup = existsSync(LEDGER) ? readFileSync(LEDGER, 'utf8') : null;
372
+
373
+ try {
374
+ // Start with clean ledger
375
+ writeFileSync(LEDGER, '', 'utf8');
376
+
377
+ const script = `
378
+ import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
379
+ const results = { errors: [] };
380
+
381
+ // 1. computePromptHash returns 12-char hex string
382
+ const hash = computePromptHash({ prompt: 'test prompt', description: 'test desc' });
383
+ if (typeof hash !== 'string') results.errors.push('hash not a string: ' + typeof hash);
384
+ else if (hash.length !== 12) results.errors.push('hash length not 12: ' + hash.length);
385
+ else if (!/^[0-9a-f]{12}$/.test(hash)) results.errors.push('hash not hex: ' + hash);
386
+
387
+ // 2. checkFailureLoop returns { isLoop, score } shape (before any failures)
388
+ const check1 = checkFailureLoop(hash);
389
+ if (typeof check1 !== 'object' || check1 === null) results.errors.push('checkFailureLoop did not return object');
390
+ else {
391
+ if (typeof check1.isLoop !== 'boolean' && typeof check1.isLoop !== 'undefined')
392
+ // isLoop should be boolean
393
+ results.errors.push('isLoop not boolean: ' + typeof check1.isLoop);
394
+ if (!('weightedScore' in check1 || 'score' in check1))
395
+ results.errors.push('checkFailureLoop missing score field');
396
+ }
397
+
398
+ // 3. recordFailure is callable without throwing
399
+ try {
400
+ recordFailure(hash, 'execute', 'test_reason');
401
+ } catch (e) {
402
+ results.errors.push('recordFailure threw: ' + e.message);
403
+ }
404
+
405
+ // 4. After recording failures, checkFailureLoop detects them
406
+ recordFailure(hash, 'execute', 'test_reason_2');
407
+ const check2 = checkFailureLoop(hash);
408
+ if (check2.count < 2) results.errors.push('expected count >= 2 after 2 recordFailure calls, got: ' + check2.count);
409
+
410
+ process.stdout.write(JSON.stringify(results));
411
+ `;
412
+ const proc = spawnSync(process.execPath, [
413
+ '--input-type=module',
414
+ '-e', script,
415
+ ], { encoding: 'utf8', timeout: 5000, cwd: HOOKS });
416
+
417
+ if (proc.status !== 0) return `failure-detector script failed: ${proc.stderr}`;
418
+ let results;
419
+ try { results = JSON.parse(proc.stdout.trim()); } catch { return `output not JSON: ${proc.stdout}`; }
420
+ if (results.errors.length > 0) return results.errors.join('; ');
421
+ return true;
422
+ } finally {
423
+ if (backup !== null) writeFileSync(LEDGER, backup, 'utf8');
424
+ else try { writeFileSync(LEDGER, '', 'utf8'); } catch {}
425
+ }
341
426
  });
342
427
 
343
428
  // ─── Test 17: enforce-tier: malformed stdin ─────────────────────────────────
@@ -642,7 +727,7 @@ test('adaptive: cost-logger records Agent errors', () => {
642
727
  let entry;
643
728
  try { entry = JSON.parse(newEntry); } catch { return `last line not valid JSON: ${newEntry}`; }
644
729
  if (entry.success !== false) return `expected success=false, got: ${entry.success}`;
645
- if (entry.type !== 'failure') return `expected type=failure, got: ${entry.type}`;
730
+ if (entry.type !== 'outcome') return `expected type=outcome, got: ${entry.type}`;
646
731
  return true;
647
732
  } finally {
648
733
  if (backup !== null) writeFileSync(LEDGER, backup, 'utf8');
@@ -799,7 +884,7 @@ test('hooks: output files use dual-brain-namespaced paths', () => {
799
884
  const src = readFileSync(resolve(__dirname, hookFile), 'utf8');
800
885
 
801
886
  // Find all file paths the hook writes to (writeFileSync / appendFileSync targets)
802
- const writeTargets = [...src.matchAll(/(?:writeFileSync|appendFileSync|renameSync)\(\s*([^,)]+)/g)].map(m => m[1].trim());
887
+ const writeTargets = [...src.matchAll(/(?:writeFileSync|appendFileSync|renameSync|atomicWriteJSON)\(\s*([^,)]+)/g)].map(m => m[1].trim());
803
888
 
804
889
  if (writeTargets.length === 0) return `${hookFile}: no write targets found`;
805
890
 
package/install.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * npx dual-brain --dry-run # detect only, don't install
9
9
  * npx dual-brain --help
10
10
  */
11
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
11
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'fs';
12
12
  import { dirname, join, resolve } from 'path';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { spawnSync } from 'child_process';
@@ -55,6 +55,7 @@ if (flag('--help') || flag('-h')) {
55
55
  --force Overwrite all existing config
56
56
  --dry-run Detect environment only
57
57
  --json Output detection as JSON
58
+ --uninstall Remove dual-brain hooks and state files
58
59
  --help Show this help
59
60
 
60
61
  🎛️ Routing modes:
@@ -697,9 +698,130 @@ function cmdExplain() {
697
698
  console.log('');
698
699
  }
699
700
 
701
+ // ─── Uninstall ─────────────────────────────────────────────────────────────
702
+
703
+ function cmdUninstall() {
704
+ const workspace = resolve(process.cwd());
705
+ const claudeDir = join(workspace, '.claude');
706
+ const hooksDir = join(claudeDir, 'hooks');
707
+ const actions = [];
708
+
709
+ // 1. Remove dual-brain hooks from settings.json
710
+ const settingsPath = join(claudeDir, 'settings.json');
711
+ if (existsSync(settingsPath)) {
712
+ try {
713
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
714
+ const DUAL_BRAIN_CMDS = [
715
+ 'node .claude/hooks/enforce-tier.mjs',
716
+ 'node .claude/hooks/cost-logger.mjs',
717
+ ];
718
+
719
+ if (settings.hooks) {
720
+ let removedCount = 0;
721
+ for (const event of Object.keys(settings.hooks)) {
722
+ const before = settings.hooks[event].length;
723
+ settings.hooks[event] = settings.hooks[event].filter(entry =>
724
+ !entry.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
725
+ );
726
+ removedCount += before - settings.hooks[event].length;
727
+
728
+ // Clean up empty arrays
729
+ if (settings.hooks[event].length === 0) {
730
+ delete settings.hooks[event];
731
+ }
732
+ }
733
+
734
+ // Clean up empty hooks object
735
+ if (Object.keys(settings.hooks).length === 0) {
736
+ delete settings.hooks;
737
+ }
738
+
739
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
740
+ if (removedCount > 0) {
741
+ actions.push(`✓ Removed ${removedCount} hook(s) from settings.json`);
742
+ } else {
743
+ actions.push('⊘ No dual-brain hooks found in settings.json');
744
+ }
745
+ } else {
746
+ actions.push('⊘ No hooks section in settings.json');
747
+ }
748
+ } catch (err) {
749
+ actions.push(`⚠ Could not parse settings.json: ${err.message}`);
750
+ }
751
+ } else {
752
+ actions.push('⊘ No settings.json found');
753
+ }
754
+
755
+ // 2. Remove state files
756
+ const stateFiles = [
757
+ join(claudeDir, 'dual-brain.profile.json'),
758
+ join(claudeDir, 'dual-brain.memory.json'),
759
+ join(claudeDir, '.launched'),
760
+ ];
761
+
762
+ // Add date-stamped usage files and summary files
763
+ const today = new Date().toISOString().slice(0, 10);
764
+ stateFiles.push(join(hooksDir, 'usage.jsonl'));
765
+ stateFiles.push(join(hooksDir, `usage-${today}.jsonl`));
766
+ stateFiles.push(join(hooksDir, 'decision-ledger.jsonl'));
767
+ stateFiles.push(join(hooksDir, '.drift-warned'));
768
+ stateFiles.push(join(hooksDir, '.budget-alerted'));
769
+
770
+ // Scan for any usage-*.jsonl and usage-summary-*.json files
771
+ try {
772
+ const files = readdirSync(hooksDir);
773
+ for (const f of files) {
774
+ if (f.startsWith('usage-') && f.endsWith('.jsonl')) {
775
+ stateFiles.push(join(hooksDir, f));
776
+ }
777
+ if (f.startsWith('usage-summary-') && f.endsWith('.json')) {
778
+ stateFiles.push(join(hooksDir, f));
779
+ }
780
+ if (f === 'burst-state.json' || f === 'failure-ledger.json') {
781
+ stateFiles.push(join(hooksDir, f));
782
+ }
783
+ }
784
+ } catch {}
785
+
786
+ // Deduplicate
787
+ const uniqueFiles = [...new Set(stateFiles)];
788
+
789
+ let removedFiles = 0;
790
+ for (const f of uniqueFiles) {
791
+ try {
792
+ if (existsSync(f)) {
793
+ unlinkSync(f);
794
+ removedFiles++;
795
+ }
796
+ } catch {}
797
+ }
798
+
799
+ if (removedFiles > 0) {
800
+ actions.push(`✓ Removed ${removedFiles} state file(s)`);
801
+ } else {
802
+ actions.push('⊘ No state files to remove');
803
+ }
804
+
805
+ // 3. Print summary
806
+ console.log('');
807
+ console.log(` 🧠 dual-brain v${VERSION} — uninstall`);
808
+ console.log(' ' + '─'.repeat(40));
809
+ for (const a of actions) {
810
+ console.log(` ${a}`);
811
+ }
812
+ console.log('');
813
+ console.log(' Hook scripts in .claude/hooks/ were left in place');
814
+ console.log(' (they are part of the npm package, not your repo).');
815
+ console.log('');
816
+ console.log(' To reinstall: npx -y dual-brain');
817
+ console.log('');
818
+ }
819
+
700
820
  // ─── Main ───────────────────────────────────────────────────────────────────
701
821
 
702
822
  function main() {
823
+ if (flag('--uninstall')) { cmdUninstall(); return; }
824
+
703
825
  if (subcommand === 'status') {
704
826
  launchPanel();
705
827
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Dual-provider orchestration for Claude Code — tiered routing, budget balancing, and GPT dual-brain review across Claude + OpenAI subscriptions",
5
5
  "type": "module",
6
6
  "bin": {