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.
Files changed (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +203 -22
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +481 -4891
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +42 -18
  24. package/bin/_lib_alert_dispatch.py +1 -1
  25. package/bin/_lib_blocks.py +6 -1
  26. package/bin/_lib_credit.py +133 -0
  27. package/bin/_lib_doctor.py +150 -1
  28. package/bin/_lib_five_hour.py +34 -0
  29. package/bin/_lib_forecast.py +144 -0
  30. package/bin/_lib_json_envelope.py +38 -0
  31. package/bin/_lib_jsonl.py +115 -73
  32. package/bin/_lib_log.py +96 -0
  33. package/bin/_lib_pricing.py +47 -1
  34. package/bin/_lib_pricing_check.py +25 -3
  35. package/bin/_lib_record.py +179 -0
  36. package/bin/_lib_render.py +18 -10
  37. package/bin/_lib_snapshot_cache.py +61 -0
  38. package/bin/cctally +51 -7
  39. package/bin/cctally-dashboard +2 -2
  40. package/bin/cctally-statusline +1 -0
  41. package/bin/cctally-tui +2 -2
  42. package/package.json +10 -1
@@ -701,7 +701,7 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
701
701
  ),
702
702
  "modelBreakdowns": breakdowns,
703
703
  }
704
- print(json.dumps(output, indent=2))
704
+ print(json.dumps(c.stamp_schema_version(output), indent=2))
705
705
  return 0
706
706
 
707
707
  if args.breakdown:
@@ -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
- """A ``1``/``true``/``yes``/any-non-empty env value is truthy; unset,
51
- empty, ``0``, ``false``, ``no`` are falsey."""
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(
@@ -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
- # Forecast dataclass shims used as bare-name constructors inside
317
- # ``_tui_build_forecast``. The classes themselves stay in bin/cctally
318
- # alongside the forecast subcommand (``cmd_forecast``); call-time
319
- # resolution keeps monkeypatches in sync.
320
- def ForecastInputs(*args, **kwargs):
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)
@@ -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
+ )
@@ -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()
@@ -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
+ )