dual-brain 0.2.6 → 0.2.8

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/src/dispatch.mjs CHANGED
@@ -527,6 +527,60 @@ function getRetryBudget() {
527
527
  };
528
528
  }
529
529
 
530
+ // ─── Preflight auth check ─────────────────────────────────────────────────────
531
+
532
+ /**
533
+ * Verify a provider CLI is present and (optionally) responds to --version.
534
+ * Uses `which` for the fast path and a 3s-capped --version call to confirm.
535
+ *
536
+ * @param {'claude'|'openai'} provider
537
+ * @param {string} [cwd] Working directory (unused, kept for signature parity)
538
+ * @returns {Promise<{ ready: boolean, provider: string, error?: string, suggestion?: string }>}
539
+ */
540
+ async function preflightAuth(provider, _cwd) {
541
+ const bin = provider === 'openai' ? 'codex' : 'claude';
542
+
543
+ // Fast path: check binary existence with `which`
544
+ const whichResult = await new Promise((resolve) => {
545
+ const p = spawn('which', [bin], { stdio: 'pipe' });
546
+ p.on('error', () => resolve(false));
547
+ p.on('close', (code) => resolve(code === 0));
548
+ setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 2000);
549
+ });
550
+
551
+ if (!whichResult) {
552
+ const installHint = provider === 'openai'
553
+ ? 'Install: npm install -g @openai/codex'
554
+ : 'Install: npm install -g @anthropic-ai/claude-code';
555
+ return {
556
+ ready: false,
557
+ provider,
558
+ error: `${bin} CLI not found in PATH`,
559
+ suggestion: installHint,
560
+ };
561
+ }
562
+
563
+ // Version check: confirms the binary actually runs (catches broken installs)
564
+ const versionOk = await new Promise((resolve) => {
565
+ const p = spawn(bin, ['--version'], { stdio: 'pipe' });
566
+ p.on('error', () => resolve(false));
567
+ p.on('close', (code) => resolve(code === 0));
568
+ setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 3000);
569
+ });
570
+
571
+ if (!versionOk) {
572
+ const loginHint = provider === 'openai' ? 'Run: codex login' : 'Run: claude login';
573
+ return {
574
+ ready: false,
575
+ provider,
576
+ error: `${bin} --version failed (auth may have expired)`,
577
+ suggestion: loginHint,
578
+ };
579
+ }
580
+
581
+ return { ready: true, provider };
582
+ }
583
+
530
584
  // ─── Command builder ──────────────────────────────────────────────────────────
531
585
 
532
586
  function buildCommand(decision, prompt, files = [], _cwd) {
@@ -840,6 +894,34 @@ async function dispatch(input = {}) {
840
894
  }
841
895
  }
842
896
 
897
+ // ── Preflight auth check ─────────────────────────────────────────────────
898
+ // Verify the target provider CLI is present and responsive before dispatching.
899
+ // Runs after model/provider resolution so we check the effective provider.
900
+ const preflight = await preflightAuth(effectiveProvider, cwd);
901
+ if (!preflight.ready) {
902
+ // Check if the other provider is available as a fallback
903
+ const otherProvider = effectiveProvider === 'claude' ? 'openai' : 'claude';
904
+ const otherPreflight = await preflightAuth(otherProvider, cwd);
905
+ const fallbackNote = otherPreflight.ready
906
+ ? ` Fallback available: ${otherProvider}.`
907
+ : '';
908
+ const errMsg = `${preflight.error}. ${preflight.suggestion}${fallbackNote}`;
909
+ return {
910
+ status: 'error',
911
+ provider: effectiveProvider,
912
+ model: effectiveModel,
913
+ command: null,
914
+ exitCode: null,
915
+ summary: errMsg,
916
+ durationMs: 0,
917
+ usage: null,
918
+ error: errMsg,
919
+ authVerified: false,
920
+ suggestion: preflight.suggestion,
921
+ };
922
+ }
923
+ // ── End preflight auth check ─────────────────────────────────────────────
924
+
843
925
  // ── Feature 2: Dirty-worktree guard for execute-tier dispatches ──────────
844
926
  if (tier === 'execute' && decision.owns && !decision._force) {
845
927
  const wtCheck = await checkWorktreeClean(decision.owns, cwd);
@@ -902,6 +984,7 @@ async function dispatch(input = {}) {
902
984
  durationMs: 0,
903
985
  usage: null,
904
986
  error: null,
987
+ authVerified: true,
905
988
  };
906
989
  }
907
990
 
@@ -1014,6 +1097,7 @@ async function dispatch(input = {}) {
1014
1097
  usage,
1015
1098
  worktreeUsed: useWorktree,
1016
1099
  autoReview,
1100
+ authVerified: true,
1017
1101
  error: success ? null : errorText.slice(0, 200),
1018
1102
  };
1019
1103
  }
@@ -1021,7 +1105,7 @@ async function dispatch(input = {}) {
1021
1105
  const command = buildCommand(effectiveDecision, prompt, files, cwd);
1022
1106
 
1023
1107
  if (dryRun) {
1024
- return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
1108
+ return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null, authVerified: true };
1025
1109
  }
1026
1110
 
1027
1111
  // Record this dispatch against the budget
@@ -1130,6 +1214,7 @@ async function dispatch(input = {}) {
1130
1214
  usage,
1131
1215
  worktreeUsed: useWorktree,
1132
1216
  autoReview,
1217
+ authVerified: true,
1133
1218
  error: success ? null : errorText.slice(0, 200),
1134
1219
  };
1135
1220
  }
@@ -1221,4 +1306,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
1221
1306
  }
1222
1307
  }
1223
1308
 
1224
- export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt };
1309
+ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt, preflightAuth };
package/src/head.mjs ADDED
@@ -0,0 +1,353 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const STATE_FILE = join(process.cwd(), '.dualbrain', 'head-state.json');
5
+
6
+ // ── Conversation phases ──────────────────────────────────────────────────────
7
+ const PHASES = ['clarify', 'discuss', 'plan', 'dispatch', 'review', 'close'];
8
+
9
+ const VALID_TRANSITIONS = {
10
+ clarify: ['discuss', 'plan', 'close'],
11
+ discuss: ['plan', 'dispatch', 'clarify', 'close'],
12
+ plan: ['dispatch', 'discuss', 'close'],
13
+ dispatch: ['review', 'dispatch', 'close'],
14
+ review: ['dispatch', 'discuss', 'close'],
15
+ close: ['clarify'],
16
+ };
17
+
18
+ // ── Intent classification ────────────────────────────────────────────────────
19
+ const INTENT_PATTERNS = {
20
+ information: [
21
+ /\b(what|where|which|how many|show me|list|find|search|grep|explain)\b/i,
22
+ /\?$/,
23
+ ],
24
+ discussion: [
25
+ /\b(should we|what do you think|thoughts on|opinion|brainstorm|consider|tradeoff|approach)\b/i,
26
+ /\b(idea|strategy|philosophy|design|architecture)\b/i,
27
+ ],
28
+ action: [
29
+ /\b(build|create|fix|implement|add|remove|update|refactor|deploy|publish|ship|go|do it)\b/i,
30
+ /\b(parallel agents|dispatch|bump|install)\b/i,
31
+ ],
32
+ approval: [
33
+ /^(yes|y|ok|sure|do it|go|approved|lgtm|ship it)\s*$/i,
34
+ /\b(go ahead|sounds good|let's do it|proceed)\b/i,
35
+ ],
36
+ correction: [
37
+ /\b(no|stop|wait|hold|wrong|not that|don't|shouldn't|instead)\b/i,
38
+ /\b(actually|but|however)\b/i,
39
+ ],
40
+ };
41
+
42
+ /**
43
+ * Classify user intent from their message.
44
+ * Returns { intent, confidence, signals }
45
+ */
46
+ export function classifyIntent(message) {
47
+ const scores = { information: 0, discussion: 0, action: 0, approval: 0, correction: 0 };
48
+ const signals = [];
49
+
50
+ for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) {
51
+ for (const pattern of patterns) {
52
+ if (pattern.test(message)) {
53
+ scores[intent] += 1;
54
+ signals.push({ intent, pattern: pattern.source });
55
+ }
56
+ }
57
+ }
58
+
59
+ // Short messages that are just "yes"/"go" are almost always approval
60
+ if (message.trim().split(/\s+/).length <= 3) {
61
+ scores.approval += 1;
62
+ }
63
+
64
+ // Find highest scoring intent
65
+ const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
66
+ const top = sorted[0];
67
+ const second = sorted[1];
68
+
69
+ // Confidence based on margin between top two
70
+ const margin = top[1] - second[1];
71
+ const confidence = top[1] === 0 ? 0.3 : margin >= 2 ? 0.95 : margin >= 1 ? 0.8 : 0.6;
72
+
73
+ return {
74
+ intent: top[1] > 0 ? top[0] : 'unknown',
75
+ confidence,
76
+ scores,
77
+ signals,
78
+ ambiguous: confidence < 0.7,
79
+ };
80
+ }
81
+
82
+ // ── Conversation state ───────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Load current HEAD state from disk.
86
+ */
87
+ export function loadState() {
88
+ try {
89
+ if (existsSync(STATE_FILE)) {
90
+ const data = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
91
+ // Reset stale sessions (>30 min gap)
92
+ if (Date.now() - (data.lastActivity || 0) > 30 * 60 * 1000) {
93
+ return freshState();
94
+ }
95
+ return data;
96
+ }
97
+ } catch {}
98
+ return freshState();
99
+ }
100
+
101
+ function freshState() {
102
+ return {
103
+ sessionId: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
104
+ phase: 'clarify',
105
+ intent: 'unknown',
106
+ confidence: 0,
107
+ userGoal: null,
108
+ activeTasks: [],
109
+ decisions: [],
110
+ contextEstimate: { messages: 0, estimatedTokens: 0, compactionRisk: 'low' },
111
+ driftSignals: [],
112
+ lastActivity: Date.now(),
113
+ created: Date.now(),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Save HEAD state to disk.
119
+ */
120
+ export function saveState(state) {
121
+ state.lastActivity = Date.now();
122
+ mkdirSync(join(process.cwd(), '.dualbrain'), { recursive: true });
123
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
124
+ }
125
+
126
+ // ── Phase transitions ────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Attempt a phase transition. Returns { allowed, from, to, reason? }
130
+ */
131
+ export function transition(state, targetPhase) {
132
+ const from = state.phase;
133
+ const allowed = VALID_TRANSITIONS[from]?.includes(targetPhase) || false;
134
+
135
+ if (allowed) {
136
+ state.phase = targetPhase;
137
+ state.decisions.push({
138
+ type: 'phase-transition',
139
+ from,
140
+ to: targetPhase,
141
+ timestamp: Date.now(),
142
+ });
143
+ }
144
+
145
+ return {
146
+ allowed,
147
+ from,
148
+ to: targetPhase,
149
+ reason: allowed ? null : `Cannot transition from ${from} to ${targetPhase}. Valid: ${(VALID_TRANSITIONS[from] || []).join(', ')}`,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Suggest the appropriate phase based on intent and current state.
155
+ */
156
+ export function suggestPhase(state, intent) {
157
+ const map = {
158
+ information: state.phase === 'clarify' ? 'clarify' : 'discuss',
159
+ discussion: 'discuss',
160
+ action: state.phase === 'discuss' || state.phase === 'plan' ? 'dispatch' : 'plan',
161
+ approval: state.phase === 'plan' ? 'dispatch' : state.phase,
162
+ correction: state.phase === 'dispatch' ? 'review' : 'clarify',
163
+ unknown: 'clarify',
164
+ };
165
+ return map[intent] || 'clarify';
166
+ }
167
+
168
+ // ── Confidence tracker ───────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Run the 4-question confidence check before dispatching.
172
+ * Returns { ready, score, checks }
173
+ */
174
+ export function checkConfidence(state) {
175
+ const checks = {
176
+ understandIntent: {
177
+ pass: !!(state.userGoal && state.intent !== 'unknown'),
178
+ question: 'Do I understand the user\'s intent?',
179
+ },
180
+ discussedApproach: {
181
+ pass: state.decisions.some(d => d.type === 'phase-transition' && d.to === 'discuss') || state.phase === 'plan',
182
+ question: 'Have we discussed the approach?',
183
+ },
184
+ honestAboutUnknowns: {
185
+ pass: state.driftSignals.length === 0 || state.driftSignals.every(s => s.resolved),
186
+ question: 'Am I honest about unknowns?',
187
+ },
188
+ reversible: {
189
+ pass: true, // default; caller should override for high-risk
190
+ question: 'Is this reversible?',
191
+ },
192
+ };
193
+
194
+ const passing = Object.values(checks).filter(c => c.pass).length;
195
+ const total = Object.values(checks).length;
196
+
197
+ return {
198
+ ready: passing === total,
199
+ score: passing / total,
200
+ passing,
201
+ total,
202
+ checks,
203
+ };
204
+ }
205
+
206
+ // ── Drift detection ──────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Check if HEAD's current action is consistent with its declared phase.
210
+ */
211
+ export function detectDrift(state, action) {
212
+ const signals = [];
213
+
214
+ // Acting while in discuss phase
215
+ if (state.phase === 'clarify' && action.type === 'dispatch') {
216
+ signals.push({ signal: 'dispatch-before-discuss', severity: 'high', msg: 'Dispatching work before discussing approach' });
217
+ }
218
+
219
+ // Dispatching without acceptance criteria
220
+ if (action.type === 'dispatch' && (!action.task?.acceptanceCriteria || action.task.acceptanceCriteria.length === 0)) {
221
+ signals.push({ signal: 'no-acceptance-criteria', severity: 'medium', msg: 'Dispatch without acceptance criteria' });
222
+ }
223
+
224
+ // HEAD doing implementation work
225
+ if (['edit', 'write', 'bash-impl'].includes(action.type)) {
226
+ signals.push({ signal: 'head-implementing', severity: 'critical', msg: 'HEAD attempting direct implementation' });
227
+ }
228
+
229
+ // Repeated dispatch failures
230
+ const recentFailures = (state.activeTasks || []).filter(t => t.status === 'failed' && Date.now() - t.endedAt < 300000);
231
+ if (recentFailures.length >= 2) {
232
+ signals.push({ signal: 'repeated-failures', severity: 'high', msg: `${recentFailures.length} recent dispatch failures — consider changing approach` });
233
+ }
234
+
235
+ // Context getting large
236
+ if (state.contextEstimate.estimatedTokens > 150000) {
237
+ signals.push({ signal: 'context-pressure', severity: 'medium', msg: 'Context estimate exceeding 150k tokens — compaction risk' });
238
+ }
239
+
240
+ if (signals.length > 0) {
241
+ state.driftSignals.push(...signals.map(s => ({ ...s, timestamp: Date.now(), resolved: false })));
242
+ }
243
+
244
+ return signals;
245
+ }
246
+
247
+ // ── Context budget ───────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Update context estimate. Called after each turn.
251
+ */
252
+ export function updateContextEstimate(state, opts = {}) {
253
+ const { messageCount, lastResponseTokens } = opts;
254
+
255
+ if (messageCount) state.contextEstimate.messages = messageCount;
256
+ if (lastResponseTokens) state.contextEstimate.estimatedTokens += lastResponseTokens;
257
+
258
+ // Rough compaction risk
259
+ const tokens = state.contextEstimate.estimatedTokens;
260
+ state.contextEstimate.compactionRisk =
261
+ tokens > 180000 ? 'critical' :
262
+ tokens > 120000 ? 'high' :
263
+ tokens > 80000 ? 'medium' : 'low';
264
+
265
+ return state.contextEstimate;
266
+ }
267
+
268
+ // ── Task tracking ────────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Register a dispatched task.
272
+ */
273
+ export function trackTask(state, task) {
274
+ state.activeTasks.push({
275
+ id: task.id || Date.now().toString(36),
276
+ objective: task.objective,
277
+ tier: task.tier,
278
+ provider: task.provider,
279
+ status: 'dispatched',
280
+ startedAt: Date.now(),
281
+ endedAt: null,
282
+ result: null,
283
+ });
284
+ return state;
285
+ }
286
+
287
+ /**
288
+ * Update a task's status.
289
+ */
290
+ export function completeTask(state, taskId, result) {
291
+ const task = state.activeTasks.find(t => t.id === taskId);
292
+ if (task) {
293
+ task.status = result.success ? 'completed' : 'failed';
294
+ task.endedAt = Date.now();
295
+ task.result = result;
296
+ }
297
+ return state;
298
+ }
299
+
300
+ // ── Decision logging ─────────────────────────────────────────────────────────
301
+
302
+ /**
303
+ * Log a HEAD decision.
304
+ */
305
+ export function logDecision(state, decision) {
306
+ state.decisions.push({
307
+ ...decision,
308
+ timestamp: Date.now(),
309
+ });
310
+ return state;
311
+ }
312
+
313
+ // ── Convenience: process a user message ──────────────────────────────────────
314
+
315
+ /**
316
+ * Full turn processor: classify intent, suggest phase, detect drift, update state.
317
+ * Returns guidance for HEAD on what to do next.
318
+ */
319
+ export function processTurn(state, userMessage) {
320
+ const intent = classifyIntent(userMessage);
321
+ const suggestedPhase = suggestPhase(state, intent.intent);
322
+
323
+ state.intent = intent.intent;
324
+ state.confidence = intent.confidence;
325
+
326
+ // Auto-transition if the suggested phase is valid
327
+ const transitionResult = suggestedPhase !== state.phase
328
+ ? transition(state, suggestedPhase)
329
+ : { allowed: true, from: state.phase, to: state.phase };
330
+
331
+ // Check confidence if we're about to dispatch
332
+ const confidenceCheck = suggestedPhase === 'dispatch' ? checkConfidence(state) : null;
333
+
334
+ // Build guidance
335
+ const guidance = {
336
+ intent,
337
+ phase: state.phase,
338
+ suggestedPhase,
339
+ transitioned: transitionResult.allowed && transitionResult.from !== transitionResult.to,
340
+ confidenceCheck,
341
+ shouldDispatch: suggestedPhase === 'dispatch' && (!confidenceCheck || confidenceCheck.ready),
342
+ shouldClarify: intent.ambiguous || intent.intent === 'unknown',
343
+ shouldDiscuss: intent.intent === 'discussion' || (suggestedPhase === 'dispatch' && confidenceCheck && !confidenceCheck.ready),
344
+ };
345
+
346
+ saveState(state);
347
+ return guidance;
348
+ }
349
+
350
+ // ── Exports summary ──────────────────────────────────────────────────────────
351
+ // classifyIntent, loadState, saveState, freshState (via loadState),
352
+ // transition, suggestPhase, checkConfidence, detectDrift,
353
+ // updateContextEstimate, trackTask, completeTask, logDecision, processTurn
package/src/health.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
14
15
 
15
16
  // ─── Auth status (delegates to replit-tools when available) ──────────────────
16
17
 
@@ -370,3 +371,158 @@ export function remainingCooldownMinutes(provider, modelClass, cwd) {
370
371
  const remaining = cooldownMs - elapsedMs;
371
372
  return remaining > 0 ? Math.ceil(remaining / 60_000) : 0;
372
373
  }
374
+
375
+ // ─── Hook health check ────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * Extract the file path from a hook command string.
379
+ * Handles patterns like `node /path/to/hook.mjs` or `node /path/to/hook.mjs --flag`.
380
+ * Returns null if the pattern doesn't match.
381
+ * @param {string} command
382
+ * @returns {string|null}
383
+ */
384
+ function extractHookPath(command) {
385
+ if (typeof command !== 'string') return null;
386
+ const match = command.match(/node\s+([^\s]+\.mjs)/);
387
+ return match ? match[1] : null;
388
+ }
389
+
390
+ /**
391
+ * Collect all hook entries from a settings object, returning
392
+ * [{ command, eventType }] pairs.
393
+ * @param {object} settings
394
+ * @returns {{ command: string, eventType: string }[]}
395
+ */
396
+ function collectHookCommands(settings) {
397
+ const entries = [];
398
+ const hooks = settings?.hooks ?? {};
399
+ for (const [eventType, matchers] of Object.entries(hooks)) {
400
+ if (!Array.isArray(matchers)) continue;
401
+ for (const matcher of matchers) {
402
+ for (const hook of (matcher?.hooks ?? [])) {
403
+ if (hook?.type === 'command' && typeof hook.command === 'string') {
404
+ entries.push({ command: hook.command, eventType });
405
+ }
406
+ }
407
+ }
408
+ }
409
+ return entries;
410
+ }
411
+
412
+ /**
413
+ * Load and parse a JSON settings file. Returns {} on any error.
414
+ * @param {string} filePath
415
+ * @returns {object}
416
+ */
417
+ function loadSettings(filePath) {
418
+ if (!existsSync(filePath)) return {};
419
+ try {
420
+ return JSON.parse(readFileSync(filePath, 'utf8'));
421
+ } catch {
422
+ return {};
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Check the health of all hook files referenced in project-local and global
428
+ * Claude Code settings.
429
+ *
430
+ * @param {string} [cwd] — project root (defaults to process.cwd())
431
+ * @returns {{
432
+ * healthy: boolean,
433
+ * hooks: Array<{ path: string, exists: boolean, syntaxValid: boolean, source: 'local'|'global', duplicate: boolean }>,
434
+ * conflicts: string[],
435
+ * degraded: string[],
436
+ * missing: string[],
437
+ * }}
438
+ */
439
+ export function checkHookHealth(cwd) {
440
+ const root = cwd ?? process.cwd();
441
+ const home = process.env.HOME || '/root';
442
+
443
+ const localSettingsPath = join(root, '.claude', 'settings.local.json');
444
+ const globalSettingsPath = join(home, '.claude', 'settings.json');
445
+
446
+ const localSettings = loadSettings(localSettingsPath);
447
+ const globalSettings = loadSettings(globalSettingsPath);
448
+
449
+ const localCommands = collectHookCommands(localSettings);
450
+ const globalCommands = collectHookCommands(globalSettings);
451
+
452
+ // Build a set of hook paths from local settings for duplicate detection
453
+ const localPaths = new Set(localCommands.map(e => extractHookPath(e.command)).filter(Boolean));
454
+ const globalPaths = new Set(globalCommands.map(e => extractHookPath(e.command)).filter(Boolean));
455
+
456
+ // Paths that appear in both local and global are conflicts
457
+ const conflictPaths = new Set([...localPaths].filter(p => globalPaths.has(p)));
458
+
459
+ const hookResults = [];
460
+ const conflicts = [];
461
+ const degraded = [];
462
+ const missing = [];
463
+
464
+ function processEntry(entry, source) {
465
+ const path = extractHookPath(entry.command);
466
+ if (!path) return; // non-node hook — skip
467
+
468
+ const fileExists = existsSync(path);
469
+ const isDuplicate = conflictPaths.has(path);
470
+
471
+ let syntaxValid = false;
472
+ if (fileExists) {
473
+ try {
474
+ const check = spawnSync('node', ['--check', path], {
475
+ timeout: 3000,
476
+ encoding: 'utf8',
477
+ });
478
+ syntaxValid = check.status === 0;
479
+ } catch {
480
+ syntaxValid = false;
481
+ }
482
+ }
483
+
484
+ const record = { path, exists: fileExists, syntaxValid, source, duplicate: isDuplicate };
485
+ hookResults.push(record);
486
+
487
+ if (!fileExists) {
488
+ missing.push(`${source}: ${path} (file not found)`);
489
+ } else if (!syntaxValid) {
490
+ degraded.push(`${source}: ${path} (syntax error)`);
491
+ }
492
+
493
+ if (isDuplicate && source === 'global') {
494
+ // Only report the conflict once (when we encounter it from the global side)
495
+ conflicts.push(`Hook defined in both local and global settings: ${path}`);
496
+ }
497
+ }
498
+
499
+ for (const entry of localCommands) processEntry(entry, 'local');
500
+ for (const entry of globalCommands) processEntry(entry, 'global');
501
+
502
+ const healthy = missing.length === 0 && degraded.length === 0 && conflicts.length === 0;
503
+
504
+ return { healthy, hooks: hookResults, conflicts, degraded, missing };
505
+ }
506
+
507
+ // ─── Hook smoke test ──────────────────────────────────────────────────────────
508
+
509
+ /**
510
+ * Run a hook with deliberately malformed input to verify it fails open
511
+ * (exits 0 even on bad input, so it never blocks the Claude Code flow).
512
+ *
513
+ * @param {string} hookPath
514
+ * @returns {{ path: string, failsOpen: boolean, stderr?: string, error?: string }}
515
+ */
516
+ export function runHookSmoke(hookPath) {
517
+ try {
518
+ const result = spawnSync('node', [hookPath], {
519
+ input: 'not valid json',
520
+ timeout: 5000,
521
+ encoding: 'utf8',
522
+ });
523
+ // Exit 0 = fails open (good), Exit non-0 = fails closed (bad)
524
+ return { path: hookPath, failsOpen: result.status === 0, stderr: (result.stderr || '').slice(0, 200) };
525
+ } catch {
526
+ return { path: hookPath, failsOpen: false, error: 'smoke test crashed' };
527
+ }
528
+ }