dual-brain 0.2.13 → 0.2.15
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 +130 -4
- package/hooks/diagnostic-companion.mjs +422 -0
- package/hooks/precompact.mjs +53 -0
- package/hooks/session-end.mjs +122 -0
- package/package.json +26 -2
- package/src/cognitive-loop.mjs +532 -0
- package/src/continuity.mjs +6 -6
- package/src/cost-tracker.mjs +3 -3
- package/src/debrief.mjs +228 -0
- package/src/doctor.mjs +13 -13
- package/src/envelope.mjs +139 -0
- package/src/head-protocol.mjs +128 -0
- package/src/head.mjs +128 -78
- package/src/inbox.mjs +195 -0
- package/src/ledger.mjs +2 -2
- package/src/living-docs.mjs +2 -2
- package/src/memory-tiers.mjs +193 -0
- package/src/narrative.mjs +169 -0
- package/src/predictive.mjs +250 -0
- package/src/provider-context.mjs +2 -2
- package/src/receipt.mjs +2 -2
- package/src/session-lock.mjs +154 -0
- package/src/simmer.mjs +241 -0
- package/src/wave-planner.mjs +294 -0
package/src/debrief.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
const STATUSES = ['success', 'partial', 'blocked', 'pivoted'];
|
|
2
|
+
|
|
3
|
+
function emptyDebrief() {
|
|
4
|
+
return {
|
|
5
|
+
status: 'partial',
|
|
6
|
+
findings: [],
|
|
7
|
+
blockers: [],
|
|
8
|
+
scopeChange: 'same',
|
|
9
|
+
confidence: 0.5,
|
|
10
|
+
recommendations: [],
|
|
11
|
+
artifacts: { filesChanged: [], filesRead: [], testsRun: 0 },
|
|
12
|
+
pivotReason: undefined,
|
|
13
|
+
unexpectedFindings: [],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Parse debrief from raw agent output ─────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const FILE_PATH_RE = /(?:^|\s)([\w./-]+\.(?:mjs|js|ts|tsx|json|md|py|sh|yaml|yml|css|html))/gm;
|
|
20
|
+
const TEST_COUNT_RE = /(\d+)\s*(?:tests?|specs?|assertions?)\s*(?:passed|ran|run|succeeded|ok)/i;
|
|
21
|
+
|
|
22
|
+
function extractByPatterns(text, patterns, minLen, maxLen, limit) {
|
|
23
|
+
const results = [];
|
|
24
|
+
for (const re of patterns) {
|
|
25
|
+
for (const m of text.matchAll(re)) {
|
|
26
|
+
const v = m[1].trim();
|
|
27
|
+
if (v.length > minLen && v.length < maxLen) results.push(v);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...new Set(results)].slice(0, limit);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const extractFindings = t => extractByPatterns(t, [
|
|
34
|
+
/(?:found|discovered|identified|noticed|see that|observed)\s+(?:that\s+)?(.+?)(?:\.|$)/gim,
|
|
35
|
+
/^[-•*]\s*(.+)$/gm,
|
|
36
|
+
], 10, 300, 20);
|
|
37
|
+
|
|
38
|
+
const extractBlockers = t => extractByPatterns(t, [
|
|
39
|
+
/(?:blocked by|couldn't|cannot|unable to|failed to|can't)\s+(.+?)(?:\.|$)/gim,
|
|
40
|
+
/(?:blocker|obstacle|issue|problem):\s*(.+?)(?:\.|$)/gim,
|
|
41
|
+
], 5, 200, 10);
|
|
42
|
+
|
|
43
|
+
const extractRecommendations = t => extractByPatterns(t, [
|
|
44
|
+
/(?:recommend|suggest|should|next step|consider)\s+(.+?)(?:\.|$)/gim,
|
|
45
|
+
/(?:TODO|ACTION):\s*(.+?)(?:\.|$)/gim,
|
|
46
|
+
], 5, 200, 10);
|
|
47
|
+
|
|
48
|
+
const extractUnexpected = t => extractByPatterns(t, [
|
|
49
|
+
/(?:also noticed|unexpected|surprisingly|interestingly|aside:)\s+(.+?)(?:\.|$)/gim,
|
|
50
|
+
], 10, 200, 5);
|
|
51
|
+
|
|
52
|
+
function inferStatus(text, blockers, findings) {
|
|
53
|
+
const lower = text.toLowerCase();
|
|
54
|
+
if (/pivoted|changed approach|switched to/i.test(lower)) return 'pivoted';
|
|
55
|
+
if (blockers.length > 0 && findings.length === 0) return 'blocked';
|
|
56
|
+
if (blockers.length > 0) return 'partial';
|
|
57
|
+
if (/complete|done|success|finished|all (?:tests )?pass/i.test(lower)) return 'success';
|
|
58
|
+
return 'partial';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function inferScopeChange(text) {
|
|
62
|
+
const lower = text.toLowerCase();
|
|
63
|
+
if (/scope.{0,20}(?:grew|larger|expanded|more than expected)/i.test(lower)) return 'larger';
|
|
64
|
+
if (/scope.{0,20}(?:smaller|reduced|simpler than)/i.test(lower)) return 'smaller';
|
|
65
|
+
if (/(?:different approach|completely different|pivoted to)/i.test(lower)) return 'different';
|
|
66
|
+
return 'same';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inferConfidence(text, findings, blockers) {
|
|
70
|
+
if (/(?:very confident|high confidence|certain)/i.test(text)) return 0.9;
|
|
71
|
+
if (/(?:uncertain|not sure|low confidence|unsure)/i.test(text)) return 0.3;
|
|
72
|
+
if (blockers.length > 2) return 0.3;
|
|
73
|
+
if (blockers.length > 0) return 0.5;
|
|
74
|
+
if (findings.length > 3) return 0.8;
|
|
75
|
+
return 0.6;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseDebrief(rawOutput) {
|
|
79
|
+
if (!rawOutput || typeof rawOutput !== 'string') return emptyDebrief();
|
|
80
|
+
|
|
81
|
+
// Try JSON-structured debrief first
|
|
82
|
+
const jsonMatch = rawOutput.match(/```(?:json)?\s*(\{[\s\S]*?"status"[\s\S]*?\})\s*```/);
|
|
83
|
+
if (jsonMatch) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(jsonMatch[1]);
|
|
86
|
+
if (STATUSES.includes(parsed.status)) {
|
|
87
|
+
const d = emptyDebrief();
|
|
88
|
+
return { ...d, ...parsed, artifacts: { ...d.artifacts, ...(parsed.artifacts || {}) } };
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Best-effort extraction from prose
|
|
94
|
+
const findings = extractFindings(rawOutput);
|
|
95
|
+
const blockers = extractBlockers(rawOutput);
|
|
96
|
+
const recommendations = extractRecommendations(rawOutput);
|
|
97
|
+
const unexpectedFindings = extractUnexpected(rawOutput);
|
|
98
|
+
|
|
99
|
+
const filesAll = [...rawOutput.matchAll(FILE_PATH_RE)].map(m => m[1]);
|
|
100
|
+
const lower = rawOutput.toLowerCase();
|
|
101
|
+
const filesChanged = filesAll.filter(f => lower.includes(`wrote ${f.toLowerCase()}`) || lower.includes(`created ${f.toLowerCase()}`) || lower.includes(`modified ${f.toLowerCase()}`) || lower.includes(`edited ${f.toLowerCase()}`));
|
|
102
|
+
const filesRead = filesAll.filter(f => !filesChanged.includes(f));
|
|
103
|
+
|
|
104
|
+
const testMatch = rawOutput.match(TEST_COUNT_RE);
|
|
105
|
+
const testsRun = testMatch ? parseInt(testMatch[1], 10) : 0;
|
|
106
|
+
|
|
107
|
+
const pivotMatch = rawOutput.match(/pivoted?\s+(?:because|since|due to)\s+(.+?)(?:\.|$)/i);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
status: inferStatus(rawOutput, blockers, findings),
|
|
111
|
+
findings,
|
|
112
|
+
blockers,
|
|
113
|
+
scopeChange: inferScopeChange(rawOutput),
|
|
114
|
+
confidence: inferConfidence(rawOutput, findings, blockers),
|
|
115
|
+
recommendations,
|
|
116
|
+
artifacts: { filesChanged, filesRead, testsRun },
|
|
117
|
+
pivotReason: pivotMatch ? pivotMatch[1].trim() : undefined,
|
|
118
|
+
unexpectedFindings,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Generate debrief instruction for agent prompts ──────────────────────────
|
|
123
|
+
|
|
124
|
+
const TIER_EMPHASIS = {
|
|
125
|
+
search: 'Focus on: findings, confidence, unexpectedFindings.',
|
|
126
|
+
execute: 'Focus on: artifacts (filesChanged, testsRun), blockers, scopeChange.',
|
|
127
|
+
think: 'Focus on: recommendations, confidence, pivotReason.',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export function generateDebriefInstruction(tier, contract) {
|
|
131
|
+
const emphasis = TIER_EMPHASIS[tier] || TIER_EMPHASIS.execute;
|
|
132
|
+
const scope = contract?.scope ? ` Scope: ${contract.scope}.` : '';
|
|
133
|
+
return `\n---\nRESPONSE FORMAT: End your response with a JSON block:\n\`\`\`json\n{"status":"success|partial|blocked|pivoted","findings":[],"blockers":[],"scopeChange":"same|larger|smaller|different","confidence":0.0-1.0,"recommendations":[],"artifacts":{"filesChanged":[],"filesRead":[],"testsRun":0},"pivotReason":"...if pivoted","unexpectedFindings":[]}\n\`\`\`\n${emphasis}${scope}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Integrate debrief into HEAD's situation model ───────────────────────────
|
|
137
|
+
|
|
138
|
+
export function integrateDebrief(currentSituation, debrief) {
|
|
139
|
+
const sit = structuredClone(currentSituation);
|
|
140
|
+
|
|
141
|
+
// Update confidence on related ledger entries
|
|
142
|
+
if (!sit.ledger) sit.ledger = [];
|
|
143
|
+
for (const finding of debrief.findings) {
|
|
144
|
+
const existing = sit.ledger.find(e => e.topic && finding.toLowerCase().includes(e.topic.toLowerCase()));
|
|
145
|
+
if (existing) {
|
|
146
|
+
existing.confidence = Math.min(1, (existing.confidence || 0.5) + 0.1);
|
|
147
|
+
existing.resolved = true;
|
|
148
|
+
} else {
|
|
149
|
+
sit.ledger.push({ topic: finding.slice(0, 80), confidence: debrief.confidence, resolved: false });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Blockers create new uncertainty entries
|
|
154
|
+
for (const blocker of debrief.blockers) {
|
|
155
|
+
sit.ledger.push({ topic: blocker.slice(0, 80), confidence: 0.3, resolved: false, isBlocker: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Scope delta
|
|
159
|
+
if (debrief.scopeChange !== 'same') {
|
|
160
|
+
if (!sit.material) sit.material = {};
|
|
161
|
+
sit.material.scopeTrend = debrief.scopeChange;
|
|
162
|
+
if (debrief.artifacts?.filesChanged) {
|
|
163
|
+
sit.material.touchedFiles = [
|
|
164
|
+
...new Set([...(sit.material.touchedFiles || []), ...debrief.artifacts.filesChanged]),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Recommendations → next wave planning
|
|
170
|
+
if (!sit.nextActions) sit.nextActions = [];
|
|
171
|
+
sit.nextActions.push(...debrief.recommendations);
|
|
172
|
+
|
|
173
|
+
// Unexpected findings → noticings
|
|
174
|
+
if (debrief.unexpectedFindings?.length) {
|
|
175
|
+
if (!sit.noticings) sit.noticings = [];
|
|
176
|
+
sit.noticings.push(...debrief.unexpectedFindings);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Overall confidence update
|
|
180
|
+
sit.confidence = sit.confidence
|
|
181
|
+
? sit.confidence * 0.7 + debrief.confidence * 0.3
|
|
182
|
+
: debrief.confidence;
|
|
183
|
+
|
|
184
|
+
return sit;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Summarize a parallel wave of debriefs ───────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export function summarizeWaveOutcome(debriefs) {
|
|
190
|
+
if (!debriefs || debriefs.length === 0) {
|
|
191
|
+
return {
|
|
192
|
+
overallStatus: 'blocked',
|
|
193
|
+
aggregateConfidence: 0,
|
|
194
|
+
allFindings: [],
|
|
195
|
+
allBlockers: [],
|
|
196
|
+
scopeDelta: 'same',
|
|
197
|
+
nextActions: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const statusPriority = { blocked: 0, pivoted: 1, partial: 2, success: 3 };
|
|
202
|
+
const worstStatus = debriefs.reduce((worst, d) =>
|
|
203
|
+
(statusPriority[d.status] ?? 2) < (statusPriority[worst] ?? 2) ? d.status : worst,
|
|
204
|
+
'success'
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const allFindings = [...new Set(debriefs.flatMap(d => d.findings || []))];
|
|
208
|
+
const allBlockers = [...new Set(debriefs.flatMap(d => d.blockers || []))];
|
|
209
|
+
const nextActions = [...new Set(debriefs.flatMap(d => d.recommendations || []))];
|
|
210
|
+
|
|
211
|
+
const aggregateConfidence = debriefs.reduce((sum, d) => sum + (d.confidence || 0.5), 0) / debriefs.length;
|
|
212
|
+
|
|
213
|
+
// Scope: any non-same scope wins, larger beats smaller, different beats all
|
|
214
|
+
const scopeDeltas = debriefs.map(d => d.scopeChange || 'same').filter(s => s !== 'same');
|
|
215
|
+
let scopeDelta = 'same';
|
|
216
|
+
if (scopeDeltas.includes('different')) scopeDelta = 'different';
|
|
217
|
+
else if (scopeDeltas.includes('larger')) scopeDelta = 'larger';
|
|
218
|
+
else if (scopeDeltas.includes('smaller')) scopeDelta = 'smaller';
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
overallStatus: worstStatus,
|
|
222
|
+
aggregateConfidence: Math.round(aggregateConfidence * 100) / 100,
|
|
223
|
+
allFindings,
|
|
224
|
+
allBlockers,
|
|
225
|
+
scopeDelta,
|
|
226
|
+
nextActions,
|
|
227
|
+
};
|
|
228
|
+
}
|
package/src/doctor.mjs
CHANGED
|
@@ -594,8 +594,8 @@ const VERIFIERS = {
|
|
|
594
594
|
catch { return { status: 'failed', evidence: 'ripgrep not found', probe: 'which rg' }; }
|
|
595
595
|
}},
|
|
596
596
|
'living-docs-init': { ttl: TTL_RUNTIME, fn: (cwd) => {
|
|
597
|
-
const exists = existsSync(join(cwd || process.cwd(), '.
|
|
598
|
-
return { status: exists ? 'verified' : 'failed', evidence: exists ? '.
|
|
597
|
+
const exists = existsSync(join(cwd || process.cwd(), '.dualbrain'));
|
|
598
|
+
return { status: exists ? 'verified' : 'failed', evidence: exists ? '.dualbrain/ exists' : '.dualbrain/ not initialized', probe: 'fs check' };
|
|
599
599
|
}},
|
|
600
600
|
'model-registry-fresh': { ttl: TTL_REGISTRY, fn: () => {
|
|
601
601
|
try {
|
|
@@ -627,15 +627,15 @@ export function verify(claim, cwd) {
|
|
|
627
627
|
}
|
|
628
628
|
|
|
629
629
|
/**
|
|
630
|
-
* verifyAll(cwd) — run all registered verifiers and append results to .
|
|
630
|
+
* verifyAll(cwd) — run all registered verifiers and append results to .dualbrain/verifications.jsonl.
|
|
631
631
|
* Returns array of verification result objects.
|
|
632
632
|
*/
|
|
633
633
|
export function verifyAll(cwd = process.cwd()) {
|
|
634
634
|
const results = Object.keys(VERIFIERS).map(claim => verify(claim, cwd));
|
|
635
635
|
|
|
636
|
-
// Persist to .
|
|
636
|
+
// Persist to .dualbrain/verifications.jsonl (append-only)
|
|
637
637
|
try {
|
|
638
|
-
const dir = join(cwd, '.
|
|
638
|
+
const dir = join(cwd, '.dualbrain');
|
|
639
639
|
if (existsSync(dir)) {
|
|
640
640
|
const logPath = join(dir, 'verifications.jsonl');
|
|
641
641
|
const lines = results.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
@@ -647,11 +647,11 @@ export function verifyAll(cwd = process.cwd()) {
|
|
|
647
647
|
}
|
|
648
648
|
|
|
649
649
|
/**
|
|
650
|
-
* getVerificationCache(cwd) — read .
|
|
650
|
+
* getVerificationCache(cwd) — read .dualbrain/verifications.jsonl, return most recent
|
|
651
651
|
* non-expired result per claim. Expired entries are skipped.
|
|
652
652
|
*/
|
|
653
653
|
export function getVerificationCache(cwd = process.cwd()) {
|
|
654
|
-
const logPath = join(cwd, '.
|
|
654
|
+
const logPath = join(cwd, '.dualbrain', 'verifications.jsonl');
|
|
655
655
|
if (!existsSync(logPath)) return [];
|
|
656
656
|
|
|
657
657
|
let lines;
|
|
@@ -718,7 +718,7 @@ const CODE_TASK_TYPES = new Set(['fix', 'feature', 'refactor', 'implement', 't
|
|
|
718
718
|
const REASONING_MODELS = new Set(['o3']);
|
|
719
719
|
|
|
720
720
|
function learningsPath(cwd) {
|
|
721
|
-
return join(cwd, '.
|
|
721
|
+
return join(cwd, '.dualbrain', 'learnings.jsonl');
|
|
722
722
|
}
|
|
723
723
|
|
|
724
724
|
function readLearnings(cwd) {
|
|
@@ -802,7 +802,7 @@ export function recordLearning(taskResult, cwd = process.cwd()) {
|
|
|
802
802
|
};
|
|
803
803
|
|
|
804
804
|
const p = learningsPath(cwd);
|
|
805
|
-
const dir = join(cwd, '.
|
|
805
|
+
const dir = join(cwd, '.dualbrain');
|
|
806
806
|
if (existsSync(dir)) {
|
|
807
807
|
appendFileSync(p, JSON.stringify(record) + '\n', 'utf8');
|
|
808
808
|
}
|
|
@@ -1167,7 +1167,7 @@ function discoverReplitFeatures(cwd) {
|
|
|
1167
1167
|
}
|
|
1168
1168
|
|
|
1169
1169
|
function loadLastDiscovery(cwd) {
|
|
1170
|
-
const logPath = join(cwd, '.
|
|
1170
|
+
const logPath = join(cwd, '.dualbrain', 'discoveries.jsonl');
|
|
1171
1171
|
if (!existsSync(logPath)) return null;
|
|
1172
1172
|
try {
|
|
1173
1173
|
const lines = readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
@@ -1177,7 +1177,7 @@ function loadLastDiscovery(cwd) {
|
|
|
1177
1177
|
}
|
|
1178
1178
|
|
|
1179
1179
|
function appendDiscoveryLog(cwd, entry) {
|
|
1180
|
-
const dir = join(cwd, '.
|
|
1180
|
+
const dir = join(cwd, '.dualbrain');
|
|
1181
1181
|
try {
|
|
1182
1182
|
if (!existsSync(dir)) execSync(`mkdir -p "${dir}"`, { timeout: 2000 });
|
|
1183
1183
|
appendFileSync(join(dir, 'discoveries.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
|
|
@@ -1217,10 +1217,10 @@ export function discover(cwd = process.cwd()) {
|
|
|
1217
1217
|
}
|
|
1218
1218
|
|
|
1219
1219
|
/**
|
|
1220
|
-
* getDiscoveryLog(cwd, limit) — read recent discovery entries from .
|
|
1220
|
+
* getDiscoveryLog(cwd, limit) — read recent discovery entries from .dualbrain/discoveries.jsonl.
|
|
1221
1221
|
*/
|
|
1222
1222
|
export function getDiscoveryLog(cwd = process.cwd(), limit = 20) {
|
|
1223
|
-
const logPath = join(cwd, '.
|
|
1223
|
+
const logPath = join(cwd, '.dualbrain', 'discoveries.jsonl');
|
|
1224
1224
|
if (!existsSync(logPath)) return [];
|
|
1225
1225
|
try {
|
|
1226
1226
|
const lines = readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
package/src/envelope.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// envelope.mjs — Dispatch envelopes that carry understanding to workers.
|
|
2
|
+
//
|
|
3
|
+
// Instead of workers getting bare instructions ("edit file X, add Y"),
|
|
4
|
+
// they get an envelope containing:
|
|
5
|
+
// 1. Context preamble — narrative excerpt explaining the "why"
|
|
6
|
+
// 2. Contract — the typed task (objective, scope, acceptance criteria)
|
|
7
|
+
// 3. Preventions — predicted failure modes and how to avoid them
|
|
8
|
+
// 4. Debrief format — how to report back
|
|
9
|
+
//
|
|
10
|
+
// This is the difference between "do this thing" and "here's where we are,
|
|
11
|
+
// here's why this matters, here's what to do, here's what to watch for."
|
|
12
|
+
|
|
13
|
+
import * as narrative from './narrative.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} Envelope
|
|
17
|
+
* @property {string} preamble - Narrative context for the worker
|
|
18
|
+
* @property {string} contract - The actual task specification
|
|
19
|
+
* @property {string} preventions - Predicted failure modes and mitigations
|
|
20
|
+
* @property {string} debriefFormat - How to report back
|
|
21
|
+
* @property {string} full - Complete prompt ready to send
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a dispatch envelope for a worker agent.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} agentSpec - From wave-planner
|
|
28
|
+
* @param {string} agentSpec.objective - What to accomplish
|
|
29
|
+
* @param {string[]} agentSpec.scope - Files/areas in scope
|
|
30
|
+
* @param {string} agentSpec.tier - Worker tier (execute, search, review, etc)
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {string} opts.preventions - From predictive.mjs
|
|
33
|
+
* @param {string} opts.debriefInstruction - From debrief.mjs
|
|
34
|
+
* @param {string} opts.inboxBrief - Messages for this worker
|
|
35
|
+
* @param {object} opts.contract - Additional contract fields (acceptance criteria, risk, allowed ops)
|
|
36
|
+
* @returns {Envelope}
|
|
37
|
+
*/
|
|
38
|
+
export function build(agentSpec, opts = {}) {
|
|
39
|
+
const { preventions, debriefInstruction, inboxBrief, contract } = opts;
|
|
40
|
+
|
|
41
|
+
// Get narrative excerpt — the "being in the song" piece
|
|
42
|
+
const preamble = _buildPreamble(agentSpec);
|
|
43
|
+
|
|
44
|
+
// Build contract section
|
|
45
|
+
const contractText = _buildContract(agentSpec, contract);
|
|
46
|
+
|
|
47
|
+
// Assemble full prompt
|
|
48
|
+
const sections = [];
|
|
49
|
+
|
|
50
|
+
if (preamble) {
|
|
51
|
+
sections.push(`## Context\n${preamble}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
sections.push(`## Task\n${contractText}`);
|
|
55
|
+
|
|
56
|
+
if (inboxBrief) {
|
|
57
|
+
sections.push(`## Notes\n${inboxBrief}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (preventions) {
|
|
61
|
+
sections.push(`## Watch For\n${preventions}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (debriefInstruction) {
|
|
65
|
+
sections.push(`## When Done\n${debriefInstruction}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const full = sections.join('\n\n');
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
preamble,
|
|
72
|
+
contract: contractText,
|
|
73
|
+
preventions: preventions || '',
|
|
74
|
+
debriefFormat: debriefInstruction || '',
|
|
75
|
+
full,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a lightweight envelope for simple/fast dispatches.
|
|
81
|
+
* Used when the task is straightforward and doesn't need full context.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} objective
|
|
84
|
+
* @param {string[]} scope
|
|
85
|
+
* @returns {string} Simple prompt string
|
|
86
|
+
*/
|
|
87
|
+
export function buildLight(objective, scope = []) {
|
|
88
|
+
const parts = [objective];
|
|
89
|
+
if (scope.length > 0) {
|
|
90
|
+
parts.push(`Scope: ${scope.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
return parts.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function _buildPreamble(agentSpec) {
|
|
98
|
+
const narr = narrative.excerpt(400);
|
|
99
|
+
if (!narr) return '';
|
|
100
|
+
|
|
101
|
+
// Tailor the preamble based on tier
|
|
102
|
+
const tier = (agentSpec.tier || '').toLowerCase();
|
|
103
|
+
|
|
104
|
+
if (tier === 'search' || tier === 'recon') {
|
|
105
|
+
// Search agents need less context, more focus on what to look for
|
|
106
|
+
return narr.length > 200 ? narr.slice(-200) : narr;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Implementation agents get fuller context
|
|
110
|
+
return narr;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _buildContract(agentSpec, extra = {}) {
|
|
114
|
+
const parts = [];
|
|
115
|
+
|
|
116
|
+
parts.push(agentSpec.objective);
|
|
117
|
+
|
|
118
|
+
if (agentSpec.scope?.length) {
|
|
119
|
+
parts.push(`\nScope: ${agentSpec.scope.join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (extra?.acceptanceCriteria) {
|
|
123
|
+
parts.push(`\nDone when: ${extra.acceptanceCriteria}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (extra?.risk) {
|
|
127
|
+
parts.push(`\nRisk level: ${extra.risk}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (extra?.allowedOps) {
|
|
131
|
+
parts.push(`\nAllowed: ${extra.allowedOps.join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (agentSpec.conditionalPivot) {
|
|
135
|
+
parts.push(`\nConditional: if ${agentSpec.conditionalPivot.if} → ${agentSpec.conditionalPivot.then}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return parts.join('');
|
|
139
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { processTurn, loadState } from './head.mjs';
|
|
4
|
+
|
|
5
|
+
const DUALBRAIN = join(process.cwd(), '.dualbrain');
|
|
6
|
+
const DELIBERATION_FILE = join(DUALBRAIN, 'deliberation.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Write the deliberation artifact after running HEAD's cognitive pipeline.
|
|
10
|
+
* This is the contract between HEAD's thinking and the deliberation-gate hook.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} userMessage - The user's message to deliberate on
|
|
13
|
+
* @param {object} context - Optional context (files, priorFailures, patterns, etc.)
|
|
14
|
+
* @returns {object} The full deliberation result from processTurn
|
|
15
|
+
*/
|
|
16
|
+
export function writeDeliberation(userMessage, context = {}) {
|
|
17
|
+
const state = loadState();
|
|
18
|
+
const result = processTurn(state, userMessage, context);
|
|
19
|
+
|
|
20
|
+
// Determine if there are multiple independent sub-tasks that could be parallelized
|
|
21
|
+
const dispatchPlan = _deriveDispatchPlan(result, context);
|
|
22
|
+
|
|
23
|
+
// Build the artifact
|
|
24
|
+
const artifact = {
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
createdAt: Date.now(),
|
|
27
|
+
message: userMessage.slice(0, 500),
|
|
28
|
+
|
|
29
|
+
// Core deliberation fields
|
|
30
|
+
action: result.action,
|
|
31
|
+
result: result.result,
|
|
32
|
+
shouldAskUser: result.shouldAskUser,
|
|
33
|
+
confidence: result.result?.confidence || null,
|
|
34
|
+
|
|
35
|
+
// Obligations and noticings
|
|
36
|
+
obligations: result.obligations || [],
|
|
37
|
+
surfaceNoticings: result.result?.surfaceNoticings || [],
|
|
38
|
+
|
|
39
|
+
// Dispatch plan (parallel-wave support)
|
|
40
|
+
dispatchPlan,
|
|
41
|
+
|
|
42
|
+
// Metadata
|
|
43
|
+
depth: result.depth,
|
|
44
|
+
rationale: result.rationale,
|
|
45
|
+
situation: {
|
|
46
|
+
taskShape: result.situation?.taskShape || null,
|
|
47
|
+
urgency: result.situation?.urgency || null,
|
|
48
|
+
scope: result.situation?.taskShape?.scope || null,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Write atomically
|
|
53
|
+
mkdirSync(DUALBRAIN, { recursive: true });
|
|
54
|
+
const tmpFile = DELIBERATION_FILE + '.tmp.' + process.pid;
|
|
55
|
+
writeFileSync(tmpFile, JSON.stringify(artifact, null, 2));
|
|
56
|
+
renameSync(tmpFile, DELIBERATION_FILE);
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the current deliberation artifact.
|
|
63
|
+
* @returns {object|null} The deliberation artifact, or null if not found/unreadable.
|
|
64
|
+
*/
|
|
65
|
+
export function readDeliberation() {
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(DELIBERATION_FILE)) return null;
|
|
68
|
+
return JSON.parse(readFileSync(DELIBERATION_FILE, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if the current deliberation is fresh (within maxAgeMs).
|
|
76
|
+
* @param {number} maxAgeMs - Maximum age in milliseconds (default 60000)
|
|
77
|
+
* @returns {boolean} True if deliberation exists and is fresh.
|
|
78
|
+
*/
|
|
79
|
+
export function isDeliberationFresh(maxAgeMs = 60_000) {
|
|
80
|
+
const delib = readDeliberation();
|
|
81
|
+
if (!delib) return false;
|
|
82
|
+
const timestamp = delib.timestamp || delib.createdAt || 0;
|
|
83
|
+
return (Date.now() - timestamp) <= maxAgeMs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Derive a dispatch plan when the situation has multiple independent sub-tasks.
|
|
90
|
+
* Returns null if parallel dispatch is not applicable.
|
|
91
|
+
*/
|
|
92
|
+
function _deriveDispatchPlan(result, context) {
|
|
93
|
+
const situation = result.situation;
|
|
94
|
+
if (!situation) return null;
|
|
95
|
+
|
|
96
|
+
// Only generate a parallel plan for multi-file, non-trivial work
|
|
97
|
+
const scope = situation.taskShape?.scope;
|
|
98
|
+
const actionType = result.action?.type;
|
|
99
|
+
|
|
100
|
+
if (scope !== 'large' && scope !== 'medium') return null;
|
|
101
|
+
if (actionType !== 'dispatch' && actionType !== 'proceed') return null;
|
|
102
|
+
|
|
103
|
+
// Check for independent sub-tasks from context
|
|
104
|
+
const subtasks = context.subtasks || [];
|
|
105
|
+
if (subtasks.length < 2) {
|
|
106
|
+
// Heuristic: large scope with multiple files could be parallel
|
|
107
|
+
const fileCount = situation.material?.touchedFiles?.length || 0;
|
|
108
|
+
if (fileCount < 3) return null;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
strategy: 'parallel-wave',
|
|
112
|
+
id: Date.now().toString(36),
|
|
113
|
+
expectedParallel: Math.min(fileCount, 5),
|
|
114
|
+
waveSize: Math.min(fileCount, 5),
|
|
115
|
+
reason: `${fileCount} independent files detected`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
strategy: 'parallel-wave',
|
|
121
|
+
id: Date.now().toString(36),
|
|
122
|
+
expectedParallel: subtasks.length,
|
|
123
|
+
waveSize: subtasks.length,
|
|
124
|
+
subtasks: subtasks.map(t => typeof t === 'string' ? t : t.name || t.description),
|
|
125
|
+
reason: `${subtasks.length} independent sub-tasks identified`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|