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 +32 -1
- package/hooks/budget-balancer.mjs +162 -109
- package/hooks/control-panel.mjs +61 -0
- package/hooks/health-check.mjs +6 -2
- package/hooks/wave-orchestrator.mjs +970 -0
- package/install.mjs +2 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
31
|
-
const
|
|
30
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
31
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
39
|
+
const SUBSCRIPTION_TIERS = {
|
|
39
40
|
claude: {
|
|
40
|
-
think:
|
|
41
|
-
execute:
|
|
42
|
-
search:
|
|
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:
|
|
46
|
-
execute:
|
|
47
|
-
search:
|
|
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
|
-
/**
|
|
52
|
-
const
|
|
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:
|
|
55
|
-
think:
|
|
55
|
+
execute: 8_000,
|
|
56
|
+
think: 15_000,
|
|
56
57
|
};
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
87
|
-
hot: 0.
|
|
88
|
-
throttled: 0.
|
|
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
|
|
151
|
-
* Scans
|
|
135
|
+
* Read usage entries within a time window.
|
|
136
|
+
* Scans log files covering the window range.
|
|
152
137
|
*/
|
|
153
|
-
function
|
|
138
|
+
function readEntriesInWindow(windowMs) {
|
|
154
139
|
const now = Date.now();
|
|
155
|
-
const cutoff = now -
|
|
156
|
-
|
|
140
|
+
const cutoff = now - windowMs;
|
|
157
141
|
const entries = [];
|
|
158
142
|
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
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
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
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 (
|
|
263
|
+
if (effectivePressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
|
|
243
264
|
state = "throttled";
|
|
244
|
-
} else if (
|
|
265
|
+
} else if (effectivePressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
|
|
245
266
|
state = "hot";
|
|
246
|
-
} else if (
|
|
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] = {
|
|
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] || {
|
|
328
|
+
const tierStatus = status[provider]?.[tier] || { effectivePressure: 0, state: "healthy" };
|
|
295
329
|
const otherProvider = provider === "claude" ? "openai" : "claude";
|
|
296
|
-
const otherTierStatus = status[otherProvider]?.[tier] || {
|
|
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.
|
|
340
|
-
otherTierStatus.
|
|
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
|
-
|
|
356
|
-
const
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
447
|
-
const pct = formatPercent(s.
|
|
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
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
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("
|
|
510
|
+
h(" Provider Balance Status"),
|
|
511
|
+
h(" (token-based, real limits)"),
|
|
464
512
|
`╠${border}╣`,
|
|
465
|
-
h(
|
|
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(
|
|
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 };
|
package/hooks/control-panel.mjs
CHANGED
|
@@ -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('');
|
package/hooks/health-check.mjs
CHANGED
|
@@ -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
|
|
200
|
-
|
|
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");
|