cctally 1.21.3 → 1.22.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,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.22.0] - 2026-05-29
9
+
10
+ ### Added
11
+ - **`cctally budget` — a weekly equivalent-$ budget with a live pace projection.** Set a per-subscription-week target in dollars (`cctally budget set 300`, clear with `cctally budget unset`) and `cctally budget` shows spend-so-far, percent-of-budget consumed, remaining dollars, current daily pace, and a low/high end-of-week projection band (week-average pace vs. trailing-24h pace), with an `ok`/`warn`/`over` verdict and a `LOW CONF` note early in the week. Spend is computed live from your session data via the same path `weekly`/`forecast` use (so embedded-pricing edits take effect immediately), scoped to Claude only (the subscription week is an Anthropic concept). Supports `--json` (`schemaVersion: 1`) and the full shareable-output surface (`--format md|html|svg`, `--theme`, `--no-branding`, `--output`, `--copy`, `--open`; `--reveal-projects` is a no-op since budget has no per-project rows). `--config PATH` is read-only — `set`/`unset` always write the default config. See `docs/commands/budget.md`.
12
+ - **Actual-spend budget alerts.** When live spend crosses a configured budget threshold (default 90% and 100% of the weekly target), cctally fires a desktop notification — recorded fire-once per `(week, threshold)` in a new `budget_milestones` table and dispatched set-then-dispatch like the existing weekly/5h percent alerts. Alerts are forward-only from the moment you set a budget (a back-set target reconciles existing crossings as already-alerted so it never floods you with retroactive popups), and a mid-week quota reset re-anchors the budget window so the new week's crossings fire fresh. `cctally alerts test --axis budget` previews the notification without writing to the DB.
13
+ - **`budget` config block** (`budget.weekly_usd` / `budget.alerts_enabled` / `budget.alert_thresholds`) — its own validated config block (distinct from the `alerts` block), settable via `cctally config set budget.weekly_usd 300` / `config set budget.alert_thresholds 90,100` / `config set budget.alerts_enabled false` and readable via `config get`, with thresholds constrained to `[1, 100]` and a positive-finite weekly target (or `null` to clear). Alerts are "on when a budget is set" by default.
14
+ - **Dashboard Recent-alerts gains a third `budget` axis.** Budget threshold crossings now surface in the existing Recent-alerts panel, modal, and toast alongside the weekly and 5h-block axes — a distinct `BUDGET` chip (green accent), an "$X of $Y budget" body line, a "Week of …" + "% of budget" context cell, and the actual-spend figure in the Cost column; the dashboard `alerts_settings` mirror now carries `budget_thresholds` + `budget_enabled` (sourced from the `budget` config block, not `alerts`), the Settings overlay shows the active budget thresholds, and `POST /api/settings` validates and persists an inbound `budget` block.
15
+ - **`cctally pricing-check` — proactive detection of stale or missing embedded model pricing.** cctally prices every session locally from the embedded `CLAUDE_MODEL_PRICING` / `CODEX_MODEL_PRICING` tables, so an unrecognized Claude model silently contributes $0 (a silent undercount) and an unrecognized Codex model is only approximated via the `gpt-5` fallback; this command surfaces those gaps before anyone has to reconcile by hand. It runs three independently-degrading legs: **coverage** (offline, all-history — models in your cached session data cctally can't price exactly), **drift** (network, LiteLLM — embedded price values vs the LiteLLM snapshot, direction-aware so a model we price but LiteLLM lacks is never flagged, and suppressible via a non-vacuity-guarded `PRICING_DRIFT_ALLOWLIST`), and **existence** (network, Anthropic `/v1/models` — vendor models the API offers that our table lacks; Anthropic-only and maintainer-local since it needs an OAuth bearer). `--json` emits a `schemaVersion: 1` payload; `--offline` runs coverage only. Exit codes follow a strict precedence — any actionable finding exits 1 even when a network leg degraded (findings always win over degradation), a clean-or-degraded-but-no-findings run exits 0, and the orthogonal `"status"` field reports whether the check was complete. See `docs/commands/pricing-check.md`.
16
+ - **`cctally doctor` now has a `pricing.coverage` check** that WARNs when your recent (trailing 30-day) session data contains a model cctally cannot price exactly — a Claude model resolving to $0 (`unpriced`) or a Codex model approximated via the `gpt-5` fallback (`fallback`) — listing each offending model ID with its entry count and token volume, with a remediation hint pointing at `cctally pricing-check` and the embedded pricing tables. It is OK when every observed model is priced (or when the cache is absent), the scan is strictly read-only (it never creates the data dir on a fresh HOME), and it rolls into the dashboard health chip/modal and the TUI for free as an ordinary doctor check. This is the offline, zero-network counterpart to `pricing-check`'s coverage leg.
17
+ - **`PRICING_SNAPSHOT_DATE` constant** (alongside `PRICING_STALENESS_DAYS`, default 60) replaces the bare `# Captured:` provenance comments beside the pricing tables, so the staleness heuristic and the release pre-flight read structured data; bump it whenever the embedded pricing tables are synced against the vendor.
18
+
19
+ ### Changed
20
+ - **A weekly `pricing-freshness` GitHub Action** now runs `cctally pricing-check --json` on a schedule and maintains exactly one auto-managed `pricing-drift`-labeled tracking issue — it opens or updates the issue (with a value-drift table, a missing-from-us list, and a remediation checklist) when the embedded pricing diverges from LiteLLM, and auto-closes it with a "resolved as of" note when the drift clears. The workflow runs only in the source repo (never on the public mirror), uses least-privilege permissions, pins Python 3.13, and captures the check's exit code so a drift run (exit 1) proceeds to issue management instead of failing the job; the create/update/close/noop decision is a pure unit-tested function. The maintainer's release pre-flight additionally surfaces a soft, overridable advisory at cut time if there is drift, a coverage gap, or a stale pricing snapshot. The zero-lag `/v1/models` existence signal is maintainer-local only (the cron has no OAuth and auto-degrades to LiteLLM drift), so the local coverage guard remains the day-0 backstop for new-model existence.
21
+
22
+ ### Documentation
23
+ - Clarified why `cctally codex <cmd>` (`daily`/`monthly`/`weekly`/`session`) reports lower token totals and cost than upstream `ccusage-codex` on older sessions — and that **cctally, not `ccusage-codex`, is the accurate one.** Older Codex CLI versions re-emit duplicate `event_msg.token_count` records (identical `last_token_usage` while the cumulative `info.total_token_usage.total_tokens` ledger stays flat); `ccusage-codex` sums every emission (up to ~2× overcount on the oldest sessions) while cctally dedups by only counting events whose cumulative advances, exactly reconstructing the Codex CLI's own authoritative total. Recent sessions don't re-emit and match upstream byte-for-byte, so a per-month comparison trends from ~0.5× on old months toward ~1.0× on recent ones. The canonical divergence note in `docs/commands/codex-daily.md` now states which tool is correct and how to verify it per session, with a discoverability pointer added to the `codex` subgroup overview (`docs/commands/codex.md`). This reconciliation is now locked by a new `bin/cctally-reconcile-test` invariant `codex_dedup_eq_codex_ground_truth`: it asserts `codex-daily`'s reported `totalTokens` equals the sum of each fixture session's final `total_token_usage.total_tokens` (an independent raw-JSONL scan that shares no aggregation code with the CLI) and carries a non-vacuity guard requiring the naive all-events sum to strictly exceed the deduped ground truth, so a regression that silently disabled the dedup guard would fail the suite.
24
+
8
25
  ## [1.21.3] - 2026-05-29
9
26
 
10
27
  ### Fixed
@@ -68,9 +68,11 @@ def _load_lib(name: str):
68
68
  _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
+ _alert_text_budget = _lib_alerts_payload._alert_text_budget
71
72
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
72
73
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
73
74
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
75
+ _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
74
76
 
75
77
 
76
78
  # === Honest imports from extracted homes ===================================
@@ -134,6 +136,8 @@ def _dispatch_alert_notification(
134
136
  title, subtitle, body = _alert_text_weekly(payload, tz)
135
137
  elif axis == "five_hour":
136
138
  title, subtitle, body = _alert_text_five_hour(payload, tz)
139
+ elif axis == "budget":
140
+ title, subtitle, body = _alert_text_budget(payload, tz)
137
141
  else:
138
142
  title, subtitle, body = (
139
143
  "cctally - alert",
@@ -168,6 +172,7 @@ def _dispatch_alert_notification(
168
172
  window_key = (
169
173
  ctx.get("week_start_date")
170
174
  or ctx.get("five_hour_window_key")
175
+ or ctx.get("week_start_at")
171
176
  or ""
172
177
  )
173
178
  line = (
@@ -198,8 +203,16 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
198
203
  2 --threshold out of [1, 100] range
199
204
  3 other spawn error (PermissionError, OSError, ...)
200
205
  """
201
- axis = "weekly" if args.axis == "weekly" else "five_hour"
206
+ if args.axis == "weekly":
207
+ axis = "weekly"
208
+ elif args.axis == "budget":
209
+ axis = "budget"
210
+ else:
211
+ axis = "five_hour"
202
212
  threshold = int(args.threshold)
213
+ # --threshold range stays [1, 100] (F5): the cap is axis-uniform with the
214
+ # existing weekly/5h thresholds. Over-budget tiers (>100%) are a v2
215
+ # deferral, not an oversight — see spec §2 (F5).
203
216
  if not (1 <= threshold <= 100):
204
217
  print(
205
218
  f"cctally: --threshold must be in [1, 100], got {threshold}",
@@ -214,6 +227,18 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
214
227
  cumulative_cost_usd=1.23,
215
228
  dollars_per_percent=0.01,
216
229
  )
230
+ elif axis == "budget":
231
+ # Synthetic budget payload — NO DB writes (test/real divergence
232
+ # contract). spent scaled to the threshold so the body line reads
233
+ # plausibly (e.g. 100% → $300 of $300).
234
+ payload = _build_alert_payload_budget(
235
+ threshold=threshold,
236
+ crossed_at_utc=now_utc_iso(),
237
+ week_start_at=dt.date.today().isoformat(),
238
+ budget_usd=300.0,
239
+ spent_usd=300.0 * threshold / 100.0,
240
+ consumption_pct=float(threshold),
241
+ )
217
242
  else:
218
243
  payload = _build_alert_payload_five_hour(
219
244
  threshold=threshold,
@@ -73,6 +73,8 @@ from _cctally_core import (
73
73
  DEFAULT_WEEK_START,
74
74
  _get_alerts_config,
75
75
  _AlertsConfigError,
76
+ _get_budget_config,
77
+ _BudgetConfigError,
76
78
  )
77
79
  from _lib_display_tz import normalize_display_tz_value
78
80
 
@@ -301,6 +303,9 @@ ALLOWED_CONFIG_KEYS = (
301
303
  "statusline.visual_burn_rate",
302
304
  "statusline.cost_source",
303
305
  "statusline.cctally_extensions",
306
+ "budget.weekly_usd",
307
+ "budget.alerts_enabled",
308
+ "budget.alert_thresholds",
304
309
  )
305
310
 
306
311
 
@@ -463,6 +468,25 @@ def _config_known_value(config: dict, key: str) -> "object":
463
468
  except ValueError:
464
469
  # Hand-edited junk: surface the default — mirrors dashboard.bind.
465
470
  return defaults[inner]
471
+ if key in (
472
+ "budget.weekly_usd",
473
+ "budget.alerts_enabled",
474
+ "budget.alert_thresholds",
475
+ ):
476
+ inner = key.split(".", 1)[1]
477
+ # Read the validated, defaults-filled block. A corrupt block falls
478
+ # back to the canonical default leaf (mirrors alerts.enabled /
479
+ # dashboard.bind, which surface the default on a hand-edited junk
480
+ # block rather than erroring out of a plain `config get`).
481
+ try:
482
+ return _get_budget_config(config)[inner]
483
+ except _BudgetConfigError:
484
+ from _cctally_core import _BUDGET_DEFAULTS
485
+
486
+ default = _BUDGET_DEFAULTS[inner]
487
+ if isinstance(default, list):
488
+ return list(default)
489
+ return default
466
490
  return None
467
491
 
468
492
 
@@ -500,6 +524,10 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
500
524
  # round-trips via `config set alerts.enabled <plain-text>` work.
501
525
  if isinstance(v, bool):
502
526
  rendered = "true" if v else "false"
527
+ elif isinstance(v, list):
528
+ # Comma-joined so `config get budget.alert_thresholds` output
529
+ # round-trips through `config set budget.alert_thresholds`.
530
+ rendered = ",".join(str(x) for x in v)
503
531
  else:
504
532
  rendered = str(v)
505
533
  print(f"{k}={rendered}")
@@ -683,6 +711,93 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
683
711
  rendered = str(normalized)
684
712
  print(f"{key}={rendered}")
685
713
  return 0
714
+ if key in (
715
+ "budget.weekly_usd",
716
+ "budget.alerts_enabled",
717
+ "budget.alert_thresholds",
718
+ ):
719
+ inner_key = key.split(".", 1)[1]
720
+ # Parse + normalize the raw value per key BEFORE acquiring the lock so
721
+ # rejection short-circuits. The whole merged block is re-validated via
722
+ # _get_budget_config under the lock so we never persist a config that
723
+ # fails subsequent reads. _load_config_unlocked is mandatory inside the
724
+ # writer lock (load_config would self-deadlock on the same fcntl fd).
725
+ if inner_key == "weekly_usd":
726
+ if raw.strip().lower() in {"null", "none", ""}:
727
+ new_val: object = None
728
+ else:
729
+ try:
730
+ new_val = float(raw)
731
+ except ValueError:
732
+ eprint(
733
+ "cctally config: budget.weekly_usd must be a number or "
734
+ f"null, got {raw!r}"
735
+ )
736
+ return 2
737
+ elif inner_key == "alerts_enabled":
738
+ lo = raw.strip().lower()
739
+ if lo in ("true", "yes", "on", "1"):
740
+ new_val = True
741
+ elif lo in ("false", "no", "off", "0"):
742
+ new_val = False
743
+ else:
744
+ eprint(
745
+ "cctally config: budget.alerts_enabled must be a boolean, "
746
+ f"got {raw!r}"
747
+ )
748
+ return 2
749
+ else: # alert_thresholds — comma-separated int list (empty = silenced)
750
+ stripped = raw.strip()
751
+ parsed: "list[int]" = []
752
+ if stripped:
753
+ for part in stripped.split(","):
754
+ tok = part.strip()
755
+ try:
756
+ parsed.append(int(tok))
757
+ except ValueError:
758
+ eprint(
759
+ "cctally config: budget.alert_thresholds must be a "
760
+ f"comma-separated list of integers, got {raw!r}"
761
+ )
762
+ return 2
763
+ new_val = parsed
764
+ with config_writer_lock():
765
+ config = _load_config_unlocked()
766
+ existing = config.get("budget")
767
+ if existing is not None and not isinstance(existing, dict):
768
+ eprint("cctally config: budget must be an object")
769
+ return 2
770
+ block = dict(existing or {})
771
+ block[inner_key] = new_val
772
+ config["budget"] = block
773
+ try:
774
+ validated = _get_budget_config(config)
775
+ except _BudgetConfigError as exc:
776
+ eprint(f"cctally config: {exc}")
777
+ return 2
778
+ # Persist the canonicalized leaf (e.g. sorted/deduped thresholds,
779
+ # float-coerced weekly_usd) so config.json matches what reads apply.
780
+ block[inner_key] = validated[inner_key]
781
+ config["budget"] = block
782
+ save_config(config)
783
+ # Forward-only reconcile (mirrors `budget set`): enabling/raising a
784
+ # budget while already past a threshold must record the crossed
785
+ # thresholds as already-alerted so the next record-usage tick does NOT
786
+ # dispatch retroactive alerts. Runs OUTSIDE config_writer_lock — the
787
+ # helper opens stats.db and must not nest under the config lock
788
+ # (fcntl.flock is per-fd; the helper has its own open_db locking).
789
+ c = _cctally()
790
+ c._reconcile_budget_on_config_write(validated)
791
+ out_val = validated[inner_key]
792
+ if getattr(args, "emit_json", False):
793
+ print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
794
+ else:
795
+ if isinstance(out_val, bool):
796
+ rendered = "true" if out_val else "false"
797
+ else:
798
+ rendered = str(out_val)
799
+ print(f"{key}={rendered}")
800
+ return 0
686
801
  return 2 # unreachable given the gate above
687
802
 
688
803
 
@@ -770,4 +885,24 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
770
885
  save_config(config)
771
886
  # idempotent: silent on missing key
772
887
  return 0
888
+ if key in (
889
+ "budget.weekly_usd",
890
+ "budget.alerts_enabled",
891
+ "budget.alert_thresholds",
892
+ ):
893
+ # Drop only the named leaf; preserve sibling budget.* keys (e.g.
894
+ # unsetting weekly_usd keeps a customized alert_thresholds). If the
895
+ # `budget` block ends up empty, drop the parent so config.json stays
896
+ # tidy. Mirrors the alerts.enabled / dashboard.bind unset branches.
897
+ inner_key = key.split(".", 1)[1]
898
+ with config_writer_lock():
899
+ config = _load_config_unlocked()
900
+ block = config.get("budget")
901
+ if isinstance(block, dict) and inner_key in block:
902
+ del block[inner_key]
903
+ if not block:
904
+ config.pop("budget", None)
905
+ save_config(config)
906
+ # idempotent: silent on missing key
907
+ return 0
773
908
  return 2 # unreachable given the gate above
@@ -12,6 +12,7 @@ below. See docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md.
12
12
  """
13
13
  from __future__ import annotations
14
14
  import datetime as dt
15
+ import math
15
16
  import os
16
17
  import pathlib
17
18
  import re
@@ -505,6 +506,89 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
505
506
  }
506
507
 
507
508
 
509
+ # === Budget validation cluster ======================================
510
+
511
+
512
+ class _BudgetConfigError(ValueError):
513
+ """Raised by _get_budget_config on an invalid budget block."""
514
+
515
+
516
+ _BUDGET_DEFAULTS = {
517
+ "weekly_usd": None, # None = no budget (default)
518
+ "alerts_enabled": True, # "on when set"
519
+ "alert_thresholds": [90, 100],
520
+ }
521
+ _BUDGET_CONFIG_VALID_KEYS = {"weekly_usd", "alerts_enabled", "alert_thresholds"}
522
+
523
+
524
+ def _get_budget_config(cfg: dict) -> dict:
525
+ """Return the validated, defaults-filled budget block.
526
+
527
+ Raises _BudgetConfigError on invalid values. Unknown sub-keys emit a
528
+ one-line warn-and-ignore (mirrors _get_alerts_config / the display.tz
529
+ posture for forward compatibility).
530
+ """
531
+ out = dict(_BUDGET_DEFAULTS)
532
+ out["alert_thresholds"] = list(_BUDGET_DEFAULTS["alert_thresholds"])
533
+ block = cfg.get("budget") if isinstance(cfg, dict) else None
534
+ if block is None:
535
+ return out
536
+ if not isinstance(block, dict):
537
+ raise _BudgetConfigError(
538
+ f"budget must be an object, got {type(block).__name__}"
539
+ )
540
+ # warn-and-ignore unknown keys (forward compat; matches _get_alerts_config)
541
+ for k in block.keys():
542
+ if k not in _BUDGET_CONFIG_VALID_KEYS:
543
+ print(
544
+ f"warning: ignoring unknown budget config key: {k}",
545
+ file=sys.stderr,
546
+ )
547
+
548
+ if "weekly_usd" in block:
549
+ v = block["weekly_usd"]
550
+ if v is None:
551
+ out["weekly_usd"] = None
552
+ elif isinstance(v, bool) or not isinstance(v, (int, float)):
553
+ raise _BudgetConfigError("budget.weekly_usd must be a number or null")
554
+ elif not math.isfinite(float(v)) or float(v) <= 0:
555
+ raise _BudgetConfigError("budget.weekly_usd must be a finite number > 0")
556
+ else:
557
+ out["weekly_usd"] = float(v)
558
+
559
+ if "alerts_enabled" in block:
560
+ v = block["alerts_enabled"]
561
+ if not isinstance(v, bool):
562
+ raise _BudgetConfigError("budget.alerts_enabled must be a boolean")
563
+ out["alerts_enabled"] = v
564
+
565
+ if "alert_thresholds" in block:
566
+ v = block["alert_thresholds"]
567
+ if not isinstance(v, list):
568
+ raise _BudgetConfigError("budget.alert_thresholds must be a list of ints")
569
+ cleaned = []
570
+ for t in v:
571
+ if isinstance(t, bool) or not isinstance(t, int):
572
+ raise _BudgetConfigError(
573
+ "budget.alert_thresholds entries must be integers"
574
+ )
575
+ if t < 1 or t > 100:
576
+ raise _BudgetConfigError(
577
+ "budget.alert_thresholds entries must be in [1, 100]"
578
+ )
579
+ cleaned.append(t)
580
+ out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
581
+
582
+ return out
583
+
584
+
585
+ def _budget_alerts_active(budget_cfg: dict) -> bool:
586
+ """True iff a budget is set AND alerts are enabled."""
587
+ return budget_cfg.get("weekly_usd") is not None and bool(
588
+ budget_cfg.get("alerts_enabled")
589
+ )
590
+
591
+
508
592
  # === DB primitive ===================================================
509
593
 
510
594
 
@@ -907,6 +991,42 @@ def open_db() -> sqlite3.Connection:
907
991
  """
908
992
  )
909
993
 
994
+ # ── budget_milestones (equiv-$ budget threshold crossings — issue #19) ──
995
+ # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
996
+ # exact posture of `five_hour_milestones` (write-once, forward-only). A
997
+ # mid-week quota reset re-anchors `week_start_at` (see
998
+ # `_resolve_current_budget_window`), so the new window naturally gets
999
+ # fresh rows under UNIQUE(week_start_at, threshold) — no `reset_event_id`
1000
+ # segment column needed (unlike the percent/5h tables). `week_start_at`
1001
+ # stores the effective/re-anchored ISO string from the resolver
1002
+ # (`isoformat(timespec="seconds")`); the resolver's `parse_iso_datetime`
1003
+ # returns a HOST-LOCAL tz-aware datetime, so this dedup key carries the
1004
+ # host's UTC offset (e.g. `…T07:00:00-07:00`) — host-consistent, NOT
1005
+ # portable across hosts, same posture as `five_hour_blocks.block_start_at`.
1006
+ # Firing + reconcile + the dashboard envelope all read/write the identical
1007
+ # string on a given host, so the UNIQUE dedup is exact. `alerted_at` is stamped BEFORE the
1008
+ # osascript Popen (set-then-dispatch invariant); NULL = "recorded without
1009
+ # dispatch" (the forward-only-from-set reconcile path) OR "not yet
1010
+ # dispatched", never "delivery failed". Lives BEFORE the migration
1011
+ # dispatcher: a plain CREATE on a framework-untracked table never touches
1012
+ # `schema_migrations`, so the dispatcher's fresh-install snapshot is
1013
+ # unaffected.
1014
+ conn.execute(
1015
+ """
1016
+ CREATE TABLE IF NOT EXISTS budget_milestones (
1017
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1018
+ week_start_at TEXT NOT NULL,
1019
+ threshold INTEGER NOT NULL,
1020
+ budget_usd REAL NOT NULL,
1021
+ spent_usd REAL NOT NULL,
1022
+ consumption_pct REAL NOT NULL,
1023
+ crossed_at_utc TEXT NOT NULL,
1024
+ alerted_at TEXT,
1025
+ UNIQUE(week_start_at, threshold)
1026
+ )
1027
+ """
1028
+ )
1029
+
910
1030
  # Migration framework dispatcher. Replaces the prior inline gate stack
911
1031
  # (has_blocks + _migration_done) with the framework's _run_pending_-
912
1032
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers