claude-token-saver 2.18.0 → 3.0.1

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,6 +82,8 @@ 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
+ | `claude-token-saver route-scan` | Detect recurring easy work on expensive models → propose haiku-delegation ratchet rules (below) |
85
87
  | `claude-token-saver install` | Manually register Skill + statusline |
86
88
 
87
89
  Switch output language with `mode ko` / `mode en` (English default; statusline chips stay symbolic).
@@ -114,11 +116,14 @@ claude-token-saver harness init # this project
114
116
  claude-token-saver harness init --global # ~/.claude/CLAUDE.md — every project
115
117
  claude-token-saver harness check # current score (global fallback honored)
116
118
  claude-token-saver harness promote <N> --project|--global # warning #N → ratchet rule (scope required)
119
+ claude-token-saver harness promote "<rule text>" --project|--global # register your own hand-written rules the same way
120
+ claude-token-saver harness pull # register the package's curated ratchet rules into your global ratchet (opt-in, dedupes)
117
121
  claude-token-saver harness list / rm <N> # view / delete rules (auto .bak)
118
122
  claude-token-saver harness off | on # toggle the 🅷 chip
119
123
  ```
120
124
 
121
125
  - `promote` **requires** `--project`/`--global` in non-TTY contexts (scripts, LLM calls) — a scope choice is never silently made for the caller.
126
+ - `pull` registers the **author-curated ratchet rules** bundled with the package (`presets/ratchet-rules.md` — only general-purpose rules promoted from real recurring mistakes) into your global ratchet (`~/.claude/ratchet.md`). `install`/`init` never auto-inject anything; `pull` is always opt-in and idempotent. Drop any rule you dislike with `harness rm`.
122
127
  - 🅷⚠ runtime warnings (`ratchet?` `no-evidence` `PEV-skip`) expire after 30 minutes, subdirectory sessions match their project correctly, and PEV-skip counts only mutating tools (Edit/Write/Bash) so read-only research sessions don't trip it (v2.16.0+).
123
128
 
124
129
  <details>
@@ -133,6 +138,38 @@ The whole point of the ratchet is **one-direction accumulation**. Deleting rules
133
138
  An auto `.bak` is kept, but **the session context that earned the rule its place is not recoverable.**
134
139
  </details>
135
140
 
141
+ ## 🔀 frugon integration — "which calls could a cheaper model handle?"
142
+
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:
144
+
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
+ ```
150
+
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`).
155
+
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.
159
+
160
+ ```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
163
+ claude-token-saver route-scan dismiss 1 # not interested — won't resurface
164
+ ```
165
+
166
+ 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**.
170
+
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
+
136
173
  ## Spike issue codes
137
174
 
138
175
  | Code | Meaning |
@@ -213,6 +250,20 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
213
250
 
214
251
  ## Release notes
215
252
 
253
+ ### v3.0.1 (2026-07-13)
254
+ - **`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
+
256
+ ### v3.0.0 (2026-07-13)
257
+ - **Major bump** — with v2.19's frugon integration and v2.20's route-scan, the product's character shifted from "after-the-fact token monitor" to "a routing layer that pushes recurring easy work down to cheaper models", so this ships as a major. No breaking changes (every existing command and setting remains compatible).
258
+ - **route-scan promote fix** — `harness promote R<N> --project` now writes the rule into the `.claude/ratchet.md` of the project the candidate was **detected in** (previously it landed in whatever directory the CLI ran from). The scan stores each candidate's real session path (`projectPath`); promoting a foreign-project candidate from a pre-3.0 cache without that field is refused with a pointer to `route-scan --refresh`.
259
+ - **New `harness pull`** — redefined in v3.0.1 (see above).
260
+
261
+ ### v2.20.0 (2026-07-13)
262
+ - **route-scan**: detect recurring easy work on expensive models → `🅷⚠ route? R<N>` chip + SessionStart hook context injection + `harness promote R<N> --project|--global` to promote haiku-delegation ratchet rules.
263
+
264
+ ### v2.19.0 (2026-07-12)
265
+ - **frugon integration**: `claude-token-saver frugon` — export session transcripts as [frugon](https://github.com/Rodiun/frugon)-compatible JSONL for model-routing savings analysis (`--run` to analyze immediately; cache-weighted tokens by default).
266
+
216
267
  ### v2.18.0 (2026-07-02)
217
268
  - **1M-context warning re-scoped** — current models (Fable 5, Opus 4.6–4.8, Sonnet 5) all default to a 1M window with no long-context premium since Opus 4.7, so the "1M mode ON = expensive" framing is retired. The warning is now a **usage signal**: `⚠ 1M ON` → `⚠ Ctx 200k+` (a single request actually exceeded 200k), and remediation is reordered to lead with `/compact`/`/clear` + `/effort` instead of "disable 1M". The incorrect "long-context pricing kicks in past 200k" copy is fixed.
218
269
  - **📦 segment shows live usage** — reads `context_window.used_percentage` from Claude Code's stdin and renders `📦 Ctx 68% of 1M`, colored by fill (green <70 / yellow 70–89 / red 90+). Falls back to transcript-inferred size when stdin is absent (1M now yellow, not red).
package/README.md CHANGED
@@ -82,6 +82,8 @@ 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
+ | `claude-token-saver route-scan` | 상위 모델이 반복 처리한 easy 작업 감지 → haiku 위임 랫쳇 룰 제안 (아래 참고) |
85
87
  | `claude-token-saver install` | Skill·statusline 수동 등록 |
86
88
 
87
89
  출력 언어는 `mode ko` / `mode en`으로 전환합니다 (기본 영어, statusline 칩은 항상 기호). 전체 옵션은 [영문 README](./README.en.md#options) 참고.
@@ -95,11 +97,14 @@ claude-token-saver harness init # 이 프로젝트에 셋업
95
97
  claude-token-saver harness init --global # ~/.claude/CLAUDE.md — 모든 프로젝트 적용
96
98
  claude-token-saver harness check # 현재 점수 (글로벌 fallback 인정)
97
99
  claude-token-saver harness promote <N> --project|--global # 경고 #N → ratchet 룰 (스코프 필수)
100
+ claude-token-saver harness promote "<룰 텍스트>" --project|--global # 내가 직접 정의한 룰도 같은 명령으로 등록
101
+ claude-token-saver harness pull # 패키지 동봉 큐레이션 룰 → 내 글로벌 랫쳇에 등록 (opt-in, 중복 스킵)
98
102
  claude-token-saver harness list / rm <N> # 룰 조회 / 삭제 (자동 .bak)
99
103
  claude-token-saver harness off | on # 🅷 표시 토글
100
104
  ```
101
105
 
102
106
  - `promote`는 non-TTY(스크립트·LLM 호출)에서 `--project`/`--global` 플래그가 **필수** — 스코프가 묻지 않고 결정되는 사고를 막기 위한 설계입니다.
107
+ - `pull`은 패키지에 동봉된 **제작자 큐레이션 랫쳇 룰**(`presets/ratchet-rules.md` — 실제 반복 사고에서 승격된 범용 룰만)을 내 글로벌 랫쳇(`~/.claude/ratchet.md`)에 등록합니다. 설치(`install`)나 `init`은 아무것도 자동 주입하지 않으며, `pull`은 항상 opt-in이고 재실행해도 중복이 없습니다(멱등). 마음에 안 드는 룰은 `harness rm`으로 제거하면 됩니다.
103
108
  - 🅷⚠ 런타임 경고(`ratchet?` `no-evidence` `PEV-skip`)는 30분 후 자동 만료되고, 하위 디렉터리 세션도 프로젝트에 올바르게 매칭됩니다. PEV-skip은 변경성 도구(Edit/Write/Bash)만 카운트해 읽기 위주 세션에서는 발동하지 않습니다 (v2.16.0+).
104
109
 
105
110
  <details>
@@ -114,6 +119,38 @@ ratchet의 가치는 **한 방향 누적**에 있습니다. 룰을 가볍게 지
114
119
  삭제 시 `.bak`이 남지만 **그 룰이 박힌 세션 컨텍스트(왜)는 복원되지 않습니다.**
115
120
  </details>
116
121
 
122
+ ## 🔀 frugon 연계 — "어떤 호출을 싼 모델로 내릴 수 있나"
123
+
124
+ claude-token-saver가 캐시·컨텍스트 낭비를 잡는다면, [frugon](https://github.com/Rodiun/frugon)(로컬 LLM 비용 분석기)은 **모델 라우팅** 절감 — 굳이 비싼 모델이 필요 없는 호출 찾기 — 을 다룹니다. `frugon` 서브커맨드가 둘을 연결합니다:
125
+
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
+ ```
131
+
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`).
136
+
137
+ ## 🔀 route-scan — "이 반복 작업, haiku로 내려도 됩니다"
138
+
139
+ frugon 연계의 실전 버전입니다. frugon식 난이도 분석을 **에피소드(사용자 요청) 단위**로 세션 기록에 적용해, 상위 모델(opus/fable)이 반복 처리해 온 easy 작업을 찾아 haiku 서브에이전트 위임 룰로 승격하도록 제안합니다. 전 과정 로컬, 토큰 비용 0.
140
+
141
+ ```bash
142
+ claude-token-saver route-scan # 스캔 (24h 캐시) + 후보 출력
143
+ claude-token-saver harness promote R1 --project # 후보 R1을 랫쳇 룰로 등록
144
+ claude-token-saver route-scan dismiss 1 # 관심 없으면 무시 (재스캔에도 안 뜸)
145
+ ```
146
+
147
+ 동작 구조 (실시간 라우팅이 아니라 **세션 경계 캘리브레이션**):
148
+ 1. `install` 시 SessionStart 훅이 등록되어, 새 세션 시작·`/clear` 때 캐시된 스캔 결과를 세션 컨텍스트로 주입합니다 (스캔 자체는 백그라운드에서 일 1회).
149
+ 2. 반복(≥3회) easy 패턴이 있으면 statusline에 `🅷⚠ route? R1` 칩이 뜨고, Claude가 등록 여부와 scope(`--project`/`--global`)를 물어봅니다.
150
+ 3. 등록된 룰은 ratchet.md에 쌓여 **다음 세션부터 메인 모델이 해당 유형을 haiku 서브에이전트로 자동 위임**합니다.
151
+
152
+ 권장 사전 준비: `~/.claude/agents/`에 `model: haiku` 서브에이전트(예: haiku-explore / haiku-runner / haiku-translate)를 만들어 두면 룰이 바로 실행 가능해집니다.
153
+
117
154
  ## 토큰 급증 원인 코드
118
155
 
119
156
  | 코드 | 의미 |
@@ -170,6 +207,20 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
170
207
 
171
208
  ## 릴리스 노트
172
209
 
210
+ ### v3.0.1 (2026-07-13)
211
+ - **`harness pull` 재정의** — v3.0.0의 "글로벌 랫쳇 → 프로젝트 복사"는 글로벌 랫쳇이 이미 프로젝트의 상위 계층으로 항상 적용되므로 무의미해 제거. `pull`은 이제 패키지에 동봉된 **제작자 큐레이션 랫쳇 룰**(`presets/ratchet-rules.md`)을 사용자의 글로벌 랫쳇에 등록합니다 — 실제 반복 사고에서 승격된 범용 룰 6종, opt-in·멱등.
212
+
213
+ ### v3.0.0 (2026-07-13)
214
+ - **메이저 승격** — v2.19 frugon 연계 + v2.20 route-scan으로 "사후 토큰 모니터링 도구"에서 "반복 easy 작업을 싼 모델로 내려보내는 라우팅 계층"으로 제품 성격이 바뀌어 메이저 버전을 올립니다. Breaking change는 없습니다 (기존 명령·설정 전부 호환).
215
+ - **route-scan promote 교정** — `harness promote R<N> --project`가 이제 후보가 **감지된 프로젝트**의 `.claude/ratchet.md`에 룰을 기록합니다 (이전에는 CLI를 실행한 디렉터리에 기록되는 버그). 스캔이 후보에 실제 세션 경로(`projectPath`)를 저장하며, 이 필드가 없는 구버전 캐시에서 다른 프로젝트 후보를 승격하려 하면 `route-scan --refresh`를 안내하고 중단합니다.
216
+ - **`harness pull` 신설** — v3.0.1에서 재정의됨 (위 참고).
217
+
218
+ ### v2.20.0 (2026-07-13)
219
+ - **route-scan**: 상위 모델이 반복 처리한 easy 작업 감지 → `🅷⚠ route? R<N>` 칩 + SessionStart 훅 컨텍스트 주입 + `harness promote R<N> --project|--global`로 haiku 위임 랫쳇 룰 승격.
220
+
221
+ ### v2.19.0 (2026-07-12)
222
+ - **frugon 연계**: `claude-token-saver frugon` — 세션 transcript를 [frugon](https://github.com/Rodiun/frugon) 호환 JSONL로 내보내 모델 라우팅 절감 분석 (`--run`으로 즉시 분석, 캐시 가중 토큰 기본).
223
+
173
224
  ### v2.18.0 (2026-07-02)
174
225
  - **1M 컨텍스트 경고 의미 재정의** — 현재 모델(Fable 5, Opus 4.6~4.8, Sonnet 5)은 전부 1M 윈도가 기본이고 Opus 4.7부터 장기 컨텍스트 프리미엄도 없어, "1M 모드 ON = 비쌈" 프레임을 폐기했습니다. 경고는 이제 **실사용 신호**입니다: `⚠ 1M ON` → `⚠ Ctx 200k+`(단일 요청이 실제로 200k 초과), 처방도 "1M 끄기" 우선에서 "`/compact`/`/clear` + `/effort` 점검" 우선으로 재정렬. 잘못된 "200k 초과 시 장기 요금 적용" 문구 정정.
175
226
  - **📦 세그먼트가 실시간 사용률 표시** — Claude Code stdin의 `context_window.used_percentage`를 사용해 `📦 Ctx 68% of 1M` 형태로 렌더 (사용률 기준 녹 <70 / 황 70–89 / 적 90+). stdin이 없으면 기존 크기 추론으로 폴백하되 1M은 빨강 대신 노랑.
package/bin/cli.js CHANGED
@@ -9,6 +9,9 @@
9
9
  * npx claude-token-saver --format json # JSON output
10
10
  * npx claude-token-saver --format csv # CSV output
11
11
  * npx claude-token-saver --project myproj # filter by project
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`
12
15
  * npx claude-token-saver --install-hook # install PostToolUse hook
13
16
  * npx claude-token-saver --uninstall-hook # remove hook
14
17
  * npx claude-token-saver --hook-run # internal: called by hook
@@ -398,6 +401,7 @@ async function main() {
398
401
  };
399
402
  const r = installAll({ force });
400
403
  print('skill', r.skill);
404
+ print('SessionStart hook (route-scan)', r.sessionStartHook);
401
405
  {
402
406
  const s = r.statusline;
403
407
  const verb = s.action === 'exists' ? 'already configured (refreshInterval=5)'
@@ -462,11 +466,173 @@ async function main() {
462
466
  return;
463
467
  }
464
468
 
469
+ // Subcommand: route-scan — detect recurring easy work on expensive models
470
+ // and propose model-delegation ratchet rules. Zero token cost, fully local.
471
+ // claude-token-saver route-scan # scan (24h cache) + print candidates
472
+ // claude-token-saver route-scan --refresh # force rescan
473
+ // claude-token-saver route-scan --days 30 # wider lookback
474
+ // claude-token-saver route-scan --hook # SessionStart hook mode (context injection)
475
+ // claude-token-saver route-scan dismiss <N> # mute candidate R<N>
476
+ // Promote a candidate to a ratchet rule (scope is always explicit):
477
+ // claude-token-saver harness promote R<N> --project|--global
478
+ if (args[0] === 'route-scan') {
479
+ const rs = await import('../src/route-scan.js');
480
+ const { userLanguage } = await import('../src/config.js');
481
+ const lang = userLanguage();
482
+
483
+ if (args[1] === 'dismiss') {
484
+ const n = parseInt(args[2], 10);
485
+ if (!Number.isFinite(n)) {
486
+ console.error('Usage: claude-token-saver route-scan dismiss <N> # N from `route? R<N>`');
487
+ process.exit(1);
488
+ }
489
+ const cand = rs.resolveCandidate(n);
490
+ if (!cand) {
491
+ console.error(`No route candidate R${n}. Run: claude-token-saver route-scan`);
492
+ process.exit(1);
493
+ }
494
+ console.log(lang === 'ko'
495
+ ? `R${n} 무시 처리: ${cand.label} (${cand.project}) — 재스캔에도 다시 뜨지 않습니다.`
496
+ : `Dismissed R${n}: ${cand.label} (${cand.project}) — won't resurface on rescans.`);
497
+ return;
498
+ }
499
+
500
+ // --hook: SessionStart hook mode. Never scans inline (session start must
501
+ // stay fast) — reads the cache, kicks a detached refresh when stale, and
502
+ // prints delegation-candidate context for the new session.
503
+ if (hasFlag('--hook')) {
504
+ let cache = rs.readRouteScan();
505
+ if (!rs.isCacheFresh(cache)) {
506
+ try {
507
+ const { spawn } = await import('node:child_process');
508
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
509
+ { detached: true, stdio: 'ignore' }).unref();
510
+ } catch { /* refresh is best-effort; stale cache still usable below */ }
511
+ }
512
+ const open = rs.openCandidates(cache);
513
+ if (open.length === 0) return; // silent — nothing to inject
514
+ const lines = [];
515
+ lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 상위 모델(opus/fable)이 처리한 반복 easy 작업이 감지되었습니다:`);
516
+ for (const c of open) {
517
+ lines.push(` R${c.id} (×${c.count}, ${c.project}): ${c.label} → ${c.agent} 위임 권장 (scope 제안: ${c.suggestedScope})`);
518
+ lines.push(` 예시: "${c.example}"`);
519
+ }
520
+ lines.push('이 패턴을 랫쳇 룰로 등록하면 다음 세션부터 자동 위임됩니다. 적절한 시점에 사용자에게 등록 여부와 scope를 물어본 뒤 실행하세요:');
521
+ lines.push(' claude-token-saver harness promote R<N> --project|--global # scope는 반드시 사용자에게 확인');
522
+ lines.push(' claude-token-saver route-scan dismiss <N> # 사용자가 원치 않으면');
523
+ console.log(lines.join('\n'));
524
+ return;
525
+ }
526
+
527
+ const days = parseFloat(getArg('--days') || '14');
528
+ let cache = rs.readRouteScan();
529
+ if (hasFlag('--refresh') || !rs.isCacheFresh(cache) || (cache && cache.days !== days)) {
530
+ cache = await rs.runRouteScan({ days });
531
+ }
532
+ if (hasFlag('--quiet')) return;
533
+ if (hasFlag('--json')) {
534
+ console.log(JSON.stringify(cache, null, 2));
535
+ return;
536
+ }
537
+ const easyPct = cache.totalEpisodes ? Math.round(cache.easyEpisodes / cache.totalEpisodes * 100) : 0;
538
+ console.log(lang === 'ko'
539
+ ? `route-scan — 최근 ${cache.days}일: 에피소드 ${cache.totalEpisodes}건 중 easy ${cache.easyEpisodes}건 (${easyPct}%) [스캔: ${cache.scannedAt}]`
540
+ : `route-scan — last ${cache.days}d: ${cache.easyEpisodes}/${cache.totalEpisodes} episodes easy (${easyPct}%) [scanned: ${cache.scannedAt}]`);
541
+ const open = rs.openCandidates(cache);
542
+ if (open.length === 0) {
543
+ console.log(lang === 'ko'
544
+ ? '위임 후보 없음 (반복 3회 미만이거나 이미 처리됨).'
545
+ : 'No delegation candidates (below recurrence threshold or already resolved).');
546
+ return;
547
+ }
548
+ console.log(lang === 'ko' ? '\n위임 후보:' : '\nDelegation candidates:');
549
+ for (const c of open) {
550
+ console.log(` R${c.id} ×${c.count} ${c.label} → ${c.agent} [${c.project}] (scope 제안: ${c.suggestedScope})`);
551
+ console.log(` 예시: "${c.example}"`);
552
+ console.log(` 룰: ${c.rule}`);
553
+ }
554
+ console.log('');
555
+ console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
556
+ console.log(' claude-token-saver harness promote R<N> --project|--global');
557
+ console.log(' claude-token-saver route-scan dismiss <N>');
558
+ return;
559
+ }
560
+
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
+
465
630
  // Subcommand: harness — manage the project's CLAUDE.md harness rules.
466
631
  // claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
467
632
  // claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
468
633
  // claude-token-saver harness check # show 🅷 N/5 + which sections are missing
469
634
  // claude-token-saver harness promote "<rule>" # append a rule to ratchet.md
635
+ // claude-token-saver harness pull [--global|--project] # register the package's curated preset rules (default global)
470
636
  // claude-token-saver harness off | on # toggle the statusline 🅷 segment
471
637
  if (args[0] === 'harness') {
472
638
  const sub = args[1];
@@ -485,7 +651,7 @@ async function main() {
485
651
  }
486
652
  return dflt;
487
653
  };
488
- const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessListRules, harnessRmRule, findProjectRoot } =
654
+ const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, findProjectRoot } =
489
655
  await import('../src/harness.js');
490
656
  const { HARNESS_SECTIONS } = await import('../src/harness-templates.js');
491
657
  const { loadConfig, saveConfig } = await import('../src/config.js');
@@ -579,6 +745,28 @@ async function main() {
579
745
  }
580
746
  rule = `반복 감지 ×${cand.count}: ${cand.pattern} — TODO: 원인·예방책 한 줄로`;
581
747
  }
748
+ // R-prefixed arg → route-scan delegation candidate (statusline `route? R<N>`).
749
+ // The rule text is pre-generated by the scan; promoting also resolves the
750
+ // candidate so the chip stops and rescans don't resurface it.
751
+ let routeCandidateId = null;
752
+ let routeCandidate = null;
753
+ if (/^[Rr]\d+$/.test(raw)) {
754
+ const n = parseInt(raw.slice(1), 10);
755
+ const rs = await import('../src/route-scan.js');
756
+ const cand = (rs.openCandidates(rs.readRouteScan()) || []).find((c) => c.id === n);
757
+ if (!cand) {
758
+ console.error(`No open route candidate R${n}. Run: claude-token-saver route-scan`);
759
+ process.exit(1);
760
+ }
761
+ rule = cand.rule;
762
+ routeCandidateId = n;
763
+ routeCandidate = cand;
764
+ if (!scope) {
765
+ console.error(`Route candidate R${n} requires an explicit scope (suggested: --${cand.suggestedScope}).`);
766
+ console.error('Ask the user, then pass --project or --global.');
767
+ process.exit(1);
768
+ }
769
+ }
582
770
  // Scope resolution: explicit flag wins. Otherwise prompt interactively
583
771
  // when running on a TTY; in non-TTY (CI/scripts) require an explicit
584
772
  // flag so the choice is never silently made for the caller.
@@ -604,9 +792,31 @@ async function main() {
604
792
  process.exit(1);
605
793
  }
606
794
  }
607
- const r = harnessPromote(rule, { scope });
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') {
801
+ 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);
810
+ }
811
+ }
812
+ const r = harnessPromote(rule, promoteRoot ? { scope, root: promoteRoot } : { scope });
608
813
  console.log(`Appended to ${r.path} [${r.scope}]:`);
609
814
  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
+ }
610
820
  if (/^\d+$/.test(raw)) {
611
821
  console.log('\n👉 ratchet.md를 열어 TODO 부분을 실제 룰로 다듬어주세요.');
612
822
  }
@@ -659,6 +869,25 @@ async function main() {
659
869
  return;
660
870
  }
661
871
 
872
+ if (sub === 'pull') {
873
+ // Register the package's curated preset rules (presets/ratchet-rules.md)
874
+ // into the user's ratchet — global by default (they're tool/environment
875
+ // rules, and a project ratchet inherits global anyway). Strictly opt-in:
876
+ // install/init never auto-injects rules.
877
+ const scope = parseHarnessScope(args.slice(2), 'global');
878
+ const r = harnessPull({ scope });
879
+ console.log(`Curated preset rules → ${r.path} [${r.scope}]`);
880
+ if (r.added.length) {
881
+ console.log(`✅ ${r.added.length}/${r.presets} rule(s) registered:`);
882
+ for (const t of r.added) console.log(` - ${t}`);
883
+ } else {
884
+ console.log(`No new rules — all ${r.presets} presets already registered.`);
885
+ }
886
+ if (r.skippedRules && r.added.length) console.log(` (${r.skippedRules} already present — skipped)`);
887
+ console.log('\n필요 없는 룰은 언제든: claude-token-saver harness list / rm <N>');
888
+ return;
889
+ }
890
+
662
891
  if (sub === 'list' || sub === 'ls') {
663
892
  const wantGlobal = hasFlag('--global');
664
893
  const wantProject = hasFlag('--project') || !wantGlobal;
@@ -722,7 +951,7 @@ async function main() {
722
951
  }
723
952
 
724
953
  console.error(`Unknown harness subcommand: ${sub}`);
725
- console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|list|rm <N>|off|on]');
954
+ console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|pull [--global|--project]|list|rm <N>|off|on]');
726
955
  process.exit(1);
727
956
  }
728
957
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "2.18.0",
3
+ "version": "3.0.1",
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": {
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "bin/",
14
14
  "src/",
15
+ "presets/",
15
16
  "examples/",
16
17
  "README.md",
17
18
  "README.en.md"
@@ -0,0 +1,16 @@
1
+ # claude-token-saver 추천 Ratchet 룰 (curated presets)
2
+
3
+ 이 파일은 패키지에 동봉되는 제작자 큐레이션 랫쳇 룰입니다.
4
+ `claude-token-saver harness pull`을 실행하면 아래 룰이 사용자의
5
+ 글로벌 랫쳇(`~/.claude/ratchet.md`)에 등록됩니다 (opt-in, 중복 자동 스킵).
6
+
7
+ 실제 반복 실수에서 승격된 룰만 담습니다 — 이론이 아니라 사고 이력.
8
+
9
+ ## Rules
10
+
11
+ - 웹 fetch/search에서 robots·UA 차단('unable to fetch' 등)이 발생하면 → curl에 브라우저 UA(Mozilla/5.0 ... Chrome/...) 헤더를 붙여 재시도. reddit 등은 .json 엔드포인트 + UA 조합으로 접근 가능
12
+ - ratchet 룰 promote 호출 직전 사용자에게 scope(project/global)를 먼저 묻는다 — Bash 환경은 non-TTY라 CLI readline 프롬프트가 안 뜨므로, 호출자(LLM)가 대신 묻고 --project/--global 플래그를 명시해 실행
13
+ - 자동화 도구가 grep·정규식으로 찾는 마커 텍스트(헤더·앵커·키워드 라인)는 리팩터링·압축·수정 금지 — 정확 매칭 검출은 한 글자만 바뀌어도 깨짐
14
+ - 자동화 설계에서 vision/screenshot 루프가 보이면 → 호출 가능한 함수·API·MCP tool부터 찾는다. 통제 가능한 내부 도구는 무조건 API, 통제 불가 외부 SaaS만 vision 차선책. 인터페이스를 바꾼 뒤에 모델을 내린다 — 거꾸로 가면 토큰 폭발
15
+ - 입력 데이터(ground-truth) 변경 시 그에 의존하는 모든 파생 자산을 끝까지 재생성한다 — 중간 단계를 생략하면 stale 산출물이 새 입력 위에 얹혀 결과가 깨짐
16
+ - 탐색·조회·상태 확인·명령 실행 같은 단순 요청은 haiku 서브에이전트(haiku-explore·haiku-runner 등)로 위임해 상위 모델 토큰을 아낀다
@@ -0,0 +1,211 @@
1
+ /**
2
+ * frugon export — convert Claude Code session transcripts into the
3
+ * OpenAI-compatible JSONL log format frugon analyzes.
4
+ * (frugon: local LLM cost analyzer — github.com/Rodiun/frugon)
5
+ *
6
+ * One output line per API call:
7
+ * {
8
+ * "model": "claude-opus-4-8",
9
+ * "timestamp": "2026-07-12T02:11:05.123Z",
10
+ * "usage": { "prompt_tokens": 1234, "completion_tokens": 56 },
11
+ * "request": { "messages": [ ...stubs..., { "role": "user", "content": "<last user prompt>" } ] },
12
+ * "response": { "choices": [ { "message": { "role": "assistant", "content": "<reply>" } } ] }
13
+ * }
14
+ *
15
+ * Design notes (kept in sync with frugon 0.2.x internals):
16
+ * - frugon prefers the usage block for token counts, so message content is
17
+ * never re-tokenized — stubs with empty content are safe.
18
+ * - frugon's easy/hard difficulty score reads prompt_tokens, completion_tokens
19
+ * and conversation depth (len(messages) - 1, saturating at 6 turns). We emit
20
+ * up to MAX_STUB_MESSAGES role-alternating stubs so depth survives the
21
+ * export without duplicating the whole conversation into every record.
22
+ * - frugon has no notion of prompt caching: every prompt token is priced at
23
+ * the base input rate. Claude Code sessions are cache-read heavy (~90%+),
24
+ * so raw totals would overstate spend ~10x. By default we fold Anthropic's
25
+ * cache multipliers (5m write 1.25x, 1h write 2x, read 0.1x) into an
26
+ * "effective" prompt_tokens so frugon's dollar figures match reality.
27
+ * Pass cacheWeighted: false for raw physical token counts.
28
+ */
29
+
30
+ import { createReadStream, createWriteStream } from 'node:fs';
31
+ import { createInterface } from 'node:readline';
32
+ import { discoverSessionFiles } from './parser.js';
33
+
34
+ // Depth cap: frugon's turn signal saturates at 6 turns (len(messages)-1 >= 6),
35
+ // so 7 messages carry the maximum-depth signal at minimum size.
36
+ const MAX_STUB_MESSAGES = 7;
37
+
38
+ // Anthropic cache multipliers relative to the base input rate — uniform
39
+ // across model tiers (see src/cost.js PRICING).
40
+ const CACHE_WEIGHTS = { write5m: 1.25, write1h: 2, read: 0.1 };
41
+
42
+ /** Strip context-window suffixes like "[1m]" so frugon's pricing table matches. */
43
+ export function normalizeModelId(model) {
44
+ return String(model || 'unknown').replace(/\[[^\]]*\]$/, '');
45
+ }
46
+
47
+ /**
48
+ * Effective prompt tokens: what the call *costs* expressed in base-rate
49
+ * input tokens, so frugon (which prices all prompt tokens at the input rate)
50
+ * reproduces the real cache-discounted spend.
51
+ */
52
+ export function effectivePromptTokens(r) {
53
+ const tracked = (r.ephemeral5mTokens || 0) + (r.ephemeral1hTokens || 0);
54
+ const untracked = Math.max(0, (r.cacheCreationTokens || 0) - tracked);
55
+ return Math.round(
56
+ (r.inputTokens || 0) +
57
+ ((r.ephemeral5mTokens || 0) + untracked) * CACHE_WEIGHTS.write5m +
58
+ (r.ephemeral1hTokens || 0) * CACHE_WEIGHTS.write1h +
59
+ (r.cacheReadTokens || 0) * CACHE_WEIGHTS.read,
60
+ );
61
+ }
62
+
63
+ /** Raw physical prompt tokens (input + cache writes + cache reads). */
64
+ export function rawPromptTokens(r) {
65
+ return (r.inputTokens || 0) + (r.cacheCreationTokens || 0) + (r.cacheReadTokens || 0);
66
+ }
67
+
68
+ /** Extract plain text from a Claude transcript message content field. */
69
+ function contentText(content) {
70
+ if (typeof content === 'string') return content;
71
+ if (!Array.isArray(content)) return '';
72
+ return content
73
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
74
+ .map((b) => b.text)
75
+ .join('\n');
76
+ }
77
+
78
+ /**
79
+ * Parse one session transcript into frugon records.
80
+ * Deduplicates by requestId (last-write-wins, matching parser.js) while
81
+ * tracking the conversation depth and last user prompt at each call.
82
+ */
83
+ export async function collectSessionRecords(filePath, { cacheWeighted = true, includeContent = true } = {}) {
84
+ const records = new Map();
85
+ let depth = 0;
86
+ let lastUserText = '';
87
+ let lastCwd = '';
88
+
89
+ const rl = createInterface({
90
+ input: createReadStream(filePath, { encoding: 'utf8' }),
91
+ crlfDelay: Infinity,
92
+ });
93
+
94
+ for await (const line of rl) {
95
+ let entry;
96
+ try {
97
+ entry = JSON.parse(line);
98
+ } catch {
99
+ continue;
100
+ }
101
+
102
+ const msg = entry.message;
103
+ if (typeof entry.cwd === 'string' && entry.cwd) lastCwd = entry.cwd;
104
+ if (entry.type === 'user' && msg) {
105
+ depth += 1;
106
+ const text = contentText(msg.content);
107
+ if (text) lastUserText = text;
108
+ continue;
109
+ }
110
+ if (entry.type !== 'assistant' || !msg) continue;
111
+ depth += 1;
112
+
113
+ if (!msg.usage || !msg.id) continue;
114
+ // "<synthetic>" is Claude Code's placeholder for locally-generated
115
+ // entries (e.g. error stubs) — no real API call, nothing to price.
116
+ if (msg.model === '<synthetic>') continue;
117
+ const usage = msg.usage;
118
+ const reqId = entry.requestId || msg.id;
119
+ const r = {
120
+ inputTokens: usage.input_tokens || 0,
121
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
122
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
123
+ ephemeral5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens || 0,
124
+ ephemeral1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens || 0,
125
+ outputTokens: usage.output_tokens || 0,
126
+ };
127
+
128
+ records.set(reqId, {
129
+ model: normalizeModelId(msg.model),
130
+ timestamp: entry.timestamp || null,
131
+ prompt_tokens: cacheWeighted ? effectivePromptTokens(r) : rawPromptTokens(r),
132
+ completion_tokens: r.outputTokens,
133
+ depth,
134
+ userText: includeContent ? lastUserText : '',
135
+ assistantText: includeContent ? contentText(msg.content) : '',
136
+ cwd: lastCwd,
137
+ });
138
+ }
139
+
140
+ return [...records.values()];
141
+ }
142
+
143
+ /** Build the frugon JSONL object for one collected record. */
144
+ export function toFrugonRecord(rec) {
145
+ const msgCount = Math.max(1, Math.min(rec.depth, MAX_STUB_MESSAGES));
146
+ const messages = [];
147
+ for (let i = 0; i < msgCount - 1; i++) {
148
+ messages.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: '' });
149
+ }
150
+ messages.push({ role: 'user', content: rec.userText || '' });
151
+
152
+ const out = {
153
+ model: rec.model,
154
+ request: { messages },
155
+ response: {
156
+ choices: [{ message: { role: 'assistant', content: rec.assistantText || '' } }],
157
+ },
158
+ usage: {
159
+ prompt_tokens: rec.prompt_tokens,
160
+ completion_tokens: rec.completion_tokens,
161
+ },
162
+ };
163
+ if (rec.timestamp) out.timestamp = rec.timestamp;
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Export Claude Code transcripts to a frugon-compatible JSONL file.
169
+ *
170
+ * @param {object} options
171
+ * days lookback window (default 30)
172
+ * projectFilter substring match on the project dir name
173
+ * outPath output JSONL path
174
+ * cacheWeighted fold cache pricing into prompt_tokens (default true)
175
+ * includeContent include user prompt / assistant reply text (default true)
176
+ * @returns {Promise<{records:number, sessions:number, models:Object, outPath:string}>}
177
+ */
178
+ export async function exportFrugonLogs({
179
+ days = 30,
180
+ projectFilter,
181
+ outPath,
182
+ cacheWeighted = true,
183
+ includeContent = true,
184
+ } = {}) {
185
+ const files = await discoverSessionFiles({ days, projectFilter });
186
+ const models = {};
187
+ let recordCount = 0;
188
+ let sessionCount = 0;
189
+
190
+ const stream = createWriteStream(outPath, { encoding: 'utf8' });
191
+ for (const f of files) {
192
+ let recs;
193
+ try {
194
+ recs = await collectSessionRecords(f.path, { cacheWeighted, includeContent });
195
+ } catch {
196
+ continue;
197
+ }
198
+ if (recs.length === 0) continue;
199
+ sessionCount += 1;
200
+ for (const rec of recs) {
201
+ stream.write(JSON.stringify(toFrugonRecord(rec)) + '\n');
202
+ models[rec.model] = (models[rec.model] || 0) + 1;
203
+ recordCount += 1;
204
+ }
205
+ }
206
+ await new Promise((resolve, reject) => {
207
+ stream.end((err) => (err ? reject(err) : resolve()));
208
+ });
209
+
210
+ return { records: recordCount, sessions: sessionCount, models, outPath };
211
+ }
package/src/harness.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  harnessRatchetMdInitial,
20
20
  appendRatchetRule,
21
21
  } from './harness-templates.js';
22
+ import { routeWarningForStatusline } from './route-scan.js';
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
  function readHarnessState() {
@@ -276,6 +277,61 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
276
277
  return { path: rmPath, root, scope };
277
278
  }
278
279
 
280
+ /**
281
+ * harness pull — register the CURATED ratchet rules bundled with this package
282
+ * (presets/ratchet-rules.md) into the user's ratchet, global by default.
283
+ *
284
+ * Rationale: a project ratchet already inherits the global one (global is the
285
+ * upper layer of the hierarchy), so there is nothing to copy between the
286
+ * user's own scopes. What CAN'T reach the user any other way is the package
287
+ * author's field-tested rules — pull ships those, strictly opt-in:
288
+ * install/init never auto-injects anything.
289
+ *
290
+ * Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
291
+ * Returns { path, scope, added, skippedRules, presets }.
292
+ */
293
+ export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {}) {
294
+ const presets = presetRules();
295
+ const rmPath = resolveRatchetPath(scope, root);
296
+ const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };
297
+ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
298
+
299
+ let content = existsSync(rmPath)
300
+ ? readFileSync(rmPath, 'utf8')
301
+ : harnessRatchetMdInitial();
302
+ const have = new Set(
303
+ harnessListRules({ root, scope }).rules.map((r) => stripDate(r.text)),
304
+ );
305
+ for (const rule of presets) {
306
+ if (have.has(rule)) {
307
+ result.skippedRules += 1;
308
+ continue;
309
+ }
310
+ content = appendRatchetRule(content, rule);
311
+ have.add(rule);
312
+ result.added.push(rule);
313
+ }
314
+ if (result.added.length) {
315
+ mkdirSync(dirname(rmPath), { recursive: true });
316
+ writeFileSync(rmPath, content);
317
+ }
318
+ return result;
319
+ }
320
+
321
+ /** Parse the bundled preset rules (markdown bullets under presets/). */
322
+ export function presetRules() {
323
+ try {
324
+ const path = join(dirname(new URL(import.meta.url).pathname), '..', 'presets', 'ratchet-rules.md');
325
+ return readFileSync(path, 'utf8')
326
+ .split('\n')
327
+ .filter((l) => /^\s*-\s+/.test(l))
328
+ .map((l) => l.replace(/^\s*-\s+/, '').trim())
329
+ .filter(Boolean);
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
279
335
  /**
280
336
  * harness list — return numbered ratchet rules from .claude/ratchet.md.
281
337
  * Numbering is 1-based and matches `harness rm <N>`.
@@ -362,6 +418,14 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
362
418
  else if (state.pevSkip) warning = 'PEV-skip';
363
419
  }
364
420
  }
421
+ // Lowest precedence: route-scan delegation candidate (`route? R<N>`).
422
+ // Session-quality warnings above always win — routing is an optimization
423
+ // nudge, not a correctness signal. Cheap: one small cached-JSON read.
424
+ if (!warning) {
425
+ try {
426
+ warning = routeWarningForStatusline(projectRoot);
427
+ } catch { /* scan cache unreadable — stay silent */ }
428
+ }
365
429
  return { ...status, warning };
366
430
  }
367
431
 
package/src/installer.js CHANGED
@@ -202,10 +202,47 @@ export function installStatusline({ force = false } = {}) {
202
202
  return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
203
203
  }
204
204
 
205
+ // Registers the SessionStart hook that surfaces route-scan delegation
206
+ // candidates as session context (startup + /clear). Idempotent: skips when a
207
+ // claude-token-saver route-scan hook is already present; never touches other
208
+ // hooks the user configured.
209
+ const ROUTE_SCAN_HOOK_COMMAND = 'claude-token-saver route-scan --hook';
210
+
211
+ export function installSessionStartHook() {
212
+ const dir = claudeUserDir();
213
+ const file = join(dir, 'settings.json');
214
+ mkdirSync(dir, { recursive: true });
215
+
216
+ let settings = {};
217
+ if (existsSync(file)) {
218
+ try {
219
+ settings = JSON.parse(readFileSync(file, 'utf8'));
220
+ } catch (e) {
221
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
222
+ }
223
+ }
224
+
225
+ settings.hooks = settings.hooks || {};
226
+ const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
227
+ const already = list.some((m) =>
228
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('route-scan --hook')),
229
+ );
230
+ if (already) return { path: file, action: 'exists' };
231
+
232
+ list.push({
233
+ matcher: 'startup|clear',
234
+ hooks: [{ type: 'command', command: ROUTE_SCAN_HOOK_COMMAND, timeout: 10 }],
235
+ });
236
+ settings.hooks.SessionStart = list;
237
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
238
+ return { path: file, action: 'created' };
239
+ }
240
+
205
241
  export function installAll({ force = false } = {}) {
206
242
  return {
207
243
  skill: installSkill({ force }),
208
244
  statusline: installStatusline({ force }),
245
+ sessionStartHook: installSessionStartHook(),
209
246
  legacy: removeLegacyCommand(),
210
247
  };
211
248
  }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * route-scan — detect recurring "easy" work running on expensive models and
3
+ * propose model-delegation ratchet rules.
4
+ *
5
+ * Runs the frugon-style difficulty idea at the EPISODE level (one user
6
+ * request = the consecutive API calls it triggered), because per-call scoring
7
+ * saturates on Claude Code's large session contexts. An episode is easy when
8
+ * the whole request finished in few calls with little generation — exactly
9
+ * the work a haiku subagent could take.
10
+ *
11
+ * Fully local, zero token cost. Results are cached (24h) so the SessionStart
12
+ * hook can read them without re-parsing a month of transcripts.
13
+ *
14
+ * Pipeline position (per design discussion): frugon/this scan is NOT a
15
+ * real-time router — it is a session-boundary calibrator that feeds the
16
+ * existing ratchet promote flow (`harness promote R<N> --project|--global`).
17
+ */
18
+
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { discoverSessionFiles } from './parser.js';
23
+ import { collectSessionRecords } from './frugon-export.js';
24
+
25
+ // Episode is "easy" when the whole user request finished within these bounds.
26
+ // Calibrated on real data (2026-07): 27% of episodes, 3-6% of tokens.
27
+ export const EASY_MAX_CALLS = 6;
28
+ export const EASY_MAX_OUT_TOKENS = 1500;
29
+ // A pattern must recur this often before we nag about it.
30
+ export const MIN_RECURRENCE = 3;
31
+ // Cache is fresh for a day — the SessionStart hook never rescans inline.
32
+ export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
33
+
34
+ // Category → recommended haiku subagent. First match wins; order matters
35
+ // (translate before read: "번역해줘" also matches the read keywords).
36
+ const CATEGORIES = [
37
+ {
38
+ id: 'translate',
39
+ label: '배치 번역·정형 텍스트 변환',
40
+ agent: 'haiku-translate',
41
+ re: /번역|translate|변환해|표로 정리|포맷팅/i,
42
+ },
43
+ {
44
+ id: 'explore',
45
+ label: '탐색·조회 (파일/값 찾기)',
46
+ agent: 'haiku-explore',
47
+ re: /grep|검색|찾아|search|find|어디|위치|목록|살펴/i,
48
+ },
49
+ {
50
+ id: 'read',
51
+ label: '읽기·요약·설명',
52
+ agent: 'haiku-explore',
53
+ re: /읽어|요약|설명|정리해|summar|explain|보여줘|알려줘|뭐야|what/i,
54
+ },
55
+ {
56
+ id: 'run',
57
+ label: '명령 실행 (빌드·테스트·git)',
58
+ agent: 'haiku-runner',
59
+ re: /git |commit|push|실행|돌려|run |build|빌드|테스트|npm |pip|설치/i,
60
+ },
61
+ {
62
+ id: 'check',
63
+ label: '상태 확인·검증',
64
+ agent: 'haiku-explore',
65
+ re: /확인|맞아\?|되나|됐나|괜찮|체크|check|verify|status|점검/i,
66
+ },
67
+ ];
68
+
69
+ // Episodes that are not user-delegable requests: bare continuations, injected
70
+ // notifications, image pastes. These are easy but there is nothing to route.
71
+ const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
72
+ const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
73
+
74
+ function stateDir() {
75
+ if (process.platform === 'win32') {
76
+ return join(process.env.APPDATA || homedir(), 'claude-token-saver');
77
+ }
78
+ if (process.platform === 'darwin') {
79
+ return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
80
+ }
81
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
82
+ return join(xdg, 'claude-token-saver');
83
+ }
84
+
85
+ export function routeScanCachePath() {
86
+ return join(stateDir(), 'route-scan.json');
87
+ }
88
+
89
+ /** Munge an absolute path the way Claude Code names project dirs. */
90
+ export function mungeProjectPath(p) {
91
+ return String(p).replace(/[^a-zA-Z0-9-]/g, '-');
92
+ }
93
+
94
+ function categorize(text) {
95
+ for (const c of CATEGORIES) if (c.re.test(text)) return c;
96
+ return null;
97
+ }
98
+
99
+ function isSkippable(text) {
100
+ if (!text) return true;
101
+ if (SKIP_RE.test(text.trim())) return true;
102
+ return SKIP_PREFIX.some((p) => text.startsWith(p));
103
+ }
104
+
105
+ /** Group a session's records into episodes (consecutive same trigger prompt). */
106
+ function toEpisodes(records) {
107
+ const episodes = [];
108
+ let cur = null;
109
+ for (const r of records) {
110
+ const text = (r.userText || '').trim();
111
+ if (!cur || cur.text !== text) {
112
+ cur = { text, calls: 0, out: 0, models: new Set(), cwd: '' };
113
+ episodes.push(cur);
114
+ }
115
+ cur.calls += 1;
116
+ cur.out += r.completion_tokens;
117
+ cur.models.add(r.model);
118
+ if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
119
+ }
120
+ return episodes;
121
+ }
122
+
123
+ function isExpensiveModel(model) {
124
+ return !/haiku/i.test(model);
125
+ }
126
+
127
+ /**
128
+ * Scan transcripts and build delegation candidates.
129
+ * Returns the cache object (also written to disk).
130
+ */
131
+ export async function runRouteScan({ days = 14 } = {}) {
132
+ const files = await discoverSessionFiles({ days });
133
+ const groups = new Map(); // "category|project" → aggregate
134
+ let totalEpisodes = 0;
135
+ let easyEpisodes = 0;
136
+
137
+ for (const f of files) {
138
+ let records;
139
+ try {
140
+ // Raw counts are irrelevant here (we classify by output size), and
141
+ // content is required for categorization.
142
+ records = await collectSessionRecords(f.path, { cacheWeighted: false, includeContent: true });
143
+ } catch {
144
+ continue;
145
+ }
146
+ for (const ep of toEpisodes(records)) {
147
+ if (!ep.text) continue;
148
+ totalEpisodes += 1;
149
+ const easy = ep.calls <= EASY_MAX_CALLS && ep.out <= EASY_MAX_OUT_TOKENS;
150
+ if (!easy) continue;
151
+ easyEpisodes += 1;
152
+ if (isSkippable(ep.text)) continue;
153
+ if (![...ep.models].some(isExpensiveModel)) continue; // already cheap
154
+ const cat = categorize(ep.text);
155
+ if (!cat) continue;
156
+ const key = `${cat.id}|${f.projectDir}`;
157
+ const g = groups.get(key) || {
158
+ category: cat.id,
159
+ label: cat.label,
160
+ agent: cat.agent,
161
+ project: f.projectDir,
162
+ projectPath: '',
163
+ count: 0,
164
+ models: new Set(),
165
+ example: '',
166
+ };
167
+ g.count += 1;
168
+ for (const m of ep.models) g.models.add(m);
169
+ if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
170
+ if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
171
+ g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
172
+ }
173
+ groups.set(key, g);
174
+ }
175
+ }
176
+
177
+ // Keep prior dismissed/promoted signatures across rescans.
178
+ const prev = readRouteScan();
179
+ const resolved = new Set(prev?.resolved || []);
180
+
181
+ const candidates = [...groups.values()]
182
+ .filter((g) => g.count >= MIN_RECURRENCE)
183
+ .sort((a, b) => b.count - a.count)
184
+ .slice(0, 5)
185
+ .map((g, i) => ({
186
+ id: i + 1,
187
+ signature: `${g.category}|${g.project}`,
188
+ category: g.category,
189
+ label: g.label,
190
+ agent: g.agent,
191
+ project: g.project,
192
+ // Real session cwd for the project (munged `project` is lossy) — lets
193
+ // `harness promote R<N> --project` write the rule into the project the
194
+ // pattern was detected in, not whatever directory the CLI runs from.
195
+ projectPath: g.projectPath || null,
196
+ count: g.count,
197
+ models: [...g.models],
198
+ example: g.example,
199
+ // Concentrated in one project dir → project rule; the scan groups by
200
+ // project already, so scope suggestion is per-candidate 'project' unless
201
+ // the same category recurs across 2+ projects (then 'global').
202
+ suggestedScope: 'project',
203
+ rule: `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다`,
204
+ }));
205
+
206
+ // Same category appearing in 2+ projects → suggest global for each.
207
+ const catProjects = new Map();
208
+ for (const c of candidates) {
209
+ catProjects.set(c.category, (catProjects.get(c.category) || 0) + 1);
210
+ }
211
+ for (const c of candidates) {
212
+ if ((catProjects.get(c.category) || 0) >= 2) c.suggestedScope = 'global';
213
+ }
214
+
215
+ const cache = {
216
+ scannedAt: new Date().toISOString(),
217
+ days,
218
+ totalEpisodes,
219
+ easyEpisodes,
220
+ candidates,
221
+ resolved: [...resolved],
222
+ };
223
+ try {
224
+ const dir = stateDir();
225
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
226
+ writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
227
+ } catch {
228
+ // best-effort — scan results are still returned
229
+ }
230
+ return cache;
231
+ }
232
+
233
+ /** Read the cached scan (null when absent/corrupt). */
234
+ export function readRouteScan() {
235
+ try {
236
+ return JSON.parse(readFileSync(routeScanCachePath(), 'utf8'));
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ export function isCacheFresh(cache) {
243
+ if (!cache?.scannedAt) return false;
244
+ const ts = Date.parse(cache.scannedAt);
245
+ return Number.isFinite(ts) && Date.now() - ts < CACHE_TTL_MS;
246
+ }
247
+
248
+ /** Candidates not yet promoted/dismissed. */
249
+ export function openCandidates(cache) {
250
+ if (!cache?.candidates) return [];
251
+ const resolved = new Set(cache.resolved || []);
252
+ return cache.candidates.filter((c) => !resolved.has(c.signature));
253
+ }
254
+
255
+ /**
256
+ * Mark a candidate resolved (promoted or dismissed) so the chip stops and
257
+ * rescans don't resurface it. Returns the candidate or null.
258
+ */
259
+ export function resolveCandidate(id) {
260
+ const cache = readRouteScan();
261
+ if (!cache) return null;
262
+ const cand = (cache.candidates || []).find((c) => c.id === id);
263
+ if (!cand) return null;
264
+ cache.resolved = [...new Set([...(cache.resolved || []), cand.signature])];
265
+ try {
266
+ writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
267
+ } catch {
268
+ return null;
269
+ }
270
+ return cand;
271
+ }
272
+
273
+ /**
274
+ * Statusline helper — cheapest possible check (one small JSON read).
275
+ * Returns `route? #N` for the top open candidate relevant to this project
276
+ * (its own project dir, or a global-scoped suggestion), else null.
277
+ */
278
+ export function routeWarningForStatusline(projectRoot) {
279
+ const cache = readRouteScan();
280
+ if (!cache) return null;
281
+ const open = openCandidates(cache);
282
+ if (open.length === 0) return null;
283
+ const munged = mungeProjectPath(projectRoot || '');
284
+ const hit = open.find((c) => c.project === munged || c.suggestedScope === 'global');
285
+ return hit ? `route? R${hit.id}` : null;
286
+ }