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.
@@ -34,15 +34,22 @@ Holds:
34
34
  ``_db_path_for_label``).
35
35
 
36
36
  What stays in bin/cctally (reached via the ``_cctally()`` accessor):
37
- - Path constants ``STATS_DB_PATH``, ``CACHE_DB_PATH``,
38
- ``MIGRATION_ERROR_LOG_PATH``, ``LOG_DIR`` (spec §86–92 — every
39
- path constant stays so monkeypatched HOME redirects propagate).
40
37
  - ``open_db`` / ``open_cache_db`` — DB-open primitives that CALL
41
38
  the dispatcher; they're the boundary owners, not internal to the
42
39
  migration system.
43
- - ``now_utc_iso``, ``parse_iso_datetime``, ``_compute_block_totals``,
44
- ``eprint``, ``format_local_iso`` tiny helpers / hot-path entry
45
- points consumed by migration handlers + cmd_db_status renderers.
40
+ - ``_compute_block_totals`` — Z-high callable consumed by migration
41
+ handlers; reached via the back-ref shim at the top of this module.
42
+
43
+ Path constants reached via ``_cctally_core.X`` at call time:
44
+ ``DB_PATH`` / ``CACHE_DB_PATH`` / ``LOG_DIR`` /
45
+ ``MIGRATION_ERROR_LOG_PATH``. After the data-globals promotion
46
+ (2026-05-22, issue #84) ``_cctally_core`` is the single source of
47
+ truth and the only legal monkeypatch target; tests redirecting
48
+ ``HOME`` via ``redirect_paths`` propagate without a sibling-side
49
+ seed block in ``bin/cctally``.
50
+
51
+ Kernel helpers (``now_utc_iso``, ``parse_iso_datetime``, ``eprint``)
52
+ are direct-imported from ``_cctally_core`` per spec §3.3.
46
53
 
47
54
  §5.6 audit: zero monkeypatch sites on any moved symbol — the
48
55
  extraction is pure-mechanical. No Option C call-site rewrites
@@ -75,6 +82,7 @@ def _cctally():
75
82
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
76
83
  # import from _cctally_core. The legacy shim functions for these names
77
84
  # are deleted.
85
+ import _cctally_core
78
86
  from _cctally_core import (
79
87
  eprint,
80
88
  now_utc_iso,
@@ -89,10 +97,12 @@ from _cctally_core import (
89
97
  # _cctally_cache via get_claude_session_entries) and is explicitly listed
90
98
  # in spec §3.7's stays-on-shim allowlist.
91
99
  #
92
- # Path constants and rarer helpers (`MIGRATION_ERROR_LOG_PATH`,
93
- # `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `format_local_iso`) are
94
- # accessed via the standard `c = _cctally()` + `c.X` pattern instead
95
- # (call-time lookup so fixture-HOME redirects propagate).
100
+ # Path constants (`MIGRATION_ERROR_LOG_PATH`, `LOG_DIR`, `DB_PATH`,
101
+ # `CACHE_DB_PATH`) are accessed via `_cctally_core.X` at call time —
102
+ # the canonical sibling pattern after the data-globals promotion
103
+ # (2026-05-22, issue #84). `_cctally_core` is the single source of
104
+ # truth and the only legal monkeypatch target; bin/cctally no longer
105
+ # seeds duplicates into this module's namespace.
96
106
  def _compute_block_totals(*args, **kwargs):
97
107
  return sys.modules["cctally"]._compute_block_totals(*args, **kwargs)
98
108
 
@@ -101,9 +111,11 @@ def _compute_block_totals(*args, **kwargs):
101
111
  # Regions below are inserted verbatim from bin/cctally. Bare-name
102
112
  # references to `now_utc_iso(...)`, `parse_iso_datetime(...)`,
103
113
  # `_compute_block_totals(...)`, and `eprint(...)` resolve to the shims
104
- # above. Path-constant references (`MIGRATION_ERROR_LOG_PATH`,
105
- # `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`) get rewritten to `c.X` form
106
- # with a top-of-function `c = _cctally()` binding inserted.
114
+ # / direct imports above. Path-constant references
115
+ # (`MIGRATION_ERROR_LOG_PATH`, `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`)
116
+ # are read as `_cctally_core.X` at call time (post-#84 canonical
117
+ # sibling pattern; no `c = _cctally()` binding required for path
118
+ # reads, since `_cctally_core` is direct-imported above).
107
119
 
108
120
  # === Region 1: add_column_if_missing (was bin/cctally:8584-8621) ===
109
121
 
@@ -697,12 +709,12 @@ def _log_migration_error(*, name: str, exc: BaseException, tb: str) -> None:
697
709
  # crash). Acceptable per "best effort" design — concurrent migration
698
710
  # failures are vanishingly rare since open_db() serializes via WAL.
699
711
  try:
700
- LOG_DIR.mkdir(parents=True, exist_ok=True)
712
+ _cctally_core.LOG_DIR.mkdir(parents=True, exist_ok=True)
701
713
  ts = now_utc_iso()
702
714
  one_line_err = str(exc).replace("\n", " ").strip() or exc.__class__.__name__
703
715
  indented_tb = "\n".join(" " + line for line in tb.rstrip().splitlines())
704
716
  block = f"[{ts}] {name}\n {one_line_err}\n{indented_tb}\n\n"
705
- with open(MIGRATION_ERROR_LOG_PATH, "a") as fh:
717
+ with open(_cctally_core.MIGRATION_ERROR_LOG_PATH, "a") as fh:
706
718
  fh.write(block)
707
719
  except Exception as log_exc:
708
720
  eprint(f"[migration-error-log] failed to write: {log_exc}")
@@ -723,9 +735,9 @@ def _clear_migration_error_log_entries(name: str) -> None:
723
735
  # extra banner cycle. Not worth fcntl.flock complexity for failure-rare
724
736
  # code path.
725
737
  try:
726
- if not MIGRATION_ERROR_LOG_PATH.exists():
738
+ if not _cctally_core.MIGRATION_ERROR_LOG_PATH.exists():
727
739
  return
728
- content = MIGRATION_ERROR_LOG_PATH.read_text()
740
+ content = _cctally_core.MIGRATION_ERROR_LOG_PATH.read_text()
729
741
  # Entries are separated by "\n\n". Each entry's first line is
730
742
  # "[ts] <name>".
731
743
  blocks = [b for b in content.split("\n\n") if b.strip()]
@@ -737,9 +749,9 @@ def _clear_migration_error_log_entries(name: str) -> None:
737
749
  continue
738
750
  kept.append(block)
739
751
  if not kept:
740
- MIGRATION_ERROR_LOG_PATH.unlink()
752
+ _cctally_core.MIGRATION_ERROR_LOG_PATH.unlink()
741
753
  return
742
- MIGRATION_ERROR_LOG_PATH.write_text("\n\n".join(kept) + "\n\n")
754
+ _cctally_core.MIGRATION_ERROR_LOG_PATH.write_text("\n\n".join(kept) + "\n\n")
743
755
  except Exception as exc:
744
756
  eprint(
745
757
  f"[migration-error-log] failed to clear entries for {name}: {exc}"
@@ -753,10 +765,10 @@ def _render_migration_error_banner() -> str | None:
753
765
  Parses the most recent entry's first line for the migration name and
754
766
  timestamp. Falls back to a generic message on parse failure.
755
767
  """
756
- if not MIGRATION_ERROR_LOG_PATH.exists():
768
+ if not _cctally_core.MIGRATION_ERROR_LOG_PATH.exists():
757
769
  return None
758
770
  try:
759
- content = MIGRATION_ERROR_LOG_PATH.read_text()
771
+ content = _cctally_core.MIGRATION_ERROR_LOG_PATH.read_text()
760
772
  except Exception:
761
773
  return None
762
774
  if not content.strip():
@@ -774,13 +786,13 @@ def _render_migration_error_banner() -> str | None:
774
786
  if ts and name:
775
787
  return (
776
788
  f"⚠ cctally: migration `{name}` failed at {ts}. "
777
- f"See {MIGRATION_ERROR_LOG_PATH}"
789
+ f"See {_cctally_core.MIGRATION_ERROR_LOG_PATH}"
778
790
  )
779
791
  except Exception:
780
792
  pass
781
793
  return (
782
794
  f"⚠ cctally: migration error logged. "
783
- f"See {MIGRATION_ERROR_LOG_PATH}"
795
+ f"See {_cctally_core.MIGRATION_ERROR_LOG_PATH}"
784
796
  )
785
797
 
786
798
 
@@ -1737,8 +1749,8 @@ def cmd_db_status(args: argparse.Namespace) -> int:
1737
1749
  payload = {
1738
1750
  "schema_version": 1,
1739
1751
  "databases": {
1740
- "stats.db": _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db"),
1741
- "cache.db": _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"),
1752
+ "stats.db": _db_status_for(_cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db"),
1753
+ "cache.db": _db_status_for(_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"),
1742
1754
  },
1743
1755
  }
1744
1756
  if getattr(args, "json", False):
@@ -1828,7 +1840,7 @@ def _db_status_for(
1828
1840
  "seq": m.seq, "name": m.name,
1829
1841
  "status": "failed",
1830
1842
  "last_failure_at": failed_names[m.name],
1831
- "log_path": str(MIGRATION_ERROR_LOG_PATH),
1843
+ "log_path": str(_cctally_core.MIGRATION_ERROR_LOG_PATH),
1832
1844
  })
1833
1845
  else:
1834
1846
  migrations_out.append({
@@ -1852,11 +1864,11 @@ def _db_status_failed_names_from_log(db_label: str) -> dict[str, str]:
1852
1864
  `merge_5h_block_duplicates_v1` are bootstrap-renamed at next open
1853
1865
  (via Task 4) so they don't accumulate post-PR.
1854
1866
  """
1855
- if not MIGRATION_ERROR_LOG_PATH.exists():
1867
+ if not _cctally_core.MIGRATION_ERROR_LOG_PATH.exists():
1856
1868
  return {}
1857
1869
  out: dict[str, str] = {}
1858
1870
  try:
1859
- content = MIGRATION_ERROR_LOG_PATH.read_text()
1871
+ content = _cctally_core.MIGRATION_ERROR_LOG_PATH.read_text()
1860
1872
  except Exception:
1861
1873
  return {}
1862
1874
  blocks = [b for b in content.split("\n\n") if b.strip()]
@@ -1929,9 +1941,9 @@ def _db_resolve_migration_name(name_arg: str) -> tuple[str, str, list[Migration]
1929
1941
 
1930
1942
  def _db_path_for_label(db_label: str) -> pathlib.Path:
1931
1943
  if db_label == "stats.db":
1932
- return DB_PATH
1944
+ return _cctally_core.DB_PATH
1933
1945
  if db_label == "cache.db":
1934
- return CACHE_DB_PATH
1946
+ return _cctally_core.CACHE_DB_PATH
1935
1947
  raise ValueError(f"unknown db_label: {db_label}")
1936
1948
 
1937
1949
 
@@ -158,6 +158,7 @@ def _cctally():
158
158
 
159
159
  # === Honest imports from extracted homes ===================================
160
160
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
161
+ import _cctally_core
161
162
  from _cctally_core import (
162
163
  eprint,
163
164
  now_utc_iso,
@@ -312,18 +313,17 @@ _logged_window_key_coerce_failure = False
312
313
 
313
314
 
314
315
  # === BEGIN MOVED REGIONS ===
315
- # Path constants (APP_DIR, HOOK_TICK_*) are accessed via the
316
- # `c = _cctally()` call-time accessor inside each function that needs
317
- # them so ``monkeypatch.setitem(ns, "APP_DIR", tmp)`` in tests
318
- # resolves on every read (no stale module-level binding).
316
+ # Path constants (APP_DIR, HOOK_TICK_*) moved to _cctally_core
317
+ # 2026-05-22 (#84). Reads use call-time ``_cctally_core.X``; tests
318
+ # patch via ``monkeypatch.setattr(_cctally_core, "X", v)``.
319
319
  #
320
- # Constants pulled from cctally at call time:
320
+ # Constants pulled at call time:
321
+ # _cctally_core.APP_DIR
322
+ # _cctally_core.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
323
+ # _cctally_core.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
321
324
  # c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
322
325
  # c._RESET_PCT_DROP_THRESHOLD — bin/cctally module-level constant
323
- # c.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
324
- # c.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
325
326
  # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
326
- # c.APP_DIR
327
327
 
328
328
 
329
329
  def _normalize_percent(value: "float | int | None") -> "float | None":
@@ -1521,7 +1521,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1521
1521
  # record-usage reader doesn't see the new HWM
1522
1522
  # before the event row is durable.
1523
1523
  try:
1524
- (c.APP_DIR / "hwm-7d").write_text(
1524
+ (_cctally_core.APP_DIR / "hwm-7d").write_text(
1525
1525
  f"{week_start_date} {weekly_percent}\n"
1526
1526
  )
1527
1527
  except OSError:
@@ -1731,7 +1731,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1731
1731
  # matches the canonical writer:
1732
1732
  # ``<key> <percent>\n``.
1733
1733
  try:
1734
- (c.APP_DIR / "hwm-5h").write_text(
1734
+ (_cctally_core.APP_DIR / "hwm-5h").write_text(
1735
1735
  f"{int(five_hour_window_key)} "
1736
1736
  f"{float(five_hour_percent)}\n"
1737
1737
  )
@@ -2096,7 +2096,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2096
2096
  # Write high-water mark so the status line never displays a regression.
2097
2097
  # The file contains "week_start_date weekly_percent" on one line.
2098
2098
  try:
2099
- hwm_path = c.APP_DIR / "hwm-7d"
2099
+ hwm_path = _cctally_core.APP_DIR / "hwm-7d"
2100
2100
  existing_hwm = 0.0
2101
2101
  try:
2102
2102
  parts = hwm_path.read_text().strip().split()
@@ -2119,7 +2119,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2119
2119
  ):
2120
2120
  try:
2121
2121
  five_resets_key = five_hour_window_key
2122
- hwm5_path = c.APP_DIR / "hwm-5h"
2122
+ hwm5_path = _cctally_core.APP_DIR / "hwm-5h"
2123
2123
  existing_hwm5 = 0.0
2124
2124
  try:
2125
2125
  parts5 = hwm5_path.read_text().strip().split()
@@ -2143,8 +2143,8 @@ def _hook_tick_log_line(line: str) -> None:
2143
2143
  """
2144
2144
  c = _cctally()
2145
2145
  try:
2146
- c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2147
- fd = os.open(c.HOOK_TICK_LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
2146
+ _cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2147
+ fd = os.open(_cctally_core.HOOK_TICK_LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
2148
2148
  try:
2149
2149
  os.write(fd, (line.rstrip("\n") + "\n").encode("utf-8", errors="replace"))
2150
2150
  finally:
@@ -2157,7 +2157,7 @@ def _hook_tick_log_rotate_if_needed() -> None:
2157
2157
  """If hook-tick.log exceeds the size cap, atomic-rename to .1 (overwriting)."""
2158
2158
  c = _cctally()
2159
2159
  try:
2160
- size = c.HOOK_TICK_LOG_PATH.stat().st_size
2160
+ size = _cctally_core.HOOK_TICK_LOG_PATH.stat().st_size
2161
2161
  except FileNotFoundError:
2162
2162
  return
2163
2163
  except OSError:
@@ -2165,7 +2165,7 @@ def _hook_tick_log_rotate_if_needed() -> None:
2165
2165
  if size <= c.HOOK_TICK_LOG_ROTATE_BYTES:
2166
2166
  return
2167
2167
  try:
2168
- os.replace(c.HOOK_TICK_LOG_PATH, c.HOOK_TICK_LOG_ROTATED_PATH)
2168
+ os.replace(_cctally_core.HOOK_TICK_LOG_PATH, _cctally_core.HOOK_TICK_LOG_ROTATED_PATH)
2169
2169
  except OSError:
2170
2170
  pass
2171
2171
 
@@ -2174,7 +2174,7 @@ def _hook_tick_throttle_age_seconds() -> float:
2174
2174
  """Return seconds since last successful OAuth fetch; +inf if never."""
2175
2175
  c = _cctally()
2176
2176
  try:
2177
- mtime = c.HOOK_TICK_THROTTLE_PATH.stat().st_mtime
2177
+ mtime = _cctally_core.HOOK_TICK_THROTTLE_PATH.stat().st_mtime
2178
2178
  except FileNotFoundError:
2179
2179
  return float("inf")
2180
2180
  except OSError:
@@ -2186,9 +2186,9 @@ def _hook_tick_throttle_touch() -> None:
2186
2186
  """Update mtime to now (creating the file if missing)."""
2187
2187
  c = _cctally()
2188
2188
  try:
2189
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
2190
- c.HOOK_TICK_THROTTLE_PATH.touch(exist_ok=True)
2191
- os.utime(c.HOOK_TICK_THROTTLE_PATH, None)
2189
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
2190
+ _cctally_core.HOOK_TICK_THROTTLE_PATH.touch(exist_ok=True)
2191
+ os.utime(_cctally_core.HOOK_TICK_THROTTLE_PATH, None)
2192
2192
  except OSError:
2193
2193
  pass
2194
2194
 
@@ -2313,9 +2313,9 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2313
2313
  # immediately after Step 7, so the leak is bounded.
2314
2314
  if not explain:
2315
2315
  try:
2316
- c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2316
+ _cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2317
2317
  log_fd = os.open(
2318
- c.HOOK_TICK_LOG_PATH,
2318
+ _cctally_core.HOOK_TICK_LOG_PATH,
2319
2319
  os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644,
2320
2320
  )
2321
2321
  os.dup2(log_fd, 1) # stdout
@@ -2368,10 +2368,10 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2368
2368
 
2369
2369
  # Throttle check + OAuth (under flock)
2370
2370
  if not no_oauth:
2371
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
2371
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
2372
2372
  try:
2373
2373
  lock_fd = os.open(
2374
- c.HOOK_TICK_THROTTLE_LOCK_PATH,
2374
+ _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH,
2375
2375
  os.O_WRONLY | os.O_CREAT, 0o644,
2376
2376
  )
2377
2377
  except OSError:
@@ -2437,7 +2437,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2437
2437
  print("[1/4] Local sync (sync_cache)")
2438
2438
  print(f" → ingested {max(0, ingested)} new entries")
2439
2439
  print("[2/4] Throttle check")
2440
- print(f" → throttle file: {c.HOOK_TICK_THROTTLE_PATH}")
2440
+ print(f" → throttle file: {_cctally_core.HOOK_TICK_THROTTLE_PATH}")
2441
2441
  if pre_age == float("inf"):
2442
2442
  print(" → mtime: (file absent)")
2443
2443
  else:
@@ -2445,7 +2445,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2445
2445
  print(f" → threshold: {int(throttle_seconds)}s → {decision}")
2446
2446
  print("[3/4] OAuth refresh")
2447
2447
  print(f" → status: {oauth_status}")
2448
- print(f"[4/4] Log written → {c.HOOK_TICK_LOG_PATH}")
2448
+ print(f"[4/4] Log written → {_cctally_core.HOOK_TICK_LOG_PATH}")
2449
2449
  print(f"\nDone in {dur_ms} ms.")
2450
2450
  return rc
2451
2451
 
@@ -64,11 +64,13 @@ def _cctally():
64
64
 
65
65
 
66
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.
67
+ # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core. Path
68
+ # constants (APP_DIR, CLAUDE_SETTINGS_PATH, HOOK_TICK_LOG_PATH, etc.)
69
+ # moved to _cctally_core 2026-05-22 (#84) and are accessed via call-time
70
+ # ``_cctally_core.X``. The setup-specific helpers (legacy migration, hook
71
+ # surgery, OAuth token, sync_cache, …) that live in bin/cctally itself
72
+ # stay on the _cctally() accessor.
73
+ import _cctally_core
72
74
  from _cctally_core import (
73
75
  eprint,
74
76
  _command_as_of,
@@ -863,7 +865,7 @@ def _setup_count_hook_entries(settings: dict) -> dict[str, int]:
863
865
 
864
866
 
865
867
  def _setup_data_dir_size_bytes() -> int:
866
- app_dir = _cctally().APP_DIR
868
+ app_dir = _cctally_core.APP_DIR
867
869
  total = 0
868
870
  if not app_dir.exists():
869
871
  return 0
@@ -891,7 +893,7 @@ def _setup_recent_log_stats(seconds: float = 24 * 3600) -> dict:
891
893
  counts = {"fires": 0, "by_event": {}, "oauth_ok": 0, "throttled": 0,
892
894
  "errors": 0, "last_fire_ago_s": None}
893
895
  last_ts = 0.0
894
- for path in (c.HOOK_TICK_LOG_ROTATED_PATH, c.HOOK_TICK_LOG_PATH):
896
+ for path in (_cctally_core.HOOK_TICK_LOG_ROTATED_PATH, _cctally_core.HOOK_TICK_LOG_PATH):
895
897
  if not path.exists():
896
898
  continue
897
899
  try:
@@ -1041,7 +1043,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1041
1043
  "files": bespoke["files"],
1042
1044
  },
1043
1045
  },
1044
- "data": {"path": str(c.APP_DIR), "size_bytes": data_bytes},
1046
+ "data": {"path": str(_cctally_core.APP_DIR), "size_bytes": data_bytes},
1045
1047
  }
1046
1048
  print(json.dumps(envelope, indent=2))
1047
1049
  return 0
@@ -1057,7 +1059,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1057
1059
  out.append(" run `cctally setup` to remove")
1058
1060
  out.append(f" PATH includes {'yes' if on_path else 'no'} "
1059
1061
  f"{'✓' if on_path else '⚠'}")
1060
- out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
1062
+ out.append(f"Hooks ({_cctally_core.CLAUDE_SETTINGS_PATH})")
1061
1063
  for ev in c.SETUP_HOOK_EVENTS:
1062
1064
  marker = "✓" if hook_counts[ev] >= 1 else "✗"
1063
1065
  word = "installed" if hook_counts[ev] >= 1 else "missing"
@@ -1093,7 +1095,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1093
1095
  )
1094
1096
  out.append(" run `cctally setup --migrate-legacy-hooks` to migrate")
1095
1097
  out.append("Data")
1096
- out.append(f" {c.APP_DIR}/ {_setup_format_bytes(data_bytes)}")
1098
+ out.append(f" {_cctally_core.APP_DIR}/ {_setup_format_bytes(data_bytes)}")
1097
1099
  _setup_emit_text(out)
1098
1100
  return 0
1099
1101
 
@@ -1115,9 +1117,9 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1115
1117
  try:
1116
1118
  c._write_claude_settings_atomic(settings)
1117
1119
  except OSError as exc:
1118
- eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1120
+ eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
1119
1121
  return 2
1120
- out.append(f"Removed {removed} hook entries from {c.CLAUDE_SETTINGS_PATH}")
1122
+ out.append(f"Removed {removed} hook entries from {_cctally_core.CLAUDE_SETTINGS_PATH}")
1121
1123
 
1122
1124
  repo_root = _setup_resolve_repo_root()
1123
1125
  dst_dir = _setup_local_bin_dir()
@@ -1201,7 +1203,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1201
1203
  "hooks_removed": removed,
1202
1204
  "symlinks_removed": sym_removed,
1203
1205
  "purged": False,
1204
- "data_path": str(c.APP_DIR),
1206
+ "data_path": str(_cctally_core.APP_DIR),
1205
1207
  "data_size_bytes": data_bytes,
1206
1208
  "legacy": {
1207
1209
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
@@ -1213,7 +1215,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1213
1215
  try:
1214
1216
  resp = input(
1215
1217
  f"Wipe {_setup_format_bytes(data_bytes)} of usage history at "
1216
- f"{c.APP_DIR}/? [y/N] "
1218
+ f"{_cctally_core.APP_DIR}/? [y/N] "
1217
1219
  )
1218
1220
  except EOFError:
1219
1221
  resp = "n"
@@ -1221,10 +1223,10 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1221
1223
  out.append("Purge declined.")
1222
1224
  _setup_emit_text(out)
1223
1225
  return 3
1224
- if c.APP_DIR.exists():
1226
+ if _cctally_core.APP_DIR.exists():
1225
1227
  try:
1226
- shutil.rmtree(c.APP_DIR)
1227
- out.append(f"Wiped {c.APP_DIR}/")
1228
+ shutil.rmtree(_cctally_core.APP_DIR)
1229
+ out.append(f"Wiped {_cctally_core.APP_DIR}/")
1228
1230
  except OSError as exc:
1229
1231
  if is_json:
1230
1232
  print(json.dumps({
@@ -1233,7 +1235,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1233
1235
  "result": "err",
1234
1236
  "reason": "rmtree_failed",
1235
1237
  "error": str(exc),
1236
- "data_path": str(c.APP_DIR),
1238
+ "data_path": str(_cctally_core.APP_DIR),
1237
1239
  "data_size_bytes": data_bytes,
1238
1240
  "legacy": {
1239
1241
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
@@ -1241,11 +1243,11 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1241
1243
  "exit_code": 1,
1242
1244
  }, indent=2))
1243
1245
  else:
1244
- eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
1246
+ eprint(f"setup: failed to wipe {_cctally_core.APP_DIR}: {exc}")
1245
1247
  return 1
1246
1248
  else:
1247
1249
  out.append(
1248
- f"Note: usage history kept at {c.APP_DIR}/ "
1250
+ f"Note: usage history kept at {_cctally_core.APP_DIR}/ "
1249
1251
  f"({_setup_format_bytes(data_bytes)}). Use --purge to remove."
1250
1252
  )
1251
1253
  if is_json:
@@ -1256,7 +1258,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1256
1258
  "hooks_removed": removed,
1257
1259
  "symlinks_removed": sym_removed,
1258
1260
  "purged": purge,
1259
- "data_path": str(c.APP_DIR),
1261
+ "data_path": str(_cctally_core.APP_DIR),
1260
1262
  "data_size_bytes": data_bytes,
1261
1263
  "legacy": {
1262
1264
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
@@ -1305,7 +1307,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1305
1307
  out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
1306
1308
  out.append(" Remove them manually then re-run.")
1307
1309
 
1308
- out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {c.CLAUDE_SETTINGS_PATH}:")
1310
+ out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
1309
1311
  abs_path = str(_setup_resolve_hook_target(repo_root))
1310
1312
  import shlex
1311
1313
  quoted = shlex.quote(abs_path)
@@ -1391,7 +1393,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1391
1393
  }
1392
1394
  for ev in c.SETUP_HOOK_EVENTS
1393
1395
  ],
1394
- "settings_path": str(c.CLAUDE_SETTINGS_PATH),
1396
+ "settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
1395
1397
  },
1396
1398
  # Sibling parity with `_setup_status` and `_setup_install`
1397
1399
  # JSON envelopes (`legacy.bespoke_hooks` shape). Lets the same
@@ -1614,7 +1616,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1614
1616
  try:
1615
1617
  c._write_claude_settings_atomic(settings)
1616
1618
  except OSError as exc:
1617
- eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1619
+ eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
1618
1620
  return 2
1619
1621
 
1620
1622
  # ── Post-write migration apply (spec §2 steps 6a, 6b) ──
@@ -1674,7 +1676,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1674
1676
  # The "✓ Wrote …" line follows any migrate-summary line so the
1675
1677
  # narrative reads "we did the migration, then wrote the new entries"
1676
1678
  # — matches the spec's success-path sample (Section 2).
1677
- out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {c.CLAUDE_SETTINGS_PATH}")
1679
+ out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}")
1678
1680
 
1679
1681
  if decision == "skip" and reason in {"user_declined", "no_migrate_flag"}:
1680
1682
  files_str = "{record-usage-stop,usage-poller{,-start,-stop}}.py"
@@ -1773,7 +1775,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1773
1775
  },
1774
1776
  "hooks": {
1775
1777
  "events_added": list(c.SETUP_HOOK_EVENTS),
1776
- "settings_path": str(c.CLAUDE_SETTINGS_PATH),
1778
+ "settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
1777
1779
  },
1778
1780
  "auth": {
1779
1781
  "oauth_token_present": oauth,
@@ -204,6 +204,7 @@ def _cctally():
204
204
 
205
205
  # === Honest imports from extracted homes ===================================
206
206
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
207
+ import _cctally_core
207
208
  from _cctally_core import (
208
209
  eprint,
209
210
  parse_iso_datetime,
@@ -1071,6 +1072,16 @@ class DataSnapshot:
1071
1072
  # declares ``ProjectsEnvelope | null`` and the client renders the
1072
1073
  # panel-empty state until the next tick replaces it.
1073
1074
  projects_envelope: dict | None = None
1075
+ # Cache-report panel + modal envelope block (spec
1076
+ # 2026-05-21-cache-report-panel-design.md §4.2). Populated on the
1077
+ # sync thread by ``build_cache_report_snapshot`` alongside the
1078
+ # existing projects build. The dashboard's
1079
+ # ``snapshot_to_envelope`` reads this back unchanged and assigns it
1080
+ # to ``envelope["cache_report"]``. ``None`` on first tick before
1081
+ # sync completes — the TS envelope mirror declares
1082
+ # ``CacheReportEnvelope | null`` and the client renders the
1083
+ # panel-empty state until the next tick replaces it.
1084
+ cache_report: Any | None = None
1074
1085
 
1075
1086
  @classmethod
1076
1087
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -2026,7 +2037,7 @@ def _tui_build_snapshot(
2026
2037
  projects_envelope_block: dict | None = None
2027
2038
  try:
2028
2039
  c = _cctally()
2029
- cache_db_path = c.CACHE_DB_PATH
2040
+ cache_db_path = _cctally_core.CACHE_DB_PATH
2030
2041
  conn.execute(
2031
2042
  "ATTACH DATABASE ? AS cache_db",
2032
2043
  (str(cache_db_path),),
@@ -2113,6 +2124,40 @@ def _tui_build_snapshot(
2113
2124
  sessions = annotated
2114
2125
  except Exception as exc:
2115
2126
  errors.append(f"projects-cross-nav-bind: {exc}")
2127
+
2128
+ # Cache-report panel + modal envelope block (spec
2129
+ # 2026-05-21-cache-report-panel-design.md §5.2). Per-tick build
2130
+ # alongside the projects envelope. Threshold is read from
2131
+ # ``config.json:cache_report.anomaly_threshold_pp`` (default
2132
+ # 15); ``anomaly_window_days`` is hardcoded at 14 in v1.
2133
+ # display_tz inherits the same resolved zone as every other
2134
+ # panel so today-bucketing matches the envelope's ``display``
2135
+ # block. Errors record on ``last_sync_error``; ``None`` lands
2136
+ # on the DataSnapshot field and the client renders the empty
2137
+ # state.
2138
+ cache_report_block = None
2139
+ try:
2140
+ cfg_cr = load_config().get("cache_report") or {}
2141
+ threshold_raw = cfg_cr.get("anomaly_threshold_pp", 15)
2142
+ try:
2143
+ threshold_pp = int(threshold_raw)
2144
+ except (TypeError, ValueError):
2145
+ threshold_pp = 15
2146
+ if threshold_pp < 1 or threshold_pp > 100:
2147
+ threshold_pp = 15
2148
+ _dash_mod = sys.modules["_cctally_dashboard"]
2149
+ _bcr = _dash_mod.build_cache_report_snapshot
2150
+ cache_report_block = _bcr(
2151
+ now_utc=now_utc,
2152
+ anomaly_threshold_pp=threshold_pp,
2153
+ # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
2154
+ anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
2155
+ display_tz=_build_display_tz,
2156
+ skip_sync=skip_sync,
2157
+ )
2158
+ except Exception as exc:
2159
+ errors.append(f"cache-report: {exc}")
2160
+
2116
2161
  return DataSnapshot(
2117
2162
  current_week=cw,
2118
2163
  forecast=fc,
@@ -2141,6 +2186,7 @@ def _tui_build_snapshot(
2141
2186
  trend_history_median_dpp=history_median_dpp,
2142
2187
  forecast_view=fc_view,
2143
2188
  projects_envelope=projects_envelope_block,
2189
+ cache_report=cache_report_block,
2144
2190
  )
2145
2191
  finally:
2146
2192
  conn.close()