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.
@@ -158,6 +158,7 @@ def _cctally():
158
158
 
159
159
  # === Honest imports from extracted homes ===================================
160
160
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
161
+ import _cctally_core
161
162
  from _cctally_core import (
162
163
  eprint,
163
164
  now_utc_iso,
@@ -312,18 +313,17 @@ _logged_window_key_coerce_failure = False
312
313
 
313
314
 
314
315
  # === BEGIN MOVED REGIONS ===
315
- # Path constants (APP_DIR, HOOK_TICK_*) are accessed via the
316
- # `c = _cctally()` call-time accessor inside each function that needs
317
- # them so ``monkeypatch.setitem(ns, "APP_DIR", tmp)`` in tests
318
- # resolves on every read (no stale module-level binding).
316
+ # Path constants (APP_DIR, HOOK_TICK_*) moved to _cctally_core
317
+ # 2026-05-22 (#84). Reads use call-time ``_cctally_core.X``; tests
318
+ # patch via ``monkeypatch.setattr(_cctally_core, "X", v)``.
319
319
  #
320
- # Constants pulled from cctally at call time:
320
+ # Constants pulled at call time:
321
+ # _cctally_core.APP_DIR
322
+ # _cctally_core.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
323
+ # _cctally_core.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
321
324
  # c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
322
325
  # c._RESET_PCT_DROP_THRESHOLD — bin/cctally module-level constant
323
- # c.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
324
- # c.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
325
326
  # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
326
- # c.APP_DIR
327
327
 
328
328
 
329
329
  def _normalize_percent(value: "float | int | None") -> "float | None":
@@ -1521,7 +1521,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1521
1521
  # record-usage reader doesn't see the new HWM
1522
1522
  # before the event row is durable.
1523
1523
  try:
1524
- (c.APP_DIR / "hwm-7d").write_text(
1524
+ (_cctally_core.APP_DIR / "hwm-7d").write_text(
1525
1525
  f"{week_start_date} {weekly_percent}\n"
1526
1526
  )
1527
1527
  except OSError:
@@ -1731,7 +1731,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1731
1731
  # matches the canonical writer:
1732
1732
  # ``<key> <percent>\n``.
1733
1733
  try:
1734
- (c.APP_DIR / "hwm-5h").write_text(
1734
+ (_cctally_core.APP_DIR / "hwm-5h").write_text(
1735
1735
  f"{int(five_hour_window_key)} "
1736
1736
  f"{float(five_hour_percent)}\n"
1737
1737
  )
@@ -2096,7 +2096,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2096
2096
  # Write high-water mark so the status line never displays a regression.
2097
2097
  # The file contains "week_start_date weekly_percent" on one line.
2098
2098
  try:
2099
- hwm_path = c.APP_DIR / "hwm-7d"
2099
+ hwm_path = _cctally_core.APP_DIR / "hwm-7d"
2100
2100
  existing_hwm = 0.0
2101
2101
  try:
2102
2102
  parts = hwm_path.read_text().strip().split()
@@ -2119,7 +2119,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2119
2119
  ):
2120
2120
  try:
2121
2121
  five_resets_key = five_hour_window_key
2122
- hwm5_path = c.APP_DIR / "hwm-5h"
2122
+ hwm5_path = _cctally_core.APP_DIR / "hwm-5h"
2123
2123
  existing_hwm5 = 0.0
2124
2124
  try:
2125
2125
  parts5 = hwm5_path.read_text().strip().split()
@@ -2143,8 +2143,8 @@ def _hook_tick_log_line(line: str) -> None:
2143
2143
  """
2144
2144
  c = _cctally()
2145
2145
  try:
2146
- c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2147
- fd = os.open(c.HOOK_TICK_LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
2146
+ _cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2147
+ fd = os.open(_cctally_core.HOOK_TICK_LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
2148
2148
  try:
2149
2149
  os.write(fd, (line.rstrip("\n") + "\n").encode("utf-8", errors="replace"))
2150
2150
  finally:
@@ -2157,7 +2157,7 @@ def _hook_tick_log_rotate_if_needed() -> None:
2157
2157
  """If hook-tick.log exceeds the size cap, atomic-rename to .1 (overwriting)."""
2158
2158
  c = _cctally()
2159
2159
  try:
2160
- size = c.HOOK_TICK_LOG_PATH.stat().st_size
2160
+ size = _cctally_core.HOOK_TICK_LOG_PATH.stat().st_size
2161
2161
  except FileNotFoundError:
2162
2162
  return
2163
2163
  except OSError:
@@ -2165,7 +2165,7 @@ def _hook_tick_log_rotate_if_needed() -> None:
2165
2165
  if size <= c.HOOK_TICK_LOG_ROTATE_BYTES:
2166
2166
  return
2167
2167
  try:
2168
- os.replace(c.HOOK_TICK_LOG_PATH, c.HOOK_TICK_LOG_ROTATED_PATH)
2168
+ os.replace(_cctally_core.HOOK_TICK_LOG_PATH, _cctally_core.HOOK_TICK_LOG_ROTATED_PATH)
2169
2169
  except OSError:
2170
2170
  pass
2171
2171
 
@@ -2174,7 +2174,7 @@ def _hook_tick_throttle_age_seconds() -> float:
2174
2174
  """Return seconds since last successful OAuth fetch; +inf if never."""
2175
2175
  c = _cctally()
2176
2176
  try:
2177
- mtime = c.HOOK_TICK_THROTTLE_PATH.stat().st_mtime
2177
+ mtime = _cctally_core.HOOK_TICK_THROTTLE_PATH.stat().st_mtime
2178
2178
  except FileNotFoundError:
2179
2179
  return float("inf")
2180
2180
  except OSError:
@@ -2186,9 +2186,9 @@ def _hook_tick_throttle_touch() -> None:
2186
2186
  """Update mtime to now (creating the file if missing)."""
2187
2187
  c = _cctally()
2188
2188
  try:
2189
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
2190
- c.HOOK_TICK_THROTTLE_PATH.touch(exist_ok=True)
2191
- os.utime(c.HOOK_TICK_THROTTLE_PATH, None)
2189
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
2190
+ _cctally_core.HOOK_TICK_THROTTLE_PATH.touch(exist_ok=True)
2191
+ os.utime(_cctally_core.HOOK_TICK_THROTTLE_PATH, None)
2192
2192
  except OSError:
2193
2193
  pass
2194
2194
 
@@ -2313,9 +2313,9 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2313
2313
  # immediately after Step 7, so the leak is bounded.
2314
2314
  if not explain:
2315
2315
  try:
2316
- c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2316
+ _cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
2317
2317
  log_fd = os.open(
2318
- c.HOOK_TICK_LOG_PATH,
2318
+ _cctally_core.HOOK_TICK_LOG_PATH,
2319
2319
  os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644,
2320
2320
  )
2321
2321
  os.dup2(log_fd, 1) # stdout
@@ -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.rows_inserted)
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
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
2371
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
2372
2372
  try:
2373
2373
  lock_fd = os.open(
2374
- c.HOOK_TICK_THROTTLE_LOCK_PATH,
2374
+ _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH,
2375
2375
  os.O_WRONLY | os.O_CREAT, 0o644,
2376
2376
  )
2377
2377
  except OSError:
@@ -2437,7 +2437,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2437
2437
  print("[1/4] Local sync (sync_cache)")
2438
2438
  print(f" → ingested {max(0, ingested)} new entries")
2439
2439
  print("[2/4] Throttle check")
2440
- print(f" → throttle file: {c.HOOK_TICK_THROTTLE_PATH}")
2440
+ print(f" → throttle file: {_cctally_core.HOOK_TICK_THROTTLE_PATH}")
2441
2441
  if pre_age == float("inf"):
2442
2442
  print(" → mtime: (file absent)")
2443
2443
  else:
@@ -2445,7 +2445,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2445
2445
  print(f" → threshold: {int(throttle_seconds)}s → {decision}")
2446
2446
  print("[3/4] OAuth refresh")
2447
2447
  print(f" → status: {oauth_status}")
2448
- print(f"[4/4] Log written → {c.HOOK_TICK_LOG_PATH}")
2448
+ print(f"[4/4] Log written → {_cctally_core.HOOK_TICK_LOG_PATH}")
2449
2449
  print(f"\nDone in {dur_ms} ms.")
2450
2450
  return rc
2451
2451
 
@@ -64,11 +64,13 @@ def _cctally():
64
64
 
65
65
 
66
66
  # === Honest imports from extracted homes ===================================
67
- # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
68
- # import from _cctally_core. Path constants (`APP_DIR`,
69
- # `CLAUDE_SETTINGS_PATH`, `HOOK_TICK_LOG_PATH`) plus the extensive
70
- # out-of-scope setup-specific helpers (legacy migration, hook surgery,
71
- # OAuth token, sync_cache, …) stay on the _cctally() accessor.
67
+ # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core. Path
68
+ # constants (APP_DIR, CLAUDE_SETTINGS_PATH, HOOK_TICK_LOG_PATH, etc.)
69
+ # moved to _cctally_core 2026-05-22 (#84) and are accessed via call-time
70
+ # ``_cctally_core.X``. The setup-specific helpers (legacy migration, hook
71
+ # surgery, OAuth token, sync_cache, …) that live in bin/cctally itself
72
+ # stay on the _cctally() accessor.
73
+ import _cctally_core
72
74
  from _cctally_core import (
73
75
  eprint,
74
76
  _command_as_of,
@@ -863,7 +865,7 @@ def _setup_count_hook_entries(settings: dict) -> dict[str, int]:
863
865
 
864
866
 
865
867
  def _setup_data_dir_size_bytes() -> int:
866
- app_dir = _cctally().APP_DIR
868
+ app_dir = _cctally_core.APP_DIR
867
869
  total = 0
868
870
  if not app_dir.exists():
869
871
  return 0
@@ -891,7 +893,7 @@ def _setup_recent_log_stats(seconds: float = 24 * 3600) -> dict:
891
893
  counts = {"fires": 0, "by_event": {}, "oauth_ok": 0, "throttled": 0,
892
894
  "errors": 0, "last_fire_ago_s": None}
893
895
  last_ts = 0.0
894
- for path in (c.HOOK_TICK_LOG_ROTATED_PATH, c.HOOK_TICK_LOG_PATH):
896
+ for path in (_cctally_core.HOOK_TICK_LOG_ROTATED_PATH, _cctally_core.HOOK_TICK_LOG_PATH):
895
897
  if not path.exists():
896
898
  continue
897
899
  try:
@@ -1041,7 +1043,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1041
1043
  "files": bespoke["files"],
1042
1044
  },
1043
1045
  },
1044
- "data": {"path": str(c.APP_DIR), "size_bytes": data_bytes},
1046
+ "data": {"path": str(_cctally_core.APP_DIR), "size_bytes": data_bytes},
1045
1047
  }
1046
1048
  print(json.dumps(envelope, indent=2))
1047
1049
  return 0
@@ -1057,7 +1059,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1057
1059
  out.append(" run `cctally setup` to remove")
1058
1060
  out.append(f" PATH includes {'yes' if on_path else 'no'} "
1059
1061
  f"{'✓' if on_path else '⚠'}")
1060
- out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
1062
+ out.append(f"Hooks ({_cctally_core.CLAUDE_SETTINGS_PATH})")
1061
1063
  for ev in c.SETUP_HOOK_EVENTS:
1062
1064
  marker = "✓" if hook_counts[ev] >= 1 else "✗"
1063
1065
  word = "installed" if hook_counts[ev] >= 1 else "missing"
@@ -1093,7 +1095,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1093
1095
  )
1094
1096
  out.append(" run `cctally setup --migrate-legacy-hooks` to migrate")
1095
1097
  out.append("Data")
1096
- out.append(f" {c.APP_DIR}/ {_setup_format_bytes(data_bytes)}")
1098
+ out.append(f" {_cctally_core.APP_DIR}/ {_setup_format_bytes(data_bytes)}")
1097
1099
  _setup_emit_text(out)
1098
1100
  return 0
1099
1101
 
@@ -1115,9 +1117,9 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1115
1117
  try:
1116
1118
  c._write_claude_settings_atomic(settings)
1117
1119
  except OSError as exc:
1118
- eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1120
+ eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
1119
1121
  return 2
1120
- out.append(f"Removed {removed} hook entries from {c.CLAUDE_SETTINGS_PATH}")
1122
+ out.append(f"Removed {removed} hook entries from {_cctally_core.CLAUDE_SETTINGS_PATH}")
1121
1123
 
1122
1124
  repo_root = _setup_resolve_repo_root()
1123
1125
  dst_dir = _setup_local_bin_dir()
@@ -1201,7 +1203,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1201
1203
  "hooks_removed": removed,
1202
1204
  "symlinks_removed": sym_removed,
1203
1205
  "purged": False,
1204
- "data_path": str(c.APP_DIR),
1206
+ "data_path": str(_cctally_core.APP_DIR),
1205
1207
  "data_size_bytes": data_bytes,
1206
1208
  "legacy": {
1207
1209
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
@@ -1213,7 +1215,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1213
1215
  try:
1214
1216
  resp = input(
1215
1217
  f"Wipe {_setup_format_bytes(data_bytes)} of usage history at "
1216
- f"{c.APP_DIR}/? [y/N] "
1218
+ f"{_cctally_core.APP_DIR}/? [y/N] "
1217
1219
  )
1218
1220
  except EOFError:
1219
1221
  resp = "n"
@@ -1221,10 +1223,10 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1221
1223
  out.append("Purge declined.")
1222
1224
  _setup_emit_text(out)
1223
1225
  return 3
1224
- if c.APP_DIR.exists():
1226
+ if _cctally_core.APP_DIR.exists():
1225
1227
  try:
1226
- shutil.rmtree(c.APP_DIR)
1227
- out.append(f"Wiped {c.APP_DIR}/")
1228
+ shutil.rmtree(_cctally_core.APP_DIR)
1229
+ out.append(f"Wiped {_cctally_core.APP_DIR}/")
1228
1230
  except OSError as exc:
1229
1231
  if is_json:
1230
1232
  print(json.dumps({
@@ -1233,7 +1235,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1233
1235
  "result": "err",
1234
1236
  "reason": "rmtree_failed",
1235
1237
  "error": str(exc),
1236
- "data_path": str(c.APP_DIR),
1238
+ "data_path": str(_cctally_core.APP_DIR),
1237
1239
  "data_size_bytes": data_bytes,
1238
1240
  "legacy": {
1239
1241
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
@@ -1241,11 +1243,11 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1241
1243
  "exit_code": 1,
1242
1244
  }, indent=2))
1243
1245
  else:
1244
- eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
1246
+ eprint(f"setup: failed to wipe {_cctally_core.APP_DIR}: {exc}")
1245
1247
  return 1
1246
1248
  else:
1247
1249
  out.append(
1248
- f"Note: usage history kept at {c.APP_DIR}/ "
1250
+ f"Note: usage history kept at {_cctally_core.APP_DIR}/ "
1249
1251
  f"({_setup_format_bytes(data_bytes)}). Use --purge to remove."
1250
1252
  )
1251
1253
  if is_json:
@@ -1256,7 +1258,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1256
1258
  "hooks_removed": removed,
1257
1259
  "symlinks_removed": sym_removed,
1258
1260
  "purged": purge,
1259
- "data_path": str(c.APP_DIR),
1261
+ "data_path": str(_cctally_core.APP_DIR),
1260
1262
  "data_size_bytes": data_bytes,
1261
1263
  "legacy": {
1262
1264
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
@@ -1305,7 +1307,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1305
1307
  out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
1306
1308
  out.append(" Remove them manually then re-run.")
1307
1309
 
1308
- out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {c.CLAUDE_SETTINGS_PATH}:")
1310
+ out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
1309
1311
  abs_path = str(_setup_resolve_hook_target(repo_root))
1310
1312
  import shlex
1311
1313
  quoted = shlex.quote(abs_path)
@@ -1391,7 +1393,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1391
1393
  }
1392
1394
  for ev in c.SETUP_HOOK_EVENTS
1393
1395
  ],
1394
- "settings_path": str(c.CLAUDE_SETTINGS_PATH),
1396
+ "settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
1395
1397
  },
1396
1398
  # Sibling parity with `_setup_status` and `_setup_install`
1397
1399
  # JSON envelopes (`legacy.bespoke_hooks` shape). Lets the same
@@ -1614,7 +1616,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1614
1616
  try:
1615
1617
  c._write_claude_settings_atomic(settings)
1616
1618
  except OSError as exc:
1617
- eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1619
+ eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
1618
1620
  return 2
1619
1621
 
1620
1622
  # ── Post-write migration apply (spec §2 steps 6a, 6b) ──
@@ -1674,7 +1676,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1674
1676
  # The "✓ Wrote …" line follows any migrate-summary line so the
1675
1677
  # narrative reads "we did the migration, then wrote the new entries"
1676
1678
  # — matches the spec's success-path sample (Section 2).
1677
- out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {c.CLAUDE_SETTINGS_PATH}")
1679
+ out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}")
1678
1680
 
1679
1681
  if decision == "skip" and reason in {"user_declined", "no_migrate_flag"}:
1680
1682
  files_str = "{record-usage-stop,usage-poller{,-start,-stop}}.py"
@@ -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.rows_inserted)
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(c.CLAUDE_SETTINGS_PATH),
1788
+ "settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
1777
1789
  },
1778
1790
  "auth": {
1779
1791
  "oauth_token_present": oauth,
@@ -204,6 +204,7 @@ def _cctally():
204
204
 
205
205
  # === Honest imports from extracted homes ===================================
206
206
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
207
+ import _cctally_core
207
208
  from _cctally_core import (
208
209
  eprint,
209
210
  parse_iso_datetime,
@@ -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 = c.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),),
@@ -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 = c.UPDATE_STATE_PATH.read_text(encoding="utf-8")
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
- c.UPDATE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
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 = c.UPDATE_STATE_PATH.with_name(
445
- f"{c.UPDATE_STATE_PATH.name}.tmp.{os.getpid()}"
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(c.UPDATE_STATE_PATH))
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 = c.UPDATE_SUPPRESS_PATH.read_text(encoding="utf-8")
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
- c.UPDATE_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
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 = c.UPDATE_SUPPRESS_PATH.with_name(
497
- f"{c.UPDATE_SUPPRESS_PATH.name}.tmp.{os.getpid()}"
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(c.UPDATE_SUPPRESS_PATH))
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
- c.UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
554
+ _cctally_core.UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
547
555
  fd = os.open(
548
- str(c.UPDATE_LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644
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 = c.UPDATE_LOG_PATH.stat().st_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
- c.UPDATE_LOG_ROTATED_PATH.unlink()
639
+ _cctally_core.UPDATE_LOG_ROTATED_PATH.unlink()
632
640
  except FileNotFoundError:
633
641
  pass
634
- c.UPDATE_LOG_PATH.rename(c.UPDATE_LOG_ROTATED_PATH)
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 (c.CHANGELOG_PATH.parent / ".git").exists():
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 = c.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_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
- c.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
1056
- c.UPDATE_CHECK_LAST_FETCH_PATH.touch()
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
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1154
+ _cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1147
1155
  try:
1148
- with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
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(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
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
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
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(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
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
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
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(c.UPDATE_LOG_PATH, "a", encoding="utf-8")
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
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1927
- with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
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
  )
@@ -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 = c.CHANGELOG_PATH.read_text(encoding="utf-8")
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)