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.
- package/CLAUDE.md +40 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/hookify.orchestrator-cost.local.md +16 -0
- package/hookify.orchestrator-gate.local.md +19 -0
- package/hookify.orchestrator-route.local.md +23 -0
- package/hooks/budget-balancer.mjs +463 -0
- package/hooks/cost-logger.mjs +250 -0
- package/hooks/cost-report.mjs +344 -0
- package/hooks/dual-brain-review.mjs +302 -0
- package/hooks/dual-brain-think.mjs +321 -0
- package/hooks/enforce-tier.mjs +282 -0
- package/hooks/gpt-work-dispatcher.mjs +254 -0
- package/hooks/health-check.mjs +390 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/quality-gate.mjs +283 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/test-orchestrator.mjs +316 -0
- package/install.mjs +153 -0
- package/orchestrator.json +215 -0
- package/package.json +38 -0
- package/review-rules.md +17 -0
|
@@ -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); });
|