Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hooks/caveman-stats.js\" --record",
"timeout": 5,
"statusMessage": "Recording caveman stats..."
}
]
}
]
}
}
19 changes: 14 additions & 5 deletions cli/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,8 @@ async function installClaude(ctx) {
// default / --all → wire only if the plugin install did NOT succeed.
// The plugin manifest already wires SessionStart + UserPromptSubmit when the
// plugin install succeeds; wiring them again in settings.json fires both per
// event (two CAVEMAN MODE blocks, two reinforcement lines).
// event (two CAVEMAN MODE blocks, two reinforcement lines). SessionEnd uses
// the same manifest path to record lifetime stats.
let shouldWireHooks;
if (opts.withHooks === false) {
shouldWireHooks = false;
Expand All @@ -546,7 +547,7 @@ async function installClaude(ctx) {
// 'auto'
shouldWireHooks = !pluginInstallSucceeded;
if (!shouldWireHooks) {
note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit');
note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit + SessionEnd');
note(' (pass --with-hooks to also wire standalone hooks in settings.json)');
results.skipped.push(['claude-hooks', 'plugin manifest handles hooks']);
} else {
Expand Down Expand Up @@ -953,7 +954,7 @@ async function installHooks(ctx) {
if (opts.dryRun) {
note(` would mkdir -p ${hooksDir}`);
for (const f of HOOK_FILES) note(` would install ${path.join(hooksDir, f)}`);
note(` would merge SessionStart + UserPromptSubmit + statusline into ${settingsPath}`);
note(` would merge SessionStart + UserPromptSubmit + SessionEnd + statusline into ${settingsPath}`);
return 'ok';
}

Expand Down Expand Up @@ -1010,6 +1011,7 @@ async function installHooks(ctx) {
const node = absoluteNodePath();
const activate = path.join(hooksDir, 'caveman-activate.js');
const tracker = path.join(hooksDir, 'caveman-mode-tracker.js');
const stats = path.join(hooksDir, 'caveman-stats.js');
const statusline = path.join(hooksDir, 'caveman-statusline.sh');

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

SETTINGS.addCommandHook(settings, 'SessionEnd', {
command: `"${node}" "${stats}" --record`,
marker: 'caveman-stats',
timeout: 5,
statusMessage: 'Recording caveman stats...',
});

// Statusline — set if absent or already pointing at our script.
// Windows: prefer pwsh (PowerShell 7+, cross-platform), fall back to
// powershell.exe (Windows PowerShell 5.1, ships with every Windows install).
Expand Down Expand Up @@ -1440,8 +1449,8 @@ FLAGS
--all Turn on hooks + init. (mcp-shrink needs an upstream;
pass --with-mcp-shrink="<cmd>" to add it.)
--minimal Just the plugin/extension install.
--with-hooks Claude Code: install SessionStart/UserPromptSubmit hooks
+ statusline badge. (Default ON.)
--with-hooks Claude Code: install SessionStart/UserPromptSubmit/
SessionEnd hooks + statusline badge. (Default ON.)
--no-hooks Skip the hooks installer.
--with-init Write per-repo IDE rule files into \$PWD.
--no-always OpenClaw only: skip \`always: true\` frontmatter and the
Expand Down
2 changes: 1 addition & 1 deletion skills/caveman-stats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Reads the current Claude Code session log directly and reports actual input/outp

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`.

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.
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.

## How to invoke

Expand Down
7 changes: 4 additions & 3 deletions skills/caveman-stats/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ name: caveman-stats
description: >
Show real token usage and estimated savings for the current session.
Reads directly from the Claude Code session log — no AI estimation.
Triggers on /caveman-stats. Output is injected by the mode-tracker hook;
the model itself does not compute the numbers.
Triggers on /caveman-stats for display and SessionEnd for silent lifetime
recording. Output is injected by the mode-tracker hook; the model itself does
not compute the numbers.
---

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.
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.

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`).
13 changes: 11 additions & 2 deletions src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ If you installed caveman standalone (without the plugin), the unified Node insta

- Reads `$CLAUDE_CONFIG_DIR/.caveman-active` (default `~/.claude/.caveman-active`) and outputs a colored badge
- Shows `[CAVEMAN]`, `[CAVEMAN:ULTRA]`, `[CAVEMAN:WENYAN]`, etc.
- 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`.
- 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`.

### `caveman-stats.js --record` — SessionEnd hook

- Runs when Claude Code ends a session
- Reads `transcript_path` from hook stdin and appends the latest stats snapshot to `$CLAUDE_CONFIG_DIR/.caveman-history.jsonl`
- Writes no stdout, so the hook does not interrupt shutdown
- Keeps duplicate snapshots safe: lifetime views count only the newest row per `session_id`

## Statusline Badge

Expand Down Expand Up @@ -86,6 +93,8 @@ Badge examples:
```
SessionStart hook ──writes "full"──▶ $CLAUDE_CONFIG_DIR/.caveman-active ◀──writes mode── UserPromptSubmit hook
SessionEnd records stats
reads
Statusline script
Expand All @@ -107,5 +116,5 @@ node cli/install.js --uninstall

Or manually:
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}`.
2. Remove the SessionStart, UserPromptSubmit, and statusLine entries from `$CLAUDE_CONFIG_DIR/settings.json`.
2. Remove the SessionStart, UserPromptSubmit, SessionEnd, and statusLine entries from `$CLAUDE_CONFIG_DIR/settings.json`.
3. Delete `$CLAUDE_CONFIG_DIR/.caveman-active` (and `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` if you ran `/caveman-stats`).
130 changes: 81 additions & 49 deletions src/hooks/caveman-stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ const path = require('path');
const os = require('os');
const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = require('./caveman-config');

const ARG_RECORD = '--record';
const ARG_SESSION_FILE = '--session-file';
const ARG_SHARE = '--share';
const ARG_ALL = '--all';
const ARG_SINCE = '--since';
const HISTORY_BASENAME = '.caveman-history.jsonl';
const ACTIVE_FLAG_BASENAME = '.caveman-active';
const STATUSLINE_SUFFIX_BASENAME = '.caveman-statusline-suffix';

// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /
// wenyan modes show no estimate until benchmarked. Add an entry here when a new
Expand Down Expand Up @@ -496,17 +505,74 @@ function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessio
(footer ? footer + '\n' : '');
}

function readHookInput() {
if (process.stdin.isTTY) return {};
let raw;
try { raw = fs.readFileSync(0, 'utf8'); }
catch { return {}; }
if (!raw.trim()) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}

function recordSessionSnapshot({ claudeDir, historyPath, sessionFile, sessionId }) {
if (!sessionFile) return null;
const parsed = parseSession(sessionFile);
if (parsed.turns <= 0) return null;

const flagPath = path.join(claudeDir, ACTIVE_FLAG_BASENAME);
const mode = readFlag(flagPath);

let flagMtimeMs = null;
try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {}
const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME));
const attribution = attributeByMode({
messages: parsed.messages,
modeLog,
mode,
flagMtimeMs,
outputTokens: parsed.outputTokens,
});

const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model });
appendFlag(historyPath, JSON.stringify({
ts: Date.now(),
session_id: sessionId || path.basename(sessionFile, '.jsonl'),
mode: mode || null,
model: parsed.model || null,
output_tokens: parsed.outputTokens,
turns: parsed.turns,
est_saved_tokens: estSavedTokens,
est_saved_usd: estSavedUsd,
}));

const agg = aggregateHistory(historyPath, null);
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
safeWriteFlag(path.join(claudeDir, STATUSLINE_SUFFIX_BASENAME), suffix);

return { parsed, mode, attribution };
}

function main() {
const args = process.argv.slice(2);
const i = args.indexOf('--session-file');
const i = args.indexOf(ARG_SESSION_FILE);
const sessionFileArg = i !== -1 ? args[i + 1] : null;
const share = args.includes('--share');
const all = args.includes('--all');
const sinceIdx = args.indexOf('--since');
const record = args.includes(ARG_RECORD);
const share = args.includes(ARG_SHARE);
const all = args.includes(ARG_ALL);
const sinceIdx = args.indexOf(ARG_SINCE);
const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;

const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const historyPath = path.join(claudeDir, '.caveman-history.jsonl');
const historyPath = path.join(claudeDir, HISTORY_BASENAME);

const hookInput = record ? readHookInput() : {};
const hookTranscriptPath = typeof hookInput.transcript_path === 'string' ? hookInput.transcript_path : null;
const hookSessionId = typeof hookInput.session_id === 'string' ? hookInput.session_id : null;

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

const sessionFile = sessionFileArg || findRecentSession(claudeDir);
const sessionFile = sessionFileArg || hookTranscriptPath || (record ? null : findRecentSession(claudeDir));

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

const parsed = parseSession(sessionFile);
const flagPath = path.join(claudeDir, '.caveman-active');
const mode = readFlag(flagPath);

// #601: attribute tokens to the mode active when each message happened,
// via the transition log the hooks maintain (fallbacks documented on
// attributeByMode). Never credit the whole session to the current flag.
let flagMtimeMs = null;
try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {}
const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME));
const attribution = attributeByMode({
messages: parsed.messages,
modeLog,
mode,
flagMtimeMs,
outputTokens: parsed.outputTokens,
});

// Append a snapshot of this session's totals to the lifetime log. Multiple
// /caveman-stats calls in one session emit multiple lines for the same
// session_id; aggregateHistory keeps only the latest per session_id.
if (parsed.turns > 0) {
const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model });
const sessionId = path.basename(sessionFile, '.jsonl');
appendFlag(historyPath, JSON.stringify({
ts: Date.now(),
session_id: sessionId,
mode: mode || null,
model: parsed.model || null,
output_tokens: parsed.outputTokens,
turns: parsed.turns,
est_saved_tokens: estSavedTokens,
est_saved_usd: estSavedUsd,
}));

// Statusline suffix: tiny pre-rendered string the shell statusline can
// cat without parsing JSONL. Updated on every /caveman-stats run.
// Routed through safeWriteFlag — the suffix path is predictable and
// user-owned, same symlink-clobber surface as the .caveman-active flag.
const agg = aggregateHistory(historyPath, null);
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix);
}
// /caveman-stats calls, plus the SessionEnd --record hook, can emit multiple
// lines for the same session_id; aggregateHistory keeps only the latest.
const snapshot = recordSessionSnapshot({ claudeDir, historyPath, sessionFile, sessionId: hookSessionId });
if (record) return;
const parsed = snapshot ? snapshot.parsed : parseSession(sessionFile);
const mode = snapshot ? snapshot.mode : readFlag(path.join(claudeDir, ACTIVE_FLAG_BASENAME));
const attribution = snapshot ? snapshot.attribution : wholeSessionAttribution(mode, parsed.outputTokens);

if (share) {
process.stdout.write(formatShare({ ...parsed, mode, attribution }) + '\n');
Expand All @@ -586,5 +618,5 @@ module.exports = {
formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
deriveNet, ruleOverheadPerTurn, parseSession, priceForModel, formatUsd, COMPRESSION,
MODEL_OUTPUT_PRICE_PER_M, findCompressedPairs, summarizeCompressed, humanizeTokens,
outputReductionPct, readModeLog, attributeByMode,
outputReductionPct, readModeLog, attributeByMode, readHookInput, recordSessionSnapshot,
};
2 changes: 1 addition & 1 deletion src/hooks/checksums.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ bece20e2d95b2502606dedc8b3bc329ac769a2f3638e3013d8477cabeab13f34 caveman-config
397cf3d243fae04859e0c135f87f456a4972256ae425ee66457da4631ffa509a caveman-parse.js
fea02dc4f0460433a5b892a32ccd4a735eb8bfd8974b592113574c6e55c90370 caveman-activate.js
07a16ec91be50900eaaa5cb24d0518fc77c98b6159102e27c37d43aae1d70640 caveman-mode-tracker.js
f598dde3cc7b701c68547c103a56d566ccc2f75d1c1f3484883ab9f396032b5d caveman-stats.js
2914ed5bc0b75479fa892e148a586c814af893be638ab1c36e1a177f40c4ab38 caveman-stats.js
4b22120731be5a23f08d0b87d627cd5ac1833d994077d554aa78d7c51a212435 caveman-statusline.sh
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
9b72e18343a5487acde46d795f4871abfae21212b6eeb853d54981aae260bdf1 cavecrew-model-overrides.js
21 changes: 19 additions & 2 deletions src/hooks/install.ps1
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# caveman — one-command hook installer for Claude Code (Windows PowerShell)
# Installs: SessionStart hook (auto-load rules) + UserPromptSubmit hook (mode tracking)
# Installs: SessionStart hook + UserPromptSubmit hook + SessionEnd stats recorder
# Usage: powershell -ExecutionPolicy Bypass -File src\hooks\install.ps1
# or: powershell -ExecutionPolicy Bypass -File src\hooks\install.ps1 -Force
# or (remote, no -Force support via pipe):
Expand Down Expand Up @@ -62,7 +62,7 @@ if (-not $Force) {
}
return $false
}
$HooksWired = (& $hasCavemanHook "SessionStart") -and (& $hasCavemanHook "UserPromptSubmit")
$HooksWired = (& $hasCavemanHook "SessionStart") -and (& $hasCavemanHook "UserPromptSubmit") -and (& $hasCavemanHook "SessionEnd")
$HasStatusLine = $null -ne $settingsObj.statusLine
} catch {
$HooksWired = $false
Expand Down Expand Up @@ -157,6 +157,22 @@ if (!hasPrompt) {
});
}

// SessionEnd
if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
const hasEnd = settings.hooks.SessionEnd.some(e =>
e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman-stats'))
);
if (!hasEnd) {
settings.hooks.SessionEnd.push({
hooks: [{
type: 'command',
command: 'node "' + hooksDir + '/caveman-stats.js" --record',
timeout: 5,
statusMessage: 'Recording caveman stats...'
}]
});
}

// Statusline
if (!settings.statusLine) {
settings.statusLine = {
Expand Down Expand Up @@ -195,4 +211,5 @@ Write-Host "What's installed:"
Write-Host " - SessionStart hook: auto-loads caveman rules every session"
Write-Host " - Mode tracker hook: updates statusline badge when you switch modes"
Write-Host " (/caveman lite, /caveman ultra, /caveman-commit, etc.)"
Write-Host " - SessionEnd hook: records lifetime stats silently"
Write-Host " - Statusline badge: shows [CAVEMAN] or [CAVEMAN:ULTRA] etc."
Loading
Loading