cctally 1.80.4 → 1.82.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 +32 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +56 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_pricing.py +27 -3
  41. package/bin/_lib_quota.py +81 -4
  42. package/bin/_lib_render.py +19 -5
  43. package/bin/_lib_share.py +25 -0
  44. package/bin/_lib_snapshot_cache.py +8 -0
  45. package/bin/_lib_subscription_weeks.py +16 -2
  46. package/bin/_lib_view_models.py +18 -9
  47. package/bin/cctally +43 -4
  48. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  49. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +4 -1
  52. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  53. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -107,10 +107,19 @@ def evt_id(kind: str, *parts: object) -> str:
107
107
  # fully-formed line records
108
108
  # --------------------------------------------------------------------------
109
109
 
110
- def make_obs(at: str, src: str, provider: str, payload: dict) -> dict:
110
+ def make_obs(at: str, src: str, provider: str, payload: dict,
111
+ account: str | None = None) -> dict:
111
112
  """Build a complete ``obs`` line record (raw capture; canonicalization is
112
- derivation-time, never baked into the stored line — spec §4.2)."""
113
+ derivation-time, never baked into the stored line — spec §4.2).
114
+
115
+ ``account`` (#341) is the account_key stamp, a top-level sibling of
116
+ ``provider``. It is emitted ONLY when supplied, so a pre-epic / single-account
117
+ writer that passes nothing produces a byte-identical line (and a byte-stable
118
+ content id). When supplied it participates in the content id, so two obs that
119
+ differ only by account are distinct records."""
113
120
  core = {"t": "obs", "at": at, "src": src, "provider": provider, "payload": payload}
121
+ if account is not None:
122
+ core["account"] = account
114
123
  return {"v": LINE_VERSION, **core, "id": content_id(core)}
115
124
 
116
125
 
@@ -133,6 +142,60 @@ def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
133
142
  "at": at, "src": "ingest", "payload": body}
134
143
 
135
144
 
145
+ # --------------------------------------------------------------------------
146
+ # accounts-machinery records (#341): registered op kinds folded into the
147
+ # `accounts` registry. These are NOT data-bearing account-stamped lines and are
148
+ # NOT legacy — the classifier recognises them by their registered `kind`.
149
+ # --------------------------------------------------------------------------
150
+
151
+ def make_account_observe(
152
+ at: str,
153
+ account_key: str,
154
+ provider: str,
155
+ *,
156
+ natural_id: str | None = None,
157
+ email: str | None = None,
158
+ plan_type: str | None = None,
159
+ label: str | None = None,
160
+ label_source: str | None = None,
161
+ ) -> dict:
162
+ """Build an ``account_observe`` op line — appended on first sight of an
163
+ account or an identity change (NOT every tick). Folded into the ``accounts``
164
+ registry by ``_apply_op_account_observe`` (rebuild applier). ``last_seen_utc``
165
+ is NOT carried here — it derives at fold time from the max ``at`` of any
166
+ account-stamped line (spec §1)."""
167
+ payload = {"kind": "account_observe", "account_key": account_key,
168
+ "provider": provider}
169
+ if natural_id is not None:
170
+ payload["natural_id"] = natural_id
171
+ if email is not None:
172
+ payload["email"] = email
173
+ if plan_type is not None:
174
+ payload["plan_type"] = plan_type
175
+ if label is not None:
176
+ payload["label"] = label
177
+ if label_source is not None:
178
+ payload["label_source"] = label_source
179
+ return make_op(at=at, src="account-observe", payload=payload)
180
+
181
+
182
+ def make_account_label(
183
+ at: str,
184
+ account_key: str,
185
+ label: str,
186
+ *,
187
+ provider: str | None = None,
188
+ ) -> dict:
189
+ """Build an ``account_label`` op line (a user rename). Folded by
190
+ ``_apply_op_account_label`` with ``label_source='user'`` — the top of the
191
+ label-precedence order (user > switcher > auto), so a later switcher/auto
192
+ enrichment never overrides it (spec §1)."""
193
+ payload = {"kind": "account_label", "account_key": account_key, "label": label}
194
+ if provider is not None:
195
+ payload["provider"] = provider
196
+ return make_op(at=at, src="account-label", payload=payload)
197
+
198
+
136
199
  # --------------------------------------------------------------------------
137
200
  # segment naming + canonical order
138
201
  # --------------------------------------------------------------------------
@@ -53,7 +53,7 @@ def _chip_for_model(name: str) -> str:
53
53
  # Date the embedded pricing snapshots below were last verified against
54
54
  # vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
55
55
  # is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
56
- PRICING_SNAPSHOT_DATE = "2026-07-19"
56
+ PRICING_SNAPSHOT_DATE = "2026-07-24"
57
57
  PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
58
58
 
59
59
  # Canonical machine-readable pricing source (Claude values + Codex values).
@@ -99,7 +99,7 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
99
99
 
100
100
  # Anthropic API pricing snapshot:
101
101
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
102
- # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
102
+ # - Captured: 2026-07-24 (see PRICING_SNAPSHOT_DATE)
103
103
  # - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
104
104
  # update in PRs touching this table.
105
105
  # 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
@@ -114,6 +114,22 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
114
114
  # value_drift on all four cost fields. Suppressed via PRICING_DRIFT_ALLOWLIST
115
115
  # above (the non-vacuity guard forces removal once LiteLLM reverts to the
116
116
  # standard rate after 2026-08-31).
117
+ # 2026-07-24: added claude-opus-5 ($5/$25 per MTok — identical to Opus
118
+ # 4.5/4.6/4.7/4.8; $6.25 5-minute cache write and $0.50 cache read at the
119
+ # standard 1.25x/0.1x multipliers). 1M context at standard pricing, so NO
120
+ # above-200k tier. Dateless pinned id with no dated twin (matches Anthropic's
121
+ # published ID/alias table). LiteLLM has no opus-5 entry yet, so this table is
122
+ # simply ahead of it — `ahead_of_litellm` is never a drift finding, so no
123
+ # PRICING_DRIFT_ALLOWLIST entry is needed.
124
+ #
125
+ # Known gap — Claude *fast mode* is not modelled. Anthropic bills fast mode at a
126
+ # premium ($10/$50 per MTok on Opus 5 and Opus 4.8; $30/$150 on Opus 4.7), and
127
+ # Claude Code records the tier per assistant entry at `message.usage.speed`
128
+ # ("standard" | "fast"), so it IS derivable from the JSONL we already ingest —
129
+ # but this table is one rate per model and `_calculate_entry_cost` ignores speed,
130
+ # so a fast-mode entry is priced at the standard rate (2x undercount on Opus 5).
131
+ # The Codex side already carries the shape for this
132
+ # (`_calculate_codex_entry_cost(..., speed=...)` + its fast-tier multiplier).
117
133
  CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
118
134
  "claude-3-5-haiku-20241022": {
119
135
  "input_cost_per_token": 8e-07,
@@ -269,6 +285,12 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
269
285
  "cache_creation_input_token_cost": 6.25e-06,
270
286
  "cache_read_input_token_cost": 5e-07,
271
287
  },
288
+ "claude-opus-5": {
289
+ "input_cost_per_token": 5e-06,
290
+ "output_cost_per_token": 2.5e-05,
291
+ "cache_creation_input_token_cost": 6.25e-06,
292
+ "cache_read_input_token_cost": 5e-07,
293
+ },
272
294
  "claude-sonnet-4-20250514": {
273
295
  "input_cost_per_token": 3e-06,
274
296
  "output_cost_per_token": 1.5e-05,
@@ -321,7 +343,9 @@ _unknown_model_warnings: set[str] = set()
321
343
  #
322
344
  # Codex (OpenAI) API pricing snapshot:
323
345
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
324
- # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
346
+ # - Captured: 2026-07-19 the last full Codex sync. PRICING_SNAPSHOT_DATE moved
347
+ # ahead to 2026-07-24 for the Claude-side opus-5 sync; these Codex values were
348
+ # NOT re-verified that day.
325
349
  # - As of the 2026-07-19 sync this carries every openai-provider
326
350
  # gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
327
351
  # nothing missing. Models absent from this table still fall back to `gpt-5`
package/bin/_lib_quota.py CHANGED
@@ -11,9 +11,11 @@ import datetime as dt
11
11
  import hashlib
12
12
  import json
13
13
  import math
14
- from dataclasses import dataclass, field
14
+ from dataclasses import dataclass, field, replace
15
15
  from typing import Iterable, Literal
16
16
 
17
+ import _lib_accounts
18
+
17
19
 
18
20
  FUTURE_CLOCK_SKEW_SECONDS = 300
19
21
  _SOURCE_PATH_KEY_PREFIX = b"cctally-source-path-v1\0"
@@ -52,11 +54,18 @@ class QuotaWindowIdentity:
52
54
  logical_limit_key: str
53
55
  observed_slot: str
54
56
  window_minutes: int
57
+ # account_key (#341) participates in equality/hashing so never-combine
58
+ # extends to accounts automatically: two accounts sharing one physical
59
+ # window key are distinct identities. It defaults to the reserved sentinel so
60
+ # a single-account / pre-#341 caller building an identity by keyword is
61
+ # byte-stable. limit_id/limit_name remain compare=False display metadata.
62
+ account_key: str = _lib_accounts.UNATTRIBUTED
55
63
  limit_id: str | None = field(default=None, compare=False)
56
64
  limit_name: str | None = field(default=None, compare=False)
57
65
 
58
66
  def __post_init__(self) -> None:
59
- for name in ("source", "source_root_key", "logical_limit_key", "observed_slot"):
67
+ for name in ("source", "source_root_key", "logical_limit_key", "observed_slot",
68
+ "account_key"):
60
69
  if not isinstance(getattr(self, name), str) or not getattr(self, name):
61
70
  raise ValueError(f"{name} must be a non-empty string")
62
71
  if not isinstance(self.window_minutes, int) or isinstance(self.window_minutes, bool):
@@ -194,14 +203,20 @@ class QuotaThresholdDecision:
194
203
  threshold: int
195
204
 
196
205
 
197
- def identity_sort_key(identity: QuotaWindowIdentity) -> tuple[str, str, str, str, int]:
198
- """Return the stable full logical identity ordering used by every selector."""
206
+ def identity_sort_key(identity: QuotaWindowIdentity) -> tuple[str, str, str, str, int, str]:
207
+ """Return the stable full logical identity ordering used by every selector.
208
+
209
+ ``account_key`` (#341) is appended LAST so the existing 5-element ordering is
210
+ preserved byte-for-byte for a single-account install (the account is constant
211
+ there) and only ever breaks a genuine cross-account tie deterministically.
212
+ """
199
213
  return (
200
214
  identity.source,
201
215
  identity.source_root_key,
202
216
  identity.logical_limit_key,
203
217
  identity.observed_slot,
204
218
  identity.window_minutes,
219
+ identity.account_key,
205
220
  )
206
221
 
207
222
 
@@ -270,6 +285,68 @@ def build_history(observations: Iterable[QuotaObservation]) -> tuple[QuotaHistor
270
285
  return tuple(result)
271
286
 
272
287
 
288
+ def _physical_window_key(observation: QuotaObservation) -> tuple[object, ...]:
289
+ """The account-INDEPENDENT physical window key used by the continuity fold.
290
+
291
+ Two observations share a physical window iff they agree on root, limit key,
292
+ slot, window minutes, and the exact (canonical UTC) reset boundary — the
293
+ account is deliberately EXCLUDED so unidentified observations can be adopted
294
+ by a same-window identified account (spec §2 window-account continuity).
295
+ """
296
+ identity = observation.identity
297
+ return (
298
+ identity.source,
299
+ identity.source_root_key,
300
+ identity.logical_limit_key,
301
+ identity.observed_slot,
302
+ identity.window_minutes,
303
+ observation.resets_at,
304
+ )
305
+
306
+
307
+ def adopt_unidentified_observations(
308
+ observations: Iterable[QuotaObservation],
309
+ ) -> tuple[QuotaObservation, ...]:
310
+ """Apply the window-account continuity rule (#341 spec §2 rev 4).
311
+
312
+ Group observations by their physical window key (account EXCLUDED). Within a
313
+ group, identified observations (``account_key != unattributed``) are
314
+ AUTHORITATIVE and NEVER re-assigned — two identified accounts sharing an
315
+ identical physical window key stay separate windows (never-combine holds
316
+ unconditionally). Unidentified observations are adopted by the window's
317
+ account IFF exactly ONE identified account is ever observed for that key;
318
+ zero or ambiguous (>=2) identified accounts leave them ``unattributed``.
319
+
320
+ Pure and order-preserving: identified observations pass through untouched; an
321
+ adopted unidentified observation is returned with its identity re-stamped to
322
+ the single identified account. A single-account install (all observations
323
+ already carry one account, or all are unattributed with no identified peer)
324
+ is a byte-stable no-op.
325
+ """
326
+ values = tuple(observations)
327
+ identified_by_window: dict[tuple[object, ...], set[str]] = {}
328
+ for observation in values:
329
+ account = observation.identity.account_key
330
+ if account != _lib_accounts.UNATTRIBUTED:
331
+ identified_by_window.setdefault(
332
+ _physical_window_key(observation), set()
333
+ ).add(account)
334
+ result: list[QuotaObservation] = []
335
+ for observation in values:
336
+ if observation.identity.account_key != _lib_accounts.UNATTRIBUTED:
337
+ result.append(observation)
338
+ continue
339
+ accounts = identified_by_window.get(_physical_window_key(observation))
340
+ if accounts is not None and len(accounts) == 1:
341
+ adopted = next(iter(accounts))
342
+ result.append(replace(
343
+ observation, identity=replace(observation.identity, account_key=adopted),
344
+ ))
345
+ else:
346
+ result.append(observation)
347
+ return tuple(result)
348
+
349
+
273
350
  def latest_physical_observation(observations: Iterable[QuotaObservation]) -> QuotaObservation | None:
274
351
  """Select the latest local physical capture with deterministic tie breaking."""
275
352
  values = tuple(observations)
@@ -811,6 +811,7 @@ def _bucket_to_json(
811
811
  *,
812
812
  list_key: str,
813
813
  date_key: str,
814
+ extra: "dict | None" = None,
814
815
  ) -> str:
815
816
  """Serialize bucket aggregates to JSON matching upstream ccusage's shape.
816
817
 
@@ -824,11 +825,15 @@ def _bucket_to_json(
824
825
  """
825
826
  bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
826
827
  totals = _bucket_totals_dict(buckets)
827
- payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
828
+ body = {list_key: bucket_list, "totals": totals}
829
+ if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
830
+ body.update(extra)
831
+ payload = stamp_schema_version(body)
828
832
  return json.dumps(payload, indent=2)
829
833
 
830
834
 
831
- def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
835
+ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date",
836
+ extra: "dict | None" = None) -> str:
832
837
  """Serialize ``[(label, [BucketUsage]), ...]`` to ``{projects:{label:[rows]},
833
838
  totals}`` (upstream ccusage daily --instances shape). Caller passes the
834
839
  disambiguated (unique, non-aliased) label per group; insertion order is
@@ -838,8 +843,10 @@ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str
838
843
  for label, buckets in project_groups:
839
844
  projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
840
845
  all_buckets.extend(buckets)
841
- payload = stamp_schema_version(
842
- {"projects": projects, "totals": _bucket_totals_dict(all_buckets)})
846
+ body = {"projects": projects, "totals": _bucket_totals_dict(all_buckets)}
847
+ if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
848
+ body.update(extra)
849
+ payload = stamp_schema_version(body)
843
850
  return json.dumps(payload, indent=2)
844
851
 
845
852
 
@@ -847,6 +854,8 @@ def _weekly_to_json(
847
854
  buckets: list[BucketUsage],
848
855
  weeks: list[SubWeek],
849
856
  week_pct_overlay: list[tuple[float | None, float | None]],
857
+ *,
858
+ extra: "dict | None" = None,
850
859
  ) -> str:
851
860
  """Serialize weekly rollup to JSON.
852
861
 
@@ -924,6 +933,8 @@ def _weekly_to_json(
924
933
  "totalCost": tot_cost,
925
934
  },
926
935
  }
936
+ if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
937
+ payload.update(extra)
927
938
  return json.dumps(stamp_schema_version(payload), indent=2)
928
939
 
929
940
 
@@ -1179,7 +1190,8 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
1179
1190
  return json.dumps(payload, indent=2)
1180
1191
 
1181
1192
 
1182
- def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
1193
+ def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage], *,
1194
+ extra: "dict | None" = None) -> str:
1183
1195
  """Serialize Claude sessions to JSON (spec A2.8, amended by issue #104).
1184
1196
 
1185
1197
  Per-session: sessionId, projectPath, sourcePaths (list), firstActivity
@@ -1242,6 +1254,8 @@ def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
1242
1254
  "totalCost": tot_cost,
1243
1255
  },
1244
1256
  }
1257
+ if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
1258
+ payload.update(extra)
1245
1259
  return json.dumps(stamp_schema_version(payload), indent=2)
1246
1260
 
1247
1261
 
package/bin/_lib_share.py CHANGED
@@ -920,6 +920,31 @@ def _build_anon_mapping(
920
920
  return mapping
921
921
 
922
922
 
923
+ def anonymize_account_label(
924
+ label: str, index: int, *, reveal: bool,
925
+ ) -> str:
926
+ """Map an account label to ``Account A/B/C`` unless ``reveal`` (#341 Task 4).
927
+
928
+ Account labels are user data (spec §4 Privacy) — a share/export must never
929
+ leak them, exactly like project names. This is the fail-closed anonymization
930
+ chokepoint: ``reveal`` is the SAME toggle that governs project reveal
931
+ (``reveal_projects``), so anon-mode (the default) rewrites the label to a
932
+ positional ``Account <letter>`` keyed by the account's deterministic registry
933
+ ``index`` (0→A, 1→B, …); only an explicit reveal shows the real label. Emails
934
+ never enter a share at all — the ``data.accounts[]`` wire carries no email
935
+ field — so this covers the only account user-data a share can see.
936
+ """
937
+ if reveal:
938
+ return label
939
+ if index < 0:
940
+ index = 0
941
+ # A..Z then Account-27, Account-28, … (registries never approach 26 real
942
+ # accounts, but stay total rather than raise).
943
+ if index < 26:
944
+ return f"Account {chr(ord('A') + index)}"
945
+ return f"Account {index + 1}"
946
+
947
+
923
948
  def _apply_anon_mapping(
924
949
  snap: ShareSnapshot, mapping: dict[_ProjectAnonKey, str] | dict[str, str],
925
950
  ) -> ShareSnapshot:
@@ -91,6 +91,12 @@ class SnapshotSignature(NamedTuple):
91
91
  # from the independently-committed quota/budget projection database.
92
92
  codex_physical_mutation_seq: int = 0
93
93
  codex_stats_digest: str = ""
94
+ # #341 finding 9: a digest of the account registry + the providers' on-disk
95
+ # identity-file/active-account state. Empty for every <=1-account install (no
96
+ # account ever observed), so it is byte-neutral there; otherwise it flips on
97
+ # an account SWITCH with zero new ingested rows, so the idle short-circuit is
98
+ # left and the `active` marker rebuilds on the next tick.
99
+ accounts_digest: str = ""
94
100
 
95
101
 
96
102
  def _max_id(conn: sqlite3.Connection, table: str) -> int:
@@ -179,6 +185,7 @@ def compute_signature(
179
185
  *,
180
186
  generation: int,
181
187
  codex_stats_digest: str = "",
188
+ accounts_digest: str = "",
182
189
  ) -> SnapshotSignature:
183
190
  """Composite data-version signature across cache.db + stats.db (spec §3).
184
191
 
@@ -199,6 +206,7 @@ def compute_signature(
199
206
  entry_mutation_seq=_entry_mutation_seq(cache_conn),
200
207
  codex_physical_mutation_seq=_codex_physical_mutation_seq(cache_conn),
201
208
  codex_stats_digest=str(codex_stats_digest),
209
+ accounts_digest=str(accounts_digest),
202
210
  )
203
211
 
204
212
 
@@ -284,9 +284,18 @@ def _compute_subscription_weeks(
284
284
  range_start: dt.datetime,
285
285
  range_end: dt.datetime,
286
286
  config: "dict | None" = None,
287
+ *,
288
+ account_key: "str | None",
287
289
  ) -> list[SubWeek]:
288
290
  """Generate the ordered list of subscription weeks overlapping [range_start, range_end].
289
291
 
292
+ ``account_key`` (#341, review finding 11): MANDATORY account context — no
293
+ silent global fallback. A real key scopes the snapshot-derived reset anchors
294
+ to that account so two accounts with different reset cadences never
295
+ re-anchor each other's week walk; ``None`` is the explicit "all accounts"
296
+ (merged) read used by the analytics callers, byte-identical to today on a
297
+ single-account install.
298
+
290
299
  Prefers snapshot rows (authoritative reset boundaries from actual data)
291
300
  and extrapolates by 7-day multiples only for the range tail before the
292
301
  earliest snapshot. When no snapshots exist at all, falls back to
@@ -309,7 +318,10 @@ def _compute_subscription_weeks(
309
318
  dates that miss actual snapshot boundaries for middle weeks. Using
310
319
  snapshot rows directly for weeks they cover avoids that drift.
311
320
  """
312
- # Case A: snapshots exist.
321
+ # Case A: snapshots exist. Account scoping (#341): a real key filters the
322
+ # reset anchors to that account; None is the merged (all-accounts) read.
323
+ acct_pred = "" if account_key is None else " AND account_key = ?"
324
+ acct_params: tuple = () if account_key is None else (account_key,)
313
325
  snap_rows = conn.execute(
314
326
  "SELECT "
315
327
  " MIN(week_start_at) AS week_start_at, "
@@ -320,8 +332,10 @@ def _compute_subscription_weeks(
320
332
  "WHERE week_start_at IS NOT NULL "
321
333
  " AND week_end_at IS NOT NULL "
322
334
  " AND week_start_date IS NOT NULL "
335
+ f" {acct_pred} "
323
336
  "GROUP BY week_start_date "
324
- "ORDER BY MIN(week_start_at) ASC"
337
+ "ORDER BY MIN(week_start_at) ASC",
338
+ acct_params,
325
339
  ).fetchall()
326
340
 
327
341
  weeks: list[SubWeek] = []
@@ -572,7 +572,8 @@ class WeeklyView:
572
572
 
573
573
 
574
574
  def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
575
- as_of_utc=None, mode="auto", aggregated_override=None):
575
+ as_of_utc=None, mode="auto", aggregated_override=None,
576
+ account_key=None):
576
577
  """Build a ``WeeklyView`` from subscription-week boundaries
577
578
  (spec §5.3).
578
579
 
@@ -648,7 +649,8 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
648
649
  week_start_at=sw.start_ts,
649
650
  week_end_at=sw.end_ts,
650
651
  )
651
- usage_row = get_usage(conn, ref, as_of_utc=as_of_utc)
652
+ usage_row = get_usage(conn, ref, as_of_utc=as_of_utc,
653
+ account_key=account_key)
652
654
  used_pct = None
653
655
  if usage_row is not None and usage_row["weekly_percent"] is not None:
654
656
  used_pct = float(usage_row["weekly_percent"])
@@ -759,7 +761,7 @@ class TrendView:
759
761
 
760
762
 
761
763
  def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
762
- use_weekref_cost_cache=False):
764
+ use_weekref_cost_cache=False, account_key=None):
763
765
  """Build a ``TrendView`` of the last ``n`` subscription weeks
764
766
  (spec §5.4).
765
767
 
@@ -799,7 +801,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
799
801
  get_usage = _cct_core.get_latest_usage_for_week
800
802
  c = _cctally()
801
803
 
802
- week_refs = c.get_recent_weeks(conn, max(1, n))
804
+ week_refs = c.get_recent_weeks(conn, max(1, n), account_key=account_key)
803
805
  if not week_refs:
804
806
  return TrendView(
805
807
  rows=(), avg_dollars_per_pct=None,
@@ -809,10 +811,13 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
809
811
 
810
812
  # Determine current_key + current_week_start_at — same pattern as
811
813
  # _tui_build_trend / cmd_report's Bug D handling.
814
+ _acct_pred = "" if account_key is None else " WHERE account_key = ?"
815
+ _acct_p = () if account_key is None else (account_key,)
812
816
  latest_usage = conn.execute(
813
817
  "SELECT week_start_date, week_end_date "
814
- "FROM weekly_usage_snapshots "
815
- "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
818
+ "FROM weekly_usage_snapshots" + _acct_pred +
819
+ " ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
820
+ _acct_p,
816
821
  ).fetchone()
817
822
  current_key = None
818
823
  current_week_start_at = None
@@ -858,6 +863,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
858
863
  as_of_utc=(
859
864
  week_ref.week_end_at if week_ref.key in split_keys else None
860
865
  ),
866
+ account_key=account_key,
861
867
  )
862
868
  usage_captured_at = usage["captured_at_utc"] if usage else None
863
869
  if c._week_ref_has_reset_event(conn, week_ref):
@@ -881,12 +887,12 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
881
887
  week_end_at=parse_iso(week_ref.week_end_at, "week_end_at"),
882
888
  now_utc=now_utc,
883
889
  compute=lambda: c._compute_cost_for_weekref(
884
- week_ref, skip_sync=True
890
+ week_ref, skip_sync=True, account_key=account_key
885
891
  ),
886
892
  )
887
893
  else:
888
894
  cost_usd = c._compute_cost_for_weekref(
889
- week_ref, skip_sync=skip_sync
895
+ week_ref, skip_sync=skip_sync, account_key=account_key
890
896
  )
891
897
  cost_captured_at = (
892
898
  usage_captured_at if cost_usd is not None else None
@@ -894,7 +900,8 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
894
900
  range_start_iso = week_ref.week_start_at
895
901
  range_end_iso = week_ref.week_end_at
896
902
  else:
897
- cost = c.get_latest_cost_for_week(conn, week_ref)
903
+ cost = c.get_latest_cost_for_week(
904
+ conn, week_ref, account_key=account_key)
898
905
  cost_usd = float(cost["cost_usd"]) if cost else None
899
906
  cost_captured_at = cost["captured_at_utc"] if cost else None
900
907
  range_start_iso = (
@@ -1605,6 +1612,7 @@ def build_forecast_view(
1605
1612
  skip_sync: bool = False,
1606
1613
  display_tz=None,
1607
1614
  use_weekref_cost_cache: bool = False,
1615
+ account_key=None,
1608
1616
  ):
1609
1617
  """Build a ``ForecastView`` (issue #57).
1610
1618
 
@@ -1626,6 +1634,7 @@ def build_forecast_view(
1626
1634
  inputs = c._load_forecast_inputs(
1627
1635
  conn, now_utc, skip_sync=skip_sync,
1628
1636
  use_weekref_cost_cache=use_weekref_cost_cache,
1637
+ account_key=account_key,
1629
1638
  )
1630
1639
  if inputs is None:
1631
1640
  return ForecastView(
package/bin/cctally CHANGED
@@ -876,6 +876,20 @@ _cmd_config_set = _cctally_config._cmd_config_set
876
876
  _cmd_config_unset = _cctally_config._cmd_config_unset
877
877
  _get_quota_alerts_config = _cctally_config._get_quota_alerts_config
878
878
 
879
+ # Account subcommand + the R8 decoration helpers (#341 Task 3). Eager re-export
880
+ # so the parser's `set_defaults(func=c.cmd_account)` binds and the alerts /
881
+ # --account / doctor / dashboard consumers reach the shared registry helpers
882
+ # through the cctally namespace.
883
+ _cctally_account = _load_sibling("_cctally_account")
884
+ cmd_account = _cctally_account.cmd_account
885
+ load_accounts = _cctally_account.load_accounts
886
+ real_account_count = _cctally_account.real_account_count
887
+ provider_is_decorated = _cctally_account.provider_is_decorated
888
+ account_label = _cctally_account.account_label
889
+ resolve_active_account_keys = _cctally_account.resolve_active_account_keys
890
+ resolve_account_filter = _cctally_account.resolve_account_filter
891
+ account_json_fields = _cctally_account.account_json_fields
892
+
879
893
 
880
894
  # `cctally refresh-usage` + the OAuth-usage fetch/render/config surface,
881
895
  # the in-process refresh helper consumed by the dashboard `POST /api/sync`
@@ -1015,6 +1029,7 @@ _collect_codex_entries_direct = _cctally_cache._collect_codex_entries_direct
1015
1029
  get_codex_entries = _cctally_cache.get_codex_entries
1016
1030
  _sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
1017
1031
  get_entries = _cctally_cache.get_entries
1032
+ AccountAttributionUnavailable = _cctally_cache.AccountAttributionUnavailable
1018
1033
  open_cache_db = _cctally_cache.open_cache_db
1019
1034
  open_conversations_db = _cctally_cache.open_conversations_db
1020
1035
  sync_claude_conversations = _cctally_cache.sync_claude_conversations
@@ -1772,14 +1787,21 @@ def _sum_cost_for_range(
1772
1787
  project: str | None = None,
1773
1788
  *,
1774
1789
  skip_sync: bool = False,
1790
+ account_key: "str | None" = None,
1775
1791
  ) -> float:
1776
1792
  """Sum USD cost of all Claude Code usage entries in [start, end].
1777
1793
 
1778
1794
  Pass `skip_sync=True` to read only the cache-as-of-now without running
1779
1795
  a JSONL ingest pass (for forecast --no-sync).
1796
+
1797
+ ``account_key`` (#341, P2-CQ2) scopes the sum to one account's stamped
1798
+ entries; ``None`` (default) sums all accounts (merged, byte-identical). Used
1799
+ by the per-account ``sync-week`` cost materialization + per-account budget
1800
+ ladders so a snapshot / budget carries genuinely per-account cost.
1780
1801
  """
1781
1802
  total = 0.0
1782
- for entry in get_entries(start, end, project=project, skip_sync=skip_sync):
1803
+ for entry in get_entries(start, end, project=project, skip_sync=skip_sync,
1804
+ account_key=account_key):
1783
1805
  total += _calculate_entry_cost(
1784
1806
  entry.model,
1785
1807
  entry.usage,
@@ -2106,10 +2128,14 @@ def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
2106
2128
 
2107
2129
 
2108
2130
  def _resolve_primary_model_for_block(
2109
- conn: sqlite3.Connection, five_hour_window_key: int
2131
+ conn: sqlite3.Connection, five_hour_window_key: int,
2132
+ *, account_key: str = "unattributed",
2110
2133
  ) -> "str | None":
2111
2134
  """Return the highest-cost model active in this 5h block, or ``None``.
2112
2135
 
2136
+ ``account_key`` (#341) scopes the child lookup so a shared physical window
2137
+ reflects THIS account's models.
2138
+
2113
2139
  Reads from ``five_hour_block_models`` (recompute-every-tick rollup-child;
2114
2140
  UNIQUE on ``(five_hour_window_key, model)``). Returns ``None`` when the
2115
2141
  child table has no row for this window (possible when the parent block
@@ -2124,9 +2150,9 @@ def _resolve_primary_model_for_block(
2124
2150
  """
2125
2151
  row = conn.execute(
2126
2152
  "SELECT model FROM five_hour_block_models "
2127
- "WHERE five_hour_window_key = ? "
2153
+ "WHERE five_hour_window_key = ? AND account_key = ? "
2128
2154
  "ORDER BY cost_usd DESC, model ASC LIMIT 1",
2129
- (int(five_hour_window_key),),
2155
+ (int(five_hour_window_key), account_key),
2130
2156
  ).fetchone()
2131
2157
  if row is None:
2132
2158
  return None
@@ -2881,6 +2907,13 @@ def _validate_source_args(args) -> None:
2881
2907
  return
2882
2908
  source = getattr(args, "source", "claude")
2883
2909
  speed = getattr(args, "speed", "auto")
2910
+ # #341: `--account` on the source-aware analytics commands scopes the CLAUDE
2911
+ # analytics only (the account dimension is provider-scoped; source `all` has
2912
+ # no account selector, and Codex account filtering lives on `codex quota`).
2913
+ # Reject `--account` with `--source {codex,all}` (spec §3 / handoff item d).
2914
+ if getattr(args, "account", None) is not None and source in {"codex", "all"}:
2915
+ raise ValueError(
2916
+ f"--account is only valid with --source claude (got --source {source})")
2884
2917
  if source == "claude" and speed != "auto":
2885
2918
  raise ValueError("--speed is only valid for Codex or all-source requests")
2886
2919
  if command == "project" and source in {"codex", "all"} and getattr(args, "sort", None) == "used":
@@ -3267,6 +3300,12 @@ def main(argv: list[str] | None = None) -> int:
3267
3300
  # generic DatabaseError so it never renders as a raw traceback.
3268
3301
  eprint(f"cctally: {exc}")
3269
3302
  return 3
3303
+ except AccountAttributionUnavailable as exc:
3304
+ # #341: a `--account`-scoped entry read hit a query-time cache degrade
3305
+ # (open failure / concurrent ingest) that would drop the account
3306
+ # identity. Fail closed (exit 3) rather than emit a mislabeled render.
3307
+ eprint(f"cctally: {exc}")
3308
+ return 3
3270
3309
  except sqlite3.DatabaseError as exc:
3271
3310
  if _is_sqlite_corruption_error(exc):
3272
3311
  eprint(