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 +31 -21
- package/README.md +31 -21
- package/bin/cli.js +89 -96
- package/package.json +1 -1
- package/src/harness-templates.js +4 -0
- package/src/harness.js +2 -1
- package/src/model-rules.js +174 -0
- package/src/route-scan.js +206 -58
- package/src/session-records.js +112 -0
- package/src/frugon-export.js +0 -211
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
|
-
|
|
141
|
+
## 🔀 route-scan — "this recurring task could run on a cheaper tier"
|
|
144
142
|
|
|
145
|
-
|
|
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
|
-
-
|
|
152
|
-
- **
|
|
153
|
-
-
|
|
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
|
-
|
|
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) +
|
|
162
|
-
claude-token-saver harness promote R1 --project # promote candidate R1 to a
|
|
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
|
|
168
|
-
2. When a recurring (≥3×)
|
|
169
|
-
3. Promoted rules
|
|
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
|
-
|
|
122
|
+
## 🔀 route-scan — "이 반복 작업, 더 싼 티어로 내려도 됩니다"
|
|
125
123
|
|
|
126
|
-
|
|
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
|
-
-
|
|
133
|
-
-
|
|
134
|
-
-
|
|
135
|
-
- frugon 설치: `pipx install frugon` (모델이 unpriced로 나오면 `frugon update`).
|
|
126
|
+
- **T2 → haiku**: 적은 호출·작은 출력·변경 거의 없음·에러 0으로 끝난 요청 (탐색·조회, 붙여넣은 화면 질문, 단순 실행 등)
|
|
127
|
+
- **T1 → sonnet**: 중간 출력·변경 소수·에러 ≤1 (빌드 파이프라인, 상태 점검 등)
|
|
128
|
+
- **T0 유지**: 에러 반복, 대량 변경, 큰 출력, 설계·분석 키워드 — 세션 모델이 계속 담당
|
|
136
129
|
|
|
137
|
-
|
|
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` 때 캐시된 스캔 결과를 세션 컨텍스트로
|
|
149
|
-
2. 반복(≥3회)
|
|
150
|
-
3. 등록된 룰은
|
|
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 (
|
|
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)이 처리한 반복
|
|
544
|
+
lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 상위 모델(opus/fable)이 처리한 위임 가능 반복 작업이 감지되었습니다:`);
|
|
516
545
|
for (const c of open) {
|
|
517
|
-
|
|
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') ||
|
|
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
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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,
|
|
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
package/src/harness-templates.js
CHANGED
|
@@ -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
|
+
}
|