dual-brain 0.1.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.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gpt-work-dispatcher.mjs
4
+ *
5
+ * Dispatches execution tasks to GPT via the Codex CLI.
6
+ * Packages a work order, runs `codex exec`, captures the results,
7
+ * and returns structured output.
8
+ *
9
+ * Usage as CLI:
10
+ * node .claude/hooks/gpt-work-dispatcher.mjs \
11
+ * --task "Add tests for budget-balancer.mjs" \
12
+ * --tier execute \
13
+ * --files hooks/budget-balancer.mjs
14
+ *
15
+ * Usage as module:
16
+ * import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
17
+ * const result = await dispatchGptTask({ task, model, tier, forceModel, files, constraints, timeoutMs });
18
+ */
19
+
20
+ import { spawnSync } from 'child_process';
21
+ import { appendFileSync, readFileSync } from 'fs';
22
+ import { dirname, join } from 'path';
23
+ import { fileURLToPath } from 'url';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
27
+ const EXECUTE_WORDS = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i;
28
+ const SEARCH_WORDS = /\b(explore|search|find|grep|locate|list\s+files|read[-\s]?only|lookup|scan)\b/i;
29
+ const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
30
+ const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
31
+ const GPT_TIER_SANDBOX = IS_REPLIT
32
+ ? { search: 'danger-full-access', execute: 'danger-full-access', think: 'danger-full-access' }
33
+ : { search: 'read-only', execute: 'danger-full-access', think: 'read-only' };
34
+ const GPT_TIER_PROMPTS = {
35
+ search: 'You are a READ-ONLY search agent. Do NOT edit files.',
36
+ execute: 'You are an execution agent. Edit files directly.',
37
+ think: 'You are an architecture/review agent. Analyze and recommend, do not edit unless explicitly asked.',
38
+ };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Codex discovery — mirrors dual-brain-review.mjs
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function findCodex() {
45
+ const candidates = [
46
+ process.env.CODEX_BIN,
47
+ ].filter(Boolean);
48
+ for (const c of candidates) {
49
+ try { spawnSync(c, ['--version'], { stdio: 'pipe', timeout: 3000 }); return c; } catch {}
50
+ }
51
+ try {
52
+ const which = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
53
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
54
+ } catch {}
55
+ const home = process.env.HOME || process.env.USERPROFILE || '';
56
+ const fallbacks = [
57
+ join(home, '.local', 'bin', 'codex'),
58
+ join(home, 'bin', 'codex'),
59
+ '/usr/local/bin/codex',
60
+ ];
61
+ for (const p of fallbacks) {
62
+ try { spawnSync(p, ['--version'], { stdio: 'pipe', timeout: 3000 }); return p; } catch {}
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function isCodexAuthenticated(result) {
68
+ const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
69
+ if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
70
+ return result?.status === 0 ||
71
+ /\b(logged\s+in|authenticated|signed\s+in)\b/.test(out);
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Prompt builder
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function normalizeTier(tier) {
79
+ return ['search', 'execute', 'think'].includes(tier) ? tier : null;
80
+ }
81
+
82
+ function loadOrchestratorConfig() {
83
+ try {
84
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function classifyGptTier(task) {
91
+ const text = [
92
+ task?.task,
93
+ ...(Array.isArray(task?.constraints) ? task.constraints : []),
94
+ ]
95
+ .filter(Boolean)
96
+ .join(' ');
97
+
98
+ if (THINK_WORDS.test(text)) return 'think';
99
+ if (EXECUTE_WORDS.test(text)) return 'execute';
100
+ if (SEARCH_WORDS.test(text)) return 'search';
101
+ return 'execute';
102
+ }
103
+
104
+ export function resolveGptModel(tier, config = loadOrchestratorConfig()) {
105
+ const normalizedTier = normalizeTier(tier);
106
+ if (!normalizedTier) return null;
107
+
108
+ const models = config?.subscriptions?.openai?.models ?? {};
109
+ for (const [model, meta] of Object.entries(models)) {
110
+ if (meta?.tier === normalizedTier) return model;
111
+ }
112
+
113
+ if (normalizedTier === 'think') return 'gpt-5.5';
114
+ if (normalizedTier === 'search') return 'gpt-4.1-mini';
115
+ return 'gpt-5.4';
116
+ }
117
+
118
+ function buildPrompt(task) {
119
+ const tierInstruction = GPT_TIER_PROMPTS[task.tier] || GPT_TIER_PROMPTS.execute;
120
+ let prompt = `You are a GPT execution agent inside the Dual-Brain Orchestrator.
121
+
122
+ Task: ${task.task}
123
+
124
+ ${tierInstruction}
125
+
126
+ Own this task completely.
127
+
128
+ `;
129
+ if (task.files?.length) {
130
+ prompt += `Relevant files:\n${task.files.map(f => `- ${f}`).join('\n')}\n\n`;
131
+ }
132
+ if (task.constraints?.length) {
133
+ prompt += `Constraints:\n${task.constraints.map(c => `- ${c}`).join('\n')}\n\n`;
134
+ }
135
+ prompt += `When done, output a summary of:
136
+ 1. What you changed (files and behavior)
137
+ 2. Tests run and results (if applicable)
138
+ 3. Remaining risks or edge cases
139
+ 4. Any assumptions you made`;
140
+ return prompt;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Codex executor
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function classifyCodexFailure(proc) {
148
+ let failureType = null;
149
+
150
+ if (proc.error?.code === 'ETIMEDOUT') {
151
+ failureType = 'timeout';
152
+ } else if (proc.error?.code === 'ENOENT') {
153
+ failureType = 'not_found';
154
+ } else if (proc.error) {
155
+ failureType = 'spawn_error';
156
+ }
157
+
158
+ const stderr = (proc.stderr || '').toLowerCase();
159
+ if (stderr.includes('unauthorized') || stderr.includes('401') || stderr.includes('not logged in')) {
160
+ failureType = 'auth';
161
+ } else if (stderr.includes('rate limit') || stderr.includes('429') || stderr.includes('too many')) {
162
+ failureType = 'rate_limit';
163
+ } else if (stderr.includes('timeout') || stderr.includes('timed out')) {
164
+ failureType = 'timeout';
165
+ }
166
+
167
+ return failureType;
168
+ }
169
+
170
+ function runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox, effort) {
171
+ const args = [
172
+ 'exec', '--json', '--ephemeral',
173
+ '-m', model,
174
+ '-s', sandbox,
175
+ ];
176
+ if (effort && ['low', 'medium', 'high', 'xhigh'].includes(effort)) {
177
+ args.push('-c', `reasoning.effort="${effort}"`);
178
+ }
179
+ args.push(prompt);
180
+ return spawnSync(codexBin, args, {
181
+ encoding: 'utf8',
182
+ stdio: ['pipe', 'pipe', 'pipe'],
183
+ timeout: timeoutMs || 120000,
184
+ cwd: cwd || process.cwd(),
185
+ });
186
+ }
187
+
188
+ function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access', effort = null) {
189
+ const startTime = Date.now();
190
+
191
+ function finalizeAttempt(proc, attemptStartTime, attemptCount) {
192
+ const durationMs = Date.now() - attemptStartTime;
193
+ const failureType = classifyCodexFailure(proc);
194
+
195
+ // Parse JSONL output
196
+ const messages = (proc.stdout || '')
197
+ .split('\n')
198
+ .filter(l => l.trim())
199
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
200
+ .filter(Boolean);
201
+
202
+ const agentMessages = messages
203
+ .filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
204
+ .map(m => m.item.text);
205
+
206
+ const usage = messages.find(m => m.type === 'turn.completed')?.usage;
207
+ const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
208
+ const errorMessages = errors.map(e => e.message || e.error?.message || 'unknown');
209
+
210
+ if (proc.error?.message) {
211
+ errorMessages.unshift(proc.error.message);
212
+ }
213
+ if (proc.stderr?.trim() && errorMessages.length === 0 && proc.status !== 0) {
214
+ errorMessages.push(proc.stderr.trim().slice(0, 200));
215
+ }
216
+
217
+ // Detect changed files from command_execution items
218
+ const commands = messages
219
+ .filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
220
+ .map(m => m.item);
221
+
222
+ // Estimate startup time: time to first agent message or completed item
223
+ const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
224
+ let startupMs = null;
225
+ if (firstItemTs) {
226
+ startupMs = Date.parse(firstItemTs) - attemptStartTime;
227
+ if (startupMs < 0 || startupMs > durationMs) startupMs = null;
228
+ }
229
+
230
+ return {
231
+ success: proc.status === 0 && errors.length === 0 && !failureType,
232
+ summary: agentMessages.join('\n\n'),
233
+ durationMs,
234
+ startupMs,
235
+ model,
236
+ usage: usage || null,
237
+ errors: errorMessages,
238
+ commands: commands.length,
239
+ exitCode: proc.status,
240
+ signal: proc.signal,
241
+ failureType: failureType || null,
242
+ stderrSummary: proc.stderr?.trim().slice(0, 200) || null,
243
+ spawnErrorMessage: proc.error?.message || null,
244
+ retryCount: attemptCount - 1,
245
+ };
246
+ }
247
+
248
+ let attemptCount = 1;
249
+ let attemptStartTime = startTime;
250
+ let proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox, effort);
251
+ let result = finalizeAttempt(proc, attemptStartTime, attemptCount);
252
+
253
+ if (!result.success && (result.failureType === 'rate_limit' || result.failureType === 'timeout')) {
254
+ spawnSync('sleep', ['3'], { stdio: 'ignore' });
255
+ attemptCount += 1;
256
+ attemptStartTime = Date.now();
257
+ proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox, effort);
258
+ result = finalizeAttempt(proc, attemptStartTime, attemptCount);
259
+ }
260
+
261
+ return result;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Usage logger
266
+ // ---------------------------------------------------------------------------
267
+
268
+ function loadActiveProfile() {
269
+ try {
270
+ return JSON.parse(readFileSync(join(__dirname, '..', 'dual-brain.profile.json'), 'utf8')).active || 'balanced';
271
+ } catch { return 'balanced'; }
272
+ }
273
+
274
+ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
275
+
276
+ function logUsageEvent(result, task) {
277
+ const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
278
+ const entryObj = {
279
+ schema_version: 4,
280
+ timestamp: new Date().toISOString(),
281
+ provider: 'openai',
282
+ tier: task.tier || 'execute',
283
+ classified_tier: task.classifiedTier || task.tier || 'execute',
284
+ tool: 'codex-exec',
285
+ model: result.model,
286
+ model_override: task.modelOverride || null,
287
+ status: result.success ? 'ok' : 'error',
288
+ durationMs: result.durationMs,
289
+ codex_startup_ms: result.startupMs || null,
290
+ codex_total_ms: result.durationMs,
291
+ input_tokens: result.usage?.input_tokens ?? null,
292
+ output_tokens: result.usage?.output_tokens ?? null,
293
+ session_id: SESSION_ID,
294
+ profile: result.profile || 'balanced',
295
+ dispatcher: 'gpt-work-dispatcher',
296
+ };
297
+ try {
298
+ appendFileSync(logFile, JSON.stringify(entryObj) + '\n');
299
+ } catch {}
300
+
301
+ // Update summary checkpoint with codex latency
302
+ import('./summary-checkpoint.mjs').then(({ updateSummary }) => {
303
+ updateSummary(entryObj);
304
+ }).catch(() => {});
305
+
306
+ // Record to decision ledger
307
+ import('./decision-ledger.mjs').then(({ recordDecision, recordOutcome }) => {
308
+ const id = recordDecision({
309
+ session_id: SESSION_ID,
310
+ profile: entryObj.profile,
311
+ tier: task.tier || 'execute',
312
+ provider: 'openai',
313
+ model: result.model,
314
+ });
315
+ recordOutcome(id, {
316
+ actual_duration_ms: result.durationMs,
317
+ codex_startup_ms: result.startupMs || null,
318
+ success: result.success,
319
+ actual_input_tokens: result.usage?.input_tokens || null,
320
+ actual_output_tokens: result.usage?.output_tokens || null,
321
+ });
322
+ }).catch(() => {});
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Main exported function
327
+ // ---------------------------------------------------------------------------
328
+
329
+ function tryHealCodexAuth(codexBin) {
330
+ const apiKey = process.env.OPENAI_API_KEY;
331
+ if (!apiKey) return false;
332
+ const pipe = spawnSync(codexBin, ['login', '--with-api-key'], {
333
+ input: apiKey,
334
+ encoding: 'utf8',
335
+ stdio: ['pipe', 'pipe', 'pipe'],
336
+ timeout: 10000,
337
+ });
338
+ return pipe.status === 0;
339
+ }
340
+
341
+ export async function dispatchGptTask(task) {
342
+ const codexBin = findCodex();
343
+ if (!codexBin) {
344
+ return {
345
+ success: false,
346
+ error: 'Codex CLI not found. Install with: npm i -g @openai/codex && codex login',
347
+ };
348
+ }
349
+
350
+ // Pre-flight: check auth and heal if possible
351
+ const loginCheck = spawnSync(codexBin, ['login', 'status'], {
352
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
353
+ });
354
+ const isAuthed = isCodexAuthenticated(loginCheck);
355
+ if (!isAuthed) {
356
+ const healed = tryHealCodexAuth(codexBin);
357
+ if (!healed) {
358
+ return {
359
+ success: false,
360
+ error: 'Codex not authenticated. Run: npx dual-brain (sign in with your ChatGPT subscription) or codex login --device-auth',
361
+ };
362
+ }
363
+ }
364
+
365
+ const config = loadOrchestratorConfig();
366
+ const classifiedTier = classifyGptTier(task);
367
+ const explicitTier = normalizeTier(task.tier);
368
+ const tier = explicitTier || classifiedTier;
369
+ const expectedModel = resolveGptModel(tier, config) || 'gpt-5.4';
370
+
371
+ let model = task.model || expectedModel;
372
+ let modelOverride = null;
373
+
374
+ if (task.model && !task.forceModel && task.model !== expectedModel) {
375
+ console.warn(`[gpt-work-dispatcher] Warning: task classified as "${tier}", overriding requested model "${task.model}" with "${expectedModel}". Use --force-model to bypass.`);
376
+ model = expectedModel;
377
+ modelOverride = {
378
+ requested: task.model,
379
+ effective: expectedModel,
380
+ forced: false,
381
+ reason: `tier:${tier}`,
382
+ };
383
+ } else if (!task.model) {
384
+ modelOverride = {
385
+ requested: null,
386
+ effective: expectedModel,
387
+ forced: false,
388
+ reason: `auto-select:${tier}`,
389
+ };
390
+ } else if (task.forceModel) {
391
+ modelOverride = {
392
+ requested: task.model,
393
+ effective: task.model,
394
+ forced: true,
395
+ reason: `force-model:${tier}`,
396
+ };
397
+ }
398
+
399
+ const preparedTask = {
400
+ ...task,
401
+ tier,
402
+ classifiedTier,
403
+ modelOverride,
404
+ };
405
+ const prompt = buildPrompt(preparedTask);
406
+ const sandbox = GPT_TIER_SANDBOX[tier] || GPT_TIER_SANDBOX.execute;
407
+ const effort = task.effort || null;
408
+ const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs, sandbox, effort);
409
+ result.tier = tier;
410
+ result.classifiedTier = classifiedTier;
411
+ result.modelOverride = modelOverride;
412
+ result.effort = effort;
413
+ result.sandbox = sandbox;
414
+ result.profile = loadActiveProfile();
415
+ logUsageEvent(result, preparedTask);
416
+ return result;
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // CLI argument parser
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function parseArgs(argv) {
424
+ const args = {};
425
+ let i = 0;
426
+ while (i < argv.length) {
427
+ const arg = argv[i];
428
+ if (arg.startsWith('--')) {
429
+ const eqIdx = arg.indexOf('=');
430
+ if (eqIdx !== -1) {
431
+ // --key=value form
432
+ const key = arg.slice(2, eqIdx);
433
+ const value = arg.slice(eqIdx + 1);
434
+ args[key] = value;
435
+ } else {
436
+ // --key value form
437
+ const key = arg.slice(2);
438
+ const next = argv[i + 1];
439
+ if (next !== undefined && !next.startsWith('--')) {
440
+ args[key] = next;
441
+ i++;
442
+ } else {
443
+ args[key] = true;
444
+ }
445
+ }
446
+ }
447
+ i++;
448
+ }
449
+
450
+ // Normalize known fields
451
+ if (typeof args.files === 'string') {
452
+ args.files = args.files.split(',').map(f => f.trim()).filter(Boolean);
453
+ }
454
+ if (typeof args.constraints === 'string') {
455
+ args.constraints = args.constraints.split(',').map(c => c.trim()).filter(Boolean);
456
+ }
457
+ if (args.timeout !== undefined) {
458
+ args.timeoutMs = Number(args.timeout) * 1000;
459
+ delete args.timeout;
460
+ }
461
+ if (typeof args['force-model'] === 'boolean') {
462
+ args.forceModel = args['force-model'];
463
+ delete args['force-model'];
464
+ }
465
+
466
+ return args;
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // CLI entry point
471
+ // ---------------------------------------------------------------------------
472
+
473
+ if (import.meta.url === `file://${process.argv[1]}`) {
474
+ const rawArgs = parseArgs(process.argv.slice(2));
475
+
476
+ if (!rawArgs.task) {
477
+ console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--tier think|execute|search] [--model MODEL] [--force-model] [--files file1,file2] [--timeout 120] [--effort low|medium|high|xhigh]');
478
+ process.exit(1);
479
+ }
480
+
481
+ const result = await dispatchGptTask(rawArgs);
482
+
483
+ if (result.success) {
484
+ console.log('\n╔══════════════════════════════════════════════════╗');
485
+ console.log('║ GPT Task Completed ║');
486
+ console.log('╠══════════════════════════════════════════════════╣');
487
+ if (result.summary) {
488
+ console.log(result.summary);
489
+ }
490
+ console.log('╠══════════════════════════════════════════════════╣');
491
+ console.log(`║ Model: ${result.model} Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
492
+ console.log('╚══════════════════════════════════════════════════╝');
493
+ } else {
494
+ if (result.failureType) {
495
+ const friendlyMessage = {
496
+ auth: 'Codex not authenticated. Run: codex login --device-auth',
497
+ rate_limit: 'Rate limited by OpenAI. Try again in a few minutes.',
498
+ timeout: 'Codex timed out. Try a simpler task or increase timeout.',
499
+ not_found: 'Codex CLI not found. Run: npm i -g @openai/codex',
500
+ spawn_error: `Failed to start Codex: ${result.spawnErrorMessage || 'unknown spawn error'}`,
501
+ }[result.failureType];
502
+
503
+ if (friendlyMessage) {
504
+ console.error(friendlyMessage);
505
+ }
506
+ }
507
+ console.error('Task failed:', result.errors?.join(', ') || result.error);
508
+ }
509
+
510
+ // Also output JSON for piping
511
+ process.stdout.write('\n' + JSON.stringify(result) + '\n');
512
+ }
@@ -0,0 +1,105 @@
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']);
19
+
20
+ // Patterns that indicate a Bash command is writing/mutating the filesystem.
21
+ // Anchored to avoid false positives on grep/find output containing these words.
22
+ const WRITE_BASH_RE = /\brm\b|\bmv\b|\bcp\b|\bmkdir\b|\btouch\b|\bchmod\b|\bchown\b|\bdd\b|\binstall\b|\btruncate\b|\btee\b|\bsed\s+-i\b|\bawk\s+-i\b|>>|(?<![><])>(?![>=])/;
23
+
24
+ function isBashWriteIntent(command) {
25
+ return WRITE_BASH_RE.test(command);
26
+ }
27
+
28
+ // Read stdin JSON payload
29
+ let input;
30
+ try {
31
+ const raw = readFileSync('/dev/stdin', 'utf8');
32
+ input = JSON.parse(raw);
33
+ } catch {
34
+ // Can't parse input — fail closed to avoid guard bypass.
35
+ const output = {
36
+ hookSpecificOutput: {
37
+ hookEventName: 'PreToolUse',
38
+ permissionDecision: 'deny',
39
+ permissionDecisionReason: '[dual-brain] head-guard could not parse hook input — blocking as a safety measure.',
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(output));
43
+ process.exit(2);
44
+ }
45
+
46
+ const toolName = input.tool_name || '';
47
+
48
+ // If this hook is firing inside a subagent, ALLOW — subagents are work agents
49
+ // and are permitted to edit/write/bash.
50
+ if (input.agent_id) {
51
+ process.exit(0);
52
+ }
53
+
54
+ // HEAD session: block direct mutation tools
55
+ if (BLOCKED_TOOLS.has(toolName)) {
56
+ const output = {
57
+ hookSpecificOutput: {
58
+ hookEventName: 'PreToolUse',
59
+ permissionDecision: 'deny',
60
+ permissionDecisionReason:
61
+ `[dual-brain] HEAD cannot use ${toolName} directly. Dispatch via: dual-brain go "task description"`,
62
+ },
63
+ };
64
+ process.stdout.write(JSON.stringify(output));
65
+ process.exit(2);
66
+ }
67
+
68
+ // Bash: allow read-only commands; block write-intent ones.
69
+ // Always allow node .claude/hooks/ and node hooks/ — CLAUDE.md instructs HEAD to run these.
70
+ if (toolName === 'Bash') {
71
+ const command = (input.tool_input && input.tool_input.command) || '';
72
+ if (/^node\s+\.?(?:\.claude\/)?hooks\//.test(command.trimStart())) {
73
+ process.exit(0);
74
+ }
75
+ if (isBashWriteIntent(command)) {
76
+ const output = {
77
+ hookSpecificOutput: {
78
+ hookEventName: 'PreToolUse',
79
+ permissionDecision: 'deny',
80
+ permissionDecisionReason:
81
+ '[dual-brain] HEAD cannot run write-intent Bash commands. Dispatch via: dual-brain go "task description"',
82
+ },
83
+ };
84
+ process.stdout.write(JSON.stringify(output));
85
+ process.exit(2);
86
+ }
87
+ process.exit(0);
88
+ }
89
+
90
+ // Block MCP filesystem write tools by name.
91
+ if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename|append|patch|truncate|copy|commit|push|stage|merge|update|overwrite/i.test(toolName)) {
92
+ const output = {
93
+ hookSpecificOutput: {
94
+ hookEventName: 'PreToolUse',
95
+ permissionDecision: 'deny',
96
+ permissionDecisionReason:
97
+ '[dual-brain] HEAD cannot use MCP write tools. Dispatch via: dual-brain go "task description"',
98
+ },
99
+ };
100
+ process.stdout.write(JSON.stringify(output));
101
+ process.exit(2);
102
+ }
103
+
104
+ // Allow everything else (Read, Agent handled by enforce-tier, etc.)
105
+ process.exit(0);