cctally 1.11.0 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.11.1] - 2026-05-22
9
+
10
+ ### Changed
11
+ - Promote 23 path globals (`APP_DIR`, `LEGACY_APP_DIR`, `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `CACHE_LOCK_PATH`, `CACHE_LOCK_CODEX_PATH`, `CONFIG_PATH`, `CONFIG_LOCK_PATH`, `MIGRATION_ERROR_LOG_PATH`, `CHANGELOG_PATH`, all five `HOOK_TICK_*` paths, all six `UPDATE_*` paths, and `CLAUDE_SETTINGS_PATH`) from `bin/cctally` into `bin/_cctally_core.py`; `_cctally_core` is now the single source of truth and the single legal monkeypatch target for these names. `bin/cctally` keeps eager re-exports so external `cctally.X` references (ad-hoc REPL, scripts) still resolve, but internal `bin/cctally` reads (~28 sites) and every `bin/_*.py` sibling read (149 sites across 11 siblings) now go through `_cctally_core.X` at call time. `tests/conftest.py:redirect_paths()` patches `_cctally_core` directly (with an `ns["X"]` introspection mirror); the 80 historical `monkeypatch.setitem(ns, "<NAME>", v)` / `monkeypatch.setattr(cctally, "<NAME>", v)` test sites are migrated to `monkeypatch.setattr(_cctally_core, "<NAME>", v)`. `bin/_cctally_alerts.py` drops its `_cctally()` accessor helper (its only data-global use was `LOG_DIR`); other siblings keep the helper for legitimate non-data deps (validators, threshold constants, `PUBLIC_REPO`, `RELEASE_HEADER_RE`, etc.). The 2026-05-17 kernel-extraction spec's §2.3 path-constant accessor carve-out is retired. `tests/test_kernel_extraction_invariants.py` gains four new AST regression guards that lock the new invariants going forward (`test_promoted_globals_live_in_core`, `test_no_sibling_accessor_reads_promoted_globals`, `test_no_old_style_test_patches_for_promoted_globals`, `test_no_value_imports_of_promoted_globals_in_siblings`). (#84)
12
+
8
13
  ## [1.11.0] - 2026-05-22
9
14
 
10
15
  ### Added
@@ -24,11 +24,11 @@ in `bin/_lib_alerts_payload.py` (Phase A extraction); this module
24
24
  imports them directly via `_load_lib`, which keeps the dispatch path
25
25
  free of an extra bounce through cctally's re-exports.
26
26
 
27
- bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same
28
- as `bin/_cctally_setup.py`):
29
- - `LOG_DIR` base dir under which `alerts.log` lives (subject to
30
- HOME-redirection by test fixtures via `monkeypatch.setitem(ns,
31
- "LOG_DIR", ...)`).
27
+ Kernel reads from `bin/_cctally_core` (call-time module-attribute access):
28
+ - `LOG_DIR` — base dir under which `alerts.log` lives. Promoted to
29
+ `_cctally_core` 2026-05-22 (#84); test fixtures redirect via
30
+ `monkeypatch.setattr(_cctally_core, "LOG_DIR", tmp)` (or the
31
+ conftest `redirect_paths()` helper).
32
32
  - `now_utc_iso` — single timestamp source used for both the log-line
33
33
  timestamp and the synthetic test payload's `crossed_at_utc`.
34
34
 
@@ -50,10 +50,7 @@ import pathlib
50
50
  import subprocess
51
51
  import sys
52
52
 
53
-
54
- def _cctally():
55
- """Resolve the current `cctally` module at call-time (spec §5.5)."""
56
- return sys.modules["cctally"]
53
+ import _cctally_core
57
54
 
58
55
 
59
56
  def _load_lib(name: str):
@@ -77,22 +74,21 @@ _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_h
77
74
 
78
75
 
79
76
  # === Honest imports from extracted homes ===================================
80
- # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
81
- # import from _cctally_core. `LOG_DIR` stays on the _cctally() accessor
82
- # per Q1=B (path constants propagate via monkeypatch.setitem against the
83
- # cctally namespace).
77
+ # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
78
+ # LOG_DIR was promoted to _cctally_core 2026-05-22 (#84) and is read
79
+ # via call-time module-attribute access (this sibling no longer needs
80
+ # the historical _cctally() accessor).
84
81
  from _cctally_core import now_utc_iso
85
82
 
86
83
 
87
84
  def _alerts_log_path() -> "pathlib.Path":
88
85
  """Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
89
86
 
90
- Resolves through the same ``APP_DIR`` / ``LOG_DIR`` derived at module
91
- import time from ``Path.home()``, so a HOME override before import (the
92
- pattern used elsewhere in this codebase — e.g. ``cctally-config-test``)
93
- transparently relocates the log without a separate env-var convention.
87
+ Reads ``LOG_DIR`` from ``_cctally_core`` at call time. Tests patch via
88
+ ``monkeypatch.setattr(_cctally_core, "LOG_DIR", tmp)`` (or the
89
+ conftest ``redirect_paths()`` helper).
94
90
  """
95
- log_dir = _cctally().LOG_DIR
91
+ log_dir = _cctally_core.LOG_DIR
96
92
  log_dir.mkdir(parents=True, exist_ok=True)
97
93
  return log_dir / "alerts.log"
98
94
 
@@ -41,21 +41,18 @@ Holds:
41
41
  - ``cmd_cache_sync`` — entry point for ``cctally cache-sync
42
42
  [--source {claude,codex,all}] [--rebuild]``.
43
43
 
44
- What stays in bin/cctally:
44
+ What lives in bin/_cctally_core (promoted 2026-05-22, #84):
45
45
  - Path constants ``APP_DIR``, ``CACHE_DB_PATH``, ``CACHE_LOCK_PATH``,
46
- ``CACHE_LOCK_CODEX_PATH``, ``CODEX_SESSIONS_DIR`` referenced from
47
- the moved bodies via the ``c = _cctally()`` call-time accessor
48
- pattern (spec §5.5, same as ``bin/_lib_subscription_weeks.py`` and
49
- ``bin/_lib_aggregators.py``). The accessor resolves
50
- ``sys.modules['cctally'].X`` on every call, so
51
- ``monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)`` and conftest
52
- ``redirect_paths`` HOME redirects propagate transparently with NO
53
- test-side changes (tests already patch ``ns["CACHE_DB_PATH"]`` etc.
54
- by setitem on the dict-as-module bridge). We chose ``c.X`` over the
55
- ``_cctally_db.py``-style seed block here because cache tests are
56
- widely scattered (record-usage tick, dashboard panels, share render
57
- kernel, block tests, every JSONL-reading subcommand fixture) and
58
- Phase C-style inline patching would touch dozens of sites.
46
+ ``CACHE_LOCK_CODEX_PATH``. Moved bodies read these via call-time
47
+ ``_cctally_core.X`` and tests patch via
48
+ ``monkeypatch.setattr(_cctally_core, "X", v)`` (or the conftest
49
+ ``redirect_paths()`` helper). The legacy
50
+ ``setitem(ns, "CACHE_DB_PATH", …)`` pattern is forbidden by
51
+ ``test_no_old_style_test_patches_for_promoted_globals``.
52
+
53
+ What stays in bin/cctally:
54
+ - ``CODEX_SESSIONS_DIR`` out of scope for #84; still read via the
55
+ ``c = _cctally()`` call-time accessor (spec §5.5).
59
56
  - ``_sum_cost_for_range`` — sits at the cache↔report boundary; 6+
60
57
  callers outside cache (forecast, weekly, report, project, doctor),
61
58
  so the directive keeps it on the bin/cctally side.
@@ -122,6 +119,7 @@ def _cctally():
122
119
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
123
120
  # (Z-leaf + Z-mid) import from _cctally_core. The legacy shim function
124
121
  # for ``eprint`` is deleted.
122
+ import _cctally_core
125
123
  from _cctally_core import eprint
126
124
 
127
125
 
@@ -175,11 +173,12 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
175
173
 
176
174
 
177
175
  # === BEGIN MOVED REGIONS ===
178
- # Path constants (APP_DIR, CACHE_DB_PATH, CACHE_LOCK_PATH,
179
- # CACHE_LOCK_CODEX_PATH, CODEX_SESSIONS_DIR) are accessed via the
180
- # `c = _cctally()` call-time accessor inside each function that
181
- # needs them — so ``monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)``
182
- # in tests resolves on every read (no stale module-level binding).
176
+ # Path constants APP_DIR / CACHE_DB_PATH / CACHE_LOCK_PATH /
177
+ # CACHE_LOCK_CODEX_PATH live in _cctally_core (promoted 2026-05-22, #84);
178
+ # moved bodies read them via call-time ``_cctally_core.X`` and tests
179
+ # patch via ``monkeypatch.setattr(_cctally_core, "X", v)``.
180
+ # CODEX_SESSIONS_DIR stays in bin/cctally (out of scope for #84) and is
181
+ # still accessed via the ``c = _cctally()`` call-time accessor.
183
182
 
184
183
  # === Region 1: ProjectKey + _resolve_project_key (was bin/cctally:1994-2069) ===
185
184
 
@@ -422,10 +421,10 @@ def sync_cache(
422
421
  """
423
422
  stats = IngestStats()
424
423
  c = _cctally()
425
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
426
- c.CACHE_LOCK_PATH.touch()
424
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
425
+ _cctally_core.CACHE_LOCK_PATH.touch()
427
426
 
428
- lock_fh = open(c.CACHE_LOCK_PATH, "w")
427
+ lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
429
428
  try:
430
429
  try:
431
430
  fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -914,10 +913,10 @@ def sync_codex_cache(
914
913
  """
915
914
  stats = CodexIngestStats()
916
915
  c = _cctally()
917
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
918
- c.CACHE_LOCK_CODEX_PATH.touch()
916
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
917
+ _cctally_core.CACHE_LOCK_CODEX_PATH.touch()
919
918
 
920
- lock_fh = open(c.CACHE_LOCK_CODEX_PATH, "w")
919
+ lock_fh = open(_cctally_core.CACHE_LOCK_CODEX_PATH, "w")
921
920
  try:
922
921
  try:
923
922
  fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -1263,17 +1262,17 @@ def open_cache_db() -> sqlite3.Connection:
1263
1262
  recreated — the cache is fully re-derivable from JSONL, so this is safe.
1264
1263
  """
1265
1264
  c = _cctally()
1266
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
1265
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
1267
1266
  try:
1268
- conn = sqlite3.connect(c.CACHE_DB_PATH)
1267
+ conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
1269
1268
  conn.execute("SELECT 1").fetchone()
1270
1269
  except sqlite3.DatabaseError as exc:
1271
1270
  eprint(f"[cache] corrupt cache DB ({exc}); recreating")
1272
1271
  try:
1273
- c.CACHE_DB_PATH.unlink()
1272
+ _cctally_core.CACHE_DB_PATH.unlink()
1274
1273
  except FileNotFoundError:
1275
1274
  pass
1276
- conn = sqlite3.connect(c.CACHE_DB_PATH)
1275
+ conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
1277
1276
 
1278
1277
  conn.execute("PRAGMA journal_mode=WAL")
1279
1278
  conn.execute("PRAGMA busy_timeout=5000")
@@ -9,25 +9,31 @@ predicate, `sync-week`, …) all resolve unchanged. Tests that mock
9
9
  work because Python's bare-name lookup inside non-extracted bin/cctally
10
10
  callers resolves in bin/cctally's namespace (where the re-export lives).
11
11
 
12
+ What lives in bin/_cctally_core (promoted 2026-05-22, #84):
13
+ - ``CONFIG_PATH`` / ``CONFIG_LOCK_PATH`` path constants. Reads use
14
+ call-time ``_cctally_core.CONFIG_PATH`` / ``_cctally_core.CONFIG_LOCK_PATH``;
15
+ tests patch via ``monkeypatch.setattr(_cctally_core, "X", v)`` (or
16
+ the conftest ``redirect_paths()`` helper). The legacy
17
+ ``setitem(ns, "CONFIG_PATH", …)`` pattern is forbidden by
18
+ ``test_no_old_style_test_patches_for_promoted_globals``.
19
+
12
20
  What stays in bin/cctally:
13
21
  - ``_ALERTS_BAD_CONFIG_WARNED`` + ``_warn_alerts_bad_config_once`` —
14
22
  alerts-coupled warn-once flag/helper; the alerts-config readers
15
23
  (``_get_alerts_config`` / ``_AlertsConfigError``) still live in
16
24
  bin/cctally and these two travel with that block.
17
- - ``CONFIG_PATH`` / ``CONFIG_LOCK_PATH`` path constants (spec §86–92
18
- keeps every path constant in bin/cctally so monkeypatched
19
- `cctally.CONFIG_PATH = …` redirects propagate everywhere).
20
25
  - ``eprint`` / ``ensure_dirs`` / ``DEFAULT_WEEK_START`` ubiquitous
21
26
  helpers/constants.
22
- - All validator/normalizer primitives (``normalize_display_tz_value``,
23
- ``_get_alerts_config``, ``_AlertsConfigError``,
24
- ``_normalize_alerts_enabled_value``, ``_validate_dashboard_bind_value``,
27
+ - Non-path validator/normalizer primitives
28
+ (``_normalize_alerts_enabled_value``, ``_validate_dashboard_bind_value``,
25
29
  ``_normalize_update_check_enabled_value``,
26
30
  ``_validate_update_check_ttl_hours_value``,
27
31
  ``UPDATE_DEFAULT_TTL_HOURS``, ``get_display_tz_pref``) — these stay
28
32
  near the subsystem they belong to; we reach them via the
29
33
  ``_cctally()`` accessor (call-time lookup so test monkeypatches on
30
34
  bin/cctally's namespace still propagate, per spec §5.2).
35
+ (``normalize_display_tz_value`` imports directly from
36
+ ``_lib_display_tz`` — see the import block below.)
31
37
 
32
38
  Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
33
39
  """
@@ -49,14 +55,17 @@ def _cctally():
49
55
 
50
56
 
51
57
  # === Honest imports from extracted homes ===================================
52
- # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
53
- # import from _cctally_core; the Bucket-X helper `normalize_display_tz_value`
54
- # imports from `_lib_display_tz`. Path constants (`CONFIG_PATH`,
55
- # `CONFIG_LOCK_PATH`) plus out-of-scope validators
56
- # (`_normalize_alerts_enabled_value`, `_validate_dashboard_bind_value`,
57
- # `_validate_update_check_ttl_hours_value`, `_normalize_update_check_enabled_value`,
58
- # `get_display_tz_pref`, `UPDATE_DEFAULT_TTL_HOURS`) stay on the
59
- # _cctally() accessor.
58
+ # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core; the
59
+ # Bucket-X helper ``normalize_display_tz_value`` imports from
60
+ # ``_lib_display_tz``. Path constants (``CONFIG_PATH``,
61
+ # ``CONFIG_LOCK_PATH``) moved to _cctally_core 2026-05-22 (#84) and are
62
+ # read via call-time ``_cctally_core.CONFIG_PATH`` etc. The out-of-scope
63
+ # non-path validators (``_normalize_alerts_enabled_value``,
64
+ # ``_validate_dashboard_bind_value``,
65
+ # ``_validate_update_check_ttl_hours_value``,
66
+ # ``_normalize_update_check_enabled_value``, ``get_display_tz_pref``,
67
+ # ``UPDATE_DEFAULT_TTL_HOURS``) stay on the _cctally() accessor.
68
+ import _cctally_core
60
69
  from _cctally_core import (
61
70
  eprint,
62
71
  ensure_dirs,
@@ -81,7 +90,7 @@ def _warn_config_corrupt_once(reason: str) -> None:
81
90
  _CONFIG_CORRUPT_WARNED = True
82
91
  c = _cctally()
83
92
  eprint(
84
- f"warning: ignoring corrupt {c.CONFIG_PATH} ({reason}); "
93
+ f"warning: ignoring corrupt {_cctally_core.CONFIG_PATH} ({reason}); "
85
94
  "using in-memory defaults"
86
95
  )
87
96
 
@@ -106,10 +115,10 @@ def _try_read_config() -> "dict[str, Any] | None":
106
115
  config writer lock.
107
116
  """
108
117
  c = _cctally()
109
- if not c.CONFIG_PATH.exists():
118
+ if not _cctally_core.CONFIG_PATH.exists():
110
119
  return None
111
120
  try:
112
- raw = c.CONFIG_PATH.read_text(encoding="utf-8")
121
+ raw = _cctally_core.CONFIG_PATH.read_text(encoding="utf-8")
113
122
  except OSError as exc:
114
123
  _warn_config_corrupt_once(f"read failed: {exc}")
115
124
  return None
@@ -141,8 +150,8 @@ def config_writer_lock():
141
150
  """
142
151
  c = _cctally()
143
152
  ensure_dirs()
144
- c.CONFIG_LOCK_PATH.touch()
145
- fh = open(c.CONFIG_LOCK_PATH, "w")
153
+ _cctally_core.CONFIG_LOCK_PATH.touch()
154
+ fh = open(_cctally_core.CONFIG_LOCK_PATH, "w")
146
155
  try:
147
156
  fcntl.flock(fh, fcntl.LOCK_EX)
148
157
  try:
@@ -177,7 +186,7 @@ def load_config() -> dict[str, Any]:
177
186
  if parsed is not None:
178
187
  return parsed
179
188
 
180
- if c.CONFIG_PATH.exists():
189
+ if _cctally_core.CONFIG_PATH.exists():
181
190
  # Corrupt file: warning already emitted by _try_read_config.
182
191
  # Return in-memory defaults; do NOT persist — a transient
183
192
  # corruption is recoverable by the next legitimate
@@ -227,14 +236,14 @@ def save_config(data: dict[str, Any]) -> None:
227
236
  c = _cctally()
228
237
  ensure_dirs()
229
238
  payload = (json.dumps(data, indent=2) + "\n").encode("utf-8")
230
- tmp = c.CONFIG_PATH.with_name(f"{c.CONFIG_PATH.name}.tmp.{os.getpid()}")
239
+ tmp = _cctally_core.CONFIG_PATH.with_name(f"{_cctally_core.CONFIG_PATH.name}.tmp.{os.getpid()}")
231
240
  fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
232
241
  try:
233
242
  os.write(fd, payload)
234
243
  os.fsync(fd)
235
244
  finally:
236
245
  os.close(fd)
237
- os.replace(str(tmp), str(c.CONFIG_PATH))
246
+ os.replace(str(tmp), str(_cctally_core.CONFIG_PATH))
238
247
 
239
248
 
240
249
  ALLOWED_CONFIG_KEYS = (
@@ -5,14 +5,15 @@ logging (eprint), datetime helpers, week-name/bounds, time-of-day,
5
5
  alerts-config validation, open_db, WeekRef + make_week_ref,
6
6
  get_latest_usage_for_week.
7
7
 
8
- Path constants (APP_DIR, DB_PATH, LOG_DIR) intentionally live in
9
- bin/cctally and are read here via a call-time _cctally() accessor
10
- this is the ONLY accessor use inside core. See
11
- docs/superpowers/specs/2026-05-17-cctally-core-kernel-extraction.md §2.
8
+ Path constants (APP_DIR, DB_PATH, LOG_DIR, etc.) live in this module as
9
+ of 2026-05-22 (issue #84); `_cctally_core` is the single source of truth
10
+ and the only legal monkeypatch target for the 23 promoted globals listed
11
+ below. See docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md.
12
12
  """
13
13
  from __future__ import annotations
14
14
  import datetime as dt
15
15
  import os
16
+ import pathlib
16
17
  import re
17
18
  import sqlite3
18
19
  import sys
@@ -25,6 +26,92 @@ def _cctally():
25
26
  return sys.modules["cctally"]
26
27
 
27
28
 
29
+ # === Path constants ==================================================
30
+ #
31
+ # Promoted from bin/cctally per docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md.
32
+ # After this promotion `_cctally_core` is the single source of truth and
33
+ # the only legal monkeypatch target. `bin/cctally` keeps eager re-exports
34
+ # for ad-hoc REPL / scripts; tests MUST target this module directly.
35
+ #
36
+ # Path-constant initialization is wrapped in `_init_paths_from_env()` so
37
+ # `tests/conftest.py:load_script()` can re-derive them from the current
38
+ # HOME env var without re-importing this module (which would invalidate
39
+ # tests' module-top `import _cctally_core` references). The bare module
40
+ # attributes below are populated by the call to _init_paths_from_env()
41
+ # at import time; subsequent load_script calls invoke it again.
42
+
43
+
44
+ def _init_paths_from_env() -> None:
45
+ """(Re)bind the 23 in-scope path globals from the current process env.
46
+
47
+ 22 of the 23 resolve under ``Path.home()`` (i.e. the ``HOME`` env var).
48
+ The 23rd, ``CHANGELOG_PATH``, resolves from ``CCTALLY_TEST_CHANGELOG_PATH``
49
+ when set, else from ``__file__`` (``<repo>/CHANGELOG.md`` relative to
50
+ this kernel module's location) — independent of ``HOME``. Tests that
51
+ redirect the changelog (e.g. ``tests/test_release_internals.py``) drive
52
+ that override and rely on this re-init.
53
+
54
+ Called once at module import to populate the defaults, then again
55
+ by `tests/conftest.py:load_script()` after each `setenv("HOME", …)`
56
+ or `setenv("CCTALLY_TEST_CHANGELOG_PATH", …)` so the test sees a fresh
57
+ path set without the cost of re-importing `_cctally_core` (which would
58
+ break tests that cached the module object via a top-level
59
+ `import _cctally_core`).
60
+ """
61
+ global APP_DIR, LEGACY_APP_DIR, LOG_DIR
62
+ global DB_PATH, CACHE_DB_PATH
63
+ global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CONFIG_LOCK_PATH
64
+ global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
65
+ global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
66
+ global HOOK_TICK_THROTTLE_PATH, HOOK_TICK_THROTTLE_LOCK_PATH
67
+ global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
68
+ global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
69
+ global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
70
+
71
+ home = pathlib.Path.home()
72
+ APP_DIR = home / ".local" / "share" / "cctally"
73
+ LEGACY_APP_DIR = home / ".local" / "share" / "ccusage-subscription"
74
+ LOG_DIR = APP_DIR / "logs"
75
+
76
+ DB_PATH = APP_DIR / "stats.db"
77
+ CACHE_DB_PATH = APP_DIR / "cache.db"
78
+
79
+ CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
80
+ CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
81
+ CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
82
+
83
+ CONFIG_PATH = APP_DIR / "config.json"
84
+
85
+ MIGRATION_ERROR_LOG_PATH = LOG_DIR / "migration-errors.log"
86
+
87
+ # CHANGELOG_PATH: honor CCTALLY_TEST_CHANGELOG_PATH env override; otherwise
88
+ # resolves to <repo>/CHANGELOG.md based on bin/_cctally_core.py's
89
+ # location (alongside bin/cctally, so the parent chain is the same).
90
+ override = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
91
+ if override:
92
+ CHANGELOG_PATH = pathlib.Path(override)
93
+ else:
94
+ CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
95
+
96
+ HOOK_TICK_LOG_DIR = APP_DIR / "logs"
97
+ HOOK_TICK_LOG_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log"
98
+ HOOK_TICK_LOG_ROTATED_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log.1"
99
+ HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
100
+ HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
101
+
102
+ UPDATE_STATE_PATH = APP_DIR / "update-state.json"
103
+ UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
104
+ UPDATE_LOCK_PATH = APP_DIR / "update.lock"
105
+ UPDATE_LOG_PATH = APP_DIR / "update.log"
106
+ UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
107
+ UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
108
+
109
+ CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
110
+
111
+
112
+ _init_paths_from_env()
113
+
114
+
28
115
  # === Logging =========================================================
29
116
 
30
117
 
@@ -240,9 +327,8 @@ def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[d
240
327
 
241
328
 
242
329
  def ensure_dirs() -> None:
243
- c = _cctally()
244
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
245
- c.LOG_DIR.mkdir(parents=True, exist_ok=True)
330
+ APP_DIR.mkdir(parents=True, exist_ok=True)
331
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
246
332
 
247
333
 
248
334
  # === Alerts validation cluster ======================================
@@ -353,7 +439,7 @@ def open_db() -> sqlite3.Connection:
353
439
  _clear_migration_error_log_entries = c._clear_migration_error_log_entries
354
440
 
355
441
  ensure_dirs()
356
- conn = sqlite3.connect(c.DB_PATH)
442
+ conn = sqlite3.connect(DB_PATH)
357
443
  conn.row_factory = sqlite3.Row
358
444
  conn.execute("PRAGMA journal_mode=WAL")
359
445
  conn.execute("PRAGMA synchronous=NORMAL")
@@ -256,6 +256,7 @@ def _cctally():
256
256
  # import from _cctally_core; already-decentralized buckets (X = _lib_*,
257
257
  # Y = _cctally_*) import from their natural home. These bypass the
258
258
  # legacy shim pattern entirely.
259
+ import _cctally_core
259
260
  from _cctally_core import (
260
261
  eprint,
261
262
  now_utc_iso,
@@ -6548,7 +6549,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6548
6549
  c = _cctally()
6549
6550
  conn.execute(
6550
6551
  "ATTACH DATABASE ? AS cache_db",
6551
- (str(c.CACHE_DB_PATH),),
6552
+ (str(_cctally_core.CACHE_DB_PATH),),
6552
6553
  )
6553
6554
  conn.execute(
6554
6555
  "CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
@@ -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