dual-brain 3.1.0 → 3.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/CLAUDE.md +13 -0
- package/hooks/budget-balancer.mjs +45 -6
- package/hooks/cost-logger.mjs +51 -26
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/enforce-tier.mjs +103 -10
- package/hooks/gpt-work-dispatcher.mjs +50 -6
- package/hooks/profiles.mjs +203 -0
- package/hooks/quality-gate.mjs +34 -6
- package/hooks/summary-checkpoint.mjs +231 -0
- package/install.mjs +367 -9
- package/package.json +2 -2
- package/hooks/usage-2026-05-14.jsonl +0 -5
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* summary-checkpoint.mjs — Fast derived state for the hot path.
|
|
4
|
+
*
|
|
5
|
+
* Maintains a summary file (usage-summary-YYYY-MM-DD.json) that hooks
|
|
6
|
+
* can read in O(1) instead of scanning the full JSONL log.
|
|
7
|
+
*
|
|
8
|
+
* The summary is rebuilt from JSONL truth if missing or corrupt.
|
|
9
|
+
*
|
|
10
|
+
* Exported API:
|
|
11
|
+
* readSummary(date?) → current summary object
|
|
12
|
+
* updateSummary(newEntry) → incrementally update summary with one entry
|
|
13
|
+
* rebuildSummary(date?) → full rebuild from JSONL
|
|
14
|
+
* getRecentPromptHashes() → last 10min of prompt hashes (for dupe detection)
|
|
15
|
+
* getPressureBuckets() → provider/tier call counts for rolling window
|
|
16
|
+
* getTokenAverages() → moving averages of actual tokens by tier
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
20
|
+
import { dirname, join } from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
function summaryPath(date) {
|
|
26
|
+
const d = date || new Date().toISOString().slice(0, 10);
|
|
27
|
+
return join(__dirname, `usage-summary-${d}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usagePath(date) {
|
|
31
|
+
const d = date || new Date().toISOString().slice(0, 10);
|
|
32
|
+
return join(__dirname, `usage-${d}.jsonl`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emptySummary() {
|
|
36
|
+
return {
|
|
37
|
+
version: 1,
|
|
38
|
+
date: new Date().toISOString().slice(0, 10),
|
|
39
|
+
updated_at: new Date().toISOString(),
|
|
40
|
+
last_offset: 0,
|
|
41
|
+
|
|
42
|
+
totals: {
|
|
43
|
+
calls: 0,
|
|
44
|
+
cost_estimate: 0,
|
|
45
|
+
by_tier: {},
|
|
46
|
+
by_provider: {},
|
|
47
|
+
by_model: {},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
pressure: {
|
|
51
|
+
claude: { think: [], execute: [], search: [] },
|
|
52
|
+
openai: { think: [], execute: [], search: [] },
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
recent_hashes: [],
|
|
56
|
+
|
|
57
|
+
token_averages: {},
|
|
58
|
+
|
|
59
|
+
codex_latencies: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
|
|
64
|
+
|
|
65
|
+
function atomicWrite(path, data) {
|
|
66
|
+
const tmp = path + '.tmp.' + process.pid;
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
68
|
+
renameSync(tmp, path);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readSummary(date) {
|
|
72
|
+
const path = summaryPath(date);
|
|
73
|
+
try {
|
|
74
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
75
|
+
if (data.version === 1) return data;
|
|
76
|
+
} catch {}
|
|
77
|
+
return rebuildSummary(date);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rebuildSummary(date) {
|
|
81
|
+
const d = date || new Date().toISOString().slice(0, 10);
|
|
82
|
+
const logPath = usagePath(d);
|
|
83
|
+
const summary = emptySummary();
|
|
84
|
+
summary.date = d;
|
|
85
|
+
|
|
86
|
+
if (!existsSync(logPath)) {
|
|
87
|
+
atomicWrite(summaryPath(d), summary);
|
|
88
|
+
return summary;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let raw;
|
|
92
|
+
try { raw = readFileSync(logPath, 'utf8'); } catch { return summary; }
|
|
93
|
+
|
|
94
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
try {
|
|
97
|
+
const entry = JSON.parse(line);
|
|
98
|
+
applyEntry(summary, entry);
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
summary.last_offset = Buffer.byteLength(raw, 'utf8');
|
|
103
|
+
summary.updated_at = new Date().toISOString();
|
|
104
|
+
atomicWrite(summaryPath(d), summary);
|
|
105
|
+
return summary;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyEntry(summary, entry) {
|
|
109
|
+
const tier = entry.tier || 'execute';
|
|
110
|
+
const provider = entry.provider || 'claude';
|
|
111
|
+
const model = entry.model || 'unknown';
|
|
112
|
+
const cost = COST_PER_CALL[tier] || COST_PER_CALL.execute;
|
|
113
|
+
|
|
114
|
+
summary.totals.calls++;
|
|
115
|
+
summary.totals.cost_estimate += cost;
|
|
116
|
+
|
|
117
|
+
summary.totals.by_tier[tier] = (summary.totals.by_tier[tier] || 0) + 1;
|
|
118
|
+
summary.totals.by_provider[provider] = (summary.totals.by_provider[provider] || 0) + 1;
|
|
119
|
+
summary.totals.by_model[model] = (summary.totals.by_model[model] || 0) + 1;
|
|
120
|
+
|
|
121
|
+
// Pressure: store timestamps for rolling window lookups
|
|
122
|
+
const ts = entry.timestamp || new Date().toISOString();
|
|
123
|
+
if (summary.pressure[provider]?.[tier]) {
|
|
124
|
+
summary.pressure[provider][tier].push(ts);
|
|
125
|
+
// Keep only last 5 hours of timestamps to bound size
|
|
126
|
+
const cutoff = Date.now() - 5 * 60 * 60 * 1000;
|
|
127
|
+
summary.pressure[provider][tier] = summary.pressure[provider][tier].filter(
|
|
128
|
+
t => Date.parse(t) >= cutoff
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Recent prompt hashes (for duplicate detection)
|
|
133
|
+
if (entry.type === 'tier_recommendation' && entry.prompt_hash) {
|
|
134
|
+
summary.recent_hashes.push({ hash: entry.prompt_hash, ts });
|
|
135
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
136
|
+
summary.recent_hashes = summary.recent_hashes.filter(
|
|
137
|
+
h => Date.parse(h.ts) >= tenMinAgo
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Token moving averages
|
|
142
|
+
if (entry.input_tokens != null && entry.output_tokens != null) {
|
|
143
|
+
const key = `${provider}:${tier}`;
|
|
144
|
+
if (!summary.token_averages[key]) {
|
|
145
|
+
summary.token_averages[key] = { count: 0, avg_input: 0, avg_output: 0 };
|
|
146
|
+
}
|
|
147
|
+
const avg = summary.token_averages[key];
|
|
148
|
+
avg.count++;
|
|
149
|
+
avg.avg_input += (entry.input_tokens - avg.avg_input) / avg.count;
|
|
150
|
+
avg.avg_output += (entry.output_tokens - avg.avg_output) / avg.count;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Codex latencies
|
|
154
|
+
if (entry.codex_startup_ms != null) {
|
|
155
|
+
summary.codex_latencies.push({
|
|
156
|
+
startup_ms: entry.codex_startup_ms,
|
|
157
|
+
total_ms: entry.codex_total_ms || null,
|
|
158
|
+
model: model,
|
|
159
|
+
ts,
|
|
160
|
+
});
|
|
161
|
+
// Keep last 50
|
|
162
|
+
if (summary.codex_latencies.length > 50) {
|
|
163
|
+
summary.codex_latencies = summary.codex_latencies.slice(-50);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function updateSummary(newEntry, date) {
|
|
169
|
+
const summary = readSummary(date);
|
|
170
|
+
applyEntry(summary, newEntry);
|
|
171
|
+
summary.updated_at = new Date().toISOString();
|
|
172
|
+
atomicWrite(summaryPath(date), summary);
|
|
173
|
+
return summary;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getRecentPromptHashes(date) {
|
|
177
|
+
const summary = readSummary(date);
|
|
178
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
179
|
+
return summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getPressureBuckets(date) {
|
|
183
|
+
const summary = readSummary(date);
|
|
184
|
+
const cutoff = Date.now() - 5 * 60 * 60 * 1000;
|
|
185
|
+
const result = {};
|
|
186
|
+
|
|
187
|
+
for (const provider of ['claude', 'openai']) {
|
|
188
|
+
result[provider] = {};
|
|
189
|
+
for (const tier of ['think', 'execute', 'search']) {
|
|
190
|
+
const timestamps = summary.pressure[provider]?.[tier] || [];
|
|
191
|
+
result[provider][tier] = timestamps.filter(t => Date.parse(t) >= cutoff).length;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getTokenAverages(date) {
|
|
198
|
+
const summary = readSummary(date);
|
|
199
|
+
return summary.token_averages;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getAdaptiveCodexThreshold(date) {
|
|
203
|
+
const summary = readSummary(date);
|
|
204
|
+
const latencies = summary.codex_latencies || [];
|
|
205
|
+
if (latencies.length < 5) return { threshold_ms: 180_000, confidence: 'low', samples: latencies.length };
|
|
206
|
+
|
|
207
|
+
const startups = latencies.map(l => l.startup_ms).filter(Boolean).sort((a, b) => a - b);
|
|
208
|
+
if (startups.length < 3) return { threshold_ms: 180_000, confidence: 'low', samples: startups.length };
|
|
209
|
+
|
|
210
|
+
const p75idx = Math.floor(startups.length * 0.75);
|
|
211
|
+
const p75 = startups[p75idx];
|
|
212
|
+
const threshold = Math.max(90_000, p75 * 4);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
threshold_ms: Math.round(threshold),
|
|
216
|
+
p75_startup_ms: Math.round(p75),
|
|
217
|
+
confidence: startups.length >= 20 ? 'high' : 'medium',
|
|
218
|
+
samples: startups.length,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export {
|
|
223
|
+
readSummary,
|
|
224
|
+
updateSummary,
|
|
225
|
+
rebuildSummary,
|
|
226
|
+
getRecentPromptHashes,
|
|
227
|
+
getPressureBuckets,
|
|
228
|
+
getTokenAverages,
|
|
229
|
+
getAdaptiveCodexThreshold,
|
|
230
|
+
atomicWrite,
|
|
231
|
+
};
|
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, writeFileSync } from 'fs';
|
|
11
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
12
12
|
import { dirname, join, resolve } from 'path';
|
|
13
13
|
import { fileURLToPath } from 'url';
|
|
14
14
|
import { spawnSync } from 'child_process';
|
|
@@ -16,6 +16,12 @@ import { spawnSync } from 'child_process';
|
|
|
16
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const VERSION = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version;
|
|
18
18
|
|
|
19
|
+
// ─── Replit Detection ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
22
|
+
|
|
23
|
+
function cmd(s) { return IS_REPLIT ? `! ${s}` : s; }
|
|
24
|
+
|
|
19
25
|
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
20
26
|
|
|
21
27
|
const argv = process.argv.slice(2);
|
|
@@ -23,6 +29,8 @@ const flag = (f) => argv.includes(f);
|
|
|
23
29
|
const force = flag('--force');
|
|
24
30
|
const dryRun = flag('--dry-run');
|
|
25
31
|
const jsonOut = flag('--json');
|
|
32
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
33
|
+
const subcommand = positional[0] || null;
|
|
26
34
|
|
|
27
35
|
if (flag('--version') || flag('-v')) {
|
|
28
36
|
console.log(`dual-brain v${VERSION}`);
|
|
@@ -33,22 +41,41 @@ if (flag('--help') || flag('-h')) {
|
|
|
33
41
|
console.log(`
|
|
34
42
|
dual-brain v${VERSION} — Dual-provider orchestrator for Claude Code
|
|
35
43
|
|
|
36
|
-
Usage: npx -y dual-brain [options]
|
|
44
|
+
Usage: npx -y dual-brain [command] [options]
|
|
45
|
+
|
|
46
|
+
Commands:
|
|
47
|
+
(none) Auto-detect and install/update orchestrator
|
|
48
|
+
status Live view of mode, spend, pressure, profile
|
|
49
|
+
mode Show or switch profile (balanced, cost-saver, quality-first)
|
|
50
|
+
budget Set session/daily spend limits
|
|
51
|
+
explain Show why the last routing decision was made
|
|
52
|
+
init Alias for default install (backward compat)
|
|
37
53
|
|
|
38
54
|
Options:
|
|
39
55
|
--force Overwrite all existing config (keeps review-rules.md)
|
|
40
56
|
--dry-run Detect environment only, don't install
|
|
41
57
|
--json Output detection as JSON (implies --dry-run)
|
|
42
58
|
--help Show this help
|
|
59
|
+
|
|
60
|
+
Profiles:
|
|
61
|
+
balanced Standard routing — best model for each tier
|
|
62
|
+
cost-saver Minimize spend — prefer cheaper models
|
|
63
|
+
quality-first Maximum quality — dual-brain for medium+ risk
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
${cmd('npx dual-brain')} # install or update
|
|
67
|
+
${cmd('npx dual-brain status')} # live dashboard
|
|
68
|
+
${cmd('npx dual-brain mode cost-saver')} # switch profile
|
|
69
|
+
${cmd('npx dual-brain budget 8 25')} # $8 session / $25 daily
|
|
70
|
+
${cmd('npx dual-brain explain')} # last routing decision
|
|
43
71
|
`);
|
|
44
72
|
process.exit(0);
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
console.error(`
|
|
51
|
-
console.error(' Run: npx dual-brain --help');
|
|
75
|
+
const SUBCOMMANDS = ['init', 'status', 'mode', 'budget', 'explain'];
|
|
76
|
+
if (subcommand && !SUBCOMMANDS.includes(subcommand)) {
|
|
77
|
+
console.error(` Unknown command: ${subcommand}`);
|
|
78
|
+
console.error(` Run: ${cmd('npx dual-brain --help')}`);
|
|
52
79
|
process.exit(1);
|
|
53
80
|
}
|
|
54
81
|
|
|
@@ -283,6 +310,9 @@ function generateGitignoreEntries(workspace) {
|
|
|
283
310
|
'.claude/reviews/',
|
|
284
311
|
'.claude/hooks/.drift-warned',
|
|
285
312
|
'.claude/hooks/.budget-alerted',
|
|
313
|
+
'.claude/dual-brain.profile.json',
|
|
314
|
+
'.claude/hooks/usage-summary-*.json',
|
|
315
|
+
'.claude/hooks/decision-ledger.jsonl',
|
|
286
316
|
];
|
|
287
317
|
let existing = '';
|
|
288
318
|
try { existing = readFileSync(join(workspace, '.gitignore'), 'utf8'); } catch {}
|
|
@@ -303,7 +333,8 @@ function install(workspace, env, mode) {
|
|
|
303
333
|
'dual-brain-review.mjs', 'dual-brain-think.mjs', 'quality-gate.mjs',
|
|
304
334
|
'test-orchestrator.mjs', 'setup-wizard.mjs', 'health-check.mjs',
|
|
305
335
|
'install-git-hooks.mjs', 'session-report.mjs', 'budget-balancer.mjs',
|
|
306
|
-
'gpt-work-dispatcher.mjs',
|
|
336
|
+
'gpt-work-dispatcher.mjs', 'profiles.mjs',
|
|
337
|
+
'summary-checkpoint.mjs', 'decision-ledger.mjs',
|
|
307
338
|
];
|
|
308
339
|
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
309
340
|
actions.push(`✓ ${HOOKS.length} hook scripts`);
|
|
@@ -425,7 +456,19 @@ function printReport(env, mode, actions) {
|
|
|
425
456
|
console.log(' Both Claude and GPT are available as work providers.');
|
|
426
457
|
}
|
|
427
458
|
console.log('');
|
|
428
|
-
|
|
459
|
+
if (IS_REPLIT) {
|
|
460
|
+
console.log(' Try these in your Replit shell (paste with ! prefix):');
|
|
461
|
+
console.log(` ${cmd('npx dual-brain status')} # live dashboard`);
|
|
462
|
+
console.log(` ${cmd('npx dual-brain mode cost-saver')} # switch profile`);
|
|
463
|
+
console.log(` ${cmd('npx dual-brain budget 8 25')} # set limits`);
|
|
464
|
+
} else {
|
|
465
|
+
console.log(' Try these in your next Claude Code session:');
|
|
466
|
+
console.log(' npx dual-brain status # live dashboard');
|
|
467
|
+
console.log(' npx dual-brain mode cost-saver # switch profile');
|
|
468
|
+
console.log(' npx dual-brain budget 8 25 # set limits');
|
|
469
|
+
}
|
|
470
|
+
console.log('');
|
|
471
|
+
console.log(' In-session tools (ask Claude to run these):');
|
|
429
472
|
console.log(' node .claude/hooks/health-check.mjs # verify setup');
|
|
430
473
|
console.log(' node .claude/hooks/cost-report.mjs # see activity');
|
|
431
474
|
console.log(' node .claude/hooks/budget-balancer.mjs # provider balance');
|
|
@@ -440,9 +483,324 @@ function printReport(env, mode, actions) {
|
|
|
440
483
|
}
|
|
441
484
|
}
|
|
442
485
|
|
|
486
|
+
// ─── Profile System ────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
const PROFILE_FILE_REL = '.claude/dual-brain.profile.json';
|
|
489
|
+
|
|
490
|
+
function profilePath(workspace) {
|
|
491
|
+
return join(workspace || process.cwd(), PROFILE_FILE_REL);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const PROFILES = {
|
|
495
|
+
balanced: {
|
|
496
|
+
description: 'Standard routing — best model for each tier, normal budgets',
|
|
497
|
+
routing: { prefer_provider: 'auto', think_threshold: 'normal', gpt_dispatch_bias: 0 },
|
|
498
|
+
budgets: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
499
|
+
quality_gate: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
|
|
500
|
+
},
|
|
501
|
+
'cost-saver': {
|
|
502
|
+
description: 'Minimize spend — prefer cheaper models, skip GPT for low risk',
|
|
503
|
+
routing: { prefer_provider: 'cheapest', think_threshold: 'strict', gpt_dispatch_bias: -20 },
|
|
504
|
+
budgets: { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
|
|
505
|
+
quality_gate: { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
|
|
506
|
+
},
|
|
507
|
+
'quality-first': {
|
|
508
|
+
description: 'Maximum quality — dual-brain for medium+, stricter reviews',
|
|
509
|
+
routing: { prefer_provider: 'most-capable', think_threshold: 'relaxed', gpt_dispatch_bias: 10 },
|
|
510
|
+
budgets: { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
|
|
511
|
+
quality_gate: { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
function loadProfile(workspace) {
|
|
516
|
+
try {
|
|
517
|
+
const data = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
|
|
518
|
+
const name = data.active && PROFILES[data.active] ? data.active : 'balanced';
|
|
519
|
+
const profile = PROFILES[name];
|
|
520
|
+
const custom = data.custom_overrides || {};
|
|
521
|
+
return {
|
|
522
|
+
name,
|
|
523
|
+
...profile,
|
|
524
|
+
budgets: { ...profile.budgets, ...custom.budgets },
|
|
525
|
+
routing: { ...profile.routing, ...custom.routing },
|
|
526
|
+
switched_at: data.switched_at || null,
|
|
527
|
+
};
|
|
528
|
+
} catch {
|
|
529
|
+
return { name: 'balanced', ...PROFILES.balanced, switched_at: null };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function saveProfile(workspace, name, customOverrides) {
|
|
534
|
+
const data = { active: name, switched_at: new Date().toISOString() };
|
|
535
|
+
if (customOverrides) data.custom_overrides = customOverrides;
|
|
536
|
+
const target = profilePath(workspace);
|
|
537
|
+
const tmp = target + '.tmp.' + process.pid;
|
|
538
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
539
|
+
renameSync(tmp, target);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─── Subcommand: status ────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
function cmdStatus() {
|
|
545
|
+
const workspace = resolve(process.cwd());
|
|
546
|
+
const env = detectEnvironment();
|
|
547
|
+
const mode = resolveMode(env);
|
|
548
|
+
const profile = loadProfile(workspace);
|
|
549
|
+
|
|
550
|
+
const lines = [];
|
|
551
|
+
lines.push(br('╔', '╗'));
|
|
552
|
+
lines.push(ln(`Dual-Brain Status — v${VERSION}`));
|
|
553
|
+
lines.push(sep());
|
|
554
|
+
|
|
555
|
+
lines.push(ln(`Mode: ${MODE_LABELS[mode.mode]}`));
|
|
556
|
+
lines.push(ln(`Profile: ${profile.name}`));
|
|
557
|
+
lines.push(ln(` ${PROFILES[profile.name]?.description || ''}`));
|
|
558
|
+
if (profile.switched_at) {
|
|
559
|
+
lines.push(ln(` Set: ${profile.switched_at.slice(0, 16).replace('T', ' ')}`));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
lines.push(sep());
|
|
563
|
+
|
|
564
|
+
lines.push(ln('Budget Limits'));
|
|
565
|
+
lines.push(ln(` Session: warn $${profile.budgets.session_warn_usd} / limit $${profile.budgets.session_limit_usd}`));
|
|
566
|
+
lines.push(ln(` Daily: warn $${profile.budgets.daily_warn_usd} / limit $${profile.budgets.daily_limit_usd}`));
|
|
567
|
+
|
|
568
|
+
lines.push(sep());
|
|
569
|
+
|
|
570
|
+
lines.push(ln('Providers'));
|
|
571
|
+
const cAuth = env.claude.authed ? 'authenticated' : 'not authenticated';
|
|
572
|
+
const xAuth = env.codex.authed ? 'authenticated' : env.codex.installed ? 'not authenticated' : 'not found';
|
|
573
|
+
lines.push(ln(` Claude: ${statusIcon(env.claude.authed)} ${cAuth}`));
|
|
574
|
+
lines.push(ln(` Codex: ${statusIcon(env.codex.authed)} ${xAuth}`));
|
|
575
|
+
|
|
576
|
+
lines.push(sep());
|
|
577
|
+
|
|
578
|
+
lines.push(ln('Quality Gate'));
|
|
579
|
+
lines.push(ln(` Reviews from: ${profile.quality_gate.sensitivity_floor} risk+`));
|
|
580
|
+
lines.push(ln(` Dual-brain at: ${profile.quality_gate.dual_brain_minimum} risk+`));
|
|
581
|
+
|
|
582
|
+
const balancer = join(workspace, '.claude', 'hooks', 'budget-balancer.mjs');
|
|
583
|
+
if (existsSync(balancer)) {
|
|
584
|
+
const proc = run(process.execPath, [balancer]);
|
|
585
|
+
if (proc.status === 0 && proc.stdout.trim()) {
|
|
586
|
+
lines.push(sep());
|
|
587
|
+
lines.push(ln('Provider Pressure (5hr rolling)'));
|
|
588
|
+
for (const l of proc.stdout.trim().split('\n')) {
|
|
589
|
+
if (l.includes('█') || l.includes('░') || l.includes('Recommendation')) {
|
|
590
|
+
const cleaned = l.replace(/[║╔╗╠╣╚╝═]/g, '').trim();
|
|
591
|
+
if (cleaned) lines.push(ln(` ${cleaned}`));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
lines.push(br('╚', '╝'));
|
|
598
|
+
|
|
599
|
+
console.log('');
|
|
600
|
+
for (const l of lines) console.log(` ${l}`);
|
|
601
|
+
console.log('');
|
|
602
|
+
|
|
603
|
+
if (IS_REPLIT) {
|
|
604
|
+
console.log(' Quick actions (paste into shell):');
|
|
605
|
+
console.log(` ${cmd('npx dual-brain mode cost-saver')} # switch profile`);
|
|
606
|
+
console.log(` ${cmd('npx dual-brain budget 8 25')} # set limits`);
|
|
607
|
+
console.log('');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─── Subcommand: mode ──────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
function cmdMode() {
|
|
614
|
+
const workspace = resolve(process.cwd());
|
|
615
|
+
const modeArg = positional[1] || null;
|
|
616
|
+
|
|
617
|
+
if (!modeArg || modeArg === 'list') {
|
|
618
|
+
const current = loadProfile(workspace);
|
|
619
|
+
console.log('');
|
|
620
|
+
console.log(' Available profiles:');
|
|
621
|
+
console.log('');
|
|
622
|
+
for (const [name, p] of Object.entries(PROFILES)) {
|
|
623
|
+
const active = name === current.name ? ' ← active' : '';
|
|
624
|
+
console.log(` ${name.padEnd(15)} ${p.description}${active}`);
|
|
625
|
+
}
|
|
626
|
+
console.log('');
|
|
627
|
+
console.log(` Switch: ${cmd('npx dual-brain mode <profile>')}`);
|
|
628
|
+
console.log('');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!PROFILES[modeArg]) {
|
|
633
|
+
console.error(` Unknown profile: ${modeArg}`);
|
|
634
|
+
console.error(` Available: ${Object.keys(PROFILES).join(', ')}`);
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const profile = PROFILES[modeArg];
|
|
639
|
+
|
|
640
|
+
let customOverrides = null;
|
|
641
|
+
try {
|
|
642
|
+
const existing = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
|
|
643
|
+
if (existing.custom_overrides?.budgets) {
|
|
644
|
+
customOverrides = { budgets: existing.custom_overrides.budgets };
|
|
645
|
+
}
|
|
646
|
+
} catch {}
|
|
647
|
+
|
|
648
|
+
saveProfile(workspace, modeArg, customOverrides);
|
|
649
|
+
|
|
650
|
+
console.log('');
|
|
651
|
+
console.log(` Profile switched to: ${modeArg}`);
|
|
652
|
+
console.log(` ${profile.description}`);
|
|
653
|
+
console.log('');
|
|
654
|
+
console.log(' What changed:');
|
|
655
|
+
console.log(` Routing: ${profile.routing.prefer_provider}`);
|
|
656
|
+
console.log(` Budget: $${profile.budgets.session_limit_usd}/session, $${profile.budgets.daily_limit_usd}/day`);
|
|
657
|
+
console.log(` Reviews from: ${profile.quality_gate.sensitivity_floor} risk+`);
|
|
658
|
+
console.log(` Dual-brain: ${profile.quality_gate.dual_brain_minimum} risk+`);
|
|
659
|
+
console.log('');
|
|
660
|
+
console.log(' Active immediately — no restart needed.');
|
|
661
|
+
console.log('');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ─── Subcommand: budget ────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
function cmdBudget() {
|
|
667
|
+
const workspace = resolve(process.cwd());
|
|
668
|
+
const sessionArg = positional[1] ? parseFloat(positional[1]) : null;
|
|
669
|
+
const dailyArg = positional[2] ? parseFloat(positional[2]) : null;
|
|
670
|
+
|
|
671
|
+
if (sessionArg == null) {
|
|
672
|
+
const profile = loadProfile(workspace);
|
|
673
|
+
console.log('');
|
|
674
|
+
console.log(' Current budget limits:');
|
|
675
|
+
console.log(` Session: warn $${profile.budgets.session_warn_usd} / limit $${profile.budgets.session_limit_usd}`);
|
|
676
|
+
console.log(` Daily: warn $${profile.budgets.daily_warn_usd} / limit $${profile.budgets.daily_limit_usd}`);
|
|
677
|
+
console.log('');
|
|
678
|
+
console.log(` Set limits: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
|
|
679
|
+
console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
|
|
680
|
+
console.log('');
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (isNaN(sessionArg) || sessionArg <= 0) {
|
|
685
|
+
console.error(' Session limit must be a positive number');
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const daily = (dailyArg != null && !isNaN(dailyArg) && dailyArg > 0) ? dailyArg : sessionArg * 3;
|
|
690
|
+
|
|
691
|
+
let existing = {};
|
|
692
|
+
try { existing = JSON.parse(readFileSync(profilePath(workspace), 'utf8')); } catch {}
|
|
693
|
+
|
|
694
|
+
const customOverrides = existing.custom_overrides || {};
|
|
695
|
+
customOverrides.budgets = {
|
|
696
|
+
session_warn_usd: +(sessionArg * 0.6).toFixed(2),
|
|
697
|
+
session_limit_usd: sessionArg,
|
|
698
|
+
daily_warn_usd: +(daily * 0.6).toFixed(2),
|
|
699
|
+
daily_limit_usd: daily,
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const data = {
|
|
703
|
+
active: existing.active || 'balanced',
|
|
704
|
+
switched_at: existing.switched_at || new Date().toISOString(),
|
|
705
|
+
custom_overrides: customOverrides,
|
|
706
|
+
};
|
|
707
|
+
const budgetTarget = profilePath(workspace);
|
|
708
|
+
const budgetTmp = budgetTarget + '.tmp.' + process.pid;
|
|
709
|
+
writeFileSync(budgetTmp, JSON.stringify(data, null, 2) + '\n');
|
|
710
|
+
renameSync(budgetTmp, budgetTarget);
|
|
711
|
+
|
|
712
|
+
console.log('');
|
|
713
|
+
console.log(' Budget limits updated:');
|
|
714
|
+
console.log(` Session: warn $${customOverrides.budgets.session_warn_usd} / limit $${sessionArg}`);
|
|
715
|
+
console.log(` Daily: warn $${customOverrides.budgets.daily_warn_usd} / limit $${daily}`);
|
|
716
|
+
console.log('');
|
|
717
|
+
console.log(' Active immediately — no restart needed.');
|
|
718
|
+
console.log('');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─── Subcommand: explain ───────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
function cmdExplain() {
|
|
724
|
+
const workspace = resolve(process.cwd());
|
|
725
|
+
const hooksDir = join(workspace, '.claude', 'hooks');
|
|
726
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
727
|
+
const logFile = join(hooksDir, `usage-${today}.jsonl`);
|
|
728
|
+
|
|
729
|
+
if (!existsSync(logFile)) {
|
|
730
|
+
console.log('');
|
|
731
|
+
console.log(' No routing decisions recorded today.');
|
|
732
|
+
console.log(' Start a Claude Code session and the tier enforcer will log decisions.');
|
|
733
|
+
console.log('');
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let lines;
|
|
738
|
+
try {
|
|
739
|
+
lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
740
|
+
} catch {
|
|
741
|
+
console.log(' Could not read usage log.');
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let lastRec = null;
|
|
746
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
747
|
+
try {
|
|
748
|
+
const entry = JSON.parse(lines[i]);
|
|
749
|
+
if (entry.type === 'tier_recommendation') { lastRec = entry; break; }
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (!lastRec) {
|
|
754
|
+
console.log('');
|
|
755
|
+
console.log(' No routing decisions found in today\'s log.');
|
|
756
|
+
console.log(' The tier enforcer logs decisions when Agent tool is used.');
|
|
757
|
+
console.log('');
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const profile = loadProfile(workspace);
|
|
762
|
+
|
|
763
|
+
console.log('');
|
|
764
|
+
console.log(' Last Routing Decision');
|
|
765
|
+
console.log(' ' + '─'.repeat(40));
|
|
766
|
+
console.log(` Time: ${lastRec.timestamp?.slice(11, 19) || 'unknown'}`);
|
|
767
|
+
console.log(` Detected: ${lastRec.detected_tier || 'unknown'} tier`);
|
|
768
|
+
console.log(` Recommended: ${lastRec.recommended_model || 'unknown'}`);
|
|
769
|
+
console.log(` Actual: ${lastRec.actual_model || 'unknown'}`);
|
|
770
|
+
console.log(` Followed: ${lastRec.followed ? 'yes' : 'no'}`);
|
|
771
|
+
console.log(` Profile: ${profile.name}`);
|
|
772
|
+
console.log('');
|
|
773
|
+
|
|
774
|
+
if (!lastRec.followed) {
|
|
775
|
+
console.log(' The recommendation was not followed. This may mean:');
|
|
776
|
+
console.log(' - The task needed a different model (valid override)');
|
|
777
|
+
console.log(' - The subagent_type forced a specific tier');
|
|
778
|
+
console.log(` - Profile "${profile.name}" adjusted the threshold`);
|
|
779
|
+
} else {
|
|
780
|
+
console.log(' The recommendation was followed — routing worked as expected.');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
let total = 0, followed = 0;
|
|
784
|
+
for (const line of lines) {
|
|
785
|
+
try {
|
|
786
|
+
const e = JSON.parse(line);
|
|
787
|
+
if (e.type === 'tier_recommendation') { total++; if (e.followed) followed++; }
|
|
788
|
+
} catch {}
|
|
789
|
+
}
|
|
790
|
+
const pct = total > 0 ? Math.round((followed / total) * 100) : 0;
|
|
791
|
+
console.log('');
|
|
792
|
+
console.log(` Today: ${followed}/${total} recommendations followed (${pct}%)`);
|
|
793
|
+
console.log('');
|
|
794
|
+
}
|
|
795
|
+
|
|
443
796
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
444
797
|
|
|
445
798
|
function main() {
|
|
799
|
+
if (subcommand === 'status') { cmdStatus(); return; }
|
|
800
|
+
if (subcommand === 'mode') { cmdMode(); return; }
|
|
801
|
+
if (subcommand === 'budget') { cmdBudget(); return; }
|
|
802
|
+
if (subcommand === 'explain') { cmdExplain(); return; }
|
|
803
|
+
|
|
446
804
|
const env = detectEnvironment();
|
|
447
805
|
const mode = resolveMode(env);
|
|
448
806
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"install.mjs",
|
|
30
|
-
"hooks
|
|
30
|
+
"hooks/*.mjs",
|
|
31
31
|
"orchestrator.json",
|
|
32
32
|
"hookify.*.local.md",
|
|
33
33
|
"review-rules.md",
|