dual-brain 0.2.7 → 0.2.8

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,146 +2,32 @@
2
2
 
3
3
  This project uses dual-provider orchestration. Config: `.claude/orchestrator.json`.
4
4
 
5
- ## Core Architecture (v7)
6
-
7
- Four modules in `src/` form the decision pipeline:
8
-
9
- - **`profile.mjs`** Load active profile, provider availability, preferences, and subscription plan
10
- - **`detect.mjs`** Classify task intent, risk, complexity, and tier from prompt + file paths
11
- - **`decide.mjs`** Route to provider/model/tier; handles budget pressure and dual-brain threshold
12
- - **`dispatch.mjs`** Execute the decision: Claude subagent, GPT via Codex, or dual-brain flow
13
-
14
- The hooks layer (`/home/runner/workspace/.claude/hooks/`) wraps these modules for Claude Code integration and is still valid.
15
-
16
- ## CLI Commands
17
-
18
- ```bash
19
- dual-brain init # First-time setup
20
- dual-brain go "task description" # Detect → decide → dispatch
21
- dual-brain go --dry-run "..." # Show routing without executing
22
- dual-brain go --files a.mjs,b.mjs "..." # Provide file context for risk classification
23
- dual-brain status # Provider health, budget pressure, models
24
- dual-brain remember "preference" # Save project-scoped preference
25
- dual-brain forget "preference" # Remove preference by fuzzy match
26
- ```
27
-
28
- ## Tier Routing
29
-
30
- - **Search** (`haiku`): Read-only lookups, grep, explore. Return: files found, line refs, confidence.
31
- - **Execute** (`sonnet`): Edits, tests, git ops. Return: files changed, tests run, edge cases.
32
- - **Think** (main session, Opus): Architecture, review, planning. Return: decision, alternatives, risks.
33
-
34
- ## Dual-Brain Collaboration
35
-
36
- Dual-brain is a multi-round conversation between Claude and GPT — not a single-shot dispatch.
37
-
38
- **Think flow** (architecture decisions):
39
- 1. Round 1: `node .claude/hooks/dual-brain-think.mjs --question "..."` → GPT gives independent analysis
40
- 2. You analyze the same question independently
41
- 3. Round 2: `node .claude/hooks/dual-brain-think.mjs --question "..." --round 2 --claude-says "<your analysis>"` → GPT responds with agreements, pushback, refined recommendation
42
- 4. You synthesize both rounds into a final decision
43
-
44
- **Review flow** (code review):
45
- 1. Round 1: `node .claude/hooks/dual-brain-review.mjs` → GPT reviews the diff independently
46
- 2. You review the same diff independently
47
- 3. Round 2: `node .claude/hooks/dual-brain-review.mjs --round 2 --claude-review "<your findings>"` → GPT confirms shared findings, acknowledges misses
48
- 4. You synthesize into a final review verdict
49
-
50
- ## Routing Rules
51
-
52
- 1. Tasks under 3 min → Claude (Codex startup overhead not worth it)
53
- 2. Isolated tasks over 3 min → check balance: `node .claude/hooks/budget-balancer.mjs`
54
- 3. High-risk decisions → dual-brain think
55
- 4. When a task spans tiers: think > execute > search
56
-
57
- ## Mandatory Workload Distribution
58
-
59
- **Claude MUST follow these rules before implementing multi-file changes:**
60
-
61
- 1. **Before starting any batch of 3+ file edits**: run `node .claude/hooks/budget-balancer.mjs` to check provider balance, then `dual-brain go --dry-run "description"` to classify tasks
62
- 2. **When budget-balancer recommends GPT**: dispatch via `src/dispatch.mjs` (or `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --tier execute`)
63
- 3. **Security/auth/credential changes**: always require dual-brain think flow before implementation
64
- 4. **Audit remediation batches**: plan waves with dual-brain think, dispatch execution to GPT, Claude reviews
65
- 5. **Claude's role in multi-task work**: define acceptance criteria, dispatch agents, review results — not solo-implement everything
66
-
67
- **Triggers that require this workflow:** 3+ production files edited in one session · auth/credentials/tokens/secrets · changes to dispatcher, agent routing, or tier logic · audit remediation across multiple subsystems · Claude think capacity above 60% per budget-balancer.
68
-
69
- **Failure to route is itself a bug.**
70
-
71
- ## Quality Gate
72
-
73
- Before ending a session with code changes:
74
- 1. `node .claude/hooks/session-report.mjs` (allowed by head-guard for hook scripts)
75
- 2. `node .claude/hooks/quality-gate.mjs`
76
-
77
- Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (GPT unavailable).
78
-
79
- ## Profiles
80
-
81
- Profile persists to `.dualbrain/profile.json` (project-scoped, gitignored).
82
-
83
- - **auto** (default): Adapts routing based on task risk, provider health, and outcomes
84
- - **balanced**: Best model per tier, normal budgets, reviews at medium+ risk
85
- - **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
86
- - **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
87
-
88
- Switch via the interactive Profile screen in `dual-brain`, or set `bias` in `.dualbrain/profile.json`.
89
-
90
- ## Adaptive Routing (Auto Mode)
91
-
92
- - **Risk classification**: auth/secrets→critical, billing/migrations→high, tests/utils→medium, docs→low
93
- - **Failure detection**: 2+ failures on same prompt in 2 hours → auto-escalate tier or trigger dual-brain
94
- - **Provider balance**: Routes to underused provider when one subscription is hot
95
- - **Burst awareness**: Suppresses duplicate warnings during agent waves (3+ agents in 90s)
96
-
97
- ## Budget Balancer
98
-
99
- `src/decide.mjs` handles routing decisions using the same token data internally. For inspection:
100
-
101
- ```bash
102
- node .claude/hooks/budget-balancer.mjs
103
- ```
104
-
105
- Tracks 5-hour and 7-day rolling windows against subscription limits (Claude Pro/Max, ChatGPT Plus/Pro). The higher pressure window is the binding constraint. Uses actual `input_tokens + output_tokens` from usage logs.
106
-
107
- **Subscription tiers** (configured in `orchestrator.json` → `subscriptions.*.plan`):
108
- - Claude: Pro $20, Max x5 $100, Max x20 $200
109
- - ChatGPT: Plus $20, Pro $100, Pro $200
110
-
111
- ## Multi-Step Work
112
-
113
- The wave orchestrator is available for complex multi-step tasks:
114
-
115
- ```bash
116
- node .claude/hooks/wave-orchestrator.mjs "fix the login bug and update the nav"
117
- node .claude/hooks/wave-orchestrator.mjs --dry-run "refactor auth module"
118
- node .claude/hooks/wave-orchestrator.mjs --resume <manifestId>
119
- ```
120
-
121
- For most tasks, prefer `dual-brain go "..."` — it runs the same detect→decide→dispatch pipeline with less overhead.
122
-
123
- ## Available Tools
124
-
125
- | Tool | Purpose |
126
- |------|---------|
127
- | `dual-brain go "..."` | Primary entry point: detect, decide, dispatch |
128
- | `dual-brain status` | Provider health, budget, models |
129
- | `node .claude/hooks/budget-balancer.mjs` | Token usage and routing recommendation |
130
- | `node .claude/hooks/dual-brain-think.mjs` | Multi-round architecture decisions with GPT |
131
- | `node .claude/hooks/dual-brain-review.mjs` | Multi-round code review with GPT |
132
- | `node .claude/hooks/wave-orchestrator.mjs "..."` | Dependency-aware multi-wave dispatch |
133
- | `node .claude/hooks/session-report.mjs` | End-of-session summary |
134
- | `node .claude/hooks/quality-gate.mjs` | Gate check before ending session |
135
- | `node .claude/hooks/health-check.mjs` | System health |
136
- | `node .claude/hooks/test-orchestrator.mjs` | Self-tests (40 tests) |
137
- | `node .claude/hooks/vibe-memory.mjs` | Persistent preferences across sessions |
138
- | `dual-brain search "..."` | Search across all previous sessions |
139
-
140
- ## Cross-Session Context
141
-
142
- When the user references past work ("we did this before", "yesterday we worked on", "remember when we", "didn't we already fix"), use the session search to find relevant context:
143
-
144
- 1. Run `dual-brain search "keyword"` to search the session index
145
- 2. Or use the MCP tool `dual_brain_search` if available
146
-
147
- This surfaces previous conversations so HEAD can provide continuity across sessions without the user having to re-explain.
5
+ ## HEAD Constitution
6
+
7
+ HEAD is the orchestration brain. Workers implement. This is enforced by architecture, not just policy.
8
+
9
+ 1. **HEAD plans, workers implement.** HEAD dispatches typed task contracts via agents. HEAD never edits files, runs implementation commands, or writes code directly.
10
+ 2. **Discuss before dispatching.** Every action task starts with intent classification. Ambiguous requests get clarified. Architecture decisions get discussed.
11
+ 3. **Typed contracts are mandatory.** Every dispatch includes: objective, scope, acceptance criteria, risk level, allowed operations. Use `src/templates.mjs` to generate prompts.
12
+ 4. **Dangerous work requires approval.** Auth, credentials, secrets, billing, migrations, destructive git — explicit user confirmation before dispatch.
13
+ 5. **Runtime state is source of truth.** HEAD's state machine (`src/head.mjs`) tracks phase, intent, confidence, and drift. Not CLAUDE.md text.
14
+ 6. **Hooks enforce boundaries.** head-guard blocks HEAD from implementing. enforce-tier ensures correct routing. Telemetry hooks observe but never block.
15
+ 7. **Subscription-only auth.** Users authenticate via `claude login` / `codex login`. No API keys.
16
+
17
+ ## Quick Reference
18
+
19
+ | Command | Purpose |
20
+ |---------|---------|
21
+ | `dual-brain go "..."` | Detect decide → dispatch |
22
+ | `dual-brain status` | Provider health, budget |
23
+ | `dual-brain install --global` | Set dual-brain as default for all sessions |
24
+ | `node .claude/hooks/dual-brain-think.mjs --question "..."` | Multi-round architecture decisions |
25
+ | `node .claude/hooks/dual-brain-review.mjs` | Multi-round code review |
26
+
27
+ ## Modules
28
+
29
+ Core pipeline: `profile.mjs` → `detect.mjs` → `decide.mjs` → `dispatch.mjs` → `pipeline.mjs`
30
+ HEAD brain: `head.mjs` (state machine, intent, confidence, drift)
31
+ Templates: `templates.mjs` (typed prompt generation)
32
+ Integrity: `integrity.mjs` (atomic writes, locks)
33
+ Quality: `prompt-audit.mjs` (prompt scoring, exchange logging)
@@ -35,7 +35,7 @@ import { runPipeline, buildExecutionPlan, formatExecutionPlan } from '../src/pip
35
35
  import { loadRepoCache } from '../src/repo.mjs';
36
36
  import { loadSession, saveSession, formatSessionCard, importReplitSessions, getSessionMeta, saveSessionMeta, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions, archiveSession, getArchivedSessions } from '../src/session.mjs';
37
37
 
38
- import { box, bar, badge, menu, separator } from '../src/tui.mjs';
38
+ import { box, bar, badge, menu, separator, panel, divider, statusChip, headerBar, prompt as tuiPrompt, signalLine } from '../src/tui.mjs';
39
39
 
40
40
  // ─── Dynamic imports for receipts + failure memory ───────────────────────────
41
41
 
@@ -2594,8 +2594,7 @@ async function mainScreen(rl, ask) {
2594
2594
 
2595
2595
  // ── Recent work items (dim, max 3) ────────────────────────────────────────
2596
2596
  const recentLines = recentWorkItems.slice(0, 3).map(item => {
2597
- const prefix = item.ok ? `${GRN}✓${RST}` : `${RED}!${RST}`;
2598
- return ` ${DIM}${prefix} ${item.text}${RST}`;
2597
+ return signalLine(item.ok ? 'success' : 'warning', `${DIM}${item.text}${RST}`);
2599
2598
  });
2600
2599
 
2601
2600
  // ── Resolve dashboard spinner before rendering ────────────────────────────
@@ -2606,26 +2605,48 @@ async function mainScreen(rl, ask) {
2606
2605
  process.stdout.write(`${DIM}${staleCount} stale sessions (>7d) — type "sessions" to manage${RST}\n`);
2607
2606
  }
2608
2607
 
2609
- // ── Render Studio Console ─────────────────────────────────────────────────
2610
- const out = [];
2611
- out.push(''); // breathing room
2612
- out.push(statusBar); // project branch Claude ● GPT ● v0.2.3
2613
- out.push('');
2614
- out.push(mainQuestion); // Resume previous work? / What do you want to build?
2615
- if (lastSummary) out.push(lastSummary);
2616
- out.push(` \x1b[1m›\x1b[0m`); // bright prompt cursor
2617
- out.push('');
2618
- out.push(suggestLine); // contextual suggestions
2608
+ // ── Render Studio Console (paneled layout) ────────────────────────────────
2609
+ const CYAN = '\x1b[36m';
2610
+ const panelW = Math.min(sepW + 2, 72);
2611
+
2612
+ // Header panel — project, branch, providers, version
2613
+ const headerLeft = `${DIM}${projectName}${RST} ${BOLD}${branchStr}${RST} ${DIM}Claude${RST} ${claudeDot} ${DIM}GPT${RST} ${openaiDot}`;
2614
+ const headerRight = `${DIM}v${version}${RST}`;
2615
+ const headerContent = [headerBar(headerLeft, headerRight, panelW - 4)];
2616
+ process.stdout.write('\n' + panel('dual-brain', headerContent, { width: panelW, titleColor: CYAN }) + '\n\n');
2617
+
2618
+ // Resume / prompt panel (only when there is something to show)
2619
+ if (isReturning || !anyProviderAvail) {
2620
+ const resumeContent = [];
2621
+ if (!anyProviderAvail) {
2622
+ resumeContent.push(`${BOLD}Connect a provider to start working${RST}`);
2623
+ } else {
2624
+ const labelTrunc = (resumeState.label || 'last session').slice(0, 45);
2625
+ const agePart = resumeState.ageLabel ? ` · ${resumeState.ageLabel}` : '';
2626
+ const nextPart = resumeState.nextAction ? ` · next: ${resumeState.nextAction}` : '';
2627
+ resumeContent.push(`${DIM}Last task${RST} ${BOLD}${labelTrunc}${RST}${DIM}${agePart}${RST}`);
2628
+ if (nextPart) resumeContent.push(`${DIM}Next step${RST} ${nextPart.replace(/^ · /, '')}`);
2629
+ }
2630
+ resumeContent.push('');
2631
+ resumeContent.push(` ${CYAN}›${RST} ${BOLD}${suggestions[0]}${RST} ${DIM}${suggestions[1] || ''}${RST} ${DIM}${suggestions[2] || ''}${RST}`);
2632
+ process.stdout.write(panel(isReturning ? 'Resume work' : 'Get started', resumeContent, { width: panelW }) + '\n\n');
2633
+ } else {
2634
+ // Fresh / no-resume state — just show suggestions inline
2635
+ const suggestContent = [` ${CYAN}›${RST} ${BOLD}${suggestions[0]}${RST} ${DIM}${suggestions[1] || ''}${RST} ${DIM}${suggestions[2] || ''}${RST}`];
2636
+ process.stdout.write(panel('Get started', suggestContent, { width: panelW }) + '\n\n');
2637
+ }
2638
+
2639
+ // Signals panel — recent work items (only when there are items)
2619
2640
  if (recentLines.length > 0) {
2620
- out.push('');
2621
- out.push(...recentLines); // ✓ / ! recent work items
2641
+ process.stdout.write(panel('Signals', recentLines, { width: panelW }) + '\n\n');
2622
2642
  }
2623
- out.push('');
2624
- out.push(` ${sepLine}`); // ━━━━ separator
2625
- // Input bar rendered inline — the key handler will overwrite this line
2626
- out.push(` ${DIM}> task or command...${RST}${' '.repeat(Math.max(1, sepW - 22))}${DIM}[?] help${RST}`);
2627
2643
 
2628
- process.stdout.write(out.join('\n') + '\n');
2644
+ // Input bar — rendered below panels; the key handler will overwrite this line
2645
+ const inputHint = `${DIM}[?] help${RST}`;
2646
+ const inputLeft = tuiPrompt('task or command...');
2647
+ const inputLeftW = (inputLeft + ' task or command...').replace(/\x1b\[[0-9;]*m/g, '').length;
2648
+ const inputGap = Math.max(1, panelW - inputLeftW - 8);
2649
+ process.stdout.write(` ${inputLeft}${' '.repeat(inputGap)}${inputHint}\n`);
2629
2650
 
2630
2651
  // ── Key handling ──────────────────────────────────────────────────────────
2631
2652
  // Use raw keypress mode so we can show a live type-to-start buffer.
@@ -3563,28 +3584,38 @@ async function settingsScreen(rl, ask) {
3563
3584
  ` ${DIM}Doctor${RESET} ${doctorStr}`,
3564
3585
  ];
3565
3586
 
3566
- // ── Render ────────────────────────────────────────────────────────────────
3567
- const out = [
3568
- '',
3569
- ` ${BOLD}Settings${RESET}`,
3570
- '',
3571
- ` ${DIM}Subscriptions${RESET}`,
3572
- ...subsLines,
3573
- ` ${DIM}[a] add [r] remove [h] health check${RESET}`,
3574
- '',
3575
- ` ${DIM}Work style${RESET}`,
3576
- ...wsLines,
3577
- ` ${DIM}[1-3] change${RESET}`,
3587
+ // ── Render (paneled layout) ───────────────────────────────────────────────
3588
+ const CYAN = '\x1b[36m';
3589
+ const settingsPanelW = 70;
3590
+
3591
+ const subsContent = [
3592
+ ...subsLines.map(l => l.replace(/^ /, '')),
3578
3593
  '',
3579
- ` ${DIM}System${RESET}`,
3580
- ...sysLines,
3581
- ` ${DIM}[d] run doctor [x] diagnostics${RESET}`,
3594
+ signalLine('info', `${DIM}[a] add [r] remove [h] health check${RESET}`),
3595
+ ];
3596
+
3597
+ const wsContent = [
3598
+ ...wsLines.map(l => l.replace(/^ /, '')),
3582
3599
  '',
3583
- ` ${DIM}[e] sessions [m] subscriptions [b] back${RESET}`,
3584
- ...(settingsPRs.length > 0 ? [` ${DIM}[p] PR triage (${settingsPRs.length} open)${RESET}`] : []),
3600
+ signalLine('info', `${DIM}[1-3] change${RESET}`),
3601
+ ];
3602
+
3603
+ const sysContent = [
3604
+ ...sysLines.map(l => l.replace(/^ /, '')),
3585
3605
  '',
3606
+ signalLine('info', `${DIM}[d] run doctor [x] diagnostics${RESET}`),
3586
3607
  ];
3587
- process.stdout.write(out.join('\n') + '\n');
3608
+
3609
+ const navContent = [
3610
+ `${DIM}[e]${RESET} sessions ${DIM}[m]${RESET} subscriptions ${DIM}[b]${RESET} back`,
3611
+ ...(settingsPRs.length > 0 ? [`${DIM}[p]${RESET} PR triage ${DIM}(${settingsPRs.length} open)${RESET}`] : []),
3612
+ ];
3613
+
3614
+ process.stdout.write('\n');
3615
+ process.stdout.write(panel('Subscriptions', subsContent, { width: settingsPanelW, titleColor: CYAN }) + '\n\n');
3616
+ process.stdout.write(panel('Work style', wsContent, { width: settingsPanelW, titleColor: CYAN }) + '\n\n');
3617
+ process.stdout.write(panel('System', sysContent, { width: settingsPanelW, titleColor: CYAN }) + '\n\n');
3618
+ process.stdout.write(panel('Navigation', navContent, { width: settingsPanelW }) + '\n\n');
3588
3619
 
3589
3620
  const raw = (await ask(' Choice: ')).trim();
3590
3621
  const choice = raw.toLowerCase();
@@ -4045,15 +4076,20 @@ async function askDefaultShell(cwd, rl, fx) {
4045
4076
  const cl = fx.colors || {};
4046
4077
  const DIM = cl.dim || '';
4047
4078
  const BOLD = cl.bold || '';
4048
- const GRAY = cl.gray || '';
4079
+ const CYAN = cl.cyan || '\x1b[36m';
4080
+ const YLW = cl.yellow || '\x1b[33m';
4049
4081
  const GREEN = cl.green || '';
4050
4082
  const RST = cl.reset || '';
4051
4083
 
4052
- process.stdout.write('\n');
4053
- process.stdout.write(` ${BOLD}Shell startup${RST}\n\n`);
4054
- process.stdout.write(` ${DIM}dual-brain can start automatically when your shell opens.${RST}\n`);
4055
- process.stdout.write(` ${DIM}This modifies .replit onBoot. You can change it anytime in Settings.${RST}\n\n`);
4056
- process.stdout.write(` ${GRAY}[y]${RST} Yes, set as default ${GRAY}[n]${RST} No, I'll run it manually\n\n`);
4084
+ const setupContent = [
4085
+ `${DIM}Start dual-brain automatically when this Replit opens?${RST}`,
4086
+ '',
4087
+ ` ${DIM}modifies${RST} ${YLW}.replit onBoot${RST}`,
4088
+ ` ${DIM}undo${RST} Settings System Startup`,
4089
+ '',
4090
+ ` ${CYAN}[Y]${RST} Start on boot ${DIM}[n] Run manually${RST}`,
4091
+ ];
4092
+ process.stdout.write('\n' + panel('dual-brain setup', setupContent) + '\n');
4057
4093
 
4058
4094
  const answer = await new Promise(res => rl.question(' ', (a) => res(a.trim().toLowerCase())));
4059
4095
  const yes = !answer || answer.startsWith('y');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,11 @@
27
27
  "./continuity": "./src/continuity.mjs",
28
28
  "./checkpoint": "./src/checkpoint.mjs",
29
29
  "./pr-agent": "./src/pr-agent.mjs",
30
- "./ci-triage": "./src/ci-triage.mjs"
30
+ "./ci-triage": "./src/ci-triage.mjs",
31
+ "./integrity": "./src/integrity.mjs",
32
+ "./prompt-audit": "./src/prompt-audit.mjs",
33
+ "./head": "./src/head.mjs",
34
+ "./templates": "./src/templates.mjs"
31
35
  },
32
36
  "keywords": [
33
37
  "claude-code",
@@ -94,6 +98,10 @@
94
98
  "src/checkpoint.mjs",
95
99
  "src/ci-triage.mjs",
96
100
  "src/pr-agent.mjs",
101
+ "src/integrity.mjs",
102
+ "src/prompt-audit.mjs",
103
+ "src/head.mjs",
104
+ "src/templates.mjs",
97
105
  "bin/*.mjs",
98
106
  "hooks/enforce-tier.mjs",
99
107
  "hooks/cost-logger.mjs",
package/src/dispatch.mjs CHANGED
@@ -527,6 +527,60 @@ function getRetryBudget() {
527
527
  };
528
528
  }
529
529
 
530
+ // ─── Preflight auth check ─────────────────────────────────────────────────────
531
+
532
+ /**
533
+ * Verify a provider CLI is present and (optionally) responds to --version.
534
+ * Uses `which` for the fast path and a 3s-capped --version call to confirm.
535
+ *
536
+ * @param {'claude'|'openai'} provider
537
+ * @param {string} [cwd] Working directory (unused, kept for signature parity)
538
+ * @returns {Promise<{ ready: boolean, provider: string, error?: string, suggestion?: string }>}
539
+ */
540
+ async function preflightAuth(provider, _cwd) {
541
+ const bin = provider === 'openai' ? 'codex' : 'claude';
542
+
543
+ // Fast path: check binary existence with `which`
544
+ const whichResult = await new Promise((resolve) => {
545
+ const p = spawn('which', [bin], { stdio: 'pipe' });
546
+ p.on('error', () => resolve(false));
547
+ p.on('close', (code) => resolve(code === 0));
548
+ setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 2000);
549
+ });
550
+
551
+ if (!whichResult) {
552
+ const installHint = provider === 'openai'
553
+ ? 'Install: npm install -g @openai/codex'
554
+ : 'Install: npm install -g @anthropic-ai/claude-code';
555
+ return {
556
+ ready: false,
557
+ provider,
558
+ error: `${bin} CLI not found in PATH`,
559
+ suggestion: installHint,
560
+ };
561
+ }
562
+
563
+ // Version check: confirms the binary actually runs (catches broken installs)
564
+ const versionOk = await new Promise((resolve) => {
565
+ const p = spawn(bin, ['--version'], { stdio: 'pipe' });
566
+ p.on('error', () => resolve(false));
567
+ p.on('close', (code) => resolve(code === 0));
568
+ setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 3000);
569
+ });
570
+
571
+ if (!versionOk) {
572
+ const loginHint = provider === 'openai' ? 'Run: codex login' : 'Run: claude login';
573
+ return {
574
+ ready: false,
575
+ provider,
576
+ error: `${bin} --version failed (auth may have expired)`,
577
+ suggestion: loginHint,
578
+ };
579
+ }
580
+
581
+ return { ready: true, provider };
582
+ }
583
+
530
584
  // ─── Command builder ──────────────────────────────────────────────────────────
531
585
 
532
586
  function buildCommand(decision, prompt, files = [], _cwd) {
@@ -840,6 +894,34 @@ async function dispatch(input = {}) {
840
894
  }
841
895
  }
842
896
 
897
+ // ── Preflight auth check ─────────────────────────────────────────────────
898
+ // Verify the target provider CLI is present and responsive before dispatching.
899
+ // Runs after model/provider resolution so we check the effective provider.
900
+ const preflight = await preflightAuth(effectiveProvider, cwd);
901
+ if (!preflight.ready) {
902
+ // Check if the other provider is available as a fallback
903
+ const otherProvider = effectiveProvider === 'claude' ? 'openai' : 'claude';
904
+ const otherPreflight = await preflightAuth(otherProvider, cwd);
905
+ const fallbackNote = otherPreflight.ready
906
+ ? ` Fallback available: ${otherProvider}.`
907
+ : '';
908
+ const errMsg = `${preflight.error}. ${preflight.suggestion}${fallbackNote}`;
909
+ return {
910
+ status: 'error',
911
+ provider: effectiveProvider,
912
+ model: effectiveModel,
913
+ command: null,
914
+ exitCode: null,
915
+ summary: errMsg,
916
+ durationMs: 0,
917
+ usage: null,
918
+ error: errMsg,
919
+ authVerified: false,
920
+ suggestion: preflight.suggestion,
921
+ };
922
+ }
923
+ // ── End preflight auth check ─────────────────────────────────────────────
924
+
843
925
  // ── Feature 2: Dirty-worktree guard for execute-tier dispatches ──────────
844
926
  if (tier === 'execute' && decision.owns && !decision._force) {
845
927
  const wtCheck = await checkWorktreeClean(decision.owns, cwd);
@@ -902,6 +984,7 @@ async function dispatch(input = {}) {
902
984
  durationMs: 0,
903
985
  usage: null,
904
986
  error: null,
987
+ authVerified: true,
905
988
  };
906
989
  }
907
990
 
@@ -1014,6 +1097,7 @@ async function dispatch(input = {}) {
1014
1097
  usage,
1015
1098
  worktreeUsed: useWorktree,
1016
1099
  autoReview,
1100
+ authVerified: true,
1017
1101
  error: success ? null : errorText.slice(0, 200),
1018
1102
  };
1019
1103
  }
@@ -1021,7 +1105,7 @@ async function dispatch(input = {}) {
1021
1105
  const command = buildCommand(effectiveDecision, prompt, files, cwd);
1022
1106
 
1023
1107
  if (dryRun) {
1024
- return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
1108
+ return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null, authVerified: true };
1025
1109
  }
1026
1110
 
1027
1111
  // Record this dispatch against the budget
@@ -1130,6 +1214,7 @@ async function dispatch(input = {}) {
1130
1214
  usage,
1131
1215
  worktreeUsed: useWorktree,
1132
1216
  autoReview,
1217
+ authVerified: true,
1133
1218
  error: success ? null : errorText.slice(0, 200),
1134
1219
  };
1135
1220
  }
@@ -1221,4 +1306,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
1221
1306
  }
1222
1307
  }
1223
1308
 
1224
- export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt };
1309
+ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt, preflightAuth };