cctally 1.80.3 → 1.81.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 (52) hide show
  1. package/CHANGELOG.md +29 -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 +459 -59
  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 +55 -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_quota.py +81 -4
  41. package/bin/_lib_render.py +19 -5
  42. package/bin/_lib_share.py +25 -0
  43. package/bin/_lib_snapshot_cache.py +8 -0
  44. package/bin/_lib_subscription_weeks.py +16 -2
  45. package/bin/_lib_view_models.py +18 -9
  46. package/bin/cctally +43 -4
  47. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  48. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +4 -1
  51. package/dashboard/static/assets/index-B3y14l1o.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -45,19 +45,23 @@ def _cctally():
45
45
  # === moved verbatim from bin/cctally (Regions R1–R2) ===
46
46
 
47
47
 
48
- def _emit_daily_view_table_or_json(view, args):
48
+ def _emit_daily_view_table_or_json(view, args, *, extra=None):
49
49
  """Order + emit a DailyView as the flat daily table or {daily} JSON.
50
50
 
51
51
  Shared by cmd_daily's default path and its -p-only (filter, no grouping)
52
52
  path so the two cannot drift. Body is exactly the default path's order +
53
53
  emit tail; callers keep their own --format share gate upstream of this.
54
+
55
+ ``extra`` (#341 R8): JSON decoration merged into the emitted payload under an
56
+ explicit ``--account`` invocation; ``None``/empty keeps the render byte-stable.
54
57
  """
55
58
  c = _cctally()
56
59
  days = list(reversed(view.aggregated))
57
60
  if args.order == "desc":
58
61
  days = list(reversed(days))
59
62
  if args.json:
60
- print(c._bucket_to_json(days, list_key="daily", date_key="date"))
63
+ print(c._bucket_to_json(days, list_key="daily", date_key="date",
64
+ extra=extra))
61
65
  return
62
66
  print(c._render_bucket_table(
63
67
  days,
@@ -91,6 +95,15 @@ def cmd_daily(args: argparse.Namespace) -> int:
91
95
  return range
92
96
  range_start, range_end = range
93
97
 
98
+ # #341 --account: resolve the render filter (provider=claude; the stamped-
99
+ # entry family fails closed with exit 3 when the entry cache is unavailable,
100
+ # since the direct-JSONL fallback carries no identity). None = merged
101
+ # (byte-identical). Threaded into every entry read below.
102
+ acct_key, acct_exit = c.resolve_account_filter(
103
+ args, "claude", needs_cache=True)
104
+ if acct_exit is not None:
105
+ return acct_exit
106
+
94
107
  # ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
95
108
  # Gated by -i/--instances or -p/--project; the default path below is
96
109
  # untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
@@ -99,7 +112,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
99
112
  project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
100
113
 
101
114
  if getattr(args, "instances", False) or project_patterns:
102
- joined = list(c.get_claude_session_entries(range_start, range_end))
115
+ joined = list(c.get_claude_session_entries(
116
+ range_start, range_end, account_key=acct_key))
103
117
  resolver_cache: dict = {}
104
118
  keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
105
119
  filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
@@ -176,7 +190,9 @@ def cmd_daily(args: argparse.Namespace) -> int:
176
190
  json_groups.append((json_label, ordered))
177
191
  table_groups.append((table_label, ordered))
178
192
  if args.json:
179
- print(c._bucket_by_project_to_json(json_groups, date_key="date"))
193
+ print(c._bucket_by_project_to_json(
194
+ json_groups, date_key="date",
195
+ extra=c.account_json_fields(acct_key))) # #341 R8
180
196
  return 0
181
197
  print(c._render_bucket_table(
182
198
  [], first_col_name="Date", title_suffix="Daily",
@@ -190,12 +206,13 @@ def cmd_daily(args: argparse.Namespace) -> int:
190
206
  # -p only (no -i): filter-only → normal date-aggregated daily output.
191
207
  view = c.build_daily_view(filtered_uentries, now_utc=_command_as_of(),
192
208
  display_tz=tz, mode=args.mode)
193
- _emit_daily_view_table_or_json(view, args)
209
+ _emit_daily_view_table_or_json(
210
+ view, args, extra=c.account_json_fields(acct_key)) # #341 R8
194
211
  return 0
195
212
 
196
213
  # ── Default path (UNCHANGED) ───────────────────────────────────────────
197
214
  # Collect entries.
198
- all_entries = c.get_entries(range_start, range_end)
215
+ all_entries = c.get_entries(range_start, range_end, account_key=acct_key)
199
216
 
200
217
  c._emit_debug_samples_if_set(
201
218
  args, all_entries, command_label="daily",
@@ -243,7 +260,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
243
260
  # Order + emit the flat daily table / {daily} JSON. Extracted into
244
261
  # `_emit_daily_view_table_or_json` so this default path and the
245
262
  # -p-only (filter, no grouping) path above stay byte-identical.
246
- _emit_daily_view_table_or_json(view, args)
263
+ _emit_daily_view_table_or_json(
264
+ view, args, extra=c.account_json_fields(acct_key)) # #341 R8
247
265
  return 0
248
266
 
249
267
 
@@ -265,7 +283,12 @@ def cmd_monthly(args: argparse.Namespace) -> int:
265
283
  return range
266
284
  range_start, range_end = range
267
285
 
268
- all_entries = c.get_entries(range_start, range_end)
286
+ acct_key, acct_exit = c.resolve_account_filter(
287
+ args, "claude", needs_cache=True) # #341 --account (see cmd_daily)
288
+ if acct_exit is not None:
289
+ return acct_exit
290
+
291
+ all_entries = c.get_entries(range_start, range_end, account_key=acct_key)
269
292
 
270
293
  c._emit_debug_samples_if_set(
271
294
  args, all_entries, command_label="monthly",
@@ -314,7 +337,9 @@ def cmd_monthly(args: argparse.Namespace) -> int:
314
337
  months = list(reversed(months))
315
338
 
316
339
  if args.json:
317
- print(c._bucket_to_json(months, list_key="monthly", date_key="month"))
340
+ print(c._bucket_to_json(
341
+ months, list_key="monthly", date_key="month",
342
+ extra=c.account_json_fields(acct_key))) # #341 R8
318
343
  return 0
319
344
 
320
345
  print(c._render_bucket_table(
@@ -336,6 +361,13 @@ def cmd_weekly(args: argparse.Namespace) -> int:
336
361
  c._bridge_z_into_tz(args, config)
337
362
  args._resolved_tz = c.resolve_display_tz(args, config)
338
363
 
364
+ # #341 --account: resolve the render filter (provider=claude; fail closed
365
+ # with exit 3 when the entry cache is unavailable, since weekly cost is
366
+ # recomputed from session_entries). None = merged / byte-stable.
367
+ acct_key, acct_exit = c.resolve_account_filter(args, "claude", needs_cache=True)
368
+ if acct_exit is not None:
369
+ return acct_exit
370
+
339
371
  now_utc = _command_as_of()
340
372
  range = c._parse_cli_date_range(args, now_utc=now_utc)
341
373
  if isinstance(range, int):
@@ -351,6 +383,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
351
383
  # calendar-week fallback uses the explicit override's `week_start`.
352
384
  weeks = c._compute_subscription_weeks(
353
385
  conn, range_start, range_end, config=config,
386
+ account_key=acct_key, # #341: None = merged (all-accounts) read
354
387
  )
355
388
 
356
389
  # Fetch entries and aggregate.
@@ -368,7 +401,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
368
401
  )
369
402
  else:
370
403
  fetch_start = range_start
371
- all_entries = c.get_entries(fetch_start, range_end)
404
+ all_entries = c.get_entries(fetch_start, range_end, account_key=acct_key)
372
405
 
373
406
  c._emit_debug_samples_if_set(
374
407
  args, all_entries, command_label="weekly",
@@ -399,6 +432,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
399
432
  view = c.build_weekly_view(
400
433
  conn, all_entries, weeks=weeks, now_utc=now_utc,
401
434
  display_tz=args._resolved_tz, as_of_utc=as_of_utc, mode=args.mode,
435
+ account_key=acct_key,
402
436
  )
403
437
  buckets = list(reversed(view.aggregated))
404
438
  overlay = list(reversed(view.overlay))
@@ -438,7 +472,10 @@ def cmd_weekly(args: argparse.Namespace) -> int:
438
472
  overlay = list(reversed(overlay))
439
473
 
440
474
  if args.json:
441
- print(c._weekly_to_json(buckets, weeks, overlay))
475
+ print(c._weekly_to_json(
476
+ buckets, weeks, overlay,
477
+ extra=c.account_json_fields(acct_key), # #341 R8 decoration
478
+ ))
442
479
  return 0
443
480
 
444
481
  if not buckets:
@@ -474,7 +511,13 @@ def cmd_session(args: argparse.Namespace) -> int:
474
511
  return range
475
512
  range_start, range_end = range
476
513
 
477
- entries = c.get_claude_session_entries(range_start, range_end)
514
+ acct_key, acct_exit = c.resolve_account_filter(
515
+ args, "claude", needs_cache=True) # #341 --account (see cmd_daily)
516
+ if acct_exit is not None:
517
+ return acct_exit
518
+
519
+ entries = c.get_claude_session_entries(
520
+ range_start, range_end, account_key=acct_key)
478
521
 
479
522
  # Issue #89: --debug report describes the joined-entry list filtered
480
523
  # by --id (post-fallback session_id resolution) when set, matching
@@ -581,7 +624,8 @@ def cmd_session(args: argparse.Namespace) -> int:
581
624
  sessions = list(reversed(sessions))
582
625
 
583
626
  if args.json:
584
- print(c._claude_sessions_to_json(sessions))
627
+ print(c._claude_sessions_to_json(
628
+ sessions, extra=c.account_json_fields(acct_key))) # #341 R8
585
629
  return 0
586
630
 
587
631
  if not sessions:
@@ -622,6 +666,11 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
622
666
  eprint("Error: --end must be after --start")
623
667
  return 1
624
668
 
669
+ acct_key, acct_exit = c.resolve_account_filter(
670
+ args, "claude", needs_cache=True) # #341 --account (see cmd_daily)
671
+ if acct_exit is not None:
672
+ return acct_exit
673
+
625
674
  total_cost = 0.0
626
675
  matched_entries = 0
627
676
  first_match: dt.datetime | None = None
@@ -633,7 +682,8 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
633
682
  # applied at the loader (SELECT-time), so the scope is the same.
634
683
  # P2.2 (issue #89 review-loop): get_entries already returns
635
684
  # list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
636
- entries_list = c.get_entries(start_dt, end_dt, project=args.project)
685
+ entries_list = c.get_entries(start_dt, end_dt, project=args.project,
686
+ account_key=acct_key)
637
687
  c._emit_debug_samples_if_set(
638
688
  args, entries_list, command_label="range-cost",
639
689
  )
@@ -702,6 +752,8 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
702
752
  ),
703
753
  "modelBreakdowns": breakdowns,
704
754
  }
755
+ # #341 R8: conditional account decoration under an explicit --account.
756
+ output.update(c.account_json_fields(acct_key))
705
757
  payload = c.stamp_schema_version(output)
706
758
  sink = getattr(args, "_source_result_sink", None)
707
759
  if sink is not None:
@@ -2726,7 +2726,15 @@ def _setup_install(args: argparse.Namespace) -> int:
2726
2726
  try:
2727
2727
  cache_conn = c.open_cache_db()
2728
2728
  try:
2729
- stats = c.sync_cache(cache_conn, progress=progress.sync_callback)
2729
+ cache_mod = c._load_sibling("_cctally_cache")
2730
+ stats, cache_conn = (
2731
+ cache_mod._run_cache_operation_with_recovery(
2732
+ cache_conn,
2733
+ lambda active_conn: c.sync_cache(
2734
+ active_conn, progress=progress.sync_callback
2735
+ ),
2736
+ )
2737
+ )
2730
2738
  finally:
2731
2739
  try:
2732
2740
  cache_conn.close()
@@ -20,6 +20,7 @@ from _cctally_core import (
20
20
  parse_iso_datetime,
21
21
  )
22
22
  from _cctally_cache import _codex_provider_roots
23
+ import _lib_accounts
23
24
  from _lib_quota import build_blocks
24
25
  from _lib_pricing import _calculate_codex_entry_cost
25
26
  from _cctally_quota import load_codex_quota_observations
@@ -81,6 +82,10 @@ class RootedCodexAccountingEntry:
81
82
  reasoning_output_tokens: int
82
83
  total_tokens: int
83
84
  cost_usd: float
85
+ # #341 Task 4: the ingest-stamped account (NULL in cache.db reads as the
86
+ # reserved ``unattributed`` sentinel). Carried so the per-account hero cards
87
+ # can scope spend without a second query shape.
88
+ account_key: str = _lib_accounts.UNATTRIBUTED
84
89
 
85
90
 
86
91
  _OPAQUE_PROJECT_KEY_RE = re.compile(r"^project:[0-9a-f]{24}$")
@@ -131,14 +136,23 @@ _CODEX_ACCOUNTING_ENTRIES_SQL = """
131
136
  """
132
137
 
133
138
 
139
+ # P3-4 (#341 Task 4 review): the `account_key` column is selected unguarded — it
140
+ # is NOT behind a `has_columns(...)` check like some other account-aware reads.
141
+ # This is safe by construction: `add_column_if_missing(conn, "codex_session_entries",
142
+ # "account_key", ...)` runs on every cache.db open (the pure column-addition
143
+ # schema-evolution path, spec §2), so the column is guaranteed present before any
144
+ # reader can reach this SQL. Defense-in-depth note only; do NOT add a runtime
145
+ # guard here (it would mask a genuinely broken/un-migrated cache instead of failing
146
+ # loudly, and every production reader opens the cache through that migration path).
134
147
  _ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL = """
135
148
  SELECT timestamp_utc, session_id, source_path, source_root_key, model,
136
149
  input_tokens, cached_input_tokens, output_tokens,
137
- reasoning_output_tokens, total_tokens
150
+ reasoning_output_tokens, total_tokens, account_key
138
151
  FROM codex_session_entries INDEXED BY idx_codex_entries_ts_root_conversation
139
152
  WHERE timestamp_utc >= ?
140
153
  AND timestamp_utc < ?
141
154
  {root_predicate}
155
+ {account_predicate}
142
156
  ORDER BY timestamp_utc ASC, source_root_key ASC, conversation_key ASC, id ASC
143
157
  """
144
158
 
@@ -274,6 +288,7 @@ def load_cached_rooted_codex_accounting_entries(
274
288
  speed: str,
275
289
  cache_conn: sqlite3.Connection,
276
290
  source_root_keys: Iterable[str] | None = None,
291
+ account_key: str | None = None,
277
292
  ) -> tuple[RootedCodexAccountingEntry, ...]:
278
293
  """Read bounded accounting from one caller-owned cache snapshot only.
279
294
 
@@ -282,6 +297,11 @@ def load_cached_rooted_codex_accounting_entries(
282
297
  cached root key plus source path are the dashboard's file identity. A
283
298
  caller can additionally constrain the read to roots that established one
284
299
  native cycle; root identities remain internal to this adapter.
300
+
301
+ ``account_key`` (#341 Task 4) optionally scopes the read to one account; the
302
+ reserved ``unattributed`` sentinel matches rows whose ``account_key`` is NULL
303
+ (``NULL ≡ unattributed`` — the cache-read rule). ``None`` reads every account
304
+ (byte-stable — the default merged read).
285
305
  """
286
306
  if start.tzinfo is None or start.utcoffset() is None:
287
307
  raise ValueError("start must be timezone-aware")
@@ -299,11 +319,22 @@ def load_cached_rooted_codex_accounting_entries(
299
319
  "AND source_root_key IN (" + ", ".join("?" for _ in root_keys) + ")"
300
320
  if source_root_keys is not None else ""
301
321
  )
322
+ account_params: tuple = ()
323
+ if account_key is None:
324
+ account_predicate = ""
325
+ elif account_key == _lib_accounts.UNATTRIBUTED:
326
+ account_predicate = "AND (account_key IS NULL OR account_key = ?)"
327
+ account_params = (_lib_accounts.UNATTRIBUTED,)
328
+ else:
329
+ account_predicate = "AND account_key = ?"
330
+ account_params = (account_key,)
302
331
  try:
303
332
  rows = tuple(cache_conn.execute(
304
- _ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(root_predicate=root_predicate),
333
+ _ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(
334
+ root_predicate=root_predicate, account_predicate=account_predicate),
305
335
  (
306
- start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(), *root_keys,
336
+ start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
337
+ *root_keys, *account_params,
307
338
  ),
308
339
  ))
309
340
  except sqlite3.Error as exc:
@@ -315,12 +346,16 @@ def load_cached_rooted_codex_accounting_entries(
315
346
  (
316
347
  timestamp_raw, session_id_raw, source_path_raw, source_root_key_raw,
317
348
  model_raw, input_raw, cached_input_raw, output_raw, reasoning_raw,
318
- total_raw,
349
+ total_raw, account_key_raw,
319
350
  ) = row
320
351
  source_path = source_path_raw if isinstance(source_path_raw, str) else ""
321
352
  source_root_key = source_root_key_raw if isinstance(source_root_key_raw, str) else ""
322
353
  if not source_path or not source_root_key:
323
354
  raise ValueError("rooted accounting identity is absent")
355
+ entry_account_key = (
356
+ account_key_raw if isinstance(account_key_raw, str) and account_key_raw
357
+ else _lib_accounts.UNATTRIBUTED
358
+ )
324
359
  model = str(model_raw)
325
360
  input_tokens = int(input_raw)
326
361
  cached_input_tokens = int(cached_input_raw)
@@ -349,6 +384,7 @@ def load_cached_rooted_codex_accounting_entries(
349
384
  reasoning_output_tokens=reasoning_output_tokens,
350
385
  total_tokens=total_tokens,
351
386
  cost_usd=cost_usd,
387
+ account_key=entry_account_key,
352
388
  ))
353
389
  return tuple(result)
354
390
 
@@ -451,7 +487,10 @@ def load_qualified_codex_entries(
451
487
  previous_row_factory = conn.row_factory
452
488
  try:
453
489
  if sync:
454
- stats = c.sync_codex_cache(conn)
490
+ cache_mod = c._load_sibling("_cctally_cache")
491
+ stats, conn = cache_mod._run_cache_operation_with_recovery(
492
+ conn, lambda active_conn: c.sync_codex_cache(active_conn)
493
+ )
455
494
  if stats.lock_contended:
456
495
  raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
457
496
  conn.row_factory = sqlite3.Row
@@ -624,11 +663,15 @@ def load_codex_accounting_entries(
624
663
  return tuple(result)
625
664
 
626
665
 
627
- def _identity_sort_key(identity: object) -> tuple[str, str, str, str, int]:
666
+ def _identity_sort_key(identity: object) -> tuple[str, str, str, str, int, str]:
667
+ # account_key (#341) is appended LAST so single-account ordering is
668
+ # byte-identical (the tiebreak is constant) while a multi-account install
669
+ # gets a deterministic tiebreak between two accounts' windows.
628
670
  return (
629
671
  str(identity.source), str(identity.source_root_key),
630
672
  str(identity.logical_limit_key), str(identity.observed_slot),
631
673
  int(identity.window_minutes),
674
+ str(getattr(identity, "account_key", "unattributed")),
632
675
  )
633
676
 
634
677
 
@@ -2286,7 +2329,13 @@ def cmd_source_report(args: object) -> int:
2286
2329
  try:
2287
2330
  cache = c.open_cache_db()
2288
2331
  try:
2289
- sync_stats = c.sync_codex_cache(cache)
2332
+ cache_mod = c._load_sibling("_cctally_cache")
2333
+ sync_stats, cache = (
2334
+ cache_mod._run_cache_operation_with_recovery(
2335
+ cache,
2336
+ lambda active_conn: c.sync_codex_cache(active_conn),
2337
+ )
2338
+ )
2290
2339
  finally:
2291
2340
  cache.close()
2292
2341
  c.reconcile_codex_quota_projection(now=as_of)
@@ -613,22 +613,59 @@ def _epoch_from_iso(value: object) -> int:
613
613
  return int(_cctally_core.parse_iso_datetime(str(value), "statusline projection").timestamp())
614
614
 
615
615
 
616
+ def _statusline_active_account() -> "str | None":
617
+ """The active Claude ``account_key`` to scope the statusline projection to
618
+ (#341, spec §3 write-path semantics — statusline resolves the active account
619
+ once per read and scopes its reset-aware clamps to it).
620
+
621
+ Torn-handling (reviewer-specified): a TORN ``~/.claude.json`` read keeps the
622
+ merged (``None``) behavior — never mis-stamp against a half-written file;
623
+ *identified* returns the real key; *stably-absent* / api-key returns the
624
+ ``unattributed`` sentinel. For a <=1-real-account install every stats row
625
+ defaults to ``unattributed``, so the scoped clamp matches all of them and
626
+ the rendered output stays byte-frozen (R8). Best-effort / never-raise: any
627
+ failure falls back to the merged read."""
628
+ try:
629
+ ident = _cctally_core._resolve_active_claude_identity()
630
+ return None if ident.get("status") == "torn" else ident.get("account_key")
631
+ except Exception:
632
+ return None
633
+
634
+
616
635
  def _read_db_projection_once() -> "_candidates.DbProjection":
617
636
  conn = open_db()
637
+ _sl_account = _statusline_active_account()
638
+ # #341 (spec §3 write-path): the statusline resolves the active account once
639
+ # per read and scopes its ENTIRE projection to it — candidate selection AND
640
+ # grouping, not only the reset-aware clamps. Scoping the two candidate
641
+ # SELECTs here means one account's interleaved snapshots can never become
642
+ # another account's projection candidate. Same three-valued semantics as
643
+ # `_reset_aware_floor`: a real key / the `unattributed` sentinel scopes to
644
+ # `account_key = ?`; `None` (TORN read) keeps the merged/last-known set —
645
+ # never mis-scope against a half-written identity file. For a <=1-real-account
646
+ # install every stats row defaults to `unattributed` and the active read
647
+ # resolves to `unattributed`, so the predicate matches all rows and the
648
+ # rendered output stays byte-frozen (R8).
649
+ _acct_pred = "" if _sl_account is None else " AND account_key = ?"
650
+ _acct_params: tuple = () if _sl_account is None else (_sl_account,)
618
651
  try:
619
652
  weekly_rows = conn.execute(
620
653
  "SELECT id, weekly_percent, week_start_date, week_start_at, week_end_at, "
621
654
  " captured_at_utc, source "
622
655
  "FROM weekly_usage_snapshots "
623
- "WHERE weekly_percent IS NOT NULL AND week_end_at IS NOT NULL "
624
- "ORDER BY unixepoch(captured_at_utc) DESC, id DESC"
656
+ "WHERE weekly_percent IS NOT NULL AND week_end_at IS NOT NULL"
657
+ + _acct_pred +
658
+ " ORDER BY unixepoch(captured_at_utc) DESC, id DESC",
659
+ _acct_params,
625
660
  ).fetchall()
626
661
  five_rows = conn.execute(
627
662
  "SELECT id, five_hour_percent, five_hour_resets_at, five_hour_window_key, "
628
663
  " captured_at_utc, source "
629
664
  "FROM weekly_usage_snapshots "
630
- "WHERE five_hour_percent IS NOT NULL AND five_hour_resets_at IS NOT NULL "
631
- "ORDER BY unixepoch(captured_at_utc) DESC, id DESC"
665
+ "WHERE five_hour_percent IS NOT NULL AND five_hour_resets_at IS NOT NULL"
666
+ + _acct_pred +
667
+ " ORDER BY unixepoch(captured_at_utc) DESC, id DESC",
668
+ _acct_params,
632
669
  ).fetchall()
633
670
 
634
671
  weekly_groups: dict[int, list] = {}
@@ -660,6 +697,7 @@ def _read_db_projection_once() -> "_candidates.DbProjection":
660
697
  str(reference["week_start_date"]),
661
698
  str(week_start_at),
662
699
  str(week_end_at),
700
+ account_key=_sl_account,
663
701
  )
664
702
  floor_epoch = _epoch_from_iso(floor) if floor else 0
665
703
  eligible = [
@@ -1573,6 +1611,14 @@ def _build_statusline_injections(warn_once):
1573
1611
  c = _cctally()
1574
1612
  five_hwm = None
1575
1613
  seven_hwm = None
1614
+ # #341 (spec §3): scope the 7d reset-aware clamp to the active Claude
1615
+ # account (torn -> merged None; identified -> real key; absent ->
1616
+ # `unattributed`). Byte-frozen for <=1 real account (all rows default
1617
+ # `unattributed`). `_resolve_active_claude_identity` is mtime-cached, so
1618
+ # resolving here (as well as in `_read_db_projection_once`) is a cache hit.
1619
+ _sl_account = _statusline_active_account()
1620
+ _acct_pred = "" if _sl_account is None else " AND account_key = ?"
1621
+ _acct_params = () if _sl_account is None else (_sl_account,)
1576
1622
  try:
1577
1623
  conn = open_db()
1578
1624
  except Exception:
@@ -1629,21 +1675,22 @@ def _build_statusline_injections(warn_once):
1629
1675
  floor_iso = c._reset_aware_floor(
1630
1676
  conn, week_start_date,
1631
1677
  week_start_dt.isoformat(), week_end_dt.isoformat(),
1678
+ account_key=_sl_account,
1632
1679
  )
1633
1680
  if floor_iso is not None:
1634
1681
  row = conn.execute(
1635
1682
  "SELECT MAX(weekly_percent) "
1636
1683
  "FROM weekly_usage_snapshots "
1637
- "WHERE week_start_date = ? "
1684
+ "WHERE week_start_date = ?" + _acct_pred + " "
1638
1685
  " AND unixepoch(captured_at_utc) >= unixepoch(?)",
1639
- (week_start_date, floor_iso),
1686
+ (week_start_date, *_acct_params, floor_iso),
1640
1687
  ).fetchone()
1641
1688
  else:
1642
1689
  row = conn.execute(
1643
1690
  "SELECT MAX(weekly_percent) "
1644
1691
  "FROM weekly_usage_snapshots "
1645
- "WHERE week_start_date = ?",
1646
- (week_start_date,),
1692
+ "WHERE week_start_date = ?" + _acct_pred,
1693
+ (week_start_date, *_acct_params),
1647
1694
  ).fetchone()
1648
1695
  if row and row[0] is not None:
1649
1696
  seven_hwm = float(row[0])
@@ -33,11 +33,11 @@ once ``user_version == registry head``. Add the column via a registered
33
33
  migration (which bumps the head and re-triggers the apply) instead.
34
34
 
35
35
  **Lock-order law** (spec §5.2 / §6.4; asserted here as documentation, exercised
36
- by the storm test): maintenance flocks → ``journal.ingest.lock`` → provider
37
- flocks (cache Claude → cache Codex conversations Claude conversations
38
- Codex) → SQLite transactions → ``journal.lock`` (leaf). Never acquire the
39
- ingest lock while holding a provider flock; no SQLite write transaction ever
40
- spans a flock acquisition.
36
+ by the storm test): maintenance flocks → ``journal.ingest.lock`` → the global
37
+ ``cache.db.lock`` writer flock the cache Codex provider flockconversation
38
+ provider flocks (Claude → Codex) → SQLite transactions → ``journal.lock``
39
+ (leaf). Never acquire the ingest lock while holding a cache writer/provider
40
+ flock; no SQLite write transaction ever spans a flock acquisition.
41
41
 
42
42
  **Raw-connect escape hatches stay OUT of this module by design** (spec §6.1):
43
43
  ``db checkpoint``'s ``mode=rw`` connect, ``db vacuum``'s exclusive connect, and
@@ -158,6 +158,12 @@ def apply_policy(conn: sqlite3.Connection, store: str) -> None:
158
158
  if policy.auto_vacuum is not None:
159
159
  conn.execute(f"PRAGMA auto_vacuum={policy.auto_vacuum}")
160
160
  conn.execute(f"PRAGMA journal_mode={policy.journal_mode}")
161
+ apply_connection_policy(conn, store)
162
+
163
+
164
+ def apply_connection_policy(conn: sqlite3.Connection, store: str) -> None:
165
+ """Apply non-schema connection settings without changing journal mode."""
166
+ policy = STORE_POLICY[store]
161
167
  conn.execute(f"PRAGMA synchronous={policy.synchronous}")
162
168
  conn.execute(f"PRAGMA busy_timeout={policy.busy_timeout}")
163
169
  conn.execute(f"PRAGMA journal_size_limit={policy.journal_size_limit}")
@@ -497,10 +503,16 @@ def resolve_stats_epoch_mismatch():
497
503
  "present to rebuild from. The journal/ directory is the "
498
504
  "durable source — restore it from backup, then run "
499
505
  "`cctally db rebuild --db stats`.")
500
- # Preserve the version-ahead DB (nothing deleted), then rebuild a
501
- # fresh epoch index into the now-absent destination.
506
+ # Preserve the version-ahead DB (nothing deleted), then run the
507
+ # #341 account epoch-transition coordinator into the now-absent
508
+ # destination: resolve the cutover identity (no stats.db open),
509
+ # append the canonical cutover op, then rebuild account-scoped
510
+ # (spec §2 — op strictly before the rebuild HW). A same-epoch or
511
+ # legacy DB never reaches here; a fresh cutover install stamps the
512
+ # epoch directly (run_cutover), so this path is the 1000->1001
513
+ # upgrade of an already-journaled install.
502
514
  _cctally_db.quarantine_db_family(path)
503
- _cctally_journal.rebuild_stats_index()
515
+ _cctally_journal.run_epoch_transition()
504
516
  finally:
505
517
  _heal_release_flock(maint_fd)
506
518
  return _cctally_core.open_db()
@@ -44,12 +44,13 @@ from _cctally_core import (
44
44
  make_week_ref,
45
45
  get_latest_usage_for_week,
46
46
  now_utc_iso,
47
+ _resolve_active_claude_account,
47
48
  )
48
49
 
49
50
 
50
51
  def cmd_sync_week(
51
52
  args: argparse.Namespace, *, conn=None, as_of: "str | None" = None,
52
- journal: "tuple | None" = None,
53
+ journal: "tuple | None" = None, account_key: str = "unattributed",
53
54
  ) -> int:
54
55
  """Compute + persist the week's cost snapshot.
55
56
 
@@ -103,6 +104,11 @@ def cmd_sync_week(
103
104
  project=args.project,
104
105
  start_iso_override=selection.start_iso_override,
105
106
  end_iso_override=selection.end_iso_override,
107
+ # #341 P2-CQ2: the cost VALUE is scoped to the same account the
108
+ # snapshot is stamped for (below), so a per-account snapshot no longer
109
+ # carries the merged cost of every account sharing the week. Default
110
+ # `unattributed` matches all-NULL legacy rows -> byte-stable.
111
+ account_key=account_key,
106
112
  )
107
113
  week_start_at = selection.start_iso_override or format_local_iso(week_start, end_of_day=False)
108
114
  week_end_at = selection.end_iso_override or format_local_iso(week_end, end_of_day=True)
@@ -120,6 +126,11 @@ def cmd_sync_week(
120
126
  commit=own_conn,
121
127
  as_of=as_of,
122
128
  journal=journal,
129
+ # Per-account cost materialization (#341 P2-1, spec §3): the snapshot
130
+ # lands under the active/crossing Claude account so the account-scoped
131
+ # milestone cost read + `_reset_aware_floor` see it. Default
132
+ # `unattributed` keeps single-account / legacy installs byte-stable.
133
+ account_key=account_key,
123
134
  )
124
135
 
125
136
  week_ref = make_week_ref(
@@ -178,19 +189,28 @@ def _cmd_sync_week_via_ingest(c, args: argparse.Namespace) -> int:
178
189
  dispatch maps it to the same exit code."""
179
190
  import _cctally_journal as _jr
180
191
  import _lib_journal as _lj
192
+ import _lib_accounts
181
193
 
194
+ # Per-account materialization (#341 P2-1, spec §3): resolve the active Claude
195
+ # account and carry it in the op payload so the fold stamps the cost snapshot
196
+ # under it (rebuild-deterministic). Byte-safe: the reserved sentinel OMITS
197
+ # the field, keeping a single-account / no-`~/.claude.json` op line identical.
198
+ _sync_account = _resolve_active_claude_account()
199
+ _sync_payload = {
200
+ "kind": "sync_week",
201
+ "week_start": args.week_start,
202
+ "week_end": args.week_end,
203
+ "week_start_name": args.week_start_name,
204
+ "mode": args.mode,
205
+ "offline": args.offline,
206
+ "project": args.project,
207
+ }
208
+ if _sync_account != _lib_accounts.UNATTRIBUTED:
209
+ _sync_payload["account_key"] = _sync_account
182
210
  op = _lj.make_op(
183
211
  at=now_utc_iso(),
184
212
  src="sync-week",
185
- payload={
186
- "kind": "sync_week",
187
- "week_start": args.week_start,
188
- "week_end": args.week_end,
189
- "week_start_name": args.week_start_name,
190
- "mode": args.mode,
191
- "offline": args.offline,
192
- "project": args.project,
193
- },
213
+ payload=_sync_payload,
194
214
  )
195
215
  _jr.append_record(op)
196
216
  res = _jr.run_stats_ingest(mode="authoritative")