dual-brain 0.2.28 → 0.2.30

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.
@@ -1106,6 +1106,29 @@ async function cmdStatus(args = []) {
1106
1106
  if (rec.action) console.log(` → ${rec.action}`);
1107
1107
  }
1108
1108
  } catch { /* non-blocking */ }
1109
+
1110
+ // Intelligence layer status
1111
+ try {
1112
+ const { getRoutingStats } = await import('../src/routing-advisor.mjs');
1113
+ const { getThinkingStats } = await import('../src/think-engine.mjs');
1114
+ const stats = getRoutingStats(cwd);
1115
+ const thinkStats = getThinkingStats(cwd);
1116
+
1117
+ if (stats.totalObservations > 0 || thinkStats.totalDecisions > 0) {
1118
+ console.log('');
1119
+ console.log(' \x1b[2m─── Intelligence ───\x1b[0m');
1120
+ if (stats.totalObservations > 0) {
1121
+ console.log(` Routing: ${stats.totalObservations} observations, learning ${stats.totalObservations >= 5 ? 'active' : 'warming up'}`);
1122
+ if (stats.topPerformers?.length > 0) {
1123
+ const top = stats.topPerformers[0];
1124
+ console.log(` Best: ${top.model} on ${top.cell} (${(top.ema * 100).toFixed(0)}% EMA, n=${top.observations})`);
1125
+ }
1126
+ }
1127
+ if (thinkStats.totalDecisions > 0) {
1128
+ console.log(` Think: ${thinkStats.totalDecisions} decisions, ${(thinkStats.cacheHitRate * 100).toFixed(0)}% cache hit rate`);
1129
+ }
1130
+ }
1131
+ } catch { /* non-blocking */ }
1109
1132
  }
1110
1133
 
1111
1134
  // ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
@@ -6510,6 +6533,12 @@ async function main() {
6510
6533
  const args = process.argv.slice(2);
6511
6534
  const cmd = args[0];
6512
6535
 
6536
+ // Session start marker — feeds routing advisor with cross-session timing signals
6537
+ try {
6538
+ const { markSessionStart } = await import('../src/routing-advisor.mjs');
6539
+ markSessionStart(process.cwd());
6540
+ } catch { /* non-blocking */ }
6541
+
6513
6542
  if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
6514
6543
  if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
6515
6544
 
@@ -6651,13 +6680,55 @@ async function main() {
6651
6680
  const { generateRecommendations, formatRecommendations } = await import('../src/recommendations.mjs');
6652
6681
  const recs = generateRecommendations(process.cwd());
6653
6682
  if (recs.length === 0) {
6654
- console.log(' No recommendations yet. Need 20+ dispatches to generate advice.');
6683
+ console.log('');
6684
+ console.log(' \x1b[2m─── HEAD Analysis ───\x1b[0m');
6685
+ console.log('');
6686
+ const { getRoutingStats } = await import('../src/routing-advisor.mjs');
6687
+ const stats = getRoutingStats(process.cwd());
6688
+ if (stats.totalObservations < 20) {
6689
+ console.log(` Need more data: ${stats.totalObservations}/20 observations before recommendations.`);
6690
+ console.log(` Keep dispatching — the system learns from every task.`);
6691
+ } else {
6692
+ console.log(' No recommendations — current configuration is performing well.');
6693
+ }
6694
+ console.log('');
6655
6695
  } else {
6656
6696
  console.log(formatRecommendations(recs));
6657
6697
  }
6658
6698
  return;
6659
6699
  }
6660
6700
 
6701
+ if (cmd === 'stats' || cmd === 'intelligence') {
6702
+ const { getRoutingStats } = await import('../src/routing-advisor.mjs');
6703
+ const { getThinkingStats } = await import('../src/think-engine.mjs');
6704
+ const stats = getRoutingStats(process.cwd());
6705
+ const thinkStats = getThinkingStats(process.cwd());
6706
+
6707
+ console.log('');
6708
+ console.log(' \x1b[1mdual-brain intelligence report\x1b[0m');
6709
+ console.log('');
6710
+ console.log(` Routing observations: ${stats.totalObservations}`);
6711
+ if (stats.topPerformers?.length > 0) {
6712
+ console.log(' Top performers:');
6713
+ for (const p of stats.topPerformers.slice(0, 5)) {
6714
+ console.log(` ${p.cell} → ${p.model} (${(p.ema * 100).toFixed(0)}%, n=${p.observations})`);
6715
+ }
6716
+ }
6717
+ if (stats.worstPerformers?.length > 0) {
6718
+ console.log(' Underperformers:');
6719
+ for (const p of stats.worstPerformers.slice(0, 3)) {
6720
+ console.log(` ${p.cell} → ${p.model} (${(p.ema * 100).toFixed(0)}%, n=${p.observations})`);
6721
+ }
6722
+ }
6723
+ console.log('');
6724
+ console.log(` Think decisions: ${thinkStats.totalDecisions}`);
6725
+ console.log(` Cache hit rate: ${(thinkStats.cacheHitRate * 100).toFixed(0)}%`);
6726
+ console.log(` Tokens saved: ~${(thinkStats.totalTokensSaved / 1000).toFixed(0)}K`);
6727
+ console.log(` Tier distribution: recall=${thinkStats.tierDistribution.recall}, quick=${thinkStats.tierDistribution.quick}, standard=${thinkStats.tierDistribution.standard}, deep=${thinkStats.tierDistribution.deep}, ultra=${thinkStats.tierDistribution.ultra}`);
6728
+ console.log('');
6729
+ return;
6730
+ }
6731
+
6661
6732
  if (cmd === 'revert' || cmd === 'undo') {
6662
6733
  const { runRevert } = await import('../src/revert.mjs');
6663
6734
  await runRevert(process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,8 @@ import { join, resolve } from 'node:path';
3
3
 
4
4
  export const MODEL_FORMAT = {
5
5
  claude: 'xml', sonnet: 'xml', haiku: 'xml', opus: 'xml',
6
- gpt: 'markdown', o3: 'markdown', 'o4-mini': 'markdown',
6
+ gpt: 'markdown', 'o4-mini': 'markdown',
7
+ o3: 'prose',
7
8
  };
8
9
 
9
10
  function detectFormat(targetModel, role) {
@@ -14,6 +15,7 @@ function detectFormat(targetModel, role) {
14
15
  }
15
16
 
16
17
  export function selectRelevant(pack, role) {
18
+ if (!pack) return { intent: '', constraints: [], acceptanceCriteria: [] };
17
19
  const { intent, constraints, priorAttempts, repoState, fileSummaries,
18
20
  acceptanceCriteria, files } = pack;
19
21
  if (role === 'thinker') {
@@ -134,7 +136,7 @@ export function attachOutputSchema(role) {
134
136
  return 'Return JSON: { pass: boolean, findings: [{ severity, file, line, issue, fix }] }';
135
137
  }
136
138
 
137
- export function shapeForRole(pack, role, targetModel, tokenBudget) {
139
+ export function shapeForRole(pack, role, targetModel = 'sonnet', tokenBudget = 8000) {
138
140
  const sections = selectRelevant(pack, role);
139
141
 
140
142
  if (role === 'worker' && sections.inScope?.length) {
package/src/handoff.mjs CHANGED
@@ -22,10 +22,11 @@ function validate(from, to, data) {
22
22
  export function createHandoff(fromStage, toStage, data, runId, cwd) {
23
23
  try {
24
24
  validate(fromStage, toStage, data);
25
+ const safeRunId = String(runId).replace(/[^a-z0-9_-]/gi, '_').slice(0, 50);
25
26
  const dir = hDir(cwd);
26
27
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
- const record = { fromStage, toStage, runId, createdAt: new Date().toISOString(), data };
28
- const dest = hPath(runId, fromStage, toStage, cwd); const tmp = dest + '.tmp';
28
+ const record = { fromStage, toStage, runId: safeRunId, createdAt: new Date().toISOString(), data };
29
+ const dest = hPath(safeRunId, fromStage, toStage, cwd); const tmp = dest + '.tmp';
29
30
  writeFileSync(tmp, JSON.stringify(record, null, 2), 'utf8');
30
31
  try { renameSync(tmp, dest); } catch { writeFileSync(dest, JSON.stringify(record, null, 2), 'utf8'); }
31
32
  return record;
@@ -34,7 +35,8 @@ export function createHandoff(fromStage, toStage, data, runId, cwd) {
34
35
 
35
36
  export function consumeHandoff(runId, fromStage, toStage, cwd) {
36
37
  try {
37
- const p = hPath(runId, fromStage, toStage, cwd);
38
+ const safeRunId = String(runId).replace(/[^a-z0-9_-]/gi, '_').slice(0, 50);
39
+ const p = hPath(safeRunId, fromStage, toStage, cwd);
38
40
  if (!existsSync(p)) return null;
39
41
  const record = JSON.parse(readFileSync(p, 'utf8'));
40
42
  try { unlinkSync(p); } catch { /* best-effort */ }
package/src/outcome.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
1
+ import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { execSync } from 'child_process';
@@ -55,6 +55,19 @@ function deriveIntent(prompt, tier) {
55
55
  return tier ?? 'execute';
56
56
  }
57
57
 
58
+ function pruneOutcomes(cwd) {
59
+ const dir = join(cwd, '.dualbrain', 'outcomes');
60
+ try {
61
+ const files = readdirSync(dir).filter(f => f.startsWith('outcome_')).sort();
62
+ if (files.length > 500) {
63
+ const toDelete = files.slice(0, files.length - 400);
64
+ for (const f of toDelete) {
65
+ try { unlinkSync(join(dir, f)); } catch {}
66
+ }
67
+ }
68
+ } catch {}
69
+ }
70
+
58
71
  export function recordDispatchOutcome(dispatchInput, result) {
59
72
  try {
60
73
  const cwd = dispatchInput.cwd ?? process.cwd();
@@ -97,6 +110,7 @@ export function recordDispatchOutcome(dispatchInput, result) {
97
110
  ).catch(() => { /* non-blocking */ });
98
111
  } catch { /* non-blocking */ }
99
112
 
113
+ pruneOutcomes(cwd);
100
114
  return record;
101
115
  } catch {
102
116
  return null;
package/src/pipeline.mjs CHANGED
@@ -1270,6 +1270,17 @@ export async function runPipeline(trigger, prompt, options = {}) {
1270
1270
  run._thinkRefinedFiles = thinkRefinement.files;
1271
1271
  decision = thinkRefinement.decision;
1272
1272
 
1273
+ // Record the think→work handoff for cross-agent context continuity
1274
+ try {
1275
+ const { createHandoff } = await import('./handoff.mjs');
1276
+ createHandoff('thinker', 'worker', {
1277
+ objective: thinkRefinement.prompt,
1278
+ files: thinkRefinement.files,
1279
+ criteria: thinkRefinement.decision?.criteria || [],
1280
+ confidence: thinkRefinement.confidence,
1281
+ }, run.id || Date.now().toString(36), cwd);
1282
+ } catch { /* non-blocking */ }
1283
+
1273
1284
  // Cascade: if think agent is highly confident and task is simple, downgrade worker model
1274
1285
  if (thinkRefinement.decision) {
1275
1286
  const thinkConf = thinkRefinement.confidence || 0;
@@ -1288,6 +1299,17 @@ export async function runPipeline(trigger, prompt, options = {}) {
1288
1299
  }
1289
1300
  }
1290
1301
 
1302
+ // Strategy selection — may override dispatch pattern
1303
+ try {
1304
+ const { selectStrategy } = await import('./strategy.mjs');
1305
+ const strategyResult = selectStrategy(run.context.detection, decision, run.context.profile);
1306
+ if (strategyResult.strategy !== 'direct') {
1307
+ decision._strategy = strategyResult.strategy;
1308
+ decision._strategyReason = strategyResult.reason;
1309
+ if (verbose) process.stderr.write(`[dual-brain] strategy: ${strategyResult.strategy} (${strategyResult.reason})\n`);
1310
+ }
1311
+ } catch { /* non-blocking */ }
1312
+
1291
1313
  // Resolve the (possibly refined) prompt and file list for dispatch
1292
1314
  const dispatchPrompt = run._thinkRefinedPrompt ?? effectivePrompt;
1293
1315
  const dispatchFiles = run._thinkRefinedFiles ?? files;
@@ -67,6 +67,7 @@ function thinkROI(metrics) {
67
67
  function modelMismatch(routingState) {
68
68
  const recs = [];
69
69
  for (const [taskType, models] of Object.entries(routingState)) {
70
+ if (taskType.startsWith('_')) continue; // skip metadata keys
70
71
  for (const [model, stats] of Object.entries(models)) {
71
72
  const { ema, observations } = stats || {};
72
73
  if (observations >= 10 && ema < 0.4) {
@@ -156,12 +157,16 @@ function subscriptionUtilization(subscription, routingState) {
156
157
  const { tier, maxMultiplier } = subscription;
157
158
  if (!tier) return null;
158
159
 
159
- const opusUses = Object.values(routingState)
160
+ const routingCells = Object.entries(routingState)
161
+ .filter(([k]) => !k.startsWith('_')) // skip metadata keys
162
+ .map(([, v]) => v);
163
+
164
+ const opusUses = routingCells
160
165
  .flatMap(m => Object.entries(m))
161
166
  .filter(([model]) => model === 'opus' || model.includes('opus'))
162
167
  .reduce((s, [, stats]) => s + (stats.observations || 0), 0);
163
168
 
164
- const totalUses = Object.values(routingState)
169
+ const totalUses = routingCells
165
170
  .flatMap(m => Object.values(m))
166
171
  .reduce((s, stats) => s + (stats.observations || 0), 0);
167
172
 
@@ -267,7 +272,7 @@ export function formatRecommendations(recs) {
267
272
  const line = (content) => `│ ${pad(content)} │`;
268
273
 
269
274
  const lines = [
270
- '╭─ Recommendations ' + '─'.repeat(WIDTH - 19) + '╮',
275
+ '╭─ Recommendations ' + '─'.repeat(WIDTH - 20) + '╮',
271
276
  line(''),
272
277
  ];
273
278
 
@@ -29,7 +29,11 @@ function stateFile(cwd) { return join(cwd || process.cwd(), '.dualbrain', 'routi
29
29
  function loadState(cwd) {
30
30
  try {
31
31
  const p = stateFile(cwd);
32
- return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
32
+ if (!existsSync(p)) return {};
33
+ const raw = readFileSync(p, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
36
+ return parsed;
33
37
  } catch { return {}; }
34
38
  }
35
39
 
@@ -78,6 +78,7 @@ export function selectStrategy(failure, originalDecision, attemptNumber) {
78
78
  case 'specification':
79
79
  return { strategy: 'give-up', reason: 'ambiguous specification; user clarification needed' };
80
80
  default: // unknown
81
+ if (tier >= 3) return { strategy: 'split', newDecision: originalDecision, reason: 'unknown failure at max tier; decomposing' };
81
82
  return { strategy: 'escalate', newDecision: originalDecision, reason: 'unknown failure; escalating as precaution' };
82
83
  }
83
84
  }
@@ -130,6 +131,7 @@ export function buildRetryDecision(originalDecision, strategy, failure) {
130
131
  // Export 4: shouldRetry(result, originalDecision, attemptNumber)
131
132
  export function shouldRetry(result, originalDecision, attemptNumber = 1) {
132
133
  try {
134
+ if (attemptNumber >= MAX_ATTEMPTS) return { retry: false, reason: `max attempts (${MAX_ATTEMPTS}) reached`, strategy: 'give-up' };
133
135
  const failure = classifyFailure(result);
134
136
  const { strategy, newDecision, reason } = selectStrategy(failure, originalDecision, attemptNumber);
135
137
 
@@ -148,6 +148,14 @@ async function askYN(rl, question, defaultYes = true) {
148
148
  export async function runSetup(cwd, options = {}) {
149
149
  const detected = detectEnvironment(cwd);
150
150
 
151
+ // Non-TTY fast path — stdin is piped or in CI
152
+ if (!process.stdin.isTTY && !options.nonInteractive) {
153
+ process.stderr.write('[dual-brain] Non-interactive terminal detected. Use --non-interactive flag or run in a TTY.\n');
154
+ const config = buildConfig({ subscription: 'claude-pro', workStyle: 'balanced' }, detected);
155
+ saveConfig(config, cwd);
156
+ return config;
157
+ }
158
+
151
159
  // Non-interactive fast path
152
160
  if (options.nonInteractive) {
153
161
  const config = buildConfig({
package/src/signal.mjs CHANGED
@@ -9,8 +9,9 @@ export const EXPECTED_DURATION_MS = { search: 15000, execute: 45000, think: 3000
9
9
 
10
10
  export function scoreDurationRatio(durationMs, tier) {
11
11
  try {
12
- const expected = EXPECTED_DURATION_MS[tier] ?? EXPECTED_DURATION_MS.execute;
13
- const ratio = durationMs / expected;
12
+ if (durationMs <= 0) return null;
13
+ const expectedMs = EXPECTED_DURATION_MS[tier] || EXPECTED_DURATION_MS.execute;
14
+ const ratio = durationMs / expectedMs;
14
15
  if (ratio >= 0.5 && ratio <= 1.5) return 1.0;
15
16
  if (ratio < 0.2) return 0.5;
16
17
  if (ratio > 3.0) return 0.3;
@@ -71,7 +72,7 @@ export function scoreOutcome(outcome, context = {}) {
71
72
  // Signal 3: token efficiency (weight 0.25)
72
73
  let effVal = null;
73
74
  const filesChanged = outcome.filesChanged ?? 0;
74
- const fileCount = typeof filesChanged === 'number' ? filesChanged : filesChanged.length;
75
+ const fileCount = Array.isArray(filesChanged) ? filesChanged.length : (typeof filesChanged === 'number' ? filesChanged : 0);
75
76
  if (!(fileCount === 0 && tier === 'think')) {
76
77
  const tokensUsed =
77
78
  outcome.tokensUsed?.output ??