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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.66.0] - 2026-07-10
9
+
10
+ ### Added
11
+ - Reporting `--json` output now carries a `schemaVersion: 1` key (rendered first) so downstream consumers can version-gate against the payload shape. It is added to every reporting surface that lacked it — `daily`/`daily --instances`/`monthly`/`weekly`/`session`, the Codex `codex-daily`/`codex-monthly`/`codex-weekly`/`codex-session` reports, `blocks`, `forecast`, `report` (`dollar-per-percent`), `project`, `range-cost`, `cache-report`, `percent-breakdown`, `sync-week`, `telemetry`, and the `budget set`/`unset` actions — including their empty and error forms. The already-versioned surfaces (`five-hour-blocks`, `five-hour-breakdown`, `budget` status, `record-credit`, `pricing-check`) are unchanged, and the shipped snake-case `schema_version` surfaces (`diff`, `doctor`, `db status`, `refresh-usage`, `setup`) keep their existing key. Existing keys are unchanged and consumers should tolerate unknown keys (#279).
12
+ - The cache sync now counts JSONL lines it can't parse (malformed) or has to drift-skip (usage/model/timestamp no longer parse), per vendor, and surfaces them on the `cache-sync` done lines, the hook-tick log line, and a new `cctally doctor` "Ingest parse health" check — so a Claude Code / Codex session-format change that would silently affect your cost numbers becomes visible instead of vanishing (#279).
13
+ - `CCTALLY_DEBUG=1` now makes `cctally` print the full Python traceback on stderr when a command crashes (default output is unchanged), and `CCTALLY_DEBUG_LOG=<path>` additionally appends the backend log to a file — so a bug is reportable without editing source (#279).
14
+ - The dashboard now logs server errors (HTTP 500) to the terminal running it, instead of swallowing them silently; routine client errors and the per-request access log stay quiet (#279).
15
+ - `cctally doctor` gains a SQLite integrity check (`PRAGMA quick_check`, run only from the CLI where it's affordable — FAILs on stats.db corruption with safe recovery guidance, WARNs on the re-derivable cache.db) and an informational lock-state check that reports whether a sync lock is currently held (#279).
16
+ - `CCTALLY_PERF_TRACE=1 cctally cache-sync` now traces both the Claude and Codex ingest under one shared `cache-sync` phase tree, with the Codex sync carrying the same flock/discover/walk timing seams as the Claude sync (#279).
17
+ - New cache migration `020_session_entries_physical_unique` adds a `UNIQUE(source_path, line_offset)` backstop to the Claude session cache (matching the Codex table) so an offset-bookkeeping regression can never silently double-count your cost data — a collision fails loudly and rolls back that file instead. It dedups any pre-existing duplicates (keeping the first-ingested row) and runs automatically; `cache.db` is re-derivable, so `cache-sync --rebuild` remains the escape hatch (#279).
18
+ - `cctally pricing-check` now flags embedded-pricing allowlist suppressions that no longer correspond to a real LiteLLM divergence (`staleSuppressions`) or that are past a new optional `expires` cutover date (`expiredSuppressions`) — two additive `--json` arrays and an exit-1 actionable finding — and the weekly pricing-freshness workflow files them on its auto-tracked issue, so a deliberate pricing override can't silently ossify past its stated cutover (#279).
19
+ - Codex pricing now covers the `gpt-5.6` model family (`gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`), so cost for those models is computed from their own published rates instead of falling back to `gpt-5` pricing.
20
+
21
+ ### Changed
22
+ - `cctally project` and `cctally forecast` now exit `2` (was `1`) on their own usage/validation errors — a bad `--weeks`, `--weeks` combined with `--since`/`--until`, `--since > --until` or an unparseable date for `project`; `--json` together with `--status-line` or a bad `--targets` for `forecast` — matching the rest of the cctally-native command family (`diff`, `budget`, `five-hour-*`, `pricing-check`, `doctor`). The ccusage-parity commands (`daily`/`monthly`/`weekly`/`session`/`blocks`/`codex-*`) keep their exit `1` on bad dates for upstream parity. The exit-code convention is now documented in `docs/cli-contract.md` (#279).
23
+ - Release tooling (maintainer): the npm-publish poll now hard-fails on timeout with `--resume` guidance instead of silently reporting success; `--npm-soft-timeout` restores the old exit-0 behavior and marks the final line "published (npm pending verification)".
24
+ - Internal: the record write path (resets-at plausibility, weekly credit / reset-to-zero debounce, 5h in-place credit, the reset-aware HWM clamp + monotonic HWM files, 5h milestone ranges, and the projected-pace alert crossings) and the forecast projection now route their decision logic through pure, unit-tested kernels (`_lib_record.py`, `_lib_credit.py`, `_lib_forecast.py`, `_lib_five_hour.py`); no behavior change (#279).
25
+ - Internal: CLI-architecture consistency pass — `build_parser()` is now a loop over an ordered per-command registration table of builder functions (every subcommand's `--help` output is byte-for-byte unchanged, verified by a recursive sweep across the flat commands, the `claude`/`codex` subgroups, and the hidden commands); the four `_iso_z` UTC-Z serializers collapse to one canonical helper (doctor keeps its deliberately divergent copy); the `_eprint` / `_load_lib` / `_ensure_sibling_loaded` per-module bootstrap copies are normalized and pinned by AST drift-guard tests; and the three deviant thin bash wrappers are aligned to the shared template. No behavior change (#279).
26
+ - Internal: the dashboard server module (`_cctally_dashboard.py`, ~10.5K lines) is split into four consumer-only sibling modules (share, envelope, cache-report, conversation) and its HTTP request routing is now table-driven; every endpoint's response bytes and status codes are byte-identical, verified against a real data corpus (#279).
27
+ - `cctally report --json`'s `generatedAt` timestamp now routes through the same as-of clock as the rest of the report (unchanged in normal use — it still reflects wall-clock when no override is set), making the JSON output reproducible under a pinned clock (#279).
28
+ - Internal: test-infrastructure hardening — every migration in both registries now ships a per-migration golden with an enforced registry-completeness guard and a documented handler-idempotency contract; a new ancient-DB→head chain test covers migration interaction (the recompute gate + the FTS search-split→drop→title sequence); `pytest-timeout` is wired as an optional-by-detection dev dependency with a deliberate-hang canary and `timeout-minutes` on every CI job; the npm shim/postinstall harnesses now assert empty-output emptiness instead of skipping it; and `daily`, `monthly`, and `report` gain golden test harnesses (#279).
29
+
30
+ ### Fixed
31
+ - `cctally setup` now installs the `cctally-budget` symlink (it existed but was never linked); a drift-guard test keeps the wrapper list and the installer in sync.
32
+ - `CCTALLY_ALLOW_PROD_MIGRATION=0`, `CCTALLY_DISABLE_DEV_AUTODETECT=0`, `CCTALLY_DEBUG=0`, and `CCTALLY_DISABLE_UPDATE_CHECK=0` now mean "disabled" — previously any value, including `0`, enabled these flags.
33
+ - A corrupt `stats.db` now produces a one-line diagnosis with recovery guidance and exit 2 instead of a raw traceback.
34
+ - Piping output to a closed reader (`cctally daily | head`) now exits 0 quietly instead of `Error: [Errno 32] Broken pipe`.
35
+ - The dashboard share endpoints now cap request bodies at 64 KiB and the dashboard server sets a 60s socket timeout (slow-loris hardening); server-sent-event streams treat a send timeout as a normal client disconnect.
36
+ - The dashboard `/api/data` endpoint now returns a JSON `500 {"error": "internal error"}` on an internal error instead of dropping the connection with no response, and a server-sent-event stream that fails mid-flight is now logged and closed cleanly instead of dying silently (#279).
37
+ - `cache.db` now opens with `PRAGMA synchronous=NORMAL` (a fully re-derivable database; fewer fsyncs during ingest).
38
+ - Codex resume tracking now persists the ingest iterator's actual dedup watermark (the cumulative token counter it compares against) rather than a reconstructed sum of per-turn deltas, closing a latent double-count/skip on rollouts whose cumulative and per-turn token accounting diverge; healthy sessions are unaffected and need no re-ingest (#279).
39
+ - The direct-JSONL fallback (used when `cache.db` can't be opened) now parses with the same single implementation as the cache path so the two can't drift, and a malformed `costUSD` value in a session line no longer aborts the read — it degrades to the token-derived cost like every other cost path (#279).
40
+
8
41
  ## [1.65.0] - 2026-07-09
9
42
 
10
43
  ### Added
@@ -44,7 +44,6 @@ from __future__ import annotations
44
44
 
45
45
  import argparse
46
46
  import datetime as dt
47
- import importlib.util as _ilu
48
47
  import os
49
48
  import pathlib
50
49
  import shutil
@@ -58,6 +57,7 @@ def _load_lib(name: str):
58
57
  cached = sys.modules.get(name)
59
58
  if cached is not None:
60
59
  return cached
60
+ import importlib.util as _ilu
61
61
  p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
62
62
  spec = _ilu.spec_from_file_location(name, p)
63
63
  mod = _ilu.module_from_spec(spec)
@@ -100,7 +100,6 @@ from __future__ import annotations
100
100
  import argparse
101
101
  import datetime as dt
102
102
  import fcntl
103
- import importlib.util as _ilu
104
103
  import json
105
104
  import os
106
105
  import pathlib
@@ -151,6 +150,7 @@ def _load_lib(name: str):
151
150
  cached = sys.modules.get(name)
152
151
  if cached is not None:
153
152
  return cached
153
+ import importlib.util as _ilu
154
154
  p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
155
155
  spec = _ilu.spec_from_file_location(name, p)
156
156
  mod = _ilu.module_from_spec(spec)
@@ -229,7 +229,7 @@ def _conv_row_tuple(m, path_str):
229
229
  )
230
230
 
231
231
 
232
- def _iter_sync_entries(fh, path_str):
232
+ def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
233
233
  """Fused single-pass sync walker (#138). Yields
234
234
  ``(byte_offset, cost_or_None, msgrow_or_None, aititle_or_None)`` for each
235
235
  JSONL line from ``fh``'s current position that produces a cost entry, a
@@ -269,11 +269,25 @@ def _iter_sync_entries(fh, path_str):
269
269
  stripped = line.strip()
270
270
  if not stripped:
271
271
  continue
272
+ # #279 S2 F1: passive parse-health counters over the new-byte span.
273
+ # lines_seen counts non-blank lines (malformed included).
274
+ if stats is not None:
275
+ stats.lines_seen += 1
272
276
  try:
273
277
  obj = json.loads(stripped)
274
278
  except json.JSONDecodeError:
279
+ if stats is not None:
280
+ stats.lines_malformed += 1
275
281
  continue
276
282
  cost = _lib_jsonl.parse_cost_entry(obj, path_str)
283
+ if cost is None and stats is not None:
284
+ # Assistant-typed line rejected for a NON-deliberate reason
285
+ # (schema-drift tripwire; <synthetic>/non-assistant are normal).
286
+ reason = _lib_jsonl.assistant_skip_reason(obj)
287
+ if reason is not None:
288
+ stats.assistant_lines_skipped += 1
289
+ stats.skip_reasons[reason] = \
290
+ stats.skip_reasons.get(reason, 0) + 1
277
291
  mrow = _lib_conversation.parse_message_row(obj, offset)
278
292
  ai = _lib_conversation.parse_ai_title(obj, offset)
279
293
  if cost is not None or mrow is not None or ai is not None:
@@ -302,6 +316,77 @@ clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
302
316
  _set_cache_meta = _cctally_db_sib._set_cache_meta
303
317
 
304
318
 
319
+ _PARSE_HEALTH_SCHEMA = 1
320
+
321
+
322
+ def _update_parse_health_meta(
323
+ conn: sqlite3.Connection,
324
+ key: str,
325
+ *,
326
+ lines_seen: int,
327
+ lines_malformed: int,
328
+ lines_skipped: int,
329
+ skip_reasons: dict,
330
+ rebuild: bool,
331
+ ) -> None:
332
+ """Anomaly-delta-gated rolling parse-health record (#279 S2 F1 /
333
+ Codex P1-2). Writes ONLY when (a) this sync's malformed+skipped delta
334
+ is nonzero, (b) rebuild=True (baseline reset — write fresh from that
335
+ walk's counters), or (c) the key is absent (first adoption). Healthy
336
+ steady-state syncs — including the ~1s live-tail targeted ingests —
337
+ never write, so the cumulative ``lines_seen`` denominator advances
338
+ only at these writes ("as of the last write"); doctor reasons from
339
+ counts + recency, never a precise ratio.
340
+
341
+ Caller holds the sync flock. Runs at end-of-sync, OUTSIDE every
342
+ per-file ``[before, after]`` total_changes window, so
343
+ ``stats.rows_changed`` stays byte-identical (#270); never bumps
344
+ ``mutation_seq``. Commits its own write (mirrors the walk-complete
345
+ sentinel's commit discipline). Fail-soft: a corrupt prior value is
346
+ treated as absent.
347
+ """
348
+ anomaly_delta = int(lines_malformed) + int(lines_skipped)
349
+ now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
350
+ prior = None
351
+ try:
352
+ row = conn.execute(
353
+ "SELECT value FROM cache_meta WHERE key = ?", (key,)
354
+ ).fetchone()
355
+ if row and row[0]:
356
+ loaded = json.loads(row[0])
357
+ if isinstance(loaded, dict):
358
+ prior = loaded
359
+ except (sqlite3.DatabaseError, ValueError):
360
+ prior = None
361
+ if not rebuild and prior is not None and anomaly_delta == 0:
362
+ return # steady state: zero-write
363
+ if rebuild or prior is None:
364
+ prior = {"lines_seen": 0, "lines_malformed": 0, "lines_skipped": 0,
365
+ "reasons": {}, "last_anomaly_at": None, "since": now_iso}
366
+ record = {
367
+ "schema": _PARSE_HEALTH_SCHEMA,
368
+ "lines_seen": int(prior.get("lines_seen", 0) or 0) + int(lines_seen),
369
+ "lines_malformed": (int(prior.get("lines_malformed", 0) or 0)
370
+ + int(lines_malformed)),
371
+ "lines_skipped": (int(prior.get("lines_skipped", 0) or 0)
372
+ + int(lines_skipped)),
373
+ "last_anomaly_at": (now_iso if anomaly_delta > 0
374
+ else prior.get("last_anomaly_at")),
375
+ "last_write_at": now_iso,
376
+ "since": prior.get("since") or now_iso,
377
+ }
378
+ reasons = dict(prior.get("reasons") or {}) \
379
+ if isinstance(prior.get("reasons"), dict) else {}
380
+ for r, n in (skip_reasons or {}).items():
381
+ reasons[r] = int(reasons.get(r, 0) or 0) + int(n)
382
+ record["reasons"] = reasons
383
+ try:
384
+ _set_cache_meta(conn, key, json.dumps(record, sort_keys=True))
385
+ conn.commit()
386
+ except sqlite3.DatabaseError:
387
+ conn.rollback() # observability must never fail the sync
388
+
389
+
305
390
  # === BEGIN MOVED REGIONS ===
306
391
  # Path constants APP_DIR / CACHE_DB_PATH / CACHE_LOCK_PATH /
307
392
  # CACHE_LOCK_CODEX_PATH live in _cctally_core (promoted 2026-05-22, #84);
@@ -497,6 +582,16 @@ class IngestStats:
497
582
  # and are otherwise unaffected.
498
583
  files_failed: int = 0
499
584
  deferred_reason: "str | None" = None
585
+ # #279 S2 F1 parse-health counters — passive observers over the new-byte
586
+ # span this sync walked. lines_seen counts non-blank lines (malformed
587
+ # included); assistant_lines_skipped counts assistant-typed lines
588
+ # parse_cost_entry rejected for a NON-deliberate reason (schema-drift
589
+ # tripwire; `<synthetic>` and non-assistant lines are normal). Reason
590
+ # vocabulary in _lib_jsonl._classify_cost_entry.
591
+ lines_seen: int = 0
592
+ lines_malformed: int = 0
593
+ assistant_lines_skipped: int = 0
594
+ skip_reasons: dict = field(default_factory=dict)
500
595
 
501
596
  @property
502
597
  def targeted_clean(self) -> bool:
@@ -1210,6 +1305,13 @@ def sync_cache(
1210
1305
 
1211
1306
  with _perf.phase("discover") as _p_disc:
1212
1307
  if targeted:
1308
+ # A requested path that vanished (session rotated/deleted
1309
+ # mid-live-tail) is deliberately DROPPED here without flagging
1310
+ # failure: marking files_failed would wedge the watch loop's
1311
+ # targeted_clean advance forever for a file that will never
1312
+ # return; the orphan-prune path owns its stale rows on the next
1313
+ # full sync. Pinned by tests/test_cache_accepted_behaviors.py
1314
+ # (#279 S3 F4).
1213
1315
  paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
1214
1316
  else:
1215
1317
  paths = list(_iter_claude_jsonl_files())
@@ -1484,7 +1586,7 @@ def sync_cache(
1484
1586
  # walk over the identical span — the "identical span"
1485
1587
  # invariant is now structural (a single stop point) rather
1486
1588
  # than a prose-enforced ``>= final_offset`` runtime break.
1487
- for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str):
1589
+ for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str, stats=stats):
1488
1590
  if cost is not None:
1489
1591
  entry, msg_id, req_id = cost
1490
1592
  usage = entry.usage
@@ -1756,6 +1858,17 @@ def sync_cache(
1756
1858
  (dt.datetime.now(dt.timezone.utc).isoformat(),),
1757
1859
  )
1758
1860
  conn.commit()
1861
+ # #279 S2 F1: rolling parse-health record. Anomaly-delta-gated so
1862
+ # steady-state (incl. targeted live-tail) syncs stay zero-write;
1863
+ # targeted syncs still accumulate — they ingest real new bytes.
1864
+ _update_parse_health_meta(
1865
+ conn, "parse_health_claude",
1866
+ lines_seen=stats.lines_seen,
1867
+ lines_malformed=stats.lines_malformed,
1868
+ lines_skipped=stats.assistant_lines_skipped,
1869
+ skip_reasons=stats.skip_reasons,
1870
+ rebuild=rebuild,
1871
+ )
1759
1872
  # At-rest hardening (Plan 2, spec §5). Runs here — at the end of the
1760
1873
  # write transaction, while the cache.db.lock flock is still held (so a
1761
1874
  # concurrent writer can't be mid-checkpoint) AND after at least one
@@ -2826,6 +2939,13 @@ class CodexIngestStats:
2826
2939
  # $CODEX_HOME root set (issue #108 — a prior-root purge, not a delta).
2827
2940
  files_pruned: int = 0
2828
2941
  lock_contended: bool = False
2942
+ # #279 S2 F1 parse-health counters — folded from each file's
2943
+ # _CodexIterState after its drain. Same vocabulary as the iterator
2944
+ # (info-non-dict / no-last-token-usage / bad-timestamp / no-session-id).
2945
+ lines_seen: int = 0
2946
+ lines_malformed: int = 0
2947
+ token_events_skipped: int = 0
2948
+ skip_reasons: dict = field(default_factory=dict)
2829
2949
 
2830
2950
 
2831
2951
  def _progress_codex_stderr(stats: CodexIngestStats, *, force: bool = False) -> None:
@@ -2863,7 +2983,10 @@ def sync_codex_cache(
2863
2983
 
2864
2984
  lock_fh = open(_cctally_core.CACHE_LOCK_CODEX_PATH, "w")
2865
2985
  try:
2866
- if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
2986
+ with _perf.phase("flock") as _p_flock:
2987
+ _acquired = _acquire_cache_flock(lock_fh, timeout=lock_timeout)
2988
+ _p_flock.set_meta(contended=not _acquired)
2989
+ if not _acquired:
2867
2990
  eprint("[codex-cache] sync already in progress; using existing cache")
2868
2991
  stats.lock_contended = True
2869
2992
  return stats
@@ -2880,8 +3003,10 @@ def sync_codex_cache(
2880
3003
  roots = _cctally()._codex_session_roots()
2881
3004
  # Pure read (glob + is_file only); safe to run before the SELECT and
2882
3005
  # the per-file loop, where no cache.db write lock may be held.
2883
- paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
2884
- stats.files_total = len(paths)
3006
+ with _perf.phase("discover") as _p_disc:
3007
+ paths: list[pathlib.Path] = list(_iter_codex_jsonl_paths(roots))
3008
+ stats.files_total = len(paths)
3009
+ _p_disc.set_count(len(paths))
2885
3010
 
2886
3011
  # Scope the cache to the CURRENT root set: drop rows ingested under a
2887
3012
  # prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
@@ -2942,6 +3067,11 @@ def sync_codex_cache(
2942
3067
  )
2943
3068
  }
2944
3069
 
3070
+ # #279 S2 F4: ONE coarse `walk` phase bracketing the per-file loop
3071
+ # (count = files_processed, never per-row — §2 rule). Manual CM so
3072
+ # the loop stays flat, mirroring sync_cache's walk seam.
3073
+ _p_walk = _perf.phase("walk")
3074
+ _p_walk.__enter__()
2945
3075
  for jp in paths:
2946
3076
  path_str = str(jp)
2947
3077
  try:
@@ -2990,15 +3120,19 @@ def sync_codex_cache(
2990
3120
  # ends on a metadata-only tail would lose the terminal
2991
3121
  # session_id/model and the next resume would mis-attribute the
2992
3122
  # first post-resume token_count.
3123
+ # #279 S3 F1: the state is BOTH the seed carrier for the dedup
3124
+ # watermark and the sink the iterator stamps it into. Seeding it
3125
+ # with initial_total_tokens (the prior resume's persisted
3126
+ # cumulative) means iter_state.total_tokens holds the guard's
3127
+ # terminal watermark once the iterator drains — which we persist
3128
+ # directly, replacing the former initial+Σ(per-turn) reconstruction
3129
+ # that could diverge from the true cumulative and double-count/skip
3130
+ # on the next resume.
2993
3131
  iter_state = _CodexIterState(
2994
3132
  session_id=initial_session_id,
2995
3133
  model=initial_model,
3134
+ total_tokens=initial_total_tokens,
2996
3135
  )
2997
- # Track the cumulative `total_token_usage.total_tokens` across this
2998
- # call. The iterator only yields when the cumulative strictly
2999
- # advances by the current turn's `last_token_usage.total_tokens`,
3000
- # so summing the per-turn totals reconstructs the final cumulative.
3001
- running_total = initial_total_tokens
3002
3136
  yielded_count = 0
3003
3137
  try:
3004
3138
  with open(jp, "r", encoding="utf-8", errors="replace") as fh:
@@ -3023,9 +3157,18 @@ def sync_codex_cache(
3023
3157
  entry.reasoning_output_tokens,
3024
3158
  entry.total_tokens,
3025
3159
  ))
3026
- running_total += int(entry.total_tokens or 0)
3027
3160
  yielded_count += 1
3028
3161
  final_offset = fh.tell()
3162
+ # #279 S2 F1: fold this file's iterator counters into the
3163
+ # sync-level stats. iter_state is per-file, so += is
3164
+ # exact. Folded inside the try: an OSError mid-read drops
3165
+ # this file's partial counters AND its offset advance
3166
+ # together, so re-walked lines are never double-counted.
3167
+ stats.lines_seen += iter_state.lines_seen
3168
+ stats.lines_malformed += iter_state.lines_malformed
3169
+ stats.token_events_skipped += iter_state.token_events_skipped
3170
+ for _r, _n in iter_state.skip_reasons.items():
3171
+ stats.skip_reasons[_r] = stats.skip_reasons.get(_r, 0) + _n
3029
3172
  except OSError as exc:
3030
3173
  eprint(f"[codex-cache] could not read {jp}: {exc}")
3031
3174
  continue
@@ -3046,11 +3189,13 @@ def sync_codex_cache(
3046
3189
  else initial_model
3047
3190
  )
3048
3191
 
3049
- # Persist the running cumulative if we yielded this call. Otherwise
3050
- # preserve the prior value never overwrite with 0, which would
3051
- # re-enable double-counting on the next resume.
3192
+ # Persist the iterator's stamped cumulative watermark if we yielded
3193
+ # this call (iter_state.total_tokens == the dedup guard's terminal
3194
+ # value by construction, #279 S3 F1). Otherwise preserve the prior
3195
+ # value — never overwrite with 0, which would re-enable
3196
+ # double-counting on the next resume.
3052
3197
  new_last_total_tokens: int | None = (
3053
- running_total if yielded_count > 0 else prev_total_tokens
3198
+ iter_state.total_tokens if yielded_count > 0 else prev_total_tokens
3054
3199
  )
3055
3200
 
3056
3201
  # Python's sqlite3 module starts an implicit transaction on the
@@ -3102,6 +3247,21 @@ def sync_codex_cache(
3102
3247
 
3103
3248
  if progress is not None:
3104
3249
  progress(stats)
3250
+ _p_walk.__exit__(None, None, None)
3251
+ _p_walk.set_count(stats.files_processed)
3252
+ _p_walk.set_meta(skipped=stats.files_skipped_unchanged,
3253
+ rows=stats.rows_changed)
3254
+ # #279 S2 F1: rolling parse-health record (codex half). Same
3255
+ # anomaly-delta gate as the Claude tail; SQLite serializes the
3256
+ # cache.db write against a concurrent Claude sync.
3257
+ _update_parse_health_meta(
3258
+ conn, "parse_health_codex",
3259
+ lines_seen=stats.lines_seen,
3260
+ lines_malformed=stats.lines_malformed,
3261
+ lines_skipped=stats.token_events_skipped,
3262
+ skip_reasons=stats.skip_reasons,
3263
+ rebuild=rebuild,
3264
+ )
3105
3265
  return stats
3106
3266
  finally:
3107
3267
  try:
@@ -3354,6 +3514,11 @@ def open_cache_db() -> sqlite3.Connection:
3354
3514
 
3355
3515
  conn.execute("PRAGMA journal_mode=WAL")
3356
3516
  conn.execute("PRAGMA busy_timeout=5000")
3517
+ # Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
3518
+ # power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
3519
+ # Fewer ingest fsyncs than the default FULL. Matches stats.db
3520
+ # (bin/_cctally_core.py open_db). #279 S1 F8.
3521
+ conn.execute("PRAGMA synchronous=NORMAL")
3357
3522
 
3358
3523
  # Apply the shared cache.db schema (cctally-dev#93, D4): Claude tables +
3359
3524
  # indexes, the session_id / project_path column adds on session_files
@@ -3454,6 +3619,13 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3454
3619
  lt = _REBUILD_LOCK_TIMEOUT_SECONDS if args.rebuild else None
3455
3620
  contended = False
3456
3621
 
3622
+ # #279 S2 F4: one shared `cache-sync` root so a single flushed tree
3623
+ # carries BOTH vendors — with two sequential sync roots,
3624
+ # flush_stderr(current_root()) would show only the last one. Opened
3625
+ # after the --prune-orphans early returns so they can't leak a root.
3626
+ _p_root = _perf.phase("cache-sync")
3627
+ _p_root.__enter__()
3628
+
3457
3629
  if source in ("claude", "all"):
3458
3630
  with _perf.phase("sync_cache"):
3459
3631
  stats = sync_cache(
@@ -3471,13 +3643,17 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3471
3643
  f"[cache-sync] claude done: {stats.files_processed} processed, "
3472
3644
  f"{stats.files_skipped_unchanged} skipped, "
3473
3645
  f"{stats.files_reset_truncated} reset, "
3474
- f"{stats.rows_changed} rows changed"
3646
+ f"{stats.rows_changed} rows changed, "
3647
+ f"{stats.lines_malformed} malformed, "
3648
+ f"{stats.assistant_lines_skipped} drift-skipped"
3475
3649
  )
3476
3650
 
3477
3651
  if source in ("codex", "all"):
3478
- stats = sync_codex_cache(
3479
- conn, progress=_progress_codex_stderr, rebuild=args.rebuild, lock_timeout=lt
3480
- )
3652
+ with _perf.phase("sync_codex_cache"):
3653
+ stats = sync_codex_cache(
3654
+ conn, progress=_progress_codex_stderr, rebuild=args.rebuild,
3655
+ lock_timeout=lt,
3656
+ )
3481
3657
  _progress_codex_stderr(stats, force=True)
3482
3658
  if stats.lock_contended and args.rebuild:
3483
3659
  eprint(
@@ -3490,11 +3666,16 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3490
3666
  f"[cache-sync] codex done: {stats.files_processed} processed, "
3491
3667
  f"{stats.files_skipped_unchanged} skipped, "
3492
3668
  f"{stats.files_reset_truncated} reset, "
3493
- f"{stats.rows_changed} rows changed"
3669
+ f"{stats.rows_changed} rows changed, "
3670
+ f"{stats.lines_malformed} malformed, "
3671
+ f"{stats.token_events_skipped} drift-skipped"
3494
3672
  )
3495
3673
 
3496
- # #276 perf: when tracing is enabled, flush the completed "sync_cache"
3674
+ _p_root.__exit__(None, None, None)
3675
+ # #276 perf: when tracing is enabled, flush the completed "cache-sync"
3497
3676
  # phase tree to stderr (stdout stays byte-identical). No-op when off.
3677
+ # As of #279 S2 F4 the root carries both the sync_cache and
3678
+ # sync_codex_cache children, so one flushed tree covers both vendors.
3498
3679
  if _perf.enabled():
3499
3680
  _perf.flush_stderr(_perf.current_root())
3500
3681
  return 1 if contended else 0
@@ -1010,7 +1010,7 @@ def _emit_cache_report_json(
1010
1010
  for row_dict in output[top_key]:
1011
1011
  row_dict["totalCost"] = row_dict["cost"]
1012
1012
  output["totals"]["totalCost"] = output["totals"]["cost"]
1013
- return json.dumps(output, indent=2)
1013
+ return json.dumps(_cctally().stamp_schema_version(output), indent=2)
1014
1014
 
1015
1015
 
1016
1016
  def _build_cache_report_title(args: argparse.Namespace, mode: str) -> str:
@@ -1141,8 +1141,9 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
1141
1141
  if since == until:
1142
1142
  if args.json:
1143
1143
  print(json.dumps(
1144
- {top_key: [], "totals": None,
1145
- "generatedAt": now_utc_iso(now_utc=now_utc)},
1144
+ c.stamp_schema_version(
1145
+ {top_key: [], "totals": None,
1146
+ "generatedAt": now_utc_iso(now_utc=now_utc)}),
1146
1147
  indent=2,
1147
1148
  ))
1148
1149
  else:
@@ -1163,8 +1164,9 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
1163
1164
  if not rows:
1164
1165
  if args.json:
1165
1166
  print(json.dumps(
1166
- {top_key: [], "totals": None,
1167
- "generatedAt": now_utc_iso(now_utc=now_utc)},
1167
+ c.stamp_schema_version(
1168
+ {top_key: [], "totals": None,
1169
+ "generatedAt": now_utc_iso(now_utc=now_utc)}),
1168
1170
  indent=2,
1169
1171
  ))
1170
1172
  else:
@@ -149,6 +149,20 @@ def _init_paths_from_env() -> None:
149
149
  CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
150
150
 
151
151
 
152
+ def _truthy_env(name: str) -> bool:
153
+ """A ``1``/``true``/``yes``/any-other-non-empty env value is truthy;
154
+ unset, empty, ``0``, ``false``, ``no`` are falsey (case-insensitive,
155
+ whitespace-stripped).
156
+
157
+ Canonical home for boolean env-flag parsing (#279 S1 F1) — presence-only
158
+ ``os.environ.get(...)`` checks made ``FLAG=0`` mean *enabled*, which for
159
+ ``CCTALLY_ALLOW_PROD_MIGRATION`` / ``CCTALLY_DISABLE_DEV_AUTODETECT`` was
160
+ the exact opposite of intent. ``_cctally_telemetry._truthy_env`` delegates
161
+ here."""
162
+ v = os.environ.get(name)
163
+ return v is not None and v.strip().lower() not in ("", "0", "false", "no")
164
+
165
+
152
166
  def _repo_root() -> pathlib.Path:
153
167
  """Repo root when running from a source checkout: this file lives at
154
168
  ``<repo>/bin/_cctally_core.py``, so the root is two parents up. Factored
@@ -165,7 +179,7 @@ def _is_dev_checkout() -> bool:
165
179
  the ``setup`` guard (which protects WHICH BINARY gets wired into
166
180
  ~/.claude/settings.json), not the data-dir relocation. The npm/brew
167
181
  install copies ship without ``.git`` so they never read True."""
168
- if os.environ.get("CCTALLY_DISABLE_DEV_AUTODETECT"):
182
+ if _truthy_env("CCTALLY_DISABLE_DEV_AUTODETECT"):
169
183
  return False
170
184
  return (_repo_root() / ".git").exists()
171
185
 
@@ -913,19 +927,40 @@ def open_db() -> sqlite3.Connection:
913
927
  ensure_dirs()
914
928
  conn = sqlite3.connect(DB_PATH)
915
929
  conn.row_factory = sqlite3.Row
916
- conn.execute("PRAGMA journal_mode=WAL")
917
- conn.execute("PRAGMA synchronous=NORMAL")
918
- # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
919
- # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
920
- # so this is not a behavior change it makes the multi-writer retry window
921
- # an explicit contract beside the WAL pragmas instead of an inherited
922
- # default a reader has to know about. NOTE: busy_timeout does NOT absorb
923
- # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
924
- # invalidated by a competing commit raises "database is locked" instantly,
925
- # bypassing the busy handler). The write paths defend against that by taking
926
- # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
927
- # first DML. See cctally-dev#87.
928
- conn.execute("PRAGMA busy_timeout=5000")
930
+ # #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db (the
931
+ # non-re-derivable DB) surfaces as a one-line diagnosis + exit 2 instead of
932
+ # a raw traceback. The catch boundary is DELIBERATELY narrow — ONLY the
933
+ # connect/PRAGMA/probe below. The DDL + `_run_pending_migrations` region
934
+ # further down is NOT wrapped: migration-handler failures have their own
935
+ # logging/suppression contract and must not be mislabeled as corruption
936
+ # (corruption that only surfaces mid-DDL stays a raw error; the probe
937
+ # catches the common case).
938
+ try:
939
+ conn.execute("PRAGMA journal_mode=WAL")
940
+ conn.execute("PRAGMA synchronous=NORMAL")
941
+ # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
942
+ # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
943
+ # so this is not a behavior change — it makes the multi-writer retry window
944
+ # an explicit contract beside the WAL pragmas instead of an inherited
945
+ # default a reader has to know about. NOTE: busy_timeout does NOT absorb
946
+ # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
947
+ # invalidated by a competing commit raises "database is locked" instantly,
948
+ # bypassing the busy handler). The write paths defend against that by taking
949
+ # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
950
+ # first DML. See cctally-dev#87.
951
+ conn.execute("PRAGMA busy_timeout=5000")
952
+ conn.execute("SELECT 1").fetchone()
953
+ except sqlite3.DatabaseError as exc:
954
+ try:
955
+ conn.close()
956
+ except Exception:
957
+ pass
958
+ raise c.StatsDbCorruptError(
959
+ f"stats.db appears corrupt or unreadable ({exc}). path: {DB_PATH}. "
960
+ f"Not auto-recreated — it holds your recorded usage history. "
961
+ f'Recovery: back up the file, then try `sqlite3 "{DB_PATH}" ".recover"`, '
962
+ f"or move it aside to start fresh. If this recurs, please file an issue."
963
+ ) from exc
929
964
  conn.execute(
930
965
  """
931
966
  CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (