cctally 1.10.3 → 1.11.1

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/cctally CHANGED
@@ -135,15 +135,8 @@ def _load_sibling(name: str):
135
135
  _THIS_MODULE = sys.modules.get(__name__) or sys.modules.get("cctally")
136
136
 
137
137
 
138
- APP_DIR = pathlib.Path.home() / ".local" / "share" / "cctally"
139
- LEGACY_APP_DIR = pathlib.Path.home() / ".local" / "share" / "ccusage-subscription"
140
- # Hook-tick runtime artifacts (Section 1 of onboarding spec).
141
- HOOK_TICK_LOG_DIR = APP_DIR / "logs"
142
- HOOK_TICK_LOG_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log"
143
- HOOK_TICK_LOG_ROTATED_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log.1"
138
+ # Hook-tick non-path constants (Section 1 of onboarding spec).
144
139
  HOOK_TICK_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB
145
- HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
146
- HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
147
140
  HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0
148
141
 
149
142
  # User-facing executables symlinked by `cctally setup` (Section 2.2 of spec).
@@ -167,19 +160,6 @@ SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
167
160
 
168
161
  # === Release automation (issue #24) ===
169
162
 
170
- _CHANGELOG_OVERRIDE = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
171
- if _CHANGELOG_OVERRIDE:
172
- # Fixture-stability hook for `bin/cctally-share-test` — points
173
- # `_share_resolve_version()` (and the broader release machinery) at a
174
- # per-scenario CHANGELOG so version stamping in goldens stays
175
- # deterministic regardless of the in-tree CHANGELOG state. Mirrors the
176
- # `CCTALLY_AS_OF` env-only precedent: not in --help, no docstring
177
- # surface; consumed exclusively by harness wrappers.
178
- CHANGELOG_PATH = pathlib.Path(_CHANGELOG_OVERRIDE)
179
- else:
180
- CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
181
-
182
-
183
163
  # Public mirror repo identity (the GitHub `<owner>/<repo>` slug). Used by
184
164
  # Phase 3 (mirror push), Phase 4 (`gh release create` / view), and the
185
165
  # fallback printout. Distinct from `.mirror-allowlist`, which classifies
@@ -230,6 +210,45 @@ make_week_ref = _cctally_core.make_week_ref
230
210
  _get_latest_row_for_week = _cctally_core._get_latest_row_for_week
231
211
  get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
232
212
 
213
+ # === Path constants — re-exported from _cctally_core ================
214
+ #
215
+ # Promoted 2026-05-22 (docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md).
216
+ # `_cctally_core` is the single source of truth and the only legal
217
+ # monkeypatch target. These re-exports exist for ad-hoc REPL / scripts
218
+ # that read `cctally.APP_DIR` directly.
219
+
220
+ APP_DIR = _cctally_core.APP_DIR
221
+ LEGACY_APP_DIR = _cctally_core.LEGACY_APP_DIR
222
+ LOG_DIR = _cctally_core.LOG_DIR
223
+
224
+ DB_PATH = _cctally_core.DB_PATH
225
+ CACHE_DB_PATH = _cctally_core.CACHE_DB_PATH
226
+
227
+ CACHE_LOCK_PATH = _cctally_core.CACHE_LOCK_PATH
228
+ CACHE_LOCK_CODEX_PATH = _cctally_core.CACHE_LOCK_CODEX_PATH
229
+ CONFIG_LOCK_PATH = _cctally_core.CONFIG_LOCK_PATH
230
+
231
+ CONFIG_PATH = _cctally_core.CONFIG_PATH
232
+
233
+ MIGRATION_ERROR_LOG_PATH = _cctally_core.MIGRATION_ERROR_LOG_PATH
234
+
235
+ CHANGELOG_PATH = _cctally_core.CHANGELOG_PATH
236
+
237
+ HOOK_TICK_LOG_DIR = _cctally_core.HOOK_TICK_LOG_DIR
238
+ HOOK_TICK_LOG_PATH = _cctally_core.HOOK_TICK_LOG_PATH
239
+ HOOK_TICK_LOG_ROTATED_PATH = _cctally_core.HOOK_TICK_LOG_ROTATED_PATH
240
+ HOOK_TICK_THROTTLE_PATH = _cctally_core.HOOK_TICK_THROTTLE_PATH
241
+ HOOK_TICK_THROTTLE_LOCK_PATH = _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH
242
+
243
+ UPDATE_STATE_PATH = _cctally_core.UPDATE_STATE_PATH
244
+ UPDATE_SUPPRESS_PATH = _cctally_core.UPDATE_SUPPRESS_PATH
245
+ UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
246
+ UPDATE_LOG_PATH = _cctally_core.UPDATE_LOG_PATH
247
+ UPDATE_LOG_ROTATED_PATH = _cctally_core.UPDATE_LOG_ROTATED_PATH
248
+ UPDATE_CHECK_LAST_FETCH_PATH = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH
249
+
250
+ CLAUDE_SETTINGS_PATH = _cctally_core.CLAUDE_SETTINGS_PATH
251
+
233
252
  _lib_semver = _load_sibling("_lib_semver")
234
253
  _SEMVER_NUM = _lib_semver._SEMVER_NUM
235
254
  _SEMVER_RE = _lib_semver._SEMVER_RE
@@ -648,11 +667,15 @@ _saved_dict_from_usage_row = _cctally_record._saved_dict_from_usage_row
648
667
  # the ``c = _cctally()`` accessor at call time so
649
668
  # ``setitem(ns, "_do_update_check", mock)`` propagates into
650
669
  # ``cmd_update_check_internal`` / ``_DashboardUpdateCheckThread.run``.
651
- # Path constants stay in bin/cctally (defined at L2001-2023 right after
652
- # APP_DIR) moved bodies read them via ``c.UPDATE_STATE_PATH`` etc. so
653
- # ``setitem(ns, "UPDATE_LOG_PATH", tmp)`` in tests propagates with zero
654
- # sibling-side patches needed (same as Phase D #17/#18 path-constant
655
- # precedent).
670
+ # Path constants (UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH,
671
+ # UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH,
672
+ # UPDATE_CHECK_LAST_FETCH_PATH) were promoted to _cctally_core
673
+ # 2026-05-22 (#84). Moved bodies in _cctally_update read them via
674
+ # call-time ``_cctally_core.UPDATE_STATE_PATH`` etc.; tests patch via
675
+ # ``monkeypatch.setattr(_cctally_core, "X", v)`` (the conftest
676
+ # ``redirect_paths()`` helper covers the full set). The legacy
677
+ # ``setitem(ns, …)`` pattern is forbidden by
678
+ # ``test_no_old_style_test_patches_for_promoted_globals``.
656
679
  _cctally_update = _load_sibling("_cctally_update")
657
680
  UpdateError = _cctally_update.UpdateError
658
681
  UpdateValidationError = _cctally_update.UpdateValidationError
@@ -822,64 +845,26 @@ LEGACY_STATUSLINE_PATHS = (
822
845
  )
823
846
  LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
824
847
 
825
- DB_PATH = APP_DIR / "stats.db"
826
- CACHE_DB_PATH = APP_DIR / "cache.db"
827
- CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
828
- CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
829
848
  CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
830
- CONFIG_PATH = APP_DIR / "config.json"
831
- CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
832
- LOG_DIR = APP_DIR / "logs"
833
- MIGRATION_ERROR_LOG_PATH = LOG_DIR / "migration-errors.log"
834
-
835
- # Seed `_cctally_db`'s namespace with path constants the migration
836
- # framework + error sentinel + cmd_db_* helpers reach via bare-name
837
- # lookup. We have to do this AFTER LOG_DIR / MIGRATION_ERROR_LOG_PATH
838
- # / DB_PATH / CACHE_DB_PATH are defined (i.e. here, not at the eager-
839
- # load site near the top). Bare-name lookup keeps the existing
840
- # `monkeypatch.setitem(helper.__globals__, "MIGRATION_ERROR_LOG_PATH",
841
- # tmp)` test pattern working (the helper's `__globals__` is now
842
- # `_cctally_db.__dict__`, so setitem there hits the bare-name lookup
843
- # path inside the helper). Tests that redirect HOME via the conftest
844
- # fixture also work because `redirect_paths` already patches the
845
- # cctally-side path constants AND will additionally need to patch the
846
- # sibling-side copy below — that's why we expose this seed: tests can
847
- # discover the sibling via `cctally_module._cctally_db` and patch via
848
- # `monkeypatch.setitem(_cctally_db.__dict__, "X", tmp)` if needed.
849
- _cctally_db.LOG_DIR = LOG_DIR
850
- _cctally_db.MIGRATION_ERROR_LOG_PATH = MIGRATION_ERROR_LOG_PATH
851
- _cctally_db.DB_PATH = DB_PATH
852
- _cctally_db.CACHE_DB_PATH = CACHE_DB_PATH
853
-
854
- # Note: `_cctally_cache.py` does NOT need a path-constant seed block
855
- # parallel to `_cctally_db` above — its moved bodies reach
856
- # `APP_DIR` / `CACHE_DB_PATH` / `CACHE_LOCK_PATH` /
857
- # `CACHE_LOCK_CODEX_PATH` / `CODEX_SESSIONS_DIR` via the
858
- # `c = _cctally()` call-time accessor (spec §5.5). That defers every
859
- # read until call-time `sys.modules['cctally'].X`, so
860
- # `monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)` test patches and
861
- # conftest `redirect_paths` HOME redirects propagate without
862
- # touching any sibling-side seeded copy. We chose `c.X` over the
863
- # `_cctally_db`-style seed block here because cache test sites are
864
- # widely scattered (record-usage tick, dashboard panels, share
865
- # render kernel, block tests, every JSONL-reading subcommand
866
- # fixture) — inline patches against ns by setitem are the
867
- # established idiom, and the c.X path keeps them all working with
868
- # zero test-file edits.
849
+
850
+ # Note: `_cctally_db` reads its four path constants
851
+ # (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
852
+ # `_cctally_core.X` at call time — the canonical sibling pattern after
853
+ # the data-globals promotion (2026-05-22, issue #84). The previous
854
+ # eager-seed block here is no longer needed: `_cctally_core` is the
855
+ # single source of truth, and `monkeypatch.setattr(_cctally_core, "X",
856
+ # v)` propagates into every reader without a sibling-side mirror.
857
+ #
858
+ # Note: `_cctally_cache.py` reaches `APP_DIR` / `CACHE_DB_PATH` /
859
+ # `CACHE_LOCK_PATH` / `CACHE_LOCK_CODEX_PATH` via call-time
860
+ # `_cctally_core.X` (promoted 2026-05-22, #84). Only `CODEX_SESSIONS_DIR`
861
+ # is out of scope for #84 that one is still read via the
862
+ # `c = _cctally()` accessor and lives in this file.
869
863
 
870
864
  # === Update subcommand (Section 1 of update-subcommand spec) ===
871
- # Path constants for the `cctally update` feature. Centralised here
872
- # (alongside other APP_DIR-derived paths) so the test fixture loader in
873
- # tests/conftest.py:redirect_paths can monkeypatch them, and so later
874
- # tasks (install detection, version-check pipeline, dashboard worker)
875
- # don't have to revisit constant placement.
876
- UPDATE_STATE_PATH = APP_DIR / "update-state.json"
877
- UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
878
- UPDATE_LOCK_PATH = APP_DIR / "update.lock"
879
- UPDATE_LOG_PATH = APP_DIR / "update.log"
880
- UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
865
+ # Non-path constants for the `cctally update` feature. Path constants
866
+ # now live in bin/_cctally_core.py (promoted 2026-05-22; see #84).
881
867
  UPDATE_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB; spec §1.5
882
- UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
883
868
 
884
869
  UPDATE_NPM_REGISTRY_URL = os.environ.get(
885
870
  "CCTALLY_TEST_UPDATE_NPM_URL",
@@ -904,14 +889,14 @@ def _migrate_legacy_data_dir() -> None:
904
889
  Removable in a future major version once early users have been on
905
890
  cctally long enough that the legacy dir is gone everywhere.
906
891
  """
907
- if APP_DIR.exists():
892
+ if _cctally_core.APP_DIR.exists():
908
893
  return # already migrated, or fresh install at the new path
909
- if not LEGACY_APP_DIR.exists():
894
+ if not _cctally_core.LEGACY_APP_DIR.exists():
910
895
  return # fresh install, no legacy data
911
- APP_DIR.parent.mkdir(parents=True, exist_ok=True)
912
- os.rename(LEGACY_APP_DIR, APP_DIR)
896
+ _cctally_core.APP_DIR.parent.mkdir(parents=True, exist_ok=True)
897
+ os.rename(_cctally_core.LEGACY_APP_DIR, _cctally_core.APP_DIR)
913
898
  print(
914
- f"cctally: migrated data dir {LEGACY_APP_DIR} -> {APP_DIR}",
899
+ f"cctally: migrated data dir {_cctally_core.LEGACY_APP_DIR} -> {_cctally_core.APP_DIR}",
915
900
  file=sys.stderr,
916
901
  )
917
902
 
@@ -2179,71 +2164,24 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
2179
2164
  return 0.0
2180
2165
 
2181
2166
 
2182
- @dataclass
2183
- class CacheModelBreakdown:
2184
- model_name: str
2185
- input_tokens: int
2186
- output_tokens: int
2187
- cache_creation_tokens: int
2188
- cache_read_tokens: int
2189
- cache_hit_percent: float
2190
- cost: float
2191
- saved_usd: float = 0.0
2192
- wasted_usd: float = 0.0
2193
- net_usd: float = 0.0
2194
-
2195
-
2196
- @dataclass
2197
- class CacheRow:
2198
- # Identity (exactly one group populated)
2199
- date: str | None = None
2200
- session_id: str | None = None
2201
- project_path: str | None = None
2202
- last_activity: dt.datetime | None = None
2203
- source_paths: list[str] = field(default_factory=list)
2204
-
2205
- # Token counters
2206
- input_tokens: int = 0
2207
- output_tokens: int = 0
2208
- cache_creation_tokens: int = 0
2209
- cache_read_tokens: int = 0
2210
-
2211
- # Financials (populated by Task 2; zero here)
2212
- cost: float = 0.0
2213
- saved_usd: float = 0.0
2214
- wasted_usd: float = 0.0
2215
- net_usd: float = 0.0
2216
-
2217
- # Per-model breakdown children
2218
- model_breakdowns: list[CacheModelBreakdown] = field(default_factory=list)
2219
-
2220
- # Anomaly (populated by Task 5; defaults here)
2221
- anomaly_triggered: bool = False
2222
- anomaly_reasons: list[str] = field(default_factory=list)
2223
-
2224
- @property
2225
- def total_tokens(self) -> int:
2226
- return (
2227
- self.input_tokens + self.output_tokens
2228
- + self.cache_creation_tokens + self.cache_read_tokens
2229
- )
2230
-
2231
- @property
2232
- def cache_hit_percent(self) -> float:
2233
- return _compute_cache_hit_percent(
2234
- self.input_tokens, self.cache_creation_tokens, self.cache_read_tokens
2235
- )
2236
-
2237
-
2238
- def _compute_cache_hit_percent(
2239
- input_tokens: int,
2240
- cache_creation_tokens: int,
2241
- cache_read_tokens: int,
2242
- ) -> float:
2243
- total_input = input_tokens + cache_creation_tokens + cache_read_tokens
2244
- if total_input == 0:
2245
- return 0.0
2246
- return (cache_read_tokens / total_input) * 100
2167
+ # === Cache-report kernel re-exports (Task A2 onward) =========================
2168
+ # The dataclasses + pure helpers below previously lived inline in bin/cctally;
2169
+ # the cache-report panel/modal effort moved them to bin/_cctally_cache_report
2170
+ # so the dashboard sync builder can reuse the same pure aggregation as the
2171
+ # CLI. cctally-side callers continue to reach for ``CacheRow`` /
2172
+ # ``CacheModelBreakdown`` / ``_compute_cache_hit_percent`` /
2173
+ # ``_compute_entry_cache_dollars`` by bare name (extensive — every cache-report
2174
+ # renderer + JSON emitter); per-symbol re-export here preserves the call sites
2175
+ # unchanged. ``_compute_entry_cache_dollars`` keeps its pre-extraction
2176
+ # signature on this side by wrapping the kernel version with the embedded
2177
+ # ``CLAUDE_MODEL_PRICING`` injected as the ``pricing`` kwarg.
2178
+ #
2179
+ # Spec: docs/superpowers/specs/2026-05-21-cache-report-panel-design.md §5.2
2180
+ _cctally_cache_report = _load_sibling("_cctally_cache_report")
2181
+ CacheModelBreakdown = _cctally_cache_report.CacheModelBreakdown
2182
+ CacheRow = _cctally_cache_report.CacheRow
2183
+ _compute_cache_hit_percent = _cctally_cache_report._compute_cache_hit_percent
2184
+ _compute_entry_cache_dollars_kernel = _cctally_cache_report._compute_entry_cache_dollars
2247
2185
 
2248
2186
 
2249
2187
  def _compute_entry_cache_dollars(
@@ -2251,142 +2189,57 @@ def _compute_entry_cache_dollars(
2251
2189
  cache_creation_tokens: int,
2252
2190
  cache_read_tokens: int,
2253
2191
  ) -> tuple[float, float, float]:
2254
- """Return (saved_usd, wasted_usd, net_usd) for a single API-call entry.
2255
-
2256
- saved_usd = cache_read_tokens x (base_rate - read_rate)
2257
- "what you'd have paid without caching"
2258
- wasted_usd = cache_creation_tokens x (create_rate - base_rate)
2259
- "the premium paid to write cache"
2260
- net_usd = saved_usd - wasted_usd
2261
- positive = caching helped; negative = caching hurt
2262
-
2263
- Applies Anthropic's per-call >200K-tokens tier (mirrors the
2264
- `_tiered` helper in `_calculate_entry_cost`). Aggregating tokens
2265
- across multiple calls and then pricing would under-count savings on
2266
- any single call that crossed the tier. Resolves `anthropic/` and
2267
- `anthropic.` aliases via `_resolve_model_pricing` so cache-dollar
2268
- numbers stay aligned with cost numbers.
2192
+ """Compatibility wrapper pre-extraction signature.
2193
+
2194
+ The kernel function takes ``pricing`` explicitly so it stays pure;
2195
+ bin/cctally callers inject the embedded ``CLAUDE_MODEL_PRICING``.
2196
+ ``_lookup_pricing`` inside the kernel handles the ``anthropic/`` /
2197
+ ``anthropic.`` alias-stripping that the legacy ``_resolve_model_pricing``
2198
+ did, but without the stderr warning (the warning is the CLI's concern
2199
+ and already fires elsewhere via ``_calculate_entry_cost``).
2269
2200
  """
2270
- pricing = _resolve_model_pricing(model) or {}
2271
-
2272
- def _tiered_rate(tokens: int, base_key: str, tiered_key: str) -> float:
2273
- """Blended $/token rate for a single-call token count under tiered pricing."""
2274
- base_rate = pricing.get(base_key, 0.0)
2275
- tiered_rate = pricing.get(tiered_key)
2276
- if tokens <= 0:
2277
- return 0.0
2278
- if tokens > TIERED_THRESHOLD and tiered_rate is not None:
2279
- below = TIERED_THRESHOLD
2280
- above = tokens - TIERED_THRESHOLD
2281
- return (below * base_rate + above * tiered_rate) / tokens
2282
- return base_rate
2283
-
2284
- base_for_read = _tiered_rate(
2285
- cache_read_tokens,
2286
- "input_cost_per_token",
2287
- "input_cost_per_token_above_200k_tokens",
2288
- )
2289
- read_rate = _tiered_rate(
2290
- cache_read_tokens,
2291
- "cache_read_input_token_cost",
2292
- "cache_read_input_token_cost_above_200k_tokens",
2293
- )
2294
- base_for_create = _tiered_rate(
2295
- cache_creation_tokens,
2296
- "input_cost_per_token",
2297
- "input_cost_per_token_above_200k_tokens",
2298
- )
2299
- create_rate = _tiered_rate(
2300
- cache_creation_tokens,
2301
- "cache_creation_input_token_cost",
2302
- "cache_creation_input_token_cost_above_200k_tokens",
2303
- )
2304
-
2305
- saved = cache_read_tokens * max(0.0, base_for_read - read_rate)
2306
- wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
2307
- net = saved - wasted
2308
- return (saved, wasted, net)
2201
+ return _compute_entry_cache_dollars_kernel(
2202
+ model, cache_creation_tokens, cache_read_tokens,
2203
+ pricing=CLAUDE_MODEL_PRICING,
2204
+ tiered_threshold=TIERED_THRESHOLD,
2205
+ )
2309
2206
 
2310
2207
 
2311
2208
  def _aggregate_cache_by_day(
2312
2209
  since: dt.datetime,
2313
2210
  until: dt.datetime,
2314
2211
  project: str | None = None,
2212
+ *,
2213
+ display_tz: "ZoneInfo | None" = None,
2315
2214
  ) -> list[CacheRow]:
2316
- """Group Claude Code entries by local date within [since, until]."""
2317
- # internal fallback: host-local intentional
2318
- local_tz = dt.datetime.now().astimezone().tzinfo
2319
-
2320
- day_model_buckets: dict[str, dict[str, dict[str, Any]]] = {}
2321
- for entry in get_entries(since, until, project=project):
2322
- day_key = entry.timestamp.astimezone(local_tz).strftime("%Y-%m-%d")
2323
- cost = _calculate_entry_cost(
2324
- entry.model, entry.usage, mode="auto", cost_usd=entry.cost_usd
2325
- )
2326
- create_tok = entry.usage.get("cache_creation_input_tokens", 0)
2327
- read_tok = entry.usage.get("cache_read_input_tokens", 0)
2328
- saved, wasted, net = _compute_entry_cache_dollars(
2329
- entry.model, create_tok, read_tok
2330
- )
2331
- models = day_model_buckets.setdefault(day_key, {})
2332
- b = models.setdefault(entry.model, {
2333
- "inputTokens": 0, "outputTokens": 0,
2334
- "cacheCreationTokens": 0, "cacheReadTokens": 0, "cost": 0.0,
2335
- "savedUsd": 0.0, "wastedUsd": 0.0, "netUsd": 0.0,
2336
- })
2337
- b["inputTokens"] += entry.usage.get("input_tokens", 0)
2338
- b["outputTokens"] += entry.usage.get("output_tokens", 0)
2339
- b["cacheCreationTokens"] += create_tok
2340
- b["cacheReadTokens"] += read_tok
2341
- b["cost"] += cost
2342
- b["savedUsd"] += saved
2343
- b["wastedUsd"] += wasted
2344
- b["netUsd"] += net
2345
-
2346
- result: list[CacheRow] = []
2347
- for day_key in sorted(day_model_buckets.keys()):
2348
- models = day_model_buckets[day_key]
2349
- row = CacheRow(date=day_key)
2350
- for model_name in sorted(models.keys()):
2351
- b = models[model_name]
2352
- mb = CacheModelBreakdown(
2353
- model_name=model_name,
2354
- input_tokens=b["inputTokens"],
2355
- output_tokens=b["outputTokens"],
2356
- cache_creation_tokens=b["cacheCreationTokens"],
2357
- cache_read_tokens=b["cacheReadTokens"],
2358
- cache_hit_percent=_compute_cache_hit_percent(
2359
- b["inputTokens"], b["cacheCreationTokens"], b["cacheReadTokens"]
2360
- ),
2361
- cost=b["cost"],
2362
- saved_usd=b["savedUsd"],
2363
- wasted_usd=b["wastedUsd"],
2364
- net_usd=b["netUsd"],
2365
- )
2366
- row.model_breakdowns.append(mb)
2367
- row.input_tokens += mb.input_tokens
2368
- row.output_tokens += mb.output_tokens
2369
- row.cache_creation_tokens += mb.cache_creation_tokens
2370
- row.cache_read_tokens += mb.cache_read_tokens
2371
- row.cost += mb.cost
2372
- row.saved_usd += mb.saved_usd
2373
- row.wasted_usd += mb.wasted_usd
2374
- row.net_usd += mb.net_usd
2375
- result.append(row)
2376
- return result
2377
-
2215
+ """CLI adapter: pulls entries from ``get_entries`` and delegates to the
2216
+ pure-fn kernel ``_cctally_cache_report._aggregate_cache_by_day``.
2217
+
2218
+ Adds an explicit ``display_tz`` kwarg (closes the pre-existing minor bug
2219
+ where ``--tz`` shifted the window edges but not the day-bucketing —
2220
+ spec §1.6, plan A3). Passes the embedded ``CLAUDE_MODEL_PRICING`` +
2221
+ ``_calculate_entry_cost`` into the kernel so the kernel itself stays
2222
+ free of pricing globals / cost-dispatch I/O.
2223
+
2224
+ Direct callers that don't pass ``display_tz`` (legacy contract) fall
2225
+ back to host-local via the kernel's ``None``-tz handling, matching
2226
+ pre-extraction behavior byte-for-byte. ``since`` / ``until`` bound
2227
+ the I/O query here; the kernel itself trusts the caller's pre-filter.
2228
+ """
2229
+ entries = list(get_entries(since, until, project=project))
2230
+ return _cctally_cache_report._aggregate_cache_by_day(
2231
+ entries,
2232
+ display_tz=display_tz,
2233
+ pricing=CLAUDE_MODEL_PRICING,
2234
+ cost_calculator=_calculate_entry_cost,
2235
+ )
2378
2236
 
2379
- def _filename_uuid_stem(path: str) -> str:
2380
- """Extract the UUID stem from a JSONL filename.
2381
2237
 
2382
- Claude JSONL files are named `<uuid>.jsonl`; fall back to the full
2383
- filename (without extension) if the stem isn't a valid UUID shape.
2384
- Matches the `session` subcommand's convention for unresolved session
2385
- IDs.
2386
- """
2387
- base = os.path.basename(path)
2388
- stem, _, _ = base.partition(".")
2389
- return stem
2238
+ # Re-export the kernel's filename stem helper so any bare-name callers
2239
+ # inside bin/cctally (and tests poking via ``ns["_filename_uuid_stem"]``)
2240
+ # resolve unchanged. Kernel is pure-string; ``os.path.basename``
2241
+ # equivalence is asserted by ``test_aggregate_by_session_falls_back_*``.
2242
+ _filename_uuid_stem = _cctally_cache_report._filename_uuid_stem
2390
2243
 
2391
2244
 
2392
2245
  def _aggregate_cache_by_session(
@@ -2394,135 +2247,40 @@ def _aggregate_cache_by_session(
2394
2247
  until: dt.datetime,
2395
2248
  project: str | None = None,
2396
2249
  ) -> list[CacheRow]:
2397
- """Group Claude entries by sessionId (resumed-merged) within [since, until].
2398
-
2399
- Uses get_claude_session_entries for the existing session_entries x
2400
- session_files LEFT JOIN. Entries with NULL session_id fall back to
2401
- the filename UUID stem of source_path (matches the `session`
2402
- subcommand's convention). A one-shot stderr warning fires when any
2403
- entry used the fallback. `project`, when set, filters by the same
2404
- slug semantics as `get_entries(project=...)`.
2250
+ """CLI adapter: pulls Claude session entries from
2251
+ ``get_claude_session_entries`` and delegates to the pure-fn kernel
2252
+ ``_cctally_cache_report._aggregate_cache_by_session``.
2253
+
2254
+ Preserves the legacy one-shot ``Warning: N entries lacked
2255
+ session_files rows (cache may be catching up).`` stderr line by
2256
+ consuming the kernel's ``fallback_count`` and calling ``eprint``
2257
+ here (kept on the I/O side; kernel stays pure). Injects
2258
+ ``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost`` +
2259
+ ``_decode_escaped_cwd`` so the kernel doesn't reach for cctally
2260
+ globals. ``since`` / ``until`` bound the I/O query; the kernel
2261
+ itself trusts the caller's pre-filter.
2405
2262
  """
2406
2263
  entries = get_claude_session_entries(since, until, project=project)
2407
2264
  if not entries:
2408
2265
  return []
2409
2266
 
2410
- # buckets[sid] = {"entries": [...], "project_path": str|None,
2411
- # "last_activity": dt|None, "source_paths": set[str]}
2412
- buckets: dict[str, dict[str, Any]] = {}
2413
- fallback_count = 0
2414
- for entry in entries:
2415
- # Skip synthetic entries (Claude Code internal markers, not real
2416
- # model calls). Mirrors `_aggregate_claude_sessions` (line ~2992).
2417
- # Must occur before the session_id fallback so synthetic entries
2418
- # don't inflate fallback_count either.
2419
- if entry.model == "<synthetic>":
2420
- continue
2421
- sid = entry.session_id
2422
- if sid is None:
2423
- sid = _filename_uuid_stem(entry.source_path)
2424
- fallback_count += 1
2425
- b = buckets.setdefault(sid, {
2426
- "entries": [],
2427
- # Seed with decoded-cwd fallback so rows still resolve a
2428
- # Project cell while session_files backfill is incomplete.
2429
- # Real project_path from session_files (if present on any
2430
- # joined row) overrides below.
2431
- "project_path": _decode_escaped_cwd(
2432
- os.path.basename(os.path.dirname(entry.source_path))
2433
- ),
2434
- "last_activity": None,
2435
- "source_paths": set(),
2436
- })
2437
- b["entries"].append(entry)
2438
- b["source_paths"].add(entry.source_path)
2439
- if b["last_activity"] is None or entry.timestamp > b["last_activity"]:
2440
- b["last_activity"] = entry.timestamp
2441
- # Project path from most-recent in-window entry that has it.
2442
- if entry.project_path:
2443
- b["project_path"] = entry.project_path
2444
-
2445
- if fallback_count:
2446
- eprint(
2447
- f"Warning: {fallback_count} entries lacked session_files rows "
2448
- "(cache may be catching up)."
2267
+ def _project_decoder(source_path: str) -> str:
2268
+ return _decode_escaped_cwd(
2269
+ os.path.basename(os.path.dirname(source_path))
2449
2270
  )
2450
2271
 
2451
- result: list[CacheRow] = []
2452
- for sid, b in buckets.items():
2453
- # Per-model sub-buckets scoped to this session's entries.
2454
- model_buckets: dict[str, dict[str, Any]] = {}
2455
- for entry in b["entries"]:
2456
- mb_raw = model_buckets.setdefault(entry.model, {
2457
- "inputTokens": 0, "outputTokens": 0,
2458
- "cacheCreationTokens": 0, "cacheReadTokens": 0, "cost": 0.0,
2459
- "savedUsd": 0.0, "wastedUsd": 0.0, "netUsd": 0.0,
2460
- })
2461
- mb_raw["inputTokens"] += entry.input_tokens
2462
- mb_raw["outputTokens"] += entry.output_tokens
2463
- mb_raw["cacheCreationTokens"] += entry.cache_creation_tokens
2464
- mb_raw["cacheReadTokens"] += entry.cache_read_tokens
2465
- mb_raw["cost"] += _calculate_entry_cost(
2466
- entry.model,
2467
- {
2468
- "input_tokens": entry.input_tokens,
2469
- "output_tokens": entry.output_tokens,
2470
- "cache_creation_input_tokens": entry.cache_creation_tokens,
2471
- "cache_read_input_tokens": entry.cache_read_tokens,
2472
- },
2473
- mode="auto",
2474
- cost_usd=entry.cost_usd,
2475
- )
2476
- saved, wasted, net = _compute_entry_cache_dollars(
2477
- entry.model,
2478
- entry.cache_creation_tokens,
2479
- entry.cache_read_tokens,
2480
- )
2481
- mb_raw["savedUsd"] += saved
2482
- mb_raw["wastedUsd"] += wasted
2483
- mb_raw["netUsd"] += net
2484
-
2485
- row = CacheRow(
2486
- session_id=sid,
2487
- project_path=b["project_path"],
2488
- last_activity=b["last_activity"],
2489
- source_paths=sorted(b["source_paths"]),
2272
+ agg = _cctally_cache_report._aggregate_cache_by_session(
2273
+ entries,
2274
+ pricing=CLAUDE_MODEL_PRICING,
2275
+ cost_calculator=_calculate_entry_cost,
2276
+ project_decoder=_project_decoder,
2277
+ )
2278
+ if agg.fallback_count:
2279
+ eprint(
2280
+ f"Warning: {agg.fallback_count} entries lacked session_files rows "
2281
+ "(cache may be catching up)."
2490
2282
  )
2491
- for model_name in sorted(model_buckets.keys()):
2492
- mb_raw = model_buckets[model_name]
2493
- mb = CacheModelBreakdown(
2494
- model_name=model_name,
2495
- input_tokens=mb_raw["inputTokens"],
2496
- output_tokens=mb_raw["outputTokens"],
2497
- cache_creation_tokens=mb_raw["cacheCreationTokens"],
2498
- cache_read_tokens=mb_raw["cacheReadTokens"],
2499
- cache_hit_percent=_compute_cache_hit_percent(
2500
- mb_raw["inputTokens"],
2501
- mb_raw["cacheCreationTokens"],
2502
- mb_raw["cacheReadTokens"],
2503
- ),
2504
- cost=mb_raw["cost"],
2505
- saved_usd=mb_raw["savedUsd"],
2506
- wasted_usd=mb_raw["wastedUsd"],
2507
- net_usd=mb_raw["netUsd"],
2508
- )
2509
- row.model_breakdowns.append(mb)
2510
- row.input_tokens += mb.input_tokens
2511
- row.output_tokens += mb.output_tokens
2512
- row.cache_creation_tokens += mb.cache_creation_tokens
2513
- row.cache_read_tokens += mb.cache_read_tokens
2514
- row.cost += mb.cost
2515
- row.saved_usd += mb.saved_usd
2516
- row.wasted_usd += mb.wasted_usd
2517
- row.net_usd += mb.net_usd
2518
- result.append(row)
2519
-
2520
- # Initial ordering descending by last_activity; Task 6 adds --sort and
2521
- # will change the session-mode default. Use tz-aware sentinel to avoid
2522
- # naive-vs-aware comparison errors on rows missing last_activity.
2523
- _min_dt = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
2524
- result.sort(key=lambda r: r.last_activity or _min_dt, reverse=True)
2525
- return result
2283
+ return agg.rows
2526
2284
 
2527
2285
 
2528
2286
  def _annotate_anomalies(
@@ -2532,83 +2290,20 @@ def _annotate_anomalies(
2532
2290
  *,
2533
2291
  enabled: bool = True,
2534
2292
  ) -> None:
2535
- """Mutate each row's anomaly_triggered / anomaly_reasons in place.
2536
-
2537
- Trigger 1 (net_negative): net_usd < 0 (strict). Skipped when the row has
2538
- zero cache activity (no-op session, not a bug).
2539
- Trigger 2 (cache_drop): cache_hit_percent is >= threshold_pp below the
2540
- trailing window_days median of OTHER rows. Requires minimum 5 (daily)
2541
- or 10 (session) baseline samples; silently skipped otherwise.
2293
+ """CLI adapter: thin shim around the kernel's ``_classify_anomalies``.
2542
2294
 
2543
- Mode is inferred from the first row: if it has a session_id, session
2544
- mode (window_days back to <= last_activity - 1s); else daily mode
2545
- (window_days back to <= date - 1 day).
2295
+ Kept under the original name so the existing call site in
2296
+ ``cmd_cache_report`` resolves unchanged. The kernel mutates each row
2297
+ in place (same contract as the pre-extraction implementation
2298
+ ``anomaly_triggered`` / ``anomaly_reasons`` set on each ``CacheRow``).
2546
2299
  """
2547
- import statistics
2548
-
2549
- if not enabled:
2550
- for row in rows:
2551
- row.anomaly_triggered = False
2552
- row.anomaly_reasons = []
2553
- return
2554
- if not rows:
2555
- return
2556
-
2557
- # Determine mode + baseline minimum from the first row's identity.
2558
- is_session_mode = rows[0].session_id is not None
2559
- min_baseline = 10 if is_session_mode else 5
2560
-
2561
- def _row_anchor(r: CacheRow) -> dt.datetime | None:
2562
- """Return the row's position in time for baseline-window comparison."""
2563
- if r.last_activity is not None:
2564
- return r.last_activity
2565
- if r.date:
2566
- # Use .astimezone() (not .replace(tzinfo=...)) so the OS tzdb
2567
- # gives the correct offset for the given date — avoids DST drift
2568
- # on dates that straddle a DST boundary. Mirrors the idiom in
2569
- # _parse_cli_date_range.
2570
- # internal fallback: host-local intentional
2571
- return dt.datetime.strptime(r.date, "%Y-%m-%d").astimezone()
2572
- return None
2573
-
2574
- window = dt.timedelta(days=window_days)
2575
- upper_offset = (
2576
- dt.timedelta(seconds=1) if is_session_mode else dt.timedelta(days=1)
2300
+ _cctally_cache_report._classify_anomalies(
2301
+ rows,
2302
+ threshold_pp=threshold_pp,
2303
+ window_days=window_days,
2304
+ enabled=enabled,
2577
2305
  )
2578
2306
 
2579
- # Pre-compute anchors once to avoid O(n^2 * datetime-parse) overhead.
2580
- anchors: list[dt.datetime | None] = [_row_anchor(r) for r in rows]
2581
-
2582
- for i, row in enumerate(rows):
2583
- reasons: list[str] = []
2584
-
2585
- # Trigger 1: net_negative (no baseline needed; cache-activity guard).
2586
- if row.cache_creation_tokens + row.cache_read_tokens > 0:
2587
- if row.net_usd < 0:
2588
- reasons.append("net_negative")
2589
-
2590
- # Trigger 2: cache_drop (requires baseline).
2591
- anchor = anchors[i]
2592
- if anchor is not None:
2593
- lower_bound = anchor - window
2594
- upper_bound = anchor - upper_offset
2595
- baseline_values: list[float] = []
2596
- for j, other in enumerate(rows):
2597
- if j == i:
2598
- continue
2599
- other_anchor = anchors[j]
2600
- if other_anchor is None:
2601
- continue
2602
- if lower_bound <= other_anchor <= upper_bound:
2603
- baseline_values.append(other.cache_hit_percent)
2604
- if len(baseline_values) >= min_baseline:
2605
- median = statistics.median(baseline_values)
2606
- if (median - row.cache_hit_percent) >= threshold_pp:
2607
- reasons.append("cache_drop")
2608
-
2609
- row.anomaly_reasons = reasons
2610
- row.anomaly_triggered = bool(reasons)
2611
-
2612
2307
 
2613
2308
  @dataclass
2614
2309
  class WeekCostResult:
@@ -8013,11 +7708,17 @@ class SetupError(RuntimeError):
8013
7708
  """Raised when setup hits a hard prerequisite failure (Section 2 of spec)."""
8014
7709
 
8015
7710
 
8016
- CLAUDE_SETTINGS_PATH = pathlib.Path.home() / ".claude" / "settings.json"
8017
-
7711
+ def _load_claude_settings(path: pathlib.Path | None = None) -> dict:
7712
+ """Read ~/.claude/settings.json. Empty/missing → {}. Malformed → SetupError.
8018
7713
 
8019
- def _load_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> dict:
8020
- """Read ~/.claude/settings.json. Empty/missing {}. Malformed → SetupError."""
7714
+ ``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
7715
+ TIME when omitted, so ``monkeypatch.setattr(_cctally_core,
7716
+ "CLAUDE_SETTINGS_PATH", tmp)`` propagates without needing to swap
7717
+ out this callable. Capturing the default at def-time would silently
7718
+ pin the maintainer's real ``~/.claude/settings.json``.
7719
+ """
7720
+ if path is None:
7721
+ path = _cctally_core.CLAUDE_SETTINGS_PATH
8021
7722
  if not path.exists():
8022
7723
  return {}
8023
7724
  raw = path.read_text(encoding="utf-8")
@@ -8034,8 +7735,14 @@ def _load_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> dict:
8034
7735
  return data
8035
7736
 
8036
7737
 
8037
- def _backup_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> pathlib.Path | None:
8038
- """Best-effort daily backup; return backup path or None."""
7738
+ def _backup_claude_settings(path: pathlib.Path | None = None) -> pathlib.Path | None:
7739
+ """Best-effort daily backup; return backup path or None.
7740
+
7741
+ ``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
7742
+ TIME when omitted (see ``_load_claude_settings`` for rationale).
7743
+ """
7744
+ if path is None:
7745
+ path = _cctally_core.CLAUDE_SETTINGS_PATH
8039
7746
  if not path.exists():
8040
7747
  return None
8041
7748
  today = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
@@ -8051,9 +7758,15 @@ def _backup_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> pathli
8051
7758
 
8052
7759
 
8053
7760
  def _write_claude_settings_atomic(
8054
- settings: dict, path: pathlib.Path = CLAUDE_SETTINGS_PATH
7761
+ settings: dict, path: pathlib.Path | None = None
8055
7762
  ) -> None:
8056
- """Atomic write with 2-space indent, trailing newline."""
7763
+ """Atomic write with 2-space indent, trailing newline.
7764
+
7765
+ ``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
7766
+ TIME when omitted (see ``_load_claude_settings`` for rationale).
7767
+ """
7768
+ if path is None:
7769
+ path = _cctally_core.CLAUDE_SETTINGS_PATH
8057
7770
  path.parent.mkdir(parents=True, exist_ok=True)
8058
7771
  tmp = path.with_suffix(path.suffix + ".tmp")
8059
7772
  tmp.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -8444,7 +8157,13 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
8444
8157
  if mode == "session":
8445
8158
  rows = _aggregate_cache_by_session(since, until, project=args.project)
8446
8159
  else:
8447
- rows = _aggregate_cache_by_day(since, until, project=args.project)
8160
+ # Task A3: pass the resolved display_tz so day buckets match the
8161
+ # ``--tz`` flag (closes the pre-existing minor bug where the
8162
+ # window edges shifted but day buckets stayed on host-local —
8163
+ # spec §1.6 / plan A3).
8164
+ rows = _aggregate_cache_by_day(
8165
+ since, until, project=args.project, display_tz=tz,
8166
+ )
8448
8167
 
8449
8168
  if not rows:
8450
8169
  if args.json:
@@ -8699,19 +8418,19 @@ def doctor_gather_state(
8699
8418
 
8700
8419
  # ── DB ───────────────────────────────────────────────────────────
8701
8420
  try:
8702
- stats_db_status = _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db")
8703
- if not DB_PATH.exists():
8421
+ stats_db_status = _db_status_for(_cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db")
8422
+ if not _cctally_core.DB_PATH.exists():
8704
8423
  stats_db_status["_file_exists"] = False
8705
8424
  except sqlite3.Error as exc:
8706
- stats_db_status = {"path": str(DB_PATH), "user_version": 0,
8425
+ stats_db_status = {"path": str(_cctally_core.DB_PATH), "user_version": 0,
8707
8426
  "registry_size": len(_STATS_MIGRATIONS),
8708
8427
  "migrations": [], "_open_error": str(exc)}
8709
8428
  try:
8710
- cache_db_status = _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
8711
- if not CACHE_DB_PATH.exists():
8429
+ cache_db_status = _db_status_for(_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
8430
+ if not _cctally_core.CACHE_DB_PATH.exists():
8712
8431
  cache_db_status["_file_exists"] = False
8713
8432
  except sqlite3.Error as exc:
8714
- cache_db_status = {"path": str(CACHE_DB_PATH), "user_version": 0,
8433
+ cache_db_status = {"path": str(_cctally_core.CACHE_DB_PATH), "user_version": 0,
8715
8434
  "registry_size": len(_CACHE_MIGRATIONS),
8716
8435
  "migrations": [], "_open_error": str(exc)}
8717
8436
 
@@ -8720,8 +8439,8 @@ def doctor_gather_state(
8720
8439
  forked_bucket_counts: dict | None = None
8721
8440
  credited_weeks: list[dict] | None = None
8722
8441
  try:
8723
- if DB_PATH.exists():
8724
- conn = sqlite3.connect(str(DB_PATH))
8442
+ if _cctally_core.DB_PATH.exists():
8443
+ conn = sqlite3.connect(str(_cctally_core.DB_PATH))
8725
8444
  try:
8726
8445
  try:
8727
8446
  row = conn.execute(
@@ -8818,8 +8537,8 @@ def doctor_gather_state(
8818
8537
  cache_entries_count = None
8819
8538
  cache_last_entry_at = None
8820
8539
  try:
8821
- if CACHE_DB_PATH.exists():
8822
- conn = sqlite3.connect(str(CACHE_DB_PATH))
8540
+ if _cctally_core.CACHE_DB_PATH.exists():
8541
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
8823
8542
  try:
8824
8543
  row = conn.execute(
8825
8544
  "SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
@@ -8848,8 +8567,8 @@ def doctor_gather_state(
8848
8567
  codex_entries_count = None
8849
8568
  codex_last_entry_at = None
8850
8569
  try:
8851
- if CACHE_DB_PATH.exists():
8852
- conn = sqlite3.connect(str(CACHE_DB_PATH))
8570
+ if _cctally_core.CACHE_DB_PATH.exists():
8571
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
8853
8572
  try:
8854
8573
  row = conn.execute(
8855
8574
  "SELECT COUNT(*), MAX(timestamp_utc) FROM codex_session_entries"
@@ -8891,8 +8610,8 @@ def doctor_gather_state(
8891
8610
  # check surfaces the corruption separately.
8892
8611
  dashboard_bind_stored = "loopback"
8893
8612
  try:
8894
- if CONFIG_PATH.exists():
8895
- raw_cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
8613
+ if _cctally_core.CONFIG_PATH.exists():
8614
+ raw_cfg = json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
8896
8615
  if isinstance(raw_cfg, dict):
8897
8616
  dashboard_bind_stored = (
8898
8617
  _config_known_value(raw_cfg, "dashboard.bind") or "loopback"
@@ -8906,8 +8625,8 @@ def doctor_gather_state(
8906
8625
  # (codex H1).
8907
8626
  config_json_error = None
8908
8627
  try:
8909
- if CONFIG_PATH.exists():
8910
- json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
8628
+ if _cctally_core.CONFIG_PATH.exists():
8629
+ json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
8911
8630
  except json.JSONDecodeError as exc:
8912
8631
  config_json_error = f"{type(exc).__name__}: {exc}"
8913
8632
  except OSError as exc: