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.
@@ -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
 
@@ -384,6 +416,7 @@ _logged_window_key_coerce_failure = False
384
416
  # _cctally_core.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
385
417
  # c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
386
418
  # c._RESET_PCT_DROP_THRESHOLD — bin/_cctally_weekrefs.py constant (re-exported on cctally ns)
419
+ # c._is_reset_drop — bin/_cctally_weekrefs.py helper (re-exported on cctally ns)
387
420
  # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
388
421
 
389
422
 
@@ -787,6 +820,235 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
787
820
  eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
788
821
 
789
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
+
790
1052
  def _compute_block_totals(
791
1053
  block_start_at: dt.datetime,
792
1054
  range_end: dt.datetime,
@@ -1433,6 +1695,134 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
1433
1695
  conn.close()
1434
1696
 
1435
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
+
1436
1826
  def cmd_record_usage(args: argparse.Namespace) -> int:
1437
1827
  """Record usage data from Claude Code status line rate_limits."""
1438
1828
  c = _cctally()
@@ -1639,7 +2029,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1639
2029
  if (
1640
2030
  prior_end_dt > now_utc
1641
2031
  and prior_pct is not None
1642
- and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
2032
+ and c._is_reset_drop(prior_pct, weekly_percent)
1643
2033
  ):
1644
2034
  # See _backfill_week_reset_events for why we floor
1645
2035
  # the reset moment to the hour (natural display
@@ -1655,146 +2045,80 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1655
2045
  )
1656
2046
  conn.commit()
1657
2047
  elif prior_end_canon and prior_end_canon == cur_end_canon:
1658
- # In-place credit branch (v1.7.2). When `resets_at` stays
1659
- # unchanged but `weekly_percent` drops by RESET_PCT_DROP_THRESHOLD
1660
- # or more, Anthropic has issued a goodwill in-place weekly
1661
- # credit. Emit one week_reset_events row keyed on the
1662
- # current end_at (old == new) so the reset-aware clamp
1663
- # above and the milestone segment writer can pivot to
1664
- # the post-credit segment. The seed snapshot lands via
1665
- # 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.
1666
2057
  prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
1667
- if (
1668
- prior_end_dt > now_utc
1669
- and prior_pct is not None
1670
- and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1671
- ):
1672
- # Pre-check (Q5 belt-and-suspenders): suppress duplicate
1673
- # event rows for the same new_week_end_at across
1674
- # consecutive ticks. UNIQUE(old, new) at the DDL
1675
- # also catches the duplicate in the (old == new) case,
1676
- # but the pre-check avoids a useless write attempt
1677
- # and keeps the log clean. After the seed lands at
1678
- # post-credit %, the next tick's `prior_pct` will be
1679
- # the post-credit value so the drop predicate alone
1680
- # also suffices — pre-check is belt-and-suspenders.
1681
- already = conn.execute(
1682
- "SELECT 1 FROM week_reset_events "
1683
- "WHERE new_week_end_at = ? LIMIT 1",
1684
- (cur_end_canon,),
1685
- ).fetchone()
1686
- effective_dt = _floor_to_hour(now_utc)
1687
- effective_iso = effective_dt.isoformat(timespec="seconds")
1688
- if already is None:
1689
- # Row shape: old=effective_iso, new=cur_end_canon
1690
- # (distinct values). The previous shape stored
1691
- # old==new==cur_end_canon, which let BOTH
1692
- # _apply_reset_events_to_weekrefs maps
1693
- # (pre_map[old] and post_map[new]) fire on the
1694
- # SAME WeekRef — pre_map rewrote week_end_at to
1695
- # effective, post_map rewrote week_start_at to
1696
- # effective, collapsing the credited week to a
1697
- # zero-width window in downstream renders. With
1698
- # old==effective and new==cur_end_canon, only
1699
- # post_map fires on the credited week (setting
1700
- # week_start_at = effective, the intended
1701
- # behavior); pre_map keys on effective_iso and
1702
- # finds no matching WeekRef in practice. The
1703
- # UNIQUE(old, new) constraint permits this
1704
- # row, and the pre-check above keys on
1705
- # new_week_end_at so dedup still works.
1706
- # Stamp ``observed_pre_credit_pct = prior_pct``
1707
- # (issue #45): durable record of the pre-credit
1708
- # baseline we observed at write time. Decouples
1709
- # any future cleanup tooling from re-deriving
1710
- # prior_pct via SELECT. Existing rows from
1711
- # migration 007 carry NULL.
1712
- conn.execute(
1713
- "INSERT OR IGNORE INTO week_reset_events "
1714
- "(detected_at_utc, old_week_end_at, new_week_end_at, "
1715
- " effective_reset_at_utc, observed_pre_credit_pct) "
1716
- "VALUES (?, ?, ?, ?, ?)",
1717
- (now_utc_iso(), effective_iso, cur_end_canon,
1718
- effective_iso, float(prior_pct)),
1719
- )
1720
- conn.commit()
1721
- # Pivots fire UNCONDITIONALLY whenever a credit
1722
- # is detected — they're NOT gated on
1723
- # ``already is None``. Memory
1724
- # ``project_dedup_must_not_gate_side_effects.md``:
1725
- # "Skipping a no-op INSERT must NOT skip
1726
- # milestones/rollups/alerts; prior run may have
1727
- # died mid-flight." Crash scenario: tick N
1728
- # committed the event row, then died before
1729
- # HWM + DELETE. Tick N+1's pre-check sees
1730
- # ``already`` non-None (the row IS in the
1731
- # table) and would skip the pivots, leaving
1732
- # the system wedged on pre-credit HWM + stale-
1733
- # replica rows. Pivots are individually
1734
- # idempotent (file overwrite + DELETE on stable
1735
- # predicate), so re-running them is safe.
1736
- # ``effective_iso`` is resolved above; on a
1737
- # recovery tick it lands on the SAME 10-min
1738
- # slot as the original (now_utc has drifted
1739
- # only seconds), so the DELETE predicate's
1740
- # ``unixepoch(captured_at_utc) >= unixepoch(?)``
1741
- # still matches every stale-replica row.
1742
- #
1743
- # Force-write hwm-7d so the next status-line
1744
- # render reflects the post-credit value. The
1745
- # monotonic guard at the normal write site
1746
- # (below) would refuse to decrease the file;
1747
- # this write is the credit-only escape hatch.
1748
- # Lands AFTER the conn.commit() so a concurrent
1749
- # record-usage reader doesn't see the new HWM
1750
- # before the event row is durable.
1751
- try:
1752
- (_cctally_core.APP_DIR / "hwm-7d").write_text(
1753
- f"{week_start_date} {weekly_percent}\n"
1754
- )
1755
- except OSError:
1756
- pass
1757
-
1758
- # Race-defensive cleanup. Between the moment
1759
- # Anthropic credited the user (effective_iso)
1760
- # and this code firing, the EXTERNAL
1761
- # claude-statusline tool can replay stale
1762
- # pre-credit `--percent` values (it has its
1763
- # own in-memory HWM cache and re-runs us once
1764
- # per status-line tick). Those replays land
1765
- # captured_at_utc >= effective_iso with
1766
- # weekly_percent near prior_pct (the pre-credit
1767
- # value), and they dominate the reset-aware
1768
- # clamp's MAX over the post-credit segment so
1769
- # legitimate fresh OAuth values are rejected.
1770
- # 1.0pp tolerance band (issue #45) around the
1771
- # observed pre-credit baseline absorbs any
1772
- # rounding drift between cctally's OAuth read
1773
- # and statusline's --percent payload (today
1774
- # they match byte-identically, but the band
1775
- # future-proofs against Anthropic or statusline
1776
- # changing rounding). The band stays well below
1777
- # the 25pp in-place credit detection threshold,
1778
- # so legitimate post-credit values are never
1779
- # caught. Bind is the in-scope ``prior_pct``,
1780
- # which equals the just-stamped
1781
- # ``observed_pre_credit_pct`` on the event row.
1782
- try:
1783
- conn.execute(
1784
- "DELETE FROM weekly_usage_snapshots "
1785
- "WHERE week_start_date = ? "
1786
- " AND unixepoch(captured_at_utc) >= "
1787
- " unixepoch(?) "
1788
- " AND ABS(weekly_percent - ?) < 1.0",
1789
- (week_start_date, effective_iso,
1790
- 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),
1791
2074
  )
1792
- conn.commit()
1793
- except sqlite3.DatabaseError as exc:
1794
- eprint(
1795
- "[record-usage] post-credit cleanup "
1796
- 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
1797
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).
1798
2122
 
1799
2123
  # ── 5h in-place credit detection (parallel to weekly above) ──
1800
2124
  # Spec §2.2 of
@@ -2328,6 +2652,16 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2328
2652
  except Exception as exc:
2329
2653
  eprint(f"[budget-milestone] unexpected error: {exc}")
2330
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
+
2331
2665
  # Write high-water mark so the status line never displays a regression.
2332
2666
  # The file contains "week_start_date weekly_percent" on one line.
2333
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,