claude-token-saver 3.3.1 โ†’ 3.4.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
@@ -143,28 +143,16 @@ An auto `.bak` is kept, but **the session context that earned the rule its place
143
143
 
144
144
  ## ๐Ÿ”€ route-scan โ€” "this recurring task could run on a cheaper tier"
145
145
 
146
- 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).
146
+ Finds the easy work your expensive model (opus/fable) keeps redoing in your session logs and proposes **haiku/sonnet delegation rules**. Fully local, zero token cost.
147
147
 
148
- - **T2 โ†’ haiku**: few calls, tiny output, near-zero mutation, no errors (lookups, pasted-screen Q&A, simple runs)
149
- - **T1 โ†’ sonnet**: moderate output, few mutations, โ‰ค1 error (build pipelines, status checks)
150
- - **T0 stays**: repeated errors, heavy mutation, big output, design/analysis keywords โ€” the session model keeps it
148
+ - **T2 โ†’ haiku**: lookups, pasted-screen Q&A, simple runs โ€” zero errors, near-zero mutation
149
+ - **T1 โ†’ sonnet**: build pipelines, status checks โ€” few mutations, โ‰ค1 error
150
+ - **T0 stays**: repeated errors, heavy mutation, design/analysis โ€” the session model keeps it
151
151
 
152
- 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).
153
-
154
- <details>
155
- <summary>Why this design โ€” research evidence (deep-research, 21 cross-verified sources + 553 locally measured episodes)</summary>
156
-
157
- A survey of existing LLM-routing research and systems shaped each axis of the design:
158
-
159
- - **Conservative demotion โ€” "only send down what's clearly easy" โ€” is the unexplored direction.** In RouterArena (2025), every academic and commercial router fell far short of the oracle (90.9%; best entrant 66.9), and the failure mode was consistently **over-routing to expensive models**. Per the routing-collapse work, the oracle needs the top model for under 20% of queries โ€” while 73% of the author's episodes were still on the top tier. The headroom is large.
160
- - **Difficulty is defined by outcome, not text guessing.** RouterArena labels difficulty as "how many of 42 models actually got it right". Our logs already contain outcomes: tool errors, mutating tool calls, episode length. Local measurement confirms it โ€” error incidence splits the tiers sharply (T2 candidates 2% / T1 8% / T0 36%).
161
- - **Thresholds calibrate to the user's own distribution.** RouteLLM's stated limitation is that fixed thresholds drift as the query distribution shifts โ€” so output-token thresholds are recomputed from your last 14 days' p25/p75 (clamped).
162
- - **An escalation path makes demotion mistakes cheap.** The FrugalGPT cascade lesson ("start cheap, escalate on failure"). In Claude Code this falls out naturally: when a subagent gets stuck, the main model takes over โ€” and promoted rules spell out that guard.
163
- - **Subagent `model:` is Anthropic's official cost-control mechanism.** The docs recommend routing to cheaper models like Haiku for cost control โ€” yet Claude Code itself does no automatic model routing; even trivial turns resend the full context on the session model. That gap is exactly what route-scan fills.
164
- - **Learned classifiers (BERT/embeddings) deliberately excluded.** Even the best is 24 points off the oracle, we have no preference-pair training data, and it would break the zero-dependency principle. Regex categories + outcome signals are the current position.
165
-
166
- Sources (RouteLLM, FrugalGPT, RouterArena, routing-survey arxiv links), the full local-measurement table, and the complete tier criteria: [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md).
167
- </details>
152
+ Three design pillars:
153
+ 1. Difficulty is judged by **outcome, not text guessing** โ€” tool errors, mutating tool calls, output tokens
154
+ 2. Thresholds **auto-calibrate to your own 14-day distribution** โ€” fixed constants drift with workload
155
+ 3. Promoted rules live in a tool-owned file (`.claude/ratchet-model.md`) that **refreshes itself every scan**, and a `โš  rule-health` flag fires when a delegated category's error rate climbs โ€” rules report their own staleness
168
156
 
169
157
  ```bash
170
158
  claude-token-saver route-scan # scan (24h cache) + tiered candidates
@@ -173,21 +161,7 @@ claude-token-saver route-scan dismiss 1 # not interested โ€” won't resu
173
161
  claude-token-saver route-scan rules # list model-fitting rules (rm <N> to remove)
174
162
  ```
175
163
 
176
- ### Model-fitting ratchet โ€” a separate file, continuously refreshed
177
-
178
- 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:
179
-
180
- - **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).
181
- - **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.
182
- - Your rules are managed by `harness list/rm`; model-fitting rules by `route-scan rules [rm <N>]` โ€” separate files, separate indexes.
183
- - 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).
184
-
185
- How it works (session-boundary calibration, NOT a real-time router):
186
- 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.
187
- 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`).
188
- 3. Promoted rules make **the main model delegate that work type to a haiku/sonnet subagent automatically from the next session on**.
189
-
190
- 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.
164
+ Dig deeper: **tier criteria & research evidence** โ†’ [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) (Korean) ยท **rule-file mechanics, scan triggers, subagent setup** โ†’ [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md) (Korean + English)
191
165
 
192
166
  ## Spike issue codes
193
167
 
package/README.md CHANGED
@@ -124,28 +124,16 @@ ratchet์˜ ๊ฐ€์น˜๋Š” **ํ•œ ๋ฐฉํ–ฅ ๋ˆ„์ **์— ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฃฐ์„ ๊ฐ€๋ณ๊ฒŒ ์ง€
124
124
 
125
125
  ## ๐Ÿ”€ route-scan โ€” "์ด ๋ฐ˜๋ณต ์ž‘์—…, ๋” ์‹ผ ํ‹ฐ์–ด๋กœ ๋‚ด๋ ค๋„ ๋ฉ๋‹ˆ๋‹ค"
126
126
 
127
- ์„ธ์…˜ ๊ธฐ๋ก์„ **์—ํ”ผ์†Œ๋“œ(์‚ฌ์šฉ์ž ์š”์ฒญ) ๋‹จ์œ„**๋กœ ๋ถ„์„ํ•ด ์ƒ์œ„ ๋ชจ๋ธ(opus/fable)์ด ๋ฐ˜๋ณต ์ฒ˜๋ฆฌํ•ด ์˜จ ์ž‘์—…์„ **ํ‹ฐ์–ด๋กœ ๋ถ„๋ฅ˜**ํ•˜๊ณ , ์œ„์ž„ ๋ฃฐ๋กœ ์Šน๊ฒฉํ•˜๋„๋ก ์ œ์•ˆํ•ฉ๋‹ˆ๋‹ค. ์ „ ๊ณผ์ • ๋กœ์ปฌ, ํ† ํฐ ๋น„์šฉ 0. ๊ธฐ์ค€ ์„ค๊ณ„์™€ ๊ทผ๊ฑฐ๋Š” [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) ์ฐธ๊ณ .
127
+ ์„ธ์…˜ ๋กœ๊ทธ์—์„œ ์ƒ์œ„ ๋ชจ๋ธ(opus/fable)์ด ๋ฐ˜๋ณต ์ฒ˜๋ฆฌํ•ด ์˜จ ์‰ฌ์šด ์ž‘์—…์„ ์ฐพ์•„ **haiku/sonnet ์œ„์ž„ ๋ฃฐ๋กœ ์Šน๊ฒฉ**์„ ์ œ์•ˆํ•ฉ๋‹ˆ๋‹ค. ์ „ ๊ณผ์ • ๋กœ์ปฌ, ํ† ํฐ ๋น„์šฉ 0.
128
128
 
129
- - **T2 โ†’ haiku**: ์ ์€ ํ˜ธ์ถœยท์ž‘์€ ์ถœ๋ ฅยท๋ณ€๊ฒฝ ๊ฑฐ์˜ ์—†์Œยท์—๋Ÿฌ 0์œผ๋กœ ๋๋‚œ ์š”์ฒญ (ํƒ์ƒ‰ยท์กฐํšŒ, ๋ถ™์—ฌ๋„ฃ์€ ํ™”๋ฉด ์งˆ๋ฌธ, ๋‹จ์ˆœ ์‹คํ–‰ ๋“ฑ)
130
- - **T1 โ†’ sonnet**: ์ค‘๊ฐ„ ์ถœ๋ ฅยท๋ณ€๊ฒฝ ์†Œ์ˆ˜ยท์—๋Ÿฌ โ‰ค1 (๋นŒ๋“œ ํŒŒ์ดํ”„๋ผ์ธ, ์ƒํƒœ ์ ๊ฒ€ ๋“ฑ)
131
- - **T0 ์œ ์ง€**: ์—๋Ÿฌ ๋ฐ˜๋ณต, ๋Œ€๋Ÿ‰ ๋ณ€๊ฒฝ, ํฐ ์ถœ๋ ฅ, ์„ค๊ณ„ยท๋ถ„์„ ํ‚ค์›Œ๋“œ โ€” ์„ธ์…˜ ๋ชจ๋ธ์ด ๊ณ„์† ๋‹ด๋‹น
129
+ - **T2 โ†’ haiku**: ํƒ์ƒ‰ยท์กฐํšŒยท๋‹จ์ˆœ ์‹คํ–‰ โ€” ์—๋Ÿฌ 0, ๋ณ€๊ฒฝ ๊ฑฐ์˜ ์—†์Œ
130
+ - **T1 โ†’ sonnet**: ๋นŒ๋“œยท์ƒํƒœ ์ ๊ฒ€ โ€” ๋ณ€๊ฒฝ ์†Œ์ˆ˜, ์—๋Ÿฌ โ‰ค1
131
+ - **T0 ์œ ์ง€**: ์—๋Ÿฌ ๋ฐ˜๋ณตยท๋Œ€๋Ÿ‰ ๋ณ€๊ฒฝยท์„ค๊ณ„/๋ถ„์„ โ€” ์„ธ์…˜ ๋ชจ๋ธ์ด ๊ณ„์† ๋‹ด๋‹น
132
132
 
133
- ํŒ์ • ์‹ ํ˜ธ๋Š” ํ˜ธ์ถœ ์ˆ˜ยท์ถœ๋ ฅ ํ† ํฐยท**๋ณ€๊ฒฝ์„ฑ ๋„๊ตฌ ์ˆ˜(Edit/Write/Bash)**ยท**๋„๊ตฌ ์—๋Ÿฌ ์ˆ˜**ยท์š”์ฒญ ํ…์ŠคํŠธ์ด๊ณ , ์ถœ๋ ฅ ์ž„๊ณ„๊ฐ’์€ ์‚ฌ์šฉ์ž ์ž์‹ ์˜ ์ตœ๊ทผ 14์ผ ๋ถ„ํฌ์—์„œ ์ž๋™ ๋ณด์ •๋ฉ๋‹ˆ๋‹ค (๊ณ ์ • ์ƒ์ˆ˜๋Š” ์›Œํฌ๋กœ๋“œ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์–ด๊ธ‹๋‚˜๊ธฐ ๋•Œ๋ฌธ).
134
-
135
- <details>
136
- <summary>์™œ ์ด ์„ค๊ณ„์ธ๊ฐ€ โ€” ๋ฆฌ์„œ์น˜ ๊ทผ๊ฑฐ (deep-research ์†Œ์Šค 21๊ฐœ ๊ต์ฐจ๊ฒ€์ฆ + ๋กœ์ปฌ ์‹ค์ธก 553 ์—ํ”ผ์†Œ๋“œ)</summary>
137
-
138
- ๊ธฐ์กด LLM ๋ผ์šฐํŒ… ์—ฐ๊ตฌยท์‹œ์Šคํ…œ์„ ์กฐ์‚ฌํ•œ ๊ฒฐ๊ณผ๊ฐ€ ์„ค๊ณ„์˜ ๊ฐ ์ถ•์„ ๊ฒฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค:
139
-
140
- - **"์‰ฌ์šด ๊ฒƒ๋งŒ ํ™•์‹คํžˆ ๋‚ด๋ฆฌ๋Š”" ๋ณด์ˆ˜์  ๊ฐ•๋“ฑ์ด ๋ฏธ๊ฐœ์ฒ™ ์ง€๋Œ€** โ€” RouterArena(2025) ํ‰๊ฐ€์—์„œ ํ•™์ˆ ยท์ƒ์šฉ ๋ผ์šฐํ„ฐ ์ „๋ถ€ ์˜ค๋ผํด(90.9%)์— ํฌ๊ฒŒ ๋ชป ๋ฏธ์ณค๊ณ (1์œ„ 66.9), ์‹คํŒจ ๋ฐฉํ–ฅ์€ ์ผ๊ด€๋˜๊ฒŒ **๋น„์‹ผ ๋ชจ๋ธ๋กœ์˜ ๊ณผ์ž‰ ๋ผ์šฐํŒ…**์ด์—ˆ์Šต๋‹ˆ๋‹ค. ์˜ค๋ผํด ๊ธฐ์ค€ ์ตœ์ƒ์œ„ ๋ชจ๋ธ์ด ์ •๋ง ํ•„์š”ํ•œ ์ฟผ๋ฆฌ๋Š” 20% ๋ฏธ๋งŒ์ธ๋ฐ(routing collapse ์—ฐ๊ตฌ), ์ €์ž ๋กœ๊ทธ์—์„  73%๊ฐ€ ์ตœ์ƒ์œ„ ํ‹ฐ์–ด์— ๋‚จ์•„ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค โ€” ํ—ค๋“œ๋ฃธ์ด ํฝ๋‹ˆ๋‹ค.
141
- - **๋‚œ์ด๋„๋Š” ํ…์ŠคํŠธ ์ถ”์ •์ด ์•„๋‹ˆ๋ผ ๊ฒฐ๊ณผ(outcome)๋กœ ์ •์˜** โ€” RouterArena๋Š” ๋‚œ์ด๋„๋ฅผ "42๊ฐœ ๋ชจ๋ธ ์ค‘ ๋ช‡ ๊ฐœ๊ฐ€ ์‹ค์ œ๋กœ ๋งžํ˜”๋‚˜"๋กœ ๋ผ๋ฒจ๋งํ–ˆ์Šต๋‹ˆ๋‹ค. ์šฐ๋ฆฌ ๋กœ๊ทธ์—๋„ ๊ฒฐ๊ณผ๊ฐ€ ์ด๋ฏธ ์žˆ์Šต๋‹ˆ๋‹ค: ๋„๊ตฌ ์—๋Ÿฌยท๋ณ€๊ฒฝ์„ฑ ๋„๊ตฌ ์ˆ˜ยท์—ํ”ผ์†Œ๋“œ ๊ธธ์ด. ๋กœ์ปฌ ์‹ค์ธก์—์„œ๋„ ์—๋Ÿฌ ๊ฒฝํ—˜๋ฅ ์ด ํ‹ฐ์–ด๋ฅผ ๊ฐ•ํ•˜๊ฒŒ ๊ฐˆ๋ž์Šต๋‹ˆ๋‹ค (T2 ํ›„๋ณด 2% / T1 8% / T0 36%).
142
- - **์ž„๊ณ„๊ฐ’์€ ์‚ฌ์šฉ์ž ๋ถ„ํฌ์—์„œ ๋ณด์ •** โ€” RouteLLM์˜ ๊ณต์‹ ํ•œ๊ณ„๊ฐ€ "๊ณ ์ • ์ž„๊ณ„๊ฐ’์€ ์ฟผ๋ฆฌ ๋ถ„ํฌ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์–ด๊ธ‹๋‚œ๋‹ค"์ž…๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ถœ๋ ฅ ํ† ํฐ ์ž„๊ณ„๊ฐ’์„ ์ตœ๊ทผ 14์ผ p25/p75์—์„œ ์žฌ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค(ํด๋žจํ”„ ํฌํ•จ).
143
- - **์‹คํŒจ ์‹œ ์Šน๊ธ‰ ๊ฒฝ๋กœ๊ฐ€ ๊ฐ•๋“ฑ ์˜คํŒ ๋น„์šฉ์„ ๋‚ฎ์ถค** โ€” FrugalGPT ์บ์Šค์ผ€์ด๋“œ("์‹ผ ๋ชจ๋ธ๋ถ€ํ„ฐ, ์•ˆ ๋˜๋ฉด ์œ„๋กœ")์˜ ๊ตํ›ˆ. Claude Code์—์„  ์„œ๋ธŒ์—์ด์ „ํŠธ๊ฐ€ ๋ง‰ํžˆ๋ฉด ๋ฉ”์ธ ๋ชจ๋ธ์ด ์ด์–ด๋ฐ›๋Š” ํ˜•ํƒœ๋กœ ์ž์—ฐ ๊ตฌํ˜„๋˜๊ณ , ์Šน๊ฒฉ ๋ฃฐ ํ…์ŠคํŠธ์—๋„ ์ด ๊ฐ€๋“œ๊ฐ€ ๋ช…์‹œ๋ฉ๋‹ˆ๋‹ค.
144
- - **์„œ๋ธŒ์—์ด์ „ํŠธ `model:`์€ Anthropic ๊ณต์‹ ๋น„์šฉ ํ†ต์ œ ๋ฉ”์ปค๋‹ˆ์ฆ˜** โ€” ๊ณต์‹ ๋ฌธ์„œ๊ฐ€ "Haiku ๋“ฑ ์‹ผ ๋ชจ๋ธ๋กœ ๋ผ์šฐํŒ…ํ•ด ๋น„์šฉ ํ†ต์ œ"๋ฅผ ๊ถŒ์žฅํ•˜์ง€๋งŒ, Claude Code ๋ณธ์ฒด๋Š” ์ž๋™ ๋ชจ๋ธ ๋ผ์šฐํŒ…์„ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์‚ฌ์†Œํ•œ ํ„ด๋„ ์„ธ์…˜ ๋ชจ๋ธ์ด ์ „์ฒด ์ปจํ…์ŠคํŠธ๋ฅผ ์žฌ์ „์†กํ•˜๋ฉฐ ์ฒ˜๋ฆฌํ•˜๋Š” ์ด ๊ณต๋ฐฑ์ด route-scan์ด ๋ฉ”์šฐ๋Š” ์ž๋ฆฌ์ž…๋‹ˆ๋‹ค.
145
- - **ํ•™์Šตํ˜• ๋ถ„๋ฅ˜๊ธฐ(BERT/์ž„๋ฒ ๋”ฉ)๋Š” ์˜๋„์ ์œผ๋กœ ๋ฐฐ์ œ** โ€” ์ตœ๊ณ  ์„ฑ๋Šฅ๋„ ์˜ค๋ผํด๊ณผ 24์  ์ฐจ์ด์ธ ๋ฐ๋‹ค ํ•™์Šต ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๊ณ  zero-dep ์›์น™์— ์–ด๊ธ‹๋‚ฉ๋‹ˆ๋‹ค. ์ •๊ทœ์‹ ์นดํ…Œ๊ณ ๋ฆฌ + outcome ์‹ ํ˜ธ ์กฐํ•ฉ์ด ํ˜„์žฌ ์œ„์น˜.
146
-
147
- ์ถœ์ฒ˜(RouteLLMยทFrugalGPTยทRouterArenaยท๋ผ์šฐํŒ… ์„œ๋ฒ ์ด arxiv ๋งํฌ)์™€ ๋กœ์ปฌ ์‹ค์ธก ์ƒ์„ธ ํ…Œ์ด๋ธ”, ํ‹ฐ์–ด ๊ธฐ์ค€ ์ „๋ฌธ์€ [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) ์ฐธ๊ณ .
148
- </details>
133
+ ํ•ต์‹ฌ ์„ค๊ณ„๋Š” ์„ธ ๊ฐ€์ง€์ž…๋‹ˆ๋‹ค:
134
+ 1. ๋‚œ์ด๋„๋Š” ํ…์ŠคํŠธ ์ถ”์ธก์ด ์•„๋‹ˆ๋ผ **๊ฒฐ๊ณผ(outcome)๋กœ ํŒ์ •** โ€” ๋„๊ตฌ ์—๋Ÿฌยท๋ณ€๊ฒฝ์„ฑ ๋„๊ตฌ ์ˆ˜ยท์ถœ๋ ฅ ํ† ํฐ
135
+ 2. ์ž„๊ณ„๊ฐ’์€ **๋‚ด ์ตœ๊ทผ 14์ผ ๋กœ๊ทธ ๋ถ„ํฌ์—์„œ ์ž๋™ ๋ณด์ •** โ€” ๊ณ ์ • ์ƒ์ˆ˜๋Š” ์›Œํฌ๋กœ๋“œ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์–ด๊ธ‹๋‚˜๋ฏ€๋กœ
136
+ 3. ์Šน๊ฒฉ๋œ ๋ฃฐ์€ ๋„๊ตฌ ์†Œ์œ  ๋ณ„๋„ ํŒŒ์ผ(`.claude/ratchet-model.md`)์—์„œ **์ž๋™ ๊ฐฑ์‹ **๋˜๊ณ , ์œ„์ž„ ํ›„ ์—๋Ÿฌ์œจ์ด ๋†’์•„์ง€๋ฉด `โš  rule-health`๋กœ ๊ฒฝ๊ณ  โ€” ๋ฃฐ์ด ์Šค์Šค๋กœ ๋‚ก์Œ์„ ์•Œ๋ฆผ
149
137
 
150
138
  ```bash
151
139
  claude-token-saver route-scan # ์Šค์บ” (24h ์บ์‹œ) + ํ‹ฐ์–ด๋ณ„ ํ›„๋ณด ์ถœ๋ ฅ
@@ -154,21 +142,7 @@ claude-token-saver route-scan dismiss 1 # ๊ด€์‹ฌ ์—†์œผ๋ฉด ๋ฌด์‹œ (์žฌ
154
142
  claude-token-saver route-scan rules # ๋“ฑ๋ก๋œ ๋ชจ๋ธ ํ”ผํŒ… ๋ฃฐ ๋ชฉ๋ก (rm <N>์œผ๋กœ ์ œ๊ฑฐ)
155
143
  ```
156
144
 
157
- ### ๋ชจ๋ธ ํ”ผํŒ… ๋žซ์ณ‡ โ€” ์‚ฌ์šฉ์ž ๋ฃฐ๊ณผ ํŒŒ์ผ๋ถ€ํ„ฐ ๋ถ„๋ฆฌ, ๋กœ๊ทธ ๊ธฐ๋ฐ˜ ์ž๋™ ๊ฐฑ์‹ 
158
-
159
- ์Šน๊ฒฉ๋œ ์œ„์ž„ ๋ฃฐ์€ ์†์œผ๋กœ ์“ด ๋žซ์ณ‡ ๋ฃฐ๊ณผ ์„ž์ด์ง€ ์•Š๋„๋ก **๋ณ„๋„ ํŒŒ์ผ**์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค โ€” ํ”„๋กœ์ ํŠธ๋Š” `.claude/ratchet-model.md`, ๊ธ€๋กœ๋ฒŒ์€ `~/.claude/ratchet-model.md`. ์ด ํŒŒ์ผ์€ ์ „์ ์œผ๋กœ ๋„๊ตฌ ์†Œ์œ ๋ผ ๋งค ์Šค์บ”๋งˆ๋‹ค ํ†ต์งธ๋กœ ์žฌ์ƒ์„ฑ๋˜๋ฉฐ, ์ดํ›„์—๋„ ์‚ด์•„ ์›€์ง์ž…๋‹ˆ๋‹ค:
160
-
161
- - **์ž๋™ ๊ฐฑ์‹ **: ๋งค ์Šค์บ”๋งˆ๋‹ค ๋ฐ˜๋ณต ํšŸ์ˆ˜ยทํ•ด๋‹น ์œ ํ˜•์˜ ์—๋Ÿฌ์œจ์„ ์ตœ์‹  ๋กœ๊ทธ๋กœ ๋‹ค์‹œ ๊ณ„์‚ฐํ•ด ํŒŒ์ผ์„ ์žฌ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ†ต๊ณ„๊ฐ€ ๋ฐ”๋€Œ์–ด๋„ ์‚ฌ์šฉ์ž์˜ `ratchet.md`๋Š” ์ „ํ˜€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์œผ๋ฏ€๋กœ, `.claude/`๋ฅผ ์ปค๋ฐ‹ํ•˜๋Š” ํ”„๋กœ์ ํŠธ์—์„œ๋„ diff ์†Œ์Œ์ด ์—†์Šต๋‹ˆ๋‹ค (`ratchet-model.md`๋Š” gitignoreํ•ด๋„ ๋ฌด๋ฐฉ โ€” ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ์—์„œ ํ•ญ์ƒ ์žฌ์ƒ์„ฑ ๊ฐ€๋Šฅ).
162
- - **rule-health**: ์œ„์ž„ ๋Œ€์ƒ ์œ ํ˜•์˜ ์—๋Ÿฌ์œจ์ด 20%๋ฅผ ๋„˜์œผ๋ฉด ๋ฃฐ์— `โš  rule-health` ๊ฒฝ๊ณ ๊ฐ€ ๋ถ™์–ด ์กฐ๊ฑด์„ ์ขํžˆ๊ฑฐ๋‚˜ ์ œ๊ฑฐํ•˜๋ผ๊ณ  ์•Œ๋ฆฝ๋‹ˆ๋‹ค โ€” "๊ฒฐ๊ณผ(outcome)๋กœ ๋‚œ์ด๋„๋ฅผ ์ •์˜"ํ•˜๋Š” ์›์น™์„ ๋ฃฐ ์ˆ˜๋ช… ๊ด€๋ฆฌ์— ์žฌ์ ์šฉํ•œ ๊ฒƒ.
163
- - ์‚ฌ์šฉ์ž ๋ฃฐ์€ `harness list/rm`, ๋ชจ๋ธ ํ”ผํŒ… ๋ฃฐ์€ `route-scan rules [rm <N>]`๋กœ ๊ฐ๊ฐ ๊ด€๋ฆฌ โ€” ์„œ๋กœ์˜ ํŒŒ์ผ๋„ ์ธ๋ฑ์Šค๋„ ์นจ๋ฒ”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
164
- - `harness init`์ด ์‹ฌ๋Š” CLAUDE.md ๋žซ์ณ‡ ์„น์…˜์ด ๋‘ ํŒŒ์ผ์„ ๋ชจ๋‘ ์ฐธ์กฐํ•˜๋ฏ€๋กœ Claude๊ฐ€ ์„ธ์…˜์—์„œ ํ•จ๊ป˜ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค (๊ธฐ์กด ์‚ฌ์šฉ์ž๋Š” `harness init` ์žฌ์‹คํ–‰์œผ๋กœ ๋ธ”๋ก ๊ฐฑ์‹ ).
165
-
166
- ๋™์ž‘ ๊ตฌ์กฐ (์‹ค์‹œ๊ฐ„ ๋ผ์šฐํŒ…์ด ์•„๋‹ˆ๋ผ **์„ธ์…˜ ๊ฒฝ๊ณ„ ์บ˜๋ฆฌ๋ธŒ๋ ˆ์ด์…˜**):
167
- 1. `install` ์‹œ SessionStart ํ›…์ด ๋“ฑ๋ก๋˜์–ด, ์ƒˆ ์„ธ์…˜ ์‹œ์ž‘ยท`/clear` ๋•Œ ์บ์‹œ๋œ ์Šค์บ” ๊ฒฐ๊ณผ๋ฅผ ์„ธ์…˜ ์ปจํ…์ŠคํŠธ๋กœ ์ฃผ์ž…ํ•ฉ๋‹ˆ๋‹ค. ์žฌ์Šค์บ”์€ ์‹œ๊ฐ„์ด ์•„๋‹ˆ๋ผ **๋ฐ์ดํ„ฐ๊ฐ€ ํŠธ๋ฆฌ๊ฑฐ**: ๋งˆ์ง€๋ง‰ ์Šค์บ” ์ดํ›„ ์ƒˆ transcript๊ฐ€ ~5MB ์ด์ƒ ์Œ“์ด๋ฉด ์ฆ‰์‹œ, ์†Œ๋Ÿ‰์ด๋ฉด ํ•˜๋ฃจ 1ํšŒ, ์•„๋ฌด ๋ณ€ํ™”๊ฐ€ ์—†์œผ๋ฉด ์•„์˜ˆ ๋Œ์ง€ ์•Š์Šต๋‹ˆ๋‹ค (๋ณ€ํ™” ์—†๋Š” ์žฌ์Šค์บ”์€ ๊ฒฐ๊ณผ๊ฐ€ ๋™์ผํ•˜๋ฏ€๋กœ). ์ตœ์†Œ ๊ฐ„๊ฒฉ 1์‹œ๊ฐ„ ๊ฐ€๋“œ ํฌํ•จ, ๋ฃฐ ๋“ฑ๋ก(promote) ์งํ›„์—๋Š” ํ†ต๊ณ„ ๊ธฐ์ค€์„  ํ™•๋ณด๋ฅผ ์œ„ํ•ด ์ฆ‰์‹œ 1ํšŒ.
168
- 2. ๋ฐ˜๋ณต(โ‰ฅ3ํšŒ) ํŒจํ„ด์ด ์žˆ์œผ๋ฉด statusline์— `๐Ÿ…ทโš  route? R1` ์นฉ์ด ๋œจ๊ณ , Claude๊ฐ€ ๋“ฑ๋ก ์—ฌ๋ถ€์™€ scope(`--project`/`--global`)๋ฅผ ๋ฌผ์–ด๋ด…๋‹ˆ๋‹ค.
169
- 3. ๋“ฑ๋ก๋œ ๋ฃฐ์€ **๋‹ค์Œ ์„ธ์…˜๋ถ€ํ„ฐ ๋ฉ”์ธ ๋ชจ๋ธ์ด ํ•ด๋‹น ์œ ํ˜•์„ haiku/sonnet ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์ž๋™ ์œ„์ž„**ํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.
170
-
171
- ๊ถŒ์žฅ ์‚ฌ์ „ ์ค€๋น„: `~/.claude/agents/`์— `model: haiku` ์„œ๋ธŒ์—์ด์ „ํŠธ(์˜ˆ: haiku-explore / haiku-runner / haiku-translate)์™€ `model: sonnet` ๋ฒ”์šฉ ์„œ๋ธŒ์—์ด์ „ํŠธ๋ฅผ ๋งŒ๋“ค์–ด ๋‘๋ฉด ๋ฃฐ์ด ๋ฐ”๋กœ ์‹คํ–‰ ๊ฐ€๋Šฅํ•ด์ง‘๋‹ˆ๋‹ค.
145
+ ๋” ์•Œ์•„๋ณด๊ธฐ: **ํ‹ฐ์–ด ๊ธฐ์ค€ยท๋ฆฌ์„œ์น˜ ๊ทผ๊ฑฐ** โ†’ [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) ยท **๋ฃฐ ํŒŒ์ผ ๊ตฌ์กฐยท์Šค์บ” ํŠธ๋ฆฌ๊ฑฐยท์„œ๋ธŒ์—์ด์ „ํŠธ ์ค€๋น„** โ†’ [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md)
172
146
 
173
147
  ## ํ† ํฐ ๊ธ‰์ฆ ์›์ธ ์ฝ”๋“œ
174
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.3.1",
3
+ "version": "3.4.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": {
@@ -35,6 +35,10 @@ import { homedir } from 'node:os';
35
35
  // Post-promotion delegated-category error rate above this flags the rule
36
36
  // for review (rule-health). Calibrated against local T0 avg error incidence.
37
37
  export const HEALTH_ERR_RATE = 0.2;
38
+ // Below this many shape-eligible episodes in the window the error rate is
39
+ // noise (1 error in 4 episodes = 25% โ€” instant flag), so the review flag is
40
+ // withheld until the sample is large enough to mean something.
41
+ export const HEALTH_MIN_SAMPLE = 10;
38
42
 
39
43
  function stateDir() {
40
44
  if (process.platform === 'win32') {
@@ -104,12 +108,37 @@ export function renderModelRatchet(rules) {
104
108
  '## Rules',
105
109
  '',
106
110
  ];
111
+ const healthOf = (r) => r.status === 'review'
112
+ ? ` โš  rule-health: ์ตœ๊ทผ ์œ„์ž„ ๋Œ€์ƒ ์—๋Ÿฌ์œจ ${Math.round((r.errRate || 0) * 100)}% โ€” ์กฐ๊ฑด์„ ์ขํžˆ๊ฑฐ๋‚˜ ์ œ๊ฑฐ ๊ฒ€ํ† `
113
+ : '';
114
+ const statsOf = (r) => `ร—${r.count || 0}, err ${Math.round((r.errRate || 0) * 100)}%, seen ${r.lastSeen || r.promotedAt}`;
115
+
116
+ // A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
117
+ // only known after an episode finishes, so two separate bullets give the
118
+ // reading LLM no way to pick one at request time โ€” merge such pairs into a
119
+ // single conditional rule with explicit request-time criteria (default to
120
+ // the cheap agent, escalate on multi-step scope, hand back on judgement /
121
+ // irreversible actions).
122
+ const byCategory = new Map();
107
123
  for (const r of rules) {
108
- const health = r.status === 'review'
109
- ? ` โš  rule-health: ์ตœ๊ทผ ์œ„์ž„ ๋Œ€์ƒ ์—๋Ÿฌ์œจ ${Math.round((r.errRate || 0) * 100)}% โ€” ์กฐ๊ฑด์„ ์ขํžˆ๊ฑฐ๋‚˜ ์ œ๊ฑฐ ๊ฒ€ํ† `
110
- : '';
111
- const stats = ` <!-- ร—${r.count || 0}, err ${Math.round((r.errRate || 0) * 100)}%, seen ${r.lastSeen || r.promotedAt} -->`;
112
- lines.push(`- ${r.rule}${health}${stats}`);
124
+ if (!byCategory.has(r.category)) byCategory.set(r.category, []);
125
+ byCategory.get(r.category).push(r);
126
+ }
127
+ for (const group of byCategory.values()) {
128
+ const t2 = group.find((r) => r.tier === 'T2');
129
+ const t1 = group.find((r) => r.tier === 'T1');
130
+ if (t2 && t1) {
131
+ const rule =
132
+ `"${t2.label}" ์œ ํ˜• ์š”์ฒญ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ${t2.agent}(haiku) ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์œ„์ž„ํ•œ๋‹ค(์˜ˆ: "${t2.example}"). ` +
133
+ `์—ฌ๋Ÿฌ ๋‹จ๊ณ„ยท์—ฌ๋Ÿฌ ํŒŒ์ผ ์ˆ˜์ •์ด ์–ฝํžŒ ์ค‘๊ฐ„ ๋‚œ๋„ ์š”์ฒญ(์˜ˆ: "${t1.example}")์€ model: sonnet ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์œ„์ž„ํ•œ๋‹ค. ` +
134
+ `์„ค๊ณ„ ํŒ๋‹จยท๋ฐฐํฌยท์Šคํ† ์–ด ์ œ์ถœ ๊ฐ™์€ ๋น„๊ฐ€์—ญ ์ž‘์—…์ด ์„ž์ด๊ฑฐ๋‚˜ ์œ„์ž„ ์ค‘ ์—๋Ÿฌ๊ฐ€ ๋ฐ˜๋ณต๋˜๋ฉด ์œ„์ž„ํ•˜์ง€ ๋ง๊ณ  ๋ฉ”์ธ ๋ชจ๋ธ์ด ์ง์ ‘ ์ฒ˜๋ฆฌํ•œ๋‹ค`;
135
+ lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
136
+ for (const r of group) {
137
+ if (r !== t2 && r !== t1) lines.push(`- ${r.rule}${healthOf(r)} <!-- ${statsOf(r)} -->`);
138
+ }
139
+ } else {
140
+ for (const r of group) lines.push(`- ${r.rule}${healthOf(r)} <!-- ${statsOf(r)} -->`);
141
+ }
113
142
  }
114
143
  return lines.join('\n') + '\n';
115
144
  }
@@ -171,8 +200,10 @@ export function syncAllFiles({ previousPaths = [] } = {}) {
171
200
  * for each registered rule, recompute recurrence count and the error rate
172
201
  * of episodes in its (tier-eligible) category โ€” the rule-health signal.
173
202
  *
174
- * `episodeStats`: Map "category|project" (plus a "category|*" wildcard key
175
- * that global-scope rules fall back to) โ†’ { count, errCount, epCount }.
203
+ * `episodeStats`: Map "tier|category|project" (plus a "tier|category|*"
204
+ * wildcard key that global-scope rules fall back to) โ†’ { count, errCount,
205
+ * epCount }. Tier is part of the key so a category carrying both a T2 and a
206
+ * T1 rule doesn't double-count every episode into both rules' stats.
176
207
  * errCount/epCount measure the scan window's shape-eligible episodes โ€” ones
177
208
  * an expensive model handled directly that still look T1/T2 by shape (tier
178
209
  * judged with the error signal zeroed; see route-scan's rule-health pass).
@@ -181,13 +212,13 @@ export function refreshModelRules(episodeStats, { now } = {}) {
181
212
  const data = loadModelRules();
182
213
  let changed = false;
183
214
  for (const r of data.rules) {
184
- const s = episodeStats.get(`${r.category}|${r.project}`)
185
- || (r.scope === 'global' ? episodeStats.get(`${r.category}|*`) : null);
215
+ const s = episodeStats.get(`${r.tier}|${r.category}|${r.project}`)
216
+ || (r.scope === 'global' ? episodeStats.get(`${r.tier}|${r.category}|*`) : null);
186
217
  if (!s) continue;
187
218
  r.count = s.count;
188
219
  r.errRate = s.epCount > 0 ? s.errCount / s.epCount : 0;
189
220
  r.lastSeen = now || r.lastSeen;
190
- r.status = r.errRate > HEALTH_ERR_RATE ? 'review' : 'active';
221
+ r.status = r.errRate > HEALTH_ERR_RATE && s.epCount >= HEALTH_MIN_SAMPLE ? 'review' : 'active';
191
222
  changed = true;
192
223
  }
193
224
  if (changed) {
package/src/route-scan.js CHANGED
@@ -42,7 +42,10 @@ export const T0_MIN_MUTATING = 7;
42
42
  // acks, feedback) โ€” skip entirely.
43
43
  export const MIN_DELEGABLE_OUT = 100;
44
44
  // Escalation keywords: design/analysis judgement stays on the top tier.
45
- export const ESCALATE_RE = /์„ค๊ณ„|์•„ํ‚คํ…์ฒ˜|๋ฆฌํŒฉํ† ๋ง|์›์ธ ๋ถ„์„|๊ฐœ์„ ํ• |๊ฒ€ํ† ํ•ด๋ณด|๋น„๊ต|์™œ |analyze|compare|evaluate|architect|refactor/i;
45
+ // Irreversible/external actions (store submission, deploy, release, merge)
46
+ // are included โ€” they may look like light "run" episodes in the logs, but
47
+ // delegating them defeats the harness's default-safe-path rule.
48
+ export const ESCALATE_RE = /์„ค๊ณ„|์•„ํ‚คํ…์ฒ˜|๋ฆฌํŒฉํ† ๋ง|์›์ธ ๋ถ„์„|๊ฐœ์„ ํ• |๊ฒ€ํ† ํ•ด๋ณด|๋น„๊ต|์™œ |์ œ์ถœ|๋ฐฐํฌ|์ถœ์‹œ|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
46
49
  // A pattern must recur this often before we nag about it.
47
50
  export const MIN_RECURRENCE = 3;
48
51
 
@@ -58,50 +61,85 @@ export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than ho
58
61
  export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data โ†’ rescan now
59
62
  export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old โ†’ rescan
60
63
 
61
- // Category โ†’ recommended subagent. First match wins; order matters
62
- // (paste before everything: keywords inside pasted UI/log text would
63
- // otherwise mislabel the episode; translate before read: "๋ฒˆ์—ญํ•ด์ค˜" also
64
- // matches the read keywords).
64
+ // Categories โ†’ recommended subagent. Classification is behavior-first with
65
+ // weighted keyword scoring as fallback โ€” see categorize().
65
66
  const PASTE_MIN_LEN = 400;
66
67
  const CATEGORIES = [
67
68
  {
68
69
  id: 'paste',
69
70
  label: '๋ถ™์—ฌ๋„ฃ์€ ํ™”๋ฉดยท๋กœ๊ทธ ์งˆ๋ฌธ',
70
71
  agent: 'haiku-explore',
71
- re: null, // matched by length, see categorize()
72
+ kw: null, // matched by length, see categorize()
72
73
  },
73
74
  {
74
75
  id: 'translate',
75
76
  label: '๋ฐฐ์น˜ ๋ฒˆ์—ญยท์ •ํ˜• ํ…์ŠคํŠธ ๋ณ€ํ™˜',
76
77
  agent: 'haiku-translate',
77
- re: /๋ฒˆ์—ญ|translate|๋ณ€ํ™˜ํ•ด|ํ‘œ๋กœ ์ •๋ฆฌ|ํฌ๋งทํŒ…/i,
78
+ kw: [[/๋ฒˆ์—ญ|translate/i, 2], [/๋ณ€ํ™˜ํ•ด|ํ‘œ๋กœ ์ •๋ฆฌ|ํฌ๋งทํŒ…/i, 1]],
78
79
  },
79
80
  {
80
81
  id: 'explore',
81
82
  label: 'ํƒ์ƒ‰ยท์กฐํšŒ (ํŒŒ์ผ/๊ฐ’ ์ฐพ๊ธฐ)',
82
83
  agent: 'haiku-explore',
83
- re: /grep|๊ฒ€์ƒ‰|์ฐพ์•„|search|find|์–ด๋””|์œ„์น˜|๋ชฉ๋ก|์‚ดํŽด/i,
84
+ kw: [[/grep|๊ฒ€์ƒ‰|search|find/i, 2], [/์ฐพ์•„|์–ด๋””|์œ„์น˜|๋ชฉ๋ก|์‚ดํŽด/i, 1]],
84
85
  },
85
86
  {
86
87
  id: 'read',
87
88
  label: '์ฝ๊ธฐยท์š”์•ฝยท์„ค๋ช…',
88
89
  agent: 'haiku-explore',
89
- re: /์ฝ์–ด|์š”์•ฝ|์„ค๋ช…|์ •๋ฆฌํ•ด|summar|explain|๋ณด์—ฌ์ค˜|์•Œ๋ ค์ค˜|๋ญ์•ผ|what/i,
90
- },
91
- {
92
- id: 'run',
93
- label: '๋ช…๋ น ์‹คํ–‰ (๋นŒ๋“œยทํ…Œ์ŠคํŠธยทgit)',
94
- agent: 'haiku-runner',
95
- re: /git |commit|push|์‹คํ–‰|๋Œ๋ ค|run |build|๋นŒ๋“œ|ํ…Œ์ŠคํŠธ|npm |pip|์„ค์น˜/i,
90
+ kw: [[/์š”์•ฝ|summar|explain/i, 2], [/์ฝ์–ด|์„ค๋ช…|์ •๋ฆฌํ•ด|๋ณด์—ฌ์ค˜|์•Œ๋ ค์ค˜|๋ญ์•ผ|what/i, 1]],
96
91
  },
97
92
  {
98
93
  id: 'check',
99
94
  label: '์ƒํƒœ ํ™•์ธยท๊ฒ€์ฆ',
100
95
  agent: 'haiku-explore',
101
- re: /ํ™•์ธ|๋งž์•„\?|๋˜๋‚˜|๋๋‚˜|๊ดœ์ฐฎ|์ฒดํฌ|check|verify|status|์ ๊ฒ€/i,
96
+ kw: [[/ํ™•์ธ|๊ฒ€์ฆ|verify|์ ๊ฒ€/i, 2], [/๋งž์•„\?|๋˜๋‚˜|๋๋‚˜|๋์–ด|๋˜๋Š”์ง€|๊ดœ์ฐฎ|์ฒดํฌ|check|status/i, 1]],
97
+ },
98
+ {
99
+ id: 'run',
100
+ label: '๋ช…๋ น ์‹คํ–‰ (๋นŒ๋“œยทํ…Œ์ŠคํŠธยทgit)',
101
+ agent: 'haiku-runner',
102
+ kw: [[/git |commit|push|npm |pip|๋นŒ๋“œํ•ด|๋นŒ๋“œ ๋Œ/i, 2], [/์‹คํ–‰|๋Œ๋ ค|run |build|๋นŒ๋“œ|ํ…Œ์ŠคํŠธ|์„ค์น˜/i, 1]],
102
103
  },
103
104
  ];
104
105
 
106
+ // โ”€โ”€ Behavior signal (1st) โ€” what the episode actually DID โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
107
+ // The tool-call histogram is ground truth the prompt's wording is not:
108
+ // "ํ…Œ์ŠคํŠธ ํ†ต๊ณผํ–ˆ๋Š”์ง€ ํ™•์ธํ•ด์ค˜" that actually ran `npx playwright test` IS a
109
+ // run episode regardless of phrasing. Keyword scores only pick within (or,
110
+ // when behavior is inconclusive, across) the plausible pool.
111
+ const RUN_TOOLS = new Set(['Bash']);
112
+ const LOOKUP_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch']);
113
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
114
+ const MIN_BEHAVIOR_CALLS = 2; // fewer calls than this โ†’ too little behavior to trust
115
+
116
+ /**
117
+ * Narrow the candidate categories from the episode's tool mix.
118
+ * Returns an array of category ids (ordered โ€” first is the default when
119
+ * keywords stay silent), or null when behavior is inconclusive.
120
+ */
121
+ export function behaviorPool(toolCounts) {
122
+ let run = 0, lookup = 0, write = 0, total = 0;
123
+ for (const [name, n] of Object.entries(toolCounts || {})) {
124
+ total += n;
125
+ if (RUN_TOOLS.has(name)) run += n;
126
+ else if (LOOKUP_TOOLS.has(name)) lookup += n;
127
+ else if (WRITE_TOOLS.has(name)) write += n;
128
+ }
129
+ if (total < MIN_BEHAVIOR_CALLS) return null;
130
+ if (run > lookup + write) return ['run'];
131
+ if (lookup > run + write) return ['explore', 'read', 'check', 'translate'];
132
+ return null; // mixed / write-heavy โ€” no reliable verdict, keywords decide
133
+ }
134
+
135
+ /** Weighted keyword score for one category (0 when it has no kw table). */
136
+ function keywordScore(cat, text) {
137
+ if (!cat.kw) return 0;
138
+ let score = 0;
139
+ for (const [re, w] of cat.kw) if (re.test(text)) score += w;
140
+ return score;
141
+ }
142
+
105
143
  // Episodes that are not user-delegable requests: bare continuations, injected
106
144
  // notifications, image pastes. These are easy but there is nothing to route.
107
145
  const SKIP_RE = /^(๊ณ„์†|์ด์–ด์„œ|continue|๋‹ค์Œ|proceed|์ง„ํ–‰|์‘|๋„ค|๋„ต|ok|okay|yes|ใ„ฑ+|๊ณ ๊ณ )\b/i;
@@ -127,10 +165,27 @@ export function mungeProjectPath(p) {
127
165
  return String(p).replace(/[^a-zA-Z0-9-]/g, '-');
128
166
  }
129
167
 
130
- function categorize(text) {
168
+ /**
169
+ * Classify an episode. Gates (paste by length) run first, then the tool-mix
170
+ * behavior signal narrows the candidate pool, then weighted keyword scores
171
+ * pick within it (highest score wins; ties fall back to CATEGORIES order).
172
+ * A behavior verdict without any keyword hit still classifies (pool's first
173
+ * id); no behavior AND no keyword hit โ†’ null (nothing delegable to name).
174
+ */
175
+ export function categorize(text, toolCounts) {
131
176
  if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
132
- for (const c of CATEGORIES) if (c.re && c.re.test(text)) return c;
133
- return null;
177
+ const pool = behaviorPool(toolCounts);
178
+ const eligible = pool
179
+ ? pool.map((id) => CATEGORIES.find((c) => c.id === id))
180
+ : CATEGORIES.filter((c) => c.kw);
181
+ let best = null;
182
+ let bestScore = 0;
183
+ for (const c of eligible) {
184
+ const s = keywordScore(c, text);
185
+ if (s > bestScore) { best = c; bestScore = s; }
186
+ }
187
+ if (best) return best;
188
+ return pool ? eligible[0] : null;
134
189
  }
135
190
 
136
191
  function isSkippable(text) {
@@ -146,7 +201,7 @@ function toEpisodes(records) {
146
201
  for (const r of records) {
147
202
  const text = (r.userText || '').trim();
148
203
  if (!cur || cur.text !== text) {
149
- cur = { text, calls: 0, out: 0, mutating: 0, errors: 0, delegated: 0, models: new Set(), cwd: '' };
204
+ cur = { text, calls: 0, out: 0, mutating: 0, errors: 0, delegated: 0, models: new Set(), cwd: '', tools: {} };
150
205
  episodes.push(cur);
151
206
  }
152
207
  cur.calls += 1;
@@ -154,6 +209,9 @@ function toEpisodes(records) {
154
209
  cur.mutating += r.mutatingToolCalls || 0;
155
210
  cur.errors += r.toolErrors || 0;
156
211
  cur.delegated += r.delegationCalls || 0;
212
+ for (const [name, n] of Object.entries(r.toolCounts || {})) {
213
+ cur.tools[name] = (cur.tools[name] || 0) + n;
214
+ }
157
215
  cur.models.add(r.model);
158
216
  if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
159
217
  }
@@ -244,17 +302,20 @@ export async function runRouteScan({ days = 14 } = {}) {
244
302
  for (const { ep, projectDir } of all) {
245
303
  if (isSkippable(ep.text)) continue;
246
304
  if (![...ep.models].some(isExpensiveModel)) continue; // already cheap
247
- const cat = categorize(ep.text);
305
+ const cat = categorize(ep.text, ep.tools);
248
306
  if (cat) {
249
307
  // rule-health denominator: episodes that LOOK delegable by shape
250
308
  // (tier judged with the error signal zeroed โ€” using real errors here
251
309
  // would be circular, since T2 requires errors=0 by definition). The
252
310
  // numerator is those that still hit errors: exactly the "light-looking
253
311
  // work in this category keeps failing" risk a delegation rule cares about.
312
+ // Keyed by tier as well: a category can carry both a T2 and a T1 rule,
313
+ // and sharing one category-wide stat would double-count every episode
314
+ // into both rules (identical ร—N / err% on unrelated tiers).
254
315
  const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
255
316
  if (shapeTier === 'T1' || shapeTier === 'T2') {
256
- bumpStats(`${cat.id}|${projectDir}`, ep);
257
- bumpStats(`${cat.id}|*`, ep);
317
+ bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
318
+ bumpStats(`${shapeTier}|${cat.id}|*`, ep);
258
319
  }
259
320
  }
260
321
  const tier = tierOf(ep, cat, thresholds);
@@ -299,8 +360,8 @@ export async function runRouteScan({ days = 14 } = {}) {
299
360
  (r.scope === 'global' || r.project === g.project));
300
361
 
301
362
  const ruleText = (g) => g.tier === 'T2'
302
- ? `"${g.label}" ์œ ํ˜•์˜ ๋‹จ์ˆœ ์š”์ฒญ(์˜ˆ: "${g.example}")์€ ${g.agent}(haiku) ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์œ„์ž„ํ•œ๋‹ค`
303
- : `"${g.label}" ์œ ํ˜•์˜ ์ค‘๊ฐ„ ๋‚œ๋„ ์š”์ฒญ(์˜ˆ: "${g.example}")์€ model: sonnet ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์œ„์ž„ํ•œ๋‹ค (์„ค๊ณ„ ํŒ๋‹จยท๋ฐ˜๋ณต ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋ฉ”์ธ ๋ชจ๋ธ์ด ์ด์–ด๋ฐ›์Œ)`;
363
+ ? `"${g.label}" ์œ ํ˜•์˜ ๋‹จ์ˆœ ์š”์ฒญ(์˜ˆ: "${g.example}")์€ ${g.agent}(haiku) ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์œ„์ž„ํ•œ๋‹ค (์„ค๊ณ„ ํŒ๋‹จยท๋ฐฐํฌยท์Šคํ† ์–ด ์ œ์ถœ ๊ฐ™์€ ๋น„๊ฐ€์—ญ ์ž‘์—…์ด ์„ž์ด๋ฉด ์œ„์ž„ํ•˜์ง€ ์•Š์Œ)`
364
+ : `"${g.label}" ์œ ํ˜•์˜ ์ค‘๊ฐ„ ๋‚œ๋„ ์š”์ฒญ(์˜ˆ: "${g.example}")์€ model: sonnet ์„œ๋ธŒ์—์ด์ „ํŠธ๋กœ ์œ„์ž„ํ•œ๋‹ค (์„ค๊ณ„ ํŒ๋‹จยท๋น„๊ฐ€์—ญ ์ž‘์—…ยท๋ฐ˜๋ณต ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋ฉ”์ธ ๋ชจ๋ธ์ด ์ด์–ด๋ฐ›์Œ)`;
304
365
 
305
366
  const candidates = [...groups.values()]
306
367
  .filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
@@ -79,14 +79,18 @@ export async function collectSessionRecords(filePath, { includeContent = true }
79
79
 
80
80
  let mutatingToolCalls = 0;
81
81
  let delegationCalls = 0;
82
+ const prev = records.get(reqId);
83
+ // Per-tool call histogram โ€” what the call actually DID. route-scan's
84
+ // categorizer trusts this over the prompt's wording (behavior-first).
85
+ const toolCounts = { ...(prev?.toolCounts || {}) };
82
86
  if (Array.isArray(msg.content)) {
83
87
  for (const b of msg.content) {
84
88
  if (!b || b.type !== 'tool_use') continue;
85
89
  if (MUTATING_TOOLS.has(b.name)) mutatingToolCalls += 1;
86
90
  if (DELEGATION_TOOLS.has(b.name)) delegationCalls += 1;
91
+ if (typeof b.name === 'string') toolCounts[b.name] = (toolCounts[b.name] || 0) + 1;
87
92
  }
88
93
  }
89
- const prev = records.get(reqId);
90
94
 
91
95
  lastRecord = {
92
96
  model: normalizeModelId(msg.model),
@@ -104,6 +108,7 @@ export async function collectSessionRecords(filePath, { includeContent = true }
104
108
  mutatingToolCalls: (prev?.mutatingToolCalls || 0) + mutatingToolCalls,
105
109
  delegationCalls: (prev?.delegationCalls || 0) + delegationCalls,
106
110
  toolErrors: prev?.toolErrors || 0,
111
+ toolCounts,
107
112
  };
108
113
  records.set(reqId, lastRecord);
109
114
  }