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
@@ -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,9 +237,35 @@ 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
 
261
+ # stats.db WAL cap (#297). Bounds the persistent WAL file so a resetting
262
+ # checkpoint truncates it back down instead of leaving it at its high-water
263
+ # size. stats.db writes are small (its -wal was observed at 0 bytes even under
264
+ # the contention that bloated cache.db to multi-GB), so a tighter 16 MB cap is
265
+ # ample. See docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
266
+ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
267
+
268
+
216
269
  # === Telemetry constants (non-path; see spec 2026-07-07) =============
217
270
  #
218
271
  # These are static (not APP_DIR-derived) so they live outside
@@ -673,6 +726,10 @@ def _validate_positive_budget_amount(v: object, label: str) -> float:
673
726
  # are reused by the parser (`--period` choices) and the config layer.
674
727
  BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
675
728
  CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
729
+ CODEX_BUDGET_LEAVES = (
730
+ "amount_usd", "period", "alerts_enabled", "alert_thresholds",
731
+ "projected_enabled",
732
+ )
676
733
  _BUDGET_DEFAULTS = {
677
734
  "weekly_usd": None, # None = no budget (default)
678
735
  "alerts_enabled": True, # "on when set"
@@ -835,12 +892,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
835
892
  f"budget.codex must be an object or null, got {type(v).__name__}"
836
893
  )
837
894
  # warn-and-ignore unknown sub-keys (forward compat, like the parent block)
838
- _codex_valid = {
839
- "amount_usd", "period", "alerts_enabled", "alert_thresholds",
840
- "projected_enabled",
841
- }
842
895
  for k in v.keys():
843
- if k not in _codex_valid:
896
+ if k not in CODEX_BUDGET_LEAVES:
844
897
  print(
845
898
  f"warning: ignoring unknown budget.codex config key: {k}",
846
899
  file=sys.stderr,
@@ -906,6 +959,118 @@ def _budget_alerts_active(budget_cfg: dict) -> bool:
906
959
  # === DB primitive ===================================================
907
960
 
908
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
+
909
1074
  def open_db() -> sqlite3.Connection:
910
1075
  c = _cctally()
911
1076
  # Spec §2.6 carve-out: open_db reaches the migration framework
@@ -939,16 +1104,17 @@ def open_db() -> sqlite3.Connection:
939
1104
  conn.execute("PRAGMA journal_mode=WAL")
940
1105
  conn.execute("PRAGMA synchronous=NORMAL")
941
1106
  # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
942
- # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
943
- # so this is not a behavior change it makes the multi-writer retry window
944
- # an explicit contract beside the WAL pragmas instead of an inherited
945
- # default a reader has to know about. NOTE: busy_timeout does NOT absorb
946
- # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
947
- # invalidated by a competing commit raises "database is locked" instantly,
948
- # bypassing the busy handler). The write paths defend against that by taking
949
- # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
950
- # first DML. See cctally-dev#87.
951
- conn.execute("PRAGMA busy_timeout=5000")
1107
+ # #297: raised 5000 -> 15000 so a writer waits out a slow-but-normal
1108
+ # sync (>5 s) instead of instantly erroring with "database is locked".
1109
+ # NOTE: busy_timeout does NOT absorb SQLITE_BUSY_SNAPSHOT (a WAL
1110
+ # read-then-write transaction whose snapshot is invalidated by a
1111
+ # competing commit raises "database is locked" instantly, bypassing the
1112
+ # busy handler). The write paths defend against that by taking the write
1113
+ # lock up front BEGIN IMMEDIATE, or a write as the transaction's first
1114
+ # DML. See cctally-dev#87.
1115
+ conn.execute("PRAGMA busy_timeout=15000")
1116
+ # #297: bound the persistent WAL file (symmetry with open_cache_db).
1117
+ conn.execute(f"PRAGMA journal_size_limit={STATS_WAL_SIZE_LIMIT_BYTES}")
952
1118
  conn.execute("SELECT 1").fetchone()
953
1119
  except sqlite3.DatabaseError as exc:
954
1120
  try:
@@ -1465,6 +1631,12 @@ def open_db() -> sqlite3.Connection:
1465
1631
  """
1466
1632
  )
1467
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
+
1468
1640
  # Migration framework dispatcher. Replaces the prior inline gate stack
1469
1641
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1470
1642
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers