cctally 1.7.3 → 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,489 +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_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
-
3904
- # ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
3905
- conn.execute(
3906
- """
3907
- CREATE TABLE IF NOT EXISTS five_hour_blocks (
3908
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3909
- five_hour_window_key INTEGER NOT NULL UNIQUE,
3910
- five_hour_resets_at TEXT NOT NULL,
3911
- block_start_at TEXT NOT NULL,
3912
- first_observed_at_utc TEXT NOT NULL,
3913
- last_observed_at_utc TEXT NOT NULL,
3914
- final_five_hour_percent REAL NOT NULL,
3915
- seven_day_pct_at_block_start REAL,
3916
- seven_day_pct_at_block_end REAL,
3917
- crossed_seven_day_reset INTEGER NOT NULL DEFAULT 0,
3918
- total_input_tokens INTEGER NOT NULL DEFAULT 0,
3919
- total_output_tokens INTEGER NOT NULL DEFAULT 0,
3920
- total_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3921
- total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3922
- total_cost_usd REAL NOT NULL DEFAULT 0,
3923
- is_closed INTEGER NOT NULL DEFAULT 0,
3924
- created_at_utc TEXT NOT NULL,
3925
- last_updated_at_utc TEXT NOT NULL
3926
- )
3927
- """
3928
- )
3929
- conn.execute(
3930
- """
3931
- CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
3932
- ON five_hour_blocks(block_start_at DESC)
3933
- """
3934
- )
3935
-
3936
- # ── five_hour_milestones (per-percent crossings inside a 5h block) ──
3937
- conn.execute(
3938
- """
3939
- CREATE TABLE IF NOT EXISTS five_hour_milestones (
3940
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3941
- block_id INTEGER NOT NULL,
3942
- five_hour_window_key INTEGER NOT NULL,
3943
- percent_threshold INTEGER NOT NULL,
3944
- captured_at_utc TEXT NOT NULL,
3945
- usage_snapshot_id INTEGER NOT NULL,
3946
- block_input_tokens INTEGER NOT NULL DEFAULT 0,
3947
- block_output_tokens INTEGER NOT NULL DEFAULT 0,
3948
- block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3949
- block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3950
- block_cost_usd REAL NOT NULL DEFAULT 0,
3951
- marginal_cost_usd REAL,
3952
- seven_day_pct_at_crossing REAL,
3953
- reset_event_id INTEGER NOT NULL DEFAULT 0,
3954
- UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
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_milestones_block
3962
- ON five_hour_milestones(block_id)
3963
- """
3964
- )
3965
-
3966
- # alerted_at: see the matching ALTER on `percent_milestones` above for
3967
- # rationale. Same write-once forward-only semantics: the alert-dispatch
3968
- # path stamps this column on milestone-INSERT rows whose threshold
3969
- # matches the user's configured `alerts.five_hour_thresholds`. NULL =
3970
- # "alerts disabled at moment of crossing OR threshold not configured"
3971
- # — never "delivery failed".
3972
- add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
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
-
3984
- # ── five_hour_block_models (per-(block, model) rollup-child) ──
3985
- # MUST be created BEFORE the parent-backfill gate below, because
3986
- # _backfill_five_hour_blocks writes into this table on the fresh-install
3987
- # path. UNIQUE keyed on (five_hour_window_key, model) — durable across
3988
- # parent rebuilds. Live writes use DELETE WHERE five_hour_window_key = ?.
3989
- conn.execute(
3990
- """
3991
- CREATE TABLE IF NOT EXISTS five_hour_block_models (
3992
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3993
- block_id INTEGER NOT NULL,
3994
- five_hour_window_key INTEGER NOT NULL,
3995
- model TEXT NOT NULL,
3996
- input_tokens INTEGER NOT NULL DEFAULT 0,
3997
- output_tokens INTEGER NOT NULL DEFAULT 0,
3998
- cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3999
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
4000
- cost_usd REAL NOT NULL DEFAULT 0,
4001
- entry_count INTEGER NOT NULL DEFAULT 0,
4002
- UNIQUE(five_hour_window_key, model),
4003
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
4004
- )
4005
- """
4006
- )
4007
- conn.execute(
4008
- """
4009
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_block
4010
- ON five_hour_block_models(block_id)
4011
- """
4012
- )
4013
- conn.execute(
4014
- """
4015
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_window
4016
- ON five_hour_block_models(five_hour_window_key)
4017
- """
4018
- )
4019
-
4020
- # ── five_hour_block_projects (per-(block, project_path) rollup-child) ──
4021
- # NULL session_files.project_path → '(unknown)' sentinel at write time,
4022
- # keeping reconcile invariant SUM(child.cost) == parent.total intact.
4023
- conn.execute(
4024
- """
4025
- CREATE TABLE IF NOT EXISTS five_hour_block_projects (
4026
- id INTEGER PRIMARY KEY AUTOINCREMENT,
4027
- block_id INTEGER NOT NULL,
4028
- five_hour_window_key INTEGER NOT NULL,
4029
- project_path TEXT NOT NULL,
4030
- input_tokens INTEGER NOT NULL DEFAULT 0,
4031
- output_tokens INTEGER NOT NULL DEFAULT 0,
4032
- cache_create_tokens INTEGER NOT NULL DEFAULT 0,
4033
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
4034
- cost_usd REAL NOT NULL DEFAULT 0,
4035
- entry_count INTEGER NOT NULL DEFAULT 0,
4036
- UNIQUE(five_hour_window_key, project_path),
4037
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
4038
- )
4039
- """
4040
- )
4041
- conn.execute(
4042
- """
4043
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_block
4044
- ON five_hour_block_projects(block_id)
4045
- """
4046
- )
4047
- conn.execute(
4048
- """
4049
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_window
4050
- ON five_hour_block_projects(five_hour_window_key)
4051
- """
4052
- )
4053
-
4054
- # Migration framework dispatcher. Replaces the prior inline gate stack
4055
- # (has_blocks + _migration_done) with the framework's _run_pending_-
4056
- # migrations entry point. See spec §2.3, §5.2 + the migration handlers
4057
- # decorated with @stats_migration further down in this file.
4058
- #
4059
- # MUST run BEFORE any DDL or write that touches `schema_migrations`
4060
- # (Codex P1 #1 fix on c3625ee + e7fdcc8): the dispatcher's fresh-install
4061
- # detection snapshots `schema_migrations`'s existence in sqlite_master
4062
- # BEFORE its own CREATE TABLE IF NOT EXISTS. Pre-creating the table
4063
- # earlier in open_db() (or letting `_backfill_five_hour_blocks` insert
4064
- # markers first) flips that snapshot to True on a brand-new DB and
4065
- # dead-codes the stamp-only fast path. The dispatcher is now the sole
4066
- # creator of `schema_migrations` + `schema_migrations_skipped`.
4067
- _run_pending_migrations(
4068
- conn, registry=_STATS_MIGRATIONS, db_label="stats.db",
4069
- )
4070
-
4071
- # One-time historical backfill of five_hour_blocks (rollup only;
4072
- # milestones are forward-only per spec §4.3 / [Write-once milestones]).
4073
- # Idempotent via UNIQUE(five_hour_window_key) + INSERT OR IGNORE.
4074
- # Runs AFTER the dispatcher so `schema_migrations` exists for the
4075
- # marker INSERTs inside the backfill body, and so any fresh-install
4076
- # stamp-only path the dispatcher took above is already committed.
4077
- existing = conn.execute(
4078
- "SELECT 1 FROM five_hour_blocks LIMIT 1"
4079
- ).fetchone()
4080
- has_snapshots = conn.execute(
4081
- "SELECT 1 FROM weekly_usage_snapshots "
4082
- "WHERE five_hour_window_key IS NOT NULL "
4083
- " AND five_hour_percent IS NOT NULL "
4084
- "LIMIT 1"
4085
- ).fetchone()
4086
- if not existing and has_snapshots:
4087
- inserted = _backfill_five_hour_blocks(conn)
4088
- # Re-run the 5h dedup migration AFTER backfill creates parents.
4089
- # The dispatcher above ran while five_hour_blocks was empty, so
4090
- # the dedup handler no-op'd and stamped its marker. Snapshot
4091
- # keys can carry jitter beyond the 600s canonical floor (the
4092
- # 003_* migration handles up to 1800s grouping), so the
4093
- # backfill's `DISTINCT five_hour_window_key` over those keys
4094
- # can produce duplicate parent rows for one physical 5h
4095
- # window. Without this re-invocation those duplicates persist
4096
- # forever — the marker says it ran. Handler owns its own
4097
- # BEGIN/COMMIT and is idempotent (no groups → no-op).
4098
- #
4099
- # Honor `db skip` here as well: if the operator marked 003 as
4100
- # skipped (e.g., poison pill on their machine), we must NOT
4101
- # back-door run the handler. Duplicates introduced by the
4102
- # backfill will persist until they `db unskip` — which is the
4103
- # explicit choice the skip records. Failure path mirrors the
4104
- # dispatcher's contract: route through _log_migration_error so
4105
- # the next interactive command renders the banner, and clear
4106
- # the log entry on success so the banner auto-dismisses.
4107
- if inserted > 0:
4108
- target_name = "003_merge_5h_block_duplicates_v1"
4109
- try:
4110
- skipped = {
4111
- row[0] for row in conn.execute(
4112
- "SELECT name FROM schema_migrations_skipped"
4113
- ).fetchall()
4114
- }
4115
- except sqlite3.OperationalError:
4116
- skipped = set()
4117
- if target_name not in skipped:
4118
- for _m in _STATS_MIGRATIONS:
4119
- if _m.name == target_name:
4120
- qualified = f"stats.db:{target_name}"
4121
- try:
4122
- _m.handler(conn)
4123
- _clear_migration_error_log_entries(qualified)
4124
- except Exception as exc:
4125
- _log_migration_error(
4126
- name=qualified,
4127
- exc=exc,
4128
- tb=traceback.format_exc(),
4129
- )
4130
- eprint(f"[migration {qualified}] failed: {exc}")
4131
- break
4132
-
4133
- conn.commit()
4134
- return conn
4135
-
4136
-
4137
-
4138
3472
  @dataclass
4139
3473
  class CacheModelBreakdown:
4140
3474
  model_name: str
@@ -4637,85 +3971,6 @@ def compute_week_cost(
4637
3971
  )
4638
3972
 
4639
3973
 
4640
- def _canonicalize_optional_iso(value: str | None, label: str) -> str | None:
4641
- if value is None:
4642
- return None
4643
- s = value.strip()
4644
- if s == "":
4645
- return None
4646
- normalized = _normalize_week_boundary_dt(parse_iso_datetime(s, label)).astimezone(dt.timezone.utc)
4647
- return normalized.isoformat(timespec="seconds")
4648
-
4649
-
4650
- @dataclass(frozen=True)
4651
- class WeekRef:
4652
- week_start: dt.date
4653
- week_end: dt.date | None
4654
- week_start_at: str | None
4655
- week_end_at: str | None
4656
- key: str
4657
-
4658
-
4659
- def make_week_ref(
4660
- week_start_date: str,
4661
- week_end_date: str | None,
4662
- week_start_at: str | None = None,
4663
- week_end_at: str | None = None,
4664
- ) -> WeekRef:
4665
- week_start = dt.date.fromisoformat(week_start_date)
4666
- week_end = dt.date.fromisoformat(week_end_date) if week_end_date else None
4667
- start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
4668
- end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
4669
-
4670
- return WeekRef(
4671
- week_start=week_start,
4672
- week_end=week_end,
4673
- week_start_at=start_at,
4674
- week_end_at=end_at,
4675
- key=week_start.isoformat(),
4676
- )
4677
-
4678
-
4679
- def _get_latest_row_for_week(
4680
- conn: sqlite3.Connection,
4681
- table_name: str,
4682
- week_ref: WeekRef,
4683
- as_of_utc: str | None = None,
4684
- ) -> sqlite3.Row | None:
4685
- if as_of_utc is None:
4686
- return conn.execute(
4687
- f"""
4688
- SELECT *
4689
- FROM {table_name}
4690
- WHERE week_start_date = ?
4691
- ORDER BY captured_at_utc DESC, id DESC
4692
- LIMIT 1
4693
- """,
4694
- (week_ref.week_start.isoformat(),),
4695
- ).fetchone()
4696
- return conn.execute(
4697
- f"""
4698
- SELECT *
4699
- FROM {table_name}
4700
- WHERE week_start_date = ?
4701
- AND captured_at_utc <= ?
4702
- ORDER BY captured_at_utc DESC, id DESC
4703
- LIMIT 1
4704
- """,
4705
- (week_ref.week_start.isoformat(), as_of_utc),
4706
- ).fetchone()
4707
-
4708
-
4709
- def get_latest_usage_for_week(
4710
- conn: sqlite3.Connection,
4711
- week_ref: WeekRef,
4712
- as_of_utc: str | None = None,
4713
- ) -> sqlite3.Row | None:
4714
- return _get_latest_row_for_week(
4715
- conn, "weekly_usage_snapshots", week_ref, as_of_utc=as_of_utc,
4716
- )
4717
-
4718
-
4719
3974
  def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
4720
3975
  return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
4721
3976
 
@@ -5532,6 +4787,18 @@ def _load_recorded_five_hour_windows(
5532
4787
  else:
5533
4788
  d = d.astimezone(dt.timezone.utc)
5534
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()
5535
4802
  except sqlite3.DatabaseError:
5536
4803
  credit_moments = []
5537
4804
  except (sqlite3.DatabaseError, OSError):
@@ -6406,46 +5673,6 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
6406
5673
  return 0
6407
5674
 
6408
5675
 
6409
- def _command_as_of() -> dt.datetime:
6410
- """Testing hook: CCTALLY_AS_OF env var overrides wall-clock `now` for
6411
- time-dependent commands. Shared by cmd_project, cmd_weekly,
6412
- cmd_cache_report, cmd_codex_weekly, cmd_diff (and any future
6413
- time-dependent command). Format: ISO-8601 with Z or explicit tz offset.
6414
- """
6415
- override = os.environ.get("CCTALLY_AS_OF")
6416
- if override:
6417
- override = override.strip()
6418
- if override.endswith("Z"):
6419
- override = override[:-1] + "+00:00"
6420
- return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
6421
- return dt.datetime.now(dt.timezone.utc)
6422
-
6423
-
6424
- def _now_utc() -> dt.datetime:
6425
- """UTC now, with CCTALLY_AS_OF env override for fixture-stability.
6426
-
6427
- Single time source for the `update` subcommand and its supporting
6428
- state machine (TTL gates, ``remind_after.until_utc`` comparisons,
6429
- log timestamps, install-method detection cache). Mirrors the
6430
- documented CCTALLY_AS_OF precedent (see CLAUDE.md — `project` has
6431
- a hidden `CCTALLY_AS_OF` env hook, and `_command_as_of` /
6432
- `_share_now_utc` reuse it for `weekly`/`forecast`/share-render).
6433
- Accepts ISO-8601 with `Z` or explicit offset; result is always
6434
- tz-aware UTC.
6435
-
6436
- Raises ValueError on malformed CCTALLY_AS_OF — deliberate fail-loud
6437
- for the dev hook so fixture authors notice typos immediately rather
6438
- than silently falling back to wall-clock time.
6439
- """
6440
- override = os.environ.get("CCTALLY_AS_OF")
6441
- if override:
6442
- override = override.strip()
6443
- if override.endswith("Z"):
6444
- override = override[:-1] + "+00:00"
6445
- return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
6446
- return dt.datetime.now(dt.timezone.utc)
6447
-
6448
-
6449
5676
  def _load_week_snapshots(
6450
5677
  since: dt.datetime, until: dt.datetime
6451
5678
  ) -> dict[dt.datetime, float]: