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,942 @@
1
+ #!/usr/bin/env node
2
+ // dispatch.mjs — Dispatch/execution module for dual-brain.
3
+ // Takes a routing decision and launches the agent via Claude CLI or Codex CLI.
4
+ // CLI: node src/dispatch.mjs --dry-run --provider claude --model sonnet --prompt "fix the bug"
5
+ // node src/dispatch.mjs --detect-runtime
6
+ // Exports: dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain,
7
+ // validateDispatch, checkWorktreeClean, getRetryBudget,
8
+ // isInsideClaude, buildNativeDispatch, normalizeResult
9
+
10
+ import { spawn } from 'node:child_process';
11
+ import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { createHash } from 'node:crypto';
15
+ import { markHot, markDegraded, markHealthy, recordDispatch } from './health.mjs';
16
+ import { redact } from './redact.mjs';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
20
+ const TIER_TIMEOUT_MS = { search: 60_000, execute: 120_000, think: 180_000 };
21
+ const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
22
+
23
+ // ─── Specialist prompt loader ─────────────────────────────────────────────────
24
+
25
+ const SPECIALISTS_DIR = join(__dirname, '..', 'agents', 'specialists');
26
+
27
+ /**
28
+ * Load specialist registry from agents/specialists/registry.json.
29
+ * Returns null if registry is missing or malformed.
30
+ * @returns {object|null}
31
+ */
32
+ function _loadSpecialistRegistry() {
33
+ try {
34
+ const raw = readFileSync(join(SPECIALISTS_DIR, 'registry.json'), 'utf8');
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Read agents/specialists/_base.md and agents/specialists/{specialist}.md,
43
+ * concatenate them (base first, specialist second). Falls back gracefully:
44
+ * - If base is missing, only specialist content is returned.
45
+ * - If specialist file is missing, only base content is returned.
46
+ * - If both are missing, returns an empty string.
47
+ *
48
+ * @param {string} specialist Specialist key (e.g. 'python', 'security')
49
+ * @returns {string}
50
+ */
51
+ function loadSpecialistPrompt(specialist) {
52
+ if (!specialist || specialist === 'generic') return '';
53
+
54
+ const tryRead = (filePath) => {
55
+ try { return readFileSync(filePath, 'utf8').trim(); } catch { return ''; }
56
+ };
57
+
58
+ const registry = _loadSpecialistRegistry();
59
+ const entry = registry?.specialists?.[specialist];
60
+ const promptFile = entry?.prompt_file ?? `${specialist}.md`;
61
+
62
+ const base = tryRead(join(SPECIALISTS_DIR, '_base.md'));
63
+ const specific = tryRead(join(SPECIALISTS_DIR, promptFile));
64
+
65
+ const parts = [base, specific].filter(Boolean);
66
+ return parts.join('\n\n');
67
+ }
68
+
69
+ // ─── Median dispatch time tracker (in-process, for slow-response detection) ──
70
+ // Rolling window of recent dispatch durations keyed by "provider:modelClass"
71
+ const _durationHistory = new Map();
72
+ const DURATION_WINDOW = 10; // keep last N durations per model class
73
+
74
+ function recordDuration(provider, model, durationMs) {
75
+ const k = `${provider}:${model}`;
76
+ if (!_durationHistory.has(k)) _durationHistory.set(k, []);
77
+ const arr = _durationHistory.get(k);
78
+ arr.push(durationMs);
79
+ if (arr.length > DURATION_WINDOW) arr.shift();
80
+ }
81
+
82
+ function medianDuration(provider, model) {
83
+ const k = `${provider}:${model}`;
84
+ const arr = _durationHistory.get(k);
85
+ if (!arr || arr.length < 3) return null; // not enough data
86
+ const sorted = [...arr].sort((a, b) => a - b);
87
+ const mid = Math.floor(sorted.length / 2);
88
+ return sorted.length % 2 === 0
89
+ ? (sorted[mid - 1] + sorted[mid]) / 2
90
+ : sorted[mid];
91
+ }
92
+
93
+ // Rate-limit error keywords
94
+ const RATE_LIMIT_PATTERNS = /rate.?limit|quota|capacity|too many requests|overloaded|throttl/i;
95
+
96
+ // ─── Native Claude Code detection ────────────────────────────────────────────
97
+
98
+ /**
99
+ * Detect whether we are running inside Claude Code (as a subagent or tool call).
100
+ * Checks the CLAUDE_CODE env var or the presence of .claude/settings.json in the project root.
101
+ * @returns {boolean}
102
+ */
103
+ function isInsideClaude() {
104
+ if (process.env.CLAUDE_CODE) return true;
105
+ // Walk up from __dirname (src/) to find .claude/settings.json in project root
106
+ const projectRoot = join(__dirname, '..');
107
+ return existsSync(join(projectRoot, '.claude', 'settings.json'));
108
+ }
109
+
110
+ // ─── Tier defaults for maxTurns ──────────────────────────────────────────────
111
+
112
+ const TIER_MAX_TURNS = { search: 5, execute: 15, think: 10 };
113
+
114
+ // ─── Agent model mapper ───────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Map a model alias or model ID to the canonical agent model name (haiku|sonnet|opus).
118
+ * Falls back to tier-based defaults when no match is found.
119
+ * @param {string} modelAlias Short alias or full model ID
120
+ * @param {string} [tier] Tier fallback ('search'|'execute'|'think')
121
+ * @returns {'haiku'|'sonnet'|'opus'}
122
+ */
123
+ function mapToAgentModel(modelAlias, tier) {
124
+ if (!modelAlias) {
125
+ const tierDefaults = { search: 'haiku', execute: 'sonnet', think: 'opus' };
126
+ return tierDefaults[tier] ?? 'sonnet';
127
+ }
128
+ const lower = String(modelAlias).toLowerCase();
129
+ if (lower === 'haiku' || lower.startsWith('claude-3-haiku') || lower.includes('haiku')) return 'haiku';
130
+ if (lower === 'opus' || lower.startsWith('claude-opus') || lower.includes('opus')) return 'opus';
131
+ if (lower === 'sonnet'|| lower.startsWith('claude-sonnet') || lower.includes('sonnet')) return 'sonnet';
132
+ // Tier-based fallback
133
+ const tierDefaults = { search: 'haiku', execute: 'sonnet', think: 'opus' };
134
+ return tierDefaults[tier] ?? 'sonnet';
135
+ }
136
+
137
+ // ─── Native dispatch builder ──────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Build a structured native Agent tool call descriptor instead of a shell command.
141
+ * The caller (CLI or plugin) is responsible for invoking the Agent tool with this object.
142
+ *
143
+ * @param {object} decision Routing decision from decide.mjs
144
+ * @param {string} prompt Task prompt (already redacted)
145
+ * @param {object} [options] Optional extras: worktree, maxTurns
146
+ * @returns {{
147
+ * type: 'native-agent',
148
+ * description: string,
149
+ * model: 'haiku'|'sonnet'|'opus',
150
+ * prompt: string,
151
+ * isolation: string|undefined,
152
+ * maxTurns: number,
153
+ * disallowedTools: string[],
154
+ * background: boolean
155
+ * }}
156
+ */
157
+ function buildNativeDispatch(decision, prompt, options = {}) {
158
+ const tier = decision.tier ?? 'execute';
159
+ const model = mapToAgentModel(decision.model, tier);
160
+
161
+ return {
162
+ type: 'native-agent',
163
+ description: `dual-brain ${tier}: ${String(prompt).slice(0, 50)}`,
164
+ model,
165
+ prompt,
166
+ isolation: options.worktree ? 'worktree' : undefined,
167
+ maxTurns: options.maxTurns ?? TIER_MAX_TURNS[tier] ?? 15,
168
+ disallowedTools: tier === 'search' ? ['Edit', 'Write', 'NotebookEdit'] : [],
169
+ background: false,
170
+ };
171
+ }
172
+
173
+ // ─── Result normalizer ────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Normalize a raw result from either a native Agent call or subprocess stdout
177
+ * into a common result shape.
178
+ *
179
+ * @param {object|string} rawResult Raw result from agent or subprocess
180
+ * @param {'native-agent'|'subprocess'} dispatchType
181
+ * @returns {{
182
+ * status: 'success'|'failure'|'partial',
183
+ * provider: string,
184
+ * model: string,
185
+ * tier: string,
186
+ * filesChanged: string[],
187
+ * filesFound: string[],
188
+ * testsRun: number,
189
+ * edgeCases: string[],
190
+ * tokensUsed: { input: number, output: number },
191
+ * errors: string[],
192
+ * rawOutput: string
193
+ * }}
194
+ */
195
+ function normalizeResult(rawResult, dispatchType) {
196
+ const raw = rawResult ?? {};
197
+
198
+ // Determine raw output string regardless of dispatch type
199
+ let rawOutput = '';
200
+ if (typeof raw === 'string') {
201
+ rawOutput = raw;
202
+ } else if (typeof raw.stdout === 'string') {
203
+ rawOutput = raw.stdout;
204
+ } else if (typeof raw.output === 'string') {
205
+ rawOutput = raw.output;
206
+ } else if (typeof raw.result === 'string') {
207
+ rawOutput = raw.result;
208
+ } else {
209
+ try { rawOutput = JSON.stringify(raw); } catch { rawOutput = String(raw); }
210
+ }
211
+
212
+ // Determine status
213
+ let status = 'success';
214
+ if (dispatchType === 'subprocess') {
215
+ const exitCode = typeof raw.exitCode === 'number' ? raw.exitCode : (raw.code ?? null);
216
+ if (exitCode !== null) {
217
+ status = exitCode === 0 ? 'success' : 'failure';
218
+ } else if (raw.status === 'failed' || raw.status === 'error') {
219
+ status = 'failure';
220
+ } else if (raw.status === 'partial') {
221
+ status = 'partial';
222
+ }
223
+ } else {
224
+ // native-agent
225
+ if (raw.status === 'failed' || raw.status === 'error' || raw.error) {
226
+ status = 'failure';
227
+ } else if (raw.status === 'partial') {
228
+ status = 'partial';
229
+ }
230
+ }
231
+
232
+ // Extract fields from raw
233
+ const provider = raw.provider ?? (dispatchType === 'native-agent' ? 'claude' : 'unknown');
234
+ const model = raw.model ?? (raw.agentModel ?? 'unknown');
235
+ const tier = raw.tier ?? 'execute';
236
+
237
+ // Files changed / found — best-effort extraction from raw output
238
+ const filesChangedSet = new Set();
239
+ const filesFoundSet = new Set();
240
+ if (Array.isArray(raw.filesChanged)) raw.filesChanged.forEach(f => filesChangedSet.add(f));
241
+ if (Array.isArray(raw.filesFound)) raw.filesFound.forEach(f => filesFoundSet.add(f));
242
+
243
+ // Scan rawOutput for file hints
244
+ const changeMatches = rawOutput.matchAll(/(?:changed|edited|wrote|created|modified)\s+([^\s,]+\.[a-z]{1,6})/gi);
245
+ for (const m of changeMatches) filesChangedSet.add(m[1]);
246
+ const foundMatches = rawOutput.matchAll(/(?:found|located|in)\s+([^\s,]+\.[a-z]{1,6})/gi);
247
+ for (const m of foundMatches) filesFoundSet.add(m[1]);
248
+
249
+ // Tests run
250
+ let testsRun = raw.testsRun ?? 0;
251
+ if (testsRun === 0) {
252
+ const testMatch = rawOutput.match(/(\d+)\s+(?:tests?|specs?)\s+(?:passed|run|ran)/i);
253
+ if (testMatch) testsRun = parseInt(testMatch[1], 10);
254
+ }
255
+
256
+ // Edge cases
257
+ const edgeCases = Array.isArray(raw.edgeCases) ? raw.edgeCases : [];
258
+
259
+ // Token usage
260
+ const tokensUsed = {
261
+ input: raw.tokensUsed?.input ?? raw.usage?.inputTokens ?? raw.usage?.input_tokens ?? 0,
262
+ output: raw.tokensUsed?.output ?? raw.usage?.outputTokens ?? raw.usage?.output_tokens ?? 0,
263
+ };
264
+
265
+ // Errors
266
+ const errors = [];
267
+ if (Array.isArray(raw.errors)) errors.push(...raw.errors);
268
+ if (typeof raw.error === 'string' && raw.error) errors.push(raw.error);
269
+ if (typeof raw.stderr === 'string' && raw.stderr) errors.push(raw.stderr.slice(0, 200));
270
+
271
+ return {
272
+ status,
273
+ provider,
274
+ model,
275
+ tier,
276
+ filesChanged: [...filesChangedSet],
277
+ filesFound: [...filesFoundSet],
278
+ testsRun,
279
+ edgeCases,
280
+ tokensUsed,
281
+ errors,
282
+ rawOutput: rawOutput.slice(0, 2000),
283
+ };
284
+ }
285
+
286
+ // ─── Runtime detection (cached) ───────────────────────────────────────────────
287
+
288
+ let _runtimeCache = null;
289
+
290
+ async function detectRuntime() {
291
+ if (_runtimeCache) return _runtimeCache;
292
+
293
+ const check = (cmd) => new Promise((resolve) => {
294
+ const p = spawn(cmd, ['--version'], { stdio: 'pipe' });
295
+ p.on('error', () => resolve(false));
296
+ p.on('close', (code) => resolve(code === 0));
297
+ setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 3000);
298
+ });
299
+
300
+ const [claudeAvailable, codexAvailable] = await Promise.all([
301
+ check('claude'),
302
+ check('codex'),
303
+ ]);
304
+
305
+ const runtime =
306
+ claudeAvailable && codexAvailable ? 'claude-code'
307
+ : claudeAvailable ? 'claude-code'
308
+ : codexAvailable ? 'codex-cli'
309
+ : process.env.CLAUDE_API_KEY || process.env.ANTHROPIC_API_KEY ? 'standalone'
310
+ : 'none';
311
+
312
+ _runtimeCache = { claudeAvailable, codexAvailable, runtime };
313
+ return _runtimeCache;
314
+ }
315
+
316
+ // ─── Feature 1: Model validation + graceful fallback ─────────────────────────
317
+
318
+ /** Valid CLI model flags per provider */
319
+ const VALID_MODELS = {
320
+ claude: ['opus', 'sonnet', 'haiku'],
321
+ openai: ['o4-mini', 'o3', 'o1', 'o1-mini', 'gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4-turbo'],
322
+ };
323
+
324
+ /** Safest default model for a given provider + tier */
325
+ function _safeModel(provider, tier) {
326
+ if (provider === 'claude') {
327
+ return tier === 'search' ? 'haiku' : 'sonnet';
328
+ }
329
+ return 'o4-mini';
330
+ }
331
+
332
+ /**
333
+ * Validate a routing decision against CLI availability and valid model lists.
334
+ * Returns either a (possibly corrected) decision object, or an error sentinel
335
+ * `{ _error: string }` when no CLI is available at all.
336
+ *
337
+ * @param {object} decision
338
+ * @param {{ claudeAvailable: boolean, codexAvailable: boolean }} rt Runtime info
339
+ * @returns {object} Corrected decision or `{ _error: string }`
340
+ */
341
+ function validateDispatch(decision, rt) {
342
+ let { provider = 'claude', model, tier = 'execute' } = decision;
343
+
344
+ // ── CLI availability ──────────────────────────────────────────────────────
345
+ const claudeOk = rt.claudeAvailable;
346
+ const codexOk = rt.codexAvailable;
347
+
348
+ if (!claudeOk && !codexOk) {
349
+ return { _error: 'No AI CLI available. Install claude or codex CLI.' };
350
+ }
351
+
352
+ if (provider === 'claude' && !claudeOk && codexOk) {
353
+ process.stderr.write('[dual-brain] Claude unavailable, falling back to OpenAI (codex)\n');
354
+ provider = 'openai';
355
+ } else if (provider === 'openai' && !codexOk && claudeOk) {
356
+ process.stderr.write('[dual-brain] OpenAI unavailable, falling back to Claude (claude)\n');
357
+ provider = 'claude';
358
+ }
359
+
360
+ // ── Model validation ──────────────────────────────────────────────────────
361
+ const validList = VALID_MODELS[provider] ?? [];
362
+ if (model && !validList.includes(model)) {
363
+ const safe = _safeModel(provider, tier);
364
+ process.stderr.write(`[dual-brain] Model "${model}" not valid for ${provider} CLI; defaulting to "${safe}"\n`);
365
+ model = safe;
366
+ }
367
+
368
+ return { ...decision, provider, model };
369
+ }
370
+
371
+ // ─── Feature 2: Dirty-worktree guard ─────────────────────────────────────────
372
+
373
+ /**
374
+ * Simple glob match:
375
+ * - `dir/*` → prefix match on `dir/`
376
+ * - `*.ext` → suffix match on `.ext`
377
+ * - otherwise → exact match
378
+ */
379
+ function _globMatch(pattern, filePath) {
380
+ if (pattern.endsWith('/*')) {
381
+ const prefix = pattern.slice(0, -1); // 'src/auth/'
382
+ return filePath.startsWith(prefix);
383
+ }
384
+ if (pattern.startsWith('*.')) {
385
+ const suffix = pattern.slice(1); // '.mjs'
386
+ return filePath.endsWith(suffix);
387
+ }
388
+ return filePath === pattern;
389
+ }
390
+
391
+ /**
392
+ * Check whether dirty worktree files overlap with the agent's ownership globs.
393
+ *
394
+ * @param {string[]} owns Glob patterns for files the agent will touch
395
+ * @param {string} cwd Working directory for git
396
+ * @returns {Promise<{ safe: boolean, conflicts?: string[] }>}
397
+ */
398
+ async function checkWorktreeClean(owns, cwd) {
399
+ if (!owns || owns.length === 0) return { safe: true };
400
+
401
+ const dirty = await new Promise((resolve) => {
402
+ const proc = spawn('git', ['status', '--porcelain', '-u'], {
403
+ cwd: cwd || process.cwd(),
404
+ stdio: ['ignore', 'pipe', 'pipe'],
405
+ });
406
+ let out = '';
407
+ proc.stdout.on('data', (d) => { out += d; });
408
+ proc.on('close', () => {
409
+ // Each line: "XY path" — grab the path part (columns 4+, after "XY ")
410
+ const files = out.split('\n')
411
+ .map(l => l.slice(3).trim())
412
+ .filter(Boolean);
413
+ resolve(files);
414
+ });
415
+ proc.on('error', () => resolve([])); // git not available → skip guard
416
+ });
417
+
418
+ if (dirty.length === 0) return { safe: true };
419
+
420
+ const conflicts = dirty.filter(f =>
421
+ owns.some(pattern => _globMatch(pattern, f))
422
+ );
423
+
424
+ if (conflicts.length > 0) return { safe: false, conflicts };
425
+ return { safe: true };
426
+ }
427
+
428
+ // ─── Feature 3: Retry budget ──────────────────────────────────────────────────
429
+
430
+ /** Per-prompt retry count (keyed by first 16 hex chars of SHA-256 of prompt) */
431
+ const _retryCount = new Map();
432
+
433
+ /** Recent dispatch timestamps for the 5-minute window rate-limit */
434
+ const _recentDispatches = [];
435
+
436
+ const MAX_RETRIES_PER_TASK = 2;
437
+ const MAX_DISPATCHES_PER_5MIN = 5;
438
+ const WINDOW_MS = 5 * 60 * 1000;
439
+
440
+ function _promptKey(prompt) {
441
+ return createHash('sha256').update(String(prompt)).digest('hex').slice(0, 16);
442
+ }
443
+
444
+ /**
445
+ * Check whether this dispatch is within budget.
446
+ * @param {string} prompt
447
+ * @returns {{ allowed: boolean, reason?: string }}
448
+ */
449
+ function _checkRetryBudget(prompt) {
450
+ const now = Date.now();
451
+
452
+ // Evict dispatch timestamps older than 5 minutes
453
+ while (_recentDispatches.length > 0 && now - _recentDispatches[0] > WINDOW_MS) {
454
+ _recentDispatches.shift();
455
+ }
456
+
457
+ if (_recentDispatches.length >= MAX_DISPATCHES_PER_5MIN) {
458
+ return { allowed: false, reason: 'Retry budget exhausted. Wait or adjust task.' };
459
+ }
460
+
461
+ const key = _promptKey(prompt);
462
+ const count = _retryCount.get(key) ?? 0;
463
+ if (count > MAX_RETRIES_PER_TASK) {
464
+ return { allowed: false, reason: 'Retry budget exhausted. Wait or adjust task.' };
465
+ }
466
+
467
+ return { allowed: true };
468
+ }
469
+
470
+ function _recordDispatchBudget(prompt) {
471
+ _recentDispatches.push(Date.now());
472
+ const key = _promptKey(prompt);
473
+ _retryCount.set(key, (_retryCount.get(key) ?? 0) + 1);
474
+ }
475
+
476
+ /**
477
+ * Return current retry budget state for status display.
478
+ * @returns {object}
479
+ */
480
+ function getRetryBudget() {
481
+ const now = Date.now();
482
+ const active = _recentDispatches.filter(t => now - t <= WINDOW_MS).length;
483
+ return {
484
+ perTaskRetries: Object.fromEntries(_retryCount),
485
+ recentDispatches: active,
486
+ windowMs: WINDOW_MS,
487
+ maxPerTask: MAX_RETRIES_PER_TASK,
488
+ maxPerWindow: MAX_DISPATCHES_PER_5MIN,
489
+ };
490
+ }
491
+
492
+ // ─── Command builder ──────────────────────────────────────────────────────────
493
+
494
+ function buildCommand(decision, prompt, files = [], _cwd) {
495
+ const provider = decision?.provider ?? 'claude';
496
+ const modelAlias = decision?.model ?? 'sonnet';
497
+ const effort = decision?.effort ?? null;
498
+ const sandbox = decision?.sandbox ?? 'danger-full-access';
499
+
500
+ if (provider === 'claude') {
501
+ const modelId = CLAUDE_MODEL_IDS[modelAlias] ?? modelAlias;
502
+ const cmd = ['claude', '--model', modelId, '--print', '--output-format', 'json', '-p', prompt];
503
+ if (effort) cmd.push('--effort', effort);
504
+ return cmd;
505
+ }
506
+
507
+ // openai / codex
508
+ const cmd = ['codex', 'exec', '-m', modelAlias, '-s', sandbox, prompt];
509
+ if (effort) cmd.push('-c', `reasoning.effort="${effort}"`);
510
+ return cmd;
511
+ }
512
+
513
+ // ─── Usage recorder ───────────────────────────────────────────────────────────
514
+ function recordUsage(entry) {
515
+ try {
516
+ mkdirSync(USAGE_DIR, { recursive: true });
517
+ const date = new Date().toISOString().slice(0, 10);
518
+ appendFileSync(
519
+ join(USAGE_DIR, `${date}.jsonl`),
520
+ JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n',
521
+ );
522
+ } catch {}
523
+ }
524
+
525
+ // ─── Result compressor ────────────────────────────────────────────────────────
526
+ function compressResult(rawOutput = '', maxLength = 300) {
527
+ if (!rawOutput) return '(no output)';
528
+
529
+ // Try JSON parse first (claude --output-format json)
530
+ try {
531
+ const parsed = JSON.parse(rawOutput);
532
+ const text = parsed?.result ?? parsed?.content ?? parsed?.message ?? JSON.stringify(parsed);
533
+ return String(text).slice(0, maxLength);
534
+ } catch {}
535
+
536
+ // Strip code blocks
537
+ let cleaned = rawOutput
538
+ .replace(/```[\s\S]*?```/g, '[code block]')
539
+ .replace(/^\s+at\s+.+$/gm, '') // stack trace lines
540
+ .replace(/\n{3,}/g, '\n\n') // collapse blank lines
541
+ .trim();
542
+
543
+ // Extract first 2 meaningful sentences
544
+ const sentences = cleaned.match(/[^.!?\n]+[.!?\n]+/g) ?? [cleaned];
545
+ const meaningful = sentences.filter(s => s.trim().length > 15).slice(0, 2);
546
+ const head = meaningful.join(' ').trim() || cleaned.slice(0, maxLength);
547
+
548
+ // Append file-change hints if present
549
+ const fileHints = rawOutput.match(/(?:changed|edited|wrote|created|modified)\s+([^\s,]+\.[a-z]+)/gi) ?? [];
550
+ const suffix = fileHints.length ? ` | files: ${[...new Set(fileHints)].slice(0, 3).join(', ')}` : '';
551
+
552
+ return (head + suffix).slice(0, maxLength);
553
+ }
554
+
555
+ // ─── Core runner ──────────────────────────────────────────────────────────────
556
+ function runProcess(cmd, cwd, timeoutMs, env) {
557
+ return new Promise((resolve) => {
558
+ const [bin, ...args] = cmd;
559
+ const start = Date.now();
560
+ let stdout = '';
561
+ let stderr = '';
562
+
563
+ const spawnEnv = env ? { ...process.env, ...env } : undefined;
564
+ const proc = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}) });
565
+
566
+ proc.stdout.on('data', (d) => { stdout += d; });
567
+ proc.stderr.on('data', (d) => { stderr += d; });
568
+
569
+ const killer = setTimeout(() => {
570
+ try { proc.kill('SIGTERM'); } catch {}
571
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 5000);
572
+ }, timeoutMs);
573
+
574
+ proc.on('close', (code) => {
575
+ clearTimeout(killer);
576
+ resolve({ exitCode: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim(), durationMs: Date.now() - start });
577
+ });
578
+
579
+ proc.on('error', (err) => {
580
+ clearTimeout(killer);
581
+ resolve({ exitCode: 1, stdout: '', stderr: err.message, durationMs: Date.now() - start });
582
+ });
583
+ });
584
+ }
585
+
586
+ // ─── Dispatch marker ─────────────────────────────────────────────────────────
587
+ // Prepend a marker to every prompt that goes through the official dispatch pipeline.
588
+ // The enforce-tier hook checks for this marker to distinguish legitimate dispatches
589
+ // from raw Agent calls made by the HEAD that bypass the dual-brain pipeline.
590
+ // Format: <!-- dual-brain-dispatch: <runId> -->
591
+ // runId is a short timestamp-based ID that ties back to this dispatch session.
592
+
593
+ let _dispatchRunId = null;
594
+
595
+ function _getDispatchRunId() {
596
+ if (!_dispatchRunId) {
597
+ // Generate once per process: timestamp + random suffix
598
+ _dispatchRunId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
599
+ }
600
+ return _dispatchRunId;
601
+ }
602
+
603
+ function _prependDispatchMarker(prompt) {
604
+ const runId = _getDispatchRunId();
605
+ return `<!-- dual-brain-dispatch: ${runId} -->\n${prompt}`;
606
+ }
607
+
608
+ // ─── Main dispatch ────────────────────────────────────────────────────────────
609
+ async function dispatch(input = {}) {
610
+ const { files = [], cwd = process.cwd(), dryRun = false } = input;
611
+ let decision = input.decision ?? {};
612
+ let { prompt } = input;
613
+
614
+ if (!prompt) throw new Error('prompt is required');
615
+
616
+ // Safety gate: redact secrets before anything reaches a subprocess or log
617
+ prompt = redact(prompt);
618
+
619
+ // Stamp the prompt with the dispatch marker so enforce-tier.mjs can recognise
620
+ // that this agent call came through the official pipeline.
621
+ prompt = _prependDispatchMarker(prompt);
622
+
623
+ // ── Specialist prompt injection ──────────────────────────────────────────────
624
+ const specialist = decision.specialist && decision.specialist !== 'generic'
625
+ ? decision.specialist
626
+ : null;
627
+
628
+ if (specialist) {
629
+ const specialistPrompt = loadSpecialistPrompt(specialist);
630
+ if (specialistPrompt) {
631
+ prompt = `${specialistPrompt}\n\n---\n\n${prompt}`;
632
+ process.stderr.write(`[dual-brain] specialist: ${specialist}\n`);
633
+ }
634
+
635
+ // Apply tier_bias from registry if decision didn't already pin a tier
636
+ if (!decision.tier) {
637
+ const registry = _loadSpecialistRegistry();
638
+ const tierBias = registry?.specialists?.[specialist]?.tier_bias;
639
+ if (tierBias) {
640
+ decision = { ...decision, tier: tierBias };
641
+ process.stderr.write(`[dual-brain] specialist tier_bias applied: ${tierBias}\n`);
642
+ }
643
+ }
644
+ }
645
+ // ── End specialist injection ─────────────────────────────────────────────────
646
+
647
+ const tier = decision.tier ?? 'execute';
648
+ const timeoutMs = TIER_TIMEOUT_MS[tier] ?? 120_000;
649
+
650
+ // ── Feature 3: Retry budget check ────────────────────────────────────────
651
+ const budget = _checkRetryBudget(prompt);
652
+ if (!budget.allowed) {
653
+ return {
654
+ status: 'error',
655
+ provider: decision.provider ?? 'claude',
656
+ model: decision.model ?? 'sonnet',
657
+ command: null,
658
+ exitCode: null,
659
+ summary: budget.reason,
660
+ durationMs: 0,
661
+ usage: null,
662
+ error: budget.reason,
663
+ };
664
+ }
665
+
666
+ // ── Feature 1: Validate dispatch (CLI availability + model) ──────────────
667
+ const rt = await detectRuntime();
668
+ const validated = validateDispatch({ ...decision, tier }, rt);
669
+
670
+ if (validated._error) {
671
+ return {
672
+ status: 'error',
673
+ provider: decision.provider ?? 'claude',
674
+ model: decision.model ?? 'sonnet',
675
+ command: null,
676
+ exitCode: null,
677
+ summary: validated._error,
678
+ durationMs: 0,
679
+ usage: null,
680
+ error: validated._error,
681
+ };
682
+ }
683
+
684
+ const effectiveProvider = validated.provider;
685
+ const effectiveModel = validated.model ?? decision.model ?? 'sonnet';
686
+ const effectiveDecision = { ...validated };
687
+
688
+ // ── Feature 2: Dirty-worktree guard for execute-tier dispatches ──────────
689
+ if (tier === 'execute' && decision.owns && !decision._force) {
690
+ const wtCheck = await checkWorktreeClean(decision.owns, cwd);
691
+ if (!wtCheck.safe) {
692
+ const msg = `Uncommitted changes conflict with agent scope: ${wtCheck.conflicts.join(', ')}. Commit or stash before dispatching.`;
693
+ return {
694
+ status: 'error',
695
+ provider: effectiveProvider,
696
+ model: effectiveModel,
697
+ command: null,
698
+ exitCode: null,
699
+ summary: msg,
700
+ durationMs: 0,
701
+ usage: null,
702
+ error: msg,
703
+ };
704
+ }
705
+ }
706
+
707
+ // ── Native Claude Code dispatch ──────────────────────────────────────────────
708
+ // When running inside Claude Code AND the provider is claude, execute via the
709
+ // claude CLI directly (foreground subprocess) so results are captured and returned.
710
+ // DUAL_BRAIN_DISPATCH=1 is set so the enforce-tier hook allows this agent call.
711
+ if (isInsideClaude() && effectiveProvider === 'claude') {
712
+ const nativeDescriptor = buildNativeDispatch(
713
+ effectiveDecision,
714
+ prompt,
715
+ { worktree: input.worktree, maxTurns: input.maxTurns },
716
+ );
717
+
718
+ const command = buildCommand(effectiveDecision, prompt, files, cwd);
719
+
720
+ if (dryRun) {
721
+ return {
722
+ status: 'dry-run',
723
+ provider: effectiveProvider,
724
+ model: effectiveModel,
725
+ specialist: specialist ?? 'generic',
726
+ command,
727
+ nativeDispatch: nativeDescriptor,
728
+ exitCode: null,
729
+ summary: null,
730
+ durationMs: 0,
731
+ usage: null,
732
+ error: null,
733
+ };
734
+ }
735
+
736
+ _recordDispatchBudget(prompt);
737
+
738
+ const dispatchEnv = { DUAL_BRAIN_DISPATCH: '1' };
739
+ const { exitCode, stdout, stderr, durationMs } = await runProcess(command, cwd, timeoutMs, dispatchEnv);
740
+
741
+ // Extract token usage from JSON output if available
742
+ let usage = null;
743
+ try {
744
+ const parsed = JSON.parse(stdout);
745
+ if (parsed?.usage) {
746
+ usage = { inputTokens: parsed.usage.input_tokens ?? 0, outputTokens: parsed.usage.output_tokens ?? 0 };
747
+ }
748
+ } catch {}
749
+
750
+ const success = exitCode === 0;
751
+ const errorText = (stderr || stdout).slice(0, 500);
752
+ const summary = success ? compressResult(stdout) : compressResult(stderr || stdout);
753
+
754
+ // ── Health tracking ────────────────────────────────────────────────────
755
+ if (success) {
756
+ recordDuration(effectiveProvider, effectiveModel, durationMs);
757
+ const median = medianDuration(effectiveProvider, effectiveModel);
758
+ if (median !== null && durationMs > median * 3) {
759
+ markDegraded(effectiveProvider, effectiveModel, cwd);
760
+ } else {
761
+ markHealthy(effectiveProvider, effectiveModel, cwd);
762
+ }
763
+ const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
764
+ recordDispatch(effectiveProvider, effectiveModel, totalTokens, cwd);
765
+ } else {
766
+ if (RATE_LIMIT_PATTERNS.test(errorText)) {
767
+ markHot(effectiveProvider, effectiveModel, cwd);
768
+ }
769
+ }
770
+ // ── End health tracking ────────────────────────────────────────────────
771
+
772
+ recordUsage({
773
+ provider: effectiveProvider,
774
+ model: effectiveModel,
775
+ tier,
776
+ durationMs,
777
+ inputTokens: usage?.inputTokens ?? null,
778
+ outputTokens: usage?.outputTokens ?? null,
779
+ success,
780
+ });
781
+
782
+ return {
783
+ status: success ? 'completed' : 'failed',
784
+ type: 'native-agent',
785
+ provider: effectiveProvider,
786
+ model: effectiveModel,
787
+ specialist: specialist ?? 'generic',
788
+ command,
789
+ nativeDispatch: nativeDescriptor,
790
+ exitCode,
791
+ summary,
792
+ durationMs,
793
+ usage,
794
+ error: success ? null : errorText.slice(0, 200),
795
+ };
796
+ }
797
+
798
+ const command = buildCommand(effectiveDecision, prompt, files, cwd);
799
+
800
+ if (dryRun) {
801
+ return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
802
+ }
803
+
804
+ // Record this dispatch against the budget
805
+ _recordDispatchBudget(prompt);
806
+
807
+ const { exitCode, stdout, stderr, durationMs } = await runProcess(command, cwd, timeoutMs);
808
+
809
+ // Extract token usage from JSON output if available
810
+ let usage = null;
811
+ try {
812
+ const parsed = JSON.parse(stdout);
813
+ if (parsed?.usage) {
814
+ usage = { inputTokens: parsed.usage.input_tokens ?? 0, outputTokens: parsed.usage.output_tokens ?? 0 };
815
+ }
816
+ } catch {}
817
+
818
+ const success = exitCode === 0;
819
+ const errorText = (stderr || stdout).slice(0, 500);
820
+ const summary = success ? compressResult(stdout) : compressResult(stderr || stdout);
821
+
822
+ // ── Health tracking ──────────────────────────────────────────────────────
823
+ if (success) {
824
+ recordDuration(effectiveProvider, effectiveModel, durationMs);
825
+ const median = medianDuration(effectiveProvider, effectiveModel);
826
+ if (median !== null && durationMs > median * 3) {
827
+ markDegraded(effectiveProvider, effectiveModel, cwd);
828
+ } else {
829
+ markHealthy(effectiveProvider, effectiveModel, cwd);
830
+ }
831
+ const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
832
+ recordDispatch(effectiveProvider, effectiveModel, totalTokens, cwd);
833
+ } else {
834
+ if (RATE_LIMIT_PATTERNS.test(errorText)) {
835
+ markHot(effectiveProvider, effectiveModel, cwd);
836
+ }
837
+ }
838
+ // ── End health tracking ──────────────────────────────────────────────────
839
+
840
+ recordUsage({
841
+ provider: effectiveProvider,
842
+ model: effectiveModel,
843
+ tier,
844
+ durationMs,
845
+ inputTokens: usage?.inputTokens ?? null,
846
+ outputTokens: usage?.outputTokens ?? null,
847
+ success,
848
+ });
849
+
850
+ return {
851
+ status: success ? 'completed' : 'failed',
852
+ provider: effectiveProvider,
853
+ model: effectiveModel,
854
+ specialist: specialist ?? 'generic',
855
+ command,
856
+ exitCode,
857
+ summary,
858
+ durationMs,
859
+ usage,
860
+ error: success ? null : errorText.slice(0, 200),
861
+ };
862
+ }
863
+
864
+ // ─── Dual-brain dispatch (parallel) ───────────────────────────────────────────
865
+ async function dispatchDualBrain(input = {}) {
866
+ const { decision = {}, files = [], cwd = process.cwd(), dryRun = false } = input;
867
+ let { prompt } = input;
868
+ if (!prompt) throw new Error('prompt is required');
869
+
870
+ // Safety gate: redact secrets before sending to either provider
871
+ prompt = redact(prompt);
872
+
873
+ // Stamp with dispatch marker so enforce-tier.mjs allows this Agent call
874
+ prompt = _prependDispatchMarker(prompt);
875
+
876
+ // Feature 1: Validate both sub-decisions before spawning anything
877
+ const rt = await detectRuntime();
878
+ const tier = decision.tier ?? 'execute';
879
+
880
+ const claudeDecision = { ...decision, provider: 'claude', model: decision.model ?? 'sonnet', tier };
881
+ const _oaiDefault = tier === 'think' ? 'o3' : tier === 'search' ? 'gpt-4o-mini' : 'gpt-4o';
882
+ const openaiDecision = { ...decision, provider: 'openai', model: decision.openaiModel ?? _oaiDefault, tier };
883
+
884
+ const validatedClaude = validateDispatch(claudeDecision, rt);
885
+ const validatedOpenai = validateDispatch(openaiDecision, rt);
886
+
887
+ const [claudeResult, openaiResult] = await Promise.all([
888
+ validatedClaude._error
889
+ ? Promise.resolve({ status: 'error', provider: 'claude', model: claudeDecision.model, command: null, exitCode: null, summary: validatedClaude._error, durationMs: 0, usage: null, error: validatedClaude._error })
890
+ : dispatch({ decision: validatedClaude, prompt, files, cwd, dryRun }),
891
+ validatedOpenai._error
892
+ ? Promise.resolve({ status: 'error', provider: 'openai', model: openaiDecision.model, command: null, exitCode: null, summary: validatedOpenai._error, durationMs: 0, usage: null, error: validatedOpenai._error })
893
+ : dispatch({ decision: validatedOpenai, prompt, files, cwd, dryRun }),
894
+ ]);
895
+
896
+ return {
897
+ tier,
898
+ claude: claudeResult,
899
+ openai: openaiResult,
900
+ consensus: claudeResult.status === 'completed' && openaiResult.status === 'completed'
901
+ ? 'both-passed'
902
+ : claudeResult.status === 'failed' && openaiResult.status === 'failed'
903
+ ? 'both-failed'
904
+ : 'split',
905
+ };
906
+ }
907
+
908
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
909
+ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
910
+ const args = process.argv.slice(2);
911
+ const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; };
912
+
913
+ if (args.includes('--detect-runtime')) {
914
+ const rt = await detectRuntime();
915
+ console.log(JSON.stringify(rt, null, 2));
916
+ process.exit(0);
917
+ }
918
+
919
+ const prompt = flag('--prompt') || args.find(a => !a.startsWith('--'));
920
+ if (!prompt) {
921
+ console.error('Usage: node src/dispatch.mjs --prompt "..." [--provider claude|openai] [--model sonnet] [--tier execute] [--dry-run]');
922
+ console.error(' node src/dispatch.mjs --detect-runtime');
923
+ process.exit(1);
924
+ }
925
+
926
+ const decision = {
927
+ provider: flag('--provider') || 'claude',
928
+ model: flag('--model') || 'sonnet',
929
+ tier: flag('--tier') || 'execute',
930
+ effort: flag('--effort') || null,
931
+ };
932
+
933
+ try {
934
+ const result = await dispatch({ decision, prompt, dryRun: args.includes('--dry-run') });
935
+ console.log(JSON.stringify(result, null, 2));
936
+ } catch (err) {
937
+ console.error('dispatch error:', err.message);
938
+ process.exit(1);
939
+ }
940
+ }
941
+
942
+ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt };