cctally 1.102.0 → 1.104.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 (42) hide show
  1. package/CHANGELOG.md +1274 -1389
  2. package/README.md +3 -3
  3. package/bin/_cctally_alerts.py +65 -4
  4. package/bin/_cctally_cache.py +31 -0
  5. package/bin/_cctally_config.py +62 -2
  6. package/bin/_cctally_core.py +62 -1
  7. package/bin/_cctally_dashboard.py +11 -0
  8. package/bin/_cctally_dashboard_envelope.py +319 -14
  9. package/bin/_cctally_dashboard_share.py +56 -25
  10. package/bin/_cctally_doctor.py +63 -0
  11. package/bin/_cctally_forecast.py +917 -44
  12. package/bin/_cctally_journal.py +153 -5
  13. package/bin/_cctally_parser.py +66 -0
  14. package/bin/_cctally_project.py +535 -10
  15. package/bin/_cctally_quota.py +13 -0
  16. package/bin/_cctally_quota_calibration.py +146 -0
  17. package/bin/_cctally_quota_model.py +1616 -0
  18. package/bin/_cctally_record.py +114 -6
  19. package/bin/_cctally_share.py +16 -8
  20. package/bin/_cctally_statusline.py +34 -0
  21. package/bin/_cctally_tui.py +214 -45
  22. package/bin/_lib_dashboard_settings_contract.py +2 -0
  23. package/bin/_lib_doctor.py +159 -1
  24. package/bin/_lib_forecast.py +337 -43
  25. package/bin/_lib_jsonl.py +102 -5
  26. package/bin/_lib_meter_rate_change.py +294 -0
  27. package/bin/_lib_pricing.py +97 -21
  28. package/bin/_lib_quota_calibration.py +311 -0
  29. package/bin/_lib_quota_copy.py +131 -0
  30. package/bin/_lib_quota_model.py +2333 -0
  31. package/bin/_lib_rederive.py +10 -0
  32. package/bin/_lib_render.py +6 -0
  33. package/bin/_lib_share_templates.py +37 -5
  34. package/bin/_lib_statusline.py +226 -2
  35. package/bin/_lib_view_models.py +30 -12
  36. package/bin/cctally +32 -0
  37. package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
  38. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  39. package/dashboard/static/dashboard.html +2 -2
  40. package/package.json +7 -1
  41. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  42. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
@@ -3611,7 +3611,16 @@ _ACCOUNTS_MACHINERY_KINDS = frozenset(
3611
3611
  # STRUCTURALLY by tests/test_accounts_journal.py (iterating both spec registries),
3612
3612
  # so a future data-bearing kind cannot silently escape classification.
3613
3613
  _CLASSIFIER_VENDOR_TAGGED_KINDS = frozenset(("budget",))
3614
- _CLASSIFIER_EXEMPT_KINDS = frozenset(("weekly_credit_effects",))
3614
+ # `meter_rate_change` (#661 S2 §6.4) is EXEMPT for the same structural reason
3615
+ # `weekly_credit_effects` is: this classifier exists to normalise the account
3616
+ # of a LEGACY, unstamped line, and no such line can exist for this family. It
3617
+ # was introduced with epoch 1011, every emission carries `payload.account_key`
3618
+ # from the first one, and its target table has no unstamped history to
3619
+ # normalise. Filing it under `_EVT_KIND_PROVIDER` would claim a fixed vendor
3620
+ # for a family whose provider is a payload field, which is the failure the
3621
+ # vendor-tagged case exists to avoid.
3622
+ _CLASSIFIER_EXEMPT_KINDS = frozenset((
3623
+ "weekly_credit_effects", "meter_rate_change"))
3615
3624
 
3616
3625
 
3617
3626
  def classify_legacy_provider(record) -> str | None:
@@ -3893,6 +3902,70 @@ def _apply_quota_alert_arming(conn, evt):
3893
3902
  return None
3894
3903
 
3895
3904
 
3905
+ def _meter_rate_change_row(evt) -> "tuple | None":
3906
+ """The row `meter_rate_change_events` takes, or None for a bad payload.
3907
+
3908
+ Normalising rather than trusting the payload keeps a malformed record from
3909
+ raising here and prefix-stopping the whole fold.
3910
+ """
3911
+ p = evt.get("payload") or {}
3912
+ provider = p.get("provider")
3913
+ effective_from = p.get("effective_from")
3914
+ if not provider or not effective_from:
3915
+ return None
3916
+ try:
3917
+ previous = float(p.get("previous_units_per_point"))
3918
+ new = float(p.get("new_units_per_point"))
3919
+ except (TypeError, ValueError):
3920
+ return None
3921
+ account_key = p.get("account_key") or _lib_accounts.UNATTRIBUTED
3922
+ created = p.get("created_at_utc") or evt.get("at")
3923
+ return (str(provider), str(account_key), str(effective_from), previous,
3924
+ new, str(p.get("severity") or "info"),
3925
+ str(p.get("detected_at_utc") or created or ""), str(created or ""))
3926
+
3927
+
3928
+ def _insert_meter_rate_change(conn, evt) -> bool:
3929
+ """Insert one rate-change row. True when THIS call created it.
3930
+
3931
+ The return value is the §6.5 step-5 predicate: a notification is queued
3932
+ only when the insert actually created a row, so a crash-replayed
3933
+ duplicate, an ordinary re-run and a rebuild all converge silently. It is
3934
+ `rowcount`, never `lastrowid` — the same predicate the milestone families
3935
+ use, because `lastrowid` is left over from a previous insert when
3936
+ `INSERT OR IGNORE` ignores.
3937
+ """
3938
+ row = _meter_rate_change_row(evt)
3939
+ if row is None:
3940
+ return False
3941
+ cur = conn.execute(
3942
+ "INSERT OR IGNORE INTO meter_rate_change_events "
3943
+ "(provider, account_key, effective_from, previous_units_per_point,"
3944
+ " new_units_per_point, severity, detected_at_utc, created_at_utc) "
3945
+ "VALUES (?,?,?,?,?,?,?,?)", row)
3946
+ return cur.rowcount == 1
3947
+
3948
+
3949
+ def _apply_meter_rate_change(conn, evt):
3950
+ """Fold a `meter_rate_change` evt (#661 S2 §6.4/§6.5).
3951
+
3952
+ `meter_rate_change_events` is the durable forward-only latch that one
3953
+ provider metering-rate transition was recorded. It has no `journal_id`
3954
+ column, so idempotence rides the natural-key `INSERT OR IGNORE` exactly as
3955
+ `quota_alert_arming` and `quota_threshold_events` do rather than an
3956
+ `INSERT OR IGNORE` on the journal id.
3957
+
3958
+ Replay reaches this applier with NO `IngestContext`, so it is
3959
+ structurally unable to queue a notification — invariant (iv), and the
3960
+ reason a rebuild cannot re-fire history. `notified_at` is deliberately not
3961
+ journaled and not touched here: whether a notification was dispatched is a
3962
+ local delivery fact, not a durable one, and replaying a stale value would
3963
+ fight the live path.
3964
+ """
3965
+ _insert_meter_rate_change(conn, evt)
3966
+ return None
3967
+
3968
+
3896
3969
  def _apply_quota_threshold_event(conn, evt):
3897
3970
  """Fold a `quota_threshold_event` evt (#416 spec §7.2, review F13).
3898
3971
 
@@ -4158,6 +4231,12 @@ _EVT_SPECS = {
4158
4231
  # insert is `INSERT OR IGNORE`, so neither can clobber the other's row.
4159
4232
  "quota_threshold_event": _EvtSpec(
4160
4233
  None, order=44, applier=_apply_quota_threshold_event),
4234
+ # #661 S2 §6.4. An independent stats.db table with no FK into the
4235
+ # journal-covered families and its own natural-key insert applier, so the
4236
+ # order is arbitrary among evts. 43 keeps it beside the other two quota
4237
+ # state families rather than implying a dependency it does not have.
4238
+ "meter_rate_change": _EvtSpec(
4239
+ None, order=43, applier=_apply_meter_rate_change),
4161
4240
  }
4162
4241
  for _hs in _HARVEST_SPECS:
4163
4242
  if _hs.children:
@@ -4388,6 +4467,54 @@ def _pipeline_op_fold(ctx, record) -> None:
4388
4467
  PIPELINE.append(_pipeline_op_fold)
4389
4468
 
4390
4469
 
4470
+ def record_meter_rate_change(ctx, transition, *, notify: bool,
4471
+ created_at: str) -> bool:
4472
+ """Steps 4 and 5 of spec §6.5, run INSIDE the cycle transaction.
4473
+
4474
+ Journal-first (invariant i): the evt line is appended and fsync'd through
4475
+ the leaf `journal.lock` before the transaction containing its row commits,
4476
+ exactly as `emit_model_a` does. The payload is a pure function of the
4477
+ transition, so a crash-replayed duplicate line is byte-identical and folds
4478
+ to a clean no-op.
4479
+
4480
+ Returns whether THIS call created the row. A notification is queued only
4481
+ then, and only when `notify` is true — §6.2 splits recording from
4482
+ notifying, so the row exists from the first upgrade while the push follows
4483
+ the existing default-off toggle. The queue is `ctx.pending_alerts`, which
4484
+ step 6 drains AFTER the commit: committed-before-notify, and a crash
4485
+ between the two loses at most one dispatch rather than recording an alert
4486
+ that never happened.
4487
+ """
4488
+ mrc = _load_meter_rate_change()
4489
+ payload = mrc.event_payload(transition, created_at=created_at)
4490
+ eid = _lib_journal.evt_id(
4491
+ mrc.EVT_ID_PREFIX, transition.provider, transition.account_key,
4492
+ transition.effective_from)
4493
+ evt = _lib_journal.make_evt(
4494
+ kind=mrc.EVT_KIND, id=eid, at=created_at, payload=payload)
4495
+ append_record(evt)
4496
+ ctx.events_emitted += 1
4497
+ created = _insert_meter_rate_change(ctx.conn, evt)
4498
+ if created and notify:
4499
+ ctx.pending_alerts.append(mrc.alert_payload(transition))
4500
+ return created
4501
+
4502
+
4503
+ def _load_meter_rate_change():
4504
+ """The pure kernel, via the call-time sibling accessor.
4505
+
4506
+ A module-level `import _lib_meter_rate_change` would be honest here, but
4507
+ this module is imported by the kernel-extraction paths that must not grow
4508
+ new import-time edges; every other optional sibling in this file is
4509
+ reached the same way.
4510
+ """
4511
+ cctally = sys.modules.get("cctally")
4512
+ if cctally is not None and hasattr(cctally, "_load_sibling"):
4513
+ return cctally._load_sibling("_lib_meter_rate_change")
4514
+ import _lib_meter_rate_change
4515
+ return _lib_meter_rate_change
4516
+
4517
+
4391
4518
  def _dispatch_pending_alerts(alerts: list) -> None:
4392
4519
  """Default post-commit dispatch (spec §5.2 step 6): fire each queued alert
4393
4520
  payload through the cctally dispatch glue (bin/_lib_alert_dispatch via
@@ -6049,7 +6176,8 @@ def _preflight_live_events(
6049
6176
  # --------------------------------------------------------------------------
6050
6177
 
6051
6178
  def _run_cycle(conn: sqlite3.Connection, *, reconcile_config=None,
6052
- codex_apply=None, post_commit=None) -> IngestResult:
6179
+ codex_apply=None, post_commit=None,
6180
+ meter_rate_change=None) -> IngestResult:
6053
6181
  # Step 1: HW snapshot (leaf lock, µs). Lines appended after this — by other
6054
6182
  # processes OR by this cycle's own evt emission — are past HW and belong to
6055
6183
  # the next cycle (§5.2.1, closes the skipped-append race).
@@ -6074,7 +6202,8 @@ def _run_cycle(conn: sqlite3.Connection, *, reconcile_config=None,
6074
6202
  cursor = None
6075
6203
  cursor_target = None
6076
6204
  if hw is None:
6077
- if reconcile_config is None and codex_apply is None:
6205
+ if (reconcile_config is None and codex_apply is None
6206
+ and meter_rate_change is None):
6078
6207
  return IngestResult(ran=True, consumed=0, malformed=0,
6079
6208
  events_emitted=0, alerts=[])
6080
6209
  else:
@@ -6190,6 +6319,18 @@ def _run_cycle(conn: sqlite3.Connection, *, reconcile_config=None,
6190
6319
  # the whole cycle back (invariant ii).
6191
6320
  if codex_apply is not None:
6192
6321
  codex_apply(ctx)
6322
+ # 4b'''. #661 S2 §6.5 steps 4-5. A confirmed metering-rate transition,
6323
+ # already decided under the calibration file's leaf lock and released
6324
+ # before any stats lock was taken (step 2), is journaled and applied
6325
+ # here — inside this transaction, journal-first, with its notification
6326
+ # queued to `ctx.pending_alerts` for the post-commit dispatch below.
6327
+ # It sits beside the Codex leg rather than in the pipeline because its
6328
+ # trigger is a persistence transition rather than a journal record.
6329
+ if meter_rate_change is not None:
6330
+ record_meter_rate_change(
6331
+ ctx, meter_rate_change["transition"],
6332
+ notify=bool(meter_rate_change.get("notify")),
6333
+ created_at=str(meter_rate_change["created_at"]))
6193
6334
  # 4c. Journal + stamp the natural-keyed rows the pipeline inserted.
6194
6335
  # Early-out (Task 6 gate P2): the ONLY source of `journal_id IS NULL`
6195
6336
  # rows is a Task-5 chokepoint called from a step-4b pipeline hook —
@@ -6267,6 +6408,7 @@ def _run_stats_ingest_once(
6267
6408
  reconcile_config=None,
6268
6409
  codex_apply=None,
6269
6410
  post_commit=None,
6411
+ meter_rate_change=None,
6270
6412
  locks_held: bool = False,
6271
6413
  ) -> IngestResult:
6272
6414
  """Run one single-flight attempt, without correction-recovery orchestration.
@@ -6439,7 +6581,8 @@ def _run_stats_ingest_once(
6439
6581
  with _cctally_store.stats_write_scope("ingest", ingest_lock=True):
6440
6582
  return _run_cycle(conn, reconcile_config=reconcile_config,
6441
6583
  codex_apply=codex_apply,
6442
- post_commit=post_commit)
6584
+ post_commit=post_commit,
6585
+ meter_rate_change=meter_rate_change)
6443
6586
  except CorrectionRebuildRequired:
6444
6587
  # The public boundary must unwind its transaction, internally owned
6445
6588
  # connection, ingest lock, and maintenance-shared lock before it can
@@ -6705,6 +6848,7 @@ def run_stats_ingest(
6705
6848
  reconcile_config=None,
6706
6849
  codex_apply=None,
6707
6850
  post_commit=None,
6851
+ meter_rate_change=None,
6708
6852
  locks_held: bool = False,
6709
6853
  ) -> IngestResult:
6710
6854
  """Run one cycle, healing one completed-correction mismatch when safe.
@@ -6730,6 +6874,7 @@ def run_stats_ingest(
6730
6874
  "reconcile_config": reconcile_config,
6731
6875
  "codex_apply": codex_apply,
6732
6876
  "post_commit": post_commit,
6877
+ "meter_rate_change": meter_rate_change,
6733
6878
  "locks_held": locks_held,
6734
6879
  }
6735
6880
  try:
@@ -6841,6 +6986,7 @@ _REBUILD_COUNT_TABLES = (
6841
6986
  "five_hour_milestones", "budget_milestones", "projected_milestones",
6842
6987
  "project_budget_milestones", "quota_alert_arming", "quota_window_blocks",
6843
6988
  "quota_percent_milestones", "quota_threshold_events", "accounts",
6989
+ "meter_rate_change_events",
6844
6990
  )
6845
6991
 
6846
6992
 
@@ -7014,6 +7160,7 @@ _REBUILD_REQUIRED_TABLES = frozenset(
7014
7160
  "journal_selector_batch_records",
7015
7161
  "journal_selector_batches",
7016
7162
  "journal_selector_state",
7163
+ "meter_rate_change_events",
7017
7164
  "percent_milestones",
7018
7165
  "project_budget_milestones",
7019
7166
  "projected_milestones",
@@ -7053,6 +7200,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
7053
7200
  "idx_five_hour_reset_events_journal_id",
7054
7201
  "idx_five_hour_reset_events_journal_id_null",
7055
7202
  "idx_journal_protocol_violations_batch",
7203
+ "idx_meter_rate_change_events_key",
7056
7204
  "idx_percent_milestones_journal_id",
7057
7205
  "idx_percent_milestones_journal_id_null",
7058
7206
  "idx_project_budget_milestones_journal_id",
@@ -7077,7 +7225,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
7077
7225
  # omitted column, constraint, partial predicate, or index definition. An epoch
7078
7226
  # schema change must update this contract alongside STATS_INDEX_EPOCH.
7079
7227
  _REBUILD_SCHEMA_FINGERPRINT = (
7080
- "2b378fc3be1c7bb249bb0c3ddd2111a802f689cf30b3fd42806a116611c799e6"
7228
+ "472e77f23b289eb9141c2b318b94f24e98d509a73a9063568fbd66b04013ead3"
7081
7229
  )
7082
7230
 
7083
7231
 
@@ -3488,6 +3488,71 @@ def _build_pricing_check_parser(subparsers, name, *, help_text, xref=None):
3488
3488
  )
3489
3489
  pc_p.set_defaults(func=c.cmd_pricing_check)
3490
3490
 
3491
+ def _build_quota_parser(subparsers, name, *, help_text, xref=None):
3492
+ """Build the top-level `quota` parser (#661 S1, spec section 8).
3493
+
3494
+ Registered ONLY at top level. There is no `claude quota` twin and no Bash
3495
+ wrapper: `_build_claude_parser` enumerates its leaves by hand and
3496
+ `_REGISTRATION` enumerates the flat commands separately, so nothing
3497
+ mirrors automatically and there is no accidental duplication to prevent.
3498
+ """
3499
+ c = _cctally()
3500
+ q = subparsers.add_parser(
3501
+ name,
3502
+ help=help_text,
3503
+ formatter_class=CLIHelpFormatter,
3504
+ description=textwrap.dedent(
3505
+ """\
3506
+ Report how much of your weekly Claude quota your usage consumes,
3507
+ computed from tokens against a budget fitted from your own
3508
+ history, and whether the provider has changed the metering rate.
3509
+
3510
+ The fitted value is one effective blended rate in weighted units
3511
+ per weekly point. It is valid for the model and token blend your
3512
+ own history shows, it is NOT a provider budget, and it is the
3513
+ provider's budget minus an unmeasured, workload-dependent amount.
3514
+
3515
+ Exit codes:
3516
+ 0 — trustworthy result, no rate change detected.
3517
+ 1 — a rate change is confirmed. Detection has its own stricter
3518
+ gates than prediction, so this can be reported while the
3519
+ calibration itself is still too thin to predict from.
3520
+ 2 — argument or validation error.
3521
+ 3 — judgment withheld: the data is unhealthy or a store is
3522
+ unavailable.
3523
+ 4 — the evidence is healthy but too thin, fragmented or
3524
+ unstable to fit.
3525
+
3526
+ See docs/commands/quota.md for the JSON schema.
3527
+ """
3528
+ ),
3529
+ )
3530
+ q.add_argument(
3531
+ "--since", metavar="DATE",
3532
+ help="Ignore data before this ISO date or timestamp. It never widens "
3533
+ "the window past the supported-composition era.",
3534
+ )
3535
+ q.add_argument(
3536
+ "--watch-from", metavar="DATE",
3537
+ help="Evaluate this split date instead of scanning for one. Bypasses "
3538
+ "the scan and the multiplicity correction, never the health or "
3539
+ "fit gates. A run passing it persists nothing.",
3540
+ )
3541
+ q.add_argument(
3542
+ "--account", metavar="REF",
3543
+ help="Scope to one account (label, email or unique key prefix)",
3544
+ )
3545
+ q.add_argument(
3546
+ "--json", action="store_true",
3547
+ help="Emit machine-readable JSON to stdout (schemaVersion: 1)",
3548
+ )
3549
+ q.add_argument(
3550
+ "--reset-calibration", action="store_true",
3551
+ help="Discard the stored calibration for the selected account",
3552
+ )
3553
+ q.set_defaults(func=c.cmd_quota)
3554
+
3555
+
3491
3556
  def _build_hook_tick_parser(subparsers, name, *, help_text, xref=None):
3492
3557
  """Build the `hook-tick` parser (registered via _REGISTRATION; #279 S6 W3).
3493
3558
 
@@ -3830,6 +3895,7 @@ _REGISTRATION = (
3830
3895
  _Reg('doctor', _build_doctor_parser, "Diagnose data freshness and install state", None, None),
3831
3896
  _Reg('dashboard-perf', _build_dashboard_perf_parser, "Read a running dashboard's tick cost; arm its phase trace", None, None),
3832
3897
  _Reg('pricing-check', _build_pricing_check_parser, "Detect stale or missing embedded model pricing", None, None),
3898
+ _Reg('quota', _build_quota_parser, "Report weekly quota consumption and metering-rate changes", None, None),
3833
3899
  _Reg('hook-tick', _build_hook_tick_parser, argparse.SUPPRESS, None, None),
3834
3900
  _Reg('__preview', _build_preview_parser, argparse.SUPPRESS, None, lambda c: getattr(c, "cmd_preview", None) is not None),
3835
3901
  _Reg('update', _build_update_parser, "Update cctally to the latest version", None, None),