cctally 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cctally CHANGED
@@ -210,6 +210,43 @@ PUBLIC_REPO = "omrikais/cctally"
210
210
  #
211
211
  # Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3-6.4
212
212
 
213
+ # === _cctally_core: leaf kernel (MUST load first; sibling imports
214
+ # `from _cctally_core import …` depend on this being in sys.modules
215
+ # before any other sibling's module body runs). Spec
216
+ # 2026-05-17-cctally-core-kernel-extraction.md §2.5. ===
217
+ _cctally_core = _load_sibling("_cctally_core")
218
+
219
+ # Eager re-exports for the 24 kernel symbols. Preserves
220
+ # `cctally.<name>` attribute access AND `ns["<name>"]` dict-subscript
221
+ # paths used heavily by tests (e.g. ns["open_db"]() in 125+ sites).
222
+ # Don't add to PEP 562 __getattr__ registry — eager binding is the
223
+ # whole point.
224
+ eprint = _cctally_core.eprint
225
+ now_utc_iso = _cctally_core.now_utc_iso
226
+ parse_iso_datetime = _cctally_core.parse_iso_datetime
227
+ parse_date_str = _cctally_core.parse_date_str
228
+ format_local_iso = _cctally_core.format_local_iso
229
+ _iso_to_epoch = _cctally_core._iso_to_epoch
230
+ _format_short_duration = _cctally_core._format_short_duration
231
+ _normalize_week_boundary_dt = _cctally_core._normalize_week_boundary_dt
232
+ _now_utc = _cctally_core._now_utc
233
+ _command_as_of = _cctally_core._command_as_of
234
+ get_week_start_name = _cctally_core.get_week_start_name
235
+ compute_week_bounds = _cctally_core.compute_week_bounds
236
+ WEEKDAY_MAP = _cctally_core.WEEKDAY_MAP
237
+ DEFAULT_WEEK_START = _cctally_core.DEFAULT_WEEK_START
238
+ ensure_dirs = _cctally_core.ensure_dirs
239
+ _AlertsConfigError = _cctally_core._AlertsConfigError
240
+ _ALERTS_CONFIG_VALID_KEYS = _cctally_core._ALERTS_CONFIG_VALID_KEYS
241
+ _validate_threshold_list = _cctally_core._validate_threshold_list
242
+ _get_alerts_config = _cctally_core._get_alerts_config
243
+ open_db = _cctally_core.open_db
244
+ WeekRef = _cctally_core.WeekRef
245
+ _canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
246
+ make_week_ref = _cctally_core.make_week_ref
247
+ _get_latest_row_for_week = _cctally_core._get_latest_row_for_week
248
+ get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
249
+
213
250
  _lib_semver = _load_sibling("_lib_semver")
214
251
  _SEMVER_NUM = _lib_semver._SEMVER_NUM
215
252
  _SEMVER_RE = _lib_semver._SEMVER_RE
@@ -2195,9 +2232,6 @@ def _migrate_legacy_data_dir() -> None:
2195
2232
  )
2196
2233
 
2197
2234
 
2198
- DEFAULT_WEEK_START = "monday"
2199
-
2200
-
2201
2235
  def _get_claude_data_dirs() -> list[pathlib.Path]:
2202
2236
  """Return Claude Code data directories containing a projects/ subdir."""
2203
2237
  env_val = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
@@ -2352,79 +2386,6 @@ def _resolve_primary_model_for_block(
2352
2386
 
2353
2387
 
2354
2388
 
2355
- WEEKDAY_MAP = {
2356
- "monday": 0,
2357
- "tuesday": 1,
2358
- "wednesday": 2,
2359
- "thursday": 3,
2360
- "friday": 4,
2361
- "saturday": 5,
2362
- "sunday": 6,
2363
- }
2364
-
2365
- _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
2366
-
2367
-
2368
- def eprint(*args: Any) -> None:
2369
- print(*args, file=sys.stderr)
2370
-
2371
-
2372
- def now_utc_iso(now_utc: dt.datetime | None = None) -> str:
2373
- """Return a UTC-ISO 'Z'-suffixed timestamp with seconds precision.
2374
-
2375
- When ``now_utc`` is omitted (the default), reads wall-clock — existing
2376
- behavior, preserved byte-for-byte for all existing callers. When a
2377
- tz-aware UTC datetime is supplied (typically via ``_command_as_of()``),
2378
- it is used verbatim so callers that honor ``CCTALLY_AS_OF`` get a
2379
- stable, caller-pinned timestamp.
2380
- """
2381
- value = now_utc if now_utc is not None else dt.datetime.now(dt.timezone.utc)
2382
- return (
2383
- value.astimezone(dt.timezone.utc)
2384
- .replace(microsecond=0)
2385
- .isoformat()
2386
- .replace("+00:00", "Z")
2387
- )
2388
-
2389
-
2390
- def _iso_to_epoch(s: str) -> int:
2391
- """Parse an ISO-8601 timestamp and return Unix epoch seconds.
2392
-
2393
- Naive ISO strings (no timezone) are treated as UTC, matching the
2394
- statusline-command.sh ``_iso_to_epoch`` helper. ``Z`` suffix is
2395
- handled by mapping to ``+00:00`` since ``datetime.fromisoformat``
2396
- accepts ``Z`` natively from Python 3.11.
2397
- """
2398
- s = s.strip()
2399
- if s.endswith("Z"):
2400
- s = s[:-1] + "+00:00"
2401
- parsed = dt.datetime.fromisoformat(s)
2402
- if parsed.tzinfo is None:
2403
- parsed = parsed.replace(tzinfo=dt.timezone.utc)
2404
- return int(parsed.timestamp())
2405
-
2406
-
2407
- def _format_short_duration(seconds: int) -> str:
2408
- """Format a duration as a short top-two-units string.
2409
-
2410
- Examples: ``6d 4h``, ``2h 15m``, ``2h``, ``45m``, ``30s``, ``0s``.
2411
- Mirrors the shape used by ``~/.claude/statusline-command.sh``'s
2412
- format_duration helper. Negative inputs clamp to ``0s``.
2413
- """
2414
- s = max(0, int(seconds))
2415
- if s >= 86400:
2416
- days = s // 86400
2417
- hours = (s % 86400) // 3600
2418
- return f"{days}d {hours}h" if hours else f"{days}d"
2419
- if s >= 3600:
2420
- hours = s // 3600
2421
- minutes = (s % 3600) // 60
2422
- return f"{hours}h {minutes}m" if minutes else f"{hours}h"
2423
- if s >= 60:
2424
- return f"{s // 60}m"
2425
- return f"{s}s"
2426
-
2427
-
2428
2389
  def _read_keychain_oauth_blob() -> str | None:
2429
2390
  """Read the Claude Code keychain entry on macOS via `security`.
2430
2391
 
@@ -2557,96 +2518,6 @@ def _select_last_known_snapshot() -> dict | None:
2557
2518
  }
2558
2519
 
2559
2520
 
2560
- def ensure_dirs() -> None:
2561
- APP_DIR.mkdir(parents=True, exist_ok=True)
2562
- LOG_DIR.mkdir(parents=True, exist_ok=True)
2563
-
2564
-
2565
-
2566
-
2567
- class _AlertsConfigError(ValueError):
2568
- """Raised by _get_alerts_config on invalid alerts block."""
2569
-
2570
-
2571
- _ALERTS_CONFIG_VALID_KEYS = {"enabled", "weekly_thresholds", "five_hour_thresholds"}
2572
-
2573
-
2574
- def _validate_threshold_list(name: str, value: object) -> "list[int]":
2575
- """Validate one of the alerts threshold lists.
2576
-
2577
- Rules: non-empty list of plain ints (NOT bools — `bool` is an `int`
2578
- subclass), each in [1, 100], strictly increasing (no duplicates).
2579
- Error messages mention `alerts.<name>` so users can locate the
2580
- offending key in their config.json.
2581
- """
2582
- if not isinstance(value, list):
2583
- raise _AlertsConfigError(f"alerts.{name} must be a list of integers")
2584
- if len(value) == 0:
2585
- raise _AlertsConfigError(
2586
- f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)"
2587
- )
2588
- out: "list[int]" = []
2589
- prev = -1
2590
- seen: "set[int]" = set()
2591
- for item in value:
2592
- if not isinstance(item, int) or isinstance(item, bool):
2593
- raise _AlertsConfigError(
2594
- f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}"
2595
- )
2596
- if item < 1 or item > 100:
2597
- raise _AlertsConfigError(
2598
- f"alerts.{name} items must be in [1, 100], got {item}"
2599
- )
2600
- if item in seen:
2601
- raise _AlertsConfigError(
2602
- f"alerts.{name} contains duplicate value {item}"
2603
- )
2604
- if item <= prev:
2605
- raise _AlertsConfigError(
2606
- f"alerts.{name} must be strictly increasing, got {prev} then {item}"
2607
- )
2608
- seen.add(item)
2609
- prev = item
2610
- out.append(item)
2611
- return out
2612
-
2613
-
2614
- def _get_alerts_config(cfg: "dict | None") -> dict:
2615
- """Return the validated alerts block. Raises _AlertsConfigError on failure.
2616
-
2617
- Defaults applied at read time so future default-tuning takes effect
2618
- for users who never customized. Unknown sub-keys under `alerts.*`
2619
- emit a one-line warn-and-ignore (mirrors the `display.tz` posture
2620
- for forward compatibility).
2621
- """
2622
- block = (cfg or {}).get("alerts", {}) or {}
2623
- if not isinstance(block, dict):
2624
- raise _AlertsConfigError("alerts must be an object")
2625
- # warn-and-ignore unknown keys (forward compat; matches display.tz posture)
2626
- for k in block.keys():
2627
- if k not in _ALERTS_CONFIG_VALID_KEYS:
2628
- print(
2629
- f"warning: ignoring unknown alerts config key: {k}",
2630
- file=sys.stderr,
2631
- )
2632
- enabled = block.get("enabled", False)
2633
- if not isinstance(enabled, bool):
2634
- raise _AlertsConfigError(
2635
- f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}"
2636
- )
2637
- weekly = _validate_threshold_list(
2638
- "weekly_thresholds", block.get("weekly_thresholds", [90, 95])
2639
- )
2640
- five_hour = _validate_threshold_list(
2641
- "five_hour_thresholds", block.get("five_hour_thresholds", [90, 95])
2642
- )
2643
- return {
2644
- "enabled": enabled,
2645
- "weekly_thresholds": weekly,
2646
- "five_hour_thresholds": five_hour,
2647
- }
2648
-
2649
-
2650
2521
  def _normalize_alerts_enabled_value(raw: str) -> bool:
2651
2522
  """Normalize a CLI string value to a JSON bool. Raises ValueError on unknown.
2652
2523
 
@@ -2724,60 +2595,6 @@ ORIGINAL_ENTRYPOINT: "str | None" = None
2724
2595
  _UPDATE_WORKER: "UpdateWorker | None" = None
2725
2596
 
2726
2597
 
2727
- def get_week_start_name(config: dict[str, Any], override: str | None = None) -> str:
2728
- if override:
2729
- name = override.strip().lower()
2730
- else:
2731
- name = str(config.get("collector", {}).get("week_start", DEFAULT_WEEK_START)).strip().lower()
2732
- if name not in WEEKDAY_MAP:
2733
- raise ValueError(
2734
- f"Invalid week start '{name}'. Allowed: {', '.join(WEEKDAY_MAP.keys())}"
2735
- )
2736
- return name
2737
-
2738
-
2739
- def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[dt.date, dt.date]:
2740
- start_idx = WEEKDAY_MAP[week_start_name]
2741
- # internal fallback: host-local intentional
2742
- local_anchor = anchor_dt.astimezone()
2743
- local_date = local_anchor.date()
2744
- diff = (local_date.weekday() - start_idx) % 7
2745
- start = local_date - dt.timedelta(days=diff)
2746
- end = start + dt.timedelta(days=6)
2747
- return start, end
2748
-
2749
-
2750
- def parse_date_str(value: str, label: str) -> dt.date:
2751
- s = value.strip()
2752
- if not _DATE_RE.match(s):
2753
- raise ValueError(f"{label} must be YYYY-MM-DD")
2754
- return dt.date.fromisoformat(s)
2755
-
2756
-
2757
- def parse_iso_datetime(value: str, label: str) -> dt.datetime:
2758
- s = value.strip()
2759
- if not s:
2760
- raise ValueError(f"{label} must be a non-empty ISO datetime")
2761
- try:
2762
- parsed = dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
2763
- except ValueError as exc:
2764
- raise ValueError(f"{label} must be ISO datetime") from exc
2765
-
2766
- if parsed.tzinfo is None:
2767
- # internal fallback: host-local intentional
2768
- local_tz = dt.datetime.now().astimezone().tzinfo
2769
- parsed = parsed.replace(tzinfo=local_tz)
2770
- # internal fallback: host-local intentional
2771
- return parsed.astimezone()
2772
-
2773
-
2774
- def format_local_iso(d: dt.date, end_of_day: bool) -> str:
2775
- t = dt.time(23, 59, 59) if end_of_day else dt.time(0, 0, 0)
2776
- # internal fallback: host-local intentional
2777
- local_dt = dt.datetime.combine(d, t).astimezone()
2778
- return local_dt.isoformat(timespec="seconds")
2779
-
2780
-
2781
2598
  def _parse_iso_datetime_optional(value: Any) -> dt.datetime | None:
2782
2599
  if not isinstance(value, str) or not value.strip():
2783
2600
  return None
@@ -3652,441 +3469,6 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
3652
3469
  return 0.0
3653
3470
 
3654
3471
 
3655
- def _normalize_week_boundary_dt(value: dt.datetime) -> dt.datetime:
3656
- """
3657
- Normalize known Anthropic boundary jitter.
3658
-
3659
- Anthropic resets are always on hour boundaries. Relative reset text
3660
- ("in XX hr YY min") produces minute-level drift on every capture, and
3661
- the UI occasionally alternates between HH:00 and HH-1:59 for the same
3662
- logical reset.
3663
-
3664
- Canonicalization: round to the nearest hour.
3665
- - minutes 0..29 -> HH:00
3666
- - minutes 30..59 -> (HH+1):00
3667
- """
3668
- normalized = value.replace(second=0, microsecond=0)
3669
- if normalized.minute >= 30:
3670
- normalized = (normalized + dt.timedelta(hours=1)).replace(
3671
- minute=0,
3672
- second=0,
3673
- microsecond=0,
3674
- )
3675
- elif normalized.minute > 0:
3676
- normalized = normalized.replace(
3677
- minute=0,
3678
- second=0,
3679
- microsecond=0,
3680
- )
3681
- return normalized
3682
-
3683
-
3684
- def open_db() -> sqlite3.Connection:
3685
- ensure_dirs()
3686
- conn = sqlite3.connect(DB_PATH)
3687
- conn.row_factory = sqlite3.Row
3688
- conn.execute("PRAGMA journal_mode=WAL")
3689
- conn.execute("PRAGMA synchronous=NORMAL")
3690
- conn.execute(
3691
- """
3692
- CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
3693
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3694
- captured_at_utc TEXT NOT NULL,
3695
- week_start_date TEXT NOT NULL,
3696
- week_end_date TEXT NOT NULL,
3697
- week_start_at TEXT,
3698
- week_end_at TEXT,
3699
- weekly_percent REAL NOT NULL,
3700
- page_url TEXT,
3701
- source TEXT NOT NULL DEFAULT 'userscript',
3702
- payload_json TEXT NOT NULL
3703
- )
3704
- """
3705
- )
3706
- conn.execute(
3707
- """
3708
- CREATE INDEX IF NOT EXISTS idx_usage_week_time
3709
- ON weekly_usage_snapshots(week_start_date, captured_at_utc DESC, id DESC)
3710
- """
3711
- )
3712
- conn.execute(
3713
- """
3714
- CREATE TABLE IF NOT EXISTS weekly_cost_snapshots (
3715
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3716
- captured_at_utc TEXT NOT NULL,
3717
- week_start_date TEXT NOT NULL,
3718
- week_end_date TEXT NOT NULL,
3719
- week_start_at TEXT,
3720
- week_end_at TEXT,
3721
- range_start_iso TEXT,
3722
- range_end_iso TEXT,
3723
- cost_usd REAL NOT NULL,
3724
- source TEXT NOT NULL DEFAULT 'cctally-range-cost',
3725
- mode TEXT NOT NULL DEFAULT 'auto',
3726
- project TEXT
3727
- )
3728
- """
3729
- )
3730
- conn.execute(
3731
- """
3732
- CREATE INDEX IF NOT EXISTS idx_cost_week_time
3733
- ON weekly_cost_snapshots(week_start_date, captured_at_utc DESC, id DESC)
3734
- """
3735
- )
3736
-
3737
- add_column_if_missing(conn, "weekly_usage_snapshots", "week_start_at", "TEXT")
3738
- add_column_if_missing(conn, "weekly_usage_snapshots", "week_end_at", "TEXT")
3739
- add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_percent", "REAL")
3740
- add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_resets_at", "TEXT")
3741
- # five_hour_window_key — canonical (10-min-floored epoch) key for
3742
- # jitter-tolerant equality. Anthropic's status-line API jitters
3743
- # rate_limits.5h.resets_at by ~seconds within the same physical 5h
3744
- # window; joining on the raw ISO string treats each jittered fetch as
3745
- # a new window, escaping the monotonic clamp at cmd_record_usage.
3746
- # Backfill is RESUMABLE: Python's sqlite3 auto-commits DDL,
3747
- # so a process killed mid-loop would leave the column added with NULL
3748
- # keys for unprocessed rows. The gating below detects that partial
3749
- # state on the next open_db() call (`five_hour_resets_at IS NOT NULL
3750
- # AND five_hour_window_key IS NULL`) and completes the backfill, so
3751
- # the original Bug B can't silently re-emerge for half-migrated rows.
3752
- needs_5h_key_backfill = add_column_if_missing(
3753
- conn, "weekly_usage_snapshots", "five_hour_window_key", "INTEGER"
3754
- )
3755
- if not needs_5h_key_backfill and conn.execute(
3756
- "SELECT 1 FROM weekly_usage_snapshots "
3757
- "WHERE five_hour_resets_at IS NOT NULL "
3758
- " AND five_hour_window_key IS NULL "
3759
- "LIMIT 1"
3760
- ).fetchone() is not None:
3761
- needs_5h_key_backfill = True
3762
-
3763
- if needs_5h_key_backfill:
3764
- backfill_rows = conn.execute(
3765
- "SELECT id, five_hour_resets_at FROM weekly_usage_snapshots "
3766
- "WHERE five_hour_resets_at IS NOT NULL "
3767
- " AND five_hour_window_key IS NULL"
3768
- ).fetchall()
3769
- for row in backfill_rows:
3770
- try:
3771
- iso = row[1]
3772
- d = parse_iso_datetime(iso, "five_hour_resets_at backfill")
3773
- epoch = int(d.timestamp())
3774
- key = _canonical_5h_window_key(epoch)
3775
- conn.execute(
3776
- "UPDATE weekly_usage_snapshots "
3777
- "SET five_hour_window_key = ? WHERE id = ?",
3778
- (key, row[0]),
3779
- )
3780
- except (ValueError, TypeError) as exc:
3781
- eprint(f"[migration] skipped row {row[0]}: {exc}")
3782
- conn.execute(
3783
- "CREATE INDEX IF NOT EXISTS idx_weekly_usage_snapshots_5h_window_key "
3784
- "ON weekly_usage_snapshots(five_hour_window_key)"
3785
- )
3786
- conn.commit()
3787
-
3788
- add_column_if_missing(conn, "weekly_cost_snapshots", "week_start_at", "TEXT")
3789
- add_column_if_missing(conn, "weekly_cost_snapshots", "week_end_at", "TEXT")
3790
- add_column_if_missing(conn, "weekly_cost_snapshots", "range_start_iso", "TEXT")
3791
- add_column_if_missing(conn, "weekly_cost_snapshots", "range_end_iso", "TEXT")
3792
-
3793
- conn.execute(
3794
- """
3795
- CREATE INDEX IF NOT EXISTS idx_usage_week_start_at_time
3796
- ON weekly_usage_snapshots(week_start_at, captured_at_utc DESC, id DESC)
3797
- """
3798
- )
3799
- conn.execute(
3800
- """
3801
- CREATE INDEX IF NOT EXISTS idx_cost_week_start_at_time
3802
- ON weekly_cost_snapshots(week_start_at, captured_at_utc DESC, id DESC)
3803
- """
3804
- )
3805
-
3806
- conn.execute(
3807
- """
3808
- CREATE TABLE IF NOT EXISTS percent_milestones (
3809
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3810
- captured_at_utc TEXT NOT NULL,
3811
- week_start_date TEXT NOT NULL,
3812
- week_end_date TEXT NOT NULL,
3813
- week_start_at TEXT,
3814
- week_end_at TEXT,
3815
- percent_threshold INTEGER NOT NULL,
3816
- cumulative_cost_usd REAL NOT NULL,
3817
- marginal_cost_usd REAL,
3818
- usage_snapshot_id INTEGER NOT NULL,
3819
- cost_snapshot_id INTEGER NOT NULL,
3820
- reset_event_id INTEGER NOT NULL DEFAULT 0,
3821
- UNIQUE(week_start_date, percent_threshold, reset_event_id)
3822
- )
3823
- """
3824
- )
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).
3834
-
3835
- # alerted_at: populated by the alert-dispatch path when a milestone-INSERT
3836
- # row's threshold matches the user's configured alerts.weekly_thresholds /
3837
- # alerts.five_hour_thresholds (and alerts.enabled is true). NULL means
3838
- # "alerts were disabled at the moment of crossing OR the threshold wasn't
3839
- # in the configured list" — never "alert delivery failed" (dispatch is
3840
- # best-effort and write-once forward-only). The matching ALTER for
3841
- # `five_hour_milestones` lives right after that table's CREATE block
3842
- # below, since the table doesn't exist yet at this point in `open_db()`.
3843
- add_column_if_missing(conn, "percent_milestones", "alerted_at", "TEXT")
3844
-
3845
- # Mid-week reset events: when Anthropic advances `rate_limits.seven_day.
3846
- # resets_at` before the previously-declared reset actually fires (i.e.,
3847
- # gives the user a fresh weekly window before the old one naturally
3848
- # expired), we record one row here so display + cost layers can treat
3849
- # the effective reset moment as the old week's end AND the new week's
3850
- # start — preventing the API's -7d-derived new week from overlapping
3851
- # the old week. Inserted by cmd_record_usage on detection; read by
3852
- # _apply_reset_events_to_weekrefs and the cost live-recompute path.
3853
- conn.execute(
3854
- """
3855
- CREATE TABLE IF NOT EXISTS week_reset_events (
3856
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3857
- detected_at_utc TEXT NOT NULL,
3858
- old_week_end_at TEXT NOT NULL,
3859
- new_week_end_at TEXT NOT NULL,
3860
- effective_reset_at_utc TEXT NOT NULL,
3861
- UNIQUE(old_week_end_at, new_week_end_at)
3862
- )
3863
- """
3864
- )
3865
- _backfill_week_reset_events(conn)
3866
-
3867
- # ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
3868
- conn.execute(
3869
- """
3870
- CREATE TABLE IF NOT EXISTS five_hour_blocks (
3871
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3872
- five_hour_window_key INTEGER NOT NULL UNIQUE,
3873
- five_hour_resets_at TEXT NOT NULL,
3874
- block_start_at TEXT NOT NULL,
3875
- first_observed_at_utc TEXT NOT NULL,
3876
- last_observed_at_utc TEXT NOT NULL,
3877
- final_five_hour_percent REAL NOT NULL,
3878
- seven_day_pct_at_block_start REAL,
3879
- seven_day_pct_at_block_end REAL,
3880
- crossed_seven_day_reset INTEGER NOT NULL DEFAULT 0,
3881
- total_input_tokens INTEGER NOT NULL DEFAULT 0,
3882
- total_output_tokens INTEGER NOT NULL DEFAULT 0,
3883
- total_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3884
- total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3885
- total_cost_usd REAL NOT NULL DEFAULT 0,
3886
- is_closed INTEGER NOT NULL DEFAULT 0,
3887
- created_at_utc TEXT NOT NULL,
3888
- last_updated_at_utc TEXT NOT NULL
3889
- )
3890
- """
3891
- )
3892
- conn.execute(
3893
- """
3894
- CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
3895
- ON five_hour_blocks(block_start_at DESC)
3896
- """
3897
- )
3898
-
3899
- # ── five_hour_milestones (per-percent crossings inside a 5h block) ──
3900
- conn.execute(
3901
- """
3902
- CREATE TABLE IF NOT EXISTS five_hour_milestones (
3903
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3904
- block_id INTEGER NOT NULL,
3905
- five_hour_window_key INTEGER NOT NULL,
3906
- percent_threshold INTEGER NOT NULL,
3907
- captured_at_utc TEXT NOT NULL,
3908
- usage_snapshot_id INTEGER NOT NULL,
3909
- block_input_tokens INTEGER NOT NULL DEFAULT 0,
3910
- block_output_tokens INTEGER NOT NULL DEFAULT 0,
3911
- block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3912
- block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3913
- block_cost_usd REAL NOT NULL DEFAULT 0,
3914
- marginal_cost_usd REAL,
3915
- seven_day_pct_at_crossing REAL,
3916
- UNIQUE(five_hour_window_key, percent_threshold),
3917
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
3918
- )
3919
- """
3920
- )
3921
- conn.execute(
3922
- """
3923
- CREATE INDEX IF NOT EXISTS idx_five_hour_milestones_block
3924
- ON five_hour_milestones(block_id)
3925
- """
3926
- )
3927
-
3928
- # alerted_at: see the matching ALTER on `percent_milestones` above for
3929
- # rationale. Same write-once forward-only semantics: the alert-dispatch
3930
- # path stamps this column on milestone-INSERT rows whose threshold
3931
- # matches the user's configured `alerts.five_hour_thresholds`. NULL =
3932
- # "alerts disabled at moment of crossing OR threshold not configured"
3933
- # — never "delivery failed".
3934
- add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
3935
-
3936
- # ── five_hour_block_models (per-(block, model) rollup-child) ──
3937
- # MUST be created BEFORE the parent-backfill gate below, because
3938
- # _backfill_five_hour_blocks writes into this table on the fresh-install
3939
- # path. UNIQUE keyed on (five_hour_window_key, model) — durable across
3940
- # parent rebuilds. Live writes use DELETE WHERE five_hour_window_key = ?.
3941
- conn.execute(
3942
- """
3943
- CREATE TABLE IF NOT EXISTS five_hour_block_models (
3944
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3945
- block_id INTEGER NOT NULL,
3946
- five_hour_window_key INTEGER NOT NULL,
3947
- model TEXT NOT NULL,
3948
- input_tokens INTEGER NOT NULL DEFAULT 0,
3949
- output_tokens INTEGER NOT NULL DEFAULT 0,
3950
- cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3951
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3952
- cost_usd REAL NOT NULL DEFAULT 0,
3953
- entry_count INTEGER NOT NULL DEFAULT 0,
3954
- UNIQUE(five_hour_window_key, model),
3955
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
3956
- )
3957
- """
3958
- )
3959
- conn.execute(
3960
- """
3961
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_block
3962
- ON five_hour_block_models(block_id)
3963
- """
3964
- )
3965
- conn.execute(
3966
- """
3967
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_window
3968
- ON five_hour_block_models(five_hour_window_key)
3969
- """
3970
- )
3971
-
3972
- # ── five_hour_block_projects (per-(block, project_path) rollup-child) ──
3973
- # NULL session_files.project_path → '(unknown)' sentinel at write time,
3974
- # keeping reconcile invariant SUM(child.cost) == parent.total intact.
3975
- conn.execute(
3976
- """
3977
- CREATE TABLE IF NOT EXISTS five_hour_block_projects (
3978
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3979
- block_id INTEGER NOT NULL,
3980
- five_hour_window_key INTEGER NOT NULL,
3981
- project_path TEXT NOT NULL,
3982
- input_tokens INTEGER NOT NULL DEFAULT 0,
3983
- output_tokens INTEGER NOT NULL DEFAULT 0,
3984
- cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3985
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3986
- cost_usd REAL NOT NULL DEFAULT 0,
3987
- entry_count INTEGER NOT NULL DEFAULT 0,
3988
- UNIQUE(five_hour_window_key, project_path),
3989
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
3990
- )
3991
- """
3992
- )
3993
- conn.execute(
3994
- """
3995
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_block
3996
- ON five_hour_block_projects(block_id)
3997
- """
3998
- )
3999
- conn.execute(
4000
- """
4001
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_window
4002
- ON five_hour_block_projects(five_hour_window_key)
4003
- """
4004
- )
4005
-
4006
- # Migration framework dispatcher. Replaces the prior inline gate stack
4007
- # (has_blocks + _migration_done) with the framework's _run_pending_-
4008
- # migrations entry point. See spec §2.3, §5.2 + the migration handlers
4009
- # decorated with @stats_migration further down in this file.
4010
- #
4011
- # MUST run BEFORE any DDL or write that touches `schema_migrations`
4012
- # (Codex P1 #1 fix on c3625ee + e7fdcc8): the dispatcher's fresh-install
4013
- # detection snapshots `schema_migrations`'s existence in sqlite_master
4014
- # BEFORE its own CREATE TABLE IF NOT EXISTS. Pre-creating the table
4015
- # earlier in open_db() (or letting `_backfill_five_hour_blocks` insert
4016
- # markers first) flips that snapshot to True on a brand-new DB and
4017
- # dead-codes the stamp-only fast path. The dispatcher is now the sole
4018
- # creator of `schema_migrations` + `schema_migrations_skipped`.
4019
- _run_pending_migrations(
4020
- conn, registry=_STATS_MIGRATIONS, db_label="stats.db",
4021
- )
4022
-
4023
- # One-time historical backfill of five_hour_blocks (rollup only;
4024
- # milestones are forward-only per spec §4.3 / [Write-once milestones]).
4025
- # Idempotent via UNIQUE(five_hour_window_key) + INSERT OR IGNORE.
4026
- # Runs AFTER the dispatcher so `schema_migrations` exists for the
4027
- # marker INSERTs inside the backfill body, and so any fresh-install
4028
- # stamp-only path the dispatcher took above is already committed.
4029
- existing = conn.execute(
4030
- "SELECT 1 FROM five_hour_blocks LIMIT 1"
4031
- ).fetchone()
4032
- has_snapshots = conn.execute(
4033
- "SELECT 1 FROM weekly_usage_snapshots "
4034
- "WHERE five_hour_window_key IS NOT NULL "
4035
- " AND five_hour_percent IS NOT NULL "
4036
- "LIMIT 1"
4037
- ).fetchone()
4038
- if not existing and has_snapshots:
4039
- inserted = _backfill_five_hour_blocks(conn)
4040
- # Re-run the 5h dedup migration AFTER backfill creates parents.
4041
- # The dispatcher above ran while five_hour_blocks was empty, so
4042
- # the dedup handler no-op'd and stamped its marker. Snapshot
4043
- # keys can carry jitter beyond the 600s canonical floor (the
4044
- # 003_* migration handles up to 1800s grouping), so the
4045
- # backfill's `DISTINCT five_hour_window_key` over those keys
4046
- # can produce duplicate parent rows for one physical 5h
4047
- # window. Without this re-invocation those duplicates persist
4048
- # forever — the marker says it ran. Handler owns its own
4049
- # BEGIN/COMMIT and is idempotent (no groups → no-op).
4050
- #
4051
- # Honor `db skip` here as well: if the operator marked 003 as
4052
- # skipped (e.g., poison pill on their machine), we must NOT
4053
- # back-door run the handler. Duplicates introduced by the
4054
- # backfill will persist until they `db unskip` — which is the
4055
- # explicit choice the skip records. Failure path mirrors the
4056
- # dispatcher's contract: route through _log_migration_error so
4057
- # the next interactive command renders the banner, and clear
4058
- # the log entry on success so the banner auto-dismisses.
4059
- if inserted > 0:
4060
- target_name = "003_merge_5h_block_duplicates_v1"
4061
- try:
4062
- skipped = {
4063
- row[0] for row in conn.execute(
4064
- "SELECT name FROM schema_migrations_skipped"
4065
- ).fetchall()
4066
- }
4067
- except sqlite3.OperationalError:
4068
- skipped = set()
4069
- if target_name not in skipped:
4070
- for _m in _STATS_MIGRATIONS:
4071
- if _m.name == target_name:
4072
- qualified = f"stats.db:{target_name}"
4073
- try:
4074
- _m.handler(conn)
4075
- _clear_migration_error_log_entries(qualified)
4076
- except Exception as exc:
4077
- _log_migration_error(
4078
- name=qualified,
4079
- exc=exc,
4080
- tb=traceback.format_exc(),
4081
- )
4082
- eprint(f"[migration {qualified}] failed: {exc}")
4083
- break
4084
-
4085
- conn.commit()
4086
- return conn
4087
-
4088
-
4089
-
4090
3472
  @dataclass
4091
3473
  class CacheModelBreakdown:
4092
3474
  model_name: str
@@ -4589,85 +3971,6 @@ def compute_week_cost(
4589
3971
  )
4590
3972
 
4591
3973
 
4592
- def _canonicalize_optional_iso(value: str | None, label: str) -> str | None:
4593
- if value is None:
4594
- return None
4595
- s = value.strip()
4596
- if s == "":
4597
- return None
4598
- normalized = _normalize_week_boundary_dt(parse_iso_datetime(s, label)).astimezone(dt.timezone.utc)
4599
- return normalized.isoformat(timespec="seconds")
4600
-
4601
-
4602
- @dataclass(frozen=True)
4603
- class WeekRef:
4604
- week_start: dt.date
4605
- week_end: dt.date | None
4606
- week_start_at: str | None
4607
- week_end_at: str | None
4608
- key: str
4609
-
4610
-
4611
- def make_week_ref(
4612
- week_start_date: str,
4613
- week_end_date: str | None,
4614
- week_start_at: str | None = None,
4615
- week_end_at: str | None = None,
4616
- ) -> WeekRef:
4617
- week_start = dt.date.fromisoformat(week_start_date)
4618
- week_end = dt.date.fromisoformat(week_end_date) if week_end_date else None
4619
- start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
4620
- end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
4621
-
4622
- return WeekRef(
4623
- week_start=week_start,
4624
- week_end=week_end,
4625
- week_start_at=start_at,
4626
- week_end_at=end_at,
4627
- key=week_start.isoformat(),
4628
- )
4629
-
4630
-
4631
- def _get_latest_row_for_week(
4632
- conn: sqlite3.Connection,
4633
- table_name: str,
4634
- week_ref: WeekRef,
4635
- as_of_utc: str | None = None,
4636
- ) -> sqlite3.Row | None:
4637
- if as_of_utc is None:
4638
- return conn.execute(
4639
- f"""
4640
- SELECT *
4641
- FROM {table_name}
4642
- WHERE week_start_date = ?
4643
- ORDER BY captured_at_utc DESC, id DESC
4644
- LIMIT 1
4645
- """,
4646
- (week_ref.week_start.isoformat(),),
4647
- ).fetchone()
4648
- return conn.execute(
4649
- f"""
4650
- SELECT *
4651
- FROM {table_name}
4652
- WHERE week_start_date = ?
4653
- AND captured_at_utc <= ?
4654
- ORDER BY captured_at_utc DESC, id DESC
4655
- LIMIT 1
4656
- """,
4657
- (week_ref.week_start.isoformat(), as_of_utc),
4658
- ).fetchone()
4659
-
4660
-
4661
- def get_latest_usage_for_week(
4662
- conn: sqlite3.Connection,
4663
- week_ref: WeekRef,
4664
- as_of_utc: str | None = None,
4665
- ) -> sqlite3.Row | None:
4666
- return _get_latest_row_for_week(
4667
- conn, "weekly_usage_snapshots", week_ref, as_of_utc=as_of_utc,
4668
- )
4669
-
4670
-
4671
3974
  def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
4672
3975
  return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
4673
3976
 
@@ -5484,6 +4787,18 @@ def _load_recorded_five_hour_windows(
5484
4787
  else:
5485
4788
  d = d.astimezone(dt.timezone.utc)
5486
4789
  credit_moments.append(d)
4790
+ # Issue #44: the inner-loop break below latches onto the
4791
+ # first credit in [next_bs, rs]. With two credits inside
4792
+ # the same pre-credit canonical 5h window, the wrong one
4793
+ # (the later one) wins when SQLite returns rows in
4794
+ # insertion order rather than time order — collapsing
4795
+ # two distinct truncated anchors onto the same floored
4796
+ # bucket and silently dropping one via override-map
4797
+ # overwrite. Sort once so the break consistently picks
4798
+ # the EARLIEST credit, which is the one that actually
4799
+ # ended the earlier block (its floor equals the next
4800
+ # block's block_start_at by construction).
4801
+ credit_moments.sort()
5487
4802
  except sqlite3.DatabaseError:
5488
4803
  credit_moments = []
5489
4804
  except (sqlite3.DatabaseError, OSError):
@@ -6358,46 +5673,6 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
6358
5673
  return 0
6359
5674
 
6360
5675
 
6361
- def _command_as_of() -> dt.datetime:
6362
- """Testing hook: CCTALLY_AS_OF env var overrides wall-clock `now` for
6363
- time-dependent commands. Shared by cmd_project, cmd_weekly,
6364
- cmd_cache_report, cmd_codex_weekly, cmd_diff (and any future
6365
- time-dependent command). Format: ISO-8601 with Z or explicit tz offset.
6366
- """
6367
- override = os.environ.get("CCTALLY_AS_OF")
6368
- if override:
6369
- override = override.strip()
6370
- if override.endswith("Z"):
6371
- override = override[:-1] + "+00:00"
6372
- return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
6373
- return dt.datetime.now(dt.timezone.utc)
6374
-
6375
-
6376
- def _now_utc() -> dt.datetime:
6377
- """UTC now, with CCTALLY_AS_OF env override for fixture-stability.
6378
-
6379
- Single time source for the `update` subcommand and its supporting
6380
- state machine (TTL gates, ``remind_after.until_utc`` comparisons,
6381
- log timestamps, install-method detection cache). Mirrors the
6382
- documented CCTALLY_AS_OF precedent (see CLAUDE.md — `project` has
6383
- a hidden `CCTALLY_AS_OF` env hook, and `_command_as_of` /
6384
- `_share_now_utc` reuse it for `weekly`/`forecast`/share-render).
6385
- Accepts ISO-8601 with `Z` or explicit offset; result is always
6386
- tz-aware UTC.
6387
-
6388
- Raises ValueError on malformed CCTALLY_AS_OF — deliberate fail-loud
6389
- for the dev hook so fixture authors notice typos immediately rather
6390
- than silently falling back to wall-clock time.
6391
- """
6392
- override = os.environ.get("CCTALLY_AS_OF")
6393
- if override:
6394
- override = override.strip()
6395
- if override.endswith("Z"):
6396
- override = override[:-1] + "+00:00"
6397
- return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
6398
- return dt.datetime.now(dt.timezone.utc)
6399
-
6400
-
6401
5676
  def _load_week_snapshots(
6402
5677
  since: dt.datetime, until: dt.datetime
6403
5678
  ) -> dict[dt.datetime, float]:
@@ -7632,6 +6907,14 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
7632
6907
  # 86→0 and 34→0 cases while filtering 35→33-style jitter.
7633
6908
  _RESET_PCT_DROP_THRESHOLD = 25.0
7634
6909
 
6910
+ # In-place 5h-credit threshold. Mirrors `_RESET_PCT_DROP_THRESHOLD` but
6911
+ # scaled down for the 5h dimension: typical 5h usage stays under ~10pp in
6912
+ # a single block, so a 5pp drop sits well above natural variation while
6913
+ # proportionally being a larger signal than 25pp is on the weekly scale.
6914
+ # See spec docs/superpowers/specs/2026-05-16-5h-in-place-credit-detection.md
6915
+ # §2.1 (Q1) for rationale.
6916
+ _FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = 5.0
6917
+
7635
6918
 
7636
6919
  def _week_ref_has_reset_event(
7637
6920
  conn: sqlite3.Connection, ref: WeekRef
@@ -9457,6 +8740,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
9457
8740
  # to fill seven_day_pct_at_block_end on the active row.
9458
8741
  latest_7d, latest_window_key = _latest_seven_day_and_window(conn)
9459
8742
 
8743
+ # Pre-load credit events for every window_key the rows query
8744
+ # returned. Single index-scan over `five_hour_reset_events`;
8745
+ # build a window_key -> list[Credit] map keyed for in-process
8746
+ # JOIN against each block dict. Used by both the text/JSON
8747
+ # render path AND the share-output snapshot wiring (spec §5.1.1).
8748
+ # Loaded in a single pass — no per-block SELECT.
8749
+ credit_rows = conn.execute(
8750
+ "SELECT five_hour_window_key, prior_percent, post_percent, "
8751
+ " effective_reset_at_utc "
8752
+ " FROM five_hour_reset_events "
8753
+ " ORDER BY five_hour_window_key, effective_reset_at_utc"
8754
+ ).fetchall()
8755
+ credits_by_window: dict[int, list[dict]] = {}
8756
+ for cr in credit_rows:
8757
+ credits_by_window.setdefault(
8758
+ int(cr["five_hour_window_key"]), []
8759
+ ).append({
8760
+ "effectiveResetAtUtc": cr["effective_reset_at_utc"],
8761
+ "priorPercent": float(cr["prior_percent"]),
8762
+ "postPercent": float(cr["post_percent"]),
8763
+ "deltaPp": round(
8764
+ float(cr["post_percent"]) - float(cr["prior_percent"]), 1
8765
+ ),
8766
+ })
8767
+
9460
8768
  # Build per-block dicts with the active-flag side-channel.
9461
8769
  block_dicts: list[dict] = []
9462
8770
  for r in rows:
@@ -9465,6 +8773,11 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
9465
8773
  d["__is_active"] = is_active
9466
8774
  if is_active and latest_7d is not None:
9467
8775
  d["seven_day_pct_at_block_end"] = latest_7d
8776
+ # Side-channel (parallel to __is_active): list of credit
8777
+ # event dicts for this block's window. Empty list when none.
8778
+ d["__credits"] = credits_by_window.get(
8779
+ int(d["five_hour_window_key"]), []
8780
+ )
9468
8781
  block_dicts.append(d)
9469
8782
 
9470
8783
  # Shareable-reports gate: --format short-circuits the JSON / table
@@ -9578,18 +8891,50 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9578
8891
  )
9579
8892
  return 2
9580
8893
 
8894
+ # Spec §5.2: ORDER BY captured_at_utc ASC (NOT percent_threshold)
8895
+ # so post-credit segments interleave with pre-credit ones in
8896
+ # time-order — same human threshold number can appear twice
8897
+ # (once per reset_event_id segment) and must render in the
8898
+ # order it crossed. Bucket B per §3.2: read ALL segments (no
8899
+ # ``reset_event_id`` filter).
9581
8900
  milestones = conn.execute(
9582
8901
  """
9583
8902
  SELECT percent_threshold, captured_at_utc,
9584
8903
  block_cost_usd, marginal_cost_usd,
9585
- seven_day_pct_at_crossing
8904
+ seven_day_pct_at_crossing, reset_event_id
9586
8905
  FROM five_hour_milestones
9587
8906
  WHERE block_id = ?
9588
- ORDER BY percent_threshold ASC
8907
+ ORDER BY captured_at_utc ASC, id ASC
9589
8908
  """,
9590
8909
  (block["id"],),
9591
8910
  ).fetchall()
9592
8911
 
8912
+ # Spec §5.2 — load in-place credit events for this block's
8913
+ # window, ascending by effective_reset_at_utc, so the text
8914
+ # renderer can interleave a ``⚡ CREDIT -Xpp @ HH:MM`` divider
8915
+ # row between pre- and post-credit milestone segments and JSON
8916
+ # consumers see the parallel ``credits[]`` array (Section 5.2).
8917
+ credit_rows = conn.execute(
8918
+ """
8919
+ SELECT effective_reset_at_utc, prior_percent, post_percent
8920
+ FROM five_hour_reset_events
8921
+ WHERE five_hour_window_key = ?
8922
+ ORDER BY effective_reset_at_utc ASC
8923
+ """,
8924
+ (block["five_hour_window_key"],),
8925
+ ).fetchall()
8926
+ credits_list: list[dict] = [
8927
+ {
8928
+ "effectiveResetAtUtc": c["effective_reset_at_utc"],
8929
+ "priorPercent": float(c["prior_percent"]),
8930
+ "postPercent": float(c["post_percent"]),
8931
+ "deltaPp": round(
8932
+ float(c["post_percent"]) - float(c["prior_percent"]), 1
8933
+ ),
8934
+ }
8935
+ for c in credit_rows
8936
+ ]
8937
+
9593
8938
  crossed = bool(block.get("crossed_seven_day_reset"))
9594
8939
  p_start = block.get("seven_day_pct_at_block_start")
9595
8940
  p_end = block.get("seven_day_pct_at_block_end")
@@ -9626,6 +8971,10 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9626
8971
  "sevenDayPctDeltaPp": delta,
9627
8972
  "crossedSevenDayReset": crossed,
9628
8973
  }
8974
+ # Spec §5.2: expose ``resetEventId`` on each milestone so JSON
8975
+ # consumers can disambiguate post-credit threshold repeats from
8976
+ # pre-credit ones. ``0`` is the pre-credit/no-credit sentinel
8977
+ # (matches the schema default).
9629
8978
  ms_out = [
9630
8979
  {
9631
8980
  "percentThreshold": m["percent_threshold"],
@@ -9636,13 +8985,23 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9636
8985
  else round(m["marginal_cost_usd"], 9)
9637
8986
  ),
9638
8987
  "sevenDayPctAtCrossing": m["seven_day_pct_at_crossing"],
8988
+ "resetEventId": int(m["reset_event_id"] or 0),
9639
8989
  }
9640
8990
  for m in milestones
9641
8991
  ]
9642
8992
 
9643
8993
  if args.json:
8994
+ # Spec §5.2: ``credits`` is the parallel array to
8995
+ # ``milestones`` — same shape as the ``credits`` field on
8996
+ # ``five-hour-blocks --json`` (§5.1). Stacked credits across
8997
+ # distinct 10-min slots produce multiple entries.
9644
8998
  print(json.dumps(
9645
- {"schemaVersion": 1, "block": block_out, "milestones": ms_out},
8999
+ {
9000
+ "schemaVersion": 1,
9001
+ "block": block_out,
9002
+ "milestones": ms_out,
9003
+ "credits": credits_list,
9004
+ },
9646
9005
  indent=2,
9647
9006
  ))
9648
9007
  return 0
@@ -9683,7 +9042,47 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
9683
9042
  headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost",
9684
9043
  "7d at crossing"]
9685
9044
  rows = []
9686
- for idx, m in enumerate(ms_out, start=1):
9045
+ # Spec §5.2 merged event stream. Interleave milestones and
9046
+ # credits in time-order (``capturedAt`` for milestones,
9047
+ # ``effectiveResetAtUtc`` for credits). Credits render as a
9048
+ # divider row with ``⚡ CREDIT`` in the Threshold cell and the
9049
+ # delta-pp + HH:MM in the rightmost cell; the milestone row
9050
+ # numbering counter (``#``) continues across the divider so the
9051
+ # ordinal still reflects "the Nth event in this block."
9052
+ merged_events: list[tuple[str, dict]] = []
9053
+ for m in ms_out:
9054
+ merged_events.append(("milestone", m))
9055
+ for c in credits_list:
9056
+ merged_events.append(("credit", c))
9057
+ merged_events.sort(key=lambda ev: (
9058
+ ev[1]["effectiveResetAtUtc"] if ev[0] == "credit"
9059
+ else ev[1]["capturedAt"]
9060
+ ))
9061
+ idx = 0
9062
+ for kind, ev in merged_events:
9063
+ idx += 1
9064
+ if kind == "credit":
9065
+ # Spec §5.2: ⚡ CREDIT -Xpp @ HH:MM divider row.
9066
+ # HH:MM rendered in the display tz via format_display_dt.
9067
+ # ``format_display_dt`` is the documented chokepoint for
9068
+ # human-displayed datetimes (CLAUDE.md). The deltaPp
9069
+ # value is float; format as integer ppm (mirrors the
9070
+ # five-hour-blocks chip in §5.1).
9071
+ hhmm = format_display_dt(
9072
+ ev["effectiveResetAtUtc"],
9073
+ args._resolved_tz,
9074
+ fmt="%H:%M",
9075
+ suffix=False,
9076
+ )
9077
+ rows.append([
9078
+ str(idx),
9079
+ "⚡ CREDIT",
9080
+ f"{ev['deltaPp']:+.0f}pp",
9081
+ "",
9082
+ f"@ {hhmm}",
9083
+ ])
9084
+ continue
9085
+ m = ev
9687
9086
  cum = f"${m['blockCostUSD']:.6f}"
9688
9087
  marg = (
9689
9088
  "n/a" if m["marginalCostUSD"] is None
@@ -13832,8 +13231,21 @@ def _build_five_hour_blocks_snapshot(
13832
13231
  used_pct = float(r.get("final_five_hour_percent") or 0.0)
13833
13232
  crossed = bool(r.get("crossed_seven_day_reset"))
13834
13233
  cell_text = "⚡" if crossed else "—"
13234
+ # Spec §5.1.1 (Codex r2 finding 3): consume the ``__credits``
13235
+ # side-channel set by ``cmd_five_hour_blocks`` and append a
13236
+ # ``⚡ -Xpp, -Ypp`` chip to the block_start cell. Pure-string
13237
+ # cell content flows uniformly through markdown / HTML table /
13238
+ # SVG text renderers without per-format additions. Symmetric to
13239
+ # the existing ⚡ glyph in the cross_reset cell — by position
13240
+ # (block_start suffix vs. dedicated column) the two annotations
13241
+ # remain visually distinguishable.
13242
+ credits = r.get("__credits") or []
13243
+ block_cell = block_lbl
13244
+ if credits:
13245
+ deltas = ", ".join(f"{c['deltaPp']:+.0f}pp" for c in credits)
13246
+ block_cell = f"{block_lbl} ⚡ {deltas}"
13835
13247
  snap_rows.append(_lib_share.Row(cells={
13836
- "block_start": _lib_share.TextCell(block_lbl),
13248
+ "block_start": _lib_share.TextCell(block_cell),
13837
13249
  "cost": _lib_share.MoneyCell(cost_usd),
13838
13250
  "used_pct": _lib_share.PercentCell(used_pct),
13839
13251
  "cross_reset": _lib_share.TextCell(cell_text),