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.
@@ -0,0 +1,532 @@
1
+ // Cognitive Loop — the integration layer that makes HEAD a continuous process.
2
+ // Replaces single-shot deliberation with: perceive → plan → predict → dispatch → debrief → replan → ...
3
+
4
+ import { processTurn, loadState, perceive, assessUncertainty, deriveObligations, notice, deliberate, assessDepth, recordDispatchOutcome } from './head.mjs';
5
+ import { planWaves, shouldReplan, replan, estimateWaveCost } from './wave-planner.mjs';
6
+ import { parseDebrief, generateDebriefInstruction, integrateDebrief, summarizeWaveOutcome } from './debrief.mjs';
7
+ import { predictFailureModes, generatePreventions, scoreDispatchReadiness, evolvePatterns, loadSessionPatterns } from './predictive.mjs';
8
+ import { writeDeliberation } from './head-protocol.mjs';
9
+ import { check as checkInbox, generateInboxBrief, purge as purgeInbox } from './inbox.mjs';
10
+ import * as memoryTiers from './memory-tiers.mjs';
11
+ import * as narrative from './narrative.mjs';
12
+ import * as simmer from './simmer.mjs';
13
+ import { build as buildEnvelope } from './envelope.mjs';
14
+ import { acquire as acquireSession, release as releaseSession, isOwner as isSessionOwner } from './session-lock.mjs';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ // ── Pattern cache ──────────────────────────────────────────────────────────
19
+ let _patternsCache = null;
20
+ let _patternsCacheTs = 0;
21
+ function getCachedPatterns() {
22
+ if (!_patternsCache || Date.now() - _patternsCacheTs > 5000) {
23
+ _patternsCache = loadSessionPatterns();
24
+ _patternsCacheTs = Date.now();
25
+ }
26
+ return _patternsCache;
27
+ }
28
+
29
+ const LOOP_STATE_DIR = join(process.cwd(), '.dualbrain');
30
+ const LOOP_STATE_FILE = join(LOOP_STATE_DIR, 'cognitive-loop.json');
31
+
32
+ // ── Loop state persistence ──────────────────────────────────────────────────
33
+
34
+ function loadLoopState() {
35
+ try {
36
+ if (existsSync(LOOP_STATE_FILE)) {
37
+ return JSON.parse(readFileSync(LOOP_STATE_FILE, 'utf8'));
38
+ }
39
+ } catch {}
40
+ return freshLoopState();
41
+ }
42
+
43
+ function freshLoopState() {
44
+ return {
45
+ sessionId: Date.now().toString(36),
46
+ activePlan: null,
47
+ completedWaves: [],
48
+ debriefs: [],
49
+ situationHistory: [],
50
+ replans: 0,
51
+ totalDispatches: 0,
52
+ totalTokensEstimated: 0,
53
+ };
54
+ }
55
+
56
+ function saveLoopState(state) {
57
+ mkdirSync(LOOP_STATE_DIR, { recursive: true });
58
+ writeFileSync(LOOP_STATE_FILE, JSON.stringify(state, null, 2));
59
+ }
60
+
61
+ // ── The cognitive loop ──────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Entry point: process a user message through the full cognitive loop.
65
+ * Returns a LoopResult that tells the caller exactly what to do next.
66
+ *
67
+ * @param {string} userMessage
68
+ * @param {object} context - { files, recentFiles, priorFailures, uncommittedFiles, etc }
69
+ * @returns {LoopResult}
70
+ */
71
+ export function enter(userMessage, context = {}) {
72
+ // Session lock: one HEAD at a time
73
+ const lock = acquireSession();
74
+ if (!lock.acquired) {
75
+ return {
76
+ phase: 'readonly',
77
+ action: { type: 'respond', mode: 'readonly' },
78
+ rationale: `Another session (${lock.existingSession}) is active. This session is read-only to prevent split-brain.`,
79
+ shouldAskUser: false,
80
+ surfaceNoticings: [],
81
+ plan: null,
82
+ nextDispatch: null,
83
+ };
84
+ }
85
+
86
+ const headState = loadState();
87
+ const loopState = loadLoopState();
88
+
89
+ // Immersion: load memory tiers so HEAD is "in the song"
90
+ const memory = memoryTiers.assemble({
91
+ userMessage,
92
+ files: context.files || [],
93
+ intent: context._detectedIntent || null,
94
+ });
95
+ if (memory.combined) {
96
+ context._immersionContext = memory.combined;
97
+ }
98
+
99
+ // Check inbox for HEAD before processing
100
+ const inboxBrief = generateInboxBrief('head');
101
+ if (inboxBrief) {
102
+ context._inboxBrief = inboxBrief;
103
+ }
104
+
105
+ // Simmer: check for crystallized ideas to surface
106
+ const crystallized = simmer.harvest();
107
+ if (crystallized.length > 0) {
108
+ context._crystallizedIdeas = crystallized.map(i => i.idea);
109
+ }
110
+
111
+ // Phase 1: Full cognitive pipeline
112
+ const turn = processTurn(headState, userMessage, context);
113
+
114
+ // Save situation for history
115
+ loopState.situationHistory.push({
116
+ ts: Date.now(),
117
+ depth: turn.depth,
118
+ action: turn.action.type,
119
+ confidence: turn.result.confidence.score,
120
+ });
121
+
122
+ // Narrative evolution: update HEAD's running understanding
123
+ _evolveNarrative(turn, userMessage, context);
124
+
125
+ // Simmer: capture any ideas from user message that aren't direct tasks
126
+ _captureSimmerSignals(userMessage, turn);
127
+
128
+ // If HEAD says don't dispatch, or it's a "proceed" with no active plan, respond
129
+ if ((!turn.shouldDispatch && !turn.shouldThink) || (turn.action.type === 'proceed' && !loopState.activePlan)) {
130
+ saveLoopState(loopState);
131
+ return {
132
+ phase: 'respond',
133
+ action: turn.action,
134
+ rationale: turn.rationale,
135
+ shouldAskUser: turn.shouldAskUser,
136
+ surfaceNoticings: turn.result.surfaceNoticings,
137
+ plan: null,
138
+ nextDispatch: null,
139
+ };
140
+ }
141
+
142
+ // Persist deliberation artifact for deliberation-gate hook validation
143
+ try {
144
+ writeDeliberation(userMessage, { situation: turn.situation, result: turn.result });
145
+ } catch (err) {
146
+ // Non-fatal: don't break the loop if deliberation persistence fails
147
+ process.stderr.write(`[cognitive-loop] writeDeliberation failed: ${err?.message?.slice(0, 120)}\n`);
148
+ }
149
+
150
+ // Phase 2: Plan waves
151
+ const plan = planWaves(turn, {
152
+ files: context.files || [],
153
+ priorDebriefs: loopState.debriefs,
154
+ diagnosticPatterns: getCachedPatterns(),
155
+ });
156
+
157
+ loopState.activePlan = plan;
158
+ saveLoopState(loopState);
159
+
160
+ // Phase 3: Prepare first wave for dispatch
161
+ const firstWave = plan.waves[0];
162
+ if (!firstWave) {
163
+ return {
164
+ phase: 'respond',
165
+ action: turn.action,
166
+ rationale: 'Wave planner produced empty plan — falling back to direct response',
167
+ shouldAskUser: true,
168
+ surfaceNoticings: turn.result.surfaceNoticings,
169
+ plan,
170
+ nextDispatch: null,
171
+ };
172
+ }
173
+
174
+ const prepared = prepareWave(firstWave, turn, context, loopState);
175
+
176
+ // Don't dispatch unready work — return as blocked
177
+ if (prepared.blockers.length > 0 && !prepared.allReady) {
178
+ saveLoopState(loopState);
179
+ return {
180
+ phase: 'blocked',
181
+ action: turn.action,
182
+ rationale: turn.rationale,
183
+ shouldAskUser: true,
184
+ surfaceNoticings: turn.result.surfaceNoticings,
185
+ plan,
186
+ nextDispatch: prepared,
187
+ suggestion: prepared.blockers[0],
188
+ };
189
+ }
190
+
191
+ return {
192
+ phase: 'dispatch',
193
+ action: turn.action,
194
+ rationale: turn.rationale,
195
+ shouldAskUser: turn.shouldAskUser,
196
+ surfaceNoticings: turn.result.surfaceNoticings,
197
+ plan,
198
+ nextDispatch: prepared,
199
+ estimatedCost: plan.estimatedCost,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Called after a wave completes. Processes debriefs and determines next action.
205
+ *
206
+ * @param {string[]} rawResults - raw output from each agent in the completed wave
207
+ * @param {string} completedWaveId - which wave just finished
208
+ * @param {object} context
209
+ * @returns {LoopResult}
210
+ */
211
+ export function advance(rawResults, completedWaveId, context = {}) {
212
+ const loopState = loadLoopState();
213
+ const plan = loopState.activePlan;
214
+
215
+ if (!plan) {
216
+ return { phase: 'done', action: { type: 'respond', mode: 'direct' }, rationale: 'No active plan', plan: null, nextDispatch: null };
217
+ }
218
+
219
+ // Parse debriefs from raw results
220
+ const debriefs = rawResults.map(r => parseDebrief(r));
221
+
222
+ // Track dispatch outcomes in HEAD state for self-awareness
223
+ const headState = loadState();
224
+ for (const d of debriefs) {
225
+ recordDispatchOutcome(headState, { type: d.artifacts?.tier || 'execute', objective: completedWaveId, status: d.status, durationMs: 0 });
226
+ }
227
+
228
+ const waveSummary = summarizeWaveOutcome(debriefs);
229
+ // Bridge field names between debrief (scopeDelta) and wave-planner (scopeChange)
230
+ waveSummary.scopeChange = waveSummary.scopeDelta;
231
+ waveSummary.confidence = waveSummary.aggregateConfidence;
232
+ waveSummary.blockers = waveSummary.allBlockers;
233
+
234
+ // Record
235
+ loopState.debriefs.push(...debriefs);
236
+ loopState.completedWaves.push(completedWaveId);
237
+
238
+ // Evolve prediction accuracy
239
+ const predictions = loopState._lastPredictions || [];
240
+ for (const d of debriefs) {
241
+ evolvePatterns(d, predictions);
242
+ }
243
+
244
+ // Post-wave verbal reflection (Reflexion pattern)
245
+ _postWaveReflection(waveSummary, loopState);
246
+
247
+ // Check if we need to replan
248
+ const needsReplan = shouldReplan(plan, waveSummary);
249
+
250
+ let activePlan = plan;
251
+ if (needsReplan) {
252
+ // Integrate each debrief into situation progressively
253
+ let updatedSituation = loopState.situationHistory[loopState.situationHistory.length - 1] || {};
254
+ for (const d of debriefs) {
255
+ updatedSituation = integrateDebrief(updatedSituation, d);
256
+ }
257
+
258
+ activePlan = replan(plan, waveSummary, { situation: updatedSituation, result: { depth: plan._depth || 'full' } });
259
+ loopState.activePlan = activePlan;
260
+ loopState.replans++;
261
+ }
262
+
263
+ // Find next wave to dispatch
264
+ const nextWave = activePlan.waves.find(w => !loopState.completedWaves.includes(w.id));
265
+
266
+ if (!nextWave) {
267
+ // All waves done — synthesize
268
+ const finalSummary = summarizeWaveOutcome(loopState.debriefs);
269
+
270
+ // Clean expired inbox messages now that all work is complete
271
+ try { purgeInbox(); } catch { /* non-fatal */ }
272
+
273
+ saveLoopState(loopState);
274
+ return {
275
+ phase: 'done',
276
+ action: { type: 'synthesize', mode: 'complete' },
277
+ rationale: `All ${loopState.completedWaves.length} waves complete. ${loopState.replans} replan(s).`,
278
+ plan: activePlan,
279
+ nextDispatch: null,
280
+ waveSummary: finalSummary,
281
+ replanned: needsReplan,
282
+ };
283
+ }
284
+
285
+ // Check gate condition
286
+ if (nextWave.gateCondition) {
287
+ const gateMet = evaluateGate(nextWave.gateCondition, waveSummary, loopState);
288
+ if (!gateMet) {
289
+ saveLoopState(loopState);
290
+ return {
291
+ phase: 'gated',
292
+ action: { type: 'pause', mode: 'gate-unmet' },
293
+ rationale: `Gate condition not met: ${nextWave.gateCondition}`,
294
+ plan: activePlan,
295
+ nextDispatch: null,
296
+ waveSummary,
297
+ gateCondition: nextWave.gateCondition,
298
+ };
299
+ }
300
+ }
301
+
302
+ // Prepare next wave
303
+ const lastDeliberation = { situation: context, result: { depth: activePlan._depth || 'full' } };
304
+ const prepared = prepareWave(nextWave, lastDeliberation, { ...context, priorDebriefs: loopState.debriefs }, loopState);
305
+
306
+ saveLoopState(loopState);
307
+
308
+ return {
309
+ phase: 'dispatch',
310
+ action: { type: 'dispatch', mode: nextWave.phase },
311
+ rationale: `Wave ${loopState.completedWaves.length + 1}/${activePlan.waves.length}: ${nextWave.phase}`,
312
+ plan: activePlan,
313
+ nextDispatch: prepared,
314
+ waveSummary,
315
+ replanned: needsReplan,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Prepare a wave for dispatch: run predictions, generate preventions, check readiness.
321
+ */
322
+ function prepareWave(wave, deliberation, context, loopState) {
323
+ const agents = wave.agents.map(agentSpec => {
324
+ // Predict failure modes for this agent
325
+ const predictions = predictFailureModes(agentSpec, {
326
+ priorDebriefs: context.priorDebriefs || [],
327
+ diagnosticPatterns: getCachedPatterns(),
328
+ files: context.files || [],
329
+ });
330
+
331
+ // Generate prevention instructions
332
+ const preventions = generatePreventions(predictions);
333
+
334
+ // Generate debrief instruction
335
+ const debriefInstruction = generateDebriefInstruction(agentSpec.tier, {
336
+ objective: agentSpec.objective,
337
+ scope: agentSpec.scope,
338
+ });
339
+
340
+ // Score readiness
341
+ const readiness = scoreDispatchReadiness(agentSpec, { waves: [wave] }, predictions);
342
+
343
+ // Check inbox for messages relevant to this worker tier
344
+ const workerInbox = generateInboxBrief(`worker:${agentSpec.tier}`);
345
+
346
+ return {
347
+ ...agentSpec,
348
+ preventions,
349
+ debriefInstruction,
350
+ readiness,
351
+ predictions,
352
+ prompt: buildAgentPrompt(agentSpec, preventions, debriefInstruction, workerInbox),
353
+ };
354
+ });
355
+
356
+ // Store predictions for later evolution
357
+ loopState._lastPredictions = agents.flatMap(a => a.predictions);
358
+
359
+ return {
360
+ waveId: wave.id,
361
+ phase: wave.phase,
362
+ parallel: wave.parallel,
363
+ agents,
364
+ allReady: agents.every(a => a.readiness.ready),
365
+ blockers: agents.flatMap(a => a.readiness.blockers),
366
+ warnings: agents.flatMap(a => a.readiness.warnings),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Build the full prompt for an agent using dispatch envelopes.
372
+ * Workers get understanding (prose preamble), not just instructions.
373
+ */
374
+ function buildAgentPrompt(agentSpec, preventions, debriefInstruction, inboxBrief) {
375
+ const envelope = buildEnvelope(agentSpec, {
376
+ preventions,
377
+ debriefInstruction,
378
+ inboxBrief,
379
+ });
380
+ return envelope.full;
381
+ }
382
+
383
+ /**
384
+ * Evaluate a wave gate condition against the current state.
385
+ */
386
+ function evaluateGate(condition, waveSummary, loopState) {
387
+ const lower = condition.toLowerCase();
388
+
389
+ if (lower.includes('confidence') && lower.includes('above')) {
390
+ const threshold = parseFloat(condition.match(/[\d.]+/)?.[0] || '0.5');
391
+ return (waveSummary.aggregateConfidence || 0) >= threshold;
392
+ }
393
+
394
+ if (lower.includes('no blocker')) {
395
+ return (waveSummary.allBlockers || []).length === 0;
396
+ }
397
+
398
+ if (lower.includes('scope confirmed')) {
399
+ return waveSummary.scopeDelta === 'same' || waveSummary.scopeDelta === 'smaller';
400
+ }
401
+
402
+ // Default: gate passes
403
+ return true;
404
+ }
405
+
406
+ // ── Immersion helpers ──────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Evolve HEAD's running narrative after processing a turn.
410
+ * Captures: what happened, what was decided, what the user cared about.
411
+ */
412
+ function _evolveNarrative(turn, userMessage, context) {
413
+ const parts = [];
414
+
415
+ // What the user said (compressed)
416
+ const userSnippet = userMessage.length > 120 ? userMessage.slice(0, 120) + '...' : userMessage;
417
+ parts.push(`User: "${userSnippet}"`);
418
+
419
+ // What HEAD decided
420
+ const action = turn.action;
421
+ if (action.type === 'dispatch') {
422
+ parts.push(`Decision: dispatch ${action.mode || 'work'} (confidence: ${turn.result.confidence.score})`);
423
+ } else if (action.type === 'respond') {
424
+ parts.push(`Decision: respond directly (${turn.depth} depth)`);
425
+ } else if (action.type === 'clarify') {
426
+ parts.push(`Decision: need clarification`);
427
+ }
428
+
429
+ // Noticings worth remembering
430
+ if (turn.result.surfaceNoticings?.length) {
431
+ parts.push(`Noticed: ${turn.result.surfaceNoticings.join('; ')}`);
432
+ }
433
+
434
+ // Crystallized simmer items surfaced this turn
435
+ if (context._crystallizedIdeas?.length) {
436
+ parts.push(`Crystallized ideas surfaced: ${context._crystallizedIdeas.join('; ')}`);
437
+ }
438
+
439
+ narrative.evolve(parts.join('. '));
440
+ }
441
+
442
+ /**
443
+ * Capture signals from user message that might be simmer-worthy.
444
+ * Looks for: analogies, "what if" ideas, vague suggestions, meta-observations.
445
+ */
446
+ function _captureSimmerSignals(userMessage, turn) {
447
+ const lower = userMessage.toLowerCase();
448
+
449
+ // Skip short/command messages
450
+ if (userMessage.length < 30) return;
451
+
452
+ // Detect idea-like patterns
453
+ const ideaPatterns = [
454
+ /what if (.{10,80})/i,
455
+ /maybe (?:we |it |this )(.{10,80})/i,
456
+ /i feel like (.{10,80})/i,
457
+ /(?:like|similar to) (.{10,80}?)(?: - | — |\.|\?|$)/i,
458
+ /consider (.{10,80})/i,
459
+ ];
460
+
461
+ for (const pattern of ideaPatterns) {
462
+ const match = userMessage.match(pattern);
463
+ if (match) {
464
+ simmer.add(match[0].slice(0, 120), { origin: 'user-message' });
465
+ return; // One simmer per message max
466
+ }
467
+ }
468
+
469
+ // If the message is exploratory (questions about approach) and depth is "full" or "deep",
470
+ // the whole message might be worth simmering
471
+ if (turn.depth === 'deep' && /\?/.test(userMessage) && userMessage.length > 60) {
472
+ const isExploration = /how|should|could|what about|think/i.test(lower);
473
+ if (isExploration) {
474
+ simmer.add(userMessage.slice(0, 150), { origin: 'exploratory-question', initialHeat: 1.5 });
475
+ }
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Post-wave narrative reflection — called after advance() processes debriefs.
481
+ * This is the verbal self-reflection piece (Reflexion pattern).
482
+ */
483
+ function _postWaveReflection(waveSummary, loopState) {
484
+ const parts = [];
485
+
486
+ parts.push(`Wave ${loopState.completedWaves.length} complete.`);
487
+
488
+ if (waveSummary.aggregateConfidence != null) {
489
+ parts.push(`Confidence: ${waveSummary.aggregateConfidence.toFixed(2)}`);
490
+ }
491
+
492
+ if (waveSummary.allBlockers?.length) {
493
+ parts.push(`Blockers emerged: ${waveSummary.allBlockers.join('; ')}`);
494
+ }
495
+
496
+ if (waveSummary.scopeDelta && waveSummary.scopeDelta !== 'same') {
497
+ parts.push(`Scope shifted: ${waveSummary.scopeDelta}`);
498
+ }
499
+
500
+ if (loopState.replans > 0) {
501
+ parts.push(`Replanned ${loopState.replans} time(s) — adapting to reality.`);
502
+ }
503
+
504
+ narrative.evolve(parts.join(' '));
505
+ }
506
+
507
+ // ── Query functions ─────────────────────────────────────────────────────────
508
+
509
+ export function getActivePlan() {
510
+ return loadLoopState().activePlan;
511
+ }
512
+
513
+ export function getLoopStatus() {
514
+ const state = loadLoopState();
515
+ return {
516
+ hasActivePlan: !!state.activePlan,
517
+ completedWaves: state.completedWaves.length,
518
+ totalWaves: state.activePlan?.waves?.length || 0,
519
+ replans: state.replans,
520
+ totalDispatches: state.totalDispatches,
521
+ debriefCount: state.debriefs.length,
522
+ };
523
+ }
524
+
525
+ export function resetLoop() {
526
+ saveLoopState(freshLoopState());
527
+ releaseSession();
528
+ }
529
+
530
+ export function shutdown() {
531
+ releaseSession();
532
+ }
@@ -54,13 +54,13 @@ export function generateHandoff(sessionState) {
54
54
  // ─── Handoff persistence ──────────────────────────────────────────────────────
55
55
 
56
56
  /**
57
- * Persist a handoff object to .dual-brain/handoffs/.
57
+ * Persist a handoff object to .dualbrain/handoffs/.
58
58
  * @param {object} handoff Result of generateHandoff()
59
59
  * @param {string} [cwd] Project root (defaults to process.cwd())
60
60
  * @returns {string} Absolute path of the written file
61
61
  */
62
62
  export function saveHandoff(handoff, cwd) {
63
- const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
63
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'handoffs');
64
64
  mkdirSync(dir, { recursive: true });
65
65
  const filename = `handoff-${Date.now()}.json`;
66
66
  writeFileSync(join(dir, filename), JSON.stringify(handoff, null, 2));
@@ -68,13 +68,13 @@ export function saveHandoff(handoff, cwd) {
68
68
  }
69
69
 
70
70
  /**
71
- * Load the most recent handoff from .dual-brain/handoffs/.
71
+ * Load the most recent handoff from .dualbrain/handoffs/.
72
72
  * Returns null when no handoffs exist or all are unreadable.
73
73
  * @param {string} [cwd]
74
74
  * @returns {object|null}
75
75
  */
76
76
  export function getLatestHandoff(cwd) {
77
- const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
77
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'handoffs');
78
78
  if (!existsSync(dir)) return null;
79
79
  const files = readdirSync(dir)
80
80
  .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
@@ -196,7 +196,7 @@ export function buildResumeBrief(cwd) {
196
196
  * @returns {number} Count of files pruned
197
197
  */
198
198
  export function pruneHandoffs(cwd, keep = 10) {
199
- const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
199
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'handoffs');
200
200
  if (!existsSync(dir)) return 0;
201
201
  const files = readdirSync(dir)
202
202
  .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
@@ -227,7 +227,7 @@ export function pruneHandoffs(cwd, keep = 10) {
227
227
  * }}
228
228
  */
229
229
  export function extractRoutingPatterns(cwd) {
230
- const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
230
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'handoffs');
231
231
  if (!existsSync(dir)) return { patterns: [], confidence: 0, sampleSize: 0 };
232
232
 
233
233
  const files = readdirSync(dir)
@@ -1,4 +1,4 @@
1
- // cost-tracker.mjs — Lightweight cost estimation and efficiency tracking for .dual-brain/costs.jsonl.
1
+ // cost-tracker.mjs — Lightweight cost estimation and efficiency tracking for .dualbrain/costs.jsonl.
2
2
 
3
3
  import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
@@ -21,7 +21,7 @@ export function estimateTokenCost(model, tokens) {
21
21
 
22
22
  export function trackCost(action, cwd = process.cwd()) {
23
23
  try {
24
- const dir = join(cwd, '.dual-brain');
24
+ const dir = join(cwd, '.dualbrain');
25
25
  mkdirSync(dir, { recursive: true });
26
26
  const entry = {
27
27
  timestamp: new Date().toISOString(),
@@ -41,7 +41,7 @@ export function trackCost(action, cwd = process.cwd()) {
41
41
  }
42
42
 
43
43
  function readCostLines(cwd) {
44
- const p = join(cwd, '.dual-brain', 'costs.jsonl');
44
+ const p = join(cwd, '.dualbrain', 'costs.jsonl');
45
45
  if (!existsSync(p)) return [];
46
46
  try {
47
47
  return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).flatMap(line => {