cctally 1.82.0 → 1.83.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 +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -43,7 +43,12 @@ from _lib_quota import (
43
43
  source_path_key,
44
44
  )
45
45
  from _lib_json_envelope import stamp_schema_version
46
- from _lib_jsonl import _codex_logical_limit_key, codex_model_scoped_quota_pool
46
+ from _lib_jsonl import _codex_logical_limit_key
47
+ from _lib_codex_pools import (
48
+ codex_history_is_model_scoped,
49
+ codex_model_scoped_quota_pool,
50
+ is_model_scoped_codex_quota,
51
+ )
47
52
 
48
53
 
49
54
  UTC = dt.timezone.utc
@@ -748,7 +753,7 @@ def _quota_alert_payload(
748
753
  def _evaluate_quota_alerts(
749
754
  conn: sqlite3.Connection,
750
755
  *, observations: tuple[QuotaObservation, ...], alert_eligible_roots: set[str],
751
- now: dt.datetime, now_iso: str, journal_emit=None,
756
+ now: dt.datetime, now_iso: str, journal_emit=None, journal_disarm=None,
752
757
  ) -> list[dict]:
753
758
  """Arm or claim quota alerts within the caller's stats transaction.
754
759
 
@@ -756,18 +761,42 @@ def _evaluate_quota_alerts(
756
761
  eligible observations can claim an alerted row. No non-terminal state is
757
762
  stored: the arming boundary plus unique terminal event key is sufficient.
758
763
  """
759
- if not alert_eligible_roots:
760
- return []
761
764
  global_enabled, quota_enabled, rules, config = _quota_alert_config()
762
765
  # Disabled delivery is entirely inert: it must not leave an arming
763
766
  # boundary that could turn disabled-period evidence into a later alert.
767
+ # This gate deliberately precedes the lifecycle-eligibility fast path:
768
+ # read-only report reconciles carry no eligible roots, but they must still
769
+ # durably disarm state after either user-facing switch is turned off.
764
770
  if not (global_enabled and quota_enabled):
765
- placeholders = ", ".join("?" for _ in alert_eligible_roots)
771
+ # Scratch rebuild/rederive replay has no alert sink and must preserve
772
+ # the provider-owned state selected from the journal verbatim. Only the
773
+ # live single-flight reconcile supplies the durable disarm emitter.
774
+ if journal_disarm is None:
775
+ return []
776
+ rows = conn.execute(
777
+ """SELECT source, source_root_key, account_key,
778
+ logical_limit_key, observed_slot, window_minutes
779
+ FROM quota_alert_arming
780
+ WHERE source='codex'"""
781
+ ).fetchall()
766
782
  conn.execute(
767
- f"""DELETE FROM quota_alert_arming
768
- WHERE source='codex' AND source_root_key IN ({placeholders})""",
769
- tuple(sorted(alert_eligible_roots)),
783
+ "DELETE FROM quota_alert_arming WHERE source='codex'"
770
784
  )
785
+ if journal_disarm is not None:
786
+ for row in rows:
787
+ journal_disarm(
788
+ QuotaWindowIdentity(
789
+ source=str(row["source"]),
790
+ source_root_key=str(row["source_root_key"]),
791
+ account_key=str(row["account_key"]),
792
+ logical_limit_key=str(row["logical_limit_key"]),
793
+ observed_slot=str(row["observed_slot"]),
794
+ window_minutes=int(row["window_minutes"]),
795
+ ),
796
+ now_iso,
797
+ )
798
+ return []
799
+ if not alert_eligible_roots:
771
800
  return []
772
801
  histories = build_history(observations)
773
802
  queued: list[dict] = []
@@ -858,7 +887,8 @@ def _evaluate_quota_alerts(
858
887
 
859
888
  def _apply_quota_projection_rows(
860
889
  conn, *, observations, active_roots, now, now_iso,
861
- sink, alert_eligible_roots, journal_emit=None, holder=None,
890
+ sink, alert_eligible_roots, journal_emit=None, journal_disarm=None,
891
+ holder=None,
862
892
  ):
863
893
  """Transaction-neutral quota projection apply (spec §5.3 "projection").
864
894
 
@@ -891,6 +921,7 @@ def _apply_quota_projection_rows(
891
921
  conn, observations=observations,
892
922
  alert_eligible_roots=alert_eligible_roots & active_roots,
893
923
  now=now, now_iso=now_iso, journal_emit=journal_emit,
924
+ journal_disarm=journal_disarm,
894
925
  )
895
926
  # The completion stamp is intentionally the final DML in the stats
896
927
  # transaction. A pre-commit failure rolls all projection updates back;
@@ -1001,6 +1032,10 @@ def reconcile_codex_quota_projection(
1001
1032
  now_iso = _utc_iso(now)
1002
1033
 
1003
1034
  alert_eligible_roots = {str(key) for key in alert_eligible_root_keys}
1035
+ global_alerts_enabled, quota_alerts_enabled, _rules, _config = (
1036
+ _quota_alert_config()
1037
+ )
1038
+ delivery_enabled = global_alerts_enabled and quota_alerts_enabled
1004
1039
 
1005
1040
  try:
1006
1041
  cache = _cache_connection()
@@ -1031,8 +1066,16 @@ def reconcile_codex_quota_projection(
1031
1066
  if physical_sequence == cert_seq and active_roots <= set(cert_sigs):
1032
1067
  stats_conn = _cctally_core.open_db()
1033
1068
  try:
1034
- if _stats_projection_signatures_match(
1035
- stats_conn, active_roots, cert_sigs
1069
+ has_arming = bool(stats_conn.execute(
1070
+ "SELECT 1 FROM quota_alert_arming "
1071
+ "WHERE source='codex' LIMIT 1"
1072
+ ).fetchone())
1073
+ can_skip_delivery = delivery_enabled or not has_arming
1074
+ if (
1075
+ can_skip_delivery
1076
+ and _stats_projection_signatures_match(
1077
+ stats_conn, active_roots, cert_sigs
1078
+ )
1036
1079
  ):
1037
1080
  return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
1038
1081
  finally:
@@ -1058,7 +1101,9 @@ def reconcile_codex_quota_projection(
1058
1101
  "signatures": None,
1059
1102
  }
1060
1103
 
1061
- def _apply_projection(conn, sink, *, journal_emit=None):
1104
+ def _apply_projection(
1105
+ conn, sink, *, journal_emit=None, journal_disarm=None,
1106
+ ):
1062
1107
  # No configured roots and no existing interpreted history means there is
1063
1108
  # no stats work. This preserves the existing empty-Codex fast path.
1064
1109
  # Delegates to the shared module-level apply so the live leg and the
@@ -1067,7 +1112,8 @@ def reconcile_codex_quota_projection(
1067
1112
  conn, observations=observations, active_roots=active_roots,
1068
1113
  now=now, now_iso=now_iso, sink=sink,
1069
1114
  alert_eligible_roots=alert_eligible_roots,
1070
- journal_emit=journal_emit, holder=holder,
1115
+ journal_emit=journal_emit, journal_disarm=journal_disarm,
1116
+ holder=holder,
1071
1117
  )
1072
1118
 
1073
1119
  import _cctally_journal as _jr
@@ -1076,15 +1122,16 @@ def reconcile_codex_quota_projection(
1076
1122
  def _codex_leg(ctx):
1077
1123
  def _emit_arming(identity, fingerprint, activated_at):
1078
1124
  # 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.
1125
+ # account_key (#341) is part of the qaa state identity (after the
1126
+ # root), while fingerprint + activation boundary make each real
1127
+ # state transition a distinct rev-0 event (#372). Exact re-emission
1128
+ # of one state still converges on one id. This order MUST match the
1129
+ # cutover export's natural_key_id.
1084
1130
  eid = _jl.evt_id(
1085
1131
  "qaa", identity.source, identity.source_root_key,
1086
1132
  identity.account_key, identity.logical_limit_key,
1087
1133
  identity.observed_slot, identity.window_minutes,
1134
+ fingerprint, activated_at,
1088
1135
  )
1089
1136
  _jr.append_record(_jl.make_evt(
1090
1137
  kind="quota_alert_arming", id=eid, at=activated_at,
@@ -1097,11 +1144,40 @@ def reconcile_codex_quota_projection(
1097
1144
  "window_minutes": identity.window_minutes,
1098
1145
  "rule_fingerprint": fingerprint,
1099
1146
  "activated_at_utc": activated_at,
1147
+ "journal_identity_version": 2,
1148
+ },
1149
+ ))
1150
+ ctx.events_emitted += 1
1151
+
1152
+ def _emit_disarm(identity, disarmed_at):
1153
+ eid = _jl.evt_id(
1154
+ "qaa", identity.source, identity.source_root_key,
1155
+ identity.account_key, identity.logical_limit_key,
1156
+ identity.observed_slot, identity.window_minutes,
1157
+ "disarmed", disarmed_at,
1158
+ )
1159
+ _jr.append_record(_jl.make_evt(
1160
+ kind="quota_alert_arming", id=eid, at=disarmed_at,
1161
+ payload={
1162
+ "source": identity.source,
1163
+ "source_root_key": identity.source_root_key,
1164
+ "account_key": identity.account_key,
1165
+ "logical_limit_key": identity.logical_limit_key,
1166
+ "observed_slot": identity.observed_slot,
1167
+ "window_minutes": identity.window_minutes,
1168
+ "state": "disarmed",
1169
+ "disarmed_at_utc": disarmed_at,
1170
+ "journal_identity_version": 2,
1100
1171
  },
1101
1172
  ))
1102
1173
  ctx.events_emitted += 1
1103
1174
 
1104
- _apply_projection(ctx.conn, ctx.pending_alerts, journal_emit=_emit_arming)
1175
+ _apply_projection(
1176
+ ctx.conn,
1177
+ ctx.pending_alerts,
1178
+ journal_emit=_emit_arming,
1179
+ journal_disarm=_emit_disarm,
1180
+ )
1105
1181
  # `_before_stats_commit` fires INSIDE the cycle txn, before COMMIT — a
1106
1182
  # raise rolls the whole cycle back, so the projection updates undo
1107
1183
  # together (the crash-consistency contract the callers test).
@@ -1321,7 +1397,19 @@ def codex_five_hour_percent_at_crossing(
1321
1397
  observations: Iterable[QuotaObservation] | None = None,
1322
1398
  *, cache_conn: sqlite3.Connection | None = None,
1323
1399
  ) -> float | None:
1324
- """Return the latest matching native five-hour percent at one crossing."""
1400
+ """Return the latest matching native five-hour percent at one crossing.
1401
+
1402
+ #373: the 5h window must share the target identity's MODEL SCOPE, not just
1403
+ its ``limit_id``. A separate model pool can reuse ``limit_id="codex"`` and
1404
+ spell itself only in the interpreted key's ``modelPool`` or in
1405
+ ``limit_name``, so the pre-existing ``limit_id`` equality lets it through
1406
+ and a standard weekly crossing gets annotated with a foreign pool's 5h
1407
+ percent. The rule is symmetric: a Spark weekly still correlates with Spark
1408
+ 5h rows, and never with standard ones.
1409
+ """
1410
+ target_model_scoped = is_model_scoped_codex_quota(
1411
+ identity.logical_limit_key, identity.limit_name,
1412
+ )
1325
1413
  if observations is not None:
1326
1414
  eligible = tuple(
1327
1415
  observation for observation in observations
@@ -1329,6 +1417,10 @@ def codex_five_hour_percent_at_crossing(
1329
1417
  and observation.identity.window_minutes == 300
1330
1418
  and observation.identity.observed_slot == identity.observed_slot
1331
1419
  and observation.identity.limit_id == identity.limit_id
1420
+ and is_model_scoped_codex_quota(
1421
+ observation.identity.logical_limit_key,
1422
+ observation.identity.limit_name,
1423
+ ) == target_model_scoped
1332
1424
  and observation.captured_at <= captured_at < observation.resets_at
1333
1425
  )
1334
1426
  if not eligible:
@@ -1356,7 +1448,7 @@ def codex_five_hour_percent_at_crossing(
1356
1448
  try:
1357
1449
  rows = cache.execute(
1358
1450
  """SELECT captured_at_utc, resets_at_utc, source_path, line_offset,
1359
- used_percent
1451
+ used_percent, logical_limit_key, limit_name
1360
1452
  FROM quota_window_snapshots
1361
1453
  WHERE source='codex' AND source_root_key=?
1362
1454
  AND window_minutes=300 AND observed_slot=? AND limit_id IS ?
@@ -1378,6 +1470,10 @@ def codex_five_hour_percent_at_crossing(
1378
1470
  used_percent = float(row[4])
1379
1471
  except (TypeError, ValueError, OverflowError):
1380
1472
  continue
1473
+ # Same model-scope rule as the in-memory branch above; the two must not
1474
+ # disagree about which 5h window belongs to this identity's pool.
1475
+ if is_model_scoped_codex_quota(row[5], row[6]) != target_model_scoped:
1476
+ continue
1381
1477
  if observed_at <= captured_at < resets_at:
1382
1478
  eligible.append((physical, used_percent))
1383
1479
  if not eligible:
@@ -1544,7 +1640,9 @@ def _sync_and_load(
1544
1640
  try:
1545
1641
  cache_mod = c._load_sibling("_cctally_cache")
1546
1642
  _, cache = cache_mod._run_cache_operation_with_recovery(
1547
- cache, lambda active_conn: c.sync_codex_cache(active_conn)
1643
+ cache,
1644
+ lambda active_conn: c.sync_codex_cache(active_conn),
1645
+ origin="codex.quota.sync",
1548
1646
  )
1549
1647
  finally:
1550
1648
  cache.close()
@@ -1890,12 +1988,28 @@ def cmd_codex_percent_breakdown(args) -> int:
1890
1988
  if block.resets_at == reset_at
1891
1989
  )
1892
1990
  else:
1991
+ # #373: a separate model pool is not account-level standard quota,
1992
+ # so it must not make the DEFAULT (no-selector) command ambiguous.
1993
+ # Scoped to this branch only — `--reset-at` still reaches a foreign
1994
+ # pool's retained history — and it falls back to the unfiltered set
1995
+ # when no standard candidate survives, so a lone foreign pool (or an
1996
+ # explicit `--limit-key` naming one) still renders rather than
1997
+ # turning into an exit-2 for a window the user genuinely has.
1998
+ # The baseline is resolved first because it is the §7.1 label
1999
+ # authority for classification.
2000
+ candidates = tuple(
2001
+ (history, select_baseline(history.observations, as_of))
2002
+ for history in histories
2003
+ )
2004
+ standard = tuple(
2005
+ (history, baseline) for history, baseline in candidates
2006
+ if not codex_history_is_model_scoped(history, baseline=baseline)
2007
+ )
1893
2008
  matching = tuple(
1894
2009
  (history, block)
1895
- for history in histories
2010
+ for history, baseline in (standard or candidates)
1896
2011
  if (
1897
- (baseline := select_baseline(history.observations, as_of))
1898
- is not None
2012
+ baseline is not None
1899
2013
  and baseline.resets_at > as_of
1900
2014
  and quota_freshness(
1901
2015
  history.physical_observations, as_of,