dual-brain 2.0.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.
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-report.mjs — Comprehensive session-end summary report.
4
+ *
5
+ * Usage:
6
+ * node .claude/hooks/session-report.mjs
7
+ *
8
+ * Reads:
9
+ * .claude/hooks/usage-YYYY-MM-DD.jsonl — today's usage log
10
+ * .claude/hooks/usage.jsonl — legacy usage log (backwards compat)
11
+ * .claude/orchestrator.json — config, rates, pricing_verified date
12
+ *
13
+ * Sections:
14
+ * 1. Activity Summary — calls and estimated cost by tier
15
+ * 2. Routing Compliance — tier_recommendation follow/ignore rates
16
+ * 3. Duplicate Warnings — prompt_hash collisions in today's recommendations
17
+ * 4. Quality Gate — run quality-gate.mjs and display result
18
+ * 5. Data Quality — token source breakdown (actual vs heuristic)
19
+ * 6. Drift Warnings — pricing staleness and config issues
20
+ */
21
+
22
+ import { existsSync, readFileSync, readdirSync } from 'fs';
23
+ import { dirname, join } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import { spawnSync } from 'child_process';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Paths
29
+ // ---------------------------------------------------------------------------
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
32
+ const QUALITY_GATE = join(__dirname, 'quality-gate.mjs');
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Box width
36
+ // ---------------------------------------------------------------------------
37
+ const BOX_W = 52; // inner width (between ║ chars, including 1-space padding each side)
38
+ const INNER = BOX_W - 2; // usable text width
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Box helpers
42
+ // ---------------------------------------------------------------------------
43
+ function boxTop() { return '╔' + '═'.repeat(BOX_W) + '╗'; }
44
+ function boxBot() { return '╚' + '═'.repeat(BOX_W) + '╝'; }
45
+ function boxDiv() { return '╠' + '═'.repeat(BOX_W) + '╣'; }
46
+ function boxLine(s) {
47
+ s = String(s ?? '');
48
+ if (s.length > INNER) s = s.slice(0, INNER - 1) + '…';
49
+ return '║ ' + s + ' '.repeat(INNER - s.length) + ' ║';
50
+ }
51
+ function boxBlank() { return boxLine(''); }
52
+ function boxTitle(s) {
53
+ const padTotal = INNER - s.length;
54
+ const left = Math.floor(padTotal / 2);
55
+ const right = padTotal - left;
56
+ return '║' + ' '.repeat(left + 1) + s + ' '.repeat(right + 1) + '║';
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Padding helpers
61
+ // ---------------------------------------------------------------------------
62
+ function padR(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length); }
63
+ function padL(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : ' '.repeat(n - s.length) + s; }
64
+ function fmt$(n) { return '$' + n.toFixed(2); }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Load orchestrator config
68
+ // ---------------------------------------------------------------------------
69
+ function loadConfig() {
70
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return null; }
71
+ }
72
+
73
+ function buildRateMap(config) {
74
+ const rates = {};
75
+ if (!config?.subscriptions) return rates;
76
+ for (const provider of Object.values(config.subscriptions)) {
77
+ for (const [modelKey, data] of Object.entries(provider.models || {})) {
78
+ rates[modelKey] = {
79
+ tier: data.tier,
80
+ input_per_mtok: data.input_per_mtok,
81
+ output_per_mtok: data.output_per_mtok,
82
+ };
83
+ }
84
+ }
85
+ return rates;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Token heuristics (mirrors cost-report.mjs)
90
+ // ---------------------------------------------------------------------------
91
+ const TOKEN_HEURISTICS = {
92
+ search: { input: 2_000, output: 500 },
93
+ execute: { input: 4_000, output: 1_500 },
94
+ think: { input: 8_000, output: 3_000 },
95
+ };
96
+
97
+ function estimateCost(tier, model, rateMap, record = {}) {
98
+ const heuristic = TOKEN_HEURISTICS[tier] || TOKEN_HEURISTICS.execute;
99
+ const hasActual = record.input_tokens != null && record.output_tokens != null;
100
+ const inputTok = hasActual ? record.input_tokens : heuristic.input;
101
+ const outputTok = hasActual ? record.output_tokens : heuristic.output;
102
+ const rate = rateMap[model] || rateMap['main-session'];
103
+ if (!rate) {
104
+ const fallbackTier = (model === 'main-session' || model === 'unknown') ? 'think' : tier;
105
+ const tierRate =
106
+ Object.values(rateMap).find(r => r.tier === fallbackTier) ||
107
+ Object.values(rateMap).find(r => r.tier === tier);
108
+ if (!tierRate) return 0;
109
+ return (inputTok / 1_000_000) * tierRate.input_per_mtok +
110
+ (outputTok / 1_000_000) * tierRate.output_per_mtok;
111
+ }
112
+ return (inputTok / 1_000_000) * rate.input_per_mtok +
113
+ (outputTok / 1_000_000) * rate.output_per_mtok;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Load today's usage records
118
+ // ---------------------------------------------------------------------------
119
+ function todayPrefix() {
120
+ return new Date().toISOString().slice(0, 10);
121
+ }
122
+
123
+ function loadTodayRecords() {
124
+ const today = todayPrefix();
125
+ const files = [];
126
+
127
+ // Today's dated file
128
+ const datedFile = join(__dirname, `usage-${today}.jsonl`);
129
+ if (existsSync(datedFile)) files.push(datedFile);
130
+
131
+ // Legacy usage.jsonl — include but filter to today
132
+ const legacyFile = join(__dirname, 'usage.jsonl');
133
+ if (existsSync(legacyFile)) files.push(legacyFile);
134
+
135
+ const records = [];
136
+ for (const f of files) {
137
+ try {
138
+ const lines = readFileSync(f, 'utf8').split('\n').filter(Boolean);
139
+ for (const line of lines) {
140
+ try {
141
+ const r = JSON.parse(line);
142
+ if (r.timestamp?.startsWith(today)) records.push(r);
143
+ } catch { /* skip bad lines */ }
144
+ }
145
+ } catch { /* skip unreadable files */ }
146
+ }
147
+ return records;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Section 1: Activity Summary
152
+ // ---------------------------------------------------------------------------
153
+ const TIER_ORDER = ['search', 'execute', 'think'];
154
+ const TIER_LABELS = { search: 'Search ', execute: 'Execute', think: 'Think ' };
155
+
156
+ function buildActivitySection(records, rateMap) {
157
+ // Aggregate by tier — only non-recommendation records
158
+ const activity = records.filter(r => r.type !== 'tier_recommendation');
159
+
160
+ const buckets = {};
161
+ for (const r of activity) {
162
+ const tier = r.tier || 'execute';
163
+ const model = r.model || 'unknown';
164
+ if (!buckets[tier]) buckets[tier] = { calls: 0, cost: 0, actualCount: 0 };
165
+ buckets[tier].calls += 1;
166
+ buckets[tier].cost += estimateCost(tier, model, rateMap, r);
167
+ if (r.input_tokens != null && r.output_tokens != null) buckets[tier].actualCount += 1;
168
+ }
169
+
170
+ const lines = [];
171
+ lines.push(boxLine('Activity Summary'));
172
+ lines.push(boxLine('─'.repeat(INNER)));
173
+
174
+ // Column widths: Tier(8) │ Calls(6) │ Est. Cost(10)
175
+ const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Est. Cost', 10);
176
+ const divRow = '─'.repeat(8) + '─┼─' + '─'.repeat(5) + '─┼─' + '─'.repeat(10);
177
+ lines.push(boxLine(header));
178
+ lines.push(boxLine(divRow));
179
+
180
+ let totalCalls = 0;
181
+ let totalCost = 0;
182
+
183
+ for (const tier of TIER_ORDER) {
184
+ const b = buckets[tier];
185
+ if (!b) continue;
186
+ const label = padR(TIER_LABELS[tier] || tier, 8);
187
+ const calls = padL(String(b.calls), 5);
188
+ const cost = padL(fmt$(b.cost), 10);
189
+ lines.push(boxLine(`${label} │ ${calls} │ ${cost}`));
190
+ totalCalls += b.calls;
191
+ totalCost += b.cost;
192
+ }
193
+
194
+ lines.push(boxLine(divRow));
195
+ lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(fmt$(totalCost), 10)));
196
+
197
+ if (totalCalls === 0) {
198
+ lines.push(boxLine(' (no usage data recorded today)'));
199
+ }
200
+
201
+ return { lines, totalCalls, totalCost, buckets };
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Section 1b: Provider Balance
206
+ // ---------------------------------------------------------------------------
207
+ function detectProvider(record) {
208
+ if (record.provider) return record.provider;
209
+ const m = (record.model || '').toLowerCase();
210
+ if (m.includes('gpt') || m.includes('o1') || m.includes('o3')) return 'openai';
211
+ if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) return 'claude';
212
+ return 'unknown';
213
+ }
214
+
215
+ function buildProviderBalanceSection(records) {
216
+ const activity = records.filter(r => r.type !== 'tier_recommendation');
217
+
218
+ // Count calls per provider
219
+ const providerCounts = {};
220
+ for (const r of activity) {
221
+ const provider = detectProvider(r);
222
+ providerCounts[provider] = (providerCounts[provider] || 0) + 1;
223
+ }
224
+
225
+ const totalCalls = Object.values(providerCounts).reduce((s, n) => s + n, 0);
226
+
227
+ // Special event counts
228
+ const gptDispatches = records.filter(r => r.dispatcher === 'gpt-work-dispatcher').length;
229
+ const dualThinkEvents = records.filter(r => r.tool === 'dual-brain-think').length;
230
+
231
+ const lines = [];
232
+ lines.push(boxLine('Provider Balance'));
233
+ lines.push(boxLine('─'.repeat(INNER)));
234
+
235
+ if (totalCalls === 0) {
236
+ lines.push(boxLine(' (no usage data recorded today)'));
237
+ } else {
238
+ // Render each provider sorted by count descending
239
+ const sorted = Object.entries(providerCounts).sort((a, b) => b[1] - a[1]);
240
+ for (const [provider, count] of sorted) {
241
+ const pct = Math.round((count / totalCalls) * 100);
242
+ const label = padR(provider.charAt(0).toUpperCase() + provider.slice(1) + ':', 9);
243
+ lines.push(boxLine(`${label} ${padL(pct + '%', 3)} of work (${count} calls)`));
244
+ }
245
+ }
246
+
247
+ lines.push(boxBlank());
248
+ lines.push(boxLine(`GPT Dispatches: ${gptDispatches}`));
249
+ lines.push(boxLine(`Dual-Think Events: ${dualThinkEvents}`));
250
+
251
+ // Recommendation line when imbalance is significant (one provider >70%)
252
+ if (totalCalls > 0) {
253
+ for (const [provider, count] of Object.entries(providerCounts)) {
254
+ const pct = (count / totalCalls) * 100;
255
+ if (pct > 70) {
256
+ const other = provider === 'claude' ? 'OpenAI' : 'Claude';
257
+ lines.push(boxBlank());
258
+ lines.push(boxLine(`Next session: Route more execution work to ${other}`));
259
+ break;
260
+ }
261
+ }
262
+ }
263
+
264
+ return { lines };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Section 2: Routing Compliance
269
+ // ---------------------------------------------------------------------------
270
+ function buildComplianceSection(records, rateMap) {
271
+ const recs = records.filter(r => r.type === 'tier_recommendation');
272
+
273
+ const total = recs.length;
274
+ const followed = recs.filter(r => r.followed === true).length;
275
+ const ignored = total - followed;
276
+ const followPct = total > 0 ? Math.round((followed / total) * 100) : 0;
277
+ const ignorePct = total > 0 ? 100 - followPct : 0;
278
+
279
+ // Overspend: for each ignored rec, diff between actual-tier cost and recommended-tier cost
280
+ let overspend = 0;
281
+ for (const r of recs) {
282
+ if (r.followed === true) continue;
283
+ if (!r.recommended_tier || !r.actual_tier) continue;
284
+ const recommended = TOKEN_HEURISTICS[r.recommended_tier] || TOKEN_HEURISTICS.execute;
285
+ const actual = TOKEN_HEURISTICS[r.actual_tier] || TOKEN_HEURISTICS.execute;
286
+
287
+ const recRate = Object.values(rateMap).find(x => x.tier === r.recommended_tier);
288
+ const actRate = Object.values(rateMap).find(x => x.tier === r.actual_tier);
289
+ if (!recRate || !actRate) continue;
290
+
291
+ const recCost = (recommended.input / 1_000_000) * recRate.input_per_mtok +
292
+ (recommended.output / 1_000_000) * recRate.output_per_mtok;
293
+ const actCost = (actual.input / 1_000_000) * actRate.input_per_mtok +
294
+ (actual.output / 1_000_000) * actRate.output_per_mtok;
295
+ const delta = actCost - recCost;
296
+ if (delta > 0) overspend += delta;
297
+ }
298
+
299
+ const lines = [];
300
+ lines.push(boxLine('Routing Compliance'));
301
+ lines.push(boxLine('─'.repeat(INNER)));
302
+ lines.push(boxLine(`Recommendations: ${total}`));
303
+ lines.push(boxLine(`Followed: ${followed} (${followPct}%)`));
304
+ lines.push(boxLine(`Ignored: ${ignored} (${ignorePct}%)`));
305
+ lines.push(boxLine(`Estimated overspend: ~${fmt$(overspend)}`));
306
+
307
+ return { lines };
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Section 3: Duplicate Warnings
312
+ // ---------------------------------------------------------------------------
313
+ function buildDuplicateSection(records) {
314
+ const recs = records.filter(r => r.type === 'tier_recommendation' && r.prompt_hash);
315
+
316
+ const hashCounts = {};
317
+ for (const r of recs) {
318
+ hashCounts[r.prompt_hash] = (hashCounts[r.prompt_hash] || 0) + 1;
319
+ }
320
+ const dups = Object.values(hashCounts).filter(c => c > 1).length;
321
+
322
+ const lines = [];
323
+ lines.push(boxLine('Duplicate Dispatches'));
324
+ lines.push(boxLine('─'.repeat(INNER)));
325
+ lines.push(boxLine(`Duplicate Dispatches: ${dups}`));
326
+ if (dups > 0) {
327
+ lines.push(boxLine(' (same prompt dispatched to multiple tiers)'));
328
+ }
329
+
330
+ return { lines };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Section 4: Quality Gate
335
+ // ---------------------------------------------------------------------------
336
+ function buildQualityGateSection() {
337
+ const lines = [];
338
+ lines.push(boxLine('Quality Gate'));
339
+ lines.push(boxLine('─'.repeat(INNER)));
340
+
341
+ if (!existsSync(QUALITY_GATE)) {
342
+ lines.push(boxLine('Quality Gate: unavailable (quality-gate.mjs not found)'));
343
+ return { lines };
344
+ }
345
+
346
+ const proc = spawnSync(process.execPath, [QUALITY_GATE], {
347
+ encoding: 'utf8',
348
+ stdio: ['pipe', 'pipe', 'pipe'],
349
+ timeout: 120_000,
350
+ });
351
+
352
+ let result = {};
353
+ try {
354
+ const stdout = (proc.stdout || '').trim();
355
+ if (stdout) result = JSON.parse(stdout);
356
+ } catch { /* leave result empty */ }
357
+
358
+ const gate = result.gate || 'error';
359
+ const files = Array.isArray(result.files) ? result.files : [];
360
+ const count = files.length;
361
+
362
+ let statusLine;
363
+ if (gate === 'pass') {
364
+ statusLine = count > 0
365
+ ? `Quality Gate: pass (${count} file${count !== 1 ? 's' : ''} reviewed)`
366
+ : `Quality Gate: pass (${result.reason || 'no qualifying changes'})`;
367
+ } else if (gate === 'issues_found') {
368
+ statusLine = 'Quality Gate: issues_found (see .claude/reviews/)';
369
+ } else if (gate === 'needs_human_review') {
370
+ statusLine = 'Quality Gate: needs_human_review (GPT unavailable)';
371
+ } else if (gate === 'disabled') {
372
+ statusLine = 'Quality Gate: disabled';
373
+ } else {
374
+ statusLine = `Quality Gate: ${gate}`;
375
+ }
376
+
377
+ lines.push(boxLine(statusLine));
378
+
379
+ if (result.review_path) {
380
+ lines.push(boxLine(` Review: ${result.review_path}`));
381
+ }
382
+
383
+ return { lines };
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Section 5: Data Quality
388
+ // ---------------------------------------------------------------------------
389
+ function buildDataQualitySection(records) {
390
+ // Only tool-call records (not recommendations)
391
+ const activity = records.filter(r => r.type !== 'tier_recommendation');
392
+ const total = activity.length;
393
+ const actual = activity.filter(r => r.input_tokens != null && r.output_tokens != null).length;
394
+ const heuristic = total - actual;
395
+ const actPct = total > 0 ? Math.round((actual / total) * 100) : 0;
396
+ const heuPct = total > 0 ? 100 - actPct : 100;
397
+ const unknownModels = activity.filter(r => !r.model || r.model === 'unknown').length;
398
+
399
+ const confidence =
400
+ total === 0 ? 'n/a' :
401
+ actPct >= 80 ? 'high' :
402
+ actPct >= 40 ? 'medium' :
403
+ 'low';
404
+
405
+ const lines = [];
406
+ lines.push(boxLine('Data Quality'));
407
+ lines.push(boxLine('─'.repeat(INNER)));
408
+ lines.push(boxLine(`Token data: ${actPct}% actual, ${heuPct}% heuristic`));
409
+ lines.push(boxLine(`Confidence: ${confidence}`));
410
+ lines.push(boxLine(`Unknown models: ${unknownModels} entries`));
411
+
412
+ return { lines };
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Section 6: Drift Warnings
417
+ // ---------------------------------------------------------------------------
418
+ function buildDriftSection(config) {
419
+ const lines = [];
420
+ lines.push(boxLine('Drift Warnings'));
421
+ lines.push(boxLine('─'.repeat(INNER)));
422
+
423
+ const warnings = [];
424
+
425
+ // Pricing staleness
426
+ const verified = config?.pricing_verified;
427
+ if (!verified) {
428
+ warnings.push('pricing_verified missing from orchestrator.json');
429
+ } else {
430
+ const verifiedMs = new Date(verified).getTime();
431
+ const nowMs = Date.now();
432
+ const ageDays = Math.floor((nowMs - verifiedMs) / (1000 * 60 * 60 * 24));
433
+ if (ageDays > 30) {
434
+ warnings.push(`Pricing data is ${ageDays} days old (last verified: ${verified})`);
435
+ } else {
436
+ warnings.push(`Pricing verified ${ageDays} day${ageDays !== 1 ? 's' : ''} ago (${verified}) — OK`);
437
+ }
438
+ }
439
+
440
+ // Check subscriptions populated
441
+ if (!config?.subscriptions || Object.keys(config.subscriptions).length === 0) {
442
+ warnings.push('No subscriptions configured in orchestrator.json');
443
+ }
444
+
445
+ // Check quality gate enabled
446
+ if (config?.quality_gate?.enabled === false) {
447
+ warnings.push('Quality gate is DISABLED in orchestrator.json');
448
+ }
449
+
450
+ if (warnings.length === 0) {
451
+ lines.push(boxLine('No drift warnings.'));
452
+ } else {
453
+ for (const w of warnings) {
454
+ lines.push(boxLine(`• ${w}`));
455
+ }
456
+ }
457
+
458
+ return { lines };
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Main
463
+ // ---------------------------------------------------------------------------
464
+ function main() {
465
+ const config = loadConfig();
466
+ const rateMap = buildRateMap(config);
467
+ const records = loadTodayRecords();
468
+
469
+ const output = [];
470
+
471
+ output.push(boxTop());
472
+ output.push(boxTitle('Session Summary Report'));
473
+ output.push(boxDiv());
474
+
475
+ // --- Section 1: Activity Summary ---
476
+ const { lines: actLines } = buildActivitySection(records, rateMap);
477
+ output.push(...actLines);
478
+ output.push(boxBlank());
479
+
480
+ // --- Section 1b: Provider Balance ---
481
+ const { lines: provLines } = buildProviderBalanceSection(records);
482
+ output.push(...provLines);
483
+ output.push(boxBlank());
484
+
485
+ // --- Section 2: Routing Compliance ---
486
+ const { lines: compLines } = buildComplianceSection(records, rateMap);
487
+ output.push(...compLines);
488
+ output.push(boxBlank());
489
+
490
+ // --- Section 3: Duplicate Warnings ---
491
+ const { lines: dupLines } = buildDuplicateSection(records);
492
+ output.push(...dupLines);
493
+ output.push(boxBlank());
494
+
495
+ // --- Section 4: Quality Gate ---
496
+ const { lines: gateLines } = buildQualityGateSection();
497
+ output.push(...gateLines);
498
+ output.push(boxBlank());
499
+
500
+ // --- Section 5: Data Quality ---
501
+ const { lines: dqLines } = buildDataQualitySection(records);
502
+ output.push(...dqLines);
503
+ output.push(boxBlank());
504
+
505
+ // --- Section 6: Drift Warnings ---
506
+ const { lines: driftLines } = buildDriftSection(config || {});
507
+ output.push(...driftLines);
508
+
509
+ output.push(boxBot());
510
+
511
+ console.log(output.join('\n'));
512
+ }
513
+
514
+ main();
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * setup-wizard.mjs — Interactive setup for the Dual-Brain Orchestrator.
4
+ * Usage: node .claude/hooks/setup-wizard.mjs
5
+ */
6
+ import { createInterface } from 'readline';
7
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
8
+ import { dirname, join } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
13
+
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
16
+
17
+ const CLAUDE_PLANS = {
18
+ '$20': { models: { sonnet: { tier: 'think', input_per_mtok: 3.0, output_per_mtok: 15.0 }, haiku: { tier: 'search', input_per_mtok: 1.0, output_per_mtok: 5.0 } } },
19
+ '$100': { models: { opus: { tier: 'think', input_per_mtok: 5.0, output_per_mtok: 25.0 }, sonnet: { tier: 'execute', input_per_mtok: 3.0, output_per_mtok: 15.0 }, haiku: { tier: 'search', input_per_mtok: 1.0, output_per_mtok: 5.0 } } },
20
+ '$200': { models: { opus: { tier: 'think', input_per_mtok: 5.0, output_per_mtok: 25.0 }, sonnet: { tier: 'execute', input_per_mtok: 3.0, output_per_mtok: 15.0 }, haiku: { tier: 'search', input_per_mtok: 1.0, output_per_mtok: 5.0 } } },
21
+ 'api': { models: { opus: { tier: 'think', input_per_mtok: 5.0, output_per_mtok: 25.0 }, sonnet: { tier: 'execute', input_per_mtok: 3.0, output_per_mtok: 15.0 }, haiku: { tier: 'search', input_per_mtok: 1.0, output_per_mtok: 5.0 } } },
22
+ };
23
+
24
+ const OPENAI_PLANS = {
25
+ '$20': { models: { 'gpt-5.4': { tier: 'think', input_per_mtok: 2.5, output_per_mtok: 15.0 }, 'gpt-4.1-mini': { tier: 'search', input_per_mtok: 0.40, output_per_mtok: 1.60 } } },
26
+ '$100': { models: { 'gpt-5.5': { tier: 'think', input_per_mtok: 5.0, output_per_mtok: 30.0 }, 'gpt-5.4': { tier: 'execute', input_per_mtok: 2.5, output_per_mtok: 15.0 }, 'gpt-4.1-mini': { tier: 'search', input_per_mtok: 0.40, output_per_mtok: 1.60 } } },
27
+ '$200': { models: { 'gpt-5.5': { tier: 'think', input_per_mtok: 5.0, output_per_mtok: 30.0 }, 'gpt-5.4': { tier: 'execute', input_per_mtok: 2.5, output_per_mtok: 15.0 }, 'gpt-4.1-mini': { tier: 'search', input_per_mtok: 0.40, output_per_mtok: 1.60 } } },
28
+ 'api': { models: { 'gpt-5.5': { tier: 'think', input_per_mtok: 5.0, output_per_mtok: 30.0 }, 'gpt-5.4': { tier: 'execute', input_per_mtok: 2.5, output_per_mtok: 15.0 }, 'gpt-4.1-mini': { tier: 'search', input_per_mtok: 0.40, output_per_mtok: 1.60 } } },
29
+ };
30
+
31
+ async function main() {
32
+ console.log('');
33
+ console.log(' ╔══════════════════════════════════════════════════╗');
34
+ console.log(' ║ Dual-Brain Orchestrator Setup Wizard ║');
35
+ console.log(' ╚══════════════════════════════════════════════════╝');
36
+ console.log('');
37
+
38
+ // Claude subscription
39
+ console.log(' Claude subscription plans: $20, $100, $200, api');
40
+ const claudePlan = (await ask(' Your Claude plan: ')).trim().toLowerCase();
41
+ const cKey = claudePlan.startsWith('$') ? claudePlan : (claudePlan === 'api' ? 'api' : `$${claudePlan}`);
42
+ const claudeConfig = CLAUDE_PLANS[cKey];
43
+ if (!claudeConfig) {
44
+ console.log(` ⚠ Unknown plan "${cKey}", using $100 defaults`);
45
+ }
46
+ const finalClaudeConfig = claudeConfig || CLAUDE_PLANS['$100'];
47
+ console.log(` -> Using Claude ${cKey} model set\n`);
48
+
49
+ // OpenAI subscription
50
+ const hasOpenai = (await ask(' Do you have an OpenAI subscription? (y/n): ')).trim().toLowerCase();
51
+ let openaiConfig = null;
52
+ let oKey = null;
53
+ if (hasOpenai === 'y' || hasOpenai === 'yes') {
54
+ console.log(' OpenAI plans: $20, $100, $200, api');
55
+ const openaiPlan = (await ask(' Your OpenAI plan: ')).trim().toLowerCase();
56
+ oKey = openaiPlan.startsWith('$') ? openaiPlan : (openaiPlan === 'api' ? 'api' : `$${openaiPlan}`);
57
+ openaiConfig = OPENAI_PLANS[oKey];
58
+ if (!openaiConfig) {
59
+ console.log(` ⚠ Unknown plan "${oKey}", using $100 defaults`);
60
+ }
61
+ openaiConfig = openaiConfig || OPENAI_PLANS['$100'];
62
+ console.log(` -> Using OpenAI ${oKey} model set\n`);
63
+ }
64
+
65
+ // Quality gate
66
+ const gateInput = (await ask(' Enable quality gate on code changes? (Y/n): ')).trim().toLowerCase();
67
+ const gateEnabled = gateInput !== 'n' && gateInput !== 'no';
68
+
69
+ // Build config
70
+ let existing = {};
71
+ if (existsSync(CONFIG_FILE)) {
72
+ try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch {}
73
+ }
74
+
75
+ const config = {
76
+ ...existing,
77
+ subscriptions: {
78
+ claude: { plan: cKey, models: finalClaudeConfig.models },
79
+ ...(openaiConfig ? { openai: { plan: oKey, models: openaiConfig.models } } : {}),
80
+ },
81
+ tiers: existing.tiers || {
82
+ search: { description: 'Read-only lookups, exploration, grep, find, file reads', prefer: 'cheapest available model', tasks: ['explore', 'grep', 'find', 'ls', 'read_file', 'git_log', 'git_status'] },
83
+ execute: { description: 'Implementation, edits, test runs, git operations, linting', prefer: 'mid-tier model', tasks: ['edit', 'write', 'test_run', 'lint', 'format', 'simple_fix', 'refactor_small'] },
84
+ think: { description: 'Architecture, review, planning, security, complex debugging', prefer: 'most capable model', tasks: ['architecture', 'review', 'planning', 'security', 'complex_debug', 'design'] },
85
+ },
86
+ quality_gate: {
87
+ enabled: gateEnabled,
88
+ trigger_extensions: ['.ts', '.tsx', '.js', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.swift', '.kt'],
89
+ skip_patterns: ['test', '__tests__', 'spec', '.md', '.json', '.yaml', '.toml', '.txt'],
90
+ },
91
+ routing_rules: existing.routing_rules || {
92
+ subagent_defaults: { Explore: 'search', 'general-purpose': 'execute', Plan: 'think', 'code-reviewer': 'think' },
93
+ max_concurrent_think: 1,
94
+ max_concurrent_execute: 3,
95
+ max_concurrent_search: 4,
96
+ },
97
+ pricing_verified: new Date().toISOString().slice(0, 10),
98
+ };
99
+
100
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
101
+
102
+ // Summary
103
+ console.log('');
104
+ console.log(' ╔══════════════════════════════════════════════════╗');
105
+ console.log(' ║ Configured! ║');
106
+ console.log(' ╠══════════════════════════════════════════════════╣');
107
+
108
+ const claudeModels = Object.keys(finalClaudeConfig.models).join(', ');
109
+ console.log(` ║ Claude: ${cKey.padEnd(6)} (${claudeModels})`.padEnd(53) + '║');
110
+
111
+ if (openaiConfig) {
112
+ const oModels = Object.keys(openaiConfig.models).join(', ');
113
+ console.log(` ║ OpenAI: yes (${oModels})`.padEnd(53) + '║');
114
+ console.log(' ║ Dual-brain: enabled'.padEnd(53) + '║');
115
+ } else {
116
+ console.log(' ║ OpenAI: none'.padEnd(53) + '║');
117
+ console.log(' ║ Dual-brain: disabled (add OpenAI or Codex)'.padEnd(53) + '║');
118
+ }
119
+
120
+ console.log(` ║ Quality gate: ${gateEnabled ? 'enabled' : 'disabled'}`.padEnd(53) + '║');
121
+ console.log(' ╠══════════════════════════════════════════════════╣');
122
+ console.log(' ║ Restart Claude Code to activate the orchestrator ║');
123
+ console.log(' ╚══════════════════════════════════════════════════╝');
124
+ console.log('');
125
+
126
+ rl.close();
127
+ }
128
+
129
+ process.on('SIGINT', () => { console.log('\n Setup cancelled.'); process.exit(0); });
130
+ main().catch(e => { console.error('Setup error:', e.message); process.exit(1); });