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.
@@ -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, classifyRiskEnhanced, extractPaths } from './risk-classifier.mjs';
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 { atomicWriteJSON(BURST_FILE, state); } catch (e) { logHookError('enforce-tier', 'burst state write', e); }
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, 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' },
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 (e) { logHookError('enforce-tier', 'drift state write', e); }
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 threeMinAgo = Date.now() - 3 * 60 * 1000;
224
- summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= threeMinAgo);
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
- atomicWriteJSON(summaryFile, summary);
228
- } catch (e) { logHookError('enforce-tier', 'summary update', e); }
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 (e) { logHookError('enforce-tier', 'decision ledger append', e); }
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 threeMinAgo = Date.now() - 3 * 60 * 1000;
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) >= threeMinAgo
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 threeMinAgo = Date.now() - 3 * 60 * 1000;
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) > threeMinAgo) {
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 && !isOnCooldown('duplicate_warning')) {
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
- const result = loadAndValidateConfig(CONFIG_FILE);
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
- // 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
- }
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
- // Outcome advisory — populated after tier is fully resolved
397
- let outcomeAdvisory = null;
257
+ let failureMessage = null;
258
+ let autoStatus = null;
398
259
 
399
- // Helper to prepend optional warnings (duplicate + drift + balance + outcome + auto) before a message
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, outcomeAdvisory].filter(Boolean);
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
- 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
-
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
- 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;
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
- // Balance hints have a 15-minute cooldown (most wallpaper-prone)
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
- // ── 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('{}');
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
- process.exit(0);
557
- }
558
-
559
- // On cooldownemit 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}`;
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
- // 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}`;
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 (e) { logHookError('failure-detector', 'recordFailure append', e); }
93
+ } catch {}
96
94
  }
97
95
 
98
96
  /**