sprag-cli 3.54.0 → 3.56.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 (42) hide show
  1. package/README.ko.md +17 -5
  2. package/README.md +23 -6
  3. package/bin/cli.js +6 -6
  4. package/docs/CODEX.ko.md +57 -10
  5. package/docs/CODEX.md +89 -20
  6. package/package.json +6 -3
  7. package/src/claude-price.js +28 -0
  8. package/src/codex-brief.js +20 -0
  9. package/src/codex-cache-policy.js +26 -41
  10. package/src/codex-delegation.js +45 -5
  11. package/src/codex-doctor.js +1 -1
  12. package/src/codex-harness.js +20 -9
  13. package/src/codex-hooks.js +114 -19
  14. package/src/codex-ledger.js +94 -16
  15. package/src/codex-panel-setup.js +98 -0
  16. package/src/codex-panel.js +12 -7
  17. package/src/codex-parser.js +41 -4
  18. package/src/codex-route-scan.js +14 -7
  19. package/src/codex-rule-health.js +18 -0
  20. package/src/commands/codex-delegate.js +12 -3
  21. package/src/commands/codex.js +41 -11
  22. package/src/commands/harness.js +13 -2
  23. package/src/commands/install.js +106 -10
  24. package/src/commands/route-scan.js +8 -3
  25. package/src/commands/seed.js +7 -4
  26. package/src/commands/uninstall.js +23 -0
  27. package/src/commands/upgrade.js +13 -0
  28. package/src/cost.js +57 -3
  29. package/src/formatters/statusline.js +18 -7
  30. package/src/formatters/table.js +1 -0
  31. package/src/gateway-prices.js +39 -0
  32. package/src/harness-templates.js +33 -1
  33. package/src/harness.js +26 -1
  34. package/src/litellm-models.js +72 -10
  35. package/src/model-rules.js +2 -1
  36. package/src/money.js +9 -0
  37. package/src/month-spend.js +10 -5
  38. package/src/openai-prices.js +33 -0
  39. package/src/preset-ratchet.js +133 -0
  40. package/src/route-scan.js +16 -8
  41. package/src/savings-ledger.js +10 -6
  42. package/src/seed-rules.js +32 -7
@@ -27,6 +27,7 @@
27
27
  import { gatewayBase } from './gateway-auth.js';
28
28
  import { loadProfileMap, updateProfileMap, resetModelAliasCache, profileIdFrom, isGatewayModelId } from './model-alias.js';
29
29
  import { isRecognizedModelId } from './cost.js';
30
+ import { deploymentPrice, PRICE_MAX_AGE_MS } from './gateway-prices.js';
30
31
  import { debug } from './debug.js';
31
32
 
32
33
  /** 게이트웨이 모델 맵의 신선도 기준. 배포 구성은 하루에 여러 번 바뀌지 않습니다. */
@@ -70,6 +71,23 @@ function normalizeCandidate(id) {
70
71
  return s.replace(/\[[^\]]*\]$/, '');
71
72
  }
72
73
 
74
+ /**
75
+ * 배포 항목 하나의 별칭을 고릅니다. model_name → model_info.base_model →
76
+ * litellm_params.model 순으로 정규화해 isRecognizedModelId()를 통과하는 첫
77
+ * 후보이며, 없으면 null입니다. 별칭 추출과 가격 키 등록이 같은 판정을 쓰도록
78
+ * 한 곳에 둡니다.
79
+ */
80
+ function pickAlias(item) {
81
+ const modelName = typeof item.model_name === 'string' ? item.model_name : '';
82
+ const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
83
+ const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
84
+ for (const candidate of [modelName, baseModel, rawModel]) {
85
+ const normalized = normalizeCandidate(candidate);
86
+ if (normalized && isRecognizedModelId(normalized)) return normalized;
87
+ }
88
+ return null;
89
+ }
90
+
73
91
  /**
74
92
  * `/model/info` 응답에서 프로파일 ID(또는 계열명 없는 사내 별칭) → 모델 계열
75
93
  * 매핑을 뽑아냅니다. 순수 함수입니다: 네트워크·파일 I/O가 없고, 잘못된 입력에도
@@ -99,17 +117,8 @@ export function deriveAliasesFromModelInfo(payload) {
99
117
  for (const item of data) {
100
118
  if (!item || typeof item !== 'object') continue;
101
119
  const modelName = typeof item.model_name === 'string' ? item.model_name : '';
102
- const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
103
120
  const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
104
-
105
- let alias = null;
106
- for (const candidate of [modelName, baseModel, rawModel]) {
107
- const normalized = normalizeCandidate(candidate);
108
- if (normalized && isRecognizedModelId(normalized)) {
109
- alias = normalized;
110
- break;
111
- }
112
- }
121
+ const alias = pickAlias(item);
113
122
 
114
123
  if (!alias) {
115
124
  if (modelName) skipped.push(modelName);
@@ -134,6 +143,58 @@ export function deriveAliasesFromModelInfo(payload) {
134
143
  return { aliases, skipped, count: data.length };
135
144
  }
136
145
 
146
+ /**
147
+ * `/model/info` 응답에서 모델 키 → 토큰당 가격 표를 만듭니다. 순수 함수입니다.
148
+ *
149
+ * 한 배포 항목의 가격을 model_name, 정규화한 model_name·base_model·
150
+ * litellm_params.model, 그리고 별칭 추출이 고른 별칭 아래에 모두 등록합니다.
151
+ * 트랜스크립트의 모델 문자열이 어느 모양으로 오든 찾을 수 있게 하려는 것입니다.
152
+ * 같은 키에 서로 다른 가격이 들어오면 어느 쪽이 맞는지 알 수 없으므로 null로
153
+ * 둡니다. 값은 숫자뿐이고, 키는 normalizeCandidate를 거쳐 ARN이 리소스 부분으로
154
+ * 줄어 있으므로 AWS 계정 ID가 파일에 남지 않습니다.
155
+ *
156
+ * @returns {Record<string, object|null>}
157
+ */
158
+ export function deriveGatewayPrices(payload) {
159
+ const data = Array.isArray(payload?.data) ? payload.data : [];
160
+ const prices = new Map();
161
+ for (const item of data) {
162
+ if (!item || typeof item !== 'object') continue;
163
+ const price = deploymentPrice(item);
164
+ const keys = new Set([
165
+ // 원문 model_name이 ARN이면 계정 ID가 있으므로 원문 키는 쓰지 않습니다.
166
+ typeof item.model_name === 'string' && !isGatewayModelId(item.model_name) ? item.model_name : '',
167
+ normalizeCandidate(item.model_name),
168
+ normalizeCandidate(item.model_info?.base_model),
169
+ normalizeCandidate(item.litellm_params?.model),
170
+ pickAlias(item) || '',
171
+ ]);
172
+ for (const key of keys) {
173
+ if (!key) continue;
174
+ if (!prices.has(key)) prices.set(key, price);
175
+ else if (JSON.stringify(prices.get(key)) !== JSON.stringify(price)) prices.set(key, null);
176
+ }
177
+ }
178
+ return Object.fromEntries(prices);
179
+ }
180
+
181
+ /**
182
+ * LiteLLM 경로로 나간 호출의 모델 가격(토큰당 USD). 게이트웨이 표에 없거나,
183
+ * 표가 PRICE_MAX_AGE_MS보다 오래됐거나, 키가 모호하면 null입니다. 정가표로
184
+ * 대신 채우지 않습니다. 호출이 실제로 지난 경로의 가격만 씁니다.
185
+ */
186
+ export function gatewayPriceFor(model, env = process.env, now = Date.now()) {
187
+ const gateway = readGatewayModelMap(env);
188
+ if (!gateway?.prices || typeof gateway.prices !== 'object') return null;
189
+ const fetchedAt = Date.parse(gateway.fetchedAt);
190
+ if (!Number.isFinite(fetchedAt) || fetchedAt > now || now - fetchedAt > PRICE_MAX_AGE_MS) return null;
191
+ if (typeof model !== 'string' || !model) return null;
192
+ for (const key of [model, normalizeCandidate(model)]) {
193
+ if (key && Object.hasOwn(gateway.prices, key)) return gateway.prices[key] || null;
194
+ }
195
+ return null;
196
+ }
197
+
137
198
  /**
138
199
  * profile-map.json의 gateway 절을 읽습니다. base가 현재 gatewayBase(env)와
139
200
  * 다르면 다른 게이트웨이의 캐시이므로 null을 돌려줍니다. 예산 캐시(budgetWindow)
@@ -240,6 +301,7 @@ export async function refreshGatewayModelMap({ base, key, fetchImpl = fetch } =
240
301
  base,
241
302
  fetchedAt: new Date().toISOString(),
242
303
  aliases: result.aliases,
304
+ prices: deriveGatewayPrices(payload),
243
305
  skipped: result.skipped,
244
306
  count: result.count,
245
307
  },
@@ -34,6 +34,7 @@ import { homedir } from 'node:os';
34
34
  import { userDataDir } from './paths.js';
35
35
  import { userLanguage } from './config.js';
36
36
  import { agentPhrase, agentPhraseEn } from './agents.js';
37
+ import { signedUsd } from './money.js';
37
38
 
38
39
  // Post-promotion delegated-category error rate above this flags the rule
39
40
  // for review (rule-health). Calibrated against local T0 avg error incidence.
@@ -356,7 +357,7 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
356
357
  ? `preset (curated), registered ${r.promotedAt}`
357
358
  : `×${r.count || 0}, err ${pct(r.errRate)}%, seen ${r.lastSeen || r.promotedAt}`;
358
359
  if (!r.delegatedRuns) return base;
359
- const saved = r.savedUsd ? `, saved ~$${r.savedUsd.toFixed(2)}` : '';
360
+ const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
360
361
  return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
361
362
  };
362
363
  // Merged T2+T1 rules carry two caps, so they state both once rather than
package/src/money.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A signed dollar amount for text output: "-$0.12" for a loss, "$0.12" for a
3
+ * saving. Routing savings are recorded signed, and "~$-0.12" misreads as a
4
+ * formatting glitch rather than a loss.
5
+ */
6
+ export function signedUsd(usd, digits = 2) {
7
+ const fixed = Math.abs(usd).toFixed(digits);
8
+ return usd < 0 && Number(fixed) !== 0 ? `-$${fixed}` : `$${fixed}`;
9
+ }
@@ -4,10 +4,12 @@
4
4
  * LiteLLM·Bedrock처럼 5h/7d cap 이 아예 없는 게이트웨이 사용자도
5
5
  * "이번 달에 얼마나 썼는지"는 늘 궁금하므로, 세션 로그를 기반으로
6
6
  * 달력 월 단위 지출을 통계선에 상시 노출합니다. 모델 단가는 세션마다
7
- * 다르므로 세션별로 estimateCost 를 적용해 합산합니다.
7
+ * 다르므로 세션별로 호출 경로에 맞는 가격(sessionCost)을 적용해 합산합니다.
8
+ * LiteLLM 게이트웨이 세션은 게이트웨이 가격표로만 계산하고, 표에 없는 모델은
9
+ * 합계에서 빼고 unpriced 로 셉니다. 정가표로 대신 채우지 않습니다.
8
10
  */
9
11
 
10
- import { estimateCost } from './cost.js';
12
+ import { sessionCost } from './claude-price.js';
11
13
 
12
14
  /** 이번 달 1일 00:00(로컬)의 epoch ms. */
13
15
  export function monthStartMs(now = new Date()) {
@@ -27,21 +29,24 @@ export function monthLabel(now = new Date()) {
27
29
  *
28
30
  * @param {Array} sessions parseAllSessions 결과
29
31
  * @param {Date} [now]
30
- * @returns {{usd:number, sessions:number, sinceMs:number, label:string}}
32
+ * @returns {{usd:number, sessions:number, unpriced:number, sinceMs:number, label:string}}
31
33
  */
32
34
  export function monthSpend(sessions, now = new Date()) {
33
35
  const since = monthStartMs(now);
34
36
  let usd = 0;
35
37
  let count = 0;
38
+ let unpriced = 0;
36
39
  for (const s of sessions || []) {
37
40
  if (!s || !s.endTime || s.endTime.getTime() < since) continue;
38
41
  if (!s.totals) continue;
39
42
  try {
40
- usd += estimateCost(s.totals, s.model).actual;
43
+ const c = sessionCost(s.totals, s.model);
44
+ if (!c) { unpriced += 1; continue; }
45
+ usd += c.actual;
41
46
  count += 1;
42
47
  } catch {
43
48
  // 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
44
49
  }
45
50
  }
46
- return { usd, sessions: count, sinceMs: since, label: monthLabel(now) };
51
+ return { usd, sessions: count, unpriced, sinceMs: since, label: monthLabel(now) };
47
52
  }
@@ -0,0 +1,33 @@
1
+ // OpenAI list prices for Codex calls that go straight to OpenAI (ChatGPT/OpenAI login).
2
+ // Source: https://developers.openai.com/api/docs/pricing , "Standard" tier, short-context
3
+ // rates, checked 2026-10-01. Long-context surcharges are not modeled, so requests past the
4
+ // long-context threshold are under-priced. Calls through a LiteLLM gateway never use this table.
5
+ export const OPENAI_PRICES_CHECKED_AT = Date.parse('2026-10-01T00:00:00Z');
6
+
7
+ // Per-1M-token numbers as listed on the page; null where the page shows "-".
8
+ const PER_MILLION = {
9
+ 'gpt-6-astra': [10, 1, 12.5, 50],
10
+ 'gpt-6.1-sol': [2, 0.1, 2.5, 10],
11
+ 'gpt-6-luna': [0.1, 0.01, 0.125, 0.5],
12
+ 'gpt-6-sol': [2, 0.2, 2.5, 10],
13
+ 'gpt-5.6-sol': [4, 0.4, 5, 20],
14
+ 'gpt-5.6-terra': [2, 0.2, 2.5, 12],
15
+ 'gpt-5.6-luna': [0.2, 0.02, 0.25, 1.2],
16
+ 'gpt-5.5': [5, 0.5, null, 30],
17
+ 'gpt-5.5-pro': [30, null, null, 180],
18
+ 'gpt-5.4': [2.5, 0.25, null, 15],
19
+ 'gpt-5.4-pro': [30, null, null, 180],
20
+ 'gpt-5.4-mini': [0.75, 0.075, null, 4.5],
21
+ 'gpt-5.4-nano': [0.2, 0.02, null, 1.25],
22
+ 'gpt-5.2': [1.75, 0.175, null, 14],
23
+ 'gpt-5.2-pro': [21, null, null, 168],
24
+ 'gpt-5.1': [1.25, 0.125, null, 10],
25
+ 'gpt-5': [1.25, 0.125, null, 10],
26
+ };
27
+
28
+ // Parse "<n>e-6" instead of dividing so 0.1 per 1M is exactly 1e-7, without float noise.
29
+ const perToken = (n) => (n === null ? null : Number(`${n}e-6`));
30
+
31
+ /** model -> per-token USD `{ input, cacheRead, cacheWrite, output }`. */
32
+ export const OPENAI_LIST_PRICES = Object.fromEntries(Object.entries(PER_MILLION).map(([model, [input, cacheRead, cacheWrite, output]]) =>
33
+ [model, { input: perToken(input), cacheRead: perToken(cacheRead), cacheWrite: perToken(cacheWrite), output: perToken(output) }]));
@@ -0,0 +1,133 @@
1
+ /**
2
+ * preset-ratchet — the bundled ratchet presets the user accepted globally,
3
+ * kept in a file of their own.
4
+ *
5
+ * Presets used to be appended to ratchet.md next to the user's own rules. Once
6
+ * there, an upgrade could not reword a preset, drop one that turned out wrong,
7
+ * or tell which lines were ours: every line looked like the user's. This file
8
+ * holds only presets and is regenerated whole from the recorded answers in
9
+ * seed-state, the same way ratchet-model.md is regenerated from the rule
10
+ * registry. So an upgrade updates it by itself, `seed skip` removes one, and
11
+ * ratchet.md stays the user's.
12
+ *
13
+ * Claude Code ~/.claude/ratchet-preset.md (imported by the global harness block)
14
+ * Codex <CODEX_HOME>/ratchet-preset.md (injected by the SessionStart hook)
15
+ *
16
+ * Presets accepted into a project scope still go to that project's ratchet.md:
17
+ * a project rule is the user's placement decision, not a tool-wide default.
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ import { codexUserDir } from './agent.js';
24
+ import { userLanguage } from './config.js';
25
+ import {
26
+ HARNESS_BLOCK_BEGIN,
27
+ HARNESS_BLOCK_END,
28
+ PRESET_RATCHET_IMPORT_RE,
29
+ presetRatchetImportLine,
30
+ renderPresetRatchet,
31
+ } from './harness-templates.js';
32
+ import { loadSeedState, ratchetPresets, saveSeedState } from './seed-rules.js';
33
+
34
+ export function presetRatchetPath({ agent = 'claude' } = {}) {
35
+ return agent === 'codex'
36
+ ? join(codexUserDir(), 'ratchet-preset.md')
37
+ : join(homedir(), '.claude', 'ratchet-preset.md');
38
+ }
39
+
40
+ function globalRatchetPath(agent) {
41
+ return agent === 'codex' ? join(codexUserDir(), 'ratchet.md') : join(homedir(), '.claude', 'ratchet.md');
42
+ }
43
+
44
+ function writeIfChanged(file, text) {
45
+ let before = null;
46
+ try { before = readFileSync(file, 'utf8'); } catch { /* first write */ }
47
+ if (before === text) return false;
48
+ mkdirSync(dirname(file), { recursive: true });
49
+ const tmp = `${file}.tmp-${process.pid}`;
50
+ writeFileSync(tmp, text);
51
+ renameSync(tmp, file);
52
+ return true;
53
+ }
54
+
55
+ /** The accepted-global presets, in the bundled order, with this version's wording. */
56
+ export function acceptedGlobalPresets({ agent = 'claude', lang = userLanguage() } = {}) {
57
+ const { decided } = loadSeedState({ agent });
58
+ return ratchetPresets(lang, { agent })
59
+ .filter((p) => decided[p.id]?.action === 'accepted' && decided[p.id]?.scope === 'global')
60
+ .map((p) => ({ id: p.id, text: p.text }));
61
+ }
62
+
63
+ /**
64
+ * Rewrite the preset file from the recorded answers. A preset that a newer
65
+ * release removed simply stops being rendered; its answer stays in seed-state
66
+ * so it is not offered again if it comes back under the same text.
67
+ */
68
+ export function syncPresetRatchet({ agent = 'claude', lang = userLanguage() } = {}) {
69
+ const file = presetRatchetPath({ agent });
70
+ const rules = acceptedGlobalPresets({ agent, lang });
71
+ const changed = writeIfChanged(file, renderPresetRatchet(rules, { lang, agent }));
72
+ return { file, rules: rules.length, changed };
73
+ }
74
+
75
+ /**
76
+ * Move presets that earlier versions appended to the global ratchet.md into
77
+ * the preset file. A line counts as a preset only when its text, without the
78
+ * date stamp, equals a bundled rule in either language, so nothing the user
79
+ * wrote themselves is touched. The original is backed up once before the
80
+ * first change.
81
+ */
82
+ export function migrateLegacyPresetRules({ agent = 'claude' } = {}) {
83
+ const file = globalRatchetPath(agent);
84
+ if (!existsSync(file)) return { file, moved: 0 };
85
+ const byText = new Map();
86
+ for (const lang of ['ko', 'en']) {
87
+ for (const p of ratchetPresets(lang, { agent })) byText.set(p.text.trim(), p.id);
88
+ }
89
+ const stamp = /^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/;
90
+ const original = readFileSync(file, 'utf8');
91
+ const moved = [];
92
+ const kept = original.split('\n').filter((line) => {
93
+ const m = line.match(/^\s*-\s+(.*)$/);
94
+ if (!m) return true;
95
+ const id = byText.get(m[1].replace(stamp, '').trim());
96
+ if (!id) return true;
97
+ moved.push(id);
98
+ return false;
99
+ });
100
+ if (!moved.length) return { file, moved: 0 };
101
+ writeFileSync(`${file}.bak-preset-migration`, original);
102
+ writeIfChanged(file, kept.join('\n'));
103
+ const state = loadSeedState({ agent });
104
+ const today = new Date().toISOString().slice(0, 10);
105
+ for (const id of moved) state.decided[id] = { action: 'accepted', at: today, scope: 'global', migrated: true };
106
+ saveSeedState(state, { agent });
107
+ return { file, moved: moved.length };
108
+ }
109
+
110
+ /**
111
+ * Add the preset import to a global harness block written before the file
112
+ * existed. Only the tool's own marked block is edited; a CLAUDE.md without the
113
+ * block is left alone, since the user chose not to install the harness there.
114
+ */
115
+ export function ensurePresetImport() {
116
+ const file = join(homedir(), '.claude', 'CLAUDE.md');
117
+ let text;
118
+ try { text = readFileSync(file, 'utf8'); } catch { return { file, added: false }; }
119
+ const begin = text.indexOf(HARNESS_BLOCK_BEGIN);
120
+ const end = text.indexOf(HARNESS_BLOCK_END);
121
+ if (begin < 0 || end < begin) return { file, added: false };
122
+ if (PRESET_RATCHET_IMPORT_RE.test(text.slice(begin, end))) return { file, added: false };
123
+ writeFileSync(file, `${text.slice(0, end)}${presetRatchetImportLine()}\n${text.slice(end)}`);
124
+ return { file, added: true };
125
+ }
126
+
127
+ /** Everything a session start or an install does: migrate, render, and wire the import. */
128
+ export function preparePresetRatchet({ agent = 'claude', lang = userLanguage() } = {}) {
129
+ const migrated = migrateLegacyPresetRules({ agent });
130
+ const synced = syncPresetRatchet({ agent, lang });
131
+ const imported = agent === 'codex' ? { added: false } : ensurePresetImport();
132
+ return { ...synced, moved: migrated.moved, importAdded: imported.added };
133
+ }
package/src/route-scan.js CHANGED
@@ -25,7 +25,8 @@ import { collectSessionRecords } from './session-records.js';
25
25
  import {
26
26
  collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
27
27
  } from './subagent-records.js';
28
- import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
28
+ import { modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
29
+ import { sessionCost } from './claude-price.js';
29
30
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
30
31
  import { modelRuleBaseText } from './model-rules.js';
31
32
 
@@ -115,7 +116,8 @@ const CATEGORIES = [
115
116
  label: '탐색·조회 (파일/값 찾기)',
116
117
  labelEn: 'lookup (finding files/values)',
117
118
  agent: 'haiku-explore',
118
- kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]],
119
+ kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
120
+ [/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
119
121
  },
120
122
  {
121
123
  id: 'read',
@@ -333,9 +335,13 @@ export function worthDelegating(tier, rank) {
333
335
  * Approximation, deliberately stated as one: it holds token counts constant,
334
336
  * which a cheaper model would not reproduce exactly. Directionally right and
335
337
  * enough to rank rules by value, so it is rendered as "~$X".
338
+ *
339
+ * Signed: a run that moved to a pricier model is a loss and comes back
340
+ * negative. null means a side has no price (a gateway session whose /model/info
341
+ * table lacks the model), which is unpriced, not zero.
336
342
  */
337
343
  export function runSaving(run, mainModel) {
338
- if (!run.model || !mainModel) return 0;
344
+ if (!run.model || !mainModel) return null;
339
345
  const totals = {
340
346
  input: run.input,
341
347
  cacheCreation: run.cacheCreation,
@@ -344,9 +350,10 @@ export function runSaving(run, mainModel) {
344
350
  ephemeral1h: run.ephemeral1h,
345
351
  output: run.out,
346
352
  };
347
- const actual = estimateCost(totals, run.model).actual;
348
- const counterfactual = estimateCost(totals, mainModel).actual;
349
- return Math.max(0, counterfactual - actual);
353
+ const actual = sessionCost(totals, run.model);
354
+ const counterfactual = sessionCost(totals, mainModel);
355
+ if (!actual || !counterfactual) return null;
356
+ return counterfactual.actual - actual.actual;
350
357
  }
351
358
 
352
359
  const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
@@ -549,7 +556,7 @@ export async function runRouteScan({ days = 14 } = {}) {
549
556
  d.runs += 1;
550
557
  if (isFailedRun(run)) d.errRuns += 1;
551
558
  d.outTokens += run.out || 0;
552
- d.savedUsd += saved;
559
+ if (Number.isFinite(saved)) d.savedUsd += saved; // unpriced runs add nothing
553
560
  delegatedStats.set(key, d);
554
561
  };
555
562
  // Ledger events feed the statusline's weekly/monthly "Routing saved"
@@ -759,7 +766,8 @@ export async function runRouteScan({ days = 14 } = {}) {
759
766
  // profile-map.json's `modelAliases` to bring these runs back in.
760
767
  if (!baseline || !isRecognizedModelId(baseline)) continue;
761
768
  const usd = runSaving(e.run, baseline);
762
- if (usd <= 0) continue;
769
+ // A loss is recorded too; only an unpriced run (null) has nothing to record.
770
+ if (!Number.isFinite(usd)) continue;
763
771
  priced.push({
764
772
  key: e.run.path,
765
773
  ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
@@ -29,6 +29,9 @@ import { userDataDir } from './paths.js';
29
29
  const WEEK_MS = 7 * 24 * 3600 * 1000;
30
30
  const MONTH_MS = 30 * 24 * 3600 * 1000;
31
31
 
32
+ /** Stored USD is kept to 4 decimals so re-scans compare equal and files stay small. */
33
+ export const roundUsd = (usd) => Math.round(Number(usd) * 10000) / 10000;
34
+
32
35
  export function ledgerPath() {
33
36
  return join(userDataDir(), 'delegation-ledger.json');
34
37
  }
@@ -55,8 +58,9 @@ export function loadLedger() {
55
58
  * Upsert saving events. `events` is an array of
56
59
  * { key, ts, usd, rule, from, to } where `key` is the run's transcript path
57
60
  * (unique per subagent run) and `from`/`to` name the models the routing
58
- * decision moved between. Zero-saving runs are skipped — they carry no
59
- * information the totals care about.
61
+ * decision moved between. `usd` is signed: a run on a pricier model is a loss
62
+ * and is recorded as a negative amount so it reduces the totals. Only
63
+ * non-finite amounts (unpriced runs) are skipped.
60
64
  */
61
65
  export function recordDelegationEvents(events) {
62
66
  if (!Array.isArray(events) || events.length === 0) return;
@@ -64,9 +68,9 @@ export function recordDelegationEvents(events) {
64
68
  data.version = LEDGER_VERSION;
65
69
  let changed = false;
66
70
  for (const e of events) {
67
- if (!e || !e.key || !(Number(e.usd) > 0) || !Number.isFinite(e.ts)) continue;
71
+ if (!e || !e.key || typeof e.usd !== 'number' || !Number.isFinite(e.usd) || !Number.isFinite(e.ts)) continue;
68
72
  const prev = data.events[e.key];
69
- const usd = Math.round(Number(e.usd) * 10000) / 10000;
73
+ const usd = roundUsd(e.usd);
70
74
  if (prev && prev.ts === e.ts && prev.usd === usd) continue;
71
75
  data.events[e.key] = {
72
76
  ts: e.ts,
@@ -115,8 +119,8 @@ export function delegationSavedTotals(now = Date.now()) {
115
119
  const byPair = new Map();
116
120
  try {
117
121
  for (const e of Object.values(loadLedger().events)) {
118
- const usd = Number(e.usd) || 0;
119
- if (usd <= 0) continue;
122
+ const usd = Number(e.usd);
123
+ if (!Number.isFinite(usd)) continue;
120
124
  totals.total += usd;
121
125
  if (Number.isFinite(e.ts)) {
122
126
  if (now - e.ts <= WEEK_MS) totals.week += usd;
package/src/seed-rules.js CHANGED
@@ -177,6 +177,15 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
177
177
  if (!seed) return null;
178
178
 
179
179
  if (seed.kind === 'ratchet') {
180
+ // A global preset goes to the tool-owned preset file, which is rendered
181
+ // from the recorded answer; a project one is the user's placement and is
182
+ // appended to that project's ratchet.md as before.
183
+ if (scope === 'global') {
184
+ recordDecision(id, 'accepted', { scope }, { agent });
185
+ const { syncPresetRatchet } = await import('./preset-ratchet.js');
186
+ const res = syncPresetRatchet({ agent, lang });
187
+ return { id, kind: 'ratchet', scope, path: res.file, rule: seed.ruleText };
188
+ }
180
189
  const res = harnessPromote(seed.ruleText, { root, scope, agent });
181
190
  recordDecision(id, 'accepted', { scope }, { agent });
182
191
  return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
@@ -218,23 +227,39 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
218
227
  lastSeen: today,
219
228
  });
220
229
  const written = syncAllFiles();
221
- recordDecision(id, 'accepted', { scope });
230
+ recordDecision(id, 'accepted', { scope }, { agent });
222
231
  return { id, kind: 'model', scope, paths: written, rule: composeRuleText(entry.rule, entry, lang) };
223
232
  }
224
233
 
225
- /** Record a "no" so the rule is never offered again (until `seed reset`). */
226
- export function skipSeed(id, opts = {}) {
234
+ /**
235
+ * Record a "no" so the rule is never offered again (until `seed reset`).
236
+ *
237
+ * A preset already accepted globally can be skipped too: that is how one comes
238
+ * out of the preset file, which is rendered from these answers.
239
+ */
240
+ export async function skipSeed(id, opts = {}) {
227
241
  const seed = findSeed(id, opts);
228
- if (!seed) return null;
229
- recordDecision(id, 'skipped', {}, { agent: opts.agent });
230
- return { id, kind: seed.kind, rule: seed.ruleText };
242
+ if (seed) {
243
+ recordDecision(id, 'skipped', {}, { agent: opts.agent });
244
+ return { id, kind: seed.kind, rule: seed.ruleText };
245
+ }
246
+ const { decided } = loadSeedState({ agent: opts.agent });
247
+ const preset = ratchetPresets(opts.lang, { agent: opts.agent }).find((p) => p.id === id);
248
+ if (!preset || decided[id]?.action !== 'accepted' || decided[id]?.scope !== 'global') return null;
249
+ recordDecision(id, 'skipped', { was: 'accepted' }, { agent: opts.agent });
250
+ const { syncPresetRatchet } = await import('./preset-ratchet.js');
251
+ syncPresetRatchet({ agent: opts.agent, lang: opts.lang });
252
+ return { id, kind: 'ratchet', rule: preset.text, removed: true };
231
253
  }
232
254
 
233
255
  /** Forget every recorded answer — the full set becomes pending again. */
234
- export function resetSeeds({ agent = 'claude' } = {}) {
256
+ export async function resetSeeds({ agent = 'claude' } = {}) {
235
257
  const state = loadSeedState({ agent });
236
258
  const count = Object.keys(state.decided).length;
237
259
  saveSeedState({ decided: {} }, { agent });
260
+ // The preset file mirrors the answers, so forgetting them empties it.
261
+ const { syncPresetRatchet } = await import('./preset-ratchet.js');
262
+ syncPresetRatchet({ agent });
238
263
  return count;
239
264
  }
240
265