cctally 1.6.2 → 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.
package/bin/cctally CHANGED
@@ -10057,7 +10057,10 @@ def _persist_install_method_to_state(method: InstallMethod) -> None:
10057
10057
  _save_update_state(state)
10058
10058
 
10059
10059
 
10060
- def _stamp_install_success_to_state(installed_version: str | None) -> None:
10060
+ def _stamp_install_success_to_state(
10061
+ installed_version: str | None,
10062
+ method: "InstallMethod | None" = None,
10063
+ ) -> None:
10061
10064
  """Stamp ``update-state.json`` with the just-installed version so the
10062
10065
  post-install banner predicate + dashboard auto-close fire immediately.
10063
10066
 
@@ -10070,19 +10073,84 @@ def _stamp_install_success_to_state(installed_version: str | None) -> None:
10070
10073
  ``refreshUpdateState`` auto-close (``current === latest``) never
10071
10074
  matches.
10072
10075
 
10073
- Falls back to ``state.latest_version`` when the caller didn't pass
10074
- an explicit ``--version`` — brew is always unpinned, and an
10075
- unpinned npm install resolves to whatever ``@latest`` advertised,
10076
- which is the value we just told the user about.
10076
+ Resolution order:
10077
+ 1. ``installed_version``caller passed an explicit ``--version``.
10078
+ 2. For brew (when ``method.method == "brew"`` and no explicit
10079
+ version), ``state.latest_version`` the freshly-probed value
10080
+ that drove the install. The running process's ``CHANGELOG_PATH``
10081
+ resolved to the OLD Cellar at boot, so a CHANGELOG read here
10082
+ returns the pre-upgrade version and would stamp the wrong
10083
+ ``current_version`` until the next dashboard self-heal (up to
10084
+ 30 min) or the next CLI invocation. The stale-probe regression
10085
+ that pushed CHANGELOG ahead of ``latest_version`` (1.6.0-after-
10086
+ installing-1.6.3) does not apply on the brew path: brew's
10087
+ install probe ran inside the user's just-issued
10088
+ ``cctally update``, so ``latest_version`` is current.
10089
+ 3. Freshly-installed CHANGELOG (``_release_read_latest_release_version``).
10090
+ For npm the install overwrites ``CHANGELOG.md`` in place, so
10091
+ this read inside the same Python process returns the just-
10092
+ installed version. Skipped on brew for the reason above.
10093
+ 4. ``state.latest_version`` — last resort, also covers the npm
10094
+ path when CHANGELOG is unreadable.
10077
10095
  """
10078
10096
  state = _load_update_state() or {"_schema": 1}
10079
- cur = installed_version or state.get("latest_version")
10097
+ cur = installed_version
10098
+ if cur is None and method is not None and method.method == "brew":
10099
+ # Brew: prefer the cached probe (just observed by `cctally
10100
+ # update`) over CHANGELOG, which reads from the OLD Cellar.
10101
+ cur = state.get("latest_version")
10102
+ if cur is None:
10103
+ fresh = _release_read_latest_release_version()
10104
+ if fresh:
10105
+ cur = fresh[0]
10106
+ if cur is None:
10107
+ cur = state.get("latest_version")
10080
10108
  if cur:
10081
10109
  state["current_version"] = cur
10082
10110
  state["last_install_success_at_utc"] = _now_utc().isoformat()
10083
10111
  _save_update_state(state)
10084
10112
 
10085
10113
 
10114
+ def _self_heal_current_version() -> None:
10115
+ """Reconcile ``update-state.json``'s ``current_version`` with the
10116
+ running binary's CHANGELOG when they disagree.
10117
+
10118
+ Closes the documented gap (memory:
10119
+ ``gotcha_update_state_cache_lies_after_version_bump``) where a user
10120
+ upgrades via ``npm install -g cctally@latest`` (or any out-of-band
10121
+ path that bypasses ``cctally update``) and ``current_version``
10122
+ stays frozen on the pre-upgrade value until the next TTL probe
10123
+ fires (24h default). The dashboard's brand-version label and the
10124
+ CLI banner predicate both read ``current_version``, so users see
10125
+ a stale "you're on <old>" indefinitely.
10126
+
10127
+ Best-effort: any failure — state missing/corrupt, CHANGELOG
10128
+ unreadable, save fails — is silently swallowed. The caller is in
10129
+ a post-command hook and must never break the parent command.
10130
+
10131
+ Why not bootstrap when state is missing: a ``None`` state means no
10132
+ update probe has ever run, so we have no ``latest_version`` /
10133
+ ``install`` block to seed alongside ``current_version``. Writing a
10134
+ partial state would mask the missing-probe condition that
10135
+ ``_check_safety_update_state`` and the doctor report rely on; the
10136
+ next ``_do_update_check`` creates the file fully.
10137
+ """
10138
+ try:
10139
+ fresh = _release_read_latest_release_version()
10140
+ if fresh is None:
10141
+ return
10142
+ running = fresh[0]
10143
+ state = _load_update_state()
10144
+ if state is None:
10145
+ return
10146
+ if state.get("current_version") == running:
10147
+ return
10148
+ state["current_version"] = running
10149
+ _save_update_state(state)
10150
+ except Exception:
10151
+ pass
10152
+
10153
+
10086
10154
  # === Update subcommand: version-check pipeline (spec §3) ====================
10087
10155
  # Per-vector parsers, TTL gate, and the chokepoint `_do_update_check` that
10088
10156
  # touches the throttle marker FIRST (crash safety) before attempting any
@@ -10791,7 +10859,7 @@ def _do_update_install(
10791
10859
  if rc != 0:
10792
10860
  return 1
10793
10861
  _log_update_event(log_fd, "INSTALL_SUCCESS")
10794
- _stamp_install_success_to_state(version)
10862
+ _stamp_install_success_to_state(version, method)
10795
10863
  return 0
10796
10864
  finally:
10797
10865
  _release_update_lock(lock_fd)
@@ -10966,7 +11034,7 @@ class UpdateWorker:
10966
11034
  self._emit(run_id, {"type": "done", "success": False})
10967
11035
  return
10968
11036
  _log_update_event(log_fd, "INSTALL_SUCCESS")
10969
- _stamp_install_success_to_state(version)
11037
+ _stamp_install_success_to_state(version, method)
10970
11038
  entrypoint, exec_argv = _resolve_execvp_target()
10971
11039
  self._emit(run_id, {"type": "execvp", "argv": exec_argv})
10972
11040
  try:
@@ -11049,6 +11117,26 @@ class _DashboardUpdateCheckThread(threading.Thread):
11049
11117
  def run(self) -> None:
11050
11118
  while not self._stop.is_set():
11051
11119
  try:
11120
+ # Self-heal runs every tick (every 30 min by default),
11121
+ # NOT gated by `_is_update_check_due`'s 24h TTL. Catches
11122
+ # the case where the user upgrades the npm package
11123
+ # out-of-band (no `cctally update` invocation) — the
11124
+ # dashboard's brand-version label needs to reflect the
11125
+ # new binary without waiting up to 24h for the next
11126
+ # TTL probe. Re-publish the snapshot after a self-heal
11127
+ # write so live SSE subscribers pick up the corrected
11128
+ # `current_version` on their next envelope.
11129
+ healed_before = _load_update_state()
11130
+ _self_heal_current_version()
11131
+ healed_after = _load_update_state()
11132
+ if (
11133
+ healed_before != healed_after
11134
+ and self._hub is not None
11135
+ and self._ref is not None
11136
+ ):
11137
+ snap = self._ref.get()
11138
+ if snap is not None:
11139
+ self._hub.publish(snap)
11052
11140
  config = load_config()
11053
11141
  if _is_update_check_due(config):
11054
11142
  _do_update_check()
@@ -15056,6 +15144,9 @@ _BANNER_SUPPRESSED_COMMANDS = frozenset({
15056
15144
  "tui", # rich Live mode takes over the screen; banner would be clobbered
15057
15145
  "db", # `db status` shows failure state in its own output;
15058
15146
  # `db skip` / `db unskip` are mid-fix — banner would be redundant.
15147
+ "doctor", # consolidates migration + update banner state into its
15148
+ # own report; double-printing the banner would duplicate
15149
+ # findings doctor already surfaces structurally.
15059
15150
  # Note: `setup` carve-out handled separately (only suppressed w/o --status).
15060
15151
  # Note: `dashboard` carve-out handled separately (banner printed in cmd_dashboard).
15061
15152
  })
@@ -15113,6 +15204,58 @@ def _semver_gt(a: str, b: str) -> bool:
15113
15204
  _release_semver_sort_key(_release_parse_semver(b))
15114
15205
 
15115
15206
 
15207
+ def _compute_effective_update_available(
15208
+ state: dict[str, Any] | None,
15209
+ suppress: dict[str, Any] | None,
15210
+ now_utc: "dt.datetime",
15211
+ ) -> "tuple[bool, str | None]":
15212
+ """Shared core of "is there a *real* pending update?"
15213
+
15214
+ Returns ``(available, reason)`` where ``reason`` is:
15215
+ - ``"missing_state"`` — current/latest unknown (no probe yet)
15216
+ - ``"no_newer"`` — latest is not strictly greater than current
15217
+ - ``"skipped"`` — user has skipped the latest version
15218
+ - ``"reminded"`` — user has deferred and the window is still active
15219
+ - ``None`` — available (warn-worthy)
15220
+
15221
+ Single source of truth for both ``_should_show_update_banner`` and
15222
+ ``cctally doctor``'s ``safety.update_available`` check. Keeping this
15223
+ shared avoids the bug where doctor would advertise an update the
15224
+ banner suppresses (see review finding "Respect skipped/reminded
15225
+ updates"). Malformed ``remind_after`` fails open — matches the
15226
+ banner's pre-extraction posture: better to show a real reminder
15227
+ than to silently drop one because of a corrupt suppress file.
15228
+ """
15229
+ if state is None:
15230
+ return False, "missing_state"
15231
+ cur = state.get("current_version")
15232
+ lat = state.get("latest_version")
15233
+ if not cur or not lat:
15234
+ return False, "missing_state"
15235
+ try:
15236
+ if not _semver_gt(lat, cur):
15237
+ return False, "no_newer"
15238
+ except ValueError:
15239
+ return False, "no_newer"
15240
+ sup = suppress or {}
15241
+ if lat in sup.get("skipped_versions", []):
15242
+ return False, "skipped"
15243
+ remind = sup.get("remind_after")
15244
+ if remind is not None:
15245
+ try:
15246
+ # Hide while the deferral is active AND the user-pinned version
15247
+ # is still the latest. A newer drop overrides the deferral.
15248
+ if not _semver_gt(lat, remind["version"]):
15249
+ until = dt.datetime.fromisoformat(remind["until_utc"])
15250
+ if now_utc < until:
15251
+ return False, "reminded"
15252
+ except (KeyError, ValueError):
15253
+ # Malformed remind_after: fail-open. Better to surface a
15254
+ # real update than to silently drop it.
15255
+ pass
15256
+ return True, None
15257
+
15258
+
15116
15259
  def _should_show_update_banner(
15117
15260
  command: str | None,
15118
15261
  args: argparse.Namespace,
@@ -15130,6 +15273,10 @@ def _should_show_update_banner(
15130
15273
  subcommand inherits the suppression automatically. Adding a parallel
15131
15274
  list here would silently regress that invariant — the spec
15132
15275
  amendment for Codex finding #8 codifies this.
15276
+
15277
+ Semver + skipped + remind logic is delegated to
15278
+ :func:`_compute_effective_update_available` so ``cctally doctor``
15279
+ stays in lockstep with this predicate.
15133
15280
  """
15134
15281
  if command in _BANNER_SUPPRESSED_COMMANDS or command in _UPDATE_BANNER_EXTRA_SUPPRESSED:
15135
15282
  return False
@@ -15143,33 +15290,8 @@ def _should_show_update_banner(
15143
15290
  return False
15144
15291
  if not config.get("update", {}).get("check", {}).get("enabled", True):
15145
15292
  return False
15146
- if state is None:
15147
- return False
15148
- cur = state.get("current_version")
15149
- lat = state.get("latest_version")
15150
- if not cur or not lat:
15151
- return False
15152
- try:
15153
- if not _semver_gt(lat, cur):
15154
- return False
15155
- except ValueError:
15156
- return False
15157
- if lat in suppress.get("skipped_versions", []):
15158
- return False
15159
- remind = suppress.get("remind_after")
15160
- if remind is not None:
15161
- try:
15162
- # Hide while the deferral is active AND the user-pinned version
15163
- # is still the latest. A newer drop overrides the deferral.
15164
- if not _semver_gt(lat, remind["version"]):
15165
- until = dt.datetime.fromisoformat(remind["until_utc"])
15166
- if _now_utc() < until:
15167
- return False
15168
- except (KeyError, ValueError):
15169
- # Malformed remind_after: fail-open (banner shows). Better
15170
- # than silently dropping a real update reminder.
15171
- pass
15172
- return True
15293
+ available, _ = _compute_effective_update_available(state, suppress, _now_utc())
15294
+ return available
15173
15295
 
15174
15296
 
15175
15297
  def _format_update_banner(state: dict[str, Any]) -> str:
@@ -24128,19 +24250,303 @@ def _setup_recent_log_stats(seconds: float = 24 * 3600) -> dict:
24128
24250
  return counts
24129
24251
 
24130
24252
 
24131
- def _setup_status(args: argparse.Namespace) -> int:
24253
+ def doctor_gather_state(
24254
+ *,
24255
+ now_utc: "dt.datetime | None" = None,
24256
+ runtime_bind: "str | None" = None,
24257
+ ):
24258
+ """I/O chokepoint for `cctally doctor` (spec §7.2).
24259
+
24260
+ H1 invariant: config.json is read RAW (NOT via load_config), since
24261
+ load_config auto-creates the file on first run — a read-only
24262
+ diagnostic command must never mutate user state.
24263
+ """
24264
+ import _lib_doctor
24265
+
24266
+ if now_utc is None:
24267
+ now_utc = _now_utc()
24268
+
24269
+ # ── Install ──────────────────────────────────────────────────────
24132
24270
  repo_root = _setup_resolve_repo_root()
24133
24271
  dst_dir = _setup_local_bin_dir()
24134
- sym_state = []
24272
+ try:
24273
+ symlink_state = _setup_compute_symlink_state(repo_root, dst_dir)
24274
+ except Exception:
24275
+ symlink_state = None
24276
+ try:
24277
+ path_includes = _setup_path_includes_local_bin()
24278
+ except Exception:
24279
+ path_includes = None
24280
+ try:
24281
+ legacy_snippet = _setup_detect_legacy_snippet()
24282
+ except Exception:
24283
+ legacy_snippet = None
24284
+
24285
+ # ── Hooks ────────────────────────────────────────────────────────
24286
+ try:
24287
+ settings = _load_claude_settings()
24288
+ except SetupError:
24289
+ settings = None
24290
+ # Below: fail-soft posture for the diagnostic — any unexpected error
24291
+ # in a sub-probe degrades that field to None rather than aborting the
24292
+ # whole report.
24293
+ try:
24294
+ hook_counts = _setup_count_hook_entries(settings or {})
24295
+ except Exception:
24296
+ hook_counts = None
24297
+ try:
24298
+ legacy_bespoke = _setup_detect_legacy_bespoke_hooks(settings or {})
24299
+ except Exception:
24300
+ legacy_bespoke = None
24301
+ try:
24302
+ activity = _setup_recent_log_stats()
24303
+ except Exception:
24304
+ activity = None
24305
+
24306
+ # ── Auth ─────────────────────────────────────────────────────────
24307
+ try:
24308
+ oauth_token_present = _setup_oauth_token_present()
24309
+ except OSError:
24310
+ oauth_token_present = None
24311
+
24312
+ # ── DB ───────────────────────────────────────────────────────────
24313
+ try:
24314
+ stats_db_status = _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db")
24315
+ if not DB_PATH.exists():
24316
+ stats_db_status["_file_exists"] = False
24317
+ except sqlite3.Error as exc:
24318
+ stats_db_status = {"path": str(DB_PATH), "user_version": 0,
24319
+ "registry_size": len(_STATS_MIGRATIONS),
24320
+ "migrations": [], "_open_error": str(exc)}
24321
+ try:
24322
+ cache_db_status = _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
24323
+ if not CACHE_DB_PATH.exists():
24324
+ cache_db_status["_file_exists"] = False
24325
+ except sqlite3.Error as exc:
24326
+ cache_db_status = {"path": str(CACHE_DB_PATH), "user_version": 0,
24327
+ "registry_size": len(_CACHE_MIGRATIONS),
24328
+ "migrations": [], "_open_error": str(exc)}
24329
+
24330
+ # ── Data freshness ───────────────────────────────────────────────
24331
+ latest_snapshot_at = None
24332
+ try:
24333
+ if DB_PATH.exists():
24334
+ conn = sqlite3.connect(str(DB_PATH))
24335
+ try:
24336
+ row = conn.execute(
24337
+ "SELECT MAX(captured_at_utc) FROM weekly_usage_snapshots"
24338
+ ).fetchone()
24339
+ if row and row[0]:
24340
+ latest_snapshot_at = parse_iso_datetime(
24341
+ row[0], "weekly_usage_snapshots.captured_at_utc",
24342
+ ).astimezone(dt.timezone.utc)
24343
+ except sqlite3.OperationalError:
24344
+ pass # table missing — treat as no snapshots yet
24345
+ finally:
24346
+ conn.close()
24347
+ except Exception:
24348
+ pass
24349
+
24350
+ cache_entries_count = None
24351
+ cache_last_entry_at = None
24352
+ try:
24353
+ if CACHE_DB_PATH.exists():
24354
+ conn = sqlite3.connect(str(CACHE_DB_PATH))
24355
+ try:
24356
+ row = conn.execute(
24357
+ "SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
24358
+ ).fetchone()
24359
+ if row:
24360
+ cache_entries_count = int(row[0]) if row[0] is not None else 0
24361
+ if row[1]:
24362
+ cache_last_entry_at = parse_iso_datetime(
24363
+ row[1], "session_entries.timestamp_utc",
24364
+ ).astimezone(dt.timezone.utc)
24365
+ except sqlite3.OperationalError:
24366
+ pass # table missing — treat as zero
24367
+ finally:
24368
+ conn.close()
24369
+ except Exception:
24370
+ pass
24371
+
24372
+ claude_jsonl_present = False
24373
+ try:
24374
+ claude_dir = pathlib.Path.home() / ".claude" / "projects"
24375
+ if claude_dir.exists():
24376
+ claude_jsonl_present = next(claude_dir.glob("**/*.jsonl"), None) is not None
24377
+ except Exception:
24378
+ pass
24379
+
24380
+ codex_entries_count = None
24381
+ codex_last_entry_at = None
24382
+ try:
24383
+ if CACHE_DB_PATH.exists():
24384
+ conn = sqlite3.connect(str(CACHE_DB_PATH))
24385
+ try:
24386
+ row = conn.execute(
24387
+ "SELECT COUNT(*), MAX(timestamp_utc) FROM codex_session_entries"
24388
+ ).fetchone()
24389
+ if row:
24390
+ codex_entries_count = int(row[0]) if row[0] is not None else 0
24391
+ if row[1]:
24392
+ codex_last_entry_at = parse_iso_datetime(
24393
+ row[1], "codex_session_entries.timestamp_utc",
24394
+ ).astimezone(dt.timezone.utc)
24395
+ except sqlite3.OperationalError:
24396
+ pass
24397
+ finally:
24398
+ conn.close()
24399
+ except Exception:
24400
+ pass
24401
+
24402
+ codex_jsonl_present = False
24403
+ try:
24404
+ codex_dir = pathlib.Path.home() / ".codex" / "sessions"
24405
+ if codex_dir.exists():
24406
+ codex_jsonl_present = next(codex_dir.glob("**/*.jsonl"), None) is not None
24407
+ except Exception:
24408
+ pass
24409
+
24410
+ # ── Safety ───────────────────────────────────────────────────────
24411
+ # `dashboard.bind` is read via the same chokepoint that powers
24412
+ # `cctally config get dashboard.bind` — `_config_known_value`
24413
+ # normalizes hand-edited junk back to "loopback", matching the
24414
+ # value cmd_dashboard would actually bind to.
24415
+ #
24416
+ # Raw JSON read (NOT load_config or _load_config_unlocked): both
24417
+ # call `ensure_dirs()`, which creates `~/.local/share/cctally/`
24418
+ # and `logs/` on a fresh HOME. Doctor is a read-only diagnostic
24419
+ # (H1 invariant) — it must never mutate user state, even by
24420
+ # creating an empty directory tree. Corrupt JSON yields
24421
+ # `dashboard_bind_stored = "loopback"` (the same fallback the
24422
+ # original try/except gave); the dedicated `config_json_valid`
24423
+ # check surfaces the corruption separately.
24424
+ dashboard_bind_stored = "loopback"
24425
+ try:
24426
+ if CONFIG_PATH.exists():
24427
+ raw_cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
24428
+ if isinstance(raw_cfg, dict):
24429
+ dashboard_bind_stored = (
24430
+ _config_known_value(raw_cfg, "dashboard.bind") or "loopback"
24431
+ )
24432
+ except (json.JSONDecodeError, OSError):
24433
+ pass
24434
+
24435
+ # config.json — RAW READ, never load_config(). load_config()
24436
+ # auto-creates on first run AND silently falls back to defaults
24437
+ # on corruption — both behaviors would hide diagnostic state
24438
+ # (codex H1).
24439
+ config_json_error = None
24440
+ try:
24441
+ if CONFIG_PATH.exists():
24442
+ json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
24443
+ except json.JSONDecodeError as exc:
24444
+ config_json_error = f"{type(exc).__name__}: {exc}"
24445
+ except OSError as exc:
24446
+ config_json_error = f"OSError: {exc}"
24447
+
24448
+ update_state = None
24449
+ update_state_error = None
24450
+ try:
24451
+ update_state = _load_update_state()
24452
+ except Exception as exc:
24453
+ update_state_error = f"{type(exc).__name__}: {exc}"
24454
+
24455
+ update_suppress = None
24456
+ update_suppress_error = None
24457
+ try:
24458
+ update_suppress = _load_update_suppress()
24459
+ except Exception as exc:
24460
+ update_suppress_error = f"{type(exc).__name__}: {exc}"
24461
+
24462
+ # Same predicate the update banner uses; doctor must not warn about
24463
+ # updates the user has already skipped or deferred.
24464
+ effective_update_available, effective_update_reason = (
24465
+ _compute_effective_update_available(update_state, update_suppress, now_utc)
24466
+ )
24467
+
24468
+ # ── Meta ─────────────────────────────────────────────────────────
24469
+ cctally_version_tuple = _release_read_latest_release_version()
24470
+ cctally_version = (
24471
+ cctally_version_tuple[0] if cctally_version_tuple else "unknown"
24472
+ )
24473
+
24474
+ return _lib_doctor.DoctorState(
24475
+ symlink_state=symlink_state,
24476
+ path_includes_local_bin=path_includes,
24477
+ legacy_snippet=legacy_snippet,
24478
+ legacy_bespoke=legacy_bespoke,
24479
+ claude_settings=settings,
24480
+ hook_counts=hook_counts,
24481
+ log_activity_24h=activity,
24482
+ oauth_token_present=oauth_token_present,
24483
+ stats_db_status=stats_db_status,
24484
+ cache_db_status=cache_db_status,
24485
+ latest_snapshot_at=latest_snapshot_at,
24486
+ cache_entries_count=cache_entries_count,
24487
+ cache_last_entry_at=cache_last_entry_at,
24488
+ claude_jsonl_present=claude_jsonl_present,
24489
+ codex_entries_count=codex_entries_count,
24490
+ codex_last_entry_at=codex_last_entry_at,
24491
+ codex_jsonl_present=codex_jsonl_present,
24492
+ dashboard_bind_stored=dashboard_bind_stored,
24493
+ runtime_bind=runtime_bind,
24494
+ config_json_error=config_json_error,
24495
+ update_state=update_state,
24496
+ update_state_error=update_state_error,
24497
+ update_suppress=update_suppress,
24498
+ update_suppress_error=update_suppress_error,
24499
+ effective_update_available=effective_update_available,
24500
+ effective_update_reason=effective_update_reason,
24501
+ now_utc=now_utc,
24502
+ cctally_version=cctally_version,
24503
+ )
24504
+
24505
+
24506
+ def _setup_compute_symlink_state(
24507
+ repo_root: pathlib.Path, dst_dir: pathlib.Path,
24508
+ ) -> "list[tuple[str, str]]":
24509
+ """Per-symlink (name, state) for `_setup_status` + `doctor_gather_state`.
24510
+
24511
+ state ∈ {"ok", "wrong", "missing"}:
24512
+ - "ok": ``dst_dir/name`` is a symlink whose target is reachable.
24513
+ The target is NOT required to match ``repo_root/bin/<name>`` —
24514
+ power users routinely have the symlinks installed by one
24515
+ cctally channel (npm/brew) while running ``doctor`` from a
24516
+ parallel source clone, which would otherwise produce a 0/N
24517
+ false negative on a perfectly healthy install. The diagnostic
24518
+ question is "is `cctally-X` invokable from PATH?", not "did
24519
+ THIS checkout install the symlink?". ``_setup_create_symlinks``
24520
+ keeps its own strict equality check for install-management
24521
+ (replace-vs-already).
24522
+ - "wrong": a non-symlink file occupies the slot, or the symlink
24523
+ target is dangling.
24524
+ - "missing": nothing at ``dst_dir/name``.
24525
+
24526
+ ``repo_root`` is unused here — retained on the signature for
24527
+ call-site stability across `_setup_status` and `doctor_gather_state`.
24528
+ """
24529
+ del repo_root # unused; see docstring
24530
+ out: list[tuple[str, str]] = []
24135
24531
  for name in SETUP_SYMLINK_NAMES:
24136
24532
  dst = dst_dir / name
24137
- src = _setup_resolve_symlink_source(repo_root, name)
24138
- if dst.is_symlink() and pathlib.Path(os.readlink(dst)) == src:
24139
- sym_state.append((name, "ok"))
24533
+ if dst.is_symlink():
24534
+ try:
24535
+ dst.resolve(strict=True)
24536
+ out.append((name, "ok"))
24537
+ except (FileNotFoundError, OSError):
24538
+ out.append((name, "wrong"))
24140
24539
  elif dst.exists():
24141
- sym_state.append((name, "wrong"))
24540
+ out.append((name, "wrong"))
24142
24541
  else:
24143
- sym_state.append((name, "missing"))
24542
+ out.append((name, "missing"))
24543
+ return out
24544
+
24545
+
24546
+ def _setup_status(args: argparse.Namespace) -> int:
24547
+ repo_root = _setup_resolve_repo_root()
24548
+ dst_dir = _setup_local_bin_dir()
24549
+ sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
24144
24550
  sym_ok = sum(1 for _, s in sym_state if s == "ok")
24145
24551
  on_path = _setup_path_includes_local_bin()
24146
24552
  try:
@@ -25255,6 +25661,39 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
25255
25661
  return 3
25256
25662
 
25257
25663
 
25664
+ def cmd_doctor(args: argparse.Namespace) -> int:
25665
+ """Run all doctor checks and emit the report. Spec §4, §7.3.
25666
+
25667
+ Calls the I/O chokepoint (doctor_gather_state) → pure kernel
25668
+ (_lib_doctor.run_checks) → renderer (render_text or
25669
+ serialize_json). The argparse `add_mutually_exclusive_group`
25670
+ handles the --quiet/--verbose collision at parse time; the
25671
+ defense-in-depth check here covers programmatic invocation that
25672
+ bypasses argparse.
25673
+
25674
+ Exit code follows the loose mapping in spec §4.5: 0 unless
25675
+ overall_severity == "fail", then 2. Note that warn → 0; doctor
25676
+ is read-only and warn-class findings are advisories, not errors.
25677
+ """
25678
+ import _lib_doctor
25679
+ quiet = bool(getattr(args, "quiet", False))
25680
+ verbose = bool(getattr(args, "verbose", False))
25681
+ if quiet and verbose:
25682
+ eprint("doctor: --quiet and --verbose are mutually exclusive")
25683
+ return 2
25684
+ state = doctor_gather_state()
25685
+ report = _lib_doctor.run_checks(state)
25686
+ if getattr(args, "json", False):
25687
+ print(json.dumps(
25688
+ _lib_doctor.serialize_json(report), indent=2, sort_keys=True,
25689
+ ))
25690
+ else:
25691
+ sys.stdout.write(_lib_doctor.render_text(
25692
+ report, quiet=quiet, verbose=verbose,
25693
+ ))
25694
+ return 2 if report.overall_severity == "fail" else 0
25695
+
25696
+
25258
25697
  def cmd_setup(args: argparse.Namespace) -> int:
25259
25698
  # Migration flags are install-mode-only. Reject combinations with
25260
25699
  # --status or --uninstall (per spec Section 2 mode×flag matrix). The
@@ -27236,6 +27675,43 @@ def build_parser() -> argparse.ArgumentParser:
27236
27675
  )
27237
27676
  db_unskip.set_defaults(func=cmd_db_unskip)
27238
27677
 
27678
+ # ─── doctor (Diagnostics) ───────────────────────────────────────────
27679
+ doctor_p = sub.add_parser(
27680
+ "doctor",
27681
+ help="Diagnose data freshness and install state",
27682
+ formatter_class=CLIHelpFormatter,
27683
+ description=textwrap.dedent(
27684
+ """\
27685
+ Run all read-only diagnostic checks and emit a report.
27686
+
27687
+ Categories: install, hooks, auth, db, data, safety. Each
27688
+ category renders a severity (✓ ok / ⚠ warn / ✗ fail) and
27689
+ actionable remediation guidance for non-OK rows.
27690
+
27691
+ Exit code: 0 unless any check is FAIL (then exit 2). WARN
27692
+ rows do not change the exit code — doctor is a read-only
27693
+ diagnostic and warn-class findings are advisories.
27694
+
27695
+ See docs/commands/doctor.md for the full check inventory
27696
+ and JSON schema reference.
27697
+ """
27698
+ ),
27699
+ )
27700
+ doctor_p.add_argument(
27701
+ "--json", action="store_true",
27702
+ help="Emit machine-readable JSON to stdout (schema_version: 1)",
27703
+ )
27704
+ doctor_mutex = doctor_p.add_mutually_exclusive_group()
27705
+ doctor_mutex.add_argument(
27706
+ "--quiet", "-q", action="store_true",
27707
+ help="Hide OK rows (human mode only)",
27708
+ )
27709
+ doctor_mutex.add_argument(
27710
+ "--verbose", "-v", action="store_true",
27711
+ help="Include each check's details block (human mode only)",
27712
+ )
27713
+ doctor_p.set_defaults(func=cmd_doctor)
27714
+
27239
27715
  # ---- release (issue #24 release automation) ----
27240
27716
  p_release = sub.add_parser(
27241
27717
  "release",
@@ -29335,6 +29811,147 @@ def _share_top_projects_for_range(
29335
29811
  return [(path, cost) for path, cost in ranked[:_SHARE_TOP_PROJECTS_BUILDER_CAP]]
29336
29812
 
29337
29813
 
29814
+ def _share_all_projects_for_range(
29815
+ range_start: "dt.datetime",
29816
+ range_end: "dt.datetime",
29817
+ *,
29818
+ skip_sync: bool = True,
29819
+ ) -> dict[str, float]:
29820
+ """Like `_share_top_projects_for_range` but uncapped and unsorted.
29821
+
29822
+ Returns {project_path_or_'(unknown)': cost_usd} for every project
29823
+ active in the range. Caller orders or caps as needed. Used by
29824
+ `_share_per_block_per_project`'s fallback path so the fallback's
29825
+ accuracy matches the canonical rollup-table path (spec §7.2.1,
29826
+ issue #33).
29827
+ """
29828
+ bucket: dict[str, float] = {}
29829
+ try:
29830
+ entries = get_claude_session_entries(
29831
+ range_start, range_end, skip_sync=skip_sync,
29832
+ )
29833
+ except Exception:
29834
+ return bucket
29835
+ for entry in entries:
29836
+ usage = {
29837
+ "input_tokens": entry.input_tokens,
29838
+ "output_tokens": entry.output_tokens,
29839
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
29840
+ "cache_read_input_tokens": entry.cache_read_tokens,
29841
+ }
29842
+ cost = _calculate_entry_cost(
29843
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
29844
+ )
29845
+ key = entry.project_path or "(unknown)"
29846
+ bucket[key] = bucket.get(key, 0.0) + cost
29847
+ return bucket
29848
+
29849
+
29850
+ def _share_per_day_per_project_for_range(
29851
+ range_start: "dt.datetime",
29852
+ range_end: "dt.datetime",
29853
+ *,
29854
+ display_tz: str,
29855
+ skip_sync: bool = True,
29856
+ ) -> dict[str, dict[str, float]]:
29857
+ """Aggregate session_entries in [range_start, range_end] by
29858
+ (day-in-display_tz, project_path).
29859
+
29860
+ Returns {date_str: {project_path_or_'(unknown)': cost_usd}}. Same
29861
+ cache-first/lock-contention/direct-JSONL fallback chain as
29862
+ `_share_top_projects_for_range`. Day bucket computed in display_tz
29863
+ so the rendered row label matches. Issue #33.
29864
+ """
29865
+ try:
29866
+ tz = ZoneInfo(display_tz) if display_tz else dt.timezone.utc
29867
+ except Exception:
29868
+ tz = dt.timezone.utc
29869
+ out: dict[str, dict[str, float]] = {}
29870
+ try:
29871
+ entries = get_claude_session_entries(
29872
+ range_start, range_end, skip_sync=skip_sync,
29873
+ )
29874
+ except Exception:
29875
+ return out
29876
+ for entry in entries:
29877
+ usage = {
29878
+ "input_tokens": entry.input_tokens,
29879
+ "output_tokens": entry.output_tokens,
29880
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
29881
+ "cache_read_input_tokens": entry.cache_read_tokens,
29882
+ }
29883
+ cost = _calculate_entry_cost(
29884
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
29885
+ )
29886
+ day = entry.timestamp.astimezone(tz).strftime("%Y-%m-%d")
29887
+ proj = entry.project_path or "(unknown)"
29888
+ out.setdefault(day, {})
29889
+ out[day][proj] = out[day].get(proj, 0.0) + cost
29890
+ return out
29891
+
29892
+
29893
+ def _share_per_block_per_project(
29894
+ recent_blocks: list[dict],
29895
+ ) -> dict[str, dict[str, float]]:
29896
+ """Aggregate per-block per-project costs from `five_hour_block_projects`.
29897
+
29898
+ Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
29899
+ Block.start_at → five_hour_window_key via `_canonical_5h_window_key`
29900
+ (10-min floor; same chokepoint as `maybe_update_five_hour_block`,
29901
+ per CLAUDE.md "5-hour windows" gotcha — never derive a third key shape).
29902
+
29903
+ Fallback (rollup empty/unreadable): per-block sweep over
29904
+ `_share_all_projects_for_range` — uncapped, accuracy parity with the
29905
+ canonical path. Fires only during the first tick after fresh install
29906
+ or before stats-migration `002_five_hour_block_projects_backfill_v1`
29907
+ completes. Issue #33.
29908
+ """
29909
+ if not recent_blocks:
29910
+ return {}
29911
+ out: dict[str, dict[str, float]] = {}
29912
+ keys: list[int] = []
29913
+ iso_by_key: dict[int, str] = {}
29914
+ for b in recent_blocks:
29915
+ try:
29916
+ ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
29917
+ except (ValueError, KeyError):
29918
+ continue
29919
+ wk = _canonical_5h_window_key(int(ts.timestamp()))
29920
+ keys.append(wk)
29921
+ iso_by_key[wk] = b["start_at"]
29922
+ if not keys:
29923
+ return out
29924
+ try:
29925
+ conn = open_db()
29926
+ placeholders = ",".join("?" for _ in keys)
29927
+ rows = conn.execute(
29928
+ f"SELECT five_hour_window_key, project_path, cost_usd "
29929
+ f"FROM five_hour_block_projects "
29930
+ f"WHERE five_hour_window_key IN ({placeholders})",
29931
+ keys,
29932
+ ).fetchall()
29933
+ for wk, project_path, cost in rows:
29934
+ block_iso = iso_by_key.get(wk)
29935
+ if block_iso is None:
29936
+ continue
29937
+ proj = project_path or "(unknown)"
29938
+ out.setdefault(block_iso, {})
29939
+ out[block_iso][proj] = out[block_iso].get(proj, 0.0) + float(cost)
29940
+ if out:
29941
+ return out
29942
+ except (sqlite3.DatabaseError, OSError):
29943
+ pass
29944
+ # Fallback: per-block uncapped session_entries sweep.
29945
+ for b in recent_blocks:
29946
+ try:
29947
+ ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
29948
+ except (ValueError, KeyError):
29949
+ continue
29950
+ end = ts + dt.timedelta(hours=5)
29951
+ out[b["start_at"]] = _share_all_projects_for_range(ts, end)
29952
+ return out
29953
+
29954
+
29338
29955
  def _build_share_panel_data(panel: str, options: dict,
29339
29956
  snap: "DataSnapshot | None") -> dict:
29340
29957
  """Dispatch to the per-panel builder; reuses the dashboard DataSnapshot.
@@ -29414,12 +30031,19 @@ def _build_weekly_share_panel_data(options: dict,
29414
30031
  ws_dt = we_dt = None
29415
30032
  if ws_dt is not None and we_dt is not None:
29416
30033
  top_projects = _share_top_projects_for_range(ws_dt, we_dt)
30034
+ # Per-week × per-model breakdown (issue #33 cross-tab Detail).
30035
+ models_list = getattr(r, "models", None) or []
30036
+ models = {
30037
+ (m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
30038
+ for m in models_list
30039
+ }
29417
30040
  weeks.append({
29418
30041
  "start_date": start_date,
29419
30042
  "cost_usd": cost,
29420
30043
  "pct_used": used_pct,
29421
30044
  "dollar_per_pct": dpp,
29422
30045
  "top_projects": top_projects,
30046
+ "models": models,
29423
30047
  })
29424
30048
  if not weeks:
29425
30049
  weeks = [_share_empty_week_stub()]
@@ -29556,6 +30180,16 @@ def _build_daily_share_panel_data(options: dict,
29556
30180
  "pct_of_period": cost / total,
29557
30181
  "top_model": top_model,
29558
30182
  })
30183
+ # `days[*].date` is bucketed in display_tz by `_dashboard_build_daily_panel`,
30184
+ # so the query window must use display-tz midnights too — otherwise entries
30185
+ # near midnight (up to ±UTC-offset hours) get queried under the wrong UTC
30186
+ # day and either spill into Other or vanish from cross-tab cells while
30187
+ # still counted in the row total.
30188
+ display_tz_name = options.get("display_tz", "Etc/UTC")
30189
+ try:
30190
+ _range_tz = ZoneInfo(display_tz_name) if display_tz_name else dt.timezone.utc
30191
+ except Exception:
30192
+ _range_tz = dt.timezone.utc
29559
30193
  # Daily top_projects: aggregate over the 7-day window. Derive the
29560
30194
  # range from the dates rendered above so the rollup covers exactly
29561
30195
  # what the panel shows (rather than re-deriving "7 days ago" from
@@ -29563,19 +30197,43 @@ def _build_daily_share_panel_data(options: dict,
29563
30197
  top_projects: list[tuple[str, float]] = []
29564
30198
  if days:
29565
30199
  try:
29566
- range_start = dt.datetime.fromisoformat(
29567
- f"{days[0]['date']}T00:00:00+00:00"
30200
+ first_date = dt.date.fromisoformat(days[0]["date"])
30201
+ last_date = dt.date.fromisoformat(days[-1]["date"])
30202
+ range_start = dt.datetime(
30203
+ first_date.year, first_date.month, first_date.day,
30204
+ tzinfo=_range_tz,
29568
30205
  )
29569
30206
  # Include the last day in full — end-exclusive boundary at
29570
- # the start of the next UTC day.
29571
- last_date = dt.date.fromisoformat(days[-1]["date"])
30207
+ # the start of the next display-tz day.
29572
30208
  range_end = dt.datetime(
29573
30209
  last_date.year, last_date.month, last_date.day,
29574
- tzinfo=dt.timezone.utc,
30210
+ tzinfo=_range_tz,
29575
30211
  ) + dt.timedelta(days=1)
29576
30212
  top_projects = _share_top_projects_for_range(range_start, range_end)
29577
30213
  except (ValueError, KeyError):
29578
30214
  top_projects = []
30215
+ # Per-day × per-project breakdown (issue #33 cross-tab Detail).
30216
+ per_day_per_project: dict[str, dict[str, float]] = {}
30217
+ if days:
30218
+ try:
30219
+ first_date = dt.date.fromisoformat(days[0]["date"])
30220
+ last_date = dt.date.fromisoformat(days[-1]["date"])
30221
+ pdpp_range_start = dt.datetime(
30222
+ first_date.year, first_date.month, first_date.day,
30223
+ tzinfo=_range_tz,
30224
+ )
30225
+ pdpp_range_end = dt.datetime(
30226
+ last_date.year, last_date.month, last_date.day,
30227
+ tzinfo=_range_tz,
30228
+ ) + dt.timedelta(days=1)
30229
+ per_day_per_project = _share_per_day_per_project_for_range(
30230
+ pdpp_range_start, pdpp_range_end,
30231
+ display_tz=display_tz_name,
30232
+ )
30233
+ except (ValueError, KeyError):
30234
+ per_day_per_project = {}
30235
+ for d in days:
30236
+ d["projects"] = per_day_per_project.get(d["date"], {})
29579
30237
  return {"days": days, "top_projects": top_projects}
29580
30238
 
29581
30239
 
@@ -29594,13 +30252,19 @@ def _build_monthly_share_panel_data(options: dict,
29594
30252
  rows = list(reversed(rows))
29595
30253
  months: list[dict] = []
29596
30254
  for r in rows:
29597
- models = getattr(r, "models", None) or []
29598
- top_model = (models[0].get("model") if models else None) or "—"
30255
+ models_list = getattr(r, "models", None) or []
30256
+ top_model = (models_list[0].get("model") if models_list else None) or "—"
30257
+ # Per-month × per-model breakdown (issue #33 cross-tab Detail).
30258
+ models = {
30259
+ (m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
30260
+ for m in models_list
30261
+ }
29599
30262
  months.append({
29600
30263
  "month": getattr(r, "label", "") or "", # "YYYY-MM"
29601
30264
  "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
29602
30265
  "pct_used": 0.0,
29603
30266
  "top_model": top_model,
30267
+ "models": models,
29604
30268
  })
29605
30269
  # Monthly top_projects: aggregate across the entire 12-month window.
29606
30270
  # Range = [first day of oldest month, last day of newest month + 1].
@@ -29755,6 +30419,10 @@ def _build_blocks_share_panel_data(options: dict,
29755
30419
  top_projects = _share_top_projects_for_range(range_start, range_end)
29756
30420
  except (ValueError, KeyError):
29757
30421
  top_projects = []
30422
+ # Per-block × per-project breakdown (issue #33 cross-tab Detail).
30423
+ per_block_per_project = _share_per_block_per_project(recent)
30424
+ for r in recent:
30425
+ r["projects"] = per_block_per_project.get(r["start_at"], {})
29758
30426
  return {
29759
30427
  "current_block": cb,
29760
30428
  "recent_blocks": recent,
@@ -32471,7 +33139,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
32471
33139
  now_utc: "dt.datetime",
32472
33140
  monotonic_now: "float | None" = None,
32473
33141
  oauth_usage_cfg: "dict | None" = None,
32474
- display_tz_pref_override: "str | None" = None) -> dict:
33142
+ display_tz_pref_override: "str | None" = None,
33143
+ runtime_bind: "str | None" = None) -> dict:
32475
33144
  """Serialize a DataSnapshot into the JSON envelope consumed by the
32476
33145
  browser (design spec §2.2).
32477
33146
 
@@ -32803,6 +33472,32 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
32803
33472
  "suppress": _update_suppress_envelope,
32804
33473
  }
32805
33474
 
33475
+ # Doctor aggregate block (spec §5.5). Only the small severity tree
33476
+ # rides on every SSE tick (~120 bytes); the full per-check payload
33477
+ # is fetched lazily via `GET /api/doctor`. `runtime_bind` is the
33478
+ # actual host the dashboard process is bound to (Codex H4) so
33479
+ # `safety.dashboard_bind` reflects the running state, not just
33480
+ # `config.json`. Defensive: never crash the snapshot pipeline on
33481
+ # a doctor failure — surface a synthetic FAIL block with `_error`.
33482
+ try:
33483
+ import _lib_doctor as _ld
33484
+ _doc_state = doctor_gather_state(now_utc=now_utc, runtime_bind=runtime_bind)
33485
+ _doc_report = _ld.run_checks(_doc_state)
33486
+ doctor_envelope: "dict" = {
33487
+ "severity": _doc_report.overall_severity,
33488
+ "counts": dict(_doc_report.counts),
33489
+ "generated_at": _ld._iso_z(_doc_report.generated_at),
33490
+ "fingerprint": _ld.fingerprint(_doc_report),
33491
+ }
33492
+ except Exception as exc: # noqa: BLE001 — never crash SSE on doctor failure
33493
+ doctor_envelope = {
33494
+ "severity": "fail",
33495
+ "counts": {"ok": 0, "warn": 0, "fail": 1},
33496
+ "generated_at": _iso_z(now_utc),
33497
+ "fingerprint": "sha1:" + ("0" * 40),
33498
+ "_error": f"{type(exc).__name__}: {exc}",
33499
+ }
33500
+
32806
33501
  return {
32807
33502
  "envelope_version": 2,
32808
33503
  "generated_at": _iso_z(snap.generated_at),
@@ -32939,6 +33634,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
32939
33634
  # dashboard client's existing coerceUpdateState/Suppress logic
32940
33635
  # consumes both surfaces uniformly.
32941
33636
  "update": update_envelope,
33637
+
33638
+ # Doctor aggregate-only block (spec §5.5). Full per-check
33639
+ # report fetched lazily via GET /api/doctor.
33640
+ "doctor": doctor_envelope,
32942
33641
  }
32943
33642
 
32944
33643
 
@@ -33000,6 +33699,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
33000
33699
  GET /api/events → SSE stream (Task 5)
33001
33700
  GET /api/session/:id → per-session detail JSON (v2)
33002
33701
  GET /api/block/:start_at → per-block detail JSON (v3)
33702
+ GET /api/doctor → full doctor report JSON (spec §5.6)
33003
33703
  POST /api/sync → trigger a sync tick (v2)
33004
33704
  Anything else → 404.
33005
33705
 
@@ -33025,6 +33725,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
33025
33725
  # routes through `_apply_display_tz_override` so the override wins
33026
33726
  # over `config.display.tz`. Set by cmd_dashboard before serve_forever.
33027
33727
  display_tz_pref_override: "str | None" = None
33728
+ # Doctor (spec §5.5, §7.4): the host the dashboard process is
33729
+ # ACTUALLY bound to (`args.host`). Threaded into `doctor_gather_state`
33730
+ # so `safety.dashboard_bind` reflects the running state — the CLI
33731
+ # path leaves this None and only sees the `config.json` view, which
33732
+ # is the Codex H4 finding. Set by cmd_dashboard before serve_forever.
33733
+ cctally_host: "str | None" = None
33028
33734
 
33029
33735
  # Silence the default per-request access log — noisy in the parent
33030
33736
  # terminal, and we pipe it through our own logger in cmd_dashboard.
@@ -33093,6 +33799,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
33093
33799
  self._handle_share_presets_get()
33094
33800
  elif path == "/api/share/history":
33095
33801
  self._handle_share_history_get()
33802
+ elif path == "/api/doctor":
33803
+ self._handle_get_doctor()
33096
33804
  else:
33097
33805
  self.send_error(404, "not found")
33098
33806
 
@@ -33776,6 +34484,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
33776
34484
  "field": "options",
33777
34485
  })
33778
34486
  return
34487
+ # Client `ShareOptions` (dashboard/web/src/share/types.ts) does
34488
+ # not carry `display_tz`; server-side config is the source of
34489
+ # truth. Inject before `_share_apply_period_override` so the
34490
+ # daily panel rebuild and per-day cross-tab bucketing both see
34491
+ # the user's display tz instead of falling back to UTC.
34492
+ if "display_tz" not in options:
34493
+ options["display_tz"] = get_display_tz_pref(load_config())
33779
34494
  if not isinstance(panel, str) or not panel:
33780
34495
  self._respond_json(400, {
33781
34496
  "error": "missing or non-string panel",
@@ -33984,6 +34699,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
33984
34699
  ls = _share_load_lib()
33985
34700
  snap_ref = type(self).snapshot_ref
33986
34701
  data_snap = snap_ref.get() if snap_ref is not None else None
34702
+ # Resolve display_tz from config once (client `ShareOptions`
34703
+ # does not carry it); applied to every section's options below
34704
+ # so daily panel rebuilds and per-day cross-tab cells bucket in
34705
+ # the user's display tz, not UTC.
34706
+ composite_display_tz = get_display_tz_pref(load_config())
33987
34707
 
33988
34708
  composed_sections: list = []
33989
34709
  section_results: list[dict] = []
@@ -34037,6 +34757,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
34037
34757
  composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
34038
34758
  "theme": theme, "format": fmt,
34039
34759
  "no_branding": no_branding}
34760
+ composite_opts.setdefault("display_tz", composite_display_tz)
34040
34761
  # Per-section period override — each basket item carries its
34041
34762
  # own period recipe, independent of the composite anon flag.
34042
34763
  sec_snap, period_err = _share_apply_period_override(
@@ -34473,10 +35194,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
34473
35194
  cfg_oauth = dict(_OAUTH_USAGE_DEFAULTS)
34474
35195
  env = snapshot_to_envelope(
34475
35196
  snap,
34476
- now_utc=dt.datetime.now(dt.timezone.utc),
35197
+ # `_now_utc()` honors CCTALLY_AS_OF for harness determinism;
35198
+ # zero production impact (the env var is never set outside
35199
+ # fixture tests). Without this, the doctor block's now_utc
35200
+ # flows from wall clock and severity flips on borderline-age
35201
+ # checks between parallel test runs, churning goldens.
35202
+ now_utc=_now_utc(),
34477
35203
  monotonic_now=time.monotonic(),
34478
35204
  oauth_usage_cfg=cfg_oauth,
34479
35205
  display_tz_pref_override=type(self).display_tz_pref_override,
35206
+ runtime_bind=type(self).cctally_host,
34480
35207
  )
34481
35208
  body = json.dumps(env, ensure_ascii=False).encode("utf-8")
34482
35209
  self.send_response(200)
@@ -34486,6 +35213,40 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
34486
35213
  self.end_headers()
34487
35214
  self.wfile.write(body)
34488
35215
 
35216
+ def _handle_get_doctor(self) -> None:
35217
+ """`GET /api/doctor` — full kernel-serialized doctor report (spec §5.6).
35218
+
35219
+ Lazy companion to the aggregate-only `doctor` block on the SSE
35220
+ envelope. Re-runs the gather + checks fresh on every call (cheap;
35221
+ no per-tick cache needed — the kernel's identity-slice fingerprint
35222
+ carries the dedup story for the SSE channel, not this endpoint).
35223
+ Passes `runtime_bind = cctally_host` so `safety.dashboard_bind`
35224
+ sees the bind the dashboard is ACTUALLY serving (Codex H4 — CLI
35225
+ path stays config-only).
35226
+
35227
+ GET endpoints are read-only and loopback-protected by the
35228
+ dashboard's default bind; no CSRF gating here (mirrors
35229
+ `/api/data`, `/api/session/:id`, `/api/block/:start_at`).
35230
+ """
35231
+ try:
35232
+ import _lib_doctor as _ld
35233
+ state = doctor_gather_state(
35234
+ runtime_bind=type(self).cctally_host,
35235
+ )
35236
+ report = _ld.run_checks(state)
35237
+ body = json.dumps(
35238
+ _ld.serialize_json(report), ensure_ascii=False,
35239
+ ).encode("utf-8")
35240
+ self.send_response(200)
35241
+ self.send_header("Content-Type", "application/json; charset=utf-8")
35242
+ self.send_header("Content-Length", str(len(body)))
35243
+ self.send_header("Cache-Control", "no-cache")
35244
+ self.end_headers()
35245
+ self.wfile.write(body)
35246
+ except Exception as exc: # noqa: BLE001
35247
+ self.log_error("/api/doctor failed: %r", exc)
35248
+ self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
35249
+
34489
35250
  def _handle_get_session_detail(self, path: str) -> None:
34490
35251
  """Return TuiSessionDetail JSON for the given session id (spec §3.2).
34491
35252
 
@@ -34666,6 +35427,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
34666
35427
  monotonic_now=time.monotonic(),
34667
35428
  oauth_usage_cfg=cfg_oauth,
34668
35429
  display_tz_pref_override=type(self).display_tz_pref_override,
35430
+ runtime_bind=type(self).cctally_host,
34669
35431
  )
34670
35432
  msg = (
34671
35433
  "event: update\n"
@@ -36834,6 +37596,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
36834
37596
  DashboardHTTPHandler.sync_lock = sync_lock
36835
37597
  DashboardHTTPHandler.no_sync = bool(args.no_sync)
36836
37598
  DashboardHTTPHandler.display_tz_pref_override = display_tz_pref_override
37599
+ # Doctor (spec §7.4 / Codex H4): runtime bind, so safety.dashboard_bind
37600
+ # in the doctor SSE block + /api/doctor reflects the actual --host
37601
+ # the process is serving, not just the config-only view the CLI sees.
37602
+ DashboardHTTPHandler.cctally_host = args.host
36837
37603
  DashboardHTTPHandler.run_sync_now = staticmethod(
36838
37604
  lambda: _run_sync_now(skip_sync=args.no_sync)
36839
37605
  )
@@ -37391,9 +38157,26 @@ def _post_command_update_hooks(command: str | None, args) -> None:
37391
38157
  ``ensure_dirs()`` first-run path, leaving a stub config.json behind
37392
38158
  and breaking the post-purge "data dir gone" invariant. Skip the
37393
38159
  hook entirely for that command — the banner is moot post-uninstall
37394
- anyway."""
38160
+ anyway.
38161
+
38162
+ Skip-for-doctor: ``doctor`` is a read-only diagnostic by contract.
38163
+ ``load_config()`` would call ``ensure_dirs()`` and write a stub
38164
+ ``config.json`` on a fresh HOME; ``_spawn_background_update_check``
38165
+ would write ``update-state.json`` and ``update.log``. Adding doctor
38166
+ to ``_BANNER_SUPPRESSED_COMMANDS`` only silences the banner — it
38167
+ does not stop these side effects. Doctor reads update state for
38168
+ its report via the gather layer; it must not refresh that state
38169
+ opportunistically. Users who want a fresh check have
38170
+ ``cctally update --check``."""
37395
38171
  if command == "setup" and getattr(args, "uninstall", False):
37396
38172
  return
38173
+ if command == "doctor":
38174
+ return
38175
+ # Self-heal: reconcile current_version with the running binary's
38176
+ # CHANGELOG. Cheap (one CHANGELOG read, write only on the first
38177
+ # command after a manual upgrade). Runs before load_config so a
38178
+ # bumped version is visible to the banner predicate immediately.
38179
+ _self_heal_current_version()
37397
38180
  config = load_config()
37398
38181
  state = _load_update_state()
37399
38182
  suppress = _load_update_suppress()