dual-brain 4.6.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +35 -130
- package/README.md +34 -179
- package/hooks/control-panel.mjs +379 -8
- package/hooks/cost-logger.mjs +11 -53
- package/hooks/cost-report.mjs +126 -65
- package/hooks/decision-ledger.mjs +3 -53
- package/hooks/dual-brain-review.mjs +25 -261
- package/hooks/dual-brain-think.mjs +37 -300
- package/hooks/enforce-tier.mjs +93 -265
- package/hooks/failure-detector.mjs +1 -3
- package/hooks/gpt-work-dispatcher.mjs +153 -12
- package/hooks/health-check.mjs +25 -17
- package/hooks/quality-gate.mjs +11 -6
- package/hooks/risk-classifier.mjs +2 -135
- package/hooks/session-report.mjs +71 -41
- package/hooks/summary-checkpoint.mjs +8 -35
- package/hooks/test-orchestrator.mjs +31 -2080
- package/install.mjs +616 -1564
- package/orchestrator.json +96 -73
- package/package.json +2 -7
- package/hooks/agent-chains.mjs +0 -369
- package/hooks/agent-templates.mjs +0 -441
- package/hooks/atomic-write.mjs +0 -109
- package/hooks/config-validator.mjs +0 -156
- package/hooks/confirmation-policy.mjs +0 -167
- package/hooks/error-channel.mjs +0 -68
- package/hooks/ship-captain.mjs +0 -1176
- package/hooks/ship-gate.mjs +0 -971
package/hooks/session-report.mjs
CHANGED
|
@@ -61,34 +61,56 @@ function boxTitle(s) {
|
|
|
61
61
|
// ---------------------------------------------------------------------------
|
|
62
62
|
function padR(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length); }
|
|
63
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); }
|
|
64
65
|
|
|
65
66
|
// ---------------------------------------------------------------------------
|
|
66
|
-
// Load orchestrator config
|
|
67
|
+
// Load orchestrator config
|
|
67
68
|
// ---------------------------------------------------------------------------
|
|
68
69
|
function loadConfig() {
|
|
69
70
|
try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return null; }
|
|
70
71
|
}
|
|
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
|
+
|
|
72
88
|
// ---------------------------------------------------------------------------
|
|
73
|
-
//
|
|
89
|
+
// Token heuristics (mirrors cost-report.mjs)
|
|
74
90
|
// ---------------------------------------------------------------------------
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
79
99
|
const hasActual = record.input_tokens != null && record.output_tokens != null;
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
111
|
}
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
function activityLabel(score) {
|
|
87
|
-
if (score <= 10) return 'minimal';
|
|
88
|
-
if (score <= 30) return 'light';
|
|
89
|
-
if (score <= 60) return 'moderate';
|
|
90
|
-
if (score <= 85) return 'heavy';
|
|
91
|
-
return 'intense';
|
|
112
|
+
return (inputTok / 1_000_000) * rate.input_per_mtok +
|
|
113
|
+
(outputTok / 1_000_000) * rate.output_per_mtok;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
// ---------------------------------------------------------------------------
|
|
@@ -131,54 +153,52 @@ function loadTodayRecords() {
|
|
|
131
153
|
const TIER_ORDER = ['search', 'execute', 'think'];
|
|
132
154
|
const TIER_LABELS = { search: 'Search ', execute: 'Execute', think: 'Think ' };
|
|
133
155
|
|
|
134
|
-
function buildActivitySection(records) {
|
|
156
|
+
function buildActivitySection(records, rateMap) {
|
|
135
157
|
// Aggregate by tier — only non-recommendation records
|
|
136
158
|
const activity = records.filter(r => r.type !== 'tier_recommendation');
|
|
137
159
|
|
|
138
160
|
const buckets = {};
|
|
139
161
|
for (const r of activity) {
|
|
140
162
|
const tier = r.tier || 'execute';
|
|
141
|
-
|
|
163
|
+
const model = r.model || 'unknown';
|
|
164
|
+
if (!buckets[tier]) buckets[tier] = { calls: 0, cost: 0, actualCount: 0 };
|
|
142
165
|
buckets[tier].calls += 1;
|
|
143
|
-
buckets[tier].
|
|
166
|
+
buckets[tier].cost += estimateCost(tier, model, rateMap, r);
|
|
144
167
|
if (r.input_tokens != null && r.output_tokens != null) buckets[tier].actualCount += 1;
|
|
145
168
|
}
|
|
146
169
|
|
|
147
|
-
const totalRaw = Object.values(buckets).reduce((s, b) => s + b.activityRaw, 0);
|
|
148
|
-
const totalScore = Math.min(100, Math.round((totalRaw / SESSION_ACTIVITY_CEILING) * 100));
|
|
149
|
-
|
|
150
170
|
const lines = [];
|
|
151
171
|
lines.push(boxLine('Activity Summary'));
|
|
152
172
|
lines.push(boxLine('─'.repeat(INNER)));
|
|
153
173
|
|
|
154
|
-
// Column widths: Tier(8) │ Calls(6) │
|
|
155
|
-
const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('
|
|
174
|
+
// Column widths: Tier(8) │ Calls(6) │ Est. Cost(10)
|
|
175
|
+
const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Est. Cost', 10);
|
|
156
176
|
const divRow = '─'.repeat(8) + '─┼─' + '─'.repeat(5) + '─┼─' + '─'.repeat(10);
|
|
157
177
|
lines.push(boxLine(header));
|
|
158
178
|
lines.push(boxLine(divRow));
|
|
159
179
|
|
|
160
180
|
let totalCalls = 0;
|
|
181
|
+
let totalCost = 0;
|
|
161
182
|
|
|
162
183
|
for (const tier of TIER_ORDER) {
|
|
163
184
|
const b = buckets[tier];
|
|
164
185
|
if (!b) continue;
|
|
165
186
|
const label = padR(TIER_LABELS[tier] || tier, 8);
|
|
166
187
|
const calls = padL(String(b.calls), 5);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
lines.push(boxLine(`${label} │ ${calls} │ ${pctStr}`));
|
|
188
|
+
const cost = padL(fmt$(b.cost), 10);
|
|
189
|
+
lines.push(boxLine(`${label} │ ${calls} │ ${cost}`));
|
|
170
190
|
totalCalls += b.calls;
|
|
191
|
+
totalCost += b.cost;
|
|
171
192
|
}
|
|
172
193
|
|
|
173
194
|
lines.push(boxLine(divRow));
|
|
174
|
-
lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(
|
|
175
|
-
lines.push(boxLine(`Activity: ${totalScore}/100 (${activityLabel(totalScore)})`));
|
|
195
|
+
lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(fmt$(totalCost), 10)));
|
|
176
196
|
|
|
177
197
|
if (totalCalls === 0) {
|
|
178
198
|
lines.push(boxLine(' (no usage data recorded today)'));
|
|
179
199
|
}
|
|
180
200
|
|
|
181
|
-
return { lines, totalCalls,
|
|
201
|
+
return { lines, totalCalls, totalCost, buckets };
|
|
182
202
|
}
|
|
183
203
|
|
|
184
204
|
// ---------------------------------------------------------------------------
|
|
@@ -247,7 +267,7 @@ function buildProviderBalanceSection(records) {
|
|
|
247
267
|
// ---------------------------------------------------------------------------
|
|
248
268
|
// Section 2: Routing Compliance
|
|
249
269
|
// ---------------------------------------------------------------------------
|
|
250
|
-
function buildComplianceSection(records) {
|
|
270
|
+
function buildComplianceSection(records, rateMap) {
|
|
251
271
|
const recs = records.filter(r => r.type === 'tier_recommendation');
|
|
252
272
|
|
|
253
273
|
const total = recs.length;
|
|
@@ -256,15 +276,24 @@ function buildComplianceSection(records) {
|
|
|
256
276
|
const followPct = total > 0 ? Math.round((followed / total) * 100) : 0;
|
|
257
277
|
const ignorePct = total > 0 ? 100 - followPct : 0;
|
|
258
278
|
|
|
259
|
-
//
|
|
260
|
-
let
|
|
279
|
+
// Overspend: for each ignored rec, diff between actual-tier cost and recommended-tier cost
|
|
280
|
+
let overspend = 0;
|
|
261
281
|
for (const r of recs) {
|
|
262
282
|
if (r.followed === true) continue;
|
|
263
283
|
if (!r.recommended_tier || !r.actual_tier) continue;
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
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;
|
|
268
297
|
}
|
|
269
298
|
|
|
270
299
|
const lines = [];
|
|
@@ -273,7 +302,7 @@ function buildComplianceSection(records) {
|
|
|
273
302
|
lines.push(boxLine(`Recommendations: ${total}`));
|
|
274
303
|
lines.push(boxLine(`Followed: ${followed} (${followPct}%)`));
|
|
275
304
|
lines.push(boxLine(`Ignored: ${ignored} (${ignorePct}%)`));
|
|
276
|
-
lines.push(boxLine(`
|
|
305
|
+
lines.push(boxLine(`Estimated overspend: ~${fmt$(overspend)}`));
|
|
277
306
|
|
|
278
307
|
return { lines };
|
|
279
308
|
}
|
|
@@ -434,6 +463,7 @@ function buildDriftSection(config) {
|
|
|
434
463
|
// ---------------------------------------------------------------------------
|
|
435
464
|
function main() {
|
|
436
465
|
const config = loadConfig();
|
|
466
|
+
const rateMap = buildRateMap(config);
|
|
437
467
|
const records = loadTodayRecords();
|
|
438
468
|
|
|
439
469
|
const output = [];
|
|
@@ -443,7 +473,7 @@ function main() {
|
|
|
443
473
|
output.push(boxDiv());
|
|
444
474
|
|
|
445
475
|
// --- Section 1: Activity Summary ---
|
|
446
|
-
const { lines: actLines } = buildActivitySection(records);
|
|
476
|
+
const { lines: actLines } = buildActivitySection(records, rateMap);
|
|
447
477
|
output.push(...actLines);
|
|
448
478
|
output.push(boxBlank());
|
|
449
479
|
|
|
@@ -453,7 +483,7 @@ function main() {
|
|
|
453
483
|
output.push(boxBlank());
|
|
454
484
|
|
|
455
485
|
// --- Section 2: Routing Compliance ---
|
|
456
|
-
const { lines: compLines } = buildComplianceSection(records);
|
|
486
|
+
const { lines: compLines } = buildComplianceSection(records, rateMap);
|
|
457
487
|
output.push(...compLines);
|
|
458
488
|
output.push(boxBlank());
|
|
459
489
|
|
|
@@ -17,11 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { execSync as _execSync } from 'child_process';
|
|
20
|
-
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
21
21
|
import { dirname, join } from 'path';
|
|
22
22
|
import { fileURLToPath } from 'url';
|
|
23
|
-
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
24
|
-
import { logHookError } from './error-channel.mjs';
|
|
25
23
|
|
|
26
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
25
|
|
|
@@ -44,9 +42,7 @@ function emptySummary() {
|
|
|
44
42
|
|
|
45
43
|
totals: {
|
|
46
44
|
calls: 0,
|
|
47
|
-
|
|
48
|
-
activity_raw: 0, // raw weighted token count before normalization
|
|
49
|
-
activity_basis: 'none', // 'actual' | 'estimated' | 'mixed'
|
|
45
|
+
cost_estimate: 0,
|
|
50
46
|
by_tier: {},
|
|
51
47
|
by_provider: {},
|
|
52
48
|
by_model: {},
|
|
@@ -82,18 +78,12 @@ function emptySummary() {
|
|
|
82
78
|
};
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
|
|
86
|
-
// These are unitless activity weights, NOT dollar costs.
|
|
87
|
-
const TIER_ACTIVITY_WEIGHTS = { search: 3, execute: 10, think: 25 };
|
|
81
|
+
const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
|
|
88
82
|
|
|
89
|
-
// Activity formula: (input_tokens * 1) + (output_tokens * 3), normalized to 0-100 per session.
|
|
90
|
-
// SESSION_ACTIVITY_CEILING is the raw token-weighted value that maps to score 100.
|
|
91
|
-
// Calibrated to ~200 moderate tool calls in a session.
|
|
92
|
-
const SESSION_ACTIVITY_CEILING = 5_000_000;
|
|
93
|
-
|
|
94
|
-
/** @deprecated Use atomicWriteJSON directly. Kept as re-export for backward compat. */
|
|
95
83
|
function atomicWrite(path, data) {
|
|
96
|
-
|
|
84
|
+
const tmp = path + '.tmp.' + process.pid;
|
|
85
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
86
|
+
renameSync(tmp, path);
|
|
97
87
|
}
|
|
98
88
|
|
|
99
89
|
function readSummary(date) {
|
|
@@ -137,27 +127,10 @@ function applyEntry(summary, entry) {
|
|
|
137
127
|
const tier = entry.tier || 'execute';
|
|
138
128
|
const provider = entry.provider || 'claude';
|
|
139
129
|
const model = entry.model || 'unknown';
|
|
140
|
-
|
|
141
|
-
// Compute activity from actual tokens when available, else use tier-based fallback
|
|
142
|
-
const hasTokens = entry.input_tokens != null && entry.output_tokens != null;
|
|
143
|
-
const rawActivity = hasTokens
|
|
144
|
-
? (entry.input_tokens * 1) + (entry.output_tokens * 3)
|
|
145
|
-
: TIER_ACTIVITY_WEIGHTS[tier] || TIER_ACTIVITY_WEIGHTS.execute;
|
|
130
|
+
const cost = COST_PER_CALL[tier] || COST_PER_CALL.execute;
|
|
146
131
|
|
|
147
132
|
summary.totals.calls++;
|
|
148
|
-
summary.totals.
|
|
149
|
-
summary.totals.activity_score = Math.min(100,
|
|
150
|
-
Math.round((summary.totals.activity_raw / SESSION_ACTIVITY_CEILING) * 100));
|
|
151
|
-
|
|
152
|
-
// Track whether scores are based on actual tokens or estimates
|
|
153
|
-
const prevBasis = summary.totals.activity_basis || 'none';
|
|
154
|
-
if (prevBasis === 'none') {
|
|
155
|
-
summary.totals.activity_basis = hasTokens ? 'actual' : 'estimated';
|
|
156
|
-
} else if (prevBasis === 'actual' && !hasTokens) {
|
|
157
|
-
summary.totals.activity_basis = 'mixed';
|
|
158
|
-
} else if (prevBasis === 'estimated' && hasTokens) {
|
|
159
|
-
summary.totals.activity_basis = 'mixed';
|
|
160
|
-
}
|
|
133
|
+
summary.totals.cost_estimate += cost;
|
|
161
134
|
|
|
162
135
|
summary.totals.by_tier[tier] = (summary.totals.by_tier[tier] || 0) + 1;
|
|
163
136
|
summary.totals.by_provider[provider] = (summary.totals.by_provider[provider] || 0) + 1;
|