cctally 1.7.0 → 1.7.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,13 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.1] - 2026-05-15
9
+
10
+ ### Fixed
11
+ - `cctally record-usage` / `sync-week`: `week_start_date` bucket key is now anchored on the canonical UTC calendar day of `week_start_at`, not the host-local-TZ `.date()` of the parsed datetime. When the cctally process briefly inherits a non-UTC `TZ` (e.g., `TZ=America/Los_Angeles` for a `+03:00` host process during refactor work), the same physical subscription week silently forks across two `week_start_date` values, leaving `cctally report` Trend with two rows per current window — one frozen at the moment of the TZ flip, one still updating. The writer fix at `_derive_week_from_payload` / `pick_week_selection` prevents new ghosts; a companion self-heal migration `004_heal_forked_week_start_date_buckets` merges any pre-existing forked rows on the next `open_db()` (usage/cost UPDATE the date columns to `substr(at, 1, 10)`; milestones DELETE on `UNIQUE(week_start_date, percent_threshold)` collision against the canonical row, else UPDATE). A new `data.forked_buckets` doctor check (visible in `cctally doctor` and the dashboard) surfaces the invariant as `fail` with per-table counts so the next regression is visible immediately. `_bootstrap_rename_legacy_markers` is now idempotent against the duplicate-marker case — both the legacy unprefixed and the new prefixed marker rows present from a back-and-forth across cctally versions — by DELETEing the legacy row when its prefixed counterpart already exists and preserving the prefixed row's authoritative `applied_at_utc`; previously the plain UPDATE collided on `schema_migrations.PRIMARY KEY` and permanently blocked the dispatcher from running any subsequent migration (including the heal).
12
+ - `brew` installs: `cctally --help`, `cctally doctor`, the dashboard share GUI, and the CLI `--format md|html|svg` flag no longer crash with `FileNotFoundError` looking for runtime sibling modules. The Homebrew formula template's install block enumerated only `USER_FACING_BINS` since v1.4.0, so the lazy-loaded `_lib_doctor.py` / `_lib_share.py` / `_lib_share_templates.py` siblings never reached `libexec/bin` on brew layouts — `doctor`, the share modal, and the `--format` flag have been latently broken on every brew install since they landed. The v1.6.1 CHANGELOG note that "Homebrew copies the whole prefix; brew unaffected" was incorrect — the formula has always copied a per-name list, not the whole prefix. The bin/cctally split refactor on this branch promoted `_lib_semver` to an EAGER import at `bin/cctally:213`, which would have turned the latent crash into an immediate one (`cctally --help` itself stops resolving on a brew install missing the sibling). `homebrew/cctally.rb.template` now installs every `bin/_lib_*.py` and `bin/_cctally_*.py` runtime sibling via `Dir.glob` alongside `USER_FACING_BINS`, and `tests/test_package_files.py` gains a parity guard so future sibling additions can't silently drop out of the brew install layout. The next release cut after this branch merges ships a working brew formula for the first time since v1.4.0.
13
+ - `update` (self-heal): `_self_heal_current_version` no longer corrupts the global `update-state.json` when `cctally` is invoked from a development clone. The post-command hook reads `CHANGELOG_PATH` via `__file__` (resolved against the dev tree's `CHANGELOG.md`, not the installed binary's), so any `./bin/cctally` invocation from the source tree — including the six phases of `cctally release` itself — stamped `current_version` to whatever the dev tree's CHANGELOG claimed, masking the actually-installed version on the user's machine until the next `rm ~/.local/share/cctally/update-state.json`. The self-heal now early-returns when a `.git/` directory sits next to `CHANGELOG_PATH`, since production tarballs (npm tar, brew archive) never ship `.git/`; legitimate out-of-band upgrades on installed npm/brew binaries still self-heal as before. Symmetric twin of the v1.7.0 brew fix (CHANGELOG-via-`__file__` ≠ installed-binary's CHANGELOG); same root cause, different trigger. Resolves [#42](https://github.com/omrikais/cctally-dev/issues/42).
14
+
8
15
  ## [1.7.0] - 2026-05-13
9
16
 
10
17
  ### Added
@@ -0,0 +1,231 @@
1
+ """Alert dispatch I/O + `cctally alerts test` entry point.
2
+
3
+ Lazy I/O sibling: holds the two helpers that perform real-world side
4
+ effects for the threshold-actions feature, plus the test-entry command:
5
+
6
+ - `_alerts_log_path()` — resolve / mkdir the `alerts.log` path under
7
+ `LOG_DIR`. Pure path-derivation that touches the filesystem (creates
8
+ the parent dir) on every call.
9
+ - `_dispatch_alert_notification(payload, *, popen_factory, mode, tz)` —
10
+ spawn `osascript` (best-effort, non-blocking) to display a macOS
11
+ Notification Center popup, then append a single tab-delimited line to
12
+ `alerts.log` with the terminal status. Fire-and-forget contract; never
13
+ raises.
14
+ - `cmd_alerts_test(args)` — synthetic-payload entry point exposed via
15
+ `cctally alerts test`. Builds a payload through the same
16
+ `_build_alert_payload_*` helpers production uses, routes through
17
+ `_dispatch_alert_notification` with `mode="test"`, and reports the
18
+ outcome via stdout / exit code.
19
+
20
+ The pure payload primitives (`_alert_text_weekly`,
21
+ `_alert_text_five_hour`, `_escape_applescript_string`,
22
+ `_build_alert_payload_weekly`, `_build_alert_payload_five_hour`) live
23
+ in `bin/_lib_alerts_payload.py` (Phase A extraction); this module
24
+ imports them directly via `_load_lib`, which keeps the dispatch path
25
+ free of an extra bounce through cctally's re-exports.
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", ...)`).
32
+ - `now_utc_iso` — single timestamp source used for both the log-line
33
+ timestamp and the synthetic test payload's `crossed_at_utc`.
34
+
35
+ bin/cctally re-exports every public symbol below so the
36
+ `bin/cctally-alerts-dispatch-test` harness (SourceFileLoader-based,
37
+ attribute access via `m._dispatch_alert_notification(...)`) and the
38
+ existing internal call sites in `cmd_record_usage` + the dashboard
39
+ alerts/test handler resolve unchanged.
40
+
41
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import datetime as dt
47
+ import importlib.util as _ilu
48
+ import os
49
+ import pathlib
50
+ import subprocess
51
+ import sys
52
+
53
+
54
+ def _cctally():
55
+ """Resolve the current `cctally` module at call-time (spec §5.5)."""
56
+ return sys.modules["cctally"]
57
+
58
+
59
+ def _load_lib(name: str):
60
+ cached = sys.modules.get(name)
61
+ if cached is not None:
62
+ return cached
63
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
64
+ spec = _ilu.spec_from_file_location(name, p)
65
+ mod = _ilu.module_from_spec(spec)
66
+ sys.modules[name] = mod
67
+ spec.loader.exec_module(mod)
68
+ return mod
69
+
70
+
71
+ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
72
+ _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
73
+ _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
74
+ _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
75
+ _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
76
+ _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
77
+
78
+
79
+ def _alerts_log_path() -> "pathlib.Path":
80
+ """Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
81
+
82
+ Resolves through the same ``APP_DIR`` / ``LOG_DIR`` derived at module
83
+ import time from ``Path.home()``, so a HOME override before import (the
84
+ pattern used elsewhere in this codebase — e.g. ``cctally-config-test``)
85
+ transparently relocates the log without a separate env-var convention.
86
+ """
87
+ log_dir = _cctally().LOG_DIR
88
+ log_dir.mkdir(parents=True, exist_ok=True)
89
+ return log_dir / "alerts.log"
90
+
91
+
92
+ def _dispatch_alert_notification(
93
+ payload: dict,
94
+ *,
95
+ popen_factory=subprocess.Popen,
96
+ mode: str = "real",
97
+ tz: "object | None" = None,
98
+ ) -> str:
99
+ """Spawn osascript to display a macOS notification (non-blocking, best-effort).
100
+
101
+ Returns ``"queued"`` on successful Popen, ``"spawn_error: <ExcType>: <msg>"``
102
+ on failure. Writes EXACTLY ONE line to ``alerts.log`` with the terminal
103
+ status (no contradictory pre-/post-Popen log pair). Never raises:
104
+ Popen-spawn failures and log-write failures are both swallowed so the
105
+ dispatch contract stays independent of the OS / FS state.
106
+
107
+ Production callers ignore the return value (fire-and-forget); test
108
+ callers assert on it via an injected ``popen_factory``.
109
+
110
+ Integration-harness escape hatch: when ``popen_factory`` is left as
111
+ its default (``subprocess.Popen``) AND the env var
112
+ ``CCTALLY_TEST_POPEN_FACTORY=raise_filenotfound`` is set, swap in a
113
+ factory that raises ``FileNotFoundError("no osascript")``. Used by
114
+ ``bin/cctally-alerts-test`` to exercise the spawn-error branch
115
+ end-to-end (subprocess invocation of ``cctally record-usage`` —
116
+ direct kwargs-injection isn't reachable through the CLI). Only the
117
+ one canonical token is honored; unknown values fall through to real
118
+ Popen so a typo can't silently neuter dispatch in production.
119
+ """
120
+ if (
121
+ popen_factory is subprocess.Popen
122
+ and os.environ.get("CCTALLY_TEST_POPEN_FACTORY") == "raise_filenotfound"
123
+ ):
124
+ def _raise_filenotfound(*_args, **_kwargs):
125
+ raise FileNotFoundError("no osascript")
126
+ popen_factory = _raise_filenotfound
127
+
128
+ axis = payload["axis"]
129
+ if axis == "weekly":
130
+ title, subtitle, body = _alert_text_weekly(payload, tz)
131
+ elif axis == "five_hour":
132
+ title, subtitle, body = _alert_text_five_hour(payload, tz)
133
+ else:
134
+ title, subtitle, body = (
135
+ "cctally - alert",
136
+ "",
137
+ f"axis={axis} threshold={payload.get('threshold')}",
138
+ )
139
+
140
+ script = (
141
+ f'display notification "{_escape_applescript_string(body)}"'
142
+ f' with title "{_escape_applescript_string(title)}"'
143
+ f' subtitle "{_escape_applescript_string(subtitle)}"'
144
+ )
145
+
146
+ status: str
147
+ try:
148
+ popen_factory(
149
+ ["osascript", "-e", script],
150
+ stdout=subprocess.DEVNULL,
151
+ stderr=subprocess.DEVNULL,
152
+ close_fds=True,
153
+ start_new_session=True,
154
+ )
155
+ status = "queued"
156
+ except (FileNotFoundError, PermissionError, OSError) as exc:
157
+ status = f"spawn_error: {exc.__class__.__name__}: {exc}"
158
+
159
+ # SINGLE log line per dispatch attempt (Codex P1#2 fix: no
160
+ # contradictory "queued" + "spawn_error" pair for the same call).
161
+ try:
162
+ log_path = _alerts_log_path()
163
+ ctx = payload.get("context") or {}
164
+ window_key = (
165
+ ctx.get("week_start_date")
166
+ or ctx.get("five_hour_window_key")
167
+ or ""
168
+ )
169
+ line = (
170
+ f"{_cctally().now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
171
+ f"\t{mode}\t{status}\n"
172
+ )
173
+ with open(log_path, "a") as f:
174
+ f.write(line)
175
+ except OSError:
176
+ pass # log-write failures must not affect dispatch contract
177
+
178
+ return status
179
+
180
+
181
+ def cmd_alerts_test(args: argparse.Namespace) -> int:
182
+ """Send a synthetic test alert through the dispatch pipeline.
183
+
184
+ Builds a synthetic payload via the same ``_build_alert_payload_*``
185
+ helpers production uses, then routes through ``_dispatch_alert_notification``
186
+ with ``mode="test"`` so the alerts.log line carries the ``test``
187
+ discriminator (5th tab-delimited field) — distinguishes from real
188
+ threshold-crossing alerts written by ``cmd_record_usage``.
189
+
190
+ No DB writes: this path exists purely to validate end-to-end
191
+ osascript + log behavior. Exit codes:
192
+ 0 alert was queued (Popen succeeded)
193
+ 1 osascript missing on this host (FileNotFoundError)
194
+ 2 --threshold out of [1, 100] range
195
+ 3 other spawn error (PermissionError, OSError, ...)
196
+ """
197
+ c = _cctally()
198
+ axis = "weekly" if args.axis == "weekly" else "five_hour"
199
+ threshold = int(args.threshold)
200
+ if not (1 <= threshold <= 100):
201
+ print(
202
+ f"cctally: --threshold must be in [1, 100], got {threshold}",
203
+ file=sys.stderr,
204
+ )
205
+ return 2
206
+ if axis == "weekly":
207
+ payload = _build_alert_payload_weekly(
208
+ threshold=threshold,
209
+ crossed_at_utc=c.now_utc_iso(),
210
+ week_start_date=dt.date.today().isoformat(),
211
+ cumulative_cost_usd=1.23,
212
+ dollars_per_percent=0.01,
213
+ )
214
+ else:
215
+ payload = _build_alert_payload_five_hour(
216
+ threshold=threshold,
217
+ crossed_at_utc=c.now_utc_iso(),
218
+ five_hour_window_key=int(dt.datetime.now(dt.timezone.utc).timestamp()),
219
+ block_start_at=c.now_utc_iso(),
220
+ block_cost_usd=1.23,
221
+ primary_model="claude-sonnet-4-6",
222
+ )
223
+ status = _dispatch_alert_notification(payload, mode="test")
224
+ if status == "queued":
225
+ print("Test alert dispatched (mode=test). Check Notification Center.")
226
+ return 0
227
+ if "FileNotFoundError" in status:
228
+ print(f"cctally: {status}", file=sys.stderr)
229
+ return 1
230
+ print(f"cctally: {status}", file=sys.stderr)
231
+ return 3