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.
package/bin/cctally CHANGED
@@ -3817,12 +3817,20 @@ def open_db() -> sqlite3.Connection:
3817
3817
  marginal_cost_usd REAL,
3818
3818
  usage_snapshot_id INTEGER NOT NULL,
3819
3819
  cost_snapshot_id INTEGER NOT NULL,
3820
- UNIQUE(week_start_date, percent_threshold)
3820
+ reset_event_id INTEGER NOT NULL DEFAULT 0,
3821
+ UNIQUE(week_start_date, percent_threshold, reset_event_id)
3821
3822
  )
3822
3823
  """
3823
3824
  )
3824
3825
 
3825
3826
  add_column_if_missing(conn, "percent_milestones", "five_hour_percent_at_crossing", "REAL")
3827
+ # reset_event_id: segment column added by migration 005. Fresh-install
3828
+ # DBs get it via the live CREATE TABLE above + the dispatcher
3829
+ # fast-stamps the migration. Existing pre-005 DBs trip the migration's
3830
+ # rename-recreate-copy idiom (handler in _cctally_db.py); the handler's
3831
+ # fast-path probe stamps the marker when the column is already present
3832
+ # (covers the corner case where a partially-upgraded DB has the column
3833
+ # but not the new UNIQUE — re-run is safe).
3826
3834
 
3827
3835
  # alerted_at: populated by the alert-dispatch path when a milestone-INSERT
3828
3836
  # row's threshold matches the user's configured alerts.weekly_thresholds /
@@ -3856,6 +3864,43 @@ def open_db() -> sqlite3.Connection:
3856
3864
  )
3857
3865
  _backfill_week_reset_events(conn)
3858
3866
 
3867
+ # ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
3868
+ # Parallel concept to ``week_reset_events`` for the 5h dimension; lives
3869
+ # adjacent in ``_apply_schema`` because the two carry the same kind of
3870
+ # signal at different cadences. Diverges from weekly in that the payload
3871
+ # is the *percent values* (prior + post) rather than boundary keys,
3872
+ # because the 5h variant has a stable ``five_hour_window_key`` and only
3873
+ # the percent moves. See spec
3874
+ # docs/superpowers/specs/2026-05-16-5h-in-place-credit-detection.md §3.1
3875
+ # for rationale.
3876
+ #
3877
+ # UNIQUE(five_hour_window_key, effective_reset_at_utc) — supports stacked
3878
+ # credits across DISTINCT 10-min slots inside one block (see spec §2.3
3879
+ # "Bounded stacked-credit resolution" for the cap statement: ~30 distinct
3880
+ # slots per 5h block when floor matches ``_canonical_5h_window_key``'s
3881
+ # 600-second floor; same-slot collisions silently absorbed by
3882
+ # INSERT OR IGNORE — an intentional cap, not a bug).
3883
+ #
3884
+ # No FK per CLAUDE.md gotcha: FKs in this codebase are documentation-only
3885
+ # (``PRAGMA foreign_keys`` not enabled). ``five_hour_window_key`` provides
3886
+ # the join key without a formal FK.
3887
+ #
3888
+ # No ``_backfill_five_hour_reset_events`` call follows (forward-only ship
3889
+ # per spec Q5; historical backfill deferred to a future issue).
3890
+ conn.execute(
3891
+ """
3892
+ CREATE TABLE IF NOT EXISTS five_hour_reset_events (
3893
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3894
+ detected_at_utc TEXT NOT NULL,
3895
+ five_hour_window_key INTEGER NOT NULL,
3896
+ prior_percent REAL NOT NULL,
3897
+ post_percent REAL NOT NULL,
3898
+ effective_reset_at_utc TEXT NOT NULL,
3899
+ UNIQUE(five_hour_window_key, effective_reset_at_utc)
3900
+ )
3901
+ """
3902
+ )
3903
+
3859
3904
  # ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
3860
3905
  conn.execute(
3861
3906
  """
@@ -3905,7 +3950,8 @@ def open_db() -> sqlite3.Connection:
3905
3950
  block_cost_usd REAL NOT NULL DEFAULT 0,
3906
3951
  marginal_cost_usd REAL,
3907
3952
  seven_day_pct_at_crossing REAL,
3908
- UNIQUE(five_hour_window_key, percent_threshold),
3953
+ reset_event_id INTEGER NOT NULL DEFAULT 0,
3954
+ UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
3909
3955
  FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
3910
3956
  )
3911
3957
  """
@@ -3925,6 +3971,16 @@ def open_db() -> sqlite3.Connection:
3925
3971
  # — never "delivery failed".
3926
3972
  add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
3927
3973
 
3974
+ # reset_event_id: segment column added by migration 006. Fresh-install
3975
+ # DBs get it via the live CREATE TABLE above + the dispatcher fast-stamps
3976
+ # the migration marker (the live DDL must carry the column AND the 3-col
3977
+ # UNIQUE for fast-stamp to be safe — see spec §3.2). Existing pre-006
3978
+ # DBs trip the migration's rename-recreate-copy idiom (handler in
3979
+ # bin/_cctally_db.py); the handler's fast-path probe stamps the marker
3980
+ # when the column is already present (covers the corner case where a
3981
+ # partially-upgraded DB has the column but not the new UNIQUE — re-run
3982
+ # is safe). Mirrors weekly migration 005 / `percent_milestones`.
3983
+
3928
3984
  # ── five_hour_block_models (per-(block, model) rollup-child) ──
3929
3985
  # MUST be created BEFORE the parent-backfill gate below, because
3930
3986
  # _backfill_five_hour_blocks writes into this table on the fresh-install
@@ -4717,15 +4773,27 @@ def insert_cost_snapshot(
4717
4773
  def get_max_milestone_for_week(
4718
4774
  conn: sqlite3.Connection,
4719
4775
  week_start_date: str,
4776
+ *,
4777
+ reset_event_id: int = 0,
4720
4778
  ) -> int | None:
4721
- """Return the highest percent_threshold recorded for a week, or None."""
4779
+ """Return the highest percent_threshold recorded for a week's segment,
4780
+ or None.
4781
+
4782
+ ``reset_event_id`` (v1.7.2): default 0 (= pre-credit / no-event
4783
+ sentinel) preserves legacy behavior on un-credited weeks. When an
4784
+ in-place credit lifts a week into a new segment, callers pass the
4785
+ segment id so the segment's threshold ledger is independent of the
4786
+ pre-credit one — the post-credit 1% / 2% / 3% milestones fire even
4787
+ if the pre-credit segment already crossed those thresholds.
4788
+ """
4722
4789
  row = conn.execute(
4723
4790
  """
4724
4791
  SELECT MAX(percent_threshold) AS max_pct
4725
4792
  FROM percent_milestones
4726
4793
  WHERE week_start_date = ?
4794
+ AND reset_event_id = ?
4727
4795
  """,
4728
- (week_start_date,),
4796
+ (week_start_date, reset_event_id),
4729
4797
  ).fetchone()
4730
4798
  if row and row["max_pct"] is not None:
4731
4799
  return int(row["max_pct"])
@@ -4736,15 +4804,27 @@ def get_milestone_cost_for_week(
4736
4804
  conn: sqlite3.Connection,
4737
4805
  week_start_date: str,
4738
4806
  percent_threshold: int,
4807
+ *,
4808
+ reset_event_id: int = 0,
4739
4809
  ) -> float | None:
4740
- """Return the cumulative_cost_usd for a specific threshold, or None."""
4810
+ """Return the cumulative_cost_usd for a specific (week, threshold,
4811
+ segment), or None.
4812
+
4813
+ ``reset_event_id`` (v1.7.2): segment-aware lookup. Default 0 preserves
4814
+ legacy behavior. Used by ``maybe_record_milestone`` to compute the
4815
+ marginal cost between consecutive thresholds inside the SAME segment
4816
+ — without the filter, the post-credit threshold-3 row would compute
4817
+ its marginal against the pre-credit threshold-2 cost (wrong segment).
4818
+ """
4741
4819
  row = conn.execute(
4742
4820
  """
4743
4821
  SELECT cumulative_cost_usd
4744
4822
  FROM percent_milestones
4745
- WHERE week_start_date = ? AND percent_threshold = ?
4823
+ WHERE week_start_date = ?
4824
+ AND percent_threshold = ?
4825
+ AND reset_event_id = ?
4746
4826
  """,
4747
- (week_start_date, percent_threshold),
4827
+ (week_start_date, percent_threshold, reset_event_id),
4748
4828
  ).fetchone()
4749
4829
  if row:
4750
4830
  return float(row["cumulative_cost_usd"])
@@ -4781,18 +4861,29 @@ def insert_percent_milestone(
4781
4861
  five_hour_percent_at_crossing: float | None = None,
4782
4862
  *,
4783
4863
  commit: bool = True,
4864
+ reset_event_id: int = 0,
4784
4865
  ) -> int:
4785
4866
  """Insert a percent_milestones row idempotently.
4786
4867
 
4787
4868
  Returns the SQLite rowcount: 1 on a genuinely new crossing, 0 if a row
4788
- for (week_start_date, percent_threshold) already exists. Race-safe under
4789
- concurrent record-usage instances — aligns with the existing 5h-milestone
4790
- INSERT OR IGNORE pattern (see five_hour_milestones write path).
4869
+ for (week_start_date, percent_threshold, reset_event_id) already exists.
4870
+ Race-safe under concurrent record-usage instances — aligns with the
4871
+ existing 5h-milestone INSERT OR IGNORE pattern (see five_hour_milestones
4872
+ write path).
4873
+
4874
+ ``reset_event_id`` (v1.7.2 segment column, migration 005): defaults to
4875
+ ``0`` (= pre-credit / no-event sentinel). When an in-place credit fires
4876
+ for the current week, the caller (``maybe_record_milestone``) resolves
4877
+ the active segment from ``week_reset_events`` and passes it in so
4878
+ post-credit threshold crossings land as a SEPARATE row from any
4879
+ pre-credit one at the same (week, threshold). The UNIQUE constraint
4880
+ is on the 3-tuple, so (week=W, threshold=T, segment=0) and (W, T,
4881
+ event_id) coexist.
4791
4882
 
4792
4883
  Callers that need the row id MUST follow up with an explicit
4793
4884
  `SELECT id FROM percent_milestones WHERE week_start_date=? AND
4794
- percent_threshold=?` query — `lastrowid` is unreliable when the row
4795
- is the silent-duplicate target.
4885
+ percent_threshold=? AND reset_event_id=?` query — `lastrowid` is
4886
+ unreliable when the row is the silent-duplicate target.
4796
4887
 
4797
4888
  ``commit=False`` skips the inner ``conn.commit()`` so the caller can
4798
4889
  bundle the INSERT with a follow-up ``alerted_at`` UPDATE in a single
@@ -4817,9 +4908,10 @@ def insert_percent_milestone(
4817
4908
  marginal_cost_usd,
4818
4909
  usage_snapshot_id,
4819
4910
  cost_snapshot_id,
4820
- five_hour_percent_at_crossing
4911
+ five_hour_percent_at_crossing,
4912
+ reset_event_id
4821
4913
  )
4822
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4914
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4823
4915
  """,
4824
4916
  (
4825
4917
  now_utc_iso(),
@@ -4833,6 +4925,7 @@ def insert_percent_milestone(
4833
4925
  usage_snapshot_id,
4834
4926
  cost_snapshot_id,
4835
4927
  five_hour_percent_at_crossing,
4928
+ reset_event_id,
4836
4929
  ),
4837
4930
  )
4838
4931
  if commit:
@@ -5343,10 +5436,31 @@ def _select_non_overlapping_recorded_windows(
5343
5436
  def _load_recorded_five_hour_windows(
5344
5437
  range_start: dt.datetime,
5345
5438
  range_end: dt.datetime,
5346
- ) -> list[dt.datetime]:
5439
+ ) -> tuple[list[dt.datetime], dict[dt.datetime, dt.datetime]]:
5347
5440
  """Return sorted, UTC-aware recorded ``five_hour_resets_at`` values
5348
5441
  that anchor real 5h windows in ``[range_start, range_end]``.
5349
5442
 
5443
+ Two sources contribute to the merged anchor set:
5444
+
5445
+ 1. ``weekly_usage_snapshots.five_hour_resets_at`` — every
5446
+ record-usage tick stores the API-derived reset moment here. The
5447
+ count of supporting rows weights each anchor (low-count anchors
5448
+ are downvoted in ``_select_non_overlapping_recorded_windows``).
5449
+
5450
+ 2. ``five_hour_blocks.five_hour_resets_at`` — the canonical
5451
+ API-anchored rollup table. Each row represents ONE accepted 5h
5452
+ window after ``maybe_update_five_hour_block`` has merged jittered
5453
+ reset values via ``_canonical_5h_window_key``. These are the
5454
+ authoritative anchors; we count them with a heavy weight (1000)
5455
+ so they always dominate over jittered raw snapshot values when
5456
+ both sources see the same physical window. Without this source,
5457
+ ``cctally blocks`` falls back to the heuristic anchor for the
5458
+ ACTIVE row whenever the most recent
5459
+ ``weekly_usage_snapshots.five_hour_resets_at`` value disagrees
5460
+ with the canonical anchor — Bug C in v1.7.2 round 3. Tied
5461
+ windows (jitter within 10-minute floor) collapse to the same
5462
+ key and the canonical weight dominates.
5463
+
5350
5464
  Each value is parsed as ISO-8601 (the storage format produced by
5351
5465
  ``cmd_record_usage``) and normalized to UTC. Naive datetimes are
5352
5466
  treated as already-UTC. Values are floored to the previous
@@ -5373,11 +5487,58 @@ def _load_recorded_five_hour_windows(
5373
5487
  " AND five_hour_resets_at <= ?",
5374
5488
  (range_start.isoformat(), range_end.isoformat()),
5375
5489
  ).fetchall()
5490
+ # Canonical API-anchored windows from the rollup table.
5491
+ # Heavy-weight (1000 per row) so they always dominate over
5492
+ # any jittered raw-snapshot value sharing the same floored
5493
+ # 10-minute bucket. Wrapped in a defensive try in case the
5494
+ # five_hour_blocks table doesn't exist yet (very-old DB on
5495
+ # first open before the bootstrap migration ran).
5496
+ # Pull ``block_start_at`` alongside ``five_hour_resets_at``
5497
+ # so Bug J's overlap-truncation step (below) can preserve
5498
+ # the real display start for credit-truncated blocks.
5499
+ canonical_rows: list[Any] = []
5500
+ try:
5501
+ canonical_rows = conn.execute(
5502
+ "SELECT five_hour_resets_at, block_start_at "
5503
+ "FROM five_hour_blocks "
5504
+ "WHERE five_hour_resets_at IS NOT NULL "
5505
+ " AND five_hour_resets_at >= ? "
5506
+ " AND five_hour_resets_at <= ?",
5507
+ (range_start.isoformat(), range_end.isoformat()),
5508
+ ).fetchall()
5509
+ except sqlite3.DatabaseError:
5510
+ canonical_rows = []
5511
+ # In-place credit events — used by Bug J to detect canonical
5512
+ # block overlaps that should be resolved by truncating the
5513
+ # earlier block at the credit moment (rather than dropping
5514
+ # one via _select_non_overlapping_recorded_windows, which
5515
+ # leaves the dropped block's entries unanchored and
5516
+ # rendered as a phantom heuristic "~" row).
5517
+ credit_moments: list[dt.datetime] = []
5518
+ try:
5519
+ credit_rows = conn.execute(
5520
+ "SELECT effective_reset_at_utc "
5521
+ "FROM week_reset_events "
5522
+ "WHERE old_week_end_at = effective_reset_at_utc"
5523
+ ).fetchall()
5524
+ for c in credit_rows:
5525
+ raw = c["effective_reset_at_utc"]
5526
+ try:
5527
+ d = dt.datetime.fromisoformat(str(raw))
5528
+ except ValueError:
5529
+ continue
5530
+ if d.tzinfo is None:
5531
+ d = d.replace(tzinfo=dt.timezone.utc)
5532
+ else:
5533
+ d = d.astimezone(dt.timezone.utc)
5534
+ credit_moments.append(d)
5535
+ except sqlite3.DatabaseError:
5536
+ credit_moments = []
5376
5537
  except (sqlite3.DatabaseError, OSError):
5377
5538
  # OSError covers ensure_dirs() failures (read-only FS, permission
5378
5539
  # denied on parent dir) that propagate from open_db() before any
5379
5540
  # SQL runs. Either way, fall back to the heuristic anchor path.
5380
- return []
5541
+ return [], {}
5381
5542
  counts: dict[dt.datetime, int] = {}
5382
5543
  for row in rows:
5383
5544
  raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
@@ -5393,7 +5554,110 @@ def _load_recorded_five_hour_windows(
5393
5554
  d = d.astimezone(dt.timezone.utc)
5394
5555
  snapped = _floor_to_ten_minutes(d)
5395
5556
  counts[snapped] = counts.get(snapped, 0) + 1
5396
- return _select_non_overlapping_recorded_windows(list(counts.items()))
5557
+ # Overlay canonical rollup anchors at heavy weight. Same flooring
5558
+ # rule so a jittered raw value (e.g. 17:48Z) and its canonicalized
5559
+ # rollup (e.g. 17:50Z) collapse into the same bucket; without that
5560
+ # the high-weight canonical entry would create a NEW bucket and
5561
+ # both would be reported as separate windows, then
5562
+ # `_select_non_overlapping_recorded_windows` (5h-disjoint
5563
+ # invariant) would drop the lower-weight one — but the wrong
5564
+ # one would win when jitter exceeds 10 minutes.
5565
+ #
5566
+ # Bug J (v1.7.2 round-5): collect canonical (block_start, R) pairs
5567
+ # so we can detect in-place-credit overlaps before flattening into
5568
+ # the weighted scheduler. When two canonical 5h blocks overlap AND
5569
+ # an in-place credit event falls inside the overlap, truncate the
5570
+ # EARLIER block's R to the credit moment (floored to 10 min so it
5571
+ # collapses with any same-bucket raw-snapshot value). The
5572
+ # truncated R keeps both blocks visible — without this fix the
5573
+ # earlier block's entries are silently rendered as a phantom
5574
+ # heuristic "~" row by `_group_entries_into_blocks`.
5575
+ canonical_pairs: list[tuple[dt.datetime, dt.datetime]] = []
5576
+ for row in canonical_rows:
5577
+ rs_raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
5578
+ bs_raw = row["block_start_at"] if hasattr(row, "keys") else row[1]
5579
+ if rs_raw is None or bs_raw is None:
5580
+ continue
5581
+ try:
5582
+ rs = dt.datetime.fromisoformat(str(rs_raw))
5583
+ bs = dt.datetime.fromisoformat(str(bs_raw))
5584
+ except ValueError:
5585
+ continue
5586
+ if rs.tzinfo is None:
5587
+ rs = rs.replace(tzinfo=dt.timezone.utc)
5588
+ else:
5589
+ rs = rs.astimezone(dt.timezone.utc)
5590
+ if bs.tzinfo is None:
5591
+ bs = bs.replace(tzinfo=dt.timezone.utc)
5592
+ else:
5593
+ bs = bs.astimezone(dt.timezone.utc)
5594
+ canonical_pairs.append((bs, rs))
5595
+ canonical_pairs.sort(key=lambda p: p[0])
5596
+
5597
+ # Detect overlap-with-credit and replace the earlier R with a
5598
+ # credit-truncated anchor. The (anchor → real_block_start) map is
5599
+ # returned alongside the anchor list so the renderer can show the
5600
+ # real block_start_at on the display row (instead of the default
5601
+ # R - 5h, which would be hours earlier for a 2h-truncated block).
5602
+ block_start_overrides: dict[dt.datetime, dt.datetime] = {}
5603
+ truncated_pairs: list[tuple[dt.datetime, dt.datetime]] = []
5604
+ for i, (bs, rs) in enumerate(canonical_pairs):
5605
+ truncated_R = rs
5606
+ if i + 1 < len(canonical_pairs):
5607
+ next_bs, _next_rs = canonical_pairs[i + 1]
5608
+ if rs > next_bs: # overlap with next block
5609
+ # Look for a credit moment inside [next_bs, rs] — the
5610
+ # part of the earlier block that overlaps the next.
5611
+ for cm in credit_moments:
5612
+ if next_bs <= cm <= rs:
5613
+ cm_floored = _floor_to_ten_minutes(cm)
5614
+ # Only truncate if cm is strictly inside the
5615
+ # earlier block; otherwise leave R alone and
5616
+ # let `_select_non_overlapping_recorded_windows`
5617
+ # drop one via its weight-tiebreaker.
5618
+ if bs < cm_floored < rs:
5619
+ truncated_R = cm_floored
5620
+ block_start_overrides[cm_floored] = bs
5621
+ break
5622
+ truncated_pairs.append((bs, truncated_R))
5623
+
5624
+ # Truncated anchors are credit-adjusted and known-good; bypass the
5625
+ # `_select_non_overlapping_recorded_windows` weighted scheduler for
5626
+ # them (the scheduler treats every R as the END of a fixed 5h
5627
+ # window and would see a truncated R conflicting with the adjacent
5628
+ # canonical block one slot earlier — e.g. truncated R=17:50 would
5629
+ # collide with the prior block's R=15:50 even though their REAL
5630
+ # intervals are [15:50, 17:50] and [10:50, 15:50] respectively —
5631
+ # adjacent, not overlapping). Add their R directly to the selector
5632
+ # input weight (so jittered same-bucket raw values still collapse)
5633
+ # but skip them when computing the overlap-safe subset.
5634
+ truncated_anchors: set[dt.datetime] = set()
5635
+ for bs, rs in truncated_pairs:
5636
+ snapped = _floor_to_ten_minutes(rs)
5637
+ if rs != _floor_to_ten_minutes(rs):
5638
+ if rs in block_start_overrides:
5639
+ block_start_overrides[snapped] = block_start_overrides.pop(rs)
5640
+ # Identify truncated anchors by membership in the override map
5641
+ # (only credit-truncated entries land there).
5642
+ if snapped in block_start_overrides:
5643
+ truncated_anchors.add(snapped)
5644
+ counts[snapped] = counts.get(snapped, 0) + 1000
5645
+
5646
+ non_truncated_items = [
5647
+ (a, w) for a, w in counts.items() if a not in truncated_anchors
5648
+ ]
5649
+ selected_non_truncated = _select_non_overlapping_recorded_windows(
5650
+ non_truncated_items
5651
+ )
5652
+ # Merge truncated anchors back in, sorted ascending. Their non-
5653
+ # overlap with the surrounding canonical blocks is guaranteed by
5654
+ # the credit-moment truncation: a truncated R sits strictly
5655
+ # between its real block_start (which equals the prior block's R)
5656
+ # and the next block's R.
5657
+ selected = sorted(
5658
+ list(selected_non_truncated) + list(truncated_anchors)
5659
+ )
5660
+ return selected, block_start_overrides
5397
5661
 
5398
5662
 
5399
5663
  def cmd_blocks(args: argparse.Namespace) -> int:
@@ -5438,15 +5702,39 @@ def cmd_blocks(args: argparse.Namespace) -> int:
5438
5702
  # reset R just after ``range_end`` (e.g. the active window when
5439
5703
  # range_end is wall-clock "now") can still anchor entries that fall
5440
5704
  # inside [range_start, range_end].
5441
- recorded_windows = _load_recorded_five_hour_windows(
5705
+ recorded_windows, block_start_overrides = _load_recorded_five_hour_windows(
5442
5706
  range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
5443
5707
  )
5444
5708
 
5445
5709
  # Group into blocks
5446
5710
  blocks = _group_entries_into_blocks(
5447
5711
  all_entries, mode="auto",
5448
- recorded_windows=recorded_windows, now=now_utc,
5449
- )
5712
+ recorded_windows=recorded_windows,
5713
+ block_start_overrides=block_start_overrides,
5714
+ now=now_utc,
5715
+ )
5716
+
5717
+ # Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
5718
+ # but a canonical ``five_hour_blocks`` row exists for the current 5h
5719
+ # window key, swap the active block's times to the API-anchored
5720
+ # ``block_start_at`` / ``five_hour_resets_at`` and flip its anchor to
5721
+ # ``"recorded"`` so the renderer drops the ``~`` prefix. The
5722
+ # heuristic anchor can sit in a different 10-minute floor bucket
5723
+ # than the canonical anchor (e.g. 23:00 IDT vs 20:50 IDT — 130 min
5724
+ # apart), so round-3's anchor-overlay in
5725
+ # ``_load_recorded_five_hour_windows`` doesn't catch this case.
5726
+ # Match by the live 5h window key (the same key
5727
+ # ``cmd_five_hour_blocks`` would surface for the ACTIVE row) — falls
5728
+ # back to heuristic behavior whenever the canonical row is missing.
5729
+ #
5730
+ # Bug F (v1.7.2 round-5): pass ``all_entries`` so the swap also
5731
+ # re-aggregates token / cost totals over the canonical interval. The
5732
+ # heuristic block holds only entries from the heuristic anchor
5733
+ # onwards; the canonical block may start earlier and include 1-2h of
5734
+ # additional entries. Without re-aggregation the displayed window
5735
+ # said one thing and the cost said another (live data: window
5736
+ # 20:50→01:50 with $45 cost vs the real $128).
5737
+ _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc)
5450
5738
 
5451
5739
  if args.json:
5452
5740
  print(_blocks_to_json(blocks))
@@ -5457,6 +5745,105 @@ def cmd_blocks(args: argparse.Namespace) -> int:
5457
5745
  return 0
5458
5746
 
5459
5747
 
5748
+ def _maybe_swap_active_block_to_canonical(
5749
+ blocks: list[Any],
5750
+ all_entries: list[Any],
5751
+ *,
5752
+ now: dt.datetime,
5753
+ ) -> None:
5754
+ """In-place swap of an ACTIVE heuristic block to its API-anchored
5755
+ canonical window — timestamps AND token/cost totals.
5756
+
5757
+ Looks up the live ``five_hour_window_key`` from the most recent
5758
+ ``weekly_usage_snapshots`` row, then joins to ``five_hour_blocks``
5759
+ for that key. If found AND the canonical window still contains
5760
+ ``now`` (resets_at > now), rewrites the active block to span the
5761
+ canonical ``[block_start_at, five_hour_resets_at)`` interval and
5762
+ flips ``anchor`` to ``"recorded"``. Token / cost totals are
5763
+ re-aggregated from ``all_entries`` filtered to that interval via
5764
+ ``_aggregate_block`` — the canonical window may contain 1-2h more
5765
+ activity than the heuristic grouping did, so the cost shown next
5766
+ to the swapped timestamps stays consistent with them (Bug F).
5767
+
5768
+ No-op when:
5769
+ - No block is active (no ``is_active`` and not gap).
5770
+ - The active block's anchor is already ``"recorded"``.
5771
+ - No live snapshot exists, or the snapshot's ``five_hour_window_key``
5772
+ is NULL.
5773
+ - No canonical ``five_hour_blocks`` row matches the live key.
5774
+ - The canonical window's ``five_hour_resets_at`` is already in
5775
+ the past relative to ``now`` (canonical block is closed; the
5776
+ heuristic block is genuinely the current activity).
5777
+
5778
+ Surgical helper called once from ``cmd_blocks`` after grouping.
5779
+ """
5780
+ # Find the active (non-gap, heuristic) block — there's at most one.
5781
+ active_idx = None
5782
+ for i, b in enumerate(blocks):
5783
+ if not b.is_gap and b.is_active:
5784
+ active_idx = i
5785
+ break
5786
+ if active_idx is None or blocks[active_idx].anchor != "heuristic":
5787
+ return
5788
+ active = blocks[active_idx]
5789
+ try:
5790
+ with open_db() as conn:
5791
+ snap = conn.execute(
5792
+ "SELECT five_hour_window_key FROM weekly_usage_snapshots "
5793
+ "WHERE five_hour_window_key IS NOT NULL "
5794
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
5795
+ ).fetchone()
5796
+ if snap is None or snap["five_hour_window_key"] is None:
5797
+ return
5798
+ key = int(snap["five_hour_window_key"])
5799
+ row = conn.execute(
5800
+ "SELECT block_start_at, five_hour_resets_at "
5801
+ "FROM five_hour_blocks WHERE five_hour_window_key = ? "
5802
+ "LIMIT 1",
5803
+ (key,),
5804
+ ).fetchone()
5805
+ except (sqlite3.DatabaseError, OSError):
5806
+ return
5807
+ if row is None:
5808
+ return
5809
+ try:
5810
+ block_start = parse_iso_datetime(
5811
+ row["block_start_at"], "five_hour_blocks.block_start_at"
5812
+ )
5813
+ block_end = parse_iso_datetime(
5814
+ row["five_hour_resets_at"], "five_hour_blocks.five_hour_resets_at"
5815
+ )
5816
+ except ValueError:
5817
+ return
5818
+ # Normalize to UTC for stable comparisons (block_start_at can carry
5819
+ # the host-local offset; five_hour_resets_at is UTC).
5820
+ block_start_utc = block_start.astimezone(dt.timezone.utc)
5821
+ block_end_utc = block_end.astimezone(dt.timezone.utc)
5822
+ # If the canonical window has already ended, don't displace the
5823
+ # heuristic active block — the canonical block is closed and the
5824
+ # heuristic anchor reflects real ongoing activity in a later window.
5825
+ if block_end_utc <= now.astimezone(dt.timezone.utc):
5826
+ return
5827
+ # Re-aggregate entries over the canonical interval. Build a fresh
5828
+ # Block via ``_build_activity_block`` (mode="auto" matches
5829
+ # ``_group_entries_into_blocks``'s default) so every total stays in
5830
+ # one code path — no field-by-field assignment that could drift if
5831
+ # the dataclass grows new fields.
5832
+ canonical_entries = [
5833
+ e for e in all_entries
5834
+ if block_start_utc <= e.timestamp < block_end_utc
5835
+ ]
5836
+ rebuilt = _build_activity_block(
5837
+ canonical_entries,
5838
+ block_start_utc,
5839
+ block_end_utc,
5840
+ now.astimezone(dt.timezone.utc),
5841
+ "auto",
5842
+ anchor="recorded",
5843
+ )
5844
+ blocks[active_idx] = rebuilt
5845
+
5846
+
5460
5847
  def _parse_cli_date_range(
5461
5848
  args: argparse.Namespace,
5462
5849
  *,
@@ -7048,11 +7435,29 @@ def _apply_reset_events_to_weekrefs(
7048
7435
  week: its API-derived start (= new resets_at - 7d) backdates into
7049
7436
  the pre-reset week. Override ref.week_start_at = effective_reset_at_utc
7050
7437
  so the new week starts at the actual reset moment.
7438
+ - **In-place credit (v1.7.2 round-3, Bug B).** Detected via the row
7439
+ shape ``old_week_end_at == effective_reset_at_utc`` (the live and
7440
+ backfill detection paths both write this shape — see
7441
+ ``test_event_row_old_is_effective_not_cur_end``). For these events,
7442
+ the credited week's ref matches ``new_week_end_at`` (the original
7443
+ resets_at is unchanged), so the post-credit override above
7444
+ rewrites ``week_start_at`` to ``effective``. But the pre-credit
7445
+ segment of the SAME week — where the user spent the bulk of their
7446
+ usage before the credit — is dropped, because no other ref in
7447
+ ``refs`` carries ``week_end_at == effective``. Synthesize a
7448
+ pre-credit ref alongside the post-credit one: its
7449
+ ``week_start_at`` stays at the ref's original API-derived value,
7450
+ its ``week_end_at`` becomes ``effective`` (closes the pre-credit
7451
+ segment). Credited weeks render as TWO trend rows downstream.
7051
7452
 
7052
7453
  The ref's `week_start` (date) and `key` fields are intentionally left at
7053
7454
  the API-derived values — they're the lookup keys for
7054
7455
  weekly_usage_snapshots / weekly_cost_snapshots. Only the display-facing
7055
7456
  `week_start_at` / `week_end_at` (and the derived `week_end` date) shift.
7457
+ Both the pre-credit and post-credit synthesized refs share the same
7458
+ `key` so downstream per-segment readers
7459
+ (``cmd_percent_breakdown`` / dashboard milestone panel) can still
7460
+ filter milestones by ``reset_event_id`` against the same lookup keys.
7056
7461
  """
7057
7462
  events = conn.execute(
7058
7463
  "SELECT old_week_end_at, new_week_end_at, effective_reset_at_utc "
@@ -7062,6 +7467,15 @@ def _apply_reset_events_to_weekrefs(
7062
7467
  return refs
7063
7468
  pre_map = {e["old_week_end_at"]: e["effective_reset_at_utc"] for e in events}
7064
7469
  post_map = {e["new_week_end_at"]: e["effective_reset_at_utc"] for e in events}
7470
+ # In-place credit events have `old == effective` (the row shape the
7471
+ # live + backfill detection paths agree on). Project the set of
7472
+ # `new_week_end_at` values for those events so we can detect them
7473
+ # while iterating refs and split the credited week into TWO refs.
7474
+ in_place_credit_new_ends: set[str] = {
7475
+ e["new_week_end_at"]
7476
+ for e in events
7477
+ if e["old_week_end_at"] == e["effective_reset_at_utc"]
7478
+ }
7065
7479
  out: list[WeekRef] = []
7066
7480
  for ref in refs:
7067
7481
  new_ref = ref
@@ -7076,7 +7490,43 @@ def _apply_reset_events_to_weekrefs(
7076
7490
  pass
7077
7491
  if ref.week_end_at and ref.week_end_at in post_map:
7078
7492
  reset_at = post_map[ref.week_end_at]
7493
+ # In-place credit: synthesize a pre-credit ref FIRST so it
7494
+ # lands in `out` before the post-credit ref. The pre-credit
7495
+ # ref keeps the ORIGINAL API-derived week_start_at; only its
7496
+ # week_end_at shifts to `effective`. The post-credit ref
7497
+ # (constructed below via the standard `replace`) carries
7498
+ # week_start_at = effective, week_end_at = original.
7499
+ # Order: pre-credit BEFORE post-credit so chronological
7500
+ # iteration in cmd_report's trend table renders them
7501
+ # naturally (older segment above the newer one in DESC
7502
+ # ordering: post-credit is "more recent" so the post-credit
7503
+ # row should come FIRST in the DESC list — but the original
7504
+ # ref was already in DESC position, and we insert pre-credit
7505
+ # AFTER the post-credit. Concretely: post-credit takes the
7506
+ # ref's original slot; pre-credit goes one slot later).
7507
+ if ref.week_end_at in in_place_credit_new_ends:
7508
+ try:
7509
+ reset_dt = parse_iso_datetime(
7510
+ reset_at, "reset_event.effective"
7511
+ )
7512
+ pre_end_date = (
7513
+ # internal fallback: host-local intentional
7514
+ reset_dt - dt.timedelta(seconds=1)
7515
+ ).astimezone().date()
7516
+ pre_credit_ref = replace(
7517
+ ref,
7518
+ week_end_at=reset_at,
7519
+ week_end=pre_end_date,
7520
+ )
7521
+ except ValueError:
7522
+ pre_credit_ref = None
7523
+ else:
7524
+ pre_credit_ref = None
7079
7525
  new_ref = replace(new_ref, week_start_at=reset_at)
7526
+ out.append(new_ref)
7527
+ if pre_credit_ref is not None:
7528
+ out.append(pre_credit_ref)
7529
+ continue
7080
7530
  out.append(new_ref)
7081
7531
  return out
7082
7532
 
@@ -7151,6 +7601,71 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
7151
7601
  " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
7152
7602
  (row["captured_at_utc"], prior_end, cur_end, effective_iso),
7153
7603
  )
7604
+ elif prior_end and cur_end == prior_end:
7605
+ # In-place credit branch (v1.7.2). Mirrors the live detection
7606
+ # in cmd_record_usage: same end_at across two captures + ≥25pp
7607
+ # drop in weekly_percent + prior end still in the future at
7608
+ # captured_dt → Anthropic-issued goodwill credit. One event
7609
+ # row with old == new == cur_end, effective = floor_to_hour
7610
+ # of the captured_at when the drop was first observed.
7611
+ try:
7612
+ prior_end_dt = parse_iso_datetime(prior_end, "backfill.prior")
7613
+ captured_dt = parse_iso_datetime(row["captured_at_utc"], "backfill.cap")
7614
+ except ValueError:
7615
+ prior_end = cur_end
7616
+ prior_pct = cur_pct
7617
+ continue
7618
+ if (
7619
+ captured_dt < prior_end_dt
7620
+ and prior_pct is not None and cur_pct is not None
7621
+ and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
7622
+ ):
7623
+ # Pre-check on ``new_week_end_at`` (mirrors the live
7624
+ # detection path's pre-check). Necessary because the
7625
+ # UNIQUE(old, new) constraint alone WON'T dedup against
7626
+ # legacy/broken-shape rows: pre-fix DBs may have
7627
+ # ``(cur_end, cur_end)`` rows for the same credit that
7628
+ # the new shape writes as ``(effective_iso, cur_end)``.
7629
+ # Without this pre-check, the backfill writes a second
7630
+ # row for the same credit on every open_db() call after
7631
+ # upgrade. (See round-2 review Bug 1.)
7632
+ already = conn.execute(
7633
+ "SELECT 1 FROM week_reset_events "
7634
+ "WHERE new_week_end_at = ? LIMIT 1",
7635
+ (cur_end,),
7636
+ ).fetchone()
7637
+ if already is not None:
7638
+ prior_end = cur_end
7639
+ prior_pct = cur_pct
7640
+ continue
7641
+ # Canonicalize to UTC before isoformat so the stored
7642
+ # offset is `+00:00`, matching the live detection path
7643
+ # (cmd_record_usage uses now_utc which is already UTC).
7644
+ # parse_iso_datetime returns .astimezone() (host-local
7645
+ # fallback at bin/cctally:_local_tz_name gate); without
7646
+ # this normalization, non-UTC hosts would store the
7647
+ # column as e.g. `+03:00`, breaking lex comparisons
7648
+ # downstream (CLAUDE.md gotcha: 5h-block cross-reset
7649
+ # comparisons go through unixepoch(), NOT lex
7650
+ # BETWEEN/</>; the reset-aware DB clamp here applies
7651
+ # the same rule). The reset-aware clamp now wraps both
7652
+ # sides with unixepoch() (Bug 2 fix), but a canonical
7653
+ # UTC offset on write is the right defense-in-depth.
7654
+ effective_iso = (
7655
+ _floor_to_hour(captured_dt.astimezone(dt.timezone.utc))
7656
+ .isoformat(timespec="seconds")
7657
+ )
7658
+ # Row shape: old=effective_iso, new=cur_end (distinct
7659
+ # values). See the live-detection site in
7660
+ # bin/_cctally_record.py for the full rationale; in
7661
+ # short, old==new collapses the credited week to a
7662
+ # zero-width window in _apply_reset_events_to_weekrefs.
7663
+ conn.execute(
7664
+ "INSERT OR IGNORE INTO week_reset_events "
7665
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
7666
+ " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
7667
+ (row["captured_at_utc"], effective_iso, cur_end, effective_iso),
7668
+ )
7154
7669
  prior_end = cur_end
7155
7670
  prior_pct = cur_pct
7156
7671
  # Flush implicit transaction so callers using explicit BEGIN
@@ -7165,6 +7680,14 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
7165
7680
  # 86→0 and 34→0 cases while filtering 35→33-style jitter.
7166
7681
  _RESET_PCT_DROP_THRESHOLD = 25.0
7167
7682
 
7683
+ # In-place 5h-credit threshold. Mirrors `_RESET_PCT_DROP_THRESHOLD` but
7684
+ # scaled down for the 5h dimension: typical 5h usage stays under ~10pp in
7685
+ # a single block, so a 5pp drop sits well above natural variation while
7686
+ # proportionally being a larger signal than 25pp is on the weekly scale.
7687
+ # See spec docs/superpowers/specs/2026-05-16-5h-in-place-credit-detection.md
7688
+ # §2.1 (Q1) for rationale.
7689
+ _FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = 5.0
7690
+
7168
7691
 
7169
7692
  def _week_ref_has_reset_event(
7170
7693
  conn: sqlite3.Connection, ref: WeekRef
@@ -8080,6 +8603,24 @@ def cmd_report(args: argparse.Namespace) -> int:
8080
8603
  week_end_date=current_end.isoformat(),
8081
8604
  )
8082
8605
 
8606
+ # Bug D (v1.7.2 round-4): when an in-place credit event exists for
8607
+ # the current subscription week, `_apply_reset_events_to_weekrefs`
8608
+ # synthesizes a pre-credit ref alongside the post-credit one (both
8609
+ # share `WeekRef.key`). The live "current week" segment is the
8610
+ # POST-credit one (its `week_start_at` was shifted to the
8611
+ # effective reset moment). Route `current_ref` through the same
8612
+ # override so its `week_start_at` reflects the post-credit start;
8613
+ # this lets the per-row match below disambiguate the synthesized
8614
+ # pre-credit ref from the live post-credit ref via both
8615
+ # `key` AND `week_start_at`. Order contract from
8616
+ # `_apply_reset_events_to_weekrefs`: post-credit ref lands at
8617
+ # index 0, pre-credit at index 1. Non-credit weeks return the
8618
+ # single input ref unchanged, so this is a no-op on the common
8619
+ # path.
8620
+ _adjusted_current = _apply_reset_events_to_weekrefs(conn, [current_ref])
8621
+ if _adjusted_current:
8622
+ current_ref = _adjusted_current[0]
8623
+
8083
8624
  weeks = get_recent_weeks(conn, max(1, args.weeks))
8084
8625
  if not weeks:
8085
8626
  # Format-aware empty path mirrors cmd_forecast:18578-18629 — a
@@ -8132,8 +8673,38 @@ def cmd_report(args: argparse.Namespace) -> int:
8132
8673
  except OauthUsageConfigError:
8133
8674
  _fresh_cfg = _get_oauth_usage_config({})
8134
8675
 
8676
+ # Build a set of week_start_date keys that occur MORE THAN ONCE
8677
+ # in `weeks` — these are credited weeks (round-3, Bug B) where
8678
+ # `_apply_reset_events_to_weekrefs` synthesized a pre-credit
8679
+ # ref alongside the post-credit ref. Both refs share
8680
+ # `week_start_date` so the default
8681
+ # ``get_latest_usage_for_week(conn, ref)`` returns the SAME row
8682
+ # (the most recent snapshot in the whole week) for both,
8683
+ # collapsing the pre-credit row's `weeklyPercent` to the
8684
+ # post-credit value. For those keys ONLY, pin `as_of_utc` to
8685
+ # the ref's `week_end_at` so each segment renders its own
8686
+ # latest snapshot. Non-credit weeks (single ref per key) keep
8687
+ # the legacy unfiltered lookup so test fixtures that seed
8688
+ # snapshots OUTSIDE the API-derived week window (e.g.
8689
+ # `test_report_freshness`) keep finding their rows.
8690
+ _split_keys = {
8691
+ r.week_start.isoformat()
8692
+ for r in weeks
8693
+ if sum(
8694
+ 1 for x in weeks
8695
+ if x.week_start.isoformat() == r.week_start.isoformat()
8696
+ ) > 1
8697
+ }
8698
+
8135
8699
  for week_ref in weeks:
8136
- usage = get_latest_usage_for_week(conn, week_ref)
8700
+ key = week_ref.week_start.isoformat()
8701
+ usage = get_latest_usage_for_week(
8702
+ conn,
8703
+ week_ref,
8704
+ as_of_utc=(
8705
+ week_ref.week_end_at if key in _split_keys else None
8706
+ ),
8707
+ )
8137
8708
 
8138
8709
  # For weeks touched by a reset event, the cached
8139
8710
  # weekly_cost_snapshots row covers the API-derived range (which
@@ -8197,7 +8768,20 @@ def cmd_report(args: argparse.Namespace) -> int:
8197
8768
  "age_seconds": int(age_s),
8198
8769
  }
8199
8770
  trend.append(row)
8200
- if week_ref.key == current_ref.key:
8771
+ # Bug D (v1.7.2 round-4): for credited weeks `weeks` contains
8772
+ # TWO refs sharing `key` (pre-credit + post-credit segments).
8773
+ # `current_ref` was routed through
8774
+ # `_apply_reset_events_to_weekrefs` above so its
8775
+ # `week_start_at` reflects the post-credit segment's effective
8776
+ # start (or the original start for non-credit weeks). Match on
8777
+ # BOTH `key` AND `week_start_at` so the pre-credit ref doesn't
8778
+ # overwrite the post-credit row's selection on the second
8779
+ # iteration (last-write-wins on key-only equality picked the
8780
+ # wrong row on the user's live data).
8781
+ if (
8782
+ week_ref.key == current_ref.key
8783
+ and week_ref.week_start_at == current_ref.week_start_at
8784
+ ):
8201
8785
  current_row = row
8202
8786
 
8203
8787
  if current_row is None and trend:
@@ -8637,7 +9221,39 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
8637
9221
  except ValueError:
8638
9222
  pass
8639
9223
 
8640
- milestones = get_milestones_for_week(conn, week_start_date)
9224
+ # v1.7.2 segment filter: when a week_reset_events row exists for
9225
+ # the current ``week_end_at``, narrow the milestone listing to
9226
+ # the active (latest) segment so a credited week's header (which
9227
+ # already reflects the post-credit window via the canon-boundary
9228
+ # rewrite above) is coherent with the body. Sentinel ``0`` covers
9229
+ # pre-credit / no-event weeks; pre-005 DBs that didn't have the
9230
+ # column also default to 0 via the migration's ALTER DEFAULT.
9231
+ active_segment = 0
9232
+ canon_end_for_lookup = None
9233
+ latest_end_row = conn.execute(
9234
+ "SELECT week_end_at FROM weekly_usage_snapshots "
9235
+ "WHERE week_start_date = ? AND week_end_at IS NOT NULL "
9236
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
9237
+ (week_start_date,),
9238
+ ).fetchone()
9239
+ if latest_end_row is not None:
9240
+ canon_end_for_lookup = _canonicalize_optional_iso(
9241
+ latest_end_row["week_end_at"], "pb.cur"
9242
+ )
9243
+ if canon_end_for_lookup:
9244
+ seg_row = conn.execute(
9245
+ "SELECT id FROM week_reset_events "
9246
+ "WHERE new_week_end_at = ? "
9247
+ "ORDER BY id DESC LIMIT 1",
9248
+ (canon_end_for_lookup,),
9249
+ ).fetchone()
9250
+ if seg_row is not None:
9251
+ active_segment = int(seg_row["id"])
9252
+
9253
+ milestones = [
9254
+ m for m in get_milestones_for_week(conn, week_start_date)
9255
+ if int(m["reset_event_id"] or 0) == active_segment
9256
+ ]
8641
9257
 
8642
9258
  milestone_list = []
8643
9259
  for m in milestones:
@@ -8672,7 +9288,17 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
8672
9288
  else:
8673
9289
  print(f"Week: {week_start_date}..{week_end_date}")
8674
9290
  if not milestone_list:
8675
- print("No percent milestones recorded for this week.")
9291
+ if active_segment > 0:
9292
+ # v1.7.2: distinguish post-credit empty (just got
9293
+ # credited, no crossings yet) from genuinely-empty week.
9294
+ # The pre-credit ledger still exists in the DB — just
9295
+ # filtered out of the body — so the user shouldn't see
9296
+ # "No milestones" and assume the data is gone.
9297
+ print(
9298
+ "(post-credit segment, no milestones crossed yet)"
9299
+ )
9300
+ else:
9301
+ print("No percent milestones recorded for this week.")
8676
9302
  return 0
8677
9303
 
8678
9304
  print("Percent breakdown:\n")
@@ -8887,6 +9513,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
8887
9513
  # to fill seven_day_pct_at_block_end on the active row.
8888
9514
  latest_7d, latest_window_key = _latest_seven_day_and_window(conn)
8889
9515
 
9516
+ # Pre-load credit events for every window_key the rows query
9517
+ # returned. Single index-scan over `five_hour_reset_events`;
9518
+ # build a window_key -> list[Credit] map keyed for in-process
9519
+ # JOIN against each block dict. Used by both the text/JSON
9520
+ # render path AND the share-output snapshot wiring (spec §5.1.1).
9521
+ # Loaded in a single pass — no per-block SELECT.
9522
+ credit_rows = conn.execute(
9523
+ "SELECT five_hour_window_key, prior_percent, post_percent, "
9524
+ " effective_reset_at_utc "
9525
+ " FROM five_hour_reset_events "
9526
+ " ORDER BY five_hour_window_key, effective_reset_at_utc"
9527
+ ).fetchall()
9528
+ credits_by_window: dict[int, list[dict]] = {}
9529
+ for cr in credit_rows:
9530
+ credits_by_window.setdefault(
9531
+ int(cr["five_hour_window_key"]), []
9532
+ ).append({
9533
+ "effectiveResetAtUtc": cr["effective_reset_at_utc"],
9534
+ "priorPercent": float(cr["prior_percent"]),
9535
+ "postPercent": float(cr["post_percent"]),
9536
+ "deltaPp": round(
9537
+ float(cr["post_percent"]) - float(cr["prior_percent"]), 1
9538
+ ),
9539
+ })
9540
+
8890
9541
  # Build per-block dicts with the active-flag side-channel.
8891
9542
  block_dicts: list[dict] = []
8892
9543
  for r in rows:
@@ -8895,6 +9546,11 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
8895
9546
  d["__is_active"] = is_active
8896
9547
  if is_active and latest_7d is not None:
8897
9548
  d["seven_day_pct_at_block_end"] = latest_7d
9549
+ # Side-channel (parallel to __is_active): list of credit
9550
+ # event dicts for this block's window. Empty list when none.
9551
+ d["__credits"] = credits_by_window.get(
9552
+ int(d["five_hour_window_key"]), []
9553
+ )
8898
9554
  block_dicts.append(d)
8899
9555
 
8900
9556
  # Shareable-reports gate: --format short-circuits the JSON / table
@@ -9008,18 +9664,50 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9008
9664
  )
9009
9665
  return 2
9010
9666
 
9667
+ # Spec §5.2: ORDER BY captured_at_utc ASC (NOT percent_threshold)
9668
+ # so post-credit segments interleave with pre-credit ones in
9669
+ # time-order — same human threshold number can appear twice
9670
+ # (once per reset_event_id segment) and must render in the
9671
+ # order it crossed. Bucket B per §3.2: read ALL segments (no
9672
+ # ``reset_event_id`` filter).
9011
9673
  milestones = conn.execute(
9012
9674
  """
9013
9675
  SELECT percent_threshold, captured_at_utc,
9014
9676
  block_cost_usd, marginal_cost_usd,
9015
- seven_day_pct_at_crossing
9677
+ seven_day_pct_at_crossing, reset_event_id
9016
9678
  FROM five_hour_milestones
9017
9679
  WHERE block_id = ?
9018
- ORDER BY percent_threshold ASC
9680
+ ORDER BY captured_at_utc ASC, id ASC
9019
9681
  """,
9020
9682
  (block["id"],),
9021
9683
  ).fetchall()
9022
9684
 
9685
+ # Spec §5.2 — load in-place credit events for this block's
9686
+ # window, ascending by effective_reset_at_utc, so the text
9687
+ # renderer can interleave a ``⚡ CREDIT -Xpp @ HH:MM`` divider
9688
+ # row between pre- and post-credit milestone segments and JSON
9689
+ # consumers see the parallel ``credits[]`` array (Section 5.2).
9690
+ credit_rows = conn.execute(
9691
+ """
9692
+ SELECT effective_reset_at_utc, prior_percent, post_percent
9693
+ FROM five_hour_reset_events
9694
+ WHERE five_hour_window_key = ?
9695
+ ORDER BY effective_reset_at_utc ASC
9696
+ """,
9697
+ (block["five_hour_window_key"],),
9698
+ ).fetchall()
9699
+ credits_list: list[dict] = [
9700
+ {
9701
+ "effectiveResetAtUtc": c["effective_reset_at_utc"],
9702
+ "priorPercent": float(c["prior_percent"]),
9703
+ "postPercent": float(c["post_percent"]),
9704
+ "deltaPp": round(
9705
+ float(c["post_percent"]) - float(c["prior_percent"]), 1
9706
+ ),
9707
+ }
9708
+ for c in credit_rows
9709
+ ]
9710
+
9023
9711
  crossed = bool(block.get("crossed_seven_day_reset"))
9024
9712
  p_start = block.get("seven_day_pct_at_block_start")
9025
9713
  p_end = block.get("seven_day_pct_at_block_end")
@@ -9056,6 +9744,10 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9056
9744
  "sevenDayPctDeltaPp": delta,
9057
9745
  "crossedSevenDayReset": crossed,
9058
9746
  }
9747
+ # Spec §5.2: expose ``resetEventId`` on each milestone so JSON
9748
+ # consumers can disambiguate post-credit threshold repeats from
9749
+ # pre-credit ones. ``0`` is the pre-credit/no-credit sentinel
9750
+ # (matches the schema default).
9059
9751
  ms_out = [
9060
9752
  {
9061
9753
  "percentThreshold": m["percent_threshold"],
@@ -9066,13 +9758,23 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9066
9758
  else round(m["marginal_cost_usd"], 9)
9067
9759
  ),
9068
9760
  "sevenDayPctAtCrossing": m["seven_day_pct_at_crossing"],
9761
+ "resetEventId": int(m["reset_event_id"] or 0),
9069
9762
  }
9070
9763
  for m in milestones
9071
9764
  ]
9072
9765
 
9073
9766
  if args.json:
9767
+ # Spec §5.2: ``credits`` is the parallel array to
9768
+ # ``milestones`` — same shape as the ``credits`` field on
9769
+ # ``five-hour-blocks --json`` (§5.1). Stacked credits across
9770
+ # distinct 10-min slots produce multiple entries.
9074
9771
  print(json.dumps(
9075
- {"schemaVersion": 1, "block": block_out, "milestones": ms_out},
9772
+ {
9773
+ "schemaVersion": 1,
9774
+ "block": block_out,
9775
+ "milestones": ms_out,
9776
+ "credits": credits_list,
9777
+ },
9076
9778
  indent=2,
9077
9779
  ))
9078
9780
  return 0
@@ -9113,7 +9815,47 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9113
9815
  headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost",
9114
9816
  "7d at crossing"]
9115
9817
  rows = []
9116
- for idx, m in enumerate(ms_out, start=1):
9818
+ # Spec §5.2 merged event stream. Interleave milestones and
9819
+ # credits in time-order (``capturedAt`` for milestones,
9820
+ # ``effectiveResetAtUtc`` for credits). Credits render as a
9821
+ # divider row with ``⚡ CREDIT`` in the Threshold cell and the
9822
+ # delta-pp + HH:MM in the rightmost cell; the milestone row
9823
+ # numbering counter (``#``) continues across the divider so the
9824
+ # ordinal still reflects "the Nth event in this block."
9825
+ merged_events: list[tuple[str, dict]] = []
9826
+ for m in ms_out:
9827
+ merged_events.append(("milestone", m))
9828
+ for c in credits_list:
9829
+ merged_events.append(("credit", c))
9830
+ merged_events.sort(key=lambda ev: (
9831
+ ev[1]["effectiveResetAtUtc"] if ev[0] == "credit"
9832
+ else ev[1]["capturedAt"]
9833
+ ))
9834
+ idx = 0
9835
+ for kind, ev in merged_events:
9836
+ idx += 1
9837
+ if kind == "credit":
9838
+ # Spec §5.2: ⚡ CREDIT -Xpp @ HH:MM divider row.
9839
+ # HH:MM rendered in the display tz via format_display_dt.
9840
+ # ``format_display_dt`` is the documented chokepoint for
9841
+ # human-displayed datetimes (CLAUDE.md). The deltaPp
9842
+ # value is float; format as integer ppm (mirrors the
9843
+ # five-hour-blocks chip in §5.1).
9844
+ hhmm = format_display_dt(
9845
+ ev["effectiveResetAtUtc"],
9846
+ args._resolved_tz,
9847
+ fmt="%H:%M",
9848
+ suffix=False,
9849
+ )
9850
+ rows.append([
9851
+ str(idx),
9852
+ "⚡ CREDIT",
9853
+ f"{ev['deltaPp']:+.0f}pp",
9854
+ "",
9855
+ f"@ {hhmm}",
9856
+ ])
9857
+ continue
9858
+ m = ev
9117
9859
  cum = f"${m['blockCostUSD']:.6f}"
9118
9860
  marg = (
9119
9861
  "n/a" if m["marginalCostUSD"] is None
@@ -9888,6 +10630,7 @@ def doctor_gather_state(
9888
10630
  # ── Data freshness ───────────────────────────────────────────────
9889
10631
  latest_snapshot_at = None
9890
10632
  forked_bucket_counts: dict | None = None
10633
+ credited_weeks: list[dict] | None = None
9891
10634
  try:
9892
10635
  if DB_PATH.exists():
9893
10636
  conn = sqlite3.connect(str(DB_PATH))
@@ -9924,6 +10667,61 @@ def doctor_gather_state(
9924
10667
  )
9925
10668
  except sqlite3.OperationalError:
9926
10669
  forked_bucket_counts[key] = 0
10670
+ # v1.7.2 credited-week tracking. For each week with a
10671
+ # past-effective ``week_reset_events`` row, gather the
10672
+ # latest weekly_percent + count of post-credit milestones.
10673
+ # The check warns when latest_percent >= 1.0 AND
10674
+ # post_credit_milestone_count == 0.
10675
+ # unixepoch() normalizes the cross-offset comparison.
10676
+ try:
10677
+ credit_rows = conn.execute(
10678
+ """
10679
+ SELECT wre.id AS event_id,
10680
+ wre.new_week_end_at AS end_at,
10681
+ wre.effective_reset_at_utc AS effective
10682
+ FROM week_reset_events wre
10683
+ WHERE unixepoch(wre.effective_reset_at_utc)
10684
+ <= unixepoch(?)
10685
+ """,
10686
+ (now_utc_iso(),),
10687
+ ).fetchall()
10688
+ credited_weeks = []
10689
+ for cr in credit_rows:
10690
+ end_at = cr[1]
10691
+ evt_id = cr[0]
10692
+ latest = conn.execute(
10693
+ """
10694
+ SELECT week_start_date, weekly_percent
10695
+ FROM weekly_usage_snapshots
10696
+ WHERE week_end_at = ?
10697
+ ORDER BY captured_at_utc DESC, id DESC
10698
+ LIMIT 1
10699
+ """,
10700
+ (end_at,),
10701
+ ).fetchone()
10702
+ if latest is None or latest[0] is None:
10703
+ continue
10704
+ ws = latest[0]
10705
+ lp = float(latest[1] or 0.0)
10706
+ try:
10707
+ mc_row = conn.execute(
10708
+ "SELECT COUNT(*) FROM percent_milestones "
10709
+ "WHERE week_start_date = ? AND reset_event_id = ?",
10710
+ (ws, evt_id),
10711
+ ).fetchone()
10712
+ mc = int(mc_row[0]) if mc_row and mc_row[0] else 0
10713
+ except sqlite3.OperationalError:
10714
+ mc = 0
10715
+ credited_weeks.append({
10716
+ "week_start_date": ws,
10717
+ "latest_weekly_percent": lp,
10718
+ "post_credit_milestone_count": mc,
10719
+ "event_id": evt_id,
10720
+ })
10721
+ except sqlite3.OperationalError:
10722
+ # week_reset_events table missing — treat as no
10723
+ # credited weeks (pre-feature DB).
10724
+ credited_weeks = []
9927
10725
  finally:
9928
10726
  conn.close()
9929
10727
  except Exception:
@@ -10069,6 +10867,7 @@ def doctor_gather_state(
10069
10867
  cache_last_entry_at=cache_last_entry_at,
10070
10868
  claude_jsonl_present=claude_jsonl_present,
10071
10869
  forked_bucket_counts=forked_bucket_counts,
10870
+ credited_weeks=credited_weeks,
10072
10871
  codex_entries_count=codex_entries_count,
10073
10872
  codex_last_entry_at=codex_last_entry_at,
10074
10873
  codex_jsonl_present=codex_jsonl_present,
@@ -13205,8 +14004,21 @@ def _build_five_hour_blocks_snapshot(
13205
14004
  used_pct = float(r.get("final_five_hour_percent") or 0.0)
13206
14005
  crossed = bool(r.get("crossed_seven_day_reset"))
13207
14006
  cell_text = "⚡" if crossed else "—"
14007
+ # Spec §5.1.1 (Codex r2 finding 3): consume the ``__credits``
14008
+ # side-channel set by ``cmd_five_hour_blocks`` and append a
14009
+ # ``⚡ -Xpp, -Ypp`` chip to the block_start cell. Pure-string
14010
+ # cell content flows uniformly through markdown / HTML table /
14011
+ # SVG text renderers without per-format additions. Symmetric to
14012
+ # the existing ⚡ glyph in the cross_reset cell — by position
14013
+ # (block_start suffix vs. dedicated column) the two annotations
14014
+ # remain visually distinguishable.
14015
+ credits = r.get("__credits") or []
14016
+ block_cell = block_lbl
14017
+ if credits:
14018
+ deltas = ", ".join(f"{c['deltaPp']:+.0f}pp" for c in credits)
14019
+ block_cell = f"{block_lbl} ⚡ {deltas}"
13208
14020
  snap_rows.append(_lib_share.Row(cells={
13209
- "block_start": _lib_share.TextCell(block_lbl),
14021
+ "block_start": _lib_share.TextCell(block_cell),
13210
14022
  "cost": _lib_share.MoneyCell(cost_usd),
13211
14023
  "used_pct": _lib_share.PercentCell(used_pct),
13212
14024
  "cross_reset": _lib_share.TextCell(cell_text),