cctally 1.7.1 → 1.7.3

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.
@@ -317,6 +317,10 @@ def get_milestones_for_week(*args, **kwargs):
317
317
  return sys.modules["cctally"].get_milestones_for_week(*args, **kwargs)
318
318
 
319
319
 
320
+ def _canonicalize_optional_iso(*args, **kwargs):
321
+ return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
322
+
323
+
320
324
  def get_recent_weeks(*args, **kwargs):
321
325
  return sys.modules["cctally"].get_recent_weeks(*args, **kwargs)
322
326
 
@@ -949,7 +953,8 @@ class TuiPercentMilestone:
949
953
  def _tui_build_percent_milestones(
950
954
  conn: sqlite3.Connection,
951
955
  ) -> list[TuiPercentMilestone]:
952
- """Return per-percent crossings for the current week, ascending by percent.
956
+ """Return per-percent crossings for the current week's ACTIVE
957
+ segment, ascending by percent.
953
958
 
954
959
  Resolves `week_start_date` from the latest `weekly_usage_snapshots` row
955
960
  — the same path `cmd_percent_breakdown` takes. The post-override
@@ -957,15 +962,55 @@ def _tui_build_percent_milestones(
957
962
  reset, `_apply_midweek_reset_override` shifts that datetime forward to
958
963
  the reset instant, whose `.date()` no longer matches the `week_start_date`
959
964
  under which milestones were recorded.
960
- Returns [] if no usage snapshot (or no milestone) exists.
965
+
966
+ v1.7.2: when a `week_reset_events` row exists for the snapshot's
967
+ `week_end_at`, narrow to the active segment so the dashboard /
968
+ TUI milestone panel stays coherent with the already-credit-aware
969
+ header. ``active_segment = 0`` (sentinel) preserves legacy
970
+ behavior on un-credited weeks.
971
+
972
+ Returns [] if no usage snapshot exists, OR if the active segment
973
+ has no milestone rows yet (post-credit "fresh" state).
961
974
  """
962
975
  latest = conn.execute(
963
- "SELECT week_start_date FROM weekly_usage_snapshots "
976
+ "SELECT week_start_date, week_end_at FROM weekly_usage_snapshots "
977
+ "WHERE week_end_at IS NOT NULL "
964
978
  "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
965
979
  ).fetchone()
966
980
  if latest is None:
967
- return []
968
- rows = get_milestones_for_week(conn, latest["week_start_date"])
981
+ # Legacy fallback: a snapshot without week_end_at can still have
982
+ # milestones keep the prior behavior in that path.
983
+ latest = conn.execute(
984
+ "SELECT week_start_date, NULL AS week_end_at "
985
+ "FROM weekly_usage_snapshots "
986
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
987
+ ).fetchone()
988
+ if latest is None:
989
+ return []
990
+
991
+ # Resolve active segment via the canonical end_at.
992
+ active_segment = 0
993
+ if latest["week_end_at"]:
994
+ try:
995
+ canon_end = _canonicalize_optional_iso(
996
+ latest["week_end_at"], "tui.pm.cur"
997
+ )
998
+ except (AttributeError, ValueError):
999
+ canon_end = None
1000
+ if canon_end:
1001
+ seg_row = conn.execute(
1002
+ "SELECT id FROM week_reset_events "
1003
+ "WHERE new_week_end_at = ? "
1004
+ "ORDER BY id DESC LIMIT 1",
1005
+ (canon_end,),
1006
+ ).fetchone()
1007
+ if seg_row is not None:
1008
+ active_segment = int(seg_row["id"])
1009
+
1010
+ rows = [
1011
+ r for r in get_milestones_for_week(conn, latest["week_start_date"])
1012
+ if int(r["reset_event_id"] or 0) == active_segment
1013
+ ]
969
1014
  out: list[TuiPercentMilestone] = []
970
1015
  for r in rows:
971
1016
  try:
@@ -985,6 +1030,56 @@ def _tui_build_percent_milestones(
985
1030
  return out
986
1031
 
987
1032
 
1033
+ def _tui_build_five_hour_milestones(
1034
+ conn: sqlite3.Connection,
1035
+ five_hour_window_key: int | None,
1036
+ ) -> list[dict]:
1037
+ """Return per-percent 5h-block milestones for the given window, in
1038
+ capture-time order. Spec §5.3 — drives the CurrentWeekModal's new
1039
+ 5h-milestone timeline section.
1040
+
1041
+ Bucket B per §3.2: NO ``reset_event_id`` filter — both pre- and
1042
+ post-credit segments render in the merged chronological stream so
1043
+ the user sees the full history of the active block including
1044
+ repeated threshold values after an in-place credit. The React layer
1045
+ differentiates rows by ``reset_event_id`` for key uniqueness.
1046
+
1047
+ Returns [] when the current week has no API-anchored 5h block. The
1048
+ envelope-shaped dict mirrors the CLI ``five-hour-breakdown --json``
1049
+ milestone objects but with snake_case keys (envelope convention).
1050
+ """
1051
+ if five_hour_window_key is None:
1052
+ return []
1053
+ rows = conn.execute(
1054
+ """
1055
+ SELECT percent_threshold, captured_at_utc, block_cost_usd,
1056
+ marginal_cost_usd, seven_day_pct_at_crossing,
1057
+ reset_event_id
1058
+ FROM five_hour_milestones
1059
+ WHERE five_hour_window_key = ?
1060
+ ORDER BY captured_at_utc ASC, id ASC
1061
+ """,
1062
+ (int(five_hour_window_key),),
1063
+ ).fetchall()
1064
+ out: list[dict] = []
1065
+ for r in rows:
1066
+ out.append({
1067
+ "percent_threshold": int(r["percent_threshold"]),
1068
+ "captured_at_utc": r["captured_at_utc"],
1069
+ "block_cost_usd": float(r["block_cost_usd"]),
1070
+ "marginal_cost_usd": (
1071
+ None if r["marginal_cost_usd"] is None
1072
+ else float(r["marginal_cost_usd"])
1073
+ ),
1074
+ "seven_day_pct_at_crossing": (
1075
+ None if r["seven_day_pct_at_crossing"] is None
1076
+ else float(r["seven_day_pct_at_crossing"])
1077
+ ),
1078
+ "reset_event_id": int(r["reset_event_id"] or 0),
1079
+ })
1080
+ return out
1081
+
1082
+
988
1083
  @dataclass
989
1084
  class DataSnapshot:
990
1085
  """All data needed to render one TUI frame. Produced by sync thread,
@@ -1017,6 +1112,13 @@ class DataSnapshot:
1017
1112
  # `current_week.five_hour_block` is precomputed via
1018
1113
  # `_select_current_block_for_envelope`).
1019
1114
  alerts: list[dict] = field(default_factory=list)
1115
+ # ---- 5h in-place credit (v1.7.x) ----
1116
+ # Already-envelope-shaped dicts for the CurrentWeekModal's new 5h
1117
+ # milestone timeline (spec §5.3, Codex r1 finding 3). Parallel to
1118
+ # ``percent_milestones`` (which carries the WEEKLY timeline). Loaded
1119
+ # at sync-thread time so ``snapshot_to_envelope`` stays a pure
1120
+ # renderer; empty list when no current 5h block is bound.
1121
+ five_hour_milestones: list[dict] = field(default_factory=list)
1020
1122
 
1021
1123
  @classmethod
1022
1124
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1423,27 +1525,71 @@ def _tui_build_trend(
1423
1525
  columns (`week_start_at`, `used_pct`, `dollars_per_percent`) matches
1424
1526
  `cmd_report` byte-for-byte — verified in the bundle regression diff.
1425
1527
  """
1426
- # `get_recent_weeks` returns WeekRef rows DESC by week_start_date.
1528
+ # `get_recent_weeks` returns WeekRef rows DESC by week_start_date,
1529
+ # already routed through `_apply_reset_events_to_weekrefs` so credited
1530
+ # weeks come back as TWO refs (pre-credit + post-credit) sharing the
1531
+ # same `WeekRef.key`.
1427
1532
  week_refs = get_recent_weeks(conn, max(1, count))
1428
1533
 
1429
1534
  # Figure out which week_ref corresponds to the current subscription week.
1430
- # Uses the same key derivation `cmd_report` does latest usage snapshot's
1431
- # week_start_date, canonicalized through `_get_canonical_boundary_for_date`.
1535
+ # Mirrors `cmd_report`'s Bug D pattern: build a current_ref from the
1536
+ # latest usage snapshot, route it through `_apply_reset_events_to_weekrefs`
1537
+ # so its `week_start_at` reflects the post-credit segment (or the
1538
+ # original start for non-credit weeks), then disambiguate the
1539
+ # synthesized pre-credit ref from the live post-credit ref via BOTH
1540
+ # `key` AND `week_start_at`. Key-only equality marks both segments
1541
+ # as current, which is why the dashboard's trend panel previously
1542
+ # showed two adjacent rows both highlighted as "current" with
1543
+ # identical 4.0% values on the user's live in-place credit data.
1432
1544
  latest_usage = conn.execute(
1433
1545
  "SELECT week_start_date, week_end_date "
1434
1546
  "FROM weekly_usage_snapshots "
1435
1547
  "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
1436
1548
  ).fetchone()
1437
1549
  current_key: str | None = None
1438
- if latest_usage is not None:
1550
+ current_week_start_at: str | None = None
1551
+ if latest_usage is not None and latest_usage["week_start_date"] is not None:
1439
1552
  current_key = latest_usage["week_start_date"]
1553
+ try:
1554
+ c = _cctally()
1555
+ canon_start, canon_end = c._get_canonical_boundary_for_date(
1556
+ conn, latest_usage["week_start_date"]
1557
+ )
1558
+ current_ref = c.make_week_ref(
1559
+ week_start_date=latest_usage["week_start_date"],
1560
+ week_end_date=latest_usage["week_end_date"],
1561
+ week_start_at=canon_start,
1562
+ week_end_at=canon_end,
1563
+ )
1564
+ _adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
1565
+ if _adjusted:
1566
+ current_week_start_at = _adjusted[0].week_start_at
1567
+ except (ValueError, sqlite3.DatabaseError, AttributeError):
1568
+ current_week_start_at = None
1440
1569
 
1441
1570
  # Build an intermediate list of (week_ref, used_pct, dpp) in oldest-first
1442
1571
  # chronological order.
1443
1572
  chrono = list(reversed(week_refs))
1573
+ # Split-key set (Bug D): credited weeks appear twice in `week_refs`
1574
+ # with identical `WeekRef.key`. For those keys ONLY, pin
1575
+ # `as_of_utc=week_ref.week_end_at` so each segment finds its own
1576
+ # latest snapshot — without this both segments collapse to the
1577
+ # post-credit snapshot's weekly_percent. Non-credit weeks (single
1578
+ # ref per key) keep the legacy unfiltered lookup.
1579
+ _split_keys = {
1580
+ r.key
1581
+ for r in week_refs
1582
+ if sum(1 for x in week_refs if x.key == r.key) > 1
1583
+ }
1444
1584
  intermediate: list[tuple[Any, float | None, float | None]] = []
1445
1585
  for week_ref in chrono:
1446
- usage = get_latest_usage_for_week(conn, week_ref)
1586
+ usage = get_latest_usage_for_week(
1587
+ conn,
1588
+ week_ref,
1589
+ as_of_utc=(
1590
+ week_ref.week_end_at if week_ref.key in _split_keys else None
1591
+ ),
1592
+ )
1447
1593
  # See cmd_report for why reset-affected weeks skip the cost cache
1448
1594
  # and live-compute from session_entries over the effective range.
1449
1595
  if _week_ref_has_reset_event(conn, week_ref):
@@ -1491,6 +1637,23 @@ def _tui_build_trend(
1491
1637
  # localizing midnight-UTC doesn't shift it to the prior day in
1492
1638
  # zones west of UTC (e.g. 2026-04-14 → "Apr 13" in America/New_York).
1493
1639
  week_label = week_ref.week_start.strftime("%b %d")
1640
+ # Bug G (v1.7.2 round-5): match on BOTH `key` AND `week_start_at`
1641
+ # for credited weeks so the pre-credit synthesized ref doesn't
1642
+ # also light up as "current" — both refs share `key`, only their
1643
+ # `week_start_at` differs (post-credit = effective reset moment,
1644
+ # pre-credit = original API-derived start). Non-credit weeks
1645
+ # have only one ref per key so `week_start_at` matching is
1646
+ # automatic. When `current_week_start_at` is None (no reset
1647
+ # event for the current week, or the resolution above failed),
1648
+ # falls back to legacy key-only matching.
1649
+ is_cur = (
1650
+ current_key is not None
1651
+ and week_ref.key == current_key
1652
+ and (
1653
+ current_week_start_at is None
1654
+ or week_ref.week_start_at == current_week_start_at
1655
+ )
1656
+ )
1494
1657
  out.append(TuiTrendRow(
1495
1658
  week_label=week_label,
1496
1659
  week_start_at=week_start_dt,
@@ -1502,7 +1665,7 @@ def _tui_build_trend(
1502
1665
  dollars_per_percent=dpp,
1503
1666
  delta_dpp=delta,
1504
1667
  spark_height=spark,
1505
- is_current=(current_key is not None and week_ref.key == current_key),
1668
+ is_current=is_cur,
1506
1669
  ))
1507
1670
  if dpp is not None:
1508
1671
  prev_dpp = dpp
@@ -1782,6 +1945,19 @@ def _tui_build_snapshot(
1782
1945
  alerts = _build_alerts_envelope_array(conn)
1783
1946
  except Exception as exc:
1784
1947
  errors.append(f"alerts: {exc}")
1948
+ # ---- 5h in-place credit (v1.7.x) ----
1949
+ # Load 5h milestones (pre + post credit) for the current
1950
+ # block's window so CurrentWeekModal can render a merged
1951
+ # chronological timeline alongside its weekly milestones.
1952
+ # Spec §5.3 (Codex r1 finding 3).
1953
+ fh_milestones: list[dict] = []
1954
+ try:
1955
+ win_key = None
1956
+ if cw is not None and isinstance(cw.five_hour_block, dict):
1957
+ win_key = cw.five_hour_block.get("five_hour_window_key")
1958
+ fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
1959
+ except Exception as exc:
1960
+ errors.append(f"five-hour-milestones: {exc}")
1785
1961
  return DataSnapshot(
1786
1962
  current_week=cw,
1787
1963
  forecast=fc,
@@ -1797,6 +1973,7 @@ def _tui_build_snapshot(
1797
1973
  blocks_panel=blocks_panel,
1798
1974
  daily_panel=daily_panel,
1799
1975
  alerts=alerts,
1976
+ five_hour_milestones=fh_milestones,
1800
1977
  )
1801
1978
  finally:
1802
1979
  conn.close()
@@ -2269,11 +2446,26 @@ def _tui_panel_current_week(
2269
2446
  f"${cw.dollars_per_percent:.2f}"
2270
2447
  if cw.dollars_per_percent is not None else "—"
2271
2448
  )
2449
+ # Spec §5.4 — credit badge next to the 5h percent. Source: same
2450
+ # ``cw.five_hour_block.credits`` channel that drives the dashboard
2451
+ # chip; only show when at least one credit is present for the
2452
+ # current block. Format: ``⚡ -Xpp`` (single) / ``⚡ -Xpp, -Ypp``
2453
+ # (stacked across distinct 10-min slots).
2454
+ fh_credit_badge = ""
2455
+ fhb = getattr(cw, "five_hour_block", None)
2456
+ if isinstance(fhb, dict):
2457
+ fh_credits = fhb.get("credits") or []
2458
+ if fh_credits:
2459
+ deltas = ", ".join(
2460
+ f"{float(c.get('delta_pp', 0.0)):+.0f}pp"
2461
+ for c in fh_credits
2462
+ )
2463
+ fh_credit_badge = f" {{bright}}⚡ {deltas}{{/}}"
2272
2464
  lines = [
2273
2465
  "",
2274
2466
  f" Used {{{used_cls}}}{bar_fill}{{/}} {{{used_cls}.b}}{cw.used_pct:>5.1f}%{{/}}",
2275
2467
  "",
2276
- f" 5-hour {{bar.accent}}{five_bar}{{/}} {{bright}}{int(five):>3d}%{{/}}",
2468
+ f" 5-hour {{bar.accent}}{five_bar}{{/}} {{bright}}{int(five):>3d}%{{/}}{fh_credit_badge}",
2277
2469
  f" {{dim}}{fr_str}{{/}}" if fr_str else "",
2278
2470
  "",
2279
2471
  f" {{dim}}Spent{{/}} {{bright}}${cw.spent_usd:.2f}{{/}} "
@@ -2322,6 +2514,19 @@ def _tui_panel_current_week_hero(
2322
2514
  else:
2323
2515
  reset_suffix = ""
2324
2516
 
2517
+ # Spec §5.4 — credit badge in the hero variant. Same source as the
2518
+ # grid variant; append after the reset suffix so the badge follows
2519
+ # the "resets in" timer.
2520
+ fhb_hero = getattr(cw, "five_hour_block", None)
2521
+ if isinstance(fhb_hero, dict):
2522
+ fh_credits_hero = fhb_hero.get("credits") or []
2523
+ if fh_credits_hero:
2524
+ deltas_hero = ", ".join(
2525
+ f"{float(c.get('delta_pp', 0.0)):+.0f}pp"
2526
+ for c in fh_credits_hero
2527
+ )
2528
+ reset_suffix = f"{reset_suffix} {{bright}}⚡ {deltas_hero}{{/}}"
2529
+
2325
2530
  if snap.last_sync_error:
2326
2531
  health = "{warn}daemon error{/}"
2327
2532
  elif snap.last_sync_at is None:
@@ -94,6 +94,7 @@ def _group_entries_into_blocks(
94
94
  mode: str = "auto",
95
95
  *,
96
96
  recorded_windows: list[dt.datetime] | None = None,
97
+ block_start_overrides: dict[dt.datetime, dt.datetime] | None = None,
97
98
  now: dt.datetime | None = None,
98
99
  ) -> list[Block]:
99
100
  """Group sorted UsageEntry objects into 5-hour blocks with gap detection.
@@ -106,6 +107,18 @@ def _group_entries_into_blocks(
106
107
  into per-R buckets and built as 'recorded' blocks. Leftover entries
107
108
  run through the existing gap-detection heuristic (anchor='heuristic').
108
109
 
110
+ `block_start_overrides` (v1.7.2 round-5 / Bug J): an optional
111
+ `{R → block_start_at}` map. When present for a given R, the
112
+ recorded block's displayed ``start_time`` becomes the override
113
+ instead of the default ``R - BLOCK_DURATION``. Used by
114
+ ``_load_recorded_five_hour_windows`` to preserve the real
115
+ ``five_hour_blocks.block_start_at`` for credit-truncated windows
116
+ (an in-place credit shortens the prior 5h block's effective end
117
+ to the credit moment, but the block's API-derived START is
118
+ unchanged — without an override the renderer would compute
119
+ ``start = truncated_R - 5h`` which is hours before the real start
120
+ and confuses the user with an off-by-hours window header).
121
+
109
122
  `now` pins the current instant (typically via `_command_as_of()`). When
110
123
  omitted, falls back to wall clock so existing callers are unaffected.
111
124
  """
@@ -117,13 +130,22 @@ def _group_entries_into_blocks(
117
130
  now = dt.datetime.now(dt.timezone.utc)
118
131
 
119
132
  recorded_windows = sorted(recorded_windows or [])
133
+ block_start_overrides = block_start_overrides or {}
120
134
 
121
135
  # ── Partition entries by recorded windows ──────────────────────────
122
136
  # For each R in recorded_windows, entries whose timestamp falls in
123
- # [R - BLOCK_DURATION, R) go into recorded_buckets[R]. Everything else
124
- # (gaps between recorded windows, or fully outside any window) drops
125
- # into `leftover` and runs through the existing heuristic grouper.
126
- # Task 5 will consume recorded_buckets; for now it is built but unused.
137
+ # [override_start_or_R-5h, R) go into recorded_buckets[R]. Everything
138
+ # else (gaps between recorded windows, or fully outside any window)
139
+ # drops into `leftover` and runs through the existing heuristic
140
+ # grouper.
141
+ #
142
+ # Why override_start_or_R-5h, not always R-5h: a credit-truncated
143
+ # canonical block has R = effective_reset_at_utc (e.g. 17:58Z) but
144
+ # its real ``block_start_at`` is unchanged (e.g. 15:50Z). Using
145
+ # `R - 5h` as the partition floor would pull entries from earlier
146
+ # blocks (e.g. 12:58-15:50Z range) into the truncated bucket. The
147
+ # override keeps the real start so each entry lands in the bucket
148
+ # whose API-defined interval actually contains it.
127
149
  recorded_buckets: dict[dt.datetime, list[UsageEntry]] = {
128
150
  R: [] for R in recorded_windows
129
151
  }
@@ -132,7 +154,8 @@ def _group_entries_into_blocks(
132
154
  idx = bisect.bisect_right(recorded_windows, entry.timestamp)
133
155
  if idx < len(recorded_windows):
134
156
  R = recorded_windows[idx]
135
- if R - BLOCK_DURATION <= entry.timestamp:
157
+ bucket_start = block_start_overrides.get(R, R - BLOCK_DURATION)
158
+ if bucket_start <= entry.timestamp:
136
159
  recorded_buckets[R].append(entry)
137
160
  continue
138
161
  leftover.append(entry)
@@ -193,7 +216,11 @@ def _group_entries_into_blocks(
193
216
  bucket = recorded_buckets[R]
194
217
  if not bucket:
195
218
  continue
196
- start_time = R - BLOCK_DURATION
219
+ # Display start: override when present (credit-truncated
220
+ # canonical blocks need their real block_start_at so the
221
+ # rendered window header matches Anthropic's actual interval);
222
+ # default to R - BLOCK_DURATION for normal canonical anchors.
223
+ start_time = block_start_overrides.get(R, R - BLOCK_DURATION)
197
224
  end_time = R
198
225
  bucket_sorted = sorted(bucket, key=lambda e: e.timestamp)
199
226
  blk = _build_activity_block(
@@ -60,6 +60,19 @@ class DoctorState:
60
60
  # skipped/failed/pending or (b) a buggy writer slipped through
61
61
  # after the migration ran.
62
62
  forked_bucket_counts: Optional[dict]
63
+ # v1.7.2 credited-week tracking. Each entry is a dict with:
64
+ # * ``week_start_date`` — the credited week's bucket key
65
+ # * ``latest_weekly_percent`` — most recent weekly_percent for that
66
+ # week (used to gate the WARN — a credit + 0% means the user
67
+ # hasn't started the new segment yet, which is the EXPECTED
68
+ # state and shouldn't warn)
69
+ # * ``post_credit_milestone_count`` — count of percent_milestones
70
+ # rows with ``reset_event_id`` matching the credit event for
71
+ # this week
72
+ # None means the stats.db couldn't be opened to gather; check
73
+ # degrades to OK rather than FAIL (consistent with the rest of
74
+ # the doctor kernel's degradation posture).
75
+ credited_weeks: Optional[list[dict]]
63
76
  codex_entries_count: Optional[int]
64
77
  codex_last_entry_at: Optional[dt.datetime]
65
78
  codex_jsonl_present: bool
@@ -559,6 +572,71 @@ def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
559
572
 
560
573
 
561
574
 
575
+ def _check_data_post_credit_milestones(s: DoctorState) -> CheckResult:
576
+ """Invariant: for every week with a ``week_reset_events`` row whose
577
+ ``effective_reset_at_utc`` is in the past AND latest_weekly_percent
578
+ >= 1.0, the percent_milestones ledger should have at least one row
579
+ in the credit's segment.
580
+
581
+ Pre-v1.7.2 the milestone writer didn't know about segments, so a
582
+ credited week could have a non-empty pre-credit ledger but zero
583
+ post-credit rows even after the user's usage climbed past 1%. This
584
+ check surfaces that drift as a WARN (informational; no remediation
585
+ — the next ``record-usage`` tick at >=1% will self-heal via the
586
+ segment-aware probe).
587
+
588
+ OK (silent) when:
589
+ * No credited weeks exist (state.credited_weeks is empty/None).
590
+ * Every credited week has at least one post-credit milestone row
591
+ OR latest_weekly_percent < 1.0 (= "new segment not started yet,
592
+ which is expected on a fresh credit").
593
+ """
594
+ weeks = s.credited_weeks
595
+ if weeks is None:
596
+ # Gather failed (stats.db open error). Don't double-warn; the
597
+ # db.stats.file check already covers DB-open issues.
598
+ return CheckResult(
599
+ id="data.post_credit_milestones",
600
+ title="Post-credit milestones",
601
+ severity="ok",
602
+ summary="no data",
603
+ remediation=None,
604
+ details={"reason": "credited_weeks gather returned None"},
605
+ )
606
+ stuck = [
607
+ w for w in weeks
608
+ if float(w.get("latest_weekly_percent") or 0.0) >= 1.0
609
+ and int(w.get("post_credit_milestone_count") or 0) == 0
610
+ ]
611
+ if not stuck:
612
+ return CheckResult(
613
+ id="data.post_credit_milestones",
614
+ title="Post-credit milestones",
615
+ severity="ok",
616
+ summary=(
617
+ f"{len(weeks)} credited week(s); all tracked"
618
+ if weeks else "no credited weeks"
619
+ ),
620
+ remediation=None,
621
+ details={"credited_weeks": len(weeks)},
622
+ )
623
+ starts = ", ".join(sorted(w["week_start_date"] for w in stuck))
624
+ return CheckResult(
625
+ id="data.post_credit_milestones",
626
+ title="Post-credit milestones",
627
+ severity="warn",
628
+ summary=(
629
+ f"{len(stuck)} credited week(s) with no post-credit milestone "
630
+ f"crossings yet: {starts}"
631
+ ),
632
+ remediation=None,
633
+ details={
634
+ "stuck_week_count": len(stuck),
635
+ "stuck_week_starts": [w["week_start_date"] for w in stuck],
636
+ },
637
+ )
638
+
639
+
562
640
  _LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
563
641
 
564
642
 
@@ -786,6 +864,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
786
864
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
787
865
  ("data.codex_cache", "_check_data_codex_cache"),
788
866
  ("data.forked_buckets", "_check_data_forked_buckets"),
867
+ ("data.post_credit_milestones", "_check_data_post_credit_milestones"),
789
868
  )),
790
869
  ("safety", "Safety", (
791
870
  ("safety.dashboard_bind", "_check_safety_dashboard_bind"),
@@ -2671,6 +2671,12 @@ def _five_hour_blocks_to_json(
2671
2671
  "sevenDayPctAtBlockEnd": p_end,
2672
2672
  "sevenDayPctDeltaPp": delta,
2673
2673
  "crossedSevenDayReset": crossed,
2674
+ # Spec §5.1 — in-place credit events for this 5h block, in
2675
+ # ascending effective_reset_at order. Empty list when the
2676
+ # block carries no credit detections. Source: ``__credits``
2677
+ # side-channel attached by ``cmd_five_hour_blocks`` via
2678
+ # ``credits_by_window``.
2679
+ "credits": d.get("__credits") or [],
2674
2680
  }
2675
2681
  if breakdown_axis == "model":
2676
2682
  out["modelBreakdowns"] = [
@@ -2752,6 +2758,20 @@ def _render_five_hour_blocks_table(
2752
2758
  formatted_start = _format_block_start(d["block_start_at"], args._resolved_tz)
2753
2759
  if crossed:
2754
2760
  formatted_start = f"⚡ {formatted_start}"
2761
+ # Spec §5.1 — credit chip suffix. When the block carries one or
2762
+ # more in-place credit events (5h credit detection v1.7.x;
2763
+ # __credits side-channel set by ``cmd_five_hour_blocks``), append
2764
+ # a ⚡-prefixed chip listing the per-credit delta-pp. Multiple
2765
+ # credits in the same block concatenate (e.g.
2766
+ # `⚡ credited -20pp, -8pp`). The chip text sits AFTER the
2767
+ # block-start cell's existing crossed-reset glyph so the two
2768
+ # signals visually disambiguate by position (crossed-reset
2769
+ # prefix vs. credit suffix). Both use ⚡ — different semantics,
2770
+ # different positions.
2771
+ credits = d.get("__credits") or []
2772
+ if credits:
2773
+ deltas = ", ".join(f"{c['deltaPp']:+.0f}pp" for c in credits)
2774
+ formatted_start = f"{formatted_start} ⚡ credited {deltas}"
2755
2775
  rows.append([
2756
2776
  formatted_start,
2757
2777
  "ACTIVE" if d["__is_active"] else "closed",