cctally 1.65.0 → 1.67.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 (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -111,8 +111,30 @@ What stays in bin/cctally:
111
111
  ``_RESET_PCT_DROP_THRESHOLD`` — boundary helpers, already-extracted
112
112
  subsystems, or constants reached through the cctally namespace
113
113
  (``_RESET_PCT_DROP_THRESHOLD`` now lives in ``bin/_cctally_weekrefs.py``,
114
- re-exported on the cctally ns). All accessed via the same shim/``c.X``
115
- pattern.
114
+ re-exported on the cctally ns). Accessed via the shim/``c.X`` pattern
115
+ EXCEPT the names honest-imported by the #279 S4 F5 collapse below.
116
+
117
+ #279 S4 F5 (the #50 treatment): the forwarding shims for
118
+ ``open_cache_db`` (→ ``_cctally_cache``), ``_floor_to_hour`` (→
119
+ ``_lib_blocks``), ``_resolve_display_tz_obj`` (→ ``_lib_display_tz``),
120
+ ``_build_alert_payload_{weekly,five_hour,budget,project_budget,
121
+ codex_budget,projected}`` (→ ``_lib_alerts_payload``), and
122
+ ``_get_oauth_usage_config`` (→ ``_cctally_refresh``) were replaced by
123
+ honest top-level imports — each real def lives in a sibling
124
+ bin/cctally eager-loads BEFORE _cctally_record, and none is
125
+ monkeypatched through the cctally namespace / this module's route.
126
+ The remaining 38 shims below STAY: patched surfaces (``load_config``,
127
+ ``sync_cache``, ``_dispatch_alert_notification``, ``compute_budget_status``,
128
+ ``_apply_reset_events_to_weekrefs``, ``resolve_display_tz``,
129
+ ``_sum_cost_by_project``, ``_project_budget_labels``,
130
+ ``get_claude_session_entries``, ``_compute_cost_for_weekref``,
131
+ ``_get_canonical_boundary_for_date``, ``_hook_tick_oauth_refresh``),
132
+ bin/cctally-homed residues (``_resolve_primary_model_for_block``,
133
+ ``_warn_alerts_bad_config_once``, ``_warn_budget_bad_config_once``,
134
+ ``_hook_tick_make_mock_refresh``, ``cmd_sync_week``), and names whose
135
+ real home (``_cctally_milestones`` / ``_cctally_forecast`` /
136
+ ``_cctally_weekrefs``) bin/cctally eager-loads AFTER _cctally_record —
137
+ honest-importing those at module top would force an early sibling load.
116
138
 
117
139
  §5.6 audit on this extraction's monkeypatch surface:
118
140
  - ``cmd_record_usage`` — patched via ``monkeypatch.setitem(ns, …)``
@@ -177,18 +199,99 @@ from _cctally_core import (
177
199
  _BudgetConfigError,
178
200
  _command_as_of,
179
201
  )
180
- from _lib_five_hour import _canonical_5h_window_key
202
+ from _lib_five_hour import _canonical_5h_window_key, five_hour_milestone_range
181
203
  from _lib_pricing import _calculate_entry_cost
182
204
 
183
205
 
184
- # Module-level back-ref shims. Each shim resolves
185
- # ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
186
- # monkeypatches on cctally's namespace propagate into the moved code
187
- # unchanged. `load_config` and `get_claude_session_entries` STAY as
188
- # shims even though their natural homes are decentralized
189
- # (_cctally_config / _cctally_cache) — tests monkeypatch them via
190
- # `ns["X"]` (21 sites total, audited 2026-05-17); direct imports would
191
- # silently bypass the patches.
206
+ import importlib.util as _ilu
207
+
208
+
209
+ def _ensure_sibling_loaded(name: str) -> None:
210
+ """Register a NON-eager-loaded ``_lib_*`` sibling in ``sys.modules``.
211
+
212
+ Every ``_lib_*`` this module imports at body-time is eager-loaded by
213
+ ``bin/cctally`` EXCEPT the #279 S4 kernels (``_lib_credit``,
214
+ ``_lib_record``) — those are consumer-only and were deliberately kept
215
+ out of ``bin/cctally``'s eager-load block so ``bin/cctally`` stays
216
+ byte-untouched (spec §2 re-export continuity). Under the
217
+ ``SourceFileLoader`` harness path (``bin/`` absent from ``sys.path``) a
218
+ bare ``from _lib_X import`` would then miss, so this pre-registers the
219
+ sibling ``__file__``-relative first — mirroring ``_cctally_cache.
220
+ _load_lib`` and ``_lib_conversation_query``'s ``_lib_perf`` fallback.
221
+ The honest ``from _lib_X import`` that follows is a ``sys.modules`` hit
222
+ in every load context (prod script, conftest, harness).
223
+ """
224
+ if name in sys.modules:
225
+ return
226
+ try:
227
+ __import__(name) # bin/ on sys.path: prod script / conftest / pytest
228
+ return
229
+ except ModuleNotFoundError:
230
+ pass
231
+ _p = os.path.join(os.path.dirname(__file__), f"{name}.py")
232
+ _spec = _ilu.spec_from_file_location(name, _p)
233
+ _mod = _ilu.module_from_spec(_spec)
234
+ sys.modules[name] = _mod
235
+ _spec.loader.exec_module(_mod)
236
+
237
+
238
+ _ensure_sibling_loaded("_lib_credit")
239
+ from _lib_credit import (
240
+ _PERCENT_NORMALIZE_DECIMALS,
241
+ _normalize_percent,
242
+ CreditPlan,
243
+ _parse_credit_at,
244
+ _build_credit_plan,
245
+ )
246
+ _ensure_sibling_loaded("_lib_record")
247
+ from _lib_record import (
248
+ check_resets_at_plausibility,
249
+ plan_weekly_credit_debounce,
250
+ plan_five_hour_credit,
251
+ hwm_clamp_applies,
252
+ milestone_coverage_owes,
253
+ hwm_file_next,
254
+ projected_crossings,
255
+ FIRE_IMMEDIATE,
256
+ CONFIRM_RESET,
257
+ CLEAR_MARKER,
258
+ ARM_MARKER,
259
+ )
260
+
261
+ # === #279 S4 F5: forwarding-shim wall collapse (the #50 treatment) =========
262
+ # Names whose real def lives in a sibling that bin/cctally eager-loads BEFORE
263
+ # _cctally_record (so these are sys.modules hits in every load context) AND
264
+ # that no test monkeypatches through the cctally namespace / _cctally_record
265
+ # route are honest-imported here instead of routed through a
266
+ # ``sys.modules["cctally"].X`` shim. The audit (full table in the commit body)
267
+ # used the suite as the authority; every name below survived the full
268
+ # bin/cctally-test-all. Patched / bin/cctally-homed / post-889-homed names
269
+ # keep their shims below.
270
+ from _cctally_cache import open_cache_db
271
+ from _lib_blocks import _floor_to_hour
272
+ from _lib_display_tz import _resolve_display_tz_obj
273
+ from _lib_alerts_payload import (
274
+ _build_alert_payload_weekly,
275
+ _build_alert_payload_five_hour,
276
+ _build_alert_payload_budget,
277
+ _build_alert_payload_project_budget,
278
+ _build_alert_payload_codex_budget,
279
+ _build_alert_payload_projected,
280
+ )
281
+ from _cctally_refresh import _get_oauth_usage_config
282
+
283
+
284
+ # Module-level back-ref shims (the REMAINING wall after the #279 S4 F5
285
+ # collapse above pulled 10 unpatched, pre-889-homed names up to honest
286
+ # imports). Each shim below resolves ``sys.modules['cctally'].X`` at CALL
287
+ # TIME (not bind time), so monkeypatches on cctally's namespace propagate
288
+ # into the moved code unchanged. `load_config` and
289
+ # `get_claude_session_entries` STAY as shims even though their natural
290
+ # homes are decentralized (_cctally_config / _cctally_cache) — tests
291
+ # monkeypatch them via `ns["X"]`; direct imports would silently bypass the
292
+ # patches. The rest stay because they are patched, bin/cctally-homed, or
293
+ # their real home is eager-loaded AFTER this module (see the F5 note in the
294
+ # module docstring for the full STAY/COLLAPSE audit).
192
295
  # See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
193
296
  def load_config(*args, **kwargs):
194
297
  return sys.modules["cctally"].load_config(*args, **kwargs)
@@ -198,18 +301,10 @@ def get_claude_session_entries(*args, **kwargs):
198
301
  return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
199
302
 
200
303
 
201
- def open_cache_db(*args, **kwargs):
202
- return sys.modules["cctally"].open_cache_db(*args, **kwargs)
203
-
204
-
205
304
  def sync_cache(*args, **kwargs):
206
305
  return sys.modules["cctally"].sync_cache(*args, **kwargs)
207
306
 
208
307
 
209
- def _floor_to_hour(*args, **kwargs):
210
- return sys.modules["cctally"]._floor_to_hour(*args, **kwargs)
211
-
212
-
213
308
  def _get_canonical_boundary_for_date(*args, **kwargs):
214
309
  return sys.modules["cctally"]._get_canonical_boundary_for_date(*args, **kwargs)
215
310
 
@@ -250,30 +345,6 @@ def _resolve_primary_model_for_block(*args, **kwargs):
250
345
  return sys.modules["cctally"]._resolve_primary_model_for_block(*args, **kwargs)
251
346
 
252
347
 
253
- def _resolve_display_tz_obj(*args, **kwargs):
254
- return sys.modules["cctally"]._resolve_display_tz_obj(*args, **kwargs)
255
-
256
-
257
- def _build_alert_payload_weekly(*args, **kwargs):
258
- return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
259
-
260
-
261
- def _build_alert_payload_five_hour(*args, **kwargs):
262
- return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
263
-
264
-
265
- def _build_alert_payload_budget(*args, **kwargs):
266
- return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
267
-
268
-
269
- def _build_alert_payload_project_budget(*args, **kwargs):
270
- return sys.modules["cctally"]._build_alert_payload_project_budget(*args, **kwargs)
271
-
272
-
273
- def _build_alert_payload_codex_budget(*args, **kwargs):
274
- return sys.modules["cctally"]._build_alert_payload_codex_budget(*args, **kwargs)
275
-
276
-
277
348
  def _budget_crossings(*args, **kwargs):
278
349
  return sys.modules["cctally"]._budget_crossings(*args, **kwargs)
279
350
 
@@ -334,10 +405,6 @@ def _projected_levels_already_latched(*args, **kwargs):
334
405
  return sys.modules["cctally"]._projected_levels_already_latched(*args, **kwargs)
335
406
 
336
407
 
337
- def _build_alert_payload_projected(*args, **kwargs):
338
- return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
339
-
340
-
341
408
  def _fetch_current_week_snapshots(*args, **kwargs):
342
409
  return sys.modules["cctally"]._fetch_current_week_snapshots(*args, **kwargs)
343
410
 
@@ -370,10 +437,6 @@ def _warn_budget_bad_config_once(*args, **kwargs):
370
437
  return sys.modules["cctally"]._warn_budget_bad_config_once(*args, **kwargs)
371
438
 
372
439
 
373
- def _get_oauth_usage_config(*args, **kwargs):
374
- return sys.modules["cctally"]._get_oauth_usage_config(*args, **kwargs)
375
-
376
-
377
440
  def _hook_tick_oauth_refresh(*args, **kwargs):
378
441
  """Shim for ``_hook_tick_oauth_refresh``.
379
442
 
@@ -400,10 +463,10 @@ def _hook_tick_make_mock_refresh(*args, **kwargs):
400
463
  # ``cmd_hook_tick`` for the three rewrites.
401
464
 
402
465
 
403
- # Constants referenced by the moved bodies. Defined here (rather than
404
- # `c.X`-routed) because they're pure literals no monkeypatch surface
405
- # and no dependency on cctally's module instance.
406
- _PERCENT_NORMALIZE_DECIMALS = 10
466
+ # ``_PERCENT_NORMALIZE_DECIMALS`` + ``_normalize_percent`` now live in
467
+ # ``bin/_lib_credit.py`` (#279 S4 F1); re-imported at module top so
468
+ # ``bin/cctally``'s ``_cctally_record._PERCENT_NORMALIZE_DECIMALS`` /
469
+ # ``_normalize_percent`` re-exports keep resolving unchanged.
407
470
 
408
471
  # Plausibility band for --resets-at / --five-hour-resets-at (issue #112).
409
472
  # Out-of-band epochs are guarded at cmd_record_usage ingress before any
@@ -459,27 +522,8 @@ _logged_window_key_coerce_failure = False
459
522
  # c._RESET_PCT_DROP_THRESHOLD — bin/_cctally_weekrefs.py constant (re-exported on cctally ns)
460
523
  # c._is_reset_drop — bin/_cctally_weekrefs.py helper (re-exported on cctally ns)
461
524
  # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
462
-
463
-
464
- def _normalize_percent(value: "float | int | None") -> "float | None":
465
- """Flush IEEE 754 ULP noise out of an ingress percent value.
466
-
467
- Single chokepoint applied at every site where a raw percent enters
468
- cctally's runtime path (OAuth fetch, hook-tick OAuth refresh, and
469
- the cmd_record_usage CLI ingress). Downstream consumers — HWM
470
- files, ``weekly_usage_snapshots.{weekly,five_hour}_percent`` REAL
471
- columns, ``five_hour_blocks.final_five_hour_percent``, milestone
472
- crossing values, and the SSE envelope's ``used_percent`` field —
473
- all read the cleaned value, so a single round here stops
474
- ``5h=7.000000000000001`` style strings from reaching any log or
475
- serialized surface.
476
-
477
- ``None`` is the canonical absent-percent sentinel; preserve it
478
- unchanged so the optional-5h branches stay simple.
479
- """
480
- if value is None:
481
- return None
482
- return round(float(value), _PERCENT_NORMALIZE_DECIMALS)
525
+ # (#279 S4 F5 collapsed only function-forwarding shims to honest imports;
526
+ # these call-time constant/helper accessors are untouched.)
483
527
 
484
528
 
485
529
  def _resolve_active_five_hour_reset_event_id(
@@ -1316,16 +1360,18 @@ def maybe_record_projected_alert(
1316
1360
  proj = _weekly_pct_week_avg_projection(conn, now_utc)
1317
1361
  if proj is not None and not proj[1]:
1318
1362
  value = proj[0]
1319
- for t in levels:
1320
- if value + 1e-9 >= t:
1321
- pending.append(dict(
1322
- week_start_at=week_key,
1323
- period="subscription-week",
1324
- metric="weekly_pct",
1325
- threshold=t,
1326
- projected_value=value,
1327
- denominator=100.0,
1328
- ))
1363
+ # weekly_pct comparand == raw threshold (denominator 100).
1364
+ for t in projected_crossings(
1365
+ value, [(t, float(t)) for t in levels]
1366
+ ):
1367
+ pending.append(dict(
1368
+ week_start_at=week_key,
1369
+ period="subscription-week",
1370
+ metric="weekly_pct",
1371
+ threshold=t,
1372
+ projected_value=value,
1373
+ denominator=100.0,
1374
+ ))
1329
1375
 
1330
1376
  # ── budget_usd leg (any Claude period — #135; shared factory) ────────
1331
1377
  if budget_on:
@@ -1359,16 +1405,19 @@ def maybe_record_projected_alert(
1359
1405
  status = compute_budget_status(inputs)
1360
1406
  if not status.low_confidence:
1361
1407
  value = status.week_avg_projection_usd
1362
- for t in thresholds:
1363
- if value + 1e-9 >= (t / 100.0) * float(target):
1364
- pending.append(dict(
1365
- week_start_at=b_week_key,
1366
- period=claude_period,
1367
- metric="budget_usd",
1368
- threshold=t,
1369
- projected_value=value,
1370
- denominator=float(target),
1371
- ))
1408
+ # budget comparand == (t/100)*target (glue pre-scales).
1409
+ for t in projected_crossings(
1410
+ value,
1411
+ [(t, (t / 100.0) * float(target)) for t in thresholds],
1412
+ ):
1413
+ pending.append(dict(
1414
+ week_start_at=b_week_key,
1415
+ period=claude_period,
1416
+ metric="budget_usd",
1417
+ threshold=t,
1418
+ projected_value=value,
1419
+ denominator=float(target),
1420
+ ))
1372
1421
 
1373
1422
  # ── codex_budget_usd leg (#135; skip_sync=False — R5) ────────────────
1374
1423
  if codex_on:
@@ -1405,16 +1454,19 @@ def maybe_record_projected_alert(
1405
1454
  c_status = compute_budget_status(c_inputs)
1406
1455
  if not c_status.low_confidence:
1407
1456
  value = c_status.week_avg_projection_usd
1408
- for t in c_thresholds:
1409
- if value + 1e-9 >= (t / 100.0) * float(c_target):
1410
- pending.append(dict(
1411
- week_start_at=c_week_key,
1412
- period=c_period,
1413
- metric="codex_budget_usd",
1414
- threshold=t,
1415
- projected_value=value,
1416
- denominator=float(c_target),
1417
- ))
1457
+ # codex comparand == (t/100)*target (glue pre-scales).
1458
+ for t in projected_crossings(
1459
+ value,
1460
+ [(t, (t / 100.0) * float(c_target)) for t in c_thresholds],
1461
+ ):
1462
+ pending.append(dict(
1463
+ week_start_at=c_week_key,
1464
+ period=c_period,
1465
+ metric="codex_budget_usd",
1466
+ threshold=t,
1467
+ projected_value=value,
1468
+ denominator=float(c_target),
1469
+ ))
1418
1470
 
1419
1471
  # ── arm (set-then-dispatch): INSERT + stamp alerted_at in one txn ────
1420
1472
  fired: list[dict[str, Any]] = []
@@ -1865,12 +1917,17 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
1865
1917
  ).fetchone()
1866
1918
  max_existing = row["m"] if row and row["m"] is not None else None
1867
1919
 
1868
- if max_existing is None:
1869
- start_threshold = current_floor # first observation: only current floor
1870
- else:
1871
- start_threshold = int(max_existing) + 1
1920
+ # Which integer 5h-% thresholds to attempt: the pure fencing
1921
+ # decision (floor snap + first-obs / resume-above-max rule).
1922
+ # `milestone_range.start` is the start_threshold used for the
1923
+ # marginal-cost check below; a non-empty range is exactly the
1924
+ # old `start_threshold <= current_floor` guard.
1925
+ milestone_range = five_hour_milestone_range(
1926
+ float(five_hour_percent), max_existing
1927
+ )
1928
+ start_threshold = milestone_range.start
1872
1929
 
1873
- if start_threshold <= current_floor:
1930
+ if milestone_range:
1874
1931
  # block_id was resolved above (before the children writes) and
1875
1932
  # is still in scope here.
1876
1933
 
@@ -1893,7 +1950,7 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
1893
1950
  if prev_row is not None:
1894
1951
  prior_cost = float(prev_row["block_cost_usd"])
1895
1952
 
1896
- for pct in range(start_threshold, current_floor + 1):
1953
+ for pct in milestone_range:
1897
1954
  if pct == start_threshold and prior_cost is not None:
1898
1955
  marginal: float | None = totals["cost_usd"] - prior_cost
1899
1956
  else:
@@ -2175,85 +2232,10 @@ def _read_reset_zero_marker():
2175
2232
  return (week_start_date, cur_end_canon, baseline_pct, first_zero_iso)
2176
2233
 
2177
2234
 
2178
- @dataclass(frozen=True)
2179
- class CreditPlan:
2180
- week_start_date: str
2181
- week_start_at: str
2182
- week_end_at: str
2183
- cur_end_canon: str
2184
- from_pct: float
2185
- from_source: str # "hwm" | "explicit" | "prior_credit"
2186
- to_pct: float
2187
- effective_iso: str # weekly_credit_floors.effective_at_utc (floored to hour)
2188
- captured_iso: str # synthetic snapshot captured_at_utc (un-floored), 'Z'
2189
-
2190
-
2191
- def _parse_credit_at(value, now):
2192
- """Parse --at as an aware UTC datetime; default to `now`. Naive => UTC
2193
- (mirrors bin/_cctally_five_hour.py:88), NOT parse_iso_datetime (host-local)."""
2194
- if value is None:
2195
- return now
2196
- try:
2197
- parsed = dt.datetime.fromisoformat(value)
2198
- except ValueError as e:
2199
- raise ValueError(f"--at: {e}") from e
2200
- if parsed.tzinfo is None:
2201
- parsed = parsed.replace(tzinfo=dt.timezone.utc)
2202
- return parsed.astimezone(dt.timezone.utc)
2203
-
2204
-
2205
- def _build_credit_plan(*, week_start_date, week_start_at, week_end_at,
2206
- from_pct, from_source, to_pct, at_dt, now,
2207
- effective_override=None):
2208
- """Validate inputs and build a CreditPlan. Pure (no DB/file I/O).
2209
- Raises ValueError(msg) on any violation — caller maps to exit 2.
2210
-
2211
- `effective_override` (an ISO string) is the completion-path reuse of an
2212
- EXISTING `weekly_credit_floors.effective_at_utc` (spec §4a): when present,
2213
- the plan's effective is that value rather than `floor_to_hour(at)`, so a
2214
- rerun of a half-applied credit at a later wall-clock keeps the original
2215
- floor moment and never leaks a stale pre-credit replay into the floored
2216
- MAX. The synthetic snapshot's captured timestamp stays the un-floored
2217
- `at` either way."""
2218
- to_pct = _normalize_percent(to_pct)
2219
- from_pct = _normalize_percent(from_pct)
2220
- # Defensive None-guard (issue #212 N3). `_normalize_percent` returns None
2221
- # for a None input. The CLI never reaches here with None (`--to` is
2222
- # required + type=float; `--from` always resolves to a float or the caller
2223
- # errors out first), but this is a public pure helper called directly by
2224
- # tests and any future non-CLI caller — surface a None as a clear ValueError
2225
- # (caller -> exit 2) instead of a TypeError from the `0.0 <= None` compare
2226
- # immediately below.
2227
- if to_pct is None or from_pct is None:
2228
- raise ValueError("--to/--from must be numeric")
2229
- if not (0.0 <= to_pct <= 100.0) or not (0.0 <= from_pct <= 100.0):
2230
- raise ValueError("--to/--from must be in [0, 100]")
2231
- if to_pct >= from_pct:
2232
- raise ValueError(f"not a credit: to >= from ({to_pct} >= {from_pct})")
2233
- ws = parse_iso_datetime(week_start_at, "week_start_at")
2234
- we = parse_iso_datetime(week_end_at, "week_end_at")
2235
- if not (ws <= at_dt < we):
2236
- raise ValueError(f"--at {at_dt.isoformat()} is outside the week window")
2237
- if at_dt > now:
2238
- raise ValueError("--at is in the future")
2239
- if effective_override is not None:
2240
- effective_iso = parse_iso_datetime(
2241
- effective_override, "effective_override"
2242
- ).astimezone(dt.timezone.utc).isoformat(timespec="seconds")
2243
- else:
2244
- effective_iso = _floor_to_hour(at_dt).isoformat(timespec="seconds")
2245
- cur_end_canon = _canonicalize_optional_iso(week_end_at, "record-credit.week_end")
2246
- return CreditPlan(
2247
- week_start_date=week_start_date,
2248
- week_start_at=week_start_at,
2249
- week_end_at=week_end_at,
2250
- cur_end_canon=cur_end_canon,
2251
- from_pct=from_pct,
2252
- from_source=from_source,
2253
- to_pct=to_pct,
2254
- effective_iso=effective_iso,
2255
- captured_iso=at_dt.isoformat(timespec="seconds").replace("+00:00", "Z"),
2256
- )
2235
+ # ``CreditPlan`` / ``_parse_credit_at`` / ``_build_credit_plan`` now live in
2236
+ # ``bin/_lib_credit.py`` (#279 S4 F1); re-imported at module top so the
2237
+ # ``bin/cctally`` re-exports and this module's own callers
2238
+ # (``cmd_record_credit``) resolve them unchanged.
2257
2239
 
2258
2240
 
2259
2241
  def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
@@ -2734,6 +2716,12 @@ def cmd_record_credit(args) -> int:
2734
2716
  f"{plan.from_pct:g}% -> {plan.to_pct:g}% "
2735
2717
  f"(effective {plan.effective_iso})")
2736
2718
  return 0
2719
+ except _cctally().StatsDbCorruptError:
2720
+ # #279 S1 F4: the global corrupt-DB contract (one-line diagnosis +
2721
+ # exit 2) wins over record-credit's documented exit-3 DB-error mapping.
2722
+ # StatsDbCorruptError subclasses sqlite3.DatabaseError, so without this
2723
+ # re-raise the handler below would swallow it and return 3.
2724
+ raise
2737
2725
  except sqlite3.DatabaseError as e:
2738
2726
  # Documented exit 3 (docs/commands/record-credit.md "3 — a database
2739
2727
  # error"). The inner ValueError->2 / EOFError paths return before
@@ -2768,9 +2756,11 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2768
2756
  # of silently reporting success on a dropped payload.
2769
2757
  now_dt = _command_as_of()
2770
2758
  now_epoch = int(now_dt.timestamp())
2771
- if not (now_epoch - _RECORD_USAGE_WEEK_PAST_SLACK_S
2772
- <= resets_at
2773
- <= now_epoch + _RECORD_USAGE_WEEK_FUTURE_BAND_S):
2759
+ if not check_resets_at_plausibility(
2760
+ resets_at, now_epoch,
2761
+ past_slack_s=_RECORD_USAGE_WEEK_PAST_SLACK_S,
2762
+ future_band_s=_RECORD_USAGE_WEEK_FUTURE_BAND_S,
2763
+ ):
2774
2764
  eprint(
2775
2765
  f"[record-usage] rejecting --resets-at={resets_at}: outside "
2776
2766
  f"plausibility band [now-30d, now+8d]; "
@@ -2799,9 +2789,11 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2799
2789
  # _compute_block_totals charges entries past the real
2800
2790
  # reset to this block). Dropping the 5h portion here
2801
2791
  # skips maybe_update_five_hour_block entirely.
2802
- if not (now_epoch - _RECORD_USAGE_5H_PAST_SLACK_S
2803
- <= five_hour_resets_at_epoch
2804
- <= now_epoch + _RECORD_USAGE_5H_FUTURE_BAND_S):
2792
+ if not check_resets_at_plausibility(
2793
+ five_hour_resets_at_epoch, now_epoch,
2794
+ past_slack_s=_RECORD_USAGE_5H_PAST_SLACK_S,
2795
+ future_band_s=_RECORD_USAGE_5H_FUTURE_BAND_S,
2796
+ ):
2805
2797
  eprint(
2806
2798
  f"[record-usage] dropping --five-hour-resets-at="
2807
2799
  f"{five_hour_resets_at_epoch}: outside plausibility band "
@@ -2980,14 +2972,28 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2980
2972
  # reachable. See the spec for the midpoint rationale.
2981
2973
  prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
2982
2974
  if prior_end_dt > now_utc and prior_pct is not None:
2983
- drop = float(prior_pct) - float(weekly_percent)
2984
- big_drop = drop >= c._RESET_PCT_DROP_THRESHOLD
2985
- zero_only = (
2986
- (not big_drop)
2987
- and float(weekly_percent) <= c._RESET_ZERO_FLOOR_PCT
2988
- and drop >= c._RESET_ZERO_MIN_DROP_PCT
2975
+ # Read the pending reset-to-zero marker up front (pure
2976
+ # file read) and compute whether it is armed for THIS
2977
+ # window; the debounce CLASSIFIER (pure) decides the
2978
+ # action from those values + the c._RESET_* constants,
2979
+ # then the glue below executes the decided I/O. The 5
2980
+ # branch outcomes (fire-immediate / confirm / clear /
2981
+ # arm / none) map 1:1 to the pre-extraction structure.
2982
+ marker = _read_reset_zero_marker()
2983
+ armed = (
2984
+ marker is not None
2985
+ and marker[0] == week_start_date
2986
+ and marker[1] == cur_end_canon
2989
2987
  )
2990
- if big_drop:
2988
+ decision = plan_weekly_credit_debounce(
2989
+ prior_pct, weekly_percent,
2990
+ drop_threshold=c._RESET_PCT_DROP_THRESHOLD,
2991
+ zero_floor_pct=c._RESET_ZERO_FLOOR_PCT,
2992
+ zero_min_drop_pct=c._RESET_ZERO_MIN_DROP_PCT,
2993
+ marker_armed=armed,
2994
+ marker_baseline=(marker[2] if armed else None),
2995
+ ).action
2996
+ if decision == FIRE_IMMEDIATE:
2991
2997
  # >=25pp goodwill credit — fire immediately, never
2992
2998
  # debounced. Clear any pending arm (now moot).
2993
2999
  _clear_reset_zero_marker()
@@ -2996,53 +3002,42 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2996
3002
  observed_pre_credit_pct=float(prior_pct),
2997
3003
  effective_dt=_floor_to_hour(now_utc),
2998
3004
  )
2999
- else:
3000
- marker = _read_reset_zero_marker()
3001
- armed = (
3002
- marker is not None
3003
- and marker[0] == week_start_date
3004
- and marker[1] == cur_end_canon
3005
+ elif decision == CONFIRM_RESET:
3006
+ # Second reading stayed low → confirm. Anchor the
3007
+ # reset at the FIRST-zero instant from the marker
3008
+ # (UTC-normalized like the backfill in-place path).
3009
+ first_zero_dt = parse_iso_datetime(
3010
+ marker[3], "reset_zero_marker.first_zero"
3011
+ ).astimezone(dt.timezone.utc)
3012
+ _fire_in_place_credit(
3013
+ conn, week_start_date, cur_end_canon,
3014
+ weekly_percent,
3015
+ observed_pre_credit_pct=marker[2],
3016
+ effective_dt=_floor_to_hour(first_zero_dt),
3005
3017
  )
3006
- if armed:
3007
- baseline_pct = marker[2]
3008
- if float(weekly_percent) <= baseline_pct / 2.0:
3009
- # Second reading stayed low → confirm. Anchor
3010
- # the reset at the FIRST-zero instant from the
3011
- # marker (UTC-normalized like the backfill
3012
- # in-place path).
3013
- first_zero_dt = parse_iso_datetime(
3014
- marker[3], "reset_zero_marker.first_zero"
3015
- ).astimezone(dt.timezone.utc)
3016
- _fire_in_place_credit(
3017
- conn, week_start_date, cur_end_canon,
3018
- weekly_percent,
3019
- observed_pre_credit_pct=baseline_pct,
3020
- effective_dt=_floor_to_hour(first_zero_dt),
3021
- )
3022
- # Clear ONLY after the fire completes (P2a):
3023
- # a mid-fire crash leaves the marker armed so
3024
- # the next zero re-confirms + re-runs the
3025
- # idempotent pivots.
3026
- _clear_reset_zero_marker()
3027
- else:
3028
- # Recovered toward baseline transient zero,
3029
- # not a reset. Clear, do not fire.
3030
- _clear_reset_zero_marker()
3031
- elif zero_only:
3032
- # First ~0 → arm; do NOT fire. The write clamp
3033
- # suppresses this 0 (no event row yet), so the
3034
- # prior snapshot stays at the baseline and this
3035
- # shape re-evaluates next tick. first_zero_iso is
3036
- # the _command_as_of() value (now_utc), NOT
3037
- # wall-clock — it becomes the effective anchor.
3038
- _arm_reset_zero_marker(
3039
- week_start_date, cur_end_canon,
3040
- baseline_pct=float(prior_pct),
3041
- first_zero_iso=now_utc.isoformat(timespec="seconds"),
3042
- )
3043
- # else: not a reset shape and not armed → nothing.
3044
- # A non-matching stale marker is inert (ignored
3045
- # on key mismatch, overwritten by the next arm).
3018
+ # Clear ONLY after the fire completes (P2a): a
3019
+ # mid-fire crash leaves the marker armed so the next
3020
+ # zero re-confirms + re-runs the idempotent pivots.
3021
+ _clear_reset_zero_marker()
3022
+ elif decision == CLEAR_MARKER:
3023
+ # Recovered toward baseline transient zero, not a
3024
+ # reset. Clear, do not fire.
3025
+ _clear_reset_zero_marker()
3026
+ elif decision == ARM_MARKER:
3027
+ # First ~0 → arm; do NOT fire. The write clamp
3028
+ # suppresses this 0 (no event row yet), so the prior
3029
+ # snapshot stays at the baseline and this shape
3030
+ # re-evaluates next tick. first_zero_iso is the
3031
+ # _command_as_of() value (now_utc), NOT wall-clock —
3032
+ # it becomes the effective anchor.
3033
+ _arm_reset_zero_marker(
3034
+ week_start_date, cur_end_canon,
3035
+ baseline_pct=float(prior_pct),
3036
+ first_zero_iso=now_utc.isoformat(timespec="seconds"),
3037
+ )
3038
+ # else NO_ACTION: not a reset shape and not armed →
3039
+ # nothing. A non-matching stale marker is inert
3040
+ # (ignored on key mismatch, overwritten by next arm).
3046
3041
 
3047
3042
  # ── 5h in-place credit detection (parallel to weekly above) ──
3048
3043
  # Spec §2.2 of
@@ -3098,10 +3093,10 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3098
3093
  # outer try block from
3099
3094
  # ``dt.datetime.now(dt.timezone.utc)``; reuse it
3100
3095
  # so both branches see the same instant.
3101
- if (
3102
- prior_5h_resets_dt > now_utc
3103
- and (prior_5h_pct - float(five_hour_percent))
3104
- >= c._FIVE_HOUR_RESET_PCT_DROP_THRESHOLD
3096
+ if plan_five_hour_credit(
3097
+ prior_5h_pct, float(five_hour_percent),
3098
+ drop_threshold=c._FIVE_HOUR_RESET_PCT_DROP_THRESHOLD,
3099
+ prior_resets_in_future=(prior_5h_resets_dt > now_utc),
3105
3100
  ):
3106
3101
  # Pair-check dedup pre-check (spec §2.2;
3107
3102
  # refined by Codex r4 P1 finding). The
@@ -3305,7 +3300,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3305
3300
  """,
3306
3301
  (week_start_date, clamp_floor_iso),
3307
3302
  ).fetchone()
3308
- if max_row and max_row["v"] is not None and round(weekly_percent, 1) < round(float(max_row["v"]), 1):
3303
+ if hwm_clamp_applies(weekly_percent, max_row["v"] if max_row else None):
3309
3304
  should_insert = False
3310
3305
  else:
3311
3306
  # 5-hour usage is monotonically non-decreasing within a window
@@ -3350,11 +3345,8 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3350
3345
  """,
3351
3346
  (int(five_hour_window_key), int(five_hour_window_key)),
3352
3347
  ).fetchone()
3353
- if (
3354
- max_5h_row
3355
- and max_5h_row["v"] is not None
3356
- and round(five_hour_percent, 1)
3357
- < round(float(max_5h_row["v"]), 1)
3348
+ if hwm_clamp_applies(
3349
+ five_hour_percent, max_5h_row["v"] if max_5h_row else None
3358
3350
  ):
3359
3351
  five_hour_percent = float(max_5h_row["v"])
3360
3352
 
@@ -3450,9 +3442,12 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3450
3442
  "WHERE week_start_date = ? AND reset_event_id = ?",
3451
3443
  (week_start_date, heal_segment),
3452
3444
  ).fetchone()
3453
- if max_existing is None or max_existing["m"] is None:
3454
- need_milestone_heal = True
3455
- elif int(max_existing["m"]) < latest_floor:
3445
+ existing_m = (
3446
+ int(max_existing["m"])
3447
+ if max_existing and max_existing["m"] is not None
3448
+ else None
3449
+ )
3450
+ if milestone_coverage_owes(existing_m, latest_floor):
3456
3451
  need_milestone_heal = True
3457
3452
 
3458
3453
  # Probe 2: do we owe a 5h-block update? Either no row
@@ -3515,14 +3510,14 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3515
3510
  " AND reset_event_id = ?",
3516
3511
  (int(window_key), heal_5h_segment),
3517
3512
  ).fetchone()
3518
- if (
3519
- max_5h_existing is None
3520
- or max_5h_existing["m"] is None
3521
- ):
3522
- need_5h_heal = True
3523
- elif (
3513
+ existing_5h_m = (
3524
3514
  int(max_5h_existing["m"])
3525
- < latest_5h_floor
3515
+ if max_5h_existing
3516
+ and max_5h_existing["m"] is not None
3517
+ else None
3518
+ )
3519
+ if milestone_coverage_owes(
3520
+ existing_5h_m, latest_5h_floor
3526
3521
  ):
3527
3522
  need_5h_heal = True
3528
3523
  finally:
@@ -3651,7 +3646,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3651
3646
  existing_hwm = float(parts[1])
3652
3647
  except (FileNotFoundError, ValueError, OSError):
3653
3648
  pass
3654
- if weekly_percent >= existing_hwm:
3649
+ if hwm_file_next(existing_hwm, weekly_percent) is not None:
3655
3650
  hwm_path.write_text(f"{week_start_date} {weekly_percent}\n")
3656
3651
  except OSError:
3657
3652
  pass
@@ -3674,7 +3669,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
3674
3669
  existing_hwm5 = float(parts5[1])
3675
3670
  except (FileNotFoundError, ValueError, OSError):
3676
3671
  pass
3677
- if five_hour_percent >= existing_hwm5:
3672
+ if hwm_file_next(existing_hwm5, five_hour_percent) is not None:
3678
3673
  hwm5_path.write_text(f"{five_resets_key} {five_hour_percent}\n")
3679
3674
  except OSError:
3680
3675
  pass
@@ -3777,12 +3772,14 @@ def _hook_tick_session_short(sid: str) -> str:
3777
3772
 
3778
3773
 
3779
3774
  def _hook_tick_format_log_line(
3780
- event: str, session: str, ingested: int, oauth_status: str, dur_ms: int
3775
+ event: str, session: str, ingested: int, oauth_status: str, dur_ms: int,
3776
+ *, malformed: int = 0, skipped: int = 0,
3781
3777
  ) -> str:
3782
3778
  ts = now_utc_iso()
3783
3779
  return (
3784
3780
  f"{ts} event={event:14s} session={session} "
3785
- f"ingested={ingested} oauth={oauth_status} dur_ms={dur_ms}"
3781
+ f"ingested={ingested} malformed={malformed} skipped={skipped} "
3782
+ f"oauth={oauth_status} dur_ms={dur_ms}"
3786
3783
  )
3787
3784
 
3788
3785
 
@@ -3880,6 +3877,10 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
3880
3877
  # --- Steps 3-7: wrap remainder in try/except (always exit 0 in normal mode) ---
3881
3878
  start = time.monotonic()
3882
3879
  ingested = 0
3880
+ # #279 S2 F1: parse-health counters from the local sync, surfaced on the
3881
+ # hook-tick log line (uniform fields — always emitted, defaulted to 0).
3882
+ parse_malformed = 0
3883
+ parse_skipped = 0
3883
3884
  oauth_status = "skipped-no-oauth" if no_oauth else "throttled(age=?s)"
3884
3885
  # Pre-fetch throttle state captured for --explain output. The OAuth
3885
3886
  # block re-touches the throttle marker after a successful fetch, so
@@ -3898,6 +3899,8 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
3898
3899
  try:
3899
3900
  stats = sync_cache(cache_conn)
3900
3901
  ingested = int(stats.rows_changed)
3902
+ parse_malformed = int(stats.lines_malformed)
3903
+ parse_skipped = int(stats.assistant_lines_skipped)
3901
3904
  finally:
3902
3905
  try:
3903
3906
  cache_conn.close()
@@ -3958,6 +3961,8 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
3958
3961
  ingested=ingested,
3959
3962
  oauth_status=oauth_status,
3960
3963
  dur_ms=dur_ms,
3964
+ malformed=parse_malformed,
3965
+ skipped=parse_skipped,
3961
3966
  )
3962
3967
  _hook_tick_log_line(line)
3963
3968
  _hook_tick_log_rotate_if_needed()