dual-brain 7.1.21 → 7.1.23

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.
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * budget-balancer.mjs — Core budget balancing module for the Dual-Brain Orchestrator.
3
+ * budget-balancer.mjs — Session-level provider balance tracker for the Dual-Brain Orchestrator.
4
4
  *
5
- * Tracks rolling usage pressure across Claude and OpenAI providers and recommends
6
- * which provider should handle incoming work.
5
+ * Tracks relative usage of Claude vs OpenAI within the current session (5-hour window)
6
+ * and recommends which provider to use next based on imbalance — not fake subscription math.
7
7
  *
8
8
  * Exported API:
9
- * getProviderStatus() → current pressure per provider/tier
9
+ * getProviderStatus() → session call counts and lean direction per provider/tier
10
10
  * chooseProvider(taskProfile) → recommended provider + model + rationale
11
11
  * recordUsageEvent(event) → append a usage event to today's log
12
12
  *
@@ -28,26 +28,6 @@ const ORCHESTRATOR_CONFIG = join(__dirname, "..", "orchestrator.json");
28
28
  // ---------------------------------------------------------------------------
29
29
 
30
30
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
31
- const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
32
-
33
- /**
34
- * Subscription tier definitions with real token budgets.
35
- * Token limits are per-model-class per rolling window.
36
- * Sources: Anthropic pricing page, OpenAI subscription docs (May 2025).
37
- * These are best-effort estimates — providers adjust limits dynamically.
38
- */
39
- const SUBSCRIPTION_TIERS = {
40
- claude: {
41
- "$20": { label: "Claude Pro $20", fiveHr: { think: 22_000, execute: 80_000, search: 300_000 }, weekly: { think: 150_000, execute: 600_000, search: 2_000_000 } },
42
- "$100": { label: "Claude Max x5", fiveHr: { think: 88_000, execute: 350_000, search: 1_200_000 }, weekly: { think: 600_000, execute: 2_500_000, search: 8_000_000 } },
43
- "$200": { label: "Claude Max x20", fiveHr: { think: 220_000, execute: 900_000, search: 3_000_000 }, weekly: { think: 1_500_000, execute: 6_000_000, search: 20_000_000 } },
44
- },
45
- openai: {
46
- "$20": { label: "ChatGPT Plus $20", fiveHr: { think: 20_000, execute: 80_000, search: 300_000 }, weekly: { think: 140_000, execute: 560_000, search: 2_000_000 } },
47
- "$100": { label: "ChatGPT Pro $100", fiveHr: { think: 50_000, execute: 200_000, search: 800_000 }, weekly: { think: 350_000, execute: 1_400_000, search: 5_000_000 } },
48
- "$200": { label: "ChatGPT Pro $200", fiveHr: { think: 100_000, execute: 400_000, search: 1_500_000 }, weekly: { think: 700_000, execute: 2_800_000, search: 10_000_000 } },
49
- },
50
- };
51
31
 
52
32
  /** Fallback tokens-per-call when usage log has no real token data for an entry */
53
33
  const TOKENS_PER_CALL_FALLBACK = {
@@ -56,23 +36,6 @@ const TOKENS_PER_CALL_FALLBACK = {
56
36
  think: 15_000,
57
37
  };
58
38
 
59
- function getSubscriptionBudgets(config) {
60
- const claudePlan = config?.subscriptions?.claude?.plan || "$100";
61
- const openaiPlan = config?.subscriptions?.openai?.plan || "$20";
62
- const claudeTier = SUBSCRIPTION_TIERS.claude[claudePlan] || SUBSCRIPTION_TIERS.claude["$100"];
63
- const openaiTier = SUBSCRIPTION_TIERS.openai[openaiPlan] || SUBSCRIPTION_TIERS.openai["$20"];
64
- return {
65
- claude: { fiveHr: claudeTier.fiveHr, weekly: claudeTier.weekly, label: claudeTier.label },
66
- openai: { fiveHr: openaiTier.fiveHr, weekly: openaiTier.weekly, label: openaiTier.label },
67
- };
68
- }
69
-
70
- const DEFAULT_THRESHOLDS = {
71
- warm: 0.55,
72
- hot: 0.75,
73
- throttled: 0.90,
74
- };
75
-
76
39
  /** Default model mapping when orchestrator.json is missing provider config */
77
40
  const DEFAULT_MODELS = {
78
41
  claude: { think: "opus", execute: "sonnet", search: "haiku" },
@@ -91,12 +54,6 @@ function loadConfig() {
91
54
  }
92
55
  }
93
56
 
94
- function getThresholds(config, provider) {
95
- return (
96
- config?.providers?.[provider]?.pressure_thresholds || DEFAULT_THRESHOLDS
97
- );
98
- }
99
-
100
57
  function getProviderModels(config, provider) {
101
58
  return config?.providers?.[provider]?.models || DEFAULT_MODELS[provider];
102
59
  }
@@ -212,30 +169,22 @@ function readEntriesInWindow(windowMs) {
212
169
  return entries;
213
170
  }
214
171
 
215
- function readRecentEntries() {
216
- return readEntriesInWindow(FIVE_HOURS_MS);
217
- }
218
-
219
172
  // ---------------------------------------------------------------------------
220
- // Exported: getProviderStatus()
173
+ // Session usage aggregation
221
174
  // ---------------------------------------------------------------------------
222
175
 
223
176
  /**
224
- * Sum actual tokens from usage entries for a provider/tier.
225
- * Uses real input_tokens + output_tokens when available, falls back to estimate.
177
+ * Count calls and tokens per provider/tier from usage entries.
178
+ * Returns raw counts only no percentage math against unknowable quota.
226
179
  */
227
- function sumTokens(entries) {
228
- const tokens = {
229
- claude: { think: 0, execute: 0, search: 0 },
230
- openai: { think: 0, execute: 0, search: 0 },
231
- };
180
+ function aggregateUsage(entries) {
232
181
  const calls = {
233
- claude: { think: 0, execute: 0, search: 0 },
234
- openai: { think: 0, execute: 0, search: 0 },
182
+ claude: { think: 0, execute: 0, search: 0, total: 0 },
183
+ openai: { think: 0, execute: 0, search: 0, total: 0 },
235
184
  };
236
- const realTokenCalls = {
237
- claude: { think: 0, execute: 0, search: 0 },
238
- openai: { think: 0, execute: 0, search: 0 },
185
+ const tokens = {
186
+ claude: { think: 0, execute: 0, search: 0, total: 0 },
187
+ openai: { think: 0, execute: 0, search: 0, total: 0 },
239
188
  };
240
189
 
241
190
  for (const entry of entries) {
@@ -250,85 +199,61 @@ function sumTokens(entries) {
250
199
  }
251
200
  }
252
201
 
253
- if (!provider || !tier || !tokens[provider] || tokens[provider][tier] === undefined) continue;
202
+ if (!provider || !calls[provider]) continue;
203
+ const t = (tier && calls[provider][tier] !== undefined) ? tier : null;
254
204
 
255
- calls[provider][tier]++;
205
+ calls[provider].total++;
206
+ if (t) calls[provider][t]++;
256
207
 
257
208
  const inp = entry.input_tokens;
258
209
  const out = entry.output_tokens;
259
- if (inp != null && out != null && (inp > 0 || out > 0)) {
260
- tokens[provider][tier] += inp + out;
261
- realTokenCalls[provider][tier]++;
262
- } else {
263
- tokens[provider][tier] += TOKENS_PER_CALL_FALLBACK[tier] || 8_000;
264
- }
210
+ const tokCount = (inp != null && out != null && (inp > 0 || out > 0))
211
+ ? inp + out
212
+ : TOKENS_PER_CALL_FALLBACK[t] || 8_000;
213
+
214
+ tokens[provider].total += tokCount;
215
+ if (t) tokens[provider][t] += tokCount;
265
216
  }
266
217
 
267
- return { tokens, calls, realTokenCalls };
218
+ return { calls, tokens };
268
219
  }
269
220
 
270
221
  /**
271
- * Compute rolling pressure for each provider/tier using actual token sums
272
- * against real subscription budgets. Returns both 5hr and weekly pressure.
273
- *
274
- * @returns {object} Status keyed by provider → tier → { pressure, weeklyPressure, state, calls, tokens, budget, weeklyBudget }
222
+ * Determine lean direction: which provider has been used more this session.
223
+ * Returns "claude", "openai", or "balanced".
275
224
  */
276
- function getProviderStatus() {
277
- const config = loadConfig();
278
- const budgets = getSubscriptionBudgets(config);
279
-
280
- const fiveHrEntries = readEntriesInWindow(FIVE_HOURS_MS);
281
- const weeklyEntries = readEntriesInWindow(SEVEN_DAYS_MS);
282
-
283
- const fiveHr = sumTokens(fiveHrEntries);
284
- const weekly = sumTokens(weeklyEntries);
285
-
286
- const status = {};
287
-
288
- for (const provider of ["claude", "openai"]) {
289
- const thresholds = getThresholds(config, provider);
290
- status[provider] = {};
291
-
292
- for (const tier of ["think", "execute", "search"]) {
293
- const tokensUsed = fiveHr.tokens[provider][tier];
294
- const budget = budgets[provider].fiveHr[tier];
295
- const pressure = budget > 0 ? tokensUsed / budget : 0;
296
-
297
- const weeklyTokens = weekly.tokens[provider][tier];
298
- const weeklyBudget = budgets[provider].weekly[tier];
299
- const weeklyPressure = weeklyBudget > 0 ? weeklyTokens / weeklyBudget : 0;
300
-
301
- const effectivePressure = Math.max(pressure, weeklyPressure);
302
-
303
- let state;
304
- if (effectivePressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
305
- state = "throttled";
306
- } else if (effectivePressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
307
- state = "hot";
308
- } else if (effectivePressure >= (thresholds.warm ?? DEFAULT_THRESHOLDS.warm)) {
309
- state = "warm";
310
- } else {
311
- state = "healthy";
312
- }
225
+ function sessionLean(calls) {
226
+ const c = calls.claude.total;
227
+ const o = calls.openai.total;
228
+ const total = c + o;
229
+ if (total === 0) return "balanced";
230
+ const claudeShare = c / total;
231
+ if (claudeShare > 0.65) return "claude";
232
+ if (claudeShare < 0.35) return "openai";
233
+ return "balanced";
234
+ }
313
235
 
314
- status[provider][tier] = {
315
- pressure,
316
- weeklyPressure,
317
- effectivePressure,
318
- state,
319
- calls: fiveHr.calls[provider][tier],
320
- tokens: tokensUsed,
321
- budget,
322
- weeklyTokens,
323
- weeklyBudget,
324
- realTokenCalls: fiveHr.realTokenCalls[provider][tier],
325
- };
326
- }
236
+ // ---------------------------------------------------------------------------
237
+ // Exported: getProviderStatus()
238
+ // ---------------------------------------------------------------------------
327
239
 
328
- status[provider]._label = budgets[provider].label;
329
- }
240
+ /**
241
+ * Return session-level usage summary per provider/tier.
242
+ * No subscription quota math — just raw counts from the 5-hour window.
243
+ *
244
+ * @returns {object} { claude: { calls, tokens, lean }, openai: { calls, tokens, lean }, sessionLean }
245
+ */
246
+ function getProviderStatus() {
247
+ const entries = readEntriesInWindow(FIVE_HOURS_MS);
248
+ const { calls, tokens } = aggregateUsage(entries);
249
+ const lean = sessionLean(calls);
330
250
 
331
- return status;
251
+ return {
252
+ claude: { calls: calls.claude, tokens: tokens.claude },
253
+ openai: { calls: calls.openai, tokens: tokens.openai },
254
+ sessionLean: lean,
255
+ totalCalls: calls.claude.total + calls.openai.total,
256
+ };
332
257
  }
333
258
 
334
259
  // ---------------------------------------------------------------------------
@@ -336,7 +261,8 @@ function getProviderStatus() {
336
261
  // ---------------------------------------------------------------------------
337
262
 
338
263
  /**
339
- * Recommend a provider for an incoming task.
264
+ * Recommend a provider for an incoming task based on session imbalance,
265
+ * task characteristics, and profile bias.
340
266
  *
341
267
  * @param {object} taskProfile
342
268
  * @param {string} taskProfile.tier - search | execute | think
@@ -367,34 +293,39 @@ function chooseProvider(taskProfile = {}) {
367
293
  }
368
294
  } catch {}
369
295
 
370
- const PRESSURE_PENALTY = {
371
- healthy: 0,
372
- warm: 15,
373
- hot: 40,
374
- throttled: 100,
375
- };
296
+ const claudeCalls = status.claude.calls.total;
297
+ const openaiCalls = status.openai.calls.total;
298
+ const totalCalls = claudeCalls + openaiCalls;
376
299
 
377
300
  const scores = {};
378
301
 
379
302
  for (const provider of ["claude", "openai"]) {
380
- const tierStatus = status[provider]?.[tier] || { effectivePressure: 0, state: "healthy" };
381
- const otherProvider = provider === "claude" ? "openai" : "claude";
382
- const otherTierStatus = status[otherProvider]?.[tier] || { effectivePressure: 0, state: "healthy" };
383
-
384
303
  let score = 50;
385
304
 
305
+ // Context coupling: Claude handles tightly-coupled context better
386
306
  if (provider === "claude") {
387
307
  if (contextCoupling === "high") score += 20;
388
308
  else if (contextCoupling === "medium") score += 10;
389
309
  } else {
310
+ // OpenAI better for isolated tasks
390
311
  if (isolation === "high") score += 20;
391
312
  else if (isolation === "medium") score += 10;
392
313
  }
393
314
 
394
- score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
315
+ // Session imbalance: reward the underused provider
316
+ if (totalCalls >= 4) {
317
+ const thisShare = provider === "claude"
318
+ ? claudeCalls / totalCalls
319
+ : openaiCalls / totalCalls;
320
+ // If heavily overused (>65% share), penalise; if underused (<35%), reward
321
+ if (thisShare > 0.65) score -= 20;
322
+ else if (thisShare < 0.35) score += 15;
323
+ }
395
324
 
325
+ // Profile bias applies to openai (positive = prefer openai more)
396
326
  if (provider === 'openai') score += profileBias;
397
327
 
328
+ // Penalise OpenAI for short tasks (startup overhead not worth it)
398
329
  if (provider === "openai") {
399
330
  let minTaskMs = 180_000;
400
331
  try {
@@ -416,33 +347,9 @@ function chooseProvider(taskProfile = {}) {
416
347
  }
417
348
  }
418
349
 
419
- if (
420
- tierStatus.effectivePressure < 0.3 &&
421
- otherTierStatus.effectivePressure > 0.5
422
- ) {
423
- score += 20;
424
- }
425
-
426
350
  scores[provider] = Math.round(score);
427
351
  }
428
352
 
429
- // Both-providers-throttled hard stop
430
- const claudeState = status.claude?.[tier]?.state;
431
- const openaiState = status.openai?.[tier]?.state;
432
- if (claudeState === 'throttled' && openaiState === 'throttled') {
433
- const claudeP = status.claude[tier].effectivePressure;
434
- const openaiP = status.openai[tier].effectivePressure;
435
- const lessThrottled = claudeP <= openaiP ? 'claude' : 'openai';
436
- const m = getProviderModels(config, lessThrottled);
437
- return {
438
- provider: lessThrottled,
439
- model: m?.[tier] || DEFAULT_MODELS[lessThrottled][tier],
440
- reason: `BOTH PROVIDERS THROTTLED (claude ${Math.round(claudeP * 100)}%, openai ${Math.round(openaiP * 100)}%). Using ${lessThrottled} as least-throttled. Consider waiting or downgrading tier.`,
441
- scores,
442
- bothThrottled: true,
443
- };
444
- }
445
-
446
353
  const winner = scores.claude >= scores.openai ? "claude" : "openai";
447
354
  const loser = winner === "claude" ? "openai" : "claude";
448
355
 
@@ -451,12 +358,11 @@ function chooseProvider(taskProfile = {}) {
451
358
 
452
359
  // Gate model by subscription tier
453
360
  if (!isModelAvailable(model, winner, config)) {
454
- const downgraded = downgradeModel(model, winner, config);
455
- model = downgraded;
361
+ model = downgradeModel(model, winner, config);
456
362
  }
457
363
 
458
- const ws = status[winner]?.[tier] || {};
459
- const ls = status[loser]?.[tier] || {};
364
+ const winnerCalls = winner === "claude" ? claudeCalls : openaiCalls;
365
+ const loserCalls = winner === "claude" ? openaiCalls : claudeCalls;
460
366
 
461
367
  let reasonParts = [];
462
368
  if (winner === "claude" && contextCoupling !== "low") {
@@ -465,13 +371,8 @@ function chooseProvider(taskProfile = {}) {
465
371
  if (winner === "openai" && isolation !== "low") {
466
372
  reasonParts.push(`isolated task`);
467
373
  }
468
- const wp = (ws.effectivePressure ?? 0);
469
- const lp = (ls.effectivePressure ?? 0);
470
- if (wp < lp) {
471
- reasonParts.push(`${winner} ${Math.round(wp * 100)}% vs ${loser} ${Math.round(lp * 100)}%`);
472
- }
473
- if (ws.weeklyPressure > ws.pressure) {
474
- reasonParts.push(`weekly limit is binding (${Math.round(ws.weeklyPressure * 100)}%)`);
374
+ if (totalCalls >= 4 && winnerCalls < loserCalls) {
375
+ reasonParts.push(`${winner} less used this session (${winnerCalls} vs ${loserCalls} calls)`);
475
376
  }
476
377
  if (!reasonParts.length) {
477
378
  reasonParts.push(`${winner} scored ${scores[winner]} vs ${scores[loser]}`);
@@ -485,40 +386,6 @@ function chooseProvider(taskProfile = {}) {
485
386
  };
486
387
  }
487
388
 
488
- // ---------------------------------------------------------------------------
489
- // Exported: estimateWaveCost(tasks)
490
- // ---------------------------------------------------------------------------
491
-
492
- function estimateWaveCost(tasks) {
493
- const config = loadConfig();
494
- const budgets = getSubscriptionBudgets(config);
495
- const status = getProviderStatus();
496
-
497
- let totalTokens = { claude: 0, openai: 0 };
498
- for (const task of tasks) {
499
- const provider = task.provider || 'claude';
500
- const tier = task.tier || 'execute';
501
- const estimate = TOKENS_PER_CALL_FALLBACK[tier] || 8_000;
502
- totalTokens[provider] += estimate;
503
- }
504
-
505
- const impact = {};
506
- for (const provider of ['claude', 'openai']) {
507
- if (totalTokens[provider] === 0) continue;
508
- const tierStatus = status[provider]?.execute || {};
509
- const remaining = Math.max(0, (tierStatus.budget || 0) - (tierStatus.tokens || 0));
510
- const pctOfBudget = tierStatus.budget > 0 ? (totalTokens[provider] / tierStatus.budget) * 100 : 0;
511
- impact[provider] = {
512
- estimatedTokens: totalTokens[provider],
513
- remaining,
514
- pctOfBudget: Math.round(pctOfBudget * 10) / 10,
515
- wouldExceed: totalTokens[provider] > remaining,
516
- };
517
- }
518
-
519
- return { totalTokens, impact, taskCount: tasks.length };
520
- }
521
-
522
389
  // ---------------------------------------------------------------------------
523
390
  // Exported: estimateTokensForTask(task)
524
391
  // ---------------------------------------------------------------------------
@@ -570,90 +437,60 @@ function recordUsageEvent(event = {}) {
570
437
  }
571
438
 
572
439
  // ---------------------------------------------------------------------------
573
- // CLI rendering helpers
440
+ // CLI rendering
574
441
  // ---------------------------------------------------------------------------
575
442
 
576
- function pressureBar(pressure, width = 10) {
577
- const filled = Math.min(width, Math.round(pressure * width));
578
- return "█".repeat(filled) + "░".repeat(width - filled);
579
- }
580
-
581
- function stateLabel(state) {
582
- return state.padEnd(8);
583
- }
584
-
585
- function formatPercent(pressure) {
586
- return String(Math.round(pressure * 100)).padStart(3) + "%";
587
- }
588
-
589
443
  function formatTokens(n) {
590
444
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
591
445
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
592
446
  return String(n);
593
447
  }
594
448
 
595
- function printStatusTable(status) {
449
+ function printStatus(status, rec) {
596
450
  const LINE_WIDTH = 62;
597
451
  const border = "═".repeat(LINE_WIDTH - 2);
598
- const blank = " ".repeat(LINE_WIDTH - 4);
599
452
 
600
453
  const h = (text) => {
601
454
  const padded = ` ${text}`.padEnd(LINE_WIDTH - 4);
602
455
  return `║ ${padded} ║`;
603
456
  };
604
457
 
605
- const row = (label, tier) => {
606
- const s = status[label]?.[tier] || { effectivePressure: 0, pressure: 0, state: "healthy", tokens: 0, budget: 0 };
607
- const bar = pressureBar(s.effectivePressure);
608
- const pct = formatPercent(s.effectivePressure);
609
- const lbl = stateLabel(s.state);
610
- const used = formatTokens(s.tokens || 0);
611
- const cap = formatTokens(s.budget || 0);
612
- const tierLabel = tier.charAt(0).toUpperCase() + tier.slice(1);
613
- const line = ` ${tierLabel.padEnd(7)}: ${bar} ${pct} ${lbl} ${used}/${cap}`;
614
- return h(line);
458
+ const providerRow = (provider) => {
459
+ const s = status[provider];
460
+ const total = s.calls.total;
461
+ const toks = formatTokens(s.tokens.total);
462
+ const breakdown = ["think", "execute", "search"]
463
+ .filter(t => s.calls[t] > 0)
464
+ .map(t => `${t}: ${s.calls[t]}`)
465
+ .join(", ");
466
+ const label = provider === "claude" ? "Claude" : "OpenAI";
467
+ const detail = breakdown ? ` (${breakdown})` : "";
468
+ return h(` ${label.padEnd(7)}: ${total} calls, ~${toks} tokens${detail}`);
615
469
  };
616
470
 
617
- const weeklyRow = (label, tier) => {
618
- const s = status[label]?.[tier] || {};
619
- if (!s.weeklyPressure || s.weeklyPressure <= 0) return null;
620
- const pct = Math.round((s.weeklyPressure || 0) * 100);
621
- const used = formatTokens(s.weeklyTokens || 0);
622
- const cap = formatTokens(s.weeklyBudget || 0);
623
- return h(` weekly: ${pct}% (${used}/${cap})`);
624
- };
625
-
626
- const claudeLabel = status.claude?._label || "Claude Max $100";
627
- const openaiLabel = status.openai?._label || "ChatGPT Plus $20";
471
+ const lean = status.sessionLean;
472
+ const leanText = lean === "balanced"
473
+ ? "Balanced either provider fine"
474
+ : `Leaning on ${lean} consider routing more to ${lean === "claude" ? "OpenAI" : "Claude"}`;
628
475
 
629
- const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
630
476
  const recText = `Route execution to ${rec.provider === "openai" ? "OpenAI" : "Claude"}`;
631
477
 
632
478
  const lines = [
633
479
  `╔${border}╗`,
634
480
  h(" Provider Balance Status"),
635
- h(" (token-based, real limits)"),
481
+ h(" (session-relative, last 5 hours)"),
636
482
  `╠${border}╣`,
637
- h(claudeLabel),
638
- row("claude", "think"),
639
- weeklyRow("claude", "think"),
640
- row("claude", "execute"),
641
- weeklyRow("claude", "execute"),
642
- row("claude", "search"),
643
- h(blank),
644
- h(openaiLabel),
645
- row("openai", "think"),
646
- weeklyRow("openai", "think"),
647
- row("openai", "execute"),
648
- weeklyRow("openai", "execute"),
649
- row("openai", "search"),
483
+ h("Session usage:"),
484
+ providerRow("claude"),
485
+ providerRow("openai"),
650
486
  `╠${border}╣`,
487
+ h(`Session lean: ${leanText}`),
651
488
  h(`Recommendation: ${recText}`),
652
489
  h(`Reason: ${rec.reason}`),
653
490
  `╚${border}╝`,
654
491
  ];
655
492
 
656
- console.log(lines.filter(Boolean).join("\n"));
493
+ console.log(lines.join("\n"));
657
494
  }
658
495
 
659
496
  // ---------------------------------------------------------------------------
@@ -662,7 +499,8 @@ function printStatusTable(status) {
662
499
 
663
500
  async function main() {
664
501
  const status = getProviderStatus();
665
- printStatusTable(status);
502
+ const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
503
+ printStatus(status, rec);
666
504
  }
667
505
 
668
506
  // Run as CLI only when invoked directly
@@ -676,4 +514,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
676
514
  // ---------------------------------------------------------------------------
677
515
  // Exports
678
516
  // ---------------------------------------------------------------------------
679
- export { getProviderStatus, chooseProvider, recordUsageEvent, getSubscriptionBudgets, estimateWaveCost, estimateTokensForTask, isModelAvailable, downgradeModel, SUBSCRIPTION_TIERS };
517
+ export { getProviderStatus, chooseProvider, recordUsageEvent, estimateTokensForTask, isModelAvailable, downgradeModel, classifyModel };
@@ -3,7 +3,7 @@
3
3
  import { classifyRisk, extractPaths } from './risk-classifier.mjs';
4
4
  import { resolveDependencies } from './plan-generator.mjs';
5
5
  import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
6
- import { getProviderStatus, chooseProvider, estimateWaveCost, estimateTokensForTask } from './budget-balancer.mjs';
6
+ import { getProviderStatus, chooseProvider, estimateTokensForTask } from './budget-balancer.mjs';
7
7
  import { recordDecision, recordOutcome } from './decision-ledger.mjs';
8
8
  import { classifyTask, selectModelEffort } from './task-classifier.mjs';
9
9
  import { getCapabilities, getDispatchConfig, recommendEffort } from './model-registry.mjs';
@@ -507,11 +507,12 @@ function routeTasks(tasks) {
507
507
  // Use task-classifier for capability-aware model+effort selection
508
508
  const profile = classifyTask(task.description, { files });
509
509
  const estimatedTokens = estimateTokensForTask({ tier, effort: profile.effort, fileCount: files.length });
510
+ // Derive rough pressure from session call imbalance (0=balanced, 1=all one provider)
511
+ const totalSessionCalls = (status.claude?.calls?.total ?? 0) + (status.openai?.calls?.total ?? 0);
512
+ const claudeShare = totalSessionCalls > 0 ? (status.claude?.calls?.total ?? 0) / totalSessionCalls : 0.5;
513
+ const sessionImbalance = Math.abs(claudeShare - 0.5) * 2; // 0=balanced, 1=fully one-sided
510
514
  const selection = selectModelEffort(profile, {
511
- budgetPressure: Math.max(
512
- status.claude?.[tier]?.effectivePressure ?? 0,
513
- status.openai?.[tier]?.effectivePressure ?? 0,
514
- ) / 100,
515
+ budgetPressure: sessionImbalance * 0.5, // scale to [0, 0.5] — soft signal only
515
516
  estimatedTokens,
516
517
  isIterating: (task.retryCount || 0) > 0,
517
518
  });
@@ -1227,20 +1228,23 @@ async function orchestrate(utterance, opts = {}) {
1227
1228
  saveManifest(manifest);
1228
1229
  printDispatchTable(manifest);
1229
1230
 
1230
- // Pre-dispatch spend estimate
1231
+ // Pre-dispatch token estimate (indicative only — no subscription quota math)
1231
1232
  const allTasks = manifest.waves.flatMap(w => w.tasks);
1232
- const costEstimate = estimateWaveCost(allTasks);
1233
- if (costEstimate.impact.claude || costEstimate.impact.openai) {
1234
- console.log('\n--- Pre-dispatch cost estimate ---');
1235
- for (const [prov, est] of Object.entries(costEstimate.impact)) {
1233
+ const tokensByProvider = { claude: 0, openai: 0 };
1234
+ for (const task of allTasks) {
1235
+ const prov = task.provider || 'claude';
1236
+ if (tokensByProvider[prov] !== undefined) {
1237
+ tokensByProvider[prov] += estimateTokensForTask({ tier: task.tier, effort: task.effort, files: task.files });
1238
+ }
1239
+ }
1240
+ console.log('\n--- Pre-dispatch token estimate ---');
1241
+ for (const [prov, toks] of Object.entries(tokensByProvider)) {
1242
+ if (toks > 0) {
1236
1243
  const label = prov === 'claude' ? 'Claude' : 'OpenAI';
1237
- console.log(` ${label}: ~${(est.estimatedTokens / 1000).toFixed(1)}K tokens (${est.pctOfBudget}% of 5hr budget, ${(est.remaining / 1000).toFixed(1)}K remaining)`);
1238
- if (est.wouldExceed) {
1239
- console.log(` WARNING: Estimated usage EXCEEDS remaining ${label} budget!`);
1240
- }
1244
+ console.log(` ${label}: ~${(toks / 1000).toFixed(1)}K tokens estimated`);
1241
1245
  }
1242
- console.log('');
1243
1246
  }
1247
+ console.log('');
1244
1248
 
1245
1249
  if (opts.dryRun) {
1246
1250
  manifest.status = 'dry-run';
@@ -1251,7 +1255,6 @@ async function orchestrate(utterance, opts = {}) {
1251
1255
  // Auto-confirm in non-interactive mode (--yes flag or piped input)
1252
1256
  if (!opts.confirmed && !opts.yes) {
1253
1257
  manifest.status = 'awaiting-confirmation';
1254
- manifest.costEstimate = costEstimate;
1255
1258
  saveManifest(refreshCounts(manifest));
1256
1259
  console.log(`Dispatch plan ready. Execute with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
1257
1260
  console.log('Or pass --yes to skip confirmation.');
@@ -1275,18 +1278,18 @@ async function orchestrate(utterance, opts = {}) {
1275
1278
  const wave = manifest.waves[i];
1276
1279
  if (wave.status === 'completed') continue;
1277
1280
 
1278
- // Per-wave spend check
1279
- const waveCost = estimateWaveCost(wave.tasks);
1280
- for (const [prov, est] of Object.entries(waveCost.impact)) {
1281
- if (est.wouldExceed) {
1282
- console.error(`[SPEND CAP] Wave ${wave.waveId} would exceed ${prov} budget (${(est.estimatedTokens / 1000).toFixed(1)}K estimated, ${(est.remaining / 1000).toFixed(1)}K remaining). Pausing.`);
1283
- manifest.status = 'paused';
1284
- manifest.pauseReason = `spend_cap:${prov}`;
1285
- saveManifest(refreshCounts(manifest));
1286
- console.error(`Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
1287
- return manifest;
1281
+ // Per-wave token estimate (informational — no quota enforcement since quota is unknowable)
1282
+ const waveTokens = { claude: 0, openai: 0 };
1283
+ for (const task of wave.tasks) {
1284
+ const prov = task.provider || 'claude';
1285
+ if (waveTokens[prov] !== undefined) {
1286
+ waveTokens[prov] += estimateTokensForTask({ tier: task.tier, effort: task.effort, files: task.files });
1288
1287
  }
1289
1288
  }
1289
+ const waveTotalK = Object.values(waveTokens).reduce((s, v) => s + v, 0) / 1000;
1290
+ if (waveTotalK > 0) {
1291
+ console.log(`[wave ${wave.waveId}] Estimated ~${waveTotalK.toFixed(1)}K tokens across providers`);
1292
+ }
1290
1293
 
1291
1294
  wave.checkpoint = gitCheckpoint(manifest, wave.waveId);
1292
1295
  saveManifest(refreshCounts(manifest));