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 +18 -0
- package/hooks/atomic-write.mjs +107 -0
- package/hooks/cost-logger.mjs +22 -0
- package/hooks/decision-ledger.mjs +50 -1
- package/hooks/enforce-tier.mjs +22 -9
- package/hooks/failure-detector.mjs +1 -0
- package/hooks/summary-checkpoint.mjs +4 -4
- package/hooks/test-orchestrator.mjs +105 -20
- package/install.mjs +123 -1
- package/package.json +1 -1
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
|
+
}
|
package/hooks/cost-logger.mjs
CHANGED
|
@@ -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 };
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, appendFileSync
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
316
|
+
// ─── Test 15: profile consistency (behavioral) ─────────────────────────────
|
|
317
317
|
test('profiles: consistent across modules', () => {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
336
|
-
test('failure-detector:
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 !== '
|
|
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