sprag-cli 3.55.0 → 3.57.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 (59) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +5 -1
  3. package/bin/cli.js +60 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +44 -5
  6. package/docs/CODEX.md +74 -12
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +49 -0
  11. package/src/codex-brief.js +55 -2
  12. package/src/codex-cache-policy.js +26 -41
  13. package/src/codex-delegation.js +56 -8
  14. package/src/codex-doctor.js +1 -1
  15. package/src/codex-harness.js +63 -18
  16. package/src/codex-hooks.js +129 -20
  17. package/src/codex-ledger.js +150 -16
  18. package/src/codex-panel.js +51 -26
  19. package/src/codex-parser.js +41 -4
  20. package/src/codex-route-scan.js +14 -7
  21. package/src/codex-rule-health.js +41 -0
  22. package/src/commands/brief.js +14 -3
  23. package/src/commands/codex-delegate.js +17 -5
  24. package/src/commands/codex.js +20 -10
  25. package/src/commands/harness.js +6 -0
  26. package/src/commands/route-scan.js +4 -3
  27. package/src/compact-window.js +12 -4
  28. package/src/config.js +3 -3
  29. package/src/cost.js +62 -5
  30. package/src/ctx-window-cache.js +61 -0
  31. package/src/delegation-guard.js +15 -9
  32. package/src/doc2md.cjs +22 -8
  33. package/src/fig2md.cjs +7 -1
  34. package/src/formatters/statusline.js +22 -11
  35. package/src/formatters/table.js +19 -10
  36. package/src/gateway-auth.js +31 -3
  37. package/src/gateway-prices.js +39 -0
  38. package/src/harness.js +43 -12
  39. package/src/history.js +6 -2
  40. package/src/hook-manager.js +54 -32
  41. package/src/installer.js +286 -153
  42. package/src/korean-lint.cjs +40 -7
  43. package/src/litellm-budget.js +10 -1
  44. package/src/litellm-models.js +72 -10
  45. package/src/model-rules.js +90 -42
  46. package/src/money.js +9 -0
  47. package/src/month-spend.js +23 -12
  48. package/src/openai-prices.js +33 -0
  49. package/src/parser.js +232 -56
  50. package/src/postinstall.js +32 -0
  51. package/src/route-caps.js +74 -0
  52. package/src/route-inject.js +31 -7
  53. package/src/route-scan.js +93 -33
  54. package/src/savings-ledger.js +43 -11
  55. package/src/seed-rules.js +3 -4
  56. package/src/session-cache.js +35 -1
  57. package/src/state-file.js +43 -0
  58. package/src/stats.js +20 -5
  59. package/src/stdin-payload.js +18 -4
package/README.ko.md CHANGED
@@ -185,7 +185,7 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
185
185
 
186
186
  | 명령 | 하는 일 |
187
187
  |---|---|
188
- | `sprag` | 최근 1일 진단 리포트 |
188
+ | `sprag` | 최근 30일 진단 리포트 |
189
189
  | `sprag handoff` | 한도에 막히기 전에 작업 상태를 백업 |
190
190
  | `sprag route-scan` | 내 기록에서 위임 후보 찾기 (LLM 호출 0회) |
191
191
  | `sprag route-scan savings` | 라우팅 절감 원장 조회 |
@@ -209,6 +209,10 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
209
209
  | [한국어 문체 지침](https://github.com/rootstudioyaml/sprag/blob/main/docs/KOREAN-STYLE.ko.md) | 주입 내용, 쓰기 시점 검사, 적용 전후 비교 |
210
210
  | [라우터가 아닌 이유](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.ko.md) | 실시간 라우팅이 캐시를 깨서 비용을 키우는 구조 |
211
211
  | [게이트웨이와 환경](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.ko.md) | Bedrock·Vertex·LiteLLM, 가격표, FAQ, 동작 원리 |
212
+ | [클로드 코드 토큰 줄이는 법](https://sprag.io/docs/ko/reduce-claude-code-tokens/) | 압축·초기화 타이밍, 서브에이전트 탐색, 문서 변환, 캐시 TTL |
213
+ | [클로드 코드 사용 한도](https://sprag.io/docs/ko/claude-code-usage-limits/) | 5시간·7일 윈도우, 함께 지켜보는 법, 턴당 비용 줄이기 |
214
+ | [코덱스 토큰 사용량](https://sprag.io/docs/ko/codex-token-usage/) | 세션 범위 나누기, 네이티브 서브에이전트 위임, 코덱스 문서 변환 |
215
+ | [도구 비교](https://sprag.io/docs/ko/compare/) | ccusage·claude-usage·CCometixLine 같은 스테이터스라인 도구와 Sprag의 차이 |
212
216
 
213
217
  LiteLLM 게이트웨이 환경에서는 `sprag profile-map --refresh` 가 게이트웨이의 `GET /model/info` 에서 모델 별칭을 바로 읽어 오므로, route-scan 이 위임 투표가 쌓이길 기다리거나 `profile-map.json` 을 손으로 편집할 필요가 없어졌습니다. 이 갱신은 LiteLLM 예산 게이지와 같은 5분 점검·24시간 캐시 주기를 씁니다. 인증 토큰은 저장하지 않으며, 호출 시점마다 `apiKeyHelper` 에서 읽어 그 헬퍼의 수명 그대로 만료되게 둡니다. 자세한 내용은 [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md) 문서에 있습니다.
214
218
 
package/README.md CHANGED
@@ -229,7 +229,7 @@ work types, and once each has a rule there is nothing left to promote.
229
229
 
230
230
  | Command | What it does |
231
231
  |---|---|
232
- | `sprag` | Last-1-day diagnostic report |
232
+ | `sprag` | Last-30-day diagnostic report |
233
233
  | `sprag handoff` | Back work up to markdown before a cap blocks you |
234
234
  | `sprag route-scan` | Find delegation candidates in your own history (0 LLM calls) |
235
235
  | `sprag route-scan savings` | The routing-savings ledger |
@@ -254,6 +254,10 @@ Every subcommand, flag and the output-language setting: [command reference](http
254
254
  | [Korean style](https://github.com/rootstudioyaml/sprag/blob/main/docs/KOREAN-STYLE.md) | What gets injected, the write-time check, before/after |
255
255
  | [Not a router](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.md) | Why realtime routing breaks the cache and costs more |
256
256
  | [Gateways & environment](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.md) | Bedrock, Vertex, LiteLLM budgets, pricing table, FAQ, how it works |
257
+ | [Reduce Claude Code tokens](https://sprag.io/docs/reduce-claude-code-tokens/) | Compact/clear timing, subagent exploration, document conversion, cache TTL |
258
+ | [Claude Code usage limits](https://sprag.io/docs/claude-code-usage-limits/) | The 5-hour and 7-day windows, watching both, cutting per-turn cost |
259
+ | [Codex token usage](https://sprag.io/docs/codex-token-usage/) | Session scoping, native subagent delegation, document conversion for Codex |
260
+ | [Compare](https://sprag.io/docs/compare/) | Sprag vs ccusage, claude-usage, and statusline tools like CCometixLine |
257
261
 
258
262
  Behind a LiteLLM gateway, `sprag profile-map --refresh` now reads model aliases straight from the gateway's own `GET /model/info`, so route-scan no longer needs to wait on learned votes or a hand-edited `profile-map.json`. The refresh uses the same 5-minute check and 24-hour cache as the LiteLLM budget gauge. It never stores your auth token: each refresh reads it from `apiKeyHelper` at call time and lets it expire on the helper's own schedule. Details: [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md).
259
263
 
package/bin/cli.js CHANGED
@@ -36,12 +36,13 @@ import {
36
36
  extractContextUsage,
37
37
  extractEffort,
38
38
  extractModel,
39
+ extractSessionId,
39
40
  extractTranscriptPath,
40
41
  } from '../src/stdin-payload.js';
41
42
 
42
43
  import { effortChipLevel } from '../src/ultracode.js';
43
44
 
44
- import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
45
+ import { parseAllSessions, sessionsSince, getLastUserMessageTime } from '../src/parser.js';
45
46
  import {
46
47
  dailyTrend,
47
48
  ttlBreakdown,
@@ -52,7 +53,7 @@ import {
52
53
  sessionMetrics,
53
54
  diagnoseSession,
54
55
  } from '../src/stats.js';
55
- import { estimateCostAcross } from '../src/cost.js';
56
+ import { sessionCostAcross } from '../src/claude-price.js';
56
57
  import { chipForIssues } from '../src/advice.js';
57
58
  import { debug } from '../src/debug.js';
58
59
  import { createArgs } from '../src/cli-args.js';
@@ -92,6 +93,15 @@ function readUpdateChip() {
92
93
  }
93
94
  }
94
95
 
96
+ /**
97
+ * Whether the table report may use ANSI codes. The formatter looked at NO_COLOR
98
+ * alone, so `--no-color` did nothing there and a piped report (`sprag | less`,
99
+ * a redirect to a file) carried escape codes into its output.
100
+ */
101
+ function tableColor() {
102
+ return !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY;
103
+ }
104
+
95
105
  // Subcommands this build knows how to run. Used only by the guard below.
96
106
  const KNOWN_SUBCOMMANDS = new Set([
97
107
  'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
@@ -131,6 +141,16 @@ Usage:
131
141
  sprag last most recent warning + how to handle it
132
142
  sprag history recent warning transitions
133
143
  sprag handoff write a session handoff file
144
+ sprag mode [keywords] statusline preferences (icon/text, en/ko, window, ttl)
145
+ sprag seed register the bundled starter rules, one answer at a time
146
+ sprag korean on|off|status Korean writing guidance + write-time check
147
+ sprag cohesion on|off|status English sentence-connection guidance
148
+ sprag doc2md on|off|<file> convert documents to Markdown before the model reads them
149
+ sprag delegate on|off|status add bounds and context to Task/Agent delegation prompts
150
+ sprag compact-window audit or pin autoCompactWindow for 1M sessions
151
+ sprag litellm-budget LiteLLM gateway key budget and spend
152
+ sprag update-check is a newer version out?
153
+ sprag brief --hook internal: the UserPromptSubmit hook entry point
134
154
  sprag feedback "<msg>" file a bug report / feature request (no browser needed)
135
155
  sprag upgrade install the latest release
136
156
  sprag --install-hook install cache-monitor PostToolUse hook
@@ -434,7 +454,7 @@ async function main() {
434
454
  const { buildTableDemoData } = await import('../src/demo.js');
435
455
  const { formatReport } = await import('../src/formatters/table.js');
436
456
  const data = buildTableDemoData({ version: PKG_VERSION });
437
- console.log(formatReport(data));
457
+ console.log(formatReport(data, { color: tableColor() }));
438
458
  return;
439
459
  }
440
460
 
@@ -529,7 +549,10 @@ async function main() {
529
549
  const excludeSessionPath =
530
550
  getArg('--exclude-session') || process.env.CACHE_MONITOR_EXCLUDE_SESSION || undefined;
531
551
 
532
- const sessions = await parseAllSessions({ days, projectFilter });
552
+ // The statusline also reports this month's spend. Naming the month start here
553
+ // lets the one pass over the transcripts trim boundary sessions to it too.
554
+ const monthSince = isStatusline ? (await import('../src/month-spend.js')).monthStartMs() : undefined;
555
+ const sessions = await parseAllSessions({ days, projectFilter, sinceMs: monthSince });
533
556
 
534
557
  if (sessions.length === 0) {
535
558
  // Statusline must always emit a single line (no multi-line help spam every
@@ -552,6 +575,15 @@ async function main() {
552
575
  // Resolved the same way as on the populated line below, so the chip does
553
576
  // not change its mind about the level when the analysis window empties.
554
577
  const effort = effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson));
578
+ // Recorded here too: a fresh session can render before it has any request
579
+ // in the analysis window, and its first prompt is briefed from this.
580
+ const ctxLive = extractContextUsage(stdinJson);
581
+ if (ctxLive?.size) {
582
+ try {
583
+ const { recordLiveWindow } = await import('../src/ctx-window-cache.js');
584
+ recordLiveWindow(extractSessionId(stdinJson), ctxLive.size);
585
+ } catch (e) { debug('ctx-window-cache:record', e); }
586
+ }
555
587
  if (caps || model) {
556
588
  try {
557
589
  const { persistSnapshot } = await import('../src/caps-cache.js');
@@ -592,7 +624,7 @@ async function main() {
592
624
  const ttl = ttlBreakdown(sessions);
593
625
  const sum = summary(sessions);
594
626
  const anomalies = detectAnomalies(trend);
595
- const cost = estimateCostAcross(sessions);
627
+ const cost = sessionCostAcross(sessions);
596
628
  const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
597
629
  const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
598
630
 
@@ -618,6 +650,16 @@ async function main() {
618
650
  ? effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson))
619
651
  : extractEffort(stdinJson);
620
652
  const ctxLive = extractContextUsage(stdinJson);
653
+ // The prompt-submit briefing has no payload field for the window, so it reads
654
+ // back what this render was told (see src/ctx-window-cache.js).
655
+ if (isStatusline && ctxLive?.size) {
656
+ try {
657
+ const { recordLiveWindow } = await import('../src/ctx-window-cache.js');
658
+ recordLiveWindow(extractSessionId(stdinJson), ctxLive.size);
659
+ } catch (e) {
660
+ debug('ctx-window-cache:record', e);
661
+ }
662
+ }
621
663
  if (isStatusline && (caps || model)) {
622
664
  try {
623
665
  const { persistSnapshot } = await import('../src/caps-cache.js');
@@ -730,28 +772,23 @@ async function main() {
730
772
  if (isStatusline) {
731
773
  try {
732
774
  const { monthSpend, monthStartMs } = await import('../src/month-spend.js');
733
- const daysNeeded = (Date.now() - monthStartMs()) / 86400000;
775
+ const since = monthStartMs();
776
+ const daysNeeded = (Date.now() - since) / 86400000;
734
777
  const monthSessions = days >= daysNeeded
735
778
  ? sessions
736
- : await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter });
737
- monthSpendInfo = monthSpend(monthSessions);
779
+ : await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter, sinceMs: since });
780
+ // 월 경계에 걸친 세션은 월초 이후 요청만 남긴다. 그러지 않으면 1일 자정을
781
+ // 넘긴 세션의 전월분이 이번 달 지출에 그대로 들어온다.
782
+ monthSpendInfo = monthSpend(await sessionsSince(monthSessions, since));
738
783
  } catch (e) {
739
784
  debug('month-spend', e);
740
785
  }
741
786
  }
742
787
 
743
- // What delegation has measurably saved, read straight from the registry
744
- // route-scan maintains. A lookup, never a scan: the statusline re-renders
745
- // every few seconds and a scan parses tens of MB of transcripts.
746
- let delegationSaved = 0;
747
- try {
748
- const { delegationSavedUsd } = await import('../src/model-rules.js');
749
- delegationSaved = delegationSavedUsd();
750
- } catch (e) {
751
- debug('model-rules:saved', e); // an unreadable registry just hides the chip
752
- }
753
788
  // Rolling week/month/lifetime totals from the delegation ledger — the
754
789
  // statusline's headline line. Falls back to null (chip hidden) on any error.
790
+ // A lookup, never a scan: the statusline re-renders every few seconds and a
791
+ // scan parses tens of MB of transcripts.
755
792
  let delegationTotals = null;
756
793
  try {
757
794
  const { delegationSavedTotals } = await import('../src/savings-ledger.js');
@@ -759,6 +796,10 @@ async function main() {
759
796
  } catch (e) {
760
797
  debug('savings-ledger:totals', e);
761
798
  }
799
+ // The single-line layout shows the same lifetime figure as the headline
800
+ // line. It used to sum the rule registry instead, a scan-window snapshot
801
+ // priced against another baseline, so one label carried two amounts.
802
+ const delegationSaved = Number(delegationTotals?.total) || 0;
762
803
  // Document conversions, same shape as the delegation totals: a lifetime sum
763
804
  // plus a document count. A lookup of a small JSON file, never a scan.
764
805
  let doc2mdTotals = null;
@@ -857,7 +898,7 @@ async function main() {
857
898
  });
858
899
  } else {
859
900
  const { formatReport } = await import('../src/formatters/table.js');
860
- output = formatReport(data);
901
+ output = formatReport(data, { color: tableColor() });
861
902
  }
862
903
 
863
904
  console.log(output);
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * npm `postinstall` entry point: runs `sprag install` for a global install and
5
+ * says why it did not for anything else. See src/postinstall.js.
6
+ */
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { postinstallDecision } from '../src/postinstall.js';
12
+
13
+ const decision = postinstallDecision(process.env);
14
+ if (!decision.run) {
15
+ console.log(`sprag: setup skipped (${decision.reason}). Run \`sprag install\` to register the hooks and the statusline.`);
16
+ process.exit(0);
17
+ }
18
+ const cli = join(dirname(fileURLToPath(import.meta.url)), 'cli.js');
19
+ spawnSync(process.execPath, [cli, 'install'], { stdio: 'inherit' });
20
+ // A failed setup must not fail the package install: the CLI is still usable
21
+ // and `sprag install` can be run again by hand.
22
+ process.exit(0);
package/docs/CODEX.ko.md CHANGED
@@ -51,7 +51,7 @@ macOS zsh 환경에서는 설치가 tmux 설치 여부를 먼저 확인합니다
51
51
  | 한국어 검사 | 파일 쓰기 후 문체 위반을 검사하며 block·warn·off를 지원합니다 |
52
52
  | 상태 표시 | 별도 패널, tmux 패널, 일회성 상태줄을 제공합니다 |
53
53
  | 브리핑·이력 | 최신 컨텍스트와 사용량 경고를 알리고 세션별로 저장합니다 |
54
- | 위임 원장 | Sprag가 위임한 하위 실행과 LiteLLM 단가로 예상 차액을 계산합니다 |
54
+ | 위임 원장 | Sprag가 위임한 하위 실행의 토큰에 호출 경로의 단가를 적용해 예상 차액을 계산합니다. 직접 호출은 OpenAI 정가를, LiteLLM 공급자는 그 게이트웨이의 단가를 씁니다 |
55
55
 
56
56
  ## 패널과 보고서
57
57
 
@@ -108,8 +108,38 @@ sprag seed reset --agent codex
108
108
  등록할 때 `--model ID`를 요구합니다. 모델 사용 가능 여부는 실제 공급자 설정에 달려 있습니다.
109
109
  명시한 모델과 사용자 정의 역할은 유지하며, 사용할 수 없는 하위 에이전트 도구를 만들어 내지 않습니다.
110
110
 
111
+ Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(기록용 훅을 붙여 직접
112
+ 확인한 결과입니다). 그래서 실제로 동작하는 경로는 프롬프트 시점입니다. 규칙에 맞는 프롬프트가
113
+ 오면 `[Sprag model routing]` 안내가 붙고, `harness init`이 넣는 AGENTS.md 블록이 이 안내를
114
+ "사용자가 위임을 요청했다"는 뜻으로 읽게 합니다. 자식은 `fork_turns "none"`으로 뜨고, 부모는
115
+ 같은 일을 직접 하지 않고 `wait_agent`(제한 시간 120초 이상)로 자식이 끝나기를 기다립니다. 귀속은
116
+ 자식이 route id를 되받아 적는 것에 기대지 않습니다. 대신 `SubagentStart`가 대기 중인 기록(같은
117
+ 부모 세션, 같은 목표 모델, 30분 이내)에 새로 뜬 자식을 매칭해 묶습니다. 어떤 자식도 가져가지
118
+ 않은 안내는 부모 세션에 다음 프롬프트가 들어올 때 닫히므로, 나중에 다른 이유로 뜬 자식이 그
119
+ 안내의 절감으로 잡히지 않습니다. 같은 프롬프트 시점에 아직 장부에 반영되지 않은 위임 실행도
120
+ 별도 프로세스로 계산하므로, `Routing saved`는 다음 route-scan을 기다리지 않고 위임 뒤 첫
121
+ 프롬프트에서 갱신됩니다.
122
+
123
+ 이 안내는 부모 세션의 컨텍스트가 `codex.delegateMinContext`(기본 60000 토큰) 아래면 붙지
124
+ 않습니다. 2026-09-30 실측에서 작은 세션은 스폰·대기·검증이라는 조율 비용이 자식이 아끼는
125
+ 비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 아직 전용 CLI 명령은
126
+ 없고, `sprag mode` 실행 시 "Stored config file"로 나오는 설정 파일(맥은
127
+ `~/Library/Application Support/claude-token-saver/config.json`)에서 `codex.delegateMinContext`
128
+ 값을 직접 고치면 됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
129
+ 있을 뿐, 0.159.2에서는 호출되지 않습니다. 위임 원장은 자식이 쓴 토큰을 부모 모델 단가로 환산한
130
+ 추정치이며, 부모가 조율에 쓴 턴은 빼지 않습니다. 끝나기 전에 중단된 자식은 부모의 일을 대신하지
131
+ 못했으므로, 그 비용 전액을 손실로 기록하고 항목에 `aborted`를 표시합니다.
132
+
111
133
  SessionStart는 캐시된 후보를 읽고 필요할 때 별도 프로세스에서 갱신합니다. 명시적인 `--refresh`는
112
- 대기 주기를 건너뜁니다. `seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
134
+ 대기 주기를 건너뜁니다.
135
+
136
+ Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만 보존하고, 그보다 길면 가운데를
137
+ 잘라 냅니다. 그래서 SessionStart는 추정치 2,000토큰 안에 들어가는 항목만 잘리지 않은 상태로
138
+ 넣습니다. 우선순위는 ratchet 룰, 한국어 지침, 응집성 지침, 문서 변환 안내, 제안 순입니다. 들어가지
139
+ 못한 룰과 지침은 Sprag 데이터 디렉터리의 프로젝트별 파일에 쓰고, 컨텍스트 첫머리에서 그 파일을
140
+ 먼저 읽으라고 안내합니다. rollout의 `originator`로 `codex exec` 실행을 알아보며, 답할 사람이
141
+ 없으므로 seed 제안과 route 안내를 넣지 않습니다.
142
+ `seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
113
143
  Codex의 수락·거절 기록도 Claude 기록과 분리합니다.
114
144
 
115
145
  ## 래칫 규칙
@@ -158,9 +188,18 @@ sprag capabilities --agent codex
158
188
  하위 실행의 토큰을 고정하고 부모·자식 단가를 각각 적용한 차이이므로, 실제 청구서의 절감액은
159
189
  아닙니다. 손해는 음수로 남기며 단가나 사용량이 없으면 0 대신 미상으로 표시합니다.
160
190
 
161
- 단가는 설정된 LiteLLM의 `/model/info`에서 읽습니다. 공급자가 다른 동명 모델을 섞지 않고,
162
- 같은 별칭 아래 배포 단가가 다르면 계산하지 않습니다. 단가 캐시는 7일 동안 사용하며 이미
163
- 계산한 실행은 단가 사본을 보존합니다. 직접 OpenAI 연결이나 구독 요금은 추정하지 않습니다.
191
+ 단가는 호출이 실제로 거친 경로로 정하며, 두 출처는 서로를 대신하지 않습니다.
192
+
193
+ - ChatGPT·OpenAI 로그인이나 `api.openai.com`으로 바로 가는 API 키는 내장된 OpenAI 정가를
194
+ 씁니다. 정가는 Standard 등급의 짧은 컨텍스트 단가이고 2026-10-01에 확인했습니다. 긴 컨텍스트
195
+ 할증은 반영하지 않으므로, 그 기준을 넘는 요청은 실제보다 낮게 계산됩니다.
196
+ - LiteLLM 공급자는 그 게이트웨이의 `/model/info` 단가를 씁니다. 같은 별칭 아래 배포 단가가
197
+ 다르면 계산하지 않고, 단가 캐시는 7일 동안 사용합니다. 게이트웨이가 단가를 주지 않는 모델은
198
+ 정가로 채우지 않고 미상으로 둡니다.
199
+
200
+ 공급자가 다른 동명 모델은 섞지 않으며, 이미 계산한 실행은 단가 사본을 보존합니다.
201
+ `sprag delegate rules --agent codex`는 룰마다 실측 위임 건수와 실패율, 절감액을 보여 줍니다.
202
+ 실패율의 95% 신뢰 하한이 20%를 넘으면(실측 5건 이상) 조건을 좁히거나 룰을 지우라고 알립니다.
164
203
  위임 후 `--refresh`로 원장을 갱신할 수 있고, 아직 일치하지 않은 기록은 7일 동안 찾습니다.
165
204
 
166
205
  캐시 타이머는 확인된 공급자 정책과 기록된 캐시 사용 시각을 보여 줍니다. 정확한 만료 시각이나
package/docs/CODEX.md CHANGED
@@ -76,14 +76,14 @@ shared hooks needed by other features.
76
76
  | Token report | Table, JSON, or CSV from local Codex rollout logs, with time and project filters |
77
77
  | Statusline | `--statusline` or `--format statusline` renders the compact panel once, with wrapping and label/color preferences |
78
78
  | Briefing | UserPromptSubmit reports fresh context/limit threshold crossings once per session and threshold cycle |
79
- | Warning history | `last` and `history` read Codex-only, per-session warning records |
79
+ | Warning history | `last` and `history` read Codex-only, per-session records of context (80%/95%) and rate-limit (90%) warnings and handoffs; cache and TTL chips are not recorded |
80
80
  | Handoff | Git state, session/model, recorded context/tokens/limits, and fillable remaining-work sections |
81
81
  | Subagents | Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance |
82
82
  | Cache timer | Resolved provider retention policy after recorded cache usage; exact expiry is unknown |
83
83
  | Route discovery | Repeated simple turns grouped by project, provider, and parent model; no automatic registration |
84
84
  | Ratchet detection | Recent repeated command/patch failures produce `ratchet?` candidates |
85
85
  | Seed presets | Compatible ratchet rules, separate decisions, explicit registration scope |
86
- | Routing saved | Sprag-attributed child tokens priced with matching LiteLLM rates; an estimate, not billed savings |
86
+ | Routing saved | Sprag-attributed child tokens priced by the path the call took (OpenAI list prices for direct calls, the gateway's `/model/info` prices for a LiteLLM provider); signed, so a pricier child is a loss; an estimate, not billed savings |
87
87
  | Diagnostics | `doctor` checks user config, hook registration, harness, logs, panel activity, and preferences |
88
88
  | Capability matrix | `capabilities` lists supported, partial, native, and unsupported areas without invoking a model |
89
89
 
@@ -151,12 +151,44 @@ warning records. A handoff file is never overwritten and identifies quota
151
151
  values as recorded snapshots, not live responses. Fill in its remaining-work
152
152
  sections before using it to continue in another session.
153
153
 
154
- Delegation is independently opt-in via `delegate on`. PreToolUse rewrites native
155
- `spawn_agent` arguments using Codex's `message`, `model`, and `reasoning_effort`
156
- fields. It preserves explicit model choices and custom roles, including custom
157
- overrides of built-in roles. SubagentStart injects scoped ratchets and enabled
158
- writing guidance. Bounds are instructions, not an enforced tool-call budget.
159
- Normal Codex sandbox and approval rules still apply. Review new hooks in `/hooks`.
154
+ Delegation is independently opt-in via `delegate on`. As of Codex 0.159.2,
155
+ `spawn_agent` never reaches `PreToolUse` (measured directly: a recording hook
156
+ saw only `Bash` calls while a spawn happened), so the rewrite below cannot see
157
+ or steer it. The live path instead runs at prompt time: `UserPromptSubmit`
158
+ checks the prompt against your category rules and, on a match, appends a
159
+ `[Sprag model routing]` note naming the target model, effort, and a route id.
160
+ That note by itself changes nothing, because Codex's own instructions tell it not to
161
+ spawn subagents unless something explicitly asked for it. So `harness init`'s
162
+ AGENTS.md block adds the missing half: a `[Sprag model routing]` note *is* the
163
+ user's delegation request, and the agent should spawn exactly what it describes
164
+ with `fork_turns "none"`, then wait for it (`wait_agent` with a timeout of at least
165
+ 120 seconds) instead of doing the same task itself. Attribution does not depend on the child echoing the
166
+ route id back: `SubagentStart` binds the id to whichever spawned child matches
167
+ the pending route (same parent thread, same target model, within 30 minutes),
168
+ so a route is credited even when the child's own first message never repeats it.
169
+ A hint that no child claimed is closed when the parent's next prompt arrives, so
170
+ a child spawned later for some other reason is not credited to it. That same
171
+ prompt also prices any delegated run the ledger has not joined yet, in a
172
+ detached process, so `Routing saved` follows a delegation by one prompt instead
173
+ of waiting for the next route scan.
174
+
175
+ The hint is withheld below `codex.delegateMinContext` (default 60000 input
176
+ tokens, read from the parent's own rollout). Measured 2026-09-30: a small
177
+ parent session spends more on the coordinating turn (spawning, waiting,
178
+ verifying) than a delegated child saves, so under the floor Sprag leaves the
179
+ request on the main agent instead of proposing a loss. There is no CLI flag for
180
+ it yet; set it by editing `codex.delegateMinContext` (an integer) in the config
181
+ file `sprag mode` prints under "Stored config file" (`~/Library/Application
182
+ Support/claude-token-saver/config.json` on macOS, `$XDG_CONFIG_HOME/claude-token-saver/config.json`
183
+ or `~/.config/claude-token-saver/config.json` on Linux).
184
+
185
+ The `PreToolUse` rewrite (native `spawn_agent` arguments using Codex's
186
+ `message`, `model`, and `reasoning_effort` fields, preserving explicit model
187
+ choices and custom roles) is kept for Codex versions that do route spawns
188
+ through hooks; on 0.159.2 it simply never fires. `SubagentStart` injects scoped
189
+ ratchets and enabled writing guidance regardless of which path routed the
190
+ child. Bounds are instructions, not an enforced tool-call budget. Normal Codex
191
+ sandbox and approval rules still apply. Review new hooks in `/hooks`.
160
192
 
161
193
  ```sh
162
194
  sprag delegate on --agent codex
@@ -174,6 +206,13 @@ exact parent model, and prefer project rules over global rules. Matching prompts
174
206
  request native delegation; unavailable subagent tools leave work on the main
175
207
  agent. Claude tier presets and history are not imported.
176
208
 
209
+ The routing ledger (`Routing Saved` below) prices a child's own tokens at the
210
+ parent model's rate as the counterfactual; it does not charge or subtract the
211
+ parent's coordination turn, so a delegation that barely clears the context
212
+ floor can still show a positive but overstated saving. A child that was
213
+ interrupted before it finished replaced no parent work, so its whole cost is
214
+ booked as a loss and the entry is marked `aborted`.
215
+
177
216
  ## Harness Commands
178
217
 
179
218
  ```sh
@@ -231,6 +270,15 @@ SessionStart reads cached candidates and schedules an eligible refresh outside
231
270
  the hook. Small log changes refresh at most daily; a 5 MiB increase can refresh
232
271
  after an hour. `--refresh` bypasses that schedule.
233
272
 
273
+ Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
274
+ middle of anything longer. SessionStart therefore fits its parts whole within a
275
+ 2,000-token estimate, in priority order: ratchet rules, Korean guidance,
276
+ cohesion guidance, the document note, then offers. Rules and guidance that do
277
+ not fit are written to a per-project file under the Sprag data directory, and
278
+ the context opens with an instruction to read it. `codex exec` runs, recognized
279
+ from the rollout's `originator`, get no seed offer or route notice, since no one
280
+ can answer them.
281
+
234
282
  `seed` offers only compatible ratchet presets, not Claude model-tier presets.
235
283
  Accept and skip decisions are separate from Claude. Both seed acceptance and
236
284
  route approval require the user's choice of global or project scope. A project
@@ -306,10 +354,24 @@ subagent earns no credit. The estimate holds the child's measured tokens
306
354
  constant and prices them at the parent and child rates. Negative differences
307
355
  remain negative; this is not a comparison of actual billed totals.
308
356
 
309
- Rates come from the configured LiteLLM `/model/info` endpoint, with agreement
310
- required across deployments behind an alias. Price snapshots last seven days.
311
- Providers are kept separate; direct OpenAI/subscription pricing is not inferred.
312
- Missing rates or usage produce unpriced entries, never invented zeroes. A priced
357
+ Prices follow the path each call took, and the two sources never fall back to
358
+ each other:
359
+
360
+ - ChatGPT/OpenAI login or an API key straight to `api.openai.com` uses the
361
+ built-in OpenAI list prices (Standard tier, short-context rates, checked
362
+ 2026-10-01). Long-context surcharges are not modeled, so requests past the
363
+ long-context threshold are under-priced.
364
+ - A LiteLLM provider uses that gateway's `/model/info` prices, with agreement
365
+ required across deployments behind an alias. Price snapshots last seven days.
366
+ A model the gateway does not price stays unpriced; it is not filled in from
367
+ the list prices.
368
+
369
+ `sprag delegate rules --agent codex` shows each rule's measured runs, failure
370
+ rate and signed saving. A rule is flagged for review when the 95% lower bound
371
+ of its failure rate exceeds 20% over at least five runs, as on the Claude side.
372
+
373
+ Providers are kept separate. Negative values are losses: the child ran on a
374
+ pricier model than the parent, and the loss reduces the totals. Missing rates or usage produce unpriced entries, never invented zeroes. A priced
313
375
  entry keeps its rate snapshot across later outages. Pending route records are
314
376
  matched for seven days; saved ledger entries remain afterward. Run `--refresh`
315
377
  after delegation to update the ledger without waiting for the next route scan.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.55.0",
3
+ "version": "3.57.0",
4
4
  "description": "Cut the tokens Claude Code and Codex spend: routes simple work to cheaper models, converts documents to compact Markdown, and shows spend and savings live. Ratchet rules and Korean writing checks included.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "deploy": "node scripts/deploy.mjs",
12
12
  "docs:statusline": "node scripts/docs-statusline.mjs",
13
- "postinstall": "node bin/cli.js install || true",
13
+ "postinstall": "node bin/postinstall.js || true",
14
14
  "publish:legacy": "node scripts/publish-legacy.mjs",
15
15
  "release:notes": "node scripts/release-notes.mjs",
16
16
  "site:docs": "node scripts/build-site-docs.mjs",
package/src/agents.js CHANGED
@@ -20,12 +20,26 @@ const AGENT_MODEL = {
20
20
  'haiku-runner': 'haiku',
21
21
  'haiku-translate': 'haiku',
22
22
  'sonnet-worker': 'sonnet',
23
+ // A T1 rule names its target by tier alone (`agent: 'sonnet'`), with no
24
+ // preset agent behind it. Without these the phrase fell through to the
25
+ // haiku default, and a sonnet rule told the session to delegate to haiku.
26
+ sonnet: 'sonnet',
27
+ haiku: 'haiku',
23
28
  };
24
29
 
25
- /** True when `<name>.md` exists in the project or user agents directory. */
30
+ /**
31
+ * True when `<name>.md` exists in the project or user agents directory.
32
+ *
33
+ * `root: null` asks about the user directory alone. Text that every project
34
+ * reads (the global ratchet-model.md) has to be worded from what every project
35
+ * can see: checked against the current directory, the same rule read
36
+ * `haiku-explore(model: haiku)` when the scan ran in a project that defines
37
+ * the agent and `model: haiku` when it ran anywhere else, and each flip
38
+ * rewrote a file sitting in the prompt-cache prefix of every session.
39
+ */
26
40
  export function agentExists(name, root = process.cwd()) {
27
41
  if (!name) return false;
28
- return existsSync(join(root, '.claude', 'agents', `${name}.md`)) ||
42
+ return (root !== null && existsSync(join(root, '.claude', 'agents', `${name}.md`))) ||
29
43
  existsSync(join(claudeUserDir(), 'agents', `${name}.md`));
30
44
  }
31
45
 
package/src/brief.js CHANGED
@@ -29,6 +29,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync,
29
29
  import { join } from 'node:path';
30
30
  import { userDataDir } from './paths.js';
31
31
  import { resolveModelId, isOneMillionModel, effectiveWindow } from './compact-window.js';
32
+ import { liveWindowFor } from './ctx-window-cache.js';
32
33
 
33
34
  // Context tiers as a fraction of the session's context window. Tier 1 warns
34
35
  // (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
@@ -83,14 +84,21 @@ function saveState(state, now) {
83
84
  *
84
85
  * `observedMax` stays as a floor: it proves a 1M window even when the model id
85
86
  * is unreadable (env override, settings we do not resolve).
87
+ *
88
+ * `liveWindow` outranks both. It is the size Claude Code reported to the
89
+ * statusline for this session, so the briefing and the statusline count against
90
+ * the same window. The model id cannot stand in for it: a model that is 1M
91
+ * without a `[1m]` suffix reads as 200k there.
86
92
  */
87
- export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
93
+ export function ctxWindowFor(observedMax = 0, root = process.cwd(), { liveWindow = null } = {}) {
88
94
  let modelWindow = observedMax > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
95
+ const reported = Number.isFinite(liveWindow) && liveWindow > 0;
96
+ if (reported) modelWindow = liveWindow;
89
97
  let window = modelWindow;
90
98
  let compactCapped = false;
91
99
  try {
92
100
  const { model } = resolveModelId(root);
93
- if (isOneMillionModel(model)) modelWindow = window = 1_000_000;
101
+ if (!reported && isOneMillionModel(model)) modelWindow = window = 1_000_000;
94
102
  const cap = effectiveWindow(root).value;
95
103
  if (cap !== null && cap < window) {
96
104
  window = cap;
@@ -104,7 +112,7 @@ export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
104
112
  * Last request's input size for THIS session, from the transcript tail.
105
113
  * Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
106
114
  */
107
- export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
115
+ export function sessionCtx(transcriptPath, { root = process.cwd(), liveWindow = null } = {}) {
108
116
  let size;
109
117
  try { size = statSync(transcriptPath).size; } catch { return null; }
110
118
  const start = Math.max(0, size - TAIL_BYTES);
@@ -130,7 +138,7 @@ export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
130
138
  if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
131
139
  }
132
140
  if (input == null) return null;
133
- const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root);
141
+ const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root, { liveWindow });
134
142
  return { input, window, modelWindow, compactCapped, pct: input / window };
135
143
  }
136
144
 
@@ -166,14 +174,15 @@ export function seedSessionBriefed(sessionId, signatures, now = Date.now()) {
166
174
  * (recorded via seedSessionBriefed) are skipped; every other fresh signature
167
175
  * is emitted, including on the session's first event.
168
176
  */
169
- export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
177
+ export async function runBrief({ sessionId, transcriptPath, root = process.cwd(), now = Date.now() }) {
170
178
  if (!sessionId) return null;
179
+ const liveWindow = liveWindowFor(sessionId);
171
180
  const state = loadState();
172
181
  const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
173
182
  const items = [];
174
183
 
175
184
  // ── context tier crossing (per-session) ──
176
- const ctx = transcriptPath ? sessionCtx(transcriptPath) : null;
185
+ const ctx = transcriptPath ? sessionCtx(transcriptPath, { root, liveWindow }) : null;
177
186
  if (ctx) {
178
187
  const tier = ctxTierOf(ctx.pct);
179
188
  // The tier is not monotonic: auto-compaction drops the live context back to
@@ -238,7 +247,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
238
247
  // never needed. 200k sessions are exempt (their window is already <= 200k).
239
248
  try {
240
249
  const cw = await import('./compact-window.js');
241
- const st = cw.compactWindowStatus({ root: process.cwd() });
250
+ const st = cw.compactWindowStatus({ root, liveWindow });
242
251
  if (!st.ok) {
243
252
  const sig = `compact-window|${st.reason}`;
244
253
  const briefed = new Set(s.briefed || []);
@@ -246,7 +255,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
246
255
  briefed.add(sig);
247
256
  s.briefed = [...briefed];
248
257
  const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
249
- items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
258
+ items.push(`1M 컨텍스트 모델(${st.model || 'Claude Code 기본 모델'})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
250
259
  }
251
260
  }
252
261
  } catch { /* settings unreadable — other briefings above still apply */ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Claude-side cost by the path the call took. A session on a LiteLLM gateway
3
+ * (gatewayBase(env) set) is billed by the gateway, so it is priced from its
4
+ * /model/info table and stays unpriced (null) when the table has no entry;
5
+ * the Anthropic list table is never a fallback there. Every other session is a
6
+ * direct call and uses the list table. Kept apart from cost.js because
7
+ * litellm-models.js imports cost.js, and this needs both.
8
+ */
9
+ import { estimateCost, estimateCostAcross } from './cost.js';
10
+ import { gatewayPriceFor } from './litellm-models.js';
11
+ import { gatewayBase } from './gateway-auth.js';
12
+
13
+ export function sessionCost(totals, model, { env = process.env, now } = {}) {
14
+ if (!gatewayBase(env)) return estimateCost(totals, model);
15
+ const price = gatewayPriceFor(model, env, now);
16
+ return price ? estimateCost(totals, model, { price }) : null;
17
+ }
18
+
19
+ /** One priced part per model that spent tokens, or the whole at its one model. */
20
+ function partsByModel(x) {
21
+ if (!x.modelTotals) return [{ totals: x.totals, model: x.model }];
22
+ return Object.entries(x.modelTotals).map(([model, totals]) => ({ totals, model }));
23
+ }
24
+
25
+ /**
26
+ * What a session's cost is made of: the session itself and each subagent run
27
+ * it spawned (parser.js attaches them as `subagentRuns`). A run has its own
28
+ * model, so it is priced on its own instead of at the session's rate, and a
29
+ * session or run that switched model is split again so each model's tokens
30
+ * meet that model's rate. `own` marks the parts that are the session itself.
31
+ */
32
+ export function pricedParts(session) {
33
+ if (!session) return [];
34
+ return [
35
+ ...partsByModel(session).map((part) => ({ ...part, own: true })),
36
+ ...(session.subagentRuns || []).flatMap(partsByModel),
37
+ ];
38
+ }
39
+
40
+ /** Like estimateCostAcross, plus how many priced parts (a session, a subagent run, or one model's share of either) had no price and were left out. */
41
+ export function sessionCostAcross(sessions, opts = {}) {
42
+ let unpriced = 0;
43
+ const total = estimateCostAcross((sessions || []).flatMap(pricedParts), (totals, model) => {
44
+ const c = sessionCost(totals, model, opts);
45
+ if (!c) unpriced++;
46
+ return c;
47
+ });
48
+ return { ...total, unpriced };
49
+ }