sprag-cli 3.63.0 → 3.65.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.
package/bin/cli.js CHANGED
@@ -57,7 +57,7 @@ import { sessionCostAcross } from '../src/claude-price.js';
57
57
  import { chipForIssues } from '../src/advice.js';
58
58
  import { debug } from '../src/debug.js';
59
59
  import { createArgs } from '../src/cli-args.js';
60
- import { updateStatus, maybeSpawnUpdateCheck, readUpdateState, updateCheckDisabled } from '../src/update-check.js';
60
+ import { readUpdateChip } from '../src/update-notice.js';
61
61
  import { selectAgent } from '../src/agent.js';
62
62
 
63
63
  const selection = (() => {
@@ -77,28 +77,6 @@ const PKG_VERSION = (() => {
77
77
 
78
78
  const { getArg, hasFlag, numArg } = createArgs(args);
79
79
 
80
- /**
81
- * Version/update state for the statusline chip. Reads a cache file and, when
82
- * that cache has aged past the check interval, kicks a detached child to
83
- * refresh it for a later render. Never awaits the network, never throws into
84
- * the render: a registry outage must not cost the statusline its other chips.
85
- */
86
- function readUpdateChip() {
87
- try {
88
- maybeSpawnUpdateCheck(PKG_VERSION);
89
- const status = updateStatus(PKG_VERSION);
90
- // When the registry was last asked, for the verbose "✓6h" on an up-to-date
91
- // version. updateStatus() does not carry it, and a second read of the
92
- // 200-byte state file is cheaper than a second status computation. With
93
- // checks switched off the file is a leftover, so it says nothing.
94
- const checkedAt = updateCheckDisabled() ? null : (Number(readUpdateState().checkedAt) || null);
95
- return { ...status, checkedAt };
96
- } catch (e) {
97
- debug('update-check:chip', e);
98
- return null;
99
- }
100
- }
101
-
102
80
  /**
103
81
  * Whether the table report may use ANSI codes. The formatter looked at NO_COLOR
104
82
  * alone, so `--no-color` did nothing there and a piped report (`sprag | less`,
@@ -605,7 +583,7 @@ async function main() {
605
583
  }
606
584
  const { withBaseStatusline } = await import('../src/statusline-base.js');
607
585
  console.log(withBaseStatusline(formatNoSession(
608
- { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
586
+ { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip(PKG_VERSION) },
609
587
  { color: colorOk, mode: labelMode },
610
588
  )));
611
589
  return;
@@ -892,7 +870,7 @@ async function main() {
892
870
  cost,
893
871
  options: { days, windowHours, windowLabel, version: PKG_VERSION },
894
872
  // Cached-only; the background refresh it may trigger lands on a later render.
895
- update: format === 'statusline' ? readUpdateChip() : null,
873
+ update: format === 'statusline' ? readUpdateChip(PKG_VERSION) : null,
896
874
  lastActivity,
897
875
  monthSpend: monthSpendInfo,
898
876
  spikeReport,
package/docs/CODEX.ko.md CHANGED
@@ -49,6 +49,7 @@ macOS zsh 환경에서는 설치가 tmux 설치 여부를 먼저 확인합니다
49
49
  | seed | 호환되는 래칫 프리셋을 제안하고 응답을 별도 저장합니다 |
50
50
  | 문서 변환 | PDF·Office·Figma 문서를 Markdown으로 변환하고 캐시 덮어쓰기를 막습니다 |
51
51
  | 한국어 검사 | 파일 쓰기 후 문체 위반을 검사하며 block·warn·off를 지원합니다 |
52
+ | 답변 언어 검사 | Stop 훅이 현재 한국어 질문과 최종 답변을 대조합니다. 영어 답변 요청, Stop 재개, `korean reply off`는 제외합니다 |
52
53
  | 상태 표시 | 별도 패널, tmux 패널, 일회성 상태줄을 제공합니다 |
53
54
  | 브리핑·이력 | 최신 컨텍스트와 사용량 경고를 알리고 세션별로 저장합니다 |
54
55
  | 위임 원장 | Sprag가 위임한 하위 실행의 토큰에 호출 경로의 단가를 적용해 예상 차액을 계산합니다. 직접 호출은 OpenAI 정가를, LiteLLM 공급자는 그 게이트웨이의 단가를 씁니다 |
@@ -240,6 +241,8 @@ sprag harness prune --global --older-than 6 --dry-run --agent codex
240
241
  ```sh
241
242
  sprag doc2md install-converter --agent codex
242
243
  sprag doc2md report.pdf --agent codex
244
+ sprag doc2md deck.pptx --agent codex --session SESSION_ID
245
+ sprag doc2md savings --agent codex --format json
243
246
  sprag korean on --agent codex
244
247
  sprag korean lint warn --agent codex
245
248
  sprag cohesion on --agent codex
@@ -250,6 +253,25 @@ sprag cohesion on --agent codex
250
253
  지원하는 라이브러리로 복사본을 편집한 뒤 다시 변환하십시오. 텍스트 추출만으로 차트·이미지·배치를
251
254
  검증했다고 말할 수 없습니다. 실패와 잘린 결과는 그대로 보고해야 합니다.
252
255
 
256
+ `Doc2md saved`는 PPTX·DOCX·XLSX의 원본 XML과 준비된 Markdown을 비교한 추정치입니다.
257
+ 각각 UTF-8 바이트 수를 4로 나눠 토큰 수를 추정하고, 그 차이에 해당 Codex 모델의 입력 단가를
258
+ 적용합니다. 네이티브 첨부 비용이나 실제 청구액을 측정한 값은 아닙니다. 변환본이 더 크면
259
+ 음수를 그대로 표시합니다. Claude 단가를 가져오지 않으며 모델, 프로바이더와 단가 출처를 기록합니다.
260
+
261
+ 같은 문서를 다시 사용해도 절감액을 더하거나 단가를 바꾸지 않습니다. 원본이 바뀌면 해당 문서의
262
+ 기존 기록을 교체합니다. PDF·Figma·구형 XLS, 잘린 변환본, 단가나 모델 정보가 없는 기록은
263
+ 미산정으로 남깁니다. 과거 기록에 현재 기본 모델의 단가를 임의로 적용하지 않습니다.
264
+ 수동 변환은 `--session` 또는 `CODEX_THREAD_ID`로 세션을 식별하며, 정보가 없으면 변환만 수행합니다.
265
+
266
+ 패널은 `Doc2md saved`와 `Routing saved`를 설정 항목 앞에 함께 표시합니다. 위임 안내만
267
+ 생성되고 자식이 실행되지 않으면 절감액은 `n/a`이며, 세션별 pending·bound·completed·unclaimed
268
+ 건수로 진행 상태를 구분합니다. 한 요청의 T1/T2 선택지는 안내 한 건으로 계산합니다.
269
+
270
+ `sprag delegate doctor --agent codex --session SESSION_ID`는 모델 호출 없이 로컬 버전,
271
+ 기능 플래그, 모델 카탈로그와 연결 기록을 확인합니다. v2 카탈로그와 기능 플래그가 다르면
272
+ 진단 단서로만 알리고 설정은 바꾸지 않습니다. 실제 세션에 `spawn_agent`와 `wait_agent`가
273
+ 있는지는 별도로 확인해야 하며, 도구가 없으면 메인 모델이 처리합니다.
274
+
253
275
  훅은 지원되는 파일 읽기와 단순한 `cat`·`head`·`tail`, 인식 가능한 쓰기 대상을 처리합니다.
254
276
  임의의 추출 스크립트나 복합 셸 명령을 모두 가로채지는 않습니다. `doc2md off`는 Codex의 자동
255
277
  변환과 문서 쓰기 보호를 끕니다. 문체 설정은 공유하지만 기능을 켤 때는 Codex 훅을 먼저 등록합니다.
@@ -286,16 +308,48 @@ sprag capabilities --agent codex
286
308
 
287
309
  ## 제거와 업그레이드
288
310
 
311
+ 패널은 새 버전이 있으면 사용량 앞에 알림을 표시하고, 백그라운드에서 버전 캐시를 갱신합니다.
312
+ 일회성 상태줄은 캐시만 읽습니다. 대화형 SessionStart는 변경 내용을 알리고 설치 동의를 구합니다.
313
+ `sprag update-check --dismiss --agent codex`로 해당 버전의 알림을 끌 수 있습니다.
314
+ `CTS_NO_UPDATE_CHECK=1` 또는 `NO_UPDATE_NOTIFIER`는 확인 자체를 끕니다.
315
+ compaction, 하위 세션 시작, `codex exec`에는 업그레이드 질문을 넣지 않습니다.
316
+
289
317
  ```sh
290
318
  sprag uninstall --agent codex
291
319
  npm i -g sprag-cli --ignore-scripts
292
320
  sprag install --agent codex
293
321
  ```
294
322
 
295
- 제거는 Codex 훅과 전역 하네스 블록만 대상으로 합니다. 프로젝트 하네스, 래칫 규칙, 백업과
323
+ 제거는 Codex 훅, 전역 하네스 블록, 관리 중인 zsh 함수를 대상으로 합니다. 새 셸을 열어야
324
+ 이미 로드된 함수도 사라집니다. 프로젝트 하네스, 래칫 규칙, 백업과
296
325
  공유 상태는 남습니다. 일반 npm 설치는 Claude postinstall을 실행하므로 Codex만 사용한다면
297
326
  업그레이드에도 `--ignore-scripts`를 유지하십시오.
298
327
 
328
+ ## 구현 분업
329
+
330
+ 설계, 원인 진단, 스펙 확정과 최종 검증은 메인 모델이 맡습니다. 여러 파일을 고치는 구현은
331
+ 설계를 확정한 뒤 T1 모델에 위임합니다. Claude는 기존처럼 `model: sonnet`을 쓰고,
332
+ Codex는 현재 부모 모델과 프로바이더의 명시적 T1 매핑을 우선합니다. 매핑이 없으면
333
+ 자동 해석한 T1을 씁니다. 이는 역할 구분이며 두 모델의 성능이 같다는 뜻은 아닙니다.
334
+
335
+ ```sh
336
+ sprag delegate implementation --agent codex --from PARENT_MODEL --provider PROVIDER --session PARENT_SESSION_ID
337
+ ```
338
+
339
+ 기본 설정 대신 실제 부모 세션의 정보를 넘깁니다. 자식에게 설계, 수정할 파일, 제약,
340
+ 테스트 명령, 완료 기준과 모델 선택 이유를 전달하고, 반환된 route 줄을 메시지 끝에 붙입니다.
341
+ `fork_turns "none"`으로 생성하며 출력 상한은 8,000토큰입니다. 메인은 `wait_agent`로
342
+ 완료를 기다린 뒤 diff와 실제 테스트 결과를 검증합니다. 상한은 지침이며 강제 제한은 아닙니다.
343
+
344
+ T1이 없거나 위임이 꺼져 있으면 메인이 처리합니다. haiku나 T2로 대체하지 않습니다.
345
+ 이미 읽은 파일의 약 50줄 이하 수정도 메인에서 처리합니다. 위임 도구가 없으면 위 명령을
346
+ 실행하지 않고 제약을 알립니다. 별도 Codex CLI로 우회하지 않습니다. 원장에는 부모, 모델,
347
+ 프로바이더가 맞는 자식 실행만 집계하며, 실행되지 않은 안내는 절감으로 계산하지 않습니다.
348
+
349
+ 공유 정책의 Codex 실패율도 규칙 목록, 패널, 브리핑에 표시합니다. 정책, 등급, 범위,
350
+ 프로바이더와 부모·자식 모델별로 집계하며 Claude 통계에는 합치지 않습니다. 매핑을 검토할
351
+ 동안 `sprag delegate shared off --agent codex`로 공유 위임을 끌 수 있습니다.
352
+
299
353
  공식 인터페이스는 [Codex 훅](https://developers.openai.com/codex/hooks/)과
300
354
  [AGENTS.md 안내](https://developers.openai.com/codex/guides/agents-md/),
301
355
  [설정 참조](https://developers.openai.com/codex/config-reference/)에서 확인할 수 있습니다.
package/docs/CODEX.md CHANGED
@@ -80,6 +80,7 @@ shared hooks needed by other features.
80
80
  | Ratchet rules | Separate Codex files; global and project rules injected at SessionStart, including resume and compaction |
81
81
  | Korean guidance / English cohesion | SessionStart injection using shared Sprag preferences |
82
82
  | Korean write-time lint | PostToolUse feedback for `apply_patch` and recognized shell write targets; block, warn, and off modes |
83
+ | Reply language | Stop checks the current Korean prompt and final answer; `korean reply off` disables it, and explicit English requests and Stop continuations are excluded |
83
84
  | Documents | Shared converter, prompt paths, supported file reads, and recognized document/cache write guards; `sprag doc2md <file> --agent codex` also works manually |
84
85
  | Token report | Table, JSON, or CSV from local Codex rollout logs, with time and project filters |
85
86
  | Statusline | `--statusline` or `--format statusline` renders the compact panel once, with wrapping and label/color preferences |
@@ -94,6 +95,7 @@ shared hooks needed by other features.
94
95
  | 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 |
95
96
  | Diagnostics | `doctor` checks user config, hook registration, harness, logs, panel activity, and preferences |
96
97
  | Capability matrix | `capabilities` lists supported, partial, native, and unsupported areas without invoking a model |
98
+ | Updates | Live panels refresh the shared version cache in the background; one-shot statuslines read it offline; interactive SessionStart offers a consent-based Codex-only upgrade |
97
99
 
98
100
  The document converter is shared with Claude Code and requires its optional
99
101
  converter dependencies. Disable automatic Codex conversion and write guards with
@@ -120,11 +122,34 @@ history is not imported. Counts are sorted by frequency, then extension, with no
120
122
  Claude pricing involved. Codex reads a neutral Markdown view without the shared
121
123
  cache's Claude-based savings banner; the shared cache remains unchanged.
122
124
 
125
+ `Doc2md saved` is a separate Codex estimate for `.pptx`, `.docx`, and `.xlsx`:
126
+ Office body XML bytes divided by four, minus the prepared Markdown bytes divided
127
+ by four, multiplied by the recorded model's input-token price. This compares
128
+ XML extraction with Markdown, not native attachment billing or measured tokens.
129
+ Negative differences remain negative. The price comes from the active Codex
130
+ provider, never Claude's price table, and its source is recorded with the result.
131
+ Repeated use of the same source does not add another saving or reprice a saved
132
+ estimate. Changed sources replace their previous entry, just like document counts.
133
+ Clipped conversions, PDF/Figma/legacy XLS, missing prices, and old records without
134
+ model identity remain unpriced. Nothing is backfilled from default config.
135
+
136
+ ```sh
137
+ sprag doc2md deck.pptx --agent codex --session SESSION_ID
138
+ sprag doc2md savings --agent codex --format json
139
+ sprag saved --agent codex
140
+ ```
141
+
142
+ Hooks use the current payload/rollout model and provider. Manual conversion can
143
+ use `--session` or the CLI's `CODEX_THREAD_ID`; without session identity, the
144
+ document is converted but the monetary estimate stays unavailable. No network
145
+ request is needed for conversion accounting; it uses a cached provider price.
146
+
123
147
  ## Diagnostics And Continuity
124
148
 
125
149
  ```sh
126
150
  sprag doctor --agent codex
127
151
  sprag doctor --agent codex --format json
152
+ sprag delegate doctor --agent codex --session SESSION_ID
128
153
  sprag capabilities --agent codex
129
154
  sprag brief --agent codex
130
155
  sprag brief on --agent codex
@@ -142,6 +167,15 @@ proof of trust. Its config report excludes provider credentials and is not an
142
167
  effective-config resolver: active profiles, project config, managed policy,
143
168
  and invocation overrides can differ. Check Codex `/hooks` for approval.
144
169
 
170
+ `delegate doctor` reads local Codex version, feature flags, bundled model catalog
171
+ and Sprag offer/binding records without calling a model. A v2 catalog/feature
172
+ mismatch is reported only as a compatibility clue, not a diagnosis. Enabled
173
+ settings and registered hooks cannot prove the running model has `spawn_agent`
174
+ and `wait_agent`. Confirm those tools in the active session. The panel reports
175
+ pending, bound, completed and unclaimed offers separately, scoped to its session;
176
+ T1/T2 alternatives from one prompt count as one offer. Unclaimed offers are not
177
+ charged or credited as savings. Missing tools leave work on the main agent.
178
+
145
179
  Briefing is enabled by default after installing and trusting the prompt hook.
146
180
  It reads at most the latest 256 KiB of the current transcript and warns when
147
181
  recorded input reaches 80% or 95% of the recorded context window, or a reported
@@ -304,6 +338,35 @@ booked as a loss and the entry is marked `aborted`.
304
338
 
305
339
  ## Harness Commands
306
340
 
341
+ ### Implementation Split
342
+
343
+ The main agent retains design, diagnosis, specification and final verification.
344
+ After design is complete, multi-file implementation can use the resolved T1:
345
+
346
+ ```sh
347
+ sprag delegate implementation --agent codex --from PARENT_MODEL --provider PROVIDER --session PARENT_SESSION_ID
348
+ ```
349
+
350
+ Use the active parent's identity, not the defaults in config. The installed
351
+ harness treats this policy as the user's delegation request. The resolver uses
352
+ an explicit T1 mapping first, otherwise automatic T1 resolution; Claude's
353
+ corresponding policy still uses `model: sonnet`. These are roles, not a claim of
354
+ equal model capability. It never falls back to T2. Disabled delegation or an
355
+ unresolved T1 leaves the work on the main agent. Small edits of roughly 50 lines
356
+ in already-read files also stay on the main agent.
357
+
358
+ The child receives a self-contained specification, owned files, constraints,
359
+ tests, completion criteria, the model-selection reason and an 8,000-output-token
360
+ cap. Use `fork_turns "none"` and end the child message with the returned route
361
+ line. Wait with `wait_agent` (at least 120 seconds per wait), then inspect its
362
+ diff and actual test results. Without native subagent tools, skip the resolver
363
+ and report the limitation; do not launch a second Codex CLI as a substitute.
364
+ The cap is guidance, not runtime enforcement. Accounting requires a child of
365
+ the recorded parent on the selected model/provider; an unused hint earns no
366
+ savings. Implementation requests are not automatically classified as cheap work.
367
+
368
+ ### Ratchet Management
369
+
307
370
  ```sh
308
371
  sprag harness init --agent codex # current project
309
372
  sprag harness check --agent codex # project, or global fallback
@@ -463,6 +526,12 @@ each other:
463
526
  rate and signed saving. A rule is flagged for review when the 95% lower bound
464
527
  of its failure rate exceeds 20% over at least five runs, as on the Claude side.
465
528
 
529
+ Shared policies also report their Codex outcomes in the rule list, panel and
530
+ prompt briefing. Outcomes are grouped by policy, tier, scope, provider and
531
+ parent/child model pair; they are never imported into Claude's rule statistics.
532
+ Use `sprag delegate shared off --agent codex` to pause shared routing while
533
+ reviewing the mapping. Removing a common policy affects both agents.
534
+
466
535
  Providers are kept separate. Negative values are losses: the child ran on a
467
536
  pricier model than the parent, and the loss reduces the totals. Missing rates or usage produce unpriced entries, never invented zeroes. A priced
468
537
  entry keeps its rate snapshot across later outages. Pending route records are
@@ -470,7 +539,8 @@ matched for seven days; saved ledger entries remain afterward. Run `--refresh`
470
539
  after delegation to update the ledger without waiting for the next route scan.
471
540
 
472
541
  Claude's ledger is never imported. Document conversion shows distinct document
473
- counts rather than a Codex dollar saving or billing measurement.
542
+ counts and the explicitly labelled Office XML-versus-Markdown estimate above,
543
+ not a billing measurement.
474
544
 
475
545
  ### Live Companion Panel
476
546
 
@@ -574,7 +644,9 @@ The inline compact panel reuses Claude's icon/narrow/text preferences and
574
644
  segmented gauges. Chips wrap as units instead of losing their trailing values;
575
645
  the pane grows with the content while keeping at least ten rows for Codex.
576
646
  If the terminal is too small, a remaining-row count marks hidden content.
577
- Cache reuse is shown in tokens, not an inferred dollar saving. Document counts
647
+ Cache reuse is shown in tokens, not an inferred dollar saving. `Doc2md saved` and
648
+ `Routing saved` stay together before preferences, even when unavailable; missing
649
+ prices or execution evidence are labelled instead of being reported as $0. Document counts
578
650
  and format breakdowns come from Codex's own document records and are labelled
579
651
  `Codex total`; Claude-based dollar estimates are not attributed to Codex.
580
652
  Unavailable cost and limit placeholders are hidden in compact mode. Cache timer
@@ -615,6 +687,7 @@ are skipped, and incomplete/copied/reset logs can only give best-effort totals.
615
687
  Reports show **cost and exact cache TTL as unavailable** (`null` in JSON). They do not
616
688
  apply Anthropic prices, infer subscription charges, or claim Claude's cache TTL.
617
689
  Document counts are not a measurement of Codex billing or avoided tokens.
690
+ The separate Doc2md estimate is a bytes-based comparison, not tokenizer output.
618
691
 
619
692
  ## Limits And Removal
620
693
 
@@ -634,7 +707,9 @@ Rate-limit gauges use Codex's recorded values only. Run
634
707
  sprag uninstall --agent codex
635
708
  ```
636
709
 
637
- Removal takes out Sprag's Codex hooks and global harness block. It keeps project
710
+ Removal takes out Sprag's Codex hooks, global harness block and managed zsh
711
+ wrapper. Open a fresh shell afterward; an already-loaded shell function remains
712
+ in the old shell. It keeps project
638
713
  harnesses, ratchet rules, backups, other hooks, and shared Sprag state.
639
714
  `--purge` is intentionally unsupported for Codex. Remove a project block with
640
715
  `sprag harness uninit --agent codex --project` in that project.
@@ -643,6 +718,13 @@ Normal npm upgrades still run the package's Claude postinstall. Codex-only users
643
718
  should keep using `npm i -g sprag-cli --ignore-scripts` followed by
644
719
  `sprag install --agent codex` when upgrading.
645
720
 
721
+ The panel places an available update before usage metrics. Dismissing a version
722
+ with `sprag update-check --dismiss --agent codex` hides its chip and session
723
+ offer until a newer version appears. `CTS_NO_UPDATE_CHECK=1` or
724
+ `NO_UPDATE_NOTIFIER` disables checks. Compaction, subagent starts and `codex exec`
725
+ do not ask to upgrade. SessionStart checks at most every three hours; the live
726
+ panel uses the shared 24-hour interval and never waits for a network response.
727
+
646
728
  ## Official References
647
729
 
648
730
  - [Codex hooks, event schemas, and trust](https://developers.openai.com/codex/hooks/)
@@ -0,0 +1,3 @@
1
+ {
2
+ "modules": ["./register.ts"]
3
+ }
@@ -0,0 +1,197 @@
1
+ import type { EngineInterface, Register, TurnUsage } from 'claude-code'
2
+
3
+ /**
4
+ * sprag-delegation-meter: what each delegated subagent actually spent.
5
+ *
6
+ * sprag's delegation ledger prices a run after the fact, from the transcript
7
+ * it left. This mod reads the same numbers while the run happens, from the
8
+ * API's own usage: agent.spawn says which model the subagent got and what its
9
+ * parent runs on, every turn.step of the subagent's loop reports one
10
+ * request's tokens, and turn.complete closes a turn of that loop.
11
+ *
12
+ * Each run is kept in $.store under a key of its own, `run:<session>:<agent>`,
13
+ * so two sessions never overwrite each other's runs. The mod only observes: no
14
+ * spawn, request or turn is changed, and sprag's transcript-based ledger keeps
15
+ * running beside it.
16
+ */
17
+
18
+ type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
19
+
20
+ type Run = {
21
+ sessionId: string
22
+ agentId: string
23
+ description: string
24
+ subagentType: string
25
+ /** The Agent tool's `model` parameter as given; null when the call named none. */
26
+ requestedModel: string | null
27
+ /** What the spawn resolved; null when this module never saw the spawn. */
28
+ model: string | null
29
+ parentModel: string | null
30
+ /** The model the API last reported answering, by its own id. */
31
+ answeredBy: string | null
32
+ startedAt: number
33
+ endedAt: number | null
34
+ durationMs: number
35
+ steps: number
36
+ turns: number
37
+ usage: Usage
38
+ lastReason: string | null
39
+ }
40
+
41
+ // Families as sprag's modelFamily() names them: versions are noise in a log line.
42
+ const FAMILIES = ['fable', 'mythos', 'opus', 'sonnet', 'haiku']
43
+
44
+ const family = (model: string) => FAMILIES.find(f => model.toLowerCase().includes(f)) ?? model
45
+
46
+ const zero = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
47
+
48
+ const fromApi = (u: TurnUsage): Usage => ({
49
+ input: u.input_tokens,
50
+ output: u.output_tokens,
51
+ cacheRead: u.cache_read_input_tokens,
52
+ cacheWrite: u.cache_creation_input_tokens,
53
+ })
54
+
55
+ const add = (into: Usage, more: Usage) => {
56
+ into.input += more.input
57
+ into.output += more.output
58
+ into.cacheRead += more.cacheRead
59
+ into.cacheWrite += more.cacheWrite
60
+ }
61
+
62
+ const tokens = (n: number) =>
63
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n)
64
+
65
+ const OUTCOME: Record<string, string> = { answer: '완료', aborted: '중단', refusal: '거절', error: '실패' }
66
+
67
+ // Runs in flight by agent id, and per run the requests of the turn still open.
68
+ // A run leaves the map once its turn is saved, so the map holds only what is
69
+ // running and does not grow with the session. $.store has the run from then
70
+ // on: a subagent resumed later (SendMessage starts another turn of the same
71
+ // loop) or met again after a module reload continues from its saved totals.
72
+ const runs = new Map<string, Run>()
73
+ const openTurn = new Map<string, { usage: Usage; steps: number }>()
74
+
75
+ const storeKey = (sessionId: string, agentId: string) => `run:${sessionId}:${agentId}`
76
+
77
+ const isRun = (value: unknown, agentId: string): value is Run =>
78
+ typeof value === 'object' && value !== null
79
+ && (value as Run).agentId === agentId
80
+ && typeof (value as Run).usage === 'object' && (value as Run).usage !== null
81
+
82
+ /**
83
+ * The run for an agent id: the one in flight, else the saved one, else a new
84
+ * one. A subagent's first request can arrive before the spawn hook resumes
85
+ * after `await next(e)`, so a step may be what creates the run.
86
+ *
87
+ * The session id is read per run and never cached in the module: the module
88
+ * outlives /clear, /resume and /branch, each of which moves the conversation
89
+ * to another session id without loading the module again.
90
+ */
91
+ async function runFor($: EngineInterface, agentId: string): Promise<Run> {
92
+ const held = runs.get(agentId)
93
+ if (held) return held
94
+ const sessionId = await $.session.id()
95
+ let saved: unknown
96
+ try {
97
+ saved = await $.store.get(storeKey(sessionId, agentId))
98
+ } catch {
99
+ // An unreadable store costs this run only its earlier turns.
100
+ }
101
+ // A copy, since the totals are added to in place.
102
+ const run: Run = isRun(saved, agentId) ? JSON.parse(JSON.stringify(saved)) : {
103
+ sessionId,
104
+ agentId,
105
+ description: '',
106
+ subagentType: '',
107
+ requestedModel: null,
108
+ model: null,
109
+ parentModel: null,
110
+ answeredBy: null,
111
+ startedAt: await $.clock.now(),
112
+ endedAt: null,
113
+ durationMs: 0,
114
+ steps: 0,
115
+ turns: 0,
116
+ usage: zero(),
117
+ lastReason: null,
118
+ }
119
+ runs.set(agentId, run)
120
+ return run
121
+ }
122
+
123
+ export const register: Register = on => {
124
+ // A meter must never stop what it measures, so every hook here fails open:
125
+ // a hook that breaks before its next() is skipped and the event goes on, and
126
+ // one that breaks after it keeps the result next() settled to. That is the
127
+ // engine's default already; each .catch states it, and answers the same way
128
+ // when the hook is reached from beneath its own $ call (a re-entry).
129
+ on('agent.spawn', async ($, e, next) => {
130
+ const spawned = await next(e)
131
+ if (spawned.deny !== undefined || !spawned.agentId) return spawned
132
+ const run = await runFor($, spawned.agentId)
133
+ run.description = e.description
134
+ run.subagentType = e.subagentType
135
+ run.requestedModel = e.model ?? null
136
+ run.model = spawned.model
137
+ run.parentModel = e.parentModel || null
138
+ // A workflow can start dozens of agents; one line each would bury the transcript.
139
+ if (!e.workflow) {
140
+ const parent = run.parentModel ? ` (메인 ${family(run.parentModel)})` : ''
141
+ $.ui.log(`🔀 [sprag] "${e.description}" → ${family(spawned.model)} 위임${parent}`)
142
+ }
143
+ return spawned
144
+ }).catch(($, e, next) => next(e))
145
+
146
+ on('turn.step', async function* ($, e, next) {
147
+ const result = yield* next(e)
148
+ if (e.agentId && result.usage) {
149
+ const run = await runFor($, e.agentId)
150
+ const turn = openTurn.get(e.agentId) ?? { usage: zero(), steps: 0 }
151
+ add(turn.usage, fromApi(result.usage))
152
+ turn.steps += 1
153
+ openTurn.set(e.agentId, turn)
154
+ run.answeredBy = result.usage.model
155
+ }
156
+ return result
157
+ }).catch(async function* ($, e, next) {
158
+ return yield* next(e)
159
+ })
160
+
161
+ on('turn.complete', async ($, e, next) => {
162
+ if (!e.agentId) return next(e)
163
+ const run = await runFor($, e.agentId)
164
+ const turn = openTurn.get(e.agentId)
165
+ openTurn.delete(e.agentId)
166
+ // The steps' own counts are the measurement. The turn's sum stands in only
167
+ // when no step was seen, e.g. the module reloaded during the turn.
168
+ if (turn && turn.steps > 0) {
169
+ add(run.usage, turn.usage)
170
+ run.steps += turn.steps
171
+ } else if (e.usage) {
172
+ add(run.usage, fromApi(e.usage))
173
+ run.answeredBy = e.usage.model
174
+ }
175
+ run.turns += 1
176
+ run.durationMs += e.durationMs
177
+ run.endedAt = await $.clock.now()
178
+ run.lastReason = e.reason
179
+
180
+ try {
181
+ await $.store.set(storeKey(run.sessionId, run.agentId), run)
182
+ runs.delete(e.agentId)
183
+ } catch {
184
+ // A store that refuses the write leaves only this run unrecorded. It
185
+ // stays in the map, so its next turn still adds to these totals.
186
+ }
187
+
188
+ const model = family(run.model ?? run.answeredBy ?? 'unknown')
189
+ const u = run.usage
190
+ $.ui.log(
191
+ `🔀 [sprag] ${model} 위임 ${OUTCOME[e.reason] ?? e.reason} · ${(run.durationMs / 1000).toFixed(1)}초`
192
+ + ` · 요청 ${run.steps}회 · 입력 ${tokens(u.input)} · 캐시 읽기 ${tokens(u.cacheRead)}`
193
+ + ` · 캐시 쓰기 ${tokens(u.cacheWrite)} · 출력 ${tokens(u.output)}`,
194
+ )
195
+ return next(e)
196
+ }).catch(($, e, next) => next(e))
197
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "modules": ["./register.ts"]
3
+ }
@@ -0,0 +1,97 @@
1
+ import type { EngineInterface, Register } from 'claude-code'
2
+
3
+ /**
4
+ * sprag-doc2md: the mod form of sprag's PreToolUse(Read) hook.
5
+ *
6
+ * The settings hook can only refuse the Read and tell Claude where the
7
+ * converted Markdown is, which costs one more tool call per document. A mod
8
+ * sits in front of the call, so it hands the same call on with the path
9
+ * swapped: Claude asked for deck.pptx and reads deck.md, and the permission
10
+ * check still runs on the path that is actually read.
11
+ *
12
+ * Whenever this mod does not redirect (not a document, conversion refused or
13
+ * failed, sprag not on PATH) the call goes on untouched, and sprag's settings
14
+ * hook beneath it explains the refusal exactly as it does today. After a
15
+ * redirect that hook sees a .md path and stays silent, so the two never
16
+ * answer the same Read twice.
17
+ */
18
+
19
+ // Mirrors TARGET_EXTENSIONS in src/doc2md.cjs. A hooks module has no Node, so
20
+ // it cannot load that file; test/mod-install.test.js holds the two in step.
21
+ const TARGET = /\.(pptx|xlsx|xls|pdf|docx|fig)$/i
22
+
23
+ // The CLI's name, as CLI_NAME in src/cli-name.js spells it. The mod cannot
24
+ // import that file either; test/mod-install.test.js holds the two in step.
25
+ const CLI = 'sprag'
26
+
27
+ // The converter gives up at 120 s on its own; the margin covers CLI start-up.
28
+ // Time spent in $.process.run does not count against the hook's 10 s budget,
29
+ // so the Read waits as long as the conversion does, as it does under the
30
+ // settings hook today. The status line says so while it lasts.
31
+ const CONVERT_TIMEOUT_MS = 150_000
32
+
33
+ // What `sprag doc2md <path>` prints on success. Parsing human output is a
34
+ // prototype shortcut: a `--json` flag on that command should replace it.
35
+ const CONVERTED = /^✓ (cached|converted): (.+)$/m
36
+
37
+ type Converted = { cacheFile: string; isCached: boolean }
38
+
39
+ const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
40
+
41
+ /**
42
+ * Runs the conversion the settings hook would run, ledger entry included.
43
+ * Answers null on any failure so the caller passes the event on unchanged.
44
+ *
45
+ * A first conversion can take a minute, and nothing else on screen says the
46
+ * Read is waiting on it, so the status line under the prompt names the file
47
+ * until the CLI returns. A status line rather than a transcript line: whether
48
+ * the file is cached is unknown until the CLI answers, and a log line on every
49
+ * Read would double the lines for the cached ones too.
50
+ */
51
+ async function convert($: EngineInterface, path: string): Promise<Converted | null> {
52
+ $.ui.status(`doc2md: ${baseName(path)} 변환 중…`)
53
+ try {
54
+ const run = await $.process.run([CLI, 'doc2md', path], { timeoutMs: CONVERT_TIMEOUT_MS })
55
+ if (run.exitCode !== 0) return null
56
+ const match = CONVERTED.exec(run.stdout)
57
+ if (!match) return null
58
+ return { cacheFile: match[2].trim(), isCached: match[1] === 'cached' }
59
+ } catch {
60
+ return null
61
+ } finally {
62
+ $.ui.status(undefined)
63
+ }
64
+ }
65
+
66
+ function logRedirect($: EngineInterface, from: string, doc: Converted) {
67
+ $.ui.log(`doc2md: ${baseName(from)} 대신 변환본을 읽습니다${doc.isCached ? ' (캐시)' : ''}`)
68
+ }
69
+
70
+ export const register: Register = on => {
71
+ // Both hooks fail open, which is the point of the .catch on each: this mod
72
+ // saves a round trip and guards nothing, so a hook that breaks hands the
73
+ // event on as it came and sprag's settings hook beneath answers as before.
74
+ // Where the hook had already called next, next(e) replays that call.
75
+ on('tool.call', { tool: 'Read' }, async ($, e, next) => {
76
+ if (!TARGET.test(e.file_path)) return next(e)
77
+ const doc = await convert($, e.file_path)
78
+ if (!doc) return next(e)
79
+ logRedirect($, e.file_path, doc)
80
+ // Line and page ranges addressed the binary and mean nothing in the
81
+ // Markdown (`pages` applies to PDFs only), so the whole file is read.
82
+ const { offset: _offset, limit: _limit, pages: _pages, ...call } = e
83
+ return next({ ...call, file_path: doc.cacheFile })
84
+ }).catch(($, e, next) => next(e))
85
+
86
+ // An @-mentioned document is attached whole, before any tool call, so the
87
+ // Read hook above never sees it. Redirect the attachment the same way.
88
+ on('prompt.mention', { path: TARGET }, async ($, e, next) => {
89
+ const doc = await convert($, e.path)
90
+ if (!doc) return next(e)
91
+ logRedirect($, e.path, doc)
92
+ // A mention carries no `pages` (PromptMentionInput has mention, path,
93
+ // offset, limit and agentId), so only the line range is dropped here.
94
+ const { offset: _offset, limit: _limit, ...mention } = e
95
+ return next({ ...mention, path: doc.cacheFile })
96
+ }).catch(($, e, next) => next(e))
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.63.0",
3
+ "version": "3.65.0",
4
4
  "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "bin/",
23
23
  "src/",
24
24
  "mod/*/.claude-plugin/plugin.json",
25
- "mod/*/hooks/",
25
+ "mod/*/hooks/**",
26
26
  "presets/",
27
27
  "examples/",
28
28
  "README.md",
@@ -7,7 +7,7 @@ import { writeViaTmp } from './state-file.js';
7
7
  import { readCodexSnapshot } from './codex-parser.js';
8
8
  import { loadCodexModelRules } from './codex-delegation.js';
9
9
  import { loadCodexLedger } from './codex-ledger.js';
10
- import { codexRulesInReview } from './codex-rule-health.js';
10
+ import { codexRulesInReview, codexSharedRuleHealth } from './codex-rule-health.js';
11
11
 
12
12
  const RESET_TOLERANCE_S = 300;
13
13
  const hash = (value) => createHash('sha256').update(value).digest('hex');
@@ -88,6 +88,15 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoo
88
88
  }
89
89
  }
90
90
  } catch { /* An unreadable rule file or ledger only skips this line. */ }
91
+ try {
92
+ for (const { rule, health, from, model, provider } of codexSharedRuleHealth({ root: cwd || projectRoot || snapshot.projectDir, dir })) {
93
+ if (health.status !== 'review') continue;
94
+ issues.push({ key: 'rule-health', tier: 1,
95
+ signature: `shared-rule-health:${JSON.stringify([rule.sharedSignature, rule.scope, rule.targetRoot, provider, from, model])}`,
96
+ level: 'warning', message: `Codex shared policy ${rule.tier} ${rule.category} (${provider}: ${from} -> ${model}) failed ${health.errs} of ${health.runs} measured runs.`,
97
+ advice: 'Review: sprag delegate rules --agent codex. Pause shared routing: sprag delegate shared off --agent codex. Common policy removal affects both agents.' });
98
+ }
99
+ } catch { /* Shared health is independent of Codex-only rule parsing. */ }
91
100
  const next = issues.filter((issue) => !active.includes(issue.signature) &&
92
101
  !(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
93
102
  // Missing or stale measurements do not resolve an earlier warning. A fresh