cctally 1.7.3 → 1.8.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.
@@ -156,25 +156,40 @@ def _cctally():
156
156
  return sys.modules["cctally"]
157
157
 
158
158
 
159
+ # === Honest imports from extracted homes ===================================
160
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
161
+ from _cctally_core import (
162
+ eprint,
163
+ now_utc_iso,
164
+ parse_iso_datetime,
165
+ open_db,
166
+ get_week_start_name,
167
+ compute_week_bounds,
168
+ parse_date_str,
169
+ _canonicalize_optional_iso,
170
+ make_week_ref,
171
+ _get_alerts_config,
172
+ _AlertsConfigError,
173
+ )
174
+ from _lib_five_hour import _canonical_5h_window_key
175
+ from _lib_pricing import _calculate_entry_cost
176
+
177
+
159
178
  # Module-level back-ref shims. Each shim resolves
160
179
  # ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
161
180
  # monkeypatches on cctally's namespace propagate into the moved code
162
- # unchanged. Mirrors the precedent established in
163
- # ``bin/_cctally_cache.py`` and ``bin/_cctally_db.py``.
164
- def eprint(*args, **kwargs):
165
- return sys.modules["cctally"].eprint(*args, **kwargs)
166
-
167
-
168
- def now_utc_iso(*args, **kwargs):
169
- return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
170
-
171
-
172
- def parse_iso_datetime(*args, **kwargs):
173
- return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
181
+ # unchanged. `load_config` and `get_claude_session_entries` STAY as
182
+ # shims even though their natural homes are decentralized
183
+ # (_cctally_config / _cctally_cache) — tests monkeypatch them via
184
+ # `ns["X"]` (21 sites total, audited 2026-05-17); direct imports would
185
+ # silently bypass the patches.
186
+ # See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
187
+ def load_config(*args, **kwargs):
188
+ return sys.modules["cctally"].load_config(*args, **kwargs)
174
189
 
175
190
 
176
- def open_db(*args, **kwargs):
177
- return sys.modules["cctally"].open_db(*args, **kwargs)
191
+ def get_claude_session_entries(*args, **kwargs):
192
+ return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
178
193
 
179
194
 
180
195
  def open_cache_db(*args, **kwargs):
@@ -185,30 +200,6 @@ def sync_cache(*args, **kwargs):
185
200
  return sys.modules["cctally"].sync_cache(*args, **kwargs)
186
201
 
187
202
 
188
- def load_config(*args, **kwargs):
189
- return sys.modules["cctally"].load_config(*args, **kwargs)
190
-
191
-
192
- def get_week_start_name(*args, **kwargs):
193
- return sys.modules["cctally"].get_week_start_name(*args, **kwargs)
194
-
195
-
196
- def compute_week_bounds(*args, **kwargs):
197
- return sys.modules["cctally"].compute_week_bounds(*args, **kwargs)
198
-
199
-
200
- def parse_date_str(*args, **kwargs):
201
- return sys.modules["cctally"].parse_date_str(*args, **kwargs)
202
-
203
-
204
- def _canonicalize_optional_iso(*args, **kwargs):
205
- return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
206
-
207
-
208
- def _canonical_5h_window_key(*args, **kwargs):
209
- return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
210
-
211
-
212
203
  def _floor_to_hour(*args, **kwargs):
213
204
  return sys.modules["cctally"]._floor_to_hour(*args, **kwargs)
214
205
 
@@ -245,22 +236,10 @@ def insert_percent_milestone(*args, **kwargs):
245
236
  return sys.modules["cctally"].insert_percent_milestone(*args, **kwargs)
246
237
 
247
238
 
248
- def make_week_ref(*args, **kwargs):
249
- return sys.modules["cctally"].make_week_ref(*args, **kwargs)
250
-
251
-
252
239
  def cmd_sync_week(*args, **kwargs):
253
240
  return sys.modules["cctally"].cmd_sync_week(*args, **kwargs)
254
241
 
255
242
 
256
- def _calculate_entry_cost(*args, **kwargs):
257
- return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
258
-
259
-
260
- def get_claude_session_entries(*args, **kwargs):
261
- return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
262
-
263
-
264
243
  def _resolve_primary_model_for_block(*args, **kwargs):
265
244
  return sys.modules["cctally"]._resolve_primary_model_for_block(*args, **kwargs)
266
245
 
@@ -281,10 +260,6 @@ def _dispatch_alert_notification(*args, **kwargs):
281
260
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
282
261
 
283
262
 
284
- def _get_alerts_config(*args, **kwargs):
285
- return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
286
-
287
-
288
263
  def _warn_alerts_bad_config_once(*args, **kwargs):
289
264
  return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
290
265
 
@@ -532,7 +507,7 @@ def maybe_record_milestone(
532
507
  # the underlying problem is config-wide, not axis-specific.
533
508
  try:
534
509
  alerts_cfg: "dict | None" = _get_alerts_config(load_config())
535
- except sys.modules["cctally"]._AlertsConfigError as exc:
510
+ except _AlertsConfigError as exc:
536
511
  _warn_alerts_bad_config_once(exc)
537
512
  alerts_cfg = None
538
513
 
@@ -803,7 +778,7 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
803
778
  cfg_for_alerts = load_config()
804
779
  try:
805
780
  alerts_cfg: "dict | None" = _get_alerts_config(cfg_for_alerts)
806
- except sys.modules["cctally"]._AlertsConfigError as exc:
781
+ except _AlertsConfigError as exc:
807
782
  _warn_alerts_bad_config_once(exc)
808
783
  alerts_cfg = None
809
784
  # Resolve display.tz once (shares the cfg load above). Threaded
@@ -1500,12 +1475,19 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1500
1475
  # UNIQUE(old, new) constraint permits this
1501
1476
  # row, and the pre-check above keys on
1502
1477
  # new_week_end_at so dedup still works.
1478
+ # Stamp ``observed_pre_credit_pct = prior_pct``
1479
+ # (issue #45): durable record of the pre-credit
1480
+ # baseline we observed at write time. Decouples
1481
+ # any future cleanup tooling from re-deriving
1482
+ # prior_pct via SELECT. Existing rows from
1483
+ # migration 007 carry NULL.
1503
1484
  conn.execute(
1504
1485
  "INSERT OR IGNORE INTO week_reset_events "
1505
1486
  "(detected_at_utc, old_week_end_at, new_week_end_at, "
1506
- " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
1487
+ " effective_reset_at_utc, observed_pre_credit_pct) "
1488
+ "VALUES (?, ?, ?, ?, ?)",
1507
1489
  (now_utc_iso(), effective_iso, cur_end_canon,
1508
- effective_iso),
1490
+ effective_iso, float(prior_pct)),
1509
1491
  )
1510
1492
  conn.commit()
1511
1493
  # Pivots fire UNCONDITIONALLY whenever a credit
@@ -1553,24 +1535,29 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1553
1535
  # own in-memory HWM cache and re-runs us once
1554
1536
  # per status-line tick). Those replays land
1555
1537
  # captured_at_utc >= effective_iso with
1556
- # weekly_percent == prior_pct (the pre-credit
1538
+ # weekly_percent near prior_pct (the pre-credit
1557
1539
  # value), and they dominate the reset-aware
1558
1540
  # clamp's MAX over the post-credit segment so
1559
1541
  # legitimate fresh OAuth values are rejected.
1560
- # Strict equality (round(.,1)) keeps this
1561
- # narrow: we only delete rows whose percent
1562
- # exactly matches the pre-credit value we just
1563
- # observed legitimate post-credit climbs
1564
- # past `prior_pct` (rare, but possible if the
1565
- # credit is small + activity is heavy) stay.
1542
+ # 1.0pp tolerance band (issue #45) around the
1543
+ # observed pre-credit baseline absorbs any
1544
+ # rounding drift between cctally's OAuth read
1545
+ # and statusline's --percent payload (today
1546
+ # they match byte-identically, but the band
1547
+ # future-proofs against Anthropic or statusline
1548
+ # changing rounding). The band stays well below
1549
+ # the 25pp in-place credit detection threshold,
1550
+ # so legitimate post-credit values are never
1551
+ # caught. Bind is the in-scope ``prior_pct``,
1552
+ # which equals the just-stamped
1553
+ # ``observed_pre_credit_pct`` on the event row.
1566
1554
  try:
1567
1555
  conn.execute(
1568
1556
  "DELETE FROM weekly_usage_snapshots "
1569
1557
  "WHERE week_start_date = ? "
1570
1558
  " AND unixepoch(captured_at_utc) >= "
1571
1559
  " unixepoch(?) "
1572
- " AND round(weekly_percent, 1) = "
1573
- " round(?, 1)",
1560
+ " AND ABS(weekly_percent - ?) < 1.0",
1574
1561
  (week_start_date, effective_iso,
1575
1562
  float(prior_pct)),
1576
1563
  )
@@ -1755,20 +1742,34 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1755
1742
  # replaying the pre-credit
1756
1743
  # ``--five-hour-percent`` value past the
1757
1744
  # credit moment from its own in-memory
1758
- # HWM cache. Strict round-1 equality
1759
- # keeps the scope narrow only rows
1760
- # whose five_hour_percent exactly matches
1761
- # the just-observed pre-credit value are
1762
- # removed. ``unixepoch()`` on both sides
1763
- # for offset robustness (Z vs +00:00).
1745
+ # HWM cache. 1.0pp tolerance band (issue
1746
+ # #48 symmetric follow-up to weekly #45)
1747
+ # around the observed pre-credit baseline
1748
+ # absorbs any rounding drift between
1749
+ # cctally's OAuth read and statusline's
1750
+ # ``--five-hour-percent`` payload (today
1751
+ # they match byte-identically, but the
1752
+ # band future-proofs against Anthropic or
1753
+ # statusline changing 5h rounding). The
1754
+ # band stays well below the 5.0pp 5h
1755
+ # in-place credit detection threshold
1756
+ # (``_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD``)
1757
+ # — 4pp safety margin — so legitimate
1758
+ # post-credit values are never caught.
1759
+ # ``unixepoch()`` on both sides for offset
1760
+ # robustness (Z vs +00:00). Bind is the
1761
+ # in-scope ``prior_5h_pct``, which equals
1762
+ # the just-stamped
1763
+ # ``five_hour_reset_events.prior_percent``
1764
+ # on the event row.
1764
1765
  try:
1765
1766
  conn.execute(
1766
1767
  "DELETE FROM weekly_usage_snapshots "
1767
1768
  " WHERE five_hour_window_key = ? "
1768
1769
  " AND unixepoch(captured_at_utc) "
1769
1770
  " >= unixepoch(?) "
1770
- " AND round(five_hour_percent, 1) "
1771
- " = round(?, 1)",
1771
+ " AND ABS(five_hour_percent - ?) "
1772
+ " < 1.0",
1772
1773
  (
1773
1774
  int(five_hour_window_key),
1774
1775
  effective_iso,
@@ -55,6 +55,23 @@ def _cctally():
55
55
  return sys.modules["cctally"]
56
56
 
57
57
 
58
+ # === Honest imports from extracted homes ===================================
59
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
60
+ # import from _cctally_core. `load_config` plus out-of-scope helpers
61
+ # (`_resolve_oauth_token`, `_fetch_oauth_usage`, `_bust_statusline_cache`,
62
+ # `_refresh_usage_inproc`, `_get_oauth_usage_config`,
63
+ # `_seconds_since_iso`, `_select_last_known_snapshot`,
64
+ # `_newest_snapshot_age_seconds`, `_normalize_percent`,
65
+ # `_forecast_color_enabled`, `_discover_cc_version`, `cmd_record_usage`)
66
+ # stay on the _cctally() accessor.
67
+ from _cctally_core import (
68
+ eprint,
69
+ now_utc_iso,
70
+ _iso_to_epoch,
71
+ _format_short_duration,
72
+ )
73
+
74
+
58
75
  # =========================================================================
59
76
  # Exception classes
60
77
  # =========================================================================
@@ -289,14 +306,13 @@ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> st
289
306
  The ``5h`` segment is omitted entirely when ``payload["five_hour"]`` is None.
290
307
  Color codes are emitted only when ``color`` is True.
291
308
  """
292
- c = _cctally()
293
309
  a = _REFRESH_USAGE_ANSI if color else {k: "" for k in _REFRESH_USAGE_ANSI}
294
310
 
295
311
  seven = payload["seven_day"]
296
312
  seven_pct = seven["used_percent"]
297
313
  seven_resets = seven.get("resets_at_epoch")
298
314
  if seven_resets is not None:
299
- seven_ttl = c._format_short_duration(seven_resets - now_epoch)
315
+ seven_ttl = _format_short_duration(seven_resets - now_epoch)
300
316
  seven_seg = (
301
317
  f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
302
318
  f" {a['orange']}(in {seven_ttl}){a['reset']}"
@@ -309,7 +325,7 @@ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> st
309
325
  five_pct = five["used_percent"]
310
326
  five_resets = five.get("resets_at_epoch")
311
327
  if five_resets is not None:
312
- five_ttl = c._format_short_duration(five_resets - now_epoch)
328
+ five_ttl = _format_short_duration(five_resets - now_epoch)
313
329
  five_seg = (
314
330
  f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
315
331
  f" {a['orange']}(in {five_ttl}){a['reset']}"
@@ -420,14 +436,13 @@ def _bust_statusline_cache(path: str = _STATUSLINE_OAUTH_CACHE) -> str:
420
436
  ``"absent"`` (file did not exist), ``"error"`` (delete failed for
421
437
  a non-FileNotFoundError reason — logged via eprint, does NOT raise).
422
438
  """
423
- c = _cctally()
424
439
  try:
425
440
  os.remove(path)
426
441
  return "busted"
427
442
  except FileNotFoundError:
428
443
  return "absent"
429
444
  except OSError as exc:
430
- c.eprint(f"refresh-usage: cache-bust failed: {exc}")
445
+ eprint(f"refresh-usage: cache-bust failed: {exc}")
431
446
  return "error"
432
447
 
433
448
 
@@ -450,7 +465,7 @@ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
450
465
  quiet = bool(getattr(args, "quiet", False))
451
466
 
452
467
  if snap is None:
453
- c.eprint("refresh-usage: rate-limited; no last-known data; "
468
+ eprint("refresh-usage: rate-limited; no last-known data; "
454
469
  "status-line will populate on next CC tick")
455
470
  if json_mode:
456
471
  print(json.dumps({
@@ -467,7 +482,7 @@ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
467
482
  _freshness_label(age_s, cfg) if age_s is not None else "stale"
468
483
  )
469
484
 
470
- c.eprint(f"refresh-usage: rate-limited; using last-known "
485
+ eprint(f"refresh-usage: rate-limited; using last-known "
471
486
  f"(captured {int(age_s) if age_s is not None else '?'}s ago)")
472
487
 
473
488
  if json_mode:
@@ -550,7 +565,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
550
565
  # built then a downstream cmd_record_usage call fails).
551
566
  seven_pct = c._normalize_percent(float(seven["utilization"]))
552
567
  seven_resets_iso = seven["resets_at"]
553
- seven_resets_epoch = c._iso_to_epoch(seven_resets_iso)
568
+ seven_resets_epoch = _iso_to_epoch(seven_resets_iso)
554
569
  except (TypeError, ValueError, KeyError) as exc:
555
570
  return _RefreshUsageResult(
556
571
  status="parse_failed",
@@ -566,7 +581,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
566
581
  try:
567
582
  five_pct = c._normalize_percent(float(five["utilization"]))
568
583
  five_resets_iso = five["resets_at"]
569
- five_resets_epoch = c._iso_to_epoch(five_resets_iso)
584
+ five_resets_epoch = _iso_to_epoch(five_resets_iso)
570
585
  except (TypeError, ValueError) as exc:
571
586
  # 5h is optional - silently degrade rather than fail the command
572
587
  # (parity with the previous cmd_refresh_usage behavior; the eprint
@@ -599,7 +614,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
599
614
  # (tests/test_refresh_usage_cmd.py:55, test_refresh_usage_inproc.py:18).
600
615
  cache_state = c._bust_statusline_cache()
601
616
 
602
- fetched_at = c.now_utc_iso()
617
+ fetched_at = now_utc_iso()
603
618
  fresh_envelope = {
604
619
  "label": "fresh",
605
620
  "captured_at": fetched_at,
@@ -652,7 +667,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
652
667
  # Emit BEFORE rendering so stderr flushes consistently for harnesses
653
668
  # that grep across both streams.
654
669
  for warning in result.warnings:
655
- c.eprint(f"refresh-usage: {warning}")
670
+ eprint(f"refresh-usage: {warning}")
656
671
  payload = result.payload or {}
657
672
  if getattr(args, "json", False):
658
673
  print(_serialize_refresh_usage_json(payload))
@@ -668,7 +683,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
668
683
  args, RefreshUsageRateLimitError(result.reason or "rate limited"))
669
684
 
670
685
  if result.status == "no_oauth_token":
671
- c.eprint("refresh-usage: no OAuth token found "
686
+ eprint("refresh-usage: no OAuth token found "
672
687
  "(run 'claude' once to authenticate)")
673
688
  return 2
674
689
 
@@ -678,9 +693,9 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
678
693
  # config-error reason string verbatim so we can detect it here.
679
694
  reason = result.reason or ""
680
695
  if reason.startswith("invalid oauth_usage config:"):
681
- c.eprint(f"cctally: {reason}")
696
+ eprint(f"cctally: {reason}")
682
697
  return 2
683
- c.eprint(f"refresh-usage: OAuth fetch failed: {reason}")
698
+ eprint(f"refresh-usage: OAuth fetch failed: {reason}")
684
699
  return 3
685
700
 
686
701
  if result.status == "parse_failed":
@@ -688,7 +703,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
688
703
  # only seven_day failures (RefreshUsageMalformedError or unparseable
689
704
  # field extraction) propagate here. Preserve the original exact
690
705
  # error-line shape for bash harnesses that grep stderr.
691
- c.eprint(f"refresh-usage: {result.reason or ''}")
706
+ eprint(f"refresh-usage: {result.reason or ''}")
692
707
  return 4
693
708
 
694
709
  if result.status == "record_failed":
@@ -698,13 +713,13 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
698
713
  rc = int(reason.split()[1])
699
714
  except (IndexError, ValueError):
700
715
  rc = -1
701
- c.eprint(f"refresh-usage: failed to record usage (exit {rc})")
716
+ eprint(f"refresh-usage: failed to record usage (exit {rc})")
702
717
  else:
703
- c.eprint(f"refresh-usage: failed to record usage: {reason}")
718
+ eprint(f"refresh-usage: failed to record usage: {reason}")
704
719
  return 5
705
720
 
706
721
  # Defensive: unknown status from _refresh_usage_inproc -> treat as parse error.
707
- c.eprint(f"refresh-usage: unexpected status {result.status!r}")
722
+ eprint(f"refresh-usage: unexpected status {result.status!r}")
708
723
  return 4
709
724
 
710
725
 
@@ -757,7 +772,7 @@ def _hook_tick_oauth_refresh(
757
772
  seven = api["seven_day"]
758
773
  try:
759
774
  seven_pct = c._normalize_percent(float(seven["utilization"]))
760
- seven_resets_epoch = c._iso_to_epoch(seven["resets_at"])
775
+ seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
761
776
  except (TypeError, ValueError, KeyError):
762
777
  return "err(parse)", None
763
778
  five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
@@ -766,7 +781,7 @@ def _hook_tick_oauth_refresh(
766
781
  if five is not None and "utilization" in five and "resets_at" in five:
767
782
  try:
768
783
  five_pct = c._normalize_percent(float(five["utilization"]))
769
- five_resets_epoch = c._iso_to_epoch(five["resets_at"])
784
+ five_resets_epoch = _iso_to_epoch(five["resets_at"])
770
785
  except (TypeError, ValueError):
771
786
  five_pct = None
772
787
  five_resets_epoch = None
@@ -63,6 +63,18 @@ def _cctally():
63
63
  return sys.modules["cctally"]
64
64
 
65
65
 
66
+ # === Honest imports from extracted homes ===================================
67
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
68
+ # import from _cctally_core. Path constants (`APP_DIR`,
69
+ # `CLAUDE_SETTINGS_PATH`, `HOOK_TICK_LOG_PATH`) plus the extensive
70
+ # out-of-scope setup-specific helpers (legacy migration, hook surgery,
71
+ # OAuth token, sync_cache, …) stay on the _cctally() accessor.
72
+ from _cctally_core import (
73
+ eprint,
74
+ _command_as_of,
75
+ )
76
+
77
+
66
78
  # ── settings.json hook surgery ─────────────────────────────────────────
67
79
 
68
80
 
@@ -430,7 +442,7 @@ def _legacy_resolve_backup_dir() -> pathlib.Path:
430
442
  so a re-run never overwrites a prior migration's snapshot.
431
443
  """
432
444
  c = _cctally()
433
- now = c._command_as_of()
445
+ now = _command_as_of()
434
446
  stamp = now.strftime("%Y%m%d-%H%M%S")
435
447
  base = pathlib.Path.home() / ".claude" / f"{c._LEGACY_BACKUP_DIR_PREFIX}{stamp}"
436
448
  base.mkdir(parents=True, exist_ok=True)
@@ -614,7 +626,6 @@ def _setup_read_legacy_prompt_input(stream, reprompt: str | None = None) -> bool
614
626
  us). When None (test default), no reprompt is emitted — useful for unit
615
627
  tests that drive `stream` from io.StringIO.
616
628
  """
617
- eprint = _cctally().eprint
618
629
  yes_words = {"y", "yes"}
619
630
  no_words = {"n", "no"}
620
631
  for attempt in range(3):
@@ -815,7 +826,7 @@ def _setup_status(args: argparse.Namespace) -> int:
815
826
  try:
816
827
  settings = c._load_claude_settings()
817
828
  except c.SetupError as exc:
818
- c.eprint(f"setup: warning: {exc}")
829
+ eprint(f"setup: warning: {exc}")
819
830
  settings = {}
820
831
  hook_counts = _setup_count_hook_entries(settings)
821
832
  oauth = _setup_oauth_token_present()
@@ -911,14 +922,14 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
911
922
  try:
912
923
  settings = c._load_claude_settings()
913
924
  except c.SetupError as exc:
914
- c.eprint(f"setup: {exc}")
925
+ eprint(f"setup: {exc}")
915
926
  return 1
916
927
  settings, removed = _settings_merge_uninstall(settings)
917
928
  if removed:
918
929
  try:
919
930
  c._write_claude_settings_atomic(settings)
920
931
  except OSError as exc:
921
- c.eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
932
+ eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
922
933
  return 2
923
934
  out.append(f"Removed {removed} hook entries from {c.CLAUDE_SETTINGS_PATH}")
924
935
 
@@ -938,7 +949,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
938
949
  dst.unlink()
939
950
  sym_removed += 1
940
951
  except OSError as exc:
941
- c.eprint(f"setup: failed to remove {dst}: {exc}")
952
+ eprint(f"setup: failed to remove {dst}: {exc}")
942
953
  out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
943
954
 
944
955
  legacy = _setup_detect_legacy_snippet()
@@ -1000,7 +1011,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1000
1011
  "exit_code": 1,
1001
1012
  }, indent=2))
1002
1013
  else:
1003
- c.eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
1014
+ eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
1004
1015
  return 1
1005
1016
  else:
1006
1017
  out.append(
@@ -1039,7 +1050,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1039
1050
  # against an empty dict simply yields detected=False for entries (files
1040
1051
  # detection is independent of settings). Mirror _setup_status's pattern
1041
1052
  # so the user sees the same condition that would fail _setup_install.
1042
- c.eprint(f"setup: warning: {exc}")
1053
+ eprint(f"setup: warning: {exc}")
1043
1054
  settings = {}
1044
1055
  detection = _setup_detect_legacy_bespoke_hooks(settings)
1045
1056
  sym_results = []
@@ -1238,7 +1249,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1238
1249
 
1239
1250
  claude_dir = pathlib.Path.home() / ".claude"
1240
1251
  if not claude_dir.exists():
1241
- c.eprint(
1252
+ eprint(
1242
1253
  f"~/.claude/ does not exist. If Claude Code isn't installed yet, "
1243
1254
  f"install it first. If it is installed, run `claude` once to "
1244
1255
  f"initialize, then re-run cctally setup."
@@ -1259,7 +1270,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1259
1270
  try:
1260
1271
  settings = c._load_claude_settings()
1261
1272
  except c.SetupError as exc:
1262
- c.eprint(f"setup: {exc}")
1273
+ eprint(f"setup: {exc}")
1263
1274
  return 1
1264
1275
 
1265
1276
  # ── Legacy bespoke hook detection + migration decision (spec §1, §2) ──
@@ -1304,7 +1315,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1304
1315
  # still propagates into this code path post-extraction (§5.6 option C).
1305
1316
  backup_dir = c._legacy_resolve_backup_dir()
1306
1317
  except OSError as exc:
1307
- c.eprint(f"setup: cannot create migration backup dir: {exc}")
1318
+ eprint(f"setup: cannot create migration backup dir: {exc}")
1308
1319
  return 1
1309
1320
  # Unwire BEFORE the merge so the same atomic write removes legacy
1310
1321
  # entries and adds cctally entries (spec §2 step 6).
@@ -1322,14 +1333,14 @@ def _setup_install(args: argparse.Namespace) -> int:
1322
1333
  try:
1323
1334
  _settings_merge_install(settings, abs_path)
1324
1335
  except c.SetupError as exc:
1325
- c.eprint(f"setup: {exc}")
1336
+ eprint(f"setup: {exc}")
1326
1337
  return 1
1327
1338
 
1328
1339
  sym_results = _setup_create_symlinks(repo_root, dst_dir)
1329
1340
  failed = [r for r in sym_results if r.status == "failed"]
1330
1341
  if failed:
1331
1342
  for r in failed:
1332
- c.eprint(f"setup: symlink {r.name} failed: {r.detail}")
1343
+ eprint(f"setup: symlink {r.name} failed: {r.detail}")
1333
1344
  return 1
1334
1345
  new_count = sum(1 for r in sym_results if r.status == "created")
1335
1346
  same_count = sum(1 for r in sym_results if r.status == "already")
@@ -1356,7 +1367,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1356
1367
  try:
1357
1368
  c._write_claude_settings_atomic(settings)
1358
1369
  except OSError as exc:
1359
- c.eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1370
+ eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1360
1371
  return 2
1361
1372
 
1362
1373
  # ── Post-write migration apply (spec §2 steps 6a, 6b) ──
@@ -1548,7 +1559,6 @@ def _setup_install(args: argparse.Namespace) -> int:
1548
1559
 
1549
1560
 
1550
1561
  def cmd_setup(args: argparse.Namespace) -> int:
1551
- c = _cctally()
1552
1562
  # Migration flags are install-mode-only. Reject combinations with
1553
1563
  # --status or --uninstall (per spec Section 2 mode×flag matrix). The
1554
1564
  # mutex group on the parser already prevents both flags being set
@@ -1560,7 +1570,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
1560
1570
  else None
1561
1571
  )
1562
1572
  if mig_flag and (getattr(args, "status", False) or getattr(args, "uninstall", False)):
1563
- c.eprint(f"setup: {mig_flag} is install-mode only")
1573
+ eprint(f"setup: {mig_flag} is install-mode only")
1564
1574
  return 2
1565
1575
  if getattr(args, "uninstall", False):
1566
1576
  return _setup_uninstall(args)
@@ -30,12 +30,27 @@ def _cctally():
30
30
  return sys.modules["cctally"]
31
31
 
32
32
 
33
+ # === Honest imports from extracted homes ===================================
34
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
35
+ # import from _cctally_core. `load_config` stays on the _cctally()
36
+ # accessor per spec §3.5 monkeypatch carve-out. Z-high helpers
37
+ # (`compute_week_cost`, `insert_cost_snapshot`) and out-of-scope
38
+ # (`pick_week_selection`) stay on the accessor per spec §3.7.
39
+ from _cctally_core import (
40
+ get_week_start_name,
41
+ open_db,
42
+ format_local_iso,
43
+ make_week_ref,
44
+ get_latest_usage_for_week,
45
+ )
46
+
47
+
33
48
  def cmd_sync_week(args: argparse.Namespace) -> int:
34
49
  c = _cctally()
35
50
  config = c.load_config()
36
- week_start_name = c.get_week_start_name(config, args.week_start_name)
51
+ week_start_name = get_week_start_name(config, args.week_start_name)
37
52
 
38
- conn = c.open_db()
53
+ conn = open_db()
39
54
  try:
40
55
  selection = c.pick_week_selection(
41
56
  conn,
@@ -54,8 +69,8 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
54
69
  start_iso_override=selection.start_iso_override,
55
70
  end_iso_override=selection.end_iso_override,
56
71
  )
57
- week_start_at = selection.start_iso_override or c.format_local_iso(week_start, end_of_day=False)
58
- week_end_at = selection.end_iso_override or c.format_local_iso(week_end, end_of_day=True)
72
+ week_start_at = selection.start_iso_override or format_local_iso(week_start, end_of_day=False)
73
+ week_end_at = selection.end_iso_override or format_local_iso(week_end, end_of_day=True)
59
74
  insert_id = c.insert_cost_snapshot(
60
75
  conn,
61
76
  week_start=week_start,
@@ -69,13 +84,13 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
69
84
  project=args.project,
70
85
  )
71
86
 
72
- week_ref = c.make_week_ref(
87
+ week_ref = make_week_ref(
73
88
  week_start_date=week_start.isoformat(),
74
89
  week_end_date=week_end.isoformat(),
75
90
  week_start_at=week_start_at,
76
91
  week_end_at=week_end_at,
77
92
  )
78
- usage_row = c.get_latest_usage_for_week(conn, week_ref)
93
+ usage_row = get_latest_usage_for_week(conn, week_ref)
79
94
  weekly_percent = float(usage_row["weekly_percent"]) if usage_row else None
80
95
  dollars_per_percent = (
81
96
  result.cost_usd / weekly_percent if weekly_percent and weekly_percent > 0 else None