dual-brain 0.2.7 → 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/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
+ }