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.
- package/bin/dual-brain.mjs +278 -66
- package/package.json +8 -2
- package/src/agents/registry.mjs +405 -0
- package/src/collaboration.mjs +545 -0
- package/src/detect.mjs +73 -1
- package/src/dispatch.mjs +47 -5
- package/src/head.mjs +705 -263
- package/src/pipeline.mjs +387 -163
- package/src/provider-context.mjs +257 -0
|
@@ -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');
|