cctally 1.82.0 → 1.83.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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -250,6 +250,10 @@ class DoctorState:
250
250
  # * journal_appendable — the dir is writable (os.access W_OK); None when
251
251
  # absent or the probe errored.
252
252
  # * journal_segment_count — number of segments (bootstrap + monthly).
253
+ # * journal_has_bytes — at least one canonical segment retains bytes.
254
+ # A merely-created journal/ directory (or wholly empty segment set)
255
+ # cannot rebuild a disposable stats index; an empty newest segment does
256
+ # not hide replayable bytes in older immutable segments.
253
257
  # * journal_malformed_count / journal_torn_tail_count — mid-file malformed
254
258
  # lines (external damage → WARN) and torn final lines (a known crash
255
259
  # artifact healed by the next append → INFO). None = not scanned (the
@@ -266,12 +270,37 @@ class DoctorState:
266
270
  journal_present: bool = False
267
271
  journal_appendable: Optional[bool] = None
268
272
  journal_segment_count: int = 0
273
+ journal_has_bytes: bool = False
269
274
  journal_malformed_count: Optional[int] = None
270
275
  journal_torn_tail_count: Optional[int] = None
271
276
  journal_cursor_lag_bytes: Optional[int] = None
272
277
  journal_hw_segment: Optional[str] = None
273
278
  journal_cursor_segment: Optional[str] = None
274
279
  journal_heal_incidents: Optional[list] = None
280
+ # * journal_writer_guard (#386) — the stats sole-writer guard's log
281
+ # (logs/stats-writer-guard.log). `None` = the log is absent (the normal
282
+ # state, and NOT an error); otherwise
283
+ # {entries, newest_age_s, path, sample} where `sample` is the most
284
+ # recent line. On an installed build the authorizer LOGS instead of
285
+ # raising, so this leg is the only surface a field violation reaches.
286
+ journal_writer_guard: Optional[dict] = None
287
+ # * journal_conflicts (#374) — the quarantined same-revision groups the
288
+ # SHARED selector reports, each an `EventConflict.to_dict()`. `None` =
289
+ # not scanned (shallow gather, no journal, or the selector raised);
290
+ # `[]` = selection completed cleanly. Deep-gated, like the malformed
291
+ # scan, because it decodes every line and normalizes account stamps
292
+ # exactly as `rebuild_stats_index` does — raw `(id, rev)` grouping would
293
+ # report superseded revisions and false account conflicts.
294
+ # * journal_protocol_violations (#402) — completed selector results for
295
+ # whole tainted correction batches. `[]` means a clean deep scan;
296
+ # `None` means not scanned or the selector failed.
297
+ # * journal_protocol_error (#374) — an out-of-scope fatal
298
+ # `JournalProtocolError` raised by that same selector. Mutually
299
+ # exclusive with conflicts/violations because selection did not finish.
300
+ journal_conflicts: Optional[list] = None
301
+ journal_protocol_violations: Optional[list] = None
302
+ journal_protocol_acknowledged: Optional[list] = None
303
+ journal_protocol_error: Optional[str] = None
275
304
  # Multi-account attribution (#341). A dict (None when unavailable) with:
276
305
  # * claude_identity_status — "identified" | "stably_absent" | "torn"
277
306
  # * claude_email — the active Claude email (or None)
@@ -286,6 +315,10 @@ class DoctorState:
286
315
  # cache.db.repairing. {exists, live, reason}; None only when gather itself
287
316
  # could not inspect the marker. Doctor never reclaims or deletes it.
288
317
  cache_repair_marker: Optional[dict] = None
318
+ # #411: read-only, fail-soft classification of whether APP_DIR is inside a
319
+ # known file-level backup/sync root. Only provider/status are retained so
320
+ # reports never expose the machine-specific data path.
321
+ backup_sync_state: Optional[dict] = None
289
322
 
290
323
 
291
324
  @dataclasses.dataclass(frozen=True)
@@ -675,6 +708,19 @@ def _db_file_check(label_id: str, label_title: str, status: Optional[dict],
675
708
  remediation="Re-run; see stderr",
676
709
  details={"reason": "gather returned None"},
677
710
  )
711
+ interrupted = status.get("_interrupted_rebuild")
712
+ if interrupted and interrupted.get("live") is True:
713
+ return CheckResult(
714
+ id=label_id,
715
+ title=label_title,
716
+ severity="warn",
717
+ summary="stats.db rebuild is in progress",
718
+ remediation="Wait for the active rebuild to finish, then re-run Doctor.",
719
+ details={
720
+ "path": status["path"],
721
+ "interrupted_rebuild": interrupted,
722
+ },
723
+ )
678
724
  if status.get("_open_error"):
679
725
  return CheckResult(
680
726
  id=label_id, title=label_title,
@@ -682,6 +728,22 @@ def _db_file_check(label_id: str, label_title: str, status: Optional[dict],
682
728
  remediation=rebuild_hint,
683
729
  details={"exception": status["_open_error"], "path": status["path"]},
684
730
  )
731
+ if interrupted and interrupted.get("live") is False:
732
+ return CheckResult(
733
+ id=label_id,
734
+ title=label_title,
735
+ severity="warn",
736
+ summary="interrupted rebuild detected; next guarded open will recover",
737
+ remediation=(
738
+ "Run `cctally report` or restart `cctally dashboard` to retry "
739
+ "automatic recovery. If recovery fails, run "
740
+ "`cctally db rebuild --db stats`."
741
+ ),
742
+ details={
743
+ "path": status["path"],
744
+ "interrupted_rebuild": interrupted,
745
+ },
746
+ )
685
747
  if status.get("_file_exists") is False:
686
748
  return CheckResult(
687
749
  id=label_id, title=label_title,
@@ -804,9 +866,10 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
804
866
  * ``uv == epoch`` (a cut-over install) → HEALTHY (steady state)
805
867
  * ``uv <= legacy_head`` (pre-cutover, ≤13) → HEALTHY (cuts over on open)
806
868
  * ``uv > legacy_head`` AND ``!= epoch`` → §7.1 index MISMATCH: WARN.
807
- It self-heals by journal REBUILD on the next open (never bricks, unlike
808
- the retired #145 version-ahead FAIL), so the remediation points at
809
- `db rebuild --db stats`, NOT the retired `db recover --db stats`.
869
+ With retained journal bytes it self-heals by journal REBUILD on the next
870
+ open; without them it fails closed and asks the operator to restore the
871
+ durable source. The remediation never points at the retired
872
+ `db recover --db stats`.
810
873
 
811
874
  cache.db is unchanged (issue #145): a ``user_version`` past the cache
812
875
  registry head auto-heals on the next open → WARN. doctor reads raw
@@ -836,11 +899,27 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
836
899
 
837
900
  stats = _eval_stats(s.stats_db_status)
838
901
  cache = _eval_cache(s.cache_db_status)
839
- details = {"stats.db": stats, "cache.db": cache}
840
902
  stats_mismatch = bool(stats and stats["mismatch"])
841
903
  cache_ahead = bool(cache and cache["ahead"])
904
+ if stats_mismatch:
905
+ stats["journal_present"] = bool(s.journal_present)
906
+ stats["journal_has_bytes"] = bool(s.journal_has_bytes)
907
+ details = {"stats.db": stats, "cache.db": cache}
842
908
 
843
909
  if stats_mismatch:
910
+ if not s.journal_has_bytes:
911
+ return CheckResult(
912
+ id="db.version_ahead", title="Version ahead", severity="warn",
913
+ summary=(
914
+ f"stats.db index mismatch (v{stats['user_version']} ≠ epoch "
915
+ f"v{stats['epoch']}) — no journal data available"
916
+ ),
917
+ remediation=(
918
+ "Restore the journal/ directory from backup, then run "
919
+ "`cctally db rebuild --db stats`"
920
+ ),
921
+ details=details,
922
+ )
844
923
  return CheckResult(
845
924
  id="db.version_ahead", title="Version ahead", severity="warn",
846
925
  summary=(f"stats.db index mismatch (v{stats['user_version']} ≠ epoch "
@@ -1541,6 +1620,42 @@ def _check_safety_dashboard_bind(s: DoctorState) -> CheckResult:
1541
1620
  )
1542
1621
 
1543
1622
 
1623
+ def _check_safety_backup_sync(s: DoctorState) -> CheckResult:
1624
+ state = (
1625
+ s.backup_sync_state
1626
+ if isinstance(s.backup_sync_state, dict)
1627
+ else {"status": "unavailable", "provider": None}
1628
+ )
1629
+ status = state.get("status")
1630
+ provider = state.get("provider")
1631
+ details = {"status": status, "provider": provider}
1632
+ if status == "included" and provider:
1633
+ return CheckResult(
1634
+ id="safety.backup_sync", title="Backup/sync",
1635
+ severity="warn",
1636
+ summary=f"cctally data is inside {provider}",
1637
+ remediation=(
1638
+ "Exclude the cctally data directory from file-level backup/sync "
1639
+ "and use `cctally db backup --db stats` or `--db cache` for "
1640
+ "consistent SQLite snapshots"
1641
+ ),
1642
+ details=details,
1643
+ )
1644
+ summaries = {
1645
+ "absent": "no configured file-level backup/sync detected",
1646
+ "excluded": f"cctally data excluded from {provider or 'backup/sync'}",
1647
+ "unsupported": "backup/sync probe unsupported on this platform",
1648
+ "unavailable": "backup/sync probe unavailable",
1649
+ }
1650
+ return CheckResult(
1651
+ id="safety.backup_sync", title="Backup/sync",
1652
+ severity="ok",
1653
+ summary=summaries.get(status, "backup/sync state unavailable"),
1654
+ remediation=None,
1655
+ details=details,
1656
+ )
1657
+
1658
+
1544
1659
  def _check_safety_config_json_valid(s: DoctorState) -> CheckResult:
1545
1660
  if s.config_json_error is None:
1546
1661
  return CheckResult(
@@ -2082,6 +2197,225 @@ def _check_journal_auto_heal(s: DoctorState) -> CheckResult:
2082
2197
  )
2083
2198
 
2084
2199
 
2200
+ #: A guard entry inside this window is actionable; older ones are history.
2201
+ _WRITER_GUARD_RECENT_SECONDS = 7 * 24 * 3600
2202
+
2203
+
2204
+ def _check_journal_writer_guard(s: DoctorState) -> CheckResult:
2205
+ """Unsanctioned stats.db writes recorded by the #386 authorizer (spec §6.4).
2206
+
2207
+ On a dev checkout the guard RAISES, so a violation is loud at the call site.
2208
+ On an installed build it appends one throttled line to
2209
+ `logs/stats-writer-guard.log` and lets the write through — deliberately, so
2210
+ the guard can never break a user's command. This leg is therefore the ONLY
2211
+ surface such a violation reaches in the field.
2212
+
2213
+ INFO when the log is absent or empty (the normal state — an absent log is
2214
+ not a failure), WARN when it holds entries newer than 7 days.
2215
+ """
2216
+ guard = s.journal_writer_guard
2217
+ if not guard or not guard.get("entries"):
2218
+ return CheckResult(
2219
+ id="journal.writer_guard", title="Stats writer guard", severity="ok",
2220
+ summary="no unsanctioned stats.db writes recorded", remediation=None,
2221
+ details={"entries": 0},
2222
+ )
2223
+ entries = int(guard.get("entries") or 0)
2224
+ age_s = guard.get("newest_age_s")
2225
+ path = guard.get("path")
2226
+ details = {
2227
+ "entries": entries,
2228
+ "newestAgeS": age_s,
2229
+ "path": path,
2230
+ "sample": guard.get("sample"),
2231
+ }
2232
+ if age_s is not None and age_s <= _WRITER_GUARD_RECENT_SECONDS:
2233
+ return CheckResult(
2234
+ id="journal.writer_guard", title="Stats writer guard", severity="warn",
2235
+ summary=(
2236
+ f"{entries} unsanctioned stats.db write(s) recorded, newest "
2237
+ f"{age_s // 3600}h ago"
2238
+ ),
2239
+ remediation=(
2240
+ "A code path wrote stats.db outside the ingest cycle and outside "
2241
+ f"the maintenance lock (#386). Inspect {path} and report it — "
2242
+ "the write was allowed through, so no data was lost."
2243
+ ),
2244
+ details=details,
2245
+ )
2246
+ return CheckResult(
2247
+ id="journal.writer_guard", title="Stats writer guard", severity="ok",
2248
+ summary=f"{entries} unsanctioned write(s), none recent", remediation=None,
2249
+ details=details,
2250
+ )
2251
+
2252
+
2253
+ # #374: the families `db rederive` owns. A quarantined group outside this set
2254
+ # (a retained `qaa:` state stream, or an unknown prefix from a newer binary) is
2255
+ # still reported, but `db rederive` is the WRONG remedy for it and the leg must
2256
+ # not promise one it cannot deliver.
2257
+ _REDERIVABLE_CONFLICT_PREFIXES = (
2258
+ "sa:", "wcs:", "wce:", "wr:", "fhc:", "fhbc:", "pm:", "fhm:",
2259
+ "bm:", "pjm:", "pbm:",
2260
+ )
2261
+
2262
+
2263
+ def _check_journal_conflicts(s: DoctorState) -> CheckResult:
2264
+ """Divergent same-revision EVENT groups quarantined behind a provisional
2265
+ winner (#374). WARN — never FAIL: the index is complete and usable, we
2266
+ simply refuse to assert that a guessed winner is authoritative.
2267
+
2268
+ Emitted only when selection COMPLETES. When the shared selector raised a
2269
+ structural violation there is no conflicts result to report, so this leg
2270
+ reports itself unavailable and `journal.protocol` carries the FAIL."""
2271
+ if s.journal_protocol_error:
2272
+ return CheckResult(
2273
+ id="journal.conflicts", title="Journal conflicts", severity="ok",
2274
+ summary="unavailable (structural protocol violation)",
2275
+ remediation=None,
2276
+ details={"scanned": True, "available": False, "conflicts": None},
2277
+ )
2278
+ if s.journal_conflicts is None:
2279
+ return CheckResult(
2280
+ id="journal.conflicts", title="Journal conflicts", severity="ok",
2281
+ summary="not scanned", remediation=None,
2282
+ details={"scanned": False, "available": None, "conflicts": None},
2283
+ )
2284
+ conflicts = list(s.journal_conflicts)
2285
+ if not conflicts:
2286
+ return CheckResult(
2287
+ id="journal.conflicts", title="Journal conflicts", severity="ok",
2288
+ summary="no quarantined events", remediation=None,
2289
+ details={"scanned": True, "available": True, "conflicts": []},
2290
+ )
2291
+ details = {"scanned": True, "available": True, "conflicts": conflicts}
2292
+ rederivable = [
2293
+ c for c in conflicts
2294
+ if str(c.get("eventId") or "").startswith(_REDERIVABLE_CONFLICT_PREFIXES)
2295
+ ]
2296
+ if rederivable:
2297
+ remediation = (
2298
+ "Run `cctally db rederive --family claude-usage` to supersede the "
2299
+ "quarantined group(s) at the next revision; until then the index "
2300
+ "uses the first-written variant"
2301
+ )
2302
+ else:
2303
+ remediation = (
2304
+ "These groups are outside the claude-usage rederive family — the "
2305
+ "index uses the first-written variant; report the event ids if the "
2306
+ "affected data looks wrong"
2307
+ )
2308
+ return CheckResult(
2309
+ id="journal.conflicts", title="Journal conflicts", severity="warn",
2310
+ summary=(f"{len(conflicts)} quarantined same-revision group(s) "
2311
+ "behind a provisional winner"),
2312
+ remediation=remediation, details=details,
2313
+ )
2314
+
2315
+
2316
+ def _check_journal_protocol(s: DoctorState) -> CheckResult:
2317
+ """Report selector failure or whole-batch tainting without false health."""
2318
+ if (
2319
+ not s.journal_protocol_error
2320
+ and s.journal_protocol_violations is None
2321
+ and s.journal_protocol_acknowledged is None
2322
+ ):
2323
+ return CheckResult(
2324
+ id="journal.protocol", title="Journal protocol", severity="ok",
2325
+ summary="not scanned", remediation=None,
2326
+ details={
2327
+ "scanned": False,
2328
+ "error": None,
2329
+ "violations": None,
2330
+ },
2331
+ )
2332
+ violations = list(s.journal_protocol_violations or [])
2333
+ acknowledged = list(s.journal_protocol_acknowledged or [])
2334
+ if not s.journal_protocol_error and not violations and not acknowledged:
2335
+ return CheckResult(
2336
+ id="journal.protocol", title="Journal protocol", severity="ok",
2337
+ summary="no protocol violations", remediation=None,
2338
+ details={
2339
+ "scanned": True,
2340
+ "error": None,
2341
+ "violations": [],
2342
+ },
2343
+ )
2344
+ if violations:
2345
+ batch_kinds = [
2346
+ f"{item.get('batchId')}: {item.get('kind')}"
2347
+ for item in violations[:10]
2348
+ ]
2349
+ selected = violations[:10]
2350
+ apply_command = "cctally db journal-repair " + " ".join(
2351
+ f"--violation {item.get('fingerprint')}"
2352
+ for item in selected
2353
+ ) + " --yes"
2354
+ return CheckResult(
2355
+ id="journal.protocol", title="Journal protocol", severity="fail",
2356
+ summary=(
2357
+ f"{len(violations)} structural violation(s); index rebuilt "
2358
+ "with tainted correction batches omitted"
2359
+ ),
2360
+ remediation=(
2361
+ "The index is usable, but the named correction batches were "
2362
+ "omitted. Preview with `cctally db journal-repair`, then apply "
2363
+ f"the exact current selection with `{apply_command}`. Do not "
2364
+ "edit journal segments by hand"
2365
+ ),
2366
+ details={
2367
+ "scanned": True,
2368
+ "error": None,
2369
+ "violations": violations,
2370
+ "sample": batch_kinds,
2371
+ "previewCommand": "cctally db journal-repair",
2372
+ "applyCommand": apply_command,
2373
+ **(
2374
+ {"acknowledgedViolations": acknowledged}
2375
+ if acknowledged else {}
2376
+ ),
2377
+ },
2378
+ )
2379
+ if acknowledged:
2380
+ return CheckResult(
2381
+ id="journal.protocol", title="Journal protocol", severity="warn",
2382
+ summary=(
2383
+ f"{len(acknowledged)} acknowledged structural violation(s); "
2384
+ "tainted correction batches remain omitted"
2385
+ ),
2386
+ remediation=(
2387
+ "The operator audit is durable and the index is usable. The "
2388
+ "named correction batches remain omitted; inspect the audit "
2389
+ "details before relying on the affected history"
2390
+ ),
2391
+ details={
2392
+ "scanned": True,
2393
+ "error": None,
2394
+ "violations": [],
2395
+ "acknowledgedViolations": acknowledged,
2396
+ "sample": [
2397
+ f"{item.get('batchId')}: {item.get('kind')}"
2398
+ for item in acknowledged[:10]
2399
+ ],
2400
+ "previewCommand": "cctally db journal-repair",
2401
+ "applyCommand": None,
2402
+ },
2403
+ )
2404
+ return CheckResult(
2405
+ id="journal.protocol", title="Journal protocol", severity="fail",
2406
+ summary="journal selector failed before rebuild",
2407
+ remediation=(
2408
+ "A journal record is invalid outside the recoverable structural "
2409
+ "batch classes. Capture the journal segments and open an issue"
2410
+ ),
2411
+ details={
2412
+ "scanned": True,
2413
+ "error": str(s.journal_protocol_error),
2414
+ "violations": None,
2415
+ },
2416
+ )
2417
+
2418
+
2085
2419
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
2086
2420
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
2087
2421
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -2246,6 +2580,9 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
2246
2580
  ("journal.integrity", "_check_journal_integrity"),
2247
2581
  ("journal.index_freshness", "_check_journal_index_freshness"),
2248
2582
  ("journal.auto_heal", "_check_journal_auto_heal"),
2583
+ ("journal.writer_guard", "_check_journal_writer_guard"),
2584
+ ("journal.conflicts", "_check_journal_conflicts"),
2585
+ ("journal.protocol", "_check_journal_protocol"),
2249
2586
  )),
2250
2587
  ("data", "Data", (
2251
2588
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
@@ -2271,6 +2608,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
2271
2608
  )),
2272
2609
  ("safety", "Safety", (
2273
2610
  ("safety.dashboard_bind", "_check_safety_dashboard_bind"),
2611
+ ("safety.backup_sync", "_check_safety_backup_sync"),
2274
2612
  ("safety.config_json_valid", "_check_safety_config_json_valid"),
2275
2613
  ("safety.update_state", "_check_safety_update_state"),
2276
2614
  ("safety.update_suppress", "_check_safety_update_suppress"),