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/CHANGELOG.md +37 -0
- package/bin/_cctally_dashboard.py +256 -10
- package/bin/_cctally_db.py +290 -21
- package/bin/_cctally_record.py +616 -32
- package/bin/_cctally_tui.py +217 -12
- package/bin/_lib_blocks.py +33 -6
- package/bin/_lib_doctor.py +79 -0
- package/bin/_lib_render.py +20 -0
- package/bin/cctally +841 -29
- package/dashboard/static/assets/index-DhCnIFq9.js +18 -0
- package/dashboard/static/assets/index-Dv5Dzag5.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BgpoazlS.js +0 -18
- package/dashboard/static/assets/index-nJdUaGys.css +0 -1
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 = ?
|
|
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.
|
|
4789
|
-
concurrent record-usage instances — aligns with the
|
|
4790
|
-
INSERT OR IGNORE pattern (see five_hour_milestones
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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(
|
|
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),
|