dual-brain 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +29 -143
- package/bin/dual-brain.mjs +80 -44
- package/package.json +11 -2
- package/src/dispatch.mjs +87 -2
- package/src/head.mjs +353 -0
- package/src/health.mjs +156 -0
- package/src/integrity.mjs +245 -0
- package/src/profile.mjs +82 -1
- package/src/prompt-audit.mjs +231 -0
- package/src/templates.mjs +223 -0
- package/src/tui.mjs +79 -0
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
|
+
}
|