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 +97 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +65 -4
- package/hooks/enforce-tier.mjs +42 -1
- package/hooks/head-guard.mjs +69 -0
- package/hooks/head-guard.sh +15 -83
- package/install.mjs +38 -27
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +330 -0
- package/package.json +8 -2
- package/plugin.json +22 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/dispatch.mjs +251 -3
- package/src/install-hooks.mjs +100 -0
- package/src/profile.mjs +195 -2
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
|
package/bin/dual-brain.mjs
CHANGED
|
@@ -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
|
|
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
|
|
325
|
-
process.exit(result.status ||
|
|
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) {
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/hooks/head-guard.sh
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# head-guard.sh —
|
|
2
|
+
# head-guard.sh — DEPRECATED. Replaced by head-guard.mjs.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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 "
|
|
34
|
+
Edit|Write|NotebookEdit|Bash)
|
|
35
|
+
echo "${BLOCK_MSG}" >&2
|
|
31
36
|
exit 2
|
|
32
37
|
;;
|
|
33
38
|
esac
|
|
34
39
|
|
|
35
|
-
# ── 3.
|
|
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
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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);
|