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/enforce-tier.mjs
CHANGED
|
@@ -1,51 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
|
2
|
+
import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
|
|
3
3
|
import { dirname, resolve, join } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { classifyRisk,
|
|
5
|
+
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
6
6
|
import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
|
|
7
|
-
import { getOutcomeStats } from './decision-ledger.mjs';
|
|
8
|
-
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
9
|
-
import { logHookError } from './error-channel.mjs';
|
|
10
|
-
import { loadAndValidateConfig } from './config-validator.mjs';
|
|
11
7
|
|
|
12
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
9
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
14
10
|
const PROFILE_FILE = resolve(__dirname, '..', 'dual-brain.profile.json');
|
|
15
11
|
const DRIFT_STATE = resolve(__dirname, '.drift-warned');
|
|
16
12
|
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
|
-
}
|
|
49
13
|
|
|
50
14
|
function detectBurst() {
|
|
51
15
|
const now = Date.now();
|
|
@@ -53,7 +17,7 @@ function detectBurst() {
|
|
|
53
17
|
try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
|
|
54
18
|
if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
|
|
55
19
|
state.count++;
|
|
56
|
-
try {
|
|
20
|
+
try { writeFileSync(BURST_FILE, JSON.stringify(state)); } catch {}
|
|
57
21
|
return state.count >= 3;
|
|
58
22
|
}
|
|
59
23
|
|
|
@@ -65,110 +29,12 @@ function loadProfile() {
|
|
|
65
29
|
}
|
|
66
30
|
|
|
67
31
|
const PROFILE_SETTINGS = {
|
|
68
|
-
auto: { demote_think: false, promote_execute: false, bias: 0
|
|
69
|
-
balanced: { demote_think: false, promote_execute: false, bias: 0
|
|
70
|
-
'cost-saver': { demote_think: true, promote_execute: false, bias: -20
|
|
71
|
-
'quality-first': { demote_think: false, promote_execute: true, bias: 10
|
|
32
|
+
auto: { demote_think: false, promote_execute: false, bias: 0 },
|
|
33
|
+
balanced: { demote_think: false, promote_execute: false, bias: 0 },
|
|
34
|
+
'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
|
|
35
|
+
'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
|
|
72
36
|
};
|
|
73
37
|
|
|
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
|
-
|
|
172
38
|
function checkPricingDrift(config) {
|
|
173
39
|
const verified = config.pricing_verified;
|
|
174
40
|
if (!verified) return null;
|
|
@@ -185,7 +51,7 @@ function checkPricingDrift(config) {
|
|
|
185
51
|
|
|
186
52
|
try {
|
|
187
53
|
writeFileSync(DRIFT_STATE, new Date().toISOString().slice(0, 10));
|
|
188
|
-
} catch
|
|
54
|
+
} catch {}
|
|
189
55
|
|
|
190
56
|
return `**[Drift Warning]** Pricing was last verified ${age} days ago. Run \`node .claude/hooks/setup-wizard.mjs\` to update.`;
|
|
191
57
|
}
|
|
@@ -220,12 +86,14 @@ function logRecommendation(event) {
|
|
|
220
86
|
if (event.promptHash) {
|
|
221
87
|
summary.recent_hashes = summary.recent_hashes || [];
|
|
222
88
|
summary.recent_hashes.push({ hash: event.promptHash, ts: entryObj.timestamp });
|
|
223
|
-
const
|
|
224
|
-
summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >=
|
|
89
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
90
|
+
summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
|
|
225
91
|
}
|
|
226
92
|
summary.updated_at = new Date().toISOString();
|
|
227
|
-
|
|
228
|
-
|
|
93
|
+
const tmp = summaryFile + '.tmp.' + process.pid;
|
|
94
|
+
writeFileSync(tmp, JSON.stringify(summary, null, 2) + '\n');
|
|
95
|
+
renameSync(tmp, summaryFile);
|
|
96
|
+
} catch {}
|
|
229
97
|
|
|
230
98
|
// Sync ledger write (append-only, fast)
|
|
231
99
|
try {
|
|
@@ -243,7 +111,7 @@ function logRecommendation(event) {
|
|
|
243
111
|
prompt_hash: event.promptHash,
|
|
244
112
|
});
|
|
245
113
|
appendFileSync(join(__dirname, 'decision-ledger.jsonl'), ledgerEntry + '\n');
|
|
246
|
-
} catch
|
|
114
|
+
} catch {}
|
|
247
115
|
}
|
|
248
116
|
|
|
249
117
|
function checkDuplicate(promptHash) {
|
|
@@ -251,9 +119,9 @@ function checkDuplicate(promptHash) {
|
|
|
251
119
|
try {
|
|
252
120
|
const summaryPath = join(__dirname, `usage-summary-${new Date().toISOString().slice(0, 10)}.json`);
|
|
253
121
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
254
|
-
const
|
|
122
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
255
123
|
const match = (summary.recent_hashes || []).find(
|
|
256
|
-
h => h.hash === promptHash && Date.parse(h.ts) >=
|
|
124
|
+
h => h.hash === promptHash && Date.parse(h.ts) >= tenMinAgo
|
|
257
125
|
);
|
|
258
126
|
if (match) return { timestamp: match.ts, prompt_hash: promptHash };
|
|
259
127
|
} catch {}
|
|
@@ -262,13 +130,13 @@ function checkDuplicate(promptHash) {
|
|
|
262
130
|
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
263
131
|
try {
|
|
264
132
|
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
265
|
-
const
|
|
133
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
266
134
|
for (const line of lines) {
|
|
267
135
|
try {
|
|
268
136
|
const entry = JSON.parse(line);
|
|
269
137
|
if (entry.type === 'tier_recommendation' &&
|
|
270
138
|
entry.prompt_hash === promptHash &&
|
|
271
|
-
Date.parse(entry.timestamp) >
|
|
139
|
+
Date.parse(entry.timestamp) > tenMinAgo) {
|
|
272
140
|
return entry;
|
|
273
141
|
}
|
|
274
142
|
} catch {}
|
|
@@ -353,7 +221,7 @@ try {
|
|
|
353
221
|
// Check for duplicate agent dispatch before tier classification
|
|
354
222
|
const duplicate = checkDuplicate(promptHash);
|
|
355
223
|
let duplicateWarning = null;
|
|
356
|
-
if (duplicate
|
|
224
|
+
if (duplicate) {
|
|
357
225
|
const minutesAgo = Math.round((Date.now() - Date.parse(duplicate.timestamp)) / 60000);
|
|
358
226
|
if (burstMode) {
|
|
359
227
|
// In burst mode, only warn on exact hash matches (same description+prompt)
|
|
@@ -368,8 +236,7 @@ try {
|
|
|
368
236
|
|
|
369
237
|
let config;
|
|
370
238
|
try {
|
|
371
|
-
|
|
372
|
-
config = result.config;
|
|
239
|
+
config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
373
240
|
} catch {
|
|
374
241
|
process.stdout.write('{}');
|
|
375
242
|
process.exit(0);
|
|
@@ -377,13 +244,7 @@ try {
|
|
|
377
244
|
|
|
378
245
|
const driftWarning = checkPricingDrift(config);
|
|
379
246
|
|
|
380
|
-
|
|
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
|
-
}
|
|
247
|
+
const intelligence = config.model_intelligence || {};
|
|
387
248
|
const defaults = config.routing_rules?.subagent_defaults || {};
|
|
388
249
|
let tier = null;
|
|
389
250
|
|
|
@@ -393,12 +254,12 @@ try {
|
|
|
393
254
|
|
|
394
255
|
// Balance hint — populated after tier is fully resolved
|
|
395
256
|
let balanceHint = null;
|
|
396
|
-
|
|
397
|
-
let
|
|
257
|
+
let failureMessage = null;
|
|
258
|
+
let autoStatus = null;
|
|
398
259
|
|
|
399
|
-
// Helper to prepend optional warnings (duplicate + drift + balance +
|
|
260
|
+
// Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
|
|
400
261
|
const prependWarnings = (msg) => {
|
|
401
|
-
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint
|
|
262
|
+
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
|
|
402
263
|
return parts.join('\n\n');
|
|
403
264
|
};
|
|
404
265
|
|
|
@@ -443,47 +304,19 @@ try {
|
|
|
443
304
|
}
|
|
444
305
|
|
|
445
306
|
// 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.
|
|
448
307
|
const filePaths = extractPaths(ti.description || '');
|
|
449
|
-
|
|
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
|
-
|
|
473
|
-
let autoStatus = null;
|
|
308
|
+
const riskResult = classifyRisk(filePaths);
|
|
474
309
|
|
|
475
310
|
// Bias high/critical risk toward think tier
|
|
476
311
|
if ((riskResult.level === 'critical' || riskResult.level === 'high') && tier !== 'think') {
|
|
477
312
|
tier = 'think';
|
|
478
|
-
|
|
479
|
-
? `This touches ${
|
|
480
|
-
: `Promoting to think tier — this is ${
|
|
481
|
-
autoStatus = riskEscalationReason ? `${baseMsg} ${riskEscalationReason}.` : baseMsg;
|
|
313
|
+
autoStatus = riskResult.level === 'critical'
|
|
314
|
+
? `This touches ${riskResult.reason.split(':')[0].toLowerCase()} — recommending dual-brain review for safety.`
|
|
315
|
+
: `Promoting to think tier — this is ${riskResult.reason.split(':')[0].toLowerCase()}.`;
|
|
482
316
|
}
|
|
483
317
|
|
|
484
318
|
// Failure loop detection
|
|
485
319
|
const failureCheck = checkFailureLoop(promptHash);
|
|
486
|
-
let failureMessage = null;
|
|
487
320
|
if (failureCheck.isLoop) {
|
|
488
321
|
if (failureCheck.suggestion === 'promote_tier' && tier === 'execute') {
|
|
489
322
|
tier = 'think';
|
|
@@ -504,8 +337,7 @@ try {
|
|
|
504
337
|
|
|
505
338
|
// Compute balance hint now that tier is resolved
|
|
506
339
|
// In burst mode, skip balance hints — one hint per wave is enough
|
|
507
|
-
|
|
508
|
-
if (!burstMode && !isOnCooldown('balance_hint')) {
|
|
340
|
+
if (!burstMode) {
|
|
509
341
|
const currentProvider = detectProvider(currentModel);
|
|
510
342
|
if (currentProvider === 'claude') {
|
|
511
343
|
const balance = quickPressureCheck(tier);
|
|
@@ -517,77 +349,73 @@ try {
|
|
|
517
349
|
}
|
|
518
350
|
}
|
|
519
351
|
|
|
520
|
-
// Outcome stats advisory — best-effort, suppressed in burst mode and on cooldown
|
|
521
|
-
if (!burstMode && !isOnCooldown('outcome_advisory')) {
|
|
522
|
-
try {
|
|
523
|
-
const stats = getOutcomeStats();
|
|
524
|
-
const tierIssue = stats.underperforming.find(u => u.tier === tier);
|
|
525
|
-
if (tierIssue) {
|
|
526
|
-
outcomeAdvisory = `Heads up — ${tierIssue.tier} tasks have been struggling lately (${tierIssue.rate}% success over ${tierIssue.total} recent outcomes). Consider escalating to a higher tier.`;
|
|
527
|
-
}
|
|
528
|
-
} catch {}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
352
|
const expected = preferredModel(config, tier);
|
|
532
353
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
553
|
-
} else {
|
|
554
|
-
process.stdout.write('{}');
|
|
354
|
+
if (tier === 'think') {
|
|
355
|
+
const thinkModels = ['opus', 'gpt-5.5', 'o1', 'o3'];
|
|
356
|
+
const isThink = !currentModel || thinkModels.some(m => currentModel.includes(m));
|
|
357
|
+
if (isThink) {
|
|
358
|
+
logRecommendation({
|
|
359
|
+
tier,
|
|
360
|
+
recommended: expected,
|
|
361
|
+
actual: currentModel,
|
|
362
|
+
promptHash,
|
|
363
|
+
followed: true,
|
|
364
|
+
profile: profileName,
|
|
365
|
+
});
|
|
366
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
367
|
+
if (onlyWarnings) {
|
|
368
|
+
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
369
|
+
} else {
|
|
370
|
+
process.stdout.write('{}');
|
|
371
|
+
}
|
|
372
|
+
process.exit(0);
|
|
555
373
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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}`;
|
|
374
|
+
// If we get here, a non-think model is being used for think work
|
|
375
|
+
const thinkBestFor = intelligence[expected || 'opus']?.best_for;
|
|
376
|
+
const thinkBestForSuffix = thinkBestFor ? ` (best for: ${thinkBestFor})` : '';
|
|
377
|
+
const msg = `This looks like think-level work (architecture/review/planning) — better kept on the main session (${expected || 'opus'}${thinkBestForSuffix}) rather than delegated to ${currentModel}.`;
|
|
378
|
+
logRecommendation({
|
|
379
|
+
tier,
|
|
380
|
+
recommended: expected,
|
|
381
|
+
actual: currentModel,
|
|
382
|
+
promptHash,
|
|
383
|
+
followed: false,
|
|
384
|
+
profile: profileName,
|
|
385
|
+
});
|
|
386
|
+
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
579
387
|
} else {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
388
|
+
if (!expected || currentModel.includes(expected)) {
|
|
389
|
+
logRecommendation({
|
|
390
|
+
tier,
|
|
391
|
+
recommended: expected,
|
|
392
|
+
actual: currentModel,
|
|
393
|
+
promptHash,
|
|
394
|
+
followed: true,
|
|
395
|
+
profile: profileName,
|
|
396
|
+
});
|
|
397
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
398
|
+
if (onlyWarnings) {
|
|
399
|
+
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
400
|
+
} else {
|
|
401
|
+
process.stdout.write('{}');
|
|
402
|
+
}
|
|
403
|
+
process.exit(0);
|
|
404
|
+
}
|
|
405
|
+
const savings = tier === 'search' ? 'Haiku is 19x cheaper than Opus for read-only lookups.' : 'Sonnet is 5x cheaper than Opus for implementation work.';
|
|
406
|
+
const bestFor = intelligence[expected]?.best_for;
|
|
407
|
+
const bestForSuffix = bestFor ? ` (best for: ${bestFor})` : '';
|
|
408
|
+
const msg = `This looks like ${tier} work — use ${expected}${bestForSuffix} instead of ${currentModel || 'opus (inherited)'}. ${savings}`;
|
|
409
|
+
logRecommendation({
|
|
410
|
+
tier,
|
|
411
|
+
recommended: expected,
|
|
412
|
+
actual: currentModel,
|
|
413
|
+
promptHash,
|
|
414
|
+
followed: false,
|
|
415
|
+
profile: profileName,
|
|
416
|
+
});
|
|
417
|
+
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
588
418
|
}
|
|
589
|
-
|
|
590
|
-
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(tierMsg) }));
|
|
591
419
|
} catch (err) {
|
|
592
420
|
process.stdout.write(JSON.stringify({
|
|
593
421
|
systemMessage: `[Tier Enforcer] Config error: ${err?.message?.slice(0, 100) || 'unknown'}. Falling back to main-session judgment.`
|
|
@@ -12,8 +12,6 @@ import { createHash } from 'crypto';
|
|
|
12
12
|
import { readFileSync, appendFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
13
13
|
import { dirname, join } from 'path';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
|
-
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
16
|
-
import { logHookError } from './error-channel.mjs';
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -92,7 +90,7 @@ function recordFailure(promptHash, tier, reason) {
|
|
|
92
90
|
});
|
|
93
91
|
try {
|
|
94
92
|
appendFileSync(LEDGER_FILE, entry + '\n');
|
|
95
|
-
} catch
|
|
93
|
+
} catch {}
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
/**
|