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.
- package/CLAUDE.md +130 -35
- package/README.md +171 -44
- package/hooks/agent-chains.mjs +369 -0
- package/hooks/agent-templates.mjs +441 -0
- package/hooks/atomic-write.mjs +5 -3
- package/hooks/config-validator.mjs +156 -0
- package/hooks/confirmation-policy.mjs +167 -0
- package/hooks/cost-logger.mjs +32 -12
- package/hooks/cost-report.mjs +60 -114
- package/hooks/decision-ledger.mjs +3 -2
- package/hooks/dual-brain-review.mjs +249 -2
- package/hooks/dual-brain-think.mjs +294 -25
- package/hooks/enforce-tier.mjs +246 -87
- package/hooks/error-channel.mjs +68 -0
- package/hooks/failure-detector.mjs +2 -1
- package/hooks/health-check.mjs +16 -17
- package/hooks/risk-classifier.mjs +135 -2
- package/hooks/session-report.mjs +41 -71
- package/hooks/ship-captain.mjs +1176 -0
- package/hooks/ship-gate.mjs +971 -0
- package/hooks/summary-checkpoint.mjs +31 -4
- package/hooks/test-orchestrator.mjs +1975 -11
- package/install.mjs +1064 -31
- package/orchestrator.json +73 -96
- package/package.json +7 -2
|
@@ -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
|
+
}
|
package/hooks/cost-logger.mjs
CHANGED
|
@@ -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
|
|
170
|
+
let activityScore = 0;
|
|
170
171
|
try {
|
|
171
172
|
const { readSummary } = await import('./summary-checkpoint.mjs');
|
|
172
173
|
const summary = readSummary();
|
|
173
|
-
|
|
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
|
|
184
|
-
|
|
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 (
|
|
189
|
-
msg = `**[
|
|
190
|
-
} else if (
|
|
191
|
-
msg = `**[
|
|
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();
|
package/hooks/cost-report.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Reads:
|
|
11
11
|
* .claude/hooks/usage.jsonl — tool call log written by cost-logger.mjs
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
115
|
+
buckets[tier] = { calls: 0, activityRaw: 0, actualCount: 0, modelCounts: {} };
|
|
165
116
|
}
|
|
166
117
|
buckets[tier].calls += 1;
|
|
167
|
-
|
|
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)
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return
|
|
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,
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
const
|
|
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
|
|
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,
|
|
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
|
|
241
|
-
return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${
|
|
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 │
|
|
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
|
|
250
|
-
actualCalls === totalCalls ? '
|
|
251
|
-
`
|
|
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(`
|
|
268
|
-
line(`
|
|
269
|
-
line(`
|
|
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}/${
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
340
|
-
|
|
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() {
|