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