dual-brain 0.2.15 → 0.2.16

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.
@@ -504,29 +504,48 @@ async function cmdGo(args, opts = {}) {
504
504
  } catch { /* non-fatal */ }
505
505
  }
506
506
 
507
- // ── Cognitive loop: enhance prompt with debrief + preventions if available ──
507
+ // ── Cognitive loop: drive dispatch decisions ──────────────────────────────
508
508
  let loopEnhancedPrompt = prompt;
509
509
  let loopDispatchMeta = null;
510
510
  try {
511
511
  const cogLoop = await _getCognitiveLoop();
512
512
  if (cogLoop) {
513
513
  const loopResult = cogLoop.enter(prompt, { files });
514
+
515
+ if (loopResult.phase === 'readonly') {
516
+ console.log('\n⚠ Another dual-brain session is active. This session is read-only.');
517
+ return;
518
+ }
519
+
514
520
  if (loopResult.phase === 'dispatch' && loopResult.nextDispatch) {
515
521
  loopDispatchMeta = loopResult;
516
- // Append debrief instructions and preventions from first agent to prompt
522
+ // Use the full envelope prompt (includes context, preventions, debrief)
517
523
  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
- }
524
+ if (firstAgent?.prompt) {
525
+ loopEnhancedPrompt = firstAgent.prompt;
525
526
  }
526
527
  if (verbose && loopResult.plan) {
527
528
  const wc = loopResult.plan.waves?.length || 0;
528
529
  console.log(` [cognitive-loop] Plan: ${wc} wave(s), phase: ${loopResult.phase}`);
529
530
  }
531
+ } else if (loopResult.phase === 'blocked') {
532
+ console.log(`\n⚠ Dispatch blocked: ${loopResult.suggestion || 'readiness check failed'}`);
533
+ if (loopResult.surfaceNoticings?.length) {
534
+ loopResult.surfaceNoticings.forEach(n => console.log(` → ${n}`));
535
+ }
536
+ return;
537
+ } else if (loopResult.phase === 'respond') {
538
+ // HEAD decided no dispatch needed — show rationale
539
+ if (loopResult.rationale) console.log(`\n${loopResult.rationale}`);
540
+ if (loopResult.surfaceNoticings?.length) {
541
+ loopResult.surfaceNoticings.forEach(n => console.log(` → ${n}`));
542
+ }
543
+ return;
544
+ }
545
+
546
+ // Surface noticings (includes update notices, diagnostics)
547
+ if (loopResult.surfaceNoticings?.length && verbose) {
548
+ loopResult.surfaceNoticings.forEach(n => console.log(` → ${n}`));
530
549
  }
531
550
  }
532
551
  } catch {
@@ -1,59 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // auto-update-wrapper.mjs — PostToolUse hook (Node.js entry point).
3
- // Runs once per session, checks for dual-brain updates, installs silently.
4
- // The parent exits immediately; all npm work happens in a detached child.
2
+ // auto-update-wrapper.mjs — PostToolUse hook.
3
+ // Checks for updates once per session. If found, installs immediately (no wait,
4
+ // no question) and writes a notice so HEAD can mention it naturally.
5
+ // "Oh, I updated to 0.2.16" — not a prompt, just awareness.
5
6
 
6
- import { spawn } from 'child_process';
7
+ import { spawnSync } from 'child_process';
7
8
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
9
  import { join, dirname, resolve } from 'path';
9
10
  import { fileURLToPath } from 'url';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const WORKSPACE = resolve(__dirname, '..');
13
- const STATE_DIR = join(WORKSPACE, '.dualbrain');
14
- const LOCK_FILE = join(STATE_DIR, '.update-checked');
15
- const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
13
+ const WORKSPACE = resolve(__dirname, '..');
14
+ const STATE_DIR = join(WORKSPACE, '.dualbrain');
15
+ const LOCK_FILE = join(STATE_DIR, '.update-checked');
16
+ const NOTICE_FILE = join(STATE_DIR, '.update-notice');
17
+ const SESSION_TTL = 30 * 60 * 1000; // Once per 30 min session
16
18
 
17
- // ── 1. Already checked recently? ─────────────────────────────────────────────
19
+ // ── 1. Already checked this session? ─────────────────────────────────────────
18
20
  if (existsSync(LOCK_FILE)) {
19
21
  try {
20
22
  const lastCheck = parseInt(readFileSync(LOCK_FILE, 'utf8').trim(), 10);
21
- if (Number.isFinite(lastCheck) && Date.now() - lastCheck < TWENTY_FOUR_HOURS) {
23
+ if (Number.isFinite(lastCheck) && Date.now() - lastCheck < SESSION_TTL) {
24
+ // Output empty JSON (no hook action)
25
+ process.stdout.write('{}\n');
22
26
  process.exit(0);
23
27
  }
24
- } catch {
25
- // Corrupt lock file — proceed with check
26
- }
28
+ } catch {}
27
29
  }
28
30
 
29
- // ── 2. Write lock BEFORE spawning (prevents concurrent session races) ─────────
31
+ // ── 2. Mark as checked ───────────────────────────────────────────────────────
30
32
  try {
31
33
  mkdirSync(STATE_DIR, { recursive: true });
32
34
  writeFileSync(LOCK_FILE, String(Date.now()));
33
35
  } catch {
34
- process.exit(0); // Can't write state — bail silently
36
+ process.stdout.write('{}\n');
37
+ process.exit(0);
35
38
  }
36
39
 
37
- // ── 3. Resolve local version ─────────────────────────────────────────────────
40
+ // ── 3. Get local version ─────────────────────────────────────────────────────
38
41
  let localVersion = '';
39
42
  try {
40
43
  const pkg = JSON.parse(readFileSync(join(WORKSPACE, 'package.json'), 'utf8'));
41
44
  localVersion = pkg.version || '';
42
45
  } catch {
46
+ process.stdout.write('{}\n');
43
47
  process.exit(0);
44
48
  }
45
49
 
46
- if (!localVersion) process.exit(0);
47
-
48
- // ── 4. Detach background worker — parent returns immediately ─────────────────
49
- // The worker script is inlined as a node -e string to avoid needing a temp file.
50
- const workerScript = `
51
- import { spawnSync } from 'child_process';
52
- import { readFileSync } from 'fs';
53
-
54
- const localVersion = ${JSON.stringify(localVersion)};
50
+ if (!localVersion) {
51
+ process.stdout.write('{}\n');
52
+ process.exit(0);
53
+ }
55
54
 
56
- // 3-second timeout npm check
55
+ // ── 4. Check registry (fast, 3s timeout) ─────────────────────────────────────
57
56
  const npmResult = spawnSync('npm', ['view', 'dual-brain', 'version'], {
58
57
  encoding: 'utf8',
59
58
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -61,42 +60,41 @@ const npmResult = spawnSync('npm', ['view', 'dual-brain', 'version'], {
61
60
  });
62
61
 
63
62
  const latestVersion = (npmResult.stdout || '').trim();
64
- if (!latestVersion) process.exit(0);
65
-
66
- // Compare: is latest strictly greater than local?
67
- function versionGt(a, b) {
68
- const ap = a.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
69
- const bp = b.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
70
- const len = Math.max(ap.length, bp.length);
71
- for (let i = 0; i < len; i++) {
72
- const diff = (bp[i] || 0) - (ap[i] || 0);
73
- if (diff !== 0) return diff > 0;
74
- }
75
- return false;
63
+ if (!latestVersion || !_isNewer(localVersion, latestVersion)) {
64
+ process.stdout.write('{}\n');
65
+ process.exit(0);
76
66
  }
77
67
 
78
- if (!versionGt(localVersion, latestVersion)) process.exit(0);
68
+ // ── 5. Install immediately — no detach, no background, just do it ────────────
69
+ process.stderr.write(`[dual-brain] updating ${localVersion} → ${latestVersion}\n`);
79
70
 
80
- // Newer version found print notice then install
81
- process.stderr.write('dual-brain: updating v' + localVersion + ' → ' + latestVersion + '...\\n');
82
-
83
- spawnSync('npx', ['-y', 'dual-brain@latest', '--quiet'], {
84
- stdio: 'ignore',
85
- timeout: 120000,
71
+ const installResult = spawnSync('npm', ['install', '-g', `dual-brain@${latestVersion}`], {
72
+ encoding: 'utf8',
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ timeout: 30000,
86
75
  });
87
- `;
88
76
 
89
- const child = spawn(
90
- process.execPath,
91
- ['--input-type=module'],
92
- {
93
- detached: true,
94
- stdio: ['pipe', 'ignore', 'ignore'],
95
- }
96
- );
97
-
98
- child.stdin.write(workerScript);
99
- child.stdin.end();
100
- child.unref(); // Let parent exit without waiting
77
+ if (installResult.status === 0) {
78
+ // Write notice for HEAD to pick up
79
+ const notice = { from: localVersion, to: latestVersion, ts: Date.now() };
80
+ writeFileSync(NOTICE_FILE, JSON.stringify(notice));
81
+ process.stderr.write(`[dual-brain] updated to ${latestVersion}\n`);
82
+ } else {
83
+ process.stderr.write(`[dual-brain] update failed, continuing with ${localVersion}\n`);
84
+ }
101
85
 
86
+ process.stdout.write('{}\n');
102
87
  process.exit(0);
88
+
89
+ // ── Helpers ──────────────────────────────────────────────────────────────────
90
+
91
+ function _isNewer(local, remote) {
92
+ const lp = local.split('.').map(Number);
93
+ const rp = remote.split('.').map(Number);
94
+ for (let i = 0; i < Math.max(lp.length, rp.length); i++) {
95
+ const diff = (rp[i] || 0) - (lp[i] || 0);
96
+ if (diff > 0) return true;
97
+ if (diff < 0) return false;
98
+ }
99
+ return false;
100
+ }
@@ -285,7 +285,7 @@ function detectStuckLoop(state) {
285
285
  }
286
286
 
287
287
  for (const [sig, count] of signatures) {
288
- if (count >= 3) {
288
+ if (count >= 5) {
289
289
  return {
290
290
  ts: Date.now(),
291
291
  type: 'stuck-loop',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,9 @@ export function enter(userMessage, context = {}) {
86
86
  const headState = loadState();
87
87
  const loopState = loadLoopState();
88
88
 
89
+ // Check if we just auto-updated — surface it naturally
90
+ _checkUpdateNotice(context);
91
+
89
92
  // Immersion: load memory tiers so HEAD is "in the song"
90
93
  const memory = memoryTiers.assemble({
91
94
  userMessage,
@@ -119,6 +122,12 @@ export function enter(userMessage, context = {}) {
119
122
  confidence: turn.result.confidence.score,
120
123
  });
121
124
 
125
+ // Surface update notice as a noticing
126
+ if (context._updateNotice) {
127
+ turn.result.surfaceNoticings = turn.result.surfaceNoticings || [];
128
+ turn.result.surfaceNoticings.unshift(context._updateNotice);
129
+ }
130
+
122
131
  // Narrative evolution: update HEAD's running understanding
123
132
  _evolveNarrative(turn, userMessage, context);
124
133
 
@@ -504,6 +513,22 @@ function _postWaveReflection(waveSummary, loopState) {
504
513
  narrative.evolve(parts.join(' '));
505
514
  }
506
515
 
516
+ /**
517
+ * Check for update notice and surface it to HEAD's awareness.
518
+ */
519
+ function _checkUpdateNotice(context) {
520
+ try {
521
+ const noticeFile = join(LOOP_STATE_DIR, '.update-notice');
522
+ if (!existsSync(noticeFile)) return;
523
+ const notice = JSON.parse(readFileSync(noticeFile, 'utf8'));
524
+ if (Date.now() - notice.ts < 5 * 60 * 1000) {
525
+ context._updateNotice = `Updated dual-brain ${notice.from} → ${notice.to}`;
526
+ }
527
+ // Clear it after reading (one-shot)
528
+ try { writeFileSync(noticeFile, ''); } catch {}
529
+ } catch {}
530
+ }
531
+
507
532
  // ── Query functions ─────────────────────────────────────────────────────────
508
533
 
509
534
  export function getActivePlan() {
@@ -32,13 +32,13 @@ import { join } from 'node:path';
32
32
  */
33
33
  export function generateHandoff(sessionState) {
34
34
  return {
35
- version: 1,
35
+ version: 2,
36
36
  timestamp: new Date().toISOString(),
37
37
  task: sessionState.taskDescription || null,
38
38
  progress: {
39
39
  filesChanged: (sessionState.filesChanged || []).slice(0, 20),
40
40
  testsRun: sessionState.testsRun || [],
41
- decisions: (sessionState.decisions || []).slice(0, 5), // most recent routing decisions
41
+ decisions: (sessionState.decisions || []).slice(0, 5),
42
42
  },
43
43
  unresolved: (sessionState.unresolved || []).slice(0, 5),
44
44
  routing: {
@@ -48,6 +48,7 @@ export function generateHandoff(sessionState) {
48
48
  },
49
49
  preferences: sessionState.activePreferences || [],
50
50
  resumeHint: sessionState.resumeHint || null,
51
+ narrative: sessionState.narrative || null,
51
52
  };
52
53
  }
53
54