cctally 1.65.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.
- package/CHANGELOG.md +33 -0
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +49 -14
- package/bin/_cctally_dashboard.py +481 -4891
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +771 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +127 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +2 -1
- package/bin/_cctally_forecast.py +78 -135
- package/bin/_cctally_parser.py +919 -784
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +8 -5
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +34 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +18 -10
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/cctally +51 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-tui +2 -2
- package/package.json +10 -1
|
@@ -111,7 +111,7 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
if args.json:
|
|
114
|
-
print(json.dumps(payload, indent=2))
|
|
114
|
+
print(json.dumps(c.stamp_schema_version(payload), indent=2))
|
|
115
115
|
elif not args.quiet:
|
|
116
116
|
print(
|
|
117
117
|
f"Synced week {payload['weekStartDate']}..{payload['weekEndDate']} "
|
|
@@ -47,10 +47,8 @@ def _core():
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def _truthy_env(name: str) -> bool:
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
v = os.environ.get(name)
|
|
53
|
-
return v is not None and v.strip().lower() not in ("", "0", "false", "no")
|
|
50
|
+
"""Canonical implementation lives in ``_cctally_core`` (#279 S1 F1)."""
|
|
51
|
+
return _core()._truthy_env(name)
|
|
54
52
|
|
|
55
53
|
|
|
56
54
|
def resolve_telemetry_state(config: dict) -> tuple[bool, str]:
|
|
@@ -347,7 +345,7 @@ def cmd_telemetry(args) -> int:
|
|
|
347
345
|
"fields": ["token", "version", "os"],
|
|
348
346
|
}
|
|
349
347
|
if getattr(args, "json", False):
|
|
350
|
-
print(json.dumps(info))
|
|
348
|
+
print(json.dumps(c.stamp_schema_version(info)))
|
|
351
349
|
return 0
|
|
352
350
|
print(f"telemetry: {'enabled' if enabled else 'disabled'} ({reason})")
|
|
353
351
|
print(
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -228,6 +228,37 @@ from _lib_fmt import stable_sum
|
|
|
228
228
|
# nothing on the default path.
|
|
229
229
|
import _lib_perf as _perf
|
|
230
230
|
|
|
231
|
+
import importlib.util as _ilu
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _ensure_sibling_loaded(name: str) -> None:
|
|
235
|
+
"""Register a NON-eager-loaded ``_lib_*`` sibling in ``sys.modules``.
|
|
236
|
+
|
|
237
|
+
``_lib_forecast`` (#279 S4 F2) is a NEW consumer-only sibling — kept out
|
|
238
|
+
of ``bin/cctally``'s eager-load block so ``bin/cctally`` stays
|
|
239
|
+
byte-untouched (spec §2). Under the ``SourceFileLoader`` harness path
|
|
240
|
+
(``bin/`` absent from ``sys.path``) a bare ``from _lib_forecast import``
|
|
241
|
+
would miss, so this pre-registers the sibling ``__file__``-relative when
|
|
242
|
+
it is not already importable (mirrors ``_cctally_cache._load_lib``). The
|
|
243
|
+
honest import that follows is a ``sys.modules`` hit in every load context.
|
|
244
|
+
"""
|
|
245
|
+
if name in sys.modules:
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
__import__(name) # bin/ on sys.path: prod script / conftest / pytest
|
|
249
|
+
return
|
|
250
|
+
except ModuleNotFoundError:
|
|
251
|
+
pass
|
|
252
|
+
_p = os.path.join(os.path.dirname(__file__), f"{name}.py")
|
|
253
|
+
_spec = _ilu.spec_from_file_location(name, _p)
|
|
254
|
+
_mod = _ilu.module_from_spec(_spec)
|
|
255
|
+
sys.modules[name] = _mod
|
|
256
|
+
_spec.loader.exec_module(_mod)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_ensure_sibling_loaded("_lib_forecast")
|
|
260
|
+
from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, BudgetRow
|
|
261
|
+
|
|
231
262
|
|
|
232
263
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
233
264
|
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
@@ -257,10 +288,6 @@ def _apply_midweek_reset_override(*args, **kwargs):
|
|
|
257
288
|
return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
|
|
258
289
|
|
|
259
290
|
|
|
260
|
-
def _compute_forecast(*args, **kwargs):
|
|
261
|
-
return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
|
|
262
|
-
|
|
263
|
-
|
|
264
291
|
def _resolve_forecast_now(*args, **kwargs):
|
|
265
292
|
return sys.modules["cctally"]._resolve_forecast_now(*args, **kwargs)
|
|
266
293
|
|
|
@@ -313,20 +340,11 @@ def sync_cache(*args, **kwargs):
|
|
|
313
340
|
return sys.modules["cctally"].sync_cache(*args, **kwargs)
|
|
314
341
|
|
|
315
342
|
|
|
316
|
-
#
|
|
317
|
-
# ``
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
|
|
321
|
-
return sys.modules["cctally"].ForecastInputs(*args, **kwargs)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def ForecastOutput(*args, **kwargs):
|
|
325
|
-
return sys.modules["cctally"].ForecastOutput(*args, **kwargs)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def BudgetRow(*args, **kwargs):
|
|
329
|
-
return sys.modules["cctally"].BudgetRow(*args, **kwargs)
|
|
343
|
+
# ``_compute_forecast`` + the forecast dataclasses (``ForecastInputs`` /
|
|
344
|
+
# ``ForecastOutput`` / ``BudgetRow``) now live in ``bin/_lib_forecast.py``
|
|
345
|
+
# (#279 S4 F2) and are honest-imported at module top — used as bare-name
|
|
346
|
+
# constructors inside ``_tui_build_forecast``. Single class def per name in
|
|
347
|
+
# ``_lib_forecast``; everything imports it, so class identity stays unique.
|
|
330
348
|
|
|
331
349
|
|
|
332
350
|
# Dashboard back-refs consumed by the TUI's snapshot builders.
|
|
@@ -5280,6 +5298,12 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
5280
5298
|
)
|
|
5281
5299
|
|
|
5282
5300
|
def _locked(skip_sync: bool) -> None:
|
|
5301
|
+
# #279 S5 F6.3 (gate P1-1): arm the snapshot-cache owner-thread tripwire
|
|
5302
|
+
# for whichever thread holds sync_lock for THIS rebuild — the periodic
|
|
5303
|
+
# sync thread, or a /api/sync / /api/settings request thread. Overwrite-
|
|
5304
|
+
# on-call, so ownership transfers to the current rebuilder; the guards in
|
|
5305
|
+
# _lib_snapshot_cache then catch a lock-bypassing foreign-thread mutation.
|
|
5306
|
+
_cctally()._load_sibling("_lib_snapshot_cache").mark_owner_thread()
|
|
5283
5307
|
try:
|
|
5284
5308
|
if not skip_sync:
|
|
5285
5309
|
# ── Decoupled ingest + build (§2.1) ─────────────────────────
|
|
@@ -18,7 +18,6 @@ Spec: docs/superpowers/specs/2026-06-02-alerts-dispatch-severity-seams-design.md
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import importlib.util as _ilu
|
|
22
21
|
import pathlib
|
|
23
22
|
import sys
|
|
24
23
|
|
|
@@ -36,6 +35,7 @@ def _load_lib(name: str):
|
|
|
36
35
|
cached = sys.modules.get(name)
|
|
37
36
|
if cached is not None:
|
|
38
37
|
return cached
|
|
38
|
+
import importlib.util as _ilu
|
|
39
39
|
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
40
40
|
spec = _ilu.spec_from_file_location(name, p)
|
|
41
41
|
mod = _ilu.module_from_spec(spec)
|
package/bin/_lib_blocks.py
CHANGED
|
@@ -61,6 +61,11 @@ UsageEntry = _lib_jsonl.UsageEntry
|
|
|
61
61
|
_lib_pricing = _load_lib("_lib_pricing")
|
|
62
62
|
_calculate_entry_cost = _lib_pricing._calculate_entry_cost
|
|
63
63
|
|
|
64
|
+
# JSON wire-format kernel — the additive camelCase schemaVersion stamp
|
|
65
|
+
# (#279 S6 W1; convention docs/cli-contract.md).
|
|
66
|
+
_lib_json_envelope = _load_lib("_lib_json_envelope")
|
|
67
|
+
stamp_schema_version = _lib_json_envelope.stamp_schema_version
|
|
68
|
+
|
|
64
69
|
|
|
65
70
|
@dataclass
|
|
66
71
|
class Block:
|
|
@@ -526,4 +531,4 @@ def _blocks_to_json(
|
|
|
526
531
|
}
|
|
527
532
|
result.append(obj)
|
|
528
533
|
|
|
529
|
-
return json.dumps({"blocks": result}, indent=2)
|
|
534
|
+
return json.dumps(stamp_schema_version({"blocks": result}), indent=2)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Credit-plan decision kernel for cctally's record-credit path.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): holds the percent-ingress
|
|
4
|
+
sanitizer and the `record-credit` plan builder — values in, a frozen
|
|
5
|
+
`CreditPlan` (or a `ValueError`) out. Every DB read, file write, and
|
|
6
|
+
`eprint` for the in-place-credit machinery stays in the I/O sibling
|
|
7
|
+
`bin/_cctally_record.py`; this module is imported by that sibling (and
|
|
8
|
+
re-exported on the `cctally` namespace via `bin/cctally`), so the
|
|
9
|
+
existing `ns["_build_credit_plan"]` / `ns["_normalize_percent"]` /
|
|
10
|
+
`cctally.CreditPlan` read paths resolve unchanged.
|
|
11
|
+
|
|
12
|
+
`_normalize_percent` is the single chokepoint that flushes IEEE 754 ULP
|
|
13
|
+
noise out of ingress percents; `_build_credit_plan` validates the
|
|
14
|
+
record-credit inputs and floors the effective moment to the hour. Both
|
|
15
|
+
are pure and stdlib-only apart from two kernel imports
|
|
16
|
+
(`parse_iso_datetime`, `_canonicalize_optional_iso` from `_cctally_core`)
|
|
17
|
+
and the pure hour-floor from `_lib_blocks` — no cross-sibling I/O and no
|
|
18
|
+
`cctally` back-import.
|
|
19
|
+
|
|
20
|
+
Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import datetime as dt
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from _cctally_core import parse_iso_datetime, _canonicalize_optional_iso
|
|
28
|
+
from _lib_blocks import _floor_to_hour
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_PERCENT_NORMALIZE_DECIMALS = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_percent(value: "float | int | None") -> "float | None":
|
|
35
|
+
"""Flush IEEE 754 ULP noise out of an ingress percent value.
|
|
36
|
+
|
|
37
|
+
Single chokepoint applied at every site where a raw percent enters
|
|
38
|
+
cctally's runtime path (OAuth fetch, hook-tick OAuth refresh, and
|
|
39
|
+
the cmd_record_usage CLI ingress). Downstream consumers — HWM
|
|
40
|
+
files, ``weekly_usage_snapshots.{weekly,five_hour}_percent`` REAL
|
|
41
|
+
columns, ``five_hour_blocks.final_five_hour_percent``, milestone
|
|
42
|
+
crossing values, and the SSE envelope's ``used_percent`` field —
|
|
43
|
+
all read the cleaned value, so a single round here stops
|
|
44
|
+
``5h=7.000000000000001`` style strings from reaching any log or
|
|
45
|
+
serialized surface.
|
|
46
|
+
|
|
47
|
+
``None`` is the canonical absent-percent sentinel; preserve it
|
|
48
|
+
unchanged so the optional-5h branches stay simple.
|
|
49
|
+
"""
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
return round(float(value), _PERCENT_NORMALIZE_DECIMALS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CreditPlan:
|
|
57
|
+
week_start_date: str
|
|
58
|
+
week_start_at: str
|
|
59
|
+
week_end_at: str
|
|
60
|
+
cur_end_canon: str
|
|
61
|
+
from_pct: float
|
|
62
|
+
from_source: str # "hwm" | "explicit" | "prior_credit"
|
|
63
|
+
to_pct: float
|
|
64
|
+
effective_iso: str # weekly_credit_floors.effective_at_utc (floored to hour)
|
|
65
|
+
captured_iso: str # synthetic snapshot captured_at_utc (un-floored), 'Z'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_credit_at(value, now):
|
|
69
|
+
"""Parse --at as an aware UTC datetime; default to `now`. Naive => UTC
|
|
70
|
+
(mirrors bin/_cctally_five_hour.py:88), NOT parse_iso_datetime (host-local)."""
|
|
71
|
+
if value is None:
|
|
72
|
+
return now
|
|
73
|
+
try:
|
|
74
|
+
parsed = dt.datetime.fromisoformat(value)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
raise ValueError(f"--at: {e}") from e
|
|
77
|
+
if parsed.tzinfo is None:
|
|
78
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
79
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_credit_plan(*, week_start_date, week_start_at, week_end_at,
|
|
83
|
+
from_pct, from_source, to_pct, at_dt, now,
|
|
84
|
+
effective_override=None):
|
|
85
|
+
"""Validate inputs and build a CreditPlan. Pure (no DB/file I/O).
|
|
86
|
+
Raises ValueError(msg) on any violation — caller maps to exit 2.
|
|
87
|
+
|
|
88
|
+
`effective_override` (an ISO string) is the completion-path reuse of an
|
|
89
|
+
EXISTING `weekly_credit_floors.effective_at_utc` (spec §4a): when present,
|
|
90
|
+
the plan's effective is that value rather than `floor_to_hour(at)`, so a
|
|
91
|
+
rerun of a half-applied credit at a later wall-clock keeps the original
|
|
92
|
+
floor moment and never leaks a stale pre-credit replay into the floored
|
|
93
|
+
MAX. The synthetic snapshot's captured timestamp stays the un-floored
|
|
94
|
+
`at` either way."""
|
|
95
|
+
to_pct = _normalize_percent(to_pct)
|
|
96
|
+
from_pct = _normalize_percent(from_pct)
|
|
97
|
+
# Defensive None-guard (issue #212 N3). `_normalize_percent` returns None
|
|
98
|
+
# for a None input. The CLI never reaches here with None (`--to` is
|
|
99
|
+
# required + type=float; `--from` always resolves to a float or the caller
|
|
100
|
+
# errors out first), but this is a public pure helper called directly by
|
|
101
|
+
# tests and any future non-CLI caller — surface a None as a clear ValueError
|
|
102
|
+
# (caller -> exit 2) instead of a TypeError from the `0.0 <= None` compare
|
|
103
|
+
# immediately below.
|
|
104
|
+
if to_pct is None or from_pct is None:
|
|
105
|
+
raise ValueError("--to/--from must be numeric")
|
|
106
|
+
if not (0.0 <= to_pct <= 100.0) or not (0.0 <= from_pct <= 100.0):
|
|
107
|
+
raise ValueError("--to/--from must be in [0, 100]")
|
|
108
|
+
if to_pct >= from_pct:
|
|
109
|
+
raise ValueError(f"not a credit: to >= from ({to_pct} >= {from_pct})")
|
|
110
|
+
ws = parse_iso_datetime(week_start_at, "week_start_at")
|
|
111
|
+
we = parse_iso_datetime(week_end_at, "week_end_at")
|
|
112
|
+
if not (ws <= at_dt < we):
|
|
113
|
+
raise ValueError(f"--at {at_dt.isoformat()} is outside the week window")
|
|
114
|
+
if at_dt > now:
|
|
115
|
+
raise ValueError("--at is in the future")
|
|
116
|
+
if effective_override is not None:
|
|
117
|
+
effective_iso = parse_iso_datetime(
|
|
118
|
+
effective_override, "effective_override"
|
|
119
|
+
).astimezone(dt.timezone.utc).isoformat(timespec="seconds")
|
|
120
|
+
else:
|
|
121
|
+
effective_iso = _floor_to_hour(at_dt).isoformat(timespec="seconds")
|
|
122
|
+
cur_end_canon = _canonicalize_optional_iso(week_end_at, "record-credit.week_end")
|
|
123
|
+
return CreditPlan(
|
|
124
|
+
week_start_date=week_start_date,
|
|
125
|
+
week_start_at=week_start_at,
|
|
126
|
+
week_end_at=week_end_at,
|
|
127
|
+
cur_end_canon=cur_end_canon,
|
|
128
|
+
from_pct=from_pct,
|
|
129
|
+
from_source=from_source,
|
|
130
|
+
to_pct=to_pct,
|
|
131
|
+
effective_iso=effective_iso,
|
|
132
|
+
captured_iso=at_dt.isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
133
|
+
)
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -177,6 +177,23 @@ class DoctorState:
|
|
|
177
177
|
# valid and default to the enabled (opt-out) posture.
|
|
178
178
|
telemetry_enabled: bool = True
|
|
179
179
|
telemetry_reason: str = "enabled"
|
|
180
|
+
# #279 S2 (F5a): rolling ingest parse-health records read from
|
|
181
|
+
# cache_meta keys parse_health_claude / parse_health_codex (JSON
|
|
182
|
+
# dicts; see _cctally_cache._update_parse_health_meta). None = key
|
|
183
|
+
# absent (pre-first-sync) or cache unreadable — check degrades OK.
|
|
184
|
+
parse_health_claude: Optional[dict] = None
|
|
185
|
+
parse_health_codex: Optional[dict] = None
|
|
186
|
+
# #279 S2 (F5b): PRAGMA quick_check(1) results, gathered ONLY under
|
|
187
|
+
# doctor_gather_state(deep=True) (CLI cmd_doctor) — the dashboard
|
|
188
|
+
# rebuild loop calls the gather every rebuild and quick_check on a
|
|
189
|
+
# large cache.db costs seconds. "ok" | first error line |
|
|
190
|
+
# "open failed: ..." | None = not run.
|
|
191
|
+
stats_db_quick_check: Optional[str] = None
|
|
192
|
+
cache_db_quick_check: Optional[str] = None
|
|
193
|
+
# #279 S2 (F5c): non-blocking flock probes on the two sync lock files
|
|
194
|
+
# (name -> True held / False free / None unreadable). Probe never
|
|
195
|
+
# creates files (doctor read-only contract). None = probe errored.
|
|
196
|
+
locks_held: Optional[dict] = None
|
|
180
197
|
|
|
181
198
|
|
|
182
199
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -1207,6 +1224,122 @@ def _check_telemetry(s: DoctorState) -> CheckResult:
|
|
|
1207
1224
|
)
|
|
1208
1225
|
|
|
1209
1226
|
|
|
1227
|
+
_PARSE_HEALTH_RECENCY_DAYS = 7
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _check_data_parse_health(s: DoctorState) -> CheckResult:
|
|
1231
|
+
details = {"claude": s.parse_health_claude, "codex": s.parse_health_codex}
|
|
1232
|
+
recent, historical_total = [], 0
|
|
1233
|
+
for label, ph in (("claude", s.parse_health_claude),
|
|
1234
|
+
("codex", s.parse_health_codex)):
|
|
1235
|
+
if not isinstance(ph, dict):
|
|
1236
|
+
continue
|
|
1237
|
+
malformed = int(ph.get("lines_malformed", 0) or 0)
|
|
1238
|
+
skipped = int(ph.get("lines_skipped", 0) or 0)
|
|
1239
|
+
historical_total += malformed + skipped
|
|
1240
|
+
last_anomaly = ph.get("last_anomaly_at")
|
|
1241
|
+
if not last_anomaly or (malformed + skipped) == 0:
|
|
1242
|
+
continue
|
|
1243
|
+
try:
|
|
1244
|
+
at = dt.datetime.fromisoformat(
|
|
1245
|
+
str(last_anomaly).replace("Z", "+00:00"))
|
|
1246
|
+
if at.tzinfo is None:
|
|
1247
|
+
at = at.replace(tzinfo=dt.timezone.utc)
|
|
1248
|
+
except ValueError:
|
|
1249
|
+
continue
|
|
1250
|
+
if (s.now_utc - at).total_seconds() <= \
|
|
1251
|
+
_PARSE_HEALTH_RECENCY_DAYS * 86400.0:
|
|
1252
|
+
reasons = ph.get("reasons") \
|
|
1253
|
+
if isinstance(ph.get("reasons"), dict) else {}
|
|
1254
|
+
top = max(reasons.items(), key=lambda kv: kv[1])[0] \
|
|
1255
|
+
if reasons else None
|
|
1256
|
+
recent.append((label, malformed, skipped, top))
|
|
1257
|
+
if recent:
|
|
1258
|
+
parts = []
|
|
1259
|
+
for label, malformed, skipped, top in recent:
|
|
1260
|
+
frag = f"{label}: {malformed} malformed / {skipped} drift-skipped"
|
|
1261
|
+
if top:
|
|
1262
|
+
frag += f" (top reason: {top})"
|
|
1263
|
+
parts.append(frag)
|
|
1264
|
+
return CheckResult(
|
|
1265
|
+
id="data.parse_health", title="Ingest parse health",
|
|
1266
|
+
severity="warn",
|
|
1267
|
+
summary="; ".join(parts)
|
|
1268
|
+
+ f" within {_PARSE_HEALTH_RECENCY_DAYS}d",
|
|
1269
|
+
remediation=(
|
|
1270
|
+
"JSONL lines are failing to parse — a Claude Code / Codex "
|
|
1271
|
+
"update may have changed the session format. Check for a "
|
|
1272
|
+
"cctally update or file an issue; `cctally cache-sync "
|
|
1273
|
+
"--rebuild` re-baselines the counters."),
|
|
1274
|
+
details=details,
|
|
1275
|
+
)
|
|
1276
|
+
if s.parse_health_claude is None and s.parse_health_codex is None:
|
|
1277
|
+
summary = "no parse-health data yet (pre-first-sync)"
|
|
1278
|
+
elif historical_total:
|
|
1279
|
+
summary = (f"no recent anomalies ({historical_total} historical; "
|
|
1280
|
+
f"see details)")
|
|
1281
|
+
else:
|
|
1282
|
+
summary = "no parse anomalies"
|
|
1283
|
+
return CheckResult(
|
|
1284
|
+
id="data.parse_health", title="Ingest parse health",
|
|
1285
|
+
severity="ok", summary=summary, remediation=None, details=details,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _check_db_integrity(s: DoctorState) -> CheckResult:
|
|
1290
|
+
details = {"stats_quick_check": s.stats_db_quick_check,
|
|
1291
|
+
"cache_quick_check": s.cache_db_quick_check}
|
|
1292
|
+
if s.stats_db_quick_check is not None and s.stats_db_quick_check != "ok":
|
|
1293
|
+
return CheckResult(
|
|
1294
|
+
id="db.integrity", title="Integrity", severity="fail",
|
|
1295
|
+
summary=f"stats.db quick_check: {s.stats_db_quick_check}",
|
|
1296
|
+
remediation=(
|
|
1297
|
+
"stats.db (the non-re-derivable DB) reports corruption. "
|
|
1298
|
+
"Back up the file FIRST, then try "
|
|
1299
|
+
"`sqlite3 <path> \".recover\"` into a fresh file; "
|
|
1300
|
+
"`cctally db recover` does not repair corruption. Please "
|
|
1301
|
+
"file a bug. Do not delete the file."),
|
|
1302
|
+
details=details,
|
|
1303
|
+
)
|
|
1304
|
+
if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
|
|
1305
|
+
return CheckResult(
|
|
1306
|
+
id="db.integrity", title="Integrity", severity="warn",
|
|
1307
|
+
summary=f"cache.db quick_check: {s.cache_db_quick_check}",
|
|
1308
|
+
remediation=("cache.db is re-derivable — run "
|
|
1309
|
+
"`cctally cache-sync --rebuild`."),
|
|
1310
|
+
details=details,
|
|
1311
|
+
)
|
|
1312
|
+
if s.stats_db_quick_check is None and s.cache_db_quick_check is None:
|
|
1313
|
+
return CheckResult(
|
|
1314
|
+
id="db.integrity", title="Integrity", severity="ok",
|
|
1315
|
+
summary="not checked (fast gather — run `cctally doctor`)",
|
|
1316
|
+
remediation=None, details=details,
|
|
1317
|
+
)
|
|
1318
|
+
return CheckResult(
|
|
1319
|
+
id="db.integrity", title="Integrity", severity="ok",
|
|
1320
|
+
summary="quick_check ok", remediation=None, details=details,
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _check_db_lock_state(s: DoctorState) -> CheckResult:
|
|
1325
|
+
locks = s.locks_held if isinstance(s.locks_held, dict) else {}
|
|
1326
|
+
held = sorted(k for k, v in locks.items() if v is True)
|
|
1327
|
+
details = {"locks": s.locks_held}
|
|
1328
|
+
if held:
|
|
1329
|
+
return CheckResult(
|
|
1330
|
+
id="db.lock_state", title="Locks", severity="ok",
|
|
1331
|
+
summary=(", ".join(held) + " held — an active sync or "
|
|
1332
|
+
"dashboard is running; a hold persisting across "
|
|
1333
|
+
"repeated doctor runs may indicate a wedged process"),
|
|
1334
|
+
remediation=None, details=details,
|
|
1335
|
+
)
|
|
1336
|
+
return CheckResult(
|
|
1337
|
+
id="db.lock_state", title="Locks", severity="ok",
|
|
1338
|
+
summary="free" if locks else "no lock files present",
|
|
1339
|
+
remediation=None, details=details,
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
|
|
1210
1343
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
1211
1344
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
1212
1345
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -1232,14 +1365,17 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1232
1365
|
("db", "Database", (
|
|
1233
1366
|
("db.stats.file", "_check_db_stats_file"),
|
|
1234
1367
|
("db.cache.file", "_check_db_cache_file"),
|
|
1368
|
+
("db.integrity", "_check_db_integrity"),
|
|
1235
1369
|
("db.version_ahead", "_check_db_version_ahead"),
|
|
1236
1370
|
("db.migrations.applied", "_check_db_migrations_applied"),
|
|
1237
1371
|
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
1372
|
+
("db.lock_state", "_check_db_lock_state"),
|
|
1238
1373
|
)),
|
|
1239
1374
|
("data", "Data", (
|
|
1240
1375
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
1241
1376
|
("data.cache_sync_state", "_check_data_cache_sync_state"),
|
|
1242
1377
|
("data.codex_cache", "_check_data_codex_cache"),
|
|
1378
|
+
("data.parse_health", "_check_data_parse_health"),
|
|
1243
1379
|
("data.forked_buckets", "_check_data_forked_buckets"),
|
|
1244
1380
|
("data.post_credit_milestones", "_check_data_post_credit_milestones"),
|
|
1245
1381
|
("data.conversation_sessions_rollup",
|
|
@@ -1320,7 +1456,20 @@ def run_checks(state: DoctorState) -> DoctorReport:
|
|
|
1320
1456
|
|
|
1321
1457
|
|
|
1322
1458
|
def _iso_z(d: dt.datetime) -> str:
|
|
1323
|
-
"""Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention).
|
|
1459
|
+
"""Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention).
|
|
1460
|
+
|
|
1461
|
+
DELIBERATELY DIVERGES from the canonical ``_lib_json_envelope._iso_z``
|
|
1462
|
+
(#279 S6 W4) on two axes and therefore KEEPS this local name (gate F11):
|
|
1463
|
+
(1) a NAIVE datetime is treated as UTC via ``replace(tzinfo=utc)`` — the
|
|
1464
|
+
canonical's ``astimezone(utc)`` would reinterpret it as host-local; and
|
|
1465
|
+
(2) microseconds are preserved via ``isoformat()`` — the canonical floors
|
|
1466
|
+
to whole seconds via ``%S``. Renaming this to the canonical would silently
|
|
1467
|
+
degrade the doctor block for the cross-module consumers that resolve it by
|
|
1468
|
+
name through the sibling loader (``_cctally_tui`` and
|
|
1469
|
+
``_cctally_dashboard_envelope``'s doctor-envelope builders, whose
|
|
1470
|
+
try/except swallows a rename). Divergence pinned by
|
|
1471
|
+
``tests/test_lib_doctor.py::test_doctor_iso_z_naive_means_utc_and_keeps_microseconds``.
|
|
1472
|
+
"""
|
|
1324
1473
|
if d.tzinfo is None:
|
|
1325
1474
|
d = d.replace(tzinfo=dt.timezone.utc)
|
|
1326
1475
|
s = d.astimezone(dt.timezone.utc).isoformat()
|
package/bin/_lib_five_hour.py
CHANGED
|
@@ -23,11 +23,45 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import datetime as dt
|
|
26
|
+
import math
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
_FIVE_HOUR_JITTER_FLOOR_SECONDS = 600 # 10 minutes; tolerance band for resets_at jitter
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def five_hour_milestone_range(pct: float, max_existing: "int | None") -> range:
|
|
33
|
+
"""Which integer 5h-% thresholds ``maybe_update_five_hour_block`` should
|
|
34
|
+
attempt to record for ``pct``, given the ACTIVE segment's highest already-
|
|
35
|
+
recorded threshold ``max_existing`` (#279 S4 F4).
|
|
36
|
+
|
|
37
|
+
Mirrors the milestone-detection loop's fencing exactly (glue call site:
|
|
38
|
+
``maybe_update_five_hour_block``'s 5h-% milestone loop):
|
|
39
|
+
|
|
40
|
+
- ``current_floor = math.floor(pct + 1e-9)`` — the 1e-9 snap flushes the
|
|
41
|
+
``0.50 * 100 == 49.99…`` ULP so the N threshold is not missed.
|
|
42
|
+
- ``current_floor < 1`` → empty range (the ``if current_floor >= 1``
|
|
43
|
+
glue guard).
|
|
44
|
+
- ``start_threshold = current_floor`` when ``max_existing is None``
|
|
45
|
+
(first observation records ONLY the current floor — no synthetic
|
|
46
|
+
1..floor-1 backfill), else ``int(max_existing) + 1``.
|
|
47
|
+
- result = ``range(start_threshold, current_floor + 1)`` — empty when
|
|
48
|
+
``start_threshold > current_floor`` (already at/above the floor),
|
|
49
|
+
which the glue reads as ``if milestone_range:``.
|
|
50
|
+
|
|
51
|
+
Pure: the MAX(percent_threshold) probe that yields ``max_existing``, the
|
|
52
|
+
marginal-cost lookup, the per-threshold INSERT, and the alert plumbing all
|
|
53
|
+
stay in glue. (The weekly ``maybe_record_milestone`` loop shares this
|
|
54
|
+
fencing formula but keeps its own range in glue — its crossing decision
|
|
55
|
+
early-returns BEFORE the cost sync when covered, a structure this
|
|
56
|
+
range-only kernel does not encapsulate; spec §6 defers it.)
|
|
57
|
+
"""
|
|
58
|
+
current_floor = math.floor(pct + 1e-9)
|
|
59
|
+
if current_floor < 1:
|
|
60
|
+
return range(0)
|
|
61
|
+
start_threshold = current_floor if max_existing is None else int(max_existing) + 1
|
|
62
|
+
return range(start_threshold, current_floor + 1)
|
|
63
|
+
|
|
64
|
+
|
|
31
65
|
def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
32
66
|
"""Floor a datetime to the previous 10-minute boundary.
|
|
33
67
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Forecast decision kernel for cctally.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): the forecast inputs/output/budget
|
|
4
|
+
dataclasses plus `_compute_forecast` — the projection + budget-headroom
|
|
5
|
+
math. Values in (`ForecastInputs`), a `ForecastOutput` out. No DB reads,
|
|
6
|
+
no config loads, no `_cctally()` accessor: the projection routes through
|
|
7
|
+
`project_linear` (the shared, pure primitive whose real home is
|
|
8
|
+
`_lib_budget`).
|
|
9
|
+
|
|
10
|
+
Imported by `_cctally_forecast.py` (and `_cctally_tui.py`) and re-exported
|
|
11
|
+
on the `cctally` namespace via `bin/cctally`, so the existing
|
|
12
|
+
`cctally.ForecastInputs` / `mod.ForecastOutput` / `inspect.getsource(
|
|
13
|
+
cctally._compute_forecast)` read paths resolve unchanged (re-export
|
|
14
|
+
continuity, spec §2). Single definition of each dataclass lives here;
|
|
15
|
+
everything else imports it, so class identity stays unique.
|
|
16
|
+
|
|
17
|
+
Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as dt
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from _lib_budget import project_linear
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ForecastInputs:
|
|
29
|
+
now_utc: dt.datetime
|
|
30
|
+
week_start_at: dt.datetime
|
|
31
|
+
week_end_at: dt.datetime
|
|
32
|
+
elapsed_hours: float
|
|
33
|
+
elapsed_fraction: float
|
|
34
|
+
remaining_hours: float
|
|
35
|
+
remaining_days: float
|
|
36
|
+
# Current state
|
|
37
|
+
p_now: float
|
|
38
|
+
five_hour_percent: float | None
|
|
39
|
+
spent_usd: float
|
|
40
|
+
snapshot_count: int
|
|
41
|
+
latest_snapshot_at: dt.datetime
|
|
42
|
+
# Rate inputs
|
|
43
|
+
p_24h_ago: float | None
|
|
44
|
+
t_24h_actual_hours: float | None
|
|
45
|
+
# $/1% selection
|
|
46
|
+
dollars_per_percent: float
|
|
47
|
+
dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse"
|
|
48
|
+
# Confidence
|
|
49
|
+
confidence: str # "high" | "low"
|
|
50
|
+
low_confidence_reasons: list[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BudgetRow:
|
|
55
|
+
target_percent: int
|
|
56
|
+
pct_headroom: float | None # None when already past target
|
|
57
|
+
dollars_per_day: float | None
|
|
58
|
+
percent_per_day: float | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ForecastOutput:
|
|
63
|
+
inputs: ForecastInputs
|
|
64
|
+
r_avg: float # pct per hour, week-avg
|
|
65
|
+
r_recent: float | None # pct per hour, 24h recent; None if no prior sample
|
|
66
|
+
final_percent_low: float
|
|
67
|
+
final_percent_high: float
|
|
68
|
+
week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
|
|
69
|
+
projected_cap: bool
|
|
70
|
+
already_capped: bool
|
|
71
|
+
cap_at: dt.datetime | None
|
|
72
|
+
budgets: list[BudgetRow]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOutput:
|
|
76
|
+
"""Implements spec §2. targets are sorted desc for stable output (100, 90, …)."""
|
|
77
|
+
# Rate methods
|
|
78
|
+
r_avg = inputs.p_now / inputs.elapsed_hours if inputs.elapsed_hours > 0 else 0.0
|
|
79
|
+
if inputs.p_24h_ago is not None and inputs.t_24h_actual_hours:
|
|
80
|
+
r_recent: float | None = max(
|
|
81
|
+
0.0, (inputs.p_now - inputs.p_24h_ago) / inputs.t_24h_actual_hours
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
r_recent = None
|
|
85
|
+
|
|
86
|
+
# Projected final % — routed through the shared project_linear primitive
|
|
87
|
+
# (spec F1). r_recent is None ⇒ collapse to the average projection.
|
|
88
|
+
if r_recent is None:
|
|
89
|
+
final_low, final_high = project_linear(
|
|
90
|
+
inputs.p_now, inputs.remaining_hours, r_avg, r_avg
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
a, b = project_linear(
|
|
94
|
+
inputs.p_now, inputs.remaining_hours, r_avg, r_recent
|
|
95
|
+
)
|
|
96
|
+
final_low, final_high = min(a, b), max(a, b)
|
|
97
|
+
|
|
98
|
+
# Smooth week-average projection (additive surface field). Distinct from
|
|
99
|
+
# the displayed band (which keys off final_high): this is the conservative
|
|
100
|
+
# week-average value the projected-pace alert axis fires on.
|
|
101
|
+
# p_now + r_avg*remaining (== project_linear collapsed to the single rate).
|
|
102
|
+
week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
|
|
103
|
+
|
|
104
|
+
already_capped = inputs.p_now >= 100.0
|
|
105
|
+
projected_cap = already_capped or final_high >= 100.0
|
|
106
|
+
|
|
107
|
+
cap_at: dt.datetime | None = None
|
|
108
|
+
if not already_capped and projected_cap:
|
|
109
|
+
r_pessimistic = max(r_avg, r_recent or 0.0)
|
|
110
|
+
if r_pessimistic > 0:
|
|
111
|
+
hours_to_cap = (100.0 - inputs.p_now) / r_pessimistic
|
|
112
|
+
if hours_to_cap < inputs.remaining_hours:
|
|
113
|
+
cap_at = inputs.now_utc + dt.timedelta(hours=hours_to_cap)
|
|
114
|
+
|
|
115
|
+
# Budgets
|
|
116
|
+
budgets: list[BudgetRow] = []
|
|
117
|
+
if not already_capped:
|
|
118
|
+
for t in sorted(targets, reverse=True):
|
|
119
|
+
headroom = t - inputs.p_now
|
|
120
|
+
if headroom <= 0 or inputs.remaining_days <= 0:
|
|
121
|
+
budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
|
|
122
|
+
dollars_per_day=None, percent_per_day=None))
|
|
123
|
+
continue
|
|
124
|
+
dollars_day = (headroom * inputs.dollars_per_percent) / inputs.remaining_days
|
|
125
|
+
pct_day = headroom / inputs.remaining_days
|
|
126
|
+
budgets.append(BudgetRow(
|
|
127
|
+
target_percent=t,
|
|
128
|
+
pct_headroom=headroom,
|
|
129
|
+
dollars_per_day=dollars_day,
|
|
130
|
+
percent_per_day=pct_day,
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
return ForecastOutput(
|
|
134
|
+
inputs=inputs,
|
|
135
|
+
r_avg=r_avg,
|
|
136
|
+
r_recent=r_recent,
|
|
137
|
+
final_percent_low=final_low,
|
|
138
|
+
final_percent_high=final_high,
|
|
139
|
+
week_avg_projection_pct=week_avg_projection_pct,
|
|
140
|
+
projected_cap=projected_cap,
|
|
141
|
+
already_capped=already_capped,
|
|
142
|
+
cap_at=cap_at,
|
|
143
|
+
budgets=budgets,
|
|
144
|
+
)
|