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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// session-lock.mjs — Ensures one active HEAD session at a time.
|
|
2
|
+
// If two shells/chats open, only one owns the cognitive state.
|
|
3
|
+
// The other gets read-only access (can observe but not dispatch).
|
|
4
|
+
//
|
|
5
|
+
// "One ring rules them all" — no split-brain.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const STATE_DIR = join(process.cwd(), '.dualbrain');
|
|
11
|
+
const LOCK_FILE = join(STATE_DIR, 'session.lock');
|
|
12
|
+
|
|
13
|
+
const STALE_THRESHOLD_MS = 90_000; // 90 seconds without heartbeat = stale
|
|
14
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
let _heartbeatTimer = null;
|
|
17
|
+
let _sessionId = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} LockResult
|
|
21
|
+
* @property {boolean} acquired - Whether this session owns HEAD
|
|
22
|
+
* @property {string} sessionId - This session's ID
|
|
23
|
+
* @property {string|null} existingSession - ID of the session that already holds the lock (if not acquired)
|
|
24
|
+
* @property {string} mode - 'primary' | 'takeover' | 'readonly'
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attempt to acquire the session lock.
|
|
29
|
+
* - If no lock exists or lock is stale: acquire as primary
|
|
30
|
+
* - If lock is fresh and held by another: return readonly
|
|
31
|
+
*
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {boolean} opts.force - Force takeover even if existing session is fresh
|
|
34
|
+
* @returns {LockResult}
|
|
35
|
+
*/
|
|
36
|
+
export function acquire({ force = false } = {}) {
|
|
37
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
38
|
+
_sessionId = _generateSessionId();
|
|
39
|
+
|
|
40
|
+
const existing = _readLock();
|
|
41
|
+
|
|
42
|
+
if (!existing) {
|
|
43
|
+
// No lock — claim it
|
|
44
|
+
_writeLock(_sessionId);
|
|
45
|
+
_startHeartbeat();
|
|
46
|
+
return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const age = Date.now() - existing.heartbeat;
|
|
50
|
+
|
|
51
|
+
if (age > STALE_THRESHOLD_MS || force) {
|
|
52
|
+
// Stale or forced takeover
|
|
53
|
+
_writeLock(_sessionId);
|
|
54
|
+
_startHeartbeat();
|
|
55
|
+
return { acquired: true, sessionId: _sessionId, existingSession: existing.sessionId, mode: 'takeover' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Another session is active — go readonly
|
|
59
|
+
return { acquired: false, sessionId: _sessionId, existingSession: existing.sessionId, mode: 'readonly' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Release the session lock (called at session end).
|
|
64
|
+
*/
|
|
65
|
+
export function release() {
|
|
66
|
+
if (_heartbeatTimer) {
|
|
67
|
+
clearInterval(_heartbeatTimer);
|
|
68
|
+
_heartbeatTimer = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const existing = _readLock();
|
|
73
|
+
if (existing && existing.sessionId === _sessionId) {
|
|
74
|
+
unlinkSync(LOCK_FILE);
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if this session currently holds the lock.
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isOwner() {
|
|
84
|
+
const existing = _readLock();
|
|
85
|
+
return existing?.sessionId === _sessionId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get current lock status without modifying it.
|
|
90
|
+
* @returns {{active: boolean, sessionId: string|null, age: number|null}}
|
|
91
|
+
*/
|
|
92
|
+
export function status() {
|
|
93
|
+
const existing = _readLock();
|
|
94
|
+
if (!existing) return { active: false, sessionId: null, age: null };
|
|
95
|
+
return {
|
|
96
|
+
active: (Date.now() - existing.heartbeat) < STALE_THRESHOLD_MS,
|
|
97
|
+
sessionId: existing.sessionId,
|
|
98
|
+
age: Date.now() - existing.heartbeat,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Manually heartbeat (useful if the automatic timer isn't running).
|
|
104
|
+
*/
|
|
105
|
+
export function heartbeat() {
|
|
106
|
+
if (!_sessionId) return;
|
|
107
|
+
const existing = _readLock();
|
|
108
|
+
if (existing && existing.sessionId === _sessionId) {
|
|
109
|
+
_writeLock(_sessionId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function _generateSessionId() {
|
|
116
|
+
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _readLock() {
|
|
120
|
+
try {
|
|
121
|
+
if (!existsSync(LOCK_FILE)) return null;
|
|
122
|
+
return JSON.parse(readFileSync(LOCK_FILE, 'utf8'));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _writeLock(sessionId) {
|
|
129
|
+
const lock = {
|
|
130
|
+
sessionId,
|
|
131
|
+
heartbeat: Date.now(),
|
|
132
|
+
pid: process.pid,
|
|
133
|
+
};
|
|
134
|
+
writeFileSync(LOCK_FILE, JSON.stringify(lock));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _startHeartbeat() {
|
|
138
|
+
if (_heartbeatTimer) clearInterval(_heartbeatTimer);
|
|
139
|
+
_heartbeatTimer = setInterval(() => {
|
|
140
|
+
try {
|
|
141
|
+
const existing = _readLock();
|
|
142
|
+
if (existing && existing.sessionId === _sessionId) {
|
|
143
|
+
_writeLock(_sessionId);
|
|
144
|
+
} else {
|
|
145
|
+
// Someone else took over — stop heartbeating
|
|
146
|
+
clearInterval(_heartbeatTimer);
|
|
147
|
+
_heartbeatTimer = null;
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
151
|
+
|
|
152
|
+
// Don't keep the process alive just for heartbeats
|
|
153
|
+
if (_heartbeatTimer.unref) _heartbeatTimer.unref();
|
|
154
|
+
}
|
package/src/simmer.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// simmer.mjs — Ideas that aren't tasks yet. They sit, gather heat, and crystallize.
|
|
2
|
+
//
|
|
3
|
+
// The "song" insight: users drop ideas casually. HEAD tends to acknowledge them
|
|
4
|
+
// verbally then move on. The simmer buffer catches these — every idea gets stored
|
|
5
|
+
// with a heat score. Heat rises when: the idea recurs, evidence supports it,
|
|
6
|
+
// adjacent work makes it more relevant, or time passes and it keeps nagging.
|
|
7
|
+
// When heat crosses a threshold, the idea crystallizes into an actionable item
|
|
8
|
+
// and surfaces to HEAD during deliberation.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const STATE_DIR = join(process.cwd(), '.dualbrain');
|
|
14
|
+
const SIMMER_FILE = join(STATE_DIR, 'simmer.json');
|
|
15
|
+
|
|
16
|
+
const CRYSTALLIZE_THRESHOLD = 5;
|
|
17
|
+
const MAX_ITEMS = 30;
|
|
18
|
+
const HEAT_DECAY_PER_HOUR = 0.3;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} SimmerItem
|
|
22
|
+
* @property {string} id
|
|
23
|
+
* @property {string} idea - The raw idea in prose
|
|
24
|
+
* @property {string} origin - Where it came from (user quote, observation, debrief finding)
|
|
25
|
+
* @property {number} heat - Current heat score
|
|
26
|
+
* @property {number} createdAt
|
|
27
|
+
* @property {number} lastHeated - Last time heat was added
|
|
28
|
+
* @property {string[]} signals - Evidence trail (why heat was added)
|
|
29
|
+
* @property {boolean} crystallized - Whether it's crossed the threshold
|
|
30
|
+
* @property {string|null} crystallizedAs - What it became (task description, architecture decision, etc)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Add a new idea to the simmer buffer.
|
|
35
|
+
* If a similar idea already exists (fuzzy match), heat it instead of duplicating.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} idea - The idea in natural language
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.origin - Where this came from
|
|
40
|
+
* @param {number} opts.initialHeat - Starting heat (default 1)
|
|
41
|
+
* @returns {SimmerItem} The created or heated item
|
|
42
|
+
*/
|
|
43
|
+
export function add(idea, { origin = 'observation', initialHeat = 1 } = {}) {
|
|
44
|
+
const items = _load();
|
|
45
|
+
|
|
46
|
+
// Check for similar existing idea
|
|
47
|
+
const existing = _findSimilar(items, idea);
|
|
48
|
+
if (existing) {
|
|
49
|
+
return heat(existing.id, initialHeat, `Recurrence: "${idea.slice(0, 60)}"`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const item = {
|
|
53
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
|
|
54
|
+
idea,
|
|
55
|
+
origin,
|
|
56
|
+
heat: initialHeat,
|
|
57
|
+
createdAt: Date.now(),
|
|
58
|
+
lastHeated: Date.now(),
|
|
59
|
+
signals: [`Created from: ${origin}`],
|
|
60
|
+
crystallized: false,
|
|
61
|
+
crystallizedAs: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
items.push(item);
|
|
65
|
+
_save(items);
|
|
66
|
+
return item;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Add heat to an existing item. If it crosses the threshold, mark as crystallized.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} id
|
|
73
|
+
* @param {number} amount - Heat to add (default 1)
|
|
74
|
+
* @param {string} signal - Why heat is being added
|
|
75
|
+
* @returns {SimmerItem|null}
|
|
76
|
+
*/
|
|
77
|
+
export function heat(id, amount = 1, signal = '') {
|
|
78
|
+
const items = _load();
|
|
79
|
+
const item = items.find(i => i.id === id);
|
|
80
|
+
if (!item) return null;
|
|
81
|
+
|
|
82
|
+
item.heat += amount;
|
|
83
|
+
item.lastHeated = Date.now();
|
|
84
|
+
if (signal) item.signals.push(signal);
|
|
85
|
+
|
|
86
|
+
// Cap signals array
|
|
87
|
+
if (item.signals.length > 10) {
|
|
88
|
+
item.signals = item.signals.slice(-10);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check crystallization
|
|
92
|
+
if (!item.crystallized && item.heat >= CRYSTALLIZE_THRESHOLD) {
|
|
93
|
+
item.crystallized = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_save(items);
|
|
97
|
+
return item;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get all items that have crystallized but haven't been surfaced yet.
|
|
102
|
+
* These should be presented to HEAD during deliberation.
|
|
103
|
+
*
|
|
104
|
+
* @returns {SimmerItem[]}
|
|
105
|
+
*/
|
|
106
|
+
export function harvest() {
|
|
107
|
+
const items = _load();
|
|
108
|
+
return items.filter(i => i.crystallized && !i.crystallizedAs);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Mark a crystallized item as actioned — record what it became.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} id
|
|
115
|
+
* @param {string} became - Description of what action was taken
|
|
116
|
+
*/
|
|
117
|
+
export function resolve(id, became) {
|
|
118
|
+
const items = _load();
|
|
119
|
+
const item = items.find(i => i.id === id);
|
|
120
|
+
if (!item) return;
|
|
121
|
+
item.crystallizedAs = became;
|
|
122
|
+
_save(items);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get all active (non-resolved) simmering items, sorted by heat descending.
|
|
127
|
+
* Used by the narrative to include "what's brewing" context.
|
|
128
|
+
*
|
|
129
|
+
* @returns {SimmerItem[]}
|
|
130
|
+
*/
|
|
131
|
+
export function active() {
|
|
132
|
+
const items = _load();
|
|
133
|
+
_applyDecay(items);
|
|
134
|
+
return items
|
|
135
|
+
.filter(i => !i.crystallizedAs)
|
|
136
|
+
.sort((a, b) => b.heat - a.heat);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if an idea already exists in the buffer (for deduplication).
|
|
141
|
+
* @param {string} idea
|
|
142
|
+
* @returns {SimmerItem|null}
|
|
143
|
+
*/
|
|
144
|
+
export function find(idea) {
|
|
145
|
+
const items = _load();
|
|
146
|
+
return _findSimilar(items, idea);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate a brief for HEAD showing what's simmering.
|
|
151
|
+
* Included in the narrative context so HEAD is aware of brewing ideas.
|
|
152
|
+
*
|
|
153
|
+
* @returns {string} Prose summary of active simmer items, or empty string
|
|
154
|
+
*/
|
|
155
|
+
export function brief() {
|
|
156
|
+
const items = active();
|
|
157
|
+
if (items.length === 0) return '';
|
|
158
|
+
|
|
159
|
+
const crystallized = items.filter(i => i.crystallized);
|
|
160
|
+
const hot = items.filter(i => !i.crystallized && i.heat >= 3);
|
|
161
|
+
const warm = items.filter(i => !i.crystallized && i.heat >= 1.5 && i.heat < 3);
|
|
162
|
+
|
|
163
|
+
const parts = [];
|
|
164
|
+
|
|
165
|
+
if (crystallized.length > 0) {
|
|
166
|
+
parts.push(`Crystallized (ready to act): ${crystallized.map(i => i.idea.slice(0, 80)).join('; ')}`);
|
|
167
|
+
}
|
|
168
|
+
if (hot.length > 0) {
|
|
169
|
+
parts.push(`Hot (building momentum): ${hot.map(i => `${i.idea.slice(0, 60)} [heat:${i.heat.toFixed(1)}]`).join('; ')}`);
|
|
170
|
+
}
|
|
171
|
+
if (warm.length > 0 && parts.length < 2) {
|
|
172
|
+
parts.push(`Warm: ${warm.slice(0, 3).map(i => i.idea.slice(0, 50)).join('; ')}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return parts.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Prune resolved and cold-dead items.
|
|
180
|
+
*/
|
|
181
|
+
export function prune() {
|
|
182
|
+
let items = _load();
|
|
183
|
+
_applyDecay(items);
|
|
184
|
+
// Remove: resolved items older than 1h, or items with heat <= 0
|
|
185
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
186
|
+
items = items.filter(i => {
|
|
187
|
+
if (i.crystallizedAs && i.lastHeated < cutoff) return false;
|
|
188
|
+
if (i.heat <= 0) return false;
|
|
189
|
+
return true;
|
|
190
|
+
});
|
|
191
|
+
_save(items);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function _load() {
|
|
197
|
+
try {
|
|
198
|
+
if (existsSync(SIMMER_FILE)) {
|
|
199
|
+
return JSON.parse(readFileSync(SIMMER_FILE, 'utf8'));
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _save(items) {
|
|
206
|
+
// Cap total items
|
|
207
|
+
if (items.length > MAX_ITEMS) {
|
|
208
|
+
items.sort((a, b) => b.heat - a.heat);
|
|
209
|
+
items = items.slice(0, MAX_ITEMS);
|
|
210
|
+
}
|
|
211
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
212
|
+
writeFileSync(SIMMER_FILE, JSON.stringify(items, null, 2));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _applyDecay(items) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
for (const item of items) {
|
|
218
|
+
if (item.crystallized) continue; // Crystallized items don't decay
|
|
219
|
+
const hoursSinceHeat = (now - item.lastHeated) / (60 * 60 * 1000);
|
|
220
|
+
if (hoursSinceHeat > 1) {
|
|
221
|
+
item.heat -= HEAT_DECAY_PER_HOUR * hoursSinceHeat;
|
|
222
|
+
if (item.heat < 0) item.heat = 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _findSimilar(items, idea) {
|
|
228
|
+
const normalized = idea.toLowerCase().replace(/[^a-z0-9\s]/g, '');
|
|
229
|
+
const words = normalized.split(/\s+/).filter(w => w.length > 4);
|
|
230
|
+
if (words.length === 0) return null;
|
|
231
|
+
|
|
232
|
+
for (const item of items) {
|
|
233
|
+
if (item.crystallizedAs) continue; // Skip resolved
|
|
234
|
+
const itemNorm = item.idea.toLowerCase().replace(/[^a-z0-9\s]/g, '');
|
|
235
|
+
const matchCount = words.filter(w => itemNorm.includes(w)).length;
|
|
236
|
+
if (matchCount >= Math.ceil(words.length * 0.5)) {
|
|
237
|
+
return item;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Wave Planner — Layer 2 cognitive loop
|
|
2
|
+
// Takes HEAD's deliberation output and produces structured wave-based execution plans.
|
|
3
|
+
|
|
4
|
+
const TIER_COST = {
|
|
5
|
+
search: { tokens: 5000, time: '~10s' },
|
|
6
|
+
execute: { tokens: 20000, time: '~45s' },
|
|
7
|
+
think: { tokens: 15000, time: '~30s' },
|
|
8
|
+
review: { tokens: 10000, time: '~20s' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
let waveCounter = 0;
|
|
12
|
+
function nextWaveId() { return `w-${Date.now().toString(36)}-${++waveCounter}`; }
|
|
13
|
+
|
|
14
|
+
/** Plan waves from HEAD's deliberation output. */
|
|
15
|
+
export function planWaves(deliberation, context = {}) {
|
|
16
|
+
const { situation = {}, uncertainties = [], result = {} } = deliberation;
|
|
17
|
+
const { files = [], priorDebriefs = [], diagnosticPatterns = [] } = context;
|
|
18
|
+
const depth = result.depth || 'light';
|
|
19
|
+
|
|
20
|
+
const blockers = uncertainties.filter(u => u.confidence < 0.3);
|
|
21
|
+
const hasFragile = situation.risk === 'high' || situation.risk === 'critical';
|
|
22
|
+
const largeScope = situation.scope === 'large';
|
|
23
|
+
const priorBlockers = priorDebriefs.filter(d => d.blockers?.length);
|
|
24
|
+
|
|
25
|
+
const waves = [];
|
|
26
|
+
const contingencies = [];
|
|
27
|
+
|
|
28
|
+
// Determine wave structure based on depth
|
|
29
|
+
if (depth === 'reflexive' || depth === 'light') {
|
|
30
|
+
waves.push(makeSingleWave(result, situation, files));
|
|
31
|
+
} else if (depth === 'full') {
|
|
32
|
+
if (blockers.length) {
|
|
33
|
+
waves.push(makeReconWave(blockers, situation, files, priorBlockers));
|
|
34
|
+
}
|
|
35
|
+
waves.push(makeImplementWave(result, situation, files, largeScope, waves));
|
|
36
|
+
if (hasFragile) {
|
|
37
|
+
waves.push(makeVerifyWave(situation, files, waves));
|
|
38
|
+
}
|
|
39
|
+
} else if (depth === 'deep') {
|
|
40
|
+
// Always start with recon for deep
|
|
41
|
+
waves.push(makeReconWave(
|
|
42
|
+
blockers.length ? blockers : uncertainties,
|
|
43
|
+
situation, files, priorBlockers
|
|
44
|
+
));
|
|
45
|
+
// Plan wave
|
|
46
|
+
waves.push({
|
|
47
|
+
id: nextWaveId(),
|
|
48
|
+
phase: 'synthesize',
|
|
49
|
+
agents: [{
|
|
50
|
+
tier: 'think',
|
|
51
|
+
objective: `Synthesize recon findings into implementation plan for: ${result.action || situation.taskShape || 'task'}`,
|
|
52
|
+
scope: files.slice(0, 10),
|
|
53
|
+
}],
|
|
54
|
+
dependsOn: [waves[0].id],
|
|
55
|
+
gateCondition: 'recon wave completed without escalation',
|
|
56
|
+
parallel: false,
|
|
57
|
+
});
|
|
58
|
+
// Implement wave
|
|
59
|
+
waves.push(makeImplementWave(result, situation, files, largeScope, waves));
|
|
60
|
+
// Verify wave
|
|
61
|
+
waves.push(makeVerifyWave(situation, files, waves));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Force recon-first if blockers exist and first wave isn't recon
|
|
65
|
+
if (blockers.length && waves.length && waves[0].phase !== 'recon') {
|
|
66
|
+
const reconWave = makeReconWave(blockers, situation, files, priorBlockers);
|
|
67
|
+
waves.forEach(w => { if (!w.dependsOn.length) w.dependsOn.push(reconWave.id); });
|
|
68
|
+
waves.unshift(reconWave);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build contingencies
|
|
72
|
+
if (largeScope) {
|
|
73
|
+
contingencies.push({
|
|
74
|
+
trigger: 'if wave 1 finds scope is larger than expected',
|
|
75
|
+
response: 'add-wave',
|
|
76
|
+
details: 'Split implementation into additional parallel waves by file group',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (hasFragile) {
|
|
80
|
+
contingencies.push({
|
|
81
|
+
trigger: 'if implementation wave introduces regressions',
|
|
82
|
+
response: 'retry-different',
|
|
83
|
+
details: 'Re-approach with smaller incremental changes',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (blockers.length) {
|
|
87
|
+
contingencies.push({
|
|
88
|
+
trigger: 'if recon cannot resolve uncertainty',
|
|
89
|
+
response: 'escalate',
|
|
90
|
+
details: 'Ask user for clarification before proceeding',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const agentCount = waves.reduce((n, w) => n + w.agents.length, 0);
|
|
95
|
+
const dominantTier = agentCount <= 1 ? (waves[0]?.agents[0]?.tier || 'search')
|
|
96
|
+
: waves.flatMap(w => w.agents.map(a => a.tier))
|
|
97
|
+
.sort((a, b) => TIER_COST[b].tokens - TIER_COST[a].tokens)[0];
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
waves,
|
|
101
|
+
rationale: buildRationale(depth, blockers, hasFragile, largeScope),
|
|
102
|
+
estimatedCost: { waves: waves.length, agents: agentCount, tier: dominantTier },
|
|
103
|
+
contingencies,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Decide if the remaining plan is still valid after a wave debrief. */
|
|
108
|
+
export function shouldReplan(currentPlan, newDebrief) {
|
|
109
|
+
if (!newDebrief) return false;
|
|
110
|
+
if (newDebrief.scopeChange === 'larger' || newDebrief.scopeChange === 'different') return true;
|
|
111
|
+
if (newDebrief.pivotReason) return true;
|
|
112
|
+
if (newDebrief.confidence !== undefined && newDebrief.confidence < 0.4) return true;
|
|
113
|
+
|
|
114
|
+
// Check if blockers intersect with upcoming wave objectives
|
|
115
|
+
if (newDebrief.blockers?.length) {
|
|
116
|
+
const remaining = currentPlan.waves.filter(w => !w.completed);
|
|
117
|
+
for (const w of remaining) {
|
|
118
|
+
for (const agent of w.agents) {
|
|
119
|
+
for (const blocker of newDebrief.blockers) {
|
|
120
|
+
const bLower = (typeof blocker === 'string' ? blocker : blocker.description || '').toLowerCase();
|
|
121
|
+
if (bLower && agent.objective.toLowerCase().includes(bLower.split(' ')[0])) return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Produce a new plan incorporating what was learned. Preserves completed waves. */
|
|
130
|
+
export function replan(currentPlan, waveSummary, originalDeliberation) {
|
|
131
|
+
const completed = currentPlan.waves.filter(w => w.completed);
|
|
132
|
+
const context = {
|
|
133
|
+
files: waveSummary.filesDiscovered || [],
|
|
134
|
+
priorDebriefs: [waveSummary],
|
|
135
|
+
diagnosticPatterns: waveSummary.patterns || [],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Merge learning into deliberation
|
|
139
|
+
const updated = { ...originalDeliberation };
|
|
140
|
+
if (waveSummary.scopeChange === 'larger') {
|
|
141
|
+
updated.situation = { ...updated.situation, scope: 'large' };
|
|
142
|
+
}
|
|
143
|
+
if (waveSummary.confidence !== undefined) {
|
|
144
|
+
updated.result = { ...updated.result, confidence: waveSummary.confidence };
|
|
145
|
+
}
|
|
146
|
+
if (waveSummary.newUncertainties) {
|
|
147
|
+
updated.uncertainties = [
|
|
148
|
+
...(updated.uncertainties || []),
|
|
149
|
+
...waveSummary.newUncertainties,
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const newPlan = planWaves(updated, context);
|
|
154
|
+
|
|
155
|
+
// Preserve completed waves at the front
|
|
156
|
+
newPlan.waves = [...completed, ...newPlan.waves];
|
|
157
|
+
newPlan.rationale = `Replanned after wave debrief: ${waveSummary.pivotReason || waveSummary.scopeChange || 'confidence drop'}. ${newPlan.rationale}`;
|
|
158
|
+
return newPlan;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Rough cost estimate for a single wave. */
|
|
162
|
+
export function estimateWaveCost(wave) {
|
|
163
|
+
let tokens = 0;
|
|
164
|
+
for (const agent of wave.agents) {
|
|
165
|
+
tokens += TIER_COST[agent.tier]?.tokens || 10000;
|
|
166
|
+
}
|
|
167
|
+
// Time: parallel agents overlap, sequential add up
|
|
168
|
+
const times = wave.agents.map(a => parseInt(TIER_COST[a.tier]?.time) || 20);
|
|
169
|
+
const seconds = wave.parallel ? Math.max(...times) : times.reduce((s, t) => s + t, 0);
|
|
170
|
+
return { tokens, time: `~${seconds}s` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function makeSingleWave(result, situation, files) {
|
|
174
|
+
const tier = mapActionToTier(result.action);
|
|
175
|
+
return {
|
|
176
|
+
id: nextWaveId(),
|
|
177
|
+
phase: tier === 'search' ? 'recon' : 'implement',
|
|
178
|
+
agents: [{
|
|
179
|
+
tier,
|
|
180
|
+
objective: situation.explicitAsk || situation.raw || (typeof result.action === 'string' ? result.action : result.action?.mode) || 'execute task',
|
|
181
|
+
scope: files.slice(0, 5),
|
|
182
|
+
}],
|
|
183
|
+
dependsOn: [],
|
|
184
|
+
parallel: false,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function makeReconWave(uncertainties, situation, files, priorBlockers) {
|
|
189
|
+
const avoidApproaches = priorBlockers.flatMap(d =>
|
|
190
|
+
d.blockers?.map(b => typeof b === 'string' ? b : b.approach) || []
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const agents = uncertainties.slice(0, 3).map(u => {
|
|
194
|
+
const spec = {
|
|
195
|
+
tier: 'search',
|
|
196
|
+
objective: `Resolve uncertainty: ${u.claim || u.description || 'unknown'}`,
|
|
197
|
+
scope: files.slice(0, 5),
|
|
198
|
+
};
|
|
199
|
+
if (u.wouldChangeIf) {
|
|
200
|
+
spec.conditionalPivot = { if: u.wouldChangeIf, then: 'report finding and stop' };
|
|
201
|
+
}
|
|
202
|
+
return spec;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// If no uncertainties provided, add a general recon agent
|
|
206
|
+
if (!agents.length) {
|
|
207
|
+
agents.push({
|
|
208
|
+
tier: 'search',
|
|
209
|
+
objective: `Explore scope and structure for: ${situation.taskShape || 'task'}`,
|
|
210
|
+
scope: files.slice(0, 5),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Annotate agents to avoid prior failed approaches
|
|
215
|
+
if (avoidApproaches.length) {
|
|
216
|
+
for (const agent of agents) {
|
|
217
|
+
agent.objective += ` (avoid: ${avoidApproaches.join(', ')})`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
id: nextWaveId(),
|
|
223
|
+
phase: 'recon',
|
|
224
|
+
agents,
|
|
225
|
+
dependsOn: [],
|
|
226
|
+
gateCondition: undefined,
|
|
227
|
+
parallel: agents.length > 1,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function makeImplementWave(result, situation, files, largeScope, existingWaves) {
|
|
232
|
+
const dependsOn = existingWaves.length ? [existingWaves[existingWaves.length - 1].id] : [];
|
|
233
|
+
const agents = [];
|
|
234
|
+
|
|
235
|
+
if (largeScope && files.length > 3) {
|
|
236
|
+
// Split into parallel agents by file group
|
|
237
|
+
const groupSize = Math.ceil(files.length / 3);
|
|
238
|
+
for (let i = 0; i < files.length; i += groupSize) {
|
|
239
|
+
agents.push({
|
|
240
|
+
tier: 'execute',
|
|
241
|
+
objective: result.action || `Implement changes in file group ${Math.floor(i / groupSize) + 1}`,
|
|
242
|
+
scope: files.slice(i, i + groupSize),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
agents.push({
|
|
247
|
+
tier: 'execute',
|
|
248
|
+
objective: result.action || situation.taskShape || 'implement changes',
|
|
249
|
+
scope: files.slice(0, 10),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
id: nextWaveId(),
|
|
255
|
+
phase: 'implement',
|
|
256
|
+
agents,
|
|
257
|
+
dependsOn,
|
|
258
|
+
gateCondition: existingWaves.length ? 'prior wave completed successfully' : undefined,
|
|
259
|
+
parallel: agents.length > 1,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function makeVerifyWave(situation, files, existingWaves) {
|
|
264
|
+
const dependsOn = existingWaves.length ? [existingWaves[existingWaves.length - 1].id] : [];
|
|
265
|
+
return {
|
|
266
|
+
id: nextWaveId(),
|
|
267
|
+
phase: 'verify',
|
|
268
|
+
agents: [{
|
|
269
|
+
tier: 'review',
|
|
270
|
+
objective: `Verify changes are correct and safe${situation.risk === 'critical' ? ' — critical risk area' : ''}`,
|
|
271
|
+
scope: files.slice(0, 10),
|
|
272
|
+
}],
|
|
273
|
+
dependsOn,
|
|
274
|
+
gateCondition: 'implementation wave completed',
|
|
275
|
+
parallel: false,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function mapActionToTier(action) {
|
|
280
|
+
if (!action) return 'execute';
|
|
281
|
+
const a = (typeof action === 'string' ? action : `${action.type || ''} ${action.mode || ''}`).toLowerCase();
|
|
282
|
+
if (a.includes('search') || a.includes('find') || a.includes('look') || a.includes('explore')) return 'search';
|
|
283
|
+
if (a.includes('review') || a.includes('check') || a.includes('verify')) return 'review';
|
|
284
|
+
if (a.includes('think') || a.includes('plan') || a.includes('design') || a.includes('architect')) return 'think';
|
|
285
|
+
return 'execute';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildRationale(depth, blockers, hasFragile, largeScope) {
|
|
289
|
+
const parts = [`Depth: ${depth}.`];
|
|
290
|
+
if (blockers.length) parts.push(`${blockers.length} blocker(s) require recon first.`);
|
|
291
|
+
if (hasFragile) parts.push('High-risk area — verification wave added.');
|
|
292
|
+
if (largeScope) parts.push('Large scope — parallel agents where possible.');
|
|
293
|
+
return parts.join(' ');
|
|
294
|
+
}
|