Skip to content

Commit 4fb0100

Browse files
feat(stats): record snapshots on SessionEnd
1 parent 7066cc8 commit 4fb0100

16 files changed

Lines changed: 281 additions & 71 deletions

.claude-plugin/plugin.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@
2929
}
3030
]
3131
}
32+
],
33+
"SessionEnd": [
34+
{
35+
"hooks": [
36+
{
37+
"type": "command",
38+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hooks/caveman-stats.js\" --record",
39+
"timeout": 5,
40+
"statusMessage": "Recording caveman stats..."
41+
}
42+
]
43+
}
3244
]
3345
}
3446
}

cli/install.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,8 @@ async function installClaude(ctx) {
532532
// default / --all → wire only if the plugin install did NOT succeed.
533533
// The plugin manifest already wires SessionStart + UserPromptSubmit when the
534534
// plugin install succeeds; wiring them again in settings.json fires both per
535-
// event (two CAVEMAN MODE blocks, two reinforcement lines).
535+
// event (two CAVEMAN MODE blocks, two reinforcement lines). SessionEnd uses
536+
// the same manifest path to record lifetime stats.
536537
let shouldWireHooks;
537538
if (opts.withHooks === false) {
538539
shouldWireHooks = false;
@@ -546,7 +547,7 @@ async function installClaude(ctx) {
546547
// 'auto'
547548
shouldWireHooks = !pluginInstallSucceeded;
548549
if (!shouldWireHooks) {
549-
note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit');
550+
note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit + SessionEnd');
550551
note(' (pass --with-hooks to also wire standalone hooks in settings.json)');
551552
results.skipped.push(['claude-hooks', 'plugin manifest handles hooks']);
552553
} else {
@@ -953,7 +954,7 @@ async function installHooks(ctx) {
953954
if (opts.dryRun) {
954955
note(` would mkdir -p ${hooksDir}`);
955956
for (const f of HOOK_FILES) note(` would install ${path.join(hooksDir, f)}`);
956-
note(` would merge SessionStart + UserPromptSubmit + statusline into ${settingsPath}`);
957+
note(` would merge SessionStart + UserPromptSubmit + SessionEnd + statusline into ${settingsPath}`);
957958
return 'ok';
958959
}
959960

@@ -1010,6 +1011,7 @@ async function installHooks(ctx) {
10101011
const node = absoluteNodePath();
10111012
const activate = path.join(hooksDir, 'caveman-activate.js');
10121013
const tracker = path.join(hooksDir, 'caveman-mode-tracker.js');
1014+
const stats = path.join(hooksDir, 'caveman-stats.js');
10131015
const statusline = path.join(hooksDir, 'caveman-statusline.sh');
10141016

10151017
// Migrate any legacy bare-`node` invocations of our managed scripts.
@@ -1029,6 +1031,13 @@ async function installHooks(ctx) {
10291031
statusMessage: 'Tracking caveman mode...',
10301032
});
10311033

1034+
SETTINGS.addCommandHook(settings, 'SessionEnd', {
1035+
command: `"${node}" "${stats}" --record`,
1036+
marker: 'caveman-stats',
1037+
timeout: 5,
1038+
statusMessage: 'Recording caveman stats...',
1039+
});
1040+
10321041
// Statusline — set if absent or already pointing at our script.
10331042
// Windows: prefer pwsh (PowerShell 7+, cross-platform), fall back to
10341043
// powershell.exe (Windows PowerShell 5.1, ships with every Windows install).
@@ -1440,8 +1449,8 @@ FLAGS
14401449
--all Turn on hooks + init. (mcp-shrink needs an upstream;
14411450
pass --with-mcp-shrink="<cmd>" to add it.)
14421451
--minimal Just the plugin/extension install.
1443-
--with-hooks Claude Code: install SessionStart/UserPromptSubmit hooks
1444-
+ statusline badge. (Default ON.)
1452+
--with-hooks Claude Code: install SessionStart/UserPromptSubmit/
1453+
SessionEnd hooks + statusline badge. (Default ON.)
14451454
--no-hooks Skip the hooks installer.
14461455
--with-init Write per-repo IDE rule files into \$PWD.
14471456
--no-always OpenClaw only: skip \`always: true\` frontmatter and the

skills/caveman-stats/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Reads the current Claude Code session log directly and reports actual input/outp
88

99
Output also includes an `Est. rule overhead` and `Est. net` line whenever the savings figure above them is unambiguous (a single benchmarked mode with a known turn count — no guessing across mixed or unattributed spans). Overhead estimates the per-turn INPUT-token cost of the rules the skill injects every turn — default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS` if you've measured your own setup. Net is savings minus that overhead. On short, terse replies this can go negative — caveman's OUTPUT savings don't clear its INPUT cost — and the line says so directly instead of hiding it behind a gross-savings number. Background: `docs/HONEST-NUMBERS.md`.
1010

11-
Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). That badge stays a gross-savings figure on purpose — it is a glanceable summary, not a full accounting; run `/caveman-stats` for the net picture.
11+
Each displayed run and each SessionEnd snapshot also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). That badge stays a gross-savings figure on purpose — it is a glanceable summary, not a full accounting; run `/caveman-stats` for the net picture.
1212

1313
## How to invoke
1414

skills/caveman-stats/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ name: caveman-stats
33
description: >
44
Show real token usage and estimated savings for the current session.
55
Reads directly from the Claude Code session log — no AI estimation.
6-
Triggers on /caveman-stats. Output is injected by the mode-tracker hook;
7-
the model itself does not compute the numbers.
6+
Triggers on /caveman-stats for display and SessionEnd for silent lifetime
7+
recording. Output is injected by the mode-tracker hook; the model itself does
8+
not compute the numbers.
89
---
910

10-
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
11+
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`, and by the SessionEnd hook as `--record`). The model does not need to do anything when this skill fires — the prompt hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately; SessionEnd records silently.
1112

1213
Output also includes `Est. rule overhead` and `Est. net` lines wherever a savings estimate exists with a known turn count. Rule overhead is the estimated per-turn INPUT-token cost of the injected caveman rules (default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS`) times the turn count. Net is savings minus that overhead — when negative, the output says so plainly and suggests turning caveman off for that workload, rather than hiding the net-negative regime behind a gross-savings number (see `docs/HONEST-NUMBERS.md`).

src/hooks/README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ If you installed caveman standalone (without the plugin), the unified Node insta
2424

2525
- Reads `$CLAUDE_CONFIG_DIR/.caveman-active` (default `~/.claude/.caveman-active`) and outputs a colored badge
2626
- Shows `[CAVEMAN]`, `[CAVEMAN:ULTRA]`, `[CAVEMAN:WENYAN]`, etc.
27-
- Appends the lifetime savings suffix `⛏ 12.4k` from `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` (written by `caveman-stats.js` on each `/caveman-stats` run; absent until the first run, so fresh installs render no fake number). Opt out with `CAVEMAN_STATUSLINE_SAVINGS=0`.
27+
- Appends the lifetime savings suffix `⛏ 12.4k` from `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` (written by `caveman-stats.js` after `/caveman-stats` and at SessionEnd; absent until the first snapshot, so fresh installs render no fake number). Opt out with `CAVEMAN_STATUSLINE_SAVINGS=0`.
28+
29+
### `caveman-stats.js --record` — SessionEnd hook
30+
31+
- Runs when Claude Code ends a session
32+
- Reads `transcript_path` from hook stdin and appends the latest stats snapshot to `$CLAUDE_CONFIG_DIR/.caveman-history.jsonl`
33+
- Writes no stdout, so the hook does not interrupt shutdown
34+
- Keeps duplicate snapshots safe: lifetime views count only the newest row per `session_id`
2835

2936
## Statusline Badge
3037

@@ -86,6 +93,8 @@ Badge examples:
8693
```
8794
SessionStart hook ──writes "full"──▶ $CLAUDE_CONFIG_DIR/.caveman-active ◀──writes mode── UserPromptSubmit hook
8895
96+
SessionEnd records stats
97+
8998
reads
9099
91100
Statusline script
@@ -107,5 +116,5 @@ node cli/install.js --uninstall
107116

108117
Or manually:
109118
1. Remove the caveman hook files from `$CLAUDE_CONFIG_DIR/hooks/` (default `~/.claude/hooks/`): `caveman-activate.js`, `caveman-mode-tracker.js`, `caveman-stats.js`, `caveman-config.js`, and `caveman-statusline.{sh,ps1}`.
110-
2. Remove the SessionStart, UserPromptSubmit, and statusLine entries from `$CLAUDE_CONFIG_DIR/settings.json`.
119+
2. Remove the SessionStart, UserPromptSubmit, SessionEnd, and statusLine entries from `$CLAUDE_CONFIG_DIR/settings.json`.
111120
3. Delete `$CLAUDE_CONFIG_DIR/.caveman-active` (and `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` if you ran `/caveman-stats`).

src/hooks/caveman-stats.js

Lines changed: 81 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ const path = require('path');
1212
const os = require('os');
1313
const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = require('./caveman-config');
1414

15+
const ARG_RECORD = '--record';
16+
const ARG_SESSION_FILE = '--session-file';
17+
const ARG_SHARE = '--share';
18+
const ARG_ALL = '--all';
19+
const ARG_SINCE = '--since';
20+
const HISTORY_BASENAME = '.caveman-history.jsonl';
21+
const ACTIVE_FLAG_BASENAME = '.caveman-active';
22+
const STATUSLINE_SUFFIX_BASENAME = '.caveman-statusline-suffix';
23+
1524
// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
1625
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /
1726
// wenyan modes show no estimate until benchmarked. Add an entry here when a new
@@ -496,17 +505,74 @@ function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessio
496505
(footer ? footer + '\n' : '');
497506
}
498507

508+
function readHookInput() {
509+
if (process.stdin.isTTY) return {};
510+
let raw;
511+
try { raw = fs.readFileSync(0, 'utf8'); }
512+
catch { return {}; }
513+
if (!raw.trim()) return {};
514+
try {
515+
const parsed = JSON.parse(raw);
516+
return parsed && typeof parsed === 'object' ? parsed : {};
517+
} catch {
518+
return {};
519+
}
520+
}
521+
522+
function recordSessionSnapshot({ claudeDir, historyPath, sessionFile, sessionId }) {
523+
if (!sessionFile) return null;
524+
const parsed = parseSession(sessionFile);
525+
if (parsed.turns <= 0) return null;
526+
527+
const flagPath = path.join(claudeDir, ACTIVE_FLAG_BASENAME);
528+
const mode = readFlag(flagPath);
529+
530+
let flagMtimeMs = null;
531+
try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {}
532+
const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME));
533+
const attribution = attributeByMode({
534+
messages: parsed.messages,
535+
modeLog,
536+
mode,
537+
flagMtimeMs,
538+
outputTokens: parsed.outputTokens,
539+
});
540+
541+
const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model });
542+
appendFlag(historyPath, JSON.stringify({
543+
ts: Date.now(),
544+
session_id: sessionId || path.basename(sessionFile, '.jsonl'),
545+
mode: mode || null,
546+
model: parsed.model || null,
547+
output_tokens: parsed.outputTokens,
548+
turns: parsed.turns,
549+
est_saved_tokens: estSavedTokens,
550+
est_saved_usd: estSavedUsd,
551+
}));
552+
553+
const agg = aggregateHistory(historyPath, null);
554+
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
555+
safeWriteFlag(path.join(claudeDir, STATUSLINE_SUFFIX_BASENAME), suffix);
556+
557+
return { parsed, mode, attribution };
558+
}
559+
499560
function main() {
500561
const args = process.argv.slice(2);
501-
const i = args.indexOf('--session-file');
562+
const i = args.indexOf(ARG_SESSION_FILE);
502563
const sessionFileArg = i !== -1 ? args[i + 1] : null;
503-
const share = args.includes('--share');
504-
const all = args.includes('--all');
505-
const sinceIdx = args.indexOf('--since');
564+
const record = args.includes(ARG_RECORD);
565+
const share = args.includes(ARG_SHARE);
566+
const all = args.includes(ARG_ALL);
567+
const sinceIdx = args.indexOf(ARG_SINCE);
506568
const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
507569

508570
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
509-
const historyPath = path.join(claudeDir, '.caveman-history.jsonl');
571+
const historyPath = path.join(claudeDir, HISTORY_BASENAME);
572+
573+
const hookInput = record ? readHookInput() : {};
574+
const hookTranscriptPath = typeof hookInput.transcript_path === 'string' ? hookInput.transcript_path : null;
575+
const hookSessionId = typeof hookInput.session_id === 'string' ? hookInput.session_id : null;
510576

511577
// Lifetime aggregation paths short-circuit before we need a live session.
512578
if (all || sinceArg) {
@@ -520,56 +586,22 @@ function main() {
520586
return;
521587
}
522588

523-
const sessionFile = sessionFileArg || findRecentSession(claudeDir);
589+
const sessionFile = sessionFileArg || hookTranscriptPath || (record ? null : findRecentSession(claudeDir));
524590

525591
if (!sessionFile) {
592+
if (record) return;
526593
process.stderr.write('caveman-stats: no Claude Code session found.\n');
527594
process.exit(1);
528595
}
529596

530-
const parsed = parseSession(sessionFile);
531-
const flagPath = path.join(claudeDir, '.caveman-active');
532-
const mode = readFlag(flagPath);
533-
534-
// #601: attribute tokens to the mode active when each message happened,
535-
// via the transition log the hooks maintain (fallbacks documented on
536-
// attributeByMode). Never credit the whole session to the current flag.
537-
let flagMtimeMs = null;
538-
try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {}
539-
const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME));
540-
const attribution = attributeByMode({
541-
messages: parsed.messages,
542-
modeLog,
543-
mode,
544-
flagMtimeMs,
545-
outputTokens: parsed.outputTokens,
546-
});
547-
548597
// Append a snapshot of this session's totals to the lifetime log. Multiple
549-
// /caveman-stats calls in one session emit multiple lines for the same
550-
// session_id; aggregateHistory keeps only the latest per session_id.
551-
if (parsed.turns > 0) {
552-
const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model });
553-
const sessionId = path.basename(sessionFile, '.jsonl');
554-
appendFlag(historyPath, JSON.stringify({
555-
ts: Date.now(),
556-
session_id: sessionId,
557-
mode: mode || null,
558-
model: parsed.model || null,
559-
output_tokens: parsed.outputTokens,
560-
turns: parsed.turns,
561-
est_saved_tokens: estSavedTokens,
562-
est_saved_usd: estSavedUsd,
563-
}));
564-
565-
// Statusline suffix: tiny pre-rendered string the shell statusline can
566-
// cat without parsing JSONL. Updated on every /caveman-stats run.
567-
// Routed through safeWriteFlag — the suffix path is predictable and
568-
// user-owned, same symlink-clobber surface as the .caveman-active flag.
569-
const agg = aggregateHistory(historyPath, null);
570-
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
571-
safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix);
572-
}
598+
// /caveman-stats calls, plus the SessionEnd --record hook, can emit multiple
599+
// lines for the same session_id; aggregateHistory keeps only the latest.
600+
const snapshot = recordSessionSnapshot({ claudeDir, historyPath, sessionFile, sessionId: hookSessionId });
601+
if (record) return;
602+
const parsed = snapshot ? snapshot.parsed : parseSession(sessionFile);
603+
const mode = snapshot ? snapshot.mode : readFlag(path.join(claudeDir, ACTIVE_FLAG_BASENAME));
604+
const attribution = snapshot ? snapshot.attribution : wholeSessionAttribution(mode, parsed.outputTokens);
573605

574606
if (share) {
575607
process.stdout.write(formatShare({ ...parsed, mode, attribution }) + '\n');
@@ -586,5 +618,5 @@ module.exports = {
586618
formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
587619
deriveNet, ruleOverheadPerTurn, parseSession, priceForModel, formatUsd, COMPRESSION,
588620
MODEL_OUTPUT_PRICE_PER_M, findCompressedPairs, summarizeCompressed, humanizeTokens,
589-
outputReductionPct, readModeLog, attributeByMode,
621+
outputReductionPct, readModeLog, attributeByMode, readHookInput, recordSessionSnapshot,
590622
};

src/hooks/checksums.sha256

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ bece20e2d95b2502606dedc8b3bc329ac769a2f3638e3013d8477cabeab13f34 caveman-config
33
397cf3d243fae04859e0c135f87f456a4972256ae425ee66457da4631ffa509a caveman-parse.js
44
fea02dc4f0460433a5b892a32ccd4a735eb8bfd8974b592113574c6e55c90370 caveman-activate.js
55
07a16ec91be50900eaaa5cb24d0518fc77c98b6159102e27c37d43aae1d70640 caveman-mode-tracker.js
6-
f598dde3cc7b701c68547c103a56d566ccc2f75d1c1f3484883ab9f396032b5d caveman-stats.js
6+
2914ed5bc0b75479fa892e148a586c814af893be638ab1c36e1a177f40c4ab38 caveman-stats.js
77
4b22120731be5a23f08d0b87d627cd5ac1833d994077d554aa78d7c51a212435 caveman-statusline.sh
88
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
99
9b72e18343a5487acde46d795f4871abfae21212b6eeb853d54981aae260bdf1 cavecrew-model-overrides.js

src/hooks/install.ps1

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# caveman — one-command hook installer for Claude Code (Windows PowerShell)
2-
# Installs: SessionStart hook (auto-load rules) + UserPromptSubmit hook (mode tracking)
2+
# Installs: SessionStart hook + UserPromptSubmit hook + SessionEnd stats recorder
33
# Usage: powershell -ExecutionPolicy Bypass -File src\hooks\install.ps1
44
# or: powershell -ExecutionPolicy Bypass -File src\hooks\install.ps1 -Force
55
# or (remote, no -Force support via pipe):
@@ -62,7 +62,7 @@ if (-not $Force) {
6262
}
6363
return $false
6464
}
65-
$HooksWired = (& $hasCavemanHook "SessionStart") -and (& $hasCavemanHook "UserPromptSubmit")
65+
$HooksWired = (& $hasCavemanHook "SessionStart") -and (& $hasCavemanHook "UserPromptSubmit") -and (& $hasCavemanHook "SessionEnd")
6666
$HasStatusLine = $null -ne $settingsObj.statusLine
6767
} catch {
6868
$HooksWired = $false
@@ -157,6 +157,22 @@ if (!hasPrompt) {
157157
});
158158
}
159159
160+
// SessionEnd
161+
if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
162+
const hasEnd = settings.hooks.SessionEnd.some(e =>
163+
e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman-stats'))
164+
);
165+
if (!hasEnd) {
166+
settings.hooks.SessionEnd.push({
167+
hooks: [{
168+
type: 'command',
169+
command: 'node "' + hooksDir + '/caveman-stats.js" --record',
170+
timeout: 5,
171+
statusMessage: 'Recording caveman stats...'
172+
}]
173+
});
174+
}
175+
160176
// Statusline
161177
if (!settings.statusLine) {
162178
settings.statusLine = {
@@ -195,4 +211,5 @@ Write-Host "What's installed:"
195211
Write-Host " - SessionStart hook: auto-loads caveman rules every session"
196212
Write-Host " - Mode tracker hook: updates statusline badge when you switch modes"
197213
Write-Host " (/caveman lite, /caveman ultra, /caveman-commit, etc.)"
214+
Write-Host " - SessionEnd hook: records lifetime stats silently"
198215
Write-Host " - Statusline badge: shows [CAVEMAN] or [CAVEMAN:ULTRA] etc."

0 commit comments

Comments
 (0)