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.
@@ -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 tenMinAgo = Date.now() - 10 * 60 * 1000;
92
- summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
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 tenMinAgo = Date.now() - 10 * 60 * 1000;
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) >= tenMinAgo
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 tenMinAgo = Date.now() - 10 * 60 * 1000;
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) > tenMinAgo) {
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
- config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
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
- const intelligence = config.model_intelligence || {};
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
- const riskResult = classifyRisk(filePaths);
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
- autoStatus = riskResult.level === 'critical'
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
- if (!burstMode) {
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
- if (tier === 'think') {
368
- const thinkModels = ['opus', 'gpt-5.5', 'o1', 'o3'];
369
- const isThink = !currentModel || thinkModels.some(m => currentModel.includes(m));
370
- if (isThink) {
371
- logRecommendation({
372
- tier,
373
- recommended: expected,
374
- actual: currentModel,
375
- promptHash,
376
- followed: true,
377
- profile: profileName,
378
- });
379
- const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
380
- if (onlyWarnings) {
381
- process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
382
- } else {
383
- process.stdout.write('{}');
384
- }
385
- process.exit(0);
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
- // If we get here, a non-think model is being used for think work
388
- const thinkBestFor = intelligence[expected || 'opus']?.best_for;
389
- const thinkBestForSuffix = thinkBestFor ? ` (best for: ${thinkBestFor})` : '';
390
- 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}.`;
391
- logRecommendation({
392
- tier,
393
- recommended: expected,
394
- actual: currentModel,
395
- promptHash,
396
- followed: false,
397
- profile: profileName,
398
- });
399
- process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
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}`;
400
579
  } else {
401
- if (!expected || currentModel.includes(expected)) {
402
- logRecommendation({
403
- tier,
404
- recommended: expected,
405
- actual: currentModel,
406
- promptHash,
407
- followed: true,
408
- profile: profileName,
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
  /**
@@ -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 — exists and covers all subscription models
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 — exists and has entries for at least the subscription models */
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
- const mi = config.model_intelligence;
107
- if (!mi || typeof mi !== "object") {
108
- return check("model_intelligence", STATUS.fail, "key missing from config");
109
- }
110
-
111
- // Collect model keys from subscriptions
112
- const subscriptionModels = new Set();
113
- for (const provider of Object.values(config.subscriptions || {})) {
114
- for (const key of Object.keys(provider.models || {})) {
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
- const miKeys = Object.keys(mi);
120
- const missing = [...subscriptionModels].filter((m) => !mi[m]);
121
- const entryCount = miKeys.length;
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 */