cctally 1.7.2 → 1.7.4

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.
@@ -251,121 +251,55 @@ def _cctally():
251
251
  return sys.modules["cctally"]
252
252
 
253
253
 
254
+ # === Honest imports from extracted homes ===================================
255
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
256
+ # import from _cctally_core; already-decentralized buckets (X = _lib_*,
257
+ # Y = _cctally_*) import from their natural home. These bypass the
258
+ # legacy shim pattern entirely.
259
+ from _cctally_core import (
260
+ eprint,
261
+ now_utc_iso,
262
+ parse_iso_datetime,
263
+ _now_utc,
264
+ _command_as_of,
265
+ open_db,
266
+ get_latest_usage_for_week,
267
+ make_week_ref,
268
+ _get_alerts_config,
269
+ _AlertsConfigError,
270
+ )
271
+ from _lib_display_tz import (
272
+ format_display_dt,
273
+ resolve_display_tz,
274
+ normalize_display_tz_value,
275
+ _compute_display_block,
276
+ )
277
+ from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
278
+ from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
279
+ from _lib_five_hour import _canonical_5h_window_key
280
+ from _lib_subscription_weeks import _compute_subscription_weeks
281
+ from _lib_blocks import _group_entries_into_blocks
282
+ from _cctally_config import save_config, _load_config_unlocked
283
+ from _cctally_db import _render_migration_error_banner
284
+ from _cctally_cache import get_entries
285
+
286
+
254
287
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
255
288
  # Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
256
289
  # time), so monkeypatches on cctally's namespace propagate into the moved
257
- # code unchanged. Mirrors the precedent established in
258
- # ``bin/_cctally_record.py`` (34 shims), ``bin/_cctally_cache.py``
259
- # (4 shims), ``bin/_cctally_db.py`` (4 shims), and
260
- # ``bin/_cctally_update.py`` (8 shims).
261
- def eprint(*args, **kwargs):
262
- return sys.modules["cctally"].eprint(*args, **kwargs)
263
-
264
-
265
- def now_utc_iso(*args, **kwargs):
266
- return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
267
-
268
-
269
- def parse_iso_datetime(*args, **kwargs):
270
- return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
271
-
272
-
273
- def _now_utc(*args, **kwargs):
274
- return sys.modules["cctally"]._now_utc(*args, **kwargs)
275
-
276
-
277
- def _command_as_of(*args, **kwargs):
278
- return sys.modules["cctally"]._command_as_of(*args, **kwargs)
279
-
280
-
290
+ # code unchanged. `load_config` and `get_claude_session_entries` STAY as
291
+ # shims even though their natural homes are decentralized (_cctally_config
292
+ # / _cctally_cache) tests monkeypatch them via `ns["X"]` (21 sites total,
293
+ # audited 2026-05-17); direct imports would silently bypass the patches.
294
+ # See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
281
295
  def load_config(*args, **kwargs):
282
296
  return sys.modules["cctally"].load_config(*args, **kwargs)
283
297
 
284
298
 
285
- def save_config(*args, **kwargs):
286
- return sys.modules["cctally"].save_config(*args, **kwargs)
287
-
288
-
289
- def open_db(*args, **kwargs):
290
- return sys.modules["cctally"].open_db(*args, **kwargs)
291
-
292
-
293
- def get_entries(*args, **kwargs):
294
- return sys.modules["cctally"].get_entries(*args, **kwargs)
295
-
296
-
297
299
  def get_claude_session_entries(*args, **kwargs):
298
300
  return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
299
301
 
300
302
 
301
- def get_latest_usage_for_week(*args, **kwargs):
302
- return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
303
-
304
-
305
- def make_week_ref(*args, **kwargs):
306
- return sys.modules["cctally"].make_week_ref(*args, **kwargs)
307
-
308
-
309
- def format_display_dt(*args, **kwargs):
310
- return sys.modules["cctally"].format_display_dt(*args, **kwargs)
311
-
312
-
313
- def resolve_display_tz(*args, **kwargs):
314
- return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
315
-
316
-
317
- def normalize_display_tz_value(*args, **kwargs):
318
- return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
319
-
320
-
321
- def _compute_display_block(*args, **kwargs):
322
- return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
323
-
324
-
325
- def _render_migration_error_banner(*args, **kwargs):
326
- return sys.modules["cctally"]._render_migration_error_banner(*args, **kwargs)
327
-
328
-
329
- def _aggregate_daily(*args, **kwargs):
330
- return sys.modules["cctally"]._aggregate_daily(*args, **kwargs)
331
-
332
-
333
- def _aggregate_monthly(*args, **kwargs):
334
- return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
335
-
336
-
337
- def _aggregate_weekly(*args, **kwargs):
338
- return sys.modules["cctally"]._aggregate_weekly(*args, **kwargs)
339
-
340
-
341
- def _calculate_entry_cost(*args, **kwargs):
342
- return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
343
-
344
-
345
- def _canonical_5h_window_key(*args, **kwargs):
346
- return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
347
-
348
-
349
- def _chip_for_model(*args, **kwargs):
350
- return sys.modules["cctally"]._chip_for_model(*args, **kwargs)
351
-
352
-
353
- def _short_model_name(*args, **kwargs):
354
- return sys.modules["cctally"]._short_model_name(*args, **kwargs)
355
-
356
-
357
- def _compute_subscription_weeks(*args, **kwargs):
358
- return sys.modules["cctally"]._compute_subscription_weeks(*args, **kwargs)
359
-
360
-
361
- def _group_entries_into_blocks(*args, **kwargs):
362
- return sys.modules["cctally"]._group_entries_into_blocks(*args, **kwargs)
363
-
364
-
365
- def _get_alerts_config(*args, **kwargs):
366
- return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
367
-
368
-
369
303
  def _warn_alerts_bad_config_once(*args, **kwargs):
370
304
  return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
371
305
 
@@ -402,10 +336,6 @@ def doctor_gather_state(*args, **kwargs):
402
336
  return sys.modules["cctally"].doctor_gather_state(*args, **kwargs)
403
337
 
404
338
 
405
- def _load_config_unlocked(*args, **kwargs):
406
- return sys.modules["cctally"]._load_config_unlocked(*args, **kwargs)
407
-
408
-
409
339
  def _apply_display_tz_override(*args, **kwargs):
410
340
  return sys.modules["cctally"]._apply_display_tz_override(*args, **kwargs)
411
341
 
@@ -2490,7 +2420,7 @@ def _select_current_block_for_envelope(
2490
2420
 
2491
2421
  block = conn.execute(
2492
2422
  """
2493
- SELECT block_start_at, last_observed_at_utc,
2423
+ SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
2494
2424
  seven_day_pct_at_block_start,
2495
2425
  crossed_seven_day_reset
2496
2426
  FROM five_hour_blocks
@@ -2539,11 +2469,41 @@ def _select_current_block_for_envelope(
2539
2469
  None if (p_anchor is None or current_used_pct is None)
2540
2470
  else round(current_used_pct - p_anchor, 9)
2541
2471
  )
2472
+
2473
+ # Spec §5.3 — in-place credit events for this 5h block's window,
2474
+ # ascending by ``effective_reset_at_utc``. Drives the
2475
+ # ``CurrentWeekPanel.tsx`` ``⚡ credited -Xpp`` chip and the
2476
+ # ``CurrentWeekModal.tsx`` merged-stream 5h milestones section.
2477
+ # Snake_case keys to match the envelope convention (see CLAUDE.md;
2478
+ # CLI ``--json`` uses camelCase, dashboard envelope is snake_case).
2479
+ cred_rows = conn.execute(
2480
+ """
2481
+ SELECT effective_reset_at_utc, prior_percent, post_percent
2482
+ FROM five_hour_reset_events
2483
+ WHERE five_hour_window_key = ?
2484
+ ORDER BY effective_reset_at_utc ASC
2485
+ """,
2486
+ (int(block["five_hour_window_key"]),),
2487
+ ).fetchall()
2488
+ credits = [
2489
+ {
2490
+ "effective_reset_at_utc": c["effective_reset_at_utc"],
2491
+ "prior_percent": float(c["prior_percent"]),
2492
+ "post_percent": float(c["post_percent"]),
2493
+ "delta_pp": round(
2494
+ float(c["post_percent"]) - float(c["prior_percent"]), 1
2495
+ ),
2496
+ }
2497
+ for c in cred_rows
2498
+ ]
2499
+
2542
2500
  return {
2543
2501
  "block_start_at": block["block_start_at"],
2502
+ "five_hour_window_key": int(block["five_hour_window_key"]),
2544
2503
  "seven_day_pct_at_block_start": p_start,
2545
2504
  "seven_day_pct_delta_pp": delta,
2546
2505
  "crossed_seven_day_reset": crossed,
2506
+ "credits": credits,
2547
2507
  }
2548
2508
 
2549
2509
 
@@ -2603,13 +2563,29 @@ def _build_alerts_envelope_array(
2603
2563
  "week_start_date": r["week_start_date"],
2604
2564
  "cumulative_cost_usd": cumulative,
2605
2565
  "dollars_per_percent": dpp,
2566
+ # Round-3: parallel to the 5h context block below — both
2567
+ # axes now expose ``reset_event_id`` so downstream
2568
+ # clients (panel, modal, third-party consumers) can
2569
+ # discriminate pre- vs post-credit crossings of the
2570
+ # same (week, threshold) without scraping the
2571
+ # envelope ``id`` string. 0 = pre-credit / no-event;
2572
+ # event.id = post-credit segment.
2573
+ "reset_event_id": int(r["reset_event_id"]),
2606
2574
  },
2607
2575
  })
2608
2576
 
2577
+ # Site F (spec §3.2 bucket C / §3.3): widen the row identity to
2578
+ # include ``reset_event_id`` so post-credit (seg=event.id) crossings
2579
+ # of the same (window_key, threshold) don't collide with pre-credit
2580
+ # (seg=0) crossings on the React row key. Older clients tolerate
2581
+ # longer ids — the id is opaque to them; only the React key
2582
+ # uniqueness invariant matters. Mirrors the weekly precedent at
2583
+ # line ~2597.
2609
2584
  fh_rows = conn.execute(
2610
2585
  """
2611
2586
  SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
2612
- m.alerted_at, m.block_cost_usd, b.block_start_at
2587
+ m.alerted_at, m.block_cost_usd, m.reset_event_id,
2588
+ b.block_start_at
2613
2589
  FROM five_hour_milestones m
2614
2590
  LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
2615
2591
  WHERE m.alerted_at IS NOT NULL
@@ -2621,7 +2597,10 @@ def _build_alerts_envelope_array(
2621
2597
  for r in fh_rows:
2622
2598
  threshold = int(r["percent_threshold"])
2623
2599
  out.append({
2624
- "id": f"five_hour:{int(r['five_hour_window_key'])}:{threshold}",
2600
+ "id": (
2601
+ f"five_hour:{int(r['five_hour_window_key'])}:"
2602
+ f"{threshold}:{int(r['reset_event_id'])}"
2603
+ ),
2625
2604
  "axis": "five_hour",
2626
2605
  "threshold": threshold,
2627
2606
  "crossed_at": r["captured_at_utc"],
@@ -2630,6 +2609,7 @@ def _build_alerts_envelope_array(
2630
2609
  "five_hour_window_key": int(r["five_hour_window_key"]),
2631
2610
  "block_start_at": r["block_start_at"] or "",
2632
2611
  "block_cost_usd": float(r["block_cost_usd"] or 0.0),
2612
+ "reset_event_id": int(r["reset_event_id"]),
2633
2613
  },
2634
2614
  })
2635
2615
 
@@ -2937,7 +2917,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2937
2917
  alerts_array = list(getattr(snap, "alerts", []) or [])
2938
2918
  try:
2939
2919
  _alerts_cfg = _get_alerts_config(load_config())
2940
- except sys.modules["cctally"]._AlertsConfigError as exc:
2920
+ except _AlertsConfigError as exc:
2941
2921
  _warn_alerts_bad_config_once(exc)
2942
2922
  _alerts_cfg = {
2943
2923
  "enabled": False,
@@ -3065,6 +3045,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
3065
3045
  }
3066
3046
  for m in (snap.percent_milestones or [])
3067
3047
  ],
3048
+ # Spec §5.3 (Codex r1 finding 3) — NEW envelope key
3049
+ # parallel to ``milestones`` (which carries the WEEKLY
3050
+ # timeline). 5h-block milestones for the active block,
3051
+ # in capture-time order, both pre- and post-credit
3052
+ # segments included (bucket B per §3.2 — no
3053
+ # ``reset_event_id`` filter; the React layer renders
3054
+ # repeated thresholds as distinct rows keyed on
3055
+ # ``reset_event_id``). Empty list when no 5h block is
3056
+ # bound or the data source crashed during sync
3057
+ # (recorded on ``last_sync_error``).
3058
+ "five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
3068
3059
  },
3069
3060
 
3070
3061
  "forecast":
@@ -3721,7 +3712,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
3721
3712
  # save_config has not yet been called).
3722
3713
  try:
3723
3714
  _get_alerts_config(merged)
3724
- except sys.modules["cctally"]._AlertsConfigError as exc:
3715
+ except _AlertsConfigError as exc:
3725
3716
  self._respond_json(400, {"error": str(exc)})
3726
3717
  return
3727
3718
 
@@ -71,35 +71,32 @@ def _cctally():
71
71
  return sys.modules["cctally"]
72
72
 
73
73
 
74
- # Module-level back-ref shims for the four callables most heavily used
75
- # across migration handlers + cmd_db_* renderers. Each shim resolves
76
- # `sys.modules['cctally'].X` at CALL TIME (not bind time), so
77
- # monkeypatches on cctally's namespace propagate into the moved code
78
- # unchanged. This lets the moved function bodies stay byte-identical
79
- # at every bare-name call site (`now_utc_iso(...)`,
80
- # `parse_iso_datetime(...)`, etc.) without requiring per-function
81
- # `c = _cctally()` boilerplate or `c.X` rewrites at every call site.
74
+ # === Honest imports from extracted homes ===================================
75
+ # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
76
+ # import from _cctally_core. The legacy shim functions for these names
77
+ # are deleted.
78
+ from _cctally_core import (
79
+ eprint,
80
+ now_utc_iso,
81
+ parse_iso_datetime,
82
+ )
83
+
84
+
85
+ # Module-level back-ref shim for the one Z-high callable that STAYS in
86
+ # bin/cctally. Resolves `sys.modules['cctally'].X` at CALL TIME (not
87
+ # bind time), so monkeypatches on cctally's namespace propagate into the
88
+ # moved code unchanged. `_compute_block_totals` is Z-high (reaches into
89
+ # _cctally_cache via get_claude_session_entries) and is explicitly listed
90
+ # in spec §3.7's stays-on-shim allowlist.
82
91
  #
83
92
  # Path constants and rarer helpers (`MIGRATION_ERROR_LOG_PATH`,
84
93
  # `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `format_local_iso`) are
85
94
  # accessed via the standard `c = _cctally()` + `c.X` pattern instead
86
95
  # (call-time lookup so fixture-HOME redirects propagate).
87
- def now_utc_iso(*args, **kwargs):
88
- return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
89
-
90
-
91
- def parse_iso_datetime(*args, **kwargs):
92
- return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
93
-
94
-
95
96
  def _compute_block_totals(*args, **kwargs):
96
97
  return sys.modules["cctally"]._compute_block_totals(*args, **kwargs)
97
98
 
98
99
 
99
- def eprint(*args, **kwargs):
100
- return sys.modules["cctally"].eprint(*args, **kwargs)
101
-
102
-
103
100
  # === BEGIN MOVED REGIONS ===
104
101
  # Regions below are inserted verbatim from bin/cctally. Bare-name
105
102
  # references to `now_utc_iso(...)`, `parse_iso_datetime(...)`,
@@ -1009,36 +1006,65 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
1009
1006
 
1010
1007
  # (c) Milestones: per-threshold dedup, keep earliest
1011
1008
  # captured_at_utc, re-FK keepers to canonical.
1009
+ #
1010
+ # Defensive widening (Codex r2 finding 1, spec §3.4): if
1011
+ # migration 006 has already landed and added ``reset_event_id``,
1012
+ # key the dedup on ``(percent_threshold, reset_event_id)`` so
1013
+ # we don't silently collapse legitimately distinct pre/post-
1014
+ # credit rows at the same physical threshold. On the legacy
1015
+ # upgrade path (column doesn't exist yet because 003 runs
1016
+ # before 006 in migration order), ``has_seg`` is False and the
1017
+ # dedup key collapses to ``(threshold, 0)`` — byte-identical
1018
+ # to the original threshold-only shape. PRAGMA probe rather
1019
+ # than version-detect so the path also covers operator
1020
+ # re-runs (e.g. ``cctally db unskip 003_*``) post-006.
1021
+ ms_cols = {
1022
+ str(r[1])
1023
+ for r in conn.execute(
1024
+ "PRAGMA table_info(five_hour_milestones)"
1025
+ ).fetchall()
1026
+ }
1027
+ has_seg = "reset_event_id" in ms_cols
1012
1028
  ms_id_placeholders = ",".join(
1013
1029
  "?" * (len(dropped_ids) + 1)
1014
1030
  )
1015
- all_milestones = conn.execute(
1016
- f"SELECT id, percent_threshold, captured_at_utc "
1017
- f" FROM five_hour_milestones "
1018
- f" WHERE block_id IN ({ms_id_placeholders})",
1019
- [canonical["id"], *dropped_ids],
1020
- ).fetchall()
1021
- by_threshold: dict[int, dict] = {}
1031
+ if has_seg:
1032
+ all_milestones = conn.execute(
1033
+ f"SELECT id, percent_threshold, captured_at_utc, "
1034
+ f" reset_event_id "
1035
+ f" FROM five_hour_milestones "
1036
+ f" WHERE block_id IN ({ms_id_placeholders})",
1037
+ [canonical["id"], *dropped_ids],
1038
+ ).fetchall()
1039
+ else:
1040
+ all_milestones = conn.execute(
1041
+ f"SELECT id, percent_threshold, captured_at_utc "
1042
+ f" FROM five_hour_milestones "
1043
+ f" WHERE block_id IN ({ms_id_placeholders})",
1044
+ [canonical["id"], *dropped_ids],
1045
+ ).fetchall()
1046
+ by_key: dict[tuple[int, int], dict] = {}
1022
1047
  for m in all_milestones:
1023
- t = m["percent_threshold"]
1048
+ seg = int(m["reset_event_id"]) if has_seg else 0
1049
+ key = (int(m["percent_threshold"]), seg)
1024
1050
  md = dict(m)
1025
1051
  if (
1026
- t not in by_threshold
1052
+ key not in by_key
1027
1053
  or md["captured_at_utc"]
1028
- < by_threshold[t]["captured_at_utc"]
1054
+ < by_key[key]["captured_at_utc"]
1029
1055
  ):
1030
- by_threshold[t] = md
1031
- keep_ids = {m["id"] for m in by_threshold.values()}
1056
+ by_key[key] = md
1057
+ keep_ids = {m["id"] for m in by_key.values()}
1032
1058
  # DELETE non-keepers BEFORE rekeying keepers. Otherwise, when
1033
1059
  # both canonical and a dropped block hold a milestone for the
1034
- # same percent_threshold and the dropped row's milestone is
1035
- # the earlier keeper, UPDATEing it to the canonical key
1036
- # collides with canonical's still-present non-keeper on
1037
- # UNIQUE(five_hour_window_key, percent_threshold), rolling
1038
- # back the migration. After this DELETE the only milestones
1039
- # referencing dropped_keys are the keepers themselves
1040
- # (one per threshold), so the UPDATE loop below is collision-
1041
- # free.
1060
+ # same physical key and the dropped row's milestone is the
1061
+ # earlier keeper, UPDATEing it to the canonical key collides
1062
+ # with canonical's still-present non-keeper on UNIQUE
1063
+ # (either the 2-col legacy shape or the 3-col post-006 shape),
1064
+ # rolling back the migration. After this DELETE the only
1065
+ # milestones referencing dropped_keys are the keepers
1066
+ # themselves (one per dedup key), so the UPDATE loop below is
1067
+ # collision-free.
1042
1068
  non_keep_ids = [
1043
1069
  m["id"] for m in all_milestones if m["id"] not in keep_ids
1044
1070
  ]
@@ -1049,7 +1075,7 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
1049
1075
  f" WHERE id IN ({nk_placeholders})",
1050
1076
  non_keep_ids,
1051
1077
  )
1052
- for m in by_threshold.values():
1078
+ for m in by_key.values():
1053
1079
  conn.execute(
1054
1080
  "UPDATE five_hour_milestones "
1055
1081
  " SET block_id = ?, "
@@ -1437,6 +1463,210 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
1437
1463
  raise
1438
1464
 
1439
1465
 
1466
+ @stats_migration("006_five_hour_milestones_reset_event_id")
1467
+ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) -> None:
1468
+ """Add ``reset_event_id`` to ``five_hour_milestones`` so post-credit
1469
+ threshold crossings can coexist with pre-credit ones for the same
1470
+ ``(five_hour_window_key, percent_threshold)``.
1471
+
1472
+ Sentinel: ``0`` = pre-credit / no event. Existing rows backfill to
1473
+ ``0`` via the ``DEFAULT 0`` clause on the new column.
1474
+
1475
+ The new UNIQUE constraint is
1476
+ ``UNIQUE(five_hour_window_key, percent_threshold, reset_event_id)`` so
1477
+ the same (window_key, threshold) pair can land twice if a goodwill
1478
+ credit re-opens the segment under a fresh ``five_hour_reset_events.id``.
1479
+ SQLite can't ALTER a UNIQUE constraint in place — we use the
1480
+ rename-recreate-copy idiom (same as migration 005 did for
1481
+ ``percent_milestones``).
1482
+
1483
+ Companion live-path edits land at (Task 2 of issue #43):
1484
+ - bin/_cctally_record.py — 5h milestone INSERT + alert paths
1485
+ (Sites A-E in spec §3.3); grep ``active_reset_event_id`` to
1486
+ locate (line numbers drift per ``gotcha_cited_line_numbers_stale``)
1487
+ - bin/_cctally_dashboard.py — alerts list row-identity widening
1488
+ (Site F in spec §3.3 — bucket C per spec §3.2's three-bucket model);
1489
+ grep ``reset_event_id`` near the 5h alerts SELECT
1490
+
1491
+ Idempotent: a second invocation finds the column already present and
1492
+ returns. Empty-table fast path: when the column is already present
1493
+ (fresh-install fast-stamp from the dispatcher because the live
1494
+ ``CREATE TABLE IF NOT EXISTS five_hour_milestones`` already carries
1495
+ the new shape — REQUIRED for fresh-install correctness per spec §3.2),
1496
+ the marker still gets stamped — no schema edit needed.
1497
+ """
1498
+ # Fast-path probe: column already present means a prior run of this
1499
+ # migration (or a fresh-install fast-stamp from the dispatcher that
1500
+ # already picked up the new live-schema CREATE TABLE) has done the
1501
+ # work. Just stamp the marker and return. The marker INSERT runs in
1502
+ # SQLite's implicit transaction (auto-opened by the write, closed by
1503
+ # ``commit()`` — same shape as migration 005's fast path); no explicit
1504
+ # ``BEGIN`` is needed for a single-statement DML.
1505
+ cols = {
1506
+ str(r[1])
1507
+ for r in conn.execute("PRAGMA table_info(five_hour_milestones)").fetchall()
1508
+ }
1509
+ if "reset_event_id" in cols:
1510
+ conn.execute(
1511
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1512
+ "VALUES (?, ?)",
1513
+ ("006_five_hour_milestones_reset_event_id", now_utc_iso()),
1514
+ )
1515
+ conn.commit()
1516
+ return
1517
+
1518
+ conn.execute("BEGIN")
1519
+ try:
1520
+ # Add the column with sentinel 0 default (covers existing rows).
1521
+ conn.execute(
1522
+ "ALTER TABLE five_hour_milestones "
1523
+ "ADD COLUMN reset_event_id INTEGER NOT NULL DEFAULT 0"
1524
+ )
1525
+ # SQLite can't ALTER a UNIQUE constraint in place; rename, recreate
1526
+ # with the new 3-column UNIQUE, copy, drop. Preserves ids and every
1527
+ # existing column (including those added by add_column_if_missing:
1528
+ # alerted_at).
1529
+ conn.execute(
1530
+ "ALTER TABLE five_hour_milestones "
1531
+ "RENAME TO five_hour_milestones_old_006"
1532
+ )
1533
+ conn.execute(
1534
+ """
1535
+ CREATE TABLE five_hour_milestones (
1536
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1537
+ block_id INTEGER NOT NULL,
1538
+ five_hour_window_key INTEGER NOT NULL,
1539
+ percent_threshold INTEGER NOT NULL,
1540
+ captured_at_utc TEXT NOT NULL,
1541
+ usage_snapshot_id INTEGER NOT NULL,
1542
+ block_input_tokens INTEGER NOT NULL DEFAULT 0,
1543
+ block_output_tokens INTEGER NOT NULL DEFAULT 0,
1544
+ block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
1545
+ block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
1546
+ block_cost_usd REAL NOT NULL DEFAULT 0,
1547
+ marginal_cost_usd REAL,
1548
+ seven_day_pct_at_crossing REAL,
1549
+ alerted_at TEXT,
1550
+ reset_event_id INTEGER NOT NULL DEFAULT 0,
1551
+ UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
1552
+ FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
1553
+ )
1554
+ """
1555
+ )
1556
+ conn.execute(
1557
+ """
1558
+ INSERT INTO five_hour_milestones (
1559
+ id, block_id, five_hour_window_key, percent_threshold,
1560
+ captured_at_utc, usage_snapshot_id,
1561
+ block_input_tokens, block_output_tokens,
1562
+ block_cache_create_tokens, block_cache_read_tokens,
1563
+ block_cost_usd, marginal_cost_usd,
1564
+ seven_day_pct_at_crossing, alerted_at, reset_event_id
1565
+ )
1566
+ SELECT id, block_id, five_hour_window_key, percent_threshold,
1567
+ captured_at_utc, usage_snapshot_id,
1568
+ block_input_tokens, block_output_tokens,
1569
+ block_cache_create_tokens, block_cache_read_tokens,
1570
+ block_cost_usd, marginal_cost_usd,
1571
+ seven_day_pct_at_crossing, alerted_at, reset_event_id
1572
+ FROM five_hour_milestones_old_006
1573
+ """
1574
+ )
1575
+ # Recreate the block_id index that was attached to the original
1576
+ # table; the rename carried index metadata with the table, but
1577
+ # the new table needs its own index entry. Safe under
1578
+ # IF NOT EXISTS if the rename preserved it (it does in practice,
1579
+ # but the explicit recreate is defensive).
1580
+ conn.execute(
1581
+ """
1582
+ CREATE INDEX IF NOT EXISTS idx_five_hour_milestones_block
1583
+ ON five_hour_milestones(block_id)
1584
+ """
1585
+ )
1586
+ conn.execute("DROP TABLE five_hour_milestones_old_006")
1587
+ conn.execute(
1588
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1589
+ "VALUES (?, ?)",
1590
+ ("006_five_hour_milestones_reset_event_id", now_utc_iso()),
1591
+ )
1592
+ conn.commit()
1593
+ except Exception:
1594
+ conn.rollback()
1595
+ raise
1596
+
1597
+
1598
+ @stats_migration("007_observed_pre_credit_pct")
1599
+ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
1600
+ """Add ``observed_pre_credit_pct`` to ``week_reset_events`` so the
1601
+ race-defensive cleanup DELETE in the in-place weekly credit branch
1602
+ has a durable record of the pre-credit baseline we observed at
1603
+ write time — independent of how the upstream claude-statusline
1604
+ tool rounds replays.
1605
+
1606
+ Today statusline replays cctally's ``hwm-7d`` value byte-identically,
1607
+ so the existing strict ``round(.,1)`` equality predicate is sound.
1608
+ Future-proofs against rounding drift: if Anthropic ever rounds the
1609
+ ``--percent`` payload differently from the OAuth API used by
1610
+ record-usage, or if statusline grows its own coarser rounding, a
1611
+ replay at e.g. 67.5 against a stored prior_pct = 67.4 would slip
1612
+ past strict equality and then dominate the reset-aware clamp's
1613
+ MAX over the post-credit segment. With the value stamped on the
1614
+ event row, the cleanup predicate widens to a 1.0pp tolerance band
1615
+ (issue #45) — wide enough to absorb single-digit drift, narrow
1616
+ enough that legitimate post-credit observations (≥25pp away by
1617
+ the in-place credit detection threshold's hypothesis) stay.
1618
+
1619
+ Backfill: NULL on existing rows. NULL is legacy / never-stamped;
1620
+ the live cleanup's bind still uses the current tick's in-scope
1621
+ ``prior_pct`` (the value we just observed and would have stamped),
1622
+ so the cleanup remains correct on the very tick that writes the
1623
+ row. The stored value matters for future tooling that may re-run
1624
+ cleanup against an already-written event row.
1625
+
1626
+ Companion live-path edits land in:
1627
+ - bin/cctally — CREATE TABLE adds the column for fresh installs.
1628
+ - bin/_cctally_record.py — in-place credit INSERT stamps
1629
+ ``observed_pre_credit_pct = prior_pct``; race-defensive DELETE
1630
+ switches from ``round(weekly_percent,1) = round(?,1)`` to
1631
+ ``ABS(weekly_percent - ?) < 1.0``.
1632
+
1633
+ Idempotent: a second invocation finds the column already present
1634
+ and returns. Empty-column fast path: when the live CREATE TABLE
1635
+ already carries the column (fresh install), stamp the marker and
1636
+ return without an ALTER. Simple ADD COLUMN — no UNIQUE constraint
1637
+ change, so no rename-recreate-copy needed (contrast migrations
1638
+ 005 / 006).
1639
+ """
1640
+ cols = {
1641
+ str(r[1])
1642
+ for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
1643
+ }
1644
+ if "observed_pre_credit_pct" in cols:
1645
+ conn.execute(
1646
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1647
+ "VALUES (?, ?)",
1648
+ ("007_observed_pre_credit_pct", now_utc_iso()),
1649
+ )
1650
+ conn.commit()
1651
+ return
1652
+
1653
+ conn.execute("BEGIN")
1654
+ try:
1655
+ conn.execute(
1656
+ "ALTER TABLE week_reset_events "
1657
+ "ADD COLUMN observed_pre_credit_pct REAL"
1658
+ )
1659
+ conn.execute(
1660
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1661
+ "VALUES (?, ?)",
1662
+ ("007_observed_pre_credit_pct", now_utc_iso()),
1663
+ )
1664
+ conn.commit()
1665
+ except Exception:
1666
+ conn.rollback()
1667
+ raise
1668
+
1669
+
1440
1670
  # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
1441
1671
 
1442
1672
  # ──────────────────────────────────────────────────────────────────────