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/src/head.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { readDiagnosticNoticings } from '../hooks/diagnostic-companion.mjs';
3
4
 
4
5
  const STATE_DIR = join(process.cwd(), '.dualbrain');
5
6
  const STATE_FILE = join(STATE_DIR, 'head-state.json');
@@ -62,9 +63,14 @@ export function assessDepth(signals) {
62
63
  score += (LEVEL_SCORES[level] || 0) * cfg.weight;
63
64
  }
64
65
 
65
- if (score <= 4) return 'reflexive';
66
- if (score <= 12) return 'light';
67
- if (score <= 22) return 'full';
66
+ // Task type floor: work requests are never reflexive
67
+ const taskType = signals.taskShape?.type || signals.type;
68
+ const isWorkRequest = ['edit', 'debug', 'review', 'research'].includes(taskType);
69
+ if (isWorkRequest && score < 3) score = 3;
70
+
71
+ if (score <= 2) return 'reflexive';
72
+ if (score <= 8) return 'light';
73
+ if (score <= 18) return 'full';
68
74
  return 'deep';
69
75
  }
70
76
 
@@ -123,7 +129,9 @@ function _inferTaskShape(message, context) {
123
129
  const lower = message.toLowerCase();
124
130
 
125
131
  // Scope: how big is this?
126
- const fileCount = context.files?.length || 0;
132
+ const files = context.files || [];
133
+ const dirCount = files.filter(f => f.endsWith('/') || !f.includes('.')).length;
134
+ const fileCount = files.length + (dirCount * 4); // directories imply multiple files
127
135
  const scope = fileCount > 5 ? 'large' : fileCount > 2 ? 'medium' : lower.length > 500 ? 'medium' : 'small';
128
136
 
129
137
  // Risk: what could go wrong?
@@ -148,7 +156,9 @@ function _inferTaskShape(message, context) {
148
156
  if (/\b(maybe|might|could|should we|not sure|thinking about|what if|somehow)\b/i.test(message)) ambiguitySignals.push('hedging-language');
149
157
  if (/\b(or|versus|vs|either|option|alternative)\b/i.test(message)) ambiguitySignals.push('considering-alternatives');
150
158
  if (message.split('?').length > 2) ambiguitySignals.push('multiple-questions');
151
- if (!context.files?.length && /\b(it|this|that|these|those)\b/i.test(message) && !context.recentFiles?.length) ambiguitySignals.push('vague-reference');
159
+ if (!context.files?.length && /\b(it|this|that|these|those)\b/i.test(message) && !context.recentFiles?.length && !/\?\s*$/.test(message.trim())) ambiguitySignals.push('vague-reference');
160
+ if (/\b(everything|all|entire|whole|every)\b/i.test(message)) ambiguitySignals.push('unbounded-scope');
161
+ if (/\b(better|improve|enhance|optimize|clean up)\b/i.test(message) && !context.files?.length) ambiguitySignals.push('vague-goal');
152
162
 
153
163
  const ambiguity = ambiguitySignals.length >= 2 ? 'high' : ambiguitySignals.length >= 1 ? 'medium' : 'low';
154
164
 
@@ -250,78 +260,32 @@ function _assessRelationship(message, context, taskShape) {
250
260
  * and what would change HEAD's mind.
251
261
  */
252
262
  export function assessUncertainty(situation, context = {}) {
253
- const ledger = [];
254
-
255
- // Scope certainty
256
- if (situation.taskShape.scope !== 'small') {
257
- ledger.push({
258
- claim: 'The change is contained to the identified files',
259
- confidence: situation.material.touchedFiles.length > 0 ? 0.7 : 0.3,
260
- basis: situation.material.touchedFiles.length > 0
261
- ? `${situation.material.touchedFiles.length} files identified`
262
- : 'No files explicitly identified scope unknown',
263
- wouldChangeIf: 'Dependency analysis reveals cross-cutting concerns',
264
- });
265
- }
266
-
267
- // Risk assessment certainty
268
- if (situation.taskShape.risk !== 'low') {
269
- ledger.push({
270
- claim: `Risk level is ${situation.taskShape.risk}`,
271
- confidence: situation.taskShape.riskSignals.length >= 2 ? 0.85 : 0.6,
272
- basis: `Signals: ${situation.taskShape.riskSignals.join(', ') || 'none'}`,
273
- wouldChangeIf: 'Closer inspection reveals the risky-looking code is actually isolated/tested',
274
- });
275
- }
276
-
277
- // Goal understanding certainty
278
- if (situation.inferredGoal) {
279
- ledger.push({
280
- claim: 'I understand what the user actually needs',
281
- confidence: 0.5,
282
- basis: `Explicit: "${situation.explicitAsk.slice(0, 80)}" but inferred: "${situation.inferredGoal}"`,
283
- wouldChangeIf: 'User clarifies their actual goal',
284
- });
285
- } else if (situation.taskShape.ambiguity === 'high') {
286
- ledger.push({
287
- claim: 'I understand the request',
288
- confidence: 0.4,
289
- basis: `Ambiguity signals: ${situation.taskShape.ambiguitySignals.join(', ')}`,
290
- wouldChangeIf: 'User provides more specific criteria or constraints',
291
- });
292
- }
293
-
294
- // Prior failure uncertainty
295
- if (situation.priorFailures >= 1) {
296
- ledger.push({
297
- claim: 'The same approach will work this time',
298
- confidence: Math.max(0.1, 0.8 - (situation.priorFailures * 0.25)),
299
- basis: `${situation.priorFailures} prior failure(s) on similar task`,
300
- wouldChangeIf: 'A fundamentally different approach is tried',
301
- });
302
- }
303
-
304
- // Material safety
305
- if (situation.material.fragileAreas.length > 0) {
306
- ledger.push({
307
- claim: 'Changes won\'t break existing functionality',
308
- confidence: 0.5,
309
- basis: `Fragile areas: ${situation.material.fragileAreas.map(a => a.file).join(', ')}`,
310
- wouldChangeIf: 'Tests exist and pass for the affected areas',
311
- });
312
- }
313
-
314
- // Context freshness
315
- if (context.contextAge === 'stale') {
316
- ledger.push({
317
- claim: 'My understanding of the codebase is current',
318
- confidence: 0.3,
319
- basis: 'Context may be outdated — files could have changed since last read',
320
- wouldChangeIf: 'Fresh file reads confirm current state',
321
- });
322
- }
323
-
324
- return ledger;
263
+ let score = 0.8; // default: reasonably confident
264
+ let blocker = null;
265
+ let shouldVerify = false;
266
+
267
+ // Confidence drops
268
+ if (situation.taskShape.ambiguity === 'high') score -= 0.3;
269
+ else if (situation.taskShape.ambiguity === 'medium') score -= 0.1;
270
+
271
+ if (situation.priorFailures >= 2) { score -= 0.3; blocker = 'Repeated failures suggest wrong approach'; }
272
+ else if (situation.priorFailures >= 1) score -= 0.15;
273
+
274
+ if (situation.material.fragileAreas.length > 0) { score -= 0.15; shouldVerify = true; }
275
+ if (situation.taskShape.scope === 'large') { score -= 0.1; shouldVerify = true; }
276
+ if (!situation.material.touchedFiles.length && situation.taskShape.type === 'edit') { score -= 0.2; blocker = blocker || 'No files identified for edit task'; }
277
+ if (context.contextAge === 'stale') score -= 0.2;
278
+ if (situation.inferredGoal) score -= 0.15;
279
+
280
+ score = Math.max(0.1, Math.min(1.0, score));
281
+
282
+ // Return same interface summarizeConfidence produces (so callers don't break)
283
+ return [{
284
+ claim: blocker || 'Task assessment',
285
+ confidence: score,
286
+ basis: `score=${score.toFixed(2)}`,
287
+ wouldChangeIf: blocker ? 'Different approach tried' : 'n/a',
288
+ }];
325
289
  }
326
290
 
327
291
  /**
@@ -398,6 +362,30 @@ export function deriveObligations(situation) {
398
362
  return active;
399
363
  }
400
364
 
365
+ // ── Turn history query ────────────────────────────────────────────────────────
366
+
367
+ export function queryRecentTurns(state, n = 3) {
368
+ if (!state.turns?.length) return { count: 0, lastActions: [], failureStreak: 0, sameActionCount: 0 };
369
+
370
+ const recent = state.turns.slice(-n);
371
+ const lastActions = recent.map(t => t.action);
372
+
373
+ // Detect same action repeated
374
+ const lastAction = lastActions[lastActions.length - 1];
375
+ const sameActionCount = lastActions.filter(a => a === lastAction).length;
376
+
377
+ // Detect failure streak (low confidence runs)
378
+ const failureStreak = [...recent].reverse().findIndex(t => t.confidence > 0.6);
379
+
380
+ return {
381
+ count: state.turns.length,
382
+ lastActions,
383
+ failureStreak: failureStreak === -1 ? recent.length : failureStreak,
384
+ sameActionCount,
385
+ avgConfidence: recent.reduce((s, t) => s + (t.confidence || 0), 0) / recent.length,
386
+ };
387
+ }
388
+
401
389
  // ── Noticings: what HEAD observes passively ─────────────────────────────────
402
390
 
403
391
  /**
@@ -408,6 +396,25 @@ export function deriveObligations(situation) {
408
396
  export function notice(situation, state, context = {}) {
409
397
  const noticings = [];
410
398
 
399
+ // Self-awareness: detect stuck patterns from turn history
400
+ const turnHistory = queryRecentTurns(state);
401
+ if (turnHistory.sameActionCount >= 3) {
402
+ noticings.push({
403
+ type: 'self-awareness',
404
+ severity: 'high',
405
+ observation: `Same action "${turnHistory.lastActions[turnHistory.lastActions.length-1]}" repeated ${turnHistory.sameActionCount} times — may be stuck`,
406
+ shouldSurface: true,
407
+ });
408
+ }
409
+ if (turnHistory.failureStreak >= 2) {
410
+ noticings.push({
411
+ type: 'self-awareness',
412
+ severity: 'medium',
413
+ observation: `${turnHistory.failureStreak} turns with low confidence — consider changing approach`,
414
+ shouldSurface: true,
415
+ });
416
+ }
417
+
411
418
  // Drift: are we doing something different from what was discussed?
412
419
  if (state.declaredGoal && situation.inferredGoal && state.declaredGoal !== situation.inferredGoal) {
413
420
  noticings.push({
@@ -483,6 +490,34 @@ export function notice(situation, state, context = {}) {
483
490
  }
484
491
  }
485
492
 
493
+
494
+ // Self-awareness: detect repeated dispatch failures
495
+ if (state.dispatches?.length >= 2) {
496
+ const recent = state.dispatches.slice(-3);
497
+ const failures = recent.filter(d => d.outcome === 'failure');
498
+ if (failures.length >= 2) {
499
+ noticings.push({
500
+ type: 'self-awareness',
501
+ severity: 'high',
502
+ observation: `${failures.length} of last ${recent.length} dispatches failed — approach may need to change`,
503
+ shouldSurface: true,
504
+ });
505
+ }
506
+ }
507
+
508
+ // Diagnostic companion: feed tool-call pattern observations into deliberation
509
+ try {
510
+ const diagnosticNoticings = readDiagnosticNoticings();
511
+ for (const dn of diagnosticNoticings) {
512
+ noticings.push({
513
+ type: 'diagnostic',
514
+ severity: dn.severity || 'medium',
515
+ observation: dn.observation,
516
+ shouldSurface: dn.severity === 'high',
517
+ });
518
+ }
519
+ } catch {}
520
+
486
521
  return noticings;
487
522
  }
488
523
 
@@ -776,6 +811,7 @@ export function freshState() {
776
811
  declaredGoal: null,
777
812
  originalScope: null,
778
813
  turns: [],
814
+ dispatches: [], // { ts, type, objective, outcome, durationMs }
779
815
  contextEstimate: { messages: 0, estimatedTokens: 0 },
780
816
  lastActivity: Date.now(),
781
817
  created: Date.now(),
@@ -788,8 +824,22 @@ export function saveState(state) {
788
824
  writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
789
825
  }
790
826
 
827
+ export function recordDispatchOutcome(state, outcome) {
828
+ if (!state.dispatches) state.dispatches = [];
829
+ state.dispatches.push({
830
+ ts: Date.now(),
831
+ type: outcome.type || 'unknown',
832
+ objective: (outcome.objective || '').slice(0, 100),
833
+ outcome: outcome.status || 'unknown', // 'success' | 'failure' | 'partial'
834
+ durationMs: outcome.durationMs || 0,
835
+ });
836
+ // Keep last 10 dispatches only
837
+ if (state.dispatches.length > 10) state.dispatches = state.dispatches.slice(-10);
838
+ saveState(state);
839
+ }
840
+
791
841
  // ── Exports ─────────────────────────────────────────────────────────────────
792
842
  // Core pipeline: perceive, assessUncertainty, deriveObligations, notice, deliberate
793
- // Convenience: processTurn, assessDepth, summarizeConfidence
843
+ // Convenience: processTurn, assessDepth, summarizeConfidence, queryRecentTurns
794
844
  // State: loadState, freshState, saveState
795
845
  // Values: HEAD_VALUES
package/src/inbox.mjs ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * inbox.mjs — Cross-session signal system for dual-brain orchestrator
3
+ *
4
+ * Agents write messages to .dualbrain/inbox/. HEAD and the cognitive loop
5
+ * check the inbox at entry points. Messages have TTL and recipient types.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { randomUUID } from 'node:crypto';
11
+
12
+ const INBOX_DIR = join(process.cwd(), '.dualbrain', 'inbox');
13
+ const INDEX_PATH = join(INBOX_DIR, '_index.json');
14
+ const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24h
15
+ const MAX_ACTIVE = 50;
16
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
17
+
18
+ function ensureDir() {
19
+ if (!existsSync(INBOX_DIR)) mkdirSync(INBOX_DIR, { recursive: true });
20
+ }
21
+
22
+ function readIndex() {
23
+ try {
24
+ if (existsSync(INDEX_PATH)) return JSON.parse(readFileSync(INDEX_PATH, 'utf8'));
25
+ } catch { /* rebuild */ }
26
+ return rebuildIndex();
27
+ }
28
+
29
+ function rebuildIndex() {
30
+ ensureDir();
31
+ const entries = [];
32
+ for (const f of readdirSync(INBOX_DIR)) {
33
+ if (f === '_index.json' || !f.endsWith('.json')) continue;
34
+ try {
35
+ const msg = JSON.parse(readFileSync(join(INBOX_DIR, f), 'utf8'));
36
+ entries.push({ id: msg.id, to: msg.to, type: msg.type, priority: msg.priority, createdAt: msg.createdAt, expired: Date.now() > msg.createdAt + msg.ttl });
37
+ } catch { /* skip corrupt */ }
38
+ }
39
+ writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2));
40
+ return entries;
41
+ }
42
+
43
+ function writeIndex(entries) {
44
+ ensureDir();
45
+ writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2));
46
+ }
47
+
48
+ function readMessage(id) {
49
+ try {
50
+ return JSON.parse(readFileSync(join(INBOX_DIR, `${id}.json`), 'utf8'));
51
+ } catch { return null; }
52
+ }
53
+
54
+ function matchesRecipient(msgTo, recipient) {
55
+ if (msgTo === 'all' || msgTo === recipient) return true;
56
+ if (msgTo === 'worker:*' && recipient.startsWith('worker:')) return true;
57
+ return false;
58
+ }
59
+
60
+ /** Write a message to the inbox. */
61
+ export function send(partial) {
62
+ if (!partial.to || !partial.type || !partial.subject || !partial.body) {
63
+ throw new Error('inbox.send requires: to, type, subject, body');
64
+ }
65
+ ensureDir();
66
+ const msg = {
67
+ id: randomUUID(),
68
+ from: partial.from || 'system',
69
+ to: partial.to,
70
+ type: partial.type,
71
+ priority: partial.priority || 'medium',
72
+ subject: partial.subject,
73
+ body: partial.body,
74
+ ttl: partial.ttl ?? DEFAULT_TTL,
75
+ createdAt: Date.now(),
76
+ readBy: partial.readBy || [],
77
+ relatedFiles: partial.relatedFiles || [],
78
+ tags: partial.tags || [],
79
+ };
80
+ // Enforce cap — purge oldest if over limit
81
+ const index = readIndex();
82
+ const active = index.filter(e => !e.expired);
83
+ if (active.length >= MAX_ACTIVE) {
84
+ const sorted = [...active].sort((a, b) => a.createdAt - b.createdAt);
85
+ const toRemove = sorted.slice(0, active.length - MAX_ACTIVE + 1);
86
+ for (const e of toRemove) {
87
+ try { unlinkSync(join(INBOX_DIR, `${e.id}.json`)); } catch { /* ok */ }
88
+ }
89
+ const removeIds = new Set(toRemove.map(e => e.id));
90
+ const trimmed = index.filter(e => !removeIds.has(e.id));
91
+ trimmed.push({ id: msg.id, to: msg.to, type: msg.type, priority: msg.priority, createdAt: msg.createdAt, expired: false });
92
+ writeIndex(trimmed);
93
+ } else {
94
+ index.push({ id: msg.id, to: msg.to, type: msg.type, priority: msg.priority, createdAt: msg.createdAt, expired: false });
95
+ writeIndex(index);
96
+ }
97
+ writeFileSync(join(INBOX_DIR, `${msg.id}.json`), JSON.stringify(msg, null, 2));
98
+ return msg;
99
+ }
100
+
101
+ /** Read messages for a recipient. */
102
+ export function check(recipient, options = {}) {
103
+ const { unreadOnly = false, types, minPriority, limit } = options;
104
+ const index = readIndex();
105
+ const now = Date.now();
106
+ const minP = minPriority ? PRIORITY_ORDER[minPriority] ?? 3 : 3;
107
+ let results = [];
108
+ for (const entry of index) {
109
+ if (now > entry.createdAt + DEFAULT_TTL) continue; // rough TTL check
110
+ if (!matchesRecipient(entry.to, recipient)) continue;
111
+ if (types && !types.includes(entry.type)) continue;
112
+ if ((PRIORITY_ORDER[entry.priority] ?? 3) > minP) continue;
113
+ const msg = readMessage(entry.id);
114
+ if (!msg) continue;
115
+ if (now > msg.createdAt + msg.ttl) continue; // precise TTL
116
+ if (unreadOnly && msg.readBy.includes(recipient)) continue;
117
+ results.push(msg);
118
+ }
119
+ results.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3) || b.createdAt - a.createdAt);
120
+ if (limit) results = results.slice(0, limit);
121
+ return results;
122
+ }
123
+
124
+ /** Mark a message as read by a specific reader. */
125
+ export function markRead(messageId, reader) {
126
+ const path = join(INBOX_DIR, `${messageId}.json`);
127
+ const msg = readMessage(messageId);
128
+ if (!msg) return;
129
+ if (!msg.readBy.includes(reader)) {
130
+ msg.readBy.push(reader);
131
+ writeFileSync(path, JSON.stringify(msg, null, 2));
132
+ }
133
+ }
134
+
135
+ /** Clean up expired and fully-read messages. */
136
+ export function purge(options = {}) {
137
+ const { purgeRead = false } = options;
138
+ const index = readIndex();
139
+ const now = Date.now();
140
+ let expired = 0, read = 0;
141
+ const keep = [];
142
+ for (const entry of index) {
143
+ const msg = readMessage(entry.id);
144
+ if (!msg) continue;
145
+ if (now > msg.createdAt + msg.ttl) {
146
+ try { unlinkSync(join(INBOX_DIR, `${entry.id}.json`)); } catch { /* ok */ }
147
+ expired++;
148
+ continue;
149
+ }
150
+ if (purgeRead && msg.readBy.length > 0 && msg.to !== 'all') {
151
+ try { unlinkSync(join(INBOX_DIR, `${entry.id}.json`)); } catch { /* ok */ }
152
+ read++;
153
+ continue;
154
+ }
155
+ keep.push(entry);
156
+ }
157
+ writeIndex(keep);
158
+ return { expired, read };
159
+ }
160
+
161
+ /** Produce a concise text summary for prompt injection. */
162
+ export function generateInboxBrief(recipient) {
163
+ const msgs = check(recipient, { unreadOnly: true, limit: 5 });
164
+ if (!msgs.length) return '';
165
+ const lines = [`\u{1F4EC} Inbox (${msgs.length} unread):`];
166
+ let len = lines[0].length;
167
+ for (const m of msgs) {
168
+ const line = `• [${m.priority}] ${m.from !== 'system' ? `From ${m.from}: ` : ''}${m.subject}`;
169
+ if (len + line.length > 480) { lines.push('• ...'); break; }
170
+ lines.push(line);
171
+ len += line.length;
172
+ }
173
+ return lines.join('\n');
174
+ }
175
+
176
+ /** Convenience: send a continuation message when session ends. */
177
+ export function sendContinuation(context) {
178
+ return send({
179
+ from: context.from || 'head',
180
+ to: 'session:next',
181
+ type: 'continuation',
182
+ priority: 'high',
183
+ subject: context.subject || 'Session continuation state',
184
+ body: typeof context.body === 'string' ? context.body : JSON.stringify(context.state || context, null, 2),
185
+ relatedFiles: context.relatedFiles || [],
186
+ tags: ['continuation', ...(context.tags || [])],
187
+ ttl: context.ttl ?? DEFAULT_TTL * 3, // 72h for continuations
188
+ });
189
+ }
190
+
191
+ /** Convenience: check for the most recent unread continuation. */
192
+ export function checkContinuation(reader = 'head') {
193
+ const msgs = check('session:next', { unreadOnly: true, types: ['continuation'], limit: 1 });
194
+ return msgs.length ? msgs[0] : null;
195
+ }
package/src/ledger.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
4
4
  import { join } from 'path';
5
5
 
6
- const LEDGER_PATH = '.dual-brain/ledger.jsonl';
6
+ const LEDGER_PATH = '.dualbrain/ledger.jsonl';
7
7
 
8
8
  function ledgerPath(cwd) {
9
9
  return join(cwd || process.cwd(), LEDGER_PATH);
@@ -19,7 +19,7 @@ function readAllEntries(cwd) {
19
19
 
20
20
  function appendEntry(entry, cwd) {
21
21
  const p = ledgerPath(cwd);
22
- mkdirSync(join(cwd || process.cwd(), '.dual-brain'), { recursive: true });
22
+ mkdirSync(join(cwd || process.cwd(), '.dualbrain'), { recursive: true });
23
23
  writeFileSync(p, JSON.stringify(entry) + '\n', { flag: 'a' });
24
24
  }
25
25
 
@@ -1,11 +1,11 @@
1
- // living-docs.mjs — Living document system for .dual-brain/.
1
+ // living-docs.mjs — Living document system for .dualbrain/.
2
2
  // Manages project.json, vision.md, roadmap.md, state.md, actions.jsonl, decisions.jsonl, checkpoints.jsonl.
3
3
 
4
4
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { execSync } from 'node:child_process';
7
7
 
8
- const DIR = '.dual-brain';
8
+ const DIR = '.dualbrain';
9
9
 
10
10
  function docsDir(cwd = process.cwd()) {
11
11
  return join(cwd, DIR);