dual-brain 6.1.0 → 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,7 +1,7 @@
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';
@@ -67,7 +67,17 @@ Options:
67
67
  // ─── Card command (default) ──────────────────────────────────────────────────
68
68
 
69
69
  async function cmdCard() {
70
- const cwd = process.cwd();
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
+
71
81
  const repo = loadRepoCache(cwd);
72
82
  const session = loadSession(cwd);
73
83
  const health = getHealth(cwd);
@@ -274,6 +284,39 @@ async function cmdStatus(args = []) {
274
284
  vtrace(`Raw profile:\n${JSON.stringify(profile, null, 2)}`);
275
285
  }
276
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
+
277
320
  // Update check
278
321
  try {
279
322
  const localVer = readVersion();
@@ -320,9 +363,27 @@ function cmdCool(providerArg) {
320
363
  }
321
364
 
322
365
  async function cmdInstall() {
366
+ const cwd = process.cwd();
367
+
368
+ // Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
323
369
  const { spawnSync } = await import('child_process');
324
- const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd: process.cwd() });
325
- 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);
326
387
  }
327
388
 
328
389
  function cmdRemember(text) {
@@ -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,86 +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
- # Interpreter one-liners that can write files (node -e, python -c, perl -e, ruby -e)
84
- if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])(node[[:space:]]+(--eval|-e)|python3?[[:space:]]+-c|perl[[:space:]]+-e|ruby[[:space:]]+-e)[[:space:]]'; then
85
- echo "HEAD cannot implement directly (interpreter one-liner). Use: node hooks/dispatch.mjs --task \"description\"" >&2
86
- exit 2
87
- fi
88
-
89
- # mv / cp where the destination looks like a source code file
90
- 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
91
- echo "HEAD cannot implement directly (mv/cp to source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
92
- exit 2
93
- fi
94
-
95
- # rm on source files
96
- 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
97
- echo "HEAD cannot implement directly (rm on source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
98
- exit 2
99
- fi
100
-
101
- # Explicitly allowed (read-only) patterns — documented here for clarity.
102
- # The checks above are specific enough that these don't need explicit allow rules,
103
- # but listing them makes the intent clear:
104
- # grep, find, cat <file (no redirect), git status/log/diff/show,
105
- # node --check, ls, wc, head, tail, jq (read), curl (read), etc.
106
- fi
107
-
108
- # ── 4. Default: allow ────────────────────────────────────────────────────────
38
+ # ── 3. Default: allow ────────────────────────────────────────────────────────
109
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.1.0",
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": {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * install-hooks.mjs — Merge dual-brain PreToolUse hooks into .claude/settings.json.
3
+ *
4
+ * Exported function: installHooks(cwd)
5
+ * Returns: { installed: string[], skipped: string[] }
6
+ */
7
+
8
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const PKG_ROOT = join(__dirname, '..');
15
+
16
+ // The hook commands we want present in .claude/settings.json PreToolUse
17
+ const HEAD_GUARD_CMD = 'bash .claude/hooks/head-guard.sh';
18
+ const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
19
+
20
+ const DESIRED_HOOKS = [
21
+ { matcher: 'Edit', command: HEAD_GUARD_CMD },
22
+ { matcher: 'Write', command: HEAD_GUARD_CMD },
23
+ { matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
24
+ { matcher: 'Bash', command: HEAD_GUARD_CMD },
25
+ { matcher: 'Agent', command: ENFORCE_TIER_CMD },
26
+ ];
27
+
28
+ /**
29
+ * Install dual-brain enforcement hooks into a project's .claude/settings.json.
30
+ *
31
+ * @param {string} cwd - Project root directory (where .claude/ should live)
32
+ * @returns {{ installed: string[], skipped: string[] }}
33
+ */
34
+ export function installHooks(cwd) {
35
+ const claudeDir = join(cwd, '.claude');
36
+ const hooksDir = join(claudeDir, 'hooks');
37
+ const settingsPath = join(claudeDir, 'settings.json');
38
+
39
+ const installed = [];
40
+ const skipped = [];
41
+
42
+ // Ensure directories exist
43
+ mkdirSync(hooksDir, { recursive: true });
44
+
45
+ // Copy hook files from package into project's .claude/hooks/
46
+ const filesToCopy = [
47
+ { name: 'head-guard.sh', exec: true },
48
+ { name: 'enforce-tier.mjs', exec: false },
49
+ ];
50
+
51
+ for (const { name, exec } of filesToCopy) {
52
+ const src = join(PKG_ROOT, 'hooks', name);
53
+ const dst = join(hooksDir, name);
54
+ if (existsSync(src)) {
55
+ cpSync(src, dst);
56
+ if (exec) {
57
+ try { chmodSync(dst, 0o755); } catch {}
58
+ }
59
+ installed.push(`hooks/${name}`);
60
+ }
61
+ }
62
+
63
+ // Read existing settings (or start fresh)
64
+ let settings = {};
65
+ try {
66
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
67
+ } catch {
68
+ // File doesn't exist or is malformed — start empty
69
+ }
70
+
71
+ // Ensure hooks.PreToolUse array exists
72
+ if (!settings.hooks) settings.hooks = {};
73
+ if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
74
+
75
+ const preToolUse = settings.hooks.PreToolUse;
76
+
77
+ // Merge: for each desired hook, add only if command is not already registered for that matcher
78
+ for (const { matcher, command } of DESIRED_HOOKS) {
79
+ const alreadyPresent = preToolUse.some(entry =>
80
+ entry.matcher === matcher &&
81
+ Array.isArray(entry.hooks) &&
82
+ entry.hooks.some(h => h.command === command)
83
+ );
84
+
85
+ if (alreadyPresent) {
86
+ skipped.push(`PreToolUse[${matcher}]`);
87
+ } else {
88
+ preToolUse.push({
89
+ matcher,
90
+ hooks: [{ type: 'command', command }],
91
+ });
92
+ installed.push(`PreToolUse[${matcher}]`);
93
+ }
94
+ }
95
+
96
+ // Write back merged settings
97
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
98
+
99
+ return { installed, skipped };
100
+ }