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
@@ -701,7 +701,7 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
701
701
  ),
702
702
  "modelBreakdowns": breakdowns,
703
703
  }
704
- print(json.dumps(output, indent=2))
704
+ print(json.dumps(c.stamp_schema_version(output), indent=2))
705
705
  return 0
706
706
 
707
707
  if args.breakdown:
@@ -64,6 +64,7 @@ import pathlib
64
64
  import shutil
65
65
  import subprocess
66
66
  import sys
67
+ import threading
67
68
  import time
68
69
 
69
70
 
@@ -1769,6 +1770,84 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1769
1770
  return 0
1770
1771
 
1771
1772
 
1773
+ _SETUP_PROGRESS_MIN_INTERVAL_SECONDS = 1.0
1774
+ _SETUP_PROGRESS_HEARTBEAT_POLL_SECONDS = 0.5
1775
+
1776
+
1777
+ def _setup_progress_enabled(*, json_mode: bool) -> bool:
1778
+ """Whether to emit setup cold-sync progress to stderr (issue #281 S7 / R6).
1779
+
1780
+ ``CCTALLY_SETUP_PROGRESS='1'`` forces on, ``'0'`` forces off (both
1781
+ directions: the golden harness force-suppresses, the timed
1782
+ fixture-ladder run force-observes). Otherwise: on iff stderr is a TTY
1783
+ and not ``--json`` (json is machine-readable — auto-suppress the
1784
+ friendly notices there). Gating on ``sys.stderr`` (not stdout) is
1785
+ correct because progress goes to stderr, so a user who pipes stdout
1786
+ still sees it while the harness (command-substitution pipe) reads
1787
+ non-TTY.
1788
+ """
1789
+ env = os.environ.get("CCTALLY_SETUP_PROGRESS")
1790
+ if env == "1":
1791
+ return True
1792
+ if env == "0":
1793
+ return False
1794
+ return (not json_mode) and sys.stderr.isatty()
1795
+
1796
+
1797
+ class _SetupProgressReporter:
1798
+ """Best-effort stderr progress for setup's cold-sync bootstrap.
1799
+
1800
+ Coordinates the per-file ``sync_cache(progress=)`` callback with a
1801
+ background heartbeat so neither a single giant JSONL nor a slow OAuth
1802
+ fetch leaves a silent gap > ~2s. All emissions throttle off one shared
1803
+ monotonic ``last_emit`` under a lock; ``force=True`` bypasses the
1804
+ throttle (used for the pre-sync and pre-OAuth notices). A no-op (and no
1805
+ thread) when disabled — so it is inert in the golden harness / non-TTY
1806
+ / ``--json``.
1807
+ """
1808
+
1809
+ def __init__(self, enabled: bool) -> None:
1810
+ self.enabled = enabled
1811
+ self._last_emit = 0.0
1812
+ self._lock = threading.Lock()
1813
+ self._stop = threading.Event()
1814
+ self._thread: "threading.Thread | None" = None
1815
+
1816
+ def emit(self, msg: str, *, force: bool = False) -> None:
1817
+ if not self.enabled:
1818
+ return
1819
+ with self._lock:
1820
+ now = time.monotonic()
1821
+ if not force and (now - self._last_emit) < _SETUP_PROGRESS_MIN_INTERVAL_SECONDS:
1822
+ return
1823
+ self._last_emit = now
1824
+ eprint(msg)
1825
+
1826
+ def sync_callback(self, stats) -> None:
1827
+ # `stats` is a duck-typed cache.IngestStats (reads files_processed /
1828
+ # files_total). sync_cache invokes this per file; emit() throttles.
1829
+ self.emit(f" … synced {stats.files_processed}/{stats.files_total} session files")
1830
+
1831
+ def _heartbeat_loop(self) -> None:
1832
+ # Fires only when no per-file line / notice emitted in the last
1833
+ # interval, bounding intra-file and intra-fetch silence.
1834
+ while not self._stop.wait(_SETUP_PROGRESS_HEARTBEAT_POLL_SECONDS):
1835
+ self.emit(" … still working…")
1836
+
1837
+ def __enter__(self) -> "_SetupProgressReporter":
1838
+ if self.enabled:
1839
+ self._thread = threading.Thread(
1840
+ target=self._heartbeat_loop, name="cctally-setup-progress", daemon=True
1841
+ )
1842
+ self._thread.start()
1843
+ return self
1844
+
1845
+ def __exit__(self, *exc) -> None:
1846
+ self._stop.set()
1847
+ if self._thread is not None:
1848
+ self._thread.join(timeout=1.0)
1849
+
1850
+
1772
1851
  def _setup_emit_text(lines: list[str]) -> None:
1773
1852
  for ln in lines:
1774
1853
  print(ln)
@@ -2075,49 +2154,72 @@ def _setup_install(args: argparse.Namespace) -> int:
2075
2154
  out.append(" leave (data is funneled correctly either way), but you can remove")
2076
2155
  out.append(" it whenever you want. We won't touch the file.")
2077
2156
 
2078
- # Bootstrap (non-fatal). sync_cache requires a connection arg mirror
2079
- # the pattern from cmd_hook_tick (Task 2 fix).
2157
+ # Bootstrap (non-fatal). Progress is stderr-only + TTY-gated so the
2158
+ # buffered stdout transcript and --json envelope are unchanged (issue
2159
+ # #281 S7 / R6). The reporter wraps BOTH the cold-sync ingest and the
2160
+ # OAuth fetch under one daemon heartbeat, so neither a single giant
2161
+ # JSONL nor a slow OAuth network call leaves a silent gap > ~2s.
2162
+ # sync_cache is untouched — we only pass its existing progress= seam.
2080
2163
  bootstrap_rows: int | None = None
2081
2164
  bootstrap_oauth_status: str | None = None
2082
- try:
2083
- cache_conn = c.open_cache_db()
2165
+ progress = _SetupProgressReporter(
2166
+ _setup_progress_enabled(json_mode=getattr(args, "json", False))
2167
+ )
2168
+ with progress:
2169
+ progress.emit(
2170
+ "⏳ Syncing session history (first run can take a moment on a large history)…",
2171
+ force=True,
2172
+ )
2084
2173
  try:
2085
- stats = c.sync_cache(cache_conn)
2086
- rows = int(stats.rows_changed)
2087
- finally:
2174
+ cache_conn = c.open_cache_db()
2088
2175
  try:
2089
- cache_conn.close()
2090
- except Exception:
2091
- pass
2092
- bootstrap_rows = rows
2093
- # `rows` counts both genuine INSERTs and ccusage-parity DO UPDATE
2094
- # replacements (see IngestStats.rows_changed). On first install
2095
- # this is always 0-vs-N pure inserts (cache is empty), so "N new
2096
- # entries" is exactly accurate. On a re-install / upgrade path
2097
- # with active sessions, `rows` also counts UPSERT replacements
2098
- # (streaming-vs-final tiebreaker swaps), so the count is more
2099
- # accurately "ingest activity" than "rows newly added" — but
2100
- # we keep "new entries" because (a) it's still a useful signal
2101
- # to the operator that the cache is alive, and (b) the dominant
2102
- # case (first install) reads literally.
2103
- out.append(f"✓ Synced session cache ({rows} new entries)")
2104
- except Exception as exc:
2105
- out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
2106
- warnings += 1
2107
- if oauth:
2108
- try:
2109
- status, _ = c._hook_tick_oauth_refresh()
2110
- bootstrap_oauth_status = status
2111
- if status.startswith("ok"):
2112
- c._hook_tick_throttle_touch()
2113
- out.append(f"✓ Bootstrapped weekly usage ({status})")
2114
- else:
2115
- out.append(f"⚠ Bootstrap OAuth fetch: {status}")
2176
+ stats = c.sync_cache(cache_conn, progress=progress.sync_callback)
2177
+ finally:
2178
+ try:
2179
+ cache_conn.close()
2180
+ except Exception:
2181
+ pass
2182
+ if stats.lock_contended:
2183
+ # Another process holds the cache flock nothing was
2184
+ # ingested (sync_cache short-circuits with rows_changed=0).
2185
+ # Report honestly rather than a false "Synced (0 new
2186
+ # entries)"; leave bootstrap_rows None for the envelope.
2187
+ out.append("⚠ Another cache sync is in progress; using existing cache.")
2116
2188
  warnings += 1
2189
+ bootstrap_rows = None
2190
+ else:
2191
+ rows = int(stats.rows_changed)
2192
+ bootstrap_rows = rows
2193
+ # `rows` counts both genuine INSERTs and ccusage-parity DO
2194
+ # UPDATE replacements (see IngestStats.rows_changed). On
2195
+ # first install this is always 0-vs-N pure inserts (cache is
2196
+ # empty), so "N new entries" is exactly accurate. On a
2197
+ # re-install / upgrade path with active sessions, `rows` also
2198
+ # counts UPSERT replacements (streaming-vs-final tiebreaker
2199
+ # swaps), so the count is more accurately "ingest activity"
2200
+ # than "rows newly added" — but we keep "new entries" because
2201
+ # (a) it's still a useful signal to the operator that the
2202
+ # cache is alive, and (b) the dominant case (first install)
2203
+ # reads literally.
2204
+ out.append(f"✓ Synced session cache ({rows} new entries)")
2117
2205
  except Exception as exc:
2118
- bootstrap_oauth_status = f"err({type(exc).__name__})"
2119
- out.append(f"⚠ Bootstrap OAuth failed: {exc}")
2206
+ out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
2120
2207
  warnings += 1
2208
+ if oauth:
2209
+ progress.emit("⏳ Fetching current usage…", force=True)
2210
+ try:
2211
+ status, _ = c._hook_tick_oauth_refresh()
2212
+ bootstrap_oauth_status = status
2213
+ if status.startswith("ok"):
2214
+ c._hook_tick_throttle_touch()
2215
+ out.append(f"✓ Bootstrapped weekly usage ({status})")
2216
+ else:
2217
+ out.append(f"⚠ Bootstrap OAuth fetch: {status}")
2218
+ warnings += 1
2219
+ except Exception as exc:
2220
+ bootstrap_oauth_status = f"err({type(exc).__name__})"
2221
+ out.append(f"⚠ Bootstrap OAuth failed: {exc}")
2222
+ warnings += 1
2121
2223
 
2122
2224
  out.append("")
2123
2225
  if warnings:
@@ -2125,13 +2227,6 @@ def _setup_install(args: argparse.Namespace) -> int:
2125
2227
  else:
2126
2228
  out.append("cctally is ready.")
2127
2229
  out.append("")
2128
- # Settings.json was modified — CC caches it at session start. The
2129
- # warning fires unconditionally because `_setup_install` always
2130
- # rewrites settings.json (legacy migration, fresh install, repair).
2131
- out.append("⚠ Restart Claude Code for the new hooks to take effect in any currently")
2132
- out.append(" open sessions. New sessions launched after this point pick them up")
2133
- out.append(" automatically. (settings.json is cached at session start.)")
2134
- out.append("")
2135
2230
  out.append(" Try:")
2136
2231
  out.append(" cctally daily # last 30 days")
2137
2232
  out.append(" cctally dashboard # live web dashboard")
@@ -2151,6 +2246,18 @@ def _setup_install(args: argparse.Namespace) -> int:
2151
2246
  out.append("Opt out anytime: cctally telemetry off (or CCTALLY_DISABLE_TELEMETRY=1)")
2152
2247
  out.append("How it works: https://github.com/omrikais/cctally/blob/main/docs/telemetry.md")
2153
2248
 
2249
+ # ▶ Next step LAST: the sole required action, set off from the wall of
2250
+ # text (issue #281 S7 / R6 — it was previously buried mid-dump, right
2251
+ # after the readiness line and followed by ~12 more lines). It's an
2252
+ # action, not a warning, so it drops the `⚠` and never touched the
2253
+ # `warnings` count. settings.json is cached at session start, so open
2254
+ # sessions need a restart; `_setup_install` always rewrites settings.json
2255
+ # (legacy migration, fresh install, repair), so this always applies.
2256
+ out.append("")
2257
+ out.append("▶ Next step: restart Claude Code to activate the new hooks.")
2258
+ out.append(" Open sessions won't record until you restart (settings.json is cached at")
2259
+ out.append(" session start). New sessions started after this pick them up automatically.")
2260
+
2154
2261
  if getattr(args, "json", False):
2155
2262
  # JSON-safe telemetry disclosure (spec 2026-07-07 §4): a structured
2156
2263
  # field, never prose. Resolved READ-ONLY via `resolve_telemetry_state`
@@ -111,7 +111,7 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
111
111
  }
112
112
 
113
113
  if args.json:
114
- print(json.dumps(payload, indent=2))
114
+ print(json.dumps(c.stamp_schema_version(payload), indent=2))
115
115
  elif not args.quiet:
116
116
  print(
117
117
  f"Synced week {payload['weekStartDate']}..{payload['weekEndDate']} "
@@ -47,10 +47,8 @@ def _core():
47
47
 
48
48
 
49
49
  def _truthy_env(name: str) -> bool:
50
- """A ``1``/``true``/``yes``/any-non-empty env value is truthy; unset,
51
- empty, ``0``, ``false``, ``no`` are falsey."""
52
- v = os.environ.get(name)
53
- return v is not None and v.strip().lower() not in ("", "0", "false", "no")
50
+ """Canonical implementation lives in ``_cctally_core`` (#279 S1 F1)."""
51
+ return _core()._truthy_env(name)
54
52
 
55
53
 
56
54
  def resolve_telemetry_state(config: dict) -> tuple[bool, str]:
@@ -347,7 +345,7 @@ def cmd_telemetry(args) -> int:
347
345
  "fields": ["token", "version", "os"],
348
346
  }
349
347
  if getattr(args, "json", False):
350
- print(json.dumps(info))
348
+ print(json.dumps(c.stamp_schema_version(info)))
351
349
  return 0
352
350
  print(f"telemetry: {'enabled' if enabled else 'disabled'} ({reason})")
353
351
  print(
@@ -0,0 +1,191 @@
1
+ """cctally transcript — anonymized-by-default export + raw cross-session search
2
+ over the conversation cache (#281 S4 R4).
3
+
4
+ Thin CLI wrappers over the SAME kernels the dashboard HTTP routes use
5
+ (``get_conversation_export`` / ``search_conversations`` / ``build_anon_plan_for_db``
6
+ / ``scrub_text``), so ``transcript export`` byte-matches ``GET …/export`` and the
7
+ viewer becomes scriptable.
8
+
9
+ - ``export``: anonymized Markdown by default; ``--raw`` disables the whole scrub
10
+ (identity + secrets) and is byte-identical to the dashboard's raw export.
11
+ Byte-exact emission: ``sys.stdout.buffer.write`` of the exact UTF-8 bytes (no
12
+ ``print``, no added trailing newline — the render already ends in exactly one),
13
+ or ``--output PATH`` writes the same exact bytes; nothing else on stdout.
14
+ Unknown session → ``transcript: …`` on stderr, exit 1.
15
+ - ``search``: RAW output (a navigation surface, not a sharing artifact). Mirrors
16
+ the full HTTP filter surface; date-only values parse through the SAME
17
+ display-tz-aware helper the HTTP handler uses. Human table by default; ``--json``
18
+ emits a ``schemaVersion``-stamped, explicitly camelCased envelope.
19
+
20
+ Accessor discipline: no ``_cctally_*`` sibling is imported directly; every helper
21
+ is reached via the call-time ``_cctally()`` accessor / ``_load_sibling`` (matching
22
+ the other command modules), except the pure ``_lib_fmt._boxed_table`` renderer.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import pathlib
28
+ import sys
29
+
30
+ from _cctally_core import eprint
31
+ from _lib_fmt import _boxed_table
32
+
33
+
34
+ def _cctally():
35
+ """Call-time accessor to the cctally module namespace (ns-patchable)."""
36
+ return sys.modules["cctally"]
37
+
38
+
39
+ def cmd_transcript(args) -> int:
40
+ action = getattr(args, "transcript_action", None)
41
+ if action == "export":
42
+ return _cmd_transcript_export(args)
43
+ if action == "search":
44
+ return _cmd_transcript_search(args)
45
+ eprint("transcript: expected a subcommand (`export` or `search`)")
46
+ return 2
47
+
48
+
49
+ # ---- export ----------------------------------------------------------------
50
+
51
+ def _cmd_transcript_export(args) -> int:
52
+ c = _cctally()
53
+ session_id = args.session_id
54
+ scope = getattr(args, "scope", "all")
55
+ raw = bool(getattr(args, "raw", False))
56
+ output = getattr(args, "output", None)
57
+
58
+ conn = c.open_cache_db()
59
+ try:
60
+ cq = c._load_sibling("_lib_conversation_query")
61
+ md = cq.get_conversation_export(conn, session_id, scope)
62
+ if md is None:
63
+ eprint(f"transcript: no conversation found for session {session_id!r}")
64
+ return 1
65
+ if not raw:
66
+ anon = c._load_sibling("_lib_conversation_anon")
67
+ plan = cq.build_anon_plan_for_db(conn, home_dir=os.path.expanduser("~"))
68
+ md = anon.scrub_text(md, plan)
69
+ finally:
70
+ conn.close()
71
+
72
+ data = md.encode("utf-8")
73
+ if output:
74
+ pathlib.Path(output).write_bytes(data)
75
+ else:
76
+ # Byte-exact: raw bytes to stdout, no `print`, no added trailing newline.
77
+ sys.stdout.buffer.write(data)
78
+ sys.stdout.buffer.flush()
79
+ return 0
80
+
81
+
82
+ # ---- search ----------------------------------------------------------------
83
+
84
+ def _cmd_transcript_search(args) -> int:
85
+ c = _cctally()
86
+ query = args.query
87
+ kind = getattr(args, "kind", "all")
88
+ limit = getattr(args, "limit", 50)
89
+ offset = getattr(args, "offset", 0)
90
+ projects = getattr(args, "project", None) or None
91
+ models = getattr(args, "model", None) or None
92
+ cost_min = getattr(args, "cost_min", None)
93
+ cost_max = getattr(args, "cost_max", None)
94
+ rebuild_min = getattr(args, "rebuild_min", None)
95
+
96
+ # Date-only bounds parse through the SAME display-tz-aware helper the HTTP
97
+ # filter handler uses (no second parser) — reuse, don't reimplement.
98
+ date_from_in = getattr(args, "date_from", None)
99
+ date_to_in = getattr(args, "date_to", None)
100
+ df = dtt = None
101
+ if date_from_in or date_to_in:
102
+ tz = c.resolve_display_tz(args, c.load_config())
103
+ tz_name = tz.key if tz is not None else None
104
+ dates = c._load_sibling("_lib_dashboard_dates")
105
+ try:
106
+ df, dtt = dates.parse_filter_date_range(
107
+ date_from_in, date_to_in, tz_name=tz_name)
108
+ except ValueError as exc:
109
+ eprint(f"transcript: {exc}")
110
+ return 2
111
+
112
+ conn = c.open_cache_db()
113
+ try:
114
+ cq = c._load_sibling("_lib_conversation_query")
115
+ try:
116
+ result = cq.search_conversations(
117
+ conn, query, limit=limit, offset=offset, kind=kind,
118
+ date_from=df, date_to=dtt, projects=projects, models=models,
119
+ cost_min=cost_min, cost_max=cost_max, rebuild_min=rebuild_min)
120
+ except ValueError as exc: # unknown kind (belt-and-suspenders)
121
+ eprint(f"transcript: {exc}")
122
+ return 2
123
+ finally:
124
+ conn.close()
125
+
126
+ if getattr(args, "json", False):
127
+ payload = c.stamp_schema_version(_search_to_camel(result))
128
+ print(_json_dumps(payload))
129
+ return 0
130
+ _render_search_table(result)
131
+ return 0
132
+
133
+
134
+ def _json_dumps(payload) -> str:
135
+ import json
136
+ return json.dumps(payload, indent=2)
137
+
138
+
139
+ def _search_to_camel(result: dict) -> dict:
140
+ """Explicit recursive camelCase mapping of the ``search_conversations`` result
141
+ (this command's own serializer — ``stamp_schema_version`` only inserts the
142
+ leading key). Top level then hit level, per spec §7."""
143
+ out = {
144
+ "query": result.get("query", ""),
145
+ "mode": result.get("mode"),
146
+ "hits": [_hit_to_camel(h) for h in result.get("hits", [])],
147
+ "total": result.get("total", 0),
148
+ "kind": result.get("kind"),
149
+ "searchDepth": result.get("search_depth"),
150
+ }
151
+ if result.get("filter_degraded"):
152
+ out["filterDegraded"] = True
153
+ return out
154
+
155
+
156
+ def _hit_to_camel(h: dict) -> dict:
157
+ return {
158
+ "sessionId": h.get("session_id"),
159
+ "uuid": h.get("uuid"),
160
+ "projectLabel": h.get("project_label"),
161
+ "title": h.get("title"),
162
+ "ts": h.get("ts"),
163
+ "snippet": h.get("snippet"),
164
+ "matchKinds": h.get("match_kinds", []),
165
+ "costUsd": h.get("cost_usd", 0.0),
166
+ }
167
+
168
+
169
+ def _render_search_table(result: dict) -> None:
170
+ hits = result.get("hits", [])
171
+ if not hits:
172
+ print("No matching transcripts.")
173
+ return
174
+ rows = []
175
+ for h in hits:
176
+ kinds = ",".join(h.get("match_kinds") or []) or "-"
177
+ rows.append([
178
+ h.get("session_id") or "",
179
+ h.get("ts") or "",
180
+ h.get("project_label") or "",
181
+ kinds,
182
+ (h.get("snippet") or "").strip(),
183
+ ])
184
+ table = _boxed_table(
185
+ ["Session", "When", "Project", "Kinds", "Snippet"], rows,
186
+ ["left", "left", "left", "left", "left"])
187
+ print(table)
188
+ total = result.get("total", len(hits))
189
+ depth = result.get("search_depth")
190
+ suffix = f" (search depth: {depth})" if depth and depth != "full" else ""
191
+ print(f"\n{len(hits)} of {total} match(es){suffix}")
@@ -228,6 +228,37 @@ from _lib_fmt import stable_sum
228
228
  # nothing on the default path.
229
229
  import _lib_perf as _perf
230
230
 
231
+ import importlib.util as _ilu
232
+
233
+
234
+ def _ensure_sibling_loaded(name: str) -> None:
235
+ """Register a NON-eager-loaded ``_lib_*`` sibling in ``sys.modules``.
236
+
237
+ ``_lib_forecast`` (#279 S4 F2) is a NEW consumer-only sibling — kept out
238
+ of ``bin/cctally``'s eager-load block so ``bin/cctally`` stays
239
+ byte-untouched (spec §2). Under the ``SourceFileLoader`` harness path
240
+ (``bin/`` absent from ``sys.path``) a bare ``from _lib_forecast import``
241
+ would miss, so this pre-registers the sibling ``__file__``-relative when
242
+ it is not already importable (mirrors ``_cctally_cache._load_lib``). The
243
+ honest import that follows is a ``sys.modules`` hit in every load context.
244
+ """
245
+ if name in sys.modules:
246
+ return
247
+ try:
248
+ __import__(name) # bin/ on sys.path: prod script / conftest / pytest
249
+ return
250
+ except ModuleNotFoundError:
251
+ pass
252
+ _p = os.path.join(os.path.dirname(__file__), f"{name}.py")
253
+ _spec = _ilu.spec_from_file_location(name, _p)
254
+ _mod = _ilu.module_from_spec(_spec)
255
+ sys.modules[name] = _mod
256
+ _spec.loader.exec_module(_mod)
257
+
258
+
259
+ _ensure_sibling_loaded("_lib_forecast")
260
+ from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, BudgetRow
261
+
231
262
 
232
263
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
233
264
  # Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
@@ -257,10 +288,6 @@ def _apply_midweek_reset_override(*args, **kwargs):
257
288
  return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
258
289
 
259
290
 
260
- def _compute_forecast(*args, **kwargs):
261
- return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
262
-
263
-
264
291
  def _resolve_forecast_now(*args, **kwargs):
265
292
  return sys.modules["cctally"]._resolve_forecast_now(*args, **kwargs)
266
293
 
@@ -313,20 +340,11 @@ def sync_cache(*args, **kwargs):
313
340
  return sys.modules["cctally"].sync_cache(*args, **kwargs)
314
341
 
315
342
 
316
- # Forecast dataclass shims used as bare-name constructors inside
317
- # ``_tui_build_forecast``. The classes themselves stay in bin/cctally
318
- # alongside the forecast subcommand (``cmd_forecast``); call-time
319
- # resolution keeps monkeypatches in sync.
320
- def ForecastInputs(*args, **kwargs):
321
- return sys.modules["cctally"].ForecastInputs(*args, **kwargs)
322
-
323
-
324
- def ForecastOutput(*args, **kwargs):
325
- return sys.modules["cctally"].ForecastOutput(*args, **kwargs)
326
-
327
-
328
- def BudgetRow(*args, **kwargs):
329
- return sys.modules["cctally"].BudgetRow(*args, **kwargs)
343
+ # ``_compute_forecast`` + the forecast dataclasses (``ForecastInputs`` /
344
+ # ``ForecastOutput`` / ``BudgetRow``) now live in ``bin/_lib_forecast.py``
345
+ # (#279 S4 F2) and are honest-imported at module top — used as bare-name
346
+ # constructors inside ``_tui_build_forecast``. Single class def per name in
347
+ # ``_lib_forecast``; everything imports it, so class identity stays unique.
330
348
 
331
349
 
332
350
  # Dashboard back-refs consumed by the TUI's snapshot builders.
@@ -5280,6 +5298,12 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5280
5298
  )
5281
5299
 
5282
5300
  def _locked(skip_sync: bool) -> None:
5301
+ # #279 S5 F6.3 (gate P1-1): arm the snapshot-cache owner-thread tripwire
5302
+ # for whichever thread holds sync_lock for THIS rebuild — the periodic
5303
+ # sync thread, or a /api/sync / /api/settings request thread. Overwrite-
5304
+ # on-call, so ownership transfers to the current rebuilder; the guards in
5305
+ # _lib_snapshot_cache then catch a lock-bypassing foreign-thread mutation.
5306
+ _cctally()._load_sibling("_lib_snapshot_cache").mark_owner_thread()
5283
5307
  try:
5284
5308
  if not skip_sync:
5285
5309
  # ── Decoupled ingest + build (§2.1) ─────────────────────────
@@ -18,7 +18,6 @@ Spec: docs/superpowers/specs/2026-06-02-alerts-dispatch-severity-seams-design.md
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
- import importlib.util as _ilu
22
21
  import pathlib
23
22
  import sys
24
23
 
@@ -36,6 +35,7 @@ def _load_lib(name: str):
36
35
  cached = sys.modules.get(name)
37
36
  if cached is not None:
38
37
  return cached
38
+ import importlib.util as _ilu
39
39
  p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
40
40
  spec = _ilu.spec_from_file_location(name, p)
41
41
  mod = _ilu.module_from_spec(spec)
@@ -61,6 +61,11 @@ UsageEntry = _lib_jsonl.UsageEntry
61
61
  _lib_pricing = _load_lib("_lib_pricing")
62
62
  _calculate_entry_cost = _lib_pricing._calculate_entry_cost
63
63
 
64
+ # JSON wire-format kernel — the additive camelCase schemaVersion stamp
65
+ # (#279 S6 W1; convention docs/cli-contract.md).
66
+ _lib_json_envelope = _load_lib("_lib_json_envelope")
67
+ stamp_schema_version = _lib_json_envelope.stamp_schema_version
68
+
64
69
 
65
70
  @dataclass
66
71
  class Block:
@@ -526,4 +531,4 @@ def _blocks_to_json(
526
531
  }
527
532
  result.append(obj)
528
533
 
529
- return json.dumps({"blocks": result}, indent=2)
534
+ return json.dumps(stamp_schema_version({"blocks": result}), indent=2)