cctally 1.67.1 → 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 +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  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 +187 -15
  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 +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -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 +238 -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 +325 -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