cctally 1.100.0 → 1.102.0

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 (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -221,7 +221,7 @@ from _lib_display_tz import (
221
221
  normalize_display_tz_value,
222
222
  _compute_display_block,
223
223
  )
224
- from _lib_aggregators import _aggregate_monthly
224
+ from _lib_aggregators import _aggregate_monthly, codex_path_scope
225
225
  from _lib_fmt import stable_sum
226
226
  # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
227
227
  # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
@@ -268,7 +268,9 @@ from _cctally_dashboard_sources import (
268
268
  _claude_accounts_wire,
269
269
  _refresh_budget_status_clock,
270
270
  accounts_identity_digest,
271
+ build_codex_source_state_from_capture,
271
272
  build_codex_source_state,
273
+ capture_codex_source_state,
272
274
  codex_decision_deadline_passed,
273
275
  refresh_codex_source_clock,
274
276
  resolve_dashboard_source_semantics,
@@ -290,6 +292,7 @@ from _lib_dashboard_sources import (
290
292
  build_aggregate_scope,
291
293
  claude_stats_digest,
292
294
  codex_stats_digest,
295
+ combined_accounting_version,
293
296
  compose_all_state,
294
297
  dashboard_resource_key,
295
298
  degrade_source_state,
@@ -2839,7 +2842,14 @@ def _tui_with_account_scope(
2839
2842
  _AGGREGATE_FOLD_FAILED = {"state": "failed", "code": "claude_fold_failed"}
2840
2843
 
2841
2844
 
2842
- def _tui_build_claude_aggregates(
2845
+ @dataclass(frozen=True)
2846
+ class _ClaudeAggregateCapture:
2847
+ optimized: object | None = None
2848
+ rows: tuple | None = None
2849
+ read_failed: bool = False
2850
+
2851
+
2852
+ def _tui_capture_claude_aggregates(
2843
2853
  cache_conn,
2844
2854
  *,
2845
2855
  shared_start: dt.datetime,
@@ -2855,15 +2865,65 @@ def _tui_build_claude_aggregates(
2855
2865
  entry_mutation_seq: "int | None" = None,
2856
2866
  generation: int = 0,
2857
2867
  ):
2858
- """Both All-only Claude legs, from ONE candidate read (spec §3.3, §3.4).
2868
+ """Capture the one-snapshot Claude inputs without folding them."""
2869
+ from zoneinfo import ZoneInfo
2870
+
2871
+ c = _cctally()
2872
+ display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
2873
+ dashboard_module = sys.modules["_cctally_dashboard"]
2874
+ cache_seams_unpatched = (
2875
+ c.build_project_aggregate_rows
2876
+ is dashboard_module.build_project_aggregate_rows
2877
+ and c.build_daily_aggregate_rows
2878
+ is dashboard_module.build_daily_aggregate_rows
2879
+ )
2880
+ if legacy_labels is not None and cache_seams_unpatched:
2881
+ try:
2882
+ return _ClaudeAggregateCapture(
2883
+ optimized=c.capture_cached_claude_range_aggregates(
2884
+ cache_conn,
2885
+ shared_start=shared_start,
2886
+ shared_end_exclusive=shared_end_exclusive,
2887
+ display_tz=display_tz,
2888
+ max_entry_id=max_entry_id,
2889
+ entry_mutation_seq=entry_mutation_seq,
2890
+ generation=generation,
2891
+ ),
2892
+ )
2893
+ except Exception:
2894
+ _lib_log.get_logger("dashboard").error(
2895
+ "claude range aggregate capture failed", exc_info=True,
2896
+ )
2897
+ try:
2898
+ rows = tuple(c.iter_shared_range_entries(
2899
+ cache_conn, start=shared_start,
2900
+ end_exclusive=shared_end_exclusive,
2901
+ ))
2902
+ except Exception:
2903
+ _lib_log.get_logger("dashboard").error(
2904
+ "claude shared-range candidate read failed", exc_info=True,
2905
+ )
2906
+ return _ClaudeAggregateCapture(read_failed=True)
2907
+ return _ClaudeAggregateCapture(rows=rows)
2908
+
2909
+
2910
+ def _tui_build_claude_aggregates_from_capture(
2911
+ capture: _ClaudeAggregateCapture,
2912
+ *,
2913
+ now_utc: dt.datetime,
2914
+ display_tz_name: str | None,
2915
+ legacy_labels: "dict[str, str] | None" = None,
2916
+ ):
2917
+ """Build both All-only Claude legs after the cache pin closes.
2859
2918
 
2860
2919
  Returns ``(payload, outcomes)`` where ``payload`` holds the published rows
2861
2920
  for whichever legs succeeded and ``outcomes`` names each leg's state.
2862
2921
 
2863
- Runs on the caller's PINNED cache connection, beside the Codex read and on
2864
- the same snapshot. Both legacy paths stay untouched: the attached-cache
2865
- block continues to serve ``env.projects`` and the Group-A read continues to
2866
- serve ``env.daily``, for the Claude tab.
2922
+ The capture came from the caller's pinned connection beside the Codex read;
2923
+ this half consumes only that immutable carrier after rollback. Both legacy
2924
+ paths stay untouched: the attached-cache block continues to serve
2925
+ ``env.projects`` and the Group-A read continues to serve ``env.daily`` for
2926
+ the Claude tab.
2867
2927
 
2868
2928
  Each fold has its OWN error boundary, so one failure cannot take the bundle
2869
2929
  or the other leg down. A failure of the shared read itself fails both, since
@@ -2872,48 +2932,41 @@ def _tui_build_claude_aggregates(
2872
2932
  outer handler, which publishes the prior bundle or none at all, so a
2873
2933
  cold-start fold failure could never become the outcome §3.7 promises.
2874
2934
  """
2875
- from zoneinfo import ZoneInfo
2876
-
2877
2935
  c = _cctally()
2936
+ from zoneinfo import ZoneInfo
2878
2937
  display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
2879
2938
  payload: dict[str, object] = {}
2880
2939
  outcomes: dict[str, object] = {
2881
2940
  "projects": {"state": "ok"}, "daily": {"state": "ok"},
2882
2941
  }
2883
- dashboard_module = sys.modules["_cctally_dashboard"]
2884
- cache_seams_unpatched = (
2885
- c.build_project_aggregate_rows
2886
- is dashboard_module.build_project_aggregate_rows
2887
- and c.build_daily_aggregate_rows
2888
- is dashboard_module.build_daily_aggregate_rows
2889
- )
2890
- if legacy_labels is not None and cache_seams_unpatched:
2942
+ if capture.read_failed:
2943
+ return {}, {
2944
+ "projects": dict(_AGGREGATE_FOLD_FAILED),
2945
+ "daily": dict(_AGGREGATE_FOLD_FAILED),
2946
+ }
2947
+ if capture.optimized is not None:
2891
2948
  try:
2892
- return c.build_cached_claude_range_aggregates(
2893
- cache_conn,
2894
- shared_start=shared_start,
2895
- shared_end_exclusive=shared_end_exclusive,
2949
+ return c.build_cached_claude_range_aggregates_from_capture(
2950
+ capture.optimized,
2896
2951
  now_utc=now_utc,
2897
2952
  display_tz=display_tz,
2898
2953
  legacy_labels=legacy_labels,
2899
- max_entry_id=max_entry_id,
2900
- entry_mutation_seq=entry_mutation_seq,
2901
- generation=generation,
2902
- ), outcomes
2954
+ tolerate_leg_failures=True,
2955
+ )
2903
2956
  except Exception:
2904
- # The accumulator is only an optimization. Its failure falls
2905
- # through to the original two independent fold boundaries below.
2957
+ # No second cache generation is opened for a fallback after the
2958
+ # pin closes. A failed optimized fold is therefore withheld and
2959
+ # retried on the next authoritative build rather than rebuilt from
2960
+ # newer cache bytes under the old version.
2906
2961
  _lib_log.get_logger("dashboard").error(
2907
2962
  "claude range aggregate cache failed", exc_info=True,
2908
2963
  )
2909
- try:
2910
- rows = tuple(c.iter_shared_range_entries(
2911
- cache_conn, start=shared_start, end_exclusive=shared_end_exclusive,
2912
- ))
2913
- except Exception:
2914
- _lib_log.get_logger("dashboard").error(
2915
- "claude shared-range candidate read failed", exc_info=True,
2916
- )
2964
+ return {}, {
2965
+ "projects": dict(_AGGREGATE_FOLD_FAILED),
2966
+ "daily": dict(_AGGREGATE_FOLD_FAILED),
2967
+ }
2968
+ rows = capture.rows or ()
2969
+ if capture.rows is None:
2917
2970
  return {}, {
2918
2971
  "projects": dict(_AGGREGATE_FOLD_FAILED),
2919
2972
  "daily": dict(_AGGREGATE_FOLD_FAILED),
@@ -2964,6 +3017,38 @@ def _tui_build_claude_aggregates(
2964
3017
  return payload, outcomes
2965
3018
 
2966
3019
 
3020
+ def _tui_build_claude_aggregates(
3021
+ cache_conn,
3022
+ *,
3023
+ shared_start: dt.datetime,
3024
+ shared_end_exclusive: dt.datetime,
3025
+ now_utc: dt.datetime,
3026
+ display_tz_name: str | None,
3027
+ legacy_labels: "dict[str, str] | None" = None,
3028
+ max_entry_id: "int | None" = None,
3029
+ entry_mutation_seq: "int | None" = None,
3030
+ generation: int = 0,
3031
+ ):
3032
+ """Compatibility wrapper for callers that do not split the cache pin."""
3033
+ capture = _tui_capture_claude_aggregates(
3034
+ cache_conn,
3035
+ shared_start=shared_start,
3036
+ shared_end_exclusive=shared_end_exclusive,
3037
+ now_utc=now_utc,
3038
+ display_tz_name=display_tz_name,
3039
+ legacy_labels=legacy_labels,
3040
+ max_entry_id=max_entry_id,
3041
+ entry_mutation_seq=entry_mutation_seq,
3042
+ generation=generation,
3043
+ )
3044
+ return _tui_build_claude_aggregates_from_capture(
3045
+ capture,
3046
+ now_utc=now_utc,
3047
+ display_tz_name=display_tz_name,
3048
+ legacy_labels=legacy_labels,
3049
+ )
3050
+
3051
+
2967
3052
  def _tui_claude_data_with_aggregates(
2968
3053
  claude_data: dict[str, object] | None,
2969
3054
  payload: dict[str, object],
@@ -3167,10 +3252,19 @@ def _tui_claude_budget_cost_events(
3167
3252
  the 1-hour cache-write portion correctly (#195).
3168
3253
  """
3169
3254
  c = _cctally()
3170
- events: list[tuple[dt.datetime, float]] = []
3171
- for row in c.iter_shared_range_entries(
3255
+ rows = tuple(c.iter_shared_range_entries(
3172
3256
  cache_conn, start=start_at, end_exclusive=end_at,
3173
- ):
3257
+ ))
3258
+ return _tui_claude_budget_events_from_rows(rows)
3259
+
3260
+
3261
+ def _tui_claude_budget_events_from_rows(
3262
+ rows: "Iterable[tuple]",
3263
+ ) -> tuple[tuple[dt.datetime, float], ...]:
3264
+ """Price already-captured Claude rows without touching cache.db."""
3265
+ c = _cctally()
3266
+ events: list[tuple[dt.datetime, float]] = []
3267
+ for row in rows:
3174
3268
  entry = c._shared_range_row_to_usage_entry(row)
3175
3269
  timestamp = entry.timestamp
3176
3270
  if timestamp.tzinfo is None or timestamp.utcoffset() is None:
@@ -3184,7 +3278,17 @@ def _tui_claude_budget_cost_events(
3184
3278
  return tuple(events)
3185
3279
 
3186
3280
 
3187
- def _tui_claude_budget_domain(
3281
+ @dataclass(frozen=True)
3282
+ class _ClaudeBudgetCapture:
3283
+ config: object
3284
+ target: object
3285
+ period: str | None
3286
+ window: tuple[dt.datetime, dt.datetime] | None = None
3287
+ rows: tuple = ()
3288
+ immediate_overlay: object | None = None
3289
+
3290
+
3291
+ def _tui_capture_claude_budget_domain(
3188
3292
  cache_conn,
3189
3293
  stats_conn,
3190
3294
  *,
@@ -3192,31 +3296,21 @@ def _tui_claude_budget_domain(
3192
3296
  now_utc: dt.datetime,
3193
3297
  display_tz_name: str | None,
3194
3298
  week_start_name: str,
3195
- ) -> tuple[dict[str, object], tuple[tuple[dt.datetime, float], ...]]:
3196
- """Return ``(budget_domain_overlay, cost_events)`` for the Claude provider.
3197
-
3198
- The status is VENDOR-WIDE (spec §3.4). A populated `budget.accounts` is NOT
3199
- consumed to simulate per-account scoping: Claude publishes no
3200
- `account_scopes` for such a map to describe, so an account-only
3201
- configuration is its own disposition rather than a fabricated status.
3202
-
3203
- Every failure is caught HERE. The Claude build has no error boundary of its
3204
- own — unlike the Codex build at `_tui_build_source_bundle`'s
3205
- `source_build_failed` handler — so an escaping budget error would take the
3206
- whole bundle down, not merely the provider.
3207
- """
3299
+ ) -> _ClaudeBudgetCapture:
3300
+ """Capture the configured budget window's raw rows under the cache pin."""
3208
3301
  config = claude_budget if isinstance(claude_budget, Mapping) else {}
3209
3302
  target = config.get("weekly_usd")
3210
3303
  if target is None:
3211
- if config.get("accounts"):
3212
- return {"not_configured": {"disposition": "account_budgets_only"}}, ()
3213
- return {}, ()
3214
- # Resolved BEFORE the boundary so the `budget_compute_failed` payload can
3215
- # still name the configured period (#556 S5 Unit 2 review F5); the reader
3216
- # is a pure config lookup with no failure mode of its own.
3304
+ overlay = (
3305
+ {"not_configured": {"disposition": "account_budgets_only"}}
3306
+ if config.get("accounts") else {}
3307
+ )
3308
+ return _ClaudeBudgetCapture(
3309
+ config=config, target=target, period=None,
3310
+ immediate_overlay=overlay,
3311
+ )
3217
3312
  period = _tui_claude_budget_period(config)
3218
3313
  try:
3219
- c = _cctally()
3220
3314
  window = _tui_claude_budget_window(
3221
3315
  stats_conn,
3222
3316
  period=period,
@@ -3225,25 +3319,56 @@ def _tui_claude_budget_domain(
3225
3319
  week_start_name=week_start_name,
3226
3320
  )
3227
3321
  if window is None:
3228
- return {
3229
- "status_unavailable": _tui_claude_budget_unavailable(
3230
- "period_unresolved", budget_usd=target, period=period),
3231
- }, ()
3322
+ return _ClaudeBudgetCapture(
3323
+ config=config, target=target, period=period,
3324
+ immediate_overlay={
3325
+ "status_unavailable": _tui_claude_budget_unavailable(
3326
+ "period_unresolved", budget_usd=target, period=period),
3327
+ },
3328
+ )
3232
3329
  start_at, end_at = window
3233
- events = _tui_claude_budget_cost_events(
3234
- cache_conn, start_at=start_at, end_at=end_at,
3330
+ rows = tuple(_cctally().iter_shared_range_entries(
3331
+ cache_conn, start=start_at, end_exclusive=end_at,
3332
+ ))
3333
+ return _ClaudeBudgetCapture(
3334
+ config=config, target=target, period=period,
3335
+ window=(start_at, end_at), rows=rows,
3336
+ )
3337
+ except Exception:
3338
+ _lib_log.get_logger("dashboard").error(
3339
+ "claude budget inputs could not be captured", exc_info=True,
3235
3340
  )
3341
+ return _ClaudeBudgetCapture(
3342
+ config=config, target=target, period=period,
3343
+ immediate_overlay={
3344
+ "status_unavailable": _tui_claude_budget_unavailable(
3345
+ "budget_compute_failed", budget_usd=target, period=period),
3346
+ },
3347
+ )
3348
+
3349
+
3350
+ def _tui_build_claude_budget_domain_from_capture(
3351
+ capture: _ClaudeBudgetCapture,
3352
+ *,
3353
+ now_utc: dt.datetime,
3354
+ ) -> tuple[dict[str, object], tuple[tuple[dt.datetime, float], ...]]:
3355
+ """Price and interpret a captured Claude budget after rollback."""
3356
+ if capture.immediate_overlay is not None:
3357
+ return dict(capture.immediate_overlay), ()
3358
+ assert capture.window is not None and capture.period is not None
3359
+ config = capture.config
3360
+ target = capture.target
3361
+ period = capture.period
3362
+ start_at, end_at = capture.window
3363
+ try:
3364
+ c = _cctally()
3365
+ events = _tui_claude_budget_events_from_rows(capture.rows)
3236
3366
  recent_start = max(start_at, now_utc - dt.timedelta(hours=24))
3237
3367
  status = c.budget_status_payload(
3238
3368
  period=period,
3239
3369
  window_start_at=start_at,
3240
3370
  window_end_at=end_at,
3241
3371
  target_usd=target,
3242
- # stable_sum, not sum: both figures are published on the dashboard
3243
- # wire and byte-compared by the dashboard goldens, and the built-in
3244
- # sum() switched to Neumaier compensated summation for floats in
3245
- # CPython 3.12, so the same spend renders 49.20424485 on 3.12+ and
3246
- # 49.204244850000016 on 3.11.
3247
3372
  spent_usd=stable_sum(
3248
3373
  cost for timestamp, cost in events
3249
3374
  if start_at <= timestamp < now_utc
@@ -3263,14 +3388,6 @@ def _tui_claude_budget_domain(
3263
3388
  "status_unavailable": _tui_claude_budget_unavailable(
3264
3389
  "budget_compute_failed", budget_usd=target, period=period),
3265
3390
  }, ()
3266
- # #556 S5 Unit 1 review R4 — retain ONLY the events a later reclock can
3267
- # read. `_refresh_budget_status_clock` consults the carrier for exactly one
3268
- # quantity, `recent_24h_usd` over `[max(window_start, now - 24h), now)`, and
3269
- # `now` advances monotonically, so an event older than `now_utc - 24h` is
3270
- # unreadable for the life of this retained state. `spent_usd` above is
3271
- # already summed from the FULL set, so nothing published is lost — while a
3272
- # `calendar-month` budget would otherwise pin up to 31 days of Claude
3273
- # `session_entries` and make every reclock tick walk all of them.
3274
3391
  reclock_floor = now_utc - dt.timedelta(hours=24)
3275
3392
  retained = tuple(
3276
3393
  (timestamp, cost) for timestamp, cost in events
@@ -3279,6 +3396,40 @@ def _tui_claude_budget_domain(
3279
3396
  return {"status": status}, retained
3280
3397
 
3281
3398
 
3399
+ def _tui_claude_budget_domain(
3400
+ cache_conn,
3401
+ stats_conn,
3402
+ *,
3403
+ claude_budget: "Mapping[str, object] | None",
3404
+ now_utc: dt.datetime,
3405
+ display_tz_name: str | None,
3406
+ week_start_name: str,
3407
+ ) -> tuple[dict[str, object], tuple[tuple[dt.datetime, float], ...]]:
3408
+ """Return ``(budget_domain_overlay, cost_events)`` for the Claude provider.
3409
+
3410
+ The status is VENDOR-WIDE (spec §3.4). A populated `budget.accounts` is NOT
3411
+ consumed to simulate per-account scoping: Claude publishes no
3412
+ `account_scopes` for such a map to describe, so an account-only
3413
+ configuration is its own disposition rather than a fabricated status.
3414
+
3415
+ Every failure is caught HERE. The Claude build has no error boundary of its
3416
+ own — unlike the Codex build at `_tui_build_source_bundle`'s
3417
+ `source_build_failed` handler — so an escaping budget error would take the
3418
+ whole bundle down, not merely the provider.
3419
+ """
3420
+ capture = _tui_capture_claude_budget_domain(
3421
+ cache_conn,
3422
+ stats_conn,
3423
+ claude_budget=claude_budget,
3424
+ now_utc=now_utc,
3425
+ display_tz_name=display_tz_name,
3426
+ week_start_name=week_start_name,
3427
+ )
3428
+ return _tui_build_claude_budget_domain_from_capture(
3429
+ capture, now_utc=now_utc,
3430
+ )
3431
+
3432
+
3282
3433
  def _tui_claude_data_with_budget(
3283
3434
  base: dict[str, object], overlay: dict[str, object],
3284
3435
  ) -> dict[str, object]:
@@ -3343,6 +3494,8 @@ def _tui_build_source_bundle(
3343
3494
  cache_conn = c.open_cache_db()
3344
3495
  cache_read_tx = False
3345
3496
  pin_started_ns: "int | None" = None
3497
+ codex_scope_cm = None
3498
+ codex_path_scope_value = None
3346
3499
 
3347
3500
  def _record_cache_pin() -> None:
3348
3501
  """Stamp the elapsed hold exactly once, on whichever path ends it.
@@ -3529,6 +3682,14 @@ def _tui_build_source_bundle(
3529
3682
  prior_bundle.sources.get("codex")
3530
3683
  if prior_bundle is not None else None
3531
3684
  )
3685
+ claude_reuse_version = combined_accounting_version(
3686
+ claude_version,
3687
+ prior_claude.combined_accounting if prior_claude is not None else None,
3688
+ )
3689
+ codex_reuse_version = combined_accounting_version(
3690
+ codex_version,
3691
+ prior_codex.combined_accounting if prior_codex is not None else None,
3692
+ )
3532
3693
  if claude_ingest_failed:
3533
3694
  warning = SourceDashboardWarning(
3534
3695
  "source_ingest_failed", "Source ingest failed.", "ingest",
@@ -3549,7 +3710,7 @@ def _tui_build_source_bundle(
3549
3710
  )
3550
3711
  else:
3551
3712
  claude = reuse_coherent_source_state(
3552
- prior_claude, data_version=claude_version,
3713
+ prior_claude, data_version=claude_reuse_version,
3553
3714
  )
3554
3715
  # #556 S2 §3.6, gate 2 of 2. The version fragment above already
3555
3716
  # rejects a failed generation, but this gate is stated explicitly
@@ -3559,6 +3720,111 @@ def _tui_build_source_bundle(
3559
3720
  # the life of the process. Gate 1 is the bundle-level idle guard.
3560
3721
  if claude is not None and aggregate_scope_failed(claude):
3561
3722
  claude = None
3723
+ if codex_ingest_failed:
3724
+ warning = SourceDashboardWarning(
3725
+ "source_ingest_failed", "Source ingest failed.", "ingest",
3726
+ )
3727
+ codex = (
3728
+ degrade_source_state(prior_codex, warning)
3729
+ if prior_codex is not None
3730
+ else unavailable_source_state("codex", warning)
3731
+ )
3732
+ elif codex_ingest_contended:
3733
+ warning = SourceDashboardWarning(
3734
+ "source_ingest_contended", "Source ingest is in progress.", "ingest",
3735
+ )
3736
+ codex = (
3737
+ degrade_source_state(prior_codex, warning)
3738
+ if prior_codex is not None
3739
+ else unavailable_source_state("codex", warning)
3740
+ )
3741
+ else:
3742
+ codex = (
3743
+ None if prior_codex is not None and (
3744
+ _codex_accounting_pending
3745
+ or any(
3746
+ warning.code == "codex_projection_incoherent"
3747
+ for warning in prior_codex.warnings
3748
+ )
3749
+ or codex_decision_deadline_passed(prior_codex, now_utc)
3750
+ ) else reuse_coherent_source_state(
3751
+ prior_codex, data_version=codex_reuse_version,
3752
+ )
3753
+ )
3754
+ if codex is not None and aggregate_scope_failed(codex):
3755
+ codex = None
3756
+ _tui_note_codex_regime("active" if codex is None else "idle")
3757
+
3758
+ # Capture every cache-backed carrier while one read transaction names
3759
+ # the generation. Public view construction happens only after rollback.
3760
+ legacy_labels = (
3761
+ c.legacy_project_labels(projects_envelope)
3762
+ if projects_envelope is not None else None
3763
+ )
3764
+ claude_aggregate_capture = None
3765
+ claude_budget_capture = None
3766
+ if claude is None:
3767
+ claude_aggregate_capture = _tui_capture_claude_aggregates(
3768
+ cache_conn,
3769
+ shared_start=common_range_start,
3770
+ shared_end_exclusive=shared_end_exclusive,
3771
+ now_utc=now_utc,
3772
+ display_tz_name=semantics.display_tz_name,
3773
+ legacy_labels=legacy_labels,
3774
+ max_entry_id=signature.max_entry_id,
3775
+ entry_mutation_seq=signature.entry_mutation_seq,
3776
+ generation=signature.generation,
3777
+ )
3778
+ claude_budget_capture = _tui_capture_claude_budget_domain(
3779
+ cache_conn,
3780
+ stats_conn,
3781
+ claude_budget=semantics.claude_budget,
3782
+ now_utc=now_utc,
3783
+ display_tz_name=semantics.display_tz_name,
3784
+ week_start_name=semantics.week_start_name,
3785
+ )
3786
+
3787
+ codex_capture = None
3788
+ codex_capture_failed = False
3789
+ codex_context = None
3790
+ codex_split_seams_unpatched = (
3791
+ build_codex_source_state
3792
+ is sys.modules["_cctally_dashboard_sources"].build_codex_source_state
3793
+ )
3794
+ if codex is None:
3795
+ try:
3796
+ codex_context = DashboardReadContext(
3797
+ cache_conn=cache_conn,
3798
+ stats_conn=stats_conn,
3799
+ range_start=common_range_start,
3800
+ now_utc=now_utc,
3801
+ display_tz_name=semantics.display_tz_name,
3802
+ week_start_idx=semantics.week_start_idx,
3803
+ week_start_name=semantics.week_start_name,
3804
+ speed=semantics.speed,
3805
+ codex_budget=semantics.codex_budget,
3806
+ codex_quota_actual_thresholds=semantics.codex_quota_actual_thresholds,
3807
+ codex_quota_projected_thresholds=semantics.codex_quota_projected_thresholds,
3808
+ cache_report_anomaly_threshold_pp=semantics.cache_report_anomaly_threshold_pp,
3809
+ stats_identity=(stats_digest, accounts_digest, claude_digest),
3810
+ )
3811
+ if codex_split_seams_unpatched:
3812
+ codex_scope_cm = codex_path_scope()
3813
+ codex_path_scope_value = codex_scope_cm.__enter__()
3814
+ codex_capture = capture_codex_source_state(
3815
+ codex_context, path_scope=codex_path_scope_value,
3816
+ )
3817
+ except Exception:
3818
+ codex_capture_failed = True
3819
+ _lib_log.get_logger("dashboard").error(
3820
+ "codex_read_model source capture failed",
3821
+ exc_info=True,
3822
+ )
3823
+
3824
+ if cache_read_tx:
3825
+ _record_cache_pin()
3826
+ cache_conn.rollback()
3827
+ cache_read_tx = False
3562
3828
  if claude is None:
3563
3829
  claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
3564
3830
  # #341 Task 4 (Ruling C): the conditional per-account Claude wire,
@@ -3568,10 +3834,14 @@ def _tui_build_source_bundle(
3568
3834
  # chip row / hero cards light up automatically for a decorated Claude
3569
3835
  # source. A read failure degrades to the byte-stable undecorated shape.
3570
3836
  claude_accounts: list[dict[str, object]] = []
3837
+ claude_combined_accounting: dict[str, object] | None = None
3571
3838
  try:
3572
3839
  import _cctally_account
3573
3840
  if _cctally_account.provider_is_decorated(stats_conn, "claude"):
3574
- claude_accounts = _claude_accounts_wire(stats_conn, now_utc=now_utc)
3841
+ (
3842
+ claude_accounts,
3843
+ claude_combined_accounting,
3844
+ ) = _claude_accounts_wire(stats_conn, now_utc=now_utc)
3575
3845
  except Exception:
3576
3846
  # #341 Task 4 P3 — degrade to no-accounts-wire on ANY read
3577
3847
  # failure, symmetric with the Codex path. Beyond sqlite, the wire
@@ -3581,50 +3851,43 @@ def _tui_build_source_bundle(
3581
3851
  # wire must never fail the whole dashboard tick — it just falls
3582
3852
  # back to the byte-stable undecorated shape.
3583
3853
  claude_accounts = []
3584
- # #556 S2 §3.3: both All-only Claude legs fold HERE, on the pinned
3585
- # cache connection, after BEGIN and beside the Codex read, so the
3586
- # two providers describe one snapshot. Folding them earlier — in
3587
- # the attached-cache block or through the Group-A daily read — runs
3588
- # against a different connection, and a cache commit in between
3589
- # would publish Claude generation A beside Codex generation B while
3590
- # the bundle's version names B.
3591
- aggregate_payload, aggregate_outcomes = _tui_build_claude_aggregates(
3592
- cache_conn,
3593
- shared_start=common_range_start,
3594
- shared_end_exclusive=shared_end_exclusive,
3595
- now_utc=now_utc,
3596
- display_tz_name=semantics.display_tz_name,
3597
- # The legacy display keys the drill-down route resolves
3598
- # against. Published rows adopt them wherever they exist, so
3599
- # the aggregate identity and the legacy one agree and the
3600
- # bounded rows stay routable. The raw envelope is required —
3601
- # `claude_data` has already replaced every legacy display key
3602
- # with an opaque key and dropped `bucket_path`, so the map
3603
- # cannot be recovered from it.
3604
- # `None` — not an empty map — when no envelope was built, so
3605
- # the fold can tell "the legacy population is empty" from "the
3606
- # legacy population is unknown" and withhold on the second.
3607
- legacy_labels=(
3608
- c.legacy_project_labels(projects_envelope)
3609
- if projects_envelope is not None else None
3610
- ),
3611
- max_entry_id=signature.max_entry_id,
3612
- entry_mutation_seq=signature.entry_mutation_seq,
3613
- generation=signature.generation,
3854
+ claude_combined_accounting = {
3855
+ "scope": "account_cycles",
3856
+ "status": "unresolved",
3857
+ "cause": "account_cost_unresolved",
3858
+ "contributions": (),
3859
+ }
3860
+ if claude_aggregate_capture is None:
3861
+ raise RuntimeError("Claude aggregate capture is missing")
3862
+ aggregate_payload, aggregate_outcomes = (
3863
+ _tui_build_claude_aggregates_from_capture(
3864
+ claude_aggregate_capture,
3865
+ now_utc=now_utc,
3866
+ display_tz_name=semantics.display_tz_name,
3867
+ # The legacy display keys the drill-down route resolves
3868
+ # against. Published rows adopt them wherever they exist,
3869
+ # so the aggregate identity and the legacy one agree and
3870
+ # the bounded rows stay routable. The raw envelope is
3871
+ # required — `claude_data` has already replaced every
3872
+ # legacy display key with an opaque key and dropped
3873
+ # `bucket_path`, so the map cannot be recovered from it.
3874
+ # `None` — not an empty map — when no envelope was built,
3875
+ # so the fold can tell "the legacy population is empty"
3876
+ # from "the legacy population is unknown" and withhold on
3877
+ # the second.
3878
+ legacy_labels=legacy_labels,
3879
+ )
3614
3880
  )
3615
- # #556 S5 §3.2: the Claude budget fold runs HERE, on this
3616
- # function's own pinned cache snapshot — the same snapshot the S2
3617
- # folds and the Codex read use. That is the narrow, true claim: the
3618
- # legacy Claude envelope was constructed BEFORE this call, and
3619
- # stats.db deliberately stays in statement-scoped autocommit, so
3620
- # this is not coherence with every number in the envelope.
3621
- claude_budget_overlay, claude_budget_events = _tui_claude_budget_domain(
3622
- cache_conn,
3623
- stats_conn,
3624
- claude_budget=semantics.claude_budget,
3625
- now_utc=now_utc,
3626
- display_tz_name=semantics.display_tz_name,
3627
- week_start_name=semantics.week_start_name,
3881
+ # #617: price and interpret the budget rows captured from this
3882
+ # function's one cache generation. The legacy Claude envelope was
3883
+ # constructed before this call, and stats.db deliberately stays in
3884
+ # statement-scoped autocommit, so the claim remains cache-local.
3885
+ if claude_budget_capture is None:
3886
+ raise RuntimeError("Claude budget capture is missing")
3887
+ claude_budget_overlay, claude_budget_events = (
3888
+ _tui_build_claude_budget_domain_from_capture(
3889
+ claude_budget_capture, now_utc=now_utc,
3890
+ )
3628
3891
  )
3629
3892
  claude_aggregate_scope = build_aggregate_scope(
3630
3893
  published_range, aggregate_outcomes,
@@ -3644,7 +3907,9 @@ def _tui_build_source_bundle(
3644
3907
  availability=claude_available,
3645
3908
  freshness="fresh",
3646
3909
  warnings=(),
3647
- data_version=claude_version,
3910
+ data_version=combined_accounting_version(
3911
+ claude_version, claude_combined_accounting,
3912
+ ),
3648
3913
  last_success_at=now_utc,
3649
3914
  capabilities={
3650
3915
  "hero": CapabilityRecord("supported", "subscription-week"),
@@ -3698,91 +3963,25 @@ def _tui_build_source_bundle(
3698
3963
  # recomputes trailing-24h spend by iterating individual events,
3699
3964
  # which the status aggregate cannot supply.
3700
3965
  clock_data={"claude_budget_cost_events": claude_budget_events},
3966
+ combined_accounting=claude_combined_accounting,
3701
3967
  aggregate_scope=claude_aggregate_scope,
3702
3968
  )
3703
- if codex_ingest_failed:
3704
- warning = SourceDashboardWarning(
3705
- "source_ingest_failed", "Source ingest failed.", "ingest",
3706
- )
3707
- codex = (
3708
- degrade_source_state(prior_codex, warning)
3709
- if prior_codex is not None
3710
- else unavailable_source_state("codex", warning)
3711
- )
3712
- elif codex_ingest_contended:
3713
- warning = SourceDashboardWarning(
3714
- "source_ingest_contended", "Source ingest is in progress.", "ingest",
3715
- )
3716
- codex = (
3717
- degrade_source_state(prior_codex, warning)
3718
- if prior_codex is not None
3719
- else unavailable_source_state("codex", warning)
3720
- )
3721
- else:
3722
- # Projection coherence can recover when S2 writes its certificate
3723
- # without changing physical accounting. A current, hero-scoped
3724
- # incoherence generation is intentionally not retained through
3725
- # that repair opportunity; all other fresh partial states remain
3726
- # eligible for exact reuse.
3727
- # #350 spec §3.3: a passed cycle decision deadline forces an
3728
- # AUTHORITATIVE rebuild. Weekly-cycle resolution is time-dependent
3729
- # even on frozen evidence, and the reuse path would otherwise hand
3730
- # back the exact prior object with no re-check at all — so an idle
3731
- # dashboard would diverge from a freshly rebuilt one. Leaving
3732
- # ``codex = None`` routes to the existing build below. Bounded by
3733
- # construction: one rebuild per crossing, not one per tick.
3734
- codex = (
3735
- None if prior_codex is not None and (
3736
- _codex_accounting_pending
3737
- or any(
3738
- warning.code == "codex_projection_incoherent"
3739
- for warning in prior_codex.warnings
3740
- )
3741
- or codex_decision_deadline_passed(prior_codex, now_utc)
3742
- ) else reuse_coherent_source_state(
3743
- prior_codex, data_version=codex_version,
3744
- )
3745
- )
3746
- # #556 S2 §3.6: symmetric with Claude. Codex's rows are already
3747
- # bounded by this same range, so its carrier records no fold of its
3748
- # own — but a retained failure state must never be reused, and the
3749
- # gate is stated on both providers so a future Codex-side fold
3750
- # inherits it.
3751
- if codex is not None and aggregate_scope_failed(codex):
3752
- codex = None
3753
- # #583 S1 §1.5: the realised decision, read at the `codex is None`
3754
- # predicate this branch has just settled. Stamped only here, so a
3755
- # tick whose Codex leg degraded on ingest failure or contention
3756
- # stays `not_observed` — no build reached the decision at all.
3757
- _tui_note_codex_regime("active" if codex is None else "idle")
3758
3969
  if codex is None:
3759
3970
  try:
3760
- codex = build_codex_source_state(
3761
- DashboardReadContext(
3762
- cache_conn=cache_conn,
3763
- stats_conn=stats_conn,
3764
- range_start=common_range_start,
3765
- now_utc=now_utc,
3766
- display_tz_name=semantics.display_tz_name,
3767
- week_start_idx=semantics.week_start_idx,
3768
- week_start_name=semantics.week_start_name,
3769
- speed=semantics.speed,
3770
- codex_budget=semantics.codex_budget,
3771
- codex_quota_actual_thresholds=semantics.codex_quota_actual_thresholds,
3772
- codex_quota_projected_thresholds=semantics.codex_quota_projected_thresholds,
3773
- cache_report_anomaly_threshold_pp=semantics.cache_report_anomaly_threshold_pp,
3774
- # #583 S5: the three digests this function already
3775
- # computed above, handed to the quota memo's reuse
3776
- # identity instead of being derived a second time per
3777
- # memo call. Same values, same order, same connection,
3778
- # and taken under the `assert_projection_readable` gate
3779
- # already run at the top of this build.
3780
- stats_identity=(
3781
- stats_digest, accounts_digest, claude_digest,
3782
- ),
3783
- ),
3784
- data_version=codex_version,
3785
- )
3971
+ if codex_capture_failed or codex_context is None:
3972
+ raise RuntimeError("Codex source capture failed")
3973
+ if codex_split_seams_unpatched:
3974
+ if codex_capture is None:
3975
+ raise RuntimeError("Codex source capture failed")
3976
+ codex = build_codex_source_state_from_capture(
3977
+ codex_capture,
3978
+ data_version=codex_version,
3979
+ path_scope=codex_path_scope_value,
3980
+ )
3981
+ else:
3982
+ codex = build_codex_source_state(
3983
+ codex_context, data_version=codex_version,
3984
+ )
3786
3985
  # Attached ONLY on a fresh build, never on the reuse or degrade
3787
3986
  # paths: those carry rows this tick did not produce, and their
3788
3987
  # own carrier already describes the range that bounds them.
@@ -3836,32 +4035,9 @@ def _tui_build_source_bundle(
3836
4035
  source_order=("claude", "codex", "all"),
3837
4036
  sources={"claude": claude, "codex": codex, "all": combined},
3838
4037
  )
3839
- # End the pinned cache snapshot before re-reading the cheap signatures.
3840
- # New cache commits after BEGIN do not make this bundle incoherent: all
3841
- # cache-backed reads above saw the same frozen generation, and its
3842
- # data_version names that generation. Reject only stats-side movement,
3843
- # whose statement-scoped autocommit reads may have mixed generations.
3844
- # Rejecting ordinary cache advancement starves publication whenever
3845
- # active Claude/Codex sessions append faster than this build completes.
3846
- #
3847
- # ONE EXCEPTION since #583 S5, stated here because a narrowing of this
3848
- # transaction has to account for it. Every cache read this build ISSUES
3849
- # runs on the pinned connection and sees the frozen generation, but the
3850
- # Codex quota memo `_CODEX_QUOTA_OBSERVATION_CACHE` now survives across
3851
- # builds, so a five-hour correlation read can be answered from a value
3852
- # another caller loaded -- the share render or the cycle-detail route,
3853
- # on an unpinned connection at a different generation. That value is
3854
- # served only while its key is unmoved, and the key covers the ledger
3855
- # sequence, the attribution revision, the database path and the three
3856
- # digests taken above, so the evidence behind it provably has not
3857
- # changed. The full argument is at `_cached_codex_quota_observations`
3858
- # in `bin/_cctally_dashboard_sources.py`. The dashboard's own bounded
3859
- # quota read is NOT in that class: it passes `memoize=False` and is
3860
- # always physically issued on the pinned connection.
3861
- if cache_read_tx:
3862
- _record_cache_pin()
3863
- cache_conn.rollback()
3864
- cache_read_tx = False
4038
+ # The cache pin ended after carrier capture. These cheap post-build
4039
+ # signatures intentionally reject only stats-side movement; a cache
4040
+ # commit during the pure folds belongs to the next generation.
3865
4041
  post_stats_digest = codex_stats_digest(stats_conn)
3866
4042
  post_accounts_digest = accounts_identity_digest(stats_conn)
3867
4043
  post_claude_digest = claude_stats_digest(stats_conn)
@@ -3890,6 +4066,8 @@ def _tui_build_source_bundle(
3890
4066
  _record_cache_pin()
3891
4067
  if cache_read_tx:
3892
4068
  cache_conn.rollback()
4069
+ if codex_scope_cm is not None:
4070
+ codex_scope_cm.__exit__(None, None, None)
3893
4071
  cache_conn.close()
3894
4072
 
3895
4073
 
@@ -7388,13 +7566,20 @@ def _tui_modal_current_week(snap, runtime, width):
7388
7566
  if not milestones:
7389
7567
  return ("{accent.b}Current Week · per-percent{/}",
7390
7568
  ["", " {dim}No milestones yet — keep recording usage.{/}", ""])
7391
- avg_dpp = (cw.dollars_per_percent or 0.0)
7569
+ # #620 S1 D5/A5: `TuiCurrentWeek.dollars_per_percent` is `None` when no
7570
+ # usage has been observed. `or 0.0` printed `avg $/1% $0.00`, which is a
7571
+ # measured-looking answer to a question nothing measured. Every other TUI
7572
+ # site already renders that absence as an em dash; this one did not.
7573
+ avg_dpp_str = (
7574
+ f"${cw.dollars_per_percent:.2f}"
7575
+ if cw.dollars_per_percent is not None else "—"
7576
+ )
7392
7577
  cumul = cw.spent_usd
7393
7578
  header = [
7394
7579
  "",
7395
7580
  f" {{dim}}Week{{/}} {{b}}{format_display_dt(cw.week_start_at, runtime.display_tz, fmt='%b %d', suffix=False)} – {format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d', suffix=False)}{{/}} "
7396
7581
  f"{{dim}}milestones reached{{/}} {{warn.b}}{len(milestones)}{{/}}",
7397
- f" {{dim}}avg $/1%{{/}} {{b}}${avg_dpp:.2f}{{/}} {{dim}}cumulative{{/}} {{b}}${cumul:.2f}{{/}}",
7582
+ f" {{dim}}avg $/1%{{/}} {{b}}{avg_dpp_str}{{/}} {{dim}}cumulative{{/}} {{b}}${cumul:.2f}{{/}}",
7398
7583
  "",
7399
7584
  ]
7400
7585
  bucket = _tui_width_bucket(width)
@@ -7469,8 +7654,14 @@ def _tui_modal_forecast(snap, runtime, width):
7469
7654
  for b in fc.budgets:
7470
7655
  if b.dollars_per_day is not None:
7471
7656
  lines.append(f" {{dim}} ≤{b.target_percent}% {{/}}{{b}}${b.dollars_per_day:.2f}/day{{/}}")
7472
- else:
7657
+ elif b.pct_headroom is None:
7473
7658
  lines.append(f" {{dim}} ≤{b.target_percent}% {{/}}{{dim}}— (already past){{/}}")
7659
+ else:
7660
+ # #620 S1 D5: `dollars_per_day` is also None when the $/1% rate
7661
+ # itself is withheld, and there the target has NOT been passed —
7662
+ # there is headroom and no rate to price it with. Naming the
7663
+ # wrong cause is the defect this session removes.
7664
+ lines.append(f" {{dim}} ≤{b.target_percent}% {{/}}{{dim}}— (no rate observed){{/}}")
7474
7665
  lines.append("")
7475
7666
  confidence = inp.confidence
7476
7667
  lines.append(f" {{dim}}confidence: {confidence} · based on 7-day rate{{/}}")