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
@@ -173,8 +173,8 @@ import re
173
173
  import sqlite3
174
174
  import sys
175
175
  import time
176
- from dataclasses import dataclass
177
- from typing import Any
176
+ from dataclasses import dataclass, field
177
+ from typing import Any, Iterable
178
178
 
179
179
 
180
180
  def _cctally():
@@ -902,8 +902,13 @@ def maybe_record_milestone(
902
902
  # Filter by reset_event_id so a credited week's
903
903
  # alert payload reads the post-credit row, not a
904
904
  # stale pre-credit row at the same (week, threshold).
905
+ # `week_start_at` comes from the row rather than the
906
+ # local variable for the same reason `cumulative_cost_usd`
907
+ # does: it is the value persisted on insert, so the alert
908
+ # states the week the milestone actually recorded.
905
909
  row = conn.execute(
906
- "SELECT cumulative_cost_usd FROM percent_milestones "
910
+ "SELECT cumulative_cost_usd, week_start_at "
911
+ "FROM percent_milestones "
907
912
  "WHERE week_start_date = ? AND percent_threshold = ? "
908
913
  " AND reset_event_id = ? AND account_key = ?",
909
914
  (week_start_date, pct, reset_event_id, account_key),
@@ -916,6 +921,7 @@ def maybe_record_milestone(
916
921
  threshold=pct,
917
922
  crossed_at_utc=crossed_at,
918
923
  week_start_date=week_start_date,
924
+ week_start_at=row["week_start_at"],
919
925
  cumulative_cost_usd=cum,
920
926
  dollars_per_percent=dpp,
921
927
  account_key=account_key,
@@ -1503,11 +1509,12 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1503
1509
  binds the fired value to forecast's ``week_avg_projection_pct`` within
1504
1510
  1e-9, so the two MUST share the formula by construction.
1505
1511
 
1506
- LOW CONF mirrors the displayed forecast confidence: the binary
1507
- ``_assess_forecast_confidence(elapsed_hours, p_now, len(samples))`` plus the
1508
- ``no_sample_ge_24h`` clause ``_load_forecast_inputs`` appends — so a thin
1509
- early-week window that forecast renders ``LOW CONF`` never fires a
1510
- projected alert.
1512
+ LOW CONF mirrors the displayed forecast confidence: ONE call to
1513
+ ``_assess_forecast_confidence(elapsed_hours, p_now, len(samples),
1514
+ has_sample_ge_24h=…)``, whose fourth trigger ``no_sample_ge_24h`` moved
1515
+ inside the predicate with #620 S2 E3 — so a thin early-week window that
1516
+ forecast renders ``LOW CONF`` never fires a projected alert, and glue no
1517
+ longer appends a reason of its own.
1511
1518
 
1512
1519
  Deliberately does NOT call ``_sum_cost_for_range`` (the weekly-% projection
1513
1520
  needs no spend; the forecast kernel's ``week_avg_projection_pct`` is also
@@ -1528,15 +1535,14 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1528
1535
  r_avg = p_now / elapsed_hours if elapsed_hours > 0 else 0.0
1529
1536
  projected_pct = p_now + r_avg * remaining_hours
1530
1537
 
1531
- # Confidence: same predicate + the same no_sample_ge_24h augmentation that
1532
- # _load_forecast_inputs applies, so this LOW CONF gate == forecast's.
1538
+ # Confidence comes from the predicate in one call, fourth trigger
1539
+ # included, so this LOW CONF gate == forecast's and glue no longer
1540
+ # downgrades a confidence the predicate returned (#620 S2 E3).
1541
+ target_24h = now_utc - dt.timedelta(hours=24)
1533
1542
  confidence, _reasons = _assess_forecast_confidence(
1534
- elapsed_hours, p_now, len(samples)
1543
+ elapsed_hours, p_now, len(samples),
1544
+ has_sample_ge_24h=any(s[0] <= target_24h for s in samples),
1535
1545
  )
1536
- target_24h = now_utc - dt.timedelta(hours=24)
1537
- has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
1538
- if not has_sample_ge_24h:
1539
- confidence = "low"
1540
1546
  return (projected_pct, confidence == "low")
1541
1547
 
1542
1548
 
@@ -1876,6 +1882,112 @@ def maybe_record_projected_alert(
1876
1882
  eprint(f"[projected-alert] payload build failed: {build_exc}")
1877
1883
 
1878
1884
 
1885
+ @dataclass
1886
+ class PricedEntry:
1887
+ """One accounting entry after pricing, as the block fold consumes it.
1888
+
1889
+ The canonical priced record: the fields `fold_block_totals` reads and
1890
+ nothing else. `_compute_block_totals` builds these from
1891
+ `_JoinedClaudeEntry` rows it has already priced; the diagnosis
1892
+ (#620 S2) builds them from its own half-open account-scoped window.
1893
+ """
1894
+ model: str
1895
+ project_path: str | None
1896
+ input_tokens: int
1897
+ output_tokens: int
1898
+ cache_creation_tokens: int
1899
+ cache_read_tokens: int
1900
+ cost_usd: float
1901
+
1902
+
1903
+ @dataclass
1904
+ class BlockBucket:
1905
+ """One model or project bucket inside a block's totals."""
1906
+ input_tokens: int = 0
1907
+ output_tokens: int = 0
1908
+ cache_create_tokens: int = 0
1909
+ cache_read_tokens: int = 0
1910
+ cost_usd: float = 0.0
1911
+ entry_count: int = 0
1912
+
1913
+ def as_legacy_dict(self) -> dict[str, Any]:
1914
+ return {
1915
+ "input_tokens": self.input_tokens,
1916
+ "output_tokens": self.output_tokens,
1917
+ "cache_create_tokens": self.cache_create_tokens,
1918
+ "cache_read_tokens": self.cache_read_tokens,
1919
+ "cost_usd": self.cost_usd,
1920
+ "entry_count": self.entry_count,
1921
+ }
1922
+
1923
+
1924
+ @dataclass
1925
+ class BlockTotals:
1926
+ """The summed result of `fold_block_totals`."""
1927
+ input_tokens: int = 0
1928
+ output_tokens: int = 0
1929
+ cache_create_tokens: int = 0
1930
+ cache_read_tokens: int = 0
1931
+ cost_usd: float = 0.0
1932
+ entry_count: int = 0
1933
+ by_model: dict[str, BlockBucket] = field(default_factory=dict)
1934
+ by_project: dict[str, BlockBucket] = field(default_factory=dict)
1935
+
1936
+ def as_legacy_dict(self) -> dict[str, Any]:
1937
+ """The exact dict `_compute_block_totals` has always returned.
1938
+
1939
+ Key order is preserved because callers and fixture builders read
1940
+ this dict directly, and `entry_count` is deliberately absent at the
1941
+ top level: it exists on the dataclass for the diagnosis, and adding
1942
+ it here would change a shape every existing caller sees.
1943
+ """
1944
+ return {
1945
+ "input_tokens": self.input_tokens,
1946
+ "output_tokens": self.output_tokens,
1947
+ "cache_create_tokens": self.cache_create_tokens,
1948
+ "cache_read_tokens": self.cache_read_tokens,
1949
+ "cost_usd": self.cost_usd,
1950
+ "by_model": {k: v.as_legacy_dict() for k, v in self.by_model.items()},
1951
+ "by_project": {k: v.as_legacy_dict() for k, v in self.by_project.items()},
1952
+ }
1953
+
1954
+
1955
+ def fold_block_totals(entries: "Iterable[PricedEntry]") -> BlockTotals:
1956
+ """Sum priced entries into block totals plus model and project buckets.
1957
+
1958
+ Pure: it opens nothing, prices nothing, and reads no clock. Summation
1959
+ follows the iteration order of `entries`, and bucket insertion order
1960
+ follows first appearance, both of which the callers observe.
1961
+
1962
+ A NULL `project_path` buckets under `(unknown)` so the reconcile
1963
+ invariant `SUM(child.cost) == parent.total` continues to hold. The
1964
+ JSONL-fallback loader always populates `project_path`, so `(unknown)`
1965
+ only appears on the cache-backed path during the brief `session_files`
1966
+ lazy-backfill window.
1967
+ """
1968
+ totals = BlockTotals()
1969
+ for entry in entries:
1970
+ totals.input_tokens += entry.input_tokens
1971
+ totals.output_tokens += entry.output_tokens
1972
+ totals.cache_create_tokens += entry.cache_creation_tokens
1973
+ totals.cache_read_tokens += entry.cache_read_tokens
1974
+ totals.cost_usd += entry.cost_usd
1975
+ totals.entry_count += 1
1976
+
1977
+ for key, bucket_dict in (
1978
+ (entry.model, totals.by_model),
1979
+ (entry.project_path or "(unknown)", totals.by_project),
1980
+ ):
1981
+ b = bucket_dict.setdefault(key, BlockBucket())
1982
+ b.input_tokens += entry.input_tokens
1983
+ b.output_tokens += entry.output_tokens
1984
+ b.cache_create_tokens += entry.cache_creation_tokens
1985
+ b.cache_read_tokens += entry.cache_read_tokens
1986
+ b.cost_usd += entry.cost_usd
1987
+ b.entry_count += 1
1988
+ return totals
1989
+
1990
+
1879
1991
  def _compute_block_totals(
1880
1992
  block_start_at: dt.datetime,
1881
1993
  range_end: dt.datetime,
@@ -1901,64 +2013,32 @@ def _compute_block_totals(
1901
2013
  cost_usd, entry_count}]
1902
2014
  by_project: dict[project_path_or_'(unknown)' -> same shape]
1903
2015
  """
1904
- totals: dict[str, Any] = {
1905
- "input_tokens": 0,
1906
- "output_tokens": 0,
1907
- "cache_create_tokens": 0,
1908
- "cache_read_tokens": 0,
1909
- "cost_usd": 0.0,
1910
- "by_model": {},
1911
- "by_project": {},
1912
- }
1913
- for entry in get_claude_session_entries(
1914
- block_start_at, range_end, skip_sync=skip_sync,
1915
- ):
1916
- usage = claude_usage_dict( # #195 chokepoint
1917
- input_tokens=entry.input_tokens,
1918
- output_tokens=entry.output_tokens,
1919
- cache_creation_tokens=entry.cache_creation_tokens,
1920
- cache_read_tokens=entry.cache_read_tokens,
1921
- cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
1922
- speed=getattr(entry, "speed", None),
1923
- )
1924
- cost = _calculate_entry_cost(
1925
- entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
1926
- )
1927
-
1928
- totals["input_tokens"] += entry.input_tokens
1929
- totals["output_tokens"] += entry.output_tokens
1930
- totals["cache_create_tokens"] += entry.cache_creation_tokens
1931
- totals["cache_read_tokens"] += entry.cache_read_tokens
1932
- totals["cost_usd"] += cost
1933
-
1934
- # Bucket by model and by project_path. NULL project_path → sentinel
1935
- # so reconcile invariant SUM(child.cost) == parent.total holds.
1936
- # Note: the JSONL-fallback path (_direct_parse_claude_session_entries)
1937
- # always populates project_path = cwd (never NULL); '(unknown)' only
1938
- # appears on the cache-backed path during the brief session_files
1939
- # lazy-backfill window.
1940
- for key, bucket_dict in (
1941
- (entry.model, totals["by_model"]),
1942
- (entry.project_path or "(unknown)", totals["by_project"]),
2016
+ def _priced():
2017
+ for entry in get_claude_session_entries(
2018
+ block_start_at, range_end, skip_sync=skip_sync,
1943
2019
  ):
1944
- b = bucket_dict.setdefault(
1945
- key,
1946
- {
1947
- "input_tokens": 0,
1948
- "output_tokens": 0,
1949
- "cache_create_tokens": 0,
1950
- "cache_read_tokens": 0,
1951
- "cost_usd": 0.0,
1952
- "entry_count": 0,
1953
- },
2020
+ usage = claude_usage_dict( # #195 chokepoint
2021
+ input_tokens=entry.input_tokens,
2022
+ output_tokens=entry.output_tokens,
2023
+ cache_creation_tokens=entry.cache_creation_tokens,
2024
+ cache_read_tokens=entry.cache_read_tokens,
2025
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
2026
+ speed=getattr(entry, "speed", None),
1954
2027
  )
1955
- b["input_tokens"] += entry.input_tokens
1956
- b["output_tokens"] += entry.output_tokens
1957
- b["cache_create_tokens"] += entry.cache_creation_tokens
1958
- b["cache_read_tokens"] += entry.cache_read_tokens
1959
- b["cost_usd"] += cost
1960
- b["entry_count"] += 1
1961
- return totals
2028
+ cost = _calculate_entry_cost(
2029
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
2030
+ )
2031
+ yield PricedEntry(
2032
+ model=entry.model,
2033
+ project_path=entry.project_path,
2034
+ input_tokens=entry.input_tokens,
2035
+ output_tokens=entry.output_tokens,
2036
+ cache_creation_tokens=entry.cache_creation_tokens,
2037
+ cache_read_tokens=entry.cache_read_tokens,
2038
+ cost_usd=cost,
2039
+ )
2040
+
2041
+ return fold_block_totals(_priced()).as_legacy_dict()
1962
2042
 
1963
2043
 
1964
2044
  def maybe_update_five_hour_block(
@@ -918,6 +918,58 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
918
918
  # Hook-tick OAuth refresh path
919
919
  # =========================================================================
920
920
 
921
+ def _hook_tick_oauth_skip_status(c, *, throttle_seconds: float) -> str | None:
922
+ """Return the automatic-refresh suppression reason under selected lock."""
923
+ now_epoch = int(time.time())
924
+ needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
925
+ obs_age = c._statusline_observe_age_seconds()
926
+ if (not needs_repair
927
+ and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
928
+ return f"skipped(statusline-fresh:{int(obs_age)}s)"
929
+ backoff_remaining = c._oauth_backoff_remaining_seconds()
930
+ if backoff_remaining > 0:
931
+ return f"skipped(backoff:{int(backoff_remaining)}s)"
932
+ age_s = c._newest_snapshot_age_seconds()
933
+ if age_s is not None and age_s < throttle_seconds:
934
+ return f"skipped(fresh:{int(age_s)}s)"
935
+ return None
936
+
937
+
938
+ def _hook_tick_parse_oauth_payload(c, api):
939
+ """Validate one OAuth response outside the selected-state lock."""
940
+ try:
941
+ seven = api["seven_day"]
942
+ seven_pct = c._normalize_percent(float(seven["utilization"]))
943
+ seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
944
+ except (TypeError, ValueError, KeyError):
945
+ return None
946
+ five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
947
+ five_pct: float | None = None
948
+ five_resets_epoch: int | None = None
949
+ if (five is not None and "utilization" in five
950
+ and isinstance(five.get("resets_at"), str)):
951
+ try:
952
+ five_pct = c._normalize_percent(float(five["utilization"]))
953
+ five_resets_epoch = _iso_to_epoch(five["resets_at"])
954
+ except (TypeError, ValueError):
955
+ five_pct = None
956
+ five_resets_epoch = None
957
+ record_args = argparse.Namespace(
958
+ percent=seven_pct,
959
+ resets_at=str(seven_resets_epoch),
960
+ five_hour_percent=five_pct,
961
+ five_hour_resets_at=(
962
+ str(five_resets_epoch) if five_resets_epoch is not None else None
963
+ ),
964
+ source="api",
965
+ )
966
+ axes = {"sevenDay", *({"fiveHour"} if five_pct is not None else set())}
967
+ parts = [f"7d={int(round(seven_pct))}"]
968
+ if five_pct is not None:
969
+ parts.append(f"5h={int(round(five_pct))}")
970
+ return record_args, axes, parts
971
+
972
+
921
973
  def _hook_tick_oauth_refresh(
922
974
  timeout_seconds: float = 5.0,
923
975
  throttle_seconds: float | None = None,
@@ -950,115 +1002,75 @@ def _hook_tick_oauth_refresh(
950
1002
  throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
951
1003
  except OauthUsageConfigError:
952
1004
  throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
953
- # The automatic writer takes the selected-state lock *before* rechecking
954
- # its suppression conditions. A concurrent publisher can otherwise make
955
- # this tick issue an unnecessary OAuth request between the initial gate
956
- # and the request itself.
957
- # #583 S2: the nudge is deferred out of the critical section. This frame
958
- # owns the lock across the OAuth fetch AND the authoritative record, so
959
- # `_authoritative_record_usage` cannot release it — the deferral has to
960
- # reach the frame that acquired it. The nudge is a loopback POST with a
961
- # multi-second timeout and this lock is an `fcntl.flock` every cctally
962
- # process contends on, so one unresponsive listener would otherwise stall
963
- # every other process's selected-state writes at status-line cadence.
964
- deferred = []
1005
+ # Check freshness under the selected-state lock, release it for the
1006
+ # potentially five-second network request, then recheck after reacquiring
1007
+ # before publication. A concurrent winner can therefore suppress this
1008
+ # response without the high-cadence hook holding the cross-process flock
1009
+ # during network I/O.
965
1010
  try:
966
1011
  with c._selected_state_lock():
967
- out = _hook_tick_oauth_refresh_locked(
968
- c,
969
- token=token,
970
- timeout_seconds=timeout_seconds,
971
- throttle_seconds=throttle_seconds,
972
- nudge_sink=lambda: deferred.append(1),
1012
+ skipped = _hook_tick_oauth_skip_status(
1013
+ c, throttle_seconds=throttle_seconds,
973
1014
  )
974
1015
  except OSError:
975
1016
  return "err(record-usage=exc)", None
976
- if deferred:
977
- c._nudge_dashboard_repaint()
978
- return out
979
-
980
-
981
- def _hook_tick_oauth_refresh_locked(
982
- c,
983
- *,
984
- token: str,
985
- timeout_seconds: float,
986
- throttle_seconds: float,
987
- nudge_sink=None,
988
- ) -> tuple[str, dict | None]:
989
- """Automatic OAuth path with the selected-state lock already held.
1017
+ if skipped is not None:
1018
+ return skipped, None
990
1019
 
991
- ``nudge_sink`` records that a dashboard nudge is warranted; the caller
992
- fires it after releasing the lock (#583 S2).
993
- """
994
- # Backfill gate: an inflight/invalid tombstone bypasses only selected-age
995
- # suppression so a later authoritative result can repair it. The normal
996
- # throttle and 429 deadline continue to bound OAuth traffic.
997
- now_epoch = int(time.time())
998
- needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
999
- obs_age = c._statusline_observe_age_seconds()
1000
- if (not needs_repair
1001
- and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
1002
- return f"skipped(statusline-fresh:{int(obs_age)}s)", None
1003
- backoff_remaining = c._oauth_backoff_remaining_seconds()
1004
- if backoff_remaining > 0:
1005
- return f"skipped(backoff:{int(backoff_remaining)}s)", None
1006
- age_s = c._newest_snapshot_age_seconds()
1007
- if age_s is not None and age_s < throttle_seconds:
1008
- return f"skipped(fresh:{int(age_s)}s)", None
1009
1020
  try:
1010
1021
  api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
1011
1022
  except RefreshUsageRateLimitError as exc:
1012
- c._oauth_backoff_register_429(
1013
- retry_after_deadline=getattr(exc, "retry_after_deadline", None),
1014
- now=time.time(),
1015
- )
1023
+ try:
1024
+ with c._selected_state_lock():
1025
+ c._oauth_backoff_register_429(
1026
+ retry_after_deadline=getattr(
1027
+ exc, "retry_after_deadline", None,
1028
+ ),
1029
+ now=time.time(),
1030
+ )
1031
+ except OSError:
1032
+ return "err(record-usage=exc)", None
1016
1033
  return "err(rate-limit)", None
1017
1034
  except RefreshUsageNetworkError:
1018
1035
  return "err(network)", None
1019
1036
  except RefreshUsageMalformedError:
1020
1037
  return "err(parse)", None
1021
- seven = api["seven_day"]
1022
- try:
1023
- seven_pct = c._normalize_percent(float(seven["utilization"]))
1024
- seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
1025
- except (TypeError, ValueError, KeyError):
1038
+
1039
+ parsed = _hook_tick_parse_oauth_payload(c, api)
1040
+ if parsed is None:
1026
1041
  return "err(parse)", None
1027
- five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
1028
- five_pct: float | None = None
1029
- five_resets_epoch: int | None = None
1030
- if (five is not None and "utilization" in five
1031
- and isinstance(five.get("resets_at"), str)):
1032
- try:
1033
- five_pct = c._normalize_percent(float(five["utilization"]))
1034
- five_resets_epoch = _iso_to_epoch(five["resets_at"])
1035
- except (TypeError, ValueError):
1036
- five_pct = None
1037
- five_resets_epoch = None
1038
- record_args = argparse.Namespace(
1039
- percent=seven_pct,
1040
- resets_at=str(seven_resets_epoch),
1041
- five_hour_percent=five_pct,
1042
- five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
1043
- source="api",
1044
- )
1045
- authoritative = c._authoritative_record_usage(
1046
- record_args,
1047
- {"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
1048
- lock_held=True,
1049
- nudge_sink=nudge_sink,
1050
- )
1051
- if authoritative.status != "ok":
1052
- reason = authoritative.reason or ""
1053
- if reason.startswith("exit "):
1054
- return f"err(record-usage={reason[5:]})", None
1042
+ record_args, axes, parts = parsed
1043
+
1044
+ # #583 S2: the nudge remains deferred until after the publication lock is
1045
+ # released. The sink contract is now enforced by the authority kernel, so
1046
+ # every lock-owning caller has to make the deferral explicit.
1047
+ deferred = []
1048
+ try:
1049
+ with c._selected_state_lock():
1050
+ skipped = _hook_tick_oauth_skip_status(
1051
+ c,
1052
+ throttle_seconds=throttle_seconds,
1053
+ )
1054
+ if skipped is not None:
1055
+ return skipped, None
1056
+ authoritative = c._authoritative_record_usage(
1057
+ record_args,
1058
+ axes,
1059
+ lock_held=True,
1060
+ nudge_sink=lambda: deferred.append(1),
1061
+ )
1062
+ if authoritative.status != "ok":
1063
+ reason = authoritative.reason or ""
1064
+ if reason.startswith("exit "):
1065
+ return f"err(record-usage={reason[5:]})", None
1066
+ return "err(record-usage=exc)", None
1067
+ # A success reaches shared backoff only after tombstones, control,
1068
+ # and freshness are authoritative under this same lock.
1069
+ c._oauth_backoff_reset()
1070
+ except OSError:
1055
1071
  return "err(record-usage=exc)", None
1056
- # Success is acknowledged to the shared OAuth backoff only after the
1057
- # authoritative writer has published tombstones, control, and freshness.
1058
- c._oauth_backoff_reset()
1059
- parts = [f"7d={int(round(seven_pct))}"]
1060
- if five_pct is not None:
1061
- parts.append(f"5h={int(round(five_pct))}")
1072
+ if deferred:
1073
+ c._nudge_dashboard_repaint()
1062
1074
  return f"ok({','.join(parts)})", api
1063
1075
 
1064
1076
 
@@ -949,7 +949,7 @@ def _build_forecast_snapshot(
949
949
  projected_low_pct: float,
950
950
  projected_high_pct: float,
951
951
  days_remaining: float,
952
- dollars_per_percent: float,
952
+ dollars_per_percent: "float | None",
953
953
  dollars_per_percent_source: str,
954
954
  low_conf: bool,
955
955
  notes: tuple[str, ...] = (),
@@ -1038,6 +1038,13 @@ def _build_forecast_snapshot(
1038
1038
  f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
1039
1039
  )
1040
1040
  dpp_source_label = dollars_per_percent_source.replace("_", " ")
1041
+ # #620 S1 D5: a withheld rate renders as the same `n/a` the report trend
1042
+ # table already uses for an absent $/1%. Forcing it through MoneyCell
1043
+ # would print $0.00, which is the fabrication this contract removes.
1044
+ dpp_cell = (
1045
+ _lib_share.TextCell("n/a") if dollars_per_percent is None
1046
+ else _lib_share.MoneyCell(float(dollars_per_percent))
1047
+ )
1041
1048
  snap_rows = (
1042
1049
  _lib_share.Row(cells={
1043
1050
  "metric": _lib_share.TextCell("Current %"),
@@ -1053,7 +1060,7 @@ def _build_forecast_snapshot(
1053
1060
  }),
1054
1061
  _lib_share.Row(cells={
1055
1062
  "metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
1056
- "value": _lib_share.MoneyCell(float(dollars_per_percent)),
1063
+ "value": dpp_cell,
1057
1064
  }),
1058
1065
  )
1059
1066
  # Caller-provided `notes` (e.g., empty-data path's clearer message)
@@ -1140,6 +1140,14 @@ def _run_claude_json_adapter(args: object, command: str) -> SourceResult[dict[st
1140
1140
  captured: list[dict[str, object]] = []
1141
1141
  claude_args._source_result_sink = captured.append
1142
1142
  exit_code = handler(claude_args)
1143
+ # Every Claude handler resolves the display zone onto its own namespace,
1144
+ # and ``claude_args`` is a copy, so that resolution never reached the
1145
+ # caller. The all-source terminal report needs the SAME zone the handler
1146
+ # used or its week windows and capture timestamps would render in a
1147
+ # different zone from `report --source claude` (#620 S1 D7). Reading it
1148
+ # back off the copy is exact by construction; re-deriving it here would
1149
+ # be a second resolution that can disagree with the first.
1150
+ args._claude_resolved_tz = getattr(claude_args, "_resolved_tz", None)
1143
1151
  if exit_code != 0 or len(captured) != 1:
1144
1152
  return SourceResult("claude", "unavailable", None)
1145
1153
  payload = captured[0]
@@ -1951,8 +1959,15 @@ def _render_codex_terminal(
1951
1959
  return "\n".join(lines)
1952
1960
 
1953
1961
 
1954
- def _render_claude_terminal(command: str, result: SourceResult) -> str:
1955
- """Render an all-source Claude section without changing legacy CLI paths."""
1962
+ def _render_claude_terminal(
1963
+ command: str, result: SourceResult, *, tz: object = None,
1964
+ ) -> str:
1965
+ """Render an all-source Claude section without changing legacy CLI paths.
1966
+
1967
+ ``tz`` is the display zone the Claude handler itself resolved, captured
1968
+ by ``_run_claude_json_adapter``; it reaches only the report renderer
1969
+ (#620 S1 D7), which formats week windows and capture timestamps.
1970
+ """
1956
1971
  title = _TERMINAL_TITLES.get(command, "Analytics Report")
1957
1972
  lines = [f"Claude {title}"]
1958
1973
  if result.status == "unavailable":
@@ -1962,11 +1977,29 @@ def _render_claude_terminal(command: str, result: SourceResult) -> str:
1962
1977
  if command == "diff" and isinstance(result.data, dict):
1963
1978
  _append_diff_terminal(lines, result.data)
1964
1979
  return "\n".join(lines)
1980
+ if command == "report" and isinstance(result.data, dict):
1981
+ # #620 S1 D7: render the real report. `_legacy_claude_totals` reads a
1982
+ # `totals` object this payload does not have, so it returned (0, 0)
1983
+ # and the section collapsed to `Data available.` — no current-week
1984
+ # table, no trend table, no `$ / 1%` column — while `report` on its
1985
+ # own printed all of it. The renderer is the SAME pure function
1986
+ # `cmd_report` calls, so the two cannot diverge.
1987
+ c = _cctally()
1988
+ lines.append(c.render_report_terminal(result.data, tz=tz))
1989
+ return "\n".join(lines)
1965
1990
  cost, tokens = _legacy_claude_totals(result.data)
1966
1991
  if cost or tokens:
1967
1992
  lines.append(f"Total: {_terminal_amount(cost)} · {_terminal_tokens(tokens)}")
1968
1993
  else:
1969
- lines.append("Data available.")
1994
+ # #620 S1 D7: a POPULATED result whose compatible cost and token
1995
+ # totals are both exactly zero. `Data available.` said nothing about
1996
+ # what was available or where to see it. State what is absent, and
1997
+ # name the command that shows the detail this section cannot.
1998
+ lines.append(
1999
+ "No compatible cost or token total for Claude in this result. "
2000
+ f"Run `cctally {command} --source claude` for the full Claude "
2001
+ "detail."
2002
+ )
1970
2003
  return "\n".join(lines)
1971
2004
 
1972
2005
 
@@ -2055,7 +2088,10 @@ def _emit_source_result(
2055
2088
  and result.status == "unavailable"
2056
2089
  )
2057
2090
  sections = [
2058
- _render_claude_terminal(command, claude),
2091
+ _render_claude_terminal(
2092
+ command, claude,
2093
+ tz=getattr(args, "_claude_resolved_tz", None),
2094
+ ),
2059
2095
  _render_codex_terminal(
2060
2096
  command, result, diff=diff,
2061
2097
  include_unavailable_diagnostic=not project_degradation,
@@ -1008,8 +1008,15 @@ def _authoritative_record_usage(
1008
1008
  selected-state writes. This frame owns the lock only on the
1009
1009
  ``lock_held=False`` path, so that is the only path that can fire the
1010
1010
  deferred nudge itself; a caller passing ``lock_held=True`` owns the lock
1011
- and must supply its own sink, or the nudge stays inside its section.
1011
+ and must supply its own sink. The fail-closed guard below enforces that
1012
+ contract before any tombstone or journal write begins.
1012
1013
  """
1014
+ if lock_held and nudge_dashboard and nudge_sink is None:
1015
+ return _AuthoritativeRecordResult(
1016
+ "record_failed",
1017
+ "lock_held authoritative record requires nudge_sink",
1018
+ )
1019
+
1013
1020
  if not lock_held:
1014
1021
  deferred = []
1015
1022
  try: