claude-token-saver 3.0.1 → 3.2.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
@@ -82,7 +82,6 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
82
82
  | `claude-token-saver handoff` | Back work up to `HANDOFF-*.md` before a cap blocks you |
83
83
  | `claude-token-saver mode [keywords...]` | Output config (`icon`/`text`, `en`/`ko`, `1h`–`30d` window, …) |
84
84
  | `claude-token-saver harness ...` | 🅷 Harness management (below) |
85
- | `claude-token-saver frugon` | Export sessions → [frugon](https://github.com/Rodiun/frugon)-compatible JSONL (model-routing savings analysis, below) |
86
85
  | `claude-token-saver route-scan` | Detect recurring easy work on expensive models → propose haiku-delegation ratchet rules (below) |
87
86
  | `claude-token-saver install` | Manually register Skill + statusline |
88
87
 
@@ -138,37 +137,39 @@ The whole point of the ratchet is **one-direction accumulation**. Deleting rules
138
137
  An auto `.bak` is kept, but **the session context that earned the rule its place is not recoverable.**
139
138
  </details>
140
139
 
141
- ## 🔀 frugon integration — "which calls could a cheaper model handle?"
142
140
 
143
- claude-token-saver catches cache/context waste; [frugon](https://github.com/Rodiun/frugon) (a local LLM cost analyzer) covers **model routing** finding calls that never needed your most expensive model. The `frugon` subcommand bridges the two:
141
+ ## 🔀 route-scan"this recurring task could run on a cheaper tier"
144
142
 
145
- ```bash
146
- claude-token-saver frugon # last 30 days → ./frugon-export.jsonl
147
- claude-token-saver frugon --run # export + run frugon analyze immediately
148
- claude-token-saver frugon --days 7 --project myproj --out logs.jsonl
149
- ```
143
+ Analyzes your session history at the **episode (user request) level**, classifies the work your expensive model (opus/fable) keeps doing into **tiers**, and proposes delegation rules. Fully local, zero token cost. Criteria design and evidence: [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md).
150
144
 
151
- - Converts `~/.claude/projects/` transcripts into the OpenAI-compatible JSONL frugon reads. **Analysis is fully local** — no logs or keys leave your machine (same principle frugon holds).
152
- - **Cache-weighted tokens (default):** frugon doesn't know about prompt caching, so raw physical tokens would overstate your spend ~10x. By default the export folds in Anthropic's cache multipliers (read 0.1x · 5m write 1.25x · 1h write 2x) so frugon's dollar figures match your real bill. Use `--raw-tokens` for physical counts.
153
- - Preserves the signals frugon's easy/hard router reads (prompt/completion tokens, conversation depth) plus the last user prompt and reply text for `--measure` quality sampling. Strip text with `--no-content`.
154
- - Install frugon with `pipx install frugon` (if models show as unpriced, run `frugon update`).
145
+ - **T2 haiku**: few calls, tiny output, near-zero mutation, no errors (lookups, pasted-screen Q&A, simple runs)
146
+ - **T1 sonnet**: moderate output, few mutations, ≤1 error (build pipelines, status checks)
147
+ - **T0 stays**: repeated errors, heavy mutation, big output, design/analysis keywords the session model keeps it
155
148
 
156
- ## 🔀 route-scan "this recurring task could run on haiku"
157
-
158
- The practical follow-through of the frugon integration. It applies frugon-style difficulty analysis at the **episode (user request) level** to your session history, finds easy work your expensive model (opus/fable) keeps doing, and proposes promoting it into a haiku-subagent delegation rule. Fully local, zero token cost.
149
+ Signals: call count, output tokens, **mutating tool calls (Edit/Write/Bash)**, **tool errors**, and request text. Output thresholds are auto-calibrated from your own 14-day distribution (fixed constants drift with workload).
159
150
 
160
151
  ```bash
161
- claude-token-saver route-scan # scan (24h cache) + print candidates
162
- claude-token-saver harness promote R1 --project # promote candidate R1 to a ratchet rule
152
+ claude-token-saver route-scan # scan (24h cache) + tiered candidates
153
+ claude-token-saver harness promote R1 --project # promote candidate R1 to a model-fitting rule
163
154
  claude-token-saver route-scan dismiss 1 # not interested — won't resurface
155
+ claude-token-saver route-scan rules # list model-fitting rules (rm <N> to remove)
164
156
  ```
165
157
 
158
+ ### Model-fitting ratchet — a separate file, continuously refreshed
159
+
160
+ Promoted delegation rules never mix with hand-written ratchet rules: they live in a **separate, fully tool-owned file** — `.claude/ratchet-model.md` per project, `~/.claude/ratchet-model.md` for global scope — regenerated wholesale on every scan, and they stay alive afterward:
161
+
162
+ - **Auto-refresh**: every rescan recomputes recurrence counts and the category's error rate from fresh logs and rewrites the file. Your `ratchet.md` is never touched by stat churn, so repos that commit `.claude/` see no diff noise (`ratchet-model.md` is safe to gitignore — it's always regenerable from the registry).
163
+ - **rule-health**: when the delegated category's error rate exceeds 20%, the rule gets a `⚠ rule-health` flag suggesting you narrow or remove it — the "define difficulty by outcome" principle applied to rule lifecycle.
164
+ - Your rules are managed by `harness list/rm`; model-fitting rules by `route-scan rules [rm <N>]` — separate files, separate indexes.
165
+ - The CLAUDE.md ratchet section planted by `harness init` references both files, so Claude applies them together (existing users: re-run `harness init` to refresh the block).
166
+
166
167
  How it works (session-boundary calibration, NOT a real-time router):
167
- 1. `install` registers a SessionStart hook that injects the cached scan results as session context on startup and `/clear` (the scan itself refreshes in the background, once a day).
168
- 2. When a recurring (≥3×) easy pattern exists, the statusline shows a `🅷⚠ route? R1` chip and Claude asks you whether to register it, and at which scope (`--project`/`--global`).
169
- 3. Promoted rules accumulate in ratchet.md, so **from the next session on, the main model delegates that work type to a haiku subagent automatically**.
168
+ 1. `install` registers a SessionStart hook that injects the cached scan results as session context on startup and `/clear`. Rescans are **data-triggered, not time-triggered**: ~5MB of new transcripts since the last scan rescans immediately, a small trickle rescans daily, and no change means no rescan at all (an unchanged-input scan is deterministic). A 1-hour minimum-interval guard applies, and promoting a rule triggers one immediate refresh to establish its stat baseline.
169
+ 2. When a recurring (≥3×) pattern exists, the statusline shows a `🅷⚠ route? R1` chip and Claude asks you whether to register it, and at which scope (`--project`/`--global`).
170
+ 3. Promoted rules make **the main model delegate that work type to a haiku/sonnet subagent automatically from the next session on**.
170
171
 
171
- Recommended companion setup: create `model: haiku` subagents under `~/.claude/agents/` (e.g. haiku-explore / haiku-runner / haiku-translate) so the rules are immediately actionable.
172
+ Recommended companion setup: create `model: haiku` subagents under `~/.claude/agents/` (e.g. haiku-explore / haiku-runner / haiku-translate) plus a `model: sonnet` general worker so the rules are immediately actionable.
172
173
 
173
174
  ## Spike issue codes
174
175
 
@@ -250,6 +251,15 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
250
251
 
251
252
  ## Release notes
252
253
 
254
+ ### v3.2.0 (2026-07-13)
255
+ - **Tier classification (T0/T1/T2)** — route-scan grows from a binary easy/other split into three tiers. New signals: mutating tool calls and tool errors; output thresholds auto-calibrate to the user's own distribution (clamped); a dedicated category for pasted screen/log Q&A; conversational episodes (<100 output tokens) excluded. Design and research evidence in `docs/TIER_CRITERIA.md`.
256
+ - **Model-fitting ratchet separated** — promoted delegation rules live in their own file (`.claude/ratchet-model.md` / `~/.claude/ratchet-model.md`), file-level-separated from hand-written rules, managed via `route-scan rules [rm <N>]`; the harness CLAUDE.md block references both files.
257
+ - **Log-driven auto-refresh + rule-health** — every rescan recomputes each registered rule's recurrence count and error rate (over delegation-shaped episodes) and rewrites the file. An error rate >20% flags the rule with `⚠ rule-health`, suggesting narrowing or removal.
258
+ - **Data-triggered rescans** — the fixed 24h TTL is gone; new transcript volume triggers rescans (~5MB → immediately, a trickle → daily, no change → skip, 1h minimum interval, one immediate refresh after promote).
259
+
260
+ ### v3.1.0 (2026-07-13)
261
+ - **frugon integration removed** — the `claude-token-saver frugon` JSONL-export subcommand is gone. An external analyzer's aggregate report can't be turned into ratchet rules (condition → action), so it never fed the delegation pipeline; 3.x instead invests in **first-party tier classification over session logs**. route-scan is unaffected (the shared parser moved to `src/session-records.js`).
262
+
253
263
  ### v3.0.1 (2026-07-13)
254
264
  - **`harness pull` redefined** — v3.0.0's "copy global ratchet → project" was pointless (the global ratchet already applies to every project as the upper layer of the hierarchy) and is removed. `pull` now registers the **author-curated ratchet rules** bundled with the package (`presets/ratchet-rules.md`) into your global ratchet — six general-purpose rules promoted from real recurring mistakes; opt-in and idempotent.
255
265
 
package/README.md CHANGED
@@ -82,7 +82,6 @@ Claude 안에서 `/claude-token-saver` Skill을 실행하거나 칩 문구를
82
82
  | `claude-token-saver handoff` | 작업 상태를 `HANDOFF-*.md`로 백업 (캡 임박 시) |
83
83
  | `claude-token-saver mode [keywords...]` | 출력 설정 (`icon`/`text`, `ko`/`en`, `1h`~`30d` 윈도 등) |
84
84
  | `claude-token-saver harness ...` | 🅷 Harness 관리 (아래 참고) |
85
- | `claude-token-saver frugon` | 세션 기록 → [frugon](https://github.com/Rodiun/frugon) 호환 JSONL 내보내기 (모델 라우팅 절감 분석, 아래 참고) |
86
85
  | `claude-token-saver route-scan` | 상위 모델이 반복 처리한 easy 작업 감지 → haiku 위임 랫쳇 룰 제안 (아래 참고) |
87
86
  | `claude-token-saver install` | Skill·statusline 수동 등록 |
88
87
 
@@ -119,37 +118,39 @@ ratchet의 가치는 **한 방향 누적**에 있습니다. 룰을 가볍게 지
119
118
  삭제 시 `.bak`이 남지만 **그 룰이 박힌 세션 컨텍스트(왜)는 복원되지 않습니다.**
120
119
  </details>
121
120
 
122
- ## 🔀 frugon 연계 — "어떤 호출을 싼 모델로 내릴 수 있나"
123
121
 
124
- claude-token-saver가 캐시·컨텍스트 낭비를 잡는다면, [frugon](https://github.com/Rodiun/frugon)(로컬 LLM 비용 분석기)은 **모델 라우팅** 절감 굳이 비싼 모델이 필요 없는 호출 찾기 — 을 다룹니다. `frugon` 서브커맨드가 둘을 연결합니다:
122
+ ## 🔀 route-scan"이 반복 작업, 티어로 내려도 됩니다"
125
123
 
126
- ```bash
127
- claude-token-saver frugon # 최근 30일 세션 → ./frugon-export.jsonl
128
- claude-token-saver frugon --run # 내보내기 + frugon analyze 바로 실행
129
- claude-token-saver frugon --days 7 --project myproj --out logs.jsonl
130
- ```
124
+ 세션 기록을 **에피소드(사용자 요청) 단위**로 분석해 상위 모델(opus/fable)이 반복 처리해 온 작업을 **티어로 분류**하고, 위임 룰로 승격하도록 제안합니다. 전 과정 로컬, 토큰 비용 0. 기준 설계와 근거는 [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) 참고.
131
125
 
132
- - `~/.claude/projects/`의 transcript를 frugon이 읽는 OpenAI 호환 JSONL로 변환합니다. **분석은 전부 로컬** 로그도 키도 밖으로 나가지 않습니다 (frugon의 원칙과 동일).
133
- - **캐시 가중 토큰(기본):** frugon은 프롬프트 캐싱을 모르기 때문에 물리 토큰을 그대로 주면 비용이 ~10배 과대평가됩니다. 기본값은 캐시 read 0.1x · 5m write 1.25x · 1h write 2x를 접어 넣은 유효 토큰이라 frugon의 달러 견적이 실제 청구액과 일치합니다. 물리 토큰이 필요하면 `--raw-tokens`.
134
- - frugon의 easy/hard 분류가 쓰는 신호(프롬프트·응답 토큰, 대화 깊이)와 `--measure` 품질 검증에 쓰는 마지막 유저 프롬프트·응답 텍스트를 보존합니다. 텍스트를 빼고 싶으면 `--no-content`.
135
- - frugon 설치: `pipx install frugon` (모델이 unpriced로 나오면 `frugon update`).
126
+ - **T2 haiku**: 적은 호출·작은 출력·변경 거의 없음·에러 0으로 끝난 요청 (탐색·조회, 붙여넣은 화면 질문, 단순 실행 )
127
+ - **T1 sonnet**: 중간 출력·변경 소수·에러 1 (빌드 파이프라인, 상태 점검 등)
128
+ - **T0 유지**: 에러 반복, 대량 변경, 출력, 설계·분석 키워드 세션 모델이 계속 담당
136
129
 
137
- ## 🔀 route-scan "이 반복 작업, haiku로 내려도 됩니다"
138
-
139
- frugon 연계의 실전 버전입니다. frugon식 난이도 분석을 **에피소드(사용자 요청) 단위**로 세션 기록에 적용해, 상위 모델(opus/fable)이 반복 처리해 온 easy 작업을 찾아 haiku 서브에이전트 위임 룰로 승격하도록 제안합니다. 전 과정 로컬, 토큰 비용 0.
130
+ 판정 신호는 호출 수·출력 토큰·**변경성 도구 수(Edit/Write/Bash)**·**도구 에러 수**·요청 텍스트이고, 출력 임계값은 사용자 자신의 최근 14일 분포에서 자동 보정됩니다 (고정 상수는 워크로드가 바뀌면 어긋나기 때문).
140
131
 
141
132
  ```bash
142
- claude-token-saver route-scan # 스캔 (24h 캐시) + 후보 출력
143
- claude-token-saver harness promote R1 --project # 후보 R1을 랫쳇 룰로 등록
133
+ claude-token-saver route-scan # 스캔 (24h 캐시) + 티어별 후보 출력
134
+ claude-token-saver harness promote R1 --project # 후보 R1을 모델 피팅 룰로 등록
144
135
  claude-token-saver route-scan dismiss 1 # 관심 없으면 무시 (재스캔에도 안 뜸)
136
+ claude-token-saver route-scan rules # 등록된 모델 피팅 룰 목록 (rm <N>으로 제거)
145
137
  ```
146
138
 
139
+ ### 모델 피팅 랫쳇 — 사용자 룰과 파일부터 분리, 로그 기반 자동 갱신
140
+
141
+ 승격된 위임 룰은 손으로 쓴 랫쳇 룰과 섞이지 않도록 **별도 파일**에 저장됩니다 — 프로젝트는 `.claude/ratchet-model.md`, 글로벌은 `~/.claude/ratchet-model.md`. 이 파일은 전적으로 도구 소유라 매 스캔마다 통째로 재생성되며, 이후에도 살아 움직입니다:
142
+
143
+ - **자동 갱신**: 매 스캔마다 반복 횟수·해당 유형의 에러율을 최신 로그로 다시 계산해 파일을 재작성합니다. 통계가 바뀌어도 사용자의 `ratchet.md`는 전혀 건드리지 않으므로, `.claude/`를 커밋하는 프로젝트에서도 diff 소음이 없습니다 (`ratchet-model.md`는 gitignore해도 무방 — 레지스트리에서 항상 재생성 가능).
144
+ - **rule-health**: 위임 대상 유형의 에러율이 20%를 넘으면 룰에 `⚠ rule-health` 경고가 붙어 조건을 좁히거나 제거하라고 알립니다 — "결과(outcome)로 난이도를 정의"하는 원칙을 룰 수명 관리에 재적용한 것.
145
+ - 사용자 룰은 `harness list/rm`, 모델 피팅 룰은 `route-scan rules [rm <N>]`로 각각 관리 — 서로의 파일도 인덱스도 침범하지 않습니다.
146
+ - `harness init`이 심는 CLAUDE.md 랫쳇 섹션이 두 파일을 모두 참조하므로 Claude가 세션에서 함께 적용합니다 (기존 사용자는 `harness init` 재실행으로 블록 갱신).
147
+
147
148
  동작 구조 (실시간 라우팅이 아니라 **세션 경계 캘리브레이션**):
148
- 1. `install` 시 SessionStart 훅이 등록되어, 새 세션 시작·`/clear` 때 캐시된 스캔 결과를 세션 컨텍스트로 주입합니다 (스캔 자체는 백그라운드에서 1).
149
- 2. 반복(≥3회) easy 패턴이 있으면 statusline에 `🅷⚠ route? R1` 칩이 뜨고, Claude가 등록 여부와 scope(`--project`/`--global`)를 물어봅니다.
150
- 3. 등록된 룰은 ratchet.md에 쌓여 **다음 세션부터 메인 모델이 해당 유형을 haiku 서브에이전트로 자동 위임**합니다.
149
+ 1. `install` 시 SessionStart 훅이 등록되어, 새 세션 시작·`/clear` 때 캐시된 스캔 결과를 세션 컨텍스트로 주입합니다. 재스캔은 시간이 아니라 **데이터가 트리거**: 마지막 스캔 이후 transcript가 ~5MB 이상 쌓이면 즉시, 소량이면 하루 1회, 아무 변화가 없으면 아예 돌지 않습니다 (변화 없는 재스캔은 결과가 동일하므로). 최소 간격 1시간 가드 포함, 룰 등록(promote) 직후에는 통계 기준선 확보를 위해 즉시 1회.
150
+ 2. 반복(≥3회) 패턴이 있으면 statusline에 `🅷⚠ route? R1` 칩이 뜨고, Claude가 등록 여부와 scope(`--project`/`--global`)를 물어봅니다.
151
+ 3. 등록된 룰은 **다음 세션부터 메인 모델이 해당 유형을 haiku/sonnet 서브에이전트로 자동 위임**하게 합니다.
151
152
 
152
- 권장 사전 준비: `~/.claude/agents/`에 `model: haiku` 서브에이전트(예: haiku-explore / haiku-runner / haiku-translate) 만들어 두면 룰이 바로 실행 가능해집니다.
153
+ 권장 사전 준비: `~/.claude/agents/`에 `model: haiku` 서브에이전트(예: haiku-explore / haiku-runner / haiku-translate) `model: sonnet` 범용 서브에이전트를 만들어 두면 룰이 바로 실행 가능해집니다.
153
154
 
154
155
  ## 토큰 급증 원인 코드
155
156
 
@@ -207,6 +208,15 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
207
208
 
208
209
  ## 릴리스 노트
209
210
 
211
+ ### v3.2.0 (2026-07-13)
212
+ - **티어 분류 (T0/T1/T2)** — route-scan이 이분법(easy/그외)에서 3티어로 진화. 신호에 변경성 도구 수·도구 에러 수 추가, 출력 임계값은 사용자 분포 기반 자동 보정(클램프 포함), 붙여넣은 화면·로그 질문 전용 카테고리 신설, 대화성 응답(출력 <100토큰) 제외. 기준 설계·리서치 근거는 `docs/TIER_CRITERIA.md`.
213
+ - **모델 피팅 랫쳇 분리** — 승격된 위임 룰은 별도 파일(`.claude/ratchet-model.md` / `~/.claude/ratchet-model.md`)에 저장돼 사용자 룰과 파일 단위로 분리. `route-scan rules [rm <N>]`로 관리하며, 하네스 CLAUDE.md 블록이 두 파일을 함께 참조.
214
+ - **로그 기반 자동 갱신 + rule-health** — 매 스캔마다 등록 룰의 반복 횟수·에러율(위임 적격 모양의 에피소드 기준)을 재계산해 파일을 재작성. 에러율 >20%면 `⚠ rule-health` 플래그로 조건 좁히기/제거를 제안.
215
+ - **데이터 트리거 재스캔** — 고정 24h TTL을 폐기하고 신규 transcript 양이 재스캔을 트리거 (~5MB 즉시 / 소량 일 1회 / 무변화 스킵 / 최소 간격 1h / promote 직후 즉시 1회).
216
+
217
+ ### v3.1.0 (2026-07-13)
218
+ - **frugon 연계 제거** — `claude-token-saver frugon` 서브커맨드(JSONL 내보내기)를 삭제했습니다. 외부 분석기의 집계 리포트는 랫쳇 룰(조건→행동)로 변환할 수 없어 위임 파이프라인에 기여하지 못했고, 3.x의 방향은 **세션 로그 기반 티어 분류를 자체적으로 탄탄히** 가져가는 것입니다. route-scan은 영향 없이 그대로 동작합니다 (공용 파서는 `src/session-records.js`로 분리).
219
+
210
220
  ### v3.0.1 (2026-07-13)
211
221
  - **`harness pull` 재정의** — v3.0.0의 "글로벌 랫쳇 → 프로젝트 복사"는 글로벌 랫쳇이 이미 프로젝트의 상위 계층으로 항상 적용되므로 무의미해 제거. `pull`은 이제 패키지에 동봉된 **제작자 큐레이션 랫쳇 룰**(`presets/ratchet-rules.md`)을 사용자의 글로벌 랫쳇에 등록합니다 — 실제 반복 사고에서 승격된 범용 룰 6종, opt-in·멱등.
212
222
 
package/bin/cli.js CHANGED
@@ -10,8 +10,6 @@
10
10
  * npx claude-token-saver --format csv # CSV output
11
11
  * npx claude-token-saver --project myproj # filter by project
12
12
  * npx claude-token-saver route-scan # detect recurring easy work → haiku-delegation candidates
13
- * npx claude-token-saver frugon # export transcripts → frugon JSONL (model-routing analysis)
14
- * npx claude-token-saver frugon --run # export + run `frugon analyze`
15
13
  * npx claude-token-saver --install-hook # install PostToolUse hook
16
14
  * npx claude-token-saver --uninstall-hook # remove hook
17
15
  * npx claude-token-saver --hook-run # internal: called by hook
@@ -480,6 +478,37 @@ async function main() {
480
478
  const { userLanguage } = await import('../src/config.js');
481
479
  const lang = userLanguage();
482
480
 
481
+ // route-scan rules [rm <N>] — the model-fitting rule registry (rules
482
+ // promoted from candidates; auto-refreshed from logs on every rescan).
483
+ if (args[1] === 'rules') {
484
+ const mr = await import('../src/model-rules.js');
485
+ if (args[2] === 'rm') {
486
+ const n = parseInt(args[3], 10);
487
+ const removed = Number.isFinite(n) ? mr.removeModelRule(n) : null;
488
+ if (!removed) {
489
+ console.error('Usage: claude-token-saver route-scan rules rm <N> # N from `route-scan rules`');
490
+ process.exit(1);
491
+ }
492
+ // A target whose last rule was removed gets its (tool-owned) file deleted.
493
+ mr.syncAllFiles({ previousPaths: [mr.modelRatchetPathFor(removed.scope, removed.targetRoot)] });
494
+ console.log(`Removed model-fitting rule #${n}: ${removed.rule}`);
495
+ return;
496
+ }
497
+ const { rules } = mr.loadModelRules();
498
+ if (rules.length === 0) {
499
+ console.log(lang === 'ko' ? '등록된 모델 피팅 룰 없음.' : 'No model-fitting rules registered.');
500
+ return;
501
+ }
502
+ console.log(lang === 'ko' ? '📐 모델 피팅 룰 (로그 기반 자동 갱신):' : '📐 Model-fitting rules (auto-refreshed from logs):');
503
+ rules.forEach((r, i) => {
504
+ const health = r.status === 'review' ? ' ⚠ rule-health' : '';
505
+ console.log(` #${i + 1} [${r.tier}|${r.scope}] ×${r.count || 0} err ${Math.round((r.errRate || 0) * 100)}%${health}`);
506
+ console.log(` ${r.rule}`);
507
+ });
508
+ console.log('\n제거: claude-token-saver route-scan rules rm <N>');
509
+ return;
510
+ }
511
+
483
512
  if (args[1] === 'dismiss') {
484
513
  const n = parseInt(args[2], 10);
485
514
  if (!Number.isFinite(n)) {
@@ -502,7 +531,7 @@ async function main() {
502
531
  // prints delegation-candidate context for the new session.
503
532
  if (hasFlag('--hook')) {
504
533
  let cache = rs.readRouteScan();
505
- if (!rs.isCacheFresh(cache)) {
534
+ if (await rs.shouldRescan(cache)) {
506
535
  try {
507
536
  const { spawn } = await import('node:child_process');
508
537
  spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
@@ -512,9 +541,10 @@ async function main() {
512
541
  const open = rs.openCandidates(cache);
513
542
  if (open.length === 0) return; // silent — nothing to inject
514
543
  const lines = [];
515
- lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 상위 모델(opus/fable)이 처리한 반복 easy 작업이 감지되었습니다:`);
544
+ lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 상위 모델(opus/fable)이 처리한 위임 가능 반복 작업이 감지되었습니다:`);
516
545
  for (const c of open) {
517
- lines.push(` R${c.id} (×${c.count}, ${c.project}): ${c.label} ${c.agent} 위임 권장 (scope 제안: ${c.suggestedScope})`);
546
+ const tierNote = c.tier === 'T1' ? 'sonnet 위임(중간 난도)' : `${c.agent} 위임(경량)`;
547
+ lines.push(` R${c.id} [${c.tier || 'T2'}] (×${c.count}, ${c.project}): ${c.label} → ${tierNote} 권장 (scope 제안: ${c.suggestedScope})`);
518
548
  lines.push(` 예시: "${c.example}"`);
519
549
  }
520
550
  lines.push('이 패턴을 랫쳇 룰로 등록하면 다음 세션부터 자동 위임됩니다. 적절한 시점에 사용자에게 등록 여부와 scope를 물어본 뒤 실행하세요:');
@@ -526,7 +556,7 @@ async function main() {
526
556
 
527
557
  const days = parseFloat(getArg('--days') || '14');
528
558
  let cache = rs.readRouteScan();
529
- if (hasFlag('--refresh') || !rs.isCacheFresh(cache) || (cache && cache.days !== days)) {
559
+ if (hasFlag('--refresh') || (cache && cache.days !== days) || await rs.shouldRescan(cache, { days })) {
530
560
  cache = await rs.runRouteScan({ days });
531
561
  }
532
562
  if (hasFlag('--quiet')) return;
@@ -547,7 +577,7 @@ async function main() {
547
577
  }
548
578
  console.log(lang === 'ko' ? '\n위임 후보:' : '\nDelegation candidates:');
549
579
  for (const c of open) {
550
- console.log(` R${c.id} ×${c.count} ${c.label} → ${c.agent} [${c.project}] (scope 제안: ${c.suggestedScope})`);
580
+ console.log(` R${c.id} [${c.tier || 'T2'}] ×${c.count} ${c.label} → ${c.agent} [${c.project}] (scope 제안: ${c.suggestedScope})`);
551
581
  console.log(` 예시: "${c.example}"`);
552
582
  console.log(` 룰: ${c.rule}`);
553
583
  }
@@ -558,75 +588,6 @@ async function main() {
558
588
  return;
559
589
  }
560
590
 
561
- // Subcommand: frugon — export Claude Code transcripts to the JSONL format
562
- // frugon (local LLM cost analyzer, github.com/Rodiun/frugon) analyzes, so
563
- // users can see which calls could route to a cheaper model.
564
- // claude-token-saver frugon # export last 30 days → ./frugon-export.jsonl
565
- // claude-token-saver frugon --days 7 # narrower window
566
- // claude-token-saver frugon --project myproj # filter by project dir substring
567
- // claude-token-saver frugon --out PATH # custom output path
568
- // claude-token-saver frugon --raw-tokens # physical token counts (no cache weighting)
569
- // claude-token-saver frugon --no-content # strip prompt/reply text (counts only)
570
- // claude-token-saver frugon --run # run `frugon analyze` on the export
571
- if (args[0] === 'frugon') {
572
- const { exportFrugonLogs } = await import('../src/frugon-export.js');
573
- const { userLanguage } = await import('../src/config.js');
574
- const lang = userLanguage();
575
- const days = parseFloat(getArg('--days') || '30');
576
- const outPath = getArg('--out') || 'frugon-export.jsonl';
577
- const cacheWeighted = !hasFlag('--raw-tokens');
578
- const includeContent = !hasFlag('--no-content');
579
- const res = await exportFrugonLogs({
580
- days,
581
- projectFilter: getArg('--project') || undefined,
582
- outPath,
583
- cacheWeighted,
584
- includeContent,
585
- });
586
- if (res.records === 0) {
587
- console.log(lang === 'ko'
588
- ? `최근 ${days}일 내 세션 기록이 없습니다 (~/.claude/projects).`
589
- : `No session records in the last ${days} days (~/.claude/projects).`);
590
- return;
591
- }
592
- console.log(lang === 'ko'
593
- ? `frugon 로그 내보내기 완료: ${res.outPath}`
594
- : `frugon log export complete: ${res.outPath}`);
595
- console.log(` ${res.records} calls / ${res.sessions} sessions / last ${days}d`);
596
- const byModel = Object.entries(res.models).sort((a, b) => b[1] - a[1]);
597
- for (const [model, count] of byModel) console.log(` ${model}: ${count}`);
598
- console.log(cacheWeighted
599
- ? (lang === 'ko'
600
- ? ' prompt_tokens는 캐시 가중치 적용값 (read 0.1x, 5m write 1.25x, 1h write 2x) — frugon 비용이 실제 청구액과 일치. 해제: --raw-tokens'
601
- : ' prompt_tokens are cache-weighted (read 0.1x, 5m write 1.25x, 1h write 2x) so frugon costs match your real bill. Disable: --raw-tokens')
602
- : (lang === 'ko'
603
- ? ' prompt_tokens는 물리 토큰 수 (캐시 가중치 없음 — frugon 비용이 실제보다 크게 나옴)'
604
- : ' prompt_tokens are raw physical counts (no cache weighting — frugon will overstate cost)'));
605
- if (hasFlag('--run')) {
606
- const { spawnSync } = await import('node:child_process');
607
- console.log('');
608
- const run = spawnSync('frugon', ['analyze', res.outPath], { stdio: 'inherit' });
609
- if (run.error && run.error.code === 'ENOENT') {
610
- console.error(lang === 'ko'
611
- ? 'frugon이 PATH에 없습니다. 설치: pipx install frugon (또는 pip install frugon)'
612
- : 'frugon not found on PATH. Install: pipx install frugon (or pip install frugon)');
613
- process.exit(1);
614
- }
615
- if (typeof run.status === 'number' && run.status !== 0) process.exit(run.status);
616
- return;
617
- }
618
- console.log('');
619
- console.log(lang === 'ko' ? '다음 단계:' : 'Next step:');
620
- console.log(` frugon analyze ${res.outPath}`);
621
- console.log(lang === 'ko'
622
- ? ' (frugon 미설치 시: pipx install frugon — 분석은 전부 로컬에서 실행됩니다)'
623
- : ' (if frugon is not installed: pipx install frugon — analysis runs fully local)');
624
- console.log(lang === 'ko'
625
- ? ' (unpriced 모델이 나오면: frugon update 로 가격표를 갱신하세요)'
626
- : ' (if models show as unpriced: run `frugon update` to refresh the pricing table)');
627
- return;
628
- }
629
-
630
591
  // Subcommand: harness — manage the project's CLAUDE.md harness rules.
631
592
  // claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
632
593
  // claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
@@ -792,31 +753,63 @@ async function main() {
792
753
  process.exit(1);
793
754
  }
794
755
  }
795
- // Route candidates were detected in a specific project's sessions — a
796
- // --project rule must land in THAT project's ratchet.md, not the cwd's.
797
- // The candidate carries the real session cwd (projectPath); older cached
798
- // scans predate that field, so fall back to verifying the cwd matches.
799
- let promoteRoot;
800
- if (routeCandidate && scope === 'project') {
756
+ // Route candidates become MODEL-FITTING rules: they live in a
757
+ // tool-managed block (separate from user-authored ratchet rules) and
758
+ // keep updating from subsequent logs recurrence counts, error rates,
759
+ // rule-health on every rescan.
760
+ if (routeCandidate) {
801
761
  const rs = await import('../src/route-scan.js');
802
- if (routeCandidate.projectPath) {
803
- promoteRoot = findProjectRoot(routeCandidate.projectPath);
804
- } else if (rs.mungeProjectPath(findProjectRoot()) !== routeCandidate.project) {
805
- console.error(`Route candidate R${routeCandidateId} was detected in another project (${routeCandidate.project}),`);
806
- console.error('but this cached scan predates project-path tracking.');
807
- console.error('Re-scan to capture it, then promote again:');
808
- console.error(' claude-token-saver route-scan --refresh');
809
- process.exit(1);
762
+ const mr = await import('../src/model-rules.js');
763
+ // A --project rule must land in THE project the pattern was detected
764
+ // in, not the cwd's. Old caches without projectPath: verify cwd match.
765
+ let targetRoot = null;
766
+ if (scope === 'project') {
767
+ if (routeCandidate.projectPath) {
768
+ targetRoot = findProjectRoot(routeCandidate.projectPath);
769
+ } else if (rs.mungeProjectPath(findProjectRoot()) === routeCandidate.project) {
770
+ targetRoot = findProjectRoot();
771
+ } else {
772
+ console.error(`Route candidate R${routeCandidateId} was detected in another project (${routeCandidate.project}),`);
773
+ console.error('but this cached scan predates project-path tracking.');
774
+ console.error('Re-scan to capture it, then promote again:');
775
+ console.error(' claude-token-saver route-scan --refresh');
776
+ process.exit(1);
777
+ }
810
778
  }
779
+ const entry = mr.addModelRule({
780
+ signature: routeCandidate.signature,
781
+ tier: routeCandidate.tier || 'T2',
782
+ category: routeCandidate.category,
783
+ label: routeCandidate.label,
784
+ agent: routeCandidate.agent,
785
+ scope,
786
+ targetRoot,
787
+ project: routeCandidate.project,
788
+ rule: routeCandidate.rule,
789
+ example: routeCandidate.example,
790
+ count: routeCandidate.count,
791
+ promotedAt: new Date().toISOString().slice(0, 10),
792
+ lastSeen: new Date().toISOString().slice(0, 10),
793
+ });
794
+ const written = mr.syncAllFiles();
795
+ rs.resolveCandidate(routeCandidateId);
796
+ console.log(`Model-fitting rule registered [${scope}${targetRoot ? ` → ${targetRoot}` : ''}] (tier ${entry.tier}):`);
797
+ console.log(` - ${entry.rule}`);
798
+ for (const p of written) console.log(` ratchet-model.md updated: ${p}`);
799
+ console.log('(route candidate R' + routeCandidateId + ' resolved — 다음 세션부터 자동 위임, 이후 스캔마다 로그 기반 갱신됩니다)');
800
+ console.log('룰 목록/제거: claude-token-saver route-scan rules [rm <N>]');
801
+ // Event-triggered refresh: establish the new rule's stat baseline
802
+ // right away instead of waiting for the next data-gated rescan.
803
+ try {
804
+ const { spawn } = await import('node:child_process');
805
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
806
+ { detached: true, stdio: 'ignore' }).unref();
807
+ } catch { /* baseline arrives on the next gated rescan instead */ }
808
+ return;
811
809
  }
812
- const r = harnessPromote(rule, promoteRoot ? { scope, root: promoteRoot } : { scope });
810
+ const r = harnessPromote(rule, { scope });
813
811
  console.log(`Appended to ${r.path} [${r.scope}]:`);
814
812
  console.log(` - ${rule}`);
815
- if (routeCandidateId !== null) {
816
- const rs = await import('../src/route-scan.js');
817
- rs.resolveCandidate(routeCandidateId);
818
- console.log(`(route candidate R${routeCandidateId} resolved — 다음 세션부터 자동 위임 룰로 적용됩니다)`);
819
- }
820
813
  if (/^\d+$/.test(raw)) {
821
814
  console.log('\n👉 ratchet.md를 열어 TODO 부분을 실제 룰로 다듬어주세요.');
822
815
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,10 @@ ${HARNESS_SECTIONS[0].heading}
37
37
  non-TTY라 CLI의 readline 프롬프트가 안 뜨므로, 호출자(LLM)가 직접 묻고
38
38
  플래그를 명시해야 함. 묻지 않고 기본값으로 등록하지 말 것.
39
39
  - 승인된 룰은 다음 세션부터 자동 적용.
40
+ - **모델 피팅 랫쳇**: \`.claude/ratchet-model.md\`(프로젝트)와
41
+ \`~/.claude/ratchet-model.md\`(글로벌)에 있는 티어 위임 룰도 ratchet.md와
42
+ 동일하게 따를 것. 이 파일은 claude-token-saver가 로그 기반으로 자동
43
+ 생성·갱신하므로 직접 수정하지 말 것 (관리: \`route-scan rules\`).
40
44
 
41
45
  ${HARNESS_SECTIONS[1].heading}
42
46
  완료 보고("다 됐어요", "테스트 통과") 시 다음 중 1개 이상을 항상 첨부:
package/src/harness.js CHANGED
@@ -344,7 +344,8 @@ export function harnessListRules({ root = findProjectRoot(), scope = 'project' }
344
344
  for (let i = 0; i < lines.length; i++) {
345
345
  const line = lines[i];
346
346
  // A "rule line" starts with "- " (markdown bullet). Header lines, blanks,
347
- // and the "## Rules" anchor are ignored.
347
+ // and the "## Rules" anchor are ignored. (Model-fitting rules live in a
348
+ // separate tool-owned file, ratchet-model.md — never listed here.)
348
349
  if (/^\s*-\s+/.test(line)) {
349
350
  rules.push({ index: rules.length + 1, lineNo: i, text: line.replace(/^\s*-\s+/, '') });
350
351
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * model-rules — the MODEL-FITTING ratchet registry.
3
+ *
4
+ * Model-fitting rules (tier-delegation rules promoted from route-scan) are
5
+ * managed SEPARATELY from user-authored ratchet rules, for two reasons the
6
+ * user set as requirements:
7
+ * 1. They must never tangle with hand-written rules — so they live in a
8
+ * fully tool-owned FILE (ratchet-model.md) next to ratchet.md,
9
+ * regenerated wholesale. A separate file (rather than a managed block
10
+ * inside ratchet.md) keeps auto-refresh churn out of the user's file:
11
+ * per-scan stat updates only ever touch ratchet-model.md, which can be
12
+ * gitignored, and there are no block markers a hand edit could corrupt.
13
+ * 2. They must keep updating from subsequent logs — recurrence counts and
14
+ * post-promotion error rates are refreshed on every route-scan, and a
15
+ * rule whose delegated episodes start failing gets flagged for review
16
+ * (rule-health, per docs/TIER_CRITERIA.md).
17
+ *
18
+ * Registry file (source of truth): <stateDir>/model-rules.json
19
+ * { rules: [ { signature, tier, category, label, agent, scope, // 'project'|'global'
20
+ * targetRoot, // project root path (project scope)
21
+ * rule, example, count, errRate, promotedAt, lastSeen,
22
+ * status } ] } // 'active' | 'review'
23
+ *
24
+ * Rendered files (regenerated from the registry, never edited in place):
25
+ * project scope → <root>/.claude/ratchet-model.md
26
+ * global scope → ~/.claude/ratchet-model.md
27
+ * The harness CLAUDE.md block points Claude at these files alongside
28
+ * ratchet.md.
29
+ */
30
+
31
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
32
+ import { join, dirname } from 'node:path';
33
+ import { homedir } from 'node:os';
34
+
35
+ // Post-promotion delegated-category error rate above this flags the rule
36
+ // for review (rule-health). Calibrated against local T0 avg error incidence.
37
+ export const HEALTH_ERR_RATE = 0.2;
38
+
39
+ function stateDir() {
40
+ if (process.platform === 'win32') {
41
+ return join(process.env.APPDATA || homedir(), 'claude-token-saver');
42
+ }
43
+ if (process.platform === 'darwin') {
44
+ return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
45
+ }
46
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
47
+ return join(xdg, 'claude-token-saver');
48
+ }
49
+
50
+ export function modelRulesPath() {
51
+ return join(stateDir(), 'model-rules.json');
52
+ }
53
+
54
+ export function loadModelRules() {
55
+ try {
56
+ const data = JSON.parse(readFileSync(modelRulesPath(), 'utf8'));
57
+ return Array.isArray(data.rules) ? data : { rules: [] };
58
+ } catch {
59
+ return { rules: [] };
60
+ }
61
+ }
62
+
63
+ export function saveModelRules(data) {
64
+ const dir = stateDir();
65
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
66
+ writeFileSync(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
67
+ }
68
+
69
+ /** Add (or re-activate) a promoted rule; returns the stored entry. */
70
+ export function addModelRule(entry) {
71
+ const data = loadModelRules();
72
+ const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope);
73
+ if (existing) {
74
+ Object.assign(existing, entry, { status: 'active' });
75
+ saveModelRules(data);
76
+ return existing;
77
+ }
78
+ const stored = { status: 'active', errRate: 0, ...entry };
79
+ data.rules.push(stored);
80
+ saveModelRules(data);
81
+ return stored;
82
+ }
83
+
84
+ export function removeModelRule(index1) {
85
+ const data = loadModelRules();
86
+ if (index1 < 1 || index1 > data.rules.length) return null;
87
+ const [removed] = data.rules.splice(index1 - 1, 1);
88
+ saveModelRules(data);
89
+ return removed;
90
+ }
91
+
92
+ /** Render the full ratchet-model.md for one target (scope+root). */
93
+ export function renderModelRatchet(rules) {
94
+ const lines = [
95
+ '# Model-Fitting Ratchet (claude-token-saver 자동 관리)',
96
+ '',
97
+ '로그 기반 티어 위임 룰. 이 파일은 route-scan이 매 스캔마다 통째로 재생성하므로',
98
+ '직접 수정하지 마세요 — 목록/제거: `claude-token-saver route-scan rules [rm <N>]`.',
99
+ '',
100
+ '## Rules',
101
+ '',
102
+ ];
103
+ for (const r of rules) {
104
+ const health = r.status === 'review'
105
+ ? ` ⚠ rule-health: 최근 위임 대상 에러율 ${Math.round((r.errRate || 0) * 100)}% — 조건을 좁히거나 제거 검토`
106
+ : '';
107
+ const stats = ` <!-- ×${r.count || 0}, err ${Math.round((r.errRate || 0) * 100)}%, seen ${r.lastSeen || r.promotedAt} -->`;
108
+ lines.push(`- ${r.rule}${health}${stats}`);
109
+ }
110
+ return lines.join('\n') + '\n';
111
+ }
112
+
113
+ export function modelRatchetPathFor(scope, targetRoot) {
114
+ return scope === 'global'
115
+ ? join(homedir(), '.claude', 'ratchet-model.md')
116
+ : join(targetRoot, '.claude', 'ratchet-model.md');
117
+ }
118
+
119
+ /**
120
+ * Regenerate ratchet-model.md for every target that carries model rules.
121
+ * A target whose rules are all gone gets its file removed (it's fully
122
+ * tool-owned, so deletion is safe).
123
+ */
124
+ export function syncAllFiles({ previousPaths = [] } = {}) {
125
+ const data = loadModelRules();
126
+ const byPath = new Map();
127
+ for (const r of data.rules) {
128
+ const p = modelRatchetPathFor(r.scope, r.targetRoot);
129
+ if (!byPath.has(p)) byPath.set(p, []);
130
+ byPath.get(p).push(r);
131
+ }
132
+ const written = [];
133
+ for (const [p, rules] of byPath) {
134
+ try {
135
+ mkdirSync(dirname(p), { recursive: true });
136
+ writeFileSync(p, renderModelRatchet(rules));
137
+ written.push(p);
138
+ } catch { /* unwritable target — skip, registry stays authoritative */ }
139
+ }
140
+ for (const p of previousPaths) {
141
+ if (!byPath.has(p) && existsSync(p)) {
142
+ try { unlinkSync(p); } catch { /* leave stale file; regenerated next sync */ }
143
+ }
144
+ }
145
+ return written;
146
+ }
147
+
148
+ /**
149
+ * Continuous update from logs (route-scan calls this on every refresh):
150
+ * for each registered rule, recompute recurrence count and the error rate
151
+ * of episodes in its (tier-eligible) category — the rule-health signal.
152
+ *
153
+ * `episodeStats`: Map "category|project" → { count, errCount, epCount }
154
+ * where errCount/epCount measure post-promotion delegated-category episodes.
155
+ */
156
+ export function refreshModelRules(episodeStats, { now } = {}) {
157
+ const data = loadModelRules();
158
+ let changed = false;
159
+ for (const r of data.rules) {
160
+ const s = episodeStats.get(`${r.category}|${r.project}`)
161
+ || (r.scope === 'global' ? episodeStats.get(`${r.category}|*`) : null);
162
+ if (!s) continue;
163
+ r.count = s.count;
164
+ r.errRate = s.epCount > 0 ? s.errCount / s.epCount : 0;
165
+ r.lastSeen = now || r.lastSeen;
166
+ r.status = r.errRate > HEALTH_ERR_RATE ? 'review' : 'active';
167
+ changed = true;
168
+ }
169
+ if (changed) {
170
+ saveModelRules(data);
171
+ syncAllFiles();
172
+ }
173
+ return data;
174
+ }