dual-brain 4.2.0 → 4.5.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,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * confirmation-policy.mjs — Centralized confirmation policy for Ship Captain.
4
+ *
5
+ * Single source of truth for "should we prompt the user?"
6
+ * Controls automation level based on risk level and user flags.
7
+ *
8
+ * Exports: getConfirmationPolicy, resolveMode, aggregateRisk, formatConfirmation
9
+ */
10
+
11
+ // Risk level ordering for comparison
12
+ const RISK_ORDER = ['low', 'medium', 'high', 'critical'];
13
+
14
+ // Steps that are mutations (vs. validation steps)
15
+ const MUTATION_STEPS = new Set(['edit', 'pr', 'push']);
16
+ // Steps that are validation (auto-run in careful mode too)
17
+ const VALIDATION_STEPS = new Set(['test', 'gate']);
18
+ // Heal step: a fix attempt — auto-runs unless in careful mode (where user confirms)
19
+ const HEAL_STEPS = new Set(['heal']);
20
+
21
+ /**
22
+ * getConfirmationPolicy({ risk, mode, step })
23
+ *
24
+ * @param {object} options
25
+ * @param {'low'|'medium'|'high'|'critical'} options.risk
26
+ * @param {'default'|'yolo'|'careful'|'plan-only'} options.mode
27
+ * @param {'plan'|'edit'|'test'|'gate'|'heal'|'pr'|'push'} options.step
28
+ * @returns {{ shouldConfirm: boolean, shouldBlock: boolean, reason: string }}
29
+ */
30
+ export function getConfirmationPolicy({ risk, mode, step }) {
31
+ const safeMode = mode || 'default';
32
+ const safeRisk = risk || 'low';
33
+ const safeStep = step || 'plan';
34
+
35
+ // PLAN-ONLY mode: block all mutations, allow plan display; skip heal (no mutations)
36
+ if (safeMode === 'plan-only') {
37
+ if (safeStep === 'plan') {
38
+ return { shouldConfirm: false, shouldBlock: false, reason: 'Plan-only mode — showing plan' };
39
+ }
40
+ if (HEAL_STEPS.has(safeStep)) {
41
+ return {
42
+ shouldConfirm: false,
43
+ shouldBlock: true,
44
+ reason: 'Plan-only mode — skipping heal (no mutations)',
45
+ };
46
+ }
47
+ return {
48
+ shouldConfirm: false,
49
+ shouldBlock: true,
50
+ reason: 'Plan-only mode — no mutations',
51
+ };
52
+ }
53
+
54
+ // YOLO mode: no confirmations for any step at any risk level; auto-heal
55
+ if (safeMode === 'yolo') {
56
+ if (safeRisk === 'critical') {
57
+ return {
58
+ shouldConfirm: false,
59
+ shouldBlock: false,
60
+ reason: '⚠ YOLO mode on critical risk surface',
61
+ };
62
+ }
63
+ return { shouldConfirm: false, shouldBlock: false, reason: 'YOLO mode — proceeding automatically' };
64
+ }
65
+
66
+ // CAREFUL mode: confirm every mutation/plan step; validation steps auto-run;
67
+ // heal step requires confirmation before each attempt
68
+ if (safeMode === 'careful') {
69
+ if (VALIDATION_STEPS.has(safeStep)) {
70
+ return { shouldConfirm: false, shouldBlock: false, reason: 'Validation step — auto-run' };
71
+ }
72
+ if (HEAL_STEPS.has(safeStep)) {
73
+ return {
74
+ shouldConfirm: true,
75
+ shouldBlock: false,
76
+ reason: 'Careful mode — confirming heal attempt',
77
+ };
78
+ }
79
+ return {
80
+ shouldConfirm: true,
81
+ shouldBlock: false,
82
+ reason: `Careful mode — confirming ${safeStep} step`,
83
+ };
84
+ }
85
+
86
+ // DEFAULT mode: risk-based policy
87
+ // Heal step: auto-heal for all risk levels — it's just a fix attempt, no worse than the original change
88
+ if (safeMode === 'default') {
89
+ if (HEAL_STEPS.has(safeStep)) {
90
+ return {
91
+ shouldConfirm: false,
92
+ shouldBlock: false,
93
+ reason: 'Heal step — auto-proceeding (fix attempt only)',
94
+ };
95
+ }
96
+ if (safeRisk === 'critical') {
97
+ return {
98
+ shouldConfirm: false,
99
+ shouldBlock: true,
100
+ reason: 'Critical risk requires --yolo flag',
101
+ };
102
+ }
103
+ if (safeRisk === 'high') {
104
+ if (safeStep === 'edit' || safeStep === 'pr') {
105
+ return {
106
+ shouldConfirm: true,
107
+ shouldBlock: false,
108
+ reason: `High-risk step requires confirmation before ${safeStep}`,
109
+ };
110
+ }
111
+ return { shouldConfirm: false, shouldBlock: false, reason: 'High risk — auto-proceeding for non-critical step' };
112
+ }
113
+ // low or medium: no confirmations
114
+ return { shouldConfirm: false, shouldBlock: false, reason: 'Low/medium risk — proceeding automatically' };
115
+ }
116
+
117
+ // Fallback: treat unknown modes as default/no-confirm
118
+ return { shouldConfirm: false, shouldBlock: false, reason: 'Unknown mode — proceeding automatically' };
119
+ }
120
+
121
+ /**
122
+ * resolveMode(argv) — Parse CLI flags into a mode string.
123
+ *
124
+ * @param {string[]} argv Process argv array (e.g. process.argv)
125
+ * @returns {'default'|'yolo'|'careful'|'plan-only'}
126
+ */
127
+ export function resolveMode(argv) {
128
+ const args = argv || [];
129
+ if (args.includes('--yolo')) return 'yolo';
130
+ if (args.includes('--careful')) return 'careful';
131
+ if (args.includes('--plan-only') || args.includes('--dry-run')) return 'plan-only';
132
+ return 'default';
133
+ }
134
+
135
+ /**
136
+ * aggregateRisk(risks) — Return the highest risk level from an array.
137
+ *
138
+ * @param {string[]} risks Array of risk strings
139
+ * @returns {'low'|'medium'|'high'|'critical'}
140
+ */
141
+ export function aggregateRisk(risks) {
142
+ if (!Array.isArray(risks) || risks.length === 0) return 'low';
143
+ let maxIndex = 0;
144
+ for (const r of risks) {
145
+ const idx = RISK_ORDER.indexOf(r);
146
+ if (idx > maxIndex) maxIndex = idx;
147
+ }
148
+ return RISK_ORDER[maxIndex];
149
+ }
150
+
151
+ /**
152
+ * formatConfirmation(step, risk, reason) — Format a human-readable confirmation prompt.
153
+ *
154
+ * @param {string} step
155
+ * @param {string} risk
156
+ * @param {string} reason
157
+ * @returns {string}
158
+ */
159
+ export function formatConfirmation(step, risk, reason) {
160
+ if (risk === 'critical') {
161
+ return `\u{1F534} Critical risk detected: ${reason}. This requires explicit approval. Continue? [Y/n]`;
162
+ }
163
+ if (risk === 'high') {
164
+ return `⚠ High-risk step: ${reason}. Continue? [Y/n]`;
165
+ }
166
+ return `\u{2139} ${reason} (step: ${step}). Continue? [Y/n]`;
167
+ }
@@ -11,6 +11,7 @@
11
11
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
12
  import { dirname, join } from "path";
13
13
  import { fileURLToPath } from "url";
14
+ import { logHookError } from './error-channel.mjs';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
@@ -166,11 +167,11 @@ async function checkBudget() {
166
167
  } catch {}
167
168
 
168
169
  // Use summary checkpoint for fast budget check (O(1) instead of full scan)
169
- let totalCost = 0;
170
+ let activityScore = 0;
170
171
  try {
171
172
  const { readSummary } = await import('./summary-checkpoint.mjs');
172
173
  const summary = readSummary();
173
- totalCost = summary.totals.cost_estimate;
174
+ activityScore = summary.totals.activity_score || 0;
174
175
  } catch {
175
176
  // Fallback: scan the log (only if summary unavailable)
176
177
  const todayFile = usageFile();
@@ -180,15 +181,26 @@ async function checkBudget() {
180
181
  try { return JSON.parse(l); } catch { return null; }
181
182
  }).filter(Boolean);
182
183
  } 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);
184
+ const TIER_WEIGHTS = { search: 3, execute: 10, think: 25 };
185
+ const rawActivity = records.reduce((sum, r) => {
186
+ if (r.input_tokens != null && r.output_tokens != null) {
187
+ return sum + (r.input_tokens * 1) + (r.output_tokens * 3);
188
+ }
189
+ return sum + (TIER_WEIGHTS[r.tier] || TIER_WEIGHTS.execute);
190
+ }, 0);
191
+ activityScore = Math.min(100, Math.round((rawActivity / 5_000_000) * 100));
185
192
  }
186
193
 
194
+ // Budget thresholds use activity score (0-100) instead of dollar amounts.
195
+ // Falls back to legacy daily_limit_usd / daily_warn_usd field names for compat.
196
+ const activityLimit = budgets.daily_activity_limit || (budgets.daily_limit_usd ? 85 : null);
197
+ const activityWarn = budgets.daily_activity_warn || (budgets.daily_warn_usd ? 65 : null);
198
+
187
199
  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.`;
200
+ if (activityLimit && activityScore >= activityLimit) {
201
+ msg = `**[Activity Alert]** Session activity score (${activityScore}/100) has reached the limit. Consider pausing non-essential work.`;
202
+ } else if (activityWarn && activityScore >= activityWarn) {
203
+ msg = `**[Activity Alert]** Session activity score (${activityScore}/100) has passed the warning threshold.`;
192
204
  }
193
205
 
194
206
  if (msg) {
@@ -222,6 +234,14 @@ async function main() {
222
234
  }
223
235
 
224
236
  const toolName = payload?.tool_name || payload?.toolName || "unknown";
237
+
238
+ // Early exit for high-frequency read-only tools — not worth logging
239
+ const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "ListDir"]);
240
+ if (READ_ONLY_TOOLS.has(toolName)) {
241
+ process.stdout.write("{}\n");
242
+ process.exit(0);
243
+ }
244
+
225
245
  const toolInput = payload?.tool_input || payload?.toolInput || {};
226
246
  const agentModel = payload?.model || payload?.agent_model || null;
227
247
 
@@ -253,13 +273,13 @@ async function main() {
253
273
 
254
274
  try {
255
275
  appendFileSync(usageFile(), entry + "\n", { encoding: "utf8", flag: "a" });
256
- } catch {}
276
+ } catch (e) { logHookError('cost-logger', 'usage log write', e); }
257
277
 
258
278
  // Update summary checkpoint (non-blocking, best-effort)
259
279
  try {
260
280
  const { updateSummary } = await import('./summary-checkpoint.mjs');
261
281
  updateSummary(entryObj);
262
- } catch {}
282
+ } catch (e) { logHookError('cost-logger', 'summary checkpoint update', e); }
263
283
 
264
284
  // Record failures for adaptive routing (failure-loop detection)
265
285
  if (status === 'error' && toolName === 'Agent') {
@@ -269,7 +289,7 @@ async function main() {
269
289
  recordFailure(promptHash, tier, payload?.error || 'agent_error');
270
290
  // Best-effort cleanup of stale failure entries (>24h old)
271
291
  try { pruneOldFailures(); } catch {}
272
- } catch {}
292
+ } catch (e) { logHookError('cost-logger', 'failure recording', e); }
273
293
  }
274
294
 
275
295
  // Record outcomes (success + failure) to decision ledger for routing feedback
@@ -291,7 +311,7 @@ async function main() {
291
311
  actual_input_tokens: inputTokens,
292
312
  actual_output_tokens: outputTokens,
293
313
  });
294
- } catch {}
314
+ } catch (e) { logHookError('cost-logger', 'decision ledger recording', e); }
295
315
  }
296
316
 
297
317
  const budgetMsg = await checkBudget();
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * Reads:
11
11
  * .claude/hooks/usage.jsonl — tool call log written by cost-logger.mjs
12
- * .claude/orchestrator.json — cost rates per model
12
+ *
13
+ * Reports token-weighted activity scores (0-100), not dollar estimates.
13
14
  */
14
15
 
15
16
  import { readFileSync, existsSync, readdirSync } from "fs";
@@ -22,37 +23,8 @@ import { execSync } from "child_process";
22
23
  // ---------------------------------------------------------------------------
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
25
  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
- }
26
+ // Config and rate maps removed — cost-report no longer estimates dollar costs.
27
+ // Activity scores are computed from token counts directly.
56
28
 
57
29
  // ---------------------------------------------------------------------------
58
30
  // Load & parse usage log
@@ -83,41 +55,20 @@ function loadUsage() {
83
55
  // Cost estimation
84
56
  // ---------------------------------------------------------------------------
85
57
 
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
- };
58
+ // Tier-based fallback weights when actual token counts are unavailable (legacy entries).
59
+ // These are unitless activity weights, NOT dollar costs.
60
+ const TIER_ACTIVITY_WEIGHTS = { search: 3, execute: 10, think: 25 };
61
+
62
+ // Activity formula: (input_tokens * 1) + (output_tokens * 3)
63
+ // SESSION_ACTIVITY_CEILING is the raw token-weighted value that maps to score 100.
64
+ const SESSION_ACTIVITY_CEILING = 5_000_000;
97
65
 
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
66
+ function computeActivity(tier, record = {}) {
101
67
  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
- );
68
+ if (hasActual) {
69
+ return { raw: (record.input_tokens * 1) + (record.output_tokens * 3), basis: 'actual' };
116
70
  }
117
- return (
118
- (inputTok / 1_000_000) * rate.input_per_mtok +
119
- (outputTok / 1_000_000) * rate.output_per_mtok
120
- );
71
+ return { raw: TIER_ACTIVITY_WEIGHTS[tier] || TIER_ACTIVITY_WEIGHTS.execute, basis: 'estimated' };
121
72
  }
122
73
 
123
74
  // ---------------------------------------------------------------------------
@@ -146,29 +97,35 @@ function todayPrefix() {
146
97
  }
147
98
 
148
99
  /**
149
- * Aggregate records into { [tier]: { model, calls, cost } }
100
+ * Aggregate records into { [tier]: { model, calls, activityRaw, actualCount } }
150
101
  * where model is the most-seen model for that tier.
151
102
  */
152
- function aggregate(records, rateMap, datePrefix = null) {
103
+ function aggregate(records, datePrefix = null) {
153
104
  const filtered = datePrefix
154
105
  ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
155
106
  : records;
156
107
 
157
- // tier → { calls: number, costSum: number, modelCounts: { model: count } }
108
+ // tier → { calls, activityRaw, actualCount, modelCounts }
158
109
  const buckets = {};
159
110
 
160
111
  for (const record of filtered) {
161
112
  const tier = record.tier || "execute";
162
113
  const model = record.model || "unknown";
163
114
  if (!buckets[tier]) {
164
- buckets[tier] = { calls: 0, costSum: 0, modelCounts: {} };
115
+ buckets[tier] = { calls: 0, activityRaw: 0, actualCount: 0, modelCounts: {} };
165
116
  }
166
117
  buckets[tier].calls += 1;
167
- buckets[tier].costSum += estimateCost(tier, model, rateMap, record);
118
+ const { raw } = computeActivity(tier, record);
119
+ buckets[tier].activityRaw += raw;
168
120
  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;
121
+ if (record.input_tokens != null && record.output_tokens != null) {
122
+ buckets[tier].actualCount += 1;
123
+ }
170
124
  }
171
125
 
126
+ // Compute total raw for percentage breakdown
127
+ const totalRaw = Object.values(buckets).reduce((s, b) => s + b.activityRaw, 0);
128
+
172
129
  // Resolve dominant model per tier
173
130
  const result = {};
174
131
  for (const [tier, data] of Object.entries(buckets)) {
@@ -176,24 +133,23 @@ function aggregate(records, rateMap, datePrefix = null) {
176
133
  result[tier] = {
177
134
  model: dominantModel,
178
135
  calls: data.calls,
179
- cost: data.costSum,
180
- actualCount: data.actualCount || 0,
136
+ activityRaw: data.activityRaw,
137
+ activityPct: totalRaw > 0 ? Math.round((data.activityRaw / totalRaw) * 100) : 0,
138
+ actualCount: data.actualCount,
181
139
  };
182
140
  }
183
141
  return result;
184
142
  }
185
143
 
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);
144
+ /**
145
+ * Classify overall activity level from score.
146
+ */
147
+ function activityLabel(score) {
148
+ if (score <= 10) return 'minimal';
149
+ if (score <= 30) return 'light';
150
+ if (score <= 60) return 'moderate';
151
+ if (score <= 85) return 'heavy';
152
+ return 'intense';
197
153
  }
198
154
 
199
155
  // ---------------------------------------------------------------------------
@@ -210,10 +166,6 @@ const TIER_LABELS = {
210
166
  think: "Think ",
211
167
  };
212
168
 
213
- function fmt$(n) {
214
- return "$" + n.toFixed(2);
215
- }
216
-
217
169
  function pad(str, len, align = "left") {
218
170
  str = String(str);
219
171
  if (str.length >= len) return str.slice(0, len);
@@ -221,39 +173,37 @@ function pad(str, len, align = "left") {
221
173
  return align === "right" ? spaces + str : str + spaces;
222
174
  }
223
175
 
224
- function renderTable(title, aggregated, allOpus, records = []) {
225
- const totalCost = Object.values(aggregated).reduce((s, v) => s + v.cost, 0);
226
- const savings = allOpus - totalCost;
227
- const savingsPct = allOpus > 0 ? Math.round((savings / allOpus) * 100) : 0;
176
+ function renderTable(title, aggregated, records = []) {
177
+ const totalRaw = Object.values(aggregated).reduce((s, v) => s + v.activityRaw, 0);
178
+ const totalScore = Math.min(100, Math.round((totalRaw / SESSION_ACTIVITY_CEILING) * 100));
179
+ const label = activityLabel(totalScore);
228
180
 
229
181
  const line = (s) => `║ ${pad(s, W - 2)} ║`;
230
- const border = (l, r, m) => l + "═".repeat(W) + r;
182
+ const border = (l, r) => l + "═".repeat(W) + r;
231
183
  const sep = () => "╠" + "═".repeat(W) + "╣";
232
184
 
233
185
  const rows = TIER_ORDER
234
186
  .filter((t) => aggregated[t])
235
187
  .map((t) => {
236
- const { model, calls, cost } = aggregated[t];
188
+ const { model, calls, activityPct } = aggregated[t];
237
189
  const tierLbl = pad(TIER_LABELS[t] || t, 8);
238
190
  const modelLbl = pad(model, 10);
239
191
  const callsLbl = pad(String(calls), 5, "right");
240
- const costLbl = pad(fmt$(cost), 12, "right");
241
- return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${costLbl}`);
192
+ const pctLbl = pad(`${activityPct}%`, 10, "right");
193
+ return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${pctLbl}`);
242
194
  });
243
195
 
244
- const header = line(`Tier │ Model │ Calls │ Est. Cost `);
196
+ const header = line(`Tier │ Model │ Calls │ Activity % `);
245
197
  const hline = line(`─────────┼────────────┼───────┼────────────`);
246
198
 
247
199
  const totalCalls = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
248
200
  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)`;
201
+ const basis = actualCalls === 0 ? 'estimated (no token data)' :
202
+ actualCalls === totalCalls ? 'actual token counts' :
203
+ `mixed (${Math.round(actualCalls/totalCalls*100)}% actual)`;
252
204
 
253
205
  // Data quality stats
254
- const totalRecords = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
255
206
  const unknownModels = records.filter(r => !r.model || r.model === 'unknown').length;
256
- const v2Records = records.filter(r => r.schema_version >= 2).length;
257
207
  const errorRecords = records.filter(r => r.status === 'error').length;
258
208
 
259
209
  const lines = [
@@ -264,15 +214,15 @@ function renderTable(title, aggregated, allOpus, records = []) {
264
214
  hline,
265
215
  ...rows,
266
216
  sep(),
267
- line(`Total estimated: ${fmt$(totalCost)}`),
268
- line(`Savings vs all-Opus: ~${fmt$(Math.max(0, savings))} (${savingsPct}%)`),
269
- line(`Confidence: ${confidence}`),
217
+ line(`Session activity: ${totalScore}/100 (${label})`),
218
+ line(`Basis: ${basis}`),
219
+ line(`Activity score based on token usage, not billing`),
270
220
  border("╚", "╝"),
271
221
  ];
272
222
 
273
223
  if (unknownModels > 0 || errorRecords > 0) {
274
224
  lines.splice(-1, 0,
275
- line(`Unknown models: ${unknownModels}/${totalRecords} entries`),
225
+ line(`Unknown models: ${unknownModels}/${totalCalls} entries`),
276
226
  line(`Errors: ${errorRecords} tool calls failed`),
277
227
  );
278
228
  }
@@ -285,7 +235,7 @@ function renderEmpty() {
285
235
  const ln = (s) => `║ ${pad(s, W - 2)} ║`;
286
236
  return [
287
237
  border("╔", "╗"),
288
- ln("Activity & Cost Estimate"),
238
+ ln("Session Activity Report"),
289
239
  border("╠", "╣"),
290
240
  ln("No usage data yet."),
291
241
  ln(""),
@@ -303,8 +253,6 @@ function main() {
303
253
  const args = process.argv.slice(2);
304
254
  const showAll = args.includes("--all");
305
255
 
306
- const config = loadConfig();
307
- const rateMap = buildRateMap(config);
308
256
  const records = loadUsage();
309
257
 
310
258
  if (records.length === 0) {
@@ -321,13 +269,12 @@ function main() {
321
269
 
322
270
  if (!showAll) {
323
271
  // Today's report
324
- const todayAgg = aggregate(records, rateMap, today);
325
- const todayOpus = allOpusCost(records, rateMap, today);
272
+ const todayAgg = aggregate(records, today);
326
273
  const todayRecords = records.filter(r => r.timestamp?.startsWith(today));
327
274
  const hasTodayData = Object.keys(todayAgg).length > 0;
328
275
 
329
276
  if (hasTodayData) {
330
- console.log(renderTable("Activity & Cost Estimate — Today", todayAgg, todayOpus, todayRecords));
277
+ console.log(renderTable("Session Activity — Today", todayAgg, todayRecords));
331
278
  } else {
332
279
  console.log(" No activity recorded for today yet.");
333
280
  }
@@ -336,9 +283,8 @@ function main() {
336
283
  }
337
284
 
338
285
  // 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));
286
+ const allAgg = aggregate(records);
287
+ console.log(renderTable("Session Activity All Time", allAgg, records));
342
288
  }
343
289
 
344
290
  main();
@@ -23,6 +23,7 @@ import { appendFileSync, existsSync, readFileSync } from 'fs';
23
23
  import { dirname, join } from 'path';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { randomBytes } from 'crypto';
26
+ import { logHookError } from './error-channel.mjs';
26
27
 
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
29
  const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
@@ -62,7 +63,7 @@ function recordDecision(decision = {}) {
62
63
 
63
64
  try {
64
65
  appendFileSync(LEDGER_FILE, entry + '\n');
65
- } catch {}
66
+ } catch (e) { logHookError('decision-ledger', 'recordDecision append', e); }
66
67
 
67
68
  return id;
68
69
  }
@@ -97,7 +98,7 @@ function recordOutcome(decisionId, outcome = {}) {
97
98
 
98
99
  try {
99
100
  appendFileSync(LEDGER_FILE, entry + '\n');
100
- } catch {}
101
+ } catch (e) { logHookError('decision-ledger', 'recordOutcome append', e); }
101
102
  }
102
103
 
103
104
  function loadLedger() {