cctally 1.6.3 → 1.7.1

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.
@@ -0,0 +1,961 @@
1
+ """Pure-function kernel for `cctally doctor`.
2
+
3
+ Module boundary: bin/cctally imports _lib_doctor — never the reverse.
4
+ Per the spec (docs/superpowers/specs/2026-05-13-doctor-design.md §7.1),
5
+ all I/O happens in `doctor_gather_state()` in bin/cctally; this
6
+ module operates on already-gathered DoctorState dataclasses and is
7
+ deterministic given its input.
8
+
9
+ User-facing reference: docs/commands/doctor.md.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import datetime as dt
16
+ import hashlib
17
+ import json
18
+ import pathlib
19
+ import sys
20
+ from typing import Optional
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class DoctorState:
25
+ """All on-disk / in-DB inputs needed to run checks. Built by
26
+ `doctor_gather_state()` in bin/cctally. Each field is independently
27
+ optional so a failed read in one corner degrades the dependent
28
+ check(s) without killing the rest of the report."""
29
+ # Install
30
+ symlink_state: Optional[list[tuple[str, str]]]
31
+ path_includes_local_bin: Optional[bool]
32
+ legacy_snippet: Optional[tuple[pathlib.Path, list[int]]]
33
+ legacy_bespoke: Optional[dict]
34
+ # Hooks
35
+ # claude_settings is populated by doctor_gather_state() (Task 13) via
36
+ # `_load_claude_settings()`; spec §7.1 includes it in the contract.
37
+ # The kernel does not currently consume it — it feeds verbose render
38
+ # and a future evaluator that walks the parsed settings tree.
39
+ claude_settings: Optional[dict]
40
+ hook_counts: Optional[dict[str, int]]
41
+ log_activity_24h: Optional[dict]
42
+ # Auth
43
+ oauth_token_present: Optional[bool]
44
+ # DB
45
+ stats_db_status: Optional[dict]
46
+ cache_db_status: Optional[dict]
47
+ # Data
48
+ latest_snapshot_at: Optional[dt.datetime]
49
+ cache_entries_count: Optional[int]
50
+ cache_last_entry_at: Optional[dt.datetime]
51
+ claude_jsonl_present: bool
52
+ # Forked-bucket invariant counts (data.forked_buckets check).
53
+ # Keys: "usage", "cost", "milestones" — each maps to the count of
54
+ # rows in the respective table where ``week_start_at IS NOT NULL``
55
+ # AND ``week_start_date != substr(week_start_at, 1, 10)``. None
56
+ # means the stats.db couldn't be opened to check; the migration
57
+ # ``004_heal_forked_week_start_date_buckets`` auto-merges any
58
+ # detected rows on the next ``open_db()``, so a non-zero count
59
+ # here indicates either (a) the migration is gated as
60
+ # skipped/failed/pending or (b) a buggy writer slipped through
61
+ # after the migration ran.
62
+ forked_bucket_counts: Optional[dict]
63
+ codex_entries_count: Optional[int]
64
+ codex_last_entry_at: Optional[dt.datetime]
65
+ codex_jsonl_present: bool
66
+ # Safety
67
+ dashboard_bind_stored: str
68
+ runtime_bind: Optional[str]
69
+ config_json_error: Optional[str]
70
+ update_state: Optional[dict]
71
+ update_state_error: Optional[str]
72
+ update_suppress: Optional[dict]
73
+ update_suppress_error: Optional[str]
74
+ # Precomputed by `doctor_gather_state` via the same predicate that
75
+ # gates the update banner (`_compute_effective_update_available`).
76
+ # Keeps the kernel free of release-version knowledge while staying
77
+ # in lockstep with the banner: doctor must never warn about an
78
+ # update the user has skipped or deferred.
79
+ effective_update_available: Optional[bool]
80
+ effective_update_reason: Optional[str]
81
+ # Meta
82
+ now_utc: dt.datetime
83
+ cctally_version: str
84
+
85
+
86
+ @dataclasses.dataclass(frozen=True)
87
+ class CheckResult:
88
+ id: str
89
+ title: str
90
+ severity: str # "ok" | "warn" | "fail"
91
+ summary: str
92
+ remediation: Optional[str]
93
+ details: dict
94
+
95
+
96
+ @dataclasses.dataclass(frozen=True)
97
+ class CategoryResult:
98
+ id: str
99
+ title: str
100
+ severity: str
101
+ checks: tuple[CheckResult, ...]
102
+
103
+
104
+ @dataclasses.dataclass(frozen=True)
105
+ class DoctorReport:
106
+ schema_version: int
107
+ generated_at: dt.datetime
108
+ cctally_version: str
109
+ overall_severity: str
110
+ counts: dict[str, int]
111
+ categories: tuple[CategoryResult, ...]
112
+
113
+
114
+ SCHEMA_VERSION = 1
115
+ SEVERITY_ORDER = ("ok", "warn", "fail")
116
+
117
+
118
+ def _max_severity(severities: list[str]) -> str:
119
+ """Return the highest severity in the list per SEVERITY_ORDER ordering."""
120
+ if not severities:
121
+ return "ok"
122
+ return max(severities, key=SEVERITY_ORDER.index)
123
+
124
+
125
+ def _check_install_symlinks(s: DoctorState) -> CheckResult:
126
+ if s.symlink_state is None:
127
+ return CheckResult(
128
+ id="install.symlinks", title="Symlinks",
129
+ severity="fail", summary="state unavailable",
130
+ remediation="See logs", details={"reason": "gather returned None"},
131
+ )
132
+ total = len(s.symlink_state)
133
+ missing = [n for n, st in s.symlink_state if st != "ok"]
134
+ ok_count = total - len(missing)
135
+ if not missing:
136
+ return CheckResult(
137
+ id="install.symlinks", title="Symlinks",
138
+ severity="ok", summary=f"{ok_count}/{total} present",
139
+ remediation=None,
140
+ details={"present": ok_count, "total": total, "missing": []},
141
+ )
142
+ return CheckResult(
143
+ id="install.symlinks", title="Symlinks",
144
+ severity="warn",
145
+ summary=f"{ok_count}/{total} present; missing {', '.join(missing)}",
146
+ remediation="Run `cctally setup`",
147
+ details={"present": ok_count, "total": total, "missing": missing},
148
+ )
149
+
150
+
151
+ def _check_install_path(s: DoctorState) -> CheckResult:
152
+ if s.path_includes_local_bin:
153
+ return CheckResult(
154
+ id="install.path", title="PATH",
155
+ severity="ok", summary="~/.local/bin on $PATH",
156
+ remediation=None, details={},
157
+ )
158
+ return CheckResult(
159
+ id="install.path", title="PATH",
160
+ severity="warn", summary="~/.local/bin not on $PATH",
161
+ remediation="Append `export PATH=\"$HOME/.local/bin:$PATH\"` to your shell rc",
162
+ details={},
163
+ )
164
+
165
+
166
+ def _check_install_legacy_snippet(s: DoctorState) -> CheckResult:
167
+ if s.legacy_snippet is None:
168
+ return CheckResult(
169
+ id="install.legacy_snippet", title="Legacy status-line snippet",
170
+ severity="ok", summary="not detected",
171
+ remediation=None, details={},
172
+ )
173
+ path, lines = s.legacy_snippet
174
+ location = f"{path}:{lines[0]}" if lines else str(path)
175
+ return CheckResult(
176
+ id="install.legacy_snippet", title="Legacy status-line snippet",
177
+ severity="warn", summary=f"detected at {location}",
178
+ remediation=f"Edit {path} to remove the cctally status-line snippet",
179
+ details={"path": str(path), "line_numbers": list(lines)},
180
+ )
181
+
182
+
183
+ def _check_install_legacy_bespoke(s: DoctorState) -> CheckResult:
184
+ info = s.legacy_bespoke or {"detected": False, "settings_entries": [], "files": []}
185
+ if not info.get("detected"):
186
+ return CheckResult(
187
+ id="install.legacy_bespoke_hooks", title="Legacy bespoke hooks",
188
+ severity="ok", summary="not detected",
189
+ remediation=None, details={},
190
+ )
191
+ n_entries = len(info.get("settings_entries") or [])
192
+ n_files = len(info.get("files") or [])
193
+ return CheckResult(
194
+ id="install.legacy_bespoke_hooks", title="Legacy bespoke hooks",
195
+ severity="warn",
196
+ summary=f"detected ({n_entries} entries, {n_files} files)",
197
+ remediation="Run `cctally setup --migrate-legacy-hooks`",
198
+ details={"entries": n_entries, "files": n_files},
199
+ )
200
+
201
+
202
+ _REQUIRED_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
203
+
204
+
205
+ def _check_hooks_installed(s: DoctorState) -> CheckResult:
206
+ counts = s.hook_counts or {}
207
+ missing = [ev for ev in _REQUIRED_HOOK_EVENTS if counts.get(ev, 0) < 1]
208
+ if not missing:
209
+ return CheckResult(
210
+ id="hooks.installed", title="Hook entries installed",
211
+ severity="ok",
212
+ summary=", ".join(_REQUIRED_HOOK_EVENTS),
213
+ remediation=None,
214
+ details={"counts": {ev: counts.get(ev, 0) for ev in _REQUIRED_HOOK_EVENTS}},
215
+ )
216
+ return CheckResult(
217
+ id="hooks.installed", title="Hook entries installed",
218
+ severity="warn",
219
+ summary=f"missing {', '.join(missing)}",
220
+ remediation="Run `cctally setup`",
221
+ details={
222
+ "counts": {ev: counts.get(ev, 0) for ev in _REQUIRED_HOOK_EVENTS},
223
+ "missing": missing,
224
+ },
225
+ )
226
+
227
+
228
+ def _check_hooks_recent_activity_24h(s: DoctorState) -> CheckResult:
229
+ act = s.log_activity_24h or {"fires": 0, "errors": 0,
230
+ "by_event": {}, "last_fire_ago_s": None,
231
+ "oauth_ok": 0, "throttled": 0}
232
+ fires = act.get("fires") or 0
233
+ errors = act.get("errors") or 0
234
+ if fires == 0:
235
+ return CheckResult(
236
+ id="hooks.recent_activity_24h", title="Recent activity (24h)",
237
+ severity="warn", summary="0 fires",
238
+ remediation="No hook fired in last 24h. Restart Claude Code, or run `cctally setup`.",
239
+ details={"fires": 0, "errors": errors, "by_event": act.get("by_event") or {},
240
+ "last_fire_age_s": act.get("last_fire_ago_s")},
241
+ )
242
+ ratio = errors / fires if fires else 0.0
243
+ if ratio >= 0.5:
244
+ return CheckResult(
245
+ id="hooks.recent_activity_24h", title="Recent activity (24h)",
246
+ severity="warn",
247
+ summary=f"high error ratio ({errors}/{fires})",
248
+ remediation="Check ~/.local/share/cctally/logs/hook-tick.log",
249
+ details={"fires": fires, "errors": errors, "ratio": ratio,
250
+ "by_event": act.get("by_event") or {}},
251
+ )
252
+ return CheckResult(
253
+ id="hooks.recent_activity_24h", title="Recent activity (24h)",
254
+ severity="ok",
255
+ summary=f"{fires} fires, {errors} errors",
256
+ remediation=None,
257
+ details={"fires": fires, "errors": errors,
258
+ "by_event": act.get("by_event") or {},
259
+ "last_fire_age_s": act.get("last_fire_ago_s")},
260
+ )
261
+
262
+
263
+ def _check_hooks_last_fire_age(s: DoctorState) -> CheckResult:
264
+ act = s.log_activity_24h or {}
265
+ age = act.get("last_fire_ago_s")
266
+ if age is None:
267
+ return CheckResult(
268
+ id="hooks.last_fire_age", title="Last hook fire",
269
+ severity="warn", summary="never",
270
+ remediation="No hook has fired yet. Restart Claude Code.",
271
+ details={"last_fire_age_s": None},
272
+ )
273
+ if age > 3600:
274
+ return CheckResult(
275
+ id="hooks.last_fire_age", title="Last hook fire",
276
+ severity="warn", summary=f"{int(age)}s ago",
277
+ remediation="No hook fired in >1h. Claude Code may not be running.",
278
+ details={"last_fire_age_s": int(age)},
279
+ )
280
+ return CheckResult(
281
+ id="hooks.last_fire_age", title="Last hook fire",
282
+ severity="ok", summary=f"{int(age)}s ago",
283
+ remediation=None,
284
+ details={"last_fire_age_s": int(age)},
285
+ )
286
+
287
+
288
+ def _check_oauth_token_present(s: DoctorState) -> CheckResult:
289
+ if s.oauth_token_present:
290
+ return CheckResult(
291
+ id="oauth.token_present", title="OAuth token",
292
+ severity="ok", summary="present",
293
+ remediation=None, details={},
294
+ )
295
+ return CheckResult(
296
+ id="oauth.token_present", title="OAuth token",
297
+ severity="fail", summary="missing",
298
+ remediation="Log into Claude Code to populate the OAuth token",
299
+ details={},
300
+ )
301
+
302
+
303
+ def _db_file_check(label_id: str, label_title: str, status: Optional[dict],
304
+ rebuild_hint: str) -> CheckResult:
305
+ if status is None:
306
+ return CheckResult(
307
+ id=label_id, title=label_title,
308
+ severity="fail", summary="state unavailable",
309
+ remediation="Re-run; see stderr",
310
+ details={"reason": "gather returned None"},
311
+ )
312
+ if status.get("_open_error"):
313
+ return CheckResult(
314
+ id=label_id, title=label_title,
315
+ severity="fail", summary=f"could not open: {status['_open_error']}",
316
+ remediation=rebuild_hint,
317
+ details={"exception": status["_open_error"], "path": status["path"]},
318
+ )
319
+ if status.get("_file_exists") is False:
320
+ return CheckResult(
321
+ id=label_id, title=label_title,
322
+ severity="warn", summary="absent (fresh install)",
323
+ remediation=None,
324
+ details={"path": status["path"]},
325
+ )
326
+ return CheckResult(
327
+ id=label_id, title=label_title,
328
+ severity="ok",
329
+ summary=f"version {status['user_version']} / {status['registry_size']} known",
330
+ remediation=None,
331
+ details={"path": status["path"],
332
+ "user_version": status["user_version"],
333
+ "registry_size": status["registry_size"]},
334
+ )
335
+
336
+
337
+ def _check_db_stats_file(s: DoctorState) -> CheckResult:
338
+ return _db_file_check("db.stats.file", "stats.db", s.stats_db_status,
339
+ "Restore from backup, or `cctally setup --uninstall --purge` + re-record")
340
+
341
+
342
+ def _check_db_cache_file(s: DoctorState) -> CheckResult:
343
+ return _db_file_check("db.cache.file", "cache.db", s.cache_db_status,
344
+ "Run `cctally cache-sync --rebuild`")
345
+
346
+
347
+ def _migrations_by_status(status: Optional[dict]) -> dict[str, list[str]]:
348
+ if not status:
349
+ return {"applied": [], "skipped": [], "pending": [], "failed": []}
350
+ out = {"applied": [], "skipped": [], "pending": [], "failed": []}
351
+ for m in status.get("migrations") or []:
352
+ out.setdefault(m["status"], []).append(m["name"])
353
+ return out
354
+
355
+
356
+ def _check_db_migrations_applied(s: DoctorState) -> CheckResult:
357
+ both = {}
358
+ for db_label, st in (("stats.db", s.stats_db_status), ("cache.db", s.cache_db_status)):
359
+ both[db_label] = _migrations_by_status(st)
360
+ any_failed = any(both[d]["failed"] for d in both)
361
+ any_skipped = any(both[d]["skipped"] for d in both)
362
+ if any_failed:
363
+ failed = [(d, n) for d, info in both.items() for n in info["failed"]]
364
+ return CheckResult(
365
+ id="db.migrations.applied", title="Migrations",
366
+ severity="fail",
367
+ summary=f"{len(failed)} failed",
368
+ remediation="Run `cctally db status`; see ~/.local/share/cctally/logs/migration-errors.log",
369
+ details={"failed": failed, "by_db": both},
370
+ )
371
+ if any_skipped:
372
+ skipped = [(d, n) for d, info in both.items() for n in info["skipped"]]
373
+ return CheckResult(
374
+ id="db.migrations.applied", title="Migrations",
375
+ severity="warn",
376
+ summary=f"{len(skipped)} skipped",
377
+ remediation="Run `cctally db unskip <name>` if you want to retry",
378
+ details={"skipped": skipped, "by_db": both},
379
+ )
380
+ total_applied = sum(len(both[d]["applied"]) for d in both)
381
+ total_registered = ((s.stats_db_status or {}).get("registry_size", 0)
382
+ + (s.cache_db_status or {}).get("registry_size", 0))
383
+ return CheckResult(
384
+ id="db.migrations.applied", title="Migrations",
385
+ severity="ok",
386
+ summary=f"{total_applied}/{total_registered} applied",
387
+ remediation=None,
388
+ details={"by_db": both},
389
+ )
390
+
391
+
392
+ def _check_db_migrations_pending(s: DoctorState) -> CheckResult:
393
+ both = {db: _migrations_by_status(st)
394
+ for db, st in (("stats.db", s.stats_db_status),
395
+ ("cache.db", s.cache_db_status))}
396
+ pending = [(d, n) for d, info in both.items() for n in info["pending"]]
397
+ if not pending:
398
+ return CheckResult(
399
+ id="db.migrations.pending", title="Pending migrations",
400
+ severity="ok", summary="none pending",
401
+ remediation=None, details={},
402
+ )
403
+ return CheckResult(
404
+ id="db.migrations.pending", title="Pending migrations",
405
+ severity="warn",
406
+ summary=f"{len(pending)} pending",
407
+ remediation="Run any cctally command — opens the DB and applies pending migrations",
408
+ details={"pending": pending},
409
+ )
410
+
411
+
412
+ def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
413
+ if s.latest_snapshot_at is None:
414
+ return CheckResult(
415
+ id="data.latest_snapshot_age", title="Latest snapshot",
416
+ severity="fail", summary="never",
417
+ remediation="Check hooks are installed and Claude Code is running",
418
+ details={"latest_snapshot_at": None},
419
+ )
420
+ age_s = int((s.now_utc - s.latest_snapshot_at).total_seconds())
421
+ if age_s <= 300:
422
+ sev, rem = "ok", None
423
+ elif age_s <= 3600:
424
+ sev = "warn"
425
+ rem = "Recent but not current. Check Claude Code session is active."
426
+ else:
427
+ sev = "fail"
428
+ rem = "No snapshot in >1h. Hooks may be broken — check `cctally setup --status`."
429
+ return CheckResult(
430
+ id="data.latest_snapshot_age", title="Latest snapshot",
431
+ severity=sev, summary=f"{age_s}s ago",
432
+ remediation=rem,
433
+ details={"latest_snapshot_at": s.latest_snapshot_at.isoformat(),
434
+ "latest_snapshot_age_s": age_s},
435
+ )
436
+
437
+
438
+ def _check_data_cache_sync_state(s: DoctorState) -> CheckResult:
439
+ count = s.cache_entries_count or 0
440
+ if count == 0:
441
+ if s.claude_jsonl_present:
442
+ return CheckResult(
443
+ id="data.cache_sync_state", title="Claude cache",
444
+ severity="warn",
445
+ summary="0 entries despite JSONL files present",
446
+ remediation="Run `cctally cache-sync --rebuild`",
447
+ details={"entries": 0, "claude_jsonl_present": True},
448
+ )
449
+ return CheckResult(
450
+ id="data.cache_sync_state", title="Claude cache",
451
+ severity="ok", summary="0 entries (no JSONL corpus)",
452
+ remediation=None,
453
+ details={"entries": 0, "claude_jsonl_present": False},
454
+ )
455
+ if s.cache_last_entry_at is None:
456
+ age_s = None
457
+ else:
458
+ age_s = int((s.now_utc - s.cache_last_entry_at).total_seconds())
459
+ if age_s is not None and age_s > 24 * 3600:
460
+ return CheckResult(
461
+ id="data.cache_sync_state", title="Claude cache",
462
+ severity="warn",
463
+ summary=f"{count:,} entries; last sync {age_s}s ago (>24h)",
464
+ remediation="Run `cctally cache-sync --rebuild`",
465
+ details={"entries": count, "cache_last_entry_age_s": age_s},
466
+ )
467
+ return CheckResult(
468
+ id="data.cache_sync_state", title="Claude cache",
469
+ severity="ok",
470
+ summary=f"{count:,} entries; last sync {age_s}s ago" if age_s is not None
471
+ else f"{count:,} entries",
472
+ remediation=None,
473
+ details={"entries": count, "cache_last_entry_age_s": age_s},
474
+ )
475
+
476
+
477
+ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
478
+ count = s.codex_entries_count or 0
479
+ if count == 0 and not s.codex_jsonl_present:
480
+ return CheckResult(
481
+ id="data.codex_cache", title="Codex cache",
482
+ severity="ok", summary="none (no ~/.codex/sessions/)",
483
+ remediation=None,
484
+ details={"entries": 0, "codex_jsonl_present": False},
485
+ )
486
+ if count == 0 and s.codex_jsonl_present:
487
+ return CheckResult(
488
+ id="data.codex_cache", title="Codex cache",
489
+ severity="warn",
490
+ summary="0 entries despite Codex JSONL files present",
491
+ remediation="Run `cctally cache-sync --source codex --rebuild`",
492
+ details={"entries": 0, "codex_jsonl_present": True},
493
+ )
494
+ if s.codex_last_entry_at is None:
495
+ age_s = None
496
+ else:
497
+ age_s = int((s.now_utc - s.codex_last_entry_at).total_seconds())
498
+ if age_s is not None and age_s > 24 * 3600:
499
+ return CheckResult(
500
+ id="data.codex_cache", title="Codex cache",
501
+ severity="warn",
502
+ summary=f"{count:,} entries; last sync {age_s}s ago (>24h)",
503
+ remediation="Run `cctally cache-sync --source codex --rebuild`",
504
+ details={"entries": count, "codex_last_entry_age_s": age_s},
505
+ )
506
+ return CheckResult(
507
+ id="data.codex_cache", title="Codex cache",
508
+ severity="ok",
509
+ summary=f"{count:,} entries; last sync {age_s}s ago" if age_s is not None
510
+ else f"{count:,} entries",
511
+ remediation=None,
512
+ details={"entries": count, "codex_last_entry_age_s": age_s},
513
+ )
514
+
515
+
516
+ def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
517
+ """Invariant: for every row with ``week_start_at IS NOT NULL``,
518
+ ``week_start_date == substr(week_start_at, 1, 10)``.
519
+
520
+ Pair with migration ``004_heal_forked_week_start_date_buckets``,
521
+ which auto-merges any detected rows on the next ``open_db()``. A
522
+ non-zero count here means either (a) the migration is gated as
523
+ skipped/failed/pending or (b) a buggy writer slipped through
524
+ after the migration ran. Either way the user has a fork that
525
+ needs attention.
526
+ """
527
+ counts = s.forked_bucket_counts
528
+ if counts is None:
529
+ return CheckResult(
530
+ id="data.forked_buckets", title="Forked week buckets",
531
+ severity="fail", summary="state unavailable",
532
+ remediation="Check stats.db opens (`cctally db status`)",
533
+ details={"reason": "gather returned None"},
534
+ )
535
+ total = sum(int(counts.get(k, 0)) for k in ("usage", "cost", "milestones"))
536
+ if total == 0:
537
+ return CheckResult(
538
+ id="data.forked_buckets", title="Forked week buckets",
539
+ severity="ok", summary="none",
540
+ remediation=None,
541
+ details=dict(counts),
542
+ )
543
+ parts = [
544
+ f"{counts.get(k, 0)} {k}"
545
+ for k in ("usage", "cost", "milestones")
546
+ if counts.get(k, 0)
547
+ ]
548
+ return CheckResult(
549
+ id="data.forked_buckets", title="Forked week buckets",
550
+ severity="fail",
551
+ summary=f"{total} forked row(s): {', '.join(parts)}",
552
+ remediation=(
553
+ "Run any cctally command to trigger the auto-heal migration "
554
+ "(`004_heal_forked_week_start_date_buckets`); if it's already "
555
+ "applied, see `cctally db status`."
556
+ ),
557
+ details=dict(counts),
558
+ )
559
+
560
+
561
+
562
+ _LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
563
+
564
+
565
+ def _check_safety_dashboard_bind(s: DoctorState) -> CheckResult:
566
+ stored_ok = s.dashboard_bind_stored in _LOOPBACK_HOSTS
567
+ runtime_ok = (s.runtime_bind is None) or (s.runtime_bind in _LOOPBACK_HOSTS)
568
+ if stored_ok and runtime_ok:
569
+ suffix = f"; running: {s.runtime_bind}" if s.runtime_bind else ""
570
+ return CheckResult(
571
+ id="safety.dashboard_bind", title="Dashboard bind",
572
+ severity="ok",
573
+ summary=f"config: {s.dashboard_bind_stored}{suffix}",
574
+ remediation=None,
575
+ details={"config": s.dashboard_bind_stored,
576
+ "runtime_bind": s.runtime_bind},
577
+ )
578
+ notes = []
579
+ if not stored_ok:
580
+ notes.append(f"config: {s.dashboard_bind_stored}")
581
+ if not runtime_ok:
582
+ notes.append(f"running: {s.runtime_bind}")
583
+ rem = "Run `cctally config set dashboard.bind loopback`"
584
+ if not runtime_ok:
585
+ rem += "; restart the dashboard process if it was launched with `--host`"
586
+ rem += "."
587
+ note = ("A separate running dashboard process may have overridden via --host; "
588
+ "the CLI sees config only.") if s.runtime_bind is None else None
589
+ return CheckResult(
590
+ id="safety.dashboard_bind", title="Dashboard bind",
591
+ severity="warn", summary="; ".join(notes),
592
+ remediation=rem,
593
+ details={"config": s.dashboard_bind_stored,
594
+ "runtime_bind": s.runtime_bind,
595
+ **({"note": note} if note else {})},
596
+ )
597
+
598
+
599
+ def _check_safety_config_json_valid(s: DoctorState) -> CheckResult:
600
+ if s.config_json_error is None:
601
+ return CheckResult(
602
+ id="safety.config_json_valid", title="config.json",
603
+ severity="ok", summary="absent or parses cleanly",
604
+ remediation=None, details={},
605
+ )
606
+ return CheckResult(
607
+ id="safety.config_json_valid", title="config.json",
608
+ severity="fail",
609
+ summary=f"unreadable: {s.config_json_error}",
610
+ remediation="Fix or remove ~/.local/share/cctally/config.json",
611
+ details={"exception": s.config_json_error},
612
+ )
613
+
614
+
615
+ # Required keys per spec §3.6 + the producer code at bin/cctally:9663-9695
616
+ # (_load_update_state). `_schema` is set on every write; `current_version` and
617
+ # `latest_version` are the two semantically-meaningful fields the banner predicate
618
+ # and the doctor summary line consume.
619
+ _UPDATE_STATE_REQUIRED_KEYS = ("current_version", "latest_version")
620
+
621
+ # Required shape per spec §3.6 + bin/cctally:9725-9753 (_load_update_suppress)
622
+ # default record: {"_schema": 1, "skipped_versions": [], "remind_after": None}.
623
+ # `remind_after` is allowed to be None per the default — only its presence and
624
+ # the type when non-None are validated.
625
+ _UPDATE_SUPPRESS_REQUIRED_KEYS = ("skipped_versions", "remind_after")
626
+
627
+
628
+ def _check_safety_update_state(s: DoctorState) -> CheckResult:
629
+ if s.update_state_error is not None:
630
+ return CheckResult(
631
+ id="safety.update_state", title="update-state.json",
632
+ severity="fail", summary=f"unreadable: {s.update_state_error}",
633
+ remediation="`rm ~/.local/share/cctally/update-state.json` (will be regenerated)",
634
+ details={"exception": s.update_state_error},
635
+ )
636
+ if s.update_state is None:
637
+ return CheckResult(
638
+ id="safety.update_state", title="update-state.json",
639
+ severity="warn", summary="absent (first run)",
640
+ remediation="Run `cctally update --check` to populate",
641
+ details={},
642
+ )
643
+ # Spec §3.6: WARN when known fields are missing. Both keys are needed
644
+ # for the version-comparison banner predicate; without them the file
645
+ # exists but is semantically unusable.
646
+ missing = [k for k in _UPDATE_STATE_REQUIRED_KEYS if k not in s.update_state]
647
+ if missing:
648
+ return CheckResult(
649
+ id="safety.update_state", title="update-state.json",
650
+ severity="warn",
651
+ summary=f"missing fields: {', '.join(missing)}",
652
+ remediation="Run `cctally update --check` to refresh",
653
+ details={"missing_keys": missing,
654
+ "current_version": s.update_state.get("current_version"),
655
+ "latest_version": s.update_state.get("latest_version")},
656
+ )
657
+ return CheckResult(
658
+ id="safety.update_state", title="update-state.json",
659
+ severity="ok",
660
+ summary=f"v{s.update_state.get('current_version', '?')}",
661
+ remediation=None,
662
+ details={"current_version": s.update_state.get("current_version"),
663
+ "latest_version": s.update_state.get("latest_version")},
664
+ )
665
+
666
+
667
+ def _check_safety_update_suppress(s: DoctorState) -> CheckResult:
668
+ if s.update_suppress_error is not None:
669
+ return CheckResult(
670
+ id="safety.update_suppress", title="update-suppress.json",
671
+ severity="fail", summary=f"unreadable: {s.update_suppress_error}",
672
+ remediation="`rm ~/.local/share/cctally/update-suppress.json`",
673
+ details={"exception": s.update_suppress_error},
674
+ )
675
+ if s.update_suppress is None:
676
+ return CheckResult(
677
+ id="safety.update_suppress", title="update-suppress.json",
678
+ severity="ok", summary="absent (no deferrals)",
679
+ remediation=None, details={},
680
+ )
681
+ # Spec §3.6: WARN on "known fields missing or unexpected types". The
682
+ # producer's default record (bin/cctally:9731) defines the canonical
683
+ # shape: {"skipped_versions": [], "remind_after": None}. Anything else
684
+ # — a partial dict, wrong types — means a hand-edit or older binary
685
+ # corrupted the file.
686
+ missing = [k for k in _UPDATE_SUPPRESS_REQUIRED_KEYS if k not in s.update_suppress]
687
+ bad_types: list[str] = []
688
+ if "skipped_versions" in s.update_suppress:
689
+ if not isinstance(s.update_suppress["skipped_versions"], list):
690
+ bad_types.append("skipped_versions")
691
+ if "remind_after" in s.update_suppress:
692
+ v = s.update_suppress["remind_after"]
693
+ # The producer (bin/cctally `_do_update_remind_later`) writes
694
+ # `remind_after` as a dict `{"version", "until_utc"}`; the
695
+ # banner predicate consumes that shape. Accept it here so a
696
+ # legitimate deferral doesn't render as "bad types: remind_after".
697
+ # `None` (default record) and the legacy scalar form (older
698
+ # binaries persisted a bare until-string) both stay valid.
699
+ if v is not None and not isinstance(v, (str, int, float, dict)):
700
+ bad_types.append("remind_after")
701
+ if missing or bad_types:
702
+ bits = []
703
+ if missing:
704
+ bits.append(f"missing: {', '.join(missing)}")
705
+ if bad_types:
706
+ bits.append(f"bad types: {', '.join(bad_types)}")
707
+ return CheckResult(
708
+ id="safety.update_suppress", title="update-suppress.json",
709
+ severity="warn",
710
+ summary="; ".join(bits),
711
+ remediation="`rm ~/.local/share/cctally/update-suppress.json` (will be regenerated)",
712
+ details={"missing_keys": missing, "bad_types": bad_types},
713
+ )
714
+ return CheckResult(
715
+ id="safety.update_suppress", title="update-suppress.json",
716
+ severity="ok", summary="parses cleanly",
717
+ remediation=None,
718
+ details={"skipped_versions": s.update_suppress.get("skipped_versions") or [],
719
+ "remind_after": s.update_suppress.get("remind_after")},
720
+ )
721
+
722
+
723
+ def _check_safety_update_available(s: DoctorState) -> CheckResult:
724
+ st = s.update_state or {}
725
+ cur = st.get("current_version")
726
+ lat = st.get("latest_version")
727
+ # `effective_update_available` is precomputed by the I/O layer via
728
+ # the same predicate the update banner uses (semver + skipped +
729
+ # remind_after). If the user has skipped or deferred a newer
730
+ # version, the banner stays silent — doctor must do the same.
731
+ if not s.effective_update_available:
732
+ details = {"current_version": cur, "latest_version": lat}
733
+ reason = s.effective_update_reason
734
+ # Surface the suppression reason only when it matters — i.e.
735
+ # there *is* a newer version, but the user has opted out.
736
+ # Preserves the byte-stable details shape for the common case
737
+ # (no probe yet / no newer version) while informing verbose
738
+ # readers when a real update is being held back.
739
+ if reason in ("skipped", "reminded"):
740
+ details["suppressed"] = True
741
+ details["suppression_reason"] = reason
742
+ return CheckResult(
743
+ id="safety.update_available", title="Update available",
744
+ severity="ok", summary="no",
745
+ remediation=None,
746
+ details=details,
747
+ )
748
+ return CheckResult(
749
+ id="safety.update_available", title="Update available",
750
+ severity="warn",
751
+ summary=f"v{lat} (you are on v{cur})",
752
+ remediation="Run `cctally update`",
753
+ details={"current_version": cur, "latest_version": lat},
754
+ )
755
+
756
+
757
+ # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
758
+ # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
759
+ # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
760
+ # `_evaluate_one` uses this id — not the function name — so the synthesized
761
+ # FAIL CheckResult retains the contract id and fingerprint stays stable across
762
+ # success-vs-raise transitions.
763
+ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
764
+ ("install", "Install", (
765
+ ("install.symlinks", "_check_install_symlinks"),
766
+ ("install.path", "_check_install_path"),
767
+ ("install.legacy_snippet", "_check_install_legacy_snippet"),
768
+ ("install.legacy_bespoke_hooks", "_check_install_legacy_bespoke"),
769
+ )),
770
+ ("hooks", "Hooks", (
771
+ ("hooks.installed", "_check_hooks_installed"),
772
+ ("hooks.recent_activity_24h", "_check_hooks_recent_activity_24h"),
773
+ ("hooks.last_fire_age", "_check_hooks_last_fire_age"),
774
+ )),
775
+ ("auth", "Auth", (
776
+ ("oauth.token_present", "_check_oauth_token_present"),
777
+ )),
778
+ ("db", "Database", (
779
+ ("db.stats.file", "_check_db_stats_file"),
780
+ ("db.cache.file", "_check_db_cache_file"),
781
+ ("db.migrations.applied", "_check_db_migrations_applied"),
782
+ ("db.migrations.pending", "_check_db_migrations_pending"),
783
+ )),
784
+ ("data", "Data", (
785
+ ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
786
+ ("data.cache_sync_state", "_check_data_cache_sync_state"),
787
+ ("data.codex_cache", "_check_data_codex_cache"),
788
+ ("data.forked_buckets", "_check_data_forked_buckets"),
789
+ )),
790
+ ("safety", "Safety", (
791
+ ("safety.dashboard_bind", "_check_safety_dashboard_bind"),
792
+ ("safety.config_json_valid", "_check_safety_config_json_valid"),
793
+ ("safety.update_state", "_check_safety_update_state"),
794
+ ("safety.update_suppress", "_check_safety_update_suppress"),
795
+ ("safety.update_available", "_check_safety_update_available"),
796
+ )),
797
+ )
798
+
799
+
800
+ def _evaluate_one(check_id: str, check_fn_name: str,
801
+ state: DoctorState) -> CheckResult:
802
+ """Invoke a single check evaluator by name, catching any exception so
803
+ one bad check does not crash the whole report. Spec §7.1, §5.4.
804
+
805
+ On exception, the synthesized FAIL CheckResult uses the canonical
806
+ dotted ``check_id`` (NOT the function name) so the JSON contract
807
+ (spec §5.2) and fingerprint identity slice (spec §5.5) stay stable
808
+ across success-vs-raise transitions.
809
+ """
810
+ mod = sys.modules[__name__]
811
+ fn = getattr(mod, check_fn_name, None)
812
+ if fn is None:
813
+ return CheckResult(
814
+ id=check_id, title=check_id,
815
+ severity="fail",
816
+ summary=f"evaluator not found: {check_fn_name}",
817
+ remediation="Internal error; see bin/_lib_doctor.py",
818
+ details={"exception": f"NameError: {check_fn_name}"},
819
+ )
820
+ try:
821
+ return fn(state)
822
+ except Exception as exc: # noqa: BLE001 — deliberate broad catch per spec §7.1
823
+ return CheckResult(
824
+ id=check_id, title=check_id,
825
+ severity="fail",
826
+ summary=f"{type(exc).__name__}: {exc}",
827
+ remediation="See details.exception",
828
+ details={"exception": f"{type(exc).__name__}: {exc}"},
829
+ )
830
+
831
+
832
+ def run_checks(state: DoctorState) -> DoctorReport:
833
+ categories: list[CategoryResult] = []
834
+ counts = {"ok": 0, "warn": 0, "fail": 0}
835
+ for cat_id, cat_title, check_specs in _CATEGORY_DEFINITIONS:
836
+ results: list[CheckResult] = []
837
+ for check_id, fn_name in check_specs:
838
+ r = _evaluate_one(check_id, fn_name, state)
839
+ results.append(r)
840
+ counts[r.severity] = counts.get(r.severity, 0) + 1
841
+ cat_sev = _max_severity([r.severity for r in results])
842
+ categories.append(CategoryResult(
843
+ id=cat_id, title=cat_title,
844
+ severity=cat_sev,
845
+ checks=tuple(results),
846
+ ))
847
+ overall = _max_severity([c.severity for c in categories])
848
+ return DoctorReport(
849
+ schema_version=SCHEMA_VERSION,
850
+ generated_at=state.now_utc,
851
+ cctally_version=state.cctally_version,
852
+ overall_severity=overall,
853
+ counts=counts,
854
+ categories=tuple(categories),
855
+ )
856
+
857
+
858
+ def _iso_z(d: dt.datetime) -> str:
859
+ """Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention)."""
860
+ if d.tzinfo is None:
861
+ d = d.replace(tzinfo=dt.timezone.utc)
862
+ s = d.astimezone(dt.timezone.utc).isoformat()
863
+ return s.replace("+00:00", "Z")
864
+
865
+
866
+ def _serialize_check(c: CheckResult) -> dict:
867
+ out = {
868
+ "id": c.id,
869
+ "title": c.title,
870
+ "severity": c.severity,
871
+ "summary": c.summary,
872
+ "details": c.details,
873
+ }
874
+ if c.severity != "ok" and c.remediation:
875
+ out["remediation"] = c.remediation
876
+ return out
877
+
878
+
879
+ def serialize_json(report: DoctorReport) -> dict:
880
+ """Produce the stable JSON payload per spec §5.1-§5.2.
881
+
882
+ Top-level fields are contract; the per-check `details` block is
883
+ unstable (consumers MUST tolerate unknown keys). schema_version
884
+ bumps only on a breaking change to the stable fields.
885
+ """
886
+ return {
887
+ "schema_version": report.schema_version,
888
+ "generated_at": _iso_z(report.generated_at),
889
+ "cctally_version": report.cctally_version,
890
+ "overall": {
891
+ "severity": report.overall_severity,
892
+ "counts": dict(report.counts),
893
+ },
894
+ "categories": [
895
+ {
896
+ "id": cat.id,
897
+ "title": cat.title,
898
+ "severity": cat.severity,
899
+ "checks": [_serialize_check(c) for c in cat.checks],
900
+ }
901
+ for cat in report.categories
902
+ ],
903
+ }
904
+
905
+
906
+ def _identity_slice(report: DoctorReport) -> dict:
907
+ """The fields the fingerprint hashes over. Excludes generated_at,
908
+ cctally_version, summary text, remediation, and the entire details
909
+ block — those carry volatile values that change tick-to-tick even
910
+ when severity doesn't flip. See spec §5.5."""
911
+ return {
912
+ "schema_version": report.schema_version,
913
+ "overall_severity": report.overall_severity,
914
+ "counts": dict(report.counts),
915
+ "checks": [
916
+ [c.id, c.severity]
917
+ for cat in report.categories
918
+ for c in cat.checks
919
+ ],
920
+ }
921
+
922
+
923
+ def fingerprint(report: DoctorReport) -> str:
924
+ """Stable SHA1 over the identity slice. Same identity slice → same
925
+ fingerprint, even when ages and rendered summaries change."""
926
+ payload = json.dumps(_identity_slice(report), sort_keys=True, separators=(",", ":"))
927
+ h = hashlib.sha1(payload.encode("utf-8")).hexdigest()
928
+ return f"sha1:{h}"
929
+
930
+
931
+ _GLYPH = {"ok": "✓", "warn": "⚠", "fail": "✗"}
932
+
933
+
934
+ def render_text(report: DoctorReport, *, quiet: bool = False, verbose: bool = False) -> str:
935
+ if quiet and verbose:
936
+ raise ValueError("render_text: --quiet and --verbose are mutually exclusive")
937
+ lines: list[str] = []
938
+ ts = _iso_z(report.generated_at).replace("T", " ").replace("Z", " UTC")
939
+ lines.append(f"cctally doctor — {ts}")
940
+ lines.append("")
941
+ for cat in report.categories:
942
+ lines.append(cat.title)
943
+ for c in cat.checks:
944
+ if quiet and c.severity == "ok":
945
+ continue
946
+ glyph = _GLYPH.get(c.severity, "?")
947
+ lines.append(f" {glyph} {c.title:<24s} {c.summary}")
948
+ if c.remediation:
949
+ lines.append(f" → {c.remediation}")
950
+ if verbose and c.details:
951
+ lines.append(" details:")
952
+ for k, v in c.details.items():
953
+ lines.append(f" {k}: {v}")
954
+ lines.append("")
955
+ counts = report.counts
956
+ lines.append(
957
+ f"Summary: {counts.get('ok', 0)} OK · "
958
+ f"{counts.get('warn', 0)} WARN · "
959
+ f"{counts.get('fail', 0)} FAIL"
960
+ )
961
+ return "\n".join(lines) + "\n"