dual-brain 0.2.14 → 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.
@@ -95,6 +95,18 @@ async function getLivingDocs() {
95
95
  return _livingDocs;
96
96
  }
97
97
 
98
+ let _cognitiveLoopCache = null;
99
+ async function _getCognitiveLoop() {
100
+ if (!_cognitiveLoopCache) {
101
+ try {
102
+ _cognitiveLoopCache = await import('../src/cognitive-loop.mjs');
103
+ } catch {
104
+ _cognitiveLoopCache = null;
105
+ }
106
+ }
107
+ return _cognitiveLoopCache;
108
+ }
109
+
98
110
  let _fx = null;
99
111
  async function getFx() {
100
112
  if (_fx !== null) return _fx;
@@ -492,6 +504,35 @@ async function cmdGo(args, opts = {}) {
492
504
  } catch { /* non-fatal */ }
493
505
  }
494
506
 
507
+ // ── Cognitive loop: enhance prompt with debrief + preventions if available ──
508
+ let loopEnhancedPrompt = prompt;
509
+ let loopDispatchMeta = null;
510
+ try {
511
+ const cogLoop = await _getCognitiveLoop();
512
+ if (cogLoop) {
513
+ const loopResult = cogLoop.enter(prompt, { files });
514
+ if (loopResult.phase === 'dispatch' && loopResult.nextDispatch) {
515
+ loopDispatchMeta = loopResult;
516
+ // Append debrief instructions and preventions from first agent to prompt
517
+ const firstAgent = loopResult.nextDispatch.agents?.[0];
518
+ if (firstAgent) {
519
+ const extras = [];
520
+ if (firstAgent.preventions) extras.push(firstAgent.preventions);
521
+ if (firstAgent.debriefInstruction) extras.push(firstAgent.debriefInstruction);
522
+ if (extras.length > 0) {
523
+ loopEnhancedPrompt = prompt + '\n\n' + extras.join('\n\n');
524
+ }
525
+ }
526
+ if (verbose && loopResult.plan) {
527
+ const wc = loopResult.plan.waves?.length || 0;
528
+ console.log(` [cognitive-loop] Plan: ${wc} wave(s), phase: ${loopResult.phase}`);
529
+ }
530
+ }
531
+ }
532
+ } catch {
533
+ // Cognitive loop unavailable or errored — proceed with original prompt
534
+ }
535
+
495
536
  // ── Dispatch visualization ─────────────────────────────────────────────────
496
537
  const fxGo = await getFx();
497
538
  let dispatchSpinner = null;
@@ -499,7 +540,7 @@ async function cmdGo(args, opts = {}) {
499
540
  dispatchSpinner = fxGo.spinner(`Dispatching agent...`).start();
500
541
  }
501
542
 
502
- const { plan, result } = await runPipeline('go', prompt, {
543
+ const { plan, result } = await runPipeline('go', loopEnhancedPrompt, {
503
544
  files,
504
545
  cwd,
505
546
  verbose,
@@ -511,6 +552,23 @@ async function cmdGo(args, opts = {}) {
511
552
  dispatchSpinner.succeed(`Agent dispatched: ${prompt.slice(0, 50)}`);
512
553
  }
513
554
 
555
+ // ── Cognitive loop: advance after dispatch completes ─────────────────────────
556
+ if (loopDispatchMeta && result && !dryRun) {
557
+ try {
558
+ const cogLoop = await _getCognitiveLoop();
559
+ if (cogLoop) {
560
+ const waveId = loopDispatchMeta.nextDispatch.waveId;
561
+ const rawResults = [result.summary || result.output || ''];
562
+ const advanceResult = cogLoop.advance(rawResults, waveId, { files });
563
+ if (verbose && advanceResult) {
564
+ console.log(` [cognitive-loop] Next phase: ${advanceResult.phase}, rationale: ${advanceResult.rationale || '-'}`);
565
+ }
566
+ }
567
+ } catch {
568
+ // Non-fatal — loop advance failure doesn't affect the completed dispatch
569
+ }
570
+ }
571
+
514
572
  if (dryRun) {
515
573
  // formatExecutionPlan already printed by pipeline when verbose/dryRun=true
516
574
  console.log('\n(dry-run — not executing)');
@@ -2149,6 +2207,48 @@ function classifyInput(input) {
2149
2207
  }
2150
2208
 
2151
2209
  // ── HEAD cognitive pipeline: replaces regex-based cheap/full split ──────
2210
+ // Try cognitive loop first (wraps HEAD with wave planning + predictions)
2211
+ if (_cognitiveLoopCache) {
2212
+ try {
2213
+ const loopResult = _cognitiveLoopCache.enter(trimmed, {});
2214
+
2215
+ const judgment = {
2216
+ depth: loopResult.action?.depth || 'full',
2217
+ action: loopResult.action,
2218
+ shouldAskUser: loopResult.shouldAskUser,
2219
+ shouldDispatch: loopResult.phase === 'dispatch',
2220
+ shouldClarify: loopResult.action?.type === 'clarify',
2221
+ shouldThink: loopResult.action?.type === 'think',
2222
+ rationale: loopResult.rationale,
2223
+ confidence: loopResult.action?.confidence,
2224
+ obligations: loopResult.action?.obligations,
2225
+ surfaceNoticings: loopResult.surfaceNoticings,
2226
+ // Cognitive loop extensions
2227
+ _loopResult: loopResult,
2228
+ _plan: loopResult.plan,
2229
+ _nextDispatch: loopResult.nextDispatch,
2230
+ };
2231
+
2232
+ // Loop says respond — no dispatch needed
2233
+ if (loopResult.phase === 'respond') {
2234
+ return { tier: 'cheap', headJudgment: judgment };
2235
+ }
2236
+
2237
+ // Loop says dispatch — full tier, use plan's first agent tier to pick model
2238
+ if (loopResult.phase === 'dispatch') {
2239
+ const firstAgent = loopResult.nextDispatch?.agents?.[0];
2240
+ const model = firstAgent?.tier === 'deep' || firstAgent?.tier === 'opus' ? 'opus' : 'sonnet';
2241
+ return { tier: 'full', headJudgment: judgment, model };
2242
+ }
2243
+
2244
+ // Default: cheap
2245
+ return { tier: 'cheap', headJudgment: judgment };
2246
+ } catch {
2247
+ // Cognitive loop failed — fall through to direct HEAD
2248
+ }
2249
+ }
2250
+
2251
+ // Direct HEAD fallback (when cognitive loop unavailable or errored)
2152
2252
  const head = _headModuleCache;
2153
2253
  if (head) {
2154
2254
  const state = _getHeadState() || head.freshState();
@@ -2722,6 +2822,19 @@ async function mainScreen(rl, ask) {
2722
2822
  return signalLine(item.ok ? 'success' : 'warning', `${DIM}${item.text}${RST}`);
2723
2823
  });
2724
2824
 
2825
+ // ── Cognitive loop status (appended to signals) ────────────────────────────
2826
+ try {
2827
+ const cogLoop = await _getCognitiveLoop();
2828
+ if (cogLoop) {
2829
+ const loopStatus = cogLoop.getLoopStatus();
2830
+ if (loopStatus.hasActivePlan) {
2831
+ const wavePart = `${loopStatus.completedWaves}/${loopStatus.totalWaves} waves`;
2832
+ const replanPart = loopStatus.replans > 0 ? ` · ${loopStatus.replans} replan${loopStatus.replans !== 1 ? 's' : ''}` : '';
2833
+ recentLines.push(signalLine('info', `${DIM}[loop] ${wavePart}${replanPart}${RST}`));
2834
+ }
2835
+ }
2836
+ } catch { /* non-fatal */ }
2837
+
2725
2838
  // ── Resolve dashboard spinner before rendering ────────────────────────────
2726
2839
  if (_spinnerTimeout) clearTimeout(_spinnerTimeout);
2727
2840
  if (dashSpinner) dashSpinner.succeed('Dashboard ready');
@@ -3064,6 +3177,18 @@ async function mainScreen(rl, ask) {
3064
3177
  process.stdout.write('\n');
3065
3178
  }
3066
3179
 
3180
+ // Show cognitive loop plan info if available
3181
+ if (hj?._plan) {
3182
+ const plan = hj._plan;
3183
+ const waveCount = plan.waves?.length || 0;
3184
+ const agentCount = plan.waves?.reduce((sum, w) => sum + (w.agents?.length || 0), 0) || 0;
3185
+ process.stdout.write(`\n \x1b[2m[plan] ${waveCount} wave${waveCount !== 1 ? 's' : ''}, ${agentCount} agent${agentCount !== 1 ? 's' : ''}\x1b[0m`);
3186
+ if (hj._nextDispatch?.warnings?.length > 0) {
3187
+ process.stdout.write(` \x1b[33m${hj._nextDispatch.warnings.length} warning(s)\x1b[0m`);
3188
+ }
3189
+ process.stdout.write('\n');
3190
+ }
3191
+
3067
3192
  // HEAD's shouldAskUser gates the dispatch — dangerous/irreversible ops
3068
3193
  if (hj?.shouldAskUser) {
3069
3194
  const reason = hj.obligations?.find(o => o.type === 'askBeforeIrreversi')?.description || hj.rationale;
@@ -3073,14 +3198,14 @@ async function mainScreen(rl, ask) {
3073
3198
  process.stdout.write(` \x1b[36mEnter\x1b[0m proceed \x1b[36mn\x1b[0m cancel\n\n`);
3074
3199
  const confirm = (await ask(' > ')).trim().toLowerCase();
3075
3200
  if (confirm === 'n' || confirm === 'no') return { next: 'main' };
3076
- return { next: 'go', prompt: input, model };
3201
+ return { next: 'go', prompt: input, model, _loopResult: hj._loopResult };
3077
3202
  }
3078
3203
 
3079
3204
  // Automode: if HEAD says it's safe, just go — no confirmation needed
3080
3205
  const automode = profile.automode ?? profile.settings?.automode ?? false;
3081
3206
  if (automode) {
3082
3207
  process.stdout.write(`\n \x1b[36m⚡\x1b[0m ${summary} (${model}, depth: ${hj?.depth || '?'})\n`);
3083
- return { next: 'go', prompt: input, model };
3208
+ return { next: 'go', prompt: input, model, _loopResult: hj._loopResult };
3084
3209
  }
3085
3210
 
3086
3211
  // Manual mode — show depth, wait for confirmation
@@ -3089,7 +3214,7 @@ async function mainScreen(rl, ask) {
3089
3214
  process.stdout.write(` \x1b[36mEnter\x1b[0m go \x1b[36mn\x1b[0m cancel\n\n`);
3090
3215
  const confirm = (await ask(' > ')).trim().toLowerCase();
3091
3216
  if (confirm === 'n' || confirm === 'no') return { next: 'main' };
3092
- return { next: 'go', prompt: input, model };
3217
+ return { next: 'go', prompt: input, model, _loopResult: hj._loopResult };
3093
3218
  }
3094
3219
 
3095
3220
  // Default fallback
@@ -6202,6 +6327,7 @@ async function main() {
6202
6327
  primeAgentRegistry().catch(() => {});
6203
6328
  _primeRegistryCache().catch(() => {});
6204
6329
  _getHeadModule().catch(() => {});
6330
+ _getCognitiveLoop().catch(() => {});
6205
6331
 
6206
6332
  const args = process.argv.slice(2);
6207
6333
  const cmd = args[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 >= 3) {
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();