claude-token-saver 3.31.0 → 3.34.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.
@@ -137,9 +137,17 @@ export async function run({ args, hasFlag, numArg }) {
137
137
  const health = r.status === 'review'
138
138
  ? (lang === 'ko' ? ' ⚠ 에러율 초과 — 재검토 필요' : ' ⚠ error rate over threshold — needs review')
139
139
  : '';
140
+ // A seeded rule has no recurrence of its own until a scan measures one,
141
+ // so "반복 0회 / seen ×0" would misreport a curated preset as work that
142
+ // never happens.
143
+ const evidence = r.origin === 'preset' && !r.count
144
+ ? (lang === 'ko' ? '동봉 프리셋 (로그 누적 전)' : 'bundled preset (no local history yet)')
145
+ : (lang === 'ko'
146
+ ? `반복 ${r.count || 0}회 · 에러율 ${Math.round((r.errRate || 0) * 100)}%`
147
+ : `seen ×${r.count || 0} · err ${Math.round((r.errRate || 0) * 100)}%`);
140
148
  const stat = lang === 'ko'
141
- ? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} · 반복 ${r.count || 0}회 · 에러율 ${Math.round((r.errRate || 0) * 100)}%`
142
- : `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} · seen ×${r.count || 0} · err ${Math.round((r.errRate || 0) * 100)}%`;
149
+ ? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} · ${evidence}`
150
+ : `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} · ${evidence}`;
143
151
  console.log(` #${i + 1} ${stat}${health}`);
144
152
  // Measured outcome of the rule actually firing, plus what it saved.
145
153
  // A rule with no measured delegations shows "—", never "$0.00": the
@@ -254,6 +262,19 @@ export async function run({ args, hasFlag, numArg }) {
254
262
  if (doc2mdHookRegistered()) doc2mdBlock = require('../doc2md.cjs').sessionNote(lang);
255
263
  } catch (e) { debug('route-scan:doc2md-note', e); /* the note is optional */ }
256
264
 
265
+ // Starter rules that the user has not answered yet (bundled presets, both
266
+ // model-fitting and ratchet). A fresh install has an empty model ratchet
267
+ // and an empty ratchet.md, so without this the tool delegates nothing
268
+ // until the user's own history is long enough to propose a candidate.
269
+ // The asking happens here because a CLI prompt cannot reach a user whose
270
+ // only interface is the chat: the model gets the list and asks one rule
271
+ // at a time. Silent — and free — once everything has an answer.
272
+ let seedBlock = null;
273
+ try {
274
+ const { seedOfferBlock } = await import('../seed-rules.js');
275
+ seedBlock = seedOfferBlock({ lang });
276
+ } catch (e) { debug('route-scan:seed-offer', e); /* the offer is optional */ }
277
+
257
278
  // Upgrade offer. A statusline cannot open a dialog, so session start is
258
279
  // where the *asking* happens: the model gets one line telling it a newer
259
280
  // version exists and to ask before installing anything. Cached read only
@@ -288,11 +309,13 @@ export async function run({ args, hasFlag, numArg }) {
288
309
 
289
310
  if (open.length === 0 && reviewRules.length === 0) {
290
311
  if (updateBlock) console.log(updateBlock);
312
+ if (seedBlock) console.log(seedBlock);
291
313
  if (doc2mdBlock) console.log(doc2mdBlock);
292
314
  if (koreanBlock) console.log(koreanBlock);
293
315
  return; // nothing else to inject
294
316
  }
295
317
  if (updateBlock) console.log(updateBlock);
318
+ if (seedBlock) console.log(seedBlock);
296
319
  if (doc2mdBlock) console.log(doc2mdBlock);
297
320
  // This text is injected straight into the model's context, so it must
298
321
  // follow the user's configured language — a Korean-only briefing in an
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Subcommand: seed — register the bundled starter rules, one answer at a time.
3
+ *
4
+ * claude-token-saver seed # what is still pending
5
+ * claude-token-saver seed accept <id> --global # register one rule
6
+ * claude-token-saver seed accept <id> --project # ... into this project only
7
+ * claude-token-saver seed accept all --global # when the user says "all of them"
8
+ * claude-token-saver seed skip <id> | skip all # never offer it again
9
+ * claude-token-saver seed reset # make every preset pending again
10
+ *
11
+ * Scope is explicit on purpose, exactly as `harness promote` requires it: the
12
+ * hook environment is non-TTY, so the model has to ask the user and pass the
13
+ * flag rather than let a default decide where a rule lands.
14
+ */
15
+
16
+ export async function run({ args, hasFlag }) {
17
+ const sub = args[1];
18
+ const seed = await import('../seed-rules.js');
19
+ const { userLanguage } = await import('../config.js');
20
+ const { findProjectRoot } = await import('../harness.js');
21
+ const lang = userLanguage();
22
+ const ko = lang === 'ko';
23
+ const root = findProjectRoot();
24
+
25
+ const scopeFlag = () => (hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null);
26
+
27
+ if (sub === 'reset') {
28
+ const n = seed.resetSeeds();
29
+ console.log(ko
30
+ ? `기록된 응답 ${n}건을 지웠습니다 — 프리셋 전체가 다시 제안 대상이 됩니다.`
31
+ : `Cleared ${n} recorded answer(s) — every preset is pending again.`);
32
+ return;
33
+ }
34
+
35
+ if (sub === 'accept' || sub === 'skip') {
36
+ const ids = args.slice(2).filter((a) => !a.startsWith('-'));
37
+ if (ids.length === 0) {
38
+ console.error(ko
39
+ ? 'id를 지정하십시오 (목록: claude-token-saver seed). 전체는 `all`.'
40
+ : 'Pass an id (list them with `claude-token-saver seed`), or `all`.');
41
+ process.exit(1);
42
+ }
43
+ const pending = seed.pendingSeeds({ lang, root });
44
+ const targets = ids.includes('all') ? pending.map((s) => s.id) : ids;
45
+
46
+ if (sub === 'skip') {
47
+ for (const id of targets) {
48
+ const r = seed.skipSeed(id, { lang, root });
49
+ console.log(r
50
+ ? (ko ? `건너뜀: ${id}` : `skipped: ${id}`)
51
+ : (ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`));
52
+ }
53
+ return;
54
+ }
55
+
56
+ const scope = scopeFlag();
57
+ if (!scope) {
58
+ console.error(ko
59
+ ? '적용 범위를 명시하십시오 (사용자에게 먼저 확인): --global 또는 --project'
60
+ : 'Scope required (confirm it with the user first): --global or --project');
61
+ process.exit(1);
62
+ }
63
+ for (const id of targets) {
64
+ const r = await seed.acceptSeed(id, { scope, root, lang });
65
+ if (!r) {
66
+ console.log(ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`);
67
+ continue;
68
+ }
69
+ console.log(ko
70
+ ? `✅ 등록: ${id} [${scope === 'global' ? '전체 프로젝트' : '이 프로젝트'}]`
71
+ : `✅ registered: ${id} [${scope}]`);
72
+ console.log(` ${r.rule}`);
73
+ for (const p of r.paths || (r.path ? [r.path] : [])) console.log(` → ${p}`);
74
+ }
75
+ return;
76
+ }
77
+
78
+ // Default: status. Lists what is pending and what was already answered, so a
79
+ // user who said no can see that they did and reverse it with `reset`.
80
+ const pending = seed.pendingSeeds({ lang, root });
81
+ const { decided } = seed.loadSeedState();
82
+ const answered = Object.entries(decided);
83
+ console.log(ko
84
+ ? `seed — 동봉 프리셋 중 대기 ${pending.length}건, 응답 완료 ${answered.length}건`
85
+ : `seed — ${pending.length} preset(s) pending, ${answered.length} already answered`);
86
+ for (const s of pending) {
87
+ const head = s.kind === 'model'
88
+ ? `[${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`
89
+ : `[${s.id}] ${ko ? '랫쳇 룰' : 'ratchet rule'}`;
90
+ console.log(` ${head}`);
91
+ console.log(` ${s.ruleText}`);
92
+ }
93
+ if (answered.length > 0) {
94
+ console.log(ko ? ' 응답 기록:' : ' answered:');
95
+ for (const [id, d] of answered) {
96
+ console.log(` ${id}: ${d.action}${d.scope ? ` (${d.scope})` : ''} — ${d.at}`);
97
+ }
98
+ }
99
+ if (pending.length > 0) {
100
+ console.log('');
101
+ console.log(ko
102
+ ? '등록: claude-token-saver seed accept <id> --global|--project · 거절: seed skip <id>'
103
+ : 'register: claude-token-saver seed accept <id> --global|--project · decline: seed skip <id>');
104
+ }
105
+ }
package/src/config.js CHANGED
@@ -197,6 +197,31 @@ export function userLanguage() {
197
197
  return v === 'ko' ? 'ko' : 'en';
198
198
  }
199
199
 
200
+ /**
201
+ * Whether the language is a recorded choice rather than the 'en' fallback.
202
+ *
203
+ * The install needs the distinction: a user who picked English must not be
204
+ * asked again on the next upgrade, and "nothing recorded" has to stay
205
+ * distinguishable from "chose en", which `userLanguage()` alone cannot say.
206
+ */
207
+ export function languageDecided() {
208
+ const cfg = loadConfig();
209
+ const v = cfg.language || (cfg.statusline && cfg.statusline.language);
210
+ return v === 'ko' || v === 'en';
211
+ }
212
+
213
+ /** Record the output language ('ko' | 'en'). Anything else is ignored. */
214
+ export function setUserLanguage(v) {
215
+ const lang = v === 'ko' ? 'ko' : v === 'en' ? 'en' : null;
216
+ if (!lang) return null;
217
+ const cfg = loadConfig();
218
+ cfg.language = lang;
219
+ // The legacy slot would win on a later read for configs written by 2.9.x.
220
+ if (cfg.statusline) delete cfg.statusline.language;
221
+ saveConfig(cfg);
222
+ return lang;
223
+ }
224
+
200
225
  /**
201
226
  * Render hours as the most natural unit:
202
227
  * 24h → "1d", 168h → "7d", 6h → "6h", 36h → "36h" (not whole days).
package/src/demo.js CHANGED
@@ -99,15 +99,16 @@ const SCENARIOS = [
99
99
  },
100
100
  {
101
101
  name: 'ctx-1m',
102
- label: '⚠ Context past 200k',
102
+ label: '⚠ Context past 500k',
103
103
  data: {
104
104
  hitRate: 0.78,
105
105
  pct1h: 0.92,
106
106
  savings: 1340,
107
107
  elapsedSec: 30,
108
108
  contextSize: '1M',
109
- ctxUsedPct: 28, // 28% of 1M ≈ 280k actually in context
110
- spikeChip: '⚠ Ctx 200k+',
109
+ ctxOverWarn: true,
110
+ ctxUsedPct: 62, // 62% of 1M ≈ 620k actually in context
111
+ spikeChip: '⚠ Ctx 500k+',
111
112
  caps: HEALTHY_CAPS,
112
113
  },
113
114
  },
@@ -271,9 +272,9 @@ export function buildTableDemoData(options = {}) {
271
272
  version: options.version ?? '',
272
273
  },
273
274
  spikeReport: { spikes, baseline: { p95: 940_000 } },
274
- contextWindow: { size: '1M', maxContext: 280_000 },
275
+ contextWindow: { size: '1M', maxContext: 620_000, overWarn: true },
275
276
  lastActivity: Date.now() - 60 * 1000,
276
- spikeChip: '⚠ Ctx 200k+',
277
+ spikeChip: '⚠ Ctx 500k+',
277
278
  };
278
279
  }
279
280
 
@@ -303,7 +304,7 @@ export function buildScenarioData(scenarioName, options) {
303
304
  if (!scenario) return null;
304
305
  }
305
306
 
306
- const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, ctxUsedPct, spikeChip, caps } = scenario.data;
307
+ const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, ctxOverWarn, ctxUsedPct, spikeChip, caps } = scenario.data;
307
308
  return {
308
309
  summary: { hitRate },
309
310
  ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
@@ -315,7 +316,7 @@ export function buildScenarioData(scenarioName, options) {
315
316
  version: options.version ?? '',
316
317
  },
317
318
  lastActivity: Date.now() - elapsedSec * 1000,
318
- contextWindow: { size: contextSize },
319
+ contextWindow: { size: contextSize, overWarn: Boolean(ctxOverWarn) },
319
320
  // Live fill level (`📦 68%`) — scenarios that set ctxUsedPct exercise the
320
321
  // stdin-driven segment; the rest fall back to the size-based chip.
321
322
  ctxLive: ctxUsedPct != null
@@ -545,7 +545,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
545
545
  }
546
546
  } else if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
547
547
  const label = contextWindow.size === '1M' ? '1M' : '200k';
548
- const ctxColor = contextWindow.size === '1M' ? YELLOW : GREEN;
548
+ // Yellow is reserved for the 500k warn line. A 1M window by itself is the
549
+ // default on every current model, so colouring it yellow cried wolf.
550
+ const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
549
551
  if (isIcon && verbose) {
550
552
  ctxSeg = `${c(ctxColor)}📦 Ctx ${label}${c(RESET)}`;
551
553
  } else if (isIcon) {
@@ -84,9 +84,9 @@ function renderSpikeSection(spikes, contextWindow) {
84
84
  const lines = [];
85
85
  lines.push(rbl(' ⚠ Token spike detected'));
86
86
  lines.push(r(` ${'─'.repeat(50)}`));
87
- if (contextWindow && contextWindow.size === '1M') {
87
+ if (contextWindow && contextWindow.overWarn) {
88
88
  lines.push(
89
- r(` Context usage exceeded 200k (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`),
89
+ r(` Context usage exceeded 500k (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`),
90
90
  );
91
91
  lines.push('');
92
92
  }
@@ -196,9 +196,11 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
196
196
  // Context window chip for the normal case too
197
197
  if (contextWindow && contextWindow.size !== 'unknown') {
198
198
  const note =
199
- contextWindow.size === '1M'
200
- ? '⚠ Context exceeded 200k in recent requests — big contexts re-bill every turn and drain the 5H/7D caps. Use /compact or /clear.'
201
- : '✓ 200k context (standard)';
199
+ contextWindow.overWarn
200
+ ? '⚠ Context exceeded 500k in recent requests — big contexts re-bill every turn and drain the 5H/7D caps. Use /compact or /clear.'
201
+ : contextWindow.size === '1M'
202
+ ? '✓ 1M window in use, under the 500k warn line'
203
+ : '✓ 200k context (standard)';
202
204
  lines.push(` Context window: ${contextWindow.size} ${note}`);
203
205
  lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
204
206
  lines.push('');
package/src/harness.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import { routeWarningForStatusline } from './route-scan.js';
26
26
  import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
27
27
  import { compactWindowWarningForStatusline } from './compact-window.js';
28
+ import { userLanguage } from './config.js';
28
29
 
29
30
  const require = createRequire(import.meta.url);
30
31
  function readHarnessState() {
@@ -366,23 +367,35 @@ export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {})
366
367
  return result;
367
368
  }
368
369
 
369
- /** Parse the bundled preset rules (markdown bullets under presets/). */
370
- export function presetRules() {
370
+ /**
371
+ * The bundled preset rules, both languages per rule.
372
+ *
373
+ * These strings are injected into the model's context (by `seed`) and written
374
+ * into the user's ratchet (by `pull`), so a Korean-only set would pull an
375
+ * English session's responses into Korean — hence the pair. The `ko` text is
376
+ * canonical: it is what identifies a rule, so revising an English wording never
377
+ * turns a rule the user already answered into a new one.
378
+ */
379
+ export function presetRuleEntries() {
371
380
  try {
372
381
  // fileURLToPath, not `.pathname`: the latter yields `/D:/repo/src` on
373
382
  // Windows, where the read fails and the catch below turns a missing
374
383
  // file into an empty rule set without ever saying so.
375
- const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.md');
376
- return readFileSync(path, 'utf8')
377
- .split('\n')
378
- .filter((l) => /^\s*-\s+/.test(l))
379
- .map((l) => l.replace(/^\s*-\s+/, '').trim())
380
- .filter(Boolean);
384
+ const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.json');
385
+ const data = JSON.parse(readFileSync(path, 'utf8'));
386
+ return (Array.isArray(data.rules) ? data.rules : [])
387
+ .filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
388
+ .map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim() }));
381
389
  } catch {
382
390
  return [];
383
391
  }
384
392
  }
385
393
 
394
+ /** Preset rule text in the user's configured language. */
395
+ export function presetRules(lang = userLanguage()) {
396
+ return presetRuleEntries().map((r) => (lang === 'ko' ? r.ko : r.en));
397
+ }
398
+
386
399
  /**
387
400
  * harness list — return numbered ratchet rules from .claude/ratchet.md.
388
401
  * Numbering is 1-based and matches `harness rm <N>`.
package/src/history.js CHANGED
@@ -69,7 +69,8 @@ function saveState(state) {
69
69
  function chipKo(chip) {
70
70
  if (!chip) return chip;
71
71
  const map = {
72
- '⚠ Ctx 200k+': '⚠ 컨텍스트 200k 초과',
72
+ '⚠ Ctx 500k+': '⚠ 컨텍스트 500k 초과',
73
+ '⚠ Ctx 200k+': '⚠ 컨텍스트 200k 초과', // legacy (pre-v3.32)
73
74
  '⚠ 1M ON': '⚠ 1M 컨텍스트 활성', // legacy (pre-v2.18)
74
75
  '⚠ Cache miss': '⚠ 캐시 미스',
75
76
  '⚠ Rebuild churn': '⚠ 캐시 재빌드 빈발',
@@ -93,8 +94,10 @@ function chipKo(chip) {
93
94
  */
94
95
  function detailKo(detail) {
95
96
  if (!detail) return detail;
96
- const m0 = detail.match(/^Single-request context exceeded 200k \(max (\d+)k tokens\)$/);
97
- if (m0) return `단일 요청 컨텍스트 200k 초과 (최대 ${m0[1]}k 토큰)`;
97
+ // The threshold moved from 200k to 500k in v3.32, so both spellings have to
98
+ // resolve: history files written by older versions still carry the old one.
99
+ const m0 = detail.match(/^Single-request context exceeded (200|500)k \(max (\d+)k tokens\)$/);
100
+ if (m0) return `단일 요청 컨텍스트 ${m0[1]}k 초과 (최대 ${m0[2]}k 토큰)`;
98
101
  // legacy detail shape (pre-v2.18)
99
102
  const m1 = detail.match(/^Context auto-promoted to 1M \(max single-request (\d+)k tokens\)$/);
100
103
  if (m1) return `1M 컨텍스트 자동 활성 (단일 요청 최대 ${m1[1]}k 토큰)`;
package/src/installer.js CHANGED
@@ -22,7 +22,7 @@ const STATUSLINE_REFRESH_INTERVAL = 5;
22
22
 
23
23
  const SKILL_BODY = `---
24
24
  name: claude-token-saver
25
- description: Use when the user mentions Claude Code token usage, prompt cache hit rate, TTL/expiry, the 1M context window, cache misses, output spikes, rate-limit caps (5h/7d), or anything in the statusline produced by claude-token-saver (chips like "🚨 5H 94%", "🚨 7D 92%", "⚠ 1M ON", "⚠ Input spike", "⚠ Cache miss", "⚠ 5m TTL", "⚠ Rebuild churn", "⚠ Output heavy", "⚠ Call surge", "⏳ Cache expires", "💰 Cache saved", "🧠 Cache hit"). Also use when they ask to view token-usage history, want to understand a warning they just saw, or want to back up work before a session cap with \`claude-token-saver handoff\`.
25
+ description: Use when the user mentions Claude Code token usage, prompt cache hit rate, TTL/expiry, the 1M context window, cache misses, output spikes, rate-limit caps (5h/7d), or anything in the statusline produced by claude-token-saver (chips like "🚨 5H 94%", "🚨 7D 92%", "⚠ Ctx 500k+", "⚠ Input spike", "⚠ Cache miss", "⚠ 5m TTL", "⚠ Rebuild churn", "⚠ Output heavy", "⚠ Call surge", "⏳ Cache expires", "💰 Cache saved", "🧠 Cache hit"). Also use when they ask to view token-usage history, want to understand a warning they just saw, or want to back up work before a session cap with \`claude-token-saver handoff\`.
26
26
  ---
27
27
 
28
28
  # claude-token-saver — Claude Code Token Monitor
@@ -46,7 +46,7 @@ honor that for the rest of the turn without changing the saved setting.
46
46
  ## When this skill should activate
47
47
 
48
48
  - The user references any chip wording: \`🚨 5H NN%\`, \`🚨 7D NN%\`,
49
- \`⚠ 1M ON\`, \`⚠ Input spike\`, \`⚠ Cache miss\`, \`⚠ 5m TTL\`,
49
+ \`⚠ Ctx 500k+\`, \`⚠ Input spike\`, \`⚠ Cache miss\`, \`⚠ 5m TTL\`,
50
50
  \`⚠ Rebuild churn\`, \`⚠ Output heavy\`, \`⚠ Call surge\`.
51
51
  - The user asks "why is my cache hit rate low", "what does this warning mean",
52
52
  "when did this start happening", or similar.
@@ -80,7 +80,7 @@ honor that for the rest of the turn without changing the saved setting.
80
80
  | ------------------ | ----------------------------------------------------- |
81
81
  | \`🚨 5H NN%\` | 5-hour rate-limit window at NN% (>=90%). Cap is imminent. |
82
82
  | \`🚨 7D NN%\` | 7-day rate-limit window at NN% (>=90%). Pace yourself. |
83
- | \`⚠ 1M ON\` | Auto-promoted to 1M context (Opus 4.7+ Max default). |
83
+ | \`⚠ Ctx 500k+\` | A single recent request carried more than 500k input tokens. |
84
84
  | \`⚠ Input spike\` | One request consumed >250k or >3× the recent p95. |
85
85
  | \`⚠ Cache miss\` | Cache hit rate dropped below ~70%. |
86
86
  | \`⚠ 5m TTL\` | Most cache writes are 5-min ephemeral (Pro plan default). |
@@ -96,7 +96,8 @@ honor that for the rest of the turn without changing the saved setting.
96
96
  6. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
97
97
  \`claude-token-saver handoff\` to back up the current work to a
98
98
  \`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
99
- session. For 1M ON, mention \`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For
99
+ session. For \`⚠ Ctx 500k+\`, recommend \`/compact\` or \`/clear\` and
100
+ checking what is pinned into context. For
100
101
  5m TTL, point at the Max plan's 1h bucket. For input spike, suggest
101
102
  splitting the conversation or compacting context.
102
103
 
@@ -67,13 +67,21 @@ export function koreanLocaleDetected({ env = process.env, platform = process.pla
67
67
  try {
68
68
  if (loadConfig().language === 'ko') return true;
69
69
  } catch { /* unreadable config falls through to the env checks */ }
70
- for (const v of [env.LC_ALL, env.LC_MESSAGES, env.LANG, env.LANGUAGE]) {
70
+ const posix = [env.LC_ALL, env.LC_MESSAGES, env.LANG, env.LANGUAGE].filter(
71
+ (v) => typeof v === 'string' && v.trim() && v !== 'C' && v !== 'POSIX',
72
+ );
73
+ for (const v of posix) {
71
74
  // `ko` must be a whole subtag: `ko`, `ko_KR.UTF-8`, `ko-KR`, and the
72
75
  // colon-separated `LANGUAGE=ko:en` all count, while `kok` (Konkani) and
73
76
  // `tok` do not.
74
- if (typeof v === 'string' && /(^|[:._-])ko([:._-]|$)/i.test(v)) return true;
77
+ if (/(^|[:._-])ko([:._-]|$)/i.test(v)) return true;
75
78
  }
76
- if (platform === 'darwin') {
79
+ // A locale variable that is set and is NOT Korean is an answer, not a missing
80
+ // signal: `LANG=en_US.UTF-8` on a machine whose system locale is Korean means
81
+ // the user chose English for their shell. Only consult the system locale when
82
+ // the POSIX variables say nothing at all, which is the macOS GUI-shell case
83
+ // this fallback exists for.
84
+ if (platform === 'darwin' && posix.length === 0) {
77
85
  try {
78
86
  // `LANG` is commonly unset in macOS GUI-launched shells, so the system
79
87
  // locale is the only reliable signal there.
@@ -100,6 +100,27 @@ export function composeRuleText(baseText, rule, lang = userLanguage()) {
100
100
  return `${baseText.replace(/\s*$/, '')}. ${clause}`;
101
101
  }
102
102
 
103
+ /**
104
+ * The base rule sentence for one tier-delegation rule, without the budget
105
+ * clause (composeRuleText adds that). Lives here rather than in route-scan so
106
+ * that the scan, the seed presets and any later producer all emit byte-identical
107
+ * wording — two copies of this template would drift the moment one is reworded,
108
+ * and the drift only shows up in a file the model reads as instructions.
109
+ *
110
+ * `g` carries { tier, label, labelEn, example, exampleEn, agent }.
111
+ */
112
+ export function modelRuleBaseText(g, lang = userLanguage()) {
113
+ if (lang === 'ko') {
114
+ return g.tier === 'T2'
115
+ ? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${agentPhrase(g.agent)} 서브에이전트로 위임한다 (설계 판단·배포·스토어 제출 같은 비가역 작업이 섞이면 위임하지 않음)`
116
+ : `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·비가역 작업·반복 에러 발생 시 메인 모델이 이어받음)`;
117
+ }
118
+ const example = g.exampleEn || g.example;
119
+ return g.tier === 'T2'
120
+ ? `Delegate simple "${g.labelEn}" requests (e.g. "${example}") to ${agentPhraseEn(g.agent)} — never when the request mixes in design judgement or irreversible work like deploy/release/submission`
121
+ : `Delegate moderate "${g.labelEn}" requests (e.g. "${example}") to a model: sonnet subagent — hand back to the main model on design judgement, irreversible work, or repeated errors`;
122
+ }
123
+
103
124
  // See the note in route-scan.js — paths.js is the only place that resolves
104
125
  // this, so an XDG_CONFIG_HOME override moves every state file together.
105
126
  const stateDir = userDataDir;
@@ -234,7 +255,12 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
234
255
  : ` ⚠ rule-health: recent error rate ${rate}% for the delegated category — narrow the condition or remove`;
235
256
  };
236
257
  const statsOf = (r) => {
237
- const base = `×${r.count || 0}, err ${pct(r.errRate)}%, seen ${r.lastSeen || r.promotedAt}`;
258
+ // A seeded rule carries no recurrence of its own until a scan measures one.
259
+ // Printing `×0` would read as "never happened"; printing the author's count
260
+ // would pass someone else's evidence off as the user's.
261
+ const base = r.origin === 'preset' && !r.count
262
+ ? `preset (curated), registered ${r.promotedAt}`
263
+ : `×${r.count || 0}, err ${pct(r.errRate)}%, seen ${r.lastSeen || r.promotedAt}`;
238
264
  if (!r.delegatedRuns) return base;
239
265
  const saved = r.savedUsd ? `, saved ~$${r.savedUsd.toFixed(2)}` : '';
240
266
  return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
@@ -260,13 +286,16 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
260
286
  const t2 = group.find((r) => r.tier === 'T2');
261
287
  const t1 = group.find((r) => r.tier === 'T1');
262
288
  if (t2 && t1) {
289
+ // Seeded rules carry a localized example; scan-produced ones only have the
290
+ // user's own prompt, so exampleEn is a preference, not a requirement.
291
+ const ex = (r) => (ko ? r.example : (r.exampleEn || r.example));
263
292
  const rule = ko
264
- ? `"${t2.label}" 유형 요청은 기본적으로 ${agentPhrase(t2.agent)} 서브에이전트로 위임한다(예: "${t2.example}"). ` +
265
- `여러 단계·여러 파일 수정이 얽힌 중간 난도 요청(예: "${t1.example}")은 model: sonnet 서브에이전트로 위임한다. ` +
293
+ ? `"${t2.label}" 유형 요청은 기본적으로 ${agentPhrase(t2.agent)} 서브에이전트로 위임한다(예: "${ex(t2)}"). ` +
294
+ `여러 단계·여러 파일 수정이 얽힌 중간 난도 요청(예: "${ex(t1)}")은 model: sonnet 서브에이전트로 위임한다. ` +
266
295
  `설계 판단이나 배포·릴리스·스토어 제출 같은 비가역 작업이 섞여 있을 때만 위임하지 않는다. ` +
267
296
  mergedBudget(t2, t1)
268
- : `Delegate "${t2.labelEn || t2.label}" requests to ${agentPhraseEn(t2.agent)} by default (e.g. "${t2.example}"). ` +
269
- `Escalate moderate ones that span multiple steps or file edits (e.g. "${t1.example}") to a model: sonnet subagent. ` +
297
+ : `Delegate "${t2.labelEn || t2.label}" requests to ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"). ` +
298
+ `Escalate moderate ones that span multiple steps or file edits (e.g. "${ex(t1)}") to a model: sonnet subagent. ` +
270
299
  `Keep it on the main model only when the request mixes in design judgement or irreversible work (deploy, release, store submission). ` +
271
300
  mergedBudget(t2, t1);
272
301
  lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
package/src/route-scan.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  } from './subagent-records.js';
28
28
  import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
29
29
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
30
- import { agentPhrase, agentPhraseEn } from './agents.js';
30
+ import { modelRuleBaseText } from './model-rules.js';
31
31
 
32
32
  // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
33
33
  // T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
@@ -615,12 +615,11 @@ export async function runRouteScan({ days = 14 } = {}) {
615
615
  calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
616
616
  out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
617
617
  });
618
- const ruleText = (g) => g.tier === 'T2'
619
- ? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${agentPhrase(g.agent)} 서브에이전트로 위임한다 (설계 판단·배포·스토어 제출 같은 비가역 작업이 섞이면 위임하지 않음)`
620
- : `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·비가역 작업·반복 에러 발생 시 메인 모델이 이어받음)`;
621
- const ruleTextEn = (g) => g.tier === 'T2'
622
- ? `Delegate simple "${g.labelEn}" requests (e.g. "${g.example}") to ${agentPhraseEn(g.agent)} — never when the request mixes in design judgement or irreversible work like deploy/release/submission`
623
- : `Delegate moderate "${g.labelEn}" requests (e.g. "${g.example}") to a model: sonnet subagent — hand back to the main model on design judgement, irreversible work, or repeated errors`;
618
+ // Shared with the seed presets (src/seed-rules.js) through model-rules.js —
619
+ // one template, so a reworded rule cannot mean two different things
620
+ // depending on which producer wrote it.
621
+ const ruleText = (g) => modelRuleBaseText(g, 'ko');
622
+ const ruleTextEn = (g) => modelRuleBaseText(g, 'en');
624
623
 
625
624
  const candidates = [...groups.values()]
626
625
  .filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))