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
@@ -5,8 +5,12 @@
5
5
  * a legacy command file is removed automatically. Cross-platform.
6
6
  * sprag install # install/update the skill
7
7
  * sprag install --force # overwrite existing skill file
8
- * sprag install --yes # take the defaults instead of asking
9
- * sprag install --no-input # same as --yes; also implied by CTS_NO_INPUT=1
8
+ * sprag install --manual # ask before each optional step instead
9
+ *
10
+ * The default is automatic: nothing is asked, the bundled presets are
11
+ * registered globally, and Codex is set up too when ~/.codex exists. `--manual`
12
+ * restores the step-by-step questions (they still need a terminal attached).
13
+ * `--yes` and `--no-input` are accepted as the explicit spelling of the default.
10
14
  */
11
15
 
12
16
  import { debug } from '../debug.js';
@@ -19,12 +23,13 @@ export async function run({ hasFlag }) {
19
23
  const { userLanguage, languageDecided, setUserLanguage } = await import('../config.js');
20
24
  const { canPrompt, confirm } = await import('../prompt.js');
21
25
  const force = hasFlag('--force');
22
- // Two features below write to ~/.claude and cost tokens in every session,
23
- // so the install shows what they contain and asks before turning them on.
24
- // Asking is only possible with a human attached: postinstall, CI and pipes
25
- // fall through to the previous automatic defaults so an unattended upgrade
26
- // behaves exactly as it did before.
27
- const interactive = canPrompt() && !hasFlag('--yes') && !hasFlag('--no-input');
26
+ // Automatic unless asked otherwise. A question per step turned the install
27
+ // into a dozen answers before any work, and every answer has a sensible
28
+ // default: the harness and presets are what make the first session save
29
+ // tokens. `--manual` brings the questions back, and only with a terminal
30
+ // attached, so postinstall, CI and pipes never wait on input.
31
+ const manual = hasFlag('--manual') && !hasFlag('--yes') && !hasFlag('--no-input');
32
+ const interactive = manual && canPrompt();
28
33
 
29
34
  // Output language, decided first because every line below it — and every
30
35
  // briefing the hooks inject from here on — is written in it. Until now it
@@ -342,10 +347,37 @@ export async function run({ hasFlag }) {
342
347
  // postinstall cannot hold a conversation, and a prompt here would be
343
348
  // answered by whoever happens to be at the terminal for a set of rules
344
349
  // they have not read. All the install does is say what is waiting.
350
+ // Presets now live in ratchet-preset.md. Moving the ones older versions
351
+ // appended to ratchet.md comes first, so they are not offered again below.
345
352
  try {
346
- const { pendingSeeds } = await import('../seed-rules.js');
353
+ const { preparePresetRatchet } = await import('../preset-ratchet.js');
354
+ const p = preparePresetRatchet();
355
+ if (p.moved) {
356
+ console.log('');
357
+ console.log(lang === 'ko'
358
+ ? ` preset: ratchet.md 에 있던 추천 룰 ${p.moved}건을 ${p.file} 로 옮겼습니다 (원본은 ratchet.md.bak-preset-migration).`
359
+ : ` preset: moved ${p.moved} bundled rule(s) from ratchet.md to ${p.file} (original kept as ratchet.md.bak-preset-migration).`);
360
+ }
361
+ } catch (e) {
362
+ debug('install:preset-ratchet', e); // the next session start retries
363
+ }
364
+
365
+ try {
366
+ const { pendingSeeds, acceptSeed } = await import('../seed-rules.js');
347
367
  const pending = pendingSeeds();
348
- if (pending.length > 0) {
368
+ if (pending.length > 0 && !manual) {
369
+ // Automatic install registers every preset globally. Global because a
370
+ // preset is a tool-wide rule, not a project's; `sprag seed` lists them
371
+ // and `sprag harness rm` removes any one afterwards.
372
+ let n = 0;
373
+ for (const p of pending) {
374
+ try { if (await acceptSeed(p.id, { scope: 'global' })) n += 1; } catch (e) { debug('install:seed-accept', e); }
375
+ }
376
+ console.log('');
377
+ console.log(lang === 'ko'
378
+ ? ` seed: 추천 룰 ${n}건을 전체 프로젝트에 등록했습니다 (모델 피팅 + 래칫 프리셋). 목록: sprag seed`
379
+ : ` seed: registered ${n} recommended rule(s) for all projects (model-fitting + ratchet presets). List: sprag seed`);
380
+ } else if (pending.length > 0) {
349
381
  console.log('');
350
382
  console.log(lang === 'ko'
351
383
  ? ` seed: 추천 룰 ${pending.length}건이 대기 중입니다 (모델 피팅 + 래칫 프리셋).`
@@ -358,6 +390,70 @@ export async function run({ hasFlag }) {
358
390
  debug('install:seed-offer', e); // optional feature; never fail install
359
391
  }
360
392
 
393
+ // Codex, when it is installed on this machine. Without this a Codex user
394
+ // had to know to run `sprag install --agent codex` after the package
395
+ // install. Registration is all we can do: Codex still asks the user to
396
+ // trust the hooks in /hooks before they run, and that step stays theirs.
397
+ try {
398
+ const { codexUserDir } = await import('../agent.js');
399
+ const { existsSync } = await import('node:fs');
400
+ if (process.env.CTS_NO_CODEX !== '1' && existsSync(codexUserDir())) {
401
+ let proceed = true;
402
+ if (interactive) {
403
+ console.log('');
404
+ proceed = await confirm(lang === 'ko'
405
+ ? ' codex: Codex 가 설치되어 있습니다. Codex 훅과 하네스도 함께 설정할까요?'
406
+ : ' codex: Codex is installed here. Set up its hooks and harness too?', { defaultValue: true });
407
+ }
408
+ if (proceed) {
409
+ const { configureCodexHooks } = await import('../codex-installer.js');
410
+ const { initCodexHarness } = await import('../codex-harness.js');
411
+ const { loadConfig } = await import('../config.js');
412
+ const { setupCodexPanel, describePanelSetup } = await import('../codex-panel-setup.js');
413
+ const config = loadConfig() || {};
414
+ // A manual install asks before touching the system package manager.
415
+ let env = process.env;
416
+ if (interactive && process.platform === 'darwin') {
417
+ const { tmuxAvailable } = await import('../codex-panel-setup.js');
418
+ if (!tmuxAvailable() && !await confirm(lang === 'ko'
419
+ ? ' codex: tmux 가 없습니다. Homebrew 로 설치해 같은 창에 패널을 붙일까요?'
420
+ : ' codex: tmux is missing. Install it with Homebrew so the panel shares the Codex window?', { defaultValue: true })) {
421
+ env = { ...process.env, CTS_NO_TMUX: '1' };
422
+ }
423
+ }
424
+ let panel;
425
+ try { panel = setupCodexPanel({ config, env }); }
426
+ catch (e) {
427
+ debug('install:codex-panel', e);
428
+ const auto = process.platform === 'darwin' && config.codex?.panelAuto !== false;
429
+ panel = { panelAuto: auto, mode: auto ? 'window' : 'off' };
430
+ }
431
+ const hook = configureCodexHooks({ panelAuto: panel.panelAuto });
432
+ console.log('');
433
+ console.log(` codex: hooks ${hook.action} (${hook.file})`);
434
+ for (const line of describePanelSetup(panel, lang)) console.log(` codex: ${line}`);
435
+ if (process.env.CTS_NO_HARNESS !== '1') {
436
+ const h = initCodexHarness({ scope: 'global' });
437
+ console.log(` codex: harness ${h.file}`);
438
+ }
439
+ try { (await import('../preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('install:codex-preset', e); }
440
+ if (!manual) {
441
+ const { pendingSeeds, acceptSeed } = await import('../seed-rules.js');
442
+ let n = 0;
443
+ for (const p of pendingSeeds({ agent: 'codex' })) {
444
+ try { if (await acceptSeed(p.id, { scope: 'global', agent: 'codex' })) n += 1; } catch (e) { debug('install:codex-seed', e); }
445
+ }
446
+ if (n) console.log(lang === 'ko' ? ` codex: 추천 룰 ${n}건 등록` : ` codex: registered ${n} recommended rule(s)`);
447
+ }
448
+ console.log(lang === 'ko'
449
+ ? ' codex: Codex 에서 /hooks 를 열어 Sprag 훅을 신뢰해야 실행됩니다 (Codex 보안 정책이라 자동화할 수 없습니다).'
450
+ : " codex: open /hooks in Codex and trust the Sprag hooks; Codex requires that step and it cannot be automated.");
451
+ }
452
+ }
453
+ } catch (e) {
454
+ debug('install:codex', e); // optional; `sprag install --agent codex` still works
455
+ }
456
+
361
457
  console.log('');
362
458
  console.log('Open Claude Code in any directory and just mention:');
363
459
  console.log(' "cache hit rate" / "1M context" / "5H cap" — the skill auto-activates.');
@@ -10,6 +10,7 @@ import { createRequire } from 'node:module';
10
10
  const require = createRequire(import.meta.url);
11
11
  import { readStdinJson } from '../stdin-payload.js';
12
12
  import { debug } from '../debug.js';
13
+ import { signedUsd } from '../money.js';
13
14
 
14
15
  /**
15
16
  * Whether the user turned doc2md on.
@@ -65,7 +66,7 @@ export async function run({ args, hasFlag, numArg }) {
65
66
  return;
66
67
  }
67
68
  const t = delegationSavedTotals();
68
- const money = (v) => `$${v.toFixed(2)}`;
69
+ const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`); // sign before the symbol
69
70
  // Lifetime leads (it is what the breakdown below adds up to); the
70
71
  // rolling windows follow as context rather than as competing headlines.
71
72
  console.log(lang === 'ko'
@@ -154,8 +155,8 @@ export async function run({ args, hasFlag, numArg }) {
154
155
  // two mean opposite things (no data vs. data showing no value).
155
156
  const measured = r.delegatedRuns
156
157
  ? (lang === 'ko'
157
- ? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감 ~$${(r.savedUsd || 0).toFixed(2)}`
158
- : `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved ~$${(r.savedUsd || 0).toFixed(2)}`)
158
+ ? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감 ~${signedUsd(r.savedUsd || 0)}`
159
+ : `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved ~${signedUsd(r.savedUsd || 0)}`)
159
160
  : (sessionRank !== null && !rs.worthDelegating(r.tier, sessionRank)
160
161
  ? (lang === 'ko'
161
162
  ? `이 규칙은 현재 기본 모델(${sessionModel}) 기준으로는 적용되지 않습니다. 세션 모델이 이미 위임 목표와 같은 급이어서 절감이 발생하지 않습니다.`
@@ -277,6 +278,10 @@ export async function run({ args, hasFlag, numArg }) {
277
278
  // when the rendering differs, so the common case is a read and a string
278
279
  // compare.
279
280
  try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
281
+ // Same reason for the preset ratchet: an upgrade that rewords or drops a
282
+ // bundled rule reaches ratchet-preset.md here, without a reinstall. It
283
+ // also moves presets older versions appended to ratchet.md.
284
+ try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
280
285
  // Registered rules whose delegated-category error rate crossed the
281
286
  // health threshold since promotion — the user approved these, so a
282
287
  // status change must be briefed, not just written into the md file.
@@ -5,7 +5,8 @@
5
5
  * sprag seed accept <id> --global # register one rule
6
6
  * sprag seed accept <id> --project # ... into this project only
7
7
  * sprag seed accept all --global # when the user says "all of them"
8
- * sprag seed skip <id> | skip all # never offer it again
8
+ * sprag seed skip <id> | skip all # never offer it again; for an
9
+ * # accepted preset, drop it from ratchet-preset.md
9
10
  * sprag seed reset # make every preset pending again
10
11
  *
11
12
  * Scope is explicit on purpose, exactly as `harness promote` requires it: the
@@ -26,7 +27,7 @@ export async function run({ args, hasFlag, agent = 'claude', root: rootArg }) {
26
27
  const scopeFlag = () => (hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null);
27
28
 
28
29
  if (sub === 'reset') {
29
- const n = seed.resetSeeds({ agent });
30
+ const n = await seed.resetSeeds({ agent });
30
31
  console.log(ko
31
32
  ? `기록된 응답 ${n}건을 지웠습니다 — 프리셋 전체가 다시 제안 대상이 됩니다.`
32
33
  : `Cleared ${n} recorded answer(s) — every preset is pending again.`);
@@ -46,9 +47,11 @@ export async function run({ args, hasFlag, agent = 'claude', root: rootArg }) {
46
47
 
47
48
  if (sub === 'skip') {
48
49
  for (const id of targets) {
49
- const r = seed.skipSeed(id, { lang, root, agent });
50
+ const r = await seed.skipSeed(id, { lang, root, agent });
50
51
  console.log(r
51
- ? (ko ? `건너뜀: ${id}` : `skipped: ${id}`)
52
+ ? (r.removed
53
+ ? (ko ? `프리셋 파일에서 뺐습니다: ${id}` : `removed from the preset file: ${id}`)
54
+ : (ko ? `건너뜀: ${id}` : `skipped: ${id}`))
52
55
  : (ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`));
53
56
  }
54
57
  return;
@@ -35,6 +35,29 @@ export async function run({ hasFlag, args = [] }) {
35
35
  for (const item of r.kept) {
36
36
  console.log(lang === 'ko' ? ` 유지: ${item}` : ` kept: ${item}`);
37
37
  }
38
+ // The install now registers Codex too when it finds it, so removal has to
39
+ // undo that as well; otherwise Codex keeps calling a command that may no
40
+ // longer exist. Only our own entries go, exactly as `uninstall --agent codex`.
41
+ try {
42
+ const { codexHookStatus, configureCodexHooks } = await import('../codex-installer.js');
43
+ const { codexHarnessStatus, uninitCodexHarness } = await import('../codex-harness.js');
44
+ const hooked = codexHookStatus().some((h) => h.registered);
45
+ let harness = false;
46
+ try { harness = codexHarnessStatus({ scope: 'global' }).configured > 0; } catch { /* no AGENTS.md */ }
47
+ if (hooked || harness) {
48
+ const hook = configureCodexHooks({ remove: true });
49
+ const h = uninitCodexHarness({ scope: 'global' });
50
+ console.log(lang === 'ko'
51
+ ? ` Codex: 훅 ${hook.action} (${hook.file}), 하네스 ${h.removed ? '제거' : '없음'}`
52
+ : ` Codex: hooks ${hook.action} (${hook.file}), harness ${h.removed ? 'removed' : 'absent'}`);
53
+ if (process.platform === 'darwin') {
54
+ try {
55
+ const { installPanelShell } = await import('../codex-panel-shell.js');
56
+ installPanelShell({ remove: true });
57
+ } catch (e) { /* a malformed or foreign block stays for the user to repair */ }
58
+ }
59
+ }
60
+ } catch { /* Codex state is optional; the Claude removal above already succeeded */ }
38
61
  if (!purge) {
39
62
  console.log(lang === 'ko'
40
63
  ? ` 기록된 절감액과 설정까지 지우려면 \`${CLI_NAME} uninstall --purge\` 를 실행하십시오.`
@@ -31,6 +31,17 @@ export async function run({ hasFlag, version }) {
31
31
  const lang = userLanguage();
32
32
  const cmd = upgradeCommand();
33
33
 
34
+ // pnpm and bun skip lifecycle scripts by default, and npm skips them under
35
+ // ignore-scripts, so the new copy's setup (Codex hooks, tmux panel) runs here.
36
+ // The install is idempotent, so npm running it twice changes nothing.
37
+ async function runSetup() {
38
+ console.log('$ sprag install');
39
+ const installCode = await runCommand('sprag install');
40
+ if (installCode !== 0) console.error(lang === 'ko'
41
+ ? '설치 후 설정(sprag install)이 실패했습니다. 새 셸에서 sprag install 을 직접 실행하십시오.'
42
+ : 'Post-upgrade setup (sprag install) failed. Run sprag install in a fresh shell.');
43
+ }
44
+
34
45
  if (hasFlag('--print')) {
35
46
  console.log(cmd);
36
47
  return;
@@ -55,6 +66,7 @@ export async function run({ hasFlag, version }) {
55
66
  : `Migration command failed with exit code ${migrateCode}. Check the output above.`);
56
67
  process.exit(migrateCode);
57
68
  }
69
+ await runSetup();
58
70
  console.log(lang === 'ko'
59
71
  ? '이전이 끝났습니다. 새 셸에서 sprag --version 으로 확인하십시오.'
60
72
  : 'Migration done. Confirm with sprag --version in a fresh shell.');
@@ -86,6 +98,7 @@ export async function run({ hasFlag, version }) {
86
98
  // still reports the old version. Refresh the cache against the new latest so
87
99
  // the statusline chip clears on the next render instead of lingering.
88
100
  await refreshUpdateState(s.latest);
101
+ await runSetup();
89
102
  console.log(lang === 'ko'
90
103
  ? `설치가 끝났습니다. 새 셸에서 sprag --version 으로 ${s.latest} 인지 확인하십시오.`
91
104
  : `Done. In a fresh shell, run sprag --version to confirm ${s.latest}.`);
package/src/cost.js CHANGED
@@ -197,7 +197,8 @@ function tokensToMillions(n) {
197
197
  * ephemeral1h — portion of cacheCreation billed at 1h rate (2x input)
198
198
  * output — output tokens
199
199
  */
200
- export function estimateCost(totals, model) {
200
+ export function estimateCost(totals, model, { price } = {}) {
201
+ if (price) return estimateCostAtPrice(totals, price);
201
202
  const tier = detectPricingTier(model);
202
203
  const p = PRICING[tier];
203
204
 
@@ -248,20 +249,73 @@ export function estimateCost(totals, model) {
248
249
  };
249
250
  }
250
251
 
252
+ /**
253
+ * Same shape as the list-price estimate, but priced at a gateway's per-token
254
+ * rates (LiteLLM /model/info). A token class the gateway does not price makes
255
+ * the whole estimate unknown (null) rather than free or list-priced.
256
+ */
257
+ function estimateCostAtPrice(totals, price) {
258
+ const write5m = totals.ephemeral5m ?? 0;
259
+ const write1h = totals.ephemeral1h ?? 0;
260
+ const untracked = Math.max(0, (totals.cacheCreation ?? 0) - write5m - write1h);
261
+ const read = totals.cacheRead ?? 0;
262
+ if (read > 0 && price.cacheRead == null) return null;
263
+ if (write5m + untracked > 0 && price.cacheWrite == null) return null;
264
+ if (write1h > 0 && price.cacheWrite1h == null) return null;
265
+ if (price.input == null || price.output == null) return null;
266
+ const perM = (rate) => (rate ?? 0) * 1e6;
267
+ const input = perM(price.input), output = perM(price.output), cacheRead = perM(price.cacheRead);
268
+ const cacheWrite5m = perM(price.cacheWrite), cacheWrite1h = perM(price.cacheWrite1h);
269
+
270
+ const actual =
271
+ tokensToMillions(totals.input) * input +
272
+ tokensToMillions(write5m + untracked) * cacheWrite5m +
273
+ tokensToMillions(write1h) * cacheWrite1h +
274
+ tokensToMillions(read) * cacheRead +
275
+ tokensToMillions(totals.output) * output;
276
+ const totalInput = totals.input + (totals.cacheCreation ?? 0) + read;
277
+ const noCacheCost = tokensToMillions(totalInput) * input + tokensToMillions(totals.output) * output;
278
+
279
+ // The 5m scenario needs a 5m write rate; without one it is not computable, so
280
+ // it collapses to the actual cost and the "extra cost" row stays hidden.
281
+ let scenario5mCost = actual;
282
+ if (price.cacheWrite != null) {
283
+ const extra5mCreation = write1h * 2;
284
+ scenario5mCost =
285
+ tokensToMillions(totals.input) * input +
286
+ tokensToMillions(write5m + write1h + untracked + extra5mCreation) * cacheWrite5m +
287
+ tokensToMillions(Math.max(0, read - extra5mCreation)) * cacheRead +
288
+ tokensToMillions(totals.output) * output;
289
+ }
290
+
291
+ return {
292
+ tier: 'gateway',
293
+ actual: round(actual),
294
+ noCacheCost: round(noCacheCost),
295
+ savings: round(noCacheCost - actual),
296
+ savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
297
+ scenario5mCost: round(scenario5mCost),
298
+ extraCostIf5m: round(scenario5mCost - actual),
299
+ extraCostIf5mApplicable: price.cacheWrite != null && write1h > 0,
300
+ };
301
+ }
302
+
251
303
  /**
252
304
  * Price a set of sessions each at its own model and sum. One estimateCost over
253
305
  * the summed totals priced every token at whichever session came first in the
254
306
  * list — sorted by mtime, so a haiku scratch session touched earliest set the
255
307
  * rate for a day of Fable work and understated the saving about tenfold.
256
308
  * Sessions whose model has no known rate are skipped, as month-spend does.
309
+ * `estimate` lets a caller price by path (see claude-price.js); null skips a session.
257
310
  */
258
- export function estimateCostAcross(sessions) {
311
+ export function estimateCostAcross(sessions, estimate = estimateCost) {
259
312
  const acc = { actual: 0, noCacheCost: 0, scenario5mCost: 0, extraCostIf5mApplicable: false };
260
313
  const tiers = new Map();
261
314
  for (const s of sessions || []) {
262
315
  if (!s?.totals) continue;
263
316
  let c;
264
- try { c = estimateCost(s.totals, s.model); } catch { continue; }
317
+ try { c = estimate(s.totals, s.model); } catch { continue; }
318
+ if (!c) continue;
265
319
  acc.actual += c.actual;
266
320
  acc.noCacheCost += c.noCacheCost;
267
321
  acc.scenario5mCost += c.scenario5mCost;
@@ -54,6 +54,12 @@ const GRAY = fg(100, 116, 139, '\x1b[90m');
54
54
  const BOLD = '\x1b[1m';
55
55
 
56
56
  export function formatMoney(usd) {
57
+ // The sign leads the currency symbol ("-$0.12"); a loss that rounds to zero
58
+ // prints as plain "$0.00" rather than "-$0.00".
59
+ if (usd < 0) {
60
+ const abs = formatMoney(-usd);
61
+ return abs === '$0.00' ? abs : `-${abs}`;
62
+ }
57
63
  // Boundaries sit at 999.5/999500 so a value that would round up into the
58
64
  // next band's width ($999.9 → "$1000") jumps to that band's unit instead —
59
65
  // otherwise "$1000" renders visually larger than "$1.0K".
@@ -512,8 +518,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
512
518
  // Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
513
519
  // appears exactly when learning has had its chance and still came up short.
514
520
  const unresolvedRuns = Number(data.unresolvedRuns) || 0;
515
- const delegateSeg = delegationSaved > 0
516
- ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
521
+ const delegateSeg = delegationSaved !== 0
522
+ ? `${c(delegationSaved < 0 ? RED : GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
517
523
  : (unresolvedRuns >= MIN_VOTES
518
524
  ? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
519
525
  : null);
@@ -552,8 +558,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
552
558
  // verbose: "Sep spend $42.1 (since Sep 1)"
553
559
  const month = data.monthSpend;
554
560
  let monthSeg = null;
555
- if (month && Number(month.usd) > 0) {
556
- const amt = formatMoney(Number(month.usd));
561
+ // Gateway sessions whose model the gateway does not price are left out of the
562
+ // sum and counted, so a partial total says so and an all-unpriced month is n/a.
563
+ const monthUnpriced = Number(month?.unpriced) || 0;
564
+ if (month && (Number(month.usd) > 0 || monthUnpriced > 0)) {
565
+ const amt = (Number(month.usd) > 0 ? formatMoney(Number(month.usd)) : 'n/a') +
566
+ (monthUnpriced > 0 ? ` +${monthUnpriced} unpriced` : '');
557
567
  if (isIcon) {
558
568
  monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
559
569
  } else if (verbose) {
@@ -576,7 +586,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
576
586
  // text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
577
587
  const totals = data.delegationTotals;
578
588
  let totalsLine = null;
579
- if (!singleLine && totals && Number(totals.total) > 0) {
589
+ if (!singleLine && totals && Number(totals.total) !== 0 && Number.isFinite(Number(totals.total))) {
580
590
  const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
581
591
  // Model changes behind the total, family-level and version-free: `opus →
582
592
  // haiku 2× $0.6`. Versions bump constantly and add nothing here — the
@@ -595,9 +605,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
595
605
  const pairText = pairs
596
606
  .map((p) => `${c(GRAY)}${p.from}→${p.to} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
597
607
  .join(` ${c(GRAY)}·${c(RESET)} `);
608
+ const tone = Number(totals.total) < 0 ? RED : GREEN; // a net loss must not read as a win
598
609
  totalsLine =
599
- `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
600
- `${c(GREEN)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
610
+ `${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
611
+ `${c(tone)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
601
612
  (pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
602
613
  }
603
614
 
@@ -253,6 +253,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
253
253
  lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
254
254
  }
255
255
  lines.push(' ' + tableBot(costW));
256
+ if (cost.unpriced > 0) lines.push(` ${cost.unpriced} session(s) not priced by the gateway are left out.`);
256
257
  lines.push('');
257
258
 
258
259
  // Daily trend
@@ -0,0 +1,39 @@
1
+ // LiteLLM /model/info prices, shared by the Codex and Claude sides. Per-token USD; both sides
2
+ // price a call by the path it took, so these are only ever the gateway's own numbers.
3
+ export const PRICE_MAX_AGE_MS = 7 * 86400000;
4
+ export const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
5
+
6
+ export function deploymentPrice(row) {
7
+ const info = row?.model_info || {};
8
+ const mode = typeof info.mode === 'string' ? info.mode : null;
9
+ if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
10
+ const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
11
+ if (input === null || output === null) return null;
12
+ return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost),
13
+ cacheWrite1h: rate(info.cache_creation_input_token_cost_above_1hr) };
14
+ }
15
+
16
+ /** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
17
+ export function gatewayPrices(payload) {
18
+ if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
19
+ const groups = new Map();
20
+ for (const row of payload.data) {
21
+ if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
22
+ groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
23
+ }
24
+ return Object.fromEntries([...groups].map(([name, prices]) => [name,
25
+ prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
26
+ }
27
+
28
+ /**
29
+ * USD for a token mix at one model's prices, or null when a needed rate is
30
+ * missing. Cached input without a cache-read rate is unknown, not free.
31
+ */
32
+ export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
33
+ if (!price || price.input === null || price.output === null) return null;
34
+ if (cached > 0 && price.cacheRead === null) return null;
35
+ // cache_write_input_tokens is billed at the creation rate when one is published, else as input.
36
+ const uncached = Math.max(0, input - cached - cacheWrite);
37
+ return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
38
+ }
39
+
@@ -40,6 +40,38 @@ export function modelRatchetImportLine(scope = 'project') {
40
40
  return scope === 'global' ? '@~/.claude/ratchet-model.md' : '@.claude/ratchet-model.md';
41
41
  }
42
42
 
43
+ /**
44
+ * The preset ratchet: the bundled rules the user accepted globally. It is a
45
+ * tool-owned file like ratchet-model.md, regenerated whole from the recorded
46
+ * answers, so an upgrade can reword, add or drop presets without touching the
47
+ * rules the user wrote into ratchet.md. Presets are global-only, so only the
48
+ * global block imports it.
49
+ */
50
+ export function presetRatchetImportLine() {
51
+ return '@~/.claude/ratchet-preset.md';
52
+ }
53
+ export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
54
+
55
+ /**
56
+ * Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
57
+ * file is loaded into every session, so the header stays short.
58
+ */
59
+ export function renderPresetRatchet(rules, { lang = 'en', agent = 'claude' } = {}) {
60
+ const ko = lang === 'ko' && agent !== 'codex';
61
+ const suffix = agent === 'codex' ? ' --agent codex' : '';
62
+ const head = ko
63
+ ? `# Preset Ratchet Rules (sprag 관리)
64
+
65
+ sprag 에 동봉된 추천 룰 가운데 전역으로 등록한 것입니다. sprag 가 이 파일을 통째로 다시 쓰므로
66
+ 직접 고치지 마십시오. 하나를 빼려면 \`sprag seed skip <id>${suffix}\`, 직접 쓰는 룰은 ratchet.md 에 둡니다.`
67
+ : `# Preset Ratchet Rules (managed by sprag)
68
+
69
+ Bundled rules you registered globally. Sprag rewrites this file whole, so do not
70
+ edit it. Drop one with \`sprag seed skip <id>${suffix}\`; your own rules belong in ratchet.md.`;
71
+ const body = rules.map((r) => `- [${r.id}] ${r.text}`).join('\n');
72
+ return `${head}\n\n## Rules\n\n${body}${body ? '\n' : ''}`;
73
+ }
74
+
43
75
  // Matches either scope's import line, so completeness checks don't need to know
44
76
  // which scope wrote the block. `(?!-)` keeps the ratchet-model import from
45
77
  // counting as the ratchet.md one.
@@ -118,7 +150,7 @@ ${HARNESS_SECTIONS[4].heading}
118
150
  📥 ratchet 룰 로드 (이 줄들을 지우면 룰이 적용되지 않습니다):
119
151
 
120
152
  ${ratchetImportLine(scope)}
121
- ${modelRatchetImportLine(scope)}
153
+ ${modelRatchetImportLine(scope)}${scope === 'global' ? `\n${presetRatchetImportLine()}` : ''}
122
154
  ${HARNESS_BLOCK_END}
123
155
  `;
124
156
  }
package/src/harness.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  appendRatchetRule,
22
22
  RATCHET_IMPORT_RE,
23
23
  MODEL_RATCHET_IMPORT_RE,
24
+ renderPresetRatchet,
24
25
  } from './harness-templates.js';
25
26
  import { routeWarningForStatusline } from './route-scan.js';
26
27
  import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
@@ -265,6 +266,18 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
265
266
  result.skipped.push(mrPath + ' (already exists)');
266
267
  }
267
268
 
269
+ // ratchet-preset.md — tool-owned and imported by the global block only, so
270
+ // it is seeded here for the same reason as ratchet-model.md above.
271
+ if (scope === 'global') {
272
+ const prPath = join(homedir(), '.claude', 'ratchet-preset.md');
273
+ if (!existsSync(prPath)) {
274
+ try {
275
+ writeFileSync(prPath, renderPresetRatchet([], { lang: userLanguage() }));
276
+ result.wrote.push(prPath);
277
+ } catch { /* unwritable — the next session start renders it */ }
278
+ }
279
+ }
280
+
268
281
  return result;
269
282
  }
270
283
 
@@ -347,7 +360,19 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
347
360
  * Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
348
361
  * Returns { path, scope, added, skippedRules, presets }.
349
362
  */
350
- export function harnessPull({ root = findProjectRoot(), scope = 'global', agent = 'claude' } = {}) {
363
+ export async function harnessPull({ root = findProjectRoot(), scope = 'global', agent = 'claude' } = {}) {
364
+ // Global presets live in ratchet-preset.md now, rendered from recorded
365
+ // answers, so pulling them globally is accepting every one of them.
366
+ if (scope === 'global') {
367
+ const { acceptedGlobalPresets, presetRatchetPath } = await import('./preset-ratchet.js');
368
+ const { pendingSeeds, acceptSeed } = await import('./seed-rules.js');
369
+ const added = [];
370
+ for (const s of pendingSeeds({ root, agent }).filter((x) => x.kind === 'ratchet')) {
371
+ if (await acceptSeed(s.id, { scope: 'global', root, agent })) added.push(s.ruleText);
372
+ }
373
+ const all = acceptedGlobalPresets({ agent }).length;
374
+ return { path: presetRatchetPath({ agent }), scope, added, skippedRules: all - added.length, presets: presetRuleEntries().length };
375
+ }
351
376
  const presets = presetRules();
352
377
  const rmPath = resolveRatchetPath(scope, root, agent);
353
378
  const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };