cctally 1.64.0 → 1.66.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 (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +262 -40
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +827 -4911
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +487 -254
  24. package/bin/_cctally_update.py +5 -0
  25. package/bin/_lib_alert_dispatch.py +1 -1
  26. package/bin/_lib_blocks.py +6 -1
  27. package/bin/_lib_conversation_query.py +349 -36
  28. package/bin/_lib_credit.py +133 -0
  29. package/bin/_lib_doctor.py +150 -1
  30. package/bin/_lib_five_hour.py +34 -0
  31. package/bin/_lib_forecast.py +144 -0
  32. package/bin/_lib_json_envelope.py +38 -0
  33. package/bin/_lib_jsonl.py +115 -73
  34. package/bin/_lib_log.py +96 -0
  35. package/bin/_lib_perf.py +180 -0
  36. package/bin/_lib_pricing.py +47 -1
  37. package/bin/_lib_pricing_check.py +25 -3
  38. package/bin/_lib_record.py +179 -0
  39. package/bin/_lib_render.py +18 -10
  40. package/bin/_lib_snapshot_cache.py +61 -0
  41. package/bin/_lib_transcript_access.py +23 -0
  42. package/bin/cctally +51 -7
  43. package/bin/cctally-dashboard +2 -2
  44. package/bin/cctally-statusline +1 -0
  45. package/bin/cctally-tui +2 -2
  46. package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
  47. package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
  48. package/dashboard/static/dashboard.html +2 -2
  49. package/package.json +11 -1
  50. package/dashboard/static/assets/index-0jzYm75p.css +0 -1
  51. package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
@@ -162,6 +162,23 @@ def stale_allowlist_entries(allowlist, claude_tbl, codex_tbl, litellm_scoped) ->
162
162
  return stale
163
163
 
164
164
 
165
+ def expired_allowlist_entries(allowlist, as_of_date) -> list:
166
+ """Return allowlist entries whose optional ``expires`` (``YYYY-MM-DD``) date
167
+ has PASSED as of ``as_of_date`` (a date/datetime or ISO string).
168
+
169
+ Date-derived, so it needs no network (runs offline too — #279 S7 W7): an
170
+ expired suppression is a signal to remove the entry / re-sync the embedded
171
+ snapshot even before LiteLLM has reverted, so the deliberate divergence
172
+ doesn't silently ossify past its stated cutover. An entry with no
173
+ ``expires`` field never expires. Comparison is strict — an entry expiring ON
174
+ a date is still valid THROUGH that date and only expired the day AFTER."""
175
+ as_of = str(as_of_date)[:10]
176
+ return [
177
+ e for e in (allowlist or [])
178
+ if e.get("expires") and str(e["expires"])[:10] < as_of
179
+ ]
180
+
181
+
165
182
  _CLAUDE_REQUIRED = ("input_cost_per_token", "output_cost_per_token",
166
183
  "cache_creation_input_token_cost", "cache_read_input_token_cost")
167
184
  _CODEX_REQUIRED = ("input_cost_per_token", "cache_read_input_token_cost",
@@ -194,8 +211,13 @@ def check_table_shapes(claude_tbl, codex_tbl, zero_sentinels) -> list:
194
211
  return problems
195
212
 
196
213
 
197
- def pricing_issue_action(drift_present: bool, existing_open: bool) -> str:
198
- """Decide the cron's GitHub-issue action. Pure; the YAML executes it."""
199
- if drift_present:
214
+ def pricing_issue_action(findings_present: bool, existing_open: bool) -> str:
215
+ """Decide the cron's GitHub-issue action. Pure; the YAML executes it.
216
+
217
+ ``findings_present`` is ANY actionable finding the cron tracks — value
218
+ drift, missing-from-us, OR (as of #279 S7 W7) a stale/expired allowlist
219
+ suppression. The name generalizes the former ``drift_present``; the
220
+ two-state machine is unchanged."""
221
+ if findings_present:
200
222
  return "update" if existing_open else "create"
201
223
  return "close" if existing_open else "noop"
@@ -0,0 +1,179 @@
1
+ """Record write-path decision kernels for cctally.
2
+
3
+ Pure-fn leaf (stdlib only, no I/O at import time): values in, decisions
4
+ out. Every DB read, config load, accessor reach (``c.<constant>``,
5
+ ``c._is_reset_drop``, ``c._floor_to_ten_minutes``), SQL statement, file
6
+ write, and ``eprint`` stays in the I/O glue in ``bin/_cctally_record.py``
7
+ — this module never touches the ``cctally`` namespace. That single rule
8
+ is what preserves the entire ns-patch surface of ``cmd_record_usage`` /
9
+ ``maybe_record_projected_alert`` while their decision cores move here.
10
+
11
+ Each kernel mirrors the exact comparison operators of the fragment it was
12
+ lifted from (``round(x, 1)`` on the HWM clamp, ``+ 1e-9`` snap on every
13
+ percent/threshold crossing, inclusive band bounds) so behavior is
14
+ byte-identical. Glue call site is named in each kernel's docstring.
15
+
16
+ Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+
23
+ # ── Fragment 1: --resets-at / --five-hour-resets-at plausibility band ──────
24
+ def check_resets_at_plausibility(
25
+ epoch: int, now_epoch: int, *, past_slack_s: int, future_band_s: int
26
+ ) -> bool:
27
+ """Return True when ``epoch`` sits inside the inclusive plausibility band
28
+ ``[now_epoch - past_slack_s, now_epoch + future_band_s]``.
29
+
30
+ Glue call sites (``cmd_record_usage``): the 7d leg (day-scale slack;
31
+ out-of-band → eprint + exit 2) and the 5h leg (10-min-past / 6h-future;
32
+ out-of-band → drop the 5h fields and continue). The two leg-specific
33
+ eprint literals and the differing consequences stay in glue; only the
34
+ raw second-granularity band check moves here.
35
+ """
36
+ return now_epoch - past_slack_s <= epoch <= now_epoch + future_band_s
37
+
38
+
39
+ # ── Fragment 2: weekly in-place-credit / reset-to-zero debounce ────────────
40
+ FIRE_IMMEDIATE = "fire_immediate"
41
+ CONFIRM_RESET = "confirm_reset"
42
+ CLEAR_MARKER = "clear_marker"
43
+ ARM_MARKER = "arm_marker"
44
+ NO_ACTION = "none"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class WeeklyDebounceDecision:
49
+ """Which weekly credit/reset-to-zero action ``cmd_record_usage``'s
50
+ same-week (``prior_end == cur_end``) branch should take. ``action`` is one
51
+ of the module constants FIRE_IMMEDIATE / CONFIRM_RESET / CLEAR_MARKER /
52
+ ARM_MARKER / NO_ACTION — mirroring the branch outcomes at the glue site."""
53
+ action: str
54
+
55
+
56
+ def plan_weekly_credit_debounce(
57
+ prev_pct, new_pct, *, drop_threshold, zero_floor_pct, zero_min_drop_pct,
58
+ marker_armed, marker_baseline,
59
+ ):
60
+ """Classify the same-window weekly-credit debounce decision (glue call
61
+ site: ``cmd_record_usage`` under ``prior_end_canon == cur_end_canon`` and
62
+ ``prior_end_dt > now_utc and prior_pct is not None``).
63
+
64
+ Mirrors the branch structure exactly:
65
+ - ``big_drop`` (drop >= drop_threshold) → FIRE_IMMEDIATE (>=25pp goodwill
66
+ credit; fires now, never debounced; glue also clears any pending arm).
67
+ - else, marker armed for this window:
68
+ - ``new_pct <= marker_baseline / 2.0`` → CONFIRM_RESET (stayed low).
69
+ - else → CLEAR_MARKER (recovered toward baseline → transient zero).
70
+ - else, ``zero_only`` (not big_drop AND new_pct <= zero_floor_pct AND
71
+ drop >= zero_min_drop_pct) → ARM_MARKER (first ~0).
72
+ - else → NO_ACTION.
73
+
74
+ Glue reads the ``c._RESET_*`` constants + the marker file, computes
75
+ ``marker_armed`` (window-key match) and passes ``marker_baseline``
76
+ (marker[2] when armed), then executes the decided I/O.
77
+ """
78
+ drop = float(prev_pct) - float(new_pct)
79
+ big_drop = drop >= drop_threshold
80
+ zero_only = (
81
+ (not big_drop)
82
+ and float(new_pct) <= zero_floor_pct
83
+ and drop >= zero_min_drop_pct
84
+ )
85
+ if big_drop:
86
+ return WeeklyDebounceDecision(FIRE_IMMEDIATE)
87
+ if marker_armed:
88
+ if float(new_pct) <= marker_baseline / 2.0:
89
+ return WeeklyDebounceDecision(CONFIRM_RESET)
90
+ return WeeklyDebounceDecision(CLEAR_MARKER)
91
+ if zero_only:
92
+ return WeeklyDebounceDecision(ARM_MARKER)
93
+ return WeeklyDebounceDecision(NO_ACTION)
94
+
95
+
96
+ # ── Fragment 3: 5h in-place-credit detection guard ─────────────────────────
97
+ def plan_five_hour_credit(
98
+ prior_pct: float, new_pct: float, *, drop_threshold: float,
99
+ prior_resets_in_future: bool,
100
+ ) -> bool:
101
+ """Return True when a 5h in-place credit is detected (glue call site:
102
+ ``cmd_record_usage``'s 5h-detection block).
103
+
104
+ Mirrors the one-line guard ``prior_5h_resets_dt > now_utc and
105
+ (prior_5h_pct - five_hour_percent) >= threshold``. ``is_dup`` is NOT an
106
+ input (gate P3-7): it gates only the glue INSERT, while the pivots
107
+ (hwm-5h force-write, stale-replica DELETE) fire unconditionally once a
108
+ credit is detected — all of that stays in glue.
109
+ """
110
+ return prior_resets_in_future and (prior_pct - new_pct) >= drop_threshold
111
+
112
+
113
+ # ── Fragment 4: reset-aware HWM clamp comparison ───────────────────────────
114
+ def hwm_clamp_applies(incoming_pct: float, recorded_max_pct) -> bool:
115
+ """Return True when ``incoming_pct`` is below the reset-aware recorded MAX
116
+ at tenths granularity (``round(x, 1)`` on both sides), i.e. the clamp fires.
117
+
118
+ Glue call sites (``cmd_record_usage``), each with a DISTINCT consequence
119
+ the glue keeps: the 7d leg sets ``should_insert = False`` (suppresses the
120
+ row); the 5h leg — NESTED inside the 7d ``else:`` — clamps the value up
121
+ (``five_hour_percent = float(max_5h_row["v"])``) and never touches
122
+ ``should_insert``. ``recorded_max_pct`` is the MAX cell (may be ``None``
123
+ when there is no in-window row); the SELECTs (including
124
+ ``_reset_aware_floor``) stay in glue verbatim.
125
+ """
126
+ if recorded_max_pct is None:
127
+ return False
128
+ return round(incoming_pct, 1) < round(float(recorded_max_pct), 1)
129
+
130
+
131
+ # ── Fragment 5 (residue): self-heal milestone-coverage predicate ───────────
132
+ def milestone_coverage_owes(existing_max_threshold, floor: int) -> bool:
133
+ """Return True when the milestone ledger for the ACTIVE segment owes a
134
+ heal — no rows yet (``existing_max_threshold is None``) or the highest
135
+ recorded threshold sits below the latest floor.
136
+
137
+ The load-bearing decision repeated at BOTH self-heal milestone-coverage
138
+ probes in ``cmd_record_usage``'s dedup self-heal block (weekly Probe 1 and
139
+ the 5h probe's milestone-coverage else-leg). Glue runs the DB probes,
140
+ reduces each to ``existing_max_threshold`` (int or None), and OR-s the
141
+ result into ``need_milestone_heal`` / ``need_5h_heal``. The broader
142
+ ``assess_self_heal`` aggregate stays glue-only (gate P3-5): its need-flags
143
+ are thin residues interleaved with four nesting levels of DB probes and
144
+ the staleness checks (``block_row is None`` / ``last_observed <
145
+ captured``), which are not cleanly separable without moving I/O.
146
+ """
147
+ return existing_max_threshold is None or existing_max_threshold < floor
148
+
149
+
150
+ # ── Fragment 6: hwm-7d / hwm-5h monotonic file step ────────────────────────
151
+ def hwm_file_next(existing, incoming: float):
152
+ """Return the value to write to the HWM file, or ``None`` when the write
153
+ should be skipped (glue call sites: ``cmd_record_usage``'s hwm-7d and
154
+ hwm-5h writers).
155
+
156
+ Mirrors the real ``>=`` operator: write when ``incoming >= existing``
157
+ (equality rewrites the same bytes — the code writes, so this returns the
158
+ value, not None). ``existing is None`` (no prior value) always writes. The
159
+ file read/parse and the actual ``write_text`` stay in glue.
160
+ """
161
+ if existing is None or incoming >= existing:
162
+ return incoming
163
+ return None
164
+
165
+
166
+ # ── Fragment 7: projected-pace alert threshold crossings ───────────────────
167
+ def projected_crossings(value: float, levels) -> list:
168
+ """Return the threshold labels crossed by ``value`` at the ``+ 1e-9`` snap.
169
+
170
+ ``levels`` is a list of ``(threshold_label, comparand)`` pairs — glue
171
+ pre-scales each comparand per leg (weekly_pct: ``(t, float(t))``; the two
172
+ budget legs: ``(t, (t / 100.0) * float(target))``), so this kernel never
173
+ rescales. A label crosses when ``value + 1e-9 >= comparand``. Glue maps
174
+ the returned labels back into the per-leg ``pending.append(dict(...))``
175
+ bodies; the leg-level ``_projected_levels_already_latched`` pre-gate stays
176
+ in glue (gate P2-2 — it is a per-leg gate BEFORE the loop, not a
177
+ per-threshold filter).
178
+ """
179
+ return [t for (t, comparand) in levels if value + 1e-9 >= comparand]
@@ -100,6 +100,11 @@ _short_model_name = _lib_pricing._short_model_name
100
100
  _lib_display_tz = _load_lib("_lib_display_tz")
101
101
  _resolve_tz = _lib_display_tz._resolve_tz
102
102
 
103
+ # JSON wire-format kernel — the additive camelCase schemaVersion stamp
104
+ # (#279 S6 W1; convention docs/cli-contract.md).
105
+ _lib_json_envelope = _load_lib("_lib_json_envelope")
106
+ stamp_schema_version = _lib_json_envelope.stamp_schema_version
107
+
103
108
  # fmt/color/table primitives honest-imported from _lib_fmt (#126 C11)
104
109
  _lib_fmt = _load_lib("_lib_fmt")
105
110
  _supports_color_stdout = _lib_fmt._supports_color_stdout
@@ -812,7 +817,8 @@ def _bucket_to_json(
812
817
  """
813
818
  bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
814
819
  totals = _bucket_totals_dict(buckets)
815
- return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
820
+ payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
821
+ return json.dumps(payload, indent=2)
816
822
 
817
823
 
818
824
  def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
@@ -825,10 +831,9 @@ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str
825
831
  for label, buckets in project_groups:
826
832
  projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
827
833
  all_buckets.extend(buckets)
828
- return json.dumps(
829
- {"projects": projects, "totals": _bucket_totals_dict(all_buckets)},
830
- indent=2,
831
- )
834
+ payload = stamp_schema_version(
835
+ {"projects": projects, "totals": _bucket_totals_dict(all_buckets)})
836
+ return json.dumps(payload, indent=2)
832
837
 
833
838
 
834
839
  def _weekly_to_json(
@@ -912,7 +917,7 @@ def _weekly_to_json(
912
917
  "totalCost": tot_cost,
913
918
  },
914
919
  }
915
- return json.dumps(payload, indent=2)
920
+ return json.dumps(stamp_schema_version(payload), indent=2)
916
921
 
917
922
 
918
923
  def _daily_compact_split(bucket: str) -> str:
@@ -976,7 +981,8 @@ def _emit_codex_no_data(args: argparse.Namespace, list_key: str) -> None:
976
981
  filter_applied = bool(getattr(args, "since", None) or getattr(args, "until", None))
977
982
  if getattr(args, "json", False):
978
983
  # Compact separators to match Node's `JSON.stringify(obj)` output exactly.
979
- print(json.dumps({list_key: [], "totals": None}, separators=(",", ":")))
984
+ print(json.dumps(stamp_schema_version({list_key: [], "totals": None}),
985
+ separators=(",", ":")))
980
986
  else:
981
987
  if filter_applied:
982
988
  print("No Codex usage data found for provided filters.")
@@ -1056,7 +1062,8 @@ def _codex_bucket_to_json(
1056
1062
  "totalTokens": tot_tokens,
1057
1063
  "costUSD": tot_cost,
1058
1064
  }
1059
- return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
1065
+ payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
1066
+ return json.dumps(payload, indent=2)
1060
1067
 
1061
1068
 
1062
1069
  def _codex_root_short_labels(roots: list[str]) -> list[str]:
@@ -1161,7 +1168,8 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
1161
1168
  "totalTokens": tot_tokens,
1162
1169
  "costUSD": tot_cost,
1163
1170
  }
1164
- return json.dumps({"sessions": session_list, "totals": totals}, indent=2)
1171
+ payload = stamp_schema_version({"sessions": session_list, "totals": totals})
1172
+ return json.dumps(payload, indent=2)
1165
1173
 
1166
1174
 
1167
1175
  def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
@@ -1227,7 +1235,7 @@ def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
1227
1235
  "totalCost": tot_cost,
1228
1236
  },
1229
1237
  }
1230
- return json.dumps(payload, indent=2)
1238
+ return json.dumps(stamp_schema_version(payload), indent=2)
1231
1239
 
1232
1240
 
1233
1241
  def _render_bucket_table(
@@ -290,6 +290,47 @@ def current_generation() -> int:
290
290
  return _GENERATION
291
291
 
292
292
 
293
+ # === Owner-thread tripwire (#279 S5 F6.3, spec §8) =========================
294
+ # The snapshot caches are single-writer-AT-A-TIME, under ``sync_lock`` — NOT
295
+ # "the periodic sync thread only": /api/sync and /api/settings legitimately
296
+ # rebuild on REQUEST threads while holding the lock (gate P1-1). So ownership
297
+ # means "the thread currently holding sync_lock for this rebuild", marked
298
+ # overwrite-on-call at the entry of the locked rebuild body
299
+ # (bin/_cctally_tui.py::_make_run_sync_now_locked._locked). Unarmed (never
300
+ # marked) => _assert_owner is a NO-OP, so every existing test and the TUI
301
+ # (which never arm) are untouched; a raise (not a log) on an armed foreign-
302
+ # thread mutation is deliberate — silent cache corruption is the failure mode
303
+ # being priced out.
304
+ _OWNER_THREAD_IDENT: int | None = None
305
+
306
+
307
+ def mark_owner_thread() -> None:
308
+ """Record the calling thread as the cache owner (overwrite-on-call).
309
+
310
+ Called at the entry of the locked rebuild body
311
+ (``_make_run_sync_now_locked._locked``) — ownership means "the thread
312
+ currently holding ``sync_lock``", NOT "the periodic sync thread":
313
+ /api/sync and /api/settings legitimately rebuild on request threads
314
+ (#279 S5 gate P1-1). Unarmed (never marked) => _assert_owner is a
315
+ no-op, so tests and the TUI need no changes.
316
+ """
317
+ global _OWNER_THREAD_IDENT
318
+ _OWNER_THREAD_IDENT = threading.get_ident()
319
+
320
+
321
+ def reset_owner_thread() -> None:
322
+ global _OWNER_THREAD_IDENT
323
+ _OWNER_THREAD_IDENT = None
324
+
325
+
326
+ def _assert_owner() -> None:
327
+ if _OWNER_THREAD_IDENT is not None and threading.get_ident() != _OWNER_THREAD_IDENT:
328
+ raise RuntimeError(
329
+ "snapshot-cache mutation from non-owner thread; rebuilds must "
330
+ "run under sync_lock (see mark_owner_thread)"
331
+ )
332
+
333
+
293
334
  # === Task 0.4 — Group A bucket cache holder ================================
294
335
 
295
336
 
@@ -431,6 +472,7 @@ def reset_group_a_state() -> None:
431
472
  cached bucket and reset the watermarks so the next rebuild recomputes
432
473
  from scratch (spec §7 / Codex F4).
433
474
  """
475
+ _assert_owner()
434
476
  _GROUP_A_CACHE.clear()
435
477
  _GROUP_A_LAST_SEEN.clear()
436
478
  reset_group_a_current_state()
@@ -470,6 +512,7 @@ _GROUP_A_CURRENT: "dict[str, CurrentBucketAccumulator]" = {}
470
512
 
471
513
  def reset_group_a_current_state() -> None:
472
514
  """Drop every builder's current-bucket accumulator (prune-site + full-invalidate)."""
515
+ _assert_owner()
473
516
  _GROUP_A_CURRENT.clear()
474
517
 
475
518
 
@@ -671,6 +714,7 @@ def build_cached_group_a(
671
714
  labels, fresh recompute for current + dirty), in ``all_bucket_labels``
672
715
  order, and updates this builder's last-seen state.
673
716
  """
717
+ _assert_owner()
674
718
  cur_max_id = _max_id(cache_conn, "session_entries")
675
719
  # #270 §7c: split max_id's two duties — the incremental watermark + "any
676
720
  # new work?" gate move to the O(1) `cache_meta` mutation counter (an
@@ -829,6 +873,7 @@ def reset_session_cache_state() -> None:
829
873
  cached session and reset the watermark so the next rebuild re-aggregates
830
874
  the whole window from scratch (spec §7 / Codex F4).
831
875
  """
876
+ _assert_owner()
832
877
  _SESSION_CACHE.clear()
833
878
  _SESSION_LAST_SEEN.clear()
834
879
 
@@ -863,6 +908,7 @@ def build_cached_sessions(
863
908
  its resolved ``session_id`` (which the aggregator sets to the stem for
864
909
  fallback sessions), so the cache keys match ``affected_session_keys``.
865
910
  """
911
+ _assert_owner()
866
912
  cur_max_id = _max_id(cache_conn, "session_entries")
867
913
  # #270 §7c/§7d: the warm "any new work?" gate + the affected-set query key
868
914
  # on the `mutation_seq` counter (an id-stable in-place finalization of an
@@ -997,6 +1043,7 @@ def store_dispatch_state(dispatch_key: object, snapshot: object) -> None:
997
1043
  Called once per rebuild (idle or full) so the next tick compares against the
998
1044
  key the just-published snapshot was built from.
999
1045
  """
1046
+ _assert_owner()
1000
1047
  global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
1001
1048
  _LAST_DISPATCH_KEY = dispatch_key
1002
1049
  _LAST_PUBLISHED_SNAPSHOT = snapshot
@@ -1010,6 +1057,7 @@ def reset_dispatch_state() -> None:
1010
1057
  part of the M5.2 prune invalidation — the generation bump (a signature leg)
1011
1058
  already forces the next rebuild off the idle path.
1012
1059
  """
1060
+ _assert_owner()
1013
1061
  global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
1014
1062
  _LAST_DISPATCH_KEY = None
1015
1063
  _LAST_PUBLISHED_SNAPSHOT = None
@@ -1056,6 +1104,7 @@ def reset_weekref_cost_state():
1056
1104
  possibly WITHOUT lowering ``MAX(id)``, so the reconcile's max-id-regression
1057
1105
  check cannot catch it — the explicit clear must) and as a test hook.
1058
1106
  """
1107
+ _assert_owner()
1059
1108
  _WEEKREF_COST_CACHE.clear()
1060
1109
  _WEEKREF_COST_LAST_SEEN.clear()
1061
1110
 
@@ -1116,6 +1165,7 @@ def reconcile_weekref_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset
1116
1165
  ``max_mutation_seq > last_seen_seq`` branch; the caller passes a short-lived
1117
1166
  cache connection, opened for that query.
1118
1167
  """
1168
+ _assert_owner()
1119
1169
  ls = _WEEKREF_COST_LAST_SEEN
1120
1170
  if not ls: # cold
1121
1171
  ls["max_id"] = max_entry_id
@@ -1207,6 +1257,7 @@ def reset_projects_env_state():
1207
1257
  current-week slot rides this same clear (spec §20): a prune can re-key a
1208
1258
  current-week bucket_path, so it must cold-refold next tick.
1209
1259
  """
1260
+ _assert_owner()
1210
1261
  _PROJECTS_ENV_WEEK_CACHE.clear()
1211
1262
  _PROJECTS_ENV_WEEK_TOTALS.clear()
1212
1263
  _PROJECTS_ENV_LAST_SEEN.clear()
@@ -1222,6 +1273,7 @@ def reset_projects_env_current_state():
1222
1273
  / prune / cache.db rebuild cold-refolds the current week the same tick the
1223
1274
  closed-week cache clears.
1224
1275
  """
1276
+ _assert_owner()
1225
1277
  _PROJECTS_ENV_CURRENT["state"] = None
1226
1278
 
1227
1279
 
@@ -1283,6 +1335,7 @@ def projects_env_week_put(week_iso, buckets_by_bp, total) -> None:
1283
1335
  computed-empty week is a HIT (not a perpetual miss). Values are stored
1284
1336
  as-is (immutable); this never mutates a stored aggregate in place.
1285
1337
  """
1338
+ _assert_owner()
1286
1339
  _PROJECTS_ENV_WEEK_TOTALS[week_iso] = total
1287
1340
  for bp, agg in buckets_by_bp.items():
1288
1341
  _PROJECTS_ENV_WEEK_CACHE[(bp, week_iso)] = agg
@@ -1354,6 +1407,7 @@ def accumulate_projects_current_week(*, week_key, cur_max_id, cur_max_seq,
1354
1407
  snapshot — ``finalize`` builds a FRESH bucket map each tick, so mutating
1355
1408
  ``mut`` next tick cannot tear a prior snapshot.
1356
1409
  """
1410
+ _assert_owner()
1357
1411
  prior = _PROJECTS_ENV_CURRENT["state"]
1358
1412
  cold = (
1359
1413
  prior is None
@@ -1439,6 +1493,7 @@ def reconcile_projects_env_cache(cache_conn, *, max_entry_id, max_mutation_seq,
1439
1493
  watermark query). The short-lived ``cache_conn`` is used only for the
1440
1494
  watermark query on the ``max_mutation_seq > last_seen_seq`` branch (Codex-4).
1441
1495
  """
1496
+ _assert_owner()
1442
1497
  ls = _PROJECTS_ENV_LAST_SEEN
1443
1498
  if not ls: # cold
1444
1499
  ls["max_id"] = max_entry_id
@@ -1574,6 +1629,7 @@ def reset_bugk_segment_state():
1574
1629
  possibly WITHOUT lowering ``MAX(id)``, so the reconcile's max-id-regression
1575
1630
  check cannot catch it — the explicit clear must) and as a test hook.
1576
1631
  """
1632
+ _assert_owner()
1577
1633
  _BUGK_SEGMENT_CACHE.clear()
1578
1634
  _BUGK_SEGMENT_LAST_SEEN.clear()
1579
1635
 
@@ -1630,6 +1686,7 @@ def reconcile_bugk_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset_si
1630
1686
  ``changed_min_timestamp`` query on the ``max_mutation_seq > last_seen_seq``
1631
1687
  branch (Codex-4 lifecycle from §4).
1632
1688
  """
1689
+ _assert_owner()
1633
1690
  ls = _BUGK_SEGMENT_LAST_SEEN
1634
1691
  if not ls: # cold
1635
1692
  ls["max_id"] = max_entry_id
@@ -1682,6 +1739,7 @@ def reset_cache_report_state():
1682
1739
  as a test hook. Mirrors ``reset_bugk_segment_state`` /
1683
1740
  ``reset_projects_env_state``.
1684
1741
  """
1742
+ _assert_owner()
1685
1743
  _CACHE_REPORT_DAY_CACHE.clear()
1686
1744
  _CACHE_REPORT_LAST_SEEN.clear()
1687
1745
 
@@ -1693,6 +1751,7 @@ def cache_report_day_get(date_key):
1693
1751
 
1694
1752
  def cache_report_day_store(date_key, value) -> None:
1695
1753
  """Store one CLOSED day's immutable ``CachedCacheReportDay`` (never mutated)."""
1754
+ _assert_owner()
1696
1755
  _CACHE_REPORT_DAY_CACHE[date_key] = value
1697
1756
 
1698
1757
 
@@ -1711,6 +1770,7 @@ def cache_report_day_evict_before(oldest_key) -> None:
1711
1770
  byte-safe (a re-needed day just re-populates on the next cold tick), so the
1712
1771
  predicate is a plain lexical ``<`` on the ``YYYY-MM-DD`` keys.
1713
1772
  """
1773
+ _assert_owner()
1714
1774
  for date_key in [d for d in _CACHE_REPORT_DAY_CACHE if d < oldest_key]:
1715
1775
  del _CACHE_REPORT_DAY_CACHE[date_key]
1716
1776
 
@@ -1762,6 +1822,7 @@ def reconcile_cache_report_cache(
1762
1822
  same-signature second call sees ``max_mutation_seq == last_seen_seq`` and
1763
1823
  no sig delta → no watermark re-query, no eviction.
1764
1824
  """
1825
+ _assert_owner()
1765
1826
  ls = _CACHE_REPORT_LAST_SEEN
1766
1827
  if not ls: # cold
1767
1828
  ls.update(
@@ -55,6 +55,29 @@ def is_loopback(host: str) -> bool:
55
55
  return False
56
56
 
57
57
 
58
+ def debug_backend_allowed(peer_ip, host) -> bool:
59
+ """Gate for the loopback-only ``/api/debug/backend`` diagnostic endpoint
60
+ (issue #276, Session A).
61
+
62
+ STRICTER than the transcript gate — it never opens to the LAN. Two checks:
63
+
64
+ * PRIMARY: the TCP peer (``client_address[0]``) must be a loopback
65
+ literal. This is the unspoofable signal — the dashboard can bind
66
+ ``0.0.0.0`` (``dashboard.bind = lan``), and a ``Host``-header-only
67
+ check would let a LAN client connect to the LAN socket while sending
68
+ ``Host: 127.0.0.1``. The peer address cannot be spoofed that way.
69
+ * DEFENSE-IN-DEPTH: the ``Host`` authority must ALSO be an IP-literal
70
+ loopback (anti-DNS-rebinding) — a hostname ``Host`` (a rebinding
71
+ vector) is rejected even from a loopback peer.
72
+
73
+ ``dashboard.expose_transcripts`` is NEVER consulted — this surface is
74
+ loopback-only, ALWAYS. Fail closed on a missing/empty peer or Host.
75
+ """
76
+ if not is_loopback(peer_ip):
77
+ return False
78
+ return is_loopback(authority_host(host))
79
+
80
+
58
81
  def transcripts_allowed(bind_host, expose: bool) -> bool:
59
82
  """Are transcripts served AT ALL on this bind? Loopback bind always; a
60
83
  non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
package/bin/cctally CHANGED
@@ -141,6 +141,7 @@ HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0
141
141
  SETUP_SYMLINK_NAMES = (
142
142
  "cctally",
143
143
  "cctally-alerts",
144
+ "cctally-budget",
144
145
  "cctally-dashboard",
145
146
  "cctally-dollar-per-percent",
146
147
  "cctally-five-hour-blocks",
@@ -292,6 +293,7 @@ classify_coverage = _lib_pricing_check.classify_coverage
292
293
  scope_litellm = _lib_pricing_check.scope_litellm
293
294
  diff_pricing = _lib_pricing_check.diff_pricing
294
295
  stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
296
+ expired_allowlist_entries = _lib_pricing_check.expired_allowlist_entries
295
297
  check_table_shapes = _lib_pricing_check.check_table_shapes
296
298
  pricing_issue_action = _lib_pricing_check.pricing_issue_action
297
299
  CoverageGap = _lib_pricing_check.CoverageGap
@@ -435,6 +437,17 @@ _parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
435
437
  _lib_statusline = _load_sibling("_lib_statusline")
436
438
  STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS
437
439
 
440
+ # #279 S2 F2: stdlib-logging chokepoint (CCTALLY_DEBUG tracebacks on the
441
+ # top-level catch-all; adopted by the dashboard log_error surface).
442
+ _lib_log = _load_sibling("_lib_log")
443
+
444
+ # #279 S6 W1: pure JSON wire-format kernel — the schemaVersion stamp helper
445
+ # (single chokepoint for the additive camelCase envelope across reporting
446
+ # --json surfaces) + the canonical _iso_z (bound in W4/Task 9, not here).
447
+ # Convention: docs/cli-contract.md.
448
+ _lib_json_envelope = _load_sibling("_lib_json_envelope")
449
+ stamp_schema_version = _lib_json_envelope.stamp_schema_version
450
+
438
451
  _lib_changelog = _load_sibling("_lib_changelog")
439
452
  # Backward-compat re-export: callers and tests reach the helper through
440
453
  # ``cctally._release_read_latest_release_version`` (incl. monkeypatch
@@ -790,6 +803,7 @@ _MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
790
803
  Migration = _cctally_db.Migration
791
804
  DowngradeDetected = _cctally_db.DowngradeDetected
792
805
  ProdMigrationRefused = _cctally_db.ProdMigrationRefused
806
+ StatsDbCorruptError = _cctally_db.StatsDbCorruptError
793
807
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
794
808
  _CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
795
809
  _make_migration_decorator = _cctally_db._make_migration_decorator
@@ -1107,7 +1121,8 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
1107
1121
  _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
1108
1122
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
1109
1123
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
1110
- _iso_z = _cctally_dashboard._iso_z
1124
+ # _iso_z is NOT bound here anymore — the former dashboard-then-forecast
1125
+ # double-bind collapses to a single canonical bind below (#279 S6 W4).
1111
1126
  # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
1112
1127
  # Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
1113
1128
  # can reach the dashboard sibling's aggregator via `c = _cctally()`.
@@ -1201,9 +1216,7 @@ cmd_range_cost = _cctally_reporting.cmd_range_cost # parser: c.cmd_range_cost
1201
1216
  # are the parser's set_defaults(func=c.cmd_*) targets; the forecast cluster is
1202
1217
  # reached by TUI/dashboard/refresh/view_models via shim/accessor, and tests
1203
1218
  # retrieve any of these off the cctally namespace. So EVERY moved symbol lives
1204
- # here. _iso_z is LAST: it overrides the L943 `_iso_z = _cctally_dashboard._iso_z`
1205
- # binding so cctally._iso_z stays the FORECAST version (the dt-only variant
1206
- # _lib_diff_kernel + _cctally_five_hour depend on). This block MUST be after L943.
1219
+ # here.
1207
1220
  _cctally_forecast = _load_sibling("_cctally_forecast")
1208
1221
  cmd_report = _cctally_forecast.cmd_report
1209
1222
  cmd_forecast = _cctally_forecast.cmd_forecast
@@ -1252,8 +1265,12 @@ _budget_emit_json = _cctally_forecast._budget_emit_json
1252
1265
  _build_budget_snapshot = _cctally_forecast._build_budget_snapshot
1253
1266
  _build_budget_no_data_snapshot = _cctally_forecast._build_budget_no_data_snapshot
1254
1267
  _build_budget_no_budget_snapshot = _cctally_forecast._build_budget_no_budget_snapshot
1255
- # _iso_z LAST overrides the L943 dashboard binding (forecast version wins).
1256
- _iso_z = _cctally_forecast._iso_z
1268
+ # #279 S6 W4: single canonical _iso_z bind (None-safe seconds-precision UTC-Z),
1269
+ # placed where the old forecast-wins override sat so the _lib_diff_kernel /
1270
+ # _cctally_five_hour shim lookups resolve to the same object. forecast and the
1271
+ # dashboard envelope now alias this same canonical; doctor keeps its divergent
1272
+ # name-shared copy.
1273
+ _iso_z = _lib_json_envelope._iso_z
1257
1274
 
1258
1275
 
1259
1276
  RELEASE_HEADER_RE = re.compile(
@@ -2801,15 +2818,42 @@ def main(argv: list[str] | None = None) -> int:
2801
2818
  _print_migration_error_banner_if_needed(args)
2802
2819
  try:
2803
2820
  rc = int(args.func(args))
2821
+ # Flush now, inside the guard, so a buffered broken pipe (small output
2822
+ # that never filled the pipe buffer, e.g. `cctally daily | head`)
2823
+ # surfaces HERE and is handled below — not as an "Exception ignored in
2824
+ # sys.stdout" at interpreter shutdown (CPython exit code 120). #279 S1 F5.
2825
+ sys.stdout.flush()
2804
2826
  except KeyboardInterrupt:
2805
2827
  return 130
2828
+ except BrokenPipeError:
2829
+ # Reader went away (`cctally daily | head`). Not an error: redirect
2830
+ # stdout to /dev/null so the interpreter's shutdown-flush doesn't emit
2831
+ # an "Exception ignored" line, then exit 0 immediately — skipping the
2832
+ # post-command update hooks (stdout is dead, banners are pointless).
2833
+ # Mirrors the KeyboardInterrupt immediate-return precedent. #279 S1 F5.
2834
+ devnull = os.open(os.devnull, os.O_WRONLY)
2835
+ os.dup2(devnull, sys.stdout.fileno())
2836
+ return 0
2806
2837
  except DowngradeDetected as exc:
2807
2838
  eprint(f"cctally: {exc}")
2808
2839
  return 2
2809
2840
  except ProdMigrationRefused as exc:
2810
2841
  eprint(f"{exc}") # message already carries the 'cctally:' prefix
2811
2842
  return 2
2843
+ except StatsDbCorruptError as exc:
2844
+ # #279 S1 F4: corrupt stats.db (non-re-derivable) — one-line diagnosis
2845
+ # + recovery guidance, exit 2. Caught before the generic handler so it
2846
+ # never renders as a raw traceback.
2847
+ eprint(f"cctally: {exc}")
2848
+ return 2
2812
2849
  except Exception as exc: # pragma: no cover
2850
+ # #279 S2 F2: CCTALLY_DEBUG=1 surfaces the full traceback on stderr
2851
+ # via the _lib_log chokepoint; default mode stays byte-identical.
2852
+ if _lib_log.debug_enabled():
2853
+ _lib_log.get_logger("cli").debug(
2854
+ "unhandled exception in %r",
2855
+ getattr(args, "command", None), exc_info=True,
2856
+ )
2813
2857
  eprint(f"Error: {exc}")
2814
2858
  rc = 1
2815
2859
  # Post-command update hooks (spec §4.2). Best-effort: any exception
@@ -2868,7 +2912,7 @@ def _post_command_update_hooks(command: str | None, args) -> None:
2868
2912
  otherwise races that detached writer re-creating the dir after the
2869
2913
  command returns. Setting this env var makes such harnesses
2870
2914
  deterministic without disabling the feature for real users."""
2871
- if os.environ.get("CCTALLY_DISABLE_UPDATE_CHECK"):
2915
+ if _cctally_core._truthy_env("CCTALLY_DISABLE_UPDATE_CHECK"):
2872
2916
  return
2873
2917
  if command == "setup" and getattr(args, "uninstall", False):
2874
2918
  return
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env bash
2
- # Wrapper that forwards to `cctally dashboard`.
3
- # Keeps CLI ergonomics in line with cctally-tui / cctally-forecast.
2
+ # Thin wrapper: `cctally-dashboard` `cctally dashboard`.
3
+ set -euo pipefail
4
4
  exec "$(dirname "$0")/cctally" dashboard "$@"