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 CHANGED
@@ -5,6 +5,28 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.11.1] - 2026-05-22
9
+
10
+ ### Changed
11
+ - Promote 23 path globals (`APP_DIR`, `LEGACY_APP_DIR`, `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `CACHE_LOCK_PATH`, `CACHE_LOCK_CODEX_PATH`, `CONFIG_PATH`, `CONFIG_LOCK_PATH`, `MIGRATION_ERROR_LOG_PATH`, `CHANGELOG_PATH`, all five `HOOK_TICK_*` paths, all six `UPDATE_*` paths, and `CLAUDE_SETTINGS_PATH`) from `bin/cctally` into `bin/_cctally_core.py`; `_cctally_core` is now the single source of truth and the single legal monkeypatch target for these names. `bin/cctally` keeps eager re-exports so external `cctally.X` references (ad-hoc REPL, scripts) still resolve, but internal `bin/cctally` reads (~28 sites) and every `bin/_*.py` sibling read (149 sites across 11 siblings) now go through `_cctally_core.X` at call time. `tests/conftest.py:redirect_paths()` patches `_cctally_core` directly (with an `ns["X"]` introspection mirror); the 80 historical `monkeypatch.setitem(ns, "<NAME>", v)` / `monkeypatch.setattr(cctally, "<NAME>", v)` test sites are migrated to `monkeypatch.setattr(_cctally_core, "<NAME>", v)`. `bin/_cctally_alerts.py` drops its `_cctally()` accessor helper (its only data-global use was `LOG_DIR`); other siblings keep the helper for legitimate non-data deps (validators, threshold constants, `PUBLIC_REPO`, `RELEASE_HEADER_RE`, etc.). The 2026-05-17 kernel-extraction spec's §2.3 path-constant accessor carve-out is retired. `tests/test_kernel_extraction_invariants.py` gains four new AST regression guards that lock the new invariants going forward (`test_promoted_globals_live_in_core`, `test_no_sibling_accessor_reads_promoted_globals`, `test_no_old_style_test_patches_for_promoted_globals`, `test_no_value_imports_of_promoted_globals_in_siblings`). (#84)
12
+
13
+ ## [1.11.0] - 2026-05-22
14
+
15
+ ### Added
16
+ - Dashboard: new Cache Report panel and modal exposing the `cctally cache-report` anomaly + savings surface as an always-on watchdog — accent-teal border by default, flips to accent-amber when today crosses an anomaly trigger (`cache_drop` ≥ 15pp drop vs the 14-day median or `net_negative` (`net_usd < 0`)), with a 14-day cache hit % mini-sparkline and a 7-day "+$X saved · N ⚠ days" subline; click the panel for a modal with six sections in order — today's spotlight (status pill + inline stat row + reasons line on anomaly), the full 14-day cache hit % timeline (`CacheSparkline` large variant with a ±5pp tinted baseline band), per-day net $ stacked bars (saved-green + thin red wasted-segment on positive days, downward amber bar on net-negative days), a counterfactual callout ("without caching, you'd have paid +$X more · cache efficiency Y%"), the daily rows table with per-column header colorization (date neutral · cache % cyan · saved green · wasted red · net purple · flag amber), and by-project (magenta) + by-model (blue) breakdown sub-cards. Panel sits at the tail of `DEFAULT_PANEL_ORDER` (position 11) on fresh installs; existing users with a saved `panelOrder` get the panel appended by the existing `reconcilePanelOrder` migration. No new third-party dependencies (hand-rolled SVG for the timeline + bar chart). Envelope is additive + nullable on the `cache_report` field — `envelope_version` stays at 2.
17
+ - Dashboard: `POST /api/settings` now accepts a `cache_report` block with key `anomaly_threshold_pp` (integer 1-100, default 15) — the modal's gear-icon settings popover round-trips through this endpoint and re-evaluates anomalies on the next sync. Invalid values return HTTP 400 with `{error, field}` (matching the existing settings-block validator convention); the popover surfaces the error inline under the input. The CLI's `cctally cache-report --anomaly-threshold-pp` flag is independent in v1 and continues to use its own default; the new `cache_report.anomaly_threshold_pp` config applies only to the dashboard sync path.
18
+ - CSS: new `--accent-teal` design token (the 11th panel accent color) plus `.panel.accent-teal` rule following the same shape as the other nine accent variants, and a `.crm-*` namespace for the Cache Report modal chrome (spotlight sub-card, chart frames, counterfactual callout, daily-rows table with per-column header accents, breakdown sub-cards, settings popover). Pickup follow-up: the panel's mobile collapse (`< 720px`) is now driven by a `useIsMobile()` hook applying the `cache-report-collapsed` modifier class (Implementor B added the CSS rule but left the JS toggle unwired).
19
+
20
+ ### Fixed
21
+ - CLI: `cctally cache-report` now buckets daily rows by the resolved `display.tz` instead of host-local. The pre-existing aggregator at `bin/cctally:2311` correctly resolved `display.tz` for `--since`/`--until` parsing but did NOT pass it to the day-bucketing path, so `--tz America/Los_Angeles` (or any non-host tz) shifted window edges without shifting the day-bucket dates rendered in the table. Threading an explicit `display_tz: ZoneInfo | None` parameter through `_aggregate_cache_by_day` closes the gap. Host-local hosts (the harness convention `TZ=Etc/UTC`) see no goldens change; non-UTC hosts may see day-bucket date strings shift by a day at midnight boundaries. Goldens regenerated via `bin/build-cache-report-fixtures.py`; aggregate per-day USD values are unchanged.
22
+ - HelpOverlay: panels at position 11+ now render an em-dash for the keybind cell instead of a literal `'11'` `<kbd>` label. The digit hotkeys at `main.tsx:62-74` only route to positions 1-10 of the active `panelOrder`; rendering `String(idx + 1)` for `idx > 8` previously emitted a `'11'` chip that wasn't bound to anything. Positional rule, not panel-id-specific — whatever panel sits at position 11 (Cache Report by default; any panel after drag-reorder) renders the em-dash.
23
+ - Dashboard Cache Report: panel chrome (border accent, header text color, "⚠ Today" header badge, sparkline today-marker color) no longer flips to amber during the baseline-building window (first 1–4 captured days). The server-side classifier can fire `net_negative` without a baseline, so `cr.today.anomaly_triggered` arrives true while `baseline_daily_row_count < CACHE_REPORT_MIN_BASELINE_DAYS` (5), but the watchdog is supposed to stay neutral until the 5-day floor exists. A new `chromeAmber = anomaly_triggered && !insufficient` gate at `CacheReportPanel.tsx` decouples the panel chrome from the headline copy — the modal's `.modal-card` accent mirrors the same gate so the panel-to-modal handoff stays teal during baseline-building. Round-2 Codex review finding (issue #77).
24
+ - Dashboard Cache Report: by-project / by-model breakdown cards now aggregate only over the same calendar dates as the displayed 14-day table. The kernel's rolling window can emit 15 distinct display-tz buckets when `now_utc - 14d` and `now_utc` straddle midnight (any non-UTC `display.tz`); `days` was already sliced to `window_days` in `bin/_cctally_dashboard.py`, but the breakdown inputs (raw `entries` for by-project; `result.rows` for by-model) were not — the cards silently included the dropped oldest bucket and stopped reconciling against the visible table / `CacheNetBars`. A `kept_dates = {r.date for r in days}` filter on both sides closes the gap; regression at `tests/test_dashboard_envelope_cache_report.py::test_build_cache_report_snapshot_breakdowns_match_days_window`. Round-2 Codex review finding (issue #77).
25
+ - Dashboard Cache Report modal: the daily-rows table's "Cache %" cell color now tracks each row's own `anomaly_reasons` instead of recomputing every row against today's `baseline_median_percent`. The server-side per-row classifier (`_cctally_cache_report._classify_anomalies`) uses `exclude_row=row` so each row has its own baseline; a window with five early 80% days followed by many 50% days kept the first 50% rows flagged `cache_drop` against the 80% reference, but the previous predicate (`d.cache_hit_percent < today.baseline_median_percent - CACHE_REPORT_BAND_PP`) painted them `hit-good` once today's median also drifted to 50% — green cells next to a ⚠ Flag column on the same row. Switching to `d.anomaly_reasons.includes('cache_drop')` keeps the cell color and the Flag column in lock-step regardless of how today's baseline moves; the `baselineKnown` neutral guard for the cold-start window is preserved. Round-2 Codex review finding (issue #77).
26
+ - Dashboard Cache Report modal: spotlight pill, sub-card border, and reasons line no longer flip to amber `⚠ Anomaly` during the baseline-building window (first 1–4 captured days). The server-side classifier fires `net_negative` without a baseline so `cr.today.anomaly_triggered` arrives true while `baseline_daily_row_count < CACHE_REPORT_MIN_BASELINE_DAYS=5`, but the panel and the outer modal-card chrome already gate the amber flip behind `!insufficient`. `CacheReportSpotlight` checked `anomaly_triggered` before `insufficient`, so the spotlight contradicted the panel and modal-card by rendering ⚠ Anomaly on the same day the rest of the surface said "Building baseline". The pill precedence now puts `insufficient` first, mirroring the gate at `CacheReportPanel.tsx:147` and `CacheReportModal.tsx:111`. Round-3 Codex review finding.
27
+ - Dashboard Cache Report modal settings: the anomaly-threshold popover now rejects fractional input (e.g. `1.5`, `15.9`, `1.0`, `15e2`) inline instead of silently truncating via `parseInt(value, 10)` and POSTing a different value than the user typed. The Save handler trims the input and matches against `/^-?\d+$/` before parsing, so any non-integer literal fails the existing `Must be an integer between 1 and 100` guard. `type="number"` does not block fractional typing/pasting on its own, and only the server previously rejected fractional values. Round-3 Codex review finding.
28
+ - Dashboard Cache Report modal: daily-rows "Cache %" cell color now tracks the same ±`CACHE_REPORT_BAND_PP`=5 band the sparkline draws around today's baseline median, replacing the round-2 binding to `anomaly_reasons.includes('cache_drop')`. The previous binding used the configurable `anomaly_threshold_pp` (default 15) for cell color, so any day 6-14pp below baseline rendered green even though it visibly sat outside the sparkline's highlighted band; raising the configured threshold widened the gap further. The Flag column (`flag-warn` / `flag-ok`) stays bound to each row's own `anomaly_triggered`, so the two signals — display-band coloring vs. per-row anomaly classifier — now stay independent and each carries its own meaning. Round-3 Codex review finding.
29
+
8
30
  ## [1.10.3] - 2026-05-21
9
31
 
10
32
  ### Fixed
@@ -24,11 +24,11 @@ in `bin/_lib_alerts_payload.py` (Phase A extraction); this module
24
24
  imports them directly via `_load_lib`, which keeps the dispatch path
25
25
  free of an extra bounce through cctally's re-exports.
26
26
 
27
- bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same
28
- as `bin/_cctally_setup.py`):
29
- - `LOG_DIR` base dir under which `alerts.log` lives (subject to
30
- HOME-redirection by test fixtures via `monkeypatch.setitem(ns,
31
- "LOG_DIR", ...)`).
27
+ Kernel reads from `bin/_cctally_core` (call-time module-attribute access):
28
+ - `LOG_DIR` — base dir under which `alerts.log` lives. Promoted to
29
+ `_cctally_core` 2026-05-22 (#84); test fixtures redirect via
30
+ `monkeypatch.setattr(_cctally_core, "LOG_DIR", tmp)` (or the
31
+ conftest `redirect_paths()` helper).
32
32
  - `now_utc_iso` — single timestamp source used for both the log-line
33
33
  timestamp and the synthetic test payload's `crossed_at_utc`.
34
34
 
@@ -50,10 +50,7 @@ import pathlib
50
50
  import subprocess
51
51
  import sys
52
52
 
53
-
54
- def _cctally():
55
- """Resolve the current `cctally` module at call-time (spec §5.5)."""
56
- return sys.modules["cctally"]
53
+ import _cctally_core
57
54
 
58
55
 
59
56
  def _load_lib(name: str):
@@ -77,22 +74,21 @@ _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_h
77
74
 
78
75
 
79
76
  # === Honest imports from extracted homes ===================================
80
- # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
81
- # import from _cctally_core. `LOG_DIR` stays on the _cctally() accessor
82
- # per Q1=B (path constants propagate via monkeypatch.setitem against the
83
- # cctally namespace).
77
+ # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
78
+ # LOG_DIR was promoted to _cctally_core 2026-05-22 (#84) and is read
79
+ # via call-time module-attribute access (this sibling no longer needs
80
+ # the historical _cctally() accessor).
84
81
  from _cctally_core import now_utc_iso
85
82
 
86
83
 
87
84
  def _alerts_log_path() -> "pathlib.Path":
88
85
  """Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
89
86
 
90
- Resolves through the same ``APP_DIR`` / ``LOG_DIR`` derived at module
91
- import time from ``Path.home()``, so a HOME override before import (the
92
- pattern used elsewhere in this codebase — e.g. ``cctally-config-test``)
93
- transparently relocates the log without a separate env-var convention.
87
+ Reads ``LOG_DIR`` from ``_cctally_core`` at call time. Tests patch via
88
+ ``monkeypatch.setattr(_cctally_core, "LOG_DIR", tmp)`` (or the
89
+ conftest ``redirect_paths()`` helper).
94
90
  """
95
- log_dir = _cctally().LOG_DIR
91
+ log_dir = _cctally_core.LOG_DIR
96
92
  log_dir.mkdir(parents=True, exist_ok=True)
97
93
  return log_dir / "alerts.log"
98
94
 
@@ -41,21 +41,18 @@ Holds:
41
41
  - ``cmd_cache_sync`` — entry point for ``cctally cache-sync
42
42
  [--source {claude,codex,all}] [--rebuild]``.
43
43
 
44
- What stays in bin/cctally:
44
+ What lives in bin/_cctally_core (promoted 2026-05-22, #84):
45
45
  - Path constants ``APP_DIR``, ``CACHE_DB_PATH``, ``CACHE_LOCK_PATH``,
46
- ``CACHE_LOCK_CODEX_PATH``, ``CODEX_SESSIONS_DIR`` referenced from
47
- the moved bodies via the ``c = _cctally()`` call-time accessor
48
- pattern (spec §5.5, same as ``bin/_lib_subscription_weeks.py`` and
49
- ``bin/_lib_aggregators.py``). The accessor resolves
50
- ``sys.modules['cctally'].X`` on every call, so
51
- ``monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)`` and conftest
52
- ``redirect_paths`` HOME redirects propagate transparently with NO
53
- test-side changes (tests already patch ``ns["CACHE_DB_PATH"]`` etc.
54
- by setitem on the dict-as-module bridge). We chose ``c.X`` over the
55
- ``_cctally_db.py``-style seed block here because cache tests are
56
- widely scattered (record-usage tick, dashboard panels, share render
57
- kernel, block tests, every JSONL-reading subcommand fixture) and
58
- Phase C-style inline patching would touch dozens of sites.
46
+ ``CACHE_LOCK_CODEX_PATH``. Moved bodies read these via call-time
47
+ ``_cctally_core.X`` and tests patch via
48
+ ``monkeypatch.setattr(_cctally_core, "X", v)`` (or the conftest
49
+ ``redirect_paths()`` helper). The legacy
50
+ ``setitem(ns, "CACHE_DB_PATH", …)`` pattern is forbidden by
51
+ ``test_no_old_style_test_patches_for_promoted_globals``.
52
+
53
+ What stays in bin/cctally:
54
+ - ``CODEX_SESSIONS_DIR`` out of scope for #84; still read via the
55
+ ``c = _cctally()`` call-time accessor (spec §5.5).
59
56
  - ``_sum_cost_for_range`` — sits at the cache↔report boundary; 6+
60
57
  callers outside cache (forecast, weekly, report, project, doctor),
61
58
  so the directive keeps it on the bin/cctally side.
@@ -122,6 +119,7 @@ def _cctally():
122
119
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
123
120
  # (Z-leaf + Z-mid) import from _cctally_core. The legacy shim function
124
121
  # for ``eprint`` is deleted.
122
+ import _cctally_core
125
123
  from _cctally_core import eprint
126
124
 
127
125
 
@@ -175,11 +173,12 @@ _CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
175
173
 
176
174
 
177
175
  # === BEGIN MOVED REGIONS ===
178
- # Path constants (APP_DIR, CACHE_DB_PATH, CACHE_LOCK_PATH,
179
- # CACHE_LOCK_CODEX_PATH, CODEX_SESSIONS_DIR) are accessed via the
180
- # `c = _cctally()` call-time accessor inside each function that
181
- # needs them — so ``monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)``
182
- # in tests resolves on every read (no stale module-level binding).
176
+ # Path constants APP_DIR / CACHE_DB_PATH / CACHE_LOCK_PATH /
177
+ # CACHE_LOCK_CODEX_PATH live in _cctally_core (promoted 2026-05-22, #84);
178
+ # moved bodies read them via call-time ``_cctally_core.X`` and tests
179
+ # patch via ``monkeypatch.setattr(_cctally_core, "X", v)``.
180
+ # CODEX_SESSIONS_DIR stays in bin/cctally (out of scope for #84) and is
181
+ # still accessed via the ``c = _cctally()`` call-time accessor.
183
182
 
184
183
  # === Region 1: ProjectKey + _resolve_project_key (was bin/cctally:1994-2069) ===
185
184
 
@@ -422,10 +421,10 @@ def sync_cache(
422
421
  """
423
422
  stats = IngestStats()
424
423
  c = _cctally()
425
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
426
- c.CACHE_LOCK_PATH.touch()
424
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
425
+ _cctally_core.CACHE_LOCK_PATH.touch()
427
426
 
428
- lock_fh = open(c.CACHE_LOCK_PATH, "w")
427
+ lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
429
428
  try:
430
429
  try:
431
430
  fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -914,10 +913,10 @@ def sync_codex_cache(
914
913
  """
915
914
  stats = CodexIngestStats()
916
915
  c = _cctally()
917
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
918
- c.CACHE_LOCK_CODEX_PATH.touch()
916
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
917
+ _cctally_core.CACHE_LOCK_CODEX_PATH.touch()
919
918
 
920
- lock_fh = open(c.CACHE_LOCK_CODEX_PATH, "w")
919
+ lock_fh = open(_cctally_core.CACHE_LOCK_CODEX_PATH, "w")
921
920
  try:
922
921
  try:
923
922
  fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -1263,17 +1262,17 @@ def open_cache_db() -> sqlite3.Connection:
1263
1262
  recreated — the cache is fully re-derivable from JSONL, so this is safe.
1264
1263
  """
1265
1264
  c = _cctally()
1266
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
1265
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
1267
1266
  try:
1268
- conn = sqlite3.connect(c.CACHE_DB_PATH)
1267
+ conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
1269
1268
  conn.execute("SELECT 1").fetchone()
1270
1269
  except sqlite3.DatabaseError as exc:
1271
1270
  eprint(f"[cache] corrupt cache DB ({exc}); recreating")
1272
1271
  try:
1273
- c.CACHE_DB_PATH.unlink()
1272
+ _cctally_core.CACHE_DB_PATH.unlink()
1274
1273
  except FileNotFoundError:
1275
1274
  pass
1276
- conn = sqlite3.connect(c.CACHE_DB_PATH)
1275
+ conn = sqlite3.connect(_cctally_core.CACHE_DB_PATH)
1277
1276
 
1278
1277
  conn.execute("PRAGMA journal_mode=WAL")
1279
1278
  conn.execute("PRAGMA busy_timeout=5000")