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
@@ -54,6 +54,15 @@ def _load_lib(name: str):
54
54
  _lib_display_tz = _load_lib("_lib_display_tz")
55
55
  format_display_dt = _lib_display_tz.format_display_dt
56
56
 
57
+ # Account dimension (#341): every alert payload carries the account_key it wrote
58
+ # under as a TOP-LEVEL field (never in `context`, to avoid touching any
59
+ # context-serialized golden). Real-account families default to the sentinel;
60
+ # vendor-wide families default to `*`. `_dispatch_alert_notification` reads it for
61
+ # the unconditional 8th `alerts.log` field + the R8 `[label]` title prefix.
62
+ _lib_accounts = _load_lib("_lib_accounts")
63
+ _UNATTRIBUTED = _lib_accounts.UNATTRIBUTED
64
+ _VENDOR_WIDE = _lib_accounts.VENDOR_WIDE
65
+
57
66
 
58
67
  def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
59
68
  """Build (title, subtitle, body) for a weekly threshold alert.
@@ -137,6 +146,7 @@ def _build_alert_payload_weekly(
137
146
  week_start_date: str,
138
147
  cumulative_cost_usd: float,
139
148
  dollars_per_percent: "float | None",
149
+ account_key: str = _UNATTRIBUTED,
140
150
  ) -> dict:
141
151
  """Build the alert payload for a weekly threshold crossing.
142
152
 
@@ -144,14 +154,15 @@ def _build_alert_payload_weekly(
144
154
  sets the DB ``alerted_at`` BEFORE invoking ``_dispatch_alert_notification``
145
155
  (set-then-dispatch invariant, spec §3.2). Consumers (envelope builders,
146
156
  test inspectors) read the ``alerted_at`` field as the authoritative
147
- "alert was attempted" timestamp.
148
- """
157
+ "alert was attempted" timestamp. ``account_key`` (#341) is the real account
158
+ the crossing wrote under (per-account family)."""
149
159
  return {
150
160
  "id": f"weekly:{week_start_date}:{threshold}",
151
161
  "axis": "weekly",
152
162
  "threshold": int(threshold),
153
163
  "crossed_at": crossed_at_utc,
154
164
  "alerted_at": crossed_at_utc, # set-then-dispatch
165
+ "account_key": str(account_key),
155
166
  "context": {
156
167
  "week_start_date": week_start_date,
157
168
  "cumulative_cost_usd": float(cumulative_cost_usd),
@@ -170,6 +181,7 @@ def _build_alert_payload_five_hour(
170
181
  block_start_at: str,
171
182
  block_cost_usd: float,
172
183
  primary_model: "str | None",
184
+ account_key: str = _UNATTRIBUTED,
173
185
  ) -> dict:
174
186
  """Build the alert payload for a 5h-block threshold crossing.
175
187
 
@@ -177,14 +189,15 @@ def _build_alert_payload_five_hour(
177
189
  rationale. ``primary_model`` is the highest-cost model active in the
178
190
  block (resolved via ``_resolve_primary_model_for_block``); ``None`` when
179
191
  the rollup-children child table is empty (e.g., direct-JSONL-fallback
180
- path before lazy backfill).
181
- """
192
+ path before lazy backfill). ``account_key`` (#341) is the block's account
193
+ (per-account family)."""
182
194
  return {
183
195
  "id": f"five_hour:{five_hour_window_key}:{threshold}",
184
196
  "axis": "five_hour",
185
197
  "threshold": int(threshold),
186
198
  "crossed_at": crossed_at_utc,
187
199
  "alerted_at": crossed_at_utc, # set-then-dispatch
200
+ "account_key": str(account_key),
188
201
  "context": {
189
202
  "five_hour_window_key": int(five_hour_window_key),
190
203
  "block_start_at": block_start_at,
@@ -223,6 +236,7 @@ def _build_alert_payload_budget(
223
236
  spent_usd: float,
224
237
  consumption_pct: float,
225
238
  period: str = "subscription-week",
239
+ account_key: str = _VENDOR_WIDE,
226
240
  ) -> dict:
227
241
  """Build the alert payload for an equiv-$ budget threshold crossing.
228
242
 
@@ -245,6 +259,7 @@ def _build_alert_payload_budget(
245
259
  "threshold": int(threshold),
246
260
  "crossed_at": crossed_at_utc,
247
261
  "alerted_at": crossed_at_utc, # set-then-dispatch
262
+ "account_key": str(account_key),
248
263
  "context": {
249
264
  "week_start_at": week_start_at,
250
265
  "period": str(period),
@@ -296,6 +311,7 @@ def _build_alert_payload_project_budget(
296
311
  budget_usd: float,
297
312
  spent_usd: float,
298
313
  consumption_pct: float,
314
+ account_key: str = _VENDOR_WIDE,
299
315
  ) -> dict:
300
316
  """Build the alert payload for a PER-PROJECT equiv-$ budget threshold
301
317
  crossing (axis ``project_budget``, the fifth alert axis; spec §5.3).
@@ -315,6 +331,7 @@ def _build_alert_payload_project_budget(
315
331
  "threshold": int(threshold),
316
332
  "crossed_at": crossed_at_utc,
317
333
  "alerted_at": crossed_at_utc, # set-then-dispatch
334
+ "account_key": str(account_key),
318
335
  "context": {
319
336
  "week_start_at": week_start_at,
320
337
  "project": project,
@@ -369,6 +386,7 @@ def _build_alert_payload_codex_budget(
369
386
  budget_usd: float,
370
387
  spent_usd: float,
371
388
  consumption_pct: float,
389
+ account_key: str = _VENDOR_WIDE,
372
390
  ) -> dict:
373
391
  """Build the alert payload for a Codex budget threshold crossing (axis
374
392
  ``codex_budget``, the sixth alert axis; spec §6).
@@ -388,6 +406,7 @@ def _build_alert_payload_codex_budget(
388
406
  "threshold": int(threshold),
389
407
  "crossed_at": crossed_at_utc,
390
408
  "alerted_at": crossed_at_utc, # set-then-dispatch
409
+ "account_key": str(account_key),
391
410
  "context": {
392
411
  "period": str(period),
393
412
  "period_start_at": period_start_at,
@@ -442,6 +461,7 @@ def _build_alert_payload_projected(
442
461
  projected_value: float,
443
462
  denominator: float,
444
463
  week_start_at: str,
464
+ account_key: str = _VENDOR_WIDE,
445
465
  ) -> dict:
446
466
  """Build the alert payload for a projected-pace threshold crossing (#121).
447
467
 
@@ -464,6 +484,7 @@ def _build_alert_payload_projected(
464
484
  "threshold": int(threshold),
465
485
  "projected_value": float(projected_value),
466
486
  "denominator": float(denominator),
487
+ "account_key": str(account_key),
467
488
  "context": {
468
489
  "week_start_at": week_start_at,
469
490
  "metric": str(metric),
@@ -0,0 +1,77 @@
1
+ """Ordered cross-process writer locks for the shared cache.db family.
2
+
3
+ Every cache.db mutator takes the global writer lock first. Codex mutators then
4
+ take the provider lock before opening a SQLite write transaction. A single
5
+ deadline covers the complete lock set so adding the provider lock cannot double
6
+ the caller's bounded-wait contract.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fcntl
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+
16
+
17
+ def acquire_ordered_flocks(
18
+ locks: list[tuple[Path, int]],
19
+ *,
20
+ timeout: float | None = None,
21
+ ) -> list[int] | None:
22
+ """Acquire ``(path, LOCK_SH|LOCK_EX)`` entries in caller-supplied order."""
23
+ deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
24
+ held: list[int] = []
25
+ try:
26
+ for path, mode in locks:
27
+ fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
28
+ os.fchmod(fd, 0o600)
29
+ held.append(fd)
30
+ while True:
31
+ try:
32
+ fcntl.flock(fd, mode | fcntl.LOCK_NB)
33
+ break
34
+ except BlockingIOError:
35
+ if deadline is None or time.monotonic() >= deadline:
36
+ release_cache_writer_flocks(held)
37
+ return None
38
+ time.sleep(min(0.2, max(0.0, deadline - time.monotonic())))
39
+ return held
40
+ except BaseException:
41
+ release_cache_writer_flocks(held)
42
+ raise
43
+
44
+
45
+ def acquire_cache_writer_flocks(
46
+ writer_path: Path,
47
+ provider_path: Path | None = None,
48
+ *,
49
+ timeout: float | None = None,
50
+ ) -> list[int] | None:
51
+ """Acquire global then optional provider flock, or return ``None``.
52
+
53
+ ``timeout=None`` performs one non-blocking attempt for the entire set.
54
+ A non-negative timeout bounds the complete acquisition, not each lock.
55
+ Returned descriptors must be released with
56
+ :func:`release_cache_writer_flocks`.
57
+ """
58
+ paths = [writer_path]
59
+ if provider_path is not None and provider_path != writer_path:
60
+ paths.append(provider_path)
61
+ return acquire_ordered_flocks(
62
+ [(path, fcntl.LOCK_EX) for path in paths],
63
+ timeout=timeout,
64
+ )
65
+
66
+
67
+ def release_cache_writer_flocks(held: list[int]) -> None:
68
+ """Release an acquired lock set in reverse order."""
69
+ for fd in reversed(held):
70
+ try:
71
+ fcntl.flock(fd, fcntl.LOCK_UN)
72
+ except OSError:
73
+ pass
74
+ try:
75
+ os.close(fd)
76
+ except OSError:
77
+ pass
@@ -301,12 +301,43 @@ def codex_hook_roots(paths: list[pathlib.Path]) -> list[CodexHookRoot]:
301
301
  return [roots[key] for key in sorted(roots)]
302
302
 
303
303
 
304
+ def _resolve_codex_hook_account(root: CodexHookRoot) -> str:
305
+ """Resolve one hook root's active account_key from ``<home>/auth.json`` via
306
+ the stable-read protocol (#341, spec §1). identified -> the real key;
307
+ stably-absent (no auth / api-key mode) OR torn -> the ``unattributed``
308
+ sentinel (so the throttle marker keeps its byte-stable name). Read-only."""
309
+ import _lib_accounts
310
+
311
+ def _reader(data: bytes):
312
+ try:
313
+ obj = json.loads(data)
314
+ except (ValueError, TypeError):
315
+ raise _lib_accounts.TornRead()
316
+ if not isinstance(obj, dict):
317
+ raise _lib_accounts.TornRead()
318
+ tokens = obj.get("tokens")
319
+ id_token = (tokens.get("id_token") if isinstance(tokens, dict)
320
+ else obj.get("id_token"))
321
+ payload = _lib_accounts.decode_id_token_payload(id_token)
322
+ nat = _lib_accounts.codex_natural_id(payload)
323
+ if nat is None:
324
+ return None
325
+ return _lib_accounts.account_key("codex", nat)
326
+
327
+ result = _lib_accounts.stable_read_identity(
328
+ str(root.codex_home / "auth.json"), _reader)
329
+ if result.status == "identified":
330
+ return str(result.value)
331
+ return _lib_accounts.UNATTRIBUTED
332
+
333
+
304
334
  def acquire_due_lifecycle_locks(
305
335
  app_dir: pathlib.Path,
306
336
  roots: list[CodexHookRoot],
307
337
  *,
308
338
  now: float,
309
339
  throttle_seconds: float = CODEX_HOOK_THROTTLE_SECONDS,
340
+ account_resolver: "Callable[[CodexHookRoot], str] | None" = None,
310
341
  ) -> list[CodexLifecycleLock]:
311
342
  base = app_dir / "codex-hook-tick"
312
343
  base.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -314,10 +345,18 @@ def acquire_due_lifecycle_locks(
314
345
  os.chmod(base, 0o700)
315
346
  except OSError:
316
347
  pass
348
+ resolve_account = account_resolver or _resolve_codex_hook_account
317
349
  held: list[CodexLifecycleLock] = []
318
350
  for root in sorted(roots, key=lambda item: item.source_root_key):
319
351
  lock_path = base / f"{root.source_root_key}.lock"
320
- marker_path = base / f"{root.source_root_key}.last-success"
352
+ # Throttle marker keys by (source_root_key, account_key) so a mid-interval
353
+ # account switch bypasses the PRIOR account's throttle (spec §1). The
354
+ # ``unattributed`` sentinel keeps the byte-stable legacy marker name (no
355
+ # account suffix) — single-account / no-auth installs are unchanged.
356
+ import _lib_accounts
357
+ account = resolve_account(root)
358
+ suffix = "" if account == _lib_accounts.UNATTRIBUTED else f".{account}"
359
+ marker_path = base / f"{root.source_root_key}{suffix}.last-success"
321
360
  fd = -1
322
361
  try:
323
362
  fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
@@ -470,7 +470,8 @@ def _humanize_tokens(n: "int | None") -> str:
470
470
  return f"{sign}{a / 1_000_000_000:.1f}B"
471
471
 
472
472
 
473
- def _diff_iter_claude_entries(window: ParsedWindow, *, skip_sync: bool):
473
+ def _diff_iter_claude_entries(window: ParsedWindow, *, skip_sync: bool,
474
+ account_key=None):
474
475
  """Honor ParsedWindow's half-open semantics by trimming end_utc by 1 µs
475
476
  before passing into the inclusive-end shared cache helper.
476
477
 
@@ -479,10 +480,14 @@ def _diff_iter_claude_entries(window: ParsedWindow, *, skip_sync: bool):
479
480
  end-of-day semantics for date-only inputs — so we cannot tighten the
480
481
  helper's SQL. `ParsedWindow.end_utc` is documented exclusive, so trim
481
482
  by one microsecond locally to bridge the convention gap.
483
+
484
+ ``account_key`` (#341 --account) scopes the read to one account's stamped
485
+ entries; ``None`` = merged (byte-identical).
482
486
  """
483
487
  end_exclusive = window.end_utc - dt.timedelta(microseconds=1)
484
488
  return get_claude_session_entries(
485
- window.start_utc, end_exclusive, skip_sync=skip_sync
489
+ window.start_utc, end_exclusive, skip_sync=skip_sync,
490
+ account_key=account_key,
486
491
  )
487
492
 
488
493
 
@@ -490,6 +495,7 @@ def _diff_aggregate_overall(
490
495
  window: ParsedWindow,
491
496
  *,
492
497
  skip_sync: bool = False,
498
+ account_key=None,
493
499
  ) -> MetricBundle:
494
500
  """Sum cost, tokens, and cache stats across all entries in `window`.
495
501
 
@@ -500,7 +506,8 @@ def _diff_aggregate_overall(
500
506
  """
501
507
  cost = 0.0
502
508
  ti = to = tcr = tcw = 0
503
- for e in _diff_iter_claude_entries(window, skip_sync=skip_sync):
509
+ for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
510
+ account_key=account_key):
504
511
  if e.model == "<synthetic>":
505
512
  continue
506
513
  cost += _calculate_entry_cost(
@@ -532,10 +539,12 @@ def _diff_aggregate_models(
532
539
  window: ParsedWindow,
533
540
  *,
534
541
  skip_sync: bool = False,
542
+ account_key=None,
535
543
  ) -> dict:
536
544
  """Group entries by model id, aggregate to per-model MetricBundle."""
537
545
  buckets: dict = {}
538
- for e in _diff_iter_claude_entries(window, skip_sync=skip_sync):
546
+ for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
547
+ account_key=account_key):
539
548
  if e.model == "<synthetic>":
540
549
  continue
541
550
  b = buckets.setdefault(e.model, {
@@ -568,12 +577,14 @@ def _diff_aggregate_projects(
568
577
  window: ParsedWindow,
569
578
  *,
570
579
  skip_sync: bool = False,
580
+ account_key=None,
571
581
  group_mode: str = "git-root",
572
582
  ) -> dict:
573
583
  """Group entries by ProjectKey.display_key (git-root resolved)."""
574
584
  resolver_cache: dict = {}
575
585
  buckets: dict = {}
576
- for e in _diff_iter_claude_entries(window, skip_sync=skip_sync):
586
+ for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
587
+ account_key=account_key):
577
588
  if e.model == "<synthetic>":
578
589
  continue
579
590
  key = _resolve_project_key(e.project_path, group_mode, resolver_cache)
@@ -607,6 +618,7 @@ def _diff_aggregate_cache(
607
618
  window: ParsedWindow,
608
619
  *,
609
620
  skip_sync: bool = False,
621
+ account_key=None,
610
622
  ) -> dict:
611
623
  """Cache-active-entries scope: only entries that touched the cache.
612
624
 
@@ -619,7 +631,8 @@ def _diff_aggregate_cache(
619
631
  """
620
632
  cost = 0.0
621
633
  tcr = tcw = ti = 0
622
- for e in _diff_iter_claude_entries(window, skip_sync=skip_sync):
634
+ for e in _diff_iter_claude_entries(window, skip_sync=skip_sync,
635
+ account_key=account_key):
623
636
  if e.model == "<synthetic>":
624
637
  continue
625
638
  if e.cache_creation_tokens == 0 and e.cache_read_tokens == 0:
@@ -891,6 +904,7 @@ def _build_diff_result(
891
904
  sort: str,
892
905
  allow_mismatch: bool = False,
893
906
  skip_sync: bool = False,
907
+ account_key=None,
894
908
  top: "int | None" = None,
895
909
  ) -> DiffResult:
896
910
  """Top-level diff builder: wire window_a vs window_b through every
@@ -936,8 +950,8 @@ def _build_diff_result(
936
950
  raw_totals: "dict[str, tuple[MetricBundle | None, MetricBundle | None]]" = {}
937
951
 
938
952
  if "overall" in sections_requested:
939
- a_overall_raw = _norm_a(_diff_aggregate_overall(window_a, skip_sync=skip_sync))
940
- b_overall_raw = _norm_b(_diff_aggregate_overall(window_b, skip_sync=skip_sync))
953
+ a_overall_raw = _norm_a(_diff_aggregate_overall(window_a, skip_sync=skip_sync, account_key=account_key))
954
+ b_overall_raw = _norm_b(_diff_aggregate_overall(window_b, skip_sync=skip_sync, account_key=account_key))
941
955
  # Splice in the resolved Used% AFTER normalization — Used % is
942
956
  # never per-day-normalized (it's a weekly ceiling ratio).
943
957
  a_overall = dataclasses.replace(a_overall_raw, used_pct=used_a)
@@ -954,9 +968,9 @@ def _build_diff_result(
954
968
 
955
969
  if "models" in sections_requested:
956
970
  a_map = {k: _norm_a(v) for k, v in
957
- _diff_aggregate_models(window_a, skip_sync=skip_sync).items()}
971
+ _diff_aggregate_models(window_a, skip_sync=skip_sync, account_key=account_key).items()}
958
972
  b_map = {k: _norm_b(v) for k, v in
959
- _diff_aggregate_models(window_b, skip_sync=skip_sync).items()}
973
+ _diff_aggregate_models(window_b, skip_sync=skip_sync, account_key=account_key).items()}
960
974
  sections.append(_diff_build_section(
961
975
  "models", "all", a_map, b_map,
962
976
  _DIFF_DEFAULT_COLUMNS_MODELS, threshold, sort,
@@ -969,9 +983,9 @@ def _build_diff_result(
969
983
 
970
984
  if "projects" in sections_requested:
971
985
  a_map = {k: _norm_a(v) for k, v in
972
- _diff_aggregate_projects(window_a, skip_sync=skip_sync).items()}
986
+ _diff_aggregate_projects(window_a, skip_sync=skip_sync, account_key=account_key).items()}
973
987
  b_map = {k: _norm_b(v) for k, v in
974
- _diff_aggregate_projects(window_b, skip_sync=skip_sync).items()}
988
+ _diff_aggregate_projects(window_b, skip_sync=skip_sync, account_key=account_key).items()}
975
989
  sections.append(_diff_build_section(
976
990
  "projects", "all", a_map, b_map,
977
991
  _DIFF_DEFAULT_COLUMNS_PROJECTS, threshold, sort,
@@ -984,9 +998,9 @@ def _build_diff_result(
984
998
 
985
999
  if "cache" in sections_requested:
986
1000
  a_map = {k: _norm_a(v) for k, v in
987
- _diff_aggregate_cache(window_a, skip_sync=skip_sync).items()}
1001
+ _diff_aggregate_cache(window_a, skip_sync=skip_sync, account_key=account_key).items()}
988
1002
  b_map = {k: _norm_b(v) for k, v in
989
- _diff_aggregate_cache(window_b, skip_sync=skip_sync).items()}
1003
+ _diff_aggregate_cache(window_b, skip_sync=skip_sync, account_key=account_key).items()}
990
1004
  sections.append(_diff_build_section(
991
1005
  "cache", "cache-active-entries", a_map, b_map,
992
1006
  _DIFF_DEFAULT_COLUMNS_CACHE,
@@ -272,6 +272,20 @@ class DoctorState:
272
272
  journal_hw_segment: Optional[str] = None
273
273
  journal_cursor_segment: Optional[str] = None
274
274
  journal_heal_incidents: Optional[list] = None
275
+ # Multi-account attribution (#341). A dict (None when unavailable) with:
276
+ # * claude_identity_status — "identified" | "stably_absent" | "torn"
277
+ # * claude_email — the active Claude email (or None)
278
+ # * real_account_count — # real accounts across providers (excludes the
279
+ # unattributed sentinel); by_provider — {provider: count}
280
+ # * missing_provider — # registry rows lacking a provider (corrupt)
281
+ # * freshest_last_seen_age_s — age of the newest last_seen_utc (or None)
282
+ # * recent_attributed / recent_unattributed — last-7-day Claude usage
283
+ # snapshot counts stamped to a real account vs the sentinel.
284
+ accounts_state: Optional[dict] = None
285
+ # Issue #344 Task B: read-only repair-owner classification for
286
+ # cache.db.repairing. {exists, live, reason}; None only when gather itself
287
+ # could not inspect the marker. Doctor never reclaims or deletes it.
288
+ cache_repair_marker: Optional[dict] = None
275
289
 
276
290
 
277
291
  @dataclasses.dataclass(frozen=True)
@@ -692,6 +706,25 @@ def _check_db_stats_file(s: DoctorState) -> CheckResult:
692
706
 
693
707
 
694
708
  def _check_db_cache_file(s: DoctorState) -> CheckResult:
709
+ marker = s.cache_repair_marker
710
+ if marker and marker.get("exists"):
711
+ if marker.get("live") is True:
712
+ return CheckResult(
713
+ id="db.cache.file",
714
+ title="cache.db",
715
+ severity="warn",
716
+ summary="repair in progress",
717
+ remediation="Wait for the active cache repair to finish.",
718
+ details={"repair_marker": marker},
719
+ )
720
+ return CheckResult(
721
+ id="db.cache.file",
722
+ title="cache.db",
723
+ severity="warn",
724
+ summary="stale repair owner",
725
+ remediation="Run `cctally cache-sync --rebuild`",
726
+ details={"repair_marker": marker},
727
+ )
695
728
  return _db_file_check("db.cache.file", "cache.db", s.cache_db_status,
696
729
  "Run `cctally cache-sync --rebuild`")
697
730
 
@@ -2055,6 +2088,127 @@ def _check_journal_auto_heal(s: DoctorState) -> CheckResult:
2055
2088
  # `_evaluate_one` uses this id — not the function name — so the synthesized
2056
2089
  # FAIL CheckResult retains the contract id and fingerprint stays stable across
2057
2090
  # success-vs-raise transitions.
2091
+ def _check_accounts_identity(s: DoctorState) -> CheckResult:
2092
+ """Is the active-account identity source (`~/.claude.json`) readable? A torn
2093
+ mid-write read WARNs (transient); identified / stably-absent are OK (a legacy
2094
+ / non-Claude-Code install has no file — that is a resolved state, not a
2095
+ fault)."""
2096
+ st = s.accounts_state or {}
2097
+ status = st.get("claude_identity_status")
2098
+ if status == "torn":
2099
+ return CheckResult(
2100
+ id="accounts.identity", title="Account identity source",
2101
+ severity="warn",
2102
+ summary="~/.claude.json is being rewritten (torn read); attribution deferred",
2103
+ remediation="Transient — re-run once Claude Code finishes writing",
2104
+ details={"claude_identity_status": status},
2105
+ )
2106
+ if status == "identified":
2107
+ summary = f"active Claude account readable ({st.get('claude_email') or 'no email'})"
2108
+ else:
2109
+ summary = "no active Claude identity on disk (single-account / api-key mode)"
2110
+ return CheckResult(
2111
+ id="accounts.identity", title="Account identity source",
2112
+ severity="ok", summary=summary, remediation=None,
2113
+ details={"claude_identity_status": status},
2114
+ )
2115
+
2116
+
2117
+ def _check_accounts_registry(s: DoctorState) -> CheckResult:
2118
+ """Is the accounts registry consistent? A row lacking a provider is corrupt
2119
+ (WARN → rebuild)."""
2120
+ st = s.accounts_state
2121
+ if st is None:
2122
+ return CheckResult(
2123
+ id="accounts.registry", title="Account registry",
2124
+ severity="ok", summary="no registry (fresh install)",
2125
+ remediation=None, details={},
2126
+ )
2127
+ missing = int(st.get("missing_provider") or 0)
2128
+ count = int(st.get("real_account_count") or 0)
2129
+ by_provider = st.get("by_provider") or {}
2130
+ details = {"real_account_count": count, "by_provider": by_provider,
2131
+ "missing_provider": missing}
2132
+ if missing:
2133
+ return CheckResult(
2134
+ id="accounts.registry", title="Account registry",
2135
+ severity="warn",
2136
+ summary=f"{missing} account row(s) missing a provider",
2137
+ remediation="Run `cctally db rebuild --db stats` to re-derive the registry",
2138
+ details=details,
2139
+ )
2140
+ return CheckResult(
2141
+ id="accounts.registry", title="Account registry",
2142
+ severity="ok",
2143
+ summary=(f"{count} account(s) known" if count
2144
+ else "no accounts observed yet"),
2145
+ remediation=None, details=details,
2146
+ )
2147
+
2148
+
2149
+ def _check_accounts_freshness(s: DoctorState) -> CheckResult:
2150
+ """Attribution freshness — how recently any account was last seen. Purely
2151
+ informational (always OK); a stale value just reports the age."""
2152
+ st = s.accounts_state or {}
2153
+ age = st.get("freshest_last_seen_age_s")
2154
+ if age is None:
2155
+ summary = "no attribution activity recorded"
2156
+ else:
2157
+ a = int(age)
2158
+ if a >= 86400:
2159
+ human = f"{a // 86400}d"
2160
+ elif a >= 3600:
2161
+ human = f"{a // 3600}h"
2162
+ elif a >= 60:
2163
+ human = f"{a // 60}m"
2164
+ else:
2165
+ human = f"{a}s"
2166
+ summary = f"most recent account activity {human} ago"
2167
+ return CheckResult(
2168
+ id="accounts.freshness", title="Account attribution freshness",
2169
+ severity="ok", summary=summary, remediation=None,
2170
+ details={"freshest_last_seen_age_s": age},
2171
+ )
2172
+
2173
+
2174
+ def _check_accounts_attribution(s: DoctorState) -> CheckResult:
2175
+ """WARN when Claude usage is flowing but landing in `unattributed` despite a
2176
+ resolved active account (the stamping pipeline is broken), or while the
2177
+ identity is torn. This is the runtime complement to the structural
2178
+ writer-audit backstop for the account_key DEFAULT."""
2179
+ st = s.accounts_state or {}
2180
+ recent_unattr = int(st.get("recent_unattributed") or 0)
2181
+ recent_attr = int(st.get("recent_attributed") or 0)
2182
+ status = st.get("claude_identity_status")
2183
+ details = {"recent_attributed": recent_attr,
2184
+ "recent_unattributed": recent_unattr,
2185
+ "claude_identity_status": status}
2186
+ # A real active account, recent usage, yet all of it unattributed -> the
2187
+ # write-path stamp is not landing.
2188
+ if status == "identified" and recent_unattr > 0 and recent_attr == 0:
2189
+ return CheckResult(
2190
+ id="accounts.attribution", title="Account attribution",
2191
+ severity="warn",
2192
+ summary="recent Claude usage is landing in 'unattributed' despite an active account",
2193
+ remediation="Run `cctally db rebuild --db stats`; if it persists, file an issue",
2194
+ details=details,
2195
+ )
2196
+ if status == "torn" and recent_unattr > 0:
2197
+ return CheckResult(
2198
+ id="accounts.attribution", title="Account attribution",
2199
+ severity="warn",
2200
+ summary="recent usage attributed to 'unattributed' while identity is torn",
2201
+ remediation="Transient — the next clean read re-attributes new usage",
2202
+ details=details,
2203
+ )
2204
+ return CheckResult(
2205
+ id="accounts.attribution", title="Account attribution",
2206
+ severity="ok",
2207
+ summary="usage attribution healthy",
2208
+ remediation=None, details=details,
2209
+ )
2210
+
2211
+
2058
2212
  _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
2059
2213
  ("install", "Install", (
2060
2214
  ("install.mode", "_check_install_dev_mode"),
@@ -2106,6 +2260,12 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
2106
2260
  ("data.conversation_sessions_rollup",
2107
2261
  "_check_data_conversation_sessions_rollup"),
2108
2262
  )),
2263
+ ("accounts", "Accounts", (
2264
+ ("accounts.identity", "_check_accounts_identity"),
2265
+ ("accounts.registry", "_check_accounts_registry"),
2266
+ ("accounts.freshness", "_check_accounts_freshness"),
2267
+ ("accounts.attribution", "_check_accounts_attribution"),
2268
+ )),
2109
2269
  ("pricing", "Pricing", (
2110
2270
  ("pricing.coverage", "_check_pricing_coverage"),
2111
2271
  )),