claude-token-saver 3.0.0 → 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/src/harness.js CHANGED
@@ -278,79 +278,58 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
278
278
  }
279
279
 
280
280
  /**
281
- * harness pull — copy the user's GLOBAL ratchet rules (~/.claude/ratchet.md)
282
- * into the current project's .claude/ratchet.md, on demand. Opt-in by design:
283
- * install/init never auto-injects rules; this is the explicit "땡겨오기" verb.
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.
284
283
  *
285
- * Rules are deduped by text (ignoring the leading YYYY-MM-DD stamp) so
286
- * repeated pulls are idempotent. With `includeBlock`, the global CLAUDE.md
287
- * harness block (including any user customizations) is also copied into the
288
- * project CLAUDE.md — replacing the project's block if one exists.
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
289
  *
290
- * Returns { root, added, skippedRules, wrote, skipped }.
290
+ * Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
291
+ * Returns { path, scope, added, skippedRules, presets }.
291
292
  */
292
- export function harnessPull({ root = findProjectRoot(), includeBlock = false } = {}) {
293
- const result = { root, added: [], skippedRules: 0, wrote: [], skipped: [] };
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 };
294
297
  const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
295
298
 
296
- // 1) Ratchet rules: global → project, dedup by rule text.
297
- const globalRules = harnessListRules({ scope: 'global' }).rules;
298
- const projPath = ratchetMdPath(root);
299
- let content = existsSync(projPath)
300
- ? readFileSync(projPath, 'utf8')
299
+ let content = existsSync(rmPath)
300
+ ? readFileSync(rmPath, 'utf8')
301
301
  : harnessRatchetMdInitial();
302
302
  const have = new Set(
303
- harnessListRules({ root, scope: 'project' }).rules.map((r) => stripDate(r.text)),
303
+ harnessListRules({ root, scope }).rules.map((r) => stripDate(r.text)),
304
304
  );
305
- for (const g of globalRules) {
306
- const key = stripDate(g.text);
307
- if (have.has(key)) {
305
+ for (const rule of presets) {
306
+ if (have.has(rule)) {
308
307
  result.skippedRules += 1;
309
308
  continue;
310
309
  }
311
- content = appendRatchetRule(content, key);
312
- have.add(key);
313
- result.added.push(key);
310
+ content = appendRatchetRule(content, rule);
311
+ have.add(rule);
312
+ result.added.push(rule);
314
313
  }
315
314
  if (result.added.length) {
316
- mkdirSync(dirname(projPath), { recursive: true });
317
- writeFileSync(projPath, content);
318
- result.wrote.push(projPath);
315
+ mkdirSync(dirname(rmPath), { recursive: true });
316
+ writeFileSync(rmPath, content);
319
317
  }
318
+ return result;
319
+ }
320
320
 
321
- // 2) Harness block (opt-in): copy the global block as-is so user edits to
322
- // the global sections travel with it.
323
- if (includeBlock) {
324
- const gPath = globalClaudeMdPath();
325
- const blockRe = new RegExp(
326
- `${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
327
- 'm',
328
- );
329
- const gContent = existsSync(gPath) ? readFileSync(gPath, 'utf8') : '';
330
- const m = gContent.match(blockRe);
331
- if (!m) {
332
- result.skipped.push(`${gPath} (no global harness block to pull)`);
333
- } else {
334
- const block = m[0];
335
- const pPath = claudeMdPath(root);
336
- if (existsSync(pPath)) {
337
- const pc = readFileSync(pPath, 'utf8');
338
- if (pc.includes(HARNESS_BLOCK_BEGIN)) {
339
- writeFileSync(pPath, pc.replace(blockRe, block));
340
- result.wrote.push(`${pPath} (harness block replaced with global copy)`);
341
- } else {
342
- const sep = pc.endsWith('\n') ? '\n' : '\n\n';
343
- writeFileSync(pPath, pc + sep + block);
344
- result.wrote.push(`${pPath} (harness block appended from global)`);
345
- }
346
- } else {
347
- writeFileSync(pPath, block);
348
- result.wrote.push(pPath);
349
- }
350
- }
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 [];
351
332
  }
352
-
353
- return result;
354
333
  }
355
334
 
356
335
  /**
@@ -365,7 +344,8 @@ export function harnessListRules({ root = findProjectRoot(), scope = 'project' }
365
344
  for (let i = 0; i < lines.length; i++) {
366
345
  const line = lines[i];
367
346
  // A "rule line" starts with "- " (markdown bullet). Header lines, blanks,
368
- // 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.)
369
349
  if (/^\s*-\s+/.test(line)) {
370
350
  rules.push({ index: rules.length + 1, lineNo: i, text: line.replace(/^\s*-\s+/, '') });
371
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
+ }
package/src/route-scan.js CHANGED
@@ -2,38 +2,74 @@
2
2
  * route-scan — detect recurring "easy" work running on expensive models and
3
3
  * propose model-delegation ratchet rules.
4
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.
5
+ * Difficulty is judged at the EPISODE level (one user request = the
6
+ * consecutive API calls it triggered), because per-call scoring saturates on
7
+ * Claude Code's large session contexts — prompt size carries no signal when
8
+ * every call ships a 100k+ cached prefix. An episode is easy when the whole
9
+ * request finished in few calls with little generation — exactly the work a
10
+ * haiku subagent could take.
10
11
  *
11
12
  * Fully local, zero token cost. Results are cached (24h) so the SessionStart
12
13
  * hook can read them without re-parsing a month of transcripts.
13
14
  *
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`).
15
+ * Pipeline position (per design discussion): this scan is NOT a real-time
16
+ * router — it is a session-boundary calibrator that feeds the existing
17
+ * ratchet promote flow (`harness promote R<N> --project|--global`).
17
18
  */
18
19
 
19
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
20
21
  import { join } from 'node:path';
21
22
  import { homedir } from 'node:os';
22
23
  import { discoverSessionFiles } from './parser.js';
23
- import { collectSessionRecords } from './frugon-export.js';
24
+ import { collectSessionRecords } from './session-records.js';
24
25
 
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;
26
+ // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
27
+ // T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
28
+ // errors. T1 (sonnet): moderate output/mutation, at most one tool error.
29
+ // T0: everything else stays on the session model. Output-token thresholds
30
+ // are calibrated per-user from their own 14-day distribution (fixed
31
+ // thresholds drift with workload — RouteLLM's stated limitation), clamped
32
+ // to sane ranges so a skewed window can't stretch them absurdly.
33
+ export const T2_MAX_CALLS = 6;
34
+ export const T2_MAX_MUTATING = 2;
35
+ export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
36
+ export const T1_MAX_MUTATING = 6;
37
+ export const T1_MAX_ERRORS = 1;
38
+ export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
39
+ export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
40
+ export const T0_MIN_MUTATING = 7;
41
+ // Episodes below this output size carry no delegable work (conversational
42
+ // acks, feedback) — skip entirely.
43
+ export const MIN_DELEGABLE_OUT = 100;
44
+ // Escalation keywords: design/analysis judgement stays on the top tier.
45
+ export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜 |analyze|compare|evaluate|architect|refactor/i;
29
46
  // A pattern must recur this often before we nag about it.
30
47
  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
48
 
34
- // Category → recommended haiku subagent. First match wins; order matters
35
- // (translate before read: "번역해줘" also matches the read keywords).
49
+ // ── Rescan gate (data-driven, not time-driven) ───────────────────────────
50
+ // A scan over unchanged transcripts is deterministic — identical output —
51
+ // so time alone is a bad trigger: it wastes scans on idle days and lags a
52
+ // full day behind heavy ones. Instead we rescan when enough NEW transcript
53
+ // data accumulated (~5MB ≈ 20-40 episodes on measured data — enough for a
54
+ // pattern to newly cross MIN_RECURRENCE), with guardrails: a minimum
55
+ // interval against session-churn thrash, a daily fallback so small trickles
56
+ // still refresh rule-health, and a hard skip when nothing changed at all.
57
+ export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than hourly
58
+ export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data → rescan now
59
+ export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old → rescan
60
+
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).
65
+ const PASTE_MIN_LEN = 400;
36
66
  const CATEGORIES = [
67
+ {
68
+ id: 'paste',
69
+ label: '붙여넣은 화면·로그 질문',
70
+ agent: 'haiku-explore',
71
+ re: null, // matched by length, see categorize()
72
+ },
37
73
  {
38
74
  id: 'translate',
39
75
  label: '배치 번역·정형 텍스트 변환',
@@ -92,7 +128,8 @@ export function mungeProjectPath(p) {
92
128
  }
93
129
 
94
130
  function categorize(text) {
95
- for (const c of CATEGORIES) if (c.re.test(text)) return c;
131
+ 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;
96
133
  return null;
97
134
  }
98
135
 
@@ -109,11 +146,14 @@ function toEpisodes(records) {
109
146
  for (const r of records) {
110
147
  const text = (r.userText || '').trim();
111
148
  if (!cur || cur.text !== text) {
112
- cur = { text, calls: 0, out: 0, models: new Set(), cwd: '' };
149
+ cur = { text, calls: 0, out: 0, mutating: 0, errors: 0, delegated: 0, models: new Set(), cwd: '' };
113
150
  episodes.push(cur);
114
151
  }
115
152
  cur.calls += 1;
116
153
  cur.out += r.completion_tokens;
154
+ cur.mutating += r.mutatingToolCalls || 0;
155
+ cur.errors += r.toolErrors || 0;
156
+ cur.delegated += r.delegationCalls || 0;
117
157
  cur.models.add(r.model);
118
158
  if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
119
159
  }
@@ -124,67 +164,139 @@ function isExpensiveModel(model) {
124
164
  return !/haiku/i.test(model);
125
165
  }
126
166
 
167
+ const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
168
+ const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
169
+
170
+ /**
171
+ * Per-user output-token thresholds from this window's episode distribution.
172
+ * Falls back to mid-clamp defaults when the sample is too small to trust.
173
+ */
174
+ export function calibrateThresholds(episodeOuts) {
175
+ if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
176
+ const sorted = [...episodeOuts].sort((a, b) => a - b);
177
+ return {
178
+ t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
179
+ t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
180
+ calibrated: true,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
186
+ * or null when the episode carries nothing delegable. Order matters: hard
187
+ * evidence (errors, heavy mutation, big output, judgement keywords) wins
188
+ * before any cheap-band check.
189
+ */
190
+ export function tierOf(ep, category, th) {
191
+ if (ep.out < MIN_DELEGABLE_OUT) return null;
192
+ if (
193
+ ep.errors >= T0_MIN_ERRORS ||
194
+ ep.mutating >= T0_MIN_MUTATING ||
195
+ ep.out > th.t1Out ||
196
+ ESCALATE_RE.test(ep.text)
197
+ ) return 'T0';
198
+ if (!category || ep.delegated > 0) return 'T0';
199
+ if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
200
+ if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
201
+ return 'T0';
202
+ }
203
+
127
204
  /**
128
205
  * Scan transcripts and build delegation candidates.
129
206
  * Returns the cache object (also written to disk).
130
207
  */
131
208
  export async function runRouteScan({ days = 14 } = {}) {
132
209
  const files = await discoverSessionFiles({ days });
133
- const groups = new Map(); // "category|project" → aggregate
134
- let totalEpisodes = 0;
135
- let easyEpisodes = 0;
136
210
 
211
+ // Pass 1 — collect episodes (needed up front: thresholds are calibrated
212
+ // from the full window's output distribution before any tiering).
213
+ const all = []; // { ep, projectDir }
214
+ let dataBytes = 0; // window size snapshot — the rescan gate diffs against it
137
215
  for (const f of files) {
138
216
  let records;
139
217
  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 });
218
+ dataBytes += statSync(f.path).size;
219
+ records = await collectSessionRecords(f.path, { includeContent: true });
143
220
  } catch {
144
221
  continue;
145
222
  }
146
223
  for (const ep of toEpisodes(records)) {
147
224
  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, ' ');
225
+ all.push({ ep, projectDir: f.projectDir });
226
+ }
227
+ }
228
+ const totalEpisodes = all.length;
229
+ const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
230
+
231
+ // Pass 2 — tier, group by tier×category×project, and accumulate the
232
+ // per-category outcome stats that keep promoted model rules fresh.
233
+ const groups = new Map(); // "tier|category|project" → aggregate
234
+ const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
235
+ let tieredEpisodes = 0;
236
+ const bumpStats = (key, ep) => {
237
+ const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0 };
238
+ s.count += 1;
239
+ s.epCount += 1;
240
+ if (ep.errors > 0) s.errCount += 1;
241
+ episodeStats.set(key, s);
242
+ };
243
+
244
+ for (const { ep, projectDir } of all) {
245
+ if (isSkippable(ep.text)) continue;
246
+ if (![...ep.models].some(isExpensiveModel)) continue; // already cheap
247
+ const cat = categorize(ep.text);
248
+ if (cat) {
249
+ // rule-health denominator: episodes that LOOK delegable by shape
250
+ // (tier judged with the error signal zeroed — using real errors here
251
+ // would be circular, since T2 requires errors=0 by definition). The
252
+ // numerator is those that still hit errors: exactly the "light-looking
253
+ // work in this category keeps failing" risk a delegation rule cares about.
254
+ const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
255
+ if (shapeTier === 'T1' || shapeTier === 'T2') {
256
+ bumpStats(`${cat.id}|${projectDir}`, ep);
257
+ bumpStats(`${cat.id}|*`, ep);
172
258
  }
173
- groups.set(key, g);
174
259
  }
260
+ const tier = tierOf(ep, cat, thresholds);
261
+ if (tier !== 'T1' && tier !== 'T2') continue;
262
+ tieredEpisodes += 1;
263
+ const key = `${tier}|${cat.id}|${projectDir}`;
264
+ const g = groups.get(key) || {
265
+ tier,
266
+ category: cat.id,
267
+ label: cat.label,
268
+ agent: tier === 'T2' ? cat.agent : 'sonnet',
269
+ project: projectDir,
270
+ projectPath: '',
271
+ count: 0,
272
+ models: new Set(),
273
+ example: '',
274
+ };
275
+ g.count += 1;
276
+ for (const m of ep.models) g.models.add(m);
277
+ if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
278
+ if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
279
+ g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
280
+ }
281
+ groups.set(key, g);
175
282
  }
176
283
 
177
284
  // Keep prior dismissed/promoted signatures across rescans.
178
285
  const prev = readRouteScan();
179
286
  const resolved = new Set(prev?.resolved || []);
180
287
 
288
+ const ruleText = (g) => g.tier === 'T2'
289
+ ? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다`
290
+ : `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·반복 에러 발생 시 메인 모델이 이어받음)`;
291
+
181
292
  const candidates = [...groups.values()]
182
293
  .filter((g) => g.count >= MIN_RECURRENCE)
183
294
  .sort((a, b) => b.count - a.count)
184
- .slice(0, 5)
295
+ .slice(0, 8)
185
296
  .map((g, i) => ({
186
297
  id: i + 1,
187
- signature: `${g.category}|${g.project}`,
298
+ signature: `${g.tier}|${g.category}|${g.project}`,
299
+ tier: g.tier,
188
300
  category: g.category,
189
301
  label: g.label,
190
302
  agent: g.agent,
@@ -200,7 +312,7 @@ export async function runRouteScan({ days = 14 } = {}) {
200
312
  // project already, so scope suggestion is per-candidate 'project' unless
201
313
  // the same category recurs across 2+ projects (then 'global').
202
314
  suggestedScope: 'project',
203
- rule: `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다`,
315
+ rule: ruleText(g),
204
316
  }));
205
317
 
206
318
  // Same category appearing in 2+ projects → suggest global for each.
@@ -216,7 +328,10 @@ export async function runRouteScan({ days = 14 } = {}) {
216
328
  scannedAt: new Date().toISOString(),
217
329
  days,
218
330
  totalEpisodes,
219
- easyEpisodes,
331
+ dataBytes,
332
+ // kept as `easyEpisodes` for statusline/back-compat; now counts T1+T2.
333
+ easyEpisodes: tieredEpisodes,
334
+ thresholds,
220
335
  candidates,
221
336
  resolved: [...resolved],
222
337
  };
@@ -227,6 +342,15 @@ export async function runRouteScan({ days = 14 } = {}) {
227
342
  } catch {
228
343
  // best-effort — scan results are still returned
229
344
  }
345
+
346
+ // Continuous update (user requirement): every rescan refreshes promoted
347
+ // model-fitting rules from the new window — recurrence counts, error
348
+ // rates, and rule-health flags — and rewrites their managed blocks.
349
+ try {
350
+ const { refreshModelRules } = await import('./model-rules.js');
351
+ refreshModelRules(episodeStats, { now: cache.scannedAt });
352
+ } catch { /* registry unwritable — scan result still valid */ }
353
+
230
354
  return cache;
231
355
  }
232
356
 
@@ -239,10 +363,34 @@ export function readRouteScan() {
239
363
  }
240
364
  }
241
365
 
242
- export function isCacheFresh(cache) {
243
- if (!cache?.scannedAt) return false;
366
+ /**
367
+ * Data-driven rescan gate (see constants above). Cheap: one stat() per
368
+ * transcript file (~32 files on measured data) — a few milliseconds.
369
+ */
370
+ export async function shouldRescan(cache, { days = 14 } = {}) {
371
+ if (!cache?.scannedAt) return true;
244
372
  const ts = Date.parse(cache.scannedAt);
245
- return Number.isFinite(ts) && Date.now() - ts < CACHE_TTL_MS;
373
+ if (!Number.isFinite(ts)) return true;
374
+ const age = Date.now() - ts;
375
+ if (age < RESCAN_MIN_INTERVAL_MS) return false;
376
+
377
+ let total = 0;
378
+ let anyNew = false;
379
+ try {
380
+ for (const f of await discoverSessionFiles({ days })) {
381
+ const s = statSync(f.path);
382
+ total += s.size;
383
+ if (s.mtimeMs > ts) anyNew = true;
384
+ }
385
+ } catch {
386
+ return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
387
+ }
388
+ if (!anyNew) return false; // nothing changed → identical scan, skip forever
389
+ // Append-only transcripts: window growth ≈ new data. Files aging out of
390
+ // the window shrink the total, making this estimate conservative.
391
+ const newBytes = Math.max(0, total - (cache.dataBytes || 0));
392
+ if (newBytes >= RESCAN_BIG_DELTA_BYTES) return true;
393
+ return age >= RESCAN_MAX_AGE_MS;
246
394
  }
247
395
 
248
396
  /** Candidates not yet promoted/dismissed. */