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
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
|
2
|
+
import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
|
|
3
3
|
import { createHash } from 'crypto';
|
|
4
4
|
import { dirname, resolve, join } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
9
|
+
const PROFILE_FILE = resolve(__dirname, '..', 'dual-brain.profile.json');
|
|
9
10
|
const DRIFT_STATE = resolve(__dirname, '.drift-warned');
|
|
10
11
|
|
|
12
|
+
function loadProfile() {
|
|
13
|
+
try {
|
|
14
|
+
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
15
|
+
return data.active || 'balanced';
|
|
16
|
+
} catch { return 'balanced'; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PROFILE_SETTINGS = {
|
|
20
|
+
balanced: { demote_think: false, promote_execute: false, bias: 0 },
|
|
21
|
+
'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
|
|
22
|
+
'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
|
|
23
|
+
};
|
|
24
|
+
|
|
11
25
|
function checkPricingDrift(config) {
|
|
12
26
|
const verified = config.pricing_verified;
|
|
13
27
|
if (!verified) return null;
|
|
@@ -29,9 +43,12 @@ function checkPricingDrift(config) {
|
|
|
29
43
|
return `**[Drift Warning]** Pricing was last verified ${age} days ago. Run \`node .claude/hooks/setup-wizard.mjs\` to update.`;
|
|
30
44
|
}
|
|
31
45
|
|
|
46
|
+
const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
|
|
47
|
+
|
|
32
48
|
function logRecommendation(event) {
|
|
33
49
|
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
34
|
-
const
|
|
50
|
+
const profileName = event.profile || 'balanced';
|
|
51
|
+
const entryObj = {
|
|
35
52
|
timestamp: new Date().toISOString(),
|
|
36
53
|
type: 'tier_recommendation',
|
|
37
54
|
detected_tier: event.tier,
|
|
@@ -39,13 +56,64 @@ function logRecommendation(event) {
|
|
|
39
56
|
actual_model: event.actual,
|
|
40
57
|
prompt_hash: event.promptHash,
|
|
41
58
|
followed: event.followed,
|
|
42
|
-
|
|
59
|
+
session_id: SESSION_ID,
|
|
60
|
+
profile: profileName,
|
|
61
|
+
};
|
|
62
|
+
const entry = JSON.stringify(entryObj);
|
|
43
63
|
try {
|
|
44
64
|
appendFileSync(logFile, entry + '\n');
|
|
45
65
|
} catch {}
|
|
66
|
+
|
|
67
|
+
// Sync summary update (for dupe detection on next call)
|
|
68
|
+
try {
|
|
69
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
70
|
+
const summaryFile = join(__dirname, `usage-summary-${today}.json`);
|
|
71
|
+
let summary;
|
|
72
|
+
try { summary = JSON.parse(readFileSync(summaryFile, 'utf8')); } catch { summary = { version: 1, recent_hashes: [] }; }
|
|
73
|
+
if (event.promptHash) {
|
|
74
|
+
summary.recent_hashes = summary.recent_hashes || [];
|
|
75
|
+
summary.recent_hashes.push({ hash: event.promptHash, ts: entryObj.timestamp });
|
|
76
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
77
|
+
summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
|
|
78
|
+
}
|
|
79
|
+
summary.updated_at = new Date().toISOString();
|
|
80
|
+
const tmp = summaryFile + '.tmp.' + process.pid;
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(summary, null, 2) + '\n');
|
|
82
|
+
renameSync(tmp, summaryFile);
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
// Sync ledger write (append-only, fast)
|
|
86
|
+
try {
|
|
87
|
+
const ledgerEntry = JSON.stringify({
|
|
88
|
+
type: 'decision',
|
|
89
|
+
id: entryObj.timestamp.replace(/\W/g, '').slice(-12),
|
|
90
|
+
timestamp: entryObj.timestamp,
|
|
91
|
+
session_id: SESSION_ID,
|
|
92
|
+
profile: profileName,
|
|
93
|
+
tier: event.tier,
|
|
94
|
+
provider: detectProvider(event.actual),
|
|
95
|
+
model: event.actual || 'unknown',
|
|
96
|
+
recommended_model: event.recommended,
|
|
97
|
+
followed: event.followed,
|
|
98
|
+
prompt_hash: event.promptHash,
|
|
99
|
+
});
|
|
100
|
+
appendFileSync(join(__dirname, 'decision-ledger.jsonl'), ledgerEntry + '\n');
|
|
101
|
+
} catch {}
|
|
46
102
|
}
|
|
47
103
|
|
|
48
104
|
function checkDuplicate(promptHash) {
|
|
105
|
+
// Try summary checkpoint first (O(1))
|
|
106
|
+
try {
|
|
107
|
+
const summaryPath = join(__dirname, `usage-summary-${new Date().toISOString().slice(0, 10)}.json`);
|
|
108
|
+
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
109
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
110
|
+
const match = (summary.recent_hashes || []).find(
|
|
111
|
+
h => h.hash === promptHash && Date.parse(h.ts) >= tenMinAgo
|
|
112
|
+
);
|
|
113
|
+
if (match) return { timestamp: match.ts, prompt_hash: promptHash };
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
// Fallback: scan log
|
|
49
117
|
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
50
118
|
try {
|
|
51
119
|
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
@@ -73,27 +141,34 @@ function detectProvider(model) {
|
|
|
73
141
|
}
|
|
74
142
|
|
|
75
143
|
function quickPressureCheck(tier) {
|
|
144
|
+
// Try summary checkpoint first (O(1))
|
|
145
|
+
try {
|
|
146
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
147
|
+
const summaryPath = join(__dirname, `usage-summary-${today}.json`);
|
|
148
|
+
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
149
|
+
const cutoff = Date.now() - 5 * 60 * 60 * 1000;
|
|
150
|
+
const claudeTs = (summary.pressure?.claude?.[tier] || []).filter(t => Date.parse(t) >= cutoff);
|
|
151
|
+
const openaiTs = (summary.pressure?.openai?.[tier] || []).filter(t => Date.parse(t) >= cutoff);
|
|
152
|
+
return { claudeCalls: claudeTs.length, openaiCalls: openaiTs.length };
|
|
153
|
+
} catch {}
|
|
154
|
+
|
|
155
|
+
// Fallback: scan log
|
|
76
156
|
try {
|
|
77
157
|
const today = new Date().toISOString().slice(0, 10);
|
|
78
158
|
const logFile = join(__dirname, `usage-${today}.jsonl`);
|
|
79
159
|
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
80
|
-
|
|
81
160
|
const fiveHoursAgo = Date.now() - 5 * 60 * 60 * 1000;
|
|
82
161
|
let claudeCalls = 0, openaiCalls = 0;
|
|
83
|
-
|
|
84
162
|
for (const line of lines) {
|
|
85
163
|
try {
|
|
86
164
|
const entry = JSON.parse(line);
|
|
87
165
|
if (Date.parse(entry.timestamp) < fiveHoursAgo) continue;
|
|
88
166
|
if (entry.tier !== tier) continue;
|
|
89
|
-
|
|
90
|
-
const provider = entry.provider ||
|
|
91
|
-
(entry.model?.includes('gpt') ? 'openai' : 'claude');
|
|
167
|
+
const provider = entry.provider || (entry.model?.includes('gpt') ? 'openai' : 'claude');
|
|
92
168
|
if (provider === 'claude') claudeCalls++;
|
|
93
169
|
else openaiCalls++;
|
|
94
170
|
} catch {}
|
|
95
171
|
}
|
|
96
|
-
|
|
97
172
|
return { claudeCalls, openaiCalls };
|
|
98
173
|
} catch {
|
|
99
174
|
return null;
|
|
@@ -162,6 +237,10 @@ try {
|
|
|
162
237
|
return parts.join('\n\n');
|
|
163
238
|
};
|
|
164
239
|
|
|
240
|
+
// Load profile early so all log entries can reference it
|
|
241
|
+
const profileName = loadProfile();
|
|
242
|
+
const profileSettings = PROFILE_SETTINGS[profileName] || PROFILE_SETTINGS.balanced;
|
|
243
|
+
|
|
165
244
|
// Multi-tier detection — only when tier is not already resolved from subagent_defaults
|
|
166
245
|
if (!tier) {
|
|
167
246
|
const hasThink = THINK_WORDS.test(text);
|
|
@@ -186,6 +265,7 @@ try {
|
|
|
186
265
|
actual: currentModel,
|
|
187
266
|
promptHash,
|
|
188
267
|
followed: false,
|
|
268
|
+
profile: profileName,
|
|
189
269
|
});
|
|
190
270
|
process.stdout.write(JSON.stringify({ systemMessage: fullMsg }));
|
|
191
271
|
process.exit(0);
|
|
@@ -197,12 +277,21 @@ try {
|
|
|
197
277
|
else tier = 'execute';
|
|
198
278
|
}
|
|
199
279
|
|
|
280
|
+
// Apply profile-driven tier adjustments
|
|
281
|
+
if (profileSettings.demote_think && tier === 'think' && !THINK_WORDS.test(text)) {
|
|
282
|
+
tier = 'execute';
|
|
283
|
+
}
|
|
284
|
+
if (profileSettings.promote_execute && tier === 'execute' && THINK_WORDS.test(text)) {
|
|
285
|
+
tier = 'think';
|
|
286
|
+
}
|
|
287
|
+
|
|
200
288
|
// Compute balance hint now that tier is resolved
|
|
201
289
|
{
|
|
202
290
|
const currentProvider = detectProvider(currentModel);
|
|
203
291
|
if (currentProvider === 'claude') {
|
|
204
292
|
const balance = quickPressureCheck(tier);
|
|
205
|
-
|
|
293
|
+
const biasThreshold = profileSettings.bias >= 0 ? 10 : 20;
|
|
294
|
+
if (balance && balance.claudeCalls > balance.openaiCalls * 2 && balance.claudeCalls > biasThreshold) {
|
|
206
295
|
const dispatchModel = tier === 'think' ? 'gpt-5.5' : tier === 'execute' ? 'gpt-5.4' : 'gpt-4.1-mini';
|
|
207
296
|
balanceHint = `\n\n💡 **Balance tip:** Claude has ${balance.claudeCalls} ${tier} calls vs OpenAI's ${balance.openaiCalls} in the last 5hrs. Consider dispatching isolated work to GPT: \`node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model ${dispatchModel}\``;
|
|
208
297
|
}
|
|
@@ -221,6 +310,7 @@ try {
|
|
|
221
310
|
actual: currentModel,
|
|
222
311
|
promptHash,
|
|
223
312
|
followed: true,
|
|
313
|
+
profile: profileName,
|
|
224
314
|
});
|
|
225
315
|
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
226
316
|
if (onlyWarnings) {
|
|
@@ -241,6 +331,7 @@ try {
|
|
|
241
331
|
actual: currentModel,
|
|
242
332
|
promptHash,
|
|
243
333
|
followed: false,
|
|
334
|
+
profile: profileName,
|
|
244
335
|
});
|
|
245
336
|
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
246
337
|
} else {
|
|
@@ -251,6 +342,7 @@ try {
|
|
|
251
342
|
actual: currentModel,
|
|
252
343
|
promptHash,
|
|
253
344
|
followed: true,
|
|
345
|
+
profile: profileName,
|
|
254
346
|
});
|
|
255
347
|
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
256
348
|
if (onlyWarnings) {
|
|
@@ -271,6 +363,7 @@ try {
|
|
|
271
363
|
actual: currentModel,
|
|
272
364
|
promptHash,
|
|
273
365
|
followed: false,
|
|
366
|
+
profile: profileName,
|
|
274
367
|
});
|
|
275
368
|
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
276
369
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { execSync, spawnSync } from 'child_process';
|
|
21
|
-
import { appendFileSync } from 'fs';
|
|
21
|
+
import { appendFileSync, readFileSync } from 'fs';
|
|
22
22
|
import { dirname, join } from 'path';
|
|
23
23
|
import { fileURLToPath } from 'url';
|
|
24
24
|
|
|
@@ -117,10 +117,19 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
|
|
|
117
117
|
.filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
|
|
118
118
|
.map(m => m.item);
|
|
119
119
|
|
|
120
|
+
// Estimate startup time: time to first agent message or completed item
|
|
121
|
+
const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
|
|
122
|
+
let startupMs = null;
|
|
123
|
+
if (firstItemTs) {
|
|
124
|
+
startupMs = Date.parse(firstItemTs) - startTime;
|
|
125
|
+
if (startupMs < 0 || startupMs > durationMs) startupMs = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
return {
|
|
121
129
|
success: proc.status === 0 && errors.length === 0,
|
|
122
130
|
summary: agentMessages.join('\n\n'),
|
|
123
131
|
durationMs,
|
|
132
|
+
startupMs,
|
|
124
133
|
model,
|
|
125
134
|
usage: usage || null,
|
|
126
135
|
errors: errors.map(e => e.message || e.error?.message || 'unknown'),
|
|
@@ -134,10 +143,18 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
|
|
|
134
143
|
// Usage logger
|
|
135
144
|
// ---------------------------------------------------------------------------
|
|
136
145
|
|
|
146
|
+
function loadActiveProfile() {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(readFileSync(join(__dirname, '..', 'dual-brain.profile.json'), 'utf8')).active || 'balanced';
|
|
149
|
+
} catch { return 'balanced'; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
|
|
153
|
+
|
|
137
154
|
function logUsageEvent(result, task) {
|
|
138
155
|
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
139
|
-
const
|
|
140
|
-
schema_version:
|
|
156
|
+
const entryObj = {
|
|
157
|
+
schema_version: 3,
|
|
141
158
|
timestamp: new Date().toISOString(),
|
|
142
159
|
provider: 'openai',
|
|
143
160
|
tier: task.tier || 'execute',
|
|
@@ -145,14 +162,40 @@ function logUsageEvent(result, task) {
|
|
|
145
162
|
model: result.model,
|
|
146
163
|
status: result.success ? 'ok' : 'error',
|
|
147
164
|
durationMs: result.durationMs,
|
|
165
|
+
codex_startup_ms: result.startupMs || null,
|
|
166
|
+
codex_total_ms: result.durationMs,
|
|
148
167
|
input_tokens: result.usage?.input_tokens ?? null,
|
|
149
168
|
output_tokens: result.usage?.output_tokens ?? null,
|
|
150
|
-
session_id:
|
|
169
|
+
session_id: SESSION_ID,
|
|
170
|
+
profile: result.profile || 'balanced',
|
|
151
171
|
dispatcher: 'gpt-work-dispatcher',
|
|
152
|
-
}
|
|
172
|
+
};
|
|
153
173
|
try {
|
|
154
|
-
appendFileSync(logFile,
|
|
174
|
+
appendFileSync(logFile, JSON.stringify(entryObj) + '\n');
|
|
155
175
|
} catch {}
|
|
176
|
+
|
|
177
|
+
// Update summary checkpoint with codex latency
|
|
178
|
+
import('./summary-checkpoint.mjs').then(({ updateSummary }) => {
|
|
179
|
+
updateSummary(entryObj);
|
|
180
|
+
}).catch(() => {});
|
|
181
|
+
|
|
182
|
+
// Record to decision ledger
|
|
183
|
+
import('./decision-ledger.mjs').then(({ recordDecision, recordOutcome }) => {
|
|
184
|
+
const id = recordDecision({
|
|
185
|
+
session_id: SESSION_ID,
|
|
186
|
+
profile: entryObj.profile,
|
|
187
|
+
tier: task.tier || 'execute',
|
|
188
|
+
provider: 'openai',
|
|
189
|
+
model: result.model,
|
|
190
|
+
});
|
|
191
|
+
recordOutcome(id, {
|
|
192
|
+
actual_duration_ms: result.durationMs,
|
|
193
|
+
codex_startup_ms: result.startupMs || null,
|
|
194
|
+
success: result.success,
|
|
195
|
+
actual_input_tokens: result.usage?.input_tokens || null,
|
|
196
|
+
actual_output_tokens: result.usage?.output_tokens || null,
|
|
197
|
+
});
|
|
198
|
+
}).catch(() => {});
|
|
156
199
|
}
|
|
157
200
|
|
|
158
201
|
// ---------------------------------------------------------------------------
|
|
@@ -171,6 +214,7 @@ export async function dispatchGptTask(task) {
|
|
|
171
214
|
const model = task.model || 'gpt-5.4';
|
|
172
215
|
const prompt = buildPrompt(task);
|
|
173
216
|
const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs);
|
|
217
|
+
result.profile = loadActiveProfile();
|
|
174
218
|
logUsageEvent(result, task);
|
|
175
219
|
return result;
|
|
176
220
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* profiles.mjs — Profile system for the Dual-Brain Orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Profiles configure routing posture, budget limits, and quality gate behavior.
|
|
6
|
+
* Active profile persists to .claude/dual-brain.profile.json.
|
|
7
|
+
*
|
|
8
|
+
* Exported API:
|
|
9
|
+
* PROFILES → built-in profile definitions
|
|
10
|
+
* getActiveProfile() → current profile name + merged settings
|
|
11
|
+
* setActiveProfile(name) → switch profile, returns success/error
|
|
12
|
+
* getProfileOverrides(key) → profile-driven overrides for a specific system
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
16
|
+
import { dirname, join } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
|
|
21
|
+
const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
|
|
22
|
+
|
|
23
|
+
const PROFILES = {
|
|
24
|
+
balanced: {
|
|
25
|
+
description: 'Standard routing — best model for each tier, normal budgets',
|
|
26
|
+
routing: {
|
|
27
|
+
prefer_provider: 'auto',
|
|
28
|
+
think_threshold: 'normal',
|
|
29
|
+
gpt_dispatch_bias: 0,
|
|
30
|
+
},
|
|
31
|
+
budgets: {
|
|
32
|
+
session_warn_usd: 5.00,
|
|
33
|
+
session_limit_usd: 10.00,
|
|
34
|
+
daily_warn_usd: 20.00,
|
|
35
|
+
daily_limit_usd: 50.00,
|
|
36
|
+
},
|
|
37
|
+
quality_gate: {
|
|
38
|
+
sensitivity_floor: 'medium',
|
|
39
|
+
dual_brain_minimum: 'high',
|
|
40
|
+
},
|
|
41
|
+
tier_overrides: null,
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
'cost-saver': {
|
|
45
|
+
description: 'Minimize spend — prefer cheaper models, skip GPT for low risk',
|
|
46
|
+
routing: {
|
|
47
|
+
prefer_provider: 'cheapest',
|
|
48
|
+
think_threshold: 'strict',
|
|
49
|
+
gpt_dispatch_bias: -20,
|
|
50
|
+
},
|
|
51
|
+
budgets: {
|
|
52
|
+
session_warn_usd: 2.00,
|
|
53
|
+
session_limit_usd: 5.00,
|
|
54
|
+
daily_warn_usd: 8.00,
|
|
55
|
+
daily_limit_usd: 20.00,
|
|
56
|
+
},
|
|
57
|
+
quality_gate: {
|
|
58
|
+
sensitivity_floor: 'high',
|
|
59
|
+
dual_brain_minimum: 'critical',
|
|
60
|
+
},
|
|
61
|
+
tier_overrides: {
|
|
62
|
+
promote_execute_to_think: false,
|
|
63
|
+
demote_think_to_execute: true,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
'quality-first': {
|
|
68
|
+
description: 'Maximum quality — dual-brain for medium+, stricter reviews',
|
|
69
|
+
routing: {
|
|
70
|
+
prefer_provider: 'most-capable',
|
|
71
|
+
think_threshold: 'relaxed',
|
|
72
|
+
gpt_dispatch_bias: 10,
|
|
73
|
+
},
|
|
74
|
+
budgets: {
|
|
75
|
+
session_warn_usd: 15.00,
|
|
76
|
+
session_limit_usd: 30.00,
|
|
77
|
+
daily_warn_usd: 50.00,
|
|
78
|
+
daily_limit_usd: 100.00,
|
|
79
|
+
},
|
|
80
|
+
quality_gate: {
|
|
81
|
+
sensitivity_floor: 'low',
|
|
82
|
+
dual_brain_minimum: 'medium',
|
|
83
|
+
},
|
|
84
|
+
tier_overrides: {
|
|
85
|
+
promote_execute_to_think: true,
|
|
86
|
+
demote_think_to_execute: false,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function loadProfileFile() {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function loadConfig() {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getActiveProfile() {
|
|
108
|
+
const saved = loadProfileFile();
|
|
109
|
+
const name = saved?.active || 'balanced';
|
|
110
|
+
const profile = PROFILES[name] || PROFILES.balanced;
|
|
111
|
+
const customOverrides = saved?.custom_overrides || {};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
name: PROFILES[name] ? name : 'balanced',
|
|
115
|
+
...profile,
|
|
116
|
+
budgets: { ...profile.budgets, ...customOverrides.budgets },
|
|
117
|
+
routing: { ...profile.routing, ...customOverrides.routing },
|
|
118
|
+
switched_at: saved?.switched_at || null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setActiveProfile(name, customOverrides = null) {
|
|
123
|
+
if (!PROFILES[name]) {
|
|
124
|
+
return { ok: false, error: `Unknown profile: ${name}. Available: ${Object.keys(PROFILES).join(', ')}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const data = {
|
|
128
|
+
active: name,
|
|
129
|
+
switched_at: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
if (customOverrides) data.custom_overrides = customOverrides;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const tmp = PROFILE_FILE + '.tmp.' + process.pid;
|
|
135
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
136
|
+
renameSync(tmp, PROFILE_FILE);
|
|
137
|
+
return { ok: true, profile: PROFILES[name] };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, error: `Failed to write profile: ${err.message}` };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setBudgetOverrides(sessionLimit, dailyLimit) {
|
|
144
|
+
const saved = loadProfileFile() || { active: 'balanced' };
|
|
145
|
+
saved.custom_overrides = saved.custom_overrides || {};
|
|
146
|
+
saved.custom_overrides.budgets = {};
|
|
147
|
+
|
|
148
|
+
if (sessionLimit != null) {
|
|
149
|
+
saved.custom_overrides.budgets.session_warn_usd = sessionLimit * 0.6;
|
|
150
|
+
saved.custom_overrides.budgets.session_limit_usd = sessionLimit;
|
|
151
|
+
}
|
|
152
|
+
if (dailyLimit != null) {
|
|
153
|
+
saved.custom_overrides.budgets.daily_warn_usd = dailyLimit * 0.6;
|
|
154
|
+
saved.custom_overrides.budgets.daily_limit_usd = dailyLimit;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
saved.switched_at = saved.switched_at || new Date().toISOString();
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const tmp = PROFILE_FILE + '.tmp.' + process.pid;
|
|
161
|
+
writeFileSync(tmp, JSON.stringify(saved, null, 2) + '\n');
|
|
162
|
+
renameSync(tmp, PROFILE_FILE);
|
|
163
|
+
return { ok: true };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { ok: false, error: err.message };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getProfileOverrides(system) {
|
|
170
|
+
const profile = getActiveProfile();
|
|
171
|
+
|
|
172
|
+
switch (system) {
|
|
173
|
+
case 'enforce-tier':
|
|
174
|
+
return {
|
|
175
|
+
think_threshold: profile.routing.think_threshold,
|
|
176
|
+
tier_overrides: profile.tier_overrides,
|
|
177
|
+
gpt_dispatch_bias: profile.routing.gpt_dispatch_bias,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
case 'budget-balancer':
|
|
181
|
+
return {
|
|
182
|
+
budgets: profile.budgets,
|
|
183
|
+
prefer_provider: profile.routing.prefer_provider,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
case 'quality-gate':
|
|
187
|
+
return {
|
|
188
|
+
sensitivity_floor: profile.quality_gate.sensitivity_floor,
|
|
189
|
+
dual_brain_minimum: profile.quality_gate.dual_brain_minimum,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
default:
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
PROFILES,
|
|
199
|
+
getActiveProfile,
|
|
200
|
+
setActiveProfile,
|
|
201
|
+
setBudgetOverrides,
|
|
202
|
+
getProfileOverrides,
|
|
203
|
+
};
|
package/hooks/quality-gate.mjs
CHANGED
|
@@ -23,9 +23,31 @@ import { fileURLToPath } from 'url';
|
|
|
23
23
|
|
|
24
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const ORCHESTRATOR_CONFIG = resolve(__dirname, '..', 'orchestrator.json');
|
|
26
|
+
const PROFILE_FILE = resolve(__dirname, '..', 'dual-brain.profile.json');
|
|
26
27
|
const REVIEWS_DIR = resolve(__dirname, '..', 'reviews');
|
|
27
28
|
const DUAL_BRAIN = resolve(__dirname, 'dual-brain-review.mjs');
|
|
28
29
|
|
|
30
|
+
const RISK_LEVELS = ['low', 'medium', 'high', 'critical'];
|
|
31
|
+
|
|
32
|
+
function loadProfileGateSettings() {
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
35
|
+
const name = data.active || 'balanced';
|
|
36
|
+
const defaults = {
|
|
37
|
+
balanced: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
|
|
38
|
+
'cost-saver': { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
|
|
39
|
+
'quality-first': { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
|
|
40
|
+
};
|
|
41
|
+
return defaults[name] || defaults.balanced;
|
|
42
|
+
} catch {
|
|
43
|
+
return { sensitivity_floor: 'medium', dual_brain_minimum: 'high' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function riskMeetsFloor(risk, floor) {
|
|
48
|
+
return RISK_LEVELS.indexOf(risk) >= RISK_LEVELS.indexOf(floor);
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
function exit(obj) {
|
|
30
52
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
31
53
|
process.exit(0);
|
|
@@ -162,14 +184,16 @@ function main() {
|
|
|
162
184
|
// 5a. Score sensitivity BEFORE running any external review
|
|
163
185
|
const sensitivity = scoreSensitivity(qualifyingFiles, config);
|
|
164
186
|
|
|
165
|
-
// 5b.
|
|
166
|
-
|
|
187
|
+
// 5b. Apply profile-driven sensitivity floor
|
|
188
|
+
const profileGate = loadProfileGateSettings();
|
|
189
|
+
if (!riskMeetsFloor(sensitivity.risk, profileGate.sensitivity_floor)) {
|
|
167
190
|
exit({
|
|
168
191
|
gate: 'pass',
|
|
169
|
-
risk:
|
|
192
|
+
risk: sensitivity.risk,
|
|
170
193
|
sensitivity_score: sensitivity.score,
|
|
171
194
|
sensitivity_reasons: sensitivity.reasons,
|
|
172
|
-
reason:
|
|
195
|
+
reason: `${sensitivity.risk} risk — below profile floor (${profileGate.sensitivity_floor})`,
|
|
196
|
+
profile_floor: profileGate.sensitivity_floor,
|
|
173
197
|
files: qualifyingFiles,
|
|
174
198
|
});
|
|
175
199
|
}
|
|
@@ -232,14 +256,18 @@ function main() {
|
|
|
232
256
|
reviewResult.error === true ||
|
|
233
257
|
!reviewResult.review;
|
|
234
258
|
|
|
259
|
+
// Profile can lower the dual-brain threshold
|
|
260
|
+
const needsDualBrain = riskMeetsFloor(sensitivity.risk, profileGate.dual_brain_minimum);
|
|
261
|
+
|
|
235
262
|
let gateStatus;
|
|
236
|
-
if (sensitivity.gate === 'dual-brain-required') {
|
|
237
|
-
// Critical: always flag for dual-brain + user attention regardless of review outcome
|
|
263
|
+
if (sensitivity.gate === 'dual-brain-required' || (needsDualBrain && sensitivity.risk === 'critical')) {
|
|
238
264
|
gateStatus = 'needs_dual_think';
|
|
239
265
|
} else if (reviewUnavailable) {
|
|
240
266
|
gateStatus = 'needs_human_review';
|
|
241
267
|
} else if (reviewResult.issues_found) {
|
|
242
268
|
gateStatus = 'issues_found';
|
|
269
|
+
} else if (needsDualBrain) {
|
|
270
|
+
gateStatus = 'reviewed';
|
|
243
271
|
} else {
|
|
244
272
|
gateStatus = sensitivity.gate === 'dual-brain-recommended' ? 'reviewed' : 'pass';
|
|
245
273
|
}
|