dual-brain 0.2.9 → 0.2.11

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.
@@ -0,0 +1,545 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // ── Blackboard: shared state across collaborating agents ────────────────────
5
+
6
+ /**
7
+ * Create a fresh collaboration session.
8
+ * All agents in a multi-agent task share this blackboard.
9
+ */
10
+ export function createSession(taskId, objective, opts = {}) {
11
+ return {
12
+ id: taskId || Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
13
+ objective,
14
+ created: Date.now(),
15
+ status: 'active',
16
+
17
+ // Shared knowledge — agents write findings here, others read them
18
+ blackboard: {
19
+ findings: [], // { agentId, type, content, confidence, timestamp }
20
+ files: new Set(), // files discovered or changed (serialized as array)
21
+ decisions: [], // { agentId, decision, rationale, timestamp }
22
+ warnings: [], // { agentId, severity, message, timestamp }
23
+ context: {}, // arbitrary key-value context any agent can set
24
+ },
25
+
26
+ // Agent tracking
27
+ agents: [], // { id, role, provider, model, status, startedAt, completedAt, result }
28
+
29
+ // Event log — HEAD reads this to know what happened
30
+ events: [], // { type, agentId, data, timestamp }
31
+
32
+ // Chain configuration
33
+ chain: opts.chain || null, // ordered list of stages if chained execution
34
+ currentStage: 0,
35
+
36
+ // Cross-review config
37
+ crossReview: opts.crossReview ?? false,
38
+ };
39
+ }
40
+
41
+ // ── Blackboard operations ───────────────────────────────────────────────────
42
+
43
+ export function addFinding(session, agentId, type, content, confidence = 0.8) {
44
+ session.blackboard.findings.push({
45
+ agentId, type, content, confidence, timestamp: Date.now(),
46
+ });
47
+ _emitEvent(session, 'finding', agentId, { type, content, confidence });
48
+ }
49
+
50
+ export function addDecision(session, agentId, decision, rationale) {
51
+ session.blackboard.decisions.push({
52
+ agentId, decision, rationale, timestamp: Date.now(),
53
+ });
54
+ _emitEvent(session, 'decision', agentId, { decision, rationale });
55
+ }
56
+
57
+ export function addWarning(session, agentId, severity, message) {
58
+ session.blackboard.warnings.push({
59
+ agentId, severity, message, timestamp: Date.now(),
60
+ });
61
+ _emitEvent(session, 'warning', agentId, { severity, message });
62
+ }
63
+
64
+ export function setContext(session, key, value, agentId = 'head') {
65
+ session.blackboard.context[key] = value;
66
+ _emitEvent(session, 'context-set', agentId, { key });
67
+ }
68
+
69
+ export function trackFile(session, filePath, agentId) {
70
+ if (typeof session.blackboard.files === 'object' && session.blackboard.files instanceof Set) {
71
+ session.blackboard.files.add(filePath);
72
+ } else {
73
+ if (!Array.isArray(session.blackboard.files)) session.blackboard.files = [];
74
+ if (!session.blackboard.files.includes(filePath)) session.blackboard.files.push(filePath);
75
+ }
76
+ _emitEvent(session, 'file-tracked', agentId, { filePath });
77
+ }
78
+
79
+ // ── Agent lifecycle ─────────────────────────────────────────────────────────
80
+
81
+ export function registerAgent(session, agentId, role, provider, model) {
82
+ const agent = {
83
+ id: agentId,
84
+ role,
85
+ provider,
86
+ model,
87
+ status: 'registered',
88
+ startedAt: null,
89
+ completedAt: null,
90
+ result: null,
91
+ summary: null,
92
+ };
93
+ session.agents.push(agent);
94
+ _emitEvent(session, 'agent-registered', agentId, { role, provider, model });
95
+ return agent;
96
+ }
97
+
98
+ export function startAgent(session, agentId) {
99
+ const agent = session.agents.find(a => a.id === agentId);
100
+ if (agent) {
101
+ agent.status = 'running';
102
+ agent.startedAt = Date.now();
103
+ _emitEvent(session, 'agent-started', agentId, {});
104
+ }
105
+ }
106
+
107
+ export function completeAgent(session, agentId, result, summary) {
108
+ const agent = session.agents.find(a => a.id === agentId);
109
+ if (agent) {
110
+ agent.status = result?.error ? 'failed' : 'completed';
111
+ agent.completedAt = Date.now();
112
+ agent.result = result;
113
+ agent.summary = summary || _extractSummary(result);
114
+ _emitEvent(session, 'agent-completed', agentId, {
115
+ status: agent.status,
116
+ durationMs: agent.completedAt - agent.startedAt,
117
+ summary: agent.summary,
118
+ });
119
+ }
120
+ }
121
+
122
+ // ── Context builder: what an agent sees from prior agents ───────────────────
123
+
124
+ /**
125
+ * Build a context injection string for the next agent in the collaboration.
126
+ * Contains: blackboard findings, decisions, warnings, and prior agent summaries.
127
+ * Token-budgeted to stay compact.
128
+ */
129
+ export function buildAgentContext(session, forAgentId, maxTokens = 2000) {
130
+ const lines = [];
131
+ const charBudget = maxTokens * 4;
132
+
133
+ lines.push('[COLLABORATION CONTEXT]');
134
+
135
+ // Prior agent summaries (most valuable — what others already did)
136
+ const completedAgents = session.agents.filter(a => a.status === 'completed' && a.id !== forAgentId);
137
+ if (completedAgents.length > 0) {
138
+ lines.push('');
139
+ lines.push('Prior work:');
140
+ for (const a of completedAgents) {
141
+ const duration = a.completedAt - a.startedAt;
142
+ const durationLabel = duration > 60000 ? `${Math.round(duration / 60000)}m` : `${Math.round(duration / 1000)}s`;
143
+ lines.push(`- ${a.role} (${a.provider}/${a.model}, ${durationLabel}): ${(a.summary || 'completed').slice(0, 200)}`);
144
+ }
145
+ }
146
+
147
+ // Key findings (high confidence first)
148
+ const findings = [...session.blackboard.findings]
149
+ .sort((a, b) => b.confidence - a.confidence)
150
+ .slice(0, 10);
151
+ if (findings.length > 0) {
152
+ lines.push('');
153
+ lines.push('Findings:');
154
+ for (const f of findings) {
155
+ lines.push(`- [${f.type}] ${f.content.slice(0, 150)}`);
156
+ }
157
+ }
158
+
159
+ // Decisions made
160
+ if (session.blackboard.decisions.length > 0) {
161
+ lines.push('');
162
+ lines.push('Decisions:');
163
+ for (const d of session.blackboard.decisions.slice(-5)) {
164
+ lines.push(`- ${d.decision}: ${d.rationale.slice(0, 100)}`);
165
+ }
166
+ }
167
+
168
+ // Active warnings
169
+ const activeWarnings = session.blackboard.warnings.filter(w => w.severity === 'high' || w.severity === 'critical');
170
+ if (activeWarnings.length > 0) {
171
+ lines.push('');
172
+ lines.push('Warnings:');
173
+ for (const w of activeWarnings) {
174
+ lines.push(`- [${w.severity}] ${w.message.slice(0, 120)}`);
175
+ }
176
+ }
177
+
178
+ // Files touched
179
+ const files = session.blackboard.files instanceof Set
180
+ ? [...session.blackboard.files]
181
+ : (Array.isArray(session.blackboard.files) ? session.blackboard.files : []);
182
+ if (files.length > 0) {
183
+ lines.push('');
184
+ lines.push(`Files in play: ${files.slice(0, 15).join(', ')}${files.length > 15 ? ` (+${files.length - 15} more)` : ''}`);
185
+ }
186
+
187
+ lines.push('[/COLLABORATION CONTEXT]');
188
+
189
+ let result = lines.join('\n');
190
+ if (result.length > charBudget) {
191
+ result = result.slice(0, charBudget - 20) + '\n[...truncated]';
192
+ }
193
+ return result;
194
+ }
195
+
196
+ // ── Chain execution: ordered multi-stage pipelines ──────────────────────────
197
+
198
+ /**
199
+ * Define a chain of agent stages.
200
+ * Each stage runs after the previous completes, with full blackboard access.
201
+ *
202
+ * @param {Array<{ role: string, tier: string, promptTemplate: Function, provider?: string, model?: string }>} stages
203
+ */
204
+ export function defineChain(stages) {
205
+ return stages.map((s, i) => ({
206
+ index: i,
207
+ role: s.role,
208
+ tier: s.tier || 'execute',
209
+ promptTemplate: s.promptTemplate,
210
+ provider: s.provider || 'claude',
211
+ model: s.model || null,
212
+ dependsOn: i > 0 ? [i - 1] : [],
213
+ }));
214
+ }
215
+
216
+ /**
217
+ * Get the next stage to execute in a chain.
218
+ * Returns null when all stages are complete or if dependencies aren't met.
219
+ */
220
+ export function getNextStage(session) {
221
+ if (!session.chain) return null;
222
+
223
+ const stage = session.chain[session.currentStage];
224
+ if (!stage) return null;
225
+
226
+ // Check dependencies
227
+ for (const depIdx of stage.dependsOn || []) {
228
+ const depAgent = session.agents.find(a => a.role === session.chain[depIdx]?.role);
229
+ if (!depAgent || depAgent.status !== 'completed') return null;
230
+ }
231
+
232
+ return stage;
233
+ }
234
+
235
+ /**
236
+ * Advance the chain to the next stage.
237
+ */
238
+ export function advanceChain(session) {
239
+ session.currentStage++;
240
+ return session.currentStage < (session.chain?.length || 0);
241
+ }
242
+
243
+ /**
244
+ * Build the prompt for a chain stage, injecting collaboration context.
245
+ */
246
+ export function buildChainPrompt(session, stage) {
247
+ const context = buildAgentContext(session, `chain-${stage.index}`);
248
+ const basePrompt = stage.promptTemplate(session);
249
+ return `${context}\n\n${basePrompt}`;
250
+ }
251
+
252
+ // ── Cross-review: opposite provider reviews the work ────────────────────────
253
+
254
+ /**
255
+ * Build a cross-review prompt for an agent's output.
256
+ * Symmetric: works in both directions (Claude→OpenAI and OpenAI→Claude).
257
+ * Falls back to same-provider review with a different model if the opposite
258
+ * provider isn't available.
259
+ *
260
+ * @param {object} session
261
+ * @param {string} agentId
262
+ * @param {string[]} [availableProviders] Which providers are online
263
+ */
264
+ export function buildCrossReviewPrompt(session, agentId, availableProviders) {
265
+ const agent = session.agents.find(a => a.id === agentId);
266
+ if (!agent || !agent.result) return null;
267
+
268
+ // Symmetric provider swap — respects availability
269
+ const opposite = agent.provider === 'claude' ? 'openai' : 'claude';
270
+ const reviewProvider = (!availableProviders || availableProviders.includes(opposite))
271
+ ? opposite
272
+ : agent.provider;
273
+
274
+ // When same-provider review, use a different model tier
275
+ const sameProvider = reviewProvider === agent.provider;
276
+ const reviewModel = sameProvider
277
+ ? (agent.model === 'opus' ? 'sonnet' : 'opus')
278
+ : null;
279
+
280
+ const prompt = [
281
+ `Review the following work by ${agent.provider}/${agent.model} (${agent.role}):`,
282
+ '',
283
+ `Objective: ${session.objective}`,
284
+ '',
285
+ `Result summary: ${(agent.summary || '').slice(0, 500)}`,
286
+ '',
287
+ 'Check for:',
288
+ '- Correctness: does the output match the objective?',
289
+ '- Missed edge cases or risks',
290
+ '- Anything the next agent should know',
291
+ '',
292
+ 'Be concise. Return: assessment (pass/flag/fail), key concerns, and suggestions.',
293
+ sameProvider ? '\nNote: You are reviewing work done by the same provider but a different model. Be especially critical.' : '',
294
+ ].join('\n');
295
+
296
+ return { prompt, provider: reviewProvider, model: reviewModel, tier: 'search' };
297
+ }
298
+
299
+ // ── HEAD observation: synthesize what happened ──────────────────────────────
300
+
301
+ /**
302
+ * Generate a compact summary of the collaboration session for HEAD.
303
+ * HEAD uses this to understand what happened without reading raw outputs.
304
+ */
305
+ export function synthesize(session) {
306
+ const completed = session.agents.filter(a => a.status === 'completed');
307
+ const failed = session.agents.filter(a => a.status === 'failed');
308
+ const running = session.agents.filter(a => a.status === 'running');
309
+
310
+ const totalDuration = completed.reduce((sum, a) => sum + (a.completedAt - a.startedAt), 0);
311
+
312
+ const files = session.blackboard.files instanceof Set
313
+ ? [...session.blackboard.files]
314
+ : (Array.isArray(session.blackboard.files) ? session.blackboard.files : []);
315
+
316
+ return {
317
+ sessionId: session.id,
318
+ objective: session.objective,
319
+ status: failed.length > 0 ? 'partial' : running.length > 0 ? 'in-progress' : 'complete',
320
+ agents: {
321
+ total: session.agents.length,
322
+ completed: completed.length,
323
+ failed: failed.length,
324
+ running: running.length,
325
+ },
326
+ summaries: completed.map(a => ({
327
+ role: a.role,
328
+ provider: a.provider,
329
+ model: a.model,
330
+ summary: a.summary,
331
+ durationMs: a.completedAt - a.startedAt,
332
+ })),
333
+ findings: session.blackboard.findings.length,
334
+ decisions: session.blackboard.decisions,
335
+ warnings: session.blackboard.warnings.filter(w => w.severity !== 'low'),
336
+ filesAffected: files,
337
+ totalDurationMs: totalDuration,
338
+ eventCount: session.events.length,
339
+ };
340
+ }
341
+
342
+ // ── Preset collaboration patterns ───────────────────────────────────────────
343
+
344
+ /**
345
+ * Plan-Code-Review: the Devin-style self-review loop.
346
+ * 1. Plan agent outlines the approach
347
+ * 2. Code agent implements
348
+ * 3. Review agent checks the work
349
+ * 4. If review fails, code agent gets another pass with review feedback
350
+ */
351
+ export function planCodeReviewChain(objective, scope, opts = {}) {
352
+ return defineChain([
353
+ {
354
+ role: 'planner',
355
+ tier: 'think',
356
+ provider: opts.planProvider || 'claude',
357
+ model: opts.planModel || 'opus',
358
+ promptTemplate: (session) => {
359
+ return [
360
+ `Plan the implementation for: ${objective}`,
361
+ '',
362
+ `Scope: ${scope.join(', ')}`,
363
+ '',
364
+ 'Return: step-by-step plan, files to modify, risks, and acceptance criteria.',
365
+ 'Do NOT implement — only plan.',
366
+ ].join('\n');
367
+ },
368
+ },
369
+ {
370
+ role: 'implementer',
371
+ tier: 'execute',
372
+ provider: opts.codeProvider || 'claude',
373
+ model: opts.codeModel || 'sonnet',
374
+ promptTemplate: (session) => {
375
+ const planAgent = session.agents.find(a => a.role === 'planner');
376
+ const plan = planAgent?.summary || 'No plan available — use best judgment.';
377
+ return [
378
+ `Implement: ${objective}`,
379
+ '',
380
+ `Plan: ${plan}`,
381
+ '',
382
+ `Scope: ${scope.join(', ')}`,
383
+ '',
384
+ 'Follow the plan exactly. Report files changed and tests run.',
385
+ ].join('\n');
386
+ },
387
+ },
388
+ {
389
+ role: 'reviewer',
390
+ tier: 'review',
391
+ provider: opts.reviewProvider || (opts.codeProvider === 'claude' ? 'openai' : 'claude'),
392
+ model: opts.reviewModel || null,
393
+ promptTemplate: (session) => {
394
+ const implAgent = session.agents.find(a => a.role === 'implementer');
395
+ return [
396
+ `Review the implementation of: ${objective}`,
397
+ '',
398
+ `What was done: ${implAgent?.summary || 'unknown'}`,
399
+ '',
400
+ `Scope: ${scope.join(', ')}`,
401
+ '',
402
+ 'Check: correctness, edge cases, security, test coverage, architectural drift.',
403
+ 'Return: pass/fail, findings with severity, and fixes needed.',
404
+ ].join('\n');
405
+ },
406
+ },
407
+ ]);
408
+ }
409
+
410
+ /**
411
+ * Research-Synthesize: multiple agents research in parallel, one synthesizes.
412
+ */
413
+ export function researchSynthesizePattern(question, sources, opts = {}) {
414
+ const researchStages = sources.map((source, i) => ({
415
+ role: `researcher-${i}`,
416
+ tier: 'search',
417
+ provider: i % 2 === 0 ? 'claude' : (opts.altProvider || 'claude'),
418
+ model: opts.researchModel || 'haiku',
419
+ promptTemplate: () => `Research: ${question}\nFocus on: ${source}\nReturn: key findings, file references, confidence level.`,
420
+ }));
421
+
422
+ return defineChain([
423
+ ...researchStages,
424
+ {
425
+ role: 'synthesizer',
426
+ tier: 'think',
427
+ provider: opts.synthProvider || 'claude',
428
+ model: opts.synthModel || 'sonnet',
429
+ promptTemplate: (session) => {
430
+ const researchFindings = session.agents
431
+ .filter(a => a.role.startsWith('researcher-') && a.status === 'completed')
432
+ .map(a => `[${a.role}]: ${a.summary || 'no findings'}`)
433
+ .join('\n');
434
+ return [
435
+ `Synthesize research on: ${question}`,
436
+ '',
437
+ 'Research findings:',
438
+ researchFindings,
439
+ '',
440
+ 'Combine findings into a coherent answer. Note disagreements between sources.',
441
+ 'Return: synthesis, confidence level, remaining unknowns.',
442
+ ].join('\n');
443
+ },
444
+ },
445
+ ]);
446
+ }
447
+
448
+ /**
449
+ * Dual-Review: two providers independently review, then a third reconciles.
450
+ */
451
+ export function dualReviewPattern(files, context, opts = {}) {
452
+ return defineChain([
453
+ {
454
+ role: 'reviewer-claude',
455
+ tier: 'review',
456
+ provider: 'claude',
457
+ model: opts.claudeModel || 'sonnet',
458
+ promptTemplate: () => `Review these files: ${files.join(', ')}\nContext: ${context}\nReturn: findings with severity and line references.`,
459
+ },
460
+ {
461
+ role: 'reviewer-openai',
462
+ tier: 'review',
463
+ provider: 'openai',
464
+ model: opts.openaiModel || 'gpt-4o',
465
+ promptTemplate: () => `Review these files: ${files.join(', ')}\nContext: ${context}\nReturn: findings with severity and line references.`,
466
+ },
467
+ {
468
+ role: 'reconciler',
469
+ tier: 'think',
470
+ provider: 'claude',
471
+ model: opts.reconcileModel || 'opus',
472
+ promptTemplate: (session) => {
473
+ const reviews = session.agents
474
+ .filter(a => a.role.startsWith('reviewer-') && a.status === 'completed')
475
+ .map(a => `[${a.provider}]: ${a.summary || 'no findings'}`)
476
+ .join('\n\n');
477
+ return [
478
+ 'Reconcile two independent code reviews:',
479
+ '',
480
+ reviews,
481
+ '',
482
+ 'Identify: agreements (high confidence), disagreements (need resolution), and missed items.',
483
+ 'Return: final consolidated review with severity ratings.',
484
+ ].join('\n');
485
+ },
486
+ },
487
+ ]);
488
+ }
489
+
490
+ // ── Persistence ─────────────────────────────────────────────────────────────
491
+
492
+ export function saveSession(session, cwd) {
493
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'collaborations');
494
+ mkdirSync(dir, { recursive: true });
495
+
496
+ // Convert Set to Array for JSON serialization
497
+ const serializable = {
498
+ ...session,
499
+ blackboard: {
500
+ ...session.blackboard,
501
+ files: session.blackboard.files instanceof Set
502
+ ? [...session.blackboard.files]
503
+ : session.blackboard.files,
504
+ },
505
+ };
506
+
507
+ writeFileSync(join(dir, `${session.id}.json`), JSON.stringify(serializable, null, 2));
508
+ }
509
+
510
+ export function loadSession(sessionId, cwd) {
511
+ const path = join(cwd || process.cwd(), '.dual-brain', 'collaborations', `${sessionId}.json`);
512
+ if (!existsSync(path)) return null;
513
+ try {
514
+ const data = JSON.parse(readFileSync(path, 'utf8'));
515
+ data.blackboard.files = new Set(data.blackboard.files || []);
516
+ return data;
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+
522
+ // ── Event bus (internal) ────────────────────────────────────────────────────
523
+
524
+ function _emitEvent(session, type, agentId, data) {
525
+ session.events.push({ type, agentId, data, timestamp: Date.now() });
526
+ }
527
+
528
+ function _extractSummary(result) {
529
+ if (!result) return null;
530
+ if (typeof result === 'string') return result.slice(0, 300);
531
+ if (result.summary) return String(result.summary).slice(0, 300);
532
+ if (result.rawOutput) return String(result.rawOutput).slice(0, 300);
533
+ return null;
534
+ }
535
+
536
+ // ── Event log persistence (append-only JSONL) ───────────────────────────────
537
+
538
+ export function persistEvents(session, cwd) {
539
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'collaborations');
540
+ mkdirSync(dir, { recursive: true });
541
+ const logPath = join(dir, `${session.id}.events.jsonl`);
542
+ for (const event of session.events) {
543
+ appendFileSync(logPath, JSON.stringify(event) + '\n');
544
+ }
545
+ }
package/src/detect.mjs CHANGED
@@ -401,7 +401,7 @@ function checkCIRisk(cwd) {
401
401
 
402
402
  /** Main detection function. Input: { prompt, files?, priorFailures?, sessionContext? } */
403
403
  function detectTask(input) {
404
- const { prompt = '', files = [], sessionContext = null } = input;
404
+ const { prompt = '', files = [], sessionContext = null, headJudgment = null } = input;
405
405
  let { priorFailures = 0 } = input;
406
406
 
407
407
  // Session context: bump priorFailures if session history shows failures on similar tasks
@@ -492,6 +492,41 @@ function detectTask(input) {
492
492
  // 11. CI risk — check if current branch has failing CI runs (best-effort, never throws)
493
493
  const ciRiskResult = checkCIRisk(input.cwd || process.cwd());
494
494
 
495
+ // 12. Match specialized agent from registry (synchronous, best-effort)
496
+ const suggestedAgent = _matchAgentSync(intent, risk, specialistResult.specialist || '');
497
+
498
+ // HEAD judgment override: when HEAD's cognitive pipeline has already assessed
499
+ // the situation, use its risk/depth as authoritative and reconcile differences.
500
+ let headOverrides = {};
501
+ if (headJudgment?.situation) {
502
+ const hj = headJudgment.situation;
503
+ const headRisk = hj.taskShape?.risk;
504
+ const headAmbiguity = hj.taskShape?.ambiguity;
505
+
506
+ // HEAD's risk takes precedence when it's higher (HEAD sees more signals)
507
+ if (headRisk && LEVEL_ORDER[headRisk] > LEVEL_ORDER[risk]) {
508
+ risk = headRisk;
509
+ headOverrides.riskElevatedBy = 'head-judgment';
510
+ }
511
+
512
+ // HEAD's depth maps to reasoning depth
513
+ const headDepthMap = { reflexive: 'low', light: 'medium', full: 'high', deep: 'ultra' };
514
+ const headDepth = headDepthMap[headJudgment.depth];
515
+ if (headDepth) {
516
+ const depthOrder = { low: 0, medium: 1, high: 2, ultra: 3 };
517
+ if (depthOrder[headDepth] > depthOrder[reasoningDepth]) {
518
+ reasoningDepth = headDepth;
519
+ reasoningSignals.push(`HEAD assessed depth as ${headJudgment.depth}`);
520
+ headOverrides.depthElevatedBy = 'head-judgment';
521
+ }
522
+ }
523
+
524
+ // HEAD's ambiguity signals complexity
525
+ if (headAmbiguity === 'high' && complexity !== 'complex') {
526
+ headOverrides.ambiguityWarning = 'HEAD detected high ambiguity';
527
+ }
528
+ }
529
+
495
530
  return {
496
531
  intent,
497
532
  risk,
@@ -508,10 +543,47 @@ function detectTask(input) {
508
543
  reasoningSignals,
509
544
  suggestedPlugins,
510
545
  ciRisk: ciRiskResult,
546
+ suggestedAgent,
511
547
  ...(repeatedFailure && { repeatedFailure: true }),
548
+ ...(Object.keys(headOverrides).length > 0 && { headOverrides }),
512
549
  };
513
550
  }
514
551
 
552
+ // ─── Agent registry bridge (synchronous, injected) ───────────────────────────
553
+ //
554
+ // detect.mjs is synchronous by design. The ESM agent registry is loaded
555
+ // asynchronously by callers (pipeline, CLI) via primeAgentRegistry(), which
556
+ // caches the matchAgent function here so detectTask can call it synchronously.
557
+
558
+ let _matchAgentFn = null;
559
+
560
+ /**
561
+ * Prime the agent registry so detectTask can match agents synchronously.
562
+ * Call this once at startup: await primeAgentRegistry()
563
+ */
564
+ export async function primeAgentRegistry() {
565
+ try {
566
+ const { matchAgent } = await import('./agents/registry.mjs');
567
+ _matchAgentFn = matchAgent;
568
+ } catch {
569
+ // Registry unavailable — detectTask continues without agent matching
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Synchronously match a specialized agent from the primed registry.
575
+ * Returns the best match or null if not yet primed.
576
+ */
577
+ function _matchAgentSync(intent, risk, taskType) {
578
+ try {
579
+ if (typeof _matchAgentFn !== 'function') return null;
580
+ const matches = _matchAgentFn(intent, risk, taskType);
581
+ return matches.length > 0 ? matches[0] : null;
582
+ } catch {
583
+ return null;
584
+ }
585
+ }
586
+
515
587
  // ─── Specialist registry ──────────────────────────────────────────────────────
516
588
 
517
589
  const SPECIALIST_REGISTRY_PATH = resolve(__dirname, '../agents/specialists/registry.json');