sprag-cli 3.40.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,527 @@
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, labelEn, 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 } from 'node:fs';
32
+ import { join, dirname } from 'node:path';
33
+ import { homedir } from 'node:os';
34
+ import { userDataDir } from './paths.js';
35
+ import { userLanguage } from './config.js';
36
+ import { agentPhrase, agentPhraseEn } from './agents.js';
37
+
38
+ // Post-promotion delegated-category error rate above this flags the rule
39
+ // for review (rule-health). Calibrated against local T0 avg error incidence.
40
+ export const HEALTH_ERR_RATE = 0.2;
41
+ // Below this many shape-eligible episodes in the window the error rate is
42
+ // noise (1 error in 4 episodes = 25% — instant flag), so the review flag is
43
+ // withheld until the sample is large enough to mean something.
44
+ export const HEALTH_MIN_SAMPLE = 10;
45
+ // Measured delegations needed before the real outcome replaces the proxy.
46
+ // Lower than HEALTH_MIN_SAMPLE on purpose: a delegated run is DIRECT evidence
47
+ // of the rule firing, so far fewer are needed to mean something than episodes
48
+ // that merely resemble the rule's shape.
49
+ export const HEALTH_MIN_SAMPLE_DELEGATED = 5;
50
+
51
+ /**
52
+ * Wilson 95% lower bound for an observed error proportion. A raw rate over a
53
+ * tiny sample flips healthy rules to review (2/8 = 25% has a 95% interval of
54
+ * [7%, 59%] — it does NOT establish the rate exceeds 20%). Only flag when the
55
+ * evidence actually clears the threshold.
56
+ */
57
+ export function wilsonLowerBound(errs, n) {
58
+ if (!n) return 0;
59
+ const z = 1.96;
60
+ const p = errs / n;
61
+ const z2 = z * z;
62
+ const denom = 1 + z2 / n;
63
+ const center = p + z2 / (2 * n);
64
+ const margin = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n);
65
+ return Math.max(0, (center - margin) / denom);
66
+ }
67
+
68
+ // Fallback budget for rules promoted before budgets were recorded — matches
69
+ // route-scan's pre-calibration defaults. Kept as local constants rather than
70
+ // imported: route-scan imports this module, and a static back-import would
71
+ // close the cycle.
72
+ const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 } };
73
+
74
+ /** The cap half of a budget clause, e.g. "도구 호출 8회·출력 1500 토큰". */
75
+ export function budgetCapPhrase(rule, lang = userLanguage()) {
76
+ const b = rule.budget || DEFAULT_BUDGET[rule.tier] || DEFAULT_BUDGET.T2;
77
+ if (lang === 'ko') {
78
+ return b.calls ? `도구 호출 ${b.calls}회·출력 ${b.out} 토큰` : `출력 ${b.out} 토큰`;
79
+ }
80
+ return b.calls ? `${b.calls} tool calls / ${b.out} output tokens` : `${b.out} output tokens`;
81
+ }
82
+
83
+ /**
84
+ * Append the probe-then-commit budget to a rule's text.
85
+ *
86
+ * Kept here — not baked into the stored rule string — so it is composed in ONE
87
+ * place: rules promoted before budgets existed gain the clause too, and the
88
+ * promote preview and the written file can never drift apart (both call this).
89
+ *
90
+ * Why the clause matters: a rule is chosen from past statistics but FIRES on
91
+ * the request text alone, and difficulty mostly surfaces after the first tool
92
+ * call. Naming a cap turns an unavoidable mis-fire into a bounded probe
93
+ * instead of a cheap model grinding at work it cannot finish.
94
+ */
95
+ export function composeRuleText(baseText, rule, lang = userLanguage()) {
96
+ const cap = budgetCapPhrase(rule, lang);
97
+ const clause = lang === 'ko'
98
+ ? `위임 상한은 ${cap}이며, 넘길 것 같거나 에러가 나면 거기서 멈춰 진행분만 보고하고 메인 모델이 이어받는다. 세션 모델이 이미 위임 목표와 같은 급 이하면 위임하지 않는다`
99
+ : `Cap the run at ${cap}; if it looks likely to exceed that or hits an error, it stops there and reports partial progress while the main model takes over. Skip delegation entirely when the session model is already at or below the target tier`;
100
+ return `${baseText.replace(/\s*$/, '')}. ${clause}`;
101
+ }
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
+
124
+ // See the note in route-scan.js — paths.js is the only place that resolves
125
+ // this, so an XDG_CONFIG_HOME override moves every state file together.
126
+ const stateDir = userDataDir;
127
+
128
+ export function modelRulesPath() {
129
+ return join(stateDir(), 'model-rules.json');
130
+ }
131
+
132
+ export function loadModelRules() {
133
+ try {
134
+ const data = JSON.parse(readFileSync(modelRulesPath(), 'utf8'));
135
+ return Array.isArray(data.rules) ? data : { rules: [] };
136
+ } catch {
137
+ return { rules: [] };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * USD that measured delegations have saved, summed across registered rules.
143
+ * Reads the file route-scan already wrote — the statusline refreshes every few
144
+ * seconds and must never start a scan of its own (a scan parses tens of MB of
145
+ * transcripts).
146
+ *
147
+ * Rules with no measured delegation contribute nothing, and an unreadable
148
+ * registry returns 0 rather than throwing: the statusline reads 0 as "draw no
149
+ * chip", which is the right outcome for anyone who never delegates.
150
+ */
151
+ export function delegationSavedUsd() {
152
+ try {
153
+ return loadModelRules().rules.reduce(
154
+ (sum, r) => sum + (r.delegatedRuns ? (Number(r.savedUsd) || 0) : 0),
155
+ 0,
156
+ );
157
+ } catch {
158
+ return 0;
159
+ }
160
+ }
161
+
162
+ export function saveModelRules(data) {
163
+ const dir = stateDir();
164
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
165
+ writeFileSync(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
166
+ }
167
+
168
+ /** Add (or re-activate) a promoted rule; returns the stored entry. */
169
+ export function addModelRule(entry) {
170
+ const data = loadModelRules();
171
+ const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope);
172
+ if (existing) {
173
+ Object.assign(existing, entry, { status: 'active' });
174
+ saveModelRules(data);
175
+ return existing;
176
+ }
177
+ const stored = { status: 'active', errRate: 0, ...entry };
178
+ data.rules.push(stored);
179
+ saveModelRules(data);
180
+ return stored;
181
+ }
182
+
183
+ export function removeModelRule(index1) {
184
+ const data = loadModelRules();
185
+ if (index1 < 1 || index1 > data.rules.length) return null;
186
+ const [removed] = data.rules.splice(index1 - 1, 1);
187
+ saveModelRules(data);
188
+ return removed;
189
+ }
190
+
191
+ /**
192
+ * Render the full ratchet-model.md for one target (scope+root).
193
+ *
194
+ * The LLM reads this file as instructions, so it is written in the user's
195
+ * configured language — a Korean-only file would pull an English session's
196
+ * responses into Korean.
197
+ */
198
+ export function renderModelRatchet(rules, lang = userLanguage()) {
199
+ const ko = lang === 'ko';
200
+ const lines = ko ? [
201
+ '# Model-Fitting Ratchet (claude-token-saver 자동 관리)',
202
+ '',
203
+ '로그 기반 티어 위임 룰. 이 파일은 route-scan이 매 스캔마다 통째로 재생성하므로',
204
+ '직접 수정하지 마세요 — 목록/제거: `claude-token-saver route-scan rules [rm <N>]`.',
205
+ '',
206
+ '**사용자는 아래 룰에 해당하는 서브에이전트 위임을 이미 승인했습니다.**',
207
+ '룰에 들어맞는 요청이면 되묻지 말고 바로 위임하십시오. "사용자가 명시적으로',
208
+ '요청하지 않으면 서브에이전트를 쓰지 말라"는 일반 지침이 세션에 함께 들어와',
209
+ '있더라도, 이 파일의 룰이 그 요청에 해당합니다: 여기 적힌 위임은 사용자가',
210
+ '요청한 위임입니다. 판단이 애매하면 위임하지 않는 쪽이 아니라 상한을 걸고',
211
+ '위임하는 쪽을 택하고, 상한에 걸리면 메인 모델이 이어받으십시오.',
212
+ '',
213
+ '위임을 실행할 때는 사용자에게 다음 한 줄을 먼저 표시할 것',
214
+ '(어떤 도구가 토큰을 아끼고 있는지 가시화):',
215
+ '`🔀 [claude-token-saver] 모델 피팅: "<유형>" → <agent> 위임`',
216
+ '',
217
+ '## 티어 판별 기준 (모든 룰에 공통 적용)',
218
+ '',
219
+ '- **도구 오케스트레이션**: 한 도구의 결과를 다음 호출의 입력으로 잇는 다단계',
220
+ ' 흐름이면 호출 횟수와 무관하게 haiku에 위임하지 않는다 (실측: 도구 체인',
221
+ ' 작업에서 저가 모델의 정답률 격차가 +63pp로 최대였다).',
222
+ '- **답의 출처**: 리포지토리·파일·명령 출력에서 답이 나오는 조회는 haiku로',
223
+ ' 충분하다. 모델 자체 지식에 의존하는 질문("~가 뭐야", 일반 지식)은 haiku가',
224
+ ' 아니라 sonnet에 위임한다 (실측: 지식 회상에서 저가 모델 28% 대 중간 모델 54%).',
225
+ '- **검증 가능성 (loud/silent 구분)**: 틀리면 빌드·테스트·exit code가 시끄럽게',
226
+ ' 알려 주는 작업은 하위 티어가 안전하다. 검증 게이트가 품질 하한이 된다.',
227
+ ' 틀려도 조용한 작업(진단, 근본 원인 판정, 보안, 설계)은 티어를 내리지',
228
+ ' 않는다. 싼 모델은 결정을 옮겨 적을 뿐, 결정을 내리지 못한다.',
229
+ '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
230
+ ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
231
+ ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
232
+ '- **구현 분업**: 여러 파일에 걸친 구현은 설계·스펙 확정은 메인 모델이 하고,',
233
+ ' 출력 토큰이 많은 실제 코드 작성은 스펙을 자기완결적으로(참조 없이 실행',
234
+ ' 가능하게) 적어 model: sonnet 서브에이전트에 위임한다. haiku에는 맡기지',
235
+ ' 않는다. 위임 프롬프트에는 작업별 권장 모델과 그 이유를 한 줄로 적는다.',
236
+ ' 스펙을 쓴 쪽이 난도를 제일 잘 알기 때문이다. 단, 이미 컨텍스트에 있는',
237
+ ' 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현은 분해',
238
+ ' 오버헤드가 더 크므로 메인 모델이 직접 한다. 위임한 구현이 돌아오면 메인',
239
+ ' 모델이 diff와 테스트 결과를 검증하고 나서 완료로 보고한다. 이 검증',
240
+ ' 비용까지가 위임 비용이다.',
241
+ '- **에스컬레이션**: 같은 에러 2회 반복, 답이 복수 후보로 갈림, 서브에이전트가',
242
+ ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 대응 순서는',
243
+ ' ① 재지시 후 재시도 → ② 한 티어 상향 → ③ 메인 모델이 직접 수습.',
244
+ '',
245
+ '## Rules',
246
+ '',
247
+ ] : [
248
+ '# Model-Fitting Ratchet (managed by claude-token-saver)',
249
+ '',
250
+ 'Log-derived tier delegation rules. route-scan regenerates this file in full',
251
+ 'on every scan — do not edit it by hand. List / remove with:',
252
+ '`claude-token-saver route-scan rules [rm <N>]`.',
253
+ '',
254
+ '**The user has already approved the subagent delegations described below.**',
255
+ 'When a request matches a rule, delegate right away instead of asking. If a',
256
+ 'general instruction like "do not use subagents unless the user requested it"',
257
+ 'is also present in this session, these rules ARE that request: the user asked',
258
+ 'for them here. When the call is close, prefer delegating with a cap over not',
259
+ 'delegating — the main model takes over as soon as a cap is hit.',
260
+ '',
261
+ 'When delegating under a rule below, show the user this line first so it is',
262
+ 'visible which tool is saving tokens:',
263
+ '`🔀 [claude-token-saver] model fitting: "<category>" → delegated to <agent>`',
264
+ '',
265
+ '## Tier criteria (apply to every rule below)',
266
+ '',
267
+ '- **Tool orchestration**: a multi-step flow that feeds one tool result into',
268
+ ' the next call never goes to haiku, regardless of call count (measured: the',
269
+ ' cheap-model accuracy gap peaks at +63pp on tool-chain work).',
270
+ '- **Answer source**: lookups answered from the repository, files, or command',
271
+ ' output are fine on haiku. Questions that lean on model knowledge ("what',
272
+ ' is X", general knowledge) go to sonnet instead (measured: 28% cheap vs 54%',
273
+ ' mid on knowledge recall).',
274
+ '- **Verifiability (loud vs silent failure)**: a lower tier is safe when a',
275
+ ' wrong result fails loudly — build breaks, tests fail, exit code flags it;',
276
+ ' the verification gate is the quality floor. Never drop the tier on work',
277
+ ' that fails silently (diagnosis, root-cause calls, security posture,',
278
+ ' design). Cheap models transcribe decisions; they do not make them.',
279
+ '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
280
+ ' generation. Bundle tasks that read the same files into one agent; split',
281
+ ' only when the areas they read differ.',
282
+ '- **Implementation split**: for multi-file implementation, the main model',
283
+ ' fixes the design and writes a self-contained spec (executable without',
284
+ ' back-references), then delegates the output-heavy code writing to a',
285
+ ' model: sonnet subagent — never haiku. The delegation prompt names the',
286
+ ' recommended model per task with a one-line why: whoever wrote the spec',
287
+ ' knows the difficulty best. A change of roughly 50 lines or less in files',
288
+ ' already in context, or a short implementation with a single dependency',
289
+ ' chain, stays on the main model: decomposition overhead outweighs the',
290
+ ' saving. When a delegated implementation comes back, the',
291
+ ' main model verifies the diff and test results before reporting done —',
292
+ ' that verification is part of the delegation cost.',
293
+ '- **Escalation**: hand off immediately on the same error twice, an answer',
294
+ ' splitting into multiple candidates, or a subagent spinning without asking',
295
+ ' questions. Order: ① re-instruct and retry → ② move up one tier → ③ the',
296
+ ' main model takes over directly.',
297
+ '',
298
+ '## Rules',
299
+ '',
300
+ ];
301
+ // Which number drove the review flag matters to the reader: a measured
302
+ // delegated error rate is evidence the rule itself is failing, while the
303
+ // proxy only says work of that shape tends to go wrong.
304
+ const pct = (v) => Math.round((v || 0) * 100);
305
+ const healthOf = (r) => {
306
+ if (r.status !== 'review') return '';
307
+ const measured = r.healthSource === 'delegated';
308
+ const rate = pct(measured ? r.delegatedErrRate : r.errRate);
309
+ if (ko) {
310
+ return measured
311
+ ? ` ⚠ rule-health: 실제 위임 ${r.delegatedRuns}건 중 에러율 ${rate}% — 조건을 좁히거나 제거 검토`
312
+ : ` ⚠ rule-health: 최근 위임 대상 에러율 ${rate}% — 조건을 좁히거나 제거 검토`;
313
+ }
314
+ return measured
315
+ ? ` ⚠ rule-health: ${rate}% error rate across ${r.delegatedRuns} measured delegations — narrow the condition or remove`
316
+ : ` ⚠ rule-health: recent error rate ${rate}% for the delegated category — narrow the condition or remove`;
317
+ };
318
+ const statsOf = (r) => {
319
+ // A seeded rule carries no recurrence of its own until a scan measures one.
320
+ // Printing `×0` would read as "never happened"; printing the author's count
321
+ // would pass someone else's evidence off as the user's.
322
+ const base = r.origin === 'preset' && !r.count
323
+ ? `preset (curated), registered ${r.promotedAt}`
324
+ : `×${r.count || 0}, err ${pct(r.errRate)}%, seen ${r.lastSeen || r.promotedAt}`;
325
+ if (!r.delegatedRuns) return base;
326
+ const saved = r.savedUsd ? `, saved ~$${r.savedUsd.toFixed(2)}` : '';
327
+ return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
328
+ };
329
+ // Merged T2+T1 rules carry two caps, so they state both once rather than
330
+ // repeating the whole stop-condition per tier.
331
+ const mergedBudget = (t2, t1) => ko
332
+ ? `위임 상한은 haiku ${budgetCapPhrase(t2, 'ko')}, sonnet ${budgetCapPhrase(t1, 'ko')}이며, 넘길 것 같거나 에러가 나면 거기서 멈춰 진행분만 보고하고 메인 모델이 이어받는다. 세션 모델이 이미 위임 목표와 같은 급 이하면 위임하지 않는다`
333
+ : `Cap haiku runs at ${budgetCapPhrase(t2, 'en')} and sonnet runs at ${budgetCapPhrase(t1, 'en')}; a run likely to exceed its cap, or hitting an error, stops there and reports partial progress while the main model takes over. Skip delegation entirely when the session model is already at or below the target tier`;
334
+
335
+ // A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
336
+ // only known after an episode finishes, so two separate bullets give the
337
+ // reading LLM no way to pick one at request time — merge such pairs into a
338
+ // single conditional rule with explicit request-time criteria (default to
339
+ // the cheap agent, escalate on multi-step scope, hand back on judgement /
340
+ // irreversible actions).
341
+ const byCategory = new Map();
342
+ for (const r of rules) {
343
+ if (!byCategory.has(r.category)) byCategory.set(r.category, []);
344
+ byCategory.get(r.category).push(r);
345
+ }
346
+ for (const group of byCategory.values()) {
347
+ const t2 = group.find((r) => r.tier === 'T2');
348
+ const t1 = group.find((r) => r.tier === 'T1');
349
+ if (t2 && t1) {
350
+ // Seeded rules carry a localized example; scan-produced ones only have the
351
+ // user's own prompt, so exampleEn is a preference, not a requirement.
352
+ const ex = (r) => (ko ? r.example : (r.exampleEn || r.example));
353
+ const rule = ko
354
+ ? `"${t2.label}" 유형 요청은 기본적으로 ${agentPhrase(t2.agent)} 서브에이전트로 위임한다(예: "${ex(t2)}"). ` +
355
+ `여러 단계·여러 파일 수정이 얽힌 중간 난도 요청(예: "${ex(t1)}")은 model: sonnet 서브에이전트로 위임한다. ` +
356
+ `설계 판단이나 배포·릴리스·스토어 제출 같은 비가역 작업이 섞여 있을 때만 위임하지 않는다. ` +
357
+ mergedBudget(t2, t1)
358
+ : `Delegate "${t2.labelEn || t2.label}" requests to ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"). ` +
359
+ `Escalate moderate ones that span multiple steps or file edits (e.g. "${ex(t1)}") to a model: sonnet subagent. ` +
360
+ `Keep it on the main model only when the request mixes in design judgement or irreversible work (deploy, release, store submission). ` +
361
+ mergedBudget(t2, t1);
362
+ lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
363
+ for (const r of group) {
364
+ if (r !== t2 && r !== t1) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
365
+ }
366
+ } else {
367
+ for (const r of group) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
368
+ }
369
+ }
370
+ return lines.join('\n') + '\n';
371
+ }
372
+
373
+ /**
374
+ * Statusline helper (docs/TIER_CRITERIA.md §rule-health) — cheapest possible
375
+ * check, one small JSON read. Returns `rule-health R<N>` for the first
376
+ * review-flagged rule relevant to this project (its own root, or global
377
+ * scope), else null. N is the 1-based registry index, matching the numbering
378
+ * of `route-scan rules [rm <N>]` so the fix command is one lookup away.
379
+ */
380
+ export function ruleHealthWarningForStatusline(projectRoot) {
381
+ const { rules } = loadModelRules();
382
+ for (let i = 0; i < rules.length; i++) {
383
+ const r = rules[i];
384
+ if (r.status !== 'review') continue;
385
+ if (r.scope === 'global' || r.targetRoot === projectRoot) return `rule-health R${i + 1}`;
386
+ }
387
+ return null;
388
+ }
389
+
390
+ export function modelRatchetPathFor(scope, targetRoot) {
391
+ return scope === 'global'
392
+ ? join(homedir(), '.claude', 'ratchet-model.md')
393
+ : join(targetRoot, '.claude', 'ratchet-model.md');
394
+ }
395
+
396
+ /**
397
+ * Regenerate ratchet-model.md for every target that carries model rules.
398
+ * A target whose rules are all gone gets its file emptied — not deleted,
399
+ * because CLAUDE.md imports the path (see the note at the loop below).
400
+ *
401
+ * Writes are content-conditional: a target whose rendering already matches
402
+ * what is on disk is left alone, and the returned list names only the files
403
+ * that actually changed. That makes the call cheap enough to run on a cache
404
+ * hit, which is what closes the upgrade gap — the file is rendered from THIS
405
+ * version's template, so an upgrade that reworded the rules reaches the disk
406
+ * on the next session rather than waiting for a rescan to happen to fire.
407
+ */
408
+ export function syncAllFiles({ previousPaths = [] } = {}) {
409
+ const data = loadModelRules();
410
+ const byPath = new Map();
411
+ for (const r of data.rules) {
412
+ const p = modelRatchetPathFor(r.scope, r.targetRoot);
413
+ if (!byPath.has(p)) byPath.set(p, []);
414
+ byPath.get(p).push(r);
415
+ }
416
+ // Skip the write when the file already says exactly this. An unreadable or
417
+ // missing file reads as "" and therefore always differs, which is the right
418
+ // outcome — it gets written.
419
+ const writeIfChanged = (p, text) => {
420
+ try {
421
+ if (existsSync(p) && readFileSync(p, 'utf8') === text) return false;
422
+ } catch { /* unreadable → fall through and rewrite it */ }
423
+ try {
424
+ mkdirSync(dirname(p), { recursive: true });
425
+ writeFileSync(p, text);
426
+ return true;
427
+ } catch {
428
+ return false; // unwritable target — skip, registry stays authoritative
429
+ }
430
+ };
431
+ const written = [];
432
+ for (const [p, rules] of byPath) {
433
+ if (writeIfChanged(p, renderModelRatchet(rules))) written.push(p);
434
+ }
435
+ // A target that lost its last rule is emptied, NOT deleted: CLAUDE.md
436
+ // imports this path, and a dangling `@` import is worse than an empty file.
437
+ for (const p of previousPaths) {
438
+ if (!byPath.has(p) && existsSync(p)) {
439
+ if (writeIfChanged(p, renderModelRatchet([]))) written.push(p);
440
+ }
441
+ }
442
+ return written;
443
+ }
444
+
445
+ /**
446
+ * Continuous update from logs (route-scan calls this on every refresh):
447
+ * for each registered rule, recompute recurrence count and the error rate
448
+ * of episodes in its (tier-eligible) category — the rule-health signal.
449
+ *
450
+ * `episodeStats`: Map "tier|category|project" (plus a "tier|category|*"
451
+ * wildcard key that global-scope rules fall back to) → { count, errCount,
452
+ * epCount }. Tier is part of the key so a category carrying both a T2 and a
453
+ * T1 rule doesn't double-count every episode into both rules' stats.
454
+ * errCount/epCount measure the scan window's shape-eligible episodes — ones
455
+ * an expensive model handled directly that still look T1/T2 by shape (tier
456
+ * judged with the error signal zeroed; see route-scan's rule-health pass).
457
+ */
458
+ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { now } = {}) {
459
+ const data = loadModelRules();
460
+ let changed = false;
461
+ const pick = (stats, r) => stats.get(`${r.tier}|${r.category}|${r.project}`)
462
+ || (r.scope === 'global' ? stats.get(`${r.tier}|${r.category}|*`) : null);
463
+
464
+ for (const r of data.rules) {
465
+ const s = pick(episodeStats, r);
466
+ const d = pick(delegatedStats, r);
467
+ if (!s && !d) {
468
+ // A rule whose category didn't appear at all this window keeps its last
469
+ // known recurrence, but its measured-delegation fields must still read
470
+ // as "nothing measured" rather than stay undefined — the CLI and the
471
+ // rendered md both branch on them.
472
+ r.delegatedRuns = r.delegatedRuns ?? 0;
473
+ r.delegatedErrRate = r.delegatedErrRate ?? 0;
474
+ r.savedUsd = r.savedUsd ?? 0;
475
+ r.healthSource = r.healthSource ?? 'proxy';
476
+ continue;
477
+ }
478
+
479
+ if (s) {
480
+ r.count = s.count;
481
+ r.errRate = s.epCount > 0 ? s.errCount / s.epCount : 0;
482
+ // Baseline = the model that handled this category before the rule moved
483
+ // it. Sticky once set: as a rule takes effect, fewer episodes stay on the
484
+ // expensive model, so a recomputed baseline would drift downward and
485
+ // shrink the very savings the rule is producing.
486
+ //
487
+ // `baselineSource` marks which definition produced it. Baselines written
488
+ // before the definition changed from "priciest model seen" to "model that
489
+ // handled the most episodes" are recomputed once — a single stray record
490
+ // of a pricier model could otherwise hold the baseline above what the
491
+ // category was really running on, inflating every saving priced against it.
492
+ if (s.baselineModel && (!r.baselineModel || r.baselineSource !== 'dominant')) {
493
+ r.baselineModel = s.baselineModel;
494
+ r.baselineSource = 'dominant';
495
+ changed = true;
496
+ }
497
+ }
498
+ // Window snapshot, not a running total: these describe the current scan
499
+ // window so a rule that stopped firing decays to zero instead of coasting
500
+ // on old credit.
501
+ r.delegatedRuns = d ? d.runs : 0;
502
+ r.delegatedErrRate = d && d.runs > 0 ? d.errRuns / d.runs : 0;
503
+ r.savedUsd = d ? Math.round(d.savedUsd * 100) / 100 : 0;
504
+ r.lastSeen = now || r.lastSeen;
505
+
506
+ // Measured outcome beats the proxy once there is enough of it. The proxy
507
+ // asks "does work SHAPED like this tend to fail?"; the measurement asks
508
+ // "does this rule fail when it actually fires?" — only the second can
509
+ // catch a rule that is mis-firing on requests it should never have taken.
510
+ if (r.delegatedRuns >= HEALTH_MIN_SAMPLE_DELEGATED) {
511
+ r.healthSource = 'delegated';
512
+ r.status = wilsonLowerBound(d ? d.errRuns : 0, r.delegatedRuns) > HEALTH_ERR_RATE
513
+ ? 'review' : 'active';
514
+ } else {
515
+ r.healthSource = 'proxy';
516
+ r.status = s && s.epCount >= HEALTH_MIN_SAMPLE
517
+ && wilsonLowerBound(s.errCount || Math.round((r.errRate || 0) * s.epCount), s.epCount) > HEALTH_ERR_RATE
518
+ ? 'review' : 'active';
519
+ }
520
+ changed = true;
521
+ }
522
+ if (changed) {
523
+ saveModelRules(data);
524
+ syncAllFiles();
525
+ }
526
+ return data;
527
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * month-spend: 이번 달 1일 00시(로컬 시각) 이후 지출한 추정 금액을 계산합니다.
3
+ *
4
+ * LiteLLM·Bedrock처럼 5h/7d cap 이 아예 없는 게이트웨이 사용자도
5
+ * "이번 달에 얼마나 썼는지"는 늘 궁금하므로, 세션 로그를 기반으로
6
+ * 달력 월 단위 지출을 통계선에 상시 노출합니다. 모델 단가는 세션마다
7
+ * 다르므로 세션별로 estimateCost 를 적용해 합산합니다.
8
+ */
9
+
10
+ import { estimateCost } from './cost.js';
11
+
12
+ /** 이번 달 1일 00:00(로컬)의 epoch ms. */
13
+ export function monthStartMs(now = new Date()) {
14
+ return new Date(now.getFullYear(), now.getMonth(), 1).getTime();
15
+ }
16
+
17
+ /** 통계선 라벨에 쓰는 짧은 월 이름 (예: 'Sep'). */
18
+ export function monthLabel(now = new Date()) {
19
+ return now.toLocaleString('en-US', { month: 'short' });
20
+ }
21
+
22
+ /**
23
+ * 월초 이후 세션들의 지출 추정치 합계(USD).
24
+ * endTime 이 월초 이후인 세션만 집계합니다. 세션이 월 경계에 걸치는
25
+ * 경우는 세션 전체를 포함하는데, 경계 세션은 드물고 통계선 지표는
26
+ * 추정치이므로 이 정도 오차는 허용합니다.
27
+ *
28
+ * @param {Array} sessions parseAllSessions 결과
29
+ * @param {Date} [now]
30
+ * @returns {{usd:number, sessions:number, sinceMs:number, label:string}}
31
+ */
32
+ export function monthSpend(sessions, now = new Date()) {
33
+ const since = monthStartMs(now);
34
+ let usd = 0;
35
+ let count = 0;
36
+ for (const s of sessions || []) {
37
+ if (!s || !s.endTime || s.endTime.getTime() < since) continue;
38
+ if (!s.totals) continue;
39
+ try {
40
+ usd += estimateCost(s.totals, s.model).actual;
41
+ count += 1;
42
+ } catch {
43
+ // 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
44
+ }
45
+ }
46
+ return { usd, sessions: count, sinceMs: since, label: monthLabel(now) };
47
+ }