cctally 1.116.1 → 1.117.2

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -183,6 +183,7 @@ from __future__ import annotations
183
183
  import argparse
184
184
  import dataclasses
185
185
  import datetime as dt
186
+ import hashlib
186
187
  import io
187
188
  import json
188
189
  import math
@@ -223,6 +224,7 @@ from _lib_display_tz import (
223
224
  _compute_display_block,
224
225
  )
225
226
  from _lib_aggregators import _aggregate_monthly, codex_path_scope
227
+ from _lib_json_envelope import _iso_z
226
228
  from _lib_fmt import _fmt_usd_accounting, stable_sum
227
229
  # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
228
230
  # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
@@ -230,6 +232,7 @@ from _lib_fmt import _fmt_usd_accounting, stable_sum
230
232
  # nothing on the default path.
231
233
  import _lib_perf as _perf
232
234
  import _lib_tick_stats as _tick_stats
235
+ import _lib_write_io
233
236
  import _lib_log
234
237
 
235
238
  import importlib.util as _ilu
@@ -295,7 +298,10 @@ from _lib_dashboard_sources import (
295
298
  aggregate_scope_failed,
296
299
  aggregate_scope_identity,
297
300
  build_aggregate_scope,
301
+ calibration_basis_digest,
302
+ calibration_supersession_digest,
298
303
  claude_stats_digest,
304
+ claude_weekly_retired_digest,
299
305
  codex_stats_digest,
300
306
  combined_accounting_version,
301
307
  compose_all_state,
@@ -909,6 +915,18 @@ class TuiCurrentWeek:
909
915
  # set. Appended last with a default so fixture modules that construct
910
916
  # `TuiCurrentWeek` positionally stay valid.
911
917
  total_tokens: int = 0
918
+ # #836 (spec 5.1): the displayed account, published as
919
+ # `current_week.account_key` / `account_label` ONLY when Claude is
920
+ # decorated (more than one real account, R8). Filled by the decorated
921
+ # path of `_tui_build_current_week`; the undecorated card leaves all four
922
+ # at their defaults. ``cycle_scope`` is that account's
923
+ # `ClaudeCycleScope`, the one cycle identity the weekly milestones and the
924
+ # scoped history index share with this card. Trailing defaults, for the
925
+ # positional fixture constructors.
926
+ account_key: "str | None" = None
927
+ account_decorated: bool = False
928
+ account_label: "str | None" = None
929
+ cycle_scope: "Any | None" = None
912
930
 
913
931
 
914
932
  # ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
@@ -954,10 +972,21 @@ class TuiPercentMilestone:
954
972
 
955
973
  def _tui_build_percent_milestones(
956
974
  conn: sqlite3.Connection,
975
+ *,
976
+ account_key: "str | None" = None,
977
+ cycle_scope: "Any | None" = None,
957
978
  ) -> list[TuiPercentMilestone]:
958
979
  """Return per-percent crossings for the current week's ACTIVE
959
980
  segment, ascending by percent.
960
981
 
982
+ #836 (spec 5.3): with ``account_key`` (a decorated store) the week, its
983
+ active segment and the milestone rows all come from ``cycle_scope`` — the
984
+ displayed account's `ClaudeCycleScope`, the identity the card was built
985
+ from — and only that account's rows are read. A decorated build with no
986
+ resolved scope publishes ``[]``, never a merged list. ``account_key=None``
987
+ is today's merged read below, unchanged, including legitimate
988
+ `unattributed` pre-attribution rows in the same week.
989
+
961
990
  Resolves `week_start_date` from the latest `weekly_usage_snapshots` row
962
991
  — the same path `cmd_percent_breakdown` takes. The post-override
963
992
  `TuiCurrentWeek.week_start_at` is NOT suitable here: after a mid-week
@@ -974,6 +1003,16 @@ def _tui_build_percent_milestones(
974
1003
  Returns [] if no usage snapshot exists, OR if the active segment
975
1004
  has no milestone rows yet (post-credit "fresh" state).
976
1005
  """
1006
+ if account_key is not None:
1007
+ if cycle_scope is None or cycle_scope.account_key != account_key:
1008
+ return []
1009
+ # The scope's segment IS the active one (no second lookup): it was
1010
+ # resolved through the same chokepoint, scoped and as-of bounded.
1011
+ return _tui_percent_milestone_rows([
1012
+ r for r in get_milestones_for_week(
1013
+ conn, cycle_scope.week_start_date, account_key=account_key)
1014
+ if int(r["reset_event_id"] or 0) == int(cycle_scope.reset_event_id)
1015
+ ])
977
1016
  latest = conn.execute(
978
1017
  "SELECT week_start_date, week_end_at FROM weekly_usage_snapshots "
979
1018
  "WHERE week_end_at IS NOT NULL "
@@ -1022,6 +1061,11 @@ def _tui_build_percent_milestones(
1022
1061
  r for r in get_milestones_for_week(conn, latest["week_start_date"])
1023
1062
  if int(r["reset_event_id"] or 0) == active_segment
1024
1063
  ]
1064
+ return _tui_percent_milestone_rows(rows)
1065
+
1066
+
1067
+ def _tui_percent_milestone_rows(rows) -> list[TuiPercentMilestone]:
1068
+ """`percent_milestones` rows as `TuiPercentMilestone`s, in row order."""
1025
1069
  out: list[TuiPercentMilestone] = []
1026
1070
  for r in rows:
1027
1071
  try:
@@ -1391,6 +1435,17 @@ class DataSnapshot:
1391
1435
  # ingest/read-model work. It remains trailing/defaulted for every legacy
1392
1436
  # positional fixture constructor.
1393
1437
  source_bundle: SourceDashboardBundle | None = None
1438
+ # #836 spec 5.7 item 2: the earliest instant after this build's as-of at
1439
+ # which evidence the build excluded by that as-of becomes eligible — a
1440
+ # weekly sample captured after it, a reset taking effect (or detected)
1441
+ # after it — or None (`_tui_evidence_eligible_at`). The clock is frozen
1442
+ # before the dispatch signature is read, so such a row is counted by the
1443
+ # signature yet excluded by the build; the idle gate refuses once a tick's
1444
+ # instant reaches this, and the Claude source version carries it, so the
1445
+ # tick that admits the evidence rebuilds and refuses source reuse.
1446
+ # Never published. Idle copies carry it unchanged. LAST, with a default,
1447
+ # for the positional constructors.
1448
+ evidence_eligible_at: dt.datetime | None = None
1394
1449
 
1395
1450
  @classmethod
1396
1451
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1670,6 +1725,138 @@ class RuntimeState:
1670
1725
  )
1671
1726
 
1672
1727
 
1728
+ def _tui_card_freshness(captured, now_utc: dt.datetime):
1729
+ """``(freshness_label, freshness_age)`` of the card's displayed sample,
1730
+ against the configured oauth_usage thresholds; ``(None, None)`` when the
1731
+ capture is not a datetime. Mirrors the dashboard envelope's cw_freshness
1732
+ derivation in `snapshot_to_envelope`. Refs spec §3.4."""
1733
+ if not isinstance(captured, dt.datetime):
1734
+ return None, None
1735
+ if captured.tzinfo is None:
1736
+ captured = captured.replace(tzinfo=dt.timezone.utc)
1737
+ age_s = max(0.0, (now_utc - captured).total_seconds())
1738
+ try:
1739
+ _fresh_cfg = _get_oauth_usage_config(load_config())
1740
+ except Exception:
1741
+ _fresh_cfg = _OAUTH_USAGE_DEFAULTS
1742
+ return _freshness_label(age_s, _fresh_cfg), int(age_s)
1743
+
1744
+
1745
+ def _tui_five_hour_resets_at(five_hour, now_utc: dt.datetime):
1746
+ """The five-hour reading's reset instant, or None when absent,
1747
+ unparseable or already elapsed (a stale reset would render as
1748
+ "resets 0h 00m")."""
1749
+ if five_hour is None or five_hour.resets_at is None:
1750
+ return None
1751
+ try:
1752
+ resets_at = parse_iso_datetime(
1753
+ five_hour.resets_at, "five_hour_resets_at")
1754
+ except ValueError:
1755
+ return None
1756
+ return None if resets_at <= now_utc else resets_at
1757
+
1758
+
1759
+ def _claude_current_cycle_scope(*args, **kwargs):
1760
+ return sys.modules["cctally"]._claude_current_cycle_scope(*args, **kwargs)
1761
+
1762
+
1763
+ def _claude_select_current_owner(*args, **kwargs):
1764
+ return sys.modules["cctally"]._claude_select_current_owner(*args, **kwargs)
1765
+
1766
+
1767
+ def _claude_cycle_scope_samples(*args, **kwargs):
1768
+ return sys.modules["cctally"]._claude_cycle_scope_samples(*args, **kwargs)
1769
+
1770
+
1771
+ def _tui_build_decorated_current_week(
1772
+ conn: sqlite3.Connection,
1773
+ now_utc: dt.datetime,
1774
+ *,
1775
+ skip_sync: bool = False,
1776
+ ) -> TuiCurrentWeek | None:
1777
+ """The Current Week card on a decorated store: ONE account (#836,
1778
+ spec 5.2).
1779
+
1780
+ The owner is the account of the newest non-held weekly sample at the
1781
+ as-of instant (`_claude_select_current_owner`), so a newer held row of
1782
+ another account cannot choose the window (D-C). Its cycle identity
1783
+ (`_claude_current_cycle_scope`) supplies the window, the active reset
1784
+ and the effective start, so another account's credit cannot re-anchor it
1785
+ (D-A). Samples, the five-hour reading, the block and the spend are all the
1786
+ owner's (U3, D-B): ``$/1%`` divides the owner's spend by the owner's
1787
+ percentage. When the owner's cycle has no eligible non-held sample (its
1788
+ own reset postdates the chosen one) the card is None; a decorated build
1789
+ never falls back to the merged population. `_apply_midweek_reset_override`
1790
+ is not called here.
1791
+ """
1792
+ owner = _claude_select_current_owner(conn, now_utc)
1793
+ if owner is None:
1794
+ return None
1795
+ scope = _claude_current_cycle_scope(conn, now_utc, account_key=owner)
1796
+ if scope is None:
1797
+ return None
1798
+ fetched = _claude_cycle_scope_samples(conn, scope, now_utc)
1799
+ if fetched is None:
1800
+ return None
1801
+ week_start_at, week_end_at, observations, week_scope = fetched
1802
+ week_start_before_override = week_start_at
1803
+ if scope.effective_start_at is not None:
1804
+ # The scope's effective start may also PRECEDE the nominal start (a
1805
+ # same-date early re-anchor, #836 review F7): the card's cycle starts
1806
+ # there either way, as its current index entry does.
1807
+ effective = parse_iso_datetime(
1808
+ scope.effective_start_at, "cycle_scope.effective_start_at")
1809
+ if effective != week_start_at:
1810
+ week_start_at = effective
1811
+ observations = [s for s in observations if s[0] >= effective]
1812
+ weekly_samples = [s for s in observations if not s[3]]
1813
+ if not weekly_samples:
1814
+ return None
1815
+ latest = weekly_samples[-1]
1816
+ used_pct = float(latest[1])
1817
+ five_hour = _current_week_five_hour_reading(
1818
+ conn, now_utc, week_scope, account_key=owner,
1819
+ week_start_at=week_start_at,
1820
+ week_start_before_override=week_start_before_override)
1821
+ spent, total_tokens = _sum_cost_and_tokens_for_range(
1822
+ week_start_at, now_utc, mode="auto", skip_sync=skip_sync,
1823
+ account_key=owner,
1824
+ )
1825
+ # The same corrected divisor as the merged card (#661 S2 section 3.4).
1826
+ used_pct_corrected = corrected_percent_point(used_pct)
1827
+ dpp_divisor = used_pct if used_pct_corrected is None else used_pct_corrected
1828
+ dpp = (spent / dpp_divisor) if used_pct > 0 else None
1829
+ freshness_label, freshness_age = _tui_card_freshness(latest[0], now_utc)
1830
+ import _cctally_account
1831
+ return TuiCurrentWeek(
1832
+ week_start_at=week_start_at,
1833
+ week_end_at=week_end_at,
1834
+ used_pct=used_pct,
1835
+ five_hour_pct=None if five_hour is None else five_hour.percent,
1836
+ five_hour_resets_at=_tui_five_hour_resets_at(five_hour, now_utc),
1837
+ spent_usd=float(spent),
1838
+ dollars_per_percent=dpp,
1839
+ latest_snapshot_at=latest[0],
1840
+ freshness_label=freshness_label,
1841
+ freshness_age=freshness_age,
1842
+ five_hour_block=_select_current_block_for_envelope(
1843
+ conn, current_used_pct=used_pct, now_utc=now_utc,
1844
+ account_key=owner,
1845
+ ),
1846
+ total_tokens=total_tokens,
1847
+ account_key=owner,
1848
+ account_decorated=True,
1849
+ account_label=_cctally_account.display_account_label(conn, owner),
1850
+ cycle_scope=scope,
1851
+ )
1852
+
1853
+
1854
+ def _decorated_claude(conn: sqlite3.Connection) -> bool:
1855
+ """The R8 decoration gate for Claude (`provider_is_decorated`)."""
1856
+ import _cctally_account
1857
+ return _cctally_account.provider_is_decorated(conn, "claude")
1858
+
1859
+
1673
1860
  def _tui_build_current_week(
1674
1861
  conn: sqlite3.Connection,
1675
1862
  now_utc: dt.datetime,
@@ -1679,7 +1866,15 @@ def _tui_build_current_week(
1679
1866
  """Build the TuiCurrentWeek from the latest snapshot + live cost.
1680
1867
 
1681
1868
  Returns None when no current-week usage snapshot exists.
1869
+
1870
+ #836: a decorated store (more than one real Claude account, R8) takes
1871
+ `_tui_build_decorated_current_week`, the one-account card. Below the
1872
+ threshold the merged card below is unchanged.
1682
1873
  """
1874
+ import _cctally_account
1875
+ if _cctally_account.provider_is_decorated(conn, "claude"):
1876
+ return _tui_build_decorated_current_week(
1877
+ conn, now_utc, skip_sync=skip_sync)
1683
1878
  # #769 S11 (#824): ONE read, held-inclusive, split per axis below.
1684
1879
  # Retain the owner of the exact non-held weekly sample this card displays,
1685
1880
  # including when one real account coexists with unattributed history. R8
@@ -1755,35 +1950,11 @@ def _tui_build_current_week(
1755
1950
  # percent and the reset together from one row), so the countdown never
1756
1951
  # describes another row. That reading is held-inclusive (#769 S11, #824),
1757
1952
  # because a held row's five-hour fields are the writing tick's own.
1758
- five_hr_resets_at: dt.datetime | None = None
1759
- if five_hour is not None and five_hour.resets_at is not None:
1760
- try:
1761
- five_hr_resets_at = parse_iso_datetime(
1762
- five_hour.resets_at, "five_hour_resets_at"
1763
- )
1764
- except ValueError:
1765
- five_hr_resets_at = None
1766
- # Suppress stale resets that have already elapsed so renderers
1767
- # don't show "resets 0h 00m" or a negative duration at the boundary.
1768
- if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
1769
- five_hr_resets_at = None
1770
- # Freshness — compute label/age from latest snapshot vs. now using the
1771
- # configured oauth_usage thresholds. Mirrors the dashboard envelope's
1772
- # cw_freshness derivation in `snapshot_to_envelope`. Refs spec §3.4.
1773
- captured = latest[0]
1774
- if isinstance(captured, dt.datetime):
1775
- if captured.tzinfo is None:
1776
- captured = captured.replace(tzinfo=dt.timezone.utc)
1777
- age_s = max(0.0, (now_utc - captured).total_seconds())
1778
- try:
1779
- _fresh_cfg = _get_oauth_usage_config(load_config())
1780
- except Exception:
1781
- _fresh_cfg = _OAUTH_USAGE_DEFAULTS
1782
- freshness_label = _freshness_label(age_s, _fresh_cfg)
1783
- freshness_age = int(age_s)
1784
- else:
1785
- freshness_label = None
1786
- freshness_age = None
1953
+ # Suppress stale resets that have already elapsed so renderers don't
1954
+ # show "resets 0h 00m" or a negative duration at the boundary.
1955
+ five_hr_resets_at = _tui_five_hour_resets_at(five_hour, now_utc)
1956
+ # Freshness — label/age from the displayed sample vs. now.
1957
+ freshness_label, freshness_age = _tui_card_freshness(latest[0], now_utc)
1787
1958
  return TuiCurrentWeek(
1788
1959
  week_start_at=week_start_at,
1789
1960
  week_end_at=week_end_at,
@@ -2424,7 +2595,72 @@ def _snapshot_data_version(sig) -> str:
2424
2595
  # moves no numeric leg above, so without this the detail endpoints' change
2425
2596
  # signal stays flat across a tick that added an alert row.
2426
2597
  claude_digest = getattr(sig, "claude_stats_digest", "")
2427
- return out if not claude_digest else f"{out}.x{claude_digest}"
2598
+ out = out if not claude_digest else f"{out}.x{claude_digest}"
2599
+ # #836 spec 5.7 item 1: the Claude weekly rejection state. Empty on a store
2600
+ # with no retired snapshot and no unavailable five-hour join, so it is
2601
+ # byte-neutral there.
2602
+ retired = getattr(sig, "claude_weekly_retired_digest", "")
2603
+ return out if not retired else f"{out}.r{retired}"
2604
+
2605
+
2606
+ def _tui_evidence_eligible_at(conn, as_of_utc: dt.datetime) -> "dt.datetime | None":
2607
+ """The earliest instant after ``as_of_utc`` at which evidence a build at
2608
+ that as-of excluded becomes eligible, or None (#836, spec 5.7 item 2).
2609
+
2610
+ The candidates are the rows a later instant's build could admit:
2611
+
2612
+ * weekly snapshots captured after ``as_of_utc`` whose `week_start_date` is
2613
+ at or after the as-of date minus eight days — every boundaryful window
2614
+ that can contain the as-of instant (including another account's older
2615
+ overlapping window), the date-only leg, a later window and the
2616
+ no-current-card case. That leading range is an `idx_usage_week_time`
2617
+ range, never a whole-table scan; held rows are included, since the
2618
+ five-hour reading reads them;
2619
+ * `week_reset_events` rows taking effect after it (the decorated cycle
2620
+ admits a reset by its effective instant) or detected after it (the
2621
+ merged override admits one by its detection instant).
2622
+
2623
+ Instants are compared with `unixepoch`, never lexically: the columns carry
2624
+ mixed offset spellings. A far-future row (a clock error) only postpones
2625
+ the one rebuild; it never disables the idle path.
2626
+ """
2627
+ as_of = as_of_utc.astimezone(dt.timezone.utc)
2628
+ as_of_iso = _iso_z(as_of)
2629
+ floor_date = (as_of.date() - dt.timedelta(days=8)).isoformat()
2630
+ candidates: list[str] = [
2631
+ row[0] for row in conn.execute(
2632
+ "SELECT captured_at_utc FROM weekly_usage_snapshots "
2633
+ "WHERE week_start_date >= ? "
2634
+ "AND unixepoch(captured_at_utc) > unixepoch(?)",
2635
+ (floor_date, as_of_iso)).fetchall()
2636
+ ]
2637
+ try:
2638
+ for effective, detected in conn.execute(
2639
+ "SELECT effective_reset_at_utc, detected_at_utc "
2640
+ "FROM week_reset_events "
2641
+ "WHERE unixepoch(effective_reset_at_utc) > unixepoch(?) "
2642
+ " OR unixepoch(detected_at_utc) > unixepoch(?)",
2643
+ (as_of_iso, as_of_iso)).fetchall():
2644
+ candidates.extend(v for v in (effective, detected) if v)
2645
+ except sqlite3.OperationalError as exc:
2646
+ if "no such table" not in str(exc).lower():
2647
+ raise
2648
+ eligible: "dt.datetime | None" = None
2649
+ for text in candidates:
2650
+ try:
2651
+ instant = parse_iso_datetime(text, "evidence_eligible_at")
2652
+ except (TypeError, ValueError):
2653
+ continue
2654
+ if instant > as_of and (eligible is None or instant < eligible):
2655
+ eligible = instant
2656
+ return eligible
2657
+
2658
+
2659
+ def _tui_evidence_matured(prior, now_utc: dt.datetime) -> bool:
2660
+ """Has evidence ``prior`` excluded by its as-of become eligible at
2661
+ ``now_utc``? (#836, spec 5.7 item 2: the idle gate refuses then.)"""
2662
+ eligible = getattr(prior, "evidence_eligible_at", None)
2663
+ return eligible is not None and now_utc >= eligible
2428
2664
 
2429
2665
 
2430
2666
  def _tui_source_copy(value: object) -> object:
@@ -2487,6 +2723,35 @@ def alert_row_owner(
2487
2723
  raise ValueError(f"no ownership rule for alert axis {axis!r}")
2488
2724
 
2489
2725
 
2726
+ def _tui_claude_owner_version_fragment(claude_data: object) -> str:
2727
+ """The decorated Claude source-version fragment (#836 spec 5.7), or "".
2728
+
2729
+ A digest of the displayed account, its label and the published scoped
2730
+ history index's identity (each entry's key, detail stamp and current
2731
+ flag), read from the already-projected source data. Only a decorated
2732
+ build publishes ``current_week.account_key``, so every undecorated build
2733
+ returns "" and its version bytes stay as they were.
2734
+ """
2735
+ hero = claude_data.get("hero") if isinstance(claude_data, dict) else None
2736
+ cw = hero.get("current_week") if isinstance(hero, dict) else None
2737
+ if not isinstance(cw, dict):
2738
+ return ""
2739
+ owner = cw.get("account_key")
2740
+ if not isinstance(owner, str) or not owner:
2741
+ return ""
2742
+ index = cw.get("week_index")
2743
+ entries = [
2744
+ (e.get("key"), e.get("detail_stamp"), bool(e.get("is_current")))
2745
+ for e in (index if isinstance(index, list) else ())
2746
+ if isinstance(e, dict)
2747
+ ]
2748
+ material = json.dumps(
2749
+ [owner, cw.get("account_label"), entries],
2750
+ sort_keys=True, separators=(",", ":"), ensure_ascii=True,
2751
+ )
2752
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
2753
+
2754
+
2490
2755
  def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
2491
2756
  """Project one completed Claude legacy envelope without further DB reads.
2492
2757
 
@@ -3840,9 +4105,14 @@ def _tui_build_source_bundle(
3840
4105
  prior_bundle: SourceDashboardBundle | None = None,
3841
4106
  raw_config: dict[str, object] | None = None,
3842
4107
  attempt: dict[str, bool] | None = None,
4108
+ evidence_eligible_at: dt.datetime | None = None,
3843
4109
  ) -> SourceDashboardBundle:
3844
4110
  """Build one frozen source bundle after the dashboard's coordinated ingest.
3845
4111
 
4112
+ ``evidence_eligible_at`` (#836, spec 5.7 item 2) is the build's
4113
+ `DataSnapshot.evidence_eligible_at`; it enters the Claude version only
4114
+ when set.
4115
+
3846
4116
  This helper is reachable only from ``precompute_envelope=True``. It opens
3847
4117
  a fresh cache handle after the ingest handle has closed, then performs
3848
4118
  read-only provider adaptation with no implicit sync or rollout fallback.
@@ -3980,6 +4250,9 @@ def _tui_build_source_bundle(
3980
4250
  # rare event and a rebuild is safe, so per-provider narrowing is not worth
3981
4251
  # the split.
3982
4252
  accounts_digest = accounts_identity_digest(stats_conn)
4253
+ # #836 spec 5.7 item 1: the Claude weekly rejection state; "" (and so
4254
+ # byte-neutral) on a store with nothing retired or unavailable.
4255
+ retired_digest = claude_weekly_retired_digest(stats_conn)
3983
4256
  with _perf.phase("source.signature"):
3984
4257
  signature = c.compute_signature(
3985
4258
  cache_conn,
@@ -3988,6 +4261,7 @@ def _tui_build_source_bundle(
3988
4261
  codex_stats_digest=stats_digest,
3989
4262
  accounts_digest=accounts_digest,
3990
4263
  claude_stats_digest=claude_digest,
4264
+ claude_weekly_retired_digest=retired_digest,
3991
4265
  )
3992
4266
  # #857: the Codex quota-dependency identity, read on the SAME pinned
3993
4267
  # cache snapshot the Codex capture below reads, so a generation built
@@ -4055,6 +4329,23 @@ def _tui_build_source_bundle(
4055
4329
  f":p{_period_identity}{_budget_suffix}{_acct_suffix}"
4056
4330
  f"{_aggregate_suffix}:x{claude_digest}"
4057
4331
  )
4332
+ # #836 spec 5.7: exact-version reuse compares this string, so a
4333
+ # retirement or restoration (`:r`) and a tick that admits evidence an
4334
+ # earlier one excluded (`:e`) both refuse the prior Claude object. Each
4335
+ # is appended only when set, so other stores keep their version bytes.
4336
+ if retired_digest:
4337
+ claude_version = f"{claude_version}:r{retired_digest}"
4338
+ if evidence_eligible_at is not None:
4339
+ claude_version = f"{claude_version}:e{_iso_z(evidence_eligible_at)}"
4340
+ # #836 spec 5.7: the decorated-only fragment. A decorated Claude source
4341
+ # publishes ONE account's card and that account's history index, and
4342
+ # exact-version reuse compares this string, so the owner, its label
4343
+ # and the published index identity (keys, stamps, current flag) enter
4344
+ # it. Undecorated envelopes carry no `account_key`, so their version
4345
+ # bytes are unchanged.
4346
+ _owner_fragment = _tui_claude_owner_version_fragment(claude_data)
4347
+ if _owner_fragment:
4348
+ claude_version = f"{claude_version}:o{_owner_fragment}"
4058
4349
  prior_claude = (
4059
4350
  prior_bundle.sources.get("claude")
4060
4351
  if prior_bundle is not None else None
@@ -5167,6 +5458,9 @@ def _tui_build_snapshot_once(
5167
5458
  # #857: the Codex quota-dependency identity, read on the
5168
5459
  # signature's own cache handle. An unreadable identity is `None`.
5169
5460
  codex_dependency = None
5461
+ # #1015 spec 5.4: the calibration dependency, read on this tick's
5462
+ # own stats connection (`_tui_read_calibration_dependency`).
5463
+ calibration_dependency = None
5170
5464
  with _perf.phase("signature"):
5171
5465
  try:
5172
5466
  dispatch_sig, codex_dependency = (
@@ -5174,6 +5468,8 @@ def _tui_build_snapshot_once(
5174
5468
  conn, with_codex_dependency=True,
5175
5469
  )
5176
5470
  )
5471
+ calibration_dependency = (
5472
+ _tui_read_calibration_dependency(conn))
5177
5473
  except QuotaProjectionIncomplete as exc:
5178
5474
  # #496 S5b §4.7: ahead of the generic handler on purpose.
5179
5475
  # Below it the refusal is sanitized into a generic
@@ -5209,7 +5505,16 @@ def _tui_build_snapshot_once(
5209
5505
  # bytes and the signature's own contract are unchanged. `None`
5210
5506
  # (an unreadable identity) is a value like any other here; the
5211
5507
  # idle gate is what refuses to idle any Codex generation on it.
5212
- dispatch_key = (dispatch_sig, render_key, codex_dependency)
5508
+ # #1015 spec 5.4: the calibration dependency — the sidecar's
5509
+ # file identity and the correction-lineage and supersession
5510
+ # digests — rides the key on the same terms, so a refit, a
5511
+ # recorded attempt, a completed correction batch or a
5512
+ # supersession leaves the idle path and rebuilds the forecast
5513
+ # and the rate-change array on the next tick.
5514
+ dispatch_key = (
5515
+ dispatch_sig, render_key, codex_dependency,
5516
+ calibration_dependency,
5517
+ )
5213
5518
  # #300: carry the change signal onto the non-idle snapshot built
5214
5519
  # below. (The idle path returns `idle_snap`, which inherits the
5215
5520
  # prior — equal, since idle ⇒ signature unchanged — value.)
@@ -5223,7 +5528,10 @@ def _tui_build_snapshot_once(
5223
5528
  )
5224
5529
  )
5225
5530
  and not _snapshot_period_rolled_over(
5226
- prior_snap, now_utc, _build_display_tz)):
5531
+ prior_snap, now_utc, _build_display_tz)
5532
+ # #836 spec 5.7 item 2: evidence the prior build
5533
+ # excluded by its as-of is eligible now.
5534
+ and not _tui_evidence_matured(prior_snap, now_utc)):
5227
5535
  with _perf.phase("idle-decision"):
5228
5536
  idle_snap = _tui_build_idle_snapshot(
5229
5537
  prior_snap, now_utc=now_utc,
@@ -5388,7 +5696,14 @@ def _tui_build_snapshot_once(
5388
5696
  with _perf.phase("build.milestones"):
5389
5697
  try:
5390
5698
  if cw is not None:
5391
- milestones = _tui_build_percent_milestones(conn)
5699
+ # #836 (spec 5.3): a decorated card's milestones are its
5700
+ # owner's, from the card's own cycle identity.
5701
+ if getattr(cw, "account_decorated", False):
5702
+ milestones = _tui_build_percent_milestones(
5703
+ conn, account_key=cw.account_key,
5704
+ cycle_scope=cw.cycle_scope)
5705
+ else:
5706
+ milestones = _tui_build_percent_milestones(conn)
5392
5707
  except Exception as exc:
5393
5708
  capture_failure("milestones", "stats", exc)
5394
5709
  history: list = []
@@ -5533,12 +5848,17 @@ def _tui_build_snapshot_once(
5533
5848
  # no store at all. An unreadable file yields no marker,
5534
5849
  # which is the same thing an absent one yields, because the
5535
5850
  # reader cannot tell them apart without scanning sidecars.
5536
- _mrc = _cctally()._load_sibling("_lib_meter_rate_change")
5851
+ #
5852
+ # #1015 spec 5.6: over the CLEAN regimes, on this tick's own
5853
+ # stats connection; a superseded transition or unverifiable
5854
+ # correction facts show no marker.
5855
+ _qcal = _cctally()._load_sibling("_cctally_quota_calibration")
5537
5856
  _qg = _cctally()._load_sibling("_cctally_quota_model")
5538
5857
  _state = _qg.read_stored_state_readonly()
5539
5858
  if _state is not None:
5540
- quota_rate_change = _mrc.active_rate_change(
5541
- _qg.stored_regimes(_state, None))
5859
+ quota_rate_change = _qcal.clean_active_rate_change(
5860
+ None, regimes=_qg.stored_regimes(_state, None),
5861
+ stats_conn=conn)
5542
5862
  except Exception as exc:
5543
5863
  capture_failure("quota_rate_change", "calibration", exc)
5544
5864
  # ---- 5h in-place credit (v1.7.x) ----
@@ -5575,7 +5895,22 @@ def _tui_build_snapshot_once(
5575
5895
  week_index: list[dict] = []
5576
5896
  with _perf.phase("build.week_index"):
5577
5897
  try:
5578
- week_index = sys.modules["cctally"].build_claude_week_index(conn)
5898
+ # #836 spec 5.5 (U1): a decorated card names its account and
5899
+ # its cycle, and the index is that account's history derived
5900
+ # with the SAME scope, so the current entry is the card's
5901
+ # cycle. Undecorated: today's merged index. A decorated store
5902
+ # with no card publishes no index (it rides `current_week`),
5903
+ # so the merged one is not built for it (review F9); the
5904
+ # extra predicate runs only when there is no card.
5905
+ if cw is not None and getattr(cw, "account_decorated", False):
5906
+ week_index = sys.modules["cctally"].build_claude_week_index(
5907
+ conn, account_key=cw.account_key,
5908
+ cycle_scope=cw.cycle_scope)
5909
+ elif cw is None and _decorated_claude(conn):
5910
+ week_index = []
5911
+ else:
5912
+ week_index = sys.modules["cctally"].build_claude_week_index(
5913
+ conn)
5579
5914
  except Exception as exc:
5580
5915
  capture_failure("week-index", "stats", exc)
5581
5916
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
@@ -5762,6 +6097,16 @@ def _tui_build_snapshot_once(
5762
6097
  daily_panel, now_utc=now_utc, display_tz=_build_display_tz,
5763
6098
  )
5764
6099
 
6100
+ # #836 spec 5.7 item 2: recorded on every dashboard (non-idle) build;
6101
+ # the idle gate and the Claude source version read it.
6102
+ evidence_eligible_at: dt.datetime | None = None
6103
+ if precompute_envelope:
6104
+ with _perf.phase("build.evidence_eligibility"):
6105
+ try:
6106
+ evidence_eligible_at = _tui_evidence_eligible_at(
6107
+ conn, now_utc)
6108
+ except Exception as exc:
6109
+ capture_failure("evidence-eligibility", "stats", exc)
5765
6110
  snap = DataSnapshot(
5766
6111
  current_week=cw,
5767
6112
  forecast=fc,
@@ -5799,6 +6144,7 @@ def _tui_build_snapshot_once(
5799
6144
  envelope_precompute=envelope_precompute_block,
5800
6145
  data_version=data_version,
5801
6146
  source_bundle=None,
6147
+ evidence_eligible_at=evidence_eligible_at,
5802
6148
  )
5803
6149
  if precompute_envelope:
5804
6150
  source_bundle: SourceDashboardBundle | None = None
@@ -5850,6 +6196,7 @@ def _tui_build_snapshot_once(
5850
6196
  projects_envelope=projects_envelope_block,
5851
6197
  prior_bundle=prior_source_bundle,
5852
6198
  raw_config=raw_config,
6199
+ evidence_eligible_at=evidence_eligible_at,
5853
6200
  )
5854
6201
  if source_bundle is None:
5855
6202
  raise RuntimeError("source bundle builder returned no bundle")
@@ -6105,6 +6452,8 @@ def _tui_compute_dispatch_signature(stats_conn, *, with_codex_dependency=False):
6105
6452
  codex_stats_digest=codex_stats_digest(stats_conn),
6106
6453
  accounts_digest=accounts_identity_digest(stats_conn),
6107
6454
  claude_stats_digest=claude_stats_digest(stats_conn),
6455
+ claude_weekly_retired_digest=(
6456
+ claude_weekly_retired_digest(stats_conn)),
6108
6457
  )
6109
6458
  if not with_codex_dependency:
6110
6459
  return signature
@@ -6115,6 +6464,41 @@ def _tui_compute_dispatch_signature(stats_conn, *, with_codex_dependency=False):
6115
6464
  cache_conn.close()
6116
6465
 
6117
6466
 
6467
+ def _tui_read_calibration_dependency(stats_conn) -> tuple:
6468
+ """The dispatch key's `calibration_dependency` component (#1015 spec 5.4).
6469
+
6470
+ A tuple of the calibration sidecar's file identity
6471
+ (``(st_ino, st_size, st_mtime_ns)``, or None when it is absent), the
6472
+ correction-lineage digest (completed batches, their footprints, and the
6473
+ reviewed decision rows with their op coordinates) and the supersession
6474
+ digest (the explicit rows and the transitions' frozen lineage).
6475
+
6476
+ What the forecast refusal and the rate-change array's supersession label
6477
+ depend on, and what no dispatch-signature leg reads: a sweep that only
6478
+ rewrites the sidecar (a refit, an annotation, a recorded attempt) moves
6479
+ the first leg, a correction batch moves the second. Both digests go
6480
+ through `_stats_relations_digest` on ``stats_conn``, the connection the
6481
+ tick already holds, each under its own memo namespace, so an unchanged,
6482
+ settled stats file costs no relation scan and no extra connection, and
6483
+ the sidecar leg is one ``stat()``. A sidecar that cannot be ``stat``-ed
6484
+ for another reason is its own stable value: the reader treats it as no
6485
+ calibration on every tick, so there is nothing to rebuild for.
6486
+ """
6487
+ qmg = _cctally()._load_sibling("_cctally_quota_model")
6488
+ try:
6489
+ st = os.stat(qmg.calibration_path())
6490
+ sidecar = (st.st_ino, st.st_size, st.st_mtime_ns)
6491
+ except FileNotFoundError:
6492
+ sidecar = None
6493
+ except OSError as exc:
6494
+ sidecar = ("unreadable", exc.errno)
6495
+ return (
6496
+ sidecar,
6497
+ calibration_basis_digest(stats_conn),
6498
+ calibration_supersession_digest(stats_conn),
6499
+ )
6500
+
6501
+
6118
6502
  def _snapshot_period_rolled_over(prior, now_utc, display_tz):
6119
6503
  """True if a day/week/month boundary was crossed since ``prior`` was built.
6120
6504
 
@@ -6311,6 +6695,8 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
6311
6695
  prior_bundle=source_bundle,
6312
6696
  raw_config=raw_config,
6313
6697
  attempt=source_attempt,
6698
+ evidence_eligible_at=getattr(
6699
+ prior, "evidence_eligible_at", None),
6314
6700
  )
6315
6701
  except _StatsSnapshotCorruption:
6316
6702
  raise
@@ -8944,10 +9330,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
8944
9330
  claude_roots = tuple(
8945
9331
  _cctally_core._resolve_claude_projects_dirs()
8946
9332
  )
8947
- codex_roots = tuple(
8948
- root / "sessions"
8949
- for root in _cctally()._codex_home_roots()
8950
- )
9333
+ # #970: native-home recognition (sessions and archive
9334
+ # locations), the same roots the walk uses.
9335
+ codex_roots = frontier_mod.codex_frontier_roots(
9336
+ _cctally()._codex_home_roots())
8951
9337
  claude_guards = (_cctally_core.CLAUDE_SETTINGS_PATH,)
8952
9338
  try:
8953
9339
  codex_hooks_mod = _cctally()._load_sibling("_lib_codex_hooks")
@@ -8991,6 +9377,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
8991
9377
  return codex_hooks_mod.codex_hook_roots_all_enabled(hook_roots)
8992
9378
  cache_conn = None
8993
9379
  publish_prior = False
9380
+ accounting_recorded = False
8994
9381
  initial_cache_identity = None
8995
9382
  try:
8996
9383
  # The standalone ingest tree is frozen before the final
@@ -9100,7 +9487,22 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
9100
9487
  return result
9101
9488
 
9102
9489
  def _finalize_frontier(active_conn):
9103
- nonlocal publish_prior
9490
+ nonlocal publish_prior, accounting_recorded
9491
+ # #939 D5: this tick's accounting census and residual,
9492
+ # computed while each provider's mode, plan and result
9493
+ # are known, and reported once the frontier is final.
9494
+ census = {}
9495
+ residuals = {}
9496
+ for provider in ("claude", "codex"):
9497
+ mode = effective_modes[provider]
9498
+ stats = result_by_provider.get(provider)
9499
+ census[provider] = (
9500
+ frontier_mod.accounting_census_complete(
9501
+ mode, stats,
9502
+ expected_paths=len(plans[provider].paths)))
9503
+ residuals[provider] = (
9504
+ None if mode == "caught_up"
9505
+ else frontier_mod.accounting_residual(stats))
9104
9506
  for provider, roots, guards in (
9105
9507
  ("claude", claude_roots, claude_guards),
9106
9508
  ("codex", codex_roots, codex_guards),
@@ -9132,6 +9534,11 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
9132
9534
  trusted=trusted_results[provider],
9133
9535
  cutoff=cutoff,
9134
9536
  )
9537
+ _lib_write_io.observe_accounting_tick(
9538
+ ended_ns=_time.monotonic_ns(),
9539
+ census_complete=all(census.values()),
9540
+ residuals=residuals)
9541
+ accounting_recorded = True
9135
9542
  return None
9136
9543
 
9137
9544
  operations = (
@@ -9173,6 +9580,12 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
9173
9580
  finally:
9174
9581
  if cache_conn is not None:
9175
9582
  cache_conn.close()
9583
+ if not accounting_recorded:
9584
+ # #939 D5: a tick that never finalized did not cover
9585
+ # its census, so no conversation pass counts after it.
9586
+ _lib_write_io.observe_accounting_tick(
9587
+ ended_ns=_time.monotonic_ns(),
9588
+ census_complete=False, residuals={})
9176
9589
  _ingest.__exit__(None, None, None)
9177
9590
  _p_ingest.__exit__(None, None, None)
9178
9591
  _perf.stash_last_ingest(