cctally 1.27.1 → 1.29.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.
@@ -140,6 +140,14 @@ class DoctorState:
140
140
  # token_total); the kernel only reads `.kind`/`.model`/`.entry_count`/
141
141
  # `.token_total`, so any duck-typed equivalent works for tests.
142
142
  pricing_coverage: Optional[list] = None
143
+ # Conversation viewer (Plan 2, spec §5): the resolved
144
+ # `dashboard.expose_transcripts` opt-in. Only consequential when the bind
145
+ # is LAN — `_check_safety_dashboard_bind` then surfaces an extra
146
+ # "transcripts exposed on LAN" detail on top of the existing LAN-bind
147
+ # WARN. Defaulted False (placed last after the other defaulted fields) so
148
+ # existing constructors stay valid and a loopback bind is byte-identical
149
+ # whether or not expose is set.
150
+ expose_transcripts: bool = False
143
151
 
144
152
 
145
153
  @dataclasses.dataclass(frozen=True)
@@ -556,6 +564,45 @@ def _check_db_migrations_pending(s: DoctorState) -> CheckResult:
556
564
  )
557
565
 
558
566
 
567
+ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
568
+ """FAIL/WARN when a DB's user_version exceeds the running binary's
569
+ registry head (issue #145). stats.db ahead bricks commands (FAIL);
570
+ cache.db ahead auto-heals on the next open (WARN). doctor reads raw
571
+ user_version (no dispatcher), so it can report without healing/bricking.
572
+ """
573
+ def _eval(status):
574
+ if not status:
575
+ return None
576
+ uv = status.get("user_version", 0) or 0
577
+ rs = status.get("registry_size", 0) or 0
578
+ return {"user_version": uv, "registry_size": rs, "ahead": uv > rs}
579
+
580
+ stats = _eval(s.stats_db_status)
581
+ cache = _eval(s.cache_db_status)
582
+ details = {"stats.db": stats, "cache.db": cache}
583
+ stats_ahead = bool(stats and stats["ahead"])
584
+ cache_ahead = bool(cache and cache["ahead"])
585
+
586
+ if stats_ahead:
587
+ return CheckResult(
588
+ id="db.version_ahead", title="Version ahead", severity="fail",
589
+ summary=f"stats.db ahead (v{stats['user_version']} > known v{stats['registry_size']})",
590
+ remediation="Run `cctally db recover --db stats --yes` (or restore from backup)",
591
+ details=details,
592
+ )
593
+ if cache_ahead:
594
+ return CheckResult(
595
+ id="db.version_ahead", title="Version ahead", severity="warn",
596
+ summary=f"cache.db ahead (v{cache['user_version']} > known v{cache['registry_size']}) — auto-heals",
597
+ remediation="Auto-heals on next command, or run `cctally db recover --db cache`",
598
+ details=details,
599
+ )
600
+ return CheckResult(
601
+ id="db.version_ahead", title="Version ahead", severity="ok",
602
+ summary="none ahead", remediation=None, details=details,
603
+ )
604
+
605
+
559
606
  def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
560
607
  if s.latest_snapshot_at is None:
561
608
  return CheckResult(
@@ -859,13 +906,24 @@ def _check_safety_dashboard_bind(s: DoctorState) -> CheckResult:
859
906
  rem += "."
860
907
  note = ("A separate running dashboard process may have overridden via --host; "
861
908
  "the CLI sees config only.") if s.runtime_bind is None else None
909
+ # Conversation viewer (Plan 2, spec §5): a LAN bind WITH the
910
+ # `dashboard.expose_transcripts` opt-in serves raw conversation prose to
911
+ # the LAN. Surface that ONLY here (the bind already WARNs and is
912
+ # non-loopback by construction), additively — a loopback bind never
913
+ # reaches this branch, so the loopback report stays byte-identical
914
+ # regardless of the expose flag.
915
+ extra = {}
916
+ if s.expose_transcripts:
917
+ notes.append("transcripts exposed on LAN")
918
+ extra["transcripts_exposed_on_lan"] = True
862
919
  return CheckResult(
863
920
  id="safety.dashboard_bind", title="Dashboard bind",
864
921
  severity="warn", summary="; ".join(notes),
865
922
  remediation=rem,
866
923
  details={"config": s.dashboard_bind_stored,
867
924
  "runtime_bind": s.runtime_bind,
868
- **({"note": note} if note else {})},
925
+ **({"note": note} if note else {}),
926
+ **extra},
869
927
  )
870
928
 
871
929
 
@@ -1052,6 +1110,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1052
1110
  ("db", "Database", (
1053
1111
  ("db.stats.file", "_check_db_stats_file"),
1054
1112
  ("db.cache.file", "_check_db_cache_file"),
1113
+ ("db.version_ahead", "_check_db_version_ahead"),
1055
1114
  ("db.migrations.applied", "_check_db_migrations_applied"),
1056
1115
  ("db.migrations.pending", "_check_db_migrations_pending"),
1057
1116
  )),
package/bin/_lib_jsonl.py CHANGED
@@ -201,6 +201,68 @@ def _parse_usage_entries(
201
201
  return no_key_entries
202
202
 
203
203
 
204
+ def parse_cost_entry(obj, path_str: str):
205
+ """Pure per-line cost parser: given a parsed JSONL object, return
206
+ ``(UsageEntry, msg_id, req_id)`` when it is a billable assistant entry, or
207
+ ``None`` otherwise (non-assistant, missing/invalid usage, model, or
208
+ timestamp, or a ``<synthetic>`` placeholder). No I/O, no byte offset — the
209
+ caller owns the readline()+tell() loop.
210
+
211
+ Extracted (#138) so the streaming ``_iter_jsonl_entries_with_offsets`` reader
212
+ and the fused single-pass sync walker (``_cctally_cache._iter_sync_entries``)
213
+ share ONE gating implementation — each JSONL line is ``json.loads``-parsed
214
+ once and classified once, never re-parsed for a separate second walk.
215
+ """
216
+ if obj.get("type") != "assistant":
217
+ return None
218
+
219
+ ts_raw = obj.get("timestamp")
220
+ if not isinstance(ts_raw, str) or not ts_raw.strip():
221
+ return None
222
+
223
+ msg = obj.get("message")
224
+ if not isinstance(msg, dict):
225
+ msg = obj
226
+
227
+ usage = msg.get("usage")
228
+ if not isinstance(usage, dict):
229
+ return None
230
+
231
+ model = msg.get("model") or obj.get("model")
232
+ if not isinstance(model, str) or not model.strip():
233
+ return None
234
+ model = model.strip()
235
+ if model == "<synthetic>":
236
+ # Matches ccusage's claude_loader.rs:454. Filtered here so the cache
237
+ # ingest path can't accidentally store these rows even if a downstream
238
+ # loop forgets to double-check (see `sync_cache` in _cctally_cache.py).
239
+ return None
240
+
241
+ try:
242
+ ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
243
+ if ts.tzinfo is None:
244
+ ts = ts.replace(tzinfo=dt.timezone.utc)
245
+ except ValueError:
246
+ return None
247
+
248
+ msg_id = msg.get("id")
249
+ req_id = obj.get("requestId")
250
+ cost_usd_raw = obj.get("costUSD")
251
+ cost_usd = float(cost_usd_raw) if cost_usd_raw is not None else None
252
+
253
+ return (
254
+ UsageEntry(
255
+ timestamp=ts,
256
+ model=model,
257
+ usage=usage,
258
+ cost_usd=cost_usd,
259
+ source_path=path_str,
260
+ ),
261
+ msg_id,
262
+ req_id,
263
+ )
264
+
265
+
204
266
  def _iter_jsonl_entries_with_offsets(fh, path_str: str):
205
267
  """Yield (byte_offset, UsageEntry, msg_id, req_id) for each assistant
206
268
  entry starting from fh's current position.
@@ -209,7 +271,9 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
209
271
  accurate for resume-from-offset after partial ingests. Malformed JSON
210
272
  and non-assistant lines are skipped, but the offset still advances past
211
273
  them so they are never re-read. Range filtering is intentionally NOT
212
- done here — filters are applied at query time by iter_entries().
274
+ done here — filters are applied at query time by iter_entries(). The
275
+ per-line gating lives in ``parse_cost_entry`` (shared with the fused
276
+ single-pass sync walker, #138).
213
277
  """
214
278
  while True:
215
279
  offset = fh.tell()
@@ -230,56 +294,11 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
230
294
  obj = json.loads(stripped)
231
295
  except json.JSONDecodeError:
232
296
  continue
233
- if obj.get("type") != "assistant":
234
- continue
235
-
236
- ts_raw = obj.get("timestamp")
237
- if not isinstance(ts_raw, str) or not ts_raw.strip():
238
- continue
239
-
240
- msg = obj.get("message")
241
- if not isinstance(msg, dict):
242
- msg = obj
243
-
244
- usage = msg.get("usage")
245
- if not isinstance(usage, dict):
246
- continue
247
-
248
- model = msg.get("model") or obj.get("model")
249
- if not isinstance(model, str) or not model.strip():
297
+ parsed = parse_cost_entry(obj, path_str)
298
+ if parsed is None:
250
299
  continue
251
- model = model.strip()
252
- if model == "<synthetic>":
253
- # Matches ccusage's claude_loader.rs:454. Filtered at the
254
- # iterator level so the cache ingest path can't accidentally
255
- # store these rows even if a downstream loop forgets to
256
- # double-check (see `sync_cache` in _cctally_cache.py).
257
- continue
258
-
259
- try:
260
- ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
261
- if ts.tzinfo is None:
262
- ts = ts.replace(tzinfo=dt.timezone.utc)
263
- except ValueError:
264
- continue
265
-
266
- msg_id = msg.get("id")
267
- req_id = obj.get("requestId")
268
- cost_usd_raw = obj.get("costUSD")
269
- cost_usd = float(cost_usd_raw) if cost_usd_raw is not None else None
270
-
271
- yield (
272
- offset,
273
- UsageEntry(
274
- timestamp=ts,
275
- model=model,
276
- usage=usage,
277
- cost_usd=cost_usd,
278
- source_path=path_str,
279
- ),
280
- msg_id,
281
- req_id,
282
- )
300
+ entry, msg_id, req_id = parsed
301
+ yield (offset, entry, msg_id, req_id)
283
302
 
284
303
 
285
304
  _CODEX_FILENAME_UUID_RE = re.compile(
@@ -0,0 +1,80 @@
1
+ """Pure loopback / Host gate predicates for the conversation-viewer transcript
2
+ endpoints (Plan 2, spec §5).
3
+
4
+ No I/O, no globals — directly unit-testable. The dashboard's
5
+ ``_require_transcripts_allowed`` composes ``transcripts_allowed`` (is this bind
6
+ served at all?) with ``host_allowed_for_transcripts`` (anti-DNS-rebinding on the
7
+ request's Host header). Loopback-by-default kills rebinding (it needs a
8
+ hostname that resolves to 127.0.0.1); under the explicit ``expose`` opt-in the
9
+ LAN device reaches the dashboard at its IP literal (allowed), while an
10
+ attacker's rebinding *domain* (a hostname) is rejected.
11
+ """
12
+ from __future__ import annotations
13
+ import ipaddress
14
+
15
+ # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
16
+ # mirror — imported by the dashboard's transcript gate at runtime.
17
+
18
+ _LOOPBACK_NAMES = {"localhost", "::1"}
19
+
20
+
21
+ def authority_host(host_header) -> str:
22
+ """Extract the lowercased host from an HTTP authority (``Host`` header or a
23
+ bind string). Strips the port, the ``[...]`` IPv6 brackets, and any
24
+ ``%zone`` id. IPv6-safe (does NOT ``split(':')``). Empty string on missing
25
+ input."""
26
+ if not host_header:
27
+ return ""
28
+ h = str(host_header).strip()
29
+ if h.startswith("["):
30
+ # [IPv6]:port -> IPv6
31
+ end = h.find("]")
32
+ if end != -1:
33
+ h = h[1:end]
34
+ else:
35
+ h = h[1:]
36
+ elif h.count(":") == 1:
37
+ # host:port (a single colon can't be a bare IPv6 literal)
38
+ h = h.split(":", 1)[0]
39
+ # else: bare IPv6 literal (multiple colons) or bare host — leave as-is
40
+ h = h.split("%", 1)[0] # drop IPv6 zone id
41
+ return h.lower()
42
+
43
+
44
+ def is_loopback(host: str) -> bool:
45
+ """True for the loopback names plus any IPv4/IPv6 loopback literal
46
+ (127.0.0.0/8, ::1)."""
47
+ if not host:
48
+ return False
49
+ h = host.lower()
50
+ if h in _LOOPBACK_NAMES:
51
+ return True
52
+ try:
53
+ return ipaddress.ip_address(h).is_loopback
54
+ except ValueError:
55
+ return False
56
+
57
+
58
+ def transcripts_allowed(bind_host, expose: bool) -> bool:
59
+ """Are transcripts served AT ALL on this bind? Loopback bind always; a
60
+ non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
61
+ return is_loopback(authority_host(bind_host)) or bool(expose)
62
+
63
+
64
+ def host_allowed_for_transcripts(host_header, expose: bool) -> bool:
65
+ """Anti-DNS-rebinding Host allowlist. Loopback Host always OK. Otherwise
66
+ only an IP-literal Host under ``expose`` (the LAN IP the dashboard is
67
+ reached at) — a rebinding *domain* (any hostname) is rejected. Fail closed
68
+ on missing/empty Host."""
69
+ h = authority_host(host_header)
70
+ if not h:
71
+ return False
72
+ if is_loopback(h):
73
+ return True
74
+ if not expose:
75
+ return False
76
+ try:
77
+ ipaddress.ip_address(h) # IP literal can't be DNS-rebound
78
+ return True
79
+ except ValueError:
80
+ return False # hostname (rebinding vector) — reject
package/bin/cctally CHANGED
@@ -306,6 +306,8 @@ BudgetInputs = _lib_budget.BudgetInputs
306
306
  BudgetStatus = _lib_budget.BudgetStatus
307
307
  compute_budget_status = _lib_budget.compute_budget_status
308
308
  project_linear = _lib_budget.project_linear
309
+ calendar_month_window = _lib_budget.calendar_month_window
310
+ calendar_week_window = _lib_budget.calendar_week_window
309
311
 
310
312
  # CLAUDE_MODEL_CONTEXT_WINDOWS / …_DEFAULT_FAMILY moved to _cctally_statusline.py (re-exported below).
311
313
 
@@ -377,12 +379,14 @@ _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
377
379
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
378
380
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
379
381
  _alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
382
+ _alert_text_codex_budget = _lib_alerts_payload._alert_text_codex_budget
380
383
  _alert_text_projected = _lib_alerts_payload._alert_text_projected
381
384
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
382
385
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
383
386
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
384
387
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
385
388
  _build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
389
+ _build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
386
390
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
387
391
 
388
392
  _lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
@@ -736,6 +740,7 @@ add_column_if_missing = _cctally_db.add_column_if_missing
736
740
  _MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
737
741
  Migration = _cctally_db.Migration
738
742
  DowngradeDetected = _cctally_db.DowngradeDetected
743
+ ProdMigrationRefused = _cctally_db.ProdMigrationRefused
739
744
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
740
745
  _CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
741
746
  _make_migration_decorator = _cctally_db._make_migration_decorator
@@ -743,7 +748,9 @@ stats_migration = _cctally_db.stats_migration
743
748
  cache_migration = _cctally_db.cache_migration
744
749
  _LEGACY_MARKER_ALIASES_BY_DB = _cctally_db._LEGACY_MARKER_ALIASES_BY_DB
745
750
  _bootstrap_rename_legacy_markers = _cctally_db._bootstrap_rename_legacy_markers
751
+ _stamp_applied = _cctally_db._stamp_applied
746
752
  _run_pending_migrations = _cctally_db._run_pending_migrations
753
+ _recover_version_ahead = _cctally_db._recover_version_ahead
747
754
  _log_migration_error = _cctally_db._log_migration_error
748
755
  _clear_migration_error_log_entries = _cctally_db._clear_migration_error_log_entries
749
756
  _render_migration_error_banner = _cctally_db._render_migration_error_banner
@@ -757,6 +764,7 @@ _db_resolve_migration_name = _cctally_db._db_resolve_migration_name
757
764
  _db_path_for_label = _cctally_db._db_path_for_label
758
765
  cmd_db_skip = _cctally_db.cmd_db_skip
759
766
  cmd_db_unskip = _cctally_db.cmd_db_unskip
767
+ cmd_db_recover = _cctally_db.cmd_db_recover
760
768
 
761
769
 
762
770
  # Session-entry cache subsystem (Claude + Codex) — read-through delta
@@ -782,6 +790,7 @@ IngestStats = _cctally_cache.IngestStats
782
790
  _progress_stderr = _cctally_cache._progress_stderr
783
791
  _ensure_session_files_row = _cctally_cache._ensure_session_files_row
784
792
  sync_cache = _cctally_cache.sync_cache
793
+ backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
785
794
  iter_entries = _cctally_cache.iter_entries
786
795
  _collect_entries_direct = _cctally_cache._collect_entries_direct
787
796
  _JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry
@@ -793,6 +802,7 @@ sync_codex_cache = _cctally_cache.sync_codex_cache
793
802
  iter_codex_entries = _cctally_cache.iter_codex_entries
794
803
  _collect_codex_entries_direct = _cctally_cache._collect_codex_entries_direct
795
804
  get_codex_entries = _cctally_cache.get_codex_entries
805
+ _sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
796
806
  get_entries = _cctally_cache.get_entries
797
807
  open_cache_db = _cctally_cache.open_cache_db
798
808
  cmd_cache_sync = _cctally_cache.cmd_cache_sync
@@ -822,6 +832,7 @@ _normalize_percent = _cctally_record._normalize_percent
822
832
  maybe_record_milestone = _cctally_record.maybe_record_milestone
823
833
  maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
824
834
  maybe_record_project_budget_milestone = _cctally_record.maybe_record_project_budget_milestone
835
+ maybe_record_codex_budget_milestone = _cctally_record.maybe_record_codex_budget_milestone
825
836
  maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
826
837
  _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
827
838
  _compute_block_totals = _cctally_record._compute_block_totals
@@ -1101,6 +1112,7 @@ _fetch_current_week_snapshots = _cctally_forecast._fetch_current_week_snapshots
1101
1112
  _apply_midweek_reset_override = _cctally_forecast._apply_midweek_reset_override
1102
1113
  _resolve_current_budget_window = _cctally_forecast._resolve_current_budget_window
1103
1114
  _build_budget_status_inputs = _cctally_forecast._build_budget_status_inputs
1115
+ _build_vendor_budget_inputs = _cctally_forecast._build_vendor_budget_inputs
1104
1116
  _select_dollars_per_percent = _cctally_forecast._select_dollars_per_percent
1105
1117
  _assess_forecast_confidence = _cctally_forecast._assess_forecast_confidence
1106
1118
  _pick_p_24h_ago = _cctally_forecast._pick_p_24h_ago
@@ -1115,6 +1127,12 @@ _forecast_color_enabled = _cctally_forecast._forecast_color_enabled
1115
1127
  _render_forecast_progress_bar = _cctally_forecast._render_forecast_progress_bar
1116
1128
  _render_forecast_terminal = _cctally_forecast._render_forecast_terminal
1117
1129
  _BUDGET_JSON_SCHEMA_VERSION = _cctally_forecast._BUDGET_JSON_SCHEMA_VERSION
1130
+ # Single-sourced `--period` spellings (code-review #5): the parser's `choices=`
1131
+ # derives from the same map the handler normalizer uses, so they can't drift.
1132
+ _BUDGET_PERIOD_ALIASES = _cctally_forecast._BUDGET_PERIOD_ALIASES
1133
+ _BUDGET_PERIOD_CHOICES = _cctally_forecast._BUDGET_PERIOD_CHOICES
1134
+ _normalize_budget_period = _cctally_forecast._normalize_budget_period
1135
+ _resolve_calendar_window = _cctally_forecast._resolve_calendar_window
1118
1136
  _cmd_budget_set = _cctally_forecast._cmd_budget_set
1119
1137
  _cmd_budget_unset = _cctally_forecast._cmd_budget_unset
1120
1138
  # Per-project budget set/unset (#19/#121, spec §4.3 / §7).
@@ -2082,12 +2100,18 @@ get_max_milestone_for_week = _cctally_milestones.get_max_milestone_for_
2082
2100
  get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for_week # record shim
2083
2101
  get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
2084
2102
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2085
- insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2103
+ insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim; test_budget_alerts / test_project_budget_dashboard ns[] (+ test_codex_budget_alerts / test_projected_alerts post-#143 vendor-param unification)
2086
2104
  insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
2105
+ _budget_crossings = _cctally_milestones._budget_crossings # record shim (shared INSERT-and-arm core for the budget axis, both vendors, #143)
2106
+ _resolve_codex_budget_period_window = _cctally_milestones._resolve_codex_budget_period_window # record shim; milestones c. (codex period window)
2107
+ _resolve_budget_window = _cctally_milestones._resolve_budget_window # record shim; milestones c. (per-vendor cheap budget window dispatcher, #143)
2108
+ _budget_spend_for_vendor = _cctally_milestones._budget_spend_for_vendor # record shim; milestones c. (per-vendor budget spend dispatcher, #143)
2109
+ _reconcile_codex_budget_on_config_write = _cctally_milestones._reconcile_codex_budget_on_config_write # forecast/config c. (forward-only codex-budget reconcile)
2110
+ _resolve_claude_budget_window = _cctally_milestones._resolve_claude_budget_window # record shim; milestones c. (period-aware Claude budget window)
2087
2111
  _project_crossings = _cctally_milestones._project_crossings # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
2088
2112
  insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2089
2113
  _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2090
- _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
2114
+ _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts / test_codex_budget_alerts ns[] (vendor-param, #143)
2091
2115
  _reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
2092
2116
  _reconcile_project_budget_milestones_on_write = _cctally_milestones._reconcile_project_budget_milestones_on_write # forecast/config/dashboard c. (forward-only project-budget reconcile)
2093
2117
 
@@ -2672,6 +2696,9 @@ def main(argv: list[str] | None = None) -> int:
2672
2696
  except DowngradeDetected as exc:
2673
2697
  eprint(f"cctally: {exc}")
2674
2698
  return 2
2699
+ except ProdMigrationRefused as exc:
2700
+ eprint(f"{exc}") # message already carries the 'cctally:' prefix
2701
+ return 2
2675
2702
  except Exception as exc: # pragma: no cover
2676
2703
  eprint(f"Error: {exc}")
2677
2704
  rc = 1