cctally 1.65.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -91,6 +91,11 @@ from _cctally_core import (
91
91
  parse_iso_datetime,
92
92
  )
93
93
 
94
+ # #279 S2 F2: demonstration adopter of the stdlib-logging chokepoint —
95
+ # the migration gate-defer diagnostic routes through _lib_log so
96
+ # CCTALLY_DEBUG verbosity is decided in one place.
97
+ import _lib_log
98
+
94
99
  # Stats migration 008 needs the same per-entry cost computation used by
95
100
  # the live cost-report path. Direct import keeps the kernel single-sourced
96
101
  # (no shim drift); _lib_pricing is a stdlib-only leaf module so no cycle
@@ -237,6 +242,19 @@ class ProdMigrationRefused(Exception):
237
242
  )
238
243
 
239
244
 
245
+ class StatsDbCorruptError(sqlite3.DatabaseError):
246
+ """stats.db failed the open-time probe (#279 S1 F4).
247
+
248
+ Subclasses ``sqlite3.DatabaseError`` so graceful-degrade sites (doctor,
249
+ dashboard/TUI background threads, the 5h-anchor fallback) keep treating it
250
+ as a DB failure exactly as before — but command-level handlers that map DB
251
+ errors to OTHER exit codes must re-raise it so the global exit-2 diagnosis
252
+ wins (``cmd_record_credit`` does; its documented DB-error exit is 3). NOT
253
+ auto-recreated: stats.db is the non-re-derivable DB (recorded usage
254
+ history), unlike cache.db.
255
+ """
256
+
257
+
240
258
  class MigrationGateNotMet(Exception):
241
259
  """Migration cannot run yet because a cross-DB prerequisite is unsatisfied.
242
260
 
@@ -383,12 +401,44 @@ def _make_migration_decorator(registry: list[Migration], db_label: str, name: st
383
401
 
384
402
 
385
403
  def stats_migration(name: str):
386
- """Register a stats.db migration. Use as @stats_migration("NNN_descriptive_name")."""
404
+ """Register a stats.db migration. Use as @stats_migration("NNN_descriptive_name").
405
+
406
+ HANDLER CONTRACT — idempotency is MANDATORY, not best-effort. The
407
+ dispatcher applies a handler and stamps its ``schema_migrations`` marker in
408
+ a TWO-TRANSACTION window (``m.handler(conn)`` commits the data change, THEN
409
+ ``_stamp_applied`` writes the marker in a separate transaction — see
410
+ ``_run_pending_migrations``). A crash BETWEEN those two commits leaves the
411
+ data change applied but unmarked, so the NEXT ``open_db()`` re-invokes the
412
+ handler. A handler that is not idempotent (re-derives from already-migrated
413
+ state, double-applies, or raises on a second run) corrupts or bricks the DB
414
+ on that retry. Every handler MUST therefore be safe to run again on its own
415
+ output: probe-then-return on the already-applied shape (a fast-path guard
416
+ like ``if 'col' in cols(...): return``), or use ``INSERT OR IGNORE`` /
417
+ ``UPDATE … WHERE not-yet-migrated`` / upsert idioms. The same contract
418
+ covers a ``MigrationGateNotMet`` deferral: the dispatcher leaves the
419
+ migration pending and re-runs it on a later open, which is just another
420
+ handler retry. Carve-out: cache ``001_dedup_highest_wins`` stamps its marker
421
+ ATOMICALLY inside its own ``BEGIN IMMEDIATE`` (the destructive wipe and the
422
+ stamp must commit together), so it has no two-transaction window — but it is
423
+ still idempotent (its in-txn ``already_applied`` re-check). Per-migration
424
+ goldens (``tests/test_*_per_migration_goldens.py``) pin the second-invocation
425
+ no-op; the registry-completeness guard enforces one per migration.
426
+ """
387
427
  return _make_migration_decorator(_STATS_MIGRATIONS, "stats.db", name)
388
428
 
389
429
 
390
430
  def cache_migration(name: str):
391
- """Register a cache.db migration. Use as @cache_migration("NNN_descriptive_name")."""
431
+ """Register a cache.db migration. Use as @cache_migration("NNN_descriptive_name").
432
+
433
+ HANDLER CONTRACT — idempotency is MANDATORY. See ``stats_migration`` above
434
+ for the full statement: the dispatcher's handler-commit → marker-stamp
435
+ two-transaction window means a crash (or a ``MigrationGateNotMet`` deferral)
436
+ re-invokes the handler on its own output, so every handler must be safe to
437
+ re-run (probe-then-return / ``INSERT OR IGNORE`` / not-yet-migrated guards).
438
+ The flag-only re-ingest cache migrations (003-007, 009, 014, 017) satisfy it
439
+ by ``_set_cache_meta`` upsert; ``001_dedup_highest_wins`` is the lone
440
+ atomic-stamp carve-out.
441
+ """
392
442
  return _make_migration_decorator(_CACHE_MIGRATIONS, "cache.db", name)
393
443
 
394
444
 
@@ -480,7 +530,7 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
480
530
  never trip it; HOME-faking-immune via _real_prod_data_dir (password DB, not
481
531
  $HOME); suppressor-INDEPENDENT raw .git check so it still fires under the
482
532
  test-suite's CCTALLY_DISABLE_DEV_AUTODETECT. Escape: CCTALLY_ALLOW_PROD_MIGRATION."""
483
- if os.environ.get("CCTALLY_ALLOW_PROD_MIGRATION"):
533
+ if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
484
534
  return False
485
535
  if not (_cctally_core._repo_root() / ".git").exists():
486
536
  return False
@@ -875,8 +925,8 @@ def _run_pending_migrations(
875
925
  # outcome (apply OR gate-defer) clears any prior failure log
876
926
  # for this migration's qualified name.
877
927
  _clear_migration_error_log_entries(qualified_name)
878
- if os.environ.get("CCTALLY_DEBUG"):
879
- eprint(
928
+ if _lib_log.debug_enabled():
929
+ _lib_log.get_logger("db").debug(
880
930
  f"[migration {qualified_name}] deferred: {gate_exc}"
881
931
  )
882
932
  # D2 — ``continue``, NOT ``break``. A gate-defer leaves the DB
@@ -2350,6 +2400,16 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2350
2400
  ON conversation_messages(source_path);
2351
2401
  CREATE INDEX IF NOT EXISTS idx_conv_turnkey
2352
2402
  ON conversation_messages(msg_id, req_id);
2403
+ -- #289: partial covering index on cwd. build_anon_plan_for_db sources
2404
+ -- anonymization tokens with `SELECT DISTINCT cwd FROM conversation_messages
2405
+ -- WHERE cwd IS NOT NULL AND cwd != ''`; without this index that DISTINCT is
2406
+ -- a full scan of the whole (prose-heavy) table (~20s cold on a 5.5 GB
2407
+ -- cache). The partial `(cwd)` index matches the WHERE exactly so the
2408
+ -- DISTINCT is answered by an ordered index-only walk. MIRRORS migration 021
2409
+ -- (base schema here for fresh/rebuilt caches, migration for existing ones).
2410
+ CREATE INDEX IF NOT EXISTS idx_conversation_messages_cwd
2411
+ ON conversation_messages(cwd)
2412
+ WHERE cwd IS NOT NULL AND cwd != '';
2353
2413
 
2354
2414
  -- #193: per-session AI-generated title, isolated from the six places
2355
2415
  -- that iterate conversation_messages. The explicit NOT NULL on the
@@ -2500,6 +2560,24 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2500
2560
  conn.execute(
2501
2561
  "CREATE INDEX IF NOT EXISTS idx_entries_mutation_seq "
2502
2562
  "ON session_entries(mutation_seq, mutation_min_ts)")
2563
+ # #279 S3 F3: DB-level idempotency backstop mirroring
2564
+ # codex_session_entries' UNIQUE(source_path, line_offset). Guarded and
2565
+ # OUTSIDE the top executescript: this function runs on EVERY open BEFORE the
2566
+ # migration dispatcher, so a legacy cache.db holding historical
2567
+ # physical-key duplicates must tolerate the index being ABSENT until cache
2568
+ # migration 020 dedups it — an unguarded CREATE UNIQUE INDEX here would
2569
+ # brick every open of such a DB before 020 could ever run. Fresh and clean
2570
+ # DBs get the index immediately; a dirty legacy one gets it from 020 (or the
2571
+ # first open after 020 dedups the duplicates). Kept after the
2572
+ # add_column_if_missing calls (like idx_entries_mutation_seq) so the columns
2573
+ # exist, and BEFORE the legacy-FTS early-return so an old-shape DB still
2574
+ # receives it.
2575
+ try:
2576
+ conn.execute(
2577
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_physical "
2578
+ "ON session_entries(source_path, line_offset)")
2579
+ except sqlite3.IntegrityError:
2580
+ pass
2503
2581
  # Existing-DB guard for the skill-content fold link (cctally-dev
2504
2582
  # skill-content-nesting): the message-level sourceToolUseID. Idempotent
2505
2583
  # column-add (no marker, no version); cache migration 006 then re-ingests
@@ -3865,6 +3943,81 @@ def _019_create_conversation_file_touches(conn: sqlite3.Connection) -> None:
3865
3943
  conn.commit()
3866
3944
 
3867
3945
 
3946
+ @cache_migration("020_session_entries_physical_unique")
3947
+ def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
3948
+ """#279 S3 F3: dedup historical (source_path, line_offset) duplicates
3949
+ (keep-first-id) and ensure the physical-key UNIQUE backstop index exists.
3950
+
3951
+ Duplicated physical keys are strictly ingest-bug artifacts (spec §4: correct
3952
+ offset bookkeeping cannot produce them) with no principled winner; MIN(id)
3953
+ keeps the first-ingested row, matching the pre-bug state. cache.db is
3954
+ re-derivable — `cache-sync --rebuild` remains the escape hatch. Mirrors
3955
+ cache 001's flock-then-BEGIN-IMMEDIATE pattern (#105): mutual exclusion with
3956
+ a mid-walk sync_cache, deferring via MigrationGateNotMet on contention.
3957
+
3958
+ Fresh installs never run this handler: the dispatcher stamps it WITHOUT
3959
+ running (the fresh-install branch), and `_apply_cache_schema` already created
3960
+ the index on the fresh DB. NO self-stamp — the dispatcher central-stamps on a
3961
+ clean return (#140). Run-twice safe: the DELETE is idempotent (no dupes left
3962
+ on a second run) and the index create is `IF NOT EXISTS`; safe on an empty
3963
+ table.
3964
+ """
3965
+ lock_path = _cache_db_lock_path_for_conn(conn)
3966
+ lock_fh = None
3967
+ if lock_path is not None:
3968
+ lock_fh = open(lock_path, "w")
3969
+ try:
3970
+ fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
3971
+ except BlockingIOError:
3972
+ lock_fh.close()
3973
+ raise MigrationGateNotMet(
3974
+ "cache.db.lock held by a concurrent sync_cache; deferring "
3975
+ "cache 020 physical-unique dedup (#279 S3)"
3976
+ )
3977
+ try:
3978
+ conn.execute("BEGIN IMMEDIATE")
3979
+ try:
3980
+ conn.execute(
3981
+ "DELETE FROM session_entries WHERE id NOT IN ("
3982
+ "SELECT MIN(id) FROM session_entries "
3983
+ "GROUP BY source_path, line_offset)")
3984
+ conn.execute(
3985
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_physical "
3986
+ "ON session_entries(source_path, line_offset)")
3987
+ conn.commit()
3988
+ except Exception:
3989
+ conn.rollback()
3990
+ raise
3991
+ finally:
3992
+ if lock_fh is not None:
3993
+ try:
3994
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
3995
+ except OSError:
3996
+ pass
3997
+ lock_fh.close()
3998
+
3999
+
4000
+ @cache_migration("021_index_conversation_messages_cwd")
4001
+ def _021_index_conversation_messages_cwd(conn: sqlite3.Connection) -> None:
4002
+ """#289: partial covering index on conversation_messages(cwd) to collapse the
4003
+ full-table `SELECT DISTINCT cwd` scan in build_anon_plan_for_db (~20s cold on
4004
+ a 5.5 GB cache) into an index-only search.
4005
+
4006
+ Fresh installs never run this handler: the dispatcher stamps it WITHOUT
4007
+ running (the fresh-install branch), and `_apply_cache_schema` already created
4008
+ the index on the fresh DB. NO self-stamp — the dispatcher central-stamps on a
4009
+ clean return (#140). Run-twice safe: CREATE INDEX is IF NOT EXISTS. cache.db
4010
+ is re-derivable — `cache-sync --rebuild` is the escape hatch.
4011
+
4012
+ No flock/BEGIN IMMEDIATE (unlike 020): a single CREATE INDEX IF NOT EXISTS is
4013
+ atomic and touches no rows, so it needs no mutual-exclusion scaffolding.
4014
+ """
4015
+ conn.execute(
4016
+ "CREATE INDEX IF NOT EXISTS idx_conversation_messages_cwd "
4017
+ "ON conversation_messages(cwd) WHERE cwd IS NOT NULL AND cwd != ''"
4018
+ )
4019
+
4020
+
3868
4021
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3869
4022
 
3870
4023
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import argparse
27
27
  import datetime as dt
28
+ import fcntl
28
29
  import json
29
30
  import pathlib
30
31
  import shutil
@@ -45,12 +46,18 @@ def doctor_gather_state(
45
46
  *,
46
47
  now_utc: "dt.datetime | None" = None,
47
48
  runtime_bind: "str | None" = None,
49
+ deep: bool = False,
48
50
  ):
49
51
  """I/O chokepoint for `cctally doctor` (spec §7.2).
50
52
 
51
53
  H1 invariant: config.json is read RAW (NOT via load_config), since
52
54
  load_config auto-creates the file on first run — a read-only
53
55
  diagnostic command must never mutate user state.
56
+
57
+ `deep=True` (CLI cmd_doctor only) additionally runs `PRAGMA
58
+ quick_check(1)` on each DB (#279 S2 F5b); the dashboard/TUI callers
59
+ stay `deep=False` — the rebuild loop calls the gather every rebuild
60
+ and quick_check on a large cache.db costs seconds.
54
61
  """
55
62
  import _lib_doctor
56
63
 
@@ -59,8 +66,17 @@ def doctor_gather_state(
59
66
  now_utc = _now_utc()
60
67
 
61
68
  # ── Install ──────────────────────────────────────────────────────
62
- repo_root = c._setup_resolve_repo_root()
63
- dst_dir = c._setup_local_bin_dir()
69
+ # #279 S2 F5d: guard the only two unguarded statements in the
70
+ # otherwise fail-soft gather — an exception here would kill the whole
71
+ # report. Downstream consumers already degrade on None.
72
+ try:
73
+ repo_root = c._setup_resolve_repo_root()
74
+ except Exception:
75
+ repo_root = None
76
+ try:
77
+ dst_dir = c._setup_local_bin_dir()
78
+ except Exception:
79
+ dst_dir = None
64
80
  try:
65
81
  symlink_state = c._setup_compute_symlink_state(repo_root, dst_dir)
66
82
  except Exception:
@@ -382,6 +398,81 @@ def doctor_gather_state(
382
398
  except Exception:
383
399
  pass
384
400
 
401
+ # ── Parse health (#279 S2 F5a) ───────────────────────────────────
402
+ parse_health_claude = parse_health_codex = None
403
+ try:
404
+ if _cctally_core.CACHE_DB_PATH.exists():
405
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
406
+ try:
407
+ for _key in ("parse_health_claude", "parse_health_codex"):
408
+ try:
409
+ row = conn.execute(
410
+ "SELECT value FROM cache_meta WHERE key = ?",
411
+ (_key,),
412
+ ).fetchone()
413
+ if row and row[0]:
414
+ _parsed = json.loads(row[0])
415
+ if isinstance(_parsed, dict):
416
+ if _key == "parse_health_claude":
417
+ parse_health_claude = _parsed
418
+ else:
419
+ parse_health_codex = _parsed
420
+ except (sqlite3.OperationalError, ValueError):
421
+ pass
422
+ finally:
423
+ conn.close()
424
+ except Exception:
425
+ pass
426
+
427
+ # ── Integrity (deep only — #279 S2 F5b) ──────────────────────────
428
+ stats_db_quick_check = cache_db_quick_check = None
429
+ if deep:
430
+ for _label, _path in (("stats", _cctally_core.DB_PATH),
431
+ ("cache", _cctally_core.CACHE_DB_PATH)):
432
+ _result = None
433
+ try:
434
+ if _path.exists():
435
+ _conn = sqlite3.connect(str(_path))
436
+ try:
437
+ _row = _conn.execute(
438
+ "PRAGMA quick_check(1)").fetchone()
439
+ _result = (str(_row[0])
440
+ if _row and _row[0] is not None else None)
441
+ finally:
442
+ _conn.close()
443
+ except sqlite3.DatabaseError as exc:
444
+ _result = f"open failed: {exc}"
445
+ except Exception:
446
+ _result = None
447
+ if _label == "stats":
448
+ stats_db_quick_check = _result
449
+ else:
450
+ cache_db_quick_check = _result
451
+
452
+ # ── Lock state (#279 S2 F5c) — read-only: never create files ─────
453
+ locks_held: "dict | None" = None
454
+ try:
455
+ locks_held = {}
456
+ for _name, _lp in (
457
+ ("cache.db.lock", _cctally_core.CACHE_LOCK_PATH),
458
+ ("cache.db.codex.lock", _cctally_core.CACHE_LOCK_CODEX_PATH),
459
+ ):
460
+ if not _lp.exists():
461
+ locks_held[_name] = False
462
+ continue
463
+ try:
464
+ with open(_lp, "r") as _lf:
465
+ try:
466
+ fcntl.flock(_lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
467
+ fcntl.flock(_lf, fcntl.LOCK_UN)
468
+ locks_held[_name] = False
469
+ except OSError:
470
+ locks_held[_name] = True
471
+ except OSError:
472
+ locks_held[_name] = None
473
+ except Exception:
474
+ locks_held = None
475
+
385
476
  # ── Safety ───────────────────────────────────────────────────────
386
477
  # `dashboard.bind` is read via the same chokepoint that powers
387
478
  # `cctally config get dashboard.bind` — `_config_known_value`
@@ -548,6 +639,13 @@ def doctor_gather_state(
548
639
  conv_sessions_rollup_count=conv_sessions_rollup_count,
549
640
  conv_messages_distinct_sessions=conv_messages_distinct_sessions,
550
641
  conv_rollup_sync_in_progress=conv_rollup_sync_in_progress,
642
+ # #279 S2 F5: parse-health records, deep quick_check results, and
643
+ # non-blocking lock-file probes (appended after the defaulted tail).
644
+ parse_health_claude=parse_health_claude,
645
+ parse_health_codex=parse_health_codex,
646
+ stats_db_quick_check=stats_db_quick_check,
647
+ cache_db_quick_check=cache_db_quick_check,
648
+ locks_held=locks_held,
551
649
  )
552
650
 
553
651
 
@@ -572,7 +670,10 @@ def cmd_doctor(args: argparse.Namespace) -> int:
572
670
  if quiet and verbose:
573
671
  eprint("doctor: --quiet and --verbose are mutually exclusive")
574
672
  return 2
575
- state = c.doctor_gather_state()
673
+ # #279 S2 F5b: deep=True runs PRAGMA quick_check(1) — CLI-only (the
674
+ # dashboard/TUI gather callers stay deep=False so their per-rebuild
675
+ # gather never pays the multi-second cost on a large cache.db).
676
+ state = c.doctor_gather_state(deep=True)
576
677
  report = _lib_doctor.run_checks(state)
577
678
  if getattr(args, "json", False):
578
679
  print(json.dumps(
@@ -708,7 +708,8 @@ def cmd_blocks(args: argparse.Namespace) -> int:
708
708
  blocks = [b for b in blocks if b.is_active and not b.is_gap]
709
709
  if not blocks:
710
710
  if args.json:
711
- print('{\n "blocks": [],\n "message": "No active block"\n}')
711
+ print(json.dumps(_c.stamp_schema_version(
712
+ {"blocks": [], "message": "No active block"}), indent=2))
712
713
  else:
713
714
  print("No active session block found.")
714
715
  return 0
@@ -846,9 +847,17 @@ def _format_block_start(iso: str, tz: "ZoneInfo | None") -> str:
846
847
  Renders as ``YYYY-MM-DD HH:MM <SUFFIX>`` where the suffix is the
847
848
  zone label per ``display_tz_label``. Naive inputs are treated as
848
849
  UTC; ``tz=None`` means "host-local via bare astimezone()".
850
+
851
+ The displayed time is rounded to the nearest 10-minute boundary to
852
+ normalize Anthropic reset-capture jitter (e.g. a :39 recorded reset
853
+ renders as :40). The stored ``block_start_at`` is unchanged.
849
854
  """
850
855
  _c = _cctally()
851
- return _c.format_display_dt(iso, tz, fmt="%Y-%m-%d %H:%M", suffix=True)
856
+ parsed = dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
857
+ return _c.format_display_dt(
858
+ _c._round_to_ten_minutes(parsed), tz,
859
+ fmt="%Y-%m-%d %H:%M", suffix=True,
860
+ )
852
861
 
853
862
 
854
863
  def _format_hhmm_in_tz(iso: str, tz: "ZoneInfo | None") -> str:
@@ -857,9 +866,16 @@ def _format_hhmm_in_tz(iso: str, tz: "ZoneInfo | None") -> str:
857
866
  Mirrors ``_format_block_start``'s tz resolution so paired start/end
858
867
  cells in the same row stay in the same zone. Naive inputs are
859
868
  treated as UTC; ``tz=None`` means host-local. No suffix.
869
+
870
+ Rounded to the nearest 10-minute boundary for the same reset-jitter
871
+ normalization as ``_format_block_start`` (the paired start/end cells
872
+ must round together or a 5h window renders as 4h59m/5h01m).
860
873
  """
861
874
  _c = _cctally()
862
- return _c.format_display_dt(iso, tz, fmt="%H:%M", suffix=False)
875
+ parsed = dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
876
+ return _c.format_display_dt(
877
+ _c._round_to_ten_minutes(parsed), tz, fmt="%H:%M", suffix=False,
878
+ )
863
879
 
864
880
 
865
881
  def _block_is_active(