cctally 1.80.4 → 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 +24 -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 +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-BzWujBS3.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -18,6 +18,7 @@ from dataclasses import dataclass
18
18
  from typing import Callable, Iterable, Mapping
19
19
 
20
20
  import _cctally_core
21
+ import _lib_accounts
21
22
  from _cctally_core import _command_as_of, eprint
22
23
  from _lib_quota import (
23
24
  QuotaBlock,
@@ -28,6 +29,7 @@ from _lib_quota import (
28
29
  QuotaPercentMilestone,
29
30
  QuotaRule,
30
31
  QuotaWindowIdentity,
32
+ adopt_unidentified_observations,
31
33
  build_blocks,
32
34
  build_history,
33
35
  forecast_quota,
@@ -285,6 +287,12 @@ def load_codex_quota_observations(
285
287
  has_observed_model = has_columns(
286
288
  "quota_window_snapshots", {"observed_model"},
287
289
  )
290
+ # account_key (#341 Task 2): NULL ≡ unattributed on the read path; a
291
+ # pre-Task-2 cache lacking the column reads every window as unattributed.
292
+ has_account = has_columns("quota_window_snapshots", {"account_key"})
293
+ account_expr = (
294
+ "account_key" if has_account else "NULL AS account_key"
295
+ )
288
296
  entry_lookup = (
289
297
  "(SELECT entries.model FROM codex_session_entries AS entries "
290
298
  "WHERE entries.source_path=quota_window_snapshots.source_path "
@@ -310,10 +318,10 @@ def load_codex_quota_observations(
310
318
  captured_at_utc, observed_slot, logical_limit_key, limit_id,
311
319
  limit_name, window_minutes, used_percent, resets_at_utc,
312
320
  plan_type, individual_limit_json, reached_type,
313
- {model_expr}
321
+ {model_expr}, {account_expr}
314
322
  FROM quota_window_snapshots
315
323
  WHERE source='codex' AND source_root_key IS NOT NULL
316
- """.format(model_expr=model_expr)
324
+ """.format(model_expr=model_expr, account_expr=account_expr)
317
325
  params: list[object] = []
318
326
  if requested is not None:
319
327
  if not requested:
@@ -368,9 +376,15 @@ def load_codex_quota_observations(
368
376
  str(row["observed_slot"]), int(row["window_minutes"]),
369
377
  str(row["observed_model"]),
370
378
  )
379
+ raw_account = row["account_key"]
380
+ account_key = (
381
+ str(raw_account) if raw_account not in (None, "")
382
+ else _lib_accounts.UNATTRIBUTED
383
+ )
371
384
  identity = QuotaWindowIdentity(
372
385
  source=str(row["source"]),
373
386
  source_root_key=str(row["source_root_key"]),
387
+ account_key=account_key,
374
388
  logical_limit_key=logical_limit_key,
375
389
  observed_slot=str(row["observed_slot"]),
376
390
  window_minutes=int(row["window_minutes"]),
@@ -410,6 +424,13 @@ def load_codex_quota_observations(
410
424
  ):
411
425
  continue
412
426
  result.append(observation)
427
+ # Window-account continuity fold (#341 spec §2): adopt unidentified
428
+ # observations into a same-physical-window identified account (exactly
429
+ # one). Physical signatures above are account-independent (computed from
430
+ # the physical tuple), so folding after them is order-invariant; the
431
+ # recursion path below re-fetches + re-folds. Idempotent for an
432
+ # already-identified set — a single-account cache is a no-op.
433
+ result = list(adopt_unidentified_observations(result))
413
434
  if physical_signatures is not None:
414
435
  physical_signatures.clear()
415
436
  roots = requested if requested is not None else set(signature_tuples)
@@ -499,19 +520,22 @@ def _block_params(block: QuotaBlock, generation: str) -> tuple[object, ...]:
499
520
  identity.limit_name, _utc_iso(block.resets_at), _utc_iso(block.nominal_start_at),
500
521
  _utc_iso(block.first_observed_at), _utc_iso(block.last_observed_at),
501
522
  block.first_percent, block.current_percent, latest.source_path,
502
- latest.line_offset, generation,
523
+ latest.line_offset, generation, identity.account_key,
503
524
  )
504
525
 
505
526
 
527
+ # account_key (#341): part of the block/milestone identity — the ON CONFLICT
528
+ # target includes it so two accounts sharing one physical window are DISTINCT
529
+ # rows (never-combine), and each account's row upserts independently.
506
530
  _BLOCK_UPSERT = """
507
531
  INSERT INTO quota_window_blocks
508
532
  (source, source_root_key, logical_limit_key, observed_slot,
509
533
  window_minutes, limit_id, limit_name, resets_at_utc, nominal_start_at_utc,
510
534
  first_observed_at_utc, last_observed_at_utc, first_percent, current_percent,
511
- last_source_path, last_line_offset, generation, orphaned_at)
512
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)
513
- ON CONFLICT(source, source_root_key, logical_limit_key, observed_slot,
514
- window_minutes, resets_at_utc) DO UPDATE SET
535
+ last_source_path, last_line_offset, generation, orphaned_at, account_key)
536
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)
537
+ ON CONFLICT(source, source_root_key, account_key, logical_limit_key,
538
+ observed_slot, window_minutes, resets_at_utc) DO UPDATE SET
515
539
  limit_id=excluded.limit_id, limit_name=excluded.limit_name,
516
540
  nominal_start_at_utc=excluded.nominal_start_at_utc,
517
541
  first_observed_at_utc=excluded.first_observed_at_utc,
@@ -526,10 +550,11 @@ _MILESTONE_UPSERT = """
526
550
  INSERT INTO quota_percent_milestones
527
551
  (source, source_root_key, logical_limit_key, observed_slot, window_minutes,
528
552
  resets_at_utc, percent_threshold, captured_at_utc, source_path,
529
- line_offset, high_water_percent, generation, orphaned_at)
530
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL)
531
- ON CONFLICT(source, source_root_key, logical_limit_key, observed_slot,
532
- window_minutes, resets_at_utc, percent_threshold) DO UPDATE SET
553
+ line_offset, high_water_percent, generation, orphaned_at, account_key)
554
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)
555
+ ON CONFLICT(source, source_root_key, account_key, logical_limit_key,
556
+ observed_slot, window_minutes, resets_at_utc, percent_threshold)
557
+ DO UPDATE SET
533
558
  captured_at_utc=excluded.captured_at_utc, source_path=excluded.source_path,
534
559
  line_offset=excluded.line_offset, high_water_percent=excluded.high_water_percent,
535
560
  generation=excluded.generation, orphaned_at=NULL
@@ -545,6 +570,7 @@ def _milestone_params(
545
570
  identity.observed_slot, identity.window_minutes, _utc_iso(block.resets_at),
546
571
  milestone.percent, _utc_iso(milestone.captured_at), milestone.observation.source_path,
547
572
  milestone.observation.line_offset, milestone.percent, generation,
573
+ identity.account_key,
548
574
  )
549
575
 
550
576
 
@@ -568,11 +594,14 @@ def _orphan_unseen(conn: sqlite3.Connection, roots: set[str], generation: str, n
568
594
  # this completed generation, so a cache rebuild that restores the exact
569
595
  # block clears a transient prune marker without creating a new terminal
570
596
  # claim.
597
+ # account_key (#341): join on the account too so account A's terminal event
598
+ # is never un-orphaned by account B's block sharing the physical window.
571
599
  event_sql = f"""UPDATE quota_threshold_events AS events
572
600
  SET orphaned_at=CASE WHEN EXISTS (
573
601
  SELECT 1 FROM quota_window_blocks AS blocks
574
602
  WHERE blocks.source=events.source
575
603
  AND blocks.source_root_key=events.source_root_key
604
+ AND blocks.account_key=events.account_key
576
605
  AND blocks.logical_limit_key=events.logical_limit_key
577
606
  AND blocks.observed_slot=events.observed_slot
578
607
  AND blocks.window_minutes=events.window_minutes
@@ -611,11 +640,11 @@ def _quota_alert_config() -> tuple[bool, bool, tuple[QuotaRule, ...], dict]:
611
640
  def _arming_row(conn: sqlite3.Connection, identity: QuotaWindowIdentity) -> sqlite3.Row | None:
612
641
  return conn.execute(
613
642
  """SELECT rule_fingerprint, activated_at_utc FROM quota_alert_arming
614
- WHERE source=? AND source_root_key=? AND logical_limit_key=?
615
- AND observed_slot=? AND window_minutes=?""",
643
+ WHERE source=? AND source_root_key=? AND account_key=?
644
+ AND logical_limit_key=? AND observed_slot=? AND window_minutes=?""",
616
645
  (
617
- identity.source, identity.source_root_key, identity.logical_limit_key,
618
- identity.observed_slot, identity.window_minutes,
646
+ identity.source, identity.source_root_key, identity.account_key,
647
+ identity.logical_limit_key, identity.observed_slot, identity.window_minutes,
619
648
  ),
620
649
  ).fetchone()
621
650
 
@@ -640,15 +669,16 @@ def _activate_quota_rule(
640
669
  conn.execute(
641
670
  """INSERT INTO quota_alert_arming
642
671
  (source, source_root_key, logical_limit_key, observed_slot,
643
- window_minutes, rule_fingerprint, activated_at_utc)
644
- VALUES (?,?,?,?,?,?,?)
645
- ON CONFLICT(source, source_root_key, logical_limit_key,
672
+ window_minutes, rule_fingerprint, activated_at_utc, account_key)
673
+ VALUES (?,?,?,?,?,?,?,?)
674
+ ON CONFLICT(source, source_root_key, account_key, logical_limit_key,
646
675
  observed_slot, window_minutes) DO UPDATE SET
647
676
  rule_fingerprint=excluded.rule_fingerprint,
648
677
  activated_at_utc=excluded.activated_at_utc""",
649
678
  (
650
679
  identity.source, identity.source_root_key, identity.logical_limit_key,
651
680
  identity.observed_slot, identity.window_minutes, fingerprint, now_iso,
681
+ identity.account_key,
652
682
  ),
653
683
  )
654
684
  if journal_emit is not None:
@@ -670,14 +700,14 @@ def _insert_quota_terminal_event(
670
700
  (source, source_root_key, logical_limit_key, observed_slot,
671
701
  window_minutes, resets_at_utc, threshold, qualifying_kind,
672
702
  qualifying_percent, projected_percent, severity, created_at_utc,
673
- disposition, alerted_at, suppressed_at)
674
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
703
+ disposition, alerted_at, suppressed_at, account_key)
704
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
675
705
  (
676
706
  identity.source, identity.source_root_key, identity.logical_limit_key,
677
707
  identity.observed_slot, identity.window_minutes, _utc_iso(resets_at),
678
708
  threshold, kind, qualifying_percent, projected_percent,
679
709
  _cctally().severity_for(threshold), now_iso, disposition,
680
- alerted_at, suppressed_at,
710
+ alerted_at, suppressed_at, identity.account_key,
681
711
  ),
682
712
  )
683
713
  return cur.rowcount == 1
@@ -711,6 +741,7 @@ def _quota_alert_payload(
711
741
  resets_at_utc=_utc_iso(resets_at), threshold=threshold, kind=kind,
712
742
  crossed_at_utc=now_iso, qualifying_percent=qualifying_percent,
713
743
  projected_percent=projected_percent,
744
+ account_key=identity.account_key,
714
745
  )
715
746
 
716
747
 
@@ -864,17 +895,35 @@ def _apply_quota_projection_rows(
864
895
  # The completion stamp is intentionally the final DML in the stats
865
896
  # transaction. A pre-commit failure rolls all projection updates back;
866
897
  # a retry sees the prior complete generation or rederives it.
898
+ #
899
+ # projection_state is (source_root_key, account_key)-keyed (#341 spec §2). The
900
+ # active account identities are derived from the FOLDED observations (so an
901
+ # adopted-unidentified window contributes its adopting account, not the raw
902
+ # `unattributed` stamp); a root with no observations still stamps one
903
+ # `unattributed` row so its coherence signature is present (byte-stable with
904
+ # the prior per-root behavior for a single-account install). The
905
+ # physical_signature stays PER-ROOT (account-independent) — it certifies the
906
+ # root's physical cache evidence and matches the per-root cache certificate.
907
+ accounts_by_root: dict[str, set[str]] = {root: set() for root in active_roots}
908
+ for observation in observations:
909
+ root = observation.identity.source_root_key
910
+ if root in accounts_by_root:
911
+ accounts_by_root[root].add(observation.identity.account_key)
867
912
  for root_key in sorted(active_roots):
868
- conn.execute(
869
- """INSERT INTO quota_projection_state
870
- (source_root_key, generation, physical_signature, completed_at_utc)
871
- VALUES (?,?,?,?)
872
- ON CONFLICT(source_root_key) DO UPDATE SET
873
- generation=excluded.generation,
874
- physical_signature=excluded.physical_signature,
875
- completed_at_utc=excluded.completed_at_utc""",
876
- (root_key, generation, _signature(observations, root_key), now_iso),
877
- )
913
+ accounts = accounts_by_root[root_key] or {_lib_accounts.UNATTRIBUTED}
914
+ root_signature = _signature(observations, root_key)
915
+ for account_key in sorted(accounts):
916
+ conn.execute(
917
+ """INSERT INTO quota_projection_state
918
+ (source_root_key, account_key, generation, physical_signature,
919
+ completed_at_utc)
920
+ VALUES (?,?,?,?,?)
921
+ ON CONFLICT(source_root_key, account_key) DO UPDATE SET
922
+ generation=excluded.generation,
923
+ physical_signature=excluded.physical_signature,
924
+ completed_at_utc=excluded.completed_at_utc""",
925
+ (root_key, account_key, generation, root_signature, now_iso),
926
+ )
878
927
  if sink is not None:
879
928
  # Set-then-dispatch: all claims committed with the cycle before the
880
929
  # cycle's post-commit ALERT_DISPATCHER fires them (spec §5.2 step 6).
@@ -1027,16 +1076,22 @@ def reconcile_codex_quota_projection(
1027
1076
  def _codex_leg(ctx):
1028
1077
  def _emit_arming(identity, fingerprint, activated_at):
1029
1078
  # Item 5: journal the arming state change (`quota_alert_arming` evt).
1079
+ # account_key (#341) is part of the qaa natural key (after the root):
1080
+ # two accounts arming the same physical window produce DISTINCT evt
1081
+ # ids + rows. This id MUST match the cutover export's natural_key_id
1082
+ # ordering in _cctally_journal._CUTOVER_SPECS so a live re-emission and
1083
+ # a cutover-exported arming for one identity converge as ONE record.
1030
1084
  eid = _jl.evt_id(
1031
1085
  "qaa", identity.source, identity.source_root_key,
1032
- identity.logical_limit_key, identity.observed_slot,
1033
- identity.window_minutes,
1086
+ identity.account_key, identity.logical_limit_key,
1087
+ identity.observed_slot, identity.window_minutes,
1034
1088
  )
1035
1089
  _jr.append_record(_jl.make_evt(
1036
1090
  kind="quota_alert_arming", id=eid, at=activated_at,
1037
1091
  payload={
1038
1092
  "source": identity.source,
1039
1093
  "source_root_key": identity.source_root_key,
1094
+ "account_key": identity.account_key,
1040
1095
  "logical_limit_key": identity.logical_limit_key,
1041
1096
  "observed_slot": identity.observed_slot,
1042
1097
  "window_minutes": identity.window_minutes,
@@ -1081,13 +1136,14 @@ def _load_active_milestones(
1081
1136
  return list(stats.execute(
1082
1137
  """SELECT percent_threshold, captured_at_utc, source_path, line_offset
1083
1138
  FROM quota_percent_milestones
1084
- WHERE source=? AND source_root_key=? AND logical_limit_key=?
1085
- AND observed_slot=? AND window_minutes=? AND resets_at_utc=?
1086
- AND orphaned_at IS NULL
1139
+ WHERE source=? AND source_root_key=? AND account_key=?
1140
+ AND logical_limit_key=? AND observed_slot=? AND window_minutes=?
1141
+ AND resets_at_utc=? AND orphaned_at IS NULL
1087
1142
  ORDER BY percent_threshold""",
1088
1143
  (
1089
- identity.source, identity.source_root_key, identity.logical_limit_key,
1090
- identity.observed_slot, identity.window_minutes, _utc_iso(resets_at),
1144
+ identity.source, identity.source_root_key, identity.account_key,
1145
+ identity.logical_limit_key, identity.observed_slot,
1146
+ identity.window_minutes, _utc_iso(resets_at),
1091
1147
  ),
1092
1148
  ))
1093
1149
  finally:
@@ -1486,7 +1542,10 @@ def _sync_and_load(
1486
1542
  if should_sync:
1487
1543
  cache = c.open_cache_db()
1488
1544
  try:
1489
- c.sync_codex_cache(cache)
1545
+ cache_mod = c._load_sibling("_cctally_cache")
1546
+ _, cache = cache_mod._run_cache_operation_with_recovery(
1547
+ cache, lambda active_conn: c.sync_codex_cache(active_conn)
1548
+ )
1490
1549
  finally:
1491
1550
  cache.close()
1492
1551
  # The nested quota leaves heal the durable projection on every read. The
@@ -1528,6 +1587,38 @@ def _command_context(
1528
1587
  return as_of, since, until, selected
1529
1588
 
1530
1589
 
1590
+ def _resolve_account_and_scope(args, histories):
1591
+ """(#341, spec §3) Resolve ``--account`` (provider=codex) and scope histories.
1592
+
1593
+ Returns ``(account_key | None, exit_code | None, filtered_histories)``:
1594
+ ``None`` key = the merged view (byte-identical to today, R8); a resolved ref
1595
+ filters to that account's quota identities; a bad ref yields exit 2 (the
1596
+ ``account: …`` diagnostic is printed by ``resolve_account_filter``).
1597
+ The native quota views read the projection, not the entry cache, so
1598
+ ``needs_cache=False``.
1599
+ """
1600
+ c = _cctally()
1601
+ account_key, acct_exit = c.resolve_account_filter(
1602
+ args, "codex", needs_cache=False)
1603
+ if acct_exit is not None:
1604
+ return None, acct_exit, histories
1605
+ if account_key is not None:
1606
+ histories = type(histories)(
1607
+ h for h in histories
1608
+ if getattr(h.identity, "account_key", _lib_accounts.UNATTRIBUTED)
1609
+ == account_key
1610
+ )
1611
+ return account_key, None, histories
1612
+
1613
+
1614
+ def _decorate_account(payload: dict[str, object], account_key: "str | None") -> dict:
1615
+ """(#341 R8) Add ``accountKey``/``accountLabel`` to a quota payload under an
1616
+ explicit ``--account`` invocation; a no-flag render stays byte-identical."""
1617
+ if account_key is not None:
1618
+ payload.update(_cctally().account_json_fields(account_key))
1619
+ return payload
1620
+
1621
+
1531
1622
  def _emit(args, payload: dict[str, object], text: str) -> int:
1532
1623
  if getattr(args, "json", False):
1533
1624
  print(json.dumps(stamp_schema_version(payload), ensure_ascii=False))
@@ -1547,6 +1638,9 @@ def cmd_codex_quota_history(args) -> int:
1547
1638
  as_of, since, until, histories = _command_context(args, range_args=True)
1548
1639
  except QuotaCLIError as exc:
1549
1640
  return _command_error(exc)
1641
+ acct_key, acct_exit, histories = _resolve_account_and_scope(args, histories)
1642
+ if acct_exit is not None:
1643
+ return acct_exit
1550
1644
  windows = []
1551
1645
  text_rows = ["Codex quota history · local-rollout"]
1552
1646
  for history in histories:
@@ -1572,6 +1666,7 @@ def cmd_codex_quota_history(args) -> int:
1572
1666
  "source": "codex", "generatedAt": _iso_z(as_of),
1573
1667
  "freshnessSource": "local-rollout", "windows": windows,
1574
1668
  }
1669
+ _decorate_account(payload, acct_key) # #341 R8
1575
1670
  if len(text_rows) == 1:
1576
1671
  text_rows.append("No Codex quota history.")
1577
1672
  return _emit(args, payload, "\n".join(text_rows))
@@ -1595,6 +1690,9 @@ def cmd_codex_quota_statusline(args) -> int:
1595
1690
  as_of, _since, _until, histories = _command_context(args)
1596
1691
  except QuotaCLIError as exc:
1597
1692
  return _command_error(exc)
1693
+ acct_key, acct_exit, histories = _resolve_account_and_scope(args, histories)
1694
+ if acct_exit is not None:
1695
+ return acct_exit
1598
1696
  windows = []
1599
1697
  text_rows = []
1600
1698
  for history in histories:
@@ -1622,6 +1720,7 @@ def cmd_codex_quota_statusline(args) -> int:
1622
1720
  "source": "codex", "generatedAt": _iso_z(as_of),
1623
1721
  "freshnessSource": "local-rollout", "windows": windows,
1624
1722
  }
1723
+ _decorate_account(payload, acct_key) # #341 R8
1625
1724
  return _emit(args, payload, "\n".join(text_rows) if text_rows else "Codex quota unavailable.")
1626
1725
 
1627
1726
 
@@ -1648,6 +1747,9 @@ def cmd_codex_quota_forecast(args) -> int:
1648
1747
  as_of, _since, _until, histories = _command_context(args)
1649
1748
  except QuotaCLIError as exc:
1650
1749
  return _command_error(exc)
1750
+ acct_key, acct_exit, histories = _resolve_account_and_scope(args, histories)
1751
+ if acct_exit is not None:
1752
+ return acct_exit
1651
1753
  forecasts = [_forecast_wire(history, as_of) for history in histories]
1652
1754
  text_rows = ["Codex quota forecast · local-rollout"]
1653
1755
  for history, forecast in zip(histories, forecasts):
@@ -1664,6 +1766,7 @@ def cmd_codex_quota_forecast(args) -> int:
1664
1766
  "source": "codex", "generatedAt": _iso_z(as_of),
1665
1767
  "freshnessSource": "local-rollout", "forecasts": forecasts,
1666
1768
  }
1769
+ _decorate_account(payload, acct_key) # #341 R8
1667
1770
  return _emit(args, payload, "\n".join(text_rows))
1668
1771
 
1669
1772
 
@@ -1673,6 +1776,9 @@ def cmd_codex_quota_blocks(args) -> int:
1673
1776
  as_of, since, until, histories = _command_context(args, range_args=True)
1674
1777
  except QuotaCLIError as exc:
1675
1778
  return _command_error(exc)
1779
+ acct_key, acct_exit, histories = _resolve_account_and_scope(args, histories)
1780
+ if acct_exit is not None:
1781
+ return acct_exit
1676
1782
  blocks = []
1677
1783
  text_rows = ["Codex quota blocks · local-rollout"]
1678
1784
  for block in build_blocks(
@@ -1698,6 +1804,7 @@ def cmd_codex_quota_blocks(args) -> int:
1698
1804
  "source": "codex", "generatedAt": _iso_z(as_of),
1699
1805
  "freshnessSource": "local-rollout", "blocks": blocks,
1700
1806
  }
1807
+ _decorate_account(payload, acct_key) # #341 R8
1701
1808
  if len(text_rows) == 1:
1702
1809
  text_rows.append("No Codex quota blocks.")
1703
1810
  return _emit(args, payload, "\n".join(text_rows))
@@ -1707,6 +1814,9 @@ def cmd_codex_quota_breakdown(args) -> int:
1707
1814
  """Render root-qualified, live-priced milestone deltas for one block."""
1708
1815
  try:
1709
1816
  as_of, _since, _until, histories = _command_context(args)
1817
+ acct_key, acct_exit, histories = _resolve_account_and_scope(args, histories)
1818
+ if acct_exit is not None:
1819
+ return acct_exit
1710
1820
  if len(histories) != 1:
1711
1821
  raise QuotaCLIError(
1712
1822
  "breakdown requires selectors resolving to exactly one quota identity; candidates:\n"
@@ -1745,6 +1855,7 @@ def cmd_codex_quota_breakdown(args) -> int:
1745
1855
  "block": {"resetAt": _iso_z(block.resets_at), "nominalStartAt": _iso_z(block.nominal_start_at)},
1746
1856
  "speed": speed, "milestones": milestones,
1747
1857
  }
1858
+ _decorate_account(payload, acct_key) # #341 R8
1748
1859
  text_rows = [
1749
1860
  f"Codex quota breakdown · {_identity_label(identity)}",
1750
1861
  f"reset {_iso_z(block.resets_at)} · speed {speed}",