dual-brain 6.0.1 → 6.1.1

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.
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // dual-brain — CLI entry point. Commands: init, go, status, remember, forget
3
3
 
4
- import { readFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { execSync } from 'node:child_process';
8
8
 
9
9
  import {
10
- ensureProfile, loadProfile, runOnboarding,
10
+ ensureProfile, loadProfile, saveProfile, runOnboarding,
11
11
  rememberPreference, forgetPreference, getActivePreferences,
12
12
  getAvailableProviders, isSoloBrain, getHeadModel,
13
13
  } from '../src/profile.mjs';
@@ -15,11 +15,18 @@ import {
15
15
  import { detectTask } from '../src/detect.mjs';
16
16
 
17
17
  import {
18
- decideRoute, getAvailableModels, estimateBudgetPressure,
18
+ decideRoute, getAvailableModels,
19
19
  } from '../src/decide.mjs';
20
20
 
21
+ import {
22
+ getHealth, markHot, markHealthy, remainingCooldownMinutes, getSessionStats,
23
+ } from '../src/health.mjs';
24
+
21
25
  import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
22
26
 
27
+ import { loadRepoCache } from '../src/repo.mjs';
28
+ import { loadSession, saveSession, formatSessionCard } from '../src/session.mjs';
29
+
23
30
  // ─── Helpers ─────────────────────────────────────────────────────────────────
24
31
 
25
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -30,6 +37,7 @@ function readVersion() {
30
37
  }
31
38
  function flag(args, name) { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; }
32
39
  function err(msg) { process.stderr.write(`Error: ${msg}\n`); process.exit(1); }
40
+ function vtrace(msg) { process.stderr.write(`[verbose] ${msg}\n`); }
33
41
 
34
42
  function printHelp() {
35
43
  console.log(`
@@ -41,20 +49,47 @@ Commands:
41
49
  go "task description" Detect → decide → dispatch a task
42
50
  --dry-run Show routing decision without executing
43
51
  --files a.mjs,b.mjs Provide file context for risk classification
44
- status Provider health, budget pressure, available models
52
+ --verbose, -v Print routing trace (intent, risk, health, model selection)
53
+ status Provider health, session stats, available models
54
+ --verbose, -v Also print profile file path and raw profile object
55
+ hot <provider> Manually mark all model classes for provider as hot
56
+ cool <provider> Manually clear hot state for a provider
45
57
  remember "preference" Save a project-scoped preference
46
58
  forget "preference" Remove a preference by fuzzy match
47
59
 
48
60
  Options:
49
61
  --version Print version
50
62
  --help Show this help
63
+ --verbose, -v Enable verbose routing trace output (stderr)
51
64
  `.trim());
52
65
  }
53
66
 
67
+ // ─── Card command (default) ──────────────────────────────────────────────────
68
+
69
+ async function cmdCard() {
70
+ const cwd = process.cwd();
71
+ const { homedir } = await import('node:os');
72
+ const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
73
+ const projectPath = join(cwd, '.dualbrain', 'profile.json');
74
+
75
+ if (!existsSync(projectPath) && !existsSync(globalPath)) {
76
+ console.log('Welcome to dual-brain! Let\'s set up your profile.\n');
77
+ await cmdInit();
78
+ return;
79
+ }
80
+
81
+ const repo = loadRepoCache(cwd);
82
+ const session = loadSession(cwd);
83
+ const health = getHealth(cwd);
84
+ const card = formatSessionCard(session, repo, health);
85
+ console.log(card);
86
+ }
87
+
54
88
  // ─── Commands ─────────────────────────────────────────────────────────────────
55
89
 
56
90
  async function cmdInit() {
57
91
  const profile = await runOnboarding({ interactive: true });
92
+ saveProfile(profile, { cwd: process.cwd() });
58
93
  const rt = await detectRuntime();
59
94
  const providers = getAvailableProviders(profile);
60
95
  const providerSummary = providers.length
@@ -65,14 +100,15 @@ async function cmdInit() {
65
100
 
66
101
  async function cmdGo(args) {
67
102
  const dryRun = args.includes('--dry-run');
103
+ const verbose = args.includes('--verbose') || args.includes('-v');
68
104
  const filesRaw = flag(args, '--files');
69
105
  const files = filesRaw && typeof filesRaw === 'string'
70
106
  ? filesRaw.split(',').map(f => f.trim()).filter(Boolean)
71
107
  : [];
72
108
 
73
109
  // prompt is the first non-flag argument (or value after --dry-run which is boolean)
74
- const prompt = args.find(a => !a.startsWith('--') && a !== (filesRaw ?? ''));
75
- if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b]');
110
+ const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
111
+ if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b] [--verbose]');
76
112
 
77
113
  const cwd = process.cwd();
78
114
  const profile = await ensureProfile(cwd);
@@ -81,8 +117,44 @@ async function cmdGo(args) {
81
117
  // Print the one-sentence classification
82
118
  console.log(detection.explanation);
83
119
 
120
+ // Verbose: emit detection trace before routing decision
121
+ if (verbose) {
122
+ vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
123
+ vtrace(`Tier: ${detection.tier} | Files: ${detection.fileCount ?? files.length} | Requires write: ${detection.requiresWrite}`);
124
+ }
125
+
126
+ // Verbose: emit provider health scores before dispatch
127
+ if (verbose) {
128
+ const providers = getAvailableProviders(profile);
129
+ const { states } = getHealth(cwd);
130
+ const providerScores = ['claude', 'openai'].map(name => {
131
+ const enabled = providers.some(p => p.name === name);
132
+ if (!enabled) return `${name}=unavailable`;
133
+ // Find any state entry for this provider
134
+ const statuses = Object.entries(states)
135
+ .filter(([k]) => k.startsWith(`${name}:`))
136
+ .map(([, v]) => v.status);
137
+ const worst = statuses.includes('hot') ? 'hot'
138
+ : statuses.includes('probing') ? 'probing'
139
+ : statuses.includes('degraded') ? 'degraded'
140
+ : 'healthy';
141
+ return `${name}=${worst}`;
142
+ }).join(' ');
143
+ vtrace(`Provider health: ${providerScores}`);
144
+ }
145
+
84
146
  const decision = decideRoute({ profile, detection, cwd });
85
147
 
148
+ // Verbose: emit model selection and dual-brain rationale
149
+ if (verbose) {
150
+ const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
151
+ const modelStatus = getAvailableModels(profile)[decision.provider]?.includes(decision.model)
152
+ ? 'available, matches tier'
153
+ : 'selected';
154
+ vtrace(`Model selection: ${modelLabel} (${modelStatus})`);
155
+ vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
156
+ }
157
+
86
158
  // Print routing table
87
159
  console.log(` provider : ${decision.provider}`);
88
160
  console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
@@ -102,38 +174,85 @@ async function cmdGo(args) {
102
174
  console.log(`\nConsensus: ${result.consensus}`);
103
175
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
104
176
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
177
+ // Save session state
178
+ saveSession({
179
+ objective: prompt,
180
+ branch: null,
181
+ filesChanged: files,
182
+ commandsRun: [`dual-brain go "${prompt}"`],
183
+ lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
184
+ provider: decision.provider,
185
+ nextAction: null,
186
+ }, cwd);
105
187
  } else {
106
188
  result = await dispatch({ decision, prompt, files, cwd });
107
189
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
108
190
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
109
191
  if (result.summary) console.log(result.summary);
110
192
  if (result.error) process.stderr.write(`${result.error}\n`);
111
- if (result.status !== 'completed') process.exit(1); }
193
+ // Save session state regardless of success/failure
194
+ saveSession({
195
+ objective: prompt,
196
+ branch: null,
197
+ filesChanged: files,
198
+ commandsRun: [`dual-brain go "${prompt}"`],
199
+ lastResult: {
200
+ status: result.status === 'completed' ? 'success' : 'failure',
201
+ summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
202
+ },
203
+ provider: decision.provider,
204
+ nextAction: null,
205
+ }, cwd);
206
+ if (result.status !== 'completed') process.exit(1);
207
+ }
112
208
  }
113
209
 
114
- async function cmdStatus() {
210
+ async function cmdStatus(args = []) {
211
+ const verbose = args.includes('--verbose') || args.includes('-v');
115
212
  const cwd = process.cwd();
116
213
  const profile = loadProfile(cwd);
117
214
  const rt = await detectRuntime();
118
215
  const providers = getAvailableProviders(profile);
119
- const pressure = estimateBudgetPressure(profile, cwd);
120
216
  const available = getAvailableModels(profile);
121
217
  const prefs = getActivePreferences(cwd);
218
+ const { states } = getHealth(cwd);
219
+ const sessionStats = getSessionStats(cwd);
122
220
 
123
221
  console.log('=== Dual-Brain Status ===\n');
124
222
 
125
- // Providers
223
+ // Providers + health
126
224
  console.log('Providers:');
127
225
  if (providers.length === 0) {
128
226
  console.log(' (none configured — run: dual-brain init)');
129
227
  } else {
130
228
  for (const p of providers) {
131
229
  const label = p.name === 'claude' ? 'Claude' : 'OpenAI';
132
- const pct = Math.round((pressure[p.name] ?? 0) * 100);
133
- console.log(` ${label} plan=${p.plan} budget=${pct}% used`);
230
+ // Collect all model-class states for this provider
231
+ const provStates = Object.entries(states)
232
+ .filter(([k]) => k.startsWith(`${p.name}:`));
233
+ const sess = sessionStats[p.name] ?? { calls: 0, tokens: 0 };
234
+
235
+ if (provStates.length === 0) {
236
+ console.log(` ${label} plan=${p.plan} status=healthy calls=${sess.calls} tokens=${sess.tokens}`);
237
+ } else {
238
+ for (const [k, st] of provStates) {
239
+ const modelClass = k.split(':').slice(1).join(':');
240
+ let statusStr = st.status;
241
+ if (st.status === 'hot') {
242
+ const remaining = remainingCooldownMinutes(p.name, modelClass, cwd);
243
+ statusStr = remaining > 0 ? `hot (retry in ${remaining}m)` : 'hot (cooling)';
244
+ }
245
+ console.log(` ${label} plan=${p.plan} model=${modelClass} status=${statusStr} calls=${sess.calls} tokens=${sess.tokens}`);
246
+ }
247
+ }
134
248
  }
135
249
  }
136
250
 
251
+ // Session totals
252
+ const totalCalls = Object.values(sessionStats).reduce((s, v) => s + v.calls, 0);
253
+ const totalTokens = Object.values(sessionStats).reduce((s, v) => s + v.tokens, 0);
254
+ console.log(`\nSession: ${totalCalls} dispatch${totalCalls !== 1 ? 'es' : ''}, ${totalTokens} tokens observed`);
255
+
137
256
  // Models
138
257
  console.log('\nAvailable models:');
139
258
  if (available.claude.length) console.log(` Claude : ${available.claude.join(', ')}`);
@@ -154,20 +273,117 @@ async function cmdStatus() {
154
273
  console.log(`\nPreferences: ${prefs.length ? '' : '(none)'}`);
155
274
  for (const p of prefs) console.log(` [${p.scope}] ${p.text}`);
156
275
 
276
+ // Verbose: profile file path and raw object
277
+ if (verbose) {
278
+ const { homedir } = await import('node:os');
279
+ const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
280
+ const projectPath = join(cwd, '.dualbrain', 'profile.json');
281
+ const { existsSync } = await import('node:fs');
282
+ const loadedFrom = existsSync(projectPath) ? projectPath : existsSync(globalPath) ? globalPath : '(defaults)';
283
+ vtrace(`Profile file: ${loadedFrom}`);
284
+ vtrace(`Raw profile:\n${JSON.stringify(profile, null, 2)}`);
285
+ }
286
+
287
+ // Enforcement health check
288
+ console.log('\nEnforcement:');
289
+ try {
290
+ const { readFileSync: rfs, existsSync: exs } = await import('node:fs');
291
+ const settingsFile = join(cwd, '.claude', 'settings.json');
292
+ if (!exs(settingsFile)) {
293
+ console.log(' NOT INSTALLED — run: dual-brain install');
294
+ } else {
295
+ const settings = JSON.parse(rfs(settingsFile, 'utf8'));
296
+ const preToolUse = settings?.hooks?.PreToolUse ?? [];
297
+ const guardCmd = 'bash .claude/hooks/head-guard.sh';
298
+ const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
299
+ const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
300
+ const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
301
+ const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
302
+ const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
303
+ const activeCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
304
+ if (activeCount === 4) {
305
+ console.log(` active (${activeCount} guards: Edit, Write, Bash, Agent)`);
306
+ } else {
307
+ const missing = [
308
+ !hasEdit && 'Edit',
309
+ !hasWrite && 'Write',
310
+ !hasBash && 'Bash',
311
+ !hasAgent && 'Agent',
312
+ ].filter(Boolean);
313
+ console.log(` PARTIAL — missing guards: ${missing.join(', ')} — run: dual-brain install`);
314
+ }
315
+ }
316
+ } catch {
317
+ console.log(' unknown (could not read .claude/settings.json)');
318
+ }
319
+
157
320
  // Update check
158
321
  try {
159
322
  const localVer = readVersion();
160
323
  const remoteVer = execSync('npm view dual-brain version 2>/dev/null', { timeout: 5000 }).toString().trim();
161
- if (remoteVer && remoteVer !== localVer) {
162
- console.log(`\nUpdate available: npm i -g dual-brain@latest (${localVer} → ${remoteVer})`);
324
+ if (remoteVer) {
325
+ const localParts = localVer.split('.').map(Number);
326
+ const remoteParts = remoteVer.split('.').map(Number);
327
+ const updateAvailable =
328
+ remoteParts[0] > localParts[0]
329
+ || (remoteParts[0] === localParts[0] && remoteParts[1] > localParts[1])
330
+ || (remoteParts[0] === localParts[0] && remoteParts[1] === localParts[1] && remoteParts[2] > localParts[2]);
331
+ if (updateAvailable) {
332
+ console.log(`\nUpdate available: npm i -g dual-brain@latest (${localVer} → ${remoteVer})`);
333
+ }
163
334
  }
164
335
  } catch { /* network unavailable — skip */ }
165
336
  }
166
337
 
338
+ // ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
339
+
340
+ const PROVIDER_MODEL_CLASSES = {
341
+ claude: ['haiku', 'sonnet', 'opus'],
342
+ openai: ['o4-mini', 'o3', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-5.4', 'gpt-5.5'],
343
+ };
344
+
345
+ function cmdHot(providerArg) {
346
+ if (!providerArg) err('Usage: dual-brain hot <provider> (claude | openai)');
347
+ const provider = providerArg.toLowerCase();
348
+ const classes = PROVIDER_MODEL_CLASSES[provider];
349
+ if (!classes) err(`Unknown provider: ${provider}. Use "claude" or "openai".`);
350
+ const cwd = process.cwd();
351
+ for (const mc of classes) markHot(provider, mc, cwd);
352
+ console.log(`Marked ${classes.length} model classes as hot for ${provider}.`);
353
+ }
354
+
355
+ function cmdCool(providerArg) {
356
+ if (!providerArg) err('Usage: dual-brain cool <provider> (claude | openai)');
357
+ const provider = providerArg.toLowerCase();
358
+ const classes = PROVIDER_MODEL_CLASSES[provider];
359
+ if (!classes) err(`Unknown provider: ${provider}. Use "claude" or "openai".`);
360
+ const cwd = process.cwd();
361
+ for (const mc of classes) markHealthy(provider, mc, cwd);
362
+ console.log(`Cleared hot state for all ${provider} model classes.`);
363
+ }
364
+
167
365
  async function cmdInstall() {
366
+ const cwd = process.cwd();
367
+
368
+ // Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
168
369
  const { spawnSync } = await import('child_process');
169
- const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd: process.cwd() });
170
- process.exit(result.status || 0);
370
+ const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd });
371
+ if (result.status !== 0) { process.exit(result.status || 1); }
372
+
373
+ // Additionally merge enforcement hooks into .claude/settings.json
374
+ const { installHooks } = await import('../src/install-hooks.mjs');
375
+ const { installed, skipped } = installHooks(cwd);
376
+
377
+ if (installed.length > 0) {
378
+ console.log(`\nEnforcement hooks installed (${installed.length}):`);
379
+ for (const item of installed) console.log(` + ${item}`);
380
+ }
381
+ if (skipped.length > 0) {
382
+ console.log(`Enforcement hooks already present (${skipped.length}):`);
383
+ for (const item of skipped) console.log(` = ${item}`);
384
+ }
385
+
386
+ process.exit(0);
171
387
  }
172
388
 
173
389
  function cmdRemember(text) {
@@ -188,13 +404,16 @@ async function main() {
188
404
  const args = process.argv.slice(2);
189
405
  const cmd = args[0];
190
406
 
191
- if (!cmd || cmd === '--help' || cmd === '-h') { printHelp(); return; }
407
+ if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
408
+ if (!cmd) { await cmdCard(); return; }
192
409
  if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
193
410
 
194
411
  if (cmd === 'init') { await cmdInit(); return; }
195
412
  if (cmd === 'install') { await cmdInstall(); return; }
196
413
  if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
197
- if (cmd === 'status') { await cmdStatus(); return; }
414
+ if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
415
+ if (cmd === 'hot') { cmdHot(args[1]); return; }
416
+ if (cmd === 'cool') { cmdCool(args[1]); return; }
198
417
  if (cmd === 'remember') { cmdRemember(args[1]); return; }
199
418
  if (cmd === 'forget') { cmdForget(args[1]); return; }
200
419
 
@@ -8,6 +8,8 @@
8
8
  # Exit 0 → allow
9
9
  # Exit 2 → block (stderr message is shown to Claude)
10
10
 
11
+ BLOCK_MSG='[dual-brain] HEAD cannot use this tool directly. Dispatch via: dual-brain go "task description"'
12
+
11
13
  # ── 1. Role check ────────────────────────────────────────────────────────────
12
14
  # Only enforce when the session has been explicitly marked as the HEAD agent.
13
15
  # If the env var is unset we allow everything (backward compat for non-dual-brain usage).
@@ -24,80 +26,14 @@ fi
24
26
  # ── 2. Tool name check ───────────────────────────────────────────────────────
25
27
  TOOL="${CLAUDE_TOOL_NAME:-}"
26
28
 
27
- # Block direct file-editing tools unconditionally for HEAD.
29
+ # Block direct file-editing tools and Bash unconditionally for HEAD.
30
+ # HEAD should use Read tool for reading and Agent (via dual-brain go) for all other work.
28
31
  case "${TOOL}" in
29
- Edit|Write|NotebookEdit)
30
- echo "HEAD cannot implement directly. Use: node hooks/dispatch.mjs --task \"description\"" >&2
32
+ Edit|Write|NotebookEdit|Bash)
33
+ echo "${BLOCK_MSG}" >&2
31
34
  exit 2
32
35
  ;;
33
36
  esac
34
37
 
35
- # ── 3. Bash content check ────────────────────────────────────────────────────
36
- # For Bash calls, read stdin JSON and extract the "command" field, then scan for
37
- # write-side shell patterns. Pure bash + standard POSIX utilities — no node
38
- # startup, no network.
39
-
40
- if [[ "${TOOL}" == "Bash" ]]; then
41
- # Read the full JSON input from stdin.
42
- INPUT="$(cat)"
43
-
44
- # Extract the value of "command" from the JSON.
45
- # Strategy: grep for the key+value pair, then strip key prefix with sed.
46
- # Handles normal ASCII command strings (not escaped unicode — acceptable for a guard).
47
- CMD="$(printf '%s' "${INPUT}" \
48
- | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \
49
- | head -1 \
50
- | sed 's/^"command"[[:space:]]*:[[:space:]]*"//;s/"$//')"
51
-
52
- # If we couldn't extract a command (unusual JSON shape), allow through.
53
- if [[ -z "${CMD}" ]]; then
54
- exit 0
55
- fi
56
-
57
- # ── Blocked patterns ─────────────────────────────────────────────────────
58
-
59
- # sed with in-place flag (-i or combined flags like -ni)
60
- if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])sed[[:space:]].*-[a-zA-Z]*i'; then
61
- echo "HEAD cannot implement directly (sed -i). Use: node hooks/dispatch.mjs --task \"description\"" >&2
62
- exit 2
63
- fi
64
-
65
- # Redirect-write: cat > file, echo > file, printf > file (single > only, not >>)
66
- if printf '%s' "${CMD}" | grep -qE '(cat|echo|printf)[^|]*>[^>]'; then
67
- echo "HEAD cannot implement directly (redirect write). Use: node hooks/dispatch.mjs --task \"description\"" >&2
68
- exit 2
69
- fi
70
-
71
- # tee writing to a file path (tee /path or tee ./path or tee filename)
72
- if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])tee[[:space:]]+[^-]'; then
73
- echo "HEAD cannot implement directly (tee). Use: node hooks/dispatch.mjs --task \"description\"" >&2
74
- exit 2
75
- fi
76
-
77
- # patch command
78
- if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])patch[[:space:]]'; then
79
- echo "HEAD cannot implement directly (patch). Use: node hooks/dispatch.mjs --task \"description\"" >&2
80
- exit 2
81
- fi
82
-
83
- # mv / cp where the destination looks like a source code file
84
- if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])(mv|cp)[[:space:]].*\.(js|mjs|cjs|ts|tsx|py|sh|json|yaml|yml|toml|rb|go|rs|java|c|cpp|h|css|html|sql)([[:space:]]|$)'; then
85
- echo "HEAD cannot implement directly (mv/cp to source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
86
- exit 2
87
- fi
88
-
89
- # rm on source files
90
- if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])rm[[:space:]].*\.(js|mjs|cjs|ts|tsx|py|sh|json|yaml|yml|toml|rb|go|rs|java|c|cpp|h|css|html|sql)([[:space:]]|$)'; then
91
- echo "HEAD cannot implement directly (rm on source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
92
- exit 2
93
- fi
94
-
95
- # Explicitly allowed (read-only) patterns — documented here for clarity.
96
- # The checks above are specific enough that these don't need explicit allow rules,
97
- # but listing them makes the intent clear:
98
- # grep, find, cat <file (no redirect), git status/log/diff/show,
99
- # node --check, ls, wc, head, tail, jq (read), curl (read), etc.
100
- fi
101
-
102
- # ── 4. Default: allow ────────────────────────────────────────────────────────
38
+ # ── 3. Default: allow ────────────────────────────────────────────────────────
103
39
  exit 0
package/install.mjs CHANGED
@@ -744,39 +744,50 @@ function generateSettings(workspace) {
744
744
  let existing = {};
745
745
  try { existing = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {}
746
746
 
747
- const hooks = {
748
- PreToolUse: [
749
- {
750
- matcher: 'Agent',
751
- hooks: [{ type: 'command', command: 'node .claude/hooks/enforce-tier.mjs' }],
752
- },
753
- ],
754
- PostToolUse: [
755
- {
756
- matcher: '',
757
- hooks: [{ type: 'command', command: 'node .claude/hooks/cost-logger.mjs' }],
758
- },
759
- {
760
- matcher: '',
761
- hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }],
762
- },
763
- ],
764
- };
747
+ const HEAD_GUARD_CMD = 'bash .claude/hooks/head-guard.sh';
748
+ const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
749
+
750
+ // All dual-brain PreToolUse hooks we manage
751
+ const DESIRED_PRE = [
752
+ { matcher: 'Edit', command: HEAD_GUARD_CMD },
753
+ { matcher: 'Write', command: HEAD_GUARD_CMD },
754
+ { matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
755
+ { matcher: 'Bash', command: HEAD_GUARD_CMD },
756
+ { matcher: 'Agent', command: ENFORCE_TIER_CMD },
757
+ ];
765
758
 
766
759
  const DUAL_BRAIN_CMDS = [
767
- 'node .claude/hooks/enforce-tier.mjs',
760
+ HEAD_GUARD_CMD,
761
+ ENFORCE_TIER_CMD,
768
762
  'node .claude/hooks/cost-logger.mjs',
769
763
  'node .claude/hooks/auto-update-wrapper.mjs',
770
764
  ];
771
765
 
772
- const merged = { ...(existing.hooks || {}) };
773
- for (const [event, entries] of Object.entries(hooks)) {
774
- const existingEntries = (merged[event] || []).filter(e =>
775
- !e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
776
- );
777
- merged[event] = [...existingEntries, ...entries];
766
+ // Build merged PreToolUse: keep user entries that aren't ours, then add ours
767
+ const existingPre = (existing.hooks?.PreToolUse || []).filter(e =>
768
+ !e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
769
+ );
770
+ const mergedPre = [...existingPre];
771
+ for (const { matcher, command } of DESIRED_PRE) {
772
+ mergedPre.push({ matcher, hooks: [{ type: 'command', command }] });
778
773
  }
779
774
 
775
+ // Build merged PostToolUse
776
+ const postHooks = [
777
+ { matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/cost-logger.mjs' }] },
778
+ { matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }] },
779
+ ];
780
+ const existingPost = (existing.hooks?.PostToolUse || []).filter(e =>
781
+ !e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
782
+ );
783
+ const mergedPost = [...existingPost, ...postHooks];
784
+
785
+ const merged = {
786
+ ...(existing.hooks || {}),
787
+ PreToolUse: mergedPre,
788
+ PostToolUse: mergedPost,
789
+ };
790
+
780
791
  return { ...existing, hooks: merged };
781
792
  }
782
793
 
@@ -875,8 +886,8 @@ function install(workspace, env, mode) {
875
886
  ];
876
887
  for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
877
888
 
878
- // Copy bash hooks (auto-update.sh lives alongside .mjs hooks in the package)
879
- const BASH_HOOKS = ['auto-update.sh'];
889
+ // Copy bash hooks (auto-update.sh and head-guard.sh live alongside .mjs hooks in the package)
890
+ const BASH_HOOKS = ['auto-update.sh', 'head-guard.sh'];
880
891
  for (const h of BASH_HOOKS) {
881
892
  const src = join(__dirname, 'hooks', h);
882
893
  const dst = join(target, 'hooks', h);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "6.0.1",
3
+ "version": "6.1.1",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,14 @@
12
12
  "./profile": "./src/profile.mjs",
13
13
  "./detect": "./src/detect.mjs",
14
14
  "./decide": "./src/decide.mjs",
15
- "./dispatch": "./src/dispatch.mjs"
15
+ "./dispatch": "./src/dispatch.mjs",
16
+ "./playbook": "./src/playbook.mjs",
17
+ "./health": "./src/health.mjs",
18
+ "./repo": "./src/repo.mjs",
19
+ "./session": "./src/session.mjs",
20
+ "./decompose": "./src/decompose.mjs",
21
+ "./brief": "./src/brief.mjs",
22
+ "./redact": "./src/redact.mjs"
16
23
  },
17
24
  "keywords": [
18
25
  "claude-code",
@@ -33,7 +40,7 @@
33
40
  "scripts": {
34
41
  "test": "node hooks/test-orchestrator.mjs",
35
42
  "test:core": "node --test src/test.mjs",
36
- "postinstall": "node install.mjs"
43
+ "postinstall": "echo 'dual-brain installed. Run: dual-brain install (in your project) to set up hooks.'"
37
44
  },
38
45
  "engines": {
39
46
  "node": ">=20.0.0"
@@ -49,6 +56,7 @@
49
56
  "review-rules.md",
50
57
  "CLAUDE.md",
51
58
  "README.md",
52
- "LICENSE"
59
+ "LICENSE",
60
+ "playbooks/*.json"
53
61
  ]
54
62
  }
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "debug",
3
+ "description": "Structured bug resolution: reproduce, isolate, hypothesize root cause, fix minimally, verify with tests",
4
+ "matchIntents": ["debug", "fix"],
5
+ "steps": [
6
+ {
7
+ "id": "reproduce",
8
+ "title": "Reproduce the Failure",
9
+ "goal": "Find the failing code path. Identify the error message, stack trace, or unexpected behavior being reported. Locate the relevant source files, the entry point where the failure originates, and any existing tests that exercise this path. Confirm you understand the expected vs actual behavior.",
10
+ "tier": "search",
11
+ "consensus": false,
12
+ "output": { "kind": "analysis", "required": true }
13
+ },
14
+ {
15
+ "id": "isolate",
16
+ "title": "Isolate the Root Cause",
17
+ "goal": "Narrow down the root cause to a specific file, function, or line range. Trace the data flow from the failing callsite back to where the incorrect value or state originates. Check recent git changes to this code path. Identify the single most likely source of the problem before moving on.",
18
+ "tier": "search",
19
+ "consensus": false,
20
+ "output": { "kind": "analysis", "required": true }
21
+ },
22
+ {
23
+ "id": "hypothesize",
24
+ "title": "Form a Root Cause Hypothesis",
25
+ "goal": "Based on the isolated evidence, form a clear hypothesis about why the bug occurs. Consider: edge cases not handled, race conditions or ordering issues, incorrect assumptions about inputs or state, stale or shared mutable state, off-by-one errors, or API contract mismatches. State your hypothesis explicitly and explain what evidence supports it.",
26
+ "tier": "think",
27
+ "consensus": false,
28
+ "output": { "kind": "analysis", "required": true }
29
+ },
30
+ {
31
+ "id": "fix",
32
+ "title": "Implement the Minimal Fix",
33
+ "goal": "Implement the smallest change that fixes the bug according to the hypothesis. Do not refactor surrounding code, rename things, or improve unrelated areas. If a regression test for this bug does not exist, add one. The fix should be easy to review and easy to revert if wrong.",
34
+ "tier": "execute",
35
+ "consensus": false,
36
+ "gate": { "type": "diff-review", "requiredWhen": "high-risk" },
37
+ "output": { "kind": "patch", "required": true }
38
+ },
39
+ {
40
+ "id": "verify",
41
+ "title": "Verify Fix and Check for Regressions",
42
+ "goal": "Run the full test suite. Confirm the bug is no longer reproducible. Confirm no previously passing tests now fail. If regressions are found, determine whether they are related to the fix or pre-existing. Summarize: the root cause in one sentence, the fix applied, and the test evidence that the bug is resolved.",
43
+ "tier": "execute",
44
+ "consensus": false,
45
+ "gate": { "type": "test", "requiredWhen": "always" },
46
+ "output": { "kind": "summary", "required": true }
47
+ }
48
+ ]
49
+ }