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/bin/dual-brain.mjs
CHANGED
|
@@ -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',
|
|
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();
|