dual-brain 4.2.0 → 4.5.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 +130 -35
- package/README.md +171 -44
- package/hooks/agent-chains.mjs +369 -0
- package/hooks/agent-templates.mjs +441 -0
- package/hooks/atomic-write.mjs +5 -3
- package/hooks/config-validator.mjs +156 -0
- package/hooks/confirmation-policy.mjs +167 -0
- package/hooks/cost-logger.mjs +32 -12
- package/hooks/cost-report.mjs +60 -114
- package/hooks/decision-ledger.mjs +3 -2
- package/hooks/dual-brain-review.mjs +249 -2
- package/hooks/dual-brain-think.mjs +294 -25
- package/hooks/enforce-tier.mjs +246 -87
- package/hooks/error-channel.mjs +68 -0
- package/hooks/failure-detector.mjs +2 -1
- package/hooks/health-check.mjs +16 -17
- package/hooks/risk-classifier.mjs +135 -2
- package/hooks/session-report.mjs +41 -71
- package/hooks/ship-captain.mjs +1176 -0
- package/hooks/ship-gate.mjs +971 -0
- package/hooks/summary-checkpoint.mjs +31 -4
- package/hooks/test-orchestrator.mjs +1975 -11
- package/install.mjs +1064 -31
- package/orchestrator.json +73 -96
- package/package.json +7 -2
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -2,16 +2,50 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
|
3
3
|
import { dirname, resolve, join } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
5
|
+
import { classifyRisk, classifyRiskEnhanced, extractPaths } from './risk-classifier.mjs';
|
|
6
6
|
import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
|
|
7
7
|
import { getOutcomeStats } from './decision-ledger.mjs';
|
|
8
8
|
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
9
|
+
import { logHookError } from './error-channel.mjs';
|
|
10
|
+
import { loadAndValidateConfig } from './config-validator.mjs';
|
|
9
11
|
|
|
10
12
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
13
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
12
14
|
const PROFILE_FILE = resolve(__dirname, '..', 'dual-brain.profile.json');
|
|
13
15
|
const DRIFT_STATE = resolve(__dirname, '.drift-warned');
|
|
14
16
|
const BURST_FILE = resolve(__dirname, '.burst-state');
|
|
17
|
+
const COOLDOWN_FILE = resolve(__dirname, '.recommendation-cooldowns');
|
|
18
|
+
|
|
19
|
+
// Cooldown durations per recommendation type (in milliseconds)
|
|
20
|
+
const COOLDOWN_MS = {
|
|
21
|
+
balance_hint: 15 * 60 * 1000, // 15 minutes — most wallpaper-prone
|
|
22
|
+
tier_warning: 5 * 60 * 1000, // 5 minutes
|
|
23
|
+
outcome_advisory: 5 * 60 * 1000, // 5 minutes
|
|
24
|
+
duplicate_warning: 5 * 60 * 1000, // 5 minutes
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a recommendation type is on cooldown.
|
|
29
|
+
* Returns true if the recommendation should be suppressed.
|
|
30
|
+
* If not on cooldown, records the emission and returns false.
|
|
31
|
+
*/
|
|
32
|
+
function isOnCooldown(type) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
let state = {};
|
|
35
|
+
try { state = JSON.parse(readFileSync(COOLDOWN_FILE, 'utf8')); } catch {}
|
|
36
|
+
|
|
37
|
+
const lastEmit = state[type] ? Date.parse(state[type]) : 0;
|
|
38
|
+
const cooldownDuration = COOLDOWN_MS[type] || 5 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
if (now - lastEmit < cooldownDuration) {
|
|
41
|
+
return true; // suppress
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Record this emission
|
|
45
|
+
state[type] = new Date(now).toISOString();
|
|
46
|
+
try { atomicWriteJSON(COOLDOWN_FILE, state); } catch (e) { logHookError('enforce-tier', 'cooldown state write', e); }
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
15
49
|
|
|
16
50
|
function detectBurst() {
|
|
17
51
|
const now = Date.now();
|
|
@@ -19,7 +53,7 @@ function detectBurst() {
|
|
|
19
53
|
try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
|
|
20
54
|
if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
|
|
21
55
|
state.count++;
|
|
22
|
-
try { atomicWriteJSON(BURST_FILE, state); } catch {}
|
|
56
|
+
try { atomicWriteJSON(BURST_FILE, state); } catch (e) { logHookError('enforce-tier', 'burst state write', e); }
|
|
23
57
|
return state.count >= 3;
|
|
24
58
|
}
|
|
25
59
|
|
|
@@ -31,12 +65,110 @@ function loadProfile() {
|
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
const PROFILE_SETTINGS = {
|
|
34
|
-
auto: { demote_think: false, promote_execute: false, bias: 0 },
|
|
35
|
-
balanced: { demote_think: false, promote_execute: false, bias: 0 },
|
|
36
|
-
'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
|
|
37
|
-
'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
|
|
68
|
+
auto: { demote_think: false, promote_execute: false, bias: 0, mismatch_tolerance: 'strict' },
|
|
69
|
+
balanced: { demote_think: false, promote_execute: false, bias: 0, mismatch_tolerance: 'strict' },
|
|
70
|
+
'cost-saver': { demote_think: true, promote_execute: false, bias: -20, mismatch_tolerance: 'lenient' },
|
|
71
|
+
'quality-first': { demote_think: false, promote_execute: true, bias: 10, mismatch_tolerance: 'paranoid' },
|
|
38
72
|
};
|
|
39
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Classify how severe a model/tier mismatch is.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} detectedTier - 'search' | 'execute' | 'think'
|
|
78
|
+
* @param {string} actualModel - lowercase model string from tool_input.model
|
|
79
|
+
* @returns {{ severity: 'none'|'minor'|'major', reason: string, suggestedModel: string }}
|
|
80
|
+
*/
|
|
81
|
+
function classifyMismatchSeverity(detectedTier, actualModel) {
|
|
82
|
+
const model = (actualModel || '').toLowerCase();
|
|
83
|
+
|
|
84
|
+
// Canonical tier membership helpers
|
|
85
|
+
const isSearchModel = model.includes('haiku') || model.includes('gpt-4.1-mini') || model.includes('4.1-mini');
|
|
86
|
+
const isExecuteModel = model.includes('sonnet') || model.includes('gpt-5.4') || model.includes('5.4');
|
|
87
|
+
const isThinkModel = model.includes('opus') || model.includes('gpt-5.5') || model.includes('5.5') ||
|
|
88
|
+
model.includes('o1') || model.includes('o3') || model.includes('o4');
|
|
89
|
+
|
|
90
|
+
const suggestedByTier = {
|
|
91
|
+
search: 'haiku (Claude) or gpt-4.1-mini (OpenAI)',
|
|
92
|
+
execute: 'sonnet (Claude) or gpt-5.4 (OpenAI)',
|
|
93
|
+
think: 'opus (Claude) or gpt-5.5 (OpenAI)',
|
|
94
|
+
};
|
|
95
|
+
const suggested = suggestedByTier[detectedTier] || 'sonnet';
|
|
96
|
+
|
|
97
|
+
// Empty/unset model — no mismatch to classify
|
|
98
|
+
if (!model || model === 'main-session') {
|
|
99
|
+
return { severity: 'none', reason: 'model not specified', suggestedModel: suggested };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (detectedTier === 'think') {
|
|
103
|
+
if (isThinkModel) return { severity: 'none', reason: 'model matches think tier', suggestedModel: suggested };
|
|
104
|
+
if (isExecuteModel) return {
|
|
105
|
+
severity: 'minor',
|
|
106
|
+
reason: `${model} is capable but not optimal for think-tier work (architecture/review/planning)`,
|
|
107
|
+
suggestedModel: suggested,
|
|
108
|
+
};
|
|
109
|
+
// search-class model (haiku, gpt-4.1-mini) on think work → MAJOR
|
|
110
|
+
return {
|
|
111
|
+
severity: 'major',
|
|
112
|
+
reason: `${model} is too weak for think-tier tasks (architecture decisions, security review, complex planning)`,
|
|
113
|
+
suggestedModel: suggested,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (detectedTier === 'execute') {
|
|
118
|
+
if (isExecuteModel) return { severity: 'none', reason: 'model matches execute tier', suggestedModel: suggested };
|
|
119
|
+
if (isThinkModel) return {
|
|
120
|
+
severity: 'minor',
|
|
121
|
+
reason: `${model} is overkill for execute-tier work — wastes budget`,
|
|
122
|
+
suggestedModel: suggested,
|
|
123
|
+
};
|
|
124
|
+
if (isSearchModel) return {
|
|
125
|
+
severity: 'minor',
|
|
126
|
+
reason: `${model} may lack capability for complex execution tasks`,
|
|
127
|
+
suggestedModel: suggested,
|
|
128
|
+
};
|
|
129
|
+
return { severity: 'none', reason: 'model tier unclear', suggestedModel: suggested };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (detectedTier === 'search') {
|
|
133
|
+
if (isSearchModel) return { severity: 'none', reason: 'model matches search tier', suggestedModel: suggested };
|
|
134
|
+
if (isExecuteModel) return {
|
|
135
|
+
severity: 'minor',
|
|
136
|
+
reason: `${model} is more capable than needed for search/explore tasks — consider haiku for cost savings`,
|
|
137
|
+
suggestedModel: suggested,
|
|
138
|
+
};
|
|
139
|
+
if (isThinkModel) return {
|
|
140
|
+
severity: 'major',
|
|
141
|
+
reason: `${model} is massive overkill for search/grep/explore tasks — burns budget unnecessarily`,
|
|
142
|
+
suggestedModel: suggested,
|
|
143
|
+
};
|
|
144
|
+
return { severity: 'none', reason: 'model tier unclear', suggestedModel: suggested };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { severity: 'none', reason: 'unknown tier', suggestedModel: suggested };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Given a mismatch severity and the active profile tolerance, decide whether
|
|
152
|
+
* to block, warn, or allow the call.
|
|
153
|
+
*
|
|
154
|
+
* @param {'none'|'minor'|'major'} severity
|
|
155
|
+
* @param {'lenient'|'strict'|'paranoid'} tolerance
|
|
156
|
+
* @returns {'block'|'warn'|'allow'}
|
|
157
|
+
*/
|
|
158
|
+
function decideAction(severity, tolerance) {
|
|
159
|
+
if (severity === 'none') return 'allow';
|
|
160
|
+
if (tolerance === 'lenient') {
|
|
161
|
+
// cost-saver: warn on major, ignore minor
|
|
162
|
+
return severity === 'major' ? 'warn' : 'allow';
|
|
163
|
+
}
|
|
164
|
+
if (tolerance === 'paranoid') {
|
|
165
|
+
// quality-first: block on both minor and major
|
|
166
|
+
return 'block';
|
|
167
|
+
}
|
|
168
|
+
// strict (auto, balanced): block on major, warn on minor
|
|
169
|
+
return severity === 'major' ? 'block' : 'warn';
|
|
170
|
+
}
|
|
171
|
+
|
|
40
172
|
function checkPricingDrift(config) {
|
|
41
173
|
const verified = config.pricing_verified;
|
|
42
174
|
if (!verified) return null;
|
|
@@ -53,7 +185,7 @@ function checkPricingDrift(config) {
|
|
|
53
185
|
|
|
54
186
|
try {
|
|
55
187
|
writeFileSync(DRIFT_STATE, new Date().toISOString().slice(0, 10));
|
|
56
|
-
} catch {}
|
|
188
|
+
} catch (e) { logHookError('enforce-tier', 'drift state write', e); }
|
|
57
189
|
|
|
58
190
|
return `**[Drift Warning]** Pricing was last verified ${age} days ago. Run \`node .claude/hooks/setup-wizard.mjs\` to update.`;
|
|
59
191
|
}
|
|
@@ -88,12 +220,12 @@ function logRecommendation(event) {
|
|
|
88
220
|
if (event.promptHash) {
|
|
89
221
|
summary.recent_hashes = summary.recent_hashes || [];
|
|
90
222
|
summary.recent_hashes.push({ hash: event.promptHash, ts: entryObj.timestamp });
|
|
91
|
-
const
|
|
92
|
-
summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >=
|
|
223
|
+
const threeMinAgo = Date.now() - 3 * 60 * 1000;
|
|
224
|
+
summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= threeMinAgo);
|
|
93
225
|
}
|
|
94
226
|
summary.updated_at = new Date().toISOString();
|
|
95
227
|
atomicWriteJSON(summaryFile, summary);
|
|
96
|
-
} catch {}
|
|
228
|
+
} catch (e) { logHookError('enforce-tier', 'summary update', e); }
|
|
97
229
|
|
|
98
230
|
// Sync ledger write (append-only, fast)
|
|
99
231
|
try {
|
|
@@ -111,7 +243,7 @@ function logRecommendation(event) {
|
|
|
111
243
|
prompt_hash: event.promptHash,
|
|
112
244
|
});
|
|
113
245
|
appendFileSync(join(__dirname, 'decision-ledger.jsonl'), ledgerEntry + '\n');
|
|
114
|
-
} catch {}
|
|
246
|
+
} catch (e) { logHookError('enforce-tier', 'decision ledger append', e); }
|
|
115
247
|
}
|
|
116
248
|
|
|
117
249
|
function checkDuplicate(promptHash) {
|
|
@@ -119,9 +251,9 @@ function checkDuplicate(promptHash) {
|
|
|
119
251
|
try {
|
|
120
252
|
const summaryPath = join(__dirname, `usage-summary-${new Date().toISOString().slice(0, 10)}.json`);
|
|
121
253
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
122
|
-
const
|
|
254
|
+
const threeMinAgo = Date.now() - 3 * 60 * 1000;
|
|
123
255
|
const match = (summary.recent_hashes || []).find(
|
|
124
|
-
h => h.hash === promptHash && Date.parse(h.ts) >=
|
|
256
|
+
h => h.hash === promptHash && Date.parse(h.ts) >= threeMinAgo
|
|
125
257
|
);
|
|
126
258
|
if (match) return { timestamp: match.ts, prompt_hash: promptHash };
|
|
127
259
|
} catch {}
|
|
@@ -130,13 +262,13 @@ function checkDuplicate(promptHash) {
|
|
|
130
262
|
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
131
263
|
try {
|
|
132
264
|
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
133
|
-
const
|
|
265
|
+
const threeMinAgo = Date.now() - 3 * 60 * 1000;
|
|
134
266
|
for (const line of lines) {
|
|
135
267
|
try {
|
|
136
268
|
const entry = JSON.parse(line);
|
|
137
269
|
if (entry.type === 'tier_recommendation' &&
|
|
138
270
|
entry.prompt_hash === promptHash &&
|
|
139
|
-
Date.parse(entry.timestamp) >
|
|
271
|
+
Date.parse(entry.timestamp) > threeMinAgo) {
|
|
140
272
|
return entry;
|
|
141
273
|
}
|
|
142
274
|
} catch {}
|
|
@@ -221,7 +353,7 @@ try {
|
|
|
221
353
|
// Check for duplicate agent dispatch before tier classification
|
|
222
354
|
const duplicate = checkDuplicate(promptHash);
|
|
223
355
|
let duplicateWarning = null;
|
|
224
|
-
if (duplicate) {
|
|
356
|
+
if (duplicate && !isOnCooldown('duplicate_warning')) {
|
|
225
357
|
const minutesAgo = Math.round((Date.now() - Date.parse(duplicate.timestamp)) / 60000);
|
|
226
358
|
if (burstMode) {
|
|
227
359
|
// In burst mode, only warn on exact hash matches (same description+prompt)
|
|
@@ -236,7 +368,8 @@ try {
|
|
|
236
368
|
|
|
237
369
|
let config;
|
|
238
370
|
try {
|
|
239
|
-
|
|
371
|
+
const result = loadAndValidateConfig(CONFIG_FILE);
|
|
372
|
+
config = result.config;
|
|
240
373
|
} catch {
|
|
241
374
|
process.stdout.write('{}');
|
|
242
375
|
process.exit(0);
|
|
@@ -244,7 +377,13 @@ try {
|
|
|
244
377
|
|
|
245
378
|
const driftWarning = checkPricingDrift(config);
|
|
246
379
|
|
|
247
|
-
|
|
380
|
+
// Build flat model intelligence lookup from subscriptions (merged in v4.2.0)
|
|
381
|
+
const intelligence = {};
|
|
382
|
+
for (const provider of Object.values(config.subscriptions || {})) {
|
|
383
|
+
for (const [name, meta] of Object.entries(provider.models || {})) {
|
|
384
|
+
intelligence[name] = meta;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
248
387
|
const defaults = config.routing_rules?.subagent_defaults || {};
|
|
249
388
|
let tier = null;
|
|
250
389
|
|
|
@@ -304,16 +443,42 @@ try {
|
|
|
304
443
|
}
|
|
305
444
|
|
|
306
445
|
// Risk classification from file paths in description
|
|
446
|
+
// Use enhanced classifier (git churn + history) when a single file path is available,
|
|
447
|
+
// fall back to static classifier for multi-path or missing cases.
|
|
307
448
|
const filePaths = extractPaths(ti.description || '');
|
|
308
|
-
|
|
449
|
+
let riskResult;
|
|
450
|
+
let riskEscalationReason = null;
|
|
451
|
+
|
|
452
|
+
if (filePaths.length === 1) {
|
|
453
|
+
try {
|
|
454
|
+
const enhanced = classifyRiskEnhanced(filePaths[0]);
|
|
455
|
+
riskResult = { level: enhanced.risk, reason: filePaths[0] };
|
|
456
|
+
// Build human-readable escalation reason if empirical data bumped the risk
|
|
457
|
+
if (enhanced.basis === 'churn' || enhanced.basis === 'churn+history') {
|
|
458
|
+
riskEscalationReason = `Risk escalated: high git churn (${enhanced.details.churn_commits} commits in 30 days)`;
|
|
459
|
+
}
|
|
460
|
+
if (enhanced.basis === 'history' || enhanced.basis === 'churn+history') {
|
|
461
|
+
const historyNote = `${enhanced.details.history_success_rate}% failure rate on this file path`;
|
|
462
|
+
riskEscalationReason = riskEscalationReason
|
|
463
|
+
? `${riskEscalationReason}; ${historyNote}`
|
|
464
|
+
: `Risk escalated: ${historyNote}`;
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
riskResult = classifyRisk(filePaths);
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
riskResult = classifyRisk(filePaths);
|
|
471
|
+
}
|
|
472
|
+
|
|
309
473
|
let autoStatus = null;
|
|
310
474
|
|
|
311
475
|
// Bias high/critical risk toward think tier
|
|
312
476
|
if ((riskResult.level === 'critical' || riskResult.level === 'high') && tier !== 'think') {
|
|
313
477
|
tier = 'think';
|
|
314
|
-
|
|
315
|
-
? `This touches ${riskResult.reason.split(':')[0].toLowerCase()} — recommending dual-brain review for safety.`
|
|
316
|
-
: `Promoting to think tier — this is ${riskResult.reason.split(':')[0].toLowerCase()}.`;
|
|
478
|
+
const baseMsg = riskResult.level === 'critical'
|
|
479
|
+
? `This touches ${String(riskResult.reason).split(':')[0].toLowerCase()} — recommending dual-brain review for safety.`
|
|
480
|
+
: `Promoting to think tier — this is ${String(riskResult.reason).split(':')[0].toLowerCase()}.`;
|
|
481
|
+
autoStatus = riskEscalationReason ? `${baseMsg} ${riskEscalationReason}.` : baseMsg;
|
|
317
482
|
}
|
|
318
483
|
|
|
319
484
|
// Failure loop detection
|
|
@@ -339,7 +504,8 @@ try {
|
|
|
339
504
|
|
|
340
505
|
// Compute balance hint now that tier is resolved
|
|
341
506
|
// In burst mode, skip balance hints — one hint per wave is enough
|
|
342
|
-
|
|
507
|
+
// Balance hints have a 15-minute cooldown (most wallpaper-prone)
|
|
508
|
+
if (!burstMode && !isOnCooldown('balance_hint')) {
|
|
343
509
|
const currentProvider = detectProvider(currentModel);
|
|
344
510
|
if (currentProvider === 'claude') {
|
|
345
511
|
const balance = quickPressureCheck(tier);
|
|
@@ -351,8 +517,8 @@ try {
|
|
|
351
517
|
}
|
|
352
518
|
}
|
|
353
519
|
|
|
354
|
-
// Outcome stats advisory — best-effort, suppressed in burst mode
|
|
355
|
-
if (!burstMode) {
|
|
520
|
+
// Outcome stats advisory — best-effort, suppressed in burst mode and on cooldown
|
|
521
|
+
if (!burstMode && !isOnCooldown('outcome_advisory')) {
|
|
356
522
|
try {
|
|
357
523
|
const stats = getOutcomeStats();
|
|
358
524
|
const tierIssue = stats.underperforming.find(u => u.tier === tier);
|
|
@@ -364,71 +530,64 @@ try {
|
|
|
364
530
|
|
|
365
531
|
const expected = preferredModel(config, tier);
|
|
366
532
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
533
|
+
// ── Mismatch severity classification (v4.5.0) ──────────────────────────────
|
|
534
|
+
const mismatch = classifyMismatchSeverity(tier, currentModel);
|
|
535
|
+
const tolerance = profileSettings.mismatch_tolerance || 'strict';
|
|
536
|
+
const action = decideAction(mismatch.severity, tolerance);
|
|
537
|
+
|
|
538
|
+
const followed = action === 'allow';
|
|
539
|
+
logRecommendation({
|
|
540
|
+
tier,
|
|
541
|
+
recommended: expected,
|
|
542
|
+
actual: currentModel,
|
|
543
|
+
promptHash,
|
|
544
|
+
followed,
|
|
545
|
+
profile: profileName,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (action === 'allow') {
|
|
549
|
+
// Model is fine — emit only ambient warnings (duplicate, drift, failure, balance, outcome)
|
|
550
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
|
|
551
|
+
if (onlyWarnings) {
|
|
552
|
+
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
553
|
+
} else {
|
|
554
|
+
process.stdout.write('{}');
|
|
386
555
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
556
|
+
process.exit(0);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// On cooldown — emit only ambient warnings (don't repeat tier advice)
|
|
560
|
+
if (isOnCooldown('tier_warning')) {
|
|
561
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
|
|
562
|
+
process.stdout.write(JSON.stringify(onlyWarnings ? { systemMessage: onlyWarnings } : {}));
|
|
563
|
+
process.exit(0);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Build the tier advice message, calibrated to severity
|
|
567
|
+
const bestFor = intelligence[expected]?.best_for;
|
|
568
|
+
const bestForSuffix = bestFor ? ` (best for: ${bestFor})` : '';
|
|
569
|
+
|
|
570
|
+
let tierMsg;
|
|
571
|
+
if (action === 'block') {
|
|
572
|
+
// Strongest available signal — Claude Code PreToolUse hooks use systemMessage
|
|
573
|
+
// (no formal "block" key in the hook contract), so we make the message impossible to ignore.
|
|
574
|
+
tierMsg =
|
|
575
|
+
`⛔ BLOCKED: ${mismatch.reason}.\n` +
|
|
576
|
+
`Resubmit with model: '${mismatch.suggestedModel}'.\n` +
|
|
577
|
+
`This ${tier}-tier task requires at least ${tier === 'think' ? 'execute' : 'search'}-tier capability or higher.\n` +
|
|
578
|
+
`Correct model${bestForSuffix}: ${expected || mismatch.suggestedModel}`;
|
|
400
579
|
} else {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
});
|
|
410
|
-
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
|
|
411
|
-
if (onlyWarnings) {
|
|
412
|
-
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
413
|
-
} else {
|
|
414
|
-
process.stdout.write('{}');
|
|
415
|
-
}
|
|
416
|
-
process.exit(0);
|
|
417
|
-
}
|
|
418
|
-
const savings = tier === 'search' ? 'Haiku is 19x cheaper than Opus for read-only lookups.' : 'Sonnet is 5x cheaper than Opus for implementation work.';
|
|
419
|
-
const bestFor = intelligence[expected]?.best_for;
|
|
420
|
-
const bestForSuffix = bestFor ? ` (best for: ${bestFor})` : '';
|
|
421
|
-
const msg = `This looks like ${tier} work — use ${expected}${bestForSuffix} instead of ${currentModel || 'opus (inherited)'}. ${savings}`;
|
|
422
|
-
logRecommendation({
|
|
423
|
-
tier,
|
|
424
|
-
recommended: expected,
|
|
425
|
-
actual: currentModel,
|
|
426
|
-
promptHash,
|
|
427
|
-
followed: false,
|
|
428
|
-
profile: profileName,
|
|
429
|
-
});
|
|
430
|
-
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
580
|
+
// warn
|
|
581
|
+
const savingsHint =
|
|
582
|
+
tier === 'search' ? 'Haiku is 19x cheaper than Opus for read-only lookups.' :
|
|
583
|
+
tier === 'execute' ? 'Sonnet is 5x cheaper than Opus for implementation work.' :
|
|
584
|
+
`${expected || 'opus'} is the recommended model for think-tier work.`;
|
|
585
|
+
tierMsg =
|
|
586
|
+
`⚠️ Model mismatch (${mismatch.severity}): ${mismatch.reason}. ` +
|
|
587
|
+
`Suggested: ${mismatch.suggestedModel}${bestForSuffix}. ${savingsHint}`;
|
|
431
588
|
}
|
|
589
|
+
|
|
590
|
+
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(tierMsg) }));
|
|
432
591
|
} catch (err) {
|
|
433
592
|
process.stdout.write(JSON.stringify({
|
|
434
593
|
systemMessage: `[Tier Enforcer] Config error: ${err?.message?.slice(0, 100) || 'unknown'}. Falling back to main-session judgment.`
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* error-channel.mjs — Lightweight error logger for dual-brain hooks.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* logHookError(hookName, operation, error, context?) → append to errors.jsonl
|
|
6
|
+
* getRecentErrors(hours?) → array of recent error entries
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, readFileSync, writeFileSync } from 'fs';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ERROR_FILE = join(__dirname, 'errors.jsonl');
|
|
15
|
+
|
|
16
|
+
const PRUNE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
17
|
+
const PRUNE_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
18
|
+
let lastPruneCheck = 0;
|
|
19
|
+
|
|
20
|
+
function maybePrune() {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (now - lastPruneCheck < PRUNE_INTERVAL_MS) return;
|
|
23
|
+
lastPruneCheck = now;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(ERROR_FILE, 'utf8');
|
|
27
|
+
const cutoff = now - PRUNE_MAX_AGE_MS;
|
|
28
|
+
const kept = raw.split('\n').filter(line => {
|
|
29
|
+
if (!line) return false;
|
|
30
|
+
try {
|
|
31
|
+
const entry = JSON.parse(line);
|
|
32
|
+
return Date.parse(entry.timestamp) >= cutoff;
|
|
33
|
+
} catch { return true; } // keep unparseable lines
|
|
34
|
+
});
|
|
35
|
+
writeFileSync(ERROR_FILE, kept.length > 0 ? kept.join('\n') + '\n' : '');
|
|
36
|
+
} catch {
|
|
37
|
+
// File doesn't exist or can't be read — nothing to prune
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function logHookError(hookName, operation, error, context = {}) {
|
|
42
|
+
const entry = JSON.stringify({
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
hook: hookName,
|
|
45
|
+
operation,
|
|
46
|
+
error: error?.message || String(error),
|
|
47
|
+
stack: error?.stack || null,
|
|
48
|
+
context,
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
appendFileSync(ERROR_FILE, entry + '\n');
|
|
52
|
+
} catch {
|
|
53
|
+
// Last resort — can't even log. Silently drop.
|
|
54
|
+
}
|
|
55
|
+
maybePrune();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getRecentErrors(hours = 24) {
|
|
59
|
+
const cutoff = Date.now() - hours * 60 * 60 * 1000;
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(ERROR_FILE, 'utf8');
|
|
62
|
+
return raw.split('\n').filter(Boolean).map(line => {
|
|
63
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
64
|
+
}).filter(e => e && Date.parse(e.timestamp) >= cutoff);
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -13,6 +13,7 @@ import { readFileSync, appendFileSync, writeFileSync, renameSync, unlinkSync } f
|
|
|
13
13
|
import { dirname, join } from 'path';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
15
|
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
16
|
+
import { logHookError } from './error-channel.mjs';
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -91,7 +92,7 @@ function recordFailure(promptHash, tier, reason) {
|
|
|
91
92
|
});
|
|
92
93
|
try {
|
|
93
94
|
appendFileSync(LEDGER_FILE, entry + '\n');
|
|
94
|
-
} catch {}
|
|
95
|
+
} catch (e) { logHookError('failure-detector', 'recordFailure append', e); }
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/**
|
package/hooks/health-check.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Checks:
|
|
12
12
|
* 1. orchestrator.json — exists and parses as valid JSON
|
|
13
13
|
* 2. pricing_verified — exists, warn if >30 days, fail if >90 days
|
|
14
|
-
* 3. model_intelligence —
|
|
14
|
+
* 3. model_intelligence — inline in subscriptions, covers all models
|
|
15
15
|
* 4. hook scripts — enforce-tier, cost-logger, quality-gate, dual-brain-review readable
|
|
16
16
|
* 5. usage.jsonl active — recent entries (last 15 min) indicate PostToolUse hook is wired
|
|
17
17
|
* 6. codex CLI — found on PATH or known locations; auth status checked
|
|
@@ -94,7 +94,7 @@ function checkPricingVerified() {
|
|
|
94
94
|
return check("pricing_verified", STATUS.pass, `${ageDays} days ago`);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** 3. model_intelligence —
|
|
97
|
+
/** 3. model_intelligence — merged into subscriptions; validate inline fields */
|
|
98
98
|
function checkModelIntelligence() {
|
|
99
99
|
let config;
|
|
100
100
|
try {
|
|
@@ -103,31 +103,30 @@ function checkModelIntelligence() {
|
|
|
103
103
|
return check("model_intelligence", STATUS.fail, "cannot read config");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
subscriptionModels.add(key);
|
|
106
|
+
// Collect all models from subscriptions and check for intelligence fields
|
|
107
|
+
let entryCount = 0;
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const [providerName, provider] of Object.entries(config.subscriptions || {})) {
|
|
110
|
+
for (const [modelName, meta] of Object.entries(provider.models || {})) {
|
|
111
|
+
entryCount++;
|
|
112
|
+
if (!meta.best_for && !meta.model_id) {
|
|
113
|
+
missing.push(modelName);
|
|
114
|
+
}
|
|
116
115
|
}
|
|
117
116
|
}
|
|
118
117
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
if (entryCount === 0) {
|
|
119
|
+
return check("model_intelligence", STATUS.fail, "no models in subscriptions");
|
|
120
|
+
}
|
|
122
121
|
|
|
123
122
|
if (missing.length > 0) {
|
|
124
123
|
return check(
|
|
125
124
|
"model_intelligence",
|
|
126
125
|
STATUS.warn,
|
|
127
|
-
`${entryCount} models, missing: ${missing.join(", ")}`
|
|
126
|
+
`${entryCount} models, missing intelligence: ${missing.join(", ")}`
|
|
128
127
|
);
|
|
129
128
|
}
|
|
130
|
-
return check("model_intelligence", STATUS.pass, `${entryCount} models`);
|
|
129
|
+
return check("model_intelligence", STATUS.pass, `${entryCount} models (inline)`);
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
/** 4. Hook scripts readable */
|