cctally 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +366 -140
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +145 -8
- package/bin/_cctally_dashboard.py +2 -1
- package/bin/_cctally_db.py +1696 -35
- package/bin/_cctally_record.py +27 -27
- package/bin/_cctally_setup.py +39 -27
- package/bin/_cctally_tui.py +2 -1
- package/bin/_cctally_update.py +41 -33
- package/bin/_lib_changelog.py +3 -1
- package/bin/_lib_jsonl.py +80 -16
- package/bin/_lib_share_templates.py +31 -13
- package/bin/cctally +112 -109
- package/package.json +1 -1
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
|
|
@@ -2350,7 +2350,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
|
2350
2350
|
cache_conn = open_cache_db()
|
|
2351
2351
|
try:
|
|
2352
2352
|
stats = sync_cache(cache_conn)
|
|
2353
|
-
ingested = int(stats.
|
|
2353
|
+
ingested = int(stats.rows_changed)
|
|
2354
2354
|
finally:
|
|
2355
2355
|
try:
|
|
2356
2356
|
cache_conn.close()
|
|
@@ -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"
|
|
@@ -1714,13 +1716,23 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1714
1716
|
cache_conn = c.open_cache_db()
|
|
1715
1717
|
try:
|
|
1716
1718
|
stats = c.sync_cache(cache_conn)
|
|
1717
|
-
rows = int(stats.
|
|
1719
|
+
rows = int(stats.rows_changed)
|
|
1718
1720
|
finally:
|
|
1719
1721
|
try:
|
|
1720
1722
|
cache_conn.close()
|
|
1721
1723
|
except Exception:
|
|
1722
1724
|
pass
|
|
1723
1725
|
bootstrap_rows = rows
|
|
1726
|
+
# `rows` counts both genuine INSERTs and ccusage-parity DO UPDATE
|
|
1727
|
+
# replacements (see IngestStats.rows_changed). On first install
|
|
1728
|
+
# this is always 0-vs-N pure inserts (cache is empty), so "N new
|
|
1729
|
+
# entries" is exactly accurate. On a re-install / upgrade path
|
|
1730
|
+
# with active sessions, `rows` also counts UPSERT replacements
|
|
1731
|
+
# (streaming-vs-final tiebreaker swaps), so the count is more
|
|
1732
|
+
# accurately "ingest activity" than "rows newly added" — but
|
|
1733
|
+
# we keep "new entries" because (a) it's still a useful signal
|
|
1734
|
+
# to the operator that the cache is alive, and (b) the dominant
|
|
1735
|
+
# case (first install) reads literally.
|
|
1724
1736
|
out.append(f"✓ Synced session cache ({rows} new entries)")
|
|
1725
1737
|
except Exception as exc:
|
|
1726
1738
|
out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
|
|
@@ -1773,7 +1785,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1773
1785
|
},
|
|
1774
1786
|
"hooks": {
|
|
1775
1787
|
"events_added": list(c.SETUP_HOOK_EVENTS),
|
|
1776
|
-
"settings_path": str(
|
|
1788
|
+
"settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
|
|
1777
1789
|
},
|
|
1778
1790
|
"auth": {
|
|
1779
1791
|
"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,
|
|
@@ -2036,7 +2037,7 @@ def _tui_build_snapshot(
|
|
|
2036
2037
|
projects_envelope_block: dict | None = None
|
|
2037
2038
|
try:
|
|
2038
2039
|
c = _cctally()
|
|
2039
|
-
cache_db_path =
|
|
2040
|
+
cache_db_path = _cctally_core.CACHE_DB_PATH
|
|
2040
2041
|
conn.execute(
|
|
2041
2042
|
"ATTACH DATABASE ? AS cache_db",
|
|
2042
2043
|
(str(cache_db_path),),
|
package/bin/_cctally_update.py
CHANGED
|
@@ -102,12 +102,19 @@ worker / polling thread:
|
|
|
102
102
|
accessor; eager re-export from this sibling means the cctally
|
|
103
103
|
namespace exposes them unchanged.
|
|
104
104
|
|
|
105
|
+
What lives in bin/_cctally_core (promoted 2026-05-22, #84):
|
|
106
|
+
- All ``UPDATE_*`` path constants: ``UPDATE_STATE_PATH``,
|
|
107
|
+
``UPDATE_SUPPRESS_PATH``, ``UPDATE_LOCK_PATH``, ``UPDATE_LOG_PATH``,
|
|
108
|
+
``UPDATE_LOG_ROTATED_PATH``, ``UPDATE_CHECK_LAST_FETCH_PATH``.
|
|
109
|
+
Moved bodies in this sibling read them via call-time
|
|
110
|
+
``_cctally_core.UPDATE_STATE_PATH`` etc. Tests patch via
|
|
111
|
+
``monkeypatch.setattr(_cctally_core, "UPDATE_STATE_PATH", tmp)`` —
|
|
112
|
+
the conftest helper ``redirect_paths()`` covers this for the full
|
|
113
|
+
set, ``tests/test_update.py:update_paths`` covers it for the
|
|
114
|
+
UPDATE_* subset. The legacy ``setitem(ns, …)`` pattern is forbidden
|
|
115
|
+
by ``test_no_old_style_test_patches_for_promoted_globals``.
|
|
116
|
+
|
|
105
117
|
What stays in bin/cctally:
|
|
106
|
-
- All ``UPDATE_*`` path constants (source-of-truth at L2001-2023);
|
|
107
|
-
consumed via ``c = _cctally(); c.UPDATE_STATE_PATH`` etc. in moved
|
|
108
|
-
code so ``monkeypatch.setitem(ns, "UPDATE_STATE_PATH", tmp)`` in
|
|
109
|
-
``tests/test_update.py`` propagates transparently — no sibling-side
|
|
110
|
-
patches needed. Mirrors Phase D #17/#18 precedent.
|
|
111
118
|
- ``ORIGINAL_SYS_ARGV`` / ``ORIGINAL_ENTRYPOINT`` /
|
|
112
119
|
``_UPDATE_WORKER`` — module-level globals written by
|
|
113
120
|
``cmd_dashboard`` at boot (``global`` statement at L23205);
|
|
@@ -200,6 +207,7 @@ def _cctally():
|
|
|
200
207
|
|
|
201
208
|
# === Honest imports from extracted homes ===================================
|
|
202
209
|
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
|
|
210
|
+
import _cctally_core
|
|
203
211
|
from _cctally_core import eprint, _now_utc
|
|
204
212
|
from _cctally_config import save_config
|
|
205
213
|
|
|
@@ -405,7 +413,7 @@ def _load_update_state() -> dict[str, Any] | None:
|
|
|
405
413
|
"""
|
|
406
414
|
c = _cctally()
|
|
407
415
|
try:
|
|
408
|
-
text =
|
|
416
|
+
text = _cctally_core.UPDATE_STATE_PATH.read_text(encoding="utf-8")
|
|
409
417
|
except FileNotFoundError:
|
|
410
418
|
return None
|
|
411
419
|
try:
|
|
@@ -437,12 +445,12 @@ def _save_update_state(state: dict[str, Any]) -> None:
|
|
|
437
445
|
writers via ``UPDATE_LOCK_PATH`` (spec §5.3).
|
|
438
446
|
"""
|
|
439
447
|
c = _cctally()
|
|
440
|
-
|
|
448
|
+
_cctally_core.UPDATE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
441
449
|
payload = (
|
|
442
450
|
json.dumps(state, indent=2, sort_keys=True) + "\n"
|
|
443
451
|
).encode("utf-8")
|
|
444
|
-
tmp =
|
|
445
|
-
f"{
|
|
452
|
+
tmp = _cctally_core.UPDATE_STATE_PATH.with_name(
|
|
453
|
+
f"{_cctally_core.UPDATE_STATE_PATH.name}.tmp.{os.getpid()}"
|
|
446
454
|
)
|
|
447
455
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
448
456
|
try:
|
|
@@ -450,7 +458,7 @@ def _save_update_state(state: dict[str, Any]) -> None:
|
|
|
450
458
|
os.fsync(fd)
|
|
451
459
|
finally:
|
|
452
460
|
os.close(fd)
|
|
453
|
-
os.replace(str(tmp), str(
|
|
461
|
+
os.replace(str(tmp), str(_cctally_core.UPDATE_STATE_PATH))
|
|
454
462
|
|
|
455
463
|
|
|
456
464
|
def _load_update_suppress() -> dict[str, Any]:
|
|
@@ -462,7 +470,7 @@ def _load_update_suppress() -> dict[str, Any]:
|
|
|
462
470
|
c = _cctally()
|
|
463
471
|
default = {"_schema": 1, "skipped_versions": [], "remind_after": None}
|
|
464
472
|
try:
|
|
465
|
-
text =
|
|
473
|
+
text = _cctally_core.UPDATE_SUPPRESS_PATH.read_text(encoding="utf-8")
|
|
466
474
|
except FileNotFoundError:
|
|
467
475
|
return default
|
|
468
476
|
try:
|
|
@@ -489,12 +497,12 @@ def _save_update_suppress(suppress: dict[str, Any]) -> None:
|
|
|
489
497
|
"""Persist ``update-suppress.json`` atomically. Same idiom as
|
|
490
498
|
:func:`_save_update_state`."""
|
|
491
499
|
c = _cctally()
|
|
492
|
-
|
|
500
|
+
_cctally_core.UPDATE_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
493
501
|
payload = (
|
|
494
502
|
json.dumps(suppress, indent=2, sort_keys=True) + "\n"
|
|
495
503
|
).encode("utf-8")
|
|
496
|
-
tmp =
|
|
497
|
-
f"{
|
|
504
|
+
tmp = _cctally_core.UPDATE_SUPPRESS_PATH.with_name(
|
|
505
|
+
f"{_cctally_core.UPDATE_SUPPRESS_PATH.name}.tmp.{os.getpid()}"
|
|
498
506
|
)
|
|
499
507
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
500
508
|
try:
|
|
@@ -502,7 +510,7 @@ def _save_update_suppress(suppress: dict[str, Any]) -> None:
|
|
|
502
510
|
os.fsync(fd)
|
|
503
511
|
finally:
|
|
504
512
|
os.close(fd)
|
|
505
|
-
os.replace(str(tmp), str(
|
|
513
|
+
os.replace(str(tmp), str(_cctally_core.UPDATE_SUPPRESS_PATH))
|
|
506
514
|
|
|
507
515
|
|
|
508
516
|
def _read_lock_pid(fd: int) -> int | None:
|
|
@@ -543,9 +551,9 @@ def _acquire_update_lock() -> int:
|
|
|
543
551
|
COMMAND=cctally update
|
|
544
552
|
"""
|
|
545
553
|
c = _cctally()
|
|
546
|
-
|
|
554
|
+
_cctally_core.UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
547
555
|
fd = os.open(
|
|
548
|
-
str(
|
|
556
|
+
str(_cctally_core.UPDATE_LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644
|
|
549
557
|
)
|
|
550
558
|
try:
|
|
551
559
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
@@ -622,16 +630,16 @@ def _rotate_update_log_if_needed() -> None:
|
|
|
622
630
|
"""
|
|
623
631
|
c = _cctally()
|
|
624
632
|
try:
|
|
625
|
-
size =
|
|
633
|
+
size = _cctally_core.UPDATE_LOG_PATH.stat().st_size
|
|
626
634
|
except FileNotFoundError:
|
|
627
635
|
return
|
|
628
636
|
if size < c.UPDATE_LOG_ROTATE_BYTES:
|
|
629
637
|
return
|
|
630
638
|
try:
|
|
631
|
-
|
|
639
|
+
_cctally_core.UPDATE_LOG_ROTATED_PATH.unlink()
|
|
632
640
|
except FileNotFoundError:
|
|
633
641
|
pass
|
|
634
|
-
|
|
642
|
+
_cctally_core.UPDATE_LOG_PATH.rename(_cctally_core.UPDATE_LOG_ROTATED_PATH)
|
|
635
643
|
|
|
636
644
|
|
|
637
645
|
def _log_update_event(log_fd, event: str, **kv: Any) -> None:
|
|
@@ -892,7 +900,7 @@ def _self_heal_current_version() -> None:
|
|
|
892
900
|
"""
|
|
893
901
|
c = _cctally()
|
|
894
902
|
try:
|
|
895
|
-
if (
|
|
903
|
+
if (_cctally_core.CHANGELOG_PATH.parent / ".git").exists():
|
|
896
904
|
return
|
|
897
905
|
fresh = _release_read_latest_release_version()
|
|
898
906
|
if fresh is None:
|
|
@@ -1029,7 +1037,7 @@ def _is_update_check_due(config: dict) -> bool:
|
|
|
1029
1037
|
return False
|
|
1030
1038
|
ttl_hours = check_cfg.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
|
|
1031
1039
|
try:
|
|
1032
|
-
mtime =
|
|
1040
|
+
mtime = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
|
|
1033
1041
|
except FileNotFoundError:
|
|
1034
1042
|
return True
|
|
1035
1043
|
return (time.time() - mtime) >= ttl_hours * 3600
|
|
@@ -1052,8 +1060,8 @@ def _do_update_check() -> None:
|
|
|
1052
1060
|
c = _cctally()
|
|
1053
1061
|
# Touch marker FIRST — crash safety: a dead process mid-fetch must
|
|
1054
1062
|
# not trigger another fetch within the TTL window.
|
|
1055
|
-
|
|
1056
|
-
|
|
1063
|
+
_cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1064
|
+
_cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.touch()
|
|
1057
1065
|
|
|
1058
1066
|
method = c._detect_install_method(mutate=True)
|
|
1059
1067
|
|
|
@@ -1143,15 +1151,15 @@ def cmd_update_check_internal(args) -> int:
|
|
|
1143
1151
|
"""
|
|
1144
1152
|
c = _cctally()
|
|
1145
1153
|
# Ensure APP_DIR exists so log + state writes succeed on first run.
|
|
1146
|
-
|
|
1154
|
+
_cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1147
1155
|
try:
|
|
1148
|
-
with open(
|
|
1156
|
+
with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1149
1157
|
_log_update_event(log_fd, "CHECK_START")
|
|
1150
1158
|
c._do_update_check()
|
|
1151
1159
|
_log_update_event(log_fd, "CHECK_EXIT", rc=0)
|
|
1152
1160
|
except Exception as e:
|
|
1153
1161
|
try:
|
|
1154
|
-
with open(
|
|
1162
|
+
with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1155
1163
|
_log_update_event(log_fd, "CHECK_EXIT", rc=1, error=str(e)[:200])
|
|
1156
1164
|
except Exception:
|
|
1157
1165
|
pass
|
|
@@ -1615,10 +1623,10 @@ def _do_update_install(
|
|
|
1615
1623
|
quoted = " ".join(shlex.quote(c2) for c2 in cmd)
|
|
1616
1624
|
print(f"Would run: {quoted}")
|
|
1617
1625
|
return 0
|
|
1618
|
-
|
|
1626
|
+
_cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1619
1627
|
lock_fd = c._acquire_update_lock()
|
|
1620
1628
|
try:
|
|
1621
|
-
with open(
|
|
1629
|
+
with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1622
1630
|
_log_update_event(log_fd, "INSTALL_START", method=method.method)
|
|
1623
1631
|
for step_name, cmd in steps:
|
|
1624
1632
|
_log_update_event(log_fd, "STEP_START", name=step_name)
|
|
@@ -1790,9 +1798,9 @@ class UpdateWorker:
|
|
|
1790
1798
|
try:
|
|
1791
1799
|
method = c._detect_install_method(mutate=True)
|
|
1792
1800
|
c._preflight_install(method, version)
|
|
1793
|
-
|
|
1801
|
+
_cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1794
1802
|
lock_fd = c._acquire_update_lock()
|
|
1795
|
-
log_fd = open(
|
|
1803
|
+
log_fd = open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8")
|
|
1796
1804
|
_log_update_event(log_fd, "INSTALL_START", method=method.method)
|
|
1797
1805
|
for step_name, cmd in c._build_update_steps(method, version):
|
|
1798
1806
|
self._emit(run_id, {"type": "step", "name": step_name})
|
|
@@ -1923,8 +1931,8 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1923
1931
|
# silently disable the polling cadence for the rest
|
|
1924
1932
|
# of the dashboard's lifetime.
|
|
1925
1933
|
try:
|
|
1926
|
-
|
|
1927
|
-
with open(
|
|
1934
|
+
_cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1935
|
+
with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1928
1936
|
_log_update_event(
|
|
1929
1937
|
log_fd, "CHECK_FAILED", error=str(e)[:200]
|
|
1930
1938
|
)
|
package/bin/_lib_changelog.py
CHANGED
|
@@ -13,6 +13,8 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import sys
|
|
15
15
|
|
|
16
|
+
import _cctally_core
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
def _cctally():
|
|
18
20
|
"""Call-time accessor for the ``cctally`` module (project memory
|
|
@@ -35,7 +37,7 @@ def _read_latest_changelog_version() -> tuple[str, str] | None:
|
|
|
35
37
|
"""
|
|
36
38
|
c = _cctally()
|
|
37
39
|
try:
|
|
38
|
-
text =
|
|
40
|
+
text = _cctally_core.CHANGELOG_PATH.read_text(encoding="utf-8")
|
|
39
41
|
except FileNotFoundError:
|
|
40
42
|
return None
|
|
41
43
|
m = c.RELEASE_HEADER_RE.search(text)
|