cctally 1.68.0 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -75,6 +75,8 @@ from _cctally_core import (
75
75
  _AlertsConfigError,
76
76
  _get_budget_config,
77
77
  _BudgetConfigError,
78
+ _validate_codex_budget_block,
79
+ CODEX_BUDGET_LEAVES,
78
80
  )
79
81
  from _lib_display_tz import normalize_display_tz_value
80
82
 
@@ -300,6 +302,7 @@ ALLOWED_CONFIG_KEYS = (
300
302
  "alerts.projected_enabled",
301
303
  "alerts.notifier",
302
304
  "alerts.command_template",
305
+ "alerts.quota",
303
306
  "dashboard.bind",
304
307
  "dashboard.expose_transcripts",
305
308
  "dashboard.cache_failure_markers",
@@ -318,10 +321,236 @@ ALLOWED_CONFIG_KEYS = (
318
321
  "budget.projects",
319
322
  "budget.project_alerts_enabled",
320
323
  "budget.codex",
324
+ "budget.codex.amount_usd",
325
+ "budget.codex.period",
326
+ "budget.codex.alerts_enabled",
327
+ "budget.codex.alert_thresholds",
328
+ "budget.codex.projected_enabled",
321
329
  "telemetry.enabled",
322
330
  )
323
331
 
324
332
 
333
+ _CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
334
+
335
+
336
+ def _config_codex_leaf_value(config: dict, key: str) -> object:
337
+ """Read one nested Codex budget leaf with its public default.
338
+
339
+ ``config get`` is deliberately forgiving of a hand-edited corrupt block,
340
+ just like the existing whole-object ``budget.codex`` read: it reports the
341
+ defaults without repairing the file. Mutations use the strict setter below.
342
+ """
343
+ if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
344
+ raise ValueError(f"not a Codex budget leaf: {key!r}")
345
+ leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
346
+ if leaf not in CODEX_BUDGET_LEAVES:
347
+ raise ValueError(f"unknown Codex budget leaf: {key!r}")
348
+ try:
349
+ block = _get_budget_config(config)["codex"]
350
+ except _BudgetConfigError:
351
+ block = None
352
+ if block is None:
353
+ defaults = {
354
+ "amount_usd": None,
355
+ "period": "calendar-month",
356
+ "alerts_enabled": False,
357
+ "alert_thresholds": [90, 100],
358
+ "projected_enabled": False,
359
+ }
360
+ value = defaults[leaf]
361
+ else:
362
+ value = block[leaf]
363
+ return list(value) if isinstance(value, list) else value
364
+
365
+
366
+ def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
367
+ """Parse exactly the command-line wire format for one Codex leaf."""
368
+ if leaf == "amount_usd":
369
+ try:
370
+ return float(raw_value)
371
+ except ValueError as exc:
372
+ raise _BudgetConfigError(
373
+ "budget.codex.amount_usd must be a finite number > 0"
374
+ ) from exc
375
+ if leaf == "period":
376
+ return raw_value.strip()
377
+ if leaf in {"alerts_enabled", "projected_enabled"}:
378
+ normalized = raw_value.strip().lower()
379
+ if normalized in {"true", "yes", "on", "1"}:
380
+ return True
381
+ if normalized in {"false", "no", "off", "0"}:
382
+ return False
383
+ raise _BudgetConfigError(f"budget.codex.{leaf} must be a boolean")
384
+ if leaf == "alert_thresholds":
385
+ if not raw_value.strip():
386
+ return []
387
+ parsed: list[int] = []
388
+ for part in raw_value.split(","):
389
+ try:
390
+ parsed.append(int(part.strip(), 10))
391
+ except ValueError as exc:
392
+ raise _BudgetConfigError(
393
+ "budget.codex.alert_thresholds must be a comma-separated "
394
+ "list of integers"
395
+ ) from exc
396
+ return parsed
397
+ raise _BudgetConfigError(f"unknown Codex budget leaf {leaf!r}")
398
+
399
+
400
+ def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
401
+ """Return the canonical prospective nested Codex budget block.
402
+
403
+ The caller owns the writer lock and persistence. This pure merge helper
404
+ validates existing state before mutating it, so malformed user state never
405
+ becomes a partially repaired write.
406
+ """
407
+ if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
408
+ raise _BudgetConfigError(f"unknown Codex budget leaf {key!r}")
409
+ leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
410
+ if leaf not in CODEX_BUDGET_LEAVES:
411
+ raise _BudgetConfigError(f"unknown Codex budget leaf {key!r}")
412
+ budget = config.get("budget")
413
+ if budget is not None and not isinstance(budget, dict):
414
+ raise _BudgetConfigError("budget must be an object")
415
+ existing = (budget or {}).get("codex")
416
+ if existing is None:
417
+ if leaf != "amount_usd":
418
+ raise _BudgetConfigError(
419
+ "budget.codex.amount_usd must be configured before setting "
420
+ f"budget.codex.{leaf}"
421
+ )
422
+ prospective: dict = {}
423
+ else:
424
+ if not isinstance(existing, dict):
425
+ raise _BudgetConfigError(
426
+ f"budget.codex must be an object or null, got {type(existing).__name__}"
427
+ )
428
+ # Strictly validate first: malformed existing data must abort without
429
+ # mutation, while valid unknown siblings retain the validator's
430
+ # existing warn-and-drop behavior when the prospective block is built.
431
+ validated_existing = _validate_codex_budget_block(existing)
432
+ assert validated_existing is not None
433
+ prospective = dict(validated_existing)
434
+ prospective[leaf] = _parse_codex_budget_leaf_value(leaf, raw_value)
435
+ validated = _validate_codex_budget_block(prospective)
436
+ assert validated is not None
437
+ return validated
438
+
439
+
440
+ # ``alerts.quota`` is an additive nested object owned by the provider-neutral
441
+ # quota projection. Keep the existing core alert reader byte-compatible for
442
+ # Claude axes while making this now-known child exempt from its unknown-key
443
+ # warning path.
444
+ _cctally_core._ALERTS_CONFIG_VALID_KEYS.add("quota")
445
+
446
+ _QUOTA_ALERT_KEYS = {
447
+ "enabled", "actual_thresholds", "projected_thresholds", "rules",
448
+ }
449
+ _QUOTA_RULE_KEYS = {
450
+ "source", "source_root_key", "logical_limit_key",
451
+ "actual_thresholds", "projected_thresholds",
452
+ }
453
+
454
+
455
+ def _quota_alert_error(message: str) -> None:
456
+ raise _cctally_core._AlertsConfigError(message)
457
+
458
+
459
+ def _validate_quota_thresholds(name: str, value: object) -> list[int]:
460
+ """Validate a quota list; unlike legacy axis lists, [] deliberately silences."""
461
+ if not isinstance(value, list):
462
+ _quota_alert_error(f"alerts.quota.{name} must be a list of integers")
463
+ result: list[int] = []
464
+ prior = 0
465
+ for item in value:
466
+ if not isinstance(item, int) or isinstance(item, bool):
467
+ _quota_alert_error(
468
+ f"alerts.quota.{name} items must be integers, got "
469
+ f"{type(item).__name__}: {item!r}"
470
+ )
471
+ if not 1 <= item <= 100:
472
+ _quota_alert_error(
473
+ f"alerts.quota.{name} items must be in [1, 100], got {item}"
474
+ )
475
+ if item <= prior:
476
+ _quota_alert_error(f"alerts.quota.{name} must be strictly increasing")
477
+ result.append(item)
478
+ prior = item
479
+ return result
480
+
481
+
482
+ def _get_quota_alerts_config(cfg: "dict | None") -> dict:
483
+ """Return the strict, defaults-filled ``alerts.quota`` configuration."""
484
+ alerts = (cfg or {}).get("alerts", {})
485
+ if alerts is None:
486
+ alerts = {}
487
+ if not isinstance(alerts, dict):
488
+ _quota_alert_error("alerts must be an object")
489
+ quota = alerts.get("quota", {})
490
+ if quota is None or not isinstance(quota, dict):
491
+ _quota_alert_error("alerts.quota must be an object")
492
+ for key in quota:
493
+ if key not in _QUOTA_ALERT_KEYS:
494
+ print(
495
+ f"warning: ignoring unknown alerts.quota config key: {key}",
496
+ file=sys.stderr,
497
+ )
498
+ enabled = quota.get("enabled", False)
499
+ if not isinstance(enabled, bool):
500
+ _quota_alert_error(
501
+ "alerts.quota.enabled must be a JSON boolean, got "
502
+ f"{type(enabled).__name__}: {enabled!r}"
503
+ )
504
+ actual = _validate_quota_thresholds(
505
+ "actual_thresholds", quota.get("actual_thresholds", [90, 95])
506
+ )
507
+ projected = _validate_quota_thresholds(
508
+ "projected_thresholds", quota.get("projected_thresholds", [])
509
+ )
510
+ raw_rules = quota.get("rules", [])
511
+ if not isinstance(raw_rules, list):
512
+ _quota_alert_error("alerts.quota.rules must be a list")
513
+ rules: list[dict] = []
514
+ seen: set[tuple[str, str, str]] = set()
515
+ for index, raw_rule in enumerate(raw_rules):
516
+ prefix = f"alerts.quota.rules[{index}]"
517
+ if not isinstance(raw_rule, dict):
518
+ _quota_alert_error(f"{prefix} must be an object")
519
+ if set(raw_rule) != _QUOTA_RULE_KEYS:
520
+ _quota_alert_error(
521
+ f"{prefix} must contain exactly {sorted(_QUOTA_RULE_KEYS)}"
522
+ )
523
+ normalized: dict[str, object] = {}
524
+ for key in ("source", "source_root_key", "logical_limit_key"):
525
+ value = raw_rule[key]
526
+ if not isinstance(value, str) or not value:
527
+ _quota_alert_error(f"{prefix}.{key} must be a non-empty string")
528
+ normalized[key] = value
529
+ normalized["actual_thresholds"] = _validate_quota_thresholds(
530
+ f"rules[{index}].actual_thresholds", raw_rule["actual_thresholds"]
531
+ )
532
+ normalized["projected_thresholds"] = _validate_quota_thresholds(
533
+ f"rules[{index}].projected_thresholds", raw_rule["projected_thresholds"]
534
+ )
535
+ identity = (
536
+ str(normalized["source"]), str(normalized["source_root_key"]),
537
+ str(normalized["logical_limit_key"]),
538
+ )
539
+ if identity in seen:
540
+ _quota_alert_error(
541
+ "alerts.quota.rules must be unique by source, source_root_key, "
542
+ "and logical_limit_key"
543
+ )
544
+ seen.add(identity)
545
+ rules.append(normalized)
546
+ return {
547
+ "enabled": enabled,
548
+ "actual_thresholds": actual,
549
+ "projected_thresholds": projected,
550
+ "rules": rules,
551
+ }
552
+
553
+
325
554
  # === statusline config validators (issue #86 Session G) ===================
326
555
 
327
556
  _STATUSLINE_VBR_VALUES = ("off", "emoji", "text", "emoji-text")
@@ -459,6 +688,11 @@ def _config_known_value(config: dict, key: str) -> "object":
459
688
  return _get_alerts_config(config)["command_template"]
460
689
  except c._AlertsConfigError:
461
690
  return None
691
+ if key == "alerts.quota":
692
+ try:
693
+ return _get_quota_alerts_config(config)
694
+ except c._AlertsConfigError:
695
+ return _get_quota_alerts_config({})
462
696
  if key == "dashboard.bind":
463
697
  # Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
464
698
  # bind time). LAN exposure is opt-in via `set dashboard.bind lan`
@@ -580,6 +814,8 @@ def _config_known_value(config: dict, key: str) -> "object":
580
814
  return c._validate_update_check_ttl_hours_value(stored)
581
815
  except ValueError:
582
816
  return c.UPDATE_DEFAULT_TTL_HOURS
817
+ if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
818
+ return _config_codex_leaf_value(config, key)
583
819
  if key in (
584
820
  "statusline.visual_burn_rate",
585
821
  "statusline.cost_source",
@@ -649,7 +885,10 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
649
885
  # (including None) must survive into the render layer — the generic
650
886
  # None->"" coercion below would break the JSON shape / round-trip.
651
887
  def _coerce(k: str, v: "object") -> "object":
652
- if k in ("alerts.command_template", "budget.projects", "budget.codex"):
888
+ if k in (
889
+ "alerts.command_template", "alerts.quota", "budget.projects",
890
+ "budget.codex",
891
+ ) or k.startswith(_CODEX_BUDGET_LEAF_PREFIX):
653
892
  return v
654
893
  return v if v is not None else ""
655
894
 
@@ -667,7 +906,19 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
667
906
  # a flat tail (`{"update": {"check.enabled": ...}}`) and
668
907
  # diverged from `config set --json` / on-disk shape.
669
908
  out: "dict[str, object]" = {}
909
+ nested_parents = {
910
+ candidate
911
+ for candidate, _value in pairs
912
+ if any(child.startswith(candidate + ".") for child, _value in pairs)
913
+ }
670
914
  for k, v in pairs:
915
+ # A bulk read includes both `budget.codex` (null when not
916
+ # configured) and its additive `budget.codex.*` leaves. A scalar
917
+ # parent cannot coexist with those children in JSON, so let the
918
+ # specific leaves build their object. Direct `config get
919
+ # budget.codex --json` has no descendant pair and remains null.
920
+ if k in nested_parents and not isinstance(v, dict):
921
+ continue
671
922
  segments = k.split(".")
672
923
  node: dict = out
673
924
  for seg in segments[:-1]:
@@ -679,7 +930,8 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
679
930
  # Preserve canonical bool stringification (true/false) so
680
931
  # round-trips via `config set alerts.enabled <plain-text>` work.
681
932
  if k in (
682
- "alerts.command_template", "budget.projects", "budget.codex"
933
+ "alerts.command_template", "alerts.quota", "budget.projects",
934
+ "budget.codex",
683
935
  ):
684
936
  # JSON-encoded so `config get` output round-trips through the
685
937
  # matching `config set` branch (both JSON-parse their value).
@@ -687,6 +939,8 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
687
939
  # `budget.projects` is an object {git-root: usd};
688
940
  # `budget.codex` is an object|null (the no-budget sentinel).
689
941
  rendered = json.dumps(v)
942
+ elif k.startswith(_CODEX_BUDGET_LEAF_PREFIX) and v is None:
943
+ rendered = "null"
690
944
  elif isinstance(v, bool):
691
945
  rendered = "true" if v else "false"
692
946
  elif isinstance(v, list):
@@ -705,6 +959,36 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
705
959
  if key not in ALLOWED_CONFIG_KEYS:
706
960
  eprint(f"cctally config: unknown config key {key!r}")
707
961
  return 2
962
+ if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
963
+ try:
964
+ with config_writer_lock():
965
+ config = _load_config_unlocked()
966
+ configured = _set_codex_budget_leaf(config, key, raw)
967
+ budget = config.get("budget")
968
+ if budget is not None and not isinstance(budget, dict):
969
+ raise _BudgetConfigError("budget must be an object")
970
+ block = dict(budget or {})
971
+ block["codex"] = configured
972
+ config["budget"] = block
973
+ save_config(config)
974
+ except _BudgetConfigError as exc:
975
+ eprint(f"cctally config: {exc}")
976
+ return 2
977
+ # Deliberately outside the config lock: this helper opens and locks DBs.
978
+ c._reconcile_codex_budget_on_config_write({"codex": configured})
979
+ leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
980
+ value = configured[leaf]
981
+ if getattr(args, "emit_json", False):
982
+ print(json.dumps({"budget": {"codex": {leaf: value}}}, indent=2))
983
+ else:
984
+ if isinstance(value, bool):
985
+ rendered = "true" if value else "false"
986
+ elif isinstance(value, list):
987
+ rendered = ",".join(str(item) for item in value)
988
+ else:
989
+ rendered = str(value)
990
+ print(f"{key}={rendered}")
991
+ return 0
708
992
  if key == "display.tz":
709
993
  try:
710
994
  canonical = normalize_display_tz_value(raw)
@@ -878,6 +1162,44 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
878
1162
  else:
879
1163
  print(f"alerts.command_template={json.dumps(parsed)}")
880
1164
  return 0
1165
+ if key == "alerts.quota":
1166
+ try:
1167
+ parsed = json.loads(raw)
1168
+ except (ValueError, TypeError) as exc:
1169
+ print(
1170
+ f"cctally: alerts.quota must be a JSON object: {exc}",
1171
+ file=sys.stderr,
1172
+ )
1173
+ return 2
1174
+ if not isinstance(parsed, dict):
1175
+ print("cctally: alerts.quota must be a JSON object", file=sys.stderr)
1176
+ return 2
1177
+ with config_writer_lock():
1178
+ config = _load_config_unlocked()
1179
+ existing_alerts = config.get("alerts")
1180
+ if existing_alerts is not None and not isinstance(existing_alerts, dict):
1181
+ print(
1182
+ "cctally: alerts config error: alerts must be an object",
1183
+ file=sys.stderr,
1184
+ )
1185
+ return 2
1186
+ alerts_block = dict(existing_alerts or {})
1187
+ alerts_block["quota"] = parsed
1188
+ candidate = {**config, "alerts": alerts_block}
1189
+ try:
1190
+ _get_alerts_config(candidate)
1191
+ normalized = _get_quota_alerts_config(candidate)
1192
+ except _AlertsConfigError as exc:
1193
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
1194
+ return 2
1195
+ alerts_block["quota"] = normalized
1196
+ config["alerts"] = alerts_block
1197
+ save_config(config)
1198
+ if getattr(args, "emit_json", False):
1199
+ print(json.dumps({"alerts": {"quota": normalized}}, indent=2))
1200
+ else:
1201
+ print(f"alerts.quota={json.dumps(normalized)}")
1202
+ return 0
881
1203
  if key == "dashboard.bind":
882
1204
  # Validation rejects whitespace / empty / non-string up front;
883
1205
  # write proceeds under config_writer_lock with _load_config_unlocked
@@ -1350,6 +1672,49 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1350
1672
  if key not in ALLOWED_CONFIG_KEYS:
1351
1673
  eprint(f"cctally config: unknown config key {key!r}")
1352
1674
  return 2
1675
+ if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
1676
+ c = _cctally()
1677
+ configured: dict | None = None
1678
+ try:
1679
+ with config_writer_lock():
1680
+ config = _load_config_unlocked()
1681
+ budget = config.get("budget")
1682
+ if budget is not None and not isinstance(budget, dict):
1683
+ raise _BudgetConfigError("budget must be an object")
1684
+ existing = (budget or {}).get("codex")
1685
+ if existing is None:
1686
+ return 0
1687
+ if not isinstance(existing, dict):
1688
+ raise _BudgetConfigError(
1689
+ f"budget.codex must be an object or null, got {type(existing).__name__}"
1690
+ )
1691
+ validated_existing = _validate_codex_budget_block(existing)
1692
+ assert validated_existing is not None
1693
+ leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
1694
+ block = dict(budget or {})
1695
+ if leaf == "amount_usd":
1696
+ block.pop("codex", None)
1697
+ if block:
1698
+ config["budget"] = block
1699
+ else:
1700
+ config.pop("budget", None)
1701
+ save_config(config)
1702
+ return 0
1703
+ prospective = dict(validated_existing)
1704
+ prospective.pop(leaf, None)
1705
+ configured = _validate_codex_budget_block(prospective)
1706
+ assert configured is not None
1707
+ block["codex"] = configured
1708
+ config["budget"] = block
1709
+ save_config(config)
1710
+ except _BudgetConfigError as exc:
1711
+ eprint(f"cctally config: {exc}")
1712
+ return 2
1713
+ # An optional leaf leaves a configured block, even when it already
1714
+ # held its default; the forward-only state must be reconciled once.
1715
+ assert configured is not None
1716
+ c._reconcile_codex_budget_on_config_write({"codex": configured})
1717
+ return 0
1353
1718
  if key == "display.tz":
1354
1719
  with config_writer_lock():
1355
1720
  config = _load_config_unlocked()
@@ -1366,6 +1731,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1366
1731
  "alerts.projected_enabled",
1367
1732
  "alerts.notifier",
1368
1733
  "alerts.command_template",
1734
+ "alerts.quota",
1369
1735
  ):
1370
1736
  # Mirror the display.tz branch: writer-lock + _load_config_unlocked
1371
1737
  # (NOT load_config — fcntl.flock is per-fd so re-entry would
@@ -65,6 +65,8 @@ def _init_paths_from_env() -> None:
65
65
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
66
66
  global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
67
67
  global HOOK_TICK_THROTTLE_PATH, HOOK_TICK_THROTTLE_LOCK_PATH
68
+ global STATUSLINE_OBSERVE_MARKER_PATH, STATUSLINE_PERSIST_LOCK_PATH
69
+ global OAUTH_BACKOFF_MARKER_PATH, OAUTH_BACKOFF_COUNT_PATH
68
70
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
69
71
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
70
72
  global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
@@ -119,6 +121,31 @@ def _init_paths_from_env() -> None:
119
121
  HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
120
122
  HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
121
123
 
124
+ # Statusline usage-persistence markers + lock (spec 2026-07-17
125
+ # usage-statusline-fallback). The statusline is the PRIMARY automatic
126
+ # writer of weekly/5h usage snapshots; these gate it.
127
+ # - STATUSLINE_OBSERVE_MARKER_PATH: liveness marker (mtime-based,
128
+ # like HOOK_TICK_THROTTLE_PATH) — touched after every statusline
129
+ # persist that fed valid 7d input through cmd_record_usage, INCLUDING
130
+ # a dedup no-op. Represents "the statusline is alive and feeding".
131
+ # The statusline persist throttle AND the OAuth backfill gate both
132
+ # key off this, NOT snapshot age (cmd_record_usage dedups unchanged
133
+ # percentages without refreshing captured_at, so snapshot age keeps
134
+ # growing while the statusline is actively feeding).
135
+ # - STATUSLINE_PERSIST_LOCK_PATH: cross-process flock serializing the
136
+ # detached persist feeder so a multi-session render herd yields at
137
+ # most one snapshot per throttle window.
138
+ # - OAUTH_BACKOFF_MARKER_PATH: shared 429 cooldown. Stores an ABSOLUTE
139
+ # epoch deadline in the file CONTENT (never the mtime — future-dating
140
+ # an mtime would corrupt HOOK_TICK_THROTTLE_PATH's age reading).
141
+ STATUSLINE_OBSERVE_MARKER_PATH = APP_DIR / "statusline-observe.last"
142
+ STATUSLINE_PERSIST_LOCK_PATH = APP_DIR / "statusline-persist.lock"
143
+ OAUTH_BACKOFF_MARKER_PATH = APP_DIR / "oauth-backoff.until"
144
+ # Consecutive-429 counter (text int) driving the headerless exponential
145
+ # backoff (base * 2**count). Separate from the deadline marker so the
146
+ # deadline file stays a single parseable float.
147
+ OAUTH_BACKOFF_COUNT_PATH = APP_DIR / "oauth-backoff.count"
148
+
122
149
  UPDATE_STATE_PATH = APP_DIR / "update-state.json"
123
150
  UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
124
151
  UPDATE_LOCK_PATH = APP_DIR / "update.lock"
@@ -210,6 +237,24 @@ def _real_prod_data_dir() -> pathlib.Path:
210
237
  return home / ".local" / "share" / "cctally"
211
238
 
212
239
 
240
+ # === Statusline-persist / OAuth-backfill tunables ==========================
241
+ # Internal (no config UI — YAGNI, spec §Out of scope); test injection only.
242
+ # Spec 2026-07-17-usage-statusline-fallback-design.
243
+ #
244
+ # STATUSLINE_PERSIST_THROTTLE_SECONDS: min seconds between statusline
245
+ # persist attempts (keyed off STATUSLINE_OBSERVE_MARKER_PATH liveness).
246
+ # OAUTH_BACKFILL_STALE_SECONDS: the OAuth poll only backfills once the
247
+ # observation marker is at least this stale (i.e. the statusline has
248
+ # NOT fed recently). Strictly greater than the persist throttle so the
249
+ # statusline is the primary writer and OAuth only covers its absence.
250
+ # OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
251
+ # exponential 429 backoff (base * 2**consecutive_429, capped).
252
+ STATUSLINE_PERSIST_THROTTLE_SECONDS = 60.0
253
+ OAUTH_BACKFILL_STALE_SECONDS = 300.0
254
+ OAUTH_BACKOFF_BASE_SECONDS = 60.0
255
+ OAUTH_BACKOFF_CAP_SECONDS = 3600.0
256
+
257
+
213
258
  _init_paths_from_env()
214
259
 
215
260
 
@@ -681,6 +726,10 @@ def _validate_positive_budget_amount(v: object, label: str) -> float:
681
726
  # are reused by the parser (`--period` choices) and the config layer.
682
727
  BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
683
728
  CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
729
+ CODEX_BUDGET_LEAVES = (
730
+ "amount_usd", "period", "alerts_enabled", "alert_thresholds",
731
+ "projected_enabled",
732
+ )
684
733
  _BUDGET_DEFAULTS = {
685
734
  "weekly_usd": None, # None = no budget (default)
686
735
  "alerts_enabled": True, # "on when set"
@@ -843,12 +892,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
843
892
  f"budget.codex must be an object or null, got {type(v).__name__}"
844
893
  )
845
894
  # warn-and-ignore unknown sub-keys (forward compat, like the parent block)
846
- _codex_valid = {
847
- "amount_usd", "period", "alerts_enabled", "alert_thresholds",
848
- "projected_enabled",
849
- }
850
895
  for k in v.keys():
851
- if k not in _codex_valid:
896
+ if k not in CODEX_BUDGET_LEAVES:
852
897
  print(
853
898
  f"warning: ignoring unknown budget.codex config key: {k}",
854
899
  file=sys.stderr,
@@ -914,6 +959,118 @@ def _budget_alerts_active(budget_cfg: dict) -> bool:
914
959
  # === DB primitive ===================================================
915
960
 
916
961
 
962
+ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
963
+ """Create the current durable provider-neutral quota projection schema.
964
+
965
+ The physical observations remain in cache.db. These tables are the
966
+ idempotent interpreted index consumed by the Codex quota adapter; migration
967
+ 013 calls this same helper for old databases while ``open_db`` calls it for
968
+ fresh installs before the migration dispatcher stamps the migration.
969
+ """
970
+ conn.executescript(
971
+ """
972
+ CREATE TABLE IF NOT EXISTS quota_window_blocks (
973
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
974
+ source TEXT NOT NULL,
975
+ source_root_key TEXT NOT NULL,
976
+ logical_limit_key TEXT NOT NULL,
977
+ observed_slot TEXT NOT NULL,
978
+ window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
979
+ limit_id TEXT,
980
+ limit_name TEXT,
981
+ resets_at_utc TEXT NOT NULL,
982
+ nominal_start_at_utc TEXT NOT NULL,
983
+ first_observed_at_utc TEXT NOT NULL,
984
+ last_observed_at_utc TEXT NOT NULL,
985
+ first_percent REAL NOT NULL,
986
+ current_percent REAL NOT NULL,
987
+ last_source_path TEXT NOT NULL,
988
+ last_line_offset INTEGER NOT NULL,
989
+ generation TEXT NOT NULL,
990
+ orphaned_at TEXT,
991
+ UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
992
+ window_minutes, resets_at_utc)
993
+ );
994
+ CREATE INDEX IF NOT EXISTS idx_quota_blocks_active
995
+ ON quota_window_blocks(source, source_root_key, orphaned_at,
996
+ logical_limit_key, observed_slot,
997
+ window_minutes, resets_at_utc);
998
+
999
+ CREATE TABLE IF NOT EXISTS quota_percent_milestones (
1000
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1001
+ source TEXT NOT NULL,
1002
+ source_root_key TEXT NOT NULL,
1003
+ logical_limit_key TEXT NOT NULL,
1004
+ observed_slot TEXT NOT NULL,
1005
+ window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
1006
+ resets_at_utc TEXT NOT NULL,
1007
+ percent_threshold INTEGER NOT NULL CHECK(percent_threshold BETWEEN 1 AND 100),
1008
+ captured_at_utc TEXT NOT NULL,
1009
+ source_path TEXT NOT NULL,
1010
+ line_offset INTEGER NOT NULL,
1011
+ high_water_percent INTEGER NOT NULL CHECK(high_water_percent BETWEEN 1 AND 100),
1012
+ generation TEXT NOT NULL,
1013
+ orphaned_at TEXT,
1014
+ UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1015
+ window_minutes, resets_at_utc, percent_threshold)
1016
+ );
1017
+ CREATE INDEX IF NOT EXISTS idx_quota_milestones_active
1018
+ ON quota_percent_milestones(source, source_root_key, orphaned_at,
1019
+ logical_limit_key, observed_slot,
1020
+ window_minutes, resets_at_utc,
1021
+ percent_threshold);
1022
+
1023
+ CREATE TABLE IF NOT EXISTS quota_threshold_events (
1024
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1025
+ source TEXT NOT NULL,
1026
+ source_root_key TEXT NOT NULL,
1027
+ logical_limit_key TEXT NOT NULL,
1028
+ observed_slot TEXT NOT NULL,
1029
+ window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
1030
+ resets_at_utc TEXT NOT NULL,
1031
+ threshold INTEGER NOT NULL CHECK(threshold BETWEEN 1 AND 100),
1032
+ qualifying_kind TEXT NOT NULL CHECK(qualifying_kind IN ('actual','projected')),
1033
+ qualifying_percent REAL,
1034
+ projected_percent REAL,
1035
+ severity TEXT NOT NULL,
1036
+ created_at_utc TEXT NOT NULL,
1037
+ disposition TEXT NOT NULL CHECK(disposition IN ('alerted','suppressed_backfill')),
1038
+ alerted_at TEXT,
1039
+ suppressed_at TEXT,
1040
+ orphaned_at TEXT,
1041
+ CHECK((disposition = 'alerted' AND alerted_at IS NOT NULL AND suppressed_at IS NULL)
1042
+ OR (disposition = 'suppressed_backfill' AND suppressed_at IS NOT NULL AND alerted_at IS NULL)),
1043
+ UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1044
+ window_minutes, resets_at_utc, threshold)
1045
+ );
1046
+ CREATE INDEX IF NOT EXISTS idx_quota_threshold_events_active
1047
+ ON quota_threshold_events(source, source_root_key, orphaned_at,
1048
+ logical_limit_key, observed_slot,
1049
+ window_minutes, resets_at_utc, threshold);
1050
+
1051
+ CREATE TABLE IF NOT EXISTS quota_projection_state (
1052
+ source_root_key TEXT PRIMARY KEY,
1053
+ generation TEXT NOT NULL,
1054
+ physical_signature TEXT NOT NULL,
1055
+ completed_at_utc TEXT NOT NULL
1056
+ );
1057
+
1058
+ CREATE TABLE IF NOT EXISTS quota_alert_arming (
1059
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1060
+ source TEXT NOT NULL,
1061
+ source_root_key TEXT NOT NULL,
1062
+ logical_limit_key TEXT NOT NULL,
1063
+ observed_slot TEXT NOT NULL,
1064
+ window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
1065
+ rule_fingerprint TEXT NOT NULL,
1066
+ activated_at_utc TEXT NOT NULL,
1067
+ UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1068
+ window_minutes)
1069
+ );
1070
+ """
1071
+ )
1072
+
1073
+
917
1074
  def open_db() -> sqlite3.Connection:
918
1075
  c = _cctally()
919
1076
  # Spec §2.6 carve-out: open_db reaches the migration framework
@@ -1474,6 +1631,12 @@ def open_db() -> sqlite3.Connection:
1474
1631
  """
1475
1632
  )
1476
1633
 
1634
+ # Stats migration 013 owns durable quota interpretation. Keep the current
1635
+ # schema in the fresh-install path before the dispatcher, exactly like the
1636
+ # existing live CREATE tables; its handler calls this same idempotent helper
1637
+ # for an older stats.db and the dispatcher central-stamps on clean return.
1638
+ _apply_quota_projection_schema(conn)
1639
+
1477
1640
  # Migration framework dispatcher. Replaces the prior inline gate stack
1478
1641
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1479
1642
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers