dual-brain 0.2.14 → 0.2.16
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 +149 -4
- package/hooks/auto-update-wrapper.mjs +56 -58
- 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 +557 -0
- package/src/continuity.mjs +9 -8
- 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 +114 -79
- 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
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* diagnostic-companion.mjs — PostToolUse hook for the Dual-Brain orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Observes ALL tool calls (HEAD + subagents) and detects inefficient patterns:
|
|
6
|
+
* - Sequential dispatches that could be parallel
|
|
7
|
+
* - Re-reading files without edits between
|
|
8
|
+
* - Assumption leaps (dispatching work without prior research)
|
|
9
|
+
* - Scope creep beyond declared plan
|
|
10
|
+
* - Ceremony (excessive config reads without dispatching)
|
|
11
|
+
* - Stuck loops (same tool called repeatedly with similar inputs)
|
|
12
|
+
*
|
|
13
|
+
* Output: JSON to stdout. High-severity issues for HEAD get a systemMessage.
|
|
14
|
+
* Subagent observations are logged silently (never interfere with workers).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const STATE_DIR = join(process.cwd(), '.dualbrain', 'diagnostic');
|
|
21
|
+
const STATE_FILE = join(STATE_DIR, 'current.json');
|
|
22
|
+
|
|
23
|
+
const MAX_TOOL_CALLS = 100;
|
|
24
|
+
const MAX_NOTICINGS = 50;
|
|
25
|
+
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 minutes
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// State management
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function freshState() {
|
|
32
|
+
return {
|
|
33
|
+
sessionId: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
34
|
+
startedAt: Date.now(),
|
|
35
|
+
toolCalls: [],
|
|
36
|
+
noticings: [],
|
|
37
|
+
stats: {
|
|
38
|
+
totalCalls: 0,
|
|
39
|
+
readCount: 0,
|
|
40
|
+
dispatchCount: 0,
|
|
41
|
+
uniqueFiles: [],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadState() {
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(STATE_FILE)) {
|
|
49
|
+
const data = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
50
|
+
// Reset if session gap > 30 minutes
|
|
51
|
+
if (Date.now() - (data.lastActivity || data.startedAt || 0) > SESSION_GAP_MS) {
|
|
52
|
+
return freshState();
|
|
53
|
+
}
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return freshState();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveState(state) {
|
|
61
|
+
state.lastActivity = Date.now();
|
|
62
|
+
// Cap arrays
|
|
63
|
+
if (state.toolCalls.length > MAX_TOOL_CALLS) {
|
|
64
|
+
state.toolCalls = state.toolCalls.slice(-MAX_TOOL_CALLS);
|
|
65
|
+
}
|
|
66
|
+
if (state.noticings.length > MAX_NOTICINGS) {
|
|
67
|
+
state.noticings = state.noticings.slice(-MAX_NOTICINGS);
|
|
68
|
+
}
|
|
69
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
70
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Record a tool call
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function recordToolCall(state, toolName, toolInput, agentId) {
|
|
78
|
+
const meta = {};
|
|
79
|
+
|
|
80
|
+
// Extract relevant metadata based on tool type
|
|
81
|
+
if (toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') {
|
|
82
|
+
meta.file = toolInput?.file_path || toolInput?.path || null;
|
|
83
|
+
}
|
|
84
|
+
if (toolName === 'Agent') {
|
|
85
|
+
meta.tier = toolInput?.tier || toolInput?.mode || 'unknown';
|
|
86
|
+
meta.prompt = (toolInput?.prompt || toolInput?.message || '').slice(0, 100);
|
|
87
|
+
}
|
|
88
|
+
if (toolName === 'Bash') {
|
|
89
|
+
meta.command = (toolInput?.command || '').slice(0, 100);
|
|
90
|
+
}
|
|
91
|
+
if (toolName === 'Grep' || toolName === 'Glob') {
|
|
92
|
+
meta.pattern = (toolInput?.pattern || toolInput?.query || '').slice(0, 60);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const entry = {
|
|
96
|
+
ts: Date.now(),
|
|
97
|
+
tool: toolName,
|
|
98
|
+
agentId: agentId || null,
|
|
99
|
+
meta,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
state.toolCalls.push(entry);
|
|
103
|
+
|
|
104
|
+
// Update stats
|
|
105
|
+
state.stats.totalCalls++;
|
|
106
|
+
if (toolName === 'Read') state.stats.readCount++;
|
|
107
|
+
if (toolName === 'Agent') state.stats.dispatchCount++;
|
|
108
|
+
if (meta.file && !state.stats.uniqueFiles.includes(meta.file)) {
|
|
109
|
+
state.stats.uniqueFiles.push(meta.file);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return entry;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Pattern detectors
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function detectSequentialDispatch(state) {
|
|
120
|
+
const dispatches = state.toolCalls
|
|
121
|
+
.filter(c => c.tool === 'Agent' && !c.agentId) // HEAD-level dispatches only
|
|
122
|
+
.slice(-5);
|
|
123
|
+
|
|
124
|
+
if (dispatches.length < 2) return null;
|
|
125
|
+
|
|
126
|
+
// Check if last 2+ dispatches happened within 30s with no dependency signals
|
|
127
|
+
for (let i = dispatches.length - 1; i >= 1; i--) {
|
|
128
|
+
const curr = dispatches[i];
|
|
129
|
+
const prev = dispatches[i - 1];
|
|
130
|
+
if (curr.ts - prev.ts < 30_000) {
|
|
131
|
+
// Check if prompts reference each other (crude dependency check)
|
|
132
|
+
const currPrompt = (curr.meta.prompt || '').toLowerCase();
|
|
133
|
+
const prevPrompt = (prev.meta.prompt || '').toLowerCase();
|
|
134
|
+
// If neither references the other's key terms, likely independent
|
|
135
|
+
const prevWords = prevPrompt.split(/\s+/).filter(w => w.length > 5);
|
|
136
|
+
const hasOverlap = prevWords.some(w => currPrompt.includes(w));
|
|
137
|
+
if (!hasOverlap) {
|
|
138
|
+
return {
|
|
139
|
+
ts: Date.now(),
|
|
140
|
+
type: 'sequential-dispatch',
|
|
141
|
+
severity: 'high',
|
|
142
|
+
observation: 'These dispatches appear independent — consider parallel execution.',
|
|
143
|
+
surfaced: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function detectReReads(state) {
|
|
152
|
+
// Find files read 2+ times without an edit between
|
|
153
|
+
const fileReads = new Map(); // file -> count since last edit
|
|
154
|
+
|
|
155
|
+
for (const call of state.toolCalls) {
|
|
156
|
+
const file = call.meta?.file;
|
|
157
|
+
if (!file) continue;
|
|
158
|
+
|
|
159
|
+
if (call.tool === 'Edit' || call.tool === 'Write') {
|
|
160
|
+
// Reset count for this file
|
|
161
|
+
fileReads.delete(file);
|
|
162
|
+
} else if (call.tool === 'Read') {
|
|
163
|
+
fileReads.set(file, (fileReads.get(file) || 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Find worst offender
|
|
168
|
+
let worst = null;
|
|
169
|
+
let worstCount = 1;
|
|
170
|
+
for (const [file, count] of fileReads) {
|
|
171
|
+
if (count >= 2 && count > worstCount) {
|
|
172
|
+
worst = file;
|
|
173
|
+
worstCount = count;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (worst) {
|
|
178
|
+
return {
|
|
179
|
+
ts: Date.now(),
|
|
180
|
+
type: 're-read',
|
|
181
|
+
severity: 'medium',
|
|
182
|
+
observation: `File ${worst} read ${worstCount} times — consider caching the content or dispatching a single agent.`,
|
|
183
|
+
surfaced: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function detectAssumptionLeap(state) {
|
|
190
|
+
// Check if last Agent dispatch was preceded by any Read/search in recent window
|
|
191
|
+
const recentCalls = state.toolCalls.slice(-10);
|
|
192
|
+
const lastDispatch = [...recentCalls].reverse().find(c => c.tool === 'Agent' && !c.agentId);
|
|
193
|
+
if (!lastDispatch) return null;
|
|
194
|
+
|
|
195
|
+
// Check if dispatch tier is execute/edit
|
|
196
|
+
const tier = (lastDispatch.meta.tier || '').toLowerCase();
|
|
197
|
+
if (!tier.includes('execute') && !tier.includes('edit') && !tier.includes('implement')) return null;
|
|
198
|
+
|
|
199
|
+
// Look for reads/searches before this dispatch in recent window
|
|
200
|
+
const dispatchIdx = recentCalls.indexOf(lastDispatch);
|
|
201
|
+
const preceding = recentCalls.slice(Math.max(0, dispatchIdx - 8), dispatchIdx);
|
|
202
|
+
const hasResearch = preceding.some(c =>
|
|
203
|
+
c.tool === 'Read' || c.tool === 'Grep' || c.tool === 'Glob' ||
|
|
204
|
+
(c.tool === 'Agent' && (c.meta.tier || '').toLowerCase().includes('search'))
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (!hasResearch) {
|
|
208
|
+
return {
|
|
209
|
+
ts: Date.now(),
|
|
210
|
+
type: 'assumption-leap',
|
|
211
|
+
severity: 'high',
|
|
212
|
+
observation: 'Dispatching work without prior research — consider a search agent first.',
|
|
213
|
+
surfaced: false,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function detectScopeCreep(state) {
|
|
220
|
+
const totalCalls = state.toolCalls.length;
|
|
221
|
+
if (totalCalls < 10) return null;
|
|
222
|
+
|
|
223
|
+
const earlyWindow = state.toolCalls.slice(0, Math.ceil(totalCalls * 0.2));
|
|
224
|
+
const earlyFiles = new Set();
|
|
225
|
+
for (const c of earlyWindow) {
|
|
226
|
+
if (c.meta?.file) earlyFiles.add(c.meta.file);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const declaredScope = Math.max(earlyFiles.size, 1);
|
|
230
|
+
const currentScope = state.stats.uniqueFiles.length;
|
|
231
|
+
|
|
232
|
+
if (currentScope >= declaredScope * 2 && currentScope > 4) {
|
|
233
|
+
return {
|
|
234
|
+
ts: Date.now(),
|
|
235
|
+
type: 'scope-creep',
|
|
236
|
+
severity: 'medium',
|
|
237
|
+
observation: `Scope has grown beyond declared plan. Started with ~${declaredScope} files, now touching ${currentScope}.`,
|
|
238
|
+
surfaced: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function detectCeremony(state) {
|
|
245
|
+
// More than 5 Reads of config/settings without a dispatch in between
|
|
246
|
+
const recentCalls = state.toolCalls.slice(-15);
|
|
247
|
+
let readStreak = 0;
|
|
248
|
+
|
|
249
|
+
for (let i = recentCalls.length - 1; i >= 0; i--) {
|
|
250
|
+
const call = recentCalls[i];
|
|
251
|
+
if (call.tool === 'Agent') break;
|
|
252
|
+
if (call.tool === 'Read') {
|
|
253
|
+
const file = call.meta?.file || '';
|
|
254
|
+
if (/config|settings|\.json|\.env|\.ya?ml/i.test(file)) {
|
|
255
|
+
readStreak++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (readStreak > 5) {
|
|
261
|
+
return {
|
|
262
|
+
ts: Date.now(),
|
|
263
|
+
type: 'ceremony',
|
|
264
|
+
severity: 'low',
|
|
265
|
+
observation: 'Consider dispatching a research agent instead of manual exploration.',
|
|
266
|
+
surfaced: false,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function detectStuckLoop(state) {
|
|
273
|
+
const recentCalls = state.toolCalls.slice(-10);
|
|
274
|
+
if (recentCalls.length < 3) return null;
|
|
275
|
+
|
|
276
|
+
// Group by tool + simplified input signature
|
|
277
|
+
const signatures = new Map();
|
|
278
|
+
for (const call of recentCalls) {
|
|
279
|
+
let sig = call.tool;
|
|
280
|
+
if (call.meta?.file) sig += ':' + call.meta.file;
|
|
281
|
+
else if (call.meta?.command) sig += ':' + call.meta.command.slice(0, 40);
|
|
282
|
+
else if (call.meta?.pattern) sig += ':' + call.meta.pattern;
|
|
283
|
+
|
|
284
|
+
signatures.set(sig, (signatures.get(sig) || 0) + 1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const [sig, count] of signatures) {
|
|
288
|
+
if (count >= 5) {
|
|
289
|
+
return {
|
|
290
|
+
ts: Date.now(),
|
|
291
|
+
type: 'stuck-loop',
|
|
292
|
+
severity: 'high',
|
|
293
|
+
observation: `Possible stuck loop — try a different approach. (${sig.split(':')[0]} called ${count} times with similar inputs)`,
|
|
294
|
+
surfaced: false,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Run all detectors
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
function runDetectors(state) {
|
|
306
|
+
const results = [];
|
|
307
|
+
const detectors = [
|
|
308
|
+
detectSequentialDispatch,
|
|
309
|
+
detectReReads,
|
|
310
|
+
detectAssumptionLeap,
|
|
311
|
+
detectScopeCreep,
|
|
312
|
+
detectCeremony,
|
|
313
|
+
detectStuckLoop,
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
for (const detector of detectors) {
|
|
317
|
+
try {
|
|
318
|
+
const result = detector(state);
|
|
319
|
+
if (result) {
|
|
320
|
+
// Deduplicate: don't re-add if same type was noticed in last 60s
|
|
321
|
+
const recent = state.noticings.filter(
|
|
322
|
+
n => n.type === result.type && Date.now() - n.ts < 60_000
|
|
323
|
+
);
|
|
324
|
+
if (recent.length === 0) {
|
|
325
|
+
results.push(result);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return results;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Public API: readDiagnosticNoticings (for head.mjs integration)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Read unsurfaced diagnostic noticings and mark them as surfaced.
|
|
340
|
+
* Called by head.mjs notice() to feed diagnostic observations into deliberation.
|
|
341
|
+
*/
|
|
342
|
+
export function readDiagnosticNoticings() {
|
|
343
|
+
try {
|
|
344
|
+
if (!existsSync(STATE_FILE)) return [];
|
|
345
|
+
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
346
|
+
const unsurfaced = (state.noticings || []).filter(n => !n.surfaced);
|
|
347
|
+
if (unsurfaced.length === 0) return [];
|
|
348
|
+
|
|
349
|
+
// Mark as surfaced
|
|
350
|
+
for (const n of state.noticings) {
|
|
351
|
+
if (!n.surfaced) n.surfaced = true;
|
|
352
|
+
}
|
|
353
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
354
|
+
|
|
355
|
+
return unsurfaced;
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Main — read stdin, record, detect, respond
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
async function main() {
|
|
366
|
+
let raw = '';
|
|
367
|
+
try {
|
|
368
|
+
for await (const chunk of process.stdin) {
|
|
369
|
+
raw += chunk;
|
|
370
|
+
if (raw.length > 64 * 1024) break;
|
|
371
|
+
}
|
|
372
|
+
} catch {}
|
|
373
|
+
|
|
374
|
+
let payload = {};
|
|
375
|
+
try {
|
|
376
|
+
payload = JSON.parse(raw);
|
|
377
|
+
} catch {}
|
|
378
|
+
|
|
379
|
+
const toolName = payload?.tool_name || payload?.toolName || 'unknown';
|
|
380
|
+
const toolInput = payload?.tool_input || payload?.toolInput || {};
|
|
381
|
+
const agentId = payload?.agent_id || payload?.agentId || null;
|
|
382
|
+
|
|
383
|
+
// Load state
|
|
384
|
+
const state = loadState();
|
|
385
|
+
|
|
386
|
+
// Record the tool call
|
|
387
|
+
recordToolCall(state, toolName, toolInput, agentId);
|
|
388
|
+
|
|
389
|
+
// Run pattern detectors
|
|
390
|
+
const newNoticings = runDetectors(state);
|
|
391
|
+
|
|
392
|
+
// Add new noticings to state
|
|
393
|
+
for (const n of newNoticings) {
|
|
394
|
+
state.noticings.push(n);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Save state
|
|
398
|
+
saveState(state);
|
|
399
|
+
|
|
400
|
+
// Determine output
|
|
401
|
+
// For HEAD (no agent_id): high-severity → systemMessage
|
|
402
|
+
// For subagents (agent_id present): only log, never inject systemMessage
|
|
403
|
+
let output = {};
|
|
404
|
+
|
|
405
|
+
if (!agentId) {
|
|
406
|
+
const highSeverity = newNoticings.filter(n => n.severity === 'high');
|
|
407
|
+
if (highSeverity.length > 0) {
|
|
408
|
+
const messages = highSeverity.map(n => `[Diagnostic] ${n.observation}`);
|
|
409
|
+
output = { systemMessage: messages.join('\n') };
|
|
410
|
+
// Mark as surfaced
|
|
411
|
+
for (const n of highSeverity) {
|
|
412
|
+
n.surfaced = true;
|
|
413
|
+
}
|
|
414
|
+
saveState(state);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
process.stdout.write(JSON.stringify(output) + '\n');
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
main();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// precompact.mjs — Fires before context compression to persist critical state.
|
|
3
|
+
// Ensures HEAD's running narrative, simmer buffer, and loop state survive
|
|
4
|
+
// context window compression without loss.
|
|
5
|
+
|
|
6
|
+
import { persist as persistNarrative, load as loadNarrative } from '../src/narrative.mjs';
|
|
7
|
+
import { active as activeSimmer, prune as pruneSimmer } from '../src/simmer.mjs';
|
|
8
|
+
import { getLoopStatus } from '../src/cognitive-loop.mjs';
|
|
9
|
+
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const STATE_DIR = join(process.cwd(), '.dualbrain');
|
|
13
|
+
const SURVIVAL_FILE = join(STATE_DIR, 'precompact-survival.json');
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
// Read stdin (hook payload) — we don't need it but must consume
|
|
17
|
+
let raw = '';
|
|
18
|
+
try {
|
|
19
|
+
for await (const chunk of process.stdin) {
|
|
20
|
+
raw += chunk;
|
|
21
|
+
if (raw.length > 16 * 1024) break;
|
|
22
|
+
}
|
|
23
|
+
} catch {}
|
|
24
|
+
|
|
25
|
+
// Persist narrative (already on disk, but archive a snapshot)
|
|
26
|
+
const narrativeText = persistNarrative();
|
|
27
|
+
|
|
28
|
+
// Prune dead simmer items before compression
|
|
29
|
+
pruneSimmer();
|
|
30
|
+
const simmering = activeSimmer();
|
|
31
|
+
|
|
32
|
+
// Get loop status for survival kit
|
|
33
|
+
const loopStatus = getLoopStatus();
|
|
34
|
+
|
|
35
|
+
// Write survival kit — this can be loaded to reconstruct context after compression
|
|
36
|
+
const survivalKit = {
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
reason: 'precompact',
|
|
39
|
+
narrative: narrativeText.slice(0, 1500),
|
|
40
|
+
simmerCount: simmering.length,
|
|
41
|
+
topSimmer: simmering.slice(0, 5).map(i => ({ idea: i.idea.slice(0, 100), heat: i.heat })),
|
|
42
|
+
loopStatus,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
46
|
+
writeFileSync(SURVIVAL_FILE, JSON.stringify(survivalKit, null, 2));
|
|
47
|
+
|
|
48
|
+
// Output: no systemMessage needed — this is a persistence hook, not advisory
|
|
49
|
+
process.stdout.write(JSON.stringify({}) + '\n');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// session-end.mjs — Stop hook for dual-brain. Runs when Claude session ends.
|
|
3
|
+
// Generates receipt, records metrics, cleans up stale locks.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const WORKSPACE = join(new URL(import.meta.url).pathname, '..', '..', '..');
|
|
9
|
+
const DUALBRAIN = join(WORKSPACE, '.dualbrain');
|
|
10
|
+
const RECEIPTS_DIR = join(DUALBRAIN, 'receipts');
|
|
11
|
+
|
|
12
|
+
// Read hook input from stdin
|
|
13
|
+
let input = {};
|
|
14
|
+
try {
|
|
15
|
+
input = JSON.parse(readFileSync('/dev/stdin', 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
// Stop hook may not always get structured input
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function run() {
|
|
21
|
+
mkdirSync(RECEIPTS_DIR, { recursive: true });
|
|
22
|
+
|
|
23
|
+
// 1. Generate session receipt
|
|
24
|
+
const receipt = {
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
sessionId: input.session_id || 'unknown',
|
|
27
|
+
reason: input.stop_hook_reason || 'session_end',
|
|
28
|
+
metrics: {},
|
|
29
|
+
cleanup: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// 2. Collect metrics from audit log
|
|
33
|
+
const auditFile = join(DUALBRAIN, 'audit', 'head-audit.jsonl');
|
|
34
|
+
if (existsSync(auditFile)) {
|
|
35
|
+
try {
|
|
36
|
+
const lines = readFileSync(auditFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
37
|
+
const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
38
|
+
|
|
39
|
+
// Filter to this session (last 2 hours as proxy)
|
|
40
|
+
const cutoff = Date.now() - 2 * 60 * 60 * 1000;
|
|
41
|
+
const sessionEntries = entries.filter(e => (e.timestamp || 0) > cutoff);
|
|
42
|
+
|
|
43
|
+
receipt.metrics.toolCalls = sessionEntries.length;
|
|
44
|
+
receipt.metrics.blocked = sessionEntries.filter(e => e.decision === 'block').length;
|
|
45
|
+
receipt.metrics.allowed = sessionEntries.filter(e => e.decision === 'allow').length;
|
|
46
|
+
receipt.metrics.agentDispatches = sessionEntries.filter(e => e.tool === 'Agent').length;
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Check for cost log
|
|
51
|
+
const costLog = join(DUALBRAIN, 'cost-log.jsonl');
|
|
52
|
+
if (existsSync(costLog)) {
|
|
53
|
+
try {
|
|
54
|
+
const lines = readFileSync(costLog, 'utf8').trim().split('\n').filter(Boolean);
|
|
55
|
+
const cutoff = Date.now() - 2 * 60 * 60 * 1000;
|
|
56
|
+
const recent = lines.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
57
|
+
.filter(e => e && (e.timestamp || 0) > cutoff);
|
|
58
|
+
|
|
59
|
+
receipt.metrics.costEntries = recent.length;
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 4. Clean up stale lock files
|
|
64
|
+
try {
|
|
65
|
+
const scanDirs = [DUALBRAIN, join(DUALBRAIN, 'doctor'), join(DUALBRAIN, 'receipts')];
|
|
66
|
+
for (const dir of scanDirs) {
|
|
67
|
+
if (!existsSync(dir)) continue;
|
|
68
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.lock'));
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const lockPath = join(dir, f);
|
|
71
|
+
try {
|
|
72
|
+
const lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
73
|
+
const age = Date.now() - (lockData.createdAt || 0);
|
|
74
|
+
if (age > 60000) { // older than 1 minute = stale
|
|
75
|
+
unlinkSync(lockPath);
|
|
76
|
+
receipt.cleanup.push(`removed stale lock: ${f}`);
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Corrupt lock — remove
|
|
80
|
+
try { unlinkSync(lockPath); receipt.cleanup.push(`removed corrupt lock: ${f}`); } catch {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
// 5. Record git state for next session
|
|
87
|
+
try {
|
|
88
|
+
const { execSync } = await import('node:child_process');
|
|
89
|
+
receipt.gitState = {
|
|
90
|
+
branch: execSync('git rev-parse --abbrev-ref HEAD', { cwd: WORKSPACE, encoding: 'utf8', timeout: 3000 }).trim(),
|
|
91
|
+
uncommitted: parseInt(execSync('git status --porcelain | wc -l', { cwd: WORKSPACE, encoding: 'utf8', timeout: 3000 }).trim()) || 0,
|
|
92
|
+
lastCommit: execSync('git log --oneline -1', { cwd: WORKSPACE, encoding: 'utf8', timeout: 3000 }).trim(),
|
|
93
|
+
};
|
|
94
|
+
} catch {}
|
|
95
|
+
|
|
96
|
+
// 6. Persist immersion state and release session lock
|
|
97
|
+
try {
|
|
98
|
+
const { persist } = await import('../src/narrative.mjs');
|
|
99
|
+
const { prune } = await import('../src/simmer.mjs');
|
|
100
|
+
const { release } = await import('../src/session-lock.mjs');
|
|
101
|
+
persist();
|
|
102
|
+
prune();
|
|
103
|
+
release();
|
|
104
|
+
receipt.immersion = { narrativePersisted: true };
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
// 7. Save receipt
|
|
108
|
+
const receiptFile = join(RECEIPTS_DIR, `receipt-${Date.now()}.json`);
|
|
109
|
+
writeFileSync(receiptFile, JSON.stringify(receipt, null, 2) + '\n');
|
|
110
|
+
|
|
111
|
+
// 8. Print summary to stderr (visible to user)
|
|
112
|
+
const summary = [];
|
|
113
|
+
if (receipt.metrics.toolCalls) summary.push(`${receipt.metrics.toolCalls} tool calls`);
|
|
114
|
+
if (receipt.metrics.agentDispatches) summary.push(`${receipt.metrics.agentDispatches} agents dispatched`);
|
|
115
|
+
if (receipt.cleanup.length) summary.push(`${receipt.cleanup.length} locks cleaned`);
|
|
116
|
+
|
|
117
|
+
if (summary.length) {
|
|
118
|
+
process.stderr.write(`[dual-brain] Session end: ${summary.join(', ')}\n`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
run().then(() => process.exit(0)).catch(() => process.exit(0));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,18 @@
|
|
|
34
34
|
"./templates": "./src/templates.mjs",
|
|
35
35
|
"./agents": "./src/agents/registry.mjs",
|
|
36
36
|
"./collaboration": "./src/collaboration.mjs",
|
|
37
|
-
"./provider-context": "./src/provider-context.mjs"
|
|
37
|
+
"./provider-context": "./src/provider-context.mjs",
|
|
38
|
+
"./cognitive-loop": "./src/cognitive-loop.mjs",
|
|
39
|
+
"./debrief": "./src/debrief.mjs",
|
|
40
|
+
"./wave-planner": "./src/wave-planner.mjs",
|
|
41
|
+
"./predictive": "./src/predictive.mjs",
|
|
42
|
+
"./inbox": "./src/inbox.mjs",
|
|
43
|
+
"./head-protocol": "./src/head-protocol.mjs",
|
|
44
|
+
"./narrative": "./src/narrative.mjs",
|
|
45
|
+
"./simmer": "./src/simmer.mjs",
|
|
46
|
+
"./memory-tiers": "./src/memory-tiers.mjs",
|
|
47
|
+
"./envelope": "./src/envelope.mjs",
|
|
48
|
+
"./session-lock": "./src/session-lock.mjs"
|
|
38
49
|
},
|
|
39
50
|
"keywords": [
|
|
40
51
|
"claude-code",
|
|
@@ -108,6 +119,17 @@
|
|
|
108
119
|
"src/agents/registry.mjs",
|
|
109
120
|
"src/collaboration.mjs",
|
|
110
121
|
"src/provider-context.mjs",
|
|
122
|
+
"src/cognitive-loop.mjs",
|
|
123
|
+
"src/debrief.mjs",
|
|
124
|
+
"src/wave-planner.mjs",
|
|
125
|
+
"src/predictive.mjs",
|
|
126
|
+
"src/inbox.mjs",
|
|
127
|
+
"src/head-protocol.mjs",
|
|
128
|
+
"src/narrative.mjs",
|
|
129
|
+
"src/simmer.mjs",
|
|
130
|
+
"src/memory-tiers.mjs",
|
|
131
|
+
"src/envelope.mjs",
|
|
132
|
+
"src/session-lock.mjs",
|
|
111
133
|
"bin/*.mjs",
|
|
112
134
|
"hooks/enforce-tier.mjs",
|
|
113
135
|
"hooks/cost-logger.mjs",
|
|
@@ -136,6 +158,8 @@
|
|
|
136
158
|
"hooks/model-registry.mjs",
|
|
137
159
|
"hooks/auto-update-wrapper.mjs",
|
|
138
160
|
"hooks/session-end.mjs",
|
|
161
|
+
"hooks/diagnostic-companion.mjs",
|
|
162
|
+
"hooks/precompact.mjs",
|
|
139
163
|
"hooks/head-guard.mjs",
|
|
140
164
|
"hooks/auto-update.sh",
|
|
141
165
|
"mcp-server/*.mjs",
|