claude-token-saver 3.13.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -1,51 +1,78 @@
1
1
  [한국어](./README.md) · **English**
2
2
 
3
- [![DeepPulse YouTube](https://img.shields.io/badge/YouTube-@DeepPulseKR-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseKR)
4
- [![DeepPulseEN YouTube](https://img.shields.io/badge/YouTube-@DeepPulseEN-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseEN)
5
- [![Homepage](https://img.shields.io/badge/Homepage-rootstudioyaml.github.io-2ea44f)](https://rootstudioyaml.github.io/)
6
3
  [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
7
4
 
8
5
  # claude-token-saver
9
6
 
10
- **Diagnose Claude Code token usage from a single statusline and route the easy work your expensive model keeps repeating down to cheaper models.** Zero dependencies, one-line install.
7
+ ## 🔀 Routing saved — the reason this tool exists
8
+
9
+ ```
10
+ 🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 · fable→haiku 1× $0.51 · opus→sonnet 2× $0.29
11
+ ```
12
+
13
+ **That figure on the first statusline row is the whole product.** It is money actually saved by moving the easy work your expensive model kept repeating onto cheaper ones — and right beside it is which model that money moved off, and onto what.
11
14
 
12
- Since v3.x this is more than after-the-fact monitoring: it's a **model-fitting routing layer**. Session logs are classified into tiers (T0/T1/T2), recurring patterns get promoted to "haiku/sonnet is enough for this" delegation rules, and your main model auto-delegates them from the next session ([route-scan](#-route-scan--this-recurring-task-could-run-on-a-cheaper-tier)).
15
+ It is not an estimate or a marketing number: it comes out of a **ledger**. For every delegated subagent run it records
16
+
17
+ - **before** — the model that handled this category before the rule existed
18
+ - **after** — the model that actually ran it
19
+ - **the gap** — the same token counts priced against both
20
+
21
+ so `route-scan savings` traces **every dollar back to the rule that produced it.**
13
22
 
14
23
  ```bash
15
- npm i -g claude-token-saver # postinstall auto-registers the statusline + Skill
16
- ```
24
+ $ claude-token-saver route-scan savings
17
25
 
18
- ![statusline example](./docs/statusline.png)
26
+ 🔀 Routing saved, lifetime $2.09 (last 7d $1.40 · 30d $2.09)
19
27
 
20
- ## 📺 Came here from the video? — 60 seconds
28
+ By model change:
29
+ claude-fable-5 → claude-sonnet-5 — 1 run, $0.72
30
+ claude-opus-5 → claude-haiku-4-5 — 1 run, $0.57
21
31
 
22
- This is not a router. It never intercepts a request in realtime.
23
- **After a session ends** it reads your local logs, finds the easy patterns your expensive model
24
- kept handling, and promotes them into rules so a cheaper model takes them **from the next session
25
- onward**. Rules are scoped global or per-project.
32
+ By run (newest first):
33
+ 2026-08-22 $0.51 claude-fable-5 claude-haiku-4-5
34
+ rule: T2|paste|-Users-me-projects-my-app
35
+ ```
36
+
37
+ **Counting honestly is the design principle.** Delegations no registered rule covers — `Explore`, your own agents, a plugin's subagents — were not routed by this tool, so they are **excluded**. When the pricing table cannot recognize a model id, the run is **dropped** rather than priced wrong. The number may be small, but it is real.
26
38
 
27
39
  ```bash
28
- npm i -g claude-token-saver@latest
29
- claude-token-saver route-scan # find delegation candidates in your own history (0 LLM calls)
30
- claude-token-saver route-scan rules # list promoted rules · rm <N> to remove
40
+ npm i -g claude-token-saver # postinstall auto-registers the statusline + Skill
31
41
  ```
32
42
 
33
- Thresholds come from **your own last-14-day distribution (p25/p75)**, not someone else's benchmark.
34
- Measured rule-health — whether a delegated run actually succeeded — landed in [v3.9.0](#v390-2026-08-01).
43
+ ![statusline example](./docs/statusline.png)
35
44
 
45
+ ---
36
46
 
37
47
  ## ⚡ Why — the 30-second pitch
38
48
 
39
49
  | | |
40
50
  |---|---|
41
- | 🔀 **Model-fitting delegation** | Classifies the easy work your expensive model (opus/fable) keeps repeating into tiers (T0/T1/T2) promotes haiku/sonnet delegation rules, auto-applied from the next session |
51
+ | 🔀 **Measured routing savings** | Every delegated run's saving is recorded in a ledger lifetime total plus the model-change breakdown on statusline row 1 (audit it all with `route-scan savings`) |
52
+ | 🎯 **Model-fitting delegation** | Classifies the easy work your expensive model (opus/fable) keeps repeating into tiers (T0/T1/T2) → promotes haiku/sonnet delegation rules, auto-applied from the next session |
42
53
  | 💸 **−18.6% measured cost** | Cost per user message $2.35 → $1.91 after adopting harness+ratchet (author's logs, [details](#real-world-impact--beforeafter-report)) |
43
54
  | 🚨 **No surprise rate limits** | Instant warning when the 5H/7D window hits 90% + `handoff` to back up your work |
44
55
  | 🧠 **Cache waste detection** | Hit rate, TTL countdown, 1M-context detection — token spikes diagnosed with issue codes |
45
56
  | 🅷 **Stop repeating mistakes** | Recurring errors get promoted to ratchet rules — auto-applied from the next session |
46
- | 💰 **Savings made visible** | See what prompt caching saved you, live (`💰 Cache saved $2.1K`) |
47
57
 
48
- 📺 [Launch Short (60s)](https://www.youtube.com/shorts/RaD8qMsPTnA)
58
+ ## Not a router — 60 seconds
59
+
60
+ It never intercepts a request in realtime.
61
+ **After a session ends** it reads your local logs, finds the easy patterns your expensive model
62
+ kept handling, and promotes them into rules so a cheaper model takes them **from the next session
63
+ onward**. Rules are scoped global or per-project.
64
+
65
+ Never switching models mid-session is the point. Prompt caches are per-model, so a mid-session switch throws away everything accumulated. This tool only ever delegates to **subagents**, leaving the main session's cache intact.
66
+
67
+ ```bash
68
+ npm i -g claude-token-saver@latest
69
+ claude-token-saver route-scan # find delegation candidates in your own history (0 LLM calls)
70
+ claude-token-saver route-scan rules # list promoted rules · rm <N> to remove
71
+ claude-token-saver route-scan savings # audit every dollar the routing saved
72
+ ```
73
+
74
+ Thresholds come from **your own last-14-day distribution (p25/p75)**, not someone else's benchmark.
75
+ Measured rule-health — whether a delegated run actually succeeded — landed in [v3.9.0](#v390-2026-08-01).
49
76
 
50
77
  ---
51
78
 
@@ -64,20 +91,25 @@ The statusline appears at the bottom of Claude Code right away. If auto-registra
64
91
 
65
92
  ## Reading the statusline
66
93
 
94
+ Once the savings ledger has entries it renders as **two rows** — routing savings on row 1, diagnostics on row 2.
95
+
67
96
  ```
68
- 🤖 Opus 4.8 · 🧠 Cache hit 98.0% · Cache expires 58:38 · ✦ current █░░░░░ 15% 🔄 08:50 · 📅 weekly █▒░░░░ 24% 🔄 Thu 13:00 · 📦 Ctx 200k · 💰 Cache saved $205 · last 1d
97
+ 🔀 Routing saved $2.09 | fable→sonnet $0.72 · opus→haiku $0.57
98
+ ⚠ Ctx 200k+ · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · ✦ current ███▓░░ 62% 🔄 21:33 · 📅 weekly ██▒░░░ 38% 🔄 Tue 19:33 · 📦 Ctx 47% of 1M · 💰 Cache saved $1.0K · last 1d
69
99
  ```
70
100
 
101
+ With an empty ledger (no measured delegation yet) row 1 is not drawn and the layout stays single-line. If your build renders only the first row (some macOS Claude Code versions), pass `--single-line`.
102
+
71
103
  | Segment | Meaning |
72
104
  |---|---|
105
+ | `🔀` **row 1** | **Lifetime routing savings + the model changes behind them.** The total is green, the breakdown gray. The breakdown sums exactly to the total (all pairs, never truncated) and drops version digits, which churn (`claude-opus-4-5-…` → `opus`). Full audit: `route-scan savings` |
73
106
  | `🤖` | Active model |
74
107
  | `🅷 5/5` | Harness principle score ([Harness mode](#-harness-mode)) |
75
108
  | `🧠` | Cache hit rate (green at 85%+) |
76
109
  | `⏳` | Cache TTL countdown — send a message before expiry to keep the cache warm |
77
110
  | `✦ current` / `📅 weekly` | 5-hour / 7-day rate-limit window usage + reset time |
78
111
  | `📦` | Context usage (e.g. `Ctx 68% of 1M`) — colored by fill. Current models default to 1M with no premium, but token volume itself drives per-turn cost and 5H/7D burn |
79
- | `💰` | Cumulative savings from prompt caching |
80
- | `🔀` | **Cumulative savings from model routing** — what work handled by a cheaper model instead of this one saved. A different number from prompt-cache savings (`💰`). Hidden until a delegation has actually been measured |
112
+ | `💰` | Cumulative savings from prompt caching — a **different** number from row 1's `🔀` (model routing) |
81
113
 
82
114
  When something is wrong, a **warning chip leads the line**:
83
115
 
@@ -104,6 +136,7 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
104
136
  | `claude-token-saver mode [keywords...]` | Output config (`icon`/`text`, `en`/`ko`, `1h`–`30d` window, …) |
105
137
  | `claude-token-saver harness ...` | 🅷 Harness management (below) |
106
138
  | `claude-token-saver route-scan` | Detect recurring easy work on expensive models → propose haiku-delegation ratchet rules (below) |
139
+ | `claude-token-saver route-scan savings` | The routing-savings ledger — per-model-change rollup + per-run log (the evidence behind the figure) |
107
140
  | `claude-token-saver compact-window` | Warn when a 1M-context session has no auto-compact cap → pin 400k with `set` (below) |
108
141
  | `claude-token-saver install` | Manually register Skill + statusline |
109
142
 
@@ -199,6 +232,7 @@ claude-token-saver route-scan # scan (24h cache) + tiered can
199
232
  claude-token-saver harness promote R1 --project # promote candidate R1 to a model-fitting rule
200
233
  claude-token-saver route-scan dismiss 1 # not interested — won't resurface
201
234
  claude-token-saver route-scan rules # list model-fitting rules (rm <N> to remove)
235
+ claude-token-saver route-scan savings # the savings ledger — which rule moved work off which model, onto which
202
236
  ```
203
237
 
204
238
  Dig deeper: **tier criteria & research evidence** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) (Korean) · **rule-file mechanics, scan triggers, subagent setup** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md) (Korean + English)
@@ -300,11 +334,25 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
300
334
  ```
301
335
  Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-saver …`.
302
336
 
303
- **Background:** [GitHub Issue #46829](https://github.com/anthropics/claude-code/issues/46829) (cache TTL regression) · [HN discussion](https://news.ycombinator.com/item?id=47736476) · [DeepPulse KR](https://www.youtube.com/@DeepPulseKR) / [EN](https://www.youtube.com/@DeepPulseEN) · [Homepage](https://rootstudioyaml.github.io/)
337
+ **Background:** [GitHub Issue #46829](https://github.com/anthropics/claude-code/issues/46829) (cache TTL regression) · [HN discussion](https://news.ycombinator.com/item?id=47736476)
304
338
  </details>
305
339
 
306
340
  ## Release notes
307
341
 
342
+ ### v3.16.0 (2026-08-22)
343
+ - **README restructured around routing savings** — `🔀 Routing saved` now opens the page, with how the figure is derived (before / after / gap) and the real `route-scan savings` output alongside it. Channel and homepage badges moved to a "Who makes this" section at the bottom.
344
+ - **Statusline screenshot refreshed to the current two-row layout** — captured from real output rather than mocked up. Regenerate with `npm run docs:statusline` (headless Chrome; no dependencies added).
345
+
346
+ ### v3.15.0 (2026-08-22)
347
+ - **The statusline headline is one lifetime figure** — `🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 …`. The weekly and monthly sums are gone: the per-model breakdown that follows is a lifetime split, and next to a row of rolling windows it read as a breakdown of whichever one it touched. One timeframe for the whole line leaves nothing to mismatch. The rolling windows are still in `route-scan savings`.
348
+ - **The per-model breakdown is gray** — green is reserved for the lifetime total. Repeating that green on every component flattened the line into one loud block with nothing to land on first.
349
+
350
+ ### v3.14.0 (2026-08-22)
351
+ - **The statusline headline names the model changes behind the total** — `🔀 Routing saved weekly $1.4 · monthly $2.1 · total $2.1 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 …`. Versions are dropped (`claude-opus-4-5-20251101-v1:0` → `opus`) since they bump constantly and add nothing here. **Every** pair is listed rather than a top-N: the amounts sit next to a total, so a truncated list misstates what that total is made of. Families collapse the list on their own, so it stays short without being cut.
352
+ - **New `route-scan savings`** — the evidence behind the headline: a per-model-change rollup (from, to, runs, dollars) and the per-run log (date, amount, model change, the rule that caused it).
353
+ - **Baselines are the model that handled a category most, not the priciest seen** — transcripts routinely carry more than one model (a mid-session switch), and taking the priciest let a single Fable record set the baseline for a category Opus had handled thirty times, inflating every later saving. Ties break toward the pricier model. Baselines written under the old definition are recomputed once.
354
+ - **The ledger is written after the rule refresh** — writing it first made a changed baseline take two scans to settle: the first stored the new baseline but billed against the old one.
355
+
308
356
  ### v3.13.0 (2026-08-22)
309
357
  - **Routing savings are now priced "model before the rule → model that ran it"** — previously the counterfactual was the session's priciest model, which booked a saving even when that model had never handled work of this shape. Each rule now remembers the **model that handled its category before promotion** (its baseline) and prices only against that. The baseline is sticky once set: as a rule takes effect fewer episodes stay on the expensive model, so a recomputed baseline would drift down and shrink the very saving the rule produces.
310
358
  - **Delegations no rule covers are excluded** — `Explore`, hand-written agents, and plugin subagents were all being counted, crediting this tool for routing it never did. Ledger events now carry `rule` / `from` / `to`, so every dollar is traceable to the rule and model change behind it.
@@ -496,3 +544,13 @@ Older versions: see `git log`.
496
544
  ## License
497
545
 
498
546
  MIT
547
+
548
+ ---
549
+
550
+ ## Who makes this
551
+
552
+ [![DeepPulse YouTube](https://img.shields.io/badge/YouTube-@DeepPulseKR-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseKR)
553
+ [![DeepPulseEN YouTube](https://img.shields.io/badge/YouTube-@DeepPulseEN-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseEN)
554
+ [![Homepage](https://img.shields.io/badge/Homepage-rootstudioyaml.github.io-2ea44f)](https://rootstudioyaml.github.io/)
555
+
556
+ Built and used at **DeepPulse**, a channel about AI developer tooling. The [launch Short (60s)](https://www.youtube.com/shorts/RaD8qMsPTnA) covers where this came from and how it is used.
package/README.md CHANGED
@@ -1,50 +1,77 @@
1
1
  **한국어** · [English](./README.en.md)
2
2
 
3
- [![DeepPulse YouTube](https://img.shields.io/badge/YouTube-@DeepPulseKR-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseKR)
4
- [![DeepPulseEN YouTube](https://img.shields.io/badge/YouTube-@DeepPulseEN-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseEN)
5
- [![Homepage](https://img.shields.io/badge/Homepage-rootstudioyaml.github.io-2ea44f)](https://rootstudioyaml.github.io/)
6
3
  [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](https://www.npmjs.com/package/claude-token-saver)
7
4
 
8
5
  # claude-token-saver
9
6
 
10
- **Claude Code 토큰 사용량을 statusline 줄로 진단하고, 상위 모델이 반복 처리하는 easy 작업은 더 싼 모델로 내려보내는 CLI.** 의존성 0, 설치 한 줄이면 끝.
7
+ ## 🔀 Routing saved 도구가 존재하는 이유
8
+
9
+ ```
10
+ 🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 · fable→haiku 1× $0.51 · opus→sonnet 2× $0.29
11
+ ```
12
+
13
+ **statusline 첫 줄에 뜨는 이 금액이 전부입니다.** 비싼 모델이 반복 처리해 온 쉬운 작업을 더 싼 모델로 내려보내서 **실제로 아낀 돈**이고, 옆에는 그 돈이 어느 모델에서 어느 모델로 옮겨서 생겼는지가 그대로 붙습니다.
11
14
 
12
- v3.x부터는 사후 모니터링을 넘어 **모델 피팅 라우팅 계층**입니다: 세션 로그를 티어(T0/T1/T2) 분류해 "이 반복 작업은 haiku/sonnet이면 충분하다"를 룰로 승격하고, 다음 세션부터 메인 모델이 자동 위임합니다 ([route-scan](#-route-scan--이-반복-작업-더-싼-티어로-내려도-됩니다)).
15
+ 숫자는 추정치나 홍보 문구가 아니라 **원장(ledger)에서 나옵니다.** 위임된 서브에이전트 실행 하나하나에 대해
16
+
17
+ - **기준(before)** — 룰 승격 전 그 유형을 실제로 처리하던 모델
18
+ - **결과(after)** — 실제로 그 일을 처리한 모델
19
+ - **차액** — 같은 토큰량에 두 모델의 가격표를 각각 적용한 값
20
+
21
+ 을 기록합니다. 그래서 `route-scan savings` 한 줄이면 **모든 금액을 룰 단위까지 되짚을 수 있습니다.**
13
22
 
14
23
  ```bash
15
- npm i -g claude-token-saver # postinstall이 statusline + Skill 자동 등록
16
- ```
24
+ $ claude-token-saver route-scan savings
17
25
 
18
- ![statusline 예시](./docs/statusline.png)
26
+ 🔀 라우팅 절감 누적 $2.09 (최근 7일 $1.40 · 30일 $2.09)
19
27
 
20
- ## 📺 영상 보고 오셨다면 — 60초
28
+ 모델 이동별:
29
+ claude-fable-5 → claude-sonnet-5 — 1회, $0.72
30
+ claude-opus-5 → claude-haiku-4-5 — 1회, $0.57
21
31
 
22
- 라우터가 아닙니다. 요청을 실시간으로 가로채지 않습니다.
23
- **세션이 끝난 뒤** 로컬 기록을 읽어서, 비싼 모델이 반복해서 처리해 온 쉬운 유형을 뽑고,
24
- 유형은 **다음 세션부터** 싼 모델이 맡도록 룰로 겁니다. 룰은 글로벌·프로젝트로 범위가 나뉩니다.
32
+ 실행별 (최근순):
33
+ 2026-08-22 $0.51 claude-fable-5 claude-haiku-4-5
34
+ 룰: T2|paste|-Users-me-projects-my-app
35
+ ```
36
+
37
+ **정직하게 세는 것이 설계 원칙입니다.** 등록된 룰이 커버하지 않는 위임(`Explore`, 직접 만든 에이전트, 플러그인 에이전트)은 이 도구가 라우팅한 것이 아니므로 **금액에서 제외**합니다. 모델명을 가격표가 알아보지 못하면 틀린 금액을 내놓는 대신 그 실행을 **빼버립니다**. 작게 나오더라도 실제 숫자입니다.
25
38
 
26
39
  ```bash
27
- npm i -g claude-token-saver@latest
28
- claude-token-saver route-scan # 내 지난 세션에서 위임 후보 뽑기 (LLM 호출 0)
29
- claude-token-saver route-scan rules # 승격된 룰 확인 · rm <N> 으로 삭제
40
+ npm i -g claude-token-saver # postinstall이 statusline + Skill 자동 등록
30
41
  ```
31
42
 
32
- 기준선은 남의 벤치마크가 아니라 **내 최근 14일 분포(p25/p75)** 로 잡습니다.
33
- 넘긴 뒤 실제로 잘 됐는지까지 재는 실측 rule-health는 [v3.9.0](#v390-2026-08-01)에 들어갔습니다.
43
+ ![statusline 예시](./docs/statusline.png)
34
44
 
45
+ ---
35
46
 
36
47
  ## ⚡ 왜 쓰나 — 30초 요약
37
48
 
38
49
  | | |
39
50
  |---|---|
40
- | 🔀 **모델 피팅 위임** | 상위 모델(opus/fable)이 반복 처리해 easy 작업을 티어(T0/T1/T2)로 분류 haiku/sonnet 위임 룰로 승격, 다음 세션부터 자동 적용 |
51
+ | 🔀 **라우팅 절감 실측** | 위임으로 아낀 금액을 원장에 실행 단위로 기록 statusline 1줄째에 누적 + 모델 이동 내역 (`route-scan savings`로 전수 추적) |
52
+ | 🎯 **모델 피팅 위임** | 상위 모델(opus/fable)이 반복 처리해 온 easy 작업을 티어(T0/T1/T2)로 분류 → haiku/sonnet 위임 룰로 승격, 다음 세션부터 자동 적용 |
41
53
  | 💸 **비용 실측 −18.6%** | harness+ratchet 도입 전후, 사용자 메시지당 비용 $2.35 → $1.91 (저자 로그, [상세](#실제-효과--도입-전후-리포트)) |
42
54
  | 🚨 **한도 초과 예방** | 5H/7D rate-limit 윈도 90% 도달 시 즉시 경고 + `handoff`로 작업 백업 |
43
55
  | 🧠 **캐시 낭비 감지** | 히트율·TTL 카운트다운·1M 컨텍스트 자동 감지 — 토큰 급증 원인을 코드로 진단 |
44
56
  | 🅷 **같은 실수 차단** | 반복 에러를 감지해 ratchet 룰로 승격 — 다음 세션부터 자동 적용 |
45
- | 💰 **절감액 가시화** | 프롬프트 캐시가 아껴준 금액을 실시간 표시 (`💰 Cache saved $2.1K`) |
46
57
 
47
- 📺 [출시 영상 (60초)](https://www.youtube.com/shorts/RaD8qMsPTnA)
58
+ ## 라우터가 아닙니다 60초
59
+
60
+ 요청을 실시간으로 가로채지 않습니다.
61
+ **세션이 끝난 뒤** 로컬 기록을 읽어서, 비싼 모델이 반복해서 처리해 온 쉬운 유형을 뽑고,
62
+ 그 유형은 **다음 세션부터** 싼 모델이 맡도록 룰로 겁니다. 룰은 글로벌·프로젝트로 범위가 나뉩니다.
63
+
64
+ 세션 중간에 모델을 바꾸지 않는 것이 핵심입니다. 프롬프트 캐시는 모델별이라 중간 전환은 누적 캐시를 통째로 날립니다. 이 도구는 **서브에이전트 위임**만 쓰므로 메인 세션의 캐시가 깨지지 않습니다.
65
+
66
+ ```bash
67
+ npm i -g claude-token-saver@latest
68
+ claude-token-saver route-scan # 내 지난 세션에서 위임 후보 뽑기 (LLM 호출 0)
69
+ claude-token-saver route-scan rules # 승격된 룰 확인 · rm <N> 으로 삭제
70
+ claude-token-saver route-scan savings # 위임으로 아낀 금액의 근거 전수 확인
71
+ ```
72
+
73
+ 기준선은 남의 벤치마크가 아니라 **내 최근 14일 분포(p25/p75)** 로 잡습니다.
74
+ 넘긴 뒤 실제로 잘 됐는지까지 재는 실측 rule-health는 [v3.9.0](#v390-2026-08-01)에 들어갔습니다.
48
75
 
49
76
  ---
50
77
 
@@ -63,20 +90,25 @@ npm i -g claude-token-saver
63
90
 
64
91
  ## statusline 읽는 법
65
92
 
93
+ 절감 원장에 기록이 쌓이면 **두 줄**로 나옵니다. 1줄째는 라우팅 절감만, 2줄째는 진단 칩입니다.
94
+
66
95
  ```
67
- 🤖 Opus 4.8 · 🧠 Cache hit 98.0% · Cache expires 58:38 · ✦ current █░░░░░ 15% 🔄 08:50 · 📅 weekly █▒░░░░ 24% 🔄 Thu 13:00 · 📦 Ctx 200k · 💰 Cache saved $205 · last 1d
96
+ 🔀 Routing saved $2.09 | fable→sonnet $0.72 · opus→haiku $0.57
97
+ ⚠ Ctx 200k+ · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · ✦ current ███▓░░ 62% 🔄 21:33 · 📅 weekly ██▒░░░ 38% 🔄 Tue 19:33 · 📦 Ctx 47% of 1M · 💰 Cache saved $1.0K · last 1d
68
98
  ```
69
99
 
100
+ 원장이 비어 있으면(아직 위임 실측이 없으면) 1줄째는 그리지 않고 종전처럼 한 줄로 나옵니다. 일부 환경(macOS 구버전 Claude Code)에서 첫 줄만 렌더된다면 `--single-line`으로 한 줄 레이아웃을 유지하세요.
101
+
70
102
  | 세그먼트 | 의미 |
71
103
  |---|---|
104
+ | `🔀` **1줄째** | **라우팅으로 아낀 누적 금액 + 모델 이동 내역.** 금액이 녹색, 내역은 회색입니다. 내역의 합은 누적과 정확히 일치하며(전부 표시, 잘라내지 않음), 버전 숫자는 계속 바뀌므로 계열명만 남깁니다(`claude-opus-4-5-…` → `opus`). 근거 전수는 `route-scan savings` |
72
105
  | `🤖` | 현재 모델 |
73
106
  | `🅷 5/5` | harness 원칙 점수 ([Harness 모드](#-harness-모드)) |
74
107
  | `🧠` | 캐시 히트율 (85%+ 녹색) |
75
108
  | `⏳` | 캐시 TTL 카운트다운 — 만료 전에 메시지를 보내면 캐시 유지 |
76
109
  | `✦ current` / `📅 weekly` | 5시간 / 7일 rate-limit 윈도 사용률 + 리셋 시각 |
77
110
  | `📦` | 컨텍스트 사용률 (예: `Ctx 68% of 1M`) — 사용률 기준 녹/황/적. 현재 모델은 1M이 기본·프리미엄 없음이지만, 토큰량 자체가 턴당 비용과 5H/7D 한도를 태웁니다 |
78
- | `💰` | 캐시가 절약해준 누적 금액 |
79
- | `🔀` | **모델 위임으로 아낀 누적 비용** — 같은 일을 더 싼 모델이 대신 처리해서 아낀 금액이며, 프롬프트 캐시 절감(`💰`)과는 다른 수치입니다. 실측 위임이 없으면 표시되지 않습니다 |
111
+ | `💰` | 프롬프트 캐시가 절약해준 누적 금액 — 1줄째 `🔀`(모델 라우팅 절감)와는 **다른 수치**입니다 |
80
112
 
81
113
  문제가 감지되면 **경고 칩이 맨 앞에** 붙습니다:
82
114
 
@@ -103,6 +135,7 @@ Claude 안에서 `/claude-token-saver` Skill을 실행하거나 칩 문구를
103
135
  | `claude-token-saver mode [keywords...]` | 출력 설정 (`icon`/`text`, `ko`/`en`, `1h`~`30d` 윈도 등) |
104
136
  | `claude-token-saver harness ...` | 🅷 Harness 관리 (아래 참고) |
105
137
  | `claude-token-saver route-scan` | 상위 모델이 반복 처리한 easy 작업 감지 → haiku 위임 랫쳇 룰 제안 (아래 참고) |
138
+ | `claude-token-saver route-scan savings` | 라우팅 절감 원장 — 모델 이동별 합계 + 실행별 내역 (금액의 근거) |
106
139
  | `claude-token-saver compact-window` | 1M 컨텍스트인데 자동 압축 창이 안 잡혀 있으면 경고 → `set`으로 40만 고정 (아래 참고) |
107
140
  | `claude-token-saver install` | Skill·statusline 수동 등록 |
108
141
 
@@ -179,6 +212,17 @@ claude-token-saver route-scan # 스캔 (24h 캐시) + 티어
179
212
  claude-token-saver harness promote R1 --project # 후보 R1을 모델 피팅 룰로 등록
180
213
  claude-token-saver route-scan dismiss 1 # 관심 없으면 무시 (재스캔에도 안 뜸)
181
214
  claude-token-saver route-scan rules # 등록된 모델 피팅 룰 목록 (rm <N>으로 제거)
215
+ claude-token-saver route-scan savings # 절감 원장 — 어느 룰이 어떤 모델에서 어떤 모델로 옮겼는지
216
+ ```
217
+
218
+ `route-scan savings`는 statusline의 `🔀 Routing saved` 한 줄 뒤에 있는 근거를 그대로 보여줍니다. 모델 이동별 합계와 실행별 내역이 함께 나오므로, 금액이 어디서 나왔는지 추적할 수 있습니다.
219
+
220
+ ```
221
+ 🔀 라우팅 절감 누적 $2.09 (최근 7일 $1.40 · 30일 $2.09)
222
+
223
+ 모델 이동별:
224
+ claude-fable-5 → claude-sonnet-5 — 1회, $0.72
225
+ claude-opus-5 → claude-haiku-4-5 — 1회, $0.57
182
226
  ```
183
227
 
184
228
  더 알아보기: **티어 기준·리서치 근거** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) · **룰 파일 구조·스캔 트리거·서브에이전트 준비** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md)
@@ -261,6 +305,20 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
261
305
 
262
306
  ## 릴리스 노트
263
307
 
308
+ ### v3.16.0 (2026-08-22)
309
+ - **README를 라우팅 절감 중심으로 재구성** — 이 도구의 핵심이 무엇인지 첫 화면에서 바로 보이도록 `🔀 Routing saved`를 최상단에 올리고, 금액이 원장에서 어떻게 나오는지(before/after/차액)와 `route-scan savings` 실제 출력을 함께 실었습니다. 채널·홈페이지 배지는 최하단 "만든 곳"으로 내렸습니다.
310
+ - **statusline 스크린샷을 현재 2줄 레이아웃으로 갱신** — 목업이 아니라 실제 출력을 캡처합니다. `npm run docs:statusline`으로 재생성할 수 있습니다(headless Chrome 사용, 의존성 추가 없음).
311
+
312
+ ### v3.15.0 (2026-08-22)
313
+ - **statusline 헤드라인을 누적 한 줄로 줄였습니다** — `🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 …`. 주간·월간 합계는 뺐습니다. 뒤에 붙는 모델 이동 내역이 누적 기준 분해인데 롤링 창 세 개와 나란히 있으면 어느 것의 내역인지 읽히지 않았습니다. 한 줄 전체가 한 시점 기준이 되면 어긋날 여지가 없습니다. 주간·월간은 `route-scan savings`에서 계속 확인할 수 있습니다.
314
+ - **모델별 절감 내역은 회색으로** — 녹색은 누적 금액 하나에만 남깁니다. 구성 요소마다 같은 녹색을 반복하면 줄 전체가 한 덩어리로 시끄러워져 먼저 눈이 닿을 곳이 사라집니다.
315
+
316
+ ### v3.14.0 (2026-08-22)
317
+ - **statusline 헤드라인이 모델 이동을 함께 보여줍니다** — `🔀 Routing saved weekly $1.4 · monthly $2.1 · total $2.1 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 …`. 버전 숫자는 계속 올라가고 statusline에서는 잡음이라 계열명만 남깁니다(`claude-opus-4-5-20251101-v1:0` → `opus`). 이동 목록은 **자르지 않고 전부** 표시합니다 — 금액이 `total` 옆에 붙어 있어서 일부만 보이면 합계를 잘못 말하게 됩니다. 계열 단위로 접히면 조합 수가 원래 많지 않아 줄은 짧게 유지됩니다.
318
+ - **`route-scan savings` 추가** — 헤드라인 뒤에 있는 근거를 그대로 조회합니다. 모델 이동별 합계(어느 모델에서 어느 모델로 몇 회, 얼마)와 실행별 내역(날짜·금액·모델 이동·해당 룰)이 함께 나옵니다.
319
+ - **기준 모델을 최고가가 아니라 '가장 많이 처리한 모델'로 정합니다** — 한 트랜스크립트에 모델이 섞이는 일이 흔한데(세션 중 모델 전환 등) 최고가를 고르면 Fable 기록 한 건이 Opus가 서른 번 처리한 카테고리의 기준을 차지해 이후 절감액을 전부 부풀렸습니다. 동수일 때만 비싼 쪽으로 기웁니다. 옛 정의로 굳은 기준은 1회 재계산됩니다.
320
+ - **원장 기록을 룰 갱신 뒤로 옮겼습니다** — 앞서 기록하면 기준이 바뀐 스캔이 새 기준을 저장하면서 청구는 옛 기준으로 해, 합계가 두 번째 스캔에야 맞았습니다.
321
+
264
322
  ### v3.13.0 (2026-08-22)
265
323
  - **라우팅 절감액의 기준을 '승격 전 모델 → 위임 모델' 차액으로 바꿨습니다** — 이전에는 세션의 최상위 모델을 반사실로 잡아, 그 모델이 해당 유형을 실제로 처리한 적이 없어도 차액을 절감으로 기록했습니다. 이제 각 룰이 **승격 전 그 유형을 직접 처리하던 모델**(baseline)을 기억하고, 그 기준 대비로만 계산합니다. baseline은 한 번 정해지면 고정됩니다 — 룰이 효력을 낼수록 직접 처리 사례가 줄어 기준이 흘러내리고, 그러면 룰이 만든 절감이 스스로 작아지기 때문입니다.
266
324
  - **룰이 커버하지 않는 위임은 집계에서 뺐습니다** — `Explore`, 직접 만든 에이전트, 플러그인 에이전트처럼 이 도구와 무관하게 돌던 서브에이전트 실행까지 절감으로 잡히고 있었습니다. 도구가 라우팅하지 않은 작업의 절감을 도구 성과로 표시하면 안 됩니다. 원장 이벤트에 `rule`/`from`/`to`를 남겨 어느 룰이 어떤 모델 차이를 만들었는지 추적할 수 있습니다.
@@ -452,3 +510,13 @@ manifest.build의 "다들 LLM 라우터 만드는데 우리는 폐기했다"(7
452
510
  ## 라이선스
453
511
 
454
512
  MIT
513
+
514
+ ---
515
+
516
+ ## 만든 곳
517
+
518
+ [![DeepPulse YouTube](https://img.shields.io/badge/YouTube-@DeepPulseKR-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseKR)
519
+ [![DeepPulseEN YouTube](https://img.shields.io/badge/YouTube-@DeepPulseEN-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseEN)
520
+ [![Homepage](https://img.shields.io/badge/Homepage-rootstudioyaml.github.io-2ea44f)](https://rootstudioyaml.github.io/)
521
+
522
+ AI 개발 도구를 다루는 채널 **DeepPulse**에서 만들고 씁니다. 이 도구의 배경과 사용법은 [출시 영상(60초)](https://www.youtube.com/shorts/RaD8qMsPTnA)에서 볼 수 있습니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.13.0",
3
+ "version": "3.16.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test",
11
+ "docs:statusline": "node scripts/docs-statusline.mjs",
11
12
  "postinstall": "node bin/cli.js install || true"
12
13
  },
13
14
  "files": [
@@ -10,6 +10,60 @@ export async function run({ args, hasFlag, numArg }) {
10
10
  const { userLanguage } = await import('../config.js');
11
11
  const lang = userLanguage();
12
12
 
13
+ // route-scan savings — the delegation ledger behind the statusline's
14
+ // "Routing saved" headline. The headline is one number; this is the
15
+ // evidence for it: which rule fired, which model the work moved off, and
16
+ // which model actually ran it.
17
+ if (args[1] === 'savings') {
18
+ const { loadLedger, delegationSavedTotals } = await import('../savings-ledger.js');
19
+ const events = Object.entries(loadLedger().events)
20
+ .map(([key, e]) => ({ key, ...e }))
21
+ .sort((a, b) => b.ts - a.ts);
22
+ if (events.length === 0) {
23
+ console.log(lang === 'ko'
24
+ ? '기록된 라우팅 절감 없음. 승격된 룰이 실제로 위임을 일으킨 뒤 `route-scan --refresh` 를 돌리면 채워집니다.'
25
+ : 'No routing savings recorded yet. Promote a rule, let it delegate, then run `route-scan --refresh`.');
26
+ return;
27
+ }
28
+ const t = delegationSavedTotals();
29
+ const money = (v) => `$${v.toFixed(2)}`;
30
+ // Lifetime leads (it is what the breakdown below adds up to); the
31
+ // rolling windows follow as context rather than as competing headlines.
32
+ console.log(lang === 'ko'
33
+ ? `🔀 라우팅 절감 누적 ${money(t.total)} (최근 7일 ${money(t.week)} · 30일 ${money(t.month)})`
34
+ : `🔀 Routing saved, lifetime ${money(t.total)} (last 7d ${money(t.week)} · 30d ${money(t.month)})`);
35
+
36
+ // Per model-pair rollup first: the "what moved where" question is what
37
+ // this view exists to answer, and it is easier to read than the log.
38
+ const pairs = new Map();
39
+ for (const e of events) {
40
+ const k = `${e.from || '?'} → ${e.to || '?'}`;
41
+ const p = pairs.get(k) || { runs: 0, usd: 0 };
42
+ p.runs += 1;
43
+ p.usd += Number(e.usd) || 0;
44
+ pairs.set(k, p);
45
+ }
46
+ console.log('');
47
+ console.log(lang === 'ko' ? '모델 이동별:' : 'By model change:');
48
+ for (const [k, p] of [...pairs].sort((a, b) => b[1].usd - a[1].usd)) {
49
+ const runs = lang === 'ko' ? `${p.runs}회` : `${p.runs} run${p.runs === 1 ? '' : 's'}`;
50
+ console.log(` ${k} — ${runs}, ${money(p.usd)}`);
51
+ }
52
+
53
+ console.log('');
54
+ console.log(lang === 'ko' ? '실행별 (최근순):' : 'By run (newest first):');
55
+ for (const e of events) {
56
+ const when = new Date(e.ts).toISOString().slice(0, 10);
57
+ console.log(` ${when} ${money(Number(e.usd) || 0).padStart(7)} ${e.from || '?'} → ${e.to || '?'}`);
58
+ console.log(` ${lang === 'ko' ? '룰' : 'rule'}: ${e.rule || '(unattributed)'}`);
59
+ }
60
+ console.log('');
61
+ console.log(lang === 'ko'
62
+ ? '금액은 "룰 승격 전 그 유형을 처리하던 모델"과 실제 실행 모델의 가격 차이입니다 (토큰 수는 고정 가정).'
63
+ : 'Each amount is the price gap between the model that handled this category before the rule and the model that actually ran it (token counts held constant).');
64
+ return;
65
+ }
66
+
13
67
  // route-scan rules [rm <N>] — the model-fitting rule registry (rules
14
68
  // promoted from candidates; auto-refreshed from logs on every rescan).
15
69
  if (args[1] === 'rules') {
@@ -274,28 +274,42 @@ export function formatReport(data, { color = true, verbose = false, timer = true
274
274
  ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
275
275
  : null;
276
276
 
277
- // Routing-totals headline line (multi-line layout). Week/month/lifetime
278
- // sums from the delegation ledger — the number the whole tool exists to
279
- // grow, so it gets line 1 to itself while the diagnostics move to line 2.
280
- // icon: "🔀 Routing saved weekly $1.2 · monthly $3.4 · total $9.8"
281
- // text: "Routing saved weekly $1.2 · monthly $3.4 · total $9.8"
277
+ // Routing-savings headline line (multi-line layout). The lifetime sum from
278
+ // the delegation ledger — the number the whole tool exists to grow, so it
279
+ // gets line 1 to itself while the diagnostics move to line 2.
280
+ //
281
+ // One figure, not three. The weekly and monthly sums used to sit here too,
282
+ // but the per-model breakdown that follows is a lifetime split, and next to
283
+ // a row of rolling windows it read as a breakdown of whichever one it
284
+ // touched. A single lifetime total makes the whole line one timeframe with
285
+ // nothing to mismatch.
286
+ // icon: "🔀 Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
287
+ // text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
282
288
  const totals = data.delegationTotals;
283
289
  let totalsLine = null;
284
290
  if (!singleLine && totals && Number(totals.total) > 0) {
285
- // Money is green throughout — it is saved cost, the one number on the
286
- // line that is unambiguously good news. The period markers stay gray so
287
- // the eye lands on the amounts, not on "wk / mo / all".
288
- // Period first, amount second — three bare amounts in a row read as one
289
- // number until the eye finds the trailing marker, so the label leads and
290
- // the green amount answers it.
291
- const part = (usd, label) =>
292
- `${c(GRAY)}${label}${c(RESET)} ${c(GREEN)}${formatMoney(Number(usd) || 0)}${c(RESET)}`;
293
291
  const head = isIcon ? '🔀 Routing saved' : 'Routing saved';
292
+ // Model changes behind the total, family-level and version-free: `opus →
293
+ // haiku 2× $0.6`. Versions bump constantly and add nothing here — the
294
+ // shape of the trade is the point.
295
+ //
296
+ // Every pair is listed, not a top-N: the amounts are shown next to a
297
+ // total, so a truncated list reads as "this is what the total is made of"
298
+ // and quietly misstates it. Families collapse the list on their own —
299
+ // there are only so many tier-to-tier moves — so it stays short without
300
+ // being cut.
301
+ const pairs = Array.isArray(totals.pairs) ? totals.pairs : [];
302
+ // The breakdown stays entirely gray, amounts included. Only the total is
303
+ // green: it is the headline figure, and repeating that green on every
304
+ // component would flatten the line into one loud block with nothing to
305
+ // land on first.
306
+ const pairText = pairs
307
+ .map((p) => `${c(GRAY)}${p.from}→${p.to} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
308
+ .join(` ${c(GRAY)}·${c(RESET)} `);
294
309
  totalsLine =
295
310
  `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
296
- `${part(totals.week, 'weekly')} ${c(GRAY)}·${c(RESET)} ` +
297
- `${part(totals.month, 'monthly')} ${c(GRAY)}·${c(RESET)} ` +
298
- `${part(totals.total, 'total')}`;
311
+ `${c(GREEN)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
312
+ (pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
299
313
  }
300
314
 
301
315
  // Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
@@ -344,8 +344,15 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
344
344
  // it. Sticky once set: as a rule takes effect, fewer episodes stay on the
345
345
  // expensive model, so a recomputed baseline would drift downward and
346
346
  // shrink the very savings the rule is producing.
347
- if (!r.baselineModel && s.baselineModel) {
347
+ //
348
+ // `baselineSource` marks which definition produced it. Baselines written
349
+ // before the definition changed from "priciest model seen" to "model that
350
+ // handled the most episodes" are recomputed once — a single stray record
351
+ // of a pricier model could otherwise hold the baseline above what the
352
+ // category was really running on, inflating every saving priced against it.
353
+ if (s.baselineModel && (!r.baselineModel || r.baselineSource !== 'dominant')) {
348
354
  r.baselineModel = s.baselineModel;
355
+ r.baselineSource = 'dominant';
349
356
  changed = true;
350
357
  }
351
358
  }
package/src/route-scan.js CHANGED
@@ -262,6 +262,24 @@ export function episodeRank(ep) {
262
262
  * net loss. (Replaces the old boolean "is it haiku?" test, which could not
263
263
  * tell a Sonnet session from a Fable one.)
264
264
  */
265
+ /**
266
+ * The model a category was mostly handled by, from a { model → episodes } map.
267
+ * Ties break toward the pricier model: with no majority either way, the more
268
+ * expensive reading of "what this used to cost" is the one worth stating.
269
+ * Returns null for an empty map, which callers read as "no baseline yet".
270
+ */
271
+ export function dominantModel(counts) {
272
+ let best = null;
273
+ let bestN = 0;
274
+ for (const [model, n] of Object.entries(counts || {})) {
275
+ if (n > bestN || (n === bestN && best && modelRank(model) > modelRank(best))) {
276
+ best = model;
277
+ bestN = n;
278
+ }
279
+ }
280
+ return best;
281
+ }
282
+
265
283
  export function worthDelegating(tier, rank) {
266
284
  const target = TIER_TARGET_RANK[tier];
267
285
  return target !== undefined && rank > target;
@@ -379,24 +397,29 @@ export async function runRouteScan({ days = 14 } = {}) {
379
397
  const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
380
398
  let tieredEpisodes = 0;
381
399
  const bumpStats = (key, ep) => {
382
- const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, baselineRank: -1 };
400
+ const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, modelCounts: {} };
383
401
  s.count += 1;
384
402
  s.epCount += 1;
385
403
  if (ep.errors > 0) s.errCount += 1;
386
404
  // Baseline model: what actually handled this category BEFORE any rule sent
387
405
  // it elsewhere. This is the only honest counterfactual for "routing saved
388
406
  // money" — the session's priciest model is not, since it may never have
389
- // touched work of this shape. Episodes counted here are by definition ones
390
- // an expensive model handled directly, so the priciest model seen on them
391
- // is the before-picture the rule replaced.
407
+ // touched work of this shape.
408
+ //
409
+ // Counted, not maxed. A transcript routinely carries more than one model
410
+ // (the user switches mid-session, a compaction pass runs elsewhere), and
411
+ // taking the priciest of them would let a single Fable record set the
412
+ // baseline for a category that Opus handled thirty times — inflating every
413
+ // later saving. The model that handled the most episodes is the one the
414
+ // rule actually replaced; price breaks a tie.
392
415
  for (const m of ep.models) {
393
416
  // Only ids the pricing table really recognizes may become a baseline —
394
417
  // an unresolved gateway id or a house alias would be priced as Sonnet
395
418
  // and quietly rewrite every saving computed against it.
396
419
  if (!isRecognizedModelId(m)) continue;
397
- const r = modelRank(m);
398
- if (r > s.baselineRank) { s.baselineRank = r; s.baselineModel = m; }
420
+ s.modelCounts[m] = (s.modelCounts[m] || 0) + 1;
399
421
  }
422
+ s.baselineModel = dominantModel(s.modelCounts);
400
423
  episodeStats.set(key, s);
401
424
  };
402
425
 
@@ -522,34 +545,13 @@ export async function runRouteScan({ days = 14 } = {}) {
522
545
  // not against whatever the session's priciest model happened to be.
523
546
  const rule = ruleForRun(runTier, cat.id, projectDir);
524
547
  if (!rule) continue; // no rule routed this run — not our saving to claim
525
- const baseline = baselineFor(rule, runTier, cat.id, projectDir);
526
- if (!baseline) continue; // baseline unknown no honest counterfactual
527
- // Both sides of the comparison must be ids the pricing table really
528
- // recognizes. A house alias from a company gateway prices as Sonnet by
529
- // default, which would fabricate a saving against a cheap baseline or
530
- // erase a real one — worse than showing nothing. Map such ids in
531
- // profile-map.json's `modelAliases` to bring these runs back in.
532
- if (!isRecognizedModelId(baseline) || !isRecognizedModelId(run.model)) continue;
533
- const routed = runSaving(run, baseline);
534
- if (routed > 0) {
535
- ledgerEvents.push({
536
- key: run.path,
537
- ts: run.endedAt ?? run.startedAt ?? Date.now(),
538
- usd: routed,
539
- rule: rule.signature,
540
- from: baseline,
541
- to: run.model,
542
- });
543
- }
548
+ // Priced below, after this scan's baselines have been written back to
549
+ // the registry see the note at the ledger write.
550
+ if (!isRecognizedModelId(run.model)) continue;
551
+ ledgerEvents.push({ run, rule, tier: runTier, catId: cat.id, projectDir });
544
552
  }
545
553
  }
546
554
  }
547
- try {
548
- const { recordDelegationEvents } = await import('./savings-ledger.js');
549
- recordDelegationEvents(ledgerEvents);
550
- } catch {
551
- // ledger write failure only delays the statusline totals, never the scan
552
- }
553
555
 
554
556
  // Keep prior dismissed/promoted signatures across rescans.
555
557
  const prev = readRouteScan();
@@ -656,6 +658,40 @@ export async function runRouteScan({ days = 14 } = {}) {
656
658
  refreshModelRules(episodeStats, delegatedStats, { now: cache.scannedAt });
657
659
  } catch { /* registry unwritable — scan result still valid */ }
658
660
 
661
+ // Ledger last, so savings are priced against the baselines this scan just
662
+ // wrote. Pricing before the refresh made a changed baseline take two scans
663
+ // to show up: the first wrote the new baseline but billed against the old
664
+ // one, and the totals only settled on the second.
665
+ try {
666
+ const { recordDelegationEvents } = await import('./savings-ledger.js');
667
+ const { loadModelRules } = await import('./model-rules.js');
668
+ const fresh = loadModelRules().rules;
669
+ const priced = [];
670
+ for (const e of ledgerEvents) {
671
+ const rule = fresh.find((r) => r.signature === e.rule.signature) || e.rule;
672
+ const baseline = baselineFor(rule, e.tier, e.catId, e.projectDir);
673
+ // Both sides of the comparison must be ids the pricing table really
674
+ // recognizes. A house alias from a company gateway prices as Sonnet by
675
+ // default, which would fabricate a saving against a cheap baseline or
676
+ // erase a real one — worse than showing nothing. Map such ids in
677
+ // profile-map.json's `modelAliases` to bring these runs back in.
678
+ if (!baseline || !isRecognizedModelId(baseline)) continue;
679
+ const usd = runSaving(e.run, baseline);
680
+ if (usd <= 0) continue;
681
+ priced.push({
682
+ key: e.run.path,
683
+ ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
684
+ usd,
685
+ rule: rule.signature,
686
+ from: baseline,
687
+ to: e.run.model,
688
+ });
689
+ }
690
+ recordDelegationEvents(priced);
691
+ } catch {
692
+ // ledger write failure only delays the statusline totals, never the scan
693
+ }
694
+
659
695
  return cache;
660
696
  }
661
697
 
@@ -88,11 +88,31 @@ export function recordDelegationEvents(events) {
88
88
  }
89
89
 
90
90
  /**
91
- * Rolling totals: last 7 days, last 30 days, and lifetime. `now` is injectable
92
- * for tests. Never throws — an unreadable ledger yields all-zero totals.
91
+ * Family name of a model id, with the version dropped: `claude-opus-5` and
92
+ * `claude-opus-4-5-20251101-v1:0` both read as `opus`.
93
+ *
94
+ * Versions move constantly, and on a statusline the digits are noise — what
95
+ * the reader wants is the shape of the trade ("opus work now runs on haiku").
96
+ * Falls back to the id itself so an unmapped name is visible rather than
97
+ * silently folded into another family.
98
+ */
99
+ export function modelFamily(model) {
100
+ const m = String(model || '').toLowerCase();
101
+ for (const f of ['fable', 'mythos', 'opus', 'sonnet', 'haiku']) {
102
+ if (m.includes(f)) return f;
103
+ }
104
+ return String(model || '?');
105
+ }
106
+
107
+ /**
108
+ * Rolling totals: last 7 days, last 30 days, and lifetime, plus `pairs` — the
109
+ * lifetime rollup by family-level model change, priciest first. `now` is
110
+ * injectable for tests. Never throws — an unreadable ledger yields zeros.
93
111
  */
94
112
  export function delegationSavedTotals(now = Date.now()) {
95
- const totals = { week: 0, month: 0, total: 0 };
113
+ const empty = () => ({ week: 0, month: 0, total: 0, pairs: [] });
114
+ const totals = empty();
115
+ const byPair = new Map();
96
116
  try {
97
117
  for (const e of Object.values(loadLedger().events)) {
98
118
  const usd = Number(e.usd) || 0;
@@ -102,9 +122,16 @@ export function delegationSavedTotals(now = Date.now()) {
102
122
  if (now - e.ts <= WEEK_MS) totals.week += usd;
103
123
  if (now - e.ts <= MONTH_MS) totals.month += usd;
104
124
  }
125
+ if (!e.from || !e.to) continue;
126
+ const key = `${modelFamily(e.from)}→${modelFamily(e.to)}`;
127
+ const p = byPair.get(key) || { from: modelFamily(e.from), to: modelFamily(e.to), runs: 0, usd: 0 };
128
+ p.runs += 1;
129
+ p.usd += usd;
130
+ byPair.set(key, p);
105
131
  }
106
132
  } catch {
107
- return { week: 0, month: 0, total: 0 };
133
+ return empty();
108
134
  }
135
+ totals.pairs = [...byPair.values()].sort((a, b) => b.usd - a.usd);
109
136
  return totals;
110
137
  }