dual-brain 7.1.3 → 7.1.4

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.
@@ -82,8 +82,9 @@ Options:
82
82
  /**
83
83
  * Print a compact auth status table to stdout.
84
84
  * @param {{ claude: object, openai: object }} auth Result from detectAuth()
85
+ * @param {object} [profile] Optional loaded profile to cross-check enabled state
85
86
  */
86
- function printAuthTable(auth) {
87
+ function printAuthTable(auth, profile) {
87
88
  const W = 55; // inner width (wide enough for source labels)
88
89
  const hbar = '═'.repeat(W);
89
90
  const pad = (s) => {
@@ -91,15 +92,21 @@ function printAuthTable(auth) {
91
92
  return s + ' '.repeat(Math.max(0, W - visible.length));
92
93
  };
93
94
 
95
+ const claudeDisabled = profile?.providers?.claude?.enabled === false;
96
+ const openaiDisabled = profile?.providers?.openai?.enabled === false;
97
+
98
+ const claudeDisabledNote = claudeDisabled ? ' (auth ok, but disabled in profile)' : '';
99
+ const openaiDisabledNote = openaiDisabled ? ' (auth ok, but disabled in profile)' : '';
100
+
94
101
  const claudeLine1 = auth.claude.found
95
- ? ` Claude: ✓ found via ${auth.claude.source}`
102
+ ? ` Claude: ✓ found via ${auth.claude.source}${claudeDisabledNote}`
96
103
  : ` Claude: ✗ not found`;
97
104
  const claudeLine2 = auth.claude.found
98
105
  ? ` ${auth.claude.masked}`
99
106
  : ` run: dual-brain auth setup`;
100
107
 
101
108
  const openaiLine1 = auth.openai.found
102
- ? ` OpenAI: ✓ found via ${auth.openai.source}`
109
+ ? ` OpenAI: ✓ found via ${auth.openai.source}${openaiDisabledNote}`
103
110
  : ` OpenAI: ✗ not found`;
104
111
  const openaiLine2 = auth.openai.found
105
112
  ? ` ${auth.openai.masked}`
@@ -122,7 +129,7 @@ async function cmdInit(rl) {
122
129
 
123
130
  // --- Step 1: Auth preflight ---
124
131
  const auth = await detectAuth();
125
- printAuthTable(auth);
132
+ printAuthTable(auth, loadProfile(cwd));
126
133
 
127
134
  const noneFound = !auth.claude.found && !auth.openai.found;
128
135
  if (noneFound) {
@@ -166,7 +173,8 @@ async function cmdAuth(subArgs = [], rl) {
166
173
  }
167
174
 
168
175
  const auth = await detectAuth();
169
- printAuthTable(auth);
176
+ const profile = loadProfile(process.cwd());
177
+ printAuthTable(auth, profile);
170
178
 
171
179
  // If anything is missing, point to setup command
172
180
  if (!auth.claude.found || !auth.openai.found) {
@@ -339,10 +347,20 @@ async function cmdStatus(args = []) {
339
347
  const totalTokens = Object.values(sessionStats).reduce((s, v) => s + v.tokens, 0);
340
348
  console.log(`\nSession: ${totalCalls} dispatch${totalCalls !== 1 ? 'es' : ''}, ${totalTokens} tokens observed`);
341
349
 
342
- // Models
350
+ // Models — only list enabled providers
343
351
  console.log('\nAvailable models:');
344
- if (available.claude.length) console.log(` Claude : ${available.claude.join(', ')}`);
345
- if (available.openai.length) console.log(` OpenAI : ${available.openai.join(', ')}`);
352
+ const claudeEnabled = profile?.providers?.claude?.enabled !== false;
353
+ const openaiEnabled = profile?.providers?.openai?.enabled !== false;
354
+ if (claudeEnabled && available.claude.length) {
355
+ console.log(` Claude : ${available.claude.join(', ')}`);
356
+ } else if (!claudeEnabled) {
357
+ console.log(` Claude : (disabled — run "dual-brain init" to enable)`);
358
+ }
359
+ if (openaiEnabled && available.openai.length) {
360
+ console.log(` OpenAI : ${available.openai.join(', ')}`);
361
+ } else if (!openaiEnabled) {
362
+ console.log(` OpenAI : (disabled — run "dual-brain init" to enable)`);
363
+ }
346
364
 
347
365
  // Head model
348
366
  console.log(`\nHead model : ${getHeadModel(profile)}`);
@@ -680,8 +698,15 @@ async function dashboardScreen(rl, ask) {
680
698
  const env = detectEnvironment();
681
699
 
682
700
  // Build status lines for box
683
- const claudeStatus = auth.claude.found ? `🟢 Claude ${badge('connected')}` : `🔴 Claude ${badge('missing')}`;
684
- const openaiStatus = auth.openai.found ? `🟢 OpenAI ${badge('connected')}` : `🔴 OpenAI ${badge('missing')}`;
701
+ // If auth is found but provider is disabled in profile, show warning instead of green
702
+ const claudeProviderEnabled = profile?.providers?.claude?.enabled !== false;
703
+ const openaiProviderEnabled = profile?.providers?.openai?.enabled !== false;
704
+ const claudeStatus = auth.claude.found
705
+ ? (claudeProviderEnabled ? `🟢 Claude ${badge('connected')}` : `⚠️ Claude ${badge('warning')} disabled`)
706
+ : `🔴 Claude ${badge('missing')}`;
707
+ const openaiStatus = auth.openai.found
708
+ ? (openaiProviderEnabled ? `🟢 OpenAI ${badge('connected')}` : `⚠️ OpenAI ${badge('warning')} disabled`)
709
+ : `🔴 OpenAI ${badge('missing')}`;
685
710
  const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : env.isReplit ? 'Replit' : 'local';
686
711
 
687
712
  // Enforcement check
@@ -283,7 +283,7 @@ async function handleRequest(msg) {
283
283
  } catch (err) {
284
284
  const code = err.code ?? -32000;
285
285
  const message = err.message ?? 'Internal error';
286
- return errorResponse(id, code, message, err.stack);
286
+ return errorResponse(id, code, message);
287
287
  }
288
288
  }
289
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.3",
3
+ "version": "7.1.4",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,10 +46,50 @@
46
46
  "node": ">=20.0.0"
47
47
  },
48
48
  "files": [
49
- "src/*.mjs",
49
+ "src/profile.mjs",
50
+ "src/detect.mjs",
51
+ "src/decide.mjs",
52
+ "src/dispatch.mjs",
53
+ "src/playbook.mjs",
54
+ "src/health.mjs",
55
+ "src/repo.mjs",
56
+ "src/session.mjs",
57
+ "src/decompose.mjs",
58
+ "src/brief.mjs",
59
+ "src/redact.mjs",
60
+ "src/index.mjs",
61
+ "src/tui.mjs",
62
+ "src/install-hooks.mjs",
63
+ "src/update-check.mjs",
50
64
  "bin/*.mjs",
51
- "hooks/*.mjs",
52
- "hooks/*.sh",
65
+ "hooks/enforce-tier.mjs",
66
+ "hooks/cost-logger.mjs",
67
+ "hooks/cost-report.mjs",
68
+ "hooks/dual-brain-review.mjs",
69
+ "hooks/dual-brain-think.mjs",
70
+ "hooks/quality-gate.mjs",
71
+ "hooks/test-orchestrator.mjs",
72
+ "hooks/setup-wizard.mjs",
73
+ "hooks/health-check.mjs",
74
+ "hooks/install-git-hooks.mjs",
75
+ "hooks/session-report.mjs",
76
+ "hooks/budget-balancer.mjs",
77
+ "hooks/gpt-work-dispatcher.mjs",
78
+ "hooks/profiles.mjs",
79
+ "hooks/summary-checkpoint.mjs",
80
+ "hooks/decision-ledger.mjs",
81
+ "hooks/control-panel.mjs",
82
+ "hooks/risk-classifier.mjs",
83
+ "hooks/failure-detector.mjs",
84
+ "hooks/vibe-router.mjs",
85
+ "hooks/plan-generator.mjs",
86
+ "hooks/vibe-memory.mjs",
87
+ "hooks/wave-orchestrator.mjs",
88
+ "hooks/task-classifier.mjs",
89
+ "hooks/model-registry.mjs",
90
+ "hooks/auto-update-wrapper.mjs",
91
+ "hooks/head-guard.mjs",
92
+ "hooks/auto-update.sh",
53
93
  "mcp-server/*.mjs",
54
94
  "mcp-server/README.md",
55
95
  "install.mjs",
package/src/decide.mjs CHANGED
@@ -449,6 +449,35 @@ export function parsePreferences(preferences) {
449
449
  return signals;
450
450
  }
451
451
 
452
+ // ─── Internal: safety floor for critical-risk tasks ───────────────────────────
453
+
454
+ /**
455
+ * Ensure critical-risk tasks are never handled by the cheapest (haiku/gpt-4.1-mini) model.
456
+ * Cost-saver mode is the main culprit; escalate silently but emit a stderr warning.
457
+ * @param {string} model
458
+ * @param {string} provider
459
+ * @param {string[]} available
460
+ * @param {'low'|'medium'|'high'|'critical'} risk
461
+ * @returns {string}
462
+ */
463
+ function applyCriticalRiskFloor(model, provider, available, risk) {
464
+ if (risk !== 'critical') return model;
465
+
466
+ const cheapModels = { claude: 'haiku', openai: 'gpt-4.1-mini' };
467
+ const floorModels = { claude: 'sonnet', openai: 'gpt-4.1' };
468
+
469
+ if (model === cheapModels[provider]) {
470
+ const floor = floorModels[provider];
471
+ const escalated = available.includes(floor) ? floor : available[available.length - 1] ?? model;
472
+ process.stderr.write(
473
+ `[dual-brain] Warning: cost-saver selected ${model} for a critical-risk task. ` +
474
+ `Escalating to ${escalated} (safety floor).\n`
475
+ );
476
+ return escalated;
477
+ }
478
+ return model;
479
+ }
480
+
452
481
  // ─── Exported: decideRoute ────────────────────────────────────────────────────
453
482
 
454
483
  /**
@@ -508,6 +537,9 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
508
537
  // Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
509
538
  model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider]);
510
539
 
540
+ // Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
541
+ model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
542
+
511
543
  // Apply preferModel signal from preferences (override after all other picks)
512
544
  if (prefSignals.preferModel) {
513
545
  const wantedModel = prefSignals.preferModel;
package/src/index.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, setupAuth, getActiveKey, removeAuthKey, disableKey, rotateToNextKey } from './profile.mjs';
10
10
  export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths } from './detect.mjs';
11
- export { decideRoute, getModelCapabilities, getAvailableModels, estimateBudgetPressure, shouldDualBrain, explainDecision } from './decide.mjs';
11
+ export { decideRoute, getModelCapabilities, getAvailableModels, shouldDualBrain, explainDecision } from './decide.mjs';
12
12
  export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain } from './dispatch.mjs';
13
13
  export { loadPlaybook, listPlaybooks, executePlaybook, createRunArtifact } from './playbook.mjs';
14
14
  export { getHealth, markHot, markDegraded, markHealthy, checkCooldown, getProviderScore, recordDispatch, getSessionStats, resetHealth, remainingCooldownMinutes } from './health.mjs';
package/src/profile.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { createInterface } from 'readline';
26
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
26
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
27
  import { homedir } from 'os';
28
28
  import { dirname, join } from 'path';
29
29
 
@@ -348,7 +348,7 @@ function saveAuthKey(provider, key, opts = {}) {
348
348
  const cwd = opts.cwd || process.cwd();
349
349
  const authFile = AUTH_FILE(cwd);
350
350
  const dir = dirname(authFile);
351
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
351
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
352
352
 
353
353
  const auth = loadAuthKeys(cwd);
354
354
  if (!Array.isArray(auth[provider])) auth[provider] = [];
@@ -370,6 +370,7 @@ function saveAuthKey(provider, key, opts = {}) {
370
370
  });
371
371
 
372
372
  writeFileSync(authFile, JSON.stringify(auth, null, 2));
373
+ chmodSync(authFile, 0o600);
373
374
 
374
375
  // Inject highest-priority valid key into process.env for this session
375
376
  const active = getActiveKey(provider, cwd);
@@ -388,13 +389,14 @@ function saveAuthKey(provider, key, opts = {}) {
388
389
  function removeAuthKey(provider, index, cwd) {
389
390
  const authFile = AUTH_FILE(cwd);
390
391
  const dir = dirname(authFile);
391
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
392
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
392
393
 
393
394
  const auth = loadAuthKeys(cwd);
394
395
  if (!Array.isArray(auth[provider])) return;
395
396
 
396
397
  auth[provider].splice(index, 1);
397
398
  writeFileSync(authFile, JSON.stringify(auth, null, 2));
399
+ chmodSync(authFile, 0o600);
398
400
  }
399
401
 
400
402
  /**
@@ -406,13 +408,14 @@ function removeAuthKey(provider, index, cwd) {
406
408
  function disableKey(provider, index, cwd) {
407
409
  const authFile = AUTH_FILE(cwd);
408
410
  const dir = dirname(authFile);
409
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
411
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
410
412
 
411
413
  const auth = loadAuthKeys(cwd);
412
414
  if (!Array.isArray(auth[provider]) || !auth[provider][index]) return;
413
415
 
414
416
  auth[provider][index].enabled = false;
415
417
  writeFileSync(authFile, JSON.stringify(auth, null, 2));
418
+ chmodSync(authFile, 0o600);
416
419
  }
417
420
 
418
421
  /**
package/src/session.mjs CHANGED
@@ -122,9 +122,10 @@ export function clearSession(cwd = process.cwd()) {
122
122
  * @param {object|null} session — from loadSession()
123
123
  * @param {object} repo — from detectRepo() / loadRepoCache()
124
124
  * @param {object} health — from getHealth() (shape: { states: {}, session: {} })
125
+ * @param {object} [profile] — optional profile for enabled-state checks
125
126
  * @returns {string}
126
127
  */
127
- export function formatSessionCard(session, repo, health) {
128
+ export function formatSessionCard(session, repo, health, profile) {
128
129
  const lines = [];
129
130
 
130
131
  // Line 1: Repo identity
@@ -157,8 +158,11 @@ export function formatSessionCard(session, repo, health) {
157
158
  lines.push(`Branch: ${repo.branch}${dirtyNote}`);
158
159
  }
159
160
 
160
- // Line 4: Health summary
161
+ // Line 4: Health summary — only show enabled providers
161
162
  const { states = {} } = health || {};
163
+ const claudeProviderEnabled = profile?.providers?.claude?.enabled !== false;
164
+ const openaiProviderEnabled = profile?.providers?.openai?.enabled !== false;
165
+
162
166
  function providerStatus(name) {
163
167
  const entries = Object.entries(states).filter(([k]) => k.startsWith(`${name}:`));
164
168
  if (entries.length === 0) return 'healthy';
@@ -168,11 +172,21 @@ export function formatSessionCard(session, repo, health) {
168
172
  if (statuses.includes('probing')) return 'probing';
169
173
  return 'healthy';
170
174
  }
171
- const claudeStatus = providerStatus('claude');
172
- const openaiStatus = providerStatus('openai');
173
- const claudeLabel = claudeStatus === 'healthy' ? 'Claude healthy' : `Claude ${claudeStatus}`;
174
- const openaiLabel = openaiStatus === 'healthy' ? 'OpenAI healthy' : `OpenAI ${openaiStatus}`;
175
- lines.push(`Health: ${claudeLabel}, ${openaiLabel}`);
175
+
176
+ const healthParts = [];
177
+ if (claudeProviderEnabled) {
178
+ const claudeStatus = providerStatus('claude');
179
+ healthParts.push(claudeStatus === 'healthy' ? 'Claude healthy' : `Claude ${claudeStatus}`);
180
+ } else {
181
+ healthParts.push('Claude disabled');
182
+ }
183
+ if (openaiProviderEnabled) {
184
+ const openaiStatus = providerStatus('openai');
185
+ healthParts.push(openaiStatus === 'healthy' ? 'OpenAI healthy' : `OpenAI ${openaiStatus}`);
186
+ } else {
187
+ healthParts.push('OpenAI disabled');
188
+ }
189
+ lines.push(`Health: ${healthParts.join(', ')}`);
176
190
 
177
191
  // Line 5: Last task summary (only if session exists)
178
192
  if (session) {
@@ -194,6 +208,9 @@ export function formatSessionCard(session, repo, health) {
194
208
  }
195
209
  }
196
210
 
211
+ // Tip line: always show a call-to-action so non-TTY output is actionable
212
+ lines.push(`Tip: run "dual-brain --help" or "dual-brain go \\"task\\""`);
213
+
197
214
  return lines.join('\n');
198
215
  }
199
216
 
package/src/tui.mjs CHANGED
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { readFileSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
7
9
 
8
10
  // ─── Unicode / ASCII mode ─────────────────────────────────────────────────────
9
11
 
@@ -172,7 +174,14 @@ export function menu(options, opts = {}) {
172
174
  // ─── Self-test ────────────────────────────────────────────────────────────────
173
175
 
174
176
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
175
- console.log(box('🧠 Dual-Brain v7.0.2', [
177
+ // Read version dynamically from package.json
178
+ let selfTestVersion = '0.0.0';
179
+ try {
180
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
181
+ selfTestVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
182
+ } catch { /* fallback to 0.0.0 */ }
183
+
184
+ console.log(box(`🧠 Dual-Brain v${selfTestVersion}`, [
176
185
  '🟢 Claude ✅ 🟢 OpenAI ✅',
177
186
  '🌀 Replit + replit-tools',
178
187
  ]));