dual-brain 5.0.0 → 5.1.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 CHANGED
@@ -98,6 +98,19 @@ Auto mode classifies risk from file paths and adjusts routing in real-time:
98
98
 
99
99
  Casual natural language → structured work. The vibe coding system translates informal requests into properly routed, risk-classified, quality-gated work.
100
100
 
101
+ **Wave Orchestrator** — the primary way to run multi-step work:
102
+ ```bash
103
+ node .claude/hooks/wave-orchestrator.mjs "fix the login bug and update the nav"
104
+ node .claude/hooks/wave-orchestrator.mjs --dry-run "refactor auth module"
105
+ node .claude/hooks/wave-orchestrator.mjs --resume <manifestId>
106
+ node .claude/hooks/wave-orchestrator.mjs --show <manifestId>
107
+ ```
108
+ The wave orchestrator decomposes intent, plans dependency-aware waves, assigns file ownership to prevent conflicts, dispatches agents with transparent routing tables, checkpoints between waves, and supports resume on failure. Every dispatch shows: provider, model, tier, effort, agent type, and routing reason.
109
+
110
+ Manifests persist to `.dualbrain/manifests/`, checkpoints to `.dualbrain/checkpoints/`.
111
+
112
+ Also available via the control panel: `[w]` Vibe workflow.
113
+
101
114
  **Intent compiler** — decompose multi-task requests:
102
115
  ```bash
103
116
  node .claude/hooks/vibe-router.mjs "fix the login bug and also update the nav"
@@ -119,13 +132,31 @@ node .claude/hooks/vibe-memory.mjs --infer # preference sug
119
132
  ```
120
133
  Tracks preferred profile, risk tolerance, active threads, and learns from usage patterns.
121
134
 
135
+ ## Budget Balancer (Token-Based)
136
+
137
+ The budget balancer tracks real token usage against actual subscription limits.
138
+
139
+ **Subscription tiers** (configured in `orchestrator.json` → `subscriptions.*.plan`):
140
+ - Claude: Pro $20, Max x5 $100, Max x20 $200
141
+ - ChatGPT: Plus $20, Pro $100, Pro $200
142
+
143
+ **Two rolling windows**: 5-hour and 7-day weekly. The higher pressure is the binding constraint.
144
+
145
+ **Token tracking**: Uses actual `input_tokens + output_tokens` from usage logs when available, falls back to conservative estimates only when logs lack token data.
146
+
147
+ ```bash
148
+ node .claude/hooks/budget-balancer.mjs
149
+ ```
150
+ Shows per-provider per-tier pressure with real token counts (e.g., `136.0K/350.0K`), weekly pressure when binding, and routing recommendation with reason.
151
+
122
152
  ## Available Tools
123
153
 
154
+ - `node .claude/hooks/wave-orchestrator.mjs "..."` — auto-wave orchestrator (plan, dispatch, test, review)
124
155
  - `node .claude/hooks/vibe-router.mjs "..."` — decompose casual requests into structured work
125
156
  - `node .claude/hooks/plan-generator.mjs --utterance "..."` — generate execution plans
126
157
  - `node .claude/hooks/vibe-memory.mjs` — persistent preferences and work threads
127
158
  - `node .claude/hooks/cost-report.mjs` — activity and cost estimates
128
159
  - `node .claude/hooks/health-check.mjs` — verify system health
129
- - `node .claude/hooks/budget-balancer.mjs` — provider balance status
160
+ - `node .claude/hooks/budget-balancer.mjs` — provider balance (token-based, real limits)
130
161
  - `node .claude/hooks/decision-ledger.mjs` — routing outcome insights
131
162
  - `node .claude/hooks/test-orchestrator.mjs` — run self-tests (40 tests)
@@ -27,65 +27,50 @@ const ORCHESTRATOR_CONFIG = join(__dirname, "..", "orchestrator.json");
27
27
  // Constants
28
28
  // ---------------------------------------------------------------------------
29
29
 
30
- /** Rolling window for pressure calculation (milliseconds) */
31
- const WINDOW_MS = 5 * 60 * 60 * 1000; // 5 hours
30
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
31
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
32
32
 
33
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.
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.
37
38
  */
38
- const WINDOW_BUDGETS = {
39
+ const SUBSCRIPTION_TIERS = {
39
40
  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
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 } },
43
44
  },
44
45
  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
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 } },
48
49
  },
49
50
  };
50
51
 
51
- /** Static fallback tokens per call, by tier */
52
- const TOKENS_PER_CALL_DEFAULT = {
52
+ /** Fallback tokens-per-call when usage log has no real token data for an entry */
53
+ const TOKENS_PER_CALL_FALLBACK = {
53
54
  search: 2_500,
54
- execute: 5_500,
55
- think: 11_000,
55
+ execute: 8_000,
56
+ think: 15_000,
56
57
  };
57
58
 
58
- /** Load moving averages from summary checkpoint, fall back to static defaults */
59
- function getTokensPerCall() {
60
- try {
61
- const today = new Date().toISOString().slice(0, 10);
62
- const summaryPath = join(__dirname, `usage-summary-${today}.json`);
63
- const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
64
- const avgs = summary.token_averages || {};
65
- const result = { ...TOKENS_PER_CALL_DEFAULT };
66
- for (const tier of ['search', 'execute', 'think']) {
67
- // Check both providers for averages, prefer whichever has data
68
- for (const provider of ['claude', 'openai']) {
69
- const key = `${provider}:${tier}`;
70
- if (avgs[key]?.count >= 5) {
71
- result[tier] = Math.round(avgs[key].avg_input + avgs[key].avg_output);
72
- break;
73
- }
74
- }
75
- }
76
- return result;
77
- } catch {
78
- return { ...TOKENS_PER_CALL_DEFAULT };
79
- }
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
+ };
80
68
  }
81
69
 
82
- const TOKENS_PER_CALL = getTokensPerCall();
83
-
84
- /** Default pressure thresholds (fraction 0–1) */
85
70
  const DEFAULT_THRESHOLDS = {
86
- warm: 0.65,
87
- hot: 0.82,
88
- throttled: 0.95,
71
+ warm: 0.55,
72
+ hot: 0.75,
73
+ throttled: 0.90,
89
74
  };
90
75
 
91
76
  /** Default model mapping when orchestrator.json is missing provider config */
@@ -147,20 +132,20 @@ function usageFilePath(date) {
147
132
  }
148
133
 
149
134
  /**
150
- * Read all usage entries from the last `WINDOW_MS` milliseconds.
151
- * Scans today's (and optionally yesterday's) log file.
135
+ * Read usage entries within a time window.
136
+ * Scans log files covering the window range.
152
137
  */
153
- function readRecentEntries() {
138
+ function readEntriesInWindow(windowMs) {
154
139
  const now = Date.now();
155
- const cutoff = now - WINDOW_MS;
156
-
140
+ const cutoff = now - windowMs;
157
141
  const entries = [];
158
142
 
159
- // Check today's and yesterday's files to cover the rolling window boundary
160
- const today = new Date().toISOString().slice(0, 10);
161
- const yesterday = new Date(now - 86_400_000).toISOString().slice(0, 10);
162
-
163
- for (const date of [yesterday, today]) {
143
+ const daysBack = Math.ceil(windowMs / 86_400_000) + 1;
144
+ const seen = new Set();
145
+ for (let i = 0; i < daysBack; i++) {
146
+ const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
147
+ if (seen.has(date)) continue;
148
+ seen.add(date);
164
149
  const file = usageFilePath(date);
165
150
  if (!existsSync(file)) continue;
166
151
  let raw;
@@ -183,32 +168,36 @@ function readRecentEntries() {
183
168
  }
184
169
  }
185
170
  }
186
-
187
171
  return entries;
188
172
  }
189
173
 
174
+ function readRecentEntries() {
175
+ return readEntriesInWindow(FIVE_HOURS_MS);
176
+ }
177
+
190
178
  // ---------------------------------------------------------------------------
191
179
  // Exported: getProviderStatus()
192
180
  // ---------------------------------------------------------------------------
193
181
 
194
182
  /**
195
- * Compute rolling 5-hour pressure for each provider/tier combination.
196
- *
197
- * @returns {object} Status keyed by provider → tier → { pressure, state, calls, estTokens }
183
+ * Sum actual tokens from usage entries for a provider/tier.
184
+ * Uses real input_tokens + output_tokens when available, falls back to estimate.
198
185
  */
199
- function getProviderStatus() {
200
- const config = loadConfig();
201
-
202
- const entries = readRecentEntries();
203
-
204
- // Accumulate call counts per provider/tier
205
- const counts = {
186
+ function sumTokens(entries) {
187
+ const tokens = {
188
+ claude: { think: 0, execute: 0, search: 0 },
189
+ openai: { think: 0, execute: 0, search: 0 },
190
+ };
191
+ const calls = {
192
+ claude: { think: 0, execute: 0, search: 0 },
193
+ openai: { think: 0, execute: 0, search: 0 },
194
+ };
195
+ const realTokenCalls = {
206
196
  claude: { think: 0, execute: 0, search: 0 },
207
197
  openai: { think: 0, execute: 0, search: 0 },
208
198
  };
209
199
 
210
200
  for (const entry of entries) {
211
- // Determine provider/tier either from stored `provider` field or by classifying model
212
201
  let provider = entry.provider;
213
202
  let tier = entry.tier;
214
203
 
@@ -220,12 +209,39 @@ function getProviderStatus() {
220
209
  }
221
210
  }
222
211
 
223
- if (provider && tier && counts[provider] && counts[provider][tier] !== undefined) {
224
- counts[provider][tier]++;
212
+ if (!provider || !tier || !tokens[provider] || tokens[provider][tier] === undefined) continue;
213
+
214
+ calls[provider][tier]++;
215
+
216
+ const inp = entry.input_tokens;
217
+ const out = entry.output_tokens;
218
+ if (inp != null && out != null && (inp > 0 || out > 0)) {
219
+ tokens[provider][tier] += inp + out;
220
+ realTokenCalls[provider][tier]++;
221
+ } else {
222
+ tokens[provider][tier] += TOKENS_PER_CALL_FALLBACK[tier] || 8_000;
225
223
  }
226
224
  }
227
225
 
228
- // Build status object
226
+ return { tokens, calls, realTokenCalls };
227
+ }
228
+
229
+ /**
230
+ * Compute rolling pressure for each provider/tier using actual token sums
231
+ * against real subscription budgets. Returns both 5hr and weekly pressure.
232
+ *
233
+ * @returns {object} Status keyed by provider → tier → { pressure, weeklyPressure, state, calls, tokens, budget, weeklyBudget }
234
+ */
235
+ function getProviderStatus() {
236
+ const config = loadConfig();
237
+ const budgets = getSubscriptionBudgets(config);
238
+
239
+ const fiveHrEntries = readEntriesInWindow(FIVE_HOURS_MS);
240
+ const weeklyEntries = readEntriesInWindow(SEVEN_DAYS_MS);
241
+
242
+ const fiveHr = sumTokens(fiveHrEntries);
243
+ const weekly = sumTokens(weeklyEntries);
244
+
229
245
  const status = {};
230
246
 
231
247
  for (const provider of ["claude", "openai"]) {
@@ -233,24 +249,42 @@ function getProviderStatus() {
233
249
  status[provider] = {};
234
250
 
235
251
  for (const tier of ["think", "execute", "search"]) {
236
- const calls = counts[provider][tier];
237
- const estTokens = calls * TOKENS_PER_CALL[tier];
238
- const budget = WINDOW_BUDGETS[provider][tier];
239
- const pressure = budget > 0 ? estTokens / budget : 0;
252
+ const tokensUsed = fiveHr.tokens[provider][tier];
253
+ const budget = budgets[provider].fiveHr[tier];
254
+ const pressure = budget > 0 ? tokensUsed / budget : 0;
255
+
256
+ const weeklyTokens = weekly.tokens[provider][tier];
257
+ const weeklyBudget = budgets[provider].weekly[tier];
258
+ const weeklyPressure = weeklyBudget > 0 ? weeklyTokens / weeklyBudget : 0;
259
+
260
+ const effectivePressure = Math.max(pressure, weeklyPressure);
240
261
 
241
262
  let state;
242
- if (pressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
263
+ if (effectivePressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
243
264
  state = "throttled";
244
- } else if (pressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
265
+ } else if (effectivePressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
245
266
  state = "hot";
246
- } else if (pressure >= (thresholds.warm ?? DEFAULT_THRESHOLDS.warm)) {
267
+ } else if (effectivePressure >= (thresholds.warm ?? DEFAULT_THRESHOLDS.warm)) {
247
268
  state = "warm";
248
269
  } else {
249
270
  state = "healthy";
250
271
  }
251
272
 
252
- status[provider][tier] = { pressure, state, calls, estTokens };
273
+ status[provider][tier] = {
274
+ pressure,
275
+ weeklyPressure,
276
+ effectivePressure,
277
+ state,
278
+ calls: fiveHr.calls[provider][tier],
279
+ tokens: tokensUsed,
280
+ budget,
281
+ weeklyTokens,
282
+ weeklyBudget,
283
+ realTokenCalls: fiveHr.realTokenCalls[provider][tier],
284
+ };
253
285
  }
286
+
287
+ status[provider]._label = budgets[provider].label;
254
288
  }
255
289
 
256
290
  return status;
@@ -291,28 +325,22 @@ function chooseProvider(taskProfile = {}) {
291
325
  const scores = {};
292
326
 
293
327
  for (const provider of ["claude", "openai"]) {
294
- const tierStatus = status[provider]?.[tier] || { pressure: 0, state: "healthy" };
328
+ const tierStatus = status[provider]?.[tier] || { effectivePressure: 0, state: "healthy" };
295
329
  const otherProvider = provider === "claude" ? "openai" : "claude";
296
- const otherTierStatus = status[otherProvider]?.[tier] || { pressure: 0, state: "healthy" };
330
+ const otherTierStatus = status[otherProvider]?.[tier] || { effectivePressure: 0, state: "healthy" };
297
331
 
298
- // Base score
299
332
  let score = 50;
300
333
 
301
- // Task-fit score
302
334
  if (provider === "claude") {
303
335
  if (contextCoupling === "high") score += 20;
304
336
  else if (contextCoupling === "medium") score += 10;
305
337
  } else {
306
- // openai
307
338
  if (isolation === "high") score += 20;
308
339
  else if (isolation === "medium") score += 10;
309
340
  }
310
341
 
311
- // Pressure penalty
312
342
  score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
313
343
 
314
- // Latency penalty (OpenAI only — Codex has higher startup overhead)
315
- // Uses adaptive threshold from observed Codex startup times when available
316
344
  if (provider === "openai") {
317
345
  let minTaskMs = 180_000;
318
346
  try {
@@ -334,10 +362,9 @@ function chooseProvider(taskProfile = {}) {
334
362
  }
335
363
  }
336
364
 
337
- // Underused bonus
338
365
  if (
339
- tierStatus.pressure < 0.3 &&
340
- otherTierStatus.pressure > 0.5
366
+ tierStatus.effectivePressure < 0.3 &&
367
+ otherTierStatus.effectivePressure > 0.5
341
368
  ) {
342
369
  score += 20;
343
370
  }
@@ -348,13 +375,11 @@ function chooseProvider(taskProfile = {}) {
348
375
  const winner = scores.claude >= scores.openai ? "claude" : "openai";
349
376
  const loser = winner === "claude" ? "openai" : "claude";
350
377
 
351
- // Resolve model name
352
378
  const models = getProviderModels(config, winner);
353
379
  const model = models?.[tier] || DEFAULT_MODELS[winner][tier];
354
380
 
355
- // Build human reason string
356
- const winnerPressure = (status[winner]?.[tier]?.pressure ?? 0).toFixed(2);
357
- const loserPressure = (status[loser]?.[tier]?.pressure ?? 0).toFixed(2);
381
+ const ws = status[winner]?.[tier] || {};
382
+ const ls = status[loser]?.[tier] || {};
358
383
 
359
384
  let reasonParts = [];
360
385
  if (winner === "claude" && contextCoupling !== "low") {
@@ -363,11 +388,16 @@ function chooseProvider(taskProfile = {}) {
363
388
  if (winner === "openai" && isolation !== "low") {
364
389
  reasonParts.push(`isolated task`);
365
390
  }
366
- if (parseFloat(winnerPressure) < parseFloat(loserPressure)) {
367
- reasonParts.push(`${winner} pressure lower (${winnerPressure} vs ${loserPressure})`);
391
+ const wp = (ws.effectivePressure ?? 0);
392
+ const lp = (ls.effectivePressure ?? 0);
393
+ if (wp < lp) {
394
+ reasonParts.push(`${winner} ${Math.round(wp * 100)}% vs ${loser} ${Math.round(lp * 100)}%`);
395
+ }
396
+ if (ws.weeklyPressure > ws.pressure) {
397
+ reasonParts.push(`weekly limit is binding (${Math.round(ws.weeklyPressure * 100)}%)`);
368
398
  }
369
399
  if (!reasonParts.length) {
370
- reasonParts.push(`${winner} scored higher (${scores[winner]} vs ${scores[loser]})`);
400
+ reasonParts.push(`${winner} scored ${scores[winner]} vs ${scores[loser]}`);
371
401
  }
372
402
 
373
403
  return {
@@ -432,8 +462,14 @@ function formatPercent(pressure) {
432
462
  return String(Math.round(pressure * 100)).padStart(3) + "%";
433
463
  }
434
464
 
465
+ function formatTokens(n) {
466
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
467
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
468
+ return String(n);
469
+ }
470
+
435
471
  function printStatusTable(status) {
436
- const LINE_WIDTH = 50;
472
+ const LINE_WIDTH = 62;
437
473
  const border = "═".repeat(LINE_WIDTH - 2);
438
474
  const blank = " ".repeat(LINE_WIDTH - 4);
439
475
 
@@ -441,42 +477,59 @@ function printStatusTable(status) {
441
477
  const padded = ` ${text}`.padEnd(LINE_WIDTH - 4);
442
478
  return `║ ${padded} ║`;
443
479
  };
480
+
444
481
  const row = (label, tier) => {
445
- const s = status[label]?.[tier] || { pressure: 0, state: "healthy" };
446
- const bar = pressureBar(s.pressure);
447
- const pct = formatPercent(s.pressure);
482
+ const s = status[label]?.[tier] || { effectivePressure: 0, pressure: 0, state: "healthy", tokens: 0, budget: 0 };
483
+ const bar = pressureBar(s.effectivePressure);
484
+ const pct = formatPercent(s.effectivePressure);
448
485
  const lbl = stateLabel(s.state);
449
- const line = ` ${tier.charAt(0).toUpperCase() + tier.slice(1).padEnd(7)}: ${bar} ${pct} ${lbl}`;
486
+ const used = formatTokens(s.tokens || 0);
487
+ const cap = formatTokens(s.budget || 0);
488
+ const tierLabel = tier.charAt(0).toUpperCase() + tier.slice(1);
489
+ const line = ` ${tierLabel.padEnd(7)}: ${bar} ${pct} ${lbl} ${used}/${cap}`;
450
490
  return h(line);
451
491
  };
452
492
 
453
- const config = loadConfig();
454
- const claudePlan = config?.subscriptions?.claude?.plan ? `Claude Max ${config.subscriptions.claude.plan}` : "Claude Max $100";
455
- const openaiPlan = config?.subscriptions?.openai?.plan ? `OpenAI Pro ${config.subscriptions.openai.plan}` : "OpenAI Pro $100";
493
+ const weeklyRow = (label, tier) => {
494
+ const s = status[label]?.[tier] || {};
495
+ if (!s.weeklyPressure || s.weeklyPressure <= 0) return null;
496
+ const pct = Math.round((s.weeklyPressure || 0) * 100);
497
+ const used = formatTokens(s.weeklyTokens || 0);
498
+ const cap = formatTokens(s.weeklyBudget || 0);
499
+ return h(` weekly: ${pct}% (${used}/${cap})`);
500
+ };
501
+
502
+ const claudeLabel = status.claude?._label || "Claude Max $100";
503
+ const openaiLabel = status.openai?._label || "ChatGPT Plus $20";
456
504
 
457
- // Recommendation
458
505
  const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
459
506
  const recText = `Route execution to ${rec.provider === "openai" ? "OpenAI" : "Claude"}`;
460
507
 
461
508
  const lines = [
462
509
  `╔${border}╗`,
463
- h(" Provider Balance Status "),
510
+ h(" Provider Balance Status"),
511
+ h(" (token-based, real limits)"),
464
512
  `╠${border}╣`,
465
- h(claudePlan),
513
+ h(claudeLabel),
466
514
  row("claude", "think"),
515
+ weeklyRow("claude", "think"),
467
516
  row("claude", "execute"),
517
+ weeklyRow("claude", "execute"),
468
518
  row("claude", "search"),
469
519
  h(blank),
470
- h(openaiPlan),
520
+ h(openaiLabel),
471
521
  row("openai", "think"),
522
+ weeklyRow("openai", "think"),
472
523
  row("openai", "execute"),
524
+ weeklyRow("openai", "execute"),
473
525
  row("openai", "search"),
474
526
  `╠${border}╣`,
475
527
  h(`Recommendation: ${recText}`),
528
+ h(`Reason: ${rec.reason}`),
476
529
  `╚${border}╝`,
477
530
  ];
478
531
 
479
- console.log(lines.join("\n"));
532
+ console.log(lines.filter(Boolean).join("\n"));
480
533
  }
481
534
 
482
535
  // ---------------------------------------------------------------------------
@@ -499,4 +552,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
499
552
  // ---------------------------------------------------------------------------
500
553
  // Exports
501
554
  // ---------------------------------------------------------------------------
502
- export { getProviderStatus, chooseProvider, recordUsageEvent };
555
+ export { getProviderStatus, chooseProvider, recordUsageEvent, getSubscriptionBudgets, SUBSCRIPTION_TIERS };
@@ -649,6 +649,60 @@ async function showToolsMenu(rl) {
649
649
  }
650
650
  }
651
651
 
652
+ // ─── Submenu: Vibe Workflow ───────────────────────────────────────────────
653
+
654
+ async function showVibeWorkflow(rl) {
655
+ console.log('');
656
+ console.log(` ${bold('Vibe Workflow')} ${dim('— describe what you want, we orchestrate it')}`);
657
+ console.log('');
658
+ console.log(` Tell us what to build, fix, or change in plain English.`);
659
+ console.log(` The wave orchestrator will plan, dispatch agents, test, and review.`);
660
+ console.log('');
661
+
662
+ const utterance = await new Promise(resolve => {
663
+ rl.question(` ${bold('What do you want?')} `, resolve);
664
+ });
665
+
666
+ const trimmed = utterance.trim();
667
+ if (!trimmed || trimmed === 'q') return;
668
+
669
+ // Ask dry-run or execute
670
+ console.log('');
671
+ const mode = await new Promise(resolve => {
672
+ rl.question(` ${bold('[d]')} Dry run (plan only) ${bold('[g]')} Go (execute) ${bold('[q]')} Cancel: `, resolve);
673
+ });
674
+
675
+ const modeChoice = mode.trim().toLowerCase();
676
+ if (modeChoice === 'q' || !modeChoice) return;
677
+
678
+ const isDryRun = modeChoice === 'd';
679
+ const args = isDryRun
680
+ ? ['hooks/wave-orchestrator.mjs', '--dry-run', trimmed]
681
+ : ['hooks/wave-orchestrator.mjs', trimmed];
682
+
683
+ console.log('');
684
+ console.log(` ${isDryRun ? 'Planning' : 'Orchestrating'}...`);
685
+ console.log('');
686
+
687
+ const result = spawnSync('node', args, {
688
+ cwd: join(__dirname, '..'),
689
+ stdio: 'inherit',
690
+ encoding: 'utf8',
691
+ timeout: 600_000,
692
+ });
693
+
694
+ if (result.status !== 0) {
695
+ console.log('');
696
+ console.log(` ${noColor ? '[!]' : '⚠️'} Wave orchestrator exited with code ${result.status}`);
697
+ if (result.error) console.log(` ${dim(result.error.message)}`);
698
+ }
699
+
700
+ console.log('');
701
+ const next = await new Promise(resolve => {
702
+ rl.question(` Press Enter to return to menu...`, resolve);
703
+ });
704
+ }
705
+
652
706
  // ─── Menu Renderers ───────────────────────────────────────────────────────
653
707
 
654
708
  function renderFirstRunMenu(providers) {
@@ -703,6 +757,7 @@ function renderFirstRunMenu(providers) {
703
757
 
704
758
  // Primary actions
705
759
  lines.push(` ${bold('[n]')} Start new session`);
760
+ lines.push(` ${bold('[w]')} Vibe workflow ${dim('(natural language → orchestrated work)')}`);
706
761
  lines.push(` ${bold('[a]')} Auth management`);
707
762
  lines.push(` ${bold('[d]')} Dashboard & diagnostics`);
708
763
  lines.push(` ${bold('[s]')} Skip — just shell`);
@@ -774,6 +829,7 @@ function renderReturningMenu(providers, sessions) {
774
829
  lines.push(` ${bold('[c]')} Continue last session`);
775
830
  if (sessions.length > 0) lines.push(` ${bold('[1-9]')} Resume numbered above`);
776
831
  lines.push(` ${bold('[n]')} New session`);
832
+ lines.push(` ${bold('[w]')} Vibe workflow ${dim('(say what you want, we handle the rest)')}`);
777
833
 
778
834
  // ── Settings
779
835
  lines.push('');
@@ -964,6 +1020,11 @@ async function mainLoop() {
964
1020
  continue;
965
1021
  }
966
1022
 
1023
+ if (choice === 'w') {
1024
+ await showVibeWorkflow(rl);
1025
+ continue;
1026
+ }
1027
+
967
1028
  if (choice === 'n') {
968
1029
  if (!providers.claude.authed) {
969
1030
  console.log('');
@@ -196,8 +196,12 @@ function checkHookRegistration() {
196
196
  const expectedPre = "node .claude/hooks/enforce-tier.mjs";
197
197
  const expectedPost = "node .claude/hooks/cost-logger.mjs";
198
198
 
199
- const hasPre = preToolUse.includes(expectedPre);
200
- const hasPost = postToolUse.includes(expectedPost);
199
+ const hasCommand = (entries, cmd) => entries.some(e =>
200
+ e === cmd || e?.command === cmd || e?.hooks?.some(h => h.command === cmd)
201
+ );
202
+
203
+ const hasPre = hasCommand(preToolUse, expectedPre);
204
+ const hasPost = hasCommand(postToolUse, expectedPost);
201
205
 
202
206
  if (hasPre && hasPost) {
203
207
  return check("hook_registration", STATUS.pass, "required hooks registered");