dual-brain 0.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.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cost-logger.mjs — PostToolUse hook for the Dual-Brain orchestrator.
4
+ *
5
+ * Reads a Claude Code PostToolUse JSON payload from stdin, classifies the
6
+ * call by tier, then appends one line to usage.jsonl.
7
+ *
8
+ * Output contract: must print "{}" to stdout and exit 0 within ~100 ms.
9
+ */
10
+
11
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
+ import { dirname, join } from "path";
13
+ import { fileURLToPath } from "url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
17
+
18
+ function usageFile(date) {
19
+ const d = date || new Date().toISOString().slice(0, 10);
20
+ return join(__dirname, `usage-${d}.jsonl`);
21
+ }
22
+
23
+ mkdirSync(__dirname, { recursive: true });
24
+
25
+ function loadActiveProfile() {
26
+ try {
27
+ const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
28
+ return data.active || 'auto';
29
+ } catch { return 'auto'; }
30
+ }
31
+
32
+ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tier classification
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Tools that are pure read-only lookups → "search" tier.
40
+ * Everything else defaults to "execute"; "think" is only detected when an
41
+ * Agent sub-agent call carries a model hint in its parameters.
42
+ */
43
+ const SEARCH_TOOLS = new Set([
44
+ "Read",
45
+ "Glob",
46
+ "Grep",
47
+ "LS",
48
+ "WebSearch",
49
+ "WebFetch",
50
+ "mcp__github__search_repositories",
51
+ "mcp__github__get_file_contents",
52
+ "mcp__github__list_commits",
53
+ "mcp__github__list_issues",
54
+ "mcp__github__list_pull_requests",
55
+ "mcp__github__search_code",
56
+ ]);
57
+
58
+ const THINK_TOOLS = new Set([
59
+ "TodoWrite", // planning artefact
60
+ "WebFetch", // sometimes used for deep research; included in both sets so
61
+ // the model param check below can upgrade it
62
+ ]);
63
+
64
+ /** Map a Claude model string → canonical tier name */
65
+ function modelToTier(model) {
66
+ if (!model) return null;
67
+ const m = String(model).toLowerCase();
68
+ if (m.includes("opus")) return "think";
69
+ if (m.includes("sonnet")) return "execute";
70
+ if (m.includes("haiku")) return "search";
71
+ if (m.includes("gpt-5.5") || m.includes("gpt4.5")) return "think";
72
+ if (m.includes("mini")) return "search";
73
+ if (m.includes("gpt-5.4") || m.includes("gpt-4.1")) return "execute";
74
+ return null;
75
+ }
76
+
77
+ /** Detect the provider from a model name */
78
+ function detectProvider(model) {
79
+ if (!model || model === 'main-session') return 'claude';
80
+ const m = String(model).toLowerCase();
81
+ if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'openai';
82
+ if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku') || m.includes('claude')) return 'claude';
83
+ return 'claude'; // default to claude since we're in Claude Code
84
+ }
85
+
86
+ /** Extract canonical model name from an arbitrary model string */
87
+ function canonicalModel(model) {
88
+ if (!model) return "main-session";
89
+ const m = String(model).toLowerCase();
90
+ if (m.includes("opus")) return "opus";
91
+ if (m.includes("sonnet")) return "sonnet";
92
+ if (m.includes("haiku")) return "haiku";
93
+ if (m.includes("gpt-5.5")) return "gpt-5.5";
94
+ if (m.includes("gpt-5.4")) return "gpt-5.4";
95
+ if (m.includes("gpt-4.1-mini") || m.includes("mini")) return "gpt-4.1-mini";
96
+ return model;
97
+ }
98
+
99
+ /**
100
+ * Classify a tool call into { tier, model }.
101
+ *
102
+ * @param {string} toolName
103
+ * @param {object} toolInput — raw input parameters from the hook payload
104
+ * @param {string|null} agentModel — model hint from the outer agent context
105
+ */
106
+ function classify(toolName, toolInput = {}, agentModel = null) {
107
+ // 1. If there's an explicit model hint in the input params (sub-agent call),
108
+ // let it drive the tier.
109
+ const inputModel =
110
+ toolInput?.model ||
111
+ toolInput?.Model ||
112
+ toolInput?.modelId ||
113
+ null;
114
+
115
+ const effectiveModel = inputModel || agentModel;
116
+ const tierFromModel = modelToTier(effectiveModel);
117
+
118
+ if (toolName === "Agent" || toolName === "Task") {
119
+ return {
120
+ tier: tierFromModel || "think", // sub-agents default to "think"
121
+ model: canonicalModel(effectiveModel),
122
+ };
123
+ }
124
+
125
+ if (THINK_TOOLS.has(toolName) && tierFromModel) {
126
+ return { tier: tierFromModel, model: canonicalModel(effectiveModel) };
127
+ }
128
+
129
+ if (SEARCH_TOOLS.has(toolName)) {
130
+ return { tier: "search", model: canonicalModel(effectiveModel) };
131
+ }
132
+
133
+ // Everything else: edit / bash / write / test → execute
134
+ return {
135
+ tier: tierFromModel || "execute",
136
+ model: canonicalModel(effectiveModel),
137
+ };
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Budget alerts
142
+ // ---------------------------------------------------------------------------
143
+
144
+ async function checkBudget() {
145
+ let config;
146
+ try {
147
+ config = JSON.parse(readFileSync(join(__dirname, '..', 'orchestrator.json'), 'utf8'));
148
+ } catch { return null; }
149
+
150
+ // Merge profile budget overrides on top of config defaults
151
+ let budgets = config.budgets;
152
+ if (!budgets) return null;
153
+ try {
154
+ const profileData = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
155
+ if (profileData.custom_overrides?.budgets) {
156
+ budgets = { ...budgets, ...profileData.custom_overrides.budgets };
157
+ }
158
+ } catch {}
159
+
160
+ // Rate limit alerts
161
+ const cooldownFile = join(__dirname, '.budget-alerted');
162
+ const cooldownMin = budgets.alert_cooldown_minutes || 15;
163
+ try {
164
+ const lastAlert = readFileSync(cooldownFile, 'utf8').trim();
165
+ if (Date.now() - Date.parse(lastAlert) < cooldownMin * 60 * 1000) return null;
166
+ } catch {}
167
+
168
+ // Use summary checkpoint for fast budget check (O(1) instead of full scan)
169
+ let totalCost = 0;
170
+ try {
171
+ const { readSummary } = await import('./summary-checkpoint.mjs');
172
+ const summary = readSummary();
173
+ totalCost = summary.totals.cost_estimate;
174
+ } catch {
175
+ // Fallback: scan the log (only if summary unavailable)
176
+ const todayFile = usageFile();
177
+ let records = [];
178
+ try {
179
+ records = readFileSync(todayFile, 'utf8').split('\n').filter(Boolean).map(l => {
180
+ try { return JSON.parse(l); } catch { return null; }
181
+ }).filter(Boolean);
182
+ } catch { return null; }
183
+ const RATES = { search: 0.003, execute: 0.012, think: 0.055 };
184
+ totalCost = records.reduce((sum, r) => sum + (RATES[r.tier] || RATES.execute), 0);
185
+ }
186
+
187
+ let msg = null;
188
+ if (budgets.daily_limit_usd && totalCost >= budgets.daily_limit_usd) {
189
+ msg = `**[Budget Alert]** Daily cost estimate (~$${totalCost.toFixed(2)}) has reached the $${budgets.daily_limit_usd} limit. Consider pausing non-essential work.`;
190
+ } else if (budgets.daily_warn_usd && totalCost >= budgets.daily_warn_usd) {
191
+ msg = `**[Budget Alert]** Daily cost estimate (~$${totalCost.toFixed(2)}) has passed the $${budgets.daily_warn_usd} warning threshold.`;
192
+ }
193
+
194
+ if (msg) {
195
+ try { writeFileSync(cooldownFile, new Date().toISOString()); } catch {}
196
+ return msg;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Main — read stdin, classify, append, respond
203
+ // ---------------------------------------------------------------------------
204
+
205
+ async function main() {
206
+ // Read all stdin (non-blocking-safe with a short timeout)
207
+ let raw = "";
208
+ try {
209
+ for await (const chunk of process.stdin) {
210
+ raw += chunk;
211
+ if (raw.length > 64 * 1024) break; // safety cap
212
+ }
213
+ } catch {
214
+ // stdin closed or empty — not fatal
215
+ }
216
+
217
+ let payload = {};
218
+ try {
219
+ payload = JSON.parse(raw);
220
+ } catch {
221
+ // Malformed JSON — proceed with empty payload
222
+ }
223
+
224
+ const toolName = payload?.tool_name || payload?.toolName || "unknown";
225
+ const toolInput = payload?.tool_input || payload?.toolInput || {};
226
+ const agentModel = payload?.model || payload?.agent_model || null;
227
+
228
+ const { tier, model } = classify(toolName, toolInput, agentModel);
229
+
230
+ // Extract actual token counts from payload (location varies by hook version)
231
+ const usage = payload?.usage || toolInput?.usage || {};
232
+ const inputTokens = usage.input_tokens ?? payload?.input_tokens ?? null;
233
+ const outputTokens = usage.output_tokens ?? payload?.output_tokens ?? null;
234
+
235
+ const status = (payload?.error || payload?.tool_response?.error || payload?.is_error) ? 'error' : 'ok';
236
+
237
+ const entryObj = {
238
+ schema_version: 3,
239
+ timestamp: new Date().toISOString(),
240
+ tier,
241
+ tool: toolName,
242
+ model,
243
+ provider: detectProvider(model),
244
+ dispatcher: 'claude-code',
245
+ status,
246
+ session_id: SESSION_ID,
247
+ profile: loadActiveProfile(),
248
+ input_tokens: inputTokens,
249
+ output_tokens: outputTokens,
250
+ };
251
+
252
+ const entry = JSON.stringify(entryObj);
253
+
254
+ try {
255
+ appendFileSync(usageFile(), entry + "\n", { encoding: "utf8", flag: "a" });
256
+ } catch {}
257
+
258
+ // Update summary checkpoint (non-blocking, best-effort)
259
+ try {
260
+ const { updateSummary } = await import('./summary-checkpoint.mjs');
261
+ updateSummary(entryObj);
262
+ } catch {}
263
+
264
+ // Record failures for adaptive routing (failure-loop detection)
265
+ if (status === 'error' && toolName === 'Agent') {
266
+ try {
267
+ const { computePromptHash, recordFailure, pruneOldFailures } = await import('./failure-detector.mjs');
268
+ const promptHash = computePromptHash(toolInput);
269
+ recordFailure(promptHash, tier, payload?.error || 'agent_error');
270
+ // Best-effort cleanup of stale failure entries (>24h old)
271
+ try { pruneOldFailures(); } catch {}
272
+ } catch {}
273
+ }
274
+
275
+ const budgetMsg = await checkBudget();
276
+
277
+ // PostToolUse hooks must emit a JSON object to stdout
278
+ if (budgetMsg) {
279
+ process.stdout.write(JSON.stringify({ systemMessage: budgetMsg }) + "\n");
280
+ } else {
281
+ process.stdout.write("{}\n");
282
+ }
283
+ process.exit(0);
284
+ }
285
+
286
+ main();
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cost-report.mjs — Dual-Brain Cost Report CLI
4
+ *
5
+ * Usage:
6
+ * node .claude/hooks/cost-report.mjs # show today + all-time
7
+ * node .claude/hooks/cost-report.mjs --all # show all-time only
8
+ * node .claude/hooks/cost-report.mjs --today # show today only (default)
9
+ *
10
+ * Reads:
11
+ * .claude/hooks/usage.jsonl — tool call log written by cost-logger.mjs
12
+ * .claude/orchestrator.json — cost rates per model
13
+ */
14
+
15
+ import { readFileSync, existsSync, readdirSync } from "fs";
16
+ import { dirname, join } from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { spawnSync } from "child_process";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Paths
22
+ // ---------------------------------------------------------------------------
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const WORKSPACE = join(__dirname, "..", ".."); // workspace root
25
+ const CONFIG_FILE = join(__dirname, "..", "orchestrator.json");
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Load orchestrator config
29
+ // ---------------------------------------------------------------------------
30
+ function loadConfig() {
31
+ try {
32
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Build a flat map: { "haiku": { input_per_mtok, output_per_mtok, tier }, … }
40
+ * from orchestrator.json's subscriptions block.
41
+ */
42
+ function buildRateMap(config) {
43
+ const rates = {};
44
+ if (!config?.subscriptions) return rates;
45
+ for (const provider of Object.values(config.subscriptions)) {
46
+ for (const [modelKey, data] of Object.entries(provider.models || {})) {
47
+ rates[modelKey] = {
48
+ tier: data.tier,
49
+ input_per_mtok: data.input_per_mtok,
50
+ output_per_mtok: data.output_per_mtok,
51
+ };
52
+ }
53
+ }
54
+ return rates;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Load & parse usage log
59
+ // ---------------------------------------------------------------------------
60
+ function loadUsage() {
61
+ const files = readdirSync(__dirname)
62
+ .filter(f => f.startsWith('usage-') && f.endsWith('.jsonl'))
63
+ .sort();
64
+
65
+ // Also check legacy usage.jsonl for backwards compat
66
+ if (existsSync(join(__dirname, 'usage.jsonl'))) {
67
+ files.unshift('usage.jsonl');
68
+ }
69
+
70
+ const records = [];
71
+ for (const f of files) {
72
+ try {
73
+ const lines = readFileSync(join(__dirname, f), 'utf8').split('\n').filter(Boolean);
74
+ for (const line of lines) {
75
+ try { records.push(JSON.parse(line)); } catch {}
76
+ }
77
+ } catch {}
78
+ }
79
+ return records;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Cost estimation
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Very rough token estimate per tool call.
88
+ * Without actual token counts from the session files, we use a conservative
89
+ * heuristic based on typical Claude Code usage patterns.
90
+ */
91
+ const TOKEN_HEURISTICS = {
92
+ // { input_tok, output_tok }
93
+ search: { input: 2_000, output: 500 },
94
+ execute: { input: 4_000, output: 1_500 },
95
+ think: { input: 8_000, output: 3_000 },
96
+ };
97
+
98
+ function estimateCost(tier, model, rateMap, record = {}) {
99
+ const heuristic = TOKEN_HEURISTICS[tier] || TOKEN_HEURISTICS.execute;
100
+ // Use actual tokens if logged, otherwise fall back to heuristics
101
+ const hasActual = record.input_tokens != null && record.output_tokens != null;
102
+ const inputTok = hasActual ? record.input_tokens : heuristic.input;
103
+ const outputTok = hasActual ? record.output_tokens : heuristic.output;
104
+ const rate = rateMap[model] || rateMap["main-session"];
105
+ if (!rate) {
106
+ // Fallback: use tier-matched rate from whatever model we know about
107
+ // "main-session" and "unknown" map to think-tier (Opus) since that's the session model
108
+ const fallbackTier = (model === "main-session" || model === "unknown") ? "think" : tier;
109
+ const tierRate = Object.values(rateMap).find((r) => r.tier === fallbackTier)
110
+ || Object.values(rateMap).find((r) => r.tier === tier);
111
+ if (!tierRate) return 0;
112
+ return (
113
+ (inputTok / 1_000_000) * tierRate.input_per_mtok +
114
+ (outputTok / 1_000_000) * tierRate.output_per_mtok
115
+ );
116
+ }
117
+ return (
118
+ (inputTok / 1_000_000) * rate.input_per_mtok +
119
+ (outputTok / 1_000_000) * rate.output_per_mtok
120
+ );
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Git log fallback — estimate work volume when usage.jsonl is empty
125
+ // ---------------------------------------------------------------------------
126
+ function gitFallbackSummary() {
127
+ try {
128
+ const today = new Date().toISOString().slice(0, 10);
129
+ const proc = spawnSync("git", [
130
+ "-C", WORKSPACE,
131
+ "log", "--oneline",
132
+ `--since=${today} 00:00`,
133
+ `--until=${today} 23:59`,
134
+ ], {
135
+ encoding: "utf8",
136
+ stdio: ["pipe", "pipe", "pipe"],
137
+ timeout: 10_000,
138
+ });
139
+ const log = proc.status === 0 ? (proc.stdout || "").trim() : "";
140
+ const commits = log ? log.split("\n").length : 0;
141
+ return commits;
142
+ } catch {
143
+ return 0;
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Aggregation
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function todayPrefix() {
152
+ return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
153
+ }
154
+
155
+ /**
156
+ * Aggregate records into { [tier]: { model, calls, cost } }
157
+ * where model is the most-seen model for that tier.
158
+ */
159
+ function aggregate(records, rateMap, datePrefix = null) {
160
+ const filtered = datePrefix
161
+ ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
162
+ : records;
163
+
164
+ // tier → { calls: number, costSum: number, modelCounts: { model: count } }
165
+ const buckets = {};
166
+
167
+ for (const record of filtered) {
168
+ const tier = record.tier || "execute";
169
+ const model = record.model || "unknown";
170
+ if (!buckets[tier]) {
171
+ buckets[tier] = { calls: 0, costSum: 0, modelCounts: {} };
172
+ }
173
+ buckets[tier].calls += 1;
174
+ buckets[tier].costSum += estimateCost(tier, model, rateMap, record);
175
+ buckets[tier].modelCounts[model] = (buckets[tier].modelCounts[model] || 0) + 1;
176
+ if (record.input_tokens != null && record.output_tokens != null) buckets[tier].actualCount = (buckets[tier].actualCount || 0) + 1;
177
+ }
178
+
179
+ // Resolve dominant model per tier
180
+ const result = {};
181
+ for (const [tier, data] of Object.entries(buckets)) {
182
+ const dominantModel = Object.entries(data.modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
183
+ result[tier] = {
184
+ model: dominantModel,
185
+ calls: data.calls,
186
+ cost: data.costSum,
187
+ actualCount: data.actualCount || 0,
188
+ };
189
+ }
190
+ return result;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Opus all-in cost (for savings calculation)
195
+ // ---------------------------------------------------------------------------
196
+ function allOpusCost(records, rateMap, datePrefix = null) {
197
+ const filtered = datePrefix
198
+ ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
199
+ : records;
200
+
201
+ return filtered.reduce((sum, record) => {
202
+ return sum + estimateCost("think", "opus", rateMap, record);
203
+ }, 0);
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Formatting helpers
208
+ // ---------------------------------------------------------------------------
209
+
210
+ const W = 50;
211
+
212
+ const TIER_ORDER = ["search", "execute", "think"];
213
+
214
+ const TIER_LABELS = {
215
+ search: "Search ",
216
+ execute: "Execute",
217
+ think: "Think ",
218
+ };
219
+
220
+ function fmt$(n) {
221
+ return "$" + n.toFixed(2);
222
+ }
223
+
224
+ function pad(str, len, align = "left") {
225
+ str = String(str);
226
+ if (str.length >= len) return str.slice(0, len);
227
+ const spaces = " ".repeat(len - str.length);
228
+ return align === "right" ? spaces + str : str + spaces;
229
+ }
230
+
231
+ function renderTable(title, aggregated, allOpus, records = []) {
232
+ const totalCost = Object.values(aggregated).reduce((s, v) => s + v.cost, 0);
233
+ const savings = allOpus - totalCost;
234
+ const savingsPct = allOpus > 0 ? Math.round((savings / allOpus) * 100) : 0;
235
+
236
+ const line = (s) => `║ ${pad(s, W - 2)} ║`;
237
+ const border = (l, r, m) => l + "═".repeat(W) + r;
238
+ const sep = () => "╠" + "═".repeat(W) + "╣";
239
+
240
+ const rows = TIER_ORDER
241
+ .filter((t) => aggregated[t])
242
+ .map((t) => {
243
+ const { model, calls, cost } = aggregated[t];
244
+ const tierLbl = pad(TIER_LABELS[t] || t, 8);
245
+ const modelLbl = pad(model, 10);
246
+ const callsLbl = pad(String(calls), 5, "right");
247
+ const costLbl = pad(fmt$(cost), 12, "right");
248
+ return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${costLbl}`);
249
+ });
250
+
251
+ const header = line(`Tier │ Model │ Calls │ Est. Cost `);
252
+ const hline = line(`─────────┼────────────┼───────┼────────────`);
253
+
254
+ const totalCalls = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
255
+ const actualCalls = Object.values(aggregated).reduce((s, v) => s + (v.actualCount || 0), 0);
256
+ const confidence = actualCalls === 0 ? 'low (heuristic only)' :
257
+ actualCalls === totalCalls ? 'high (actual tokens)' :
258
+ `medium (${Math.round(actualCalls/totalCalls*100)}% actual)`;
259
+
260
+ // Data quality stats
261
+ const totalRecords = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
262
+ const unknownModels = records.filter(r => !r.model || r.model === 'unknown').length;
263
+ const v2Records = records.filter(r => r.schema_version >= 2).length;
264
+ const errorRecords = records.filter(r => r.status === 'error').length;
265
+
266
+ const lines = [
267
+ border("╔", "╗"),
268
+ line(pad(title, W - 2)),
269
+ sep(),
270
+ header,
271
+ hline,
272
+ ...rows,
273
+ sep(),
274
+ line(`Total estimated: ${fmt$(totalCost)}`),
275
+ line(`Savings vs all-Opus: ~${fmt$(Math.max(0, savings))} (${savingsPct}%)`),
276
+ line(`Confidence: ${confidence}`),
277
+ border("╚", "╝"),
278
+ ];
279
+
280
+ if (unknownModels > 0 || errorRecords > 0) {
281
+ lines.splice(-1, 0,
282
+ line(`Unknown models: ${unknownModels}/${totalRecords} entries`),
283
+ line(`Errors: ${errorRecords} tool calls failed`),
284
+ );
285
+ }
286
+
287
+ return lines.join("\n");
288
+ }
289
+
290
+ function renderEmpty() {
291
+ const border = (l, r) => l + "═".repeat(W) + r;
292
+ const ln = (s) => `║ ${pad(s, W - 2)} ║`;
293
+ return [
294
+ border("╔", "╗"),
295
+ ln("Activity & Cost Estimate"),
296
+ border("╠", "╣"),
297
+ ln("No usage data yet."),
298
+ ln(""),
299
+ ln("Install cost-logger.mjs as a PostToolUse hook"),
300
+ ln("to start tracking usage."),
301
+ border("╚", "╝"),
302
+ ].join("\n");
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Main
307
+ // ---------------------------------------------------------------------------
308
+
309
+ function main() {
310
+ const args = process.argv.slice(2);
311
+ const showAll = args.includes("--all");
312
+
313
+ const config = loadConfig();
314
+ const rateMap = buildRateMap(config);
315
+ const records = loadUsage();
316
+
317
+ if (records.length === 0) {
318
+ // Try git log fallback for a rough mention
319
+ const commits = gitFallbackSummary();
320
+ console.log(renderEmpty());
321
+ if (commits > 0) {
322
+ console.log(`\n (Git log shows ${commits} commit(s) today — no tool-level data available.)`);
323
+ }
324
+ return;
325
+ }
326
+
327
+ const today = todayPrefix();
328
+
329
+ if (!showAll) {
330
+ // Today's report
331
+ const todayAgg = aggregate(records, rateMap, today);
332
+ const todayOpus = allOpusCost(records, rateMap, today);
333
+ const todayRecords = records.filter(r => r.timestamp?.startsWith(today));
334
+ const hasTodayData = Object.keys(todayAgg).length > 0;
335
+
336
+ if (hasTodayData) {
337
+ console.log(renderTable("Activity & Cost Estimate — Today", todayAgg, todayOpus, todayRecords));
338
+ } else {
339
+ console.log(" No activity recorded for today yet.");
340
+ }
341
+
342
+ console.log(); // blank line separator
343
+ }
344
+
345
+ // All-time report
346
+ const allAgg = aggregate(records, rateMap);
347
+ const allOpus = allOpusCost(records, rateMap);
348
+ console.log(renderTable("Activity & Cost Estimate — All Time", allAgg, allOpus, records));
349
+ }
350
+
351
+ main();