cctally 1.114.0 → 1.115.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 (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. package/package.json +14 -1
@@ -348,6 +348,10 @@ def _fetch_current_week_snapshots(*args, **kwargs):
348
348
  return sys.modules["cctally"]._fetch_current_week_snapshots(*args, **kwargs)
349
349
 
350
350
 
351
+ def _current_week_five_hour_reading(*args, **kwargs):
352
+ return sys.modules["cctally"]._current_week_five_hour_reading(*args, **kwargs)
353
+
354
+
351
355
  def _load_forecast_inputs(*args, **kwargs):
352
356
  return sys.modules["cctally"]._load_forecast_inputs(*args, **kwargs)
353
357
 
@@ -1682,12 +1686,14 @@ def _tui_build_current_week(
1682
1686
  # controls decoration, not the physical population used for computation.
1683
1687
  # Cost and forecast retain their existing merged semantics.
1684
1688
  fetched = _fetch_current_week_snapshots(
1685
- conn, now_utc, include_held=True, include_account=True)
1689
+ conn, now_utc, include_held=True, include_account=True,
1690
+ include_scope=True)
1686
1691
  if fetched is None:
1687
1692
  return None
1688
- week_start_at, week_end_at, observations = fetched
1693
+ week_start_at, week_end_at, observations, week_scope = fetched
1689
1694
  if not observations:
1690
1695
  return None
1696
+ week_start_before_override = week_start_at
1691
1697
  # Mirror the reset override applied by `_load_forecast_inputs` so the
1692
1698
  # Current Week card's spent_usd and $/1% reflect the post-reset window.
1693
1699
  week_start_at, observations = _apply_midweek_reset_override(
@@ -1698,8 +1704,8 @@ def _tui_build_current_week(
1698
1704
  # Held-inclusive sample shape: (captured_at_utc, weekly_percent,
1699
1705
  # five_hour_percent, weekly_observation_held[, account_key]). The optional
1700
1706
  # account key identifies the displayed weekly sample on a decorated store.
1701
- # The helper does not surface five_hour_resets_at, so do a targeted lookup
1702
- # below for the freshest non-NULL reset timestamp on the current week.
1707
+ # The helper does not surface five_hour_resets_at; the five-hour reading
1708
+ # selected below supplies it, with its percentage.
1703
1709
  #
1704
1710
  # #769 S11 (#824). This card publishes BOTH axes, and the dashboard
1705
1711
  # envelope reads `used_pct` and `latest_snapshot_at` straight off it for
@@ -1716,10 +1722,16 @@ def _tui_build_current_week(
1716
1722
  used_pct = float(latest[1])
1717
1723
  weekly_account = latest[4]
1718
1724
  # Five-hour readings may be newer held observations, but must remain on
1719
- # the account that supplied the displayed weekly reading.
1720
- five_hour_samples = [s for s in observations if s[4] == weekly_account]
1721
- five_hr_raw = five_hour_samples[-1][2]
1722
- five_hr_pct = float(five_hr_raw) if five_hr_raw is not None else None
1725
+ # the account that supplied the displayed weekly reading. The reading is
1726
+ # the newest five-hour READING under this card's own week and as-of scope
1727
+ # (#825/#832 spec 4.7 revision 7, the one shared selection): the newest
1728
+ # sample can be a retired replica or a weekly-only reading, which carries
1729
+ # no five-hour value and used to render as 0 %.
1730
+ five_hour = _current_week_five_hour_reading(
1731
+ conn, now_utc, week_scope, account_key=weekly_account,
1732
+ week_start_at=week_start_at,
1733
+ week_start_before_override=week_start_before_override)
1734
+ five_hr_pct = None if five_hour is None else five_hour.percent
1723
1735
  # #556 S1 §3.3: one walk yields both halves. The range is whatever
1724
1736
  # `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
1725
1737
  # above, so a mid-week reset shortens the accumulation and the published
@@ -1738,47 +1750,23 @@ def _tui_build_current_week(
1738
1750
  used_pct_corrected = corrected_percent_point(used_pct)
1739
1751
  dpp_divisor = used_pct if used_pct_corrected is None else used_pct_corrected
1740
1752
  dpp = (spent / dpp_divisor) if used_pct > 0 else None
1741
- # Collect every textual variant of week_start_at that parses to the same
1742
- # instant — mirrors `_fetch_current_week_snapshots` lines 9199-9210 so
1743
- # legacy local-offset rows and newly UTC-canonicalized rows both contribute.
1744
- ws_texts = conn.execute(
1745
- "SELECT DISTINCT week_start_at FROM weekly_usage_snapshots "
1746
- "WHERE week_start_at IS NOT NULL"
1747
- ).fetchall()
1748
- matching_ws_texts: list[str] = []
1749
- for r in ws_texts:
1753
+ # The reset instant comes from the SAME reading as the percentage beside
1754
+ # it (#825/#832 spec 4.7 revision 7: the shared selection returns the
1755
+ # percent and the reset together from one row), so the countdown never
1756
+ # describes another row. That reading is held-inclusive (#769 S11, #824),
1757
+ # 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:
1750
1760
  try:
1751
- rws = parse_iso_datetime(r[0], "week_start_at")
1761
+ five_hr_resets_at = parse_iso_datetime(
1762
+ five_hour.resets_at, "five_hour_resets_at"
1763
+ )
1752
1764
  except ValueError:
1753
- continue
1754
- if rws == week_start_at:
1755
- matching_ws_texts.append(r[0])
1756
- five_hr_resets_at: dt.datetime | None = None
1757
- if matching_ws_texts:
1758
- placeholders = ",".join("?" * len(matching_ws_texts))
1759
- # HELD-INCLUSIVE, deliberately (#769 S11, #824): the reset instant is a
1760
- # five-hour fact, and a held row's five-hour fields are the writing
1761
- # tick's own. Excluding held rows here would make the countdown lag the
1762
- # five-hour percentage rendered beside it.
1763
- reset_row = conn.execute(
1764
- f"SELECT five_hour_resets_at FROM weekly_usage_snapshots "
1765
- f"WHERE week_start_at IN ({placeholders}) "
1766
- f" AND five_hour_resets_at IS NOT NULL "
1767
- f" AND account_key = ? "
1768
- f"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
1769
- tuple(matching_ws_texts) + (weekly_account,),
1770
- ).fetchone()
1771
- if reset_row is not None:
1772
- try:
1773
- five_hr_resets_at = parse_iso_datetime(
1774
- reset_row[0], "five_hour_resets_at"
1775
- )
1776
- except ValueError:
1777
- five_hr_resets_at = None
1778
- # Suppress stale resets that have already elapsed so renderers
1779
- # don't show "resets 0h 00m" or a negative duration at the boundary.
1780
- if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
1781
- five_hr_resets_at = None
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
1782
1770
  # Freshness — compute label/age from latest snapshot vs. now using the
1783
1771
  # configured oauth_usage thresholds. Mirrors the dashboard envelope's
1784
1772
  # cw_freshness derivation in `snapshot_to_envelope`. Refs spec §3.4.
@@ -6878,7 +6866,6 @@ def _tui_panel_current_week(
6878
6866
  bar_fill = _tui_bar_string(cw.used_pct, bar_w)
6879
6867
 
6880
6868
  five = cw.five_hour_pct or 0.0
6881
- five_bar = _tui_bar_string(five, bar_w)
6882
6869
 
6883
6870
  reset_delta = cw.week_end_at - snap.generated_at
6884
6871
  reset_days = max(0, reset_delta.days)
@@ -6914,6 +6901,17 @@ def _tui_panel_current_week(
6914
6901
  for c in fh_credits
6915
6902
  )
6916
6903
  fh_credit_badge = f" {{bright}}⚡ {deltas}{{/}}"
6904
+ # The 5-hour row is " 5-hour " (8 cells) + bar + " NNN%" (5) + badge, and
6905
+ # it must fit the panel interior (width - 2). A bar of the Used row's width
6906
+ # leaves five cells, while the badge's wide ⚡ glyph alone costs two, so a
6907
+ # badge shortens the 5-hour bar instead of spilling into the next panel.
6908
+ # It also leaves one blank cell before the right border, as every other
6909
+ # row of the panel does.
6910
+ five_w = bar_w
6911
+ if fh_credit_badge:
6912
+ badge_cells = _tui_lines_to_text([fh_credit_badge]).cell_len
6913
+ five_w = max(10, min(bar_w, width - 2 - 13 - badge_cells - 1))
6914
+ five_bar = _tui_bar_string(five, five_w)
6917
6915
  lines = [
6918
6916
  "",
6919
6917
  f" Used {{{used_cls}}}{bar_fill}{{/}} {{{used_cls}.b}}{cw.used_pct:>5.1f}%{{/}}",
@@ -7648,7 +7646,13 @@ def _tui_tagged_box_lines(
7648
7646
  When a body line's plain length exceeds the interior width it is truncated
7649
7647
  with ``{/}`` appended as a safety net — callers should size their content
7650
7648
  to avoid this branch.
7649
+
7650
+ Lengths are terminal CELLS, not characters: a wide glyph such as the
7651
+ 5-hour credit badge's ``⚡`` is one character but two cells, and padding
7652
+ by characters pushed that row's right border one cell out of the box
7653
+ (#825/#832, UI-QA of fix round K16c).
7651
7654
  """
7655
+ from rich.cells import cell_len, set_cell_size
7652
7656
  H, V = _TUI_BOX["h"], _TUI_BOX["v"]
7653
7657
  TL, TR, BL, BR = _TUI_BOX["tl"], _TUI_BOX["tr"], _TUI_BOX["bl"], _TUI_BOX["br"]
7654
7658
  interior = width - 2
@@ -7662,12 +7666,12 @@ def _tui_tagged_box_lines(
7662
7666
  t_seg = f" {title} " if title else ""
7663
7667
  p_seg = f" {pin} " if pin else ""
7664
7668
  if title and pin:
7665
- fill = width - 4 - len(_tui_strip_tags(t_seg)) - len(_tui_strip_tags(p_seg))
7669
+ fill = width - 4 - cell_len(_tui_strip_tags(t_seg)) - cell_len(_tui_strip_tags(p_seg))
7666
7670
  if fill >= 1:
7667
7671
  return (_wrap_border(TL + H) + t_seg
7668
7672
  + _wrap_border(H * fill) + p_seg + _wrap_border(H + TR))
7669
7673
  if title:
7670
- fill = width - 3 - len(_tui_strip_tags(t_seg))
7674
+ fill = width - 3 - cell_len(_tui_strip_tags(t_seg))
7671
7675
  if fill >= 1:
7672
7676
  return _wrap_border(TL + H) + t_seg + _wrap_border(H * fill + TR)
7673
7677
  return _wrap_border(TL + H * interior + TR)
@@ -7675,14 +7679,14 @@ def _tui_tagged_box_lines(
7675
7679
  lines: list[str] = [_top()]
7676
7680
  for line in body_tagged:
7677
7681
  plain = _tui_strip_tags(line)
7678
- if len(plain) > interior:
7682
+ if cell_len(plain) > interior:
7679
7683
  # On overflow, drop color markup entirely — partial-tag truncation
7680
7684
  # would produce malformed tokens that crash _tui_colortag. Plain-
7681
7685
  # text is always safe, and callers are expected to size content
7682
7686
  # to fit (this branch is a safety net only).
7683
- line = plain[: max(0, interior - 1)] + "…"
7687
+ line = set_cell_size(plain, max(0, interior - 1)) + "…"
7684
7688
  plain = line
7685
- pad = interior - len(plain)
7689
+ pad = interior - cell_len(plain)
7686
7690
  lines.append(_wrap_border(V) + line + " " * pad + _wrap_border(V))
7687
7691
  lines.append(_wrap_border(BL + H * interior + BR))
7688
7692
  return lines
@@ -0,0 +1,152 @@
1
+ """Immutable account binding carried by every current statusline/OAuth producer.
2
+
3
+ #825/#832 spec 4.1. A binding is captured ONCE, from the stable-read identity
4
+ resolver (`_cctally_core._resolve_active_claude_identity`), and then travels
5
+ with the candidate, the publication plan or the OAuth publication it belongs
6
+ to. No later stage re-resolves the active account: an A->B switch between
7
+ capture and publication can therefore never re-label A's reading as B's.
8
+
9
+ - ``identified`` binds the real account key, which must equal
10
+ ``_lib_accounts.account_key("claude", natural_id)``;
11
+ - ``stably_absent`` (no ``~/.claude.json`` account, api-key mode) binds the
12
+ ``unattributed`` sentinel deliberately;
13
+ - ``torn`` and every unreadable or internally inconsistent read yield NO
14
+ binding. The caller then writes no candidate, mutates no control state and
15
+ consumes no retry attempt.
16
+
17
+ The credential evidence is a salted digest of the selected OAuth credential,
18
+ never the credential itself. Serialization is strict: an exact camelCase key
19
+ set, and ``load`` re-derives the identity evidence and compares it.
20
+
21
+ Pure: no filesystem, clock or ``cctally`` namespace dependency.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import dataclasses
26
+ import hashlib
27
+ import json
28
+ import re
29
+
30
+ import _lib_accounts
31
+
32
+ STATUS_IDENTIFIED = "identified"
33
+ STATUS_STABLY_ABSENT = "stably_absent"
34
+
35
+ _CREDENTIAL_SALT = "cctally-selected-credential-v1\0"
36
+ _DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z")
37
+ _DOCUMENT_KEYS = frozenset({
38
+ "status", "accountKey", "identityEvidence", "credentialEvidence",
39
+ "naturalId", "email", "planType",
40
+ })
41
+
42
+
43
+ @dataclasses.dataclass(frozen=True)
44
+ class AccountBinding:
45
+ status: str
46
+ account_key: str
47
+ identity_evidence: str
48
+ credential_evidence: str | None = None
49
+ natural_id: str | None = None
50
+ email: str | None = None
51
+ plan_type: str | None = None
52
+
53
+ def document(self) -> dict:
54
+ """The exact persisted shape (camelCase, every key always present)."""
55
+ return {
56
+ "status": self.status,
57
+ "accountKey": self.account_key,
58
+ "identityEvidence": self.identity_evidence,
59
+ "credentialEvidence": self.credential_evidence,
60
+ "naturalId": self.natural_id,
61
+ "email": self.email,
62
+ "planType": self.plan_type,
63
+ }
64
+
65
+ def identity(self) -> dict:
66
+ """The resolver's own shape, for consumers that take an identity."""
67
+ return {
68
+ "status": self.status,
69
+ "account_key": self.account_key,
70
+ "natural_id": self.natural_id,
71
+ "email": self.email,
72
+ "plan_type": self.plan_type,
73
+ }
74
+
75
+ def without_credential(self) -> "AccountBinding":
76
+ """The same identity with no credential evidence (identity equality)."""
77
+ return dataclasses.replace(self, credential_evidence=None)
78
+
79
+
80
+ def credential_digest(credential: str) -> str:
81
+ """The salted credential evidence; the credential itself is never kept."""
82
+ return hashlib.sha256(
83
+ (_CREDENTIAL_SALT + credential).encode("utf-8")).hexdigest()
84
+
85
+
86
+ def _identity_evidence(fields: dict) -> str:
87
+ return hashlib.sha256(json.dumps(
88
+ fields, sort_keys=True, separators=(",", ":")).encode("utf-8")
89
+ ).hexdigest()
90
+
91
+
92
+ def capture(identity, *, credential: str | None = None) -> AccountBinding | None:
93
+ """Bind one resolver result, or return None for a torn, unreadable or
94
+ internally inconsistent read. Never guesses an account."""
95
+ if not isinstance(identity, dict):
96
+ return None
97
+ status = identity.get("status")
98
+ key = identity.get("account_key")
99
+ natural = identity.get("natural_id")
100
+ if status == STATUS_IDENTIFIED:
101
+ if not isinstance(natural, str) or not natural:
102
+ return None
103
+ if key != _lib_accounts.account_key("claude", natural):
104
+ return None
105
+ elif status == STATUS_STABLY_ABSENT:
106
+ if key != _lib_accounts.UNATTRIBUTED or natural is not None:
107
+ return None
108
+ else:
109
+ return None
110
+ fields = {
111
+ "status": status,
112
+ "account_key": key,
113
+ "natural_id": natural,
114
+ "email": identity.get("email"),
115
+ "plan_type": identity.get("plan_type"),
116
+ }
117
+ if any(value is not None and not isinstance(value, str)
118
+ for value in fields.values()):
119
+ return None
120
+ if credential is not None and (not isinstance(credential, str)
121
+ or not credential):
122
+ return None
123
+ return AccountBinding(
124
+ status=status,
125
+ account_key=key,
126
+ identity_evidence=_identity_evidence(fields),
127
+ credential_evidence=(
128
+ None if credential is None else credential_digest(credential)),
129
+ natural_id=natural,
130
+ email=fields["email"],
131
+ plan_type=fields["plan_type"],
132
+ )
133
+
134
+
135
+ def load(document) -> AccountBinding:
136
+ """Validate a persisted binding document; ValueError on any mismatch."""
137
+ if not isinstance(document, dict) or set(document) != _DOCUMENT_KEYS:
138
+ raise ValueError("invalid account binding document")
139
+ binding = capture({
140
+ "status": document["status"],
141
+ "account_key": document["accountKey"],
142
+ "natural_id": document["naturalId"],
143
+ "email": document["email"],
144
+ "plan_type": document["planType"],
145
+ })
146
+ if binding is None or binding.identity_evidence != document["identityEvidence"]:
147
+ raise ValueError("inconsistent account binding")
148
+ evidence = document["credentialEvidence"]
149
+ if evidence is not None and (not isinstance(evidence, str)
150
+ or _DIGEST_RE.fullmatch(evidence) is None):
151
+ raise ValueError("invalid credential evidence")
152
+ return dataclasses.replace(binding, credential_evidence=evidence)