context-carwash 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -5,7 +5,7 @@ A small, local-first utility for Claude Code.
5
5
  - **Meter:** a quiet estimate of the active context window in the status line.
6
6
  - **Wash:** when you ask, Claude writes a short handoff brief, and your next `/clear` starts a fresh session that begins with it.
7
7
 
8
- Nothing happens automatically, and nothing leaves your machine. The one exception: the installer asks npm whether a newer version is out.
8
+ Nothing happens automatically, and nothing leaves your machine. Two exceptions: the installer asks npm whether a newer version is out, and `carwash stats --share` sends anonymous totals if you run it and say yes.
9
9
 
10
10
  **Status:** pre-release. The meter and the typed `/wash` command work today. Clickable links in the status line and the one-step restart are not built yet. See [plan-3.md](plan-3.md) for the design.
11
11
 
@@ -59,6 +59,7 @@ Your old conversation is not deleted. It stays in `/resume`.
59
59
  | `/wash detailed` | Adds rationale, history, test results, risks, a step checklist |
60
60
  | `/wash discard` | Drop the brief. `/clear` is a normal clear again |
61
61
  | `/wash load [id]` | Make a waiting brief the one your next `/clear` uses |
62
+ | `/wash stats` | How much context your washes have saved (see below) |
62
63
  | `/wash help` | Explain the meter and these options, with this session's size limits |
63
64
 
64
65
  Each level has a size limit: the most tokens its brief may take, worked out from what the conversation is using (a hundredth for `light`, three hundredths for `mid`, a tenth for `detailed`). `/wash help` shows the limits for the session you are in.
@@ -74,9 +75,40 @@ Things worth knowing:
74
75
  - **Closed the terminal before clearing?** Start `claude` in the same folder. If exactly one brief is waiting for that folder, it is under 12 hours old, and the session that wrote it has ended, it is loaded. If there is any doubt about which brief is meant, nothing is loaded and you are shown what is waiting, with the `/wash load <id>` to use.
75
76
  - **Briefs expire after 48 hours.** A delivered brief is kept for 24 hours in case you want it again (`/wash load <id>`), then deleted.
76
77
 
78
+ ## Stats
79
+
80
+ ```sh
81
+ npx context-carwash stats
82
+ ```
83
+
84
+ ```text
85
+ Carwash stats, since 3 Oct 2026 (12 days)
86
+
87
+ Briefs written 7 (light 1 · mid 5 · detailed 1)
88
+ Started fresh with one 6
89
+ Prompts in fresh sessions 94 (410 requests to Claude)
90
+ Context not re-sent ~21M tokens
91
+ Spent writing briefs ~0.9M tokens
92
+ Net saved ~20M tokens
93
+ ```
94
+
95
+ `/wash stats` shows the same inside Claude Code, at no cost.
96
+
97
+ How it is worked out: every request to Claude carries the whole conversation, and a single prompt can make many requests while Claude uses tools. After a wash, each request in the fresh session starts from the brief instead of the old conversation. Claude Code's transcript of that session records the size of every request, so Carwash counts the requests and multiplies by the gap. Nothing is assumed about how long you would have kept going: only requests you actually made count. Counting stops where the old session would have compacted on its own.
98
+
99
+ Claude caches repeated context and charges about a tenth for it, so in money terms the saving is roughly a tenth of these numbers. The brief cost counts every brief written, used or not.
100
+
101
+ ### Sharing your stats
102
+
103
+ ```sh
104
+ npx context-carwash stats --share
105
+ ```
106
+
107
+ This shows every field it would send, and sends only if you say yes. It sends totals to a Google Form: a random share id made for sharing only (so sharing again replaces your last share), the Carwash version, your operating system, days since your first brief, and the numbers above. No names, folders, projects, prompts or briefs. You are not signed in to send it.
108
+
77
109
  ## Where things are kept
78
110
 
79
- Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. Nothing is uploaded, and there is no telemetry. The installer reads the newest version number from npm, and nothing else goes online; set `CARWASH_NO_UPDATE_CHECK=1` to skip even that.
111
+ Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. So is the stats ledger (`~/.carwash/stats`): one small file per brief with its size, its level, and which fresh sessions it went to, but never the brief or your prompts. Nothing is uploaded in the background, and there is no telemetry. The installer reads the newest version number from npm (set `CARWASH_NO_UPDATE_CHECK=1` to skip that), and `stats --share` sends only when you run it and say yes.
80
112
 
81
113
  If a hook fails it stays silent and writes the error to `~/.carwash/errors.log`.
82
114
 
@@ -86,7 +118,7 @@ If a hook fails it stays silent and writes the error to `~/.carwash/errors.log`.
86
118
  npx context-carwash uninstall
87
119
  ```
88
120
 
89
- This puts `settings.json` back exactly as it was. If you have edited the file since installing, it removes only Carwash's own status line and hooks and keeps your edits. It then removes the `/wash` command and deletes `~/.carwash`, including any briefs still waiting.
121
+ This puts `settings.json` back exactly as it was. If you have edited the file since installing, it removes only Carwash's own status line and hooks and keeps your edits. It then removes the `/wash` command and deletes `~/.carwash`, including any briefs still waiting and your stats.
90
122
 
91
123
  ## Limitations
92
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-carwash",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A quiet context meter and deliberate fresh-session handoff for Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.mjs CHANGED
@@ -4,6 +4,8 @@ import { applyInstall, applyUninstall, describeInstall, describeUninstall, packa
4
4
  import { UserError } from './install/settings.mjs';
5
5
  import { isNewer, latestVersion, runRelease } from './install/update.mjs';
6
6
  import { errorLogPath, welcomedPath } from './paths.mjs';
7
+ import { refreshLedger, totals } from './stats/ledger.mjs';
8
+ import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport } from './stats/report.mjs';
7
9
  import { render } from './statusline/render.mjs';
8
10
  import { statusline } from './statusline/run.mjs';
9
11
  import { runHook } from './wash/hooks.mjs';
@@ -15,6 +17,7 @@ Usage: carwash <command>
15
17
  install [--yes] Add the context meter and the /wash command to Claude Code
16
18
  uninstall [--yes] Undo everything install did
17
19
  preview Print sample meters in this terminal
20
+ stats [--share] Show how much context /wash has saved; --share sends the totals anonymously, after asking
18
21
  --version Print the version
19
22
 
20
23
  Inside Claude Code, once installed:
@@ -23,9 +26,11 @@ Inside Claude Code, once installed:
23
26
  /clear Start fresh with the brief that is ready
24
27
  /wash discard Drop the brief; /clear is a normal clear again
25
28
  /wash load [id] Make a waiting brief the one /clear will use
29
+ /wash stats The same totals as carwash stats
26
30
  /wash help Explain the meter and the options
27
31
 
28
- Nothing happens automatically. Only install goes online, to ask npm for the newest version.`;
32
+ Nothing happens automatically. Only two things go online: install, to ask npm
33
+ for the newest version, and stats --share, once you have said yes.`;
29
34
 
30
35
  async function readAll(stream) {
31
36
  if (stream.isTTY) return '';
@@ -88,7 +93,7 @@ function logError(env, error) {
88
93
  }
89
94
  }
90
95
 
91
- export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease } = {}) {
96
+ export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease, form = FORM, share = sendShare } = {}) {
92
97
  const [command, ...rest] = argv;
93
98
  const yes = rest.includes('--yes') || rest.includes('-y');
94
99
  try {
@@ -99,7 +104,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
99
104
  case 'hook': {
100
105
  let output = null;
101
106
  try {
102
- output = runHook({ input: await readAll(stdin), env });
107
+ output = runHook({ input: await readAll(stdin), env, onError: (error) => logError(env, error) });
103
108
  } catch (error) {
104
109
  logError(env, error);
105
110
  }
@@ -142,6 +147,32 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
142
147
  stdout.write('Uninstalled.\n');
143
148
  return 0;
144
149
  }
150
+ case 'stats': {
151
+ const sum = totals(refreshLedger(env, { force: true }));
152
+ stdout.write(statsReport(sum) + '\n');
153
+ if (!rest.includes('--share')) return 0;
154
+ stdout.write('\n');
155
+ if (!shareReady(form)) {
156
+ stdout.write('Sharing is not switched on in this version yet. Nothing was sent.\n');
157
+ return 1;
158
+ }
159
+ if (!sum.washes) {
160
+ stdout.write('There is nothing to share yet. Nothing was sent.\n');
161
+ return 1;
162
+ }
163
+ const payload = sharePayload(sum, { shareId: shareId(env), version: packageVersion() });
164
+ stdout.write(describeShare(payload) + '\n\n');
165
+ if (!(await confirm('Send it?', { yes, stdin, stdout }))) {
166
+ stdout.write('Nothing was sent.\n');
167
+ return 1;
168
+ }
169
+ if (!(await share(payload, { form }))) {
170
+ stdout.write('Could not reach the form, so nothing was sent. Try again later.\n');
171
+ return 1;
172
+ }
173
+ stdout.write('Sent. Thank you.\n');
174
+ return 0;
175
+ }
145
176
  case 'preview':
146
177
  preview(env, stdout);
147
178
  return 0;
package/src/paths.mjs CHANGED
@@ -26,6 +26,13 @@ export const consumedDir = (env = process.env) => join(carwashHome(env), 'consum
26
26
  // Small per-session notes: the last usage reading and a wash in progress.
27
27
  export const sessionsDir = (env = process.env) => join(carwashHome(env), 'sessions');
28
28
 
29
+ // The savings ledger behind `carwash stats`: one file per brief written.
30
+ export const statsDir = (env = process.env) => join(carwashHome(env), 'stats');
31
+
32
+ // A random id made the first time someone shares their stats, so a repeat
33
+ // share can replace the earlier one. It is not derived from anything.
34
+ export const shareIdPath = (env = process.env) => join(carwashHome(env), 'share-id');
35
+
29
36
  export const errorLogPath = (env = process.env) => join(carwashHome(env), 'errors.log');
30
37
 
31
38
  // Present once the first-run welcome has been shown.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: wash
3
3
  description: Context Carwash. Writes a short handoff brief of this session, so that /clear can start a fresh session that carries on from it.
4
- argument-hint: "[light | mid | detailed | discard | load <id> | help]"
4
+ argument-hint: "[light | mid | detailed | discard | load <id> | stats | help]"
5
5
  disable-model-invocation: true
6
6
  metadata:
7
7
  installed-by: context-carwash
@@ -0,0 +1,166 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readJson, writePrivateJson } from '../files.mjs';
4
+ import { statsDir } from '../paths.mjs';
5
+
6
+ // The savings ledger. Briefs themselves are deleted within days, so each one
7
+ // written leaves a small entry here: its level, what writing it cost, and the
8
+ // fresh sessions it was delivered to. Nothing in it leaves this machine unless
9
+ // the user runs `carwash stats --share` and says yes, and then only totals.
10
+ //
11
+ // What a wash saves is measured, not assumed. Every request Claude Code sends
12
+ // carries the whole conversation. A fresh session's transcript records the
13
+ // size of each request, so for every request after a wash the saving is the
14
+ // gap between the old session's size and the fresh session's starting size.
15
+
16
+ const DAY = 24 * 3_600_000;
17
+
18
+ // Roughly where Claude Code compacts a session on its own. Past this point the
19
+ // old session would have shrunk anyway, so requests beyond it count for nothing.
20
+ export const AUTO_COMPACT_SHARE = 0.9;
21
+
22
+ const keyOf = (record) => `${Date.parse(record.createdAt).toString(36)}-${record.id}`;
23
+ const fileOf = (env, key) => join(statsDir(env), `${key}.json`);
24
+
25
+ // A brief was saved. `wash` is the wash that produced it.
26
+ export function recordWash(env, record, wash) {
27
+ const key = keyOf(record);
28
+ writePrivateJson(fileOf(env, key), {
29
+ key,
30
+ level: record.level,
31
+ washedAt: record.createdAt,
32
+ used: record.used ?? null,
33
+ size: wash.size ?? null,
34
+ tokens: record.tokens,
35
+ attempts: (wash.rewrites ?? 0) + 1,
36
+ deliveries: [],
37
+ });
38
+ }
39
+
40
+ // A brief was handed to a fresh session.
41
+ export function recordDelivery(env, record, payload, now = Date.now()) {
42
+ const file = fileOf(env, keyOf(record));
43
+ const entry = readJson(file);
44
+ if (!entry || typeof payload.transcript_path !== 'string') return;
45
+ entry.deliveries.push({ sessionId: payload.session_id ?? null, transcript: payload.transcript_path, at: new Date(now).toISOString(), measured: null });
46
+ writePrivateJson(file, entry);
47
+ }
48
+
49
+ export function readLedger(env) {
50
+ let names;
51
+ try {
52
+ names = readdirSync(statsDir(env));
53
+ } catch {
54
+ return [];
55
+ }
56
+ const entries = [];
57
+ for (const name of names) {
58
+ if (!name.endsWith('.json')) continue;
59
+ const entry = readJson(join(statsDir(env), name));
60
+ if (entry && `${entry.key}.json` === name && Array.isArray(entry.deliveries) && Number.isFinite(Date.parse(entry.washedAt))) entries.push(entry);
61
+ }
62
+ return entries.sort((a, b) => Date.parse(a.washedAt) - Date.parse(b.washedAt));
63
+ }
64
+
65
+ // A line of the transcript the user typed, as opposed to a tool result, a
66
+ // shell command, a slash command's output or a compaction summary.
67
+ function isPrompt(line) {
68
+ if (line.type !== 'user' || line.isMeta || line.isCompactSummary) return false;
69
+ const content = line.message?.content;
70
+ if (typeof content === 'string') return !content.trimStart().startsWith('<');
71
+ return Array.isArray(content) && content.some((block) => block?.type === 'text') && !content.some((block) => block?.type === 'tool_result');
72
+ }
73
+
74
+ // Count what a fresh session sent after a wash. `before` is the old session's
75
+ // size when the brief was written, `size` its context window.
76
+ export function measure(text, before, size) {
77
+ const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
78
+ const seen = new Set();
79
+ let start = null;
80
+ let prompts = 0;
81
+ let requests = 0;
82
+ for (const raw of text.split('\n')) {
83
+ let line;
84
+ try {
85
+ line = JSON.parse(raw);
86
+ } catch {
87
+ continue;
88
+ }
89
+ if (!line || line.isSidechain) continue;
90
+ // Compacted: the comparison with the old session no longer holds.
91
+ if (line.type === 'system' && line.subtype === 'compact_boundary') break;
92
+ if (isPrompt(line)) {
93
+ prompts++;
94
+ continue;
95
+ }
96
+ if (line.type !== 'assistant') continue;
97
+ // One request can be written as several lines; they share an id.
98
+ const id = line.message?.id;
99
+ const usage = line.message?.usage;
100
+ if (!id || !usage || seen.has(id)) continue;
101
+ seen.add(id);
102
+ const sent = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
103
+ start ??= sent;
104
+ if (before - start <= 0 || sent + (before - start) > limit) break;
105
+ requests++;
106
+ }
107
+ const gap = start === null ? 0 : Math.max(0, before - start);
108
+ return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests };
109
+ }
110
+
111
+ // Bring each delivery's measurement up to date with its transcript. Without
112
+ // `force`, a measurement less than a day old is left alone, so a session start
113
+ // does not reread transcripts every time. A transcript that has gone (Claude
114
+ // Code deletes old ones) keeps its last measurement.
115
+ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
116
+ const entries = readLedger(env);
117
+ for (const entry of entries) {
118
+ if (!(entry.used > 0)) continue;
119
+ let changed = false;
120
+ for (const delivery of entry.deliveries) {
121
+ const last = delivery.measured;
122
+ if (last && !force && now - last.at < DAY) continue;
123
+ let mtime;
124
+ try {
125
+ mtime = statSync(delivery.transcript).mtimeMs;
126
+ } catch {
127
+ continue;
128
+ }
129
+ if (last && last.mtime === mtime) continue;
130
+ let text;
131
+ try {
132
+ text = readFileSync(delivery.transcript, 'utf8');
133
+ } catch {
134
+ continue;
135
+ }
136
+ delivery.measured = { ...measure(text, entry.used, entry.size), mtime, at: now };
137
+ changed = true;
138
+ }
139
+ if (changed) writePrivateJson(fileOf(env, entry.key), entry);
140
+ }
141
+ return entries;
142
+ }
143
+
144
+ // What writing a brief cost: each attempt re-read the old session and wrote
145
+ // the brief out.
146
+ export const costOf = (entry) => (entry.used > 0 ? entry.attempts * (entry.used + entry.tokens) : 0);
147
+
148
+ export function totals(entries, now = Date.now()) {
149
+ const sum = { washes: entries.length, levels: {}, delivered: 0, prompts: 0, requests: 0, notResent: 0, briefCost: 0, since: null, days: 0 };
150
+ for (const entry of entries) {
151
+ sum.levels[entry.level] = (sum.levels[entry.level] ?? 0) + 1;
152
+ sum.briefCost += costOf(entry);
153
+ if (entry.deliveries.length) sum.delivered++;
154
+ for (const { measured } of entry.deliveries) {
155
+ if (!measured) continue;
156
+ sum.prompts += measured.prompts;
157
+ sum.requests += measured.requests;
158
+ sum.notResent += measured.saved;
159
+ }
160
+ }
161
+ if (entries.length) {
162
+ sum.since = entries[0].washedAt;
163
+ sum.days = Math.max(1, Math.ceil((now - Date.parse(sum.since)) / DAY));
164
+ }
165
+ return sum;
166
+ }
@@ -0,0 +1,125 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { writeFileAtomic } from '../files.mjs';
4
+ import { shareIdPath } from '../paths.mjs';
5
+ import { formatTokens } from '../statusline/render.mjs';
6
+
7
+ // `carwash stats` and `/wash stats`: the ledger's totals in words, and the
8
+ // anonymous share that `carwash stats --share` sends after asking.
9
+
10
+ const LEVEL_ORDER = ['light', 'mid', 'detailed'];
11
+
12
+ const tokens = (n) => `~${formatTokens(n)} tokens`;
13
+
14
+ export function statsReport(sum, { inClaude = false } = {}) {
15
+ if (!sum.washes) {
16
+ return 'Carwash stats: no briefs written yet. Once you have used /wash and /clear, this shows how much context they kept from being sent again.';
17
+ }
18
+ const since = new Date(sum.since).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
19
+ const levels = LEVEL_ORDER.filter((name) => sum.levels[name]).map((name) => `${name} ${sum.levels[name]}`).join(' · ');
20
+ const net = sum.notResent - sum.briefCost;
21
+ const row = (label, value) => ` ${label.padEnd(28)}${value}`;
22
+ return [
23
+ `Carwash stats, since ${since} (${sum.days} day${sum.days === 1 ? '' : 's'})`,
24
+ '',
25
+ row('Briefs written', `${sum.washes}${levels ? ` (${levels})` : ''}`),
26
+ row('Started fresh with one', `${sum.delivered}`),
27
+ row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to Claude)`),
28
+ row('Context not re-sent', tokens(sum.notResent)),
29
+ row('Spent writing briefs', tokens(sum.briefCost)),
30
+ row(net >= 0 ? 'Net saved' : 'Net cost so far', tokens(Math.abs(net))),
31
+ '',
32
+ 'How it is worked out: every request to Claude carries the whole conversation. After a wash, each request in the fresh session starts from the brief instead of the old conversation, and the difference is context that was not sent again. Counting stops where the old session would have compacted on its own.',
33
+ 'Claude caches repeated context and charges about a tenth for it, so in money terms the saving is roughly a tenth of these numbers.',
34
+ '',
35
+ inClaude ? 'To share these totals anonymously, run in a terminal: npx context-carwash stats --share' : 'To share these totals anonymously: npx context-carwash stats --share',
36
+ ].join('\n');
37
+ }
38
+
39
+ // --- sharing ---------------------------------------------------------------
40
+
41
+ // The Google Form shares go to. Each share becomes one row of the form's
42
+ // response sheet.
43
+ export const FORM = {
44
+ action: 'https://docs.google.com/forms/d/e/1FAIpQLScMU96dzQ1DC4p-K-09wRF6aTYC23Kw3VNmOZbnI55iyXQnYQ/formResponse',
45
+ fields: {
46
+ shareId: 'entry.444662411',
47
+ version: 'entry.237740820',
48
+ system: 'entry.1585190609',
49
+ days: 'entry.1959206368',
50
+ washes: 'entry.822622585',
51
+ prompts: 'entry.93311455',
52
+ notResent: 'entry.1048114223',
53
+ briefCost: 'entry.164624594',
54
+ },
55
+ };
56
+
57
+ const SYSTEMS = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
58
+
59
+ export function sharePayload(sum, { shareId, version, platform = process.platform }) {
60
+ return {
61
+ shareId,
62
+ version,
63
+ system: SYSTEMS[platform] ?? 'other',
64
+ days: sum.days,
65
+ washes: sum.washes,
66
+ prompts: sum.prompts,
67
+ notResent: sum.notResent,
68
+ briefCost: sum.briefCost,
69
+ };
70
+ }
71
+
72
+ const LABELS = {
73
+ shareId: 'Share id',
74
+ version: 'Carwash version',
75
+ system: 'Operating system',
76
+ days: 'Days since first brief',
77
+ washes: 'Briefs written',
78
+ prompts: 'Prompts in fresh sessions',
79
+ notResent: 'Context not re-sent',
80
+ briefCost: 'Spent writing briefs',
81
+ };
82
+
83
+ // Everything that would be sent, shown in full before asking.
84
+ export function describeShare(payload) {
85
+ return [
86
+ 'This is everything that would be sent, to a Google Form the Carwash author reads:',
87
+ '',
88
+ ...Object.entries(payload).map(([key, value]) => ` ${LABELS[key].padEnd(28)}${value}`),
89
+ '',
90
+ 'The share id is a random number made on this machine for sharing only, so sharing again replaces your last share instead of counting twice.',
91
+ 'No names, folders, projects, prompts, briefs or account details are included. You are not signed in to send it, and Google Forms does not show the author your IP address.',
92
+ ].join('\n');
93
+ }
94
+
95
+ // The random id for shares, made on first use.
96
+ export function shareId(env) {
97
+ try {
98
+ const id = readFileSync(shareIdPath(env), 'utf8').trim();
99
+ if (/^[0-9a-f]{12}$/.test(id)) return id;
100
+ } catch {
101
+ // not made yet
102
+ }
103
+ const id = randomBytes(6).toString('hex');
104
+ writeFileAtomic(shareIdPath(env), `${id}\n`, 0o600);
105
+ return id;
106
+ }
107
+
108
+ export const shareReady = (form = FORM) => Boolean(form.action) && Object.values(form.fields).every(Boolean);
109
+
110
+ // Send a share. Returns true once the form has accepted it.
111
+ export async function sendShare(payload, { form = FORM, fetchImpl = fetch, timeoutMs = 10_000 } = {}) {
112
+ const body = new URLSearchParams();
113
+ for (const [key, value] of Object.entries(payload)) body.set(form.fields[key], String(value));
114
+ try {
115
+ const response = await fetchImpl(form.action, {
116
+ method: 'POST',
117
+ body,
118
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
119
+ signal: AbortSignal.timeout(timeoutMs),
120
+ });
121
+ return response.ok;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import { DEFAULT_LEVEL, capFor, estimateTokens, isLevel } from '../levels.mjs';
3
3
  import { installRecordPath, welcomedPath } from '../paths.mjs';
4
+ import { recordDelivery, recordWash, refreshLedger, totals } from '../stats/ledger.mjs';
5
+ import { statsReport } from '../stats/report.mjs';
4
6
  import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, retryNote, stalenessLine, welcomeMessage } from './brief.mjs';
5
7
  import { repoState, worktreeRoot } from './git.mjs';
6
8
  import { currentProcess, isRunning, sameProcess } from './process.mjs';
@@ -10,7 +12,7 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
10
12
  // Carwash's three Claude Code hooks:
11
13
  //
12
14
  // UserPromptExpansion /wash was typed. A level starts a wash and tells
13
- // Claude the size limit; discard, load and help are
15
+ // Claude the size limit; discard, load, stats and help are
14
16
  // handled here and never reach Claude.
15
17
  // Stop Claude finished replying. If that reply is the brief,
16
18
  // save it.
@@ -20,7 +22,7 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
20
22
  // Each handler returns the JSON to print for Claude Code, or null to stay out
21
23
  // of the way. None of them ever clears, restarts, or runs anything.
22
24
 
23
- const USAGE = 'Usage: /wash [light | mid | detailed], /wash discard [id], /wash load [id], /wash help';
25
+ const USAGE = 'Usage: /wash [light | mid | detailed], /wash discard [id], /wash load [id], /wash stats, /wash help';
24
26
 
25
27
  // How many times to ask for a shorter brief before keeping an oversized one.
26
28
  const MAX_REWRITES = 2;
@@ -33,6 +35,15 @@ const block = (reason) => ({ decision: 'block', reason: `Carwash: ${reason}` });
33
35
  // conversation, so it must not use up a brief meant for a person.
34
36
  const unattended = (env) => env.CLAUDE_CODE_SESSION_ATTENDED === '0' || /^sdk/.test(env.CLAUDE_CODE_ENTRYPOINT ?? '');
35
37
 
38
+ // The savings ledger is bookkeeping: if it fails, the brief still has to work.
39
+ function quietly(ctx, fn) {
40
+ try {
41
+ fn();
42
+ } catch (error) {
43
+ ctx.onError?.(error);
44
+ }
45
+ }
46
+
36
47
  const cwdOf = (payload) => (typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd());
37
48
 
38
49
  // --- /wash ---------------------------------------------------------------
@@ -43,14 +54,16 @@ function onExpansion(payload, ctx) {
43
54
  if (isLevel(verb)) return begin(payload, verb, ctx);
44
55
  if (verb === 'discard') return block(discard(payload, ref, ctx));
45
56
  if (verb === 'load') return block(load(payload, ref, ctx));
57
+ if (verb === 'stats') return block(statsReport(totals(refreshLedger(ctx.env, { now: ctx.now, force: true }), ctx.now), { inClaude: true }));
46
58
  if (verb === 'help') return block(helpMessage(loadUsage(ctx.env, payload.session_id)?.used ?? null));
47
59
  return block(`/wash does not know "${verb}". ${USAGE}`);
48
60
  }
49
61
 
50
62
  function begin(payload, level, { env, now }) {
51
- const used = loadUsage(env, payload.session_id)?.used ?? null;
63
+ const usage = loadUsage(env, payload.session_id);
64
+ const used = usage?.used ?? null;
52
65
  const cap = capFor(level, used);
53
- startWash(env, payload.session_id, { promptId: payload.prompt_id ?? null, level, cap, used, startedAt: now, rewrites: 0 });
66
+ startWash(env, payload.session_id, { promptId: payload.prompt_id ?? null, level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0 });
54
67
  return { hookSpecificOutput: { hookEventName: 'UserPromptExpansion', additionalContext: budgetNote(level, cap, used) } };
55
68
  }
56
69
 
@@ -157,6 +170,7 @@ function onStop(payload, ctx) {
157
170
  },
158
171
  now,
159
172
  );
173
+ quietly(ctx, () => recordWash(env, record, wash));
160
174
  // One ready brief per terminal. An earlier wash of this same session is out
161
175
  // of date and goes; anything else this terminal held goes back to waiting.
162
176
  for (const other of readyRecords(env, now)) {
@@ -170,9 +184,11 @@ function onStop(payload, ctx) {
170
184
 
171
185
  // --- SessionStart --------------------------------------------------------
172
186
 
173
- function deliver(record, payload, { env, now }) {
187
+ function deliver(record, payload, ctx) {
188
+ const { env, now } = ctx;
174
189
  // Another session may have taken it between the listing and now.
175
190
  if (!claim(env, record, payload.session_id, now)) return null;
191
+ quietly(ctx, () => recordDelivery(env, record, payload, now));
176
192
  const stale = stalenessLine(record.source, repoState(cwdOf(payload)));
177
193
  return {
178
194
  systemMessage: loadedMessage(record, now, stale),
@@ -207,6 +223,7 @@ function onSessionStart(payload, ctx) {
207
223
  function briefAtStart(payload, ctx) {
208
224
  const { env, now, me } = ctx;
209
225
  purge(env, now);
226
+ quietly(ctx, () => refreshLedger(env, { now }));
210
227
  const ready = readyRecords(env, now);
211
228
 
212
229
  // Relaunched by Carwash's shell function for one particular brief.
@@ -248,7 +265,7 @@ function briefAtStart(payload, ctx) {
248
265
  // --- entry point -----------------------------------------------------------
249
266
 
250
267
  // Handle one hook call. `input` is the JSON Claude Code sends on stdin.
251
- export function runHook({ input, env = process.env, platform = process.platform, now = Date.now(), me, running } = {}) {
268
+ export function runHook({ input, env = process.env, platform = process.platform, now = Date.now(), me, running, onError } = {}) {
252
269
  let payload;
253
270
  try {
254
271
  payload = JSON.parse(input);
@@ -261,6 +278,7 @@ export function runHook({ input, env = process.env, platform = process.platform,
261
278
  now,
262
279
  me: me ?? currentProcess(env, platform),
263
280
  isRunning: running ?? ((owner) => isRunning(owner, platform)),
281
+ onError,
264
282
  };
265
283
  switch (payload.hook_event_name) {
266
284
  case 'UserPromptExpansion':