dual-brain 7.1.3 → 7.1.5

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/CLAUDE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This project uses dual-provider orchestration. Config: `.claude/orchestrator.json`.
4
4
 
5
- ## Core Architecture (v6)
5
+ ## Core Architecture (v7)
6
6
 
7
7
  Four modules in `src/` form the decision pipeline:
8
8
 
@@ -71,21 +71,21 @@ Dual-brain is a multi-round conversation between Claude and GPT — not a single
71
71
  ## Quality Gate
72
72
 
73
73
  Before ending a session with code changes:
74
- 1. Run `node .claude/hooks/session-report.mjs`
75
- 2. Run `node .claude/hooks/quality-gate.mjs`
74
+ 1. `node .claude/hooks/session-report.mjs` (allowed by head-guard for hook scripts)
75
+ 2. `node .claude/hooks/quality-gate.mjs`
76
76
 
77
77
  Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (GPT unavailable).
78
78
 
79
79
  ## Profiles
80
80
 
81
- Profile persists to `.claude/dual-brain.profile.json` (gitignored).
81
+ Profile persists to `.dualbrain/profile.json` (project-scoped, gitignored).
82
82
 
83
83
  - **auto** (default): Adapts routing based on task risk, provider health, and outcomes
84
84
  - **balanced**: Best model per tier, normal budgets, reviews at medium+ risk
85
85
  - **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
86
86
  - **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
87
87
 
88
- Switch: `dual-brain mode cost-saver` · Natural language aliases work: "be careful", "cheap mode", "thorough"
88
+ Switch via the interactive Profile screen in `dual-brain`, or set `bias` in `.dualbrain/profile.json`.
89
89
 
90
90
  ## Adaptive Routing (Auto Mode)
91
91
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ dual-brain detects the intent and risk of your task, picks the best model based
24
24
 
25
25
  ### `dual-brain init`
26
26
 
27
- First-time setup. Three questions: which providers you have, subscription tiers, and optimization preference.
27
+ First-time setup. Three questions: which providers you have, subscription tiers, and optimization preference. The actual flow auto-detects existing auth and adapts — you may see fewer prompts if credentials are already configured.
28
28
 
29
29
  ```
30
30
  Dual-Brain Orchestrator — First-time setup
@@ -121,7 +121,7 @@ Preferences are stored in `.dualbrain/profile.json` and applied on every `go` in
121
121
  import { orchestrate } from 'dual-brain';
122
122
 
123
123
  const result = await orchestrate({ prompt: "fix the bug", cwd: "." });
124
- console.log(result.summary);
124
+ console.log(result.result?.summary);
125
125
  ```
126
126
 
127
127
  Individual modules are also exported:
@@ -174,7 +174,7 @@ For Claude Code users, a hooks layer provides deeper integration. Hooks fire on
174
174
 
175
175
  ```bash
176
176
  # Install hooks into .claude/settings.json
177
- npx -y dual-brain
177
+ npx dual-brain install
178
178
  ```
179
179
 
180
180
  The installer auto-detects your environment (Claude CLI, Codex CLI, Replit), registers `enforce-tier.mjs` and `cost-logger.mjs` hooks, and writes `orchestrator.json` with your subscription config. Re-run anytime — it's idempotent.
@@ -66,9 +66,7 @@ Commands:
66
66
 
67
67
  Interactive mode (entered with no args on a TTY):
68
68
  Shows dashboard screen with menu-driven navigation.
69
- [g] Go dispatch a task
70
- [s] Status, [p] Profile, [a] Auth, [d] Diagnostics
71
- [c] Command mode (REPL), [q] Exit
69
+ [s] Status, [p] Profile, [a] Auth, [d] Diagnostics, [q] Exit
72
70
 
73
71
  Options:
74
72
  --version Print version
@@ -82,8 +80,9 @@ Options:
82
80
  /**
83
81
  * Print a compact auth status table to stdout.
84
82
  * @param {{ claude: object, openai: object }} auth Result from detectAuth()
83
+ * @param {object} [profile] Optional loaded profile to cross-check enabled state
85
84
  */
86
- function printAuthTable(auth) {
85
+ function printAuthTable(auth, profile) {
87
86
  const W = 55; // inner width (wide enough for source labels)
88
87
  const hbar = '═'.repeat(W);
89
88
  const pad = (s) => {
@@ -91,15 +90,21 @@ function printAuthTable(auth) {
91
90
  return s + ' '.repeat(Math.max(0, W - visible.length));
92
91
  };
93
92
 
93
+ const claudeDisabled = profile?.providers?.claude?.enabled === false;
94
+ const openaiDisabled = profile?.providers?.openai?.enabled === false;
95
+
96
+ const claudeDisabledNote = claudeDisabled ? ' (auth ok, but disabled in profile)' : '';
97
+ const openaiDisabledNote = openaiDisabled ? ' (auth ok, but disabled in profile)' : '';
98
+
94
99
  const claudeLine1 = auth.claude.found
95
- ? ` Claude: ✓ found via ${auth.claude.source}`
100
+ ? ` Claude: ✓ found via ${auth.claude.source}${claudeDisabledNote}`
96
101
  : ` Claude: ✗ not found`;
97
102
  const claudeLine2 = auth.claude.found
98
103
  ? ` ${auth.claude.masked}`
99
104
  : ` run: dual-brain auth setup`;
100
105
 
101
106
  const openaiLine1 = auth.openai.found
102
- ? ` OpenAI: ✓ found via ${auth.openai.source}`
107
+ ? ` OpenAI: ✓ found via ${auth.openai.source}${openaiDisabledNote}`
103
108
  : ` OpenAI: ✗ not found`;
104
109
  const openaiLine2 = auth.openai.found
105
110
  ? ` ${auth.openai.masked}`
@@ -122,7 +127,7 @@ async function cmdInit(rl) {
122
127
 
123
128
  // --- Step 1: Auth preflight ---
124
129
  const auth = await detectAuth();
125
- printAuthTable(auth);
130
+ printAuthTable(auth, loadProfile(cwd));
126
131
 
127
132
  const noneFound = !auth.claude.found && !auth.openai.found;
128
133
  if (noneFound) {
@@ -148,6 +153,9 @@ async function cmdInit(rl) {
148
153
  const profile = await runOnboarding({ interactive: true, detectedAuth: auth, rl });
149
154
  saveProfile(profile, { cwd });
150
155
 
156
+ // --- Step 2b: Install hooks so enforcement is active from first run ---
157
+ await cmdInstall(cwd);
158
+
151
159
  // --- Step 3: Show dashboard ---
152
160
  console.log('');
153
161
  const repo = loadRepoCache(cwd);
@@ -166,7 +174,8 @@ async function cmdAuth(subArgs = [], rl) {
166
174
  }
167
175
 
168
176
  const auth = await detectAuth();
169
- printAuthTable(auth);
177
+ const profile = loadProfile(process.cwd());
178
+ printAuthTable(auth, profile);
170
179
 
171
180
  // If anything is missing, point to setup command
172
181
  if (!auth.claude.found || !auth.openai.found) {
@@ -272,6 +281,27 @@ async function cmdGo(args) {
272
281
  }, cwd);
273
282
  } else {
274
283
  result = await dispatch({ decision, prompt, files, cwd });
284
+ if (result.status === 'completed' && result.type === 'native-agent') {
285
+ const nd = result.nativeDispatch || {};
286
+ const promptPreview = (nd.prompt || prompt).slice(0, 100);
287
+ const promptSuffix = (nd.prompt || prompt).length > 100 ? '...' : '';
288
+ console.log(`\nRouted: ${decision.provider}/${nd.model || decision.model} (${decision.tier})`);
289
+ console.log('To dispatch, use the Agent tool with:');
290
+ console.log(` model: ${nd.model || decision.model}`);
291
+ console.log(` prompt: ${promptPreview}${promptSuffix}`);
292
+ if (nd.isolation) console.log(` isolation: ${nd.isolation}`);
293
+ if (nd.maxTurns) console.log(` maxTurns: ${nd.maxTurns}`);
294
+ saveSession({
295
+ objective: prompt,
296
+ branch: null,
297
+ filesChanged: files,
298
+ commandsRun: [`dual-brain go "${prompt}"`],
299
+ lastResult: { status: 'success', summary: `native-agent routed to ${nd.model || decision.model}` },
300
+ provider: decision.provider,
301
+ nextAction: null,
302
+ }, cwd);
303
+ return;
304
+ }
275
305
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
276
306
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
277
307
  if (result.summary) console.log(result.summary);
@@ -339,10 +369,20 @@ async function cmdStatus(args = []) {
339
369
  const totalTokens = Object.values(sessionStats).reduce((s, v) => s + v.tokens, 0);
340
370
  console.log(`\nSession: ${totalCalls} dispatch${totalCalls !== 1 ? 'es' : ''}, ${totalTokens} tokens observed`);
341
371
 
342
- // Models
372
+ // Models — only list enabled providers
343
373
  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(', ')}`);
374
+ const claudeEnabled = profile?.providers?.claude?.enabled !== false;
375
+ const openaiEnabled = profile?.providers?.openai?.enabled !== false;
376
+ if (claudeEnabled && available.claude.length) {
377
+ console.log(` Claude : ${available.claude.join(', ')}`);
378
+ } else if (!claudeEnabled) {
379
+ console.log(` Claude : (disabled — run "dual-brain init" to enable)`);
380
+ }
381
+ if (openaiEnabled && available.openai.length) {
382
+ console.log(` OpenAI : ${available.openai.join(', ')}`);
383
+ } else if (!openaiEnabled) {
384
+ console.log(` OpenAI : (disabled — run "dual-brain init" to enable)`);
385
+ }
346
386
 
347
387
  // Head model
348
388
  console.log(`\nHead model : ${getHeadModel(profile)}`);
@@ -425,7 +465,7 @@ async function cmdStatus(args = []) {
425
465
 
426
466
  const PROVIDER_MODEL_CLASSES = {
427
467
  claude: ['haiku', 'sonnet', 'opus'],
428
- openai: ['o4-mini', 'o3', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-5.4', 'gpt-5.5'],
468
+ openai: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5'],
429
469
  };
430
470
 
431
471
  function cmdHot(providerArg) {
@@ -448,8 +488,8 @@ function cmdCool(providerArg) {
448
488
  console.log(`Cleared hot state for all ${provider} model classes.`);
449
489
  }
450
490
 
451
- async function cmdInstall() {
452
- const cwd = process.cwd();
491
+ async function cmdInstall(cwd) {
492
+ if (!cwd) cwd = process.cwd();
453
493
 
454
494
  // Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
455
495
  const { spawnSync } = await import('child_process');
@@ -468,8 +508,6 @@ async function cmdInstall() {
468
508
  console.log(`Enforcement hooks already present (${skipped.length}):`);
469
509
  for (const item of skipped) console.log(` = ${item}`);
470
510
  }
471
-
472
- process.exit(0);
473
511
  }
474
512
 
475
513
  function cmdRemember(text) {
@@ -541,6 +579,7 @@ async function welcomeScreen(rl, ask) {
541
579
  } else {
542
580
  // Enter or anything else → save and go to dashboard
543
581
  saveProfile(setup.profile, { cwd });
582
+ await cmdInstall(cwd);
544
583
  return { next: 'dashboard' };
545
584
  }
546
585
  } else {
@@ -667,6 +706,8 @@ async function welcomeScreen(rl, ask) {
667
706
  console.log(box('Setup Complete', summaryLines));
668
707
  console.log('');
669
708
 
709
+ await cmdInstall(cwd);
710
+
670
711
  return { next: 'dashboard' };
671
712
  }
672
713
 
@@ -680,8 +721,15 @@ async function dashboardScreen(rl, ask) {
680
721
  const env = detectEnvironment();
681
722
 
682
723
  // 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')}`;
724
+ // If auth is found but provider is disabled in profile, show warning instead of green
725
+ const claudeProviderEnabled = profile?.providers?.claude?.enabled !== false;
726
+ const openaiProviderEnabled = profile?.providers?.openai?.enabled !== false;
727
+ const claudeStatus = auth.claude.found
728
+ ? (claudeProviderEnabled ? `🟢 Claude ${badge('connected')}` : `⚠️ Claude ${badge('warning')} disabled`)
729
+ : `🔴 Claude ${badge('missing')}`;
730
+ const openaiStatus = auth.openai.found
731
+ ? (openaiProviderEnabled ? `🟢 OpenAI ${badge('connected')}` : `⚠️ OpenAI ${badge('warning')} disabled`)
732
+ : `🔴 OpenAI ${badge('missing')}`;
685
733
  const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : env.isReplit ? 'Replit' : 'local';
686
734
 
687
735
  // Enforcement check
@@ -15,7 +15,15 @@
15
15
 
16
16
  import { readFileSync } from 'fs';
17
17
 
18
- const BLOCKED_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
18
+ const BLOCKED_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
19
+
20
+ // Patterns that indicate a Bash command is writing/mutating the filesystem.
21
+ // Anchored to avoid false positives on grep/find output containing these words.
22
+ const WRITE_BASH_RE = /\brm\b|\bmv\b|\bcp\b|\bmkdir\b|\btouch\b|\bchmod\b|\bchown\b|\bdd\b|\binstall\b|\btruncate\b|\btee\b|\bsed\s+-i\b|\bawk\s+-i\b|>>|(?<![><])>(?![>=])/;
23
+
24
+ function isBashWriteIntent(command) {
25
+ return WRITE_BASH_RE.test(command);
26
+ }
19
27
 
20
28
  // Read stdin JSON payload
21
29
  let input;
@@ -23,9 +31,16 @@ try {
23
31
  const raw = readFileSync('/dev/stdin', 'utf8');
24
32
  input = JSON.parse(raw);
25
33
  } catch {
26
- // If we can't read / parse input, fail open don't break sessions
27
- // that aren't using dual-brain at all.
28
- process.exit(0);
34
+ // Can't parse input fail closed to avoid guard bypass.
35
+ const output = {
36
+ hookSpecificOutput: {
37
+ hookEventName: 'PreToolUse',
38
+ permissionDecision: 'deny',
39
+ permissionDecisionReason: '[dual-brain] head-guard could not parse hook input — blocking as a safety measure.',
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(output));
43
+ process.exit(2);
29
44
  }
30
45
 
31
46
  const toolName = input.tool_name || '';
@@ -50,9 +65,30 @@ if (BLOCKED_TOOLS.has(toolName)) {
50
65
  process.exit(2);
51
66
  }
52
67
 
53
- // Also block MCP filesystem write tools (any mcp__ tool with write/create/
54
- // delete/remove/move/rename in the name).
55
- if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename/i.test(toolName)) {
68
+ // Bash: allow read-only commands; block write-intent ones.
69
+ // Always allow node .claude/hooks/ and node hooks/ CLAUDE.md instructs HEAD to run these.
70
+ if (toolName === 'Bash') {
71
+ const command = (input.tool_input && input.tool_input.command) || '';
72
+ if (/^node\s+\.?(?:\.claude\/)?hooks\//.test(command.trimStart())) {
73
+ process.exit(0);
74
+ }
75
+ if (isBashWriteIntent(command)) {
76
+ const output = {
77
+ hookSpecificOutput: {
78
+ hookEventName: 'PreToolUse',
79
+ permissionDecision: 'deny',
80
+ permissionDecisionReason:
81
+ '[dual-brain] HEAD cannot run write-intent Bash commands. Dispatch via: dual-brain go "task description"',
82
+ },
83
+ };
84
+ process.stdout.write(JSON.stringify(output));
85
+ process.exit(2);
86
+ }
87
+ process.exit(0);
88
+ }
89
+
90
+ // Block MCP filesystem write tools by name.
91
+ if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename|append|patch|truncate|copy|commit|push|stage|merge|update|overwrite/i.test(toolName)) {
56
92
  const output = {
57
93
  hookSpecificOutput: {
58
94
  hookEventName: 'PreToolUse',
@@ -445,7 +445,7 @@ test('enforce-tier: cost-saver demotes think', () => {
445
445
  // cost-saver's demote_think=true demotes think→execute when text lacks think words
446
446
  const payload = JSON.stringify({
447
447
  tool_name: 'Agent',
448
- tool_input: { prompt: 'edit the README file', model: 'opus' },
448
+ tool_input: { prompt: '<!-- dual-brain-dispatch: test23 -->edit the README file', model: 'opus' },
449
449
  });
450
450
  const { parsed, status } = run(ENFORCE_TIER, payload);
451
451
  if (status !== 0) return `non-zero exit: ${status}`;
@@ -494,7 +494,7 @@ test('enforce-tier: auto profile with high-risk file', () => {
494
494
  // Description with auth/credentials path → risk classifier detects critical risk → promote to think
495
495
  const payload = JSON.stringify({
496
496
  tool_name: 'Agent',
497
- tool_input: { description: 'update src/auth/credentials.mjs', prompt: 'change the token logic', model: 'sonnet' },
497
+ tool_input: { description: 'update src/auth/credentials.mjs', prompt: '<!-- dual-brain-dispatch: test25 -->change the token logic', model: 'sonnet' },
498
498
  });
499
499
  const { parsed, status } = run(ENFORCE_TIER, payload);
500
500
  if (status !== 0) return `non-zero exit: ${status}`;
@@ -740,9 +740,9 @@ test('install: preserves existing hooks', () => {
740
740
  if (!installSrc.includes('.filter'))
741
741
  return 'install.mjs missing .filter() call — may clobber non-dual-brain hooks';
742
742
 
743
- // The merge logic should spread existingEntries first, then add dual-brain hooks
744
- if (!installSrc.includes('existingEntries'))
745
- return 'install.mjs missing existingEntries variable — may not preserve other hooks';
743
+ // The merge logic should filter existing hooks before merging dual-brain hooks
744
+ if (!installSrc.includes('existingPre') && !installSrc.includes('existingEntries'))
745
+ return 'install.mjs missing existing hook preservation — may not preserve other hooks';
746
746
 
747
747
  // Verify it reads existing settings before overwriting
748
748
  if (!installSrc.includes('existing') || !installSrc.includes('settings.json'))
@@ -1017,7 +1017,7 @@ test('adaptive loop: end-to-end hash match', () => {
1017
1017
  writeFileSync(LEDGER, '', 'utf8');
1018
1018
 
1019
1019
  // Step 1: Define a specific Agent payload used consistently across all steps
1020
- const toolInput = { prompt: 'fix the auth bug', description: 'patch auth module' };
1020
+ const toolInput = { prompt: '<!-- dual-brain-dispatch: test40 -->fix the auth bug', description: 'patch auth module' };
1021
1021
  const agentPayload = JSON.stringify({ tool_name: 'Agent', tool_input: toolInput });
1022
1022
 
1023
1023
  // Step 2: Run enforce-tier with this payload (computes and may log a promptHash)
@@ -253,7 +253,7 @@ async function handleRequest(msg) {
253
253
  return respond(id, {
254
254
  protocolVersion: '2024-11-05',
255
255
  capabilities: { tools: {} },
256
- serverInfo: { name: 'dual-brain', version: '7.1.0' },
256
+ serverInfo: { name: 'dual-brain', version: '7.1.4' },
257
257
  });
258
258
 
259
259
  case 'initialized':
@@ -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.5",
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/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.0",
3
+ "version": "7.1.4",
4
4
  "description": "Dual-provider AI orchestration — smart routing between Claude and OpenAI",
5
5
  "skills": [
6
6
  { "name": "go", "description": "Route and dispatch a task", "file": "skills/go.md" },
package/src/decide.mjs CHANGED
@@ -71,10 +71,37 @@ const MODEL_CAPABILITIES = {
71
71
  effortLevels: ['low', 'medium', 'high'],
72
72
  costTier: 'medium',
73
73
  },
74
+ 'gpt-5.2': {
75
+ provider: 'openai',
76
+ tierFit: ['search', 'execute'],
77
+ contextWindow: 200_000,
78
+ costTier: 'medium',
79
+ strengths: ['code-generation', 'analysis'],
80
+ weaknesses: [],
81
+ effortLevels: null,
82
+ },
83
+ 'gpt-5.4-mini': {
84
+ provider: 'openai',
85
+ tierFit: ['search'],
86
+ contextWindow: 200_000,
87
+ costTier: 'low',
88
+ strengths: ['quick-tasks', 'search'],
89
+ weaknesses: ['complex-edits', 'architecture'],
90
+ effortLevels: null,
91
+ },
92
+ 'gpt-5.3-codex': {
93
+ provider: 'openai',
94
+ tierFit: ['execute'],
95
+ contextWindow: 200_000,
96
+ costTier: 'medium',
97
+ strengths: ['code-generation', 'refactoring'],
98
+ weaknesses: ['architecture', 'security'],
99
+ effortLevels: null,
100
+ },
74
101
  'gpt-5.4': {
75
102
  provider: 'openai',
76
103
  tierFit: ['execute', 'think'],
77
- contextWindow: 200_000,
104
+ contextWindow: 1_050_000,
78
105
  strengths: ['refactor', 'debug', 'code-generation', 'test'],
79
106
  weaknesses: ['cost'],
80
107
  effortLevels: ['low', 'medium', 'high', 'xhigh'],
@@ -83,7 +110,7 @@ const MODEL_CAPABILITIES = {
83
110
  'gpt-5.5': {
84
111
  provider: 'openai',
85
112
  tierFit: ['think'],
86
- contextWindow: 200_000,
113
+ contextWindow: 1_000_000,
87
114
  strengths: ['architecture', 'security', 'review', 'planning', 'complex-debug'],
88
115
  weaknesses: ['cost', 'latency'],
89
116
  effortLevels: ['low', 'medium', 'high', 'xhigh'],
@@ -264,16 +291,19 @@ function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
264
291
  }
265
292
  }
266
293
 
267
- function applyProfileBias(model, profile, provider, available) {
294
+ function applyProfileBias(model, profile, provider, available, tier) {
268
295
  const mode = profile?.mode || profile?.profile || 'auto';
269
296
  if (mode === 'cost-saver') {
270
- // Prefer cheapest available
297
+ // Prefer cheapest available that also fits the required tier
271
298
  const ranks = {
272
299
  claude: ['haiku', 'sonnet', 'opus'],
273
300
  openai: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5'],
274
301
  };
275
302
  for (const m of ranks[provider]) {
276
- if (available.includes(m)) return m;
303
+ if (!available.includes(m)) continue;
304
+ const caps = MODEL_CAPABILITIES[m];
305
+ if (tier && caps && !caps.tierFit.includes(tier)) continue;
306
+ return m;
277
307
  }
278
308
  }
279
309
  if (mode === 'quality-first') {
@@ -449,6 +479,35 @@ export function parsePreferences(preferences) {
449
479
  return signals;
450
480
  }
451
481
 
482
+ // ─── Internal: safety floor for critical-risk tasks ───────────────────────────
483
+
484
+ /**
485
+ * Ensure critical-risk tasks are never handled by the cheapest (haiku/gpt-4.1-mini) model.
486
+ * Cost-saver mode is the main culprit; escalate silently but emit a stderr warning.
487
+ * @param {string} model
488
+ * @param {string} provider
489
+ * @param {string[]} available
490
+ * @param {'low'|'medium'|'high'|'critical'} risk
491
+ * @returns {string}
492
+ */
493
+ function applyCriticalRiskFloor(model, provider, available, risk) {
494
+ if (risk !== 'critical') return model;
495
+
496
+ const cheapModels = { claude: 'haiku', openai: 'gpt-4.1-mini' };
497
+ const floorModels = { claude: 'sonnet', openai: 'gpt-4.1' };
498
+
499
+ if (model === cheapModels[provider]) {
500
+ const floor = floorModels[provider];
501
+ const escalated = available.includes(floor) ? floor : available[available.length - 1] ?? model;
502
+ process.stderr.write(
503
+ `[dual-brain] Warning: cost-saver selected ${model} for a critical-risk task. ` +
504
+ `Escalating to ${escalated} (safety floor).\n`
505
+ );
506
+ return escalated;
507
+ }
508
+ return model;
509
+ }
510
+
452
511
  // ─── Exported: decideRoute ────────────────────────────────────────────────────
453
512
 
454
513
  /**
@@ -506,7 +565,10 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
506
565
  model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
507
566
 
508
567
  // Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
509
- model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider]);
568
+ model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
569
+
570
+ // Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
571
+ model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
510
572
 
511
573
  // Apply preferModel signal from preferences (override after all other picks)
512
574
  if (prefSignals.preferModel) {
package/src/dispatch.mjs CHANGED
@@ -647,13 +647,14 @@ async function dispatch(input = {}) {
647
647
  }
648
648
  _recordDispatchBudget(prompt);
649
649
  return {
650
- status: 'native-agent',
650
+ status: 'completed',
651
+ type: 'native-agent',
651
652
  provider: effectiveProvider,
652
653
  model: effectiveModel,
653
654
  command: null,
654
655
  nativeDispatch: nativeDescriptor,
655
- exitCode: null,
656
- summary: `[native] ${nativeDescriptor.description}`,
656
+ exitCode: 0,
657
+ summary: `Routed to ${effectiveProvider}/${effectiveModel} (${effectiveDecision.tier})`,
657
658
  durationMs: 0,
658
659
  usage: null,
659
660
  error: null,
package/src/index.mjs CHANGED
@@ -6,9 +6,9 @@
6
6
  * orchestrate() convenience function for programmatic use.
7
7
  */
8
8
 
9
- export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, setupAuth, getActiveKey, removeAuthKey, disableKey, rotateToNextKey } from './profile.mjs';
9
+ export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, setupAuth, getActiveKey } 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';