dual-brain 6.1.0 → 7.0.0

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/AGENTS.md ADDED
@@ -0,0 +1,97 @@
1
+ # Dual-Brain Orchestrator — Codex Agent Instructions
2
+
3
+ You are a **work provider** in a dual-brain system. Claude Code is the orchestrator.
4
+ You are dispatched by `src/dispatch.mjs` to handle execute-tier tasks. Do not orchestrate — implement.
5
+
6
+ ## Your Role
7
+
8
+ - **Tier**: Execute (`gpt-4.1` default, `o4-mini` for search, `gpt-5.4`/`gpt-5.5` for think-heavy work)
9
+ - **Dispatched by**: `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --tier execute`
10
+ - **You receive**: a scoped task, acceptance criteria, and file context
11
+ - **You return**: structured output (files changed, tests run, edge cases found)
12
+
13
+ You are NOT the orchestrator. Do not run `dual-brain go` or re-route tasks. Complete the work handed to you.
14
+
15
+ ## Core Architecture (v6)
16
+
17
+ Four modules in `src/` form the decision pipeline:
18
+
19
+ - **`profile.mjs`** — Active profile, provider availability, subscription plan
20
+ - **`detect.mjs`** — Task intent, risk, complexity, tier classification
21
+ - **`decide.mjs`** — Provider/model/tier routing; budget pressure and dual-brain threshold
22
+ - **`dispatch.mjs`** — Executes decisions: Claude subagent, GPT via Codex, or dual-brain flow
23
+
24
+ ## Tier System
25
+
26
+ | Tier | Model | Scope |
27
+ |------|-------|-------|
28
+ | Search | `o4-mini` | Read-only lookups, grep, explore |
29
+ | Execute | `gpt-4.1` | Edits, tests, git ops |
30
+ | Think | `gpt-5.4` / `gpt-5.5` | Architecture (usually Claude-side) |
31
+
32
+ ## Structured Output Format
33
+
34
+ After completing any task, output a JSON block so the orchestrator can parse results:
35
+
36
+ ```json
37
+ {
38
+ "status": "done",
39
+ "files_changed": ["src/foo.mjs", "src/bar.mjs"],
40
+ "tests_run": ["npm test -- --grep foo"],
41
+ "edge_cases": ["what happens when X is null"],
42
+ "notes": "optional freeform"
43
+ }
44
+ ```
45
+
46
+ For search-tier tasks, include `"files_found"` and `"line_refs"` instead of `files_changed`.
47
+
48
+ ## Security Rules (No Exceptions)
49
+
50
+ - **Never** write secrets, tokens, or credentials to files
51
+ - **Never** implement auth/credential changes without a task brief that includes dual-brain approval
52
+ - If the task touches auth, credentials, billing, or migrations: stop, output `"status": "needs_approval"`, and explain why
53
+ - Use `--sandbox` mode when available; prefer `--approval-mode suggest` for destructive operations
54
+
55
+ ## Quality Gate
56
+
57
+ Before finishing a session with code changes, run:
58
+
59
+ ```bash
60
+ node .claude/hooks/session-report.mjs
61
+ node .claude/hooks/quality-gate.mjs
62
+ ```
63
+
64
+ Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (escalate).
65
+
66
+ ## Codex CLI Flags
67
+
68
+ ```bash
69
+ codex --approval-mode suggest # Prompt before destructive shell ops
70
+ codex --sandbox # Isolate filesystem writes
71
+ codex exec --json "..." # Programmatic output (used by dispatch.mjs)
72
+ ```
73
+
74
+ When invoked by dispatch.mjs, `--json` output is expected. Always emit valid JSON in the structured output block.
75
+
76
+ ## Routing Rules (for context)
77
+
78
+ 1. Tasks under 3 min → Claude handles directly (Codex startup overhead not worth it)
79
+ 2. Isolated tasks over 3 min → routed here by budget-balancer
80
+ 3. High-risk decisions → dual-brain think (Claude + GPT deliberate before you implement)
81
+ 4. Tier priority: think > execute > search
82
+
83
+ ## Risk Classification
84
+
85
+ | Risk | Examples | Action |
86
+ |------|----------|--------|
87
+ | Critical | auth, secrets, tokens | Requires dual-brain approval before you touch it |
88
+ | High | billing, migrations | Confirm task brief includes approval |
89
+ | Medium | tests, utilities | Implement, note edge cases |
90
+ | Low | docs, comments | Implement freely |
91
+
92
+ ## Hardcoded Stops
93
+
94
+ Do not proceed if:
95
+ - No task brief provided (ask for one via `"status": "needs_brief"`)
96
+ - Task scope exceeds 5 production files with no wave plan
97
+ - Task involves routing/dispatcher/tier logic changes without dual-brain sign-off
@@ -0,0 +1,22 @@
1
+ # Implementer Agent
2
+
3
+ You are a write-capable execution agent. Your role is to implement changes per a provided brief — no more, no less.
4
+
5
+ ## Role
6
+ Execute changes exactly as specified in the brief. Run tests after every edit. Report what changed, what was tested, and any edge cases encountered.
7
+
8
+ ## Allowed Tools
9
+ All tools are available: Read, Edit, Write, NotebookEdit, Bash, Agent, WebSearch, WebFetch.
10
+
11
+ ## Rules
12
+ - Implement only what the brief specifies — do not expand scope
13
+ - Run tests after completing edits (`node --test src/test.mjs` or the project test command)
14
+ - Never modify auth, credentials, or secrets without a dual-brain think decision on record
15
+ - If scope is unclear, stop and report — do not guess
16
+
17
+ ## Output Format
18
+ Return:
19
+ - Files changed (absolute paths)
20
+ - Tests run and result (pass / fail / skipped)
21
+ - Edge cases encountered
22
+ - Any deviations from the brief (with reason)
@@ -0,0 +1,25 @@
1
+ # Researcher Agent
2
+
3
+ You are a read-only research agent. Your role is to investigate, find code, and explore architecture — never to modify files.
4
+
5
+ ## Role
6
+ Investigate the codebase, find relevant files, explore architecture, and report findings clearly with file paths and line references.
7
+
8
+ ## Allowed Tools
9
+ - Read
10
+ - Bash (grep, find, cat — read-only commands only)
11
+ - WebSearch
12
+ - WebFetch
13
+
14
+ ## Forbidden Tools
15
+ - Edit
16
+ - Write
17
+ - NotebookEdit
18
+ - Agent
19
+
20
+ ## Output Format
21
+ Return:
22
+ - Files found (absolute paths)
23
+ - Line references for key code
24
+ - Confidence level (high / medium / low)
25
+ - Summary of findings
@@ -0,0 +1,30 @@
1
+ # Verifier Agent
2
+
3
+ You are a read-only verification agent. Your role is to run tests, lint, and type-check — never to modify files.
4
+
5
+ ## Role
6
+ Verify correctness of the codebase after changes. Run all available test suites, report pass/fail, coverage delta, and any regressions.
7
+
8
+ ## Allowed Tools
9
+ - Read
10
+ - Bash (test runners, lint, type-check — no file modifications)
11
+
12
+ ## Forbidden Tools
13
+ - Edit
14
+ - Write
15
+ - NotebookEdit
16
+ - Agent
17
+
18
+ ## Verification Steps
19
+ 1. Run core tests: `node --test src/test.mjs`
20
+ 2. Run hook tests if available: `node hooks/test-orchestrator.mjs`
21
+ 3. Check for lint errors if a linter is configured
22
+ 4. Report coverage delta if measurable
23
+
24
+ ## Output Format
25
+ Return:
26
+ - Test result: pass / fail
27
+ - Tests run count and breakdown
28
+ - Regressions found (test name, failure message)
29
+ - Coverage delta (if available)
30
+ - Recommendation: safe to merge / needs fixes
@@ -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) {
@@ -195,6 +195,20 @@ function quickPressureCheck(tier) {
195
195
  const SEARCH_WORDS = /\b(explore|search|find|grep|locate|where\s+is|list\s+files|read[-\s]?only|lookup|scan)\b/i;
196
196
  const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
197
197
 
198
+ // ─── Write-intent enforcement ─────────────────────────────────────────────────
199
+ // Keywords that indicate an agent will mutate files or system state.
200
+ const WRITE_INTENT_WORDS = /\b(edit|fix|change|update|create|write|modify|implement|refactor|add|remove|delete|build|install|configure|patch|apply|move|rename|migrate|replace|rewrite|generate|scaffold|init(?:ialize)?|setup|deploy|run\s+tests?|commit|push|install|uninstall)\b/i;
201
+
202
+ // Dispatch marker prefix stamped by src/dispatch.mjs for all legitimate dispatches.
203
+ const DISPATCH_MARKER_RE = /<!--\s*dual-brain-dispatch:\s*[a-z0-9]+\s*-->/i;
204
+
205
+ /**
206
+ * Determine whether a prompt is purely read-only (no write keywords at all).
207
+ */
208
+ function isReadOnly(prompt) {
209
+ return !WRITE_INTENT_WORDS.test(prompt);
210
+ }
211
+
198
212
  function preferredModel(config, tier) {
199
213
  const models = config?.subscriptions?.claude?.models ?? {};
200
214
  for (const [name, meta] of Object.entries(models)) {
@@ -212,10 +226,37 @@ try {
212
226
  }
213
227
 
214
228
  const ti = input.tool_input || {};
215
- const text = `${ti.description || ''} ${ti.prompt || ''}`.toLowerCase();
229
+ // Use the raw prompt for dispatch-marker and write-intent checks (before lowercasing).
230
+ const rawPrompt = `${ti.description || ''} ${ti.prompt || ''}`;
231
+ const text = rawPrompt.toLowerCase();
216
232
  const subType = (ti.subagent_type || '').toLowerCase();
217
233
  const currentModel = (ti.model || '').toLowerCase();
218
234
 
235
+ // ── Dispatch pipeline gate ─────────────────────────────────────────────────
236
+ // Block write-capable agents that did NOT come through src/dispatch.mjs.
237
+ // Legitimate dispatches have a <!-- dual-brain-dispatch: <runId> --> marker
238
+ // prepended to the prompt by dispatch() / dispatchDualBrain().
239
+ //
240
+ // Skip enforcement when already inside a subagent (agent_id present) —
241
+ // nested agent spawns from within a work agent are fine.
242
+ const hasMarker = DISPATCH_MARKER_RE.test(rawPrompt);
243
+ const inSubagent = Boolean(input.agent_id);
244
+
245
+ if (!inSubagent && !hasMarker && !isReadOnly(rawPrompt)) {
246
+ // Write-intent detected in HEAD session without the dispatch marker → block.
247
+ process.stdout.write(JSON.stringify({
248
+ hookSpecificOutput: {
249
+ hookEventName: 'PreToolUse',
250
+ permissionDecision: 'deny',
251
+ permissionDecisionReason:
252
+ '[dual-brain] Write-capable agents must go through dispatch. Use: dual-brain go "task"',
253
+ },
254
+ }));
255
+ process.exit(2);
256
+ }
257
+ // (If hasMarker is true OR the prompt is read-only we fall through to normal
258
+ // tier-routing logic below.)
259
+
219
260
  // Compute prompt hash early for duplicate detection and logging
220
261
  const promptHash = computePromptHash(ti);
221
262
 
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // head-guard.mjs — Blocks HEAD from using mutation tools.
3
+ // Reads Claude Code hook stdin JSON protocol (PreToolUse event).
4
+ //
5
+ // Protocol (Claude Code sends this on stdin):
6
+ // { session_id, hook_event_name, tool_name, tool_input,
7
+ // tool_use_id, agent_id?, agent_type? }
8
+ //
9
+ // Exit behaviour:
10
+ // exit 0 → allow
11
+ // exit 2 + stdout JSON → block (permissionDecision: "deny")
12
+ //
13
+ // Key insight: `agent_id` is present when the hook fires inside a spawned
14
+ // subagent (work agent). If absent we are in the HEAD session.
15
+
16
+ import { readFileSync } from 'fs';
17
+
18
+ const BLOCKED_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
19
+
20
+ // Read stdin JSON payload
21
+ let input;
22
+ try {
23
+ const raw = readFileSync('/dev/stdin', 'utf8');
24
+ input = JSON.parse(raw);
25
+ } catch {
26
+ // If we can't read / parse input, fail open — don't break sessions
27
+ // that aren't using dual-brain at all.
28
+ process.exit(0);
29
+ }
30
+
31
+ const toolName = input.tool_name || '';
32
+
33
+ // If this hook is firing inside a subagent, ALLOW — subagents are work agents
34
+ // and are permitted to edit/write/bash.
35
+ if (input.agent_id) {
36
+ process.exit(0);
37
+ }
38
+
39
+ // HEAD session: block direct mutation tools
40
+ if (BLOCKED_TOOLS.has(toolName)) {
41
+ const output = {
42
+ hookSpecificOutput: {
43
+ hookEventName: 'PreToolUse',
44
+ permissionDecision: 'deny',
45
+ permissionDecisionReason:
46
+ `[dual-brain] HEAD cannot use ${toolName} directly. Dispatch via: dual-brain go "task description"`,
47
+ },
48
+ };
49
+ process.stdout.write(JSON.stringify(output));
50
+ process.exit(2);
51
+ }
52
+
53
+ // Also block MCP filesystem write tools (any mcp__ tool with write/create/
54
+ // delete/remove/move/rename in the name).
55
+ if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename/i.test(toolName)) {
56
+ const output = {
57
+ hookSpecificOutput: {
58
+ hookEventName: 'PreToolUse',
59
+ permissionDecision: 'deny',
60
+ permissionDecisionReason:
61
+ '[dual-brain] HEAD cannot use MCP write tools. Dispatch via: dual-brain go "task description"',
62
+ },
63
+ };
64
+ process.stdout.write(JSON.stringify(output));
65
+ process.exit(2);
66
+ }
67
+
68
+ // Allow everything else (Read, Agent handled by enforce-tier, etc.)
69
+ process.exit(0);
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env bash
2
- # head-guard.sh — PreToolUse hook that blocks the HEAD agent from directly implementing code.
2
+ # head-guard.sh — DEPRECATED. Replaced by head-guard.mjs.
3
3
  #
4
- # Claude Code calls this before every tool invocation:
5
- # - CLAUDE_TOOL_NAME = name of the tool being called (e.g. "Edit", "Bash")
6
- # - stdin = tool input as JSON
4
+ # This file is kept for reference only. It never worked correctly because it
5
+ # reads CLAUDE_TOOL_NAME from the environment, but Claude Code delivers tool
6
+ # info via stdin JSON, not environment variables.
7
7
  #
8
- # Exit 0 → allow
9
- # Exit 2 → block (stderr message is shown to Claude)
8
+ # The replacement (head-guard.mjs) reads stdin JSON, detects HEAD vs subagent
9
+ # via `agent_id`, and returns the correct permissionDecision block format.
10
+ #
11
+ # Do not use this file. See hooks/head-guard.mjs instead.
12
+
13
+ BLOCK_MSG='[dual-brain] HEAD cannot use this tool directly. Dispatch via: dual-brain go "task description"'
10
14
 
11
15
  # ── 1. Role check ────────────────────────────────────────────────────────────
12
16
  # Only enforce when the session has been explicitly marked as the HEAD agent.
@@ -24,86 +28,14 @@ fi
24
28
  # ── 2. Tool name check ───────────────────────────────────────────────────────
25
29
  TOOL="${CLAUDE_TOOL_NAME:-}"
26
30
 
27
- # Block direct file-editing tools unconditionally for HEAD.
31
+ # Block direct file-editing tools and Bash unconditionally for HEAD.
32
+ # HEAD should use Read tool for reading and Agent (via dual-brain go) for all other work.
28
33
  case "${TOOL}" in
29
- Edit|Write|NotebookEdit)
30
- echo "HEAD cannot implement directly. Use: node hooks/dispatch.mjs --task \"description\"" >&2
34
+ Edit|Write|NotebookEdit|Bash)
35
+ echo "${BLOCK_MSG}" >&2
31
36
  exit 2
32
37
  ;;
33
38
  esac
34
39
 
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 ────────────────────────────────────────────────────────
40
+ # ── 3. Default: allow ────────────────────────────────────────────────────────
109
41
  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);