dual-brain 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
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
+ // ---------------------------------------------------------------------------
16
+ // Paths
17
+ // ---------------------------------------------------------------------------
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
20
+ function usageFile(date) {
21
+ const d = date || new Date().toISOString().slice(0, 10);
22
+ return join(__dirname, `usage-${d}.jsonl`);
23
+ }
24
+
25
+ // Ensure the hooks dir exists (idempotent, defensive)
26
+ mkdirSync(__dirname, { recursive: true });
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Tier classification
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Tools that are pure read-only lookups → "search" tier.
34
+ * Everything else defaults to "execute"; "think" is only detected when an
35
+ * Agent sub-agent call carries a model hint in its parameters.
36
+ */
37
+ const SEARCH_TOOLS = new Set([
38
+ "Read",
39
+ "Glob",
40
+ "Grep",
41
+ "LS",
42
+ "WebSearch",
43
+ "WebFetch",
44
+ "mcp__github__search_repositories",
45
+ "mcp__github__get_file_contents",
46
+ "mcp__github__list_commits",
47
+ "mcp__github__list_issues",
48
+ "mcp__github__list_pull_requests",
49
+ "mcp__github__search_code",
50
+ ]);
51
+
52
+ const THINK_TOOLS = new Set([
53
+ "TodoWrite", // planning artefact
54
+ "WebFetch", // sometimes used for deep research; included in both sets so
55
+ // the model param check below can upgrade it
56
+ ]);
57
+
58
+ /** Map a Claude model string → canonical tier name */
59
+ function modelToTier(model) {
60
+ if (!model) return null;
61
+ const m = String(model).toLowerCase();
62
+ if (m.includes("opus")) return "think";
63
+ if (m.includes("sonnet")) return "execute";
64
+ if (m.includes("haiku")) return "search";
65
+ if (m.includes("gpt-5.5") || m.includes("gpt4.5")) return "think";
66
+ if (m.includes("mini")) return "search";
67
+ if (m.includes("gpt-5.4") || m.includes("gpt-4.1")) return "execute";
68
+ return null;
69
+ }
70
+
71
+ /** Detect the provider from a model name */
72
+ function detectProvider(model) {
73
+ if (!model || model === 'main-session') return 'claude';
74
+ const m = String(model).toLowerCase();
75
+ if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'openai';
76
+ if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku') || m.includes('claude')) return 'claude';
77
+ return 'claude'; // default to claude since we're in Claude Code
78
+ }
79
+
80
+ /** Extract canonical model name from an arbitrary model string */
81
+ function canonicalModel(model) {
82
+ if (!model) return "main-session";
83
+ const m = String(model).toLowerCase();
84
+ if (m.includes("opus")) return "opus";
85
+ if (m.includes("sonnet")) return "sonnet";
86
+ if (m.includes("haiku")) return "haiku";
87
+ if (m.includes("gpt-5.5")) return "gpt-5.5";
88
+ if (m.includes("gpt-5.4")) return "gpt-5.4";
89
+ if (m.includes("gpt-4.1-mini") || m.includes("mini")) return "gpt-4.1-mini";
90
+ return model;
91
+ }
92
+
93
+ /**
94
+ * Classify a tool call into { tier, model }.
95
+ *
96
+ * @param {string} toolName
97
+ * @param {object} toolInput — raw input parameters from the hook payload
98
+ * @param {string|null} agentModel — model hint from the outer agent context
99
+ */
100
+ function classify(toolName, toolInput = {}, agentModel = null) {
101
+ // 1. If there's an explicit model hint in the input params (sub-agent call),
102
+ // let it drive the tier.
103
+ const inputModel =
104
+ toolInput?.model ||
105
+ toolInput?.Model ||
106
+ toolInput?.modelId ||
107
+ null;
108
+
109
+ const effectiveModel = inputModel || agentModel;
110
+ const tierFromModel = modelToTier(effectiveModel);
111
+
112
+ if (toolName === "Agent" || toolName === "Task") {
113
+ return {
114
+ tier: tierFromModel || "think", // sub-agents default to "think"
115
+ model: canonicalModel(effectiveModel),
116
+ };
117
+ }
118
+
119
+ if (THINK_TOOLS.has(toolName) && tierFromModel) {
120
+ return { tier: tierFromModel, model: canonicalModel(effectiveModel) };
121
+ }
122
+
123
+ if (SEARCH_TOOLS.has(toolName)) {
124
+ return { tier: "search", model: canonicalModel(effectiveModel) };
125
+ }
126
+
127
+ // Everything else: edit / bash / write / test → execute
128
+ return {
129
+ tier: tierFromModel || "execute",
130
+ model: canonicalModel(effectiveModel),
131
+ };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Budget alerts
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function checkBudget() {
139
+ let config;
140
+ try {
141
+ config = JSON.parse(readFileSync(join(__dirname, '..', 'orchestrator.json'), 'utf8'));
142
+ } catch { return null; }
143
+
144
+ const budgets = config.budgets;
145
+ if (!budgets) return null;
146
+
147
+ // Rate limit alerts
148
+ const cooldownFile = join(__dirname, '.budget-alerted');
149
+ const cooldownMin = budgets.alert_cooldown_minutes || 15;
150
+ try {
151
+ const lastAlert = readFileSync(cooldownFile, 'utf8').trim();
152
+ if (Date.now() - Date.parse(lastAlert) < cooldownMin * 60 * 1000) return null;
153
+ } catch {}
154
+
155
+ // Calculate today's estimated cost
156
+ const todayFile = usageFile();
157
+ let records = [];
158
+ try {
159
+ records = readFileSync(todayFile, 'utf8').split('\n').filter(Boolean).map(l => {
160
+ try { return JSON.parse(l); } catch { return null; }
161
+ }).filter(Boolean);
162
+ } catch { return null; }
163
+
164
+ // Simple cost estimate using tier heuristics
165
+ const RATES = { search: 0.003, execute: 0.012, think: 0.055 };
166
+ const totalCost = records.reduce((sum, r) => sum + (RATES[r.tier] || RATES.execute), 0);
167
+
168
+ let msg = null;
169
+ if (budgets.daily_limit_usd && totalCost >= budgets.daily_limit_usd) {
170
+ msg = `**[Budget Alert]** Daily cost estimate (~$${totalCost.toFixed(2)}) has reached the $${budgets.daily_limit_usd} limit. Consider pausing non-essential work.`;
171
+ } else if (budgets.daily_warn_usd && totalCost >= budgets.daily_warn_usd) {
172
+ msg = `**[Budget Alert]** Daily cost estimate (~$${totalCost.toFixed(2)}) has passed the $${budgets.daily_warn_usd} warning threshold.`;
173
+ }
174
+
175
+ if (msg) {
176
+ try { writeFileSync(cooldownFile, new Date().toISOString()); } catch {}
177
+ return msg;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Main — read stdin, classify, append, respond
184
+ // ---------------------------------------------------------------------------
185
+
186
+ async function main() {
187
+ // Read all stdin (non-blocking-safe with a short timeout)
188
+ let raw = "";
189
+ try {
190
+ for await (const chunk of process.stdin) {
191
+ raw += chunk;
192
+ if (raw.length > 64 * 1024) break; // safety cap
193
+ }
194
+ } catch {
195
+ // stdin closed or empty — not fatal
196
+ }
197
+
198
+ let payload = {};
199
+ try {
200
+ payload = JSON.parse(raw);
201
+ } catch {
202
+ // Malformed JSON — proceed with empty payload
203
+ }
204
+
205
+ const toolName = payload?.tool_name || payload?.toolName || "unknown";
206
+ const toolInput = payload?.tool_input || payload?.toolInput || {};
207
+ const agentModel = payload?.model || payload?.agent_model || null;
208
+
209
+ const { tier, model } = classify(toolName, toolInput, agentModel);
210
+
211
+ // Extract actual token counts from payload (location varies by hook version)
212
+ const usage = payload?.usage || toolInput?.usage || {};
213
+ const inputTokens = usage.input_tokens ?? payload?.input_tokens ?? null;
214
+ const outputTokens = usage.output_tokens ?? payload?.output_tokens ?? null;
215
+
216
+ const status = (payload?.error || payload?.tool_response?.error || payload?.is_error) ? 'error' : 'ok';
217
+
218
+ const entry = JSON.stringify({
219
+ schema_version: 2,
220
+ timestamp: new Date().toISOString(),
221
+ tier,
222
+ tool: toolName,
223
+ model,
224
+ provider: detectProvider(model),
225
+ dispatcher: 'claude-code',
226
+ status,
227
+ session_id: process.env.CLAUDE_SESSION_ID || null,
228
+ input_tokens: inputTokens,
229
+ output_tokens: outputTokens,
230
+ });
231
+
232
+ try {
233
+ appendFileSync(usageFile(), entry + "\n", { encoding: "utf8", flag: "a" });
234
+ } catch {
235
+ // Disk write failed — silently ignore so the hook never blocks the IDE
236
+ }
237
+
238
+ // Check budget thresholds and emit a systemMessage if over limit
239
+ const budgetMsg = checkBudget();
240
+
241
+ // PostToolUse hooks must emit a JSON object to stdout
242
+ if (budgetMsg) {
243
+ process.stdout.write(JSON.stringify({ systemMessage: budgetMsg }) + "\n");
244
+ } else {
245
+ process.stdout.write("{}\n");
246
+ }
247
+ process.exit(0);
248
+ }
249
+
250
+ main();
@@ -0,0 +1,344 @@
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 { execSync } 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 log = execSync(
130
+ `git -C "${WORKSPACE}" log --oneline --since="${today} 00:00" --until="${today} 23:59"`,
131
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
132
+ ).trim();
133
+ const commits = log ? log.split("\n").length : 0;
134
+ return commits;
135
+ } catch {
136
+ return 0;
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Aggregation
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function todayPrefix() {
145
+ return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
146
+ }
147
+
148
+ /**
149
+ * Aggregate records into { [tier]: { model, calls, cost } }
150
+ * where model is the most-seen model for that tier.
151
+ */
152
+ function aggregate(records, rateMap, datePrefix = null) {
153
+ const filtered = datePrefix
154
+ ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
155
+ : records;
156
+
157
+ // tier → { calls: number, costSum: number, modelCounts: { model: count } }
158
+ const buckets = {};
159
+
160
+ for (const record of filtered) {
161
+ const tier = record.tier || "execute";
162
+ const model = record.model || "unknown";
163
+ if (!buckets[tier]) {
164
+ buckets[tier] = { calls: 0, costSum: 0, modelCounts: {} };
165
+ }
166
+ buckets[tier].calls += 1;
167
+ buckets[tier].costSum += estimateCost(tier, model, rateMap, record);
168
+ buckets[tier].modelCounts[model] = (buckets[tier].modelCounts[model] || 0) + 1;
169
+ if (record.input_tokens != null && record.output_tokens != null) buckets[tier].actualCount = (buckets[tier].actualCount || 0) + 1;
170
+ }
171
+
172
+ // Resolve dominant model per tier
173
+ const result = {};
174
+ for (const [tier, data] of Object.entries(buckets)) {
175
+ const dominantModel = Object.entries(data.modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
176
+ result[tier] = {
177
+ model: dominantModel,
178
+ calls: data.calls,
179
+ cost: data.costSum,
180
+ actualCount: data.actualCount || 0,
181
+ };
182
+ }
183
+ return result;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Opus all-in cost (for savings calculation)
188
+ // ---------------------------------------------------------------------------
189
+ function allOpusCost(records, rateMap, datePrefix = null) {
190
+ const filtered = datePrefix
191
+ ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
192
+ : records;
193
+
194
+ return filtered.reduce((sum, record) => {
195
+ return sum + estimateCost("think", "opus", rateMap, record);
196
+ }, 0);
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Formatting helpers
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const TIER_ORDER = ["search", "execute", "think"];
204
+
205
+ const TIER_LABELS = {
206
+ search: "Search ",
207
+ execute: "Execute",
208
+ think: "Think ",
209
+ };
210
+
211
+ function fmt$(n) {
212
+ return "$" + n.toFixed(2);
213
+ }
214
+
215
+ function pad(str, len, align = "left") {
216
+ str = String(str);
217
+ if (str.length >= len) return str.slice(0, len);
218
+ const spaces = " ".repeat(len - str.length);
219
+ return align === "right" ? spaces + str : str + spaces;
220
+ }
221
+
222
+ function renderTable(title, aggregated, allOpus, records = []) {
223
+ const totalCost = Object.values(aggregated).reduce((s, v) => s + v.cost, 0);
224
+ const savings = allOpus - totalCost;
225
+ const savingsPct = allOpus > 0 ? Math.round((savings / allOpus) * 100) : 0;
226
+
227
+ const W = 50; // total inner width (between ║ chars)
228
+
229
+ const line = (s) => `║ ${pad(s, W - 2)} ║`;
230
+ const border = (l, r, m) => l + "═".repeat(W) + r;
231
+ const sep = () => "╠" + "═".repeat(W) + "╣";
232
+
233
+ const rows = TIER_ORDER
234
+ .filter((t) => aggregated[t])
235
+ .map((t) => {
236
+ const { model, calls, cost } = aggregated[t];
237
+ const tierLbl = pad(TIER_LABELS[t] || t, 8);
238
+ const modelLbl = pad(model, 10);
239
+ const callsLbl = pad(String(calls), 5, "right");
240
+ const costLbl = pad(fmt$(cost), 12, "right");
241
+ return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${costLbl}`);
242
+ });
243
+
244
+ const header = line(`Tier │ Model │ Calls │ Est. Cost `);
245
+ const hline = line(`─────────┼────────────┼───────┼────────────`);
246
+
247
+ const totalCalls = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
248
+ const actualCalls = Object.values(aggregated).reduce((s, v) => s + (v.actualCount || 0), 0);
249
+ const confidence = actualCalls === 0 ? 'low (heuristic only)' :
250
+ actualCalls === totalCalls ? 'high (actual tokens)' :
251
+ `medium (${Math.round(actualCalls/totalCalls*100)}% actual)`;
252
+
253
+ // Data quality stats
254
+ const totalRecords = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
255
+ const unknownModels = records.filter(r => !r.model || r.model === 'unknown').length;
256
+ const v2Records = records.filter(r => r.schema_version >= 2).length;
257
+ const errorRecords = records.filter(r => r.status === 'error').length;
258
+
259
+ const lines = [
260
+ border("╔", "╗"),
261
+ line(pad(title, W - 2)),
262
+ sep(),
263
+ header,
264
+ hline,
265
+ ...rows,
266
+ sep(),
267
+ line(`Total estimated: ${fmt$(totalCost)}`),
268
+ line(`Savings vs all-Opus: ~${fmt$(Math.max(0, savings))} (${savingsPct}%)`),
269
+ line(`Confidence: ${confidence}`),
270
+ border("╚", "╝"),
271
+ ];
272
+
273
+ if (unknownModels > 0 || errorRecords > 0) {
274
+ lines.splice(-1, 0,
275
+ line(`Unknown models: ${unknownModels}/${totalRecords} entries`),
276
+ line(`Errors: ${errorRecords} tool calls failed`),
277
+ );
278
+ }
279
+
280
+ return lines.join("\n");
281
+ }
282
+
283
+ function renderEmpty() {
284
+ const border = (l, r) => l + "═".repeat(W) + r;
285
+ const ln = (s) => `║ ${pad(s, W - 2)} ║`;
286
+ return [
287
+ border("╔", "╗"),
288
+ ln("Activity & Cost Estimate"),
289
+ border("╠", "╣"),
290
+ ln("No usage data yet."),
291
+ ln(""),
292
+ ln("Install cost-logger.mjs as a PostToolUse hook"),
293
+ ln("to start tracking usage."),
294
+ border("╚", "╝"),
295
+ ].join("\n");
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Main
300
+ // ---------------------------------------------------------------------------
301
+
302
+ function main() {
303
+ const args = process.argv.slice(2);
304
+ const showAll = args.includes("--all");
305
+
306
+ const config = loadConfig();
307
+ const rateMap = buildRateMap(config);
308
+ const records = loadUsage();
309
+
310
+ if (records.length === 0) {
311
+ // Try git log fallback for a rough mention
312
+ const commits = gitFallbackSummary();
313
+ console.log(renderEmpty());
314
+ if (commits > 0) {
315
+ console.log(`\n (Git log shows ${commits} commit(s) today — no tool-level data available.)`);
316
+ }
317
+ return;
318
+ }
319
+
320
+ const today = todayPrefix();
321
+
322
+ if (!showAll) {
323
+ // Today's report
324
+ const todayAgg = aggregate(records, rateMap, today);
325
+ const todayOpus = allOpusCost(records, rateMap, today);
326
+ const todayRecords = records.filter(r => r.timestamp?.startsWith(today));
327
+ const hasTodayData = Object.keys(todayAgg).length > 0;
328
+
329
+ if (hasTodayData) {
330
+ console.log(renderTable("Activity & Cost Estimate — Today", todayAgg, todayOpus, todayRecords));
331
+ } else {
332
+ console.log(" No activity recorded for today yet.");
333
+ }
334
+
335
+ console.log(); // blank line separator
336
+ }
337
+
338
+ // All-time report
339
+ const allAgg = aggregate(records, rateMap);
340
+ const allOpus = allOpusCost(records, rateMap);
341
+ console.log(renderTable("Activity & Cost Estimate — All Time", allAgg, allOpus, records));
342
+ }
343
+
344
+ main();