dual-brain 3.5.0 → 3.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.
- package/CLAUDE.md +10 -1
- package/hooks/control-panel.mjs +75 -65
- package/hooks/enforce-tier.mjs +35 -6
- package/hooks/failure-detector.mjs +62 -0
- package/hooks/profiles.mjs +26 -6
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/summary-checkpoint.mjs +19 -0
- package/install.mjs +22 -18
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -56,13 +56,22 @@ Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_re
|
|
|
56
56
|
Active profile controls routing posture, budgets, and quality gate behavior.
|
|
57
57
|
Profile persists to `.claude/dual-brain.profile.json` (gitignored).
|
|
58
58
|
|
|
59
|
-
- **
|
|
59
|
+
- **auto** (default): Adapts routing based on task risk, provider health, and outcomes. Uses file-path risk classification and failure-loop detection to auto-escalate when needed.
|
|
60
|
+
- **balanced**: Best model per tier, normal budgets, reviews at medium+ risk
|
|
60
61
|
- **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
|
|
61
62
|
- **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
|
|
62
63
|
|
|
63
64
|
Switch profiles: `npx dual-brain mode cost-saver`
|
|
64
65
|
Check status: `npx dual-brain status`
|
|
65
66
|
|
|
67
|
+
## Adaptive Routing (Auto Mode)
|
|
68
|
+
|
|
69
|
+
Auto mode classifies risk from file paths and adjusts routing in real-time:
|
|
70
|
+
|
|
71
|
+
- **Risk classification**: auth/secrets→critical, billing/migrations→high, tests/utils→medium, docs→low
|
|
72
|
+
- **Failure detection**: 2+ failures on same prompt in 2 hours → auto-escalate tier or trigger dual-brain
|
|
73
|
+
- **Provider balance**: Routes to underused provider when one subscription is hot
|
|
74
|
+
|
|
66
75
|
## Available Tools
|
|
67
76
|
|
|
68
77
|
- `node .claude/hooks/cost-report.mjs` — activity and cost estimates
|
package/hooks/control-panel.mjs
CHANGED
|
@@ -40,12 +40,14 @@ const blue = s => e('1;38;5;33', s);
|
|
|
40
40
|
// ─── Profiles ──────────────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
42
|
const PROFILES = {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'
|
|
43
|
+
auto: { emoji: '🤖', uiLabel: 'Auto', desc: 'Adapts routing based on task risk, provider health, and outcomes' },
|
|
44
|
+
balanced: { emoji: '⚖️', uiLabel: 'Balanced', desc: 'Routes by complexity, uses both providers evenly' },
|
|
45
|
+
'cost-saver': { emoji: '🛡️', uiLabel: 'Conservative', desc: 'Fewer GPT dispatches, sticks to Claude for most work' },
|
|
46
|
+
'quality-first': { emoji: '🚀', uiLabel: 'Aggressive', desc: 'Maximizes both subscriptions, dual-brain for medium+ risk' },
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const PROFILE_BUDGETS = {
|
|
50
|
+
auto: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
49
51
|
balanced: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
50
52
|
'cost-saver': { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
|
|
51
53
|
'quality-first': { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
|
|
@@ -54,11 +56,11 @@ const PROFILE_BUDGETS = {
|
|
|
54
56
|
function loadProfile() {
|
|
55
57
|
try {
|
|
56
58
|
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
57
|
-
const name = data.active && PROFILES[data.active] ? data.active : '
|
|
59
|
+
const name = data.active && PROFILES[data.active] ? data.active : 'auto';
|
|
58
60
|
const custom = data.custom_overrides || {};
|
|
59
61
|
return { name, budgets: { ...PROFILE_BUDGETS[name], ...custom.budgets }, hasCustomBudget: !!custom.budgets };
|
|
60
62
|
} catch {
|
|
61
|
-
return { name: '
|
|
63
|
+
return { name: 'auto', budgets: PROFILE_BUDGETS.auto, hasCustomBudget: false };
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -245,14 +247,48 @@ function countRunning() {
|
|
|
245
247
|
return { claude, codex };
|
|
246
248
|
}
|
|
247
249
|
|
|
248
|
-
// ───
|
|
250
|
+
// ─── Provider Balance ─────────────────────────────────────────────────────
|
|
249
251
|
|
|
250
|
-
function
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
252
|
+
function loadProviderBalance() {
|
|
253
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
254
|
+
const logFile = join(__dirname, `usage-${today}.jsonl`);
|
|
255
|
+
if (!existsSync(logFile)) return { claude: 0, openai: 0, total: 0, label: 'No activity yet' };
|
|
256
|
+
|
|
257
|
+
let claude = 0, openai = 0;
|
|
258
|
+
try {
|
|
259
|
+
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
260
|
+
for (const line of lines) {
|
|
261
|
+
try {
|
|
262
|
+
const e = JSON.parse(line);
|
|
263
|
+
if (e.provider === 'claude') claude++;
|
|
264
|
+
else if (e.provider === 'openai') openai++;
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
|
|
269
|
+
const total = claude + openai;
|
|
270
|
+
if (total === 0) return { claude: 0, openai: 0, total: 0, label: 'No activity yet' };
|
|
271
|
+
|
|
272
|
+
const claudePct = Math.round((claude / total) * 100);
|
|
273
|
+
const openaiPct = 100 - claudePct;
|
|
274
|
+
|
|
275
|
+
let label;
|
|
276
|
+
if (openaiPct === 0) label = 'Claude only — GPT subscription unused';
|
|
277
|
+
else if (claudePct === 0) label = 'GPT only — Claude subscription unused';
|
|
278
|
+
else if (Math.abs(claudePct - openaiPct) <= 20) label = 'Well balanced';
|
|
279
|
+
else if (claudePct > openaiPct) label = `Claude-heavy — GPT has capacity`;
|
|
280
|
+
else label = `GPT-heavy — Claude has capacity`;
|
|
281
|
+
|
|
282
|
+
return { claude: claudePct, openai: openaiPct, total, label };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function balanceBar(claudePct, openaiPct, width = 20) {
|
|
286
|
+
if (claudePct === 0 && openaiPct === 0) return dim('░'.repeat(width) + ' no activity');
|
|
287
|
+
const cFill = Math.round((claudePct / 100) * width);
|
|
288
|
+
const oFill = width - cFill;
|
|
289
|
+
const cBar = noColor ? '█'.repeat(cFill) : `\x1b[38;5;208m${'█'.repeat(cFill)}\x1b[0m`;
|
|
290
|
+
const oBar = noColor ? '▓'.repeat(oFill) : `\x1b[32m${'▓'.repeat(oFill)}\x1b[0m`;
|
|
291
|
+
return `${cBar}${oBar} ${orange(claudePct + '%')} Claude · ${green(openaiPct + '%')} GPT`;
|
|
256
292
|
}
|
|
257
293
|
|
|
258
294
|
// ─── Menu Renderers ───────────────────────────────────────────────────────
|
|
@@ -314,16 +350,33 @@ function renderReturningMenu(providers, sessions) {
|
|
|
314
350
|
const profile = loadProfile();
|
|
315
351
|
const pf = PROFILES[profile.name];
|
|
316
352
|
const running = countRunning();
|
|
353
|
+
const balance = loadProviderBalance();
|
|
317
354
|
const lines = [];
|
|
318
355
|
|
|
319
356
|
lines.push('');
|
|
320
357
|
lines.push(` 🧠 ${bold(`Dual-Brain v${VERSION}`)}`);
|
|
321
358
|
lines.push('');
|
|
322
359
|
|
|
323
|
-
//
|
|
360
|
+
// Provider status
|
|
324
361
|
const cStat = providers.claude.authed ? '✅' : '⚠️';
|
|
325
362
|
const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
|
|
326
|
-
|
|
363
|
+
let modeStatus = pf.uiLabel;
|
|
364
|
+
if (profile.name === 'auto') {
|
|
365
|
+
if (balance.total === 0) {
|
|
366
|
+
modeStatus = 'Auto · learning your workflow';
|
|
367
|
+
} else if (balance.openai > balance.claude + 20) {
|
|
368
|
+
modeStatus = 'Auto · routing GPT for isolated work';
|
|
369
|
+
} else if (balance.claude > balance.openai + 20) {
|
|
370
|
+
modeStatus = 'Auto · Claude-primary, GPT available';
|
|
371
|
+
} else {
|
|
372
|
+
modeStatus = 'Auto · balanced routing active';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat} ${pf.emoji} ${bold(modeStatus)}`);
|
|
376
|
+
|
|
377
|
+
// Provider balance bar
|
|
378
|
+
lines.push(` ${balanceBar(balance.claude, balance.openai)}`);
|
|
379
|
+
if (balance.total > 0) lines.push(` ${dim(balance.label + ' · ' + balance.total + ' calls today')}`);
|
|
327
380
|
|
|
328
381
|
// Recent sessions
|
|
329
382
|
if (sessions.length > 0) {
|
|
@@ -350,7 +403,6 @@ function renderReturningMenu(providers, sessions) {
|
|
|
350
403
|
if (sessions.length > 0) lines.push(` ${bold('[1-9]')} Resume numbered above`);
|
|
351
404
|
lines.push(` ${bold('[n]')} New session`);
|
|
352
405
|
lines.push(` ${bold('[p]')} Mode: ${dim(pf.uiLabel)}`);
|
|
353
|
-
lines.push(` ${bold('[b]')} Cost alerts: ${dim(costAlertLabel(profile))}`);
|
|
354
406
|
|
|
355
407
|
// Auth if needed
|
|
356
408
|
if (!providers.claude.authed) lines.push(` ${bold('[j]')} Sign in to Claude`);
|
|
@@ -368,12 +420,17 @@ function renderReturningMenu(providers, sessions) {
|
|
|
368
420
|
function showProfilePicker(rl) {
|
|
369
421
|
return new Promise((resolve) => {
|
|
370
422
|
const current = loadProfile();
|
|
423
|
+
const balance = loadProviderBalance();
|
|
371
424
|
console.log('');
|
|
372
|
-
console.log(` ${bold('Switch mode:')}`);
|
|
425
|
+
console.log(` ${bold('Switch routing mode:')}`);
|
|
426
|
+
if (balance.total > 0) {
|
|
427
|
+
console.log(` ${dim('Current balance: Claude ' + balance.claude + '% / GPT ' + balance.openai + '% · ' + balance.label)}`);
|
|
428
|
+
}
|
|
373
429
|
console.log('');
|
|
374
430
|
for (const [i, [name, pf]] of Object.entries(PROFILES).entries()) {
|
|
375
431
|
const active = name === current.name ? ' ✅' : '';
|
|
376
|
-
|
|
432
|
+
const recommended = name === 'auto' && current.name !== 'auto' ? dim(' (recommended)') : '';
|
|
433
|
+
console.log(` ${bold('[' + (i + 1) + ']')} ${pf.emoji} ${pf.uiLabel.padEnd(15)} ${dim(pf.desc)}${active}${recommended}`);
|
|
377
434
|
}
|
|
378
435
|
console.log(` ${bold('[q]')} Cancel`);
|
|
379
436
|
console.log('');
|
|
@@ -396,49 +453,7 @@ function showProfilePicker(rl) {
|
|
|
396
453
|
});
|
|
397
454
|
}
|
|
398
455
|
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
function showCostAlertEditor(rl) {
|
|
402
|
-
return new Promise((resolve) => {
|
|
403
|
-
const profile = loadProfile();
|
|
404
|
-
console.log('');
|
|
405
|
-
console.log(` ${bold('Cost alerts')}`);
|
|
406
|
-
console.log(` ${dim('Dual-brain estimates API costs from session activity.')}`);
|
|
407
|
-
console.log(` ${dim('These are alerts, not billing caps.')}`);
|
|
408
|
-
console.log('');
|
|
409
|
-
console.log(` Current: warn at $${profile.budgets.session_warn_usd}/session, $${profile.budgets.daily_warn_usd}/day`);
|
|
410
|
-
console.log(` limit at $${profile.budgets.session_limit_usd}/session, $${profile.budgets.daily_limit_usd}/day`);
|
|
411
|
-
console.log('');
|
|
412
|
-
|
|
413
|
-
rl.question(' Session alert limit ($, Enter = keep): ', (sessionStr) => {
|
|
414
|
-
if (!sessionStr.trim()) return resolve();
|
|
415
|
-
const session = parseFloat(sessionStr);
|
|
416
|
-
if (isNaN(session) || session <= 0) { console.log(' Cancelled.'); return resolve(); }
|
|
417
|
-
|
|
418
|
-
rl.question(' Daily alert limit ($, Enter = auto): ', (dailyStr) => {
|
|
419
|
-
const daily = parseFloat(dailyStr);
|
|
420
|
-
const finalDaily = (isNaN(daily) || daily <= 0) ? session * 3 : daily;
|
|
421
|
-
|
|
422
|
-
let existing = {};
|
|
423
|
-
try { existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8')); } catch {}
|
|
424
|
-
const custom = existing.custom_overrides || {};
|
|
425
|
-
custom.budgets = {
|
|
426
|
-
session_warn_usd: +(session * 0.6).toFixed(2),
|
|
427
|
-
session_limit_usd: session,
|
|
428
|
-
daily_warn_usd: +(finalDaily * 0.6).toFixed(2),
|
|
429
|
-
daily_limit_usd: finalDaily,
|
|
430
|
-
};
|
|
431
|
-
const data = { active: existing.active || 'balanced', switched_at: existing.switched_at || new Date().toISOString(), custom_overrides: custom };
|
|
432
|
-
const tmp = PROFILE_FILE + '.tmp.' + process.pid;
|
|
433
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
434
|
-
renameSync(tmp, PROFILE_FILE);
|
|
435
|
-
|
|
436
|
-
console.log(` ✅ Cost alerts: $${session}/session · $${finalDaily}/day`);
|
|
437
|
-
resolve();
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
});
|
|
441
|
-
}
|
|
456
|
+
// (Cost alert editor removed — replaced by provider balance + mode switching)
|
|
442
457
|
|
|
443
458
|
// ─── Session Runner ───────────────────────────────────────────────────────
|
|
444
459
|
|
|
@@ -514,11 +529,6 @@ async function mainLoop() {
|
|
|
514
529
|
continue;
|
|
515
530
|
}
|
|
516
531
|
|
|
517
|
-
if (choice === 'b') {
|
|
518
|
-
await showCostAlertEditor(rl);
|
|
519
|
-
continue;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
532
|
if (choice === 'j') {
|
|
523
533
|
console.log('');
|
|
524
534
|
console.log(' Starting Claude login...');
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
|
|
|
3
3
|
import { createHash } from 'crypto';
|
|
4
4
|
import { dirname, resolve, join } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
7
|
+
import { checkFailureLoop } from './failure-detector.mjs';
|
|
6
8
|
|
|
7
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
@@ -12,11 +14,12 @@ const DRIFT_STATE = resolve(__dirname, '.drift-warned');
|
|
|
12
14
|
function loadProfile() {
|
|
13
15
|
try {
|
|
14
16
|
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
15
|
-
return data.active || '
|
|
16
|
-
} catch { return '
|
|
17
|
+
return data.active || 'auto';
|
|
18
|
+
} catch { return 'auto'; }
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
const PROFILE_SETTINGS = {
|
|
22
|
+
auto: { demote_think: false, promote_execute: false, bias: 0 },
|
|
20
23
|
balanced: { demote_think: false, promote_execute: false, bias: 0 },
|
|
21
24
|
'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
|
|
22
25
|
'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
|
|
@@ -231,9 +234,9 @@ try {
|
|
|
231
234
|
// Balance hint — populated after tier is fully resolved
|
|
232
235
|
let balanceHint = null;
|
|
233
236
|
|
|
234
|
-
// Helper to prepend optional warnings (duplicate + drift + balance) before a message
|
|
237
|
+
// Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
|
|
235
238
|
const prependWarnings = (msg) => {
|
|
236
|
-
const parts = [duplicateWarning, driftWarning, msg, balanceHint].filter(Boolean);
|
|
239
|
+
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
|
|
237
240
|
return parts.join('\n\n');
|
|
238
241
|
};
|
|
239
242
|
|
|
@@ -277,6 +280,32 @@ try {
|
|
|
277
280
|
else tier = 'execute';
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
// Risk classification from file paths in description
|
|
284
|
+
const filePaths = extractPaths(ti.description || '');
|
|
285
|
+
const riskResult = classifyRisk(filePaths);
|
|
286
|
+
let autoStatus = null;
|
|
287
|
+
|
|
288
|
+
// Bias high/critical risk toward think tier
|
|
289
|
+
if ((riskResult.level === 'critical' || riskResult.level === 'high') && tier !== 'think') {
|
|
290
|
+
tier = 'think';
|
|
291
|
+
autoStatus = riskResult.level === 'critical'
|
|
292
|
+
? `Dual-brain: dual-brain review recommended — ${riskResult.reason.split(':')[0]} detected`
|
|
293
|
+
: `Dual-brain: promoting to think tier — ${riskResult.reason.split(':')[0]}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Failure loop detection
|
|
297
|
+
const failureCheck = checkFailureLoop(promptHash);
|
|
298
|
+
let failureMessage = null;
|
|
299
|
+
if (failureCheck.isLoop) {
|
|
300
|
+
if (failureCheck.suggestion === 'promote_tier' && tier === 'execute') {
|
|
301
|
+
tier = 'think';
|
|
302
|
+
autoStatus = 'Dual-brain: escalating to think tier — previous attempt failed';
|
|
303
|
+
} else if (failureCheck.suggestion === 'escalate_to_dual_brain') {
|
|
304
|
+
autoStatus = 'Dual-brain: dual-brain review recommended — repeated failures detected';
|
|
305
|
+
}
|
|
306
|
+
failureMessage = `**[Failure Loop]** ${failureCheck.count} failed attempts in 2hrs. Consider: \`node .claude/hooks/dual-brain-think.mjs --question "why is this failing?"\``;
|
|
307
|
+
}
|
|
308
|
+
|
|
280
309
|
// Apply profile-driven tier adjustments
|
|
281
310
|
if (profileSettings.demote_think && tier === 'think' && !THINK_WORDS.test(text)) {
|
|
282
311
|
tier = 'execute';
|
|
@@ -312,7 +341,7 @@ try {
|
|
|
312
341
|
followed: true,
|
|
313
342
|
profile: profileName,
|
|
314
343
|
});
|
|
315
|
-
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
344
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
316
345
|
if (onlyWarnings) {
|
|
317
346
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
318
347
|
} else {
|
|
@@ -344,7 +373,7 @@ try {
|
|
|
344
373
|
followed: true,
|
|
345
374
|
profile: profileName,
|
|
346
375
|
});
|
|
347
|
-
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
376
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
348
377
|
if (onlyWarnings) {
|
|
349
378
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
350
379
|
} else {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* failure-detector.mjs — Detects repeated failure loops for adaptive routing.
|
|
4
|
+
*
|
|
5
|
+
* Exports:
|
|
6
|
+
* checkFailureLoop(promptHash) → { isLoop, count, suggestion }
|
|
7
|
+
* recordFailure(promptHash, tier, reason) → void
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, appendFileSync } from 'fs';
|
|
11
|
+
import { dirname, join } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
|
|
16
|
+
|
|
17
|
+
function checkFailureLoop(promptHash) {
|
|
18
|
+
if (!promptHash) return { isLoop: false, count: 0, suggestion: null };
|
|
19
|
+
|
|
20
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
21
|
+
let failures = 0;
|
|
22
|
+
let lastTier = null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const lines = readFileSync(LEDGER_FILE, 'utf8').split('\n').filter(Boolean);
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
try {
|
|
28
|
+
const entry = JSON.parse(line);
|
|
29
|
+
if (entry.prompt_hash !== promptHash) continue;
|
|
30
|
+
if (Date.parse(entry.timestamp) < twoHoursAgo) continue;
|
|
31
|
+
if (entry.success === false || entry.followed === false) {
|
|
32
|
+
failures++;
|
|
33
|
+
lastTier = entry.tier;
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
if (failures < 2) return { isLoop: false, count: failures, suggestion: null };
|
|
40
|
+
|
|
41
|
+
const suggestion = lastTier === 'execute'
|
|
42
|
+
? 'promote_tier'
|
|
43
|
+
: 'escalate_to_dual_brain';
|
|
44
|
+
|
|
45
|
+
return { isLoop: true, count: failures, suggestion };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordFailure(promptHash, tier, reason) {
|
|
49
|
+
const entry = JSON.stringify({
|
|
50
|
+
type: 'failure',
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
prompt_hash: promptHash,
|
|
53
|
+
tier,
|
|
54
|
+
reason: reason || 'unknown',
|
|
55
|
+
success: false,
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
appendFileSync(LEDGER_FILE, entry + '\n');
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { checkFailureLoop, recordFailure };
|
package/hooks/profiles.mjs
CHANGED
|
@@ -21,8 +21,28 @@ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
|
|
|
21
21
|
const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
|
|
22
22
|
|
|
23
23
|
const PROFILES = {
|
|
24
|
+
auto: {
|
|
25
|
+
description: 'Adapts routing based on task risk, provider health, and outcomes',
|
|
26
|
+
routing: {
|
|
27
|
+
prefer_provider: 'auto',
|
|
28
|
+
think_threshold: 'adaptive',
|
|
29
|
+
gpt_dispatch_bias: 0,
|
|
30
|
+
},
|
|
31
|
+
budgets: {
|
|
32
|
+
session_warn_usd: 5.00,
|
|
33
|
+
session_limit_usd: 10.00,
|
|
34
|
+
daily_warn_usd: 20.00,
|
|
35
|
+
daily_limit_usd: 50.00,
|
|
36
|
+
},
|
|
37
|
+
quality_gate: {
|
|
38
|
+
sensitivity_floor: 'medium',
|
|
39
|
+
dual_brain_minimum: 'high',
|
|
40
|
+
},
|
|
41
|
+
tier_overrides: null,
|
|
42
|
+
},
|
|
43
|
+
|
|
24
44
|
balanced: {
|
|
25
|
-
description: '
|
|
45
|
+
description: 'Auto-routes by complexity, uses both providers evenly',
|
|
26
46
|
routing: {
|
|
27
47
|
prefer_provider: 'auto',
|
|
28
48
|
think_threshold: 'normal',
|
|
@@ -42,7 +62,7 @@ const PROFILES = {
|
|
|
42
62
|
},
|
|
43
63
|
|
|
44
64
|
'cost-saver': {
|
|
45
|
-
description: '
|
|
65
|
+
description: 'Conservative — fewer GPT dispatches, sticks to Claude',
|
|
46
66
|
routing: {
|
|
47
67
|
prefer_provider: 'cheapest',
|
|
48
68
|
think_threshold: 'strict',
|
|
@@ -65,7 +85,7 @@ const PROFILES = {
|
|
|
65
85
|
},
|
|
66
86
|
|
|
67
87
|
'quality-first': {
|
|
68
|
-
description: '
|
|
88
|
+
description: 'Aggressive — maximizes both subscriptions, dual-brain for medium+',
|
|
69
89
|
routing: {
|
|
70
90
|
prefer_provider: 'most-capable',
|
|
71
91
|
think_threshold: 'relaxed',
|
|
@@ -106,12 +126,12 @@ function loadConfig() {
|
|
|
106
126
|
|
|
107
127
|
function getActiveProfile() {
|
|
108
128
|
const saved = loadProfileFile();
|
|
109
|
-
const name = saved?.active || '
|
|
110
|
-
const profile = PROFILES[name] || PROFILES.
|
|
129
|
+
const name = saved?.active || 'auto';
|
|
130
|
+
const profile = PROFILES[name] || PROFILES.auto;
|
|
111
131
|
const customOverrides = saved?.custom_overrides || {};
|
|
112
132
|
|
|
113
133
|
return {
|
|
114
|
-
name: PROFILES[name] ? name : '
|
|
134
|
+
name: PROFILES[name] ? name : 'auto',
|
|
115
135
|
...profile,
|
|
116
136
|
budgets: { ...profile.budgets, ...customOverrides.budgets },
|
|
117
137
|
routing: { ...profile.routing, ...customOverrides.routing },
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* risk-classifier.mjs — File-path risk classification for adaptive routing.
|
|
4
|
+
*
|
|
5
|
+
* Export: classifyRisk(paths) → { level, reason }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PATTERNS = [
|
|
9
|
+
{ level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate|cert[s]?|\.pem|\.key)\b/i, label: 'security-sensitive' },
|
|
10
|
+
{ level: 'high', regex: /\b(billing|payment|migration|deploy|ci[-/]cd|\.github\/workflows|security|permission|policy|schema\.prisma|schema\.sql|api[-_]?contract|openapi|swagger)\b/i, label: 'high-impact infrastructure' },
|
|
11
|
+
{ level: 'medium', regex: /\b(test|spec|\.test\.|\.spec\.|shared|util[s]?|lib\/|public[-_]?api|integrat|config|\.config\.)\b/i, label: 'shared/tested code' },
|
|
12
|
+
{ level: 'low', regex: /\b(readme|\.md$|docs?\/|comment|format|lint|\.prettierrc|local[-_]?script|internal[-_]?only|changelog)\b/i, label: 'docs/formatting' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
16
|
+
|
|
17
|
+
function classifyRisk(paths) {
|
|
18
|
+
if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
|
|
19
|
+
|
|
20
|
+
let highest = { level: 'low', reason: 'no matching risk patterns' };
|
|
21
|
+
|
|
22
|
+
for (const p of paths) {
|
|
23
|
+
for (const pattern of PATTERNS) {
|
|
24
|
+
if (pattern.regex.test(p) && LEVEL_ORDER[pattern.level] > LEVEL_ORDER[highest.level]) {
|
|
25
|
+
highest = { level: pattern.level, reason: `${pattern.label}: ${p}` };
|
|
26
|
+
if (pattern.level === 'critical') return highest;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return highest;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractPaths(text) {
|
|
35
|
+
if (!text) return [];
|
|
36
|
+
const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
|
|
37
|
+
if (!matches) return [];
|
|
38
|
+
return matches.map(m => m.trim().replace(/^["'`]/, ''));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { classifyRisk, extractPaths };
|
|
@@ -57,6 +57,14 @@ function emptySummary() {
|
|
|
57
57
|
token_averages: {},
|
|
58
58
|
|
|
59
59
|
codex_latencies: [],
|
|
60
|
+
|
|
61
|
+
session_insights: {
|
|
62
|
+
gpt_latency_status: 'normal',
|
|
63
|
+
provider_override_count: 0,
|
|
64
|
+
failure_domains: [],
|
|
65
|
+
dual_brain_useful: false,
|
|
66
|
+
balance_posture: 'no activity yet',
|
|
67
|
+
},
|
|
60
68
|
};
|
|
61
69
|
}
|
|
62
70
|
|
|
@@ -199,6 +207,16 @@ function getTokenAverages(date) {
|
|
|
199
207
|
return summary.token_averages;
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
function updateSessionInsight(key, value, date) {
|
|
211
|
+
const validKeys = ['gpt_latency_status', 'provider_override_count', 'failure_domains', 'dual_brain_useful', 'balance_posture'];
|
|
212
|
+
if (!validKeys.includes(key)) return;
|
|
213
|
+
const summary = readSummary(date);
|
|
214
|
+
if (!summary.session_insights) summary.session_insights = {};
|
|
215
|
+
summary.session_insights[key] = value;
|
|
216
|
+
summary.updated_at = new Date().toISOString();
|
|
217
|
+
atomicWrite(summaryPath(date), summary);
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
function getAdaptiveCodexThreshold(date) {
|
|
203
221
|
const summary = readSummary(date);
|
|
204
222
|
const latencies = summary.codex_latencies || [];
|
|
@@ -227,5 +245,6 @@ export {
|
|
|
227
245
|
getPressureBuckets,
|
|
228
246
|
getTokenAverages,
|
|
229
247
|
getAdaptiveCodexThreshold,
|
|
248
|
+
updateSessionInsight,
|
|
230
249
|
atomicWrite,
|
|
231
250
|
};
|
package/install.mjs
CHANGED
|
@@ -57,10 +57,10 @@ if (flag('--help') || flag('-h')) {
|
|
|
57
57
|
--json Output detection as JSON
|
|
58
58
|
--help Show this help
|
|
59
59
|
|
|
60
|
-
🎛️
|
|
61
|
-
⚖️
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
🎛️ Routing modes:
|
|
61
|
+
⚖️ Default Auto-routes, uses both providers evenly
|
|
62
|
+
🛡️ Conservative Fewer GPT dispatches, sticks to Claude
|
|
63
|
+
🚀 Aggressive Maximizes both subscriptions, dual-brain for medium+
|
|
64
64
|
|
|
65
65
|
🚀 Examples:
|
|
66
66
|
${cmd('npx dual-brain')} # install or update
|
|
@@ -336,6 +336,7 @@ function install(workspace, env, mode) {
|
|
|
336
336
|
'install-git-hooks.mjs', 'session-report.mjs', 'budget-balancer.mjs',
|
|
337
337
|
'gpt-work-dispatcher.mjs', 'profiles.mjs',
|
|
338
338
|
'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
|
|
339
|
+
'risk-classifier.mjs', 'failure-detector.mjs',
|
|
339
340
|
];
|
|
340
341
|
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
341
342
|
actions.push(`✓ ${HOOKS.length} hook scripts`);
|
|
@@ -424,19 +425,19 @@ function profilePath(workspace) {
|
|
|
424
425
|
|
|
425
426
|
const PROFILES = {
|
|
426
427
|
balanced: {
|
|
427
|
-
description: '
|
|
428
|
+
description: 'Auto-routes by complexity, uses both providers evenly',
|
|
428
429
|
routing: { prefer_provider: 'auto', think_threshold: 'normal', gpt_dispatch_bias: 0 },
|
|
429
430
|
budgets: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
430
431
|
quality_gate: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
|
|
431
432
|
},
|
|
432
433
|
'cost-saver': {
|
|
433
|
-
description: '
|
|
434
|
+
description: 'Conservative — fewer GPT dispatches, sticks to Claude',
|
|
434
435
|
routing: { prefer_provider: 'cheapest', think_threshold: 'strict', gpt_dispatch_bias: -20 },
|
|
435
436
|
budgets: { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
|
|
436
437
|
quality_gate: { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
|
|
437
438
|
},
|
|
438
439
|
'quality-first': {
|
|
439
|
-
description: '
|
|
440
|
+
description: 'Aggressive — maximizes both subscriptions, dual-brain for medium+',
|
|
440
441
|
routing: { prefer_provider: 'most-capable', think_threshold: 'relaxed', gpt_dispatch_bias: 10 },
|
|
441
442
|
budgets: { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
|
|
442
443
|
quality_gate: { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
|
|
@@ -490,16 +491,18 @@ function cmdMode() {
|
|
|
490
491
|
|
|
491
492
|
if (!modeArg || modeArg === 'list') {
|
|
492
493
|
const current = loadProfile(workspace);
|
|
493
|
-
const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '
|
|
494
|
+
const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
495
|
+
const UI_NAMES = { balanced: 'Default', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
|
|
494
496
|
console.log('');
|
|
495
|
-
console.log(' 🎛️
|
|
497
|
+
console.log(' 🎛️ Routing modes:');
|
|
496
498
|
console.log('');
|
|
497
499
|
for (const [name, p] of Object.entries(PROFILES)) {
|
|
498
500
|
const active = name === current.name ? ' ✅ active' : '';
|
|
499
|
-
|
|
501
|
+
const label = UI_NAMES[name] || name;
|
|
502
|
+
console.log(` ${PEMOJIS[name] || ' '} ${label.padEnd(15)} ${p.description}${active}`);
|
|
500
503
|
}
|
|
501
504
|
console.log('');
|
|
502
|
-
console.log(` Switch: ${cmd('npx dual-brain mode <
|
|
505
|
+
console.log(` Switch: ${cmd('npx dual-brain mode <name>')}`);
|
|
503
506
|
console.log('');
|
|
504
507
|
return;
|
|
505
508
|
}
|
|
@@ -522,9 +525,10 @@ function cmdMode() {
|
|
|
522
525
|
|
|
523
526
|
saveProfile(workspace, modeArg, customOverrides);
|
|
524
527
|
|
|
525
|
-
const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '
|
|
528
|
+
const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
529
|
+
const UI_NAMES = { balanced: 'Default', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
|
|
526
530
|
console.log('');
|
|
527
|
-
console.log(` ✅
|
|
531
|
+
console.log(` ✅ Mode switched: ${PEMOJIS[modeArg] || ''} ${UI_NAMES[modeArg] || modeArg}`);
|
|
528
532
|
console.log(` ${profile.description}`);
|
|
529
533
|
console.log('');
|
|
530
534
|
console.log(' 🧭 Routing changes:');
|
|
@@ -547,12 +551,12 @@ function cmdBudget() {
|
|
|
547
551
|
if (sessionArg == null) {
|
|
548
552
|
const profile = loadProfile(workspace);
|
|
549
553
|
console.log('');
|
|
550
|
-
console.log('
|
|
551
|
-
console.log(` Session: ⚠️ $${profile.budgets.session_warn_usd} warn · 🛑 $${profile.budgets.session_limit_usd}
|
|
552
|
-
console.log(` Daily: ⚠️ $${profile.budgets.daily_warn_usd} warn · 🛑 $${profile.budgets.daily_limit_usd}
|
|
554
|
+
console.log(' 📊 Usage alert thresholds (estimated, not billing caps):');
|
|
555
|
+
console.log(` Session: ⚠️ $${profile.budgets.session_warn_usd} warn · 🛑 $${profile.budgets.session_limit_usd} alert`);
|
|
556
|
+
console.log(` Daily: ⚠️ $${profile.budgets.daily_warn_usd} warn · 🛑 $${profile.budgets.daily_limit_usd} alert`);
|
|
553
557
|
console.log('');
|
|
554
|
-
console.log(`
|
|
555
|
-
console.log(` Example:
|
|
558
|
+
console.log(` Adjust: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
|
|
559
|
+
console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
|
|
556
560
|
console.log('');
|
|
557
561
|
return;
|
|
558
562
|
}
|
package/package.json
CHANGED