cctally 1.7.0 → 1.7.2

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,33 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.2] - 2026-05-16
9
+
10
+ ### Fixed
11
+ - `cctally record-usage`: detect Anthropic-issued in-place weekly credits (utilization drops while `resets_at` stays unchanged) and emit a `week_reset_events` row + force-write `hwm-7d` + seed a post-credit snapshot so dashboard / forecast / report / percent-breakdown / TUI stop freezing at the pre-credit high-water mark. Fires on a `>=25.0pp` drop, the same threshold as the existing boundary-shift path that catches Anthropic-shifted `resets_at` advances mid-week. Deduped via belt-and-suspenders: a pre-check `SELECT 1 FROM week_reset_events WHERE new_week_end_at = ?` short-circuits before any INSERT attempt, the `UNIQUE(old_week_end_at, new_week_end_at)` DDL constraint absorbs any race that slips past, and the post-credit seed snapshot brings prior_pct down to ~current_pct so the next tick's drop predicate is naturally below threshold (single trigger per credit). HWM file `hwm-7d` is force-written via the credit-only escape hatch since the normal monotonic-up write path at `bin/_cctally_record.py:1511-1525` would refuse to decrease it; force-write lands AFTER `conn.commit()` of the event row so a concurrent reader doesn't see the new HWM before the durable signal of the credit.
12
+ - `cctally record-usage`: the monotonic 7d DB clamp now joins against `week_reset_events`, so post-credit `MAX(weekly_percent)` filters to samples captured at-or-after `effective_reset_at_utc`. Fresh OAuth values land naturally instead of being held back by pre-credit history. No-op when no event row exists for the week (`COALESCE` defaults the filter to epoch-zero, so the regression-guard `test_reset_aware_clamp_without_event_preserves_legacy_behavior` confirms byte-identical legacy clamp behavior on un-credited weeks).
13
+ - `cctally`: `_backfill_week_reset_events` extended to detect historical in-place weekly credits in existing DBs via the same predicate as the live path — parallel `elif prior_end == cur_end` branch inside the existing scan loop, same `prior_end_dt > captured_dt` + `>=25pp` drop gate, same `_floor_to_hour` for the effective moment. Idempotent via `UNIQUE(old_week_end_at, new_week_end_at)` + `INSERT OR IGNORE`; the existing boundary-shift branch is byte-identical to v1.7.1 so existing user DBs synthesize event rows for past credits without affecting prior backfill output.
14
+ - `cctally percent_milestones`: schema migration 005 adds a `reset_event_id` column (default 0 = pre-credit segment / no-event sentinel) and reshapes the UNIQUE constraint from `(week_start_date, percent_threshold)` to `(week_start_date, percent_threshold, reset_event_id)` so post-credit threshold crossings land as separate rows from any pre-credit ones at the same threshold. SQLite can't ALTER a UNIQUE constraint in place — the handler uses the rename-recreate-copy idiom inside its own `BEGIN/COMMIT`; fast-path probe stamps the marker without re-doing the rename when the column is already present (covers fresh-install + partial-failure-retry cases). Existing rows backfill to `reset_event_id = 0` via the column DEFAULT; the migration's per-migration goldens at `tests/fixtures/migrations/per-migration/005_percent_milestones_reset_event_id/{pre,post}.sqlite` are the first lazy-adopted entries under that directory pattern.
15
+ - `cctally percent-breakdown` + dashboard milestone panel + TUI percent-milestones panel: now filter milestone rows by the active `week_reset_events` segment for the queried week (the latest event keyed on the canonical hour-floored `week_end_at`). A credited week's header (which already reflects the post-credit window via the canon-boundary rewrite) is now coherent with its body — pre- and post-credit crossings read as independent ledgers. An empty post-credit segment renders a distinct "(post-credit segment, no milestones crossed yet)" hint so the user can distinguish a freshly-credited week from a genuinely silent one; without this, a fresh-credited week would render "No percent milestones recorded for this week" while the pre-credit ledger is still intact in the DB.
16
+ - `cctally` milestone writer (`maybe_record_milestone` + helpers): now stamps the active `week_reset_events.id` into `percent_milestones.reset_event_id` so post-credit threshold crossings land as separate rows; `get_max_milestone_for_week`, `get_milestone_cost_for_week`, the `alerted_at` UPDATE inside the writer, and the post-INSERT cumulative-cost SELECT for the alert payload all gained a `reset_event_id` filter. Active-segment resolution uses `unixepoch()` on both sides of the `<=` comparison to absorb the `+00:00` vs `Z` offset mix between `week_reset_events.effective_reset_at_utc` (stored as `+00:00`) and a snapshot's `captured_at_utc` (stored as `Z`). Combined with the new UNIQUE shape this means a credited week sees post-credit 1% / 2% / 3% alerts fire fresh even if the pre-credit ledger already crossed those thresholds; the self-heal probe in the dedup-no-insert bail-out path is now also segment-scoped so the post-credit ledger doesn't get silently suppressed by a high pre-credit MAX.
17
+ - `cctally doctor`: new `data.post_credit_milestones` check warns when a credited week (`week_reset_events` row with effective < now) has `latest_weekly_percent >= 1.0` AND zero post-credit milestone rows. Informational WARN (no remediation), since the next `record-usage` tick at >=1% will self-heal via the segment-aware probe — surfaces the upgrade-window gap between when the credit lands and when the user accumulates enough usage to cross the post-credit 1% threshold. The `weekly_percent < 1.0` short-circuit prevents false-positive warns immediately after a credit when the user simply hasn't started using the new segment yet.
18
+ - `cctally record-usage`: round-3 user-test follow-up — defensive cleanup in the in-place credit detection branch. Between the moment Anthropic credits the user and `cctally record-usage` firing, the external `claude-statusline` tool can replay stale pre-credit `--percent` values (its in-memory HWM cache hasn't refreshed yet) — those replays land `captured_at_utc >= effective_reset_at_utc` and poison the reset-aware clamp's MAX over the post-credit segment, blocking legitimate fresh OAuth values from landing. The credit branch now runs a narrow DELETE pass scoped to `(week_start_date = ?, captured_at_utc >= effective_iso, round(weekly_percent, 1) = round(prior_pct, 1))` after writing the event row + force-writing `hwm-7d`. Strict-equality predicate avoids deleting legitimate post-credit climbs. Reported by user on the v1.7.2 dev branch with manual recovery already applied to the production DB; fix prevents recurrence.
19
+ - `cctally report` / `weekly`: round-3 user-test follow-up — credited weeks now render as TWO trend rows (pre-credit segment closed at `effective_reset_at_utc` AND post-credit segment opening at `effective_reset_at_utc`). Previously only the post-credit segment surfaced and the pre-credit segment's usage + cost (the bulk of the week's spend in the originating incident: 67% / $1484 across 6 days) was silently dropped from the trend table. `_apply_reset_events_to_weekrefs` synthesizes the pre-credit ref alongside the post-credit one for events whose row shape is `old_week_end_at == effective_reset_at_utc` (the in-place credit marker — boundary-shift events stay single-ref). `cmd_report`'s per-trend-row usage lookup now passes `as_of_utc = ref.week_end_at` for credited weeks so each segment renders its own latest snapshot (the shared `week_start_date` lookup key would otherwise return the post-credit value for both rows); non-credit weeks still use the unfiltered lookup so existing test fixtures that seed snapshots outside the API-derived week window keep finding their rows.
20
+ - `cctally blocks`: round-3 user-test follow-up — `_load_recorded_five_hour_windows` now overlays canonical anchors from `five_hour_blocks.five_hour_resets_at` (heavy-weight = 1000 per row) on top of the existing `weekly_usage_snapshots.five_hour_resets_at` source. The canonical rollup table holds the API-anchored 5h reset moment after `_canonical_5h_window_key` has absorbed Anthropic's seconds-level jitter; the heavy weight ensures that whenever the rollup table sees a window, the rollup's anchor always wins over any jittered raw snapshot value at the same 10-minute-floored key. Symptom this fixes: after an in-place credit, `cctally blocks` showed the ACTIVE row with the heuristic `~HH:MM` prefix while `cctally five-hour-blocks` correctly showed `⚡ HH:MM` (API-anchored). Both views now agree on the API anchor whenever the rollup table has it.
21
+ - `cctally report`: round-4 user-test follow-up — the "current week" summary box no longer renders the PRE-credit row (the closed segment) for credited weeks. Round-3's pre-credit ref synthesis in `_apply_reset_events_to_weekrefs` left both refs sharing `WeekRef.key`, so the match predicate `week_ref.key == current_ref.key` matched BOTH refs in `cmd_report`'s `current_row` loop and last-write-wins picked whichever was processed last. `current_ref` is now routed through `_apply_reset_events_to_weekrefs` itself so its `week_start_at` reflects the post-credit segment's effective start, and the row-match tightens to require BOTH `key` AND `week_start_at` equality — the pre-credit ref (original `week_start_at`) no longer overwrites the post-credit row's selection. Non-credited weeks are unaffected (`_apply_reset_events_to_weekrefs` is a no-op without an event row). On the user's live DB: summary box now correctly shows post-credit `4%` instead of pre-credit `67%`.
22
+ - `cctally blocks`: round-4 user-test follow-up — the ACTIVE 5h row now swaps to the API-anchored window when a canonical `five_hour_blocks` row exists for the current `five_hour_window_key`. Round-3's anchor-overlay in `_load_recorded_five_hour_windows` only fires when heuristic and canonical fall in the same 10-minute floor bucket; the user's heuristic ACTIVE anchor at `23:00 IDT` and the API-anchored `20:50 IDT` are 130 minutes apart (different floor buckets) so the swap didn't trigger. A new post-pass helper `_maybe_swap_active_block_to_canonical` looks up the live key from `weekly_usage_snapshots`, joins to `five_hour_blocks`, and — when the canonical window is still open relative to `now` — rewrites the active block's `start_time` / `end_time` to the canonical pair and flips `anchor` to `"recorded"` so the renderer drops the `~` prefix. Skips cleanly when no canonical row matches or the canonical window has already closed (then the heuristic anchor reflects genuine ongoing activity in a later window). `cctally blocks` ACTIVE row + `cctally five-hour-blocks` ACTIVE row now agree on the API anchor.
23
+ - `cctally blocks`: round-5 user-test follow-up — the active-block canonical swap now ALSO re-aggregates token / cost totals over the canonical interval (Bug F). The round-4 swap only rewrote the displayed timestamps; the underlying entries were still grouped against the heuristic anchor's `[heuristic_start, heuristic_end)` interval, so a displayed `20:50 IDT → 01:50 IDT` window could show cost from the heuristic's `23:00 → 04:00` group instead — on live data the user saw the swapped window with a $45.42 total when the canonical window's real cost was $128+. `_maybe_swap_active_block_to_canonical` now takes the `all_entries` list, filters to `[canonical_block_start, canonical_block_end)`, and rebuilds the block via `_build_activity_block(...)` so every total stays in one code path (no field-by-field assignment that could drift if the dataclass grows). The displayed timestamps and totals are now coherent on every active-block swap.
24
+ - `cctally` dashboard envelope `trend.weeks[]` (and `cctally weekly-history`): round-5 user-test follow-up — credited weeks now render as TWO trend rows with correct per-segment `used_pct` values (Bug G). Round-3 fixed `cmd_report`'s trend table but `_tui_build_trend` (in `bin/_cctally_tui.py`) — which feeds the dashboard's `trend.weeks[]` envelope, the dashboard share modal's trend panel, and `weekly-history` — still used a key-only `get_latest_usage_for_week(conn, week_ref)` lookup that returned the SAME post-credit snapshot for both segments (both refs share `WeekRef.key`). User saw "May 09 4%" and "May 15 4%" side-by-side in the dashboard's `$/1% Trend` panel — both segments collapsed to the post-credit value. The fix mirrors `cmd_report`'s pattern: detect split-keys (where multiple refs in `week_refs` share `key`), pin `as_of_utc = week_ref.week_end_at` for those refs so each segment finds its own latest snapshot, and route the current_ref through `_apply_reset_events_to_weekrefs` so the `is_current` predicate can disambiguate by BOTH `key` AND `week_start_at` (not just key). Non-credit weeks keep the legacy unfiltered lookup so existing fixtures stay byte-stable.
25
+ - `cctally` dashboard Weekly panel: round-5 user-test follow-up — credited weeks now show TWO rows in the Weekly panel (pre-credit + post-credit segments) instead of silently dropping the pre-credit interval (Bug K). `_apply_reset_events_to_subweeks` shifts the credited SubWeek's `start_ts` to `effective_reset_at_utc`, so `_aggregate_weekly`'s bucket for that week covers ONLY the post-credit interval; the bulk of the week's cost (the user's $1491 of pre-credit spend) was invisible in the panel — only the $134 post-credit segment showed up. `_dashboard_build_weekly_periods` now post-processes its bucket-built rows: for each in-place credit event (`old_week_end_at == effective_reset_at_utc` shape) whose post-credit SubWeek end_ts matches a built row, the dashboard re-walks the entries list filtered to `[original_start, effective)`, re-aggregates cost / tokens / per-model via `_calculate_entry_cost`, looks up `weekly_percent` capped to `captured_at_utc <= effective_reset_at_utc` (the pre-credit peak), and inserts a synthesized pre-credit `WeeklyPeriodRow` BEFORE the post-credit row. Pre-credit row's label uses the original start date, post-credit's label keeps the effective reset date; `is_current` only fires on the post-credit row (the live segment). No-op on non-credit weeks.
26
+ - `cctally blocks`: round-5 user-test follow-up — eliminated the phantom heuristic "~" block that appeared between two canonical blocks after an in-place credit (Bug J). Anthropic's credit creates two overlapping canonical 5h windows: the pre-credit `[block_start_at, original_resets_at]` (e.g. 15:50 → 20:50 UTC) and the post-credit `[block_start_at, new_resets_at]` (e.g. 17:50 → 22:50 UTC). `_select_non_overlapping_recorded_windows` enforces the 5h-disjoint invariant by dropping ONE of the overlapping anchors — leaving the dropped block's entries unanchored and rendered as a heuristic "~" row at $45 sandwiched between the two real canonical rows (visible on the user's `cctally blocks` output). `_load_recorded_five_hour_windows` now detects overlapping canonical pairs where an in-place credit moment falls inside the overlap, truncates the EARLIER block's R to the credit moment (10-min floor), and records `(truncated_R → original_block_start)` in a new `block_start_overrides` map returned alongside the anchor list. Truncated anchors bypass the weighted scheduler (their non-overlap is guaranteed by construction, but the scheduler treats every R as the end of a full 5h window and would still flag them as colliding with the adjacent earlier block). `_group_entries_into_blocks` accepts `block_start_overrides` and uses it both for entry partitioning (the bucket's lower bound becomes the override, not `R - 5h`) and display (the recorded block's `start_time` becomes the override, not `R - 5h` which would be hours before the real start). Result: the truncated block displays correctly with its real `block_start_at` and its real ~2-hour duration instead of the misleading 5h heuristic window. Threaded through `cmd_blocks`, `_dashboard_build_blocks_panel`, and the dashboard `/api/block/:start_at` handler so all three callers see the same anchor set.
27
+
28
+ ## [1.7.1] - 2026-05-15
29
+
30
+ ### Fixed
31
+ - `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).
32
+ - `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.
33
+ - `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).
34
+
8
35
  ## [1.7.0] - 2026-05-13
9
36
 
10
37
  ### 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