cctally 1.22.2 → 1.22.3

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.
@@ -0,0 +1,518 @@
1
+ # bin/_cctally_codex.py
2
+ """Codex (OpenAI) parity command family.
3
+
4
+ Holds the four codex commands — `cmd_codex_daily`, `cmd_codex_monthly`,
5
+ `cmd_codex_weekly`, `cmd_codex_session` — their speed/tz resolvers
6
+ (`_detect_codex_fast_service_tier`, `_resolve_codex_speed`,
7
+ `_resolve_codex_tz_name`) and the cost-stats/debug cluster
8
+ (`_CodexCostSample`, `_CodexCostStats`, `_compute_codex_cost_stats`,
9
+ `_render_codex_cost_report`, `_emit_codex_debug_samples_if_set`).
10
+
11
+ Honest *name* imports are KERNEL-ONLY (`_cctally_core`). This module
12
+ references the bin/cctally RE-EXPORTED names of every library kernel it
13
+ needs (`build_codex_daily_view`, `_calculate_codex_entry_cost`,
14
+ `_render_codex_session_table`, …) — NOT the `_lib_*` module objects — so
15
+ NO qualified `_lib_*` import is required; every such name is reached via
16
+ the call-time `_cctally()` accessor so test monkeypatches through
17
+ `cctally`'s namespace are preserved (spec §3.1). The codex path-resolvers
18
+ `_codex_home_roots`/`_codex_session_roots` STAY in bin/cctally (shared
19
+ with cache/doctor/aggregators) and are reached via `c.`.
20
+
21
+ THE SHARED DEBUG GUARD: `_DEBUG_REPORT_EMITTED` STAYS in bin/cctally
22
+ (module-global); `_emit_codex_debug_samples_if_set` reaches it via
23
+ `c._DEBUG_REPORT_EMITTED` for BOTH read and write — there is NO `global`
24
+ declaration here (spec §3.3).
25
+
26
+ bin/cctally re-exports EVERY moved symbol (eager): the parser resolves
27
+ `c.cmd_codex_*`; tests reach `mod._compute_codex_cost_stats` /
28
+ `mod._render_codex_cost_report` / `cc._resolve_codex_speed` /
29
+ `cc._detect_codex_fast_service_tier` off the `cctally` namespace.
30
+
31
+ Spec: docs/superpowers/specs/2026-05-31-extract-codex-reporting-cmd-design.md
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import os
38
+ import sys
39
+ from dataclasses import dataclass, field
40
+
41
+ from _cctally_core import WEEKDAY_MAP, _command_as_of, eprint, get_week_start_name
42
+
43
+
44
+ def _cctally():
45
+ """Resolve the current `cctally` module at call-time (spec §3.1)."""
46
+ return sys.modules["cctally"]
47
+
48
+
49
+ # === moved verbatim from bin/cctally (Regions A–C) ===
50
+
51
+
52
+ @dataclass
53
+ class _CodexCostSample:
54
+ file: str
55
+ timestamp: str
56
+ model: str
57
+ calculated_cost: float
58
+ usage: dict
59
+ is_fallback: bool
60
+
61
+
62
+ @dataclass
63
+ class _CodexCostStats:
64
+ command_label: str | None = None
65
+ total_entries: int = 0
66
+ total_cost: float = 0.0
67
+ model_counts: dict = field(default_factory=dict)
68
+ fallback_models: set = field(default_factory=set)
69
+ samples: list = field(default_factory=list)
70
+
71
+
72
+ def _compute_codex_cost_stats(entries, speed: str = "standard"):
73
+ """Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
74
+ per-entry computed-cost samples that ``_render_codex_cost_report``
75
+ consumes (issue #92).
76
+
77
+ Unlike the Claude ``_compute_pricing_mismatch_stats`` there is no
78
+ recorded cost to diff against, so every entry contributes a sample.
79
+ Samples are collected for all entries and sorted descending by
80
+ computed cost; the renderer slices to ``--debug-samples``. (Memory is
81
+ O(entries); acceptable for typical codex histories and symmetric with
82
+ the Claude helper, which retains its full discrepancy list.)
83
+
84
+ Cost + fallback resolution mirror the live aggregation path:
85
+ ``_calculate_codex_entry_cost`` (LiteLLM token semantics) and
86
+ ``_resolve_codex_pricing`` (unknown model → ``gpt-5`` fallback).
87
+ """
88
+ c = _cctally()
89
+ stats = _CodexCostStats()
90
+ for entry in entries:
91
+ stats.total_entries += 1
92
+ stats.model_counts[entry.model] = (
93
+ stats.model_counts.get(entry.model, 0) + 1
94
+ )
95
+ _, is_fallback = c._resolve_codex_pricing(entry.model)
96
+ if is_fallback:
97
+ stats.fallback_models.add(entry.model)
98
+ cost = c._calculate_codex_entry_cost(
99
+ entry.model,
100
+ entry.input_tokens,
101
+ entry.cached_input_tokens,
102
+ entry.output_tokens,
103
+ entry.reasoning_output_tokens,
104
+ speed=speed,
105
+ )
106
+ stats.total_cost += cost
107
+ stats.samples.append(_CodexCostSample(
108
+ file=os.path.basename(entry.source_path),
109
+ timestamp=entry.timestamp.isoformat(),
110
+ model=entry.model,
111
+ calculated_cost=cost,
112
+ usage={
113
+ "input_tokens": entry.input_tokens,
114
+ "cached_input_tokens": entry.cached_input_tokens,
115
+ "output_tokens": entry.output_tokens,
116
+ "reasoning_output_tokens": entry.reasoning_output_tokens,
117
+ "total_tokens": entry.total_tokens,
118
+ },
119
+ is_fallback=is_fallback,
120
+ ))
121
+ # Stable sort: equal-cost samples keep iteration order (mirrors the
122
+ # Claude helper's iteration-order discrepancy list).
123
+ stats.samples.sort(key=lambda s: -s.calculated_cost)
124
+ return stats
125
+
126
+
127
+ def _render_codex_cost_report(stats, sample_limit):
128
+ """Return the codex --debug report as a list of stderr lines (issue #92).
129
+
130
+ Structurally parallel to ``_render_pricing_mismatch_report`` but with
131
+ no match/mismatch framing (codex has no recorded cost):
132
+
133
+ - Early-return ``"No Codex usage data found to analyze."`` when
134
+ ``total_entries == 0``.
135
+ - Totals header: entries processed, models seen (count desc, ties
136
+ by name asc; fallback models tagged ``(N, fallback→gpt-5)``),
137
+ total computed cost.
138
+ - ``Command: cctally <label>`` self-identifier when set (parity
139
+ with the Claude report's one non-upstream line).
140
+ - Sample block omitted when ``sample_limit == 0`` or no samples;
141
+ header prints the requested ``sample_limit`` (upstream parity).
142
+ Each sample carries ``Recorded cost: (none)`` and a
143
+ ``(fallback→gpt-5)`` model-line marker when applicable.
144
+ """
145
+ c = _cctally()
146
+ out = []
147
+ if stats.total_entries == 0:
148
+ out.append("No Codex usage data found to analyze.")
149
+ return out
150
+
151
+ fallback = c.CODEX_LEGACY_FALLBACK_MODEL
152
+ parts = []
153
+ for model, count in sorted(
154
+ stats.model_counts.items(), key=lambda kv: (-kv[1], kv[0]),
155
+ ):
156
+ if model in stats.fallback_models:
157
+ parts.append(f"{model} ({count:,}, fallback→{fallback})")
158
+ else:
159
+ parts.append(f"{model} ({count:,})")
160
+
161
+ out.append("")
162
+ out.append("=== Codex Pricing Debug Report ===")
163
+ if stats.command_label:
164
+ out.append(f"Command: cctally {stats.command_label}")
165
+ out.append(f"Total entries processed: {stats.total_entries:,}")
166
+ out.append(f"Models seen: {', '.join(parts)}")
167
+ out.append(f"Total computed cost: ${stats.total_cost:.6f}")
168
+
169
+ if stats.samples and sample_limit > 0:
170
+ out.append("")
171
+ out.append(f"=== Sample Top Entries (first {sample_limit}) ===")
172
+ for s in stats.samples[:sample_limit]:
173
+ model_line = (
174
+ f"{s.model} (fallback→{fallback})"
175
+ if s.is_fallback else s.model
176
+ )
177
+ out.append(f"File: {s.file}")
178
+ out.append(f"Timestamp: {s.timestamp}")
179
+ out.append(f"Model: {model_line}")
180
+ out.append("Recorded cost: (none)")
181
+ out.append(f"Calculated cost: ${s.calculated_cost:.6f}")
182
+ out.append(f"Tokens: {json.dumps(s.usage)}")
183
+ out.append("---")
184
+ return out
185
+
186
+
187
+ def _emit_codex_debug_samples_if_set(
188
+ args,
189
+ entries,
190
+ *,
191
+ command_label: str,
192
+ speed: str = "standard",
193
+ ) -> None:
194
+ """Emit the codex --debug report once per process when ``args.debug``
195
+ is True (issue #92).
196
+
197
+ ``entries`` is an eager ``list[CodexEntry]`` — each ``cmd_codex_*`` body
198
+ already loads them via ``get_codex_entries`` before this call, so unlike
199
+ the Claude helper there is no deferred-loader variant. Shares the
200
+ process-wide ``_DEBUG_REPORT_EMITTED`` guard with
201
+ ``_emit_debug_samples_if_set`` so a single CLI invocation emits one
202
+ report regardless of family.
203
+ """
204
+ c = _cctally()
205
+ if c._DEBUG_REPORT_EMITTED:
206
+ return
207
+ if not getattr(args, "debug", False):
208
+ return
209
+ sample_limit = int(getattr(args, "debug_samples", 5))
210
+ stats = _compute_codex_cost_stats(entries, speed=speed)
211
+ stats.command_label = command_label
212
+ for line in _render_codex_cost_report(stats, sample_limit):
213
+ eprint(line)
214
+ c._DEBUG_REPORT_EMITTED = True
215
+
216
+
217
+ def _resolve_codex_tz_name(args: argparse.Namespace,
218
+ config: "dict | None") -> "str | None":
219
+ """Resolve the IANA tz NAME (or None for host-local) used by Codex
220
+ aggregators (`codex-{daily,monthly,weekly,session}`).
221
+
222
+ Precedence (F2 fix):
223
+ 1. Explicit `--tz <anything>` flag → use it (None on canonical "local").
224
+ 2. Explicit `display.tz` set in config → use it (None on "local").
225
+ 3. Else fall back to upstream's `--timezone` (drop-in parity).
226
+ 4. Else None (host local).
227
+
228
+ Steps 1+2 funnel through `resolve_display_tz`; step 3+4 are the
229
+ pre-existing fallback path. The bug `resolve_display_tz` could not
230
+ fix on its own: it returns None for both "explicit local" AND
231
+ "implicit local fallback when no config exists", which collapsed the
232
+ two semantically distinct cases. We disambiguate by inspecting
233
+ `args.tz` and `config["display"]["tz"]` directly.
234
+ """
235
+ c = _cctally()
236
+ flag_set = (
237
+ getattr(args, "tz", None) is not None
238
+ and str(getattr(args, "tz")).strip() != ""
239
+ )
240
+ if flag_set or c._config_has_explicit_display_tz(config):
241
+ tz_obj = c.resolve_display_tz(args, config)
242
+ return tz_obj.key if tz_obj is not None else None
243
+ # No explicit display tz pin → defer to upstream's --timezone, then
244
+ # host-local as the final default.
245
+ return getattr(args, "timezone", None)
246
+
247
+
248
+ def _detect_codex_fast_service_tier() -> bool:
249
+ """True iff any $CODEX_HOME root's config.toml requests fast/priority tier.
250
+
251
+ Reads <root>/config.toml for EVERY entry in _codex_home_roots() (comma-
252
+ separated $CODEX_HOME, else ~/.codex) — including direct-JSONL entries,
253
+ which usually have no config.toml (read → absent → skipped) but DO count
254
+ if one is present. Returns on the first root that requests it (any-root
255
+ semantics, matching upstream ccusage). Tolerates absent/unreadable config
256
+ (→ that root contributes nothing).
257
+ """
258
+ c = _cctally()
259
+ for root in c._codex_home_roots():
260
+ cfg = root / "config.toml"
261
+ try:
262
+ content = cfg.read_text(encoding="utf-8", errors="replace")
263
+ except OSError:
264
+ continue
265
+ if c._codex_config_requests_fast_service_tier(content):
266
+ return True
267
+ return False
268
+
269
+
270
+ def _resolve_codex_speed(requested: str) -> str:
271
+ """Resolve a ``--speed`` value to an effective tier.
272
+
273
+ ``auto`` → ``fast`` iff any ``$CODEX_HOME`` root's ``config.toml``
274
+ requests it, else ``standard``. ``fast``/``standard`` pass through
275
+ unchanged.
276
+ """
277
+ if requested == "auto":
278
+ return "fast" if _detect_codex_fast_service_tier() else "standard"
279
+ return requested
280
+
281
+
282
+ def cmd_codex_daily(args: argparse.Namespace) -> int:
283
+ """Show Codex usage report grouped by date (display tz, --tz, or --timezone)."""
284
+ c = _cctally()
285
+ config = c.load_config()
286
+ tz_obj = c.resolve_display_tz(args, config)
287
+ args._resolved_tz = tz_obj
288
+ # Codex aggregators take a tz_name string. F2 fix: precedence is
289
+ # `--tz` flag > config.display.tz > `--timezone` > host-local. Without
290
+ # this, an explicit "--tz local" silently falls through to --timezone
291
+ # (because resolve_display_tz returns None for canonical "local").
292
+ tz_name = _resolve_codex_tz_name(args, config)
293
+ force_compact = bool(getattr(args, "compact", False))
294
+ range = c._parse_cli_date_range(
295
+ args, tz_name=tz_name, now_utc=_command_as_of(),
296
+ )
297
+ if isinstance(range, int):
298
+ return range
299
+ range_start, range_end = range
300
+
301
+ entries = c.get_codex_entries(range_start, range_end)
302
+ speed = _resolve_codex_speed(args.speed)
303
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily", speed=speed)
304
+ # Route through ``build_codex_daily_view`` (issue #58). The View
305
+ # wraps ``_aggregate_codex_daily`` without changing it — preserves
306
+ # LiteLLM token semantics, intentional dedup vs upstream, and
307
+ # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
308
+ view = c.build_codex_daily_view(
309
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
310
+ )
311
+ days = list(view.rows) # asc — matches aggregator default
312
+ if args.order == "desc":
313
+ days = list(reversed(days))
314
+
315
+ if not days:
316
+ # Match upstream's no-data sentinel (see _emit_codex_no_data docstring).
317
+ c._emit_codex_no_data(args, "daily")
318
+ return 0
319
+
320
+ if args.json:
321
+ # Upstream daily --json uses "Dec 25, 2025" style for the date key.
322
+ print(c._codex_bucket_to_json(
323
+ days, list_key="daily", date_key="date",
324
+ display_fn=c._codex_daily_bucket_display,
325
+ ))
326
+ return 0
327
+
328
+ # Wide-mode table Date cell: two-line "Dec 25,\n2025"
329
+ def daily_table_display(bucket: str) -> str:
330
+ y, m, d = bucket.split("-")
331
+ return f"{c._CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
332
+
333
+ tz_label = view.display_tz_label
334
+ title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
335
+ print(c._render_codex_bucket_table(
336
+ days,
337
+ first_col_name="Date",
338
+ title=title,
339
+ compact_split_fn=c._daily_compact_split, # reuse existing helper (YYYY-MM-DD split)
340
+ bucket_display_fn=daily_table_display,
341
+ breakdown=args.breakdown,
342
+ force_compact=force_compact,
343
+ ))
344
+ return 0
345
+
346
+
347
+ def cmd_codex_monthly(args: argparse.Namespace) -> int:
348
+ """Show Codex usage report grouped by calendar month (display tz, --tz, or --timezone)."""
349
+ c = _cctally()
350
+ config = c.load_config()
351
+ tz_obj = c.resolve_display_tz(args, config)
352
+ args._resolved_tz = tz_obj
353
+ # F2 fix: see cmd_codex_daily.
354
+ tz_name = _resolve_codex_tz_name(args, config)
355
+ force_compact = bool(getattr(args, "compact", False))
356
+ range = c._parse_cli_date_range(
357
+ args, tz_name=tz_name, now_utc=_command_as_of(),
358
+ )
359
+ if isinstance(range, int):
360
+ return range
361
+ range_start, range_end = range
362
+
363
+ entries = c.get_codex_entries(range_start, range_end)
364
+ speed = _resolve_codex_speed(args.speed)
365
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly", speed=speed)
366
+ # Route through ``build_codex_monthly_view`` (issue #58).
367
+ view = c.build_codex_monthly_view(
368
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
369
+ )
370
+ months = list(view.rows)
371
+ if args.order == "desc":
372
+ months = list(reversed(months))
373
+
374
+ if not months:
375
+ # Match upstream's no-data sentinel (see _emit_codex_no_data docstring).
376
+ c._emit_codex_no_data(args, "monthly")
377
+ return 0
378
+
379
+ if args.json:
380
+ # Upstream monthly --json uses "Dec 2025" style for the month key.
381
+ print(c._codex_bucket_to_json(
382
+ months, list_key="monthly", date_key="month",
383
+ display_fn=c._codex_monthly_bucket_display,
384
+ ))
385
+ return 0
386
+
387
+ # Wide-mode table Month cell: two-line "Dec\n2025"
388
+ def monthly_table_display(bucket: str) -> str:
389
+ y, m = bucket.split("-")
390
+ return f"{c._CODEX_MONTHS[int(m) - 1]}\n{y}"
391
+
392
+ tz_label = view.display_tz_label
393
+ title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
394
+ print(c._render_codex_bucket_table(
395
+ months,
396
+ first_col_name="Month",
397
+ title=title,
398
+ compact_split_fn=c._monthly_compact_split, # reuse existing Claude helper (YYYY-MM split)
399
+ bucket_display_fn=monthly_table_display,
400
+ breakdown=args.breakdown,
401
+ force_compact=force_compact,
402
+ ))
403
+ return 0
404
+
405
+
406
+ def cmd_codex_weekly(args: argparse.Namespace) -> int:
407
+ """Show Codex usage grouped by week (display tz, --tz, or --timezone)."""
408
+ c = _cctally()
409
+ now_utc = _command_as_of()
410
+ config = c.load_config()
411
+ tz_obj = c.resolve_display_tz(args, config)
412
+ args._resolved_tz = tz_obj
413
+ # F2 fix: see cmd_codex_daily.
414
+ tz_name = _resolve_codex_tz_name(args, config)
415
+ force_compact = bool(getattr(args, "compact", False))
416
+ range = c._parse_cli_date_range(args, tz_name=tz_name, now_utc=now_utc)
417
+ if isinstance(range, int):
418
+ return range
419
+ range_start, range_end = range
420
+
421
+ # Resolve week-start from config (Monday default; reuse already-loaded config).
422
+ week_start_name = get_week_start_name(config)
423
+ week_start_idx = WEEKDAY_MAP[week_start_name]
424
+
425
+ entries = c.get_codex_entries(range_start, range_end)
426
+ speed = _resolve_codex_speed(args.speed)
427
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly", speed=speed)
428
+ # Route through ``build_codex_weekly_view`` (issue #58).
429
+ view = c.build_codex_weekly_view(
430
+ entries, now_utc=now_utc, tz_name=tz_name,
431
+ week_start_idx=week_start_idx, speed=speed,
432
+ )
433
+ weeks = list(view.rows)
434
+ if args.order == "desc":
435
+ weeks = list(reversed(weeks))
436
+
437
+ if not weeks:
438
+ # Match upstream's no-data sentinel (same string daily/monthly use).
439
+ c._emit_codex_no_data(args, "weekly")
440
+ return 0
441
+
442
+ if args.json:
443
+ # No upstream codex weekly JSON exists — use MMM DD, YYYY style matching codex-daily.
444
+ def weekly_bucket_display(bucket: str) -> str:
445
+ y, m, d = bucket.split("-")
446
+ return f"{c._CODEX_MONTHS[int(m) - 1]} {int(d):02d}, {y}"
447
+ print(c._codex_bucket_to_json(
448
+ weeks, list_key="weekly", date_key="week",
449
+ display_fn=weekly_bucket_display,
450
+ ))
451
+ return 0
452
+
453
+ # Wide-mode table Week cell: two-line "Apr 13,\n2026"
454
+ def weekly_table_display(bucket: str) -> str:
455
+ y, m, d = bucket.split("-")
456
+ return f"{c._CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
457
+
458
+ tz_label = view.display_tz_label
459
+ title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
460
+ print(c._render_codex_bucket_table(
461
+ weeks,
462
+ first_col_name="Week",
463
+ title=title,
464
+ compact_split_fn=c._daily_compact_split, # two-line split of "YYYY-MM-DD" — same shape as daily
465
+ bucket_display_fn=weekly_table_display,
466
+ breakdown=args.breakdown,
467
+ force_compact=force_compact,
468
+ ))
469
+ return 0
470
+
471
+
472
+ def cmd_codex_session(args: argparse.Namespace) -> int:
473
+ """Show Codex usage report grouped by session (sorted by last activity)."""
474
+ c = _cctally()
475
+ config = c.load_config()
476
+ tz_obj = c.resolve_display_tz(args, config)
477
+ args._resolved_tz = tz_obj
478
+ # F2 fix: see cmd_codex_daily.
479
+ tz_name = _resolve_codex_tz_name(args, config)
480
+ force_compact = bool(getattr(args, "compact", False))
481
+ range = c._parse_cli_date_range(
482
+ args, tz_name=tz_name, now_utc=_command_as_of(),
483
+ )
484
+ if isinstance(range, int):
485
+ return range
486
+ range_start, range_end = range
487
+
488
+ entries = c.get_codex_entries(range_start, range_end)
489
+ speed = _resolve_codex_speed(args.speed)
490
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session", speed=speed)
491
+ # Route through ``build_codex_session_view`` (issue #58). View rows
492
+ # come descending by last_activity (aggregator default + upstream
493
+ # parity); --order asc reverses.
494
+ view = c.build_codex_session_view(
495
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
496
+ )
497
+ sessions = list(view.rows)
498
+ if args.order == "asc":
499
+ sessions = list(reversed(sessions))
500
+
501
+ if not sessions:
502
+ # Match upstream's no-data sentinel (plural "sessions" matches upstream
503
+ # — confirmed in @ccusage/codex@18.0.8 dist/index.js around line 7962).
504
+ c._emit_codex_no_data(args, "sessions")
505
+ return 0
506
+
507
+ if args.json:
508
+ print(c._codex_sessions_to_json(sessions))
509
+ return 0
510
+
511
+ tz_label = view.display_tz_label
512
+ # Upstream uses "Sessions" (plural) in the session banner title.
513
+ title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
514
+ print(c._render_codex_session_table(
515
+ sessions, title=title,
516
+ force_compact=force_compact, tz_name=tz_name,
517
+ ))
518
+ return 0
@@ -1916,11 +1916,11 @@ class CacheReportSnapshot:
1916
1916
  # synthetic entries on the same project.
1917
1917
 
1918
1918
  def _cache_report_load_kernel():
1919
- """Lazy-load ``_cctally_cache_report`` via the cctally ``_load_sibling``
1919
+ """Lazy-load ``_lib_cache_report`` via the cctally ``_load_sibling``
1920
1920
  bridge so monkeypatch-driven test reloads of cctally see the same
1921
1921
  kernel module instance (matches the late-load pattern used by share /
1922
1922
  doctor helpers in this file)."""
1923
- return sys.modules["cctally"]._load_sibling("_cctally_cache_report")
1923
+ return sys.modules["cctally"]._load_sibling("_lib_cache_report")
1924
1924
 
1925
1925
 
1926
1926
  def build_cache_report_snapshot(
@@ -1936,7 +1936,7 @@ def build_cache_report_snapshot(
1936
1936
  Pulls entries via ``get_claude_session_entries`` (uses the cache when
1937
1937
  warm, falls back to direct-JSONL parse on cache miss / lock
1938
1938
  contention — same chain the CLI uses). Delegates aggregation +
1939
- anomaly classification to ``_cctally_cache_report._build_cache_report``;
1939
+ anomaly classification to ``_lib_cache_report._build_cache_report``;
1940
1940
  shapes the result into a frozen ``CacheReportSnapshot``.
1941
1941
 
1942
1942
  ``window_days`` is hardcoded at 14 in v1 (spec §6.1 hardcodes