cctrack 0.5.0__tar.gz → 0.7.0__tar.gz

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.
@@ -31,12 +31,15 @@ uv build
31
31
 
32
32
  This is a single-module CLI tool. All logic lives in `src/cctrack/__init__.py`:
33
33
 
34
- 1. **Rate card** (`RATES`, `get_rates`, `calculate_cost`, `is_known_model`) - Model-to-price mapping using prefix matching. Order matters: longer/more-specific prefixes must come first (the legacy `claude-opus-4` 15/75 entry must stay below every `claude-opus-4-x`). Unknown models fall back to Sonnet rates and are flagged with `?` in the report.
34
+ 1. **Rate card** (`RATES`, `get_rates`, `calculate_cost`, `is_known_model`) - Model-to-price mapping using prefix matching. Order matters: longer/more-specific prefixes must come first (the legacy `claude-opus-4` 15/75 entry must stay below every `claude-opus-4-x`). Unknown models fall back to Sonnet rates and are flagged with `?` in the report. `get_rates`/`calculate_cost` take the usage date so time-limited `intro` pricing (e.g. Sonnet 5 through 2026-08-31) applies to past usage correctly.
35
+ 1a. **Energy model** (`ENERGY_WH_PER_MTOK`, `estimate_energy_wh`) - Opt-in (`--energy`) order-of-magnitude estimate keyed off each rate entry's `tier`. Deliberately NOT a function of price: both cache-write TTLs use one figure because a 1h write costs more money but the same compute.
35
36
  2. **File discovery** (`default_dirs`, `discover_files`) - Walks `~/.claude/projects/` and `~/.sandy/sandboxes/` for `.jsonl` files.
36
37
  3. **Remote aggregation** (`fetch_remote_jsonl`, `parse_remote_events`, `fetch_remote_hook_data`, `parse_remote_hook_data`) - SSHs to hosts, cats all JSONL logs plus statusline hook data (statusline files, monthly summaries, session baselines) with file separators, parses locally.
38
+ 3a. **Hook parsing** (`parse_hook_rows`, `compute_hook_deltas`) - The hook gives **cost only**. Its `input_tokens`/`output_tokens`/`cache_*` fields are context-window SNAPSHOTS (`input_tokens` ≈ `cache_read + cache_write`; `output_tokens` is just the last message's), not cumulative — never use them for token counts. `cost_usd` is a session-lifetime cumulative that is unreliable in two ways handled by `compute_hook_deltas`: (1) it can already be large the first time a session is observed (a long-running/unattended session that only rendered its statusline partway through), so the **first observation is a baseline that is NOT counted** — booking a session's pre-existing cumulative onto its first-seen day is what produced a bogus $3,370 day; (2) it **drops** on resume/compaction (or when parallel processes share one session id), so a drop **rebaselines without counting the new level** — otherwise a day's booked spend can exceed the counter's own peak, which is impossible for real money. Net rule: **book only observed upward increments**; a rolled-up baseline seeds `prev` so already-counted days aren't recounted.
37
39
  4. **JSONL parsing** (`parse_lines`, `parse_events`, `dedup_events`) - Extracts `assistant` events with token usage. Deduplicates by `requestId` across all of a host's files (last event wins) — resumed sessions (`--resume`/`--continue`) repeat events with the same `requestId` in new files. Events without `requestId` are never deduped.
38
40
  5. **Aggregation** (`aggregate`) - Buckets events into daily and monthly summaries with token counts and costs.
39
41
  6. **Output** (`print_report`) - Prints current month-to-date summary with projections, daily breakdown, and previous month summaries. Cost is composed **per source** (one source per machine): hook cost where a source has statusline coverage, accuracy-factor-adjusted JSONL cost otherwise, summed per date. The accuracy factor is measured per source over dates strictly after that source's hook install day.
42
+ 6a. **Explore** (`print_explore`, `--explore DATE`) - Drills into one day: dollar decomposition by token class, then by model / project / session / machine. Each JSONL event carries its `project` (from `cwd`), `session_id`, `source` (host) and `git_branch` for attribution. Per-session JSONL estimate is shown against the hook's authoritative per-session cost; a >15% day-total divergence is flagged so an inflated estimate (incomplete logs, unknown model) is distinguishable from real spend.
40
43
 
41
44
  Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script).
42
45
 
@@ -44,7 +47,9 @@ Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script)
44
47
 
45
48
  - Zero external dependencies - stdlib only (json, argparse, subprocess, etc.)
46
49
  - Deduplication is global per host by `requestId` (last wins) - resumed sessions replay history into new files and must not double count. Cross-host `requestId` collisions can't occur (distinct API calls).
47
- - Hook data is authoritative for cost where present; JSONL-derived cost is an estimate. Never let one machine's hook coverage suppress another machine's JSONL cost - compose per source.
50
+ - Hook data is authoritative for **cost only**; every token count comes from the JSONL logs. Never mix the two in one figure - the hook's token fields are snapshots and would double count against JSONL cache columns.
51
+ - Never let one machine's hook coverage suppress another machine's JSONL cost - compose per source.
52
+ - Cumulative counters from the hook are not monotonic and don't start at zero when first observed. `compute_hook_deltas` books only observed upward increments (first observation and post-drop levels are baselines, never counted), so a day's hook cost can never exceed the counter's peak and a session's pre-observation history is never mis-dated onto its first-seen day. Test fixtures must start sessions at ~$0 to mirror real statusline behavior.
48
53
  - `session-baselines.json` carries each session's rolled-up cumulative across the 90-day retention boundary so straddling sessions aren't re-counted from zero.
49
54
  - Build system is Hatch (`hatchling`)
50
55
  - Requires Python 3.10+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctrack
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
5
5
  Project-URL: Homepage, https://github.com/rappdw/cctrack
6
6
  Project-URL: Repository, https://github.com/rappdw/cctrack
@@ -84,6 +84,32 @@ Daily breakdown:
84
84
  4. Calculates cost using Anthropic's published rates per model
85
85
  5. Prints daily breakdown with input/output/cache token splits and monthly summaries
86
86
 
87
+ ## Explore a day
88
+
89
+ When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
90
+
91
+ ```bash
92
+ cctrack --explore 2026-08-03 # a specific day
93
+ cctrack --explore yesterday # or 'today'
94
+ cctrack --explore yesterday --remote air dgx # across machines
95
+ ```
96
+
97
+ It reports, for that day:
98
+
99
+ - **Where it went** — the day's cost decomposed by token class (output, 1h/5m cache writes, input, cache reads), so a cache-write blowup or a runaway output loop is obvious at a glance.
100
+ - **By model / by project / by machine** — cost and share for each, so you can see which model, which repo, and which host drove it.
101
+ - **Top sessions** — ranked by cost, each with its project and model, and — where the statusline hook covered that session — its **authoritative** cost next to the JSONL estimate.
102
+
103
+ The day's JSONL-estimated total is shown next to the hook-authoritative total. If they diverge by more than ~15% the report says so: a big gap means the estimate is off (incomplete logs, or a model the rate card doesn't know), not that you actually spent that much. This is the fastest way to tell a real expensive day from an accounting artifact.
104
+
105
+ The project label is the basename of each session's working directory (`cwd`). When that's an unfamiliar name — a container path like `/workspace/team-inbox`, or a directory you don't recognize — trace it to its real location:
106
+
107
+ ```bash
108
+ cctrack --explore 2026-08-03 --project team-inbox
109
+ ```
110
+
111
+ This restricts the view to that project and prints each session's full `cwd`, git branch, and the log file on disk (which reveals the host directory or the specific Sandy sandbox it came from). The unfiltered view flags this automatically when a `cwd` looks like a container path. `scripts/trace_project.py` and `scripts/diagnose_session_cost.py` are standalone versions of the trace and per-session cost diagnostics.
112
+
87
113
  ## Remote aggregation
88
114
 
89
115
  With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
@@ -98,19 +124,39 @@ cctrack --install-hook --remote dgx macbook-air
98
124
 
99
125
  ## Rate card
100
126
 
101
- Prices per million tokens, from [Anthropic's pricing page](https://www.anthropic.com/pricing). Cache writes are priced per TTL: 5-minute at 1.25× input, 1-hour at 2× input.
127
+ Prices per million tokens, from [Anthropic's pricing page](https://platform.claude.com/docs/en/about-claude/pricing). Cache writes are priced per TTL: 5-minute at 1.25× input, 1-hour at 2× input.
102
128
 
103
129
  | Model | Input | Output | Cache Read | Cache Write 5m | Cache Write 1h |
104
130
  |---|---|---|---|---|---|
105
131
  | Fable 5 / Mythos 5 | $10.00 | $50.00 | $1.00 | $12.50 | $20.00 |
106
- | Opus 4.8 / 4.7 / 4.6 / 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | $10.00 |
132
+ | Opus 5 / 4.8 / 4.7 / 4.6 / 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | $10.00 |
107
133
  | Opus 4 / 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | $30.00 |
108
- | Sonnet 5 / 4.x / 3.7 | $3.00 | $15.00 | $0.30 | $3.75 | $6.00 |
134
+ | Sonnet 5 (through 2026-08-31) | $2.00 | $10.00 | $0.20 | $2.50 | $4.00 |
135
+ | Sonnet 5 (from 2026-09-01) / 4.x / 3.7 | $3.00 | $15.00 | $0.30 | $3.75 | $6.00 |
109
136
  | Haiku 4.x | $1.00 | $5.00 | $0.10 | $1.25 | $2.00 |
110
137
  | Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 | $1.60 |
111
138
  | Haiku 3 | $0.25 | $1.25 | $0.03 | $0.30 | $0.50 |
112
139
 
113
- Unknown models fall back to Sonnet rates and are flagged with `?` in the report's Model column.
140
+ Usage is priced with the rates in effect on the day it happened, so time-limited introductory pricing is applied correctly to past usage.
141
+
142
+ Unknown models fall back to Sonnet rates and are flagged with `?` in the report's Model column — a prompt to update the rate card rather than a silent guess.
143
+
144
+ ## Energy estimate
145
+
146
+ `cctrack --energy` adds an estimated inference energy column (Wh/kWh):
147
+
148
+ ```bash
149
+ cctrack --energy
150
+ ```
151
+
152
+ This is a **rough estimate, not a measurement** — Anthropic does not publish per-token energy figures or model sizes. It is modelled from logged tokens using the published full-stack per-prompt figures the major labs have disclosed (Google: 0.24 Wh median Gemini text prompt; OpenAI: ~0.34 Wh average ChatGPT query — both include accelerator, host, cooling and idle capacity), apportioned across token classes by relative compute cost and scaled by model tier:
153
+
154
+ - **output tokens dominate** — decode is sequential and memory-bandwidth bound
155
+ - **input and cache writes** are prefill: parallel, far cheaper per token
156
+ - **cache reads** skip prefill entirely (~0.1× input)
157
+ - both cache-write TTLs use the same figure — a 1-hour write costs more money but the same compute
158
+
159
+ Treat results as ±1 order of magnitude. It covers datacenter inference only: not model training, and not your own machine. Scale the whole model with `CCTRACK_ENERGY_SCALE=1.5` if you have better numbers for your workload.
114
160
 
115
161
  ## Origins
116
162
 
@@ -0,0 +1,142 @@
1
+ # cctrack
2
+
3
+ A lightweight CLI tool that scans [Claude Code](https://docs.anthropic.com/en/docs/claude-code) JSONL logs and reports token usage and estimated cost. Zero dependencies, runs anywhere Python 3.10+ is available.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Run directly (no install needed)
9
+ uvx cctrack
10
+
11
+ # Or install globally
12
+ uv tool install cctrack
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Scan local logs and print report
19
+ cctrack
20
+
21
+ # Last 7 days only
22
+ cctrack --days 7
23
+
24
+ # Aggregate with remote machines via SSH
25
+ cctrack --remote dgx macbook-air
26
+
27
+ # Custom log directories
28
+ cctrack --dirs ~/.claude/projects ~/.sandy/sandboxes
29
+ ```
30
+
31
+ ## Example output
32
+
33
+ ```
34
+ cctrack — Claude Code Cost Report
35
+ ══════════════════════════════════
36
+
37
+ April 2026 — month to date (day 1, 1 active)
38
+ ───────────────────────────────────────
39
+ Input tokens: 23,381
40
+ Output tokens: 199,069
41
+ Cache read: 30,862,977
42
+ Cache write: 1,051,023
43
+ Total tokens: 32,136,450
44
+ Total cost: $25.85
45
+ Avg/day: $25.85
46
+ Projected/mo: $775.50 (based on 1-day avg)
47
+
48
+ Daily breakdown:
49
+ Date Input Output Cache R Cache W Cost
50
+ ──────────── ──────────── ──────────── ──────────── ──────────── ──────────
51
+ 2026-04-01 23,381 199,069 30,862,977 1,051,023 $ 25.85
52
+ ```
53
+
54
+ ## What it does
55
+
56
+ 1. Walks `~/.claude/projects/` and `~/.sandy/sandboxes/` for JSONL log files
57
+ 2. Parses `assistant` events with token usage
58
+ 3. Deduplicates by `requestId` (last event wins, matching Claude Code's semantics)
59
+ 4. Calculates cost using Anthropic's published rates per model
60
+ 5. Prints daily breakdown with input/output/cache token splits and monthly summaries
61
+
62
+ ## Explore a day
63
+
64
+ When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
65
+
66
+ ```bash
67
+ cctrack --explore 2026-08-03 # a specific day
68
+ cctrack --explore yesterday # or 'today'
69
+ cctrack --explore yesterday --remote air dgx # across machines
70
+ ```
71
+
72
+ It reports, for that day:
73
+
74
+ - **Where it went** — the day's cost decomposed by token class (output, 1h/5m cache writes, input, cache reads), so a cache-write blowup or a runaway output loop is obvious at a glance.
75
+ - **By model / by project / by machine** — cost and share for each, so you can see which model, which repo, and which host drove it.
76
+ - **Top sessions** — ranked by cost, each with its project and model, and — where the statusline hook covered that session — its **authoritative** cost next to the JSONL estimate.
77
+
78
+ The day's JSONL-estimated total is shown next to the hook-authoritative total. If they diverge by more than ~15% the report says so: a big gap means the estimate is off (incomplete logs, or a model the rate card doesn't know), not that you actually spent that much. This is the fastest way to tell a real expensive day from an accounting artifact.
79
+
80
+ The project label is the basename of each session's working directory (`cwd`). When that's an unfamiliar name — a container path like `/workspace/team-inbox`, or a directory you don't recognize — trace it to its real location:
81
+
82
+ ```bash
83
+ cctrack --explore 2026-08-03 --project team-inbox
84
+ ```
85
+
86
+ This restricts the view to that project and prints each session's full `cwd`, git branch, and the log file on disk (which reveals the host directory or the specific Sandy sandbox it came from). The unfiltered view flags this automatically when a `cwd` looks like a container path. `scripts/trace_project.py` and `scripts/diagnose_session_cost.py` are standalone versions of the trace and per-session cost diagnostics.
87
+
88
+ ## Remote aggregation
89
+
90
+ With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
91
+
92
+ ```bash
93
+ # Aggregate this machine + DGX server + laptop
94
+ cctrack --remote dgx macbook-air
95
+
96
+ # For accurate aggregate costs, install the hook on each host too
97
+ cctrack --install-hook --remote dgx macbook-air
98
+ ```
99
+
100
+ ## Rate card
101
+
102
+ Prices per million tokens, from [Anthropic's pricing page](https://platform.claude.com/docs/en/about-claude/pricing). Cache writes are priced per TTL: 5-minute at 1.25× input, 1-hour at 2× input.
103
+
104
+ | Model | Input | Output | Cache Read | Cache Write 5m | Cache Write 1h |
105
+ |---|---|---|---|---|---|
106
+ | Fable 5 / Mythos 5 | $10.00 | $50.00 | $1.00 | $12.50 | $20.00 |
107
+ | Opus 5 / 4.8 / 4.7 / 4.6 / 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | $10.00 |
108
+ | Opus 4 / 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | $30.00 |
109
+ | Sonnet 5 (through 2026-08-31) | $2.00 | $10.00 | $0.20 | $2.50 | $4.00 |
110
+ | Sonnet 5 (from 2026-09-01) / 4.x / 3.7 | $3.00 | $15.00 | $0.30 | $3.75 | $6.00 |
111
+ | Haiku 4.x | $1.00 | $5.00 | $0.10 | $1.25 | $2.00 |
112
+ | Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 | $1.60 |
113
+ | Haiku 3 | $0.25 | $1.25 | $0.03 | $0.30 | $0.50 |
114
+
115
+ Usage is priced with the rates in effect on the day it happened, so time-limited introductory pricing is applied correctly to past usage.
116
+
117
+ Unknown models fall back to Sonnet rates and are flagged with `?` in the report's Model column — a prompt to update the rate card rather than a silent guess.
118
+
119
+ ## Energy estimate
120
+
121
+ `cctrack --energy` adds an estimated inference energy column (Wh/kWh):
122
+
123
+ ```bash
124
+ cctrack --energy
125
+ ```
126
+
127
+ This is a **rough estimate, not a measurement** — Anthropic does not publish per-token energy figures or model sizes. It is modelled from logged tokens using the published full-stack per-prompt figures the major labs have disclosed (Google: 0.24 Wh median Gemini text prompt; OpenAI: ~0.34 Wh average ChatGPT query — both include accelerator, host, cooling and idle capacity), apportioned across token classes by relative compute cost and scaled by model tier:
128
+
129
+ - **output tokens dominate** — decode is sequential and memory-bandwidth bound
130
+ - **input and cache writes** are prefill: parallel, far cheaper per token
131
+ - **cache reads** skip prefill entirely (~0.1× input)
132
+ - both cache-write TTLs use the same figure — a 1-hour write costs more money but the same compute
133
+
134
+ Treat results as ±1 order of magnitude. It covers datacenter inference only: not model training, and not your own machine. Scale the whole model with `CCTRACK_ENERGY_SCALE=1.5` if you have better numbers for your workload.
135
+
136
+ ## Origins
137
+
138
+ This is a Python rewrite of the Go-based [cctrack dashboard](https://github.com/ksred/cctrack). The Go version provides a full web dashboard with real-time updates, session explorer, and project breakdown. This Python version strips it down to the essentials: a single command that parses logs and prints a cost report. The JSONL parsing logic, deduplication strategy, and rate card are ported directly from the Go implementation.
139
+
140
+ ## License
141
+
142
+ [MIT](LICENSE)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctrack"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Diagnose a cctrack --explore day and show what each session really spent.
3
+
4
+ The hook's `total_cost_usd` is a session-lifetime cumulative that (a) can be
5
+ large the moment cctrack first sees a long-running session, and (b) drops on
6
+ resume/compaction. cctrack 0.6.0 mis-attributed both: it booked a session's
7
+ whole pre-existing cumulative onto the first day it saw the session, and it
8
+ re-added post-drop levels as fresh spend — so a session whose counter never
9
+ passed $1,086 could be reported at $2,083, and a day's total could balloon far
10
+ above real spend.
11
+
12
+ This script reads the raw statusline data for a date and, per session, prints:
13
+
14
+ * the cost_usd trajectory,
15
+ * BUGGY: what cctrack ≤0.6.0 booked (first level + every post-drop level),
16
+ * FIXED: what cctrack now books — only observed increments, never the
17
+ standing level (== the honest spend cctrack can see on that day).
18
+
19
+ If FIXED is far below BUGGY, the old report was inflated. FIXED is what the
20
+ corrected cctrack reports; any spend from earlier days the hook didn't capture
21
+ is genuinely unattributable and isn't booked on this day.
22
+
23
+ Usage:
24
+ python3 scripts/diagnose_session_cost.py <YYYY-MM-DD> [session_prefix ...]
25
+
26
+ # every session that has data that day:
27
+ python3 scripts/diagnose_session_cost.py 2026-08-03
28
+
29
+ # just specific ones (any unique prefix of the session id works):
30
+ python3 scripts/diagnose_session_cost.py 2026-08-03 34dfdf14 63fd9dae 7c112e0a
31
+
32
+ Reads ~/.claude/cctrack and any ~/.sandy/sandboxes/*/claude/cctrack. Stdlib only.
33
+ """
34
+ import glob
35
+ import json
36
+ import sys
37
+ from pathlib import Path
38
+
39
+
40
+ def buggy_increment(cur: float, prev: float) -> float:
41
+ """What cctrack <= 0.6.0 did: a meaningful drop counted the new value in
42
+ full (and prev started at 0, so the first reading counted in full too)."""
43
+ if cur < prev - max(1e-6, abs(prev) * 0.001):
44
+ return max(0.0, cur)
45
+ return max(0.0, cur - prev)
46
+
47
+
48
+ def hook_dirs() -> list[Path]:
49
+ dirs = [Path.home() / ".claude" / "cctrack"]
50
+ dirs += [Path(p) for p in glob.glob(
51
+ str(Path.home() / ".sandy" / "sandboxes" / "*" / "claude" / "cctrack"))]
52
+ return [d for d in dirs if d.exists()]
53
+
54
+
55
+ def load_rows(date_str: str) -> dict[str, list[tuple[str, float]]]:
56
+ """Return {session_id: [(ts, cost_usd), ...]} for the given date."""
57
+ rows: dict[str, list[tuple[str, float]]] = {}
58
+ for d in hook_dirs():
59
+ for f in glob.glob(str(d / f"statusline-{date_str}.jsonl")):
60
+ try:
61
+ lines = Path(f).read_text(errors="replace").splitlines()
62
+ except OSError:
63
+ continue
64
+ for line in lines:
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ try:
69
+ e = json.loads(line)
70
+ except (json.JSONDecodeError, ValueError):
71
+ continue
72
+ sid = e.get("session_id", "")
73
+ ts = e.get("_ts", "")
74
+ if not sid or not ts:
75
+ continue
76
+ rows.setdefault(sid, []).append((ts, float(e.get("cost_usd", 0) or 0)))
77
+ return rows
78
+
79
+
80
+ def analyze(sid: str, seq: list[tuple[str, float]]) -> dict:
81
+ seq.sort()
82
+ costs = [c for _, c in seq]
83
+
84
+ # BUGGY (<= 0.6.0): prev starts at 0; drops re-add the full new level.
85
+ buggy = prev = 0.0
86
+ drops = 0
87
+ for c in costs:
88
+ if c < prev - max(1e-6, abs(prev) * 0.001):
89
+ drops += 1
90
+ buggy += buggy_increment(c, prev)
91
+ prev = c
92
+
93
+ # FIXED: first reading is a baseline (not counted); a drop rebaselines
94
+ # (not counted); only genuine upward increments are booked.
95
+ fixed = 0.0
96
+ prev = None
97
+ for c in costs:
98
+ if prev is None:
99
+ prev = c
100
+ elif c < prev - max(1e-6, abs(prev) * 0.001):
101
+ prev = c
102
+ else:
103
+ fixed += c - prev
104
+ prev = c
105
+
106
+ return {
107
+ "sid": sid,
108
+ "renders": len(costs),
109
+ "min": min(costs),
110
+ "max": max(costs),
111
+ "first": costs[0],
112
+ "last": costs[-1],
113
+ "drops": drops,
114
+ "buggy": buggy,
115
+ "fixed": fixed,
116
+ "costs": costs,
117
+ }
118
+
119
+
120
+ def main() -> int:
121
+ if len(sys.argv) < 2:
122
+ print(__doc__)
123
+ return 2
124
+
125
+ date_str = sys.argv[1]
126
+ prefixes = sys.argv[2:]
127
+
128
+ rows = load_rows(date_str)
129
+ if not rows:
130
+ print(f"No statusline data found for {date_str} in:")
131
+ for d in hook_dirs() or ["(no hook directories exist)"]:
132
+ print(f" {d}")
133
+ return 1
134
+
135
+ selected = {sid: seq for sid, seq in rows.items()
136
+ if not prefixes or any(sid.startswith(p) for p in prefixes)}
137
+ if not selected:
138
+ print(f"No sessions matching {prefixes} on {date_str}. "
139
+ f"Sessions present: {', '.join(s[:12] for s in rows)}")
140
+ return 1
141
+
142
+ results = [analyze(sid, seq) for sid, seq in selected.items()]
143
+ results.sort(key=lambda r: -r["buggy"])
144
+
145
+ grand_buggy = grand_fixed = 0.0
146
+ for r in results:
147
+ grand_buggy += r["buggy"]
148
+ grand_fixed += r["fixed"]
149
+ inflation = (r["buggy"] / r["fixed"]) if r["fixed"] > 0.005 else float("inf")
150
+ tag = "" if inflation < 1.15 else f" ← inflated {inflation:.0f}×" if inflation != float("inf") else " ← booked from nothing"
151
+ print(f"\n=== {r['sid'][:12]} ({r['renders']} renders) ===")
152
+ print(f" cost_usd first={r['first']:.2f} min={r['min']:.2f} "
153
+ f"max={r['max']:.2f} last={r['last']:.2f} drops={r['drops']}")
154
+ print(f" BUGGY (cctrack ≤0.6.0 booked on this day): ${r['buggy']:.2f}{tag}")
155
+ print(f" FIXED (real observed spend this day): ${r['fixed']:.2f}")
156
+ shown = " ".join(f"{c:.1f}" for c in r["costs"][:40])
157
+ print(f" trajectory: {shown}" + (" …" if len(r["costs"]) > 40 else ""))
158
+
159
+ print(f"\n{'─' * 60}")
160
+ print(f"selected sessions — buggy total: ${grand_buggy:.2f} fixed total: ${grand_fixed:.2f}")
161
+ if grand_fixed > 0.005 and grand_buggy / grand_fixed > 1.15:
162
+ print(f"The old report over-stated these sessions by "
163
+ f"{grand_buggy / grand_fixed:.1f}×. The FIXED column is the honest")
164
+ print("spend cctrack can see for this day. Any earlier-day spend the hook")
165
+ print("never captured is unattributable and is not booked here.")
166
+ else:
167
+ print("Buggy and fixed agree — these sessions were attributed correctly.")
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Trace where a cctrack --explore project label actually comes from.
3
+
4
+ `--explore` labels each session by the basename of the `cwd` recorded on its
5
+ JSONL events (e.g. a cwd of /workspace/team-inbox shows as "team-inbox"). That
6
+ basename can be a container/automation path that doesn't match any sandbox or
7
+ directory you recognize. This script shows the FULL cwd, the git branch, the
8
+ session id, and the exact log file on disk for a project or session, so you can
9
+ see where the events physically are.
10
+
11
+ Usage:
12
+ python3 scripts/trace_project.py [YYYY-MM-DD] [name_or_prefix ...]
13
+
14
+ # everything logged yesterday, grouped by project:
15
+ python3 scripts/trace_project.py 2026-08-03
16
+
17
+ # just the mystery project (matches project basename OR session-id prefix):
18
+ python3 scripts/trace_project.py 2026-08-03 team-inbox
19
+
20
+ # a whole date range is not supported; omit the date to scan all logs:
21
+ python3 scripts/trace_project.py team-inbox
22
+
23
+ Scans ~/.claude/projects and ~/.sandy/sandboxes/*/... for *.jsonl. Stdlib only.
24
+ """
25
+ import glob
26
+ import json
27
+ import sys
28
+ from collections import defaultdict
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+
32
+
33
+ def is_date(tok: str) -> bool:
34
+ try:
35
+ datetime.strptime(tok, "%Y-%m-%d")
36
+ return True
37
+ except ValueError:
38
+ return False
39
+
40
+
41
+ def scan_dirs() -> list[Path]:
42
+ home = Path.home()
43
+ roots = [home / ".claude" / "projects"]
44
+ sandy = home / ".sandy" / "sandboxes"
45
+ if sandy.exists():
46
+ roots.append(sandy)
47
+ files: list[Path] = []
48
+ for root in roots:
49
+ if root.exists():
50
+ files += [Path(p) for p in glob.glob(str(root / "**" / "*.jsonl"), recursive=True)]
51
+ return files
52
+
53
+
54
+ def main() -> int:
55
+ args = sys.argv[1:]
56
+ date_str = None
57
+ if args and is_date(args[0]):
58
+ date_str = args[0]
59
+ args = args[1:]
60
+ filters = args # project basenames or session-id prefixes
61
+
62
+ def matches(project: str, sid: str) -> bool:
63
+ if not filters:
64
+ return True
65
+ return any(project == f or project.endswith(f) or sid.startswith(f) for f in filters)
66
+
67
+ # key: (cwd, sessionId, file) -> {count, models, branches, first, last}
68
+ groups: dict[tuple, dict] = defaultdict(
69
+ lambda: {"count": 0, "models": set(), "branches": set(), "first": "", "last": ""})
70
+
71
+ files = scan_dirs()
72
+ for f in files:
73
+ try:
74
+ lines = f.read_text(errors="replace").splitlines()
75
+ except OSError:
76
+ continue
77
+ for line in lines:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ e = json.loads(line)
83
+ except (json.JSONDecodeError, ValueError):
84
+ continue
85
+ if e.get("type") != "assistant":
86
+ continue
87
+ ts = e.get("timestamp", "")
88
+ if date_str and not ts.startswith(date_str):
89
+ continue
90
+ cwd = e.get("cwd", "")
91
+ project = Path(cwd).name if cwd else "(unknown)"
92
+ sid = e.get("sessionId", "")
93
+ if not matches(project, sid):
94
+ continue
95
+ g = groups[(cwd, sid, str(f))]
96
+ g["count"] += 1
97
+ model = e.get("message", {}).get("model", "")
98
+ if model:
99
+ g["models"].add(model)
100
+ if e.get("gitBranch"):
101
+ g["branches"].add(e["gitBranch"])
102
+ if ts:
103
+ g["first"] = ts if not g["first"] else min(g["first"], ts)
104
+ g["last"] = max(g["last"], ts)
105
+
106
+ if not groups:
107
+ where = f" on {date_str}" if date_str else ""
108
+ filt = f" matching {filters}" if filters else ""
109
+ print(f"No assistant events{filt}{where} found in ~/.claude/projects or ~/.sandy/sandboxes.")
110
+ return 1
111
+
112
+ # Group the display by project basename.
113
+ by_project: dict[str, list[tuple]] = defaultdict(list)
114
+ for (cwd, sid, f), g in groups.items():
115
+ by_project[Path(cwd).name if cwd else "(unknown)"].append((cwd, sid, f, g))
116
+
117
+ for project in sorted(by_project):
118
+ rows = by_project[project]
119
+ total = sum(g["count"] for *_ , g in rows)
120
+ print(f"\n=== project '{project}' ({total} assistant events, "
121
+ f"{len(set(s for _, s, _, _ in rows))} sessions) ===")
122
+ # distinct cwds under this basename (reveals collisions / container paths)
123
+ for cwd in sorted(set(c for c, _, _, _ in rows)):
124
+ print(f" cwd: {cwd or '(empty)'}")
125
+ for cwd, sid, f, g in sorted(rows, key=lambda r: -r[3]["count"]):
126
+ branch = ",".join(sorted(g["branches"])) or "-"
127
+ models = ",".join(sorted(m.replace("claude-", "") for m in g["models"])) or "-"
128
+ print(f" session {sid[:12]} {g['count']:>4} ev branch={branch} model={models}")
129
+ print(f" {g['first'][:19]} … {g['last'][:19]}")
130
+ print(f" file: {f}")
131
+ print()
132
+ print("The `cwd` line is the working directory Claude Code recorded — that is")
133
+ print("the real source of the project label. If it's a container/automation")
134
+ print("path (e.g. /workspace/...), the run happened inside that environment,")
135
+ print("not a host directory or sandbox of that name.")
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())