dual-brain 2.0.0

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 ADDED
@@ -0,0 +1,40 @@
1
+ # Dual-Brain Orchestrator
2
+
3
+ This project uses dual-provider orchestration. Config: `.claude/orchestrator.json`.
4
+
5
+ ## Tier Routing
6
+
7
+ Route subagents by task complexity:
8
+
9
+ - **Search** (`model: "haiku"`): Read-only lookups, grep, explore. Return: files found, line refs, confidence.
10
+ - **Execute** (`model: "sonnet"`): Edits, tests, git ops. Return: files changed, tests run, edge cases.
11
+ - **Think** (main session, Opus): Architecture, review, planning. Return: decision, alternatives, risks.
12
+
13
+ ## GPT Lane
14
+
15
+ For isolated or parallel work, dispatch to GPT via Codex CLI:
16
+
17
+ - `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model gpt-5.4` — execution tasks
18
+ - `node .claude/hooks/dual-brain-think.mjs --question "..."` — dual-perspective decisions
19
+
20
+ ## Routing Rules
21
+
22
+ 1. Tasks under 3 min → Claude (Codex startup overhead not worth it)
23
+ 2. Isolated tasks over 3 min → check balance: `node .claude/hooks/budget-balancer.mjs`
24
+ 3. High-risk decisions → dual-brain think
25
+ 4. When a task spans tiers: think > execute > search
26
+
27
+ ## Quality Gate
28
+
29
+ Before ending a session with code changes:
30
+ 1. Run `node .claude/hooks/session-report.mjs`
31
+ 2. Run `node .claude/hooks/quality-gate.mjs`
32
+
33
+ Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (GPT unavailable).
34
+
35
+ ## Available Tools
36
+
37
+ - `node .claude/hooks/cost-report.mjs` — activity and cost estimates
38
+ - `node .claude/hooks/health-check.mjs` — verify system health
39
+ - `node .claude/hooks/budget-balancer.mjs` — provider balance status
40
+ - `node .claude/hooks/test-orchestrator.mjs` — run self-tests
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1xmint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Dual-Brain Orchestrator
2
+
3
+ Dual-provider orchestration for Claude Code across Claude and OpenAI subscriptions. Routes search work to cheap models, execution to mid-tier, and reserves the most capable models for thinking. Dispatches isolated tasks to GPT via Codex CLI, with dual-brain analysis for high-risk decisions.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npx dual-brain init
9
+ ```
10
+
11
+ Then run the setup wizard:
12
+
13
+ ```bash
14
+ node .claude/hooks/setup-wizard.mjs
15
+ ```
16
+
17
+ Restart your Claude Code session. The wizard configures `orchestrator.json` with the right models and cost rates for your subscription tier.
18
+
19
+ **What the installer does:**
20
+ - Copies 13 hook scripts to `.claude/hooks/`
21
+ - Copies orchestrator config and hookify rules to `.claude/`
22
+ - Registers `enforce-tier.mjs` (PreToolUse) and `cost-logger.mjs` (PostToolUse) in `.claude/settings.json`
23
+ - Creates a `review-rules.md` template for your project-specific GPT review rules
24
+ - Updates `.gitignore` to exclude usage logs and review artifacts
25
+
26
+ ## How it works
27
+
28
+ Two hooks are registered in `.claude/settings.json` and run automatically:
29
+
30
+ - **enforce-tier.mjs** (PreToolUse on Agent): Classifies agent tasks by keyword, advises the correct model tier, detects duplicates, and suggests cross-provider routing
31
+ - **cost-logger.mjs** (PostToolUse on all tools): Logs usage data to daily rotated files for cost tracking
32
+
33
+ Three hookify rules in `.claude/hookify.orchestrator-*.local.md` provide session-level guidance:
34
+
35
+ - **Route**: Reminds the session to delegate subagents at the right tier
36
+ - **Gate**: Catches code changes that weren't reviewed before the session ends
37
+ - **Cost**: Checks that dispatched subagents use the correct model tier
38
+
39
+ ## Scripts
40
+
41
+ | Script | Purpose |
42
+ |--------|---------|
43
+ | `hooks/setup-wizard.mjs` | Interactive setup — configure your subscription and preferences |
44
+ | `hooks/cost-report.mjs` | Activity & cost estimates by model tier |
45
+ | `hooks/dual-brain-review.mjs` | Send current git diff to GPT for independent review |
46
+ | `hooks/dual-brain-think.mjs` | Dual-perspective analysis on architecture decisions |
47
+ | `hooks/quality-gate.mjs` | Config-driven quality gate with sensitivity scoring |
48
+ | `hooks/budget-balancer.mjs` | Show provider balance and routing recommendations |
49
+ | `hooks/gpt-work-dispatcher.mjs` | Dispatch execution tasks to GPT via Codex CLI |
50
+ | `hooks/session-report.mjs` | Session-end summary: activity, routing compliance, quality gate |
51
+ | `hooks/health-check.mjs` | Verify all hooks and dependencies are configured |
52
+ | `hooks/test-orchestrator.mjs` | Self-test harness — validates all hooks work correctly |
53
+ | `hooks/install-git-hooks.mjs` | Install a git pre-commit hook for the quality gate |
54
+ | `hooks/enforce-tier.mjs` | PreToolUse hook — enforces model tier routing (automatic) |
55
+ | `hooks/cost-logger.mjs` | PostToolUse hook — logs usage data (automatic) |
56
+
57
+ ## Model Intelligence
58
+
59
+ The `model_intelligence` section in `orchestrator.json` provides per-model metadata: strengths, weaknesses, best-for/avoid-for task guidance, context windows, and Codex compatibility. The `enforce-tier.mjs` hook reads this to give context-aware routing advice.
60
+
61
+ ## Customize
62
+
63
+ - `orchestrator.json` — subscriptions, tiers, quality gate, routing rules, budgets
64
+ - `.claude/review-rules.md` — project-specific rules injected into GPT review prompts
65
+ - `.claude/settings.json` — hook registrations (auto-generated by installer)
66
+
67
+ ## Requirements
68
+
69
+ - Node 20+
70
+ - Codex CLI (optional) — for GPT-lane features: `npm i -g @openai/codex` then `codex login`. Falls back to `OPENAI_API_KEY` env var. Without Codex, Claude-lane features work normally.
71
+
72
+ ## Works with any subscription
73
+
74
+ The setup wizard supports any combination:
75
+ - Claude only ($20 Pro / $100 Max / $200 Max / API)
76
+ - OpenAI only ($20 Plus / $100 Pro)
77
+ - Both providers (recommended for dual-brain features)
78
+
79
+ Without an OpenAI subscription, GPT-lane features gracefully degrade — all work routes through Claude.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: orchestrator-cost-check
3
+ enabled: true
4
+ event: all
5
+ tool_matcher: Agent
6
+ action: warn
7
+ conditions:
8
+ - field: tool_name
9
+ operator: equals
10
+ pattern: Agent
11
+ ---
12
+
13
+ **[Cost]** Subagent dispatched. Verify you selected the right tier:
14
+ - `model: "haiku"` for search/exploration
15
+ - `model: "sonnet"` for execution/implementation
16
+ - No model param (inherit Opus) only for thinking tasks
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: orchestrator-quality-gate
3
+ enabled: true
4
+ event: stop
5
+ action: warn
6
+ conditions:
7
+ - field: transcript
8
+ operator: regex_match
9
+ pattern: (Edit|Write|MultiEdit).+\.(ts|tsx|js|jsx|py|rs|go|java|rb|swift|kt)
10
+ ---
11
+
12
+ **[Quality Gate]** Before ending this session:
13
+ 1. Run `node .claude/hooks/session-report.mjs` to see the session summary
14
+ 2. Run `node .claude/hooks/quality-gate.mjs` and check the output:
15
+ - `gate: "pass"` — safe to end
16
+ - `gate: "issues_found"` — address flagged issues first
17
+ - `gate: "needs_human_review"` — GPT unavailable, review diff manually
18
+ - `gate: "disabled"` — gate is off in config
19
+ Do NOT skip these steps.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: orchestrator-routing
3
+ enabled: true
4
+ event: prompt
5
+ action: warn
6
+ conditions:
7
+ - field: user_prompt
8
+ operator: regex_match
9
+ pattern: .+
10
+ ---
11
+
12
+ **[Tier Router]** Route work across both providers (config: `.claude/orchestrator.json`):
13
+
14
+ **Claude lane** (fast, interactive):
15
+ - Search (`model: "haiku"`): Read-only lookups, grep, explore. Agent must return: files found, line refs, confidence.
16
+ - Execute (`model: "sonnet"`): Edits, tests, git ops. Agent must return: files changed, tests run, edge cases.
17
+ - Think (main session): Architecture, review, planning. Agent must return: decision, alternatives, risks.
18
+
19
+ **GPT lane** (parallel, isolated work):
20
+ - Use `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model gpt-5.4` for isolated execution
21
+ - Use `node .claude/hooks/dual-brain-think.mjs --question "..."` for dual-perspective decisions
22
+
23
+ **Routing:** Tasks <3min → Claude. Isolated tasks >3min → check balance first (`node .claude/hooks/budget-balancer.mjs`). High-risk → dual-brain. Think > execute > search when task spans tiers.
@@ -0,0 +1,463 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * budget-balancer.mjs — Core budget balancing module for the Dual-Brain Orchestrator.
4
+ *
5
+ * Tracks rolling usage pressure across Claude and OpenAI providers and recommends
6
+ * which provider should handle incoming work.
7
+ *
8
+ * Exported API:
9
+ * getProviderStatus() → current pressure per provider/tier
10
+ * chooseProvider(taskProfile) → recommended provider + model + rationale
11
+ * recordUsageEvent(event) → append a usage event to today's log
12
+ *
13
+ * Also works as a standalone CLI: node .claude/hooks/budget-balancer.mjs
14
+ */
15
+
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
17
+ import { dirname, join } from "path";
18
+ import { fileURLToPath } from "url";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Paths
22
+ // ---------------------------------------------------------------------------
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const ORCHESTRATOR_CONFIG = join(__dirname, "..", "orchestrator.json");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Rolling window for pressure calculation (milliseconds) */
31
+ const WINDOW_MS = 5 * 60 * 60 * 1000; // 5 hours
32
+
33
+ /**
34
+ * Rough per-tier token budgets per 5-hour window.
35
+ * Based on $100/month Claude Max 5x and OpenAI Pro subscription estimates.
36
+ * These are approximations — the real limit is monthly, distributed evenly.
37
+ */
38
+ const WINDOW_BUDGETS = {
39
+ claude: {
40
+ think: 500_000, // Opus — costly, use sparingly
41
+ execute: 2_000_000, // Sonnet — primary workhorse
42
+ search: 5_000_000, // Haiku — cheap, generous budget
43
+ },
44
+ openai: {
45
+ think: 500_000, // gpt-5.5
46
+ execute: 2_000_000, // gpt-5.4
47
+ search: 5_000_000, // gpt-4.1-mini
48
+ },
49
+ };
50
+
51
+ /** Estimated tokens consumed per call, by tier */
52
+ const TOKENS_PER_CALL = {
53
+ search: 2_500,
54
+ execute: 5_500,
55
+ think: 11_000,
56
+ };
57
+
58
+ /** Default pressure thresholds (fraction 0–1) */
59
+ const DEFAULT_THRESHOLDS = {
60
+ warm: 0.65,
61
+ hot: 0.82,
62
+ throttled: 0.95,
63
+ };
64
+
65
+ /** Default model mapping when orchestrator.json is missing provider config */
66
+ const DEFAULT_MODELS = {
67
+ claude: { think: "opus", execute: "sonnet", search: "haiku" },
68
+ openai: { think: "gpt-5.5", execute: "gpt-5.4", search: "gpt-4.1-mini" },
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Config helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function loadConfig() {
76
+ try {
77
+ return JSON.parse(readFileSync(ORCHESTRATOR_CONFIG, "utf8"));
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ function getThresholds(config, provider) {
84
+ return (
85
+ config?.providers?.[provider]?.pressure_thresholds || DEFAULT_THRESHOLDS
86
+ );
87
+ }
88
+
89
+ function getProviderModels(config, provider) {
90
+ return config?.providers?.[provider]?.models || DEFAULT_MODELS[provider];
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Provider / tier detection from model name
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Given a model string, return { provider, tier } or null if unrecognised.
99
+ */
100
+ function classifyModel(model) {
101
+ if (!model) return null;
102
+ const m = String(model).toLowerCase();
103
+
104
+ if (m.includes("opus")) return { provider: "claude", tier: "think" };
105
+ if (m.includes("sonnet")) return { provider: "claude", tier: "execute" };
106
+ if (m.includes("haiku")) return { provider: "claude", tier: "search" };
107
+ if (m.includes("gpt-5.5") || m.includes("gpt4.5")) return { provider: "openai", tier: "think" };
108
+ if (m.includes("gpt-5.4") || (m.includes("gpt-4.1") && !m.includes("mini"))) return { provider: "openai", tier: "execute" };
109
+ if (m.includes("mini")) return { provider: "openai", tier: "search" };
110
+
111
+ return null;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Usage log helpers
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function usageFilePath(date) {
119
+ const d = date || new Date().toISOString().slice(0, 10);
120
+ return join(__dirname, `usage-${d}.jsonl`);
121
+ }
122
+
123
+ /**
124
+ * Read all usage entries from the last `WINDOW_MS` milliseconds.
125
+ * Scans today's (and optionally yesterday's) log file.
126
+ */
127
+ function readRecentEntries() {
128
+ const now = Date.now();
129
+ const cutoff = now - WINDOW_MS;
130
+
131
+ const entries = [];
132
+
133
+ // Check today's and yesterday's files to cover the rolling window boundary
134
+ const today = new Date().toISOString().slice(0, 10);
135
+ const yesterday = new Date(now - 86_400_000).toISOString().slice(0, 10);
136
+
137
+ for (const date of [yesterday, today]) {
138
+ const file = usageFilePath(date);
139
+ if (!existsSync(file)) continue;
140
+ let raw;
141
+ try {
142
+ raw = readFileSync(file, "utf8");
143
+ } catch {
144
+ continue;
145
+ }
146
+ for (const line of raw.split("\n")) {
147
+ if (!line.trim()) continue;
148
+ let record;
149
+ try {
150
+ record = JSON.parse(line);
151
+ } catch {
152
+ continue;
153
+ }
154
+ const ts = Date.parse(record.timestamp);
155
+ if (!isNaN(ts) && ts >= cutoff) {
156
+ entries.push(record);
157
+ }
158
+ }
159
+ }
160
+
161
+ return entries;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Exported: getProviderStatus()
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Compute rolling 5-hour pressure for each provider/tier combination.
170
+ *
171
+ * @returns {object} Status keyed by provider → tier → { pressure, state, calls, estTokens }
172
+ */
173
+ function getProviderStatus() {
174
+ const config = loadConfig();
175
+
176
+ const entries = readRecentEntries();
177
+
178
+ // Accumulate call counts per provider/tier
179
+ const counts = {
180
+ claude: { think: 0, execute: 0, search: 0 },
181
+ openai: { think: 0, execute: 0, search: 0 },
182
+ };
183
+
184
+ for (const entry of entries) {
185
+ // Determine provider/tier either from stored `provider` field or by classifying model
186
+ let provider = entry.provider;
187
+ let tier = entry.tier;
188
+
189
+ if (!provider && entry.model) {
190
+ const classified = classifyModel(entry.model);
191
+ if (classified) {
192
+ provider = classified.provider;
193
+ tier = classified.tier;
194
+ }
195
+ }
196
+
197
+ if (provider && tier && counts[provider] && counts[provider][tier] !== undefined) {
198
+ counts[provider][tier]++;
199
+ }
200
+ }
201
+
202
+ // Build status object
203
+ const status = {};
204
+
205
+ for (const provider of ["claude", "openai"]) {
206
+ const thresholds = getThresholds(config, provider);
207
+ status[provider] = {};
208
+
209
+ for (const tier of ["think", "execute", "search"]) {
210
+ const calls = counts[provider][tier];
211
+ const estTokens = calls * TOKENS_PER_CALL[tier];
212
+ const budget = WINDOW_BUDGETS[provider][tier];
213
+ const pressure = budget > 0 ? estTokens / budget : 0;
214
+
215
+ let state;
216
+ if (pressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
217
+ state = "throttled";
218
+ } else if (pressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
219
+ state = "hot";
220
+ } else if (pressure >= (thresholds.warm ?? DEFAULT_THRESHOLDS.warm)) {
221
+ state = "warm";
222
+ } else {
223
+ state = "healthy";
224
+ }
225
+
226
+ status[provider][tier] = { pressure, state, calls, estTokens };
227
+ }
228
+ }
229
+
230
+ return status;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Exported: chooseProvider(taskProfile)
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Recommend a provider for an incoming task.
239
+ *
240
+ * @param {object} taskProfile
241
+ * @param {string} taskProfile.tier - search | execute | think
242
+ * @param {number} [taskProfile.estimatedDurationMs] - expected task duration
243
+ * @param {string} [taskProfile.contextCoupling] - low | medium | high
244
+ * @param {string} [taskProfile.isolation] - low | medium | high
245
+ * @returns {{ provider, model, reason, scores }}
246
+ */
247
+ function chooseProvider(taskProfile = {}) {
248
+ const {
249
+ tier = "execute",
250
+ estimatedDurationMs = 0,
251
+ contextCoupling = "low",
252
+ isolation = "low",
253
+ } = taskProfile;
254
+
255
+ const config = loadConfig();
256
+ const status = getProviderStatus();
257
+
258
+ const PRESSURE_PENALTY = {
259
+ healthy: 0,
260
+ warm: 15,
261
+ hot: 40,
262
+ throttled: 100,
263
+ };
264
+
265
+ const scores = {};
266
+
267
+ for (const provider of ["claude", "openai"]) {
268
+ const tierStatus = status[provider]?.[tier] || { pressure: 0, state: "healthy" };
269
+ const otherProvider = provider === "claude" ? "openai" : "claude";
270
+ const otherTierStatus = status[otherProvider]?.[tier] || { pressure: 0, state: "healthy" };
271
+
272
+ // Base score
273
+ let score = 50;
274
+
275
+ // Task-fit score
276
+ if (provider === "claude") {
277
+ if (contextCoupling === "high") score += 20;
278
+ else if (contextCoupling === "medium") score += 10;
279
+ } else {
280
+ // openai
281
+ if (isolation === "high") score += 20;
282
+ else if (isolation === "medium") score += 10;
283
+ }
284
+
285
+ // Pressure penalty
286
+ score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
287
+
288
+ // Latency penalty (OpenAI only — Codex has higher startup overhead)
289
+ if (provider === "openai") {
290
+ if (estimatedDurationMs < 180_000) {
291
+ score -= 25; // < 3 min: overhead not worth it
292
+ } else if (estimatedDurationMs < 600_000) {
293
+ score -= 10; // < 10 min: minor penalty
294
+ }
295
+ // >= 10 min: no penalty
296
+ }
297
+
298
+ // Underused bonus
299
+ if (
300
+ tierStatus.pressure < 0.3 &&
301
+ otherTierStatus.pressure > 0.5
302
+ ) {
303
+ score += 20;
304
+ }
305
+
306
+ scores[provider] = Math.round(score);
307
+ }
308
+
309
+ const winner = scores.claude >= scores.openai ? "claude" : "openai";
310
+ const loser = winner === "claude" ? "openai" : "claude";
311
+
312
+ // Resolve model name
313
+ const models = getProviderModels(config, winner);
314
+ const model = models?.[tier] || DEFAULT_MODELS[winner][tier];
315
+
316
+ // Build human reason string
317
+ const winnerPressure = (status[winner]?.[tier]?.pressure ?? 0).toFixed(2);
318
+ const loserPressure = (status[loser]?.[tier]?.pressure ?? 0).toFixed(2);
319
+
320
+ let reasonParts = [];
321
+ if (winner === "claude" && contextCoupling !== "low") {
322
+ reasonParts.push(`high session context coupling`);
323
+ }
324
+ if (winner === "openai" && isolation !== "low") {
325
+ reasonParts.push(`isolated task`);
326
+ }
327
+ if (parseFloat(winnerPressure) < parseFloat(loserPressure)) {
328
+ reasonParts.push(`${winner} pressure lower (${winnerPressure} vs ${loserPressure})`);
329
+ }
330
+ if (!reasonParts.length) {
331
+ reasonParts.push(`${winner} scored higher (${scores[winner]} vs ${scores[loser]})`);
332
+ }
333
+
334
+ return {
335
+ provider: winner,
336
+ model,
337
+ reason: reasonParts.join(", "),
338
+ scores,
339
+ };
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Exported: recordUsageEvent(event)
344
+ // ---------------------------------------------------------------------------
345
+
346
+ /**
347
+ * Append a usage event to today's daily log file.
348
+ * Automatically adds `provider` field if not present.
349
+ *
350
+ * @param {object} event - Usage event (see cost-logger.mjs schema)
351
+ */
352
+ function recordUsageEvent(event = {}) {
353
+ // Infer provider from model name if not supplied
354
+ let provider = event.provider;
355
+ if (!provider && event.model) {
356
+ const classified = classifyModel(event.model);
357
+ provider = classified?.provider || "claude";
358
+ }
359
+ if (!provider) provider = "claude";
360
+
361
+ const entry = JSON.stringify({
362
+ schema_version: 2,
363
+ timestamp: event.timestamp || new Date().toISOString(),
364
+ provider,
365
+ ...event,
366
+ });
367
+
368
+ const file = usageFilePath();
369
+ mkdirSync(dirname(file), { recursive: true });
370
+
371
+ try {
372
+ appendFileSync(file, entry + "\n", { encoding: "utf8", flag: "a" });
373
+ } catch (err) {
374
+ // Non-fatal — log to stderr but don't crash callers
375
+ process.stderr.write(`[budget-balancer] Failed to write usage event: ${err.message}\n`);
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // CLI rendering helpers
381
+ // ---------------------------------------------------------------------------
382
+
383
+ function pressureBar(pressure, width = 10) {
384
+ const filled = Math.min(width, Math.round(pressure * width));
385
+ return "█".repeat(filled) + "░".repeat(width - filled);
386
+ }
387
+
388
+ function stateLabel(state) {
389
+ return state.padEnd(8);
390
+ }
391
+
392
+ function formatPercent(pressure) {
393
+ return String(Math.round(pressure * 100)).padStart(3) + "%";
394
+ }
395
+
396
+ function printStatusTable(status) {
397
+ const LINE_WIDTH = 50;
398
+ const border = "═".repeat(LINE_WIDTH - 2);
399
+ const blank = " ".repeat(LINE_WIDTH - 4);
400
+
401
+ const h = (text) => {
402
+ const padded = ` ${text}`.padEnd(LINE_WIDTH - 4);
403
+ return `║ ${padded} ║`;
404
+ };
405
+ const row = (label, tier) => {
406
+ const s = status[label]?.[tier] || { pressure: 0, state: "healthy" };
407
+ const bar = pressureBar(s.pressure);
408
+ const pct = formatPercent(s.pressure);
409
+ const lbl = stateLabel(s.state);
410
+ const line = ` ${tier.charAt(0).toUpperCase() + tier.slice(1).padEnd(7)}: ${bar} ${pct} ${lbl}`;
411
+ return h(line);
412
+ };
413
+
414
+ const config = loadConfig();
415
+ const claudePlan = config?.subscriptions?.claude?.plan ? `Claude Max ${config.subscriptions.claude.plan}` : "Claude Max $100";
416
+ const openaiPlan = config?.subscriptions?.openai?.plan ? `OpenAI Pro ${config.subscriptions.openai.plan}` : "OpenAI Pro $100";
417
+
418
+ // Recommendation
419
+ const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
420
+ const recText = `Route execution to ${rec.provider === "openai" ? "OpenAI" : "Claude"}`;
421
+
422
+ const lines = [
423
+ `╔${border}╗`,
424
+ h(" Provider Balance Status "),
425
+ `╠${border}╣`,
426
+ h(claudePlan),
427
+ row("claude", "think"),
428
+ row("claude", "execute"),
429
+ row("claude", "search"),
430
+ h(blank),
431
+ h(openaiPlan),
432
+ row("openai", "think"),
433
+ row("openai", "execute"),
434
+ row("openai", "search"),
435
+ `╠${border}╣`,
436
+ h(`Recommendation: ${recText}`),
437
+ `╚${border}╝`,
438
+ ];
439
+
440
+ console.log(lines.join("\n"));
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // CLI entry point
445
+ // ---------------------------------------------------------------------------
446
+
447
+ async function main() {
448
+ const status = getProviderStatus();
449
+ printStatusTable(status);
450
+ }
451
+
452
+ // Run as CLI only when invoked directly
453
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
454
+ main().catch((err) => {
455
+ process.stderr.write(`[budget-balancer] ${err.message}\n`);
456
+ process.exit(1);
457
+ });
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Exports
462
+ // ---------------------------------------------------------------------------
463
+ export { getProviderStatus, chooseProvider, recordUsageEvent };