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.
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-templates.mjs — Pre-built specialist agent templates for dual-brain.
4
+ *
5
+ * Exports:
6
+ * TEMPLATES — all template definitions
7
+ * getTemplate(name) → template object or null
8
+ * listTemplates() → [{name, description, tier, risk}]
9
+ * buildAgentPrompt(templateName, flags) → { prompt, model, description, risk, quality_gate, tier, output_contract }
10
+ * buildPrompt(templateName, args) → prompt string (legacy compat)
11
+ *
12
+ * CLI:
13
+ * node agent-templates.mjs --list
14
+ * node agent-templates.mjs --template explorer --question "where is auth handled?"
15
+ * node agent-templates.mjs --run explorer --question "where is auth handled?"
16
+ *
17
+ * Via npx dual-brain:
18
+ * npx dual-brain agents # list all templates
19
+ * npx dual-brain agent explorer --question "..."
20
+ */
21
+
22
+ import { spawnSync } from 'child_process';
23
+ import { dirname, resolve } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+
28
+ // ─── Model Mapping ─────────────────────────────────────────────────────────
29
+
30
+ /** Maps tier name → Claude model ID */
31
+ const TIER_MODELS = {
32
+ search: 'claude-haiku-4-5',
33
+ execute: 'claude-sonnet-4-5',
34
+ think: 'claude-opus-4-5',
35
+ };
36
+
37
+ // ─── Template Definitions ──────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Built-in agent templates.
41
+ *
42
+ * Fields:
43
+ * name - unique slug (also used as CLI argument)
44
+ * tier - search | execute | think
45
+ * model - haiku | sonnet | opus (short form, for display)
46
+ * risk - low | medium | high | critical
47
+ * quality_gate - self_check | review_recommended | dual_brain_review
48
+ * description - one-line shown in --list
49
+ * output_contract - array of items the agent MUST return
50
+ * flags - { '--flag': { required, description } }
51
+ * prompt_template - string with {var} tokens OR function(args) → string
52
+ */
53
+ export const TEMPLATES = {
54
+ explorer: {
55
+ name: 'explorer',
56
+ tier: 'search',
57
+ model: 'haiku',
58
+ risk: 'low',
59
+ quality_gate: 'self_check',
60
+ description: 'Read-only codebase exploration — find files, symbols, and patterns',
61
+ output_contract: [
62
+ 'files found (with line numbers)',
63
+ 'key symbols/functions discovered',
64
+ 'confidence level (high/medium/low)',
65
+ 'areas you did NOT check',
66
+ ],
67
+ flags: {
68
+ '--question': { required: true, description: 'What to explore or find' },
69
+ '--scope': { required: false, description: 'Limit search to a path (e.g. src/auth)' },
70
+ },
71
+ prompt_template:
72
+ 'You are a codebase exploration agent. Your task: {question}. ' +
73
+ '{scope ? \'Focus on: \' + scope : \'Search the entire codebase.\'}. ' +
74
+ 'Return a structured report with: ' +
75
+ '1) Files found (with line numbers), ' +
76
+ '2) Key symbols/functions discovered, ' +
77
+ '3) Confidence level (high/medium/low), ' +
78
+ '4) Areas you did NOT check. ' +
79
+ 'Do not modify any files.',
80
+ },
81
+
82
+ 'security-review': {
83
+ name: 'security-review',
84
+ tier: 'think',
85
+ model: 'opus',
86
+ risk: 'high',
87
+ quality_gate: 'dual_brain_review',
88
+ description: 'Security audit agent — OWASP Top 10 scan with severity-filtered findings',
89
+ output_contract: [
90
+ 'vulnerabilities found (severity)',
91
+ 'recommendations',
92
+ 'files reviewed',
93
+ 'OWASP categories checked',
94
+ ],
95
+ flags: {
96
+ '--scope': { required: false, description: 'Path to audit (e.g. src/auth); defaults to entire codebase' },
97
+ '--severity': { required: false, description: 'Minimum severity to report: critical|high|medium|low' },
98
+ },
99
+ prompt_template:
100
+ 'You are a security review agent. ' +
101
+ 'Audit {scope || \'the current codebase\'} for vulnerabilities. ' +
102
+ 'Check OWASP Top 10 categories. ' +
103
+ 'For each finding report: severity (critical/high/medium/low), file and line, description, remediation. ' +
104
+ '{severity ? \'Only report \' + severity + \' severity and above.\' : \'\'} ' +
105
+ 'Do not modify any files.',
106
+ },
107
+
108
+ 'test-writer': {
109
+ name: 'test-writer',
110
+ tier: 'execute',
111
+ model: 'sonnet',
112
+ risk: 'medium',
113
+ quality_gate: 'review_recommended',
114
+ description: 'Test generation agent — comprehensive tests with edge cases',
115
+ output_contract: [
116
+ 'test files created',
117
+ 'coverage areas',
118
+ 'edge cases covered',
119
+ 'assumptions made',
120
+ ],
121
+ flags: {
122
+ '--file': { required: true, description: 'Source file to generate tests for (e.g. src/api.ts)' },
123
+ '--framework': { required: false, description: 'Test framework to use (e.g. jest); auto-detect if omitted' },
124
+ },
125
+ prompt_template:
126
+ 'You are a test writing agent. Generate comprehensive tests for {file}. ' +
127
+ '{framework ? \'Use \' + framework + \' framework.\' : \'Auto-detect the test framework from the project.\'} ' +
128
+ 'Cover: happy path, edge cases, error handling, boundary conditions. ' +
129
+ 'Create the test file adjacent to the source. ' +
130
+ 'Return: files created, test count, coverage areas, assumptions made.',
131
+ },
132
+
133
+ 'bug-hunter': {
134
+ name: 'bug-hunter',
135
+ tier: 'execute',
136
+ model: 'sonnet',
137
+ risk: 'medium',
138
+ quality_gate: 'review_recommended',
139
+ description: 'Bug finding agent — logic errors, race conditions, edge cases',
140
+ output_contract: [
141
+ 'bugs found (severity)',
142
+ 'reproduction steps',
143
+ 'suggested fixes',
144
+ 'files examined',
145
+ ],
146
+ flags: {
147
+ '--area': { required: true, description: 'Code area to hunt bugs in (e.g. payments)' },
148
+ '--depth': { required: false, description: 'Analysis depth: normal (default) or deep' },
149
+ },
150
+ prompt_template:
151
+ 'You are a bug hunting agent. ' +
152
+ 'Search {area} for bugs, logic errors, race conditions, and edge cases. ' +
153
+ '{depth === \'deep\' ? \'Do an exhaustive analysis including data flow tracing.\' : \'Focus on the most likely problem areas.\'} ' +
154
+ 'For each bug found report: severity, file and line, description, reproduction scenario, suggested fix. ' +
155
+ 'Do not modify any files.',
156
+ },
157
+ };
158
+
159
+ // ─── Public API ─────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Return the template object for the given name, or null if not found.
163
+ * @param {string} name
164
+ * @returns {object|null}
165
+ */
166
+ export function getTemplate(name) {
167
+ return TEMPLATES[name] ?? null;
168
+ }
169
+
170
+ /**
171
+ * Return a summary list of all templates.
172
+ * @returns {{name: string, description: string, tier: string, risk: string, quality_gate: string, model: string}[]}
173
+ */
174
+ export function listTemplates() {
175
+ return Object.values(TEMPLATES).map(({ name, description, tier, risk, quality_gate, model }) => ({
176
+ name,
177
+ description,
178
+ tier,
179
+ model,
180
+ risk,
181
+ quality_gate,
182
+ }));
183
+ }
184
+
185
+ /**
186
+ * Build an agent prompt config by interpolating flags into the prompt template.
187
+ *
188
+ * Supports both string templates (with {var} tokens that may contain JS
189
+ * expressions) and function templates (legacy, for backwards compatibility).
190
+ *
191
+ * @param {string} templateName
192
+ * @param {Record<string, string>} flags e.g. { question: 'where is auth?', scope: 'src/auth' }
193
+ * @returns {{ prompt, model, description, risk, quality_gate, tier, output_contract }|null}
194
+ */
195
+ export function buildAgentPrompt(templateName, flags = {}) {
196
+ const tmpl = getTemplate(templateName);
197
+ if (!tmpl) return null;
198
+
199
+ const {
200
+ question = '',
201
+ scope = '',
202
+ severity = '',
203
+ framework = '',
204
+ file = '',
205
+ area = '',
206
+ depth = 'normal',
207
+ context = '',
208
+ } = flags;
209
+
210
+ let prompt;
211
+
212
+ if (typeof tmpl.prompt_template === 'function') {
213
+ // Legacy function-style template
214
+ prompt = tmpl.prompt_template({ question, scope, severity, framework, file, area, depth, context });
215
+ } else {
216
+ // String template: replace {expr} tokens with evaluated JS expressions.
217
+ // We convert {expr} → ${expr} and evaluate via template literal inside a
218
+ // Function scope that has all flag vars bound. Fallback to naive {var}
219
+ // replacement if the Function evaluation throws.
220
+ try {
221
+ // eslint-disable-next-line no-new-func
222
+ const interpolate = new Function(
223
+ 'question', 'scope', 'severity', 'framework', 'file', 'area', 'depth', 'context',
224
+ // Escape backticks and backslashes in the template before wrapping
225
+ `return \`${tmpl.prompt_template
226
+ .replace(/\\/g, '\\\\')
227
+ .replace(/`/g, '\\`')
228
+ .replace(/\{/g, '${')
229
+ }\`;`
230
+ );
231
+ prompt = interpolate(question, scope, severity, framework, file, area, depth, context);
232
+ } catch {
233
+ // Fallback: naive {var} token replacement
234
+ prompt = tmpl.prompt_template
235
+ .replace(/\{question\}/g, question)
236
+ .replace(/\{scope\}/g, scope)
237
+ .replace(/\{severity\}/g, severity)
238
+ .replace(/\{framework\}/g, framework)
239
+ .replace(/\{file\}/g, file)
240
+ .replace(/\{area\}/g, area)
241
+ .replace(/\{depth\}/g, depth)
242
+ .replace(/\{context\}/g, context);
243
+ }
244
+ }
245
+
246
+ // Normalise whitespace
247
+ prompt = prompt.replace(/\s+/g, ' ').trim();
248
+
249
+ const modelId = TIER_MODELS[tmpl.tier] || TIER_MODELS.execute;
250
+
251
+ return {
252
+ prompt,
253
+ model: modelId,
254
+ description: tmpl.description,
255
+ risk: tmpl.risk,
256
+ quality_gate: tmpl.quality_gate,
257
+ tier: tmpl.tier,
258
+ output_contract: tmpl.output_contract || [],
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Legacy compat: build just the prompt string.
264
+ * Use buildAgentPrompt() for new code.
265
+ * @param {string} templateName
266
+ * @param {object} args
267
+ * @returns {string}
268
+ */
269
+ export function buildPrompt(templateName, args = {}) {
270
+ const template = getTemplate(templateName);
271
+ if (!template) throw new Error(`Unknown template: ${templateName}`);
272
+ if (typeof template.prompt_template === 'function') {
273
+ return template.prompt_template(args);
274
+ }
275
+ const config = buildAgentPrompt(templateName, args);
276
+ return config ? config.prompt : '';
277
+ }
278
+
279
+ // ─── CLI Helpers ────────────────────────────────────────────────────────────
280
+
281
+ function parseCliFlags(argv) {
282
+ const flags = {};
283
+ for (let i = 0; i < argv.length; i++) {
284
+ const a = argv[i];
285
+ if (a === '--list') { flags.list = true; continue; }
286
+ if (a.startsWith('--')) {
287
+ const key = a.slice(2);
288
+ const next = argv[i + 1];
289
+ if (next !== undefined && !next.startsWith('--')) {
290
+ flags[key] = next;
291
+ i++;
292
+ } else {
293
+ flags[key] = true;
294
+ }
295
+ }
296
+ }
297
+ return flags;
298
+ }
299
+
300
+ function printTable(templates) {
301
+ const TIER_ICON = { search: '🔍', execute: '⚙️ ', think: '🧠' };
302
+ const RISK_ICON = { low: '🟢', medium: '🟡', high: '🔴', critical: '🚨' };
303
+
304
+ const maxName = Math.max(...templates.map(t => t.name.length), 14);
305
+ const hr = '─'.repeat(maxName + 8 + 10 + 55);
306
+
307
+ console.log('');
308
+ console.log(' 🧠 dual-brain — Agent Templates');
309
+ console.log(` ${hr}`);
310
+ console.log(` ${'Template'.padEnd(maxName)} ${'Tier'.padEnd(9)} ${'Risk'.padEnd(8)} Description`);
311
+ console.log(` ${hr}`);
312
+
313
+ for (const t of templates) {
314
+ const tierLabel = (TIER_ICON[t.tier] || ' ') + ' ' + t.tier;
315
+ const riskLabel = (RISK_ICON[t.risk] || ' ') + ' ' + t.risk;
316
+ console.log(
317
+ ` ${t.name.padEnd(maxName)} ${tierLabel.padEnd(12)} ${riskLabel.padEnd(11)} ${t.description}`
318
+ );
319
+ }
320
+
321
+ console.log(` ${hr}`);
322
+ console.log('');
323
+ console.log(' Usage:');
324
+ console.log(' npx dual-brain agent <template> [flags]');
325
+ console.log(' npx dual-brain agent explorer --question "where is auth handled?"');
326
+ console.log(' npx dual-brain agent security-review --scope src/auth --severity high');
327
+ console.log(' npx dual-brain agent test-writer --file src/api.ts --framework jest');
328
+ console.log(' npx dual-brain agent bug-hunter --area payments --depth deep');
329
+ console.log('');
330
+ }
331
+
332
+ function printAgentConfig(config, templateName) {
333
+ console.log('');
334
+ console.log(` Agent: ${templateName}`);
335
+ console.log(` Tier: ${config.tier} → model: ${config.model}`);
336
+ console.log(` Risk: ${config.risk}`);
337
+ console.log(` Quality gate: ${config.quality_gate}`);
338
+ console.log('');
339
+ console.log(' Prompt:');
340
+ console.log(' ' + '─'.repeat(60));
341
+ const words = config.prompt.split(' ');
342
+ let line = ' ';
343
+ for (const w of words) {
344
+ if ((line + w).length > 78) { console.log(line.trimEnd()); line = ' ' + w + ' '; }
345
+ else line += w + ' ';
346
+ }
347
+ if (line.trim()) console.log(line.trimEnd());
348
+ console.log(' ' + '─'.repeat(60));
349
+ console.log('');
350
+ if (config.output_contract && config.output_contract.length) {
351
+ console.log(' Output contract (agent must return):');
352
+ for (const item of config.output_contract) console.log(` • ${item}`);
353
+ console.log('');
354
+ }
355
+ }
356
+
357
+ function runAgent(templateName, flags) {
358
+ const tmpl = getTemplate(templateName);
359
+ if (!tmpl) {
360
+ console.error(` Unknown template: ${templateName}`);
361
+ console.error(` Available: ${Object.keys(TEMPLATES).join(', ')}`);
362
+ console.error(` Run: npx dual-brain agents to see all templates`);
363
+ process.exit(1);
364
+ }
365
+
366
+ // Validate required flags
367
+ const missing = Object.entries(tmpl.flags || {})
368
+ .filter(([flagName, meta]) => meta.required && !flags[flagName.slice(2)])
369
+ .map(([flagName, meta]) => `${flagName} — ${meta.description}`);
370
+
371
+ if (missing.length > 0) {
372
+ console.error(` Missing required flags for "${templateName}":`);
373
+ for (const m of missing) console.error(` ${m}`);
374
+ process.exit(1);
375
+ }
376
+
377
+ const config = buildAgentPrompt(templateName, flags);
378
+
379
+ console.log('');
380
+ console.log(` 🧠 Spawning agent: ${templateName}`);
381
+ console.log(` Tier: ${config.tier} | Model: ${config.model} | Risk: ${config.risk}`);
382
+ console.log('');
383
+
384
+ const claudeWhich = spawnSync('which', ['claude'], { encoding: 'utf8' });
385
+ const claudeBin = (claudeWhich.status === 0 && claudeWhich.stdout.trim())
386
+ ? claudeWhich.stdout.trim()
387
+ : 'claude';
388
+
389
+ const claudeArgs = ['-p', config.prompt, '--model', config.model];
390
+
391
+ console.log(` Running: claude -p "<prompt>" --model ${config.model}`);
392
+ console.log('');
393
+
394
+ const { status } = spawnSync(claudeBin, claudeArgs, {
395
+ stdio: 'inherit',
396
+ cwd: resolve(process.cwd()),
397
+ env: process.env,
398
+ });
399
+
400
+ process.exit(status ?? 0);
401
+ }
402
+
403
+ // ─── Main (direct execution only) ──────────────────────────────────────────
404
+
405
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
406
+ const cliFlags = parseCliFlags(process.argv.slice(2));
407
+
408
+ if (cliFlags.list) {
409
+ printTable(listTemplates());
410
+ process.exit(0);
411
+ }
412
+
413
+ // --template <name> → build and print the agent prompt config (dry-run)
414
+ if (cliFlags.template) {
415
+ const config = buildAgentPrompt(cliFlags.template, cliFlags);
416
+ if (!config) {
417
+ console.error(` Unknown template: ${cliFlags.template}`);
418
+ console.error(` Available: ${Object.keys(TEMPLATES).join(', ')}`);
419
+ process.exit(1);
420
+ }
421
+ printAgentConfig(config, cliFlags.template);
422
+ process.exit(0);
423
+ }
424
+
425
+ // --run <name> → actually execute the agent
426
+ if (cliFlags.run) {
427
+ runAgent(cliFlags.run, cliFlags);
428
+ // runAgent calls process.exit internally
429
+ }
430
+
431
+ // No recognised command
432
+ console.log(`
433
+ Usage:
434
+ node agent-templates.mjs --list
435
+ node agent-templates.mjs --template <name> [--flag value ...]
436
+ node agent-templates.mjs --run <name> [--flag value ...]
437
+
438
+ Templates: ${Object.keys(TEMPLATES).join(', ')}
439
+ `);
440
+ process.exit(0);
441
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
13
13
  import { constants } from 'fs';
14
+ import { logHookError } from './error-channel.mjs';
14
15
 
15
16
  const LOCK_TIMEOUT_MS = 5_000;
16
17
  const STALE_LOCK_MS = 10_000;
@@ -86,8 +87,9 @@ export function lockedReadModifyWrite(filePath, modifyFn, defaultValue = {}) {
86
87
  const locked = acquireLock(lockPath);
87
88
 
88
89
  if (!locked) {
89
- // Timeout fall through without lock (better than hanging)
90
- // This matches the previous unlocked behavior as a degraded fallback
90
+ const err = new Error(`Lock acquisition timed out after ${LOCK_TIMEOUT_MS}ms for ${filePath}`);
91
+ logHookError('atomic-write', 'lockedReadModifyWrite', err, { filePath });
92
+ throw err;
91
93
  }
92
94
 
93
95
  try {
@@ -102,6 +104,6 @@ export function lockedReadModifyWrite(filePath, modifyFn, defaultValue = {}) {
102
104
  atomicWriteJSON(filePath, updated);
103
105
  return updated;
104
106
  } finally {
105
- if (locked) releaseLock(lockPath);
107
+ releaseLock(lockPath);
106
108
  }
107
109
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * config-validator.mjs — Validates orchestrator.json on load.
3
+ *
4
+ * Exports:
5
+ * validateConfig(config) → { valid: boolean, errors: string[], warnings: string[] }
6
+ * loadAndValidateConfig(configPath) → { config, validation }
7
+ *
8
+ * On invalid config: logs errors via error-channel.mjs, returns sensible defaults.
9
+ * On unknown top-level keys: warns (potential typos).
10
+ */
11
+
12
+ import { readFileSync } from 'fs';
13
+ import { logHookError } from './error-channel.mjs';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Known top-level keys (anything else triggers a typo warning)
17
+ // ---------------------------------------------------------------------------
18
+ const KNOWN_TOP_LEVEL_KEYS = new Set([
19
+ 'subscriptions',
20
+ 'tiers',
21
+ 'routing',
22
+ 'routing_rules',
23
+ 'quality_gate',
24
+ 'pricing_verified',
25
+ 'budgets',
26
+ 'providers',
27
+ 'dual_thinking',
28
+ ]);
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Sensible defaults for graceful degradation
32
+ // ---------------------------------------------------------------------------
33
+ const DEFAULT_CONFIG = {
34
+ subscriptions: {
35
+ claude: {
36
+ plan: '$100',
37
+ models: {
38
+ opus: { tier: 'think', name: 'opus', provider: 'claude' },
39
+ sonnet: { tier: 'execute', name: 'sonnet', provider: 'claude' },
40
+ haiku: { tier: 'search', name: 'haiku', provider: 'claude' },
41
+ },
42
+ },
43
+ },
44
+ tiers: {
45
+ search: { description: 'Read-only lookups' },
46
+ execute: { description: 'Implementation and edits' },
47
+ think: { description: 'Architecture and review' },
48
+ },
49
+ routing: {
50
+ strategy: 'hybrid-specialized-balanced',
51
+ },
52
+ quality_gate: {
53
+ enabled: true,
54
+ },
55
+ };
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Validation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Validate an orchestrator config object.
63
+ * Returns { valid, errors, warnings }.
64
+ */
65
+ export function validateConfig(config) {
66
+ const errors = [];
67
+ const warnings = [];
68
+
69
+ if (!config || typeof config !== 'object') {
70
+ errors.push('Config is not a valid object');
71
+ return { valid: false, errors, warnings };
72
+ }
73
+
74
+ // Required top-level keys
75
+ const requiredKeys = ['subscriptions', 'tiers', 'routing', 'quality_gate'];
76
+ for (const key of requiredKeys) {
77
+ if (!(key in config)) {
78
+ errors.push(`Missing required top-level key: "${key}"`);
79
+ }
80
+ }
81
+
82
+ // Unknown top-level keys (typo detection)
83
+ for (const key of Object.keys(config)) {
84
+ if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
85
+ warnings.push(`Unknown top-level key: "${key}" — possible typo?`);
86
+ }
87
+ }
88
+
89
+ // Validate subscriptions structure
90
+ if (config.subscriptions && typeof config.subscriptions === 'object') {
91
+ for (const [providerName, provider] of Object.entries(config.subscriptions)) {
92
+ if (!provider.models || typeof provider.models !== 'object') {
93
+ errors.push(`subscriptions.${providerName} missing "models" object`);
94
+ continue;
95
+ }
96
+ for (const [modelName, meta] of Object.entries(provider.models)) {
97
+ if (!meta.tier) {
98
+ errors.push(`subscriptions.${providerName}.models.${modelName} missing "tier"`);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ // Validate tiers has search, execute, think
105
+ if (config.tiers && typeof config.tiers === 'object') {
106
+ for (const required of ['search', 'execute', 'think']) {
107
+ if (!(required in config.tiers)) {
108
+ errors.push(`tiers missing required tier: "${required}"`);
109
+ }
110
+ }
111
+ }
112
+
113
+ return {
114
+ valid: errors.length === 0,
115
+ errors,
116
+ warnings,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Load orchestrator.json from disk, validate it, log issues, and return
122
+ * either the parsed config or sensible defaults on failure.
123
+ */
124
+ export function loadAndValidateConfig(configPath) {
125
+ let config;
126
+ try {
127
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
128
+ } catch (err) {
129
+ logHookError('config-validator', 'load', err, { configPath });
130
+ return { config: DEFAULT_CONFIG, validation: { valid: false, errors: [`Failed to parse config: ${err.message}`], warnings: [] } };
131
+ }
132
+
133
+ const validation = validateConfig(config);
134
+
135
+ // Log errors
136
+ for (const err of validation.errors) {
137
+ logHookError('config-validator', 'validate', new Error(err), { configPath });
138
+ }
139
+
140
+ // Log warnings to stderr (non-fatal)
141
+ for (const warn of validation.warnings) {
142
+ process.stderr.write(`[config-validator] WARNING: ${warn}\n`);
143
+ }
144
+
145
+ // If invalid, merge defaults for missing keys
146
+ if (!validation.valid) {
147
+ const merged = { ...DEFAULT_CONFIG, ...config };
148
+ if (!config.subscriptions) merged.subscriptions = DEFAULT_CONFIG.subscriptions;
149
+ if (!config.tiers) merged.tiers = DEFAULT_CONFIG.tiers;
150
+ if (!config.routing) merged.routing = DEFAULT_CONFIG.routing;
151
+ if (!config.quality_gate) merged.quality_gate = DEFAULT_CONFIG.quality_gate;
152
+ return { config: merged, validation };
153
+ }
154
+
155
+ return { config, validation };
156
+ }