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 +5 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +28 -29
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +94 -8
- package/bin/_cctally_dashboard.py +2 -1
- package/bin/_cctally_db.py +42 -30
- package/bin/_cctally_record.py +26 -26
- package/bin/_cctally_setup.py +28 -26
- package/bin/_cctally_tui.py +2 -1
- package/bin/_cctally_update.py +41 -33
- package/bin/_lib_changelog.py +3 -1
- package/bin/_lib_share_templates.py +31 -13
- package/bin/cctally +112 -109
- package/package.json +1 -1
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
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 =
|
|
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
|
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
``
|
|
50
|
-
``
|
|
51
|
-
``
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
``
|
|
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
|
|
179
|
-
# CACHE_LOCK_CODEX_PATH
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
# in
|
|
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
|
-
|
|
426
|
-
|
|
424
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
425
|
+
_cctally_core.CACHE_LOCK_PATH.touch()
|
|
427
426
|
|
|
428
|
-
lock_fh = open(
|
|
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
|
-
|
|
918
|
-
|
|
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(
|
|
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
|
-
|
|
1265
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1267
1266
|
try:
|
|
1268
|
-
conn = sqlite3.connect(
|
|
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
|
-
|
|
1272
|
+
_cctally_core.CACHE_DB_PATH.unlink()
|
|
1274
1273
|
except FileNotFoundError:
|
|
1275
1274
|
pass
|
|
1276
|
-
conn = sqlite3.connect(
|
|
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")
|
package/bin/_cctally_config.py
CHANGED
|
@@ -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
|
-
-
|
|
23
|
-
``
|
|
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
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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 {
|
|
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
|
|
118
|
+
if not _cctally_core.CONFIG_PATH.exists():
|
|
110
119
|
return None
|
|
111
120
|
try:
|
|
112
|
-
raw =
|
|
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
|
-
|
|
145
|
-
fh = open(
|
|
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
|
|
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 =
|
|
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(
|
|
246
|
+
os.replace(str(tmp), str(_cctally_core.CONFIG_PATH))
|
|
238
247
|
|
|
239
248
|
|
|
240
249
|
ALLOWED_CONFIG_KEYS = (
|
package/bin/_cctally_core.py
CHANGED
|
@@ -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)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
docs/superpowers/specs/2026-05-
|
|
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
|
-
|
|
244
|
-
|
|
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(
|
|
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(
|
|
6552
|
+
(str(_cctally_core.CACHE_DB_PATH),),
|
|
6552
6553
|
)
|
|
6553
6554
|
conn.execute(
|
|
6554
6555
|
"CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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
|
-
- ``
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
93
|
-
# `
|
|
94
|
-
#
|
|
95
|
-
# (
|
|
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
|
|
105
|
-
# `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`)
|
|
106
|
-
#
|
|
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
|
|