cctally 1.22.3 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.23.0] - 2026-06-02
9
+
10
+ ### Added
11
+ - **Projected-pace alerts: a new opt-in `projected` alert axis that warns you *before* you cross a ceiling, firing on your week-average pace rather than waiting for the actual crossing.** It tracks two metrics — `weekly_pct` (projected to reach 90% / 100% of your subscription cap by the week's reset) and `budget_usd` (projected to reach your `budget.alert_thresholds` of the weekly $ budget) — using the smooth week-average projection (`now + average-rate × time-remaining`), the same conservative number `forecast`/`budget` already display; it deliberately ignores the hotter trailing-24h estimate so a brief spike doesn't trigger a false alarm. Each level fires once per week (no re-fire, no recovery alert), is suppressed while the forecast is `LOW CONF` (too early in the week / too few samples), and re-anchors cleanly across a mid-week reset.
12
+ - **Both projected toggles default OFF, gated behind their parent axis** — enable weekly-% projected alerts with `cctally config set alerts.projected_enabled true` (requires `alerts.enabled`) and budget-$ projected alerts with `cctally config set budget.projected_enabled true` (requires a configured `budget.weekly_usd` + `budget.alerts_enabled`); preview either without writing any data via `cctally alerts test --axis projected --metric weekly_pct` (or `--metric budget_usd`). The local web dashboard surfaces fired projected alerts with a dedicated **Projected** chip and forecast-aware context, and exposes both enable/disable toggles in its settings panel.
13
+ - **`forecast --json` and `budget --json` now expose an additive `week_avg_projection_pct` / `week_avg_projection_usd` field** — the exact week-average end-of-week projection the projected-pace alert axis fires on, surfaced for scripting and reconciliation (additive only; no schema-version bump, existing keys unchanged).
14
+
15
+ ### Fixed
16
+ - **The mid-week reset-to-zero detector now waits for a corroborating second reading before segmenting the week (#128).** When the Anthropic usage API reports a clean drop to ~0% against non-trivial prior usage (below the 25pp goodwill-credit threshold), `record-usage` no longer writes a `week_reset_events` row on the very first zero: it arms a one-tick debounce and fires only if the next reading stays low (at or below half the pre-zero level), treating a zero that bounces back toward the prior percentage as a transient API/replica glitch rather than a real reset. A genuine reset still surfaces — one status-line tick later — and the ≥25pp credit path, the boundary-advance path, and the 5-hour detector are unchanged. This is belt-and-suspenders hardening on the 2026-06-01 surprise-reset fix; the debounce is best-effort under concurrent `record-usage` runs (a race degrades to the previous single-zero behavior, never worse).
17
+ - **Test-suite isolation (no shipped-code change): the `*_ns_patch.py` binding-regression tests no longer read the developer's real `~/.local/share/cctally` database (#127).** Their shared `cctally_mod` fixture loaded `bin/cctally` with a bespoke loader that only set `HOME` and relied on `_cctally_core`'s import-time path derivation — which silently no-ops once any earlier test has cached `_cctally_core` in `sys.modules` (every `load_script()` user does), so the handler under test fell back to the real prod DB. That stayed invisible until the prod DB happened to hold a `week_reset_events` row for the current week, at which point `test_percent_breakdown_md_reaches_all_accessors_via_ns` flipped to a failure that only reproduced under certain test orderings (it filters milestones by the DB's active segment, so the seeded fake milestone was dropped and the table never rendered). The five `*_ns_patch.py` fixtures now route through a shared `conftest.load_isolated_cctally_module()` helper built on `load_script()` + `redirect_paths()`, pinning `_cctally_core`'s path constants to the per-test tmp dir deterministically regardless of import order. A new order-independent regression (`test_ns_patch_loader_isolates_db_when_core_cached`) asserts the loader re-isolates `DB_PATH` even when `_cctally_core` is pre-cached pointing at a real prod path.
18
+
19
+ ## [1.22.4] - 2026-06-01
20
+
21
+ ### Fixed
22
+ - **A surprise mid-week Anthropic usage reset is now reflected in the 7d percentage even when you were below ~25% usage.** When Anthropic zeroes the weekly counter mid-window (same reset timestamp, usage drops to ~0 — e.g. the 2026-06-01 incident), the reset detector previously only fired when the drop was at least 25 percentage points, so any account reset from a lower base (the observed 14% → 0%) slipped through: no `week_reset_events` row was written, the monotonic high-water-mark clamp kept reporting the stale pre-reset percentage across the statusline, `weekly`/`report`, and the dashboard, and post-reset 0% reads were silently dropped at the write site. The detector now ALSO fires on a reset-to-zero — when the post-reset value collapses to ~0 (≤ 1%) with at least a 3pp drop — independently of the 25pp magnitude gate, since a clean drop to zero is an unambiguous reset regardless of size (a lagging API replica reports a slightly-lower number, never a clean 0 against real usage), while the 3pp floor rejects 1%→0% replica jitter that would otherwise spuriously segment the week. The new discriminator is a single shared `_is_reset_drop()` helper wired into all four 7d detection sites (live + backfill, boundary-advance + in-place-credit branches) so they stay byte-identical; the 25pp partial-credit path and the separate 5h detector are unchanged. Regression: three new cases in `tests/test_in_place_credit_detection.py` (live reset-to-zero fires below 25pp, the 3pp floor rejects 1%→0% jitter, and backfill parity).
23
+
24
+ ### Changed
25
+ - **Internal refactor (no user-facing change): extracted the shared formatting/color/table render primitives — the boxed-table renderer, the color/unicode capability detectors, the ANSI styling + display-width helpers, the integer/width-budget number formatters, the compact timestamp/week-window formatters, and the `_ANSI_ESC_RE` regex (plus the small `_parse_iso_datetime_optional` helper they depend on) — out of the `bin/cctally` single-file program into a new stdlib-only pure-function sibling module, `bin/_lib_fmt.py` (#126, the final tranche of the `bin/cctally` split).** This trims the main script by ~250 lines (2,969 → 2,715), and additionally converts the eight back-references these primitives had in `bin/_lib_render.py` and `bin/_lib_diff_kernel.py` from `cctally`-namespace shims into honest direct imports of the new kernel — with no change to any command's behavior, flags, output, or exit codes: every reporting/`diff`/`project`/`cache-report`/`forecast`/`blocks`/`percent-breakdown`/`five-hour-blocks` golden test is byte-identical and the full harness + pytest suite (1,329 checks) passes, a new `tests/test_lib_fmt_extraction_invariants.py` locks the extraction's import discipline, and the new module ships in the npm/brew/public packages (promoted to the mirror allowlist). Purely a maintainability / code-organization change; nothing to do on upgrade.
26
+
8
27
  ## [1.22.3] - 2026-06-01
9
28
 
10
29
  ### Changed
@@ -69,10 +69,12 @@ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
69
69
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
70
70
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
71
71
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
72
+ _alert_text_projected = _lib_alerts_payload._alert_text_projected
72
73
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
73
74
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
74
75
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
75
76
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
77
+ _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
76
78
 
77
79
 
78
80
  # === Honest imports from extracted homes ===================================
@@ -138,6 +140,8 @@ def _dispatch_alert_notification(
138
140
  title, subtitle, body = _alert_text_five_hour(payload, tz)
139
141
  elif axis == "budget":
140
142
  title, subtitle, body = _alert_text_budget(payload, tz)
143
+ elif axis == "projected":
144
+ title, subtitle, body = _alert_text_projected(payload, tz)
141
145
  else:
142
146
  title, subtitle, body = (
143
147
  "cctally - alert",
@@ -207,6 +211,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
207
211
  axis = "weekly"
208
212
  elif args.axis == "budget":
209
213
  axis = "budget"
214
+ elif args.axis == "projected":
215
+ axis = "projected"
210
216
  else:
211
217
  axis = "five_hour"
212
218
  threshold = int(args.threshold)
@@ -239,6 +245,27 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
239
245
  spent_usd=300.0 * threshold / 100.0,
240
246
  consumption_pct=float(threshold),
241
247
  )
248
+ elif axis == "projected":
249
+ # Synthetic projected-pace payload — NO DB writes (test/real divergence
250
+ # contract). The metric discriminator picks the wiring; projected_value
251
+ # is the threshold's denominator-relative value (so the body reads
252
+ # plausibly, e.g. weekly 100% → "~100% of cap", budget 100% → "$300 of
253
+ # $300"). denominator is the at-crossing target the row would carry
254
+ # (Codex P0-4): 100.0 for weekly_pct, $300 for budget_usd.
255
+ metric = getattr(args, "metric", "weekly_pct")
256
+ if metric == "budget_usd":
257
+ denominator = 300.0
258
+ projected_value = 300.0 * threshold / 100.0
259
+ else: # weekly_pct
260
+ denominator = 100.0
261
+ projected_value = float(threshold)
262
+ payload = _build_alert_payload_projected(
263
+ metric=metric,
264
+ threshold=threshold,
265
+ projected_value=projected_value,
266
+ denominator=denominator,
267
+ week_start_at=dt.date.today().isoformat(),
268
+ )
242
269
  else:
243
270
  payload = _build_alert_payload_five_hour(
244
271
  threshold=threshold,
@@ -297,6 +297,7 @@ def save_config(data: dict[str, Any]) -> None:
297
297
  ALLOWED_CONFIG_KEYS = (
298
298
  "display.tz",
299
299
  "alerts.enabled",
300
+ "alerts.projected_enabled",
300
301
  "dashboard.bind",
301
302
  "update.check.enabled",
302
303
  "update.check.ttl_hours",
@@ -306,6 +307,7 @@ ALLOWED_CONFIG_KEYS = (
306
307
  "budget.weekly_usd",
307
308
  "budget.alerts_enabled",
308
309
  "budget.alert_thresholds",
310
+ "budget.projected_enabled",
309
311
  )
310
312
 
311
313
 
@@ -405,6 +407,13 @@ def _config_known_value(config: dict, key: str) -> "object":
405
407
  return c.get_display_tz_pref(config)
406
408
  if key == "alerts.enabled":
407
409
  return bool(_get_alerts_config(config)["enabled"])
410
+ if key == "alerts.projected_enabled":
411
+ # Validated boolean (defaults to False when unset). A corrupt alerts
412
+ # block surfaces the default — mirrors alerts.enabled.
413
+ try:
414
+ return bool(_get_alerts_config(config)["projected_enabled"])
415
+ except c._AlertsConfigError:
416
+ return False
408
417
  if key == "dashboard.bind":
409
418
  # Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
410
419
  # bind time). LAN exposure is opt-in via `set dashboard.bind lan`
@@ -472,6 +481,7 @@ def _config_known_value(config: dict, key: str) -> "object":
472
481
  "budget.weekly_usd",
473
482
  "budget.alerts_enabled",
474
483
  "budget.alert_thresholds",
484
+ "budget.projected_enabled",
475
485
  ):
476
486
  inner = key.split(".", 1)[1]
477
487
  # Read the validated, defaults-filled block. A corrupt block falls
@@ -598,6 +608,51 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
598
608
  else:
599
609
  print(f"alerts.enabled={'true' if normalized else 'false'}")
600
610
  return 0
611
+ if key == "alerts.projected_enabled":
612
+ # Projected-pace opt-in (#121). Same bool-normalizer + read-modify-write
613
+ # posture as alerts.enabled (preserves sibling alerts.* keys).
614
+ # _normalize_alerts_enabled_value hardcodes "alerts.enabled" in its
615
+ # ValueError text, so catch + re-message with the actual key name
616
+ # (mirrors _normalize_update_check_enabled_value's precedent) — the
617
+ # budget side already names its own key correctly.
618
+ try:
619
+ normalized = c._normalize_alerts_enabled_value(raw)
620
+ except ValueError:
621
+ print(
622
+ f"cctally: invalid boolean value for alerts.projected_enabled: "
623
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
624
+ file=sys.stderr,
625
+ )
626
+ return 2
627
+ with config_writer_lock():
628
+ config = _load_config_unlocked()
629
+ existing_alerts = config.get("alerts")
630
+ if existing_alerts is not None and not isinstance(
631
+ existing_alerts, dict
632
+ ):
633
+ print(
634
+ "cctally: alerts config error: alerts must be an object",
635
+ file=sys.stderr,
636
+ )
637
+ return 2
638
+ alerts_block = dict(existing_alerts or {})
639
+ alerts_block["projected_enabled"] = normalized
640
+ try:
641
+ _get_alerts_config({**config, "alerts": alerts_block})
642
+ except _AlertsConfigError as exc:
643
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
644
+ return 2
645
+ config["alerts"] = alerts_block
646
+ save_config(config)
647
+ if getattr(args, "emit_json", False):
648
+ print(
649
+ json.dumps({"alerts": {"projected_enabled": normalized}}, indent=2)
650
+ )
651
+ else:
652
+ print(
653
+ f"alerts.projected_enabled={'true' if normalized else 'false'}"
654
+ )
655
+ return 0
601
656
  if key == "dashboard.bind":
602
657
  # Validation rejects whitespace / empty / non-string up front;
603
658
  # write proceeds under config_writer_lock with _load_config_unlocked
@@ -715,6 +770,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
715
770
  "budget.weekly_usd",
716
771
  "budget.alerts_enabled",
717
772
  "budget.alert_thresholds",
773
+ "budget.projected_enabled",
718
774
  ):
719
775
  inner_key = key.split(".", 1)[1]
720
776
  # Parse + normalize the raw value per key BEFORE acquiring the lock so
@@ -734,7 +790,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
734
790
  f"null, got {raw!r}"
735
791
  )
736
792
  return 2
737
- elif inner_key == "alerts_enabled":
793
+ elif inner_key in ("alerts_enabled", "projected_enabled"):
738
794
  lo = raw.strip().lower()
739
795
  if lo in ("true", "yes", "on", "1"):
740
796
  new_val = True
@@ -742,7 +798,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
742
798
  new_val = False
743
799
  else:
744
800
  eprint(
745
- "cctally config: budget.alerts_enabled must be a boolean, "
801
+ f"cctally config: budget.{inner_key} must be a boolean, "
746
802
  f"got {raw!r}"
747
803
  )
748
804
  return 2
@@ -817,19 +873,21 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
817
873
  save_config(config)
818
874
  # idempotent: silent on missing key
819
875
  return 0
820
- if key == "alerts.enabled":
876
+ if key in ("alerts.enabled", "alerts.projected_enabled"):
821
877
  # Mirror the display.tz branch: writer-lock + _load_config_unlocked
822
878
  # (NOT load_config — fcntl.flock is per-fd so re-entry would
823
879
  # self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
824
- # `enabled` key preserves any user-customized threshold lists
825
- # (`weekly_thresholds`, `five_hour_thresholds`); the read-time
826
- # validator (`_get_alerts_config`) re-applies the canonical
827
- # default of `enabled = False` for the missing key on next get.
880
+ # named key preserves any user-customized threshold lists
881
+ # (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
882
+ # enabled/projected_enabled toggle; the read-time validator
883
+ # (`_get_alerts_config`) re-applies the canonical default of `False`
884
+ # for the missing key on next get.
885
+ inner_key = key.split(".", 1)[1]
828
886
  with config_writer_lock():
829
887
  config = _load_config_unlocked()
830
888
  block = config.get("alerts")
831
- if isinstance(block, dict) and "enabled" in block:
832
- del block["enabled"]
889
+ if isinstance(block, dict) and inner_key in block:
890
+ del block[inner_key]
833
891
  if not block:
834
892
  config.pop("alerts", None)
835
893
  save_config(config)
@@ -889,6 +947,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
889
947
  "budget.weekly_usd",
890
948
  "budget.alerts_enabled",
891
949
  "budget.alert_thresholds",
950
+ "budget.projected_enabled",
892
951
  ):
893
952
  # Drop only the named leaf; preserve sibling budget.* keys (e.g.
894
953
  # unsetting weekly_usd keeps a customized alert_thresholds). If the
@@ -427,7 +427,12 @@ class _AlertsConfigError(ValueError):
427
427
  """Raised by _get_alerts_config on invalid alerts block."""
428
428
 
429
429
 
430
- _ALERTS_CONFIG_VALID_KEYS = {"enabled", "weekly_thresholds", "five_hour_thresholds"}
430
+ _ALERTS_CONFIG_VALID_KEYS = {
431
+ "enabled",
432
+ "weekly_thresholds",
433
+ "five_hour_thresholds",
434
+ "projected_enabled",
435
+ }
431
436
 
432
437
 
433
438
  def _validate_threshold_list(name: str, value: object) -> "list[int]":
@@ -499,10 +504,20 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
499
504
  five_hour = _validate_threshold_list(
500
505
  "five_hour_thresholds", block.get("five_hour_thresholds", [90, 95])
501
506
  )
507
+ # projected-pace opt-in (#121); default OFF so upgrades fire no surprise
508
+ # notifications. Bool-validated (NOT coerced) so a non-bool is a config
509
+ # error, not silently truthy.
510
+ projected_enabled = block.get("projected_enabled", False)
511
+ if not isinstance(projected_enabled, bool):
512
+ raise _AlertsConfigError(
513
+ f"alerts.projected_enabled must be a JSON boolean, got "
514
+ f"{type(projected_enabled).__name__}: {projected_enabled!r}"
515
+ )
502
516
  return {
503
517
  "enabled": enabled,
504
518
  "weekly_thresholds": weekly,
505
519
  "five_hour_thresholds": five_hour,
520
+ "projected_enabled": projected_enabled,
506
521
  }
507
522
 
508
523
 
@@ -517,8 +532,14 @@ _BUDGET_DEFAULTS = {
517
532
  "weekly_usd": None, # None = no budget (default)
518
533
  "alerts_enabled": True, # "on when set"
519
534
  "alert_thresholds": [90, 100],
535
+ "projected_enabled": False, # projected-pace opt-in (#121); default OFF
536
+ }
537
+ _BUDGET_CONFIG_VALID_KEYS = {
538
+ "weekly_usd",
539
+ "alerts_enabled",
540
+ "alert_thresholds",
541
+ "projected_enabled",
520
542
  }
521
- _BUDGET_CONFIG_VALID_KEYS = {"weekly_usd", "alerts_enabled", "alert_thresholds"}
522
543
 
523
544
 
524
545
  def _get_budget_config(cfg: dict) -> dict:
@@ -579,6 +600,12 @@ def _get_budget_config(cfg: dict) -> dict:
579
600
  cleaned.append(t)
580
601
  out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
581
602
 
603
+ if "projected_enabled" in block:
604
+ v = block["projected_enabled"]
605
+ if not isinstance(v, bool):
606
+ raise _BudgetConfigError("budget.projected_enabled must be a boolean")
607
+ out["projected_enabled"] = v
608
+
582
609
  return out
583
610
 
584
611
 
@@ -1027,6 +1054,37 @@ def open_db() -> sqlite3.Connection:
1027
1054
  """
1028
1055
  )
1029
1056
 
1057
+ # ── projected_milestones (week-average-pace projection crossings — #121) ──
1058
+ # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — same
1059
+ # posture as `budget_milestones` (write-once, forward-only, no
1060
+ # `reset_event_id` segment column). Two metrics share the table, keyed by
1061
+ # `metric` ('weekly_pct' | 'budget_usd'); a level fires once the
1062
+ # WEEK-AVERAGE projection (not the displayed high-end verdict) crosses
1063
+ # `threshold`. `denominator` snapshots the target AT crossing (target_usd
1064
+ # for budget_usd, 100.0 for weekly_pct) so the dashboard envelope renders
1065
+ # context "$312 of $300" / "102% of cap" from the ROW, not from live config
1066
+ # that may have changed since (Codex P0-4). A mid-week reset re-anchors
1067
+ # `week_start_at` (new window → fresh rows under the UNIQUE key), the
1068
+ # budget-pattern reset handling — hence NO `reset_event_id` column.
1069
+ # `alerted_at` is stamped BEFORE the osascript Popen (set-then-dispatch).
1070
+ # Lives BEFORE the migration dispatcher: a plain CREATE on a
1071
+ # framework-untracked table never touches `schema_migrations`.
1072
+ conn.execute(
1073
+ """
1074
+ CREATE TABLE IF NOT EXISTS projected_milestones (
1075
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1076
+ week_start_at TEXT NOT NULL,
1077
+ metric TEXT NOT NULL, -- 'weekly_pct' | 'budget_usd'
1078
+ threshold INTEGER NOT NULL, -- 90 | 100
1079
+ projected_value REAL NOT NULL,
1080
+ denominator REAL NOT NULL, -- target_usd (budget) | 100.0 (weekly)
1081
+ crossed_at_utc TEXT NOT NULL,
1082
+ alerted_at TEXT,
1083
+ UNIQUE(week_start_at, metric, threshold)
1084
+ )
1085
+ """
1086
+ )
1087
+
1030
1088
  # Migration framework dispatcher. Replaces the prior inline gate stack
1031
1089
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1032
1090
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers