cctally 1.10.3 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +28 -29
- package/bin/_cctally_cache_report.py +938 -0
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +94 -8
- package/bin/_cctally_dashboard.py +621 -7
- 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 +47 -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 +214 -495
- package/dashboard/static/assets/index-BJ16SzRL.js +18 -0
- package/dashboard/static/assets/index-C1xH9GBW.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Cy59E7Ru.js +0 -18
- package/dashboard/static/assets/index-Dp14ELVt.css +0 -1
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
|
|
package/bin/_cctally_record.py
CHANGED
|
@@ -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_*)
|
|
316
|
-
#
|
|
317
|
-
#
|
|
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
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
2147
|
-
fd = os.open(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
2190
|
-
|
|
2191
|
-
os.utime(
|
|
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
|
-
|
|
2316
|
+
_cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
2317
2317
|
log_fd = os.open(
|
|
2318
|
-
|
|
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
|
-
|
|
2371
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
2372
2372
|
try:
|
|
2373
2373
|
lock_fd = os.open(
|
|
2374
|
-
|
|
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: {
|
|
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 → {
|
|
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
|
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -64,11 +64,13 @@ def _cctally():
|
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
# === Honest imports from extracted homes ===================================
|
|
67
|
-
# Spec 2026-05-17
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
# OAuth token, sync_cache, …)
|
|
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 =
|
|
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 (
|
|
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(
|
|
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 ({
|
|
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" {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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"{
|
|
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
|
|
1226
|
+
if _cctally_core.APP_DIR.exists():
|
|
1225
1227
|
try:
|
|
1226
|
-
shutil.rmtree(
|
|
1227
|
-
out.append(f"Wiped {
|
|
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(
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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 {
|
|
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 {
|
|
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(
|
|
1778
|
+
"settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
|
|
1777
1779
|
},
|
|
1778
1780
|
"auth": {
|
|
1779
1781
|
"oauth_token_present": oauth,
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -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 =
|
|
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()
|