cctally 1.22.4 → 1.24.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.
@@ -285,6 +285,38 @@ def insert_budget_milestone(*args, **kwargs):
285
285
  return sys.modules["cctally"].insert_budget_milestone(*args, **kwargs)
286
286
 
287
287
 
288
+ def insert_projected_milestone(*args, **kwargs):
289
+ return sys.modules["cctally"].insert_projected_milestone(*args, **kwargs)
290
+
291
+
292
+ def _projected_levels_already_latched(*args, **kwargs):
293
+ return sys.modules["cctally"]._projected_levels_already_latched(*args, **kwargs)
294
+
295
+
296
+ def _build_alert_payload_projected(*args, **kwargs):
297
+ return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
298
+
299
+
300
+ def _fetch_current_week_snapshots(*args, **kwargs):
301
+ return sys.modules["cctally"]._fetch_current_week_snapshots(*args, **kwargs)
302
+
303
+
304
+ def _apply_midweek_reset_override(*args, **kwargs):
305
+ return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
306
+
307
+
308
+ def _assess_forecast_confidence(*args, **kwargs):
309
+ return sys.modules["cctally"]._assess_forecast_confidence(*args, **kwargs)
310
+
311
+
312
+ def _build_budget_status_inputs(*args, **kwargs):
313
+ return sys.modules["cctally"]._build_budget_status_inputs(*args, **kwargs)
314
+
315
+
316
+ def compute_budget_status(*args, **kwargs):
317
+ return sys.modules["cctally"].compute_budget_status(*args, **kwargs)
318
+
319
+
288
320
  def _dispatch_alert_notification(*args, **kwargs):
289
321
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
290
322
 
@@ -788,6 +820,235 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
788
820
  eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
789
821
 
790
822
 
823
+ def _weekly_pct_week_avg_projection(conn, now_utc):
824
+ """Compute the week-AVERAGE weekly-% projection for the current
825
+ subscription week, snapshot-only (CHEAP — no cost SUM, no ``sync_cache``).
826
+
827
+ Returns ``(projected_pct, low_conf)`` or ``None`` when no current-week
828
+ snapshot resolves. The value is computed by the IDENTICAL formula +
829
+ IDENTICAL inputs that produce ``ForecastOutput.week_avg_projection_pct``
830
+ (``_load_forecast_inputs`` → ``_compute_forecast``): ``p_now`` / elapsed /
831
+ remaining come from ``_fetch_current_week_snapshots`` +
832
+ ``_apply_midweek_reset_override`` (the same reset-aware window resolution
833
+ forecast uses), ``r_avg = p_now / elapsed_hours`` (``p_week_start`` treated
834
+ as 0, matching the forecast kernel), and
835
+ ``projected = p_now + r_avg * remaining_hours``. The reconcile invariant
836
+ binds the fired value to forecast's ``week_avg_projection_pct`` within
837
+ 1e-9, so the two MUST share the formula by construction.
838
+
839
+ LOW CONF mirrors the displayed forecast confidence: the binary
840
+ ``_assess_forecast_confidence(elapsed_hours, p_now, len(samples))`` plus the
841
+ ``no_sample_ge_24h`` clause ``_load_forecast_inputs`` appends — so a thin
842
+ early-week window that forecast renders ``LOW CONF`` never fires a
843
+ projected alert.
844
+
845
+ Deliberately does NOT call ``_sum_cost_for_range`` (the weekly-% projection
846
+ needs no spend; the forecast kernel's ``week_avg_projection_pct`` is also
847
+ spend-free).
848
+ """
849
+ fetched = _fetch_current_week_snapshots(conn, now_utc)
850
+ if fetched is None:
851
+ return None
852
+ week_start_at, week_end_at, samples = fetched
853
+ week_start_at, samples = _apply_midweek_reset_override(
854
+ conn, week_start_at, week_end_at, samples
855
+ )
856
+ if not samples:
857
+ return None
858
+ p_now = samples[-1][1]
859
+ elapsed_hours = (now_utc - week_start_at).total_seconds() / 3600.0
860
+ remaining_hours = max(0.0, (week_end_at - now_utc).total_seconds() / 3600.0)
861
+ r_avg = p_now / elapsed_hours if elapsed_hours > 0 else 0.0
862
+ projected_pct = p_now + r_avg * remaining_hours
863
+
864
+ # Confidence: same predicate + the same no_sample_ge_24h augmentation that
865
+ # _load_forecast_inputs applies, so this LOW CONF gate == forecast's.
866
+ confidence, _reasons = _assess_forecast_confidence(
867
+ elapsed_hours, p_now, len(samples)
868
+ )
869
+ target_24h = now_utc - dt.timedelta(hours=24)
870
+ has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
871
+ if not has_sample_ge_24h:
872
+ confidence = "low"
873
+ return (projected_pct, confidence == "low")
874
+
875
+
876
+ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
877
+ """Projected-pace detect-and-arm (axis ``projected``, #121).
878
+
879
+ Fires on the WEEK-AVERAGE projection (never the displayed high-end verdict
880
+ band) for ``weekly_pct`` and/or ``budget_usd``. Its OWN detect-and-arm —
881
+ NOT folded into ``maybe_record_milestone`` (Section 1 / Codex P0-3) — called
882
+ from ``cmd_record_usage`` in its own ``try`` after the weekly/5h/budget
883
+ blocks.
884
+
885
+ Master gates (Codex P1-2): ``weekly_pct`` fires only under
886
+ ``alerts.enabled && alerts.projected_enabled``; ``budget_usd`` only under
887
+ ``_budget_alerts_active(budget_cfg) && budget.projected_enabled``. Both
888
+ toggles default OFF (no surprise notifications on upgrade). When NEITHER is
889
+ on, returns after only a cheap config read — no projection math, no cost
890
+ work.
891
+
892
+ Pre-probe (Codex P1-1): a metric whose levels are ALL already latched is
893
+ skipped BEFORE any projection / cost work.
894
+
895
+ Snap-up (Codex P2-1): a level fires when ``projected + 1e-9 >= threshold``.
896
+ Latch / fire-once: ``UNIQUE(week_start_at, metric, threshold)`` + the
897
+ rowcount==1 predicate — a later recovery neither un-fires nor re-fires.
898
+ Mid-week reset re-anchors ``week_start_at`` (budget pattern; no
899
+ ``reset_event_id``).
900
+
901
+ Set-then-dispatch: INSERT ``commit=False``, stamp ``alerted_at`` in the same
902
+ txn, commit, THEN best-effort dispatch. A dispatch failure never rolls back
903
+ the milestone.
904
+
905
+ The ``budget_usd`` leg reuses the SAME ``_build_budget_status_inputs`` +
906
+ ``compute_budget_status`` path that produces ``budget --json``'s
907
+ ``week_avg_projection_usd`` (the reconcile-bound field) — value-exact by
908
+ construction. The cache is already warm from the actual-budget axis's spend
909
+ SUM this same tick, so this is not a second aggregation pass; the pre-probe
910
+ additionally skips it entirely when all budget levels are latched.
911
+ """
912
+ # The `projected_enabled` toggles are validated keys on the alerts/budget
913
+ # blocks (bool-validated; default OFF), so read them straight off the
914
+ # validated getter dicts — no raw-block fallback (which would re-emit the
915
+ # "unknown alerts config key" warning every tick and bypass bool
916
+ # validation). Master gates still compose with the parent-axis predicates.
917
+ cfg = load_config()
918
+ try:
919
+ alerts_cfg = _get_alerts_config(cfg)
920
+ except _AlertsConfigError as exc:
921
+ _warn_alerts_bad_config_once(exc)
922
+ alerts_cfg = {"enabled": False, "projected_enabled": False}
923
+ try:
924
+ budget_cfg = _get_budget_config(cfg)
925
+ except _BudgetConfigError as exc:
926
+ _warn_budget_bad_config_once(exc)
927
+ budget_cfg = {}
928
+
929
+ weekly_on = bool(alerts_cfg.get("enabled")) and bool(
930
+ alerts_cfg.get("projected_enabled")
931
+ )
932
+ budget_on = _budget_alerts_active(budget_cfg) and bool(
933
+ budget_cfg.get("projected_enabled")
934
+ )
935
+ if not (weekly_on or budget_on):
936
+ return # cheap config-only path — non-projected users pay nothing
937
+
938
+ now_utc = _command_as_of()
939
+ pending: list[dict[str, Any]] = []
940
+ conn = open_db()
941
+ try:
942
+ # ── weekly_pct leg (snapshot-only, cheap) ───────────────────────────
943
+ if weekly_on:
944
+ w_window = _fetch_current_week_snapshots(conn, now_utc)
945
+ if w_window is not None:
946
+ ws_at, we_at, samples = w_window
947
+ ws_at, _ = _apply_midweek_reset_override(
948
+ conn, ws_at, we_at, samples
949
+ )
950
+ week_key = ws_at.isoformat(timespec="seconds")
951
+ levels = (90, 100)
952
+ if not _projected_levels_already_latched(
953
+ conn, week_start_at=week_key, metric="weekly_pct",
954
+ levels=levels,
955
+ ):
956
+ proj = _weekly_pct_week_avg_projection(conn, now_utc)
957
+ if proj is not None and not proj[1]:
958
+ value = proj[0]
959
+ for t in levels:
960
+ if value + 1e-9 >= t:
961
+ pending.append(dict(
962
+ week_start_at=week_key,
963
+ metric="weekly_pct",
964
+ threshold=t,
965
+ projected_value=value,
966
+ denominator=100.0,
967
+ ))
968
+
969
+ # ── budget_usd leg (reuses the tick's spend via the shared path) ─────
970
+ if budget_on:
971
+ target = budget_cfg["weekly_usd"]
972
+ thresholds = tuple(
973
+ sorted(set(int(t) for t in budget_cfg["alert_thresholds"]))
974
+ )
975
+ window = _resolve_current_budget_window(conn, now_utc)
976
+ if window is not None and thresholds:
977
+ b_ws_at, _b_we_at = window
978
+ b_week_key = b_ws_at.isoformat(timespec="seconds")
979
+ if not _projected_levels_already_latched(
980
+ conn, week_start_at=b_week_key, metric="budget_usd",
981
+ levels=thresholds,
982
+ ):
983
+ # skip_sync=True: the actual-budget axis
984
+ # (maybe_record_budget_milestone) already ran a
985
+ # _sum_cost_for_range this same tick, warming the cache.
986
+ # Avoids a redundant JSONL ingest pass here.
987
+ inputs = _build_budget_status_inputs(
988
+ conn, target_usd=target, now_utc=now_utc,
989
+ alert_thresholds=thresholds, skip_sync=True,
990
+ )
991
+ if inputs is not None:
992
+ status = compute_budget_status(inputs)
993
+ if not status.low_confidence:
994
+ value = status.week_avg_projection_usd
995
+ for t in thresholds:
996
+ if value + 1e-9 >= (t / 100.0) * float(target):
997
+ pending.append(dict(
998
+ week_start_at=b_week_key,
999
+ metric="budget_usd",
1000
+ threshold=t,
1001
+ projected_value=value,
1002
+ denominator=float(target),
1003
+ ))
1004
+
1005
+ # ── arm (set-then-dispatch): INSERT + stamp alerted_at in one txn ────
1006
+ fired: list[dict[str, Any]] = []
1007
+ for p in pending:
1008
+ inserted = insert_projected_milestone(
1009
+ conn,
1010
+ week_start_at=p["week_start_at"],
1011
+ metric=p["metric"],
1012
+ threshold=p["threshold"],
1013
+ projected_value=p["projected_value"],
1014
+ denominator=p["denominator"],
1015
+ commit=False,
1016
+ )
1017
+ # Only the genuine-new-crossing winner (rowcount==1) arms+dispatches;
1018
+ # a racing record-usage instance gets rowcount==0 and skips.
1019
+ if inserted == 1:
1020
+ conn.execute(
1021
+ "UPDATE projected_milestones SET alerted_at = ? "
1022
+ "WHERE week_start_at = ? AND metric = ? AND threshold = ? "
1023
+ " AND alerted_at IS NULL",
1024
+ (now_utc_iso(), p["week_start_at"], p["metric"],
1025
+ p["threshold"]),
1026
+ )
1027
+ fired.append(p)
1028
+ # Single commit: every INSERT + its alerted_at marker durable together.
1029
+ conn.commit()
1030
+ except Exception as exc:
1031
+ eprint(f"[projected-alert] error recording projected milestone: {exc}")
1032
+ fired = []
1033
+ finally:
1034
+ conn.close()
1035
+
1036
+ # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
1037
+ # (set-then-dispatch invariant).
1038
+ for p in fired:
1039
+ try:
1040
+ payload = _build_alert_payload_projected(
1041
+ metric=p["metric"],
1042
+ threshold=p["threshold"],
1043
+ projected_value=p["projected_value"],
1044
+ denominator=p["denominator"],
1045
+ week_start_at=p["week_start_at"],
1046
+ )
1047
+ _dispatch_alert_notification(payload, mode="real")
1048
+ except Exception as dispatch_exc:
1049
+ eprint(f"[projected-alert] dispatch failed: {dispatch_exc}")
1050
+
1051
+
791
1052
  def _compute_block_totals(
792
1053
  block_start_at: dt.datetime,
793
1054
  range_end: dt.datetime,
@@ -1434,6 +1695,134 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
1434
1695
  conn.close()
1435
1696
 
1436
1697
 
1698
+ # ── Reset-to-zero debounce marker (issue #128) ─────────────────────────────
1699
+ # A transient Anthropic OAuth zero (cold replica / outage) against non-trivial
1700
+ # usage would otherwise mis-fire the live in-place reset-to-zero detector. We
1701
+ # debounce: the first ~0 ARMS this marker (it does not fire); the next reading
1702
+ # CONFIRMS (fires) only if usage stayed low, or CLEARS on recovery toward the
1703
+ # baseline. The marker is needed because the write-site clamp suppresses the
1704
+ # deferred first zero, so it leaves no DB trace. Losing the marker is always
1705
+ # safe (a real reset re-arms and fires one tick later). Best-effort file I/O —
1706
+ # the detector must never crash on a marker hiccup. See
1707
+ # docs/superpowers/specs/2026-06-02-reset-zero-debounce-design.md.
1708
+ _RESET_ZERO_MARKER_NAME = "pending-reset-zero-7d"
1709
+
1710
+
1711
+ def _reset_zero_marker_path():
1712
+ return _cctally_core.APP_DIR / _RESET_ZERO_MARKER_NAME
1713
+
1714
+
1715
+ def _arm_reset_zero_marker(week_start_date, cur_end_canon, *,
1716
+ baseline_pct, first_zero_iso):
1717
+ """Persist the pending reset-to-zero candidate. ``first_zero_iso`` MUST be
1718
+ the ``_command_as_of()`` clock value (it becomes the effective anchor on
1719
+ confirm), NOT wall-clock."""
1720
+ try:
1721
+ _reset_zero_marker_path().write_text(
1722
+ f"{week_start_date} {cur_end_canon} "
1723
+ f"{float(baseline_pct)} {first_zero_iso}\n"
1724
+ )
1725
+ except OSError:
1726
+ pass
1727
+
1728
+
1729
+ def _clear_reset_zero_marker():
1730
+ try:
1731
+ _reset_zero_marker_path().unlink(missing_ok=True)
1732
+ except OSError:
1733
+ pass
1734
+
1735
+
1736
+ def _read_reset_zero_marker():
1737
+ """Return ``(week_start_date, cur_end_canon, baseline_pct, first_zero_iso)``
1738
+ or ``None`` when missing / empty / garbled. Validates ALL fields (arity,
1739
+ float baseline, parseable timestamp) so a malformed marker re-arms cleanly
1740
+ rather than wedging the confirm path."""
1741
+ try:
1742
+ raw = _reset_zero_marker_path().read_text().strip()
1743
+ except OSError:
1744
+ return None
1745
+ if not raw:
1746
+ return None
1747
+ parts = raw.split()
1748
+ if len(parts) != 4:
1749
+ return None
1750
+ week_start_date, cur_end_canon, baseline_raw, first_zero_iso = parts
1751
+ try:
1752
+ baseline_pct = float(baseline_raw)
1753
+ except ValueError:
1754
+ return None
1755
+ try:
1756
+ parse_iso_datetime(first_zero_iso, "reset_zero_marker.first_zero")
1757
+ except ValueError:
1758
+ return None
1759
+ return (week_start_date, cur_end_canon, baseline_pct, first_zero_iso)
1760
+
1761
+
1762
+ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
1763
+ *, observed_pre_credit_pct, effective_dt):
1764
+ """Emit/refresh the in-place weekly-credit artifacts (issue #19 + #128).
1765
+ Shared by the immediate >=25pp path and the debounced reset-to-zero
1766
+ confirmation path.
1767
+
1768
+ Side-effect ordering is load-bearing: the event-row INSERT is dedup-gated
1769
+ on a pre-check, but the hwm force-write and stale-replica DELETE run
1770
+ UNCONDITIONALLY — a prior run may have committed the event then died before
1771
+ the pivots (memory: project_dedup_must_not_gate_side_effects). The pivots
1772
+ are individually idempotent (file overwrite + DELETE on a stable predicate).
1773
+
1774
+ ``effective_dt`` is the (already-resolved) reset moment; the immediate path
1775
+ passes ``_floor_to_hour(now_utc)``, the debounced path passes the floored
1776
+ first-zero instant from the marker."""
1777
+ effective_iso = effective_dt.isoformat(timespec="seconds")
1778
+ # Pre-check keyed on new_week_end_at: suppress a duplicate event row across
1779
+ # ticks. UNIQUE(old, new) also dedups, but the pre-check avoids a useless
1780
+ # write attempt and keeps logs clean.
1781
+ already = conn.execute(
1782
+ "SELECT 1 FROM week_reset_events WHERE new_week_end_at = ? LIMIT 1",
1783
+ (cur_end_canon,),
1784
+ ).fetchone()
1785
+ if already is None:
1786
+ # Row shape: old=effective_iso, new=cur_end_canon (DISTINCT) so only
1787
+ # post_map fires on the credited week in _apply_reset_events_to_weekrefs
1788
+ # (old==new collapses it to a zero-width window). observed_pre_credit_pct
1789
+ # stamps the pre-credit baseline (issue #45).
1790
+ conn.execute(
1791
+ "INSERT OR IGNORE INTO week_reset_events "
1792
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
1793
+ " effective_reset_at_utc, observed_pre_credit_pct) "
1794
+ "VALUES (?, ?, ?, ?, ?)",
1795
+ (now_utc_iso(), effective_iso, cur_end_canon,
1796
+ effective_iso, float(observed_pre_credit_pct)),
1797
+ )
1798
+ conn.commit()
1799
+ # Unconditional pivot 1: force-write hwm-7d so the next status-line render
1800
+ # reflects the post-credit value (the monotonic guard at the normal write
1801
+ # site would refuse to decrease the file).
1802
+ try:
1803
+ (_cctally_core.APP_DIR / "hwm-7d").write_text(
1804
+ f"{week_start_date} {weekly_percent}\n"
1805
+ )
1806
+ except OSError:
1807
+ pass
1808
+ # Unconditional pivot 2: race-defensive cleanup of stale pre-credit replays
1809
+ # (external claude-statusline can replay pre-credit --percent values that
1810
+ # land captured_at >= effective with pct ~= baseline and dominate the
1811
+ # reset-aware clamp). 1.0pp tolerance band absorbs rounding drift; both
1812
+ # sides wrapped in unixepoch() for offset robustness.
1813
+ try:
1814
+ conn.execute(
1815
+ "DELETE FROM weekly_usage_snapshots "
1816
+ "WHERE week_start_date = ? "
1817
+ " AND unixepoch(captured_at_utc) >= unixepoch(?) "
1818
+ " AND ABS(weekly_percent - ?) < 1.0",
1819
+ (week_start_date, effective_iso, float(observed_pre_credit_pct)),
1820
+ )
1821
+ conn.commit()
1822
+ except sqlite3.DatabaseError as exc:
1823
+ eprint(f"[record-usage] post-credit cleanup failed: {exc}")
1824
+
1825
+
1437
1826
  def cmd_record_usage(args: argparse.Namespace) -> int:
1438
1827
  """Record usage data from Claude Code status line rate_limits."""
1439
1828
  c = _cctally()
@@ -1656,146 +2045,80 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1656
2045
  )
1657
2046
  conn.commit()
1658
2047
  elif prior_end_canon and prior_end_canon == cur_end_canon:
1659
- # In-place credit branch (v1.7.2). When `resets_at` stays
1660
- # unchanged but `weekly_percent` drops by RESET_PCT_DROP_THRESHOLD
1661
- # or more, Anthropic has issued a goodwill in-place weekly
1662
- # credit. Emit one week_reset_events row keyed on the
1663
- # current end_at (old == new) so the reset-aware clamp
1664
- # above and the milestone segment writer can pivot to
1665
- # the post-credit segment. The seed snapshot lands via
1666
- # the now-reset-aware clamp on this same call.
2048
+ # In-place credit branch (v1.7.2) + reset-to-zero debounce
2049
+ # (issue #128). Same end_at across two captures. A >=25pp drop
2050
+ # is a goodwill credit and fires immediately; a reset-to-zero
2051
+ # (post <= floor, 3..25pp drop) is debounced against a
2052
+ # transient API zero armed on the first ~0, confirmed only
2053
+ # if the next reading stays low (<= half the pre-zero
2054
+ # baseline), cleared on recovery toward baseline. The gate
2055
+ # drops the _is_reset_drop term so the recovery-clear path is
2056
+ # reachable. See the spec for the midpoint rationale.
1667
2057
  prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
1668
- if (
1669
- prior_end_dt > now_utc
1670
- and prior_pct is not None
1671
- and c._is_reset_drop(prior_pct, weekly_percent)
1672
- ):
1673
- # Pre-check (Q5 belt-and-suspenders): suppress duplicate
1674
- # event rows for the same new_week_end_at across
1675
- # consecutive ticks. UNIQUE(old, new) at the DDL
1676
- # also catches the duplicate in the (old == new) case,
1677
- # but the pre-check avoids a useless write attempt
1678
- # and keeps the log clean. After the seed lands at
1679
- # post-credit %, the next tick's `prior_pct` will be
1680
- # the post-credit value so the drop predicate alone
1681
- # also suffices — pre-check is belt-and-suspenders.
1682
- already = conn.execute(
1683
- "SELECT 1 FROM week_reset_events "
1684
- "WHERE new_week_end_at = ? LIMIT 1",
1685
- (cur_end_canon,),
1686
- ).fetchone()
1687
- effective_dt = _floor_to_hour(now_utc)
1688
- effective_iso = effective_dt.isoformat(timespec="seconds")
1689
- if already is None:
1690
- # Row shape: old=effective_iso, new=cur_end_canon
1691
- # (distinct values). The previous shape stored
1692
- # old==new==cur_end_canon, which let BOTH
1693
- # _apply_reset_events_to_weekrefs maps
1694
- # (pre_map[old] and post_map[new]) fire on the
1695
- # SAME WeekRef — pre_map rewrote week_end_at to
1696
- # effective, post_map rewrote week_start_at to
1697
- # effective, collapsing the credited week to a
1698
- # zero-width window in downstream renders. With
1699
- # old==effective and new==cur_end_canon, only
1700
- # post_map fires on the credited week (setting
1701
- # week_start_at = effective, the intended
1702
- # behavior); pre_map keys on effective_iso and
1703
- # finds no matching WeekRef in practice. The
1704
- # UNIQUE(old, new) constraint permits this
1705
- # row, and the pre-check above keys on
1706
- # new_week_end_at so dedup still works.
1707
- # Stamp ``observed_pre_credit_pct = prior_pct``
1708
- # (issue #45): durable record of the pre-credit
1709
- # baseline we observed at write time. Decouples
1710
- # any future cleanup tooling from re-deriving
1711
- # prior_pct via SELECT. Existing rows from
1712
- # migration 007 carry NULL.
1713
- conn.execute(
1714
- "INSERT OR IGNORE INTO week_reset_events "
1715
- "(detected_at_utc, old_week_end_at, new_week_end_at, "
1716
- " effective_reset_at_utc, observed_pre_credit_pct) "
1717
- "VALUES (?, ?, ?, ?, ?)",
1718
- (now_utc_iso(), effective_iso, cur_end_canon,
1719
- effective_iso, float(prior_pct)),
1720
- )
1721
- conn.commit()
1722
- # Pivots fire UNCONDITIONALLY whenever a credit
1723
- # is detected — they're NOT gated on
1724
- # ``already is None``. Memory
1725
- # ``project_dedup_must_not_gate_side_effects.md``:
1726
- # "Skipping a no-op INSERT must NOT skip
1727
- # milestones/rollups/alerts; prior run may have
1728
- # died mid-flight." Crash scenario: tick N
1729
- # committed the event row, then died before
1730
- # HWM + DELETE. Tick N+1's pre-check sees
1731
- # ``already`` non-None (the row IS in the
1732
- # table) and would skip the pivots, leaving
1733
- # the system wedged on pre-credit HWM + stale-
1734
- # replica rows. Pivots are individually
1735
- # idempotent (file overwrite + DELETE on stable
1736
- # predicate), so re-running them is safe.
1737
- # ``effective_iso`` is resolved above; on a
1738
- # recovery tick it lands on the SAME 10-min
1739
- # slot as the original (now_utc has drifted
1740
- # only seconds), so the DELETE predicate's
1741
- # ``unixepoch(captured_at_utc) >= unixepoch(?)``
1742
- # still matches every stale-replica row.
1743
- #
1744
- # Force-write hwm-7d so the next status-line
1745
- # render reflects the post-credit value. The
1746
- # monotonic guard at the normal write site
1747
- # (below) would refuse to decrease the file;
1748
- # this write is the credit-only escape hatch.
1749
- # Lands AFTER the conn.commit() so a concurrent
1750
- # record-usage reader doesn't see the new HWM
1751
- # before the event row is durable.
1752
- try:
1753
- (_cctally_core.APP_DIR / "hwm-7d").write_text(
1754
- f"{week_start_date} {weekly_percent}\n"
1755
- )
1756
- except OSError:
1757
- pass
1758
-
1759
- # Race-defensive cleanup. Between the moment
1760
- # Anthropic credited the user (effective_iso)
1761
- # and this code firing, the EXTERNAL
1762
- # claude-statusline tool can replay stale
1763
- # pre-credit `--percent` values (it has its
1764
- # own in-memory HWM cache and re-runs us once
1765
- # per status-line tick). Those replays land
1766
- # captured_at_utc >= effective_iso with
1767
- # weekly_percent near prior_pct (the pre-credit
1768
- # value), and they dominate the reset-aware
1769
- # clamp's MAX over the post-credit segment so
1770
- # legitimate fresh OAuth values are rejected.
1771
- # 1.0pp tolerance band (issue #45) around the
1772
- # observed pre-credit baseline absorbs any
1773
- # rounding drift between cctally's OAuth read
1774
- # and statusline's --percent payload (today
1775
- # they match byte-identically, but the band
1776
- # future-proofs against Anthropic or statusline
1777
- # changing rounding). The band stays well below
1778
- # the 25pp in-place credit detection threshold,
1779
- # so legitimate post-credit values are never
1780
- # caught. Bind is the in-scope ``prior_pct``,
1781
- # which equals the just-stamped
1782
- # ``observed_pre_credit_pct`` on the event row.
1783
- try:
1784
- conn.execute(
1785
- "DELETE FROM weekly_usage_snapshots "
1786
- "WHERE week_start_date = ? "
1787
- " AND unixepoch(captured_at_utc) >= "
1788
- " unixepoch(?) "
1789
- " AND ABS(weekly_percent - ?) < 1.0",
1790
- (week_start_date, effective_iso,
1791
- float(prior_pct)),
2058
+ if prior_end_dt > now_utc and prior_pct is not None:
2059
+ drop = float(prior_pct) - float(weekly_percent)
2060
+ big_drop = drop >= c._RESET_PCT_DROP_THRESHOLD
2061
+ zero_only = (
2062
+ (not big_drop)
2063
+ and float(weekly_percent) <= c._RESET_ZERO_FLOOR_PCT
2064
+ and drop >= c._RESET_ZERO_MIN_DROP_PCT
2065
+ )
2066
+ if big_drop:
2067
+ # >=25pp goodwill credit fire immediately, never
2068
+ # debounced. Clear any pending arm (now moot).
2069
+ _clear_reset_zero_marker()
2070
+ _fire_in_place_credit(
2071
+ conn, week_start_date, cur_end_canon, weekly_percent,
2072
+ observed_pre_credit_pct=float(prior_pct),
2073
+ effective_dt=_floor_to_hour(now_utc),
1792
2074
  )
1793
- conn.commit()
1794
- except sqlite3.DatabaseError as exc:
1795
- eprint(
1796
- "[record-usage] post-credit cleanup "
1797
- f"failed: {exc}"
2075
+ else:
2076
+ marker = _read_reset_zero_marker()
2077
+ armed = (
2078
+ marker is not None
2079
+ and marker[0] == week_start_date
2080
+ and marker[1] == cur_end_canon
1798
2081
  )
2082
+ if armed:
2083
+ baseline_pct = marker[2]
2084
+ if float(weekly_percent) <= baseline_pct / 2.0:
2085
+ # Second reading stayed low → confirm. Anchor
2086
+ # the reset at the FIRST-zero instant from the
2087
+ # marker (UTC-normalized like the backfill
2088
+ # in-place path).
2089
+ first_zero_dt = parse_iso_datetime(
2090
+ marker[3], "reset_zero_marker.first_zero"
2091
+ ).astimezone(dt.timezone.utc)
2092
+ _fire_in_place_credit(
2093
+ conn, week_start_date, cur_end_canon,
2094
+ weekly_percent,
2095
+ observed_pre_credit_pct=baseline_pct,
2096
+ effective_dt=_floor_to_hour(first_zero_dt),
2097
+ )
2098
+ # Clear ONLY after the fire completes (P2a):
2099
+ # a mid-fire crash leaves the marker armed so
2100
+ # the next zero re-confirms + re-runs the
2101
+ # idempotent pivots.
2102
+ _clear_reset_zero_marker()
2103
+ else:
2104
+ # Recovered toward baseline → transient zero,
2105
+ # not a reset. Clear, do not fire.
2106
+ _clear_reset_zero_marker()
2107
+ elif zero_only:
2108
+ # First ~0 → arm; do NOT fire. The write clamp
2109
+ # suppresses this 0 (no event row yet), so the
2110
+ # prior snapshot stays at the baseline and this
2111
+ # shape re-evaluates next tick. first_zero_iso is
2112
+ # the _command_as_of() value (now_utc), NOT
2113
+ # wall-clock — it becomes the effective anchor.
2114
+ _arm_reset_zero_marker(
2115
+ week_start_date, cur_end_canon,
2116
+ baseline_pct=float(prior_pct),
2117
+ first_zero_iso=now_utc.isoformat(timespec="seconds"),
2118
+ )
2119
+ # else: not a reset shape and not armed → nothing.
2120
+ # A non-matching stale marker is inert (ignored
2121
+ # on key mismatch, overwritten by the next arm).
1799
2122
 
1800
2123
  # ── 5h in-place credit detection (parallel to weekly above) ──
1801
2124
  # Spec §2.2 of
@@ -2329,6 +2652,16 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2329
2652
  except Exception as exc:
2330
2653
  eprint(f"[budget-milestone] unexpected error: {exc}")
2331
2654
 
2655
+ # NEW: projected-pace alert firing (axis `projected`, #121). Runs in its
2656
+ # OWN detect-and-arm AFTER the weekly/5h/budget blocks; gated up front on
2657
+ # (alerts.enabled && alerts.projected_enabled) ||
2658
+ # (_budget_alerts_active && budget.projected_enabled) — both toggles
2659
+ # default OFF, so non-projected users pay only a cheap config read.
2660
+ try:
2661
+ maybe_record_projected_alert(saved)
2662
+ except Exception as exc:
2663
+ eprint(f"[projected-alert] unexpected error: {exc}")
2664
+
2332
2665
  # Write high-water mark so the status line never displays a regression.
2333
2666
  # The file contains "week_start_date weekly_percent" on one line.
2334
2667
  try:
@@ -1193,6 +1193,7 @@ class DataSnapshot:
1193
1193
  r_recent=r_recent,
1194
1194
  final_percent_low=final_low,
1195
1195
  final_percent_high=final_high,
1196
+ week_avg_projection_pct=used_pct + r_avg * remaining_hours,
1196
1197
  projected_cap=final_high >= 100.0,
1197
1198
  already_capped=False,
1198
1199
  cap_at=None,