cctally 1.8.2 → 1.10.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.
@@ -242,10 +242,22 @@ def _setup_local_bin_dir() -> pathlib.Path:
242
242
  @dataclasses.dataclass
243
243
  class _SetupSymlinkResult:
244
244
  name: str
245
- status: str # "created" | "already" | "replaced" | "failed"
245
+ status: str # "created" | "already" | "replaced" | "failed" | "removed-stale"
246
246
  detail: str = ""
247
247
 
248
248
 
249
+ # Symlink names cctally USED to install but no longer does. We keep
250
+ # cleaning them up for one major-version's worth of upgraders so
251
+ # `~/.local/bin/` doesn't accumulate dangling symlinks pointing at
252
+ # scripts the current cctally checkout doesn't ship. Removal is
253
+ # conservative: we only unlink symlinks whose `readlink()` target's
254
+ # basename matches the stale name — a user's hand-rolled symlink with
255
+ # the same name pointing elsewhere is left alone.
256
+ _SETUP_STALE_SYMLINK_NAMES = (
257
+ "cctally-release", # Removed in v1.9.0; release tooling went private.
258
+ )
259
+
260
+
249
261
  def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
250
262
  """Resolve the symlink target for a given PATH-name.
251
263
 
@@ -326,6 +338,148 @@ def _setup_create_symlinks(
326
338
  return results
327
339
 
328
340
 
341
+ def _setup_cleanup_stale_symlinks(
342
+ dst_dir: pathlib.Path,
343
+ ) -> list[_SetupSymlinkResult]:
344
+ """Unlink stale `cctally-*` symlinks left over from prior versions.
345
+
346
+ For each entry in :data:`_SETUP_STALE_SYMLINK_NAMES`, removes the
347
+ matching symlink in ``dst_dir`` when its readlink target basename
348
+ matches the stale name AND the target points at a "retired" cctally
349
+ install — meaning either (a) the target is *dangling* (no longer
350
+ resolves to a real file, e.g. an old checkout that was deleted) OR
351
+ (b) the target lives under a *foreign* cctally install root
352
+ (Homebrew keg, npm ``node_modules/cctally/``), i.e. an install root
353
+ other than the one currently running ``cctally setup``.
354
+
355
+ The foreign-root clause is what handles the common upgrade path:
356
+ after ``brew upgrade cctally`` or ``npm i -g cctally@<new>``, the
357
+ *prior* keg / module dir often lingers on disk until ``brew
358
+ cleanup`` (or the next npm install GC), so a legacy
359
+ ``~/.local/bin/cctally-release`` symlink from a pre-v1.9.0 ``cctally
360
+ setup`` still resolves to an existing file under the *old* install
361
+ root. The dangling-only predicate would skip it, leaving the retired
362
+ command on PATH; the foreign-root check retires it instead.
363
+
364
+ Both clauses still preserve the maintainer's intentional manual
365
+ link to the *current* checkout's still-shipped retired tooling
366
+ (e.g. ``~/.local/bin/cctally-release ->
367
+ <cctally-dev>/bin/cctally-release``) — that target is neither
368
+ dangling nor foreign, so it's left alone. Likewise a hand-rolled
369
+ link pointing somewhere unrelated (``~/scripts/...``) survives.
370
+
371
+ Returns a list of :class:`_SetupSymlinkResult` entries for the
372
+ actions taken (so callers can fold them into install output).
373
+ """
374
+ results: list[_SetupSymlinkResult] = []
375
+ repo_root = _setup_resolve_repo_root()
376
+ for name in _SETUP_STALE_SYMLINK_NAMES:
377
+ dst = dst_dir / name
378
+ if not _setup_symlink_is_retired(dst, name, repo_root):
379
+ continue
380
+ try:
381
+ dst.unlink()
382
+ results.append(
383
+ _SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
384
+ )
385
+ except OSError as exc:
386
+ results.append(
387
+ _SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
388
+ )
389
+ return results
390
+
391
+
392
+ # Path tokens that identify a "foreign" cctally install root — i.e. a
393
+ # directory tree managed by a different distribution channel (or a
394
+ # different version of the same channel) than the one currently running
395
+ # ``cctally setup``. A symlink whose target sits under one of these is
396
+ # almost certainly a legacy auto-installed link from a prior version
397
+ # whose install root the current run does NOT manage. Pre-resolved (no
398
+ # trailing slash) so substring matching works on either UNIX or
399
+ # resolved forms.
400
+ _SETUP_FOREIGN_INSTALL_ROOT_TOKENS = (
401
+ # Homebrew keeps every installed version under
402
+ # ``<prefix>/Cellar/cctally/<version>/``. A target inside any of
403
+ # these directories points at a brew install — either the current
404
+ # one (when ``cctally setup`` runs from a brew install — possible
405
+ # but rare) or an older keg still on disk pending ``brew cleanup``.
406
+ "/Cellar/cctally/",
407
+ # npm globals land at ``<prefix>/lib/node_modules/cctally/``; npm
408
+ # locals at ``<project>/node_modules/cctally/``. Either way the
409
+ # ``/node_modules/cctally/`` segment is the discriminator. pnpm and
410
+ # yarn variants keep the segment, so this catches them too.
411
+ "/node_modules/cctally/",
412
+ )
413
+
414
+
415
+ def _setup_symlink_is_retired(
416
+ dst: pathlib.Path, name: str, repo_root: pathlib.Path,
417
+ ) -> bool:
418
+ """Detection predicate for retired auto-symlinks at install time.
419
+
420
+ Returns True iff ``dst`` is a symlink, its readlink target basename
421
+ matches ``name``, AND the target points at a "retired" cctally
422
+ install — i.e. one the current ``cctally setup`` run does not
423
+ manage. Two retirement classes:
424
+
425
+ 1. **Dangling target** — readlink target does not resolve to an
426
+ existing filesystem entry. Covers the deleted-checkout case.
427
+ 2. **Foreign install root** — target path contains one of
428
+ :data:`_SETUP_FOREIGN_INSTALL_ROOT_TOKENS` (``/Cellar/cctally/``
429
+ or ``/node_modules/cctally/``) AND that token does NOT also
430
+ appear in the current ``repo_root``. The second clause is what
431
+ keeps the (rare) case of running ``cctally setup`` *from* an
432
+ npm/brew install correctly — a symlink at the same install root
433
+ is NOT foreign, so it's left to the active-symlinks loop in
434
+ :func:`_setup_install` / :func:`_setup_uninstall`.
435
+
436
+ Targets that exist but live outside any recognized install root
437
+ (e.g. a maintainer's manual link to ``<checkout>/bin/cctally-release``
438
+ or a hand-rolled link at ``~/scripts/cctally-release``) are
439
+ preserved — they're either explicit operator setups or genuine
440
+ user-managed scripts that happen to share the name.
441
+
442
+ Shared by the cleanup site (``_setup_cleanup_stale_symlinks``) and
443
+ the read-only detection site (``_setup_detect_stale_symlinks``) so
444
+ ``--status`` and ``setup`` agree on what they call "stale".
445
+ """
446
+ if not dst.is_symlink():
447
+ return False
448
+ try:
449
+ target = os.readlink(dst)
450
+ except OSError:
451
+ return False
452
+ if pathlib.Path(target).name != name:
453
+ # User-managed symlink that happens to share the name; leave alone.
454
+ return False
455
+ # Resolve target relative to the symlink's parent so relative
456
+ # readlinks (rare for cctally setup-installed links, but possible
457
+ # for hand-rolled ones) classify correctly. Use lexists()-style
458
+ # check via Path.exists() — broken links return False, which is
459
+ # the "dangling, treat as stale" branch we want.
460
+ target_path = pathlib.Path(target)
461
+ if not target_path.is_absolute():
462
+ target_path = dst.parent / target_path
463
+ try:
464
+ target_exists = target_path.exists()
465
+ except OSError:
466
+ # Permission errors etc. — be conservative and don't remove.
467
+ return False
468
+ if not target_exists:
469
+ return True
470
+ # Target exists — retire only if it lives under a *foreign* install
471
+ # root (a different brew keg / npm module dir than the one running
472
+ # this setup). Compare against ``repo_root`` so a setup running
473
+ # *from* an npm/brew install doesn't classify its own siblings as
474
+ # foreign — that's the active-symlinks loop's job.
475
+ target_str = str(target_path)
476
+ repo_root_str = str(repo_root)
477
+ for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
478
+ if token in target_str and token not in repo_root_str:
479
+ return True
480
+ return False
481
+
482
+
329
483
  def _setup_path_includes_local_bin() -> bool:
330
484
  local_bin = str(_setup_local_bin_dir())
331
485
  return local_bin in os.environ.get("PATH", "").split(os.pathsep)
@@ -816,12 +970,38 @@ def _setup_compute_symlink_state(
816
970
  return out
817
971
 
818
972
 
973
+ def _setup_detect_stale_symlinks(dst_dir: pathlib.Path) -> list[str]:
974
+ """Return names of stale-but-still-present cctally symlinks in ``dst_dir``.
975
+
976
+ Mirrors :func:`_setup_cleanup_stale_symlinks` detection (without
977
+ removing): a symlink is "stale" when its name appears in
978
+ :data:`_SETUP_STALE_SYMLINK_NAMES`, its readlink target basename
979
+ matches that name, AND the target is "retired" — either dangling
980
+ OR pointing at a foreign cctally install root (see
981
+ :func:`_setup_symlink_is_retired`). Routing through the same
982
+ predicate keeps ``--status`` and ``setup`` in lockstep so a
983
+ manually-maintained link to a still-shipped retired tool
984
+ (``~/.local/bin/cctally-release -> <checkout>/bin/cctally-release``)
985
+ is neither reported as stale nor removed, while a legacy link left
986
+ over from a pre-v1.9.0 brew/npm install (target still resolves into
987
+ the old keg / ``node_modules`` tree) IS retired.
988
+ """
989
+ found: list[str] = []
990
+ repo_root = _setup_resolve_repo_root()
991
+ for name in _SETUP_STALE_SYMLINK_NAMES:
992
+ dst = dst_dir / name
993
+ if _setup_symlink_is_retired(dst, name, repo_root):
994
+ found.append(name)
995
+ return found
996
+
997
+
819
998
  def _setup_status(args: argparse.Namespace) -> int:
820
999
  c = _cctally()
821
1000
  repo_root = _setup_resolve_repo_root()
822
1001
  dst_dir = _setup_local_bin_dir()
823
1002
  sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
824
1003
  sym_ok = sum(1 for _, s in sym_state if s == "ok")
1004
+ stale_syms = _setup_detect_stale_symlinks(dst_dir)
825
1005
  on_path = _setup_path_includes_local_bin()
826
1006
  try:
827
1007
  settings = c._load_claude_settings()
@@ -842,6 +1022,7 @@ def _setup_status(args: argparse.Namespace) -> int:
842
1022
  "install": {
843
1023
  "symlinks_present": sym_ok,
844
1024
  "symlinks_total": len(c.SETUP_SYMLINK_NAMES),
1025
+ "symlinks_stale": stale_syms,
845
1026
  "path_includes": on_path,
846
1027
  },
847
1028
  "hooks": {ev: hook_counts[ev] for ev in c.SETUP_HOOK_EVENTS},
@@ -869,6 +1050,11 @@ def _setup_status(args: argparse.Namespace) -> int:
869
1050
  out.append("Install")
870
1051
  sym_marker = "✓" if sym_ok == len(c.SETUP_SYMLINK_NAMES) else "✗"
871
1052
  out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} present at {dst_dir}/ {sym_marker}")
1053
+ if stale_syms:
1054
+ out.append(
1055
+ f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
1056
+ )
1057
+ out.append(" run `cctally setup` to remove")
872
1058
  out.append(f" PATH includes {'yes' if on_path else 'no'} "
873
1059
  f"{'✓' if on_path else '⚠'}")
874
1060
  out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
@@ -950,6 +1136,50 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
950
1136
  sym_removed += 1
951
1137
  except OSError as exc:
952
1138
  eprint(f"setup: failed to remove {dst}: {exc}")
1139
+ # Also clean up legacy symlinks that older cctally versions used to
1140
+ # install but the current version no longer manages (see
1141
+ # :data:`_SETUP_STALE_SYMLINK_NAMES`). Without this loop, an
1142
+ # upgrader who ran ``cctally setup`` on a prior version and now runs
1143
+ # ``cctally setup --uninstall`` would keep e.g.
1144
+ # ``~/.local/bin/cctally-release`` on PATH.
1145
+ #
1146
+ # Two removal predicates compose here:
1147
+ # 1. ``target == _setup_resolve_symlink_source(repo_root, name)``
1148
+ # — the legacy auto-installed link points at *this* install
1149
+ # root's binary (the common case for git-checkout upgraders
1150
+ # who ran ``cctally setup`` on a prior version, then ``git
1151
+ # pull``ed; symmetric with how the active-symlink loop above
1152
+ # treats user-pointed `cctally` itself).
1153
+ # 2. ``_setup_symlink_is_retired`` — target is dangling OR lives
1154
+ # under a *foreign* cctally install root (old brew keg /
1155
+ # ``node_modules/cctally/`` left behind by ``brew upgrade``
1156
+ # or ``npm i -g cctally@<new>``). Without this, the common
1157
+ # brew/npm upgrade-then-uninstall path leaves the legacy
1158
+ # symlink in place because the prior keg's binary still
1159
+ # exists on disk (``target != expected``).
1160
+ #
1161
+ # A maintainer's hand-rolled link pointing somewhere unrelated
1162
+ # (``~/scripts/...``) is preserved by both predicates.
1163
+ for name in _SETUP_STALE_SYMLINK_NAMES:
1164
+ dst = dst_dir / name
1165
+ if not dst.is_symlink():
1166
+ continue
1167
+ try:
1168
+ target = pathlib.Path(os.readlink(dst))
1169
+ except OSError:
1170
+ continue
1171
+ expected = _setup_resolve_symlink_source(repo_root, name)
1172
+ should_remove = (
1173
+ target == expected
1174
+ or _setup_symlink_is_retired(dst, name, repo_root)
1175
+ )
1176
+ if not should_remove:
1177
+ continue
1178
+ try:
1179
+ dst.unlink()
1180
+ sym_removed += 1
1181
+ except OSError as exc:
1182
+ eprint(f"setup: failed to remove {dst}: {exc}")
953
1183
  out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
954
1184
 
955
1185
  legacy = _setup_detect_legacy_snippet()
@@ -1336,6 +1566,13 @@ def _setup_install(args: argparse.Namespace) -> int:
1336
1566
  eprint(f"setup: {exc}")
1337
1567
  return 1
1338
1568
 
1569
+ # Clean up symlinks left behind by prior cctally versions whose
1570
+ # subcommand surface has changed (e.g. v1.9.0 retired
1571
+ # `cctally-release` when release tooling went private). Only unlinks
1572
+ # symlinks whose target's basename matches the stale name; never
1573
+ # disturbs a user's hand-rolled symlink sharing the name.
1574
+ stale_results = _setup_cleanup_stale_symlinks(dst_dir)
1575
+
1339
1576
  sym_results = _setup_create_symlinks(repo_root, dst_dir)
1340
1577
  failed = [r for r in sym_results if r.status == "failed"]
1341
1578
  if failed:
@@ -1354,6 +1591,16 @@ def _setup_install(args: argparse.Namespace) -> int:
1354
1591
  detail_parts.append(f"{repl_count} re-pointed")
1355
1592
  detail = ", ".join(detail_parts) or "no changes"
1356
1593
  out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
1594
+ removed_stale = [r for r in stale_results if r.status == "removed-stale"]
1595
+ failed_stale = [r for r in stale_results if r.status == "failed"]
1596
+ if removed_stale:
1597
+ out.append(
1598
+ "✓ Cleaned up stale symlink(s) from prior version: "
1599
+ + ", ".join(r.name for r in removed_stale)
1600
+ )
1601
+ for r in failed_stale:
1602
+ out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
1603
+ warnings += 1
1357
1604
 
1358
1605
  if not _setup_path_includes_local_bin():
1359
1606
  warnings += 1
@@ -1061,6 +1061,16 @@ class DataSnapshot:
1061
1061
  # through ``_tui_build_snapshot``; the envelope adapter falls
1062
1062
  # back to the legacy inline routing in that case.
1063
1063
  forecast_view: Any | None = None
1064
+ # Projects panel + modal envelope block (spec §5.2 /
1065
+ # 2026-05-19-projects-panel-design.md). Populated on the sync
1066
+ # thread by ``_build_projects_envelope`` (per-tick DB-touching
1067
+ # aggregation that runs alongside the existing per-panel builds);
1068
+ # the dashboard's pure ``snapshot_to_envelope`` reads this back
1069
+ # unchanged and assigns it to ``envelope["projects"]``. ``None``
1070
+ # on first tick before sync completes — the TS envelope mirror
1071
+ # declares ``ProjectsEnvelope | null`` and the client renders the
1072
+ # panel-empty state until the next tick replaces it.
1073
+ projects_envelope: dict | None = None
1064
1074
 
1065
1075
  @classmethod
1066
1076
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1612,6 +1622,95 @@ class TuiSessionDetail:
1612
1622
  cost_total_usd: float
1613
1623
 
1614
1624
 
1625
+ def _tui_build_session_detail_indexed(
1626
+ session_id: str,
1627
+ range_start: dt.datetime,
1628
+ range_end: dt.datetime,
1629
+ ) -> Any | None:
1630
+ """Indexed direct lookup for one session by id.
1631
+
1632
+ Walks ``session_files`` (indexed by ``session_id`` — migration
1633
+ ``idx_session_files_session_id``) for the 1-3 source_paths the
1634
+ session lives in, then fetches ONLY the entries from those paths
1635
+ in the supplied range. Aggregates the filtered list and returns
1636
+ the single matching ``ClaudeSessionUsage`` row.
1637
+
1638
+ Returns ``None`` on three indistinguishable misses (the caller's
1639
+ fallback path handles them all):
1640
+
1641
+ 1. session_files row hasn't been backfilled yet for this id
1642
+ (CLAUDE.md "session_files is populated lazily" — first run
1643
+ after deploy).
1644
+ 2. cache DB unavailable (open / lock contention).
1645
+ 3. session_id is genuinely unknown.
1646
+
1647
+ Falling back uniformly preserves correctness without distinguishing
1648
+ the cases; if the slow path also misses, the modal renders 404.
1649
+ """
1650
+ c = _cctally()
1651
+ open_cache_db = c.open_cache_db
1652
+ _JoinedClaudeEntry = c._JoinedClaudeEntry
1653
+ try:
1654
+ conn = open_cache_db()
1655
+ except (sqlite3.DatabaseError, OSError):
1656
+ return None
1657
+ try:
1658
+ # 1) Source paths for this session id (indexed lookup).
1659
+ rows = conn.execute(
1660
+ "SELECT path FROM session_files WHERE session_id = ?",
1661
+ (session_id,),
1662
+ ).fetchall()
1663
+ if not rows:
1664
+ return None
1665
+ paths = [r[0] for r in rows]
1666
+ # 2) Entries restricted to those paths in the range. Typical
1667
+ # path-count is 1-3 (resume across files), well below SQLite's
1668
+ # 999 parameter cap.
1669
+ start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
1670
+ end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
1671
+ placeholders = ",".join("?" * len(paths))
1672
+ cur = conn.execute(
1673
+ f"SELECT se.timestamp_utc, se.model, "
1674
+ f" se.input_tokens, se.output_tokens, "
1675
+ f" se.cache_create_tokens, se.cache_read_tokens, "
1676
+ f" se.source_path, sf.session_id, sf.project_path, "
1677
+ f" se.cost_usd_raw "
1678
+ f"FROM session_entries se "
1679
+ f"LEFT JOIN session_files sf ON sf.path = se.source_path "
1680
+ f"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
1681
+ f" AND se.source_path IN ({placeholders}) "
1682
+ f"ORDER BY se.timestamp_utc ASC",
1683
+ [start_iso, end_iso, *paths],
1684
+ )
1685
+ entries = [
1686
+ _JoinedClaudeEntry(
1687
+ timestamp=dt.datetime.fromisoformat(row[0]),
1688
+ model=row[1],
1689
+ input_tokens=row[2],
1690
+ output_tokens=row[3],
1691
+ cache_creation_tokens=row[4],
1692
+ cache_read_tokens=row[5],
1693
+ source_path=row[6],
1694
+ session_id=row[7],
1695
+ project_path=row[8],
1696
+ cost_usd=row[9],
1697
+ )
1698
+ for row in cur
1699
+ ]
1700
+ if not entries:
1701
+ return None
1702
+ sessions = _aggregate_claude_sessions(entries)
1703
+ for s in sessions:
1704
+ if s.session_id == session_id:
1705
+ return s
1706
+ return None
1707
+ finally:
1708
+ try:
1709
+ conn.close()
1710
+ except sqlite3.Error:
1711
+ pass
1712
+
1713
+
1615
1714
  def _tui_build_session_detail(
1616
1715
  session_id: str,
1617
1716
  *,
@@ -1619,19 +1718,34 @@ def _tui_build_session_detail(
1619
1718
  ) -> TuiSessionDetail | None:
1620
1719
  """Look up one session by ID; return None if not found.
1621
1720
 
1622
- Reuses the same `get_claude_session_entries` + `_aggregate_claude_sessions`
1623
- pipeline as `_tui_build_sessions` but filters down to the matching ID.
1624
- Bounded scan window matches the panel builder (365 days).
1721
+ Fast path: ``_tui_build_session_detail_indexed`` reads
1722
+ ``session_files`` by id, scopes the entries SELECT to the matching
1723
+ source_paths, and aggregates only those rows turning the lookup
1724
+ from "build every session in 365 days" into an indexed direct
1725
+ fetch (~3000× fewer rows on real DBs).
1726
+
1727
+ Slow-path fallback: when the indexed lookup misses (session_files
1728
+ not yet backfilled, cache unavailable, or genuinely unknown), the
1729
+ legacy bulk-fetch + linear scan still runs so the modal renders
1730
+ consistently with the panel's session list during the lazy-
1731
+ backfill window.
1625
1732
  """
1626
1733
  now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
1627
1734
  range_start = now_utc - dt.timedelta(days=365)
1628
- entries = get_claude_session_entries(range_start, now_utc, skip_sync=True)
1629
- sessions = _aggregate_claude_sessions(entries)
1630
- match: Any | None = None
1631
- for s in sessions:
1632
- if s.session_id == session_id:
1633
- match = s
1634
- break
1735
+ match: Any | None = _tui_build_session_detail_indexed(
1736
+ session_id, range_start, now_utc,
1737
+ )
1738
+ if match is None:
1739
+ # Fall back to the bulk-aggregate path. Same shape as before,
1740
+ # used only when the index lookup couldn't conclude.
1741
+ entries = get_claude_session_entries(
1742
+ range_start, now_utc, skip_sync=True,
1743
+ )
1744
+ sessions = _aggregate_claude_sessions(entries)
1745
+ for s in sessions:
1746
+ if s.session_id == session_id:
1747
+ match = s
1748
+ break
1635
1749
  if match is None:
1636
1750
  return None
1637
1751
  duration_min = (match.last_activity - match.first_activity).total_seconds() / 60.0
@@ -1896,6 +2010,109 @@ def _tui_build_snapshot(
1896
2010
  fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
1897
2011
  except Exception as exc:
1898
2012
  errors.append(f"five-hour-milestones: {exc}")
2013
+ # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
2014
+ # Per-tick aggregation lives on the sync thread; the dashboard's
2015
+ # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
2016
+ # back unchanged. Errors are recorded on ``last_sync_error`` —
2017
+ # the client renders the panel-empty state when the field is
2018
+ # None (first tick, or sub-build failure).
2019
+ #
2020
+ # ATTACH cache.db onto the open stats conn so
2021
+ # ``_build_projects_envelope`` (which reads ``session_entries`` +
2022
+ # ``session_files`` + ``weekly_usage_snapshots`` off one conn —
2023
+ # the test contract per tests/test_projects_envelope.py) sees
2024
+ # all three tables. ATTACH/DETACH is cheap and scoped to this
2025
+ # sub-build; no schema migration / lock acquisition is needed.
2026
+ projects_envelope_block: dict | None = None
2027
+ try:
2028
+ c = _cctally()
2029
+ cache_db_path = c.CACHE_DB_PATH
2030
+ conn.execute(
2031
+ "ATTACH DATABASE ? AS cache_db",
2032
+ (str(cache_db_path),),
2033
+ )
2034
+ # session_entries / session_files live in cache.db; the
2035
+ # builder reads them via raw SQL keyed by the unqualified
2036
+ # table names. SQLite's name resolution prefers the `main`
2037
+ # schema, so create temporary views in `main` that point
2038
+ # at the attached schema's tables. Aliasing via VIEWs keeps
2039
+ # the builder portable: unit tests pass one conn carrying
2040
+ # both schemas; production wiring uses an attached cache.
2041
+ conn.execute(
2042
+ "CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
2043
+ "SELECT * FROM cache_db.session_entries"
2044
+ )
2045
+ conn.execute(
2046
+ "CREATE TEMP VIEW IF NOT EXISTS session_files AS "
2047
+ "SELECT * FROM cache_db.session_files"
2048
+ )
2049
+ projects_envelope_block = c._build_projects_envelope(
2050
+ conn,
2051
+ now_utc=now_utc,
2052
+ current_week=cw,
2053
+ weeks_back=12,
2054
+ )
2055
+ except Exception as exc:
2056
+ errors.append(f"projects-envelope: {exc}")
2057
+ finally:
2058
+ try:
2059
+ conn.execute("DROP VIEW IF EXISTS session_entries")
2060
+ conn.execute("DROP VIEW IF EXISTS session_files")
2061
+ except Exception:
2062
+ pass
2063
+ try:
2064
+ conn.execute("DETACH DATABASE cache_db")
2065
+ except Exception:
2066
+ pass
2067
+ # Late-bind disambiguated `project_key` onto each SessionsPanel
2068
+ # row so the SessionsPanel → ProjectsModal cross-nav (spec §4.1)
2069
+ # routes by the same identity the Projects envelope emits.
2070
+ # Cheap dict-lookup per row; no second aggregation pass.
2071
+ #
2072
+ # `key_by_bucket_path` is indexed by git-root bucket_path (the
2073
+ # envelope builder calls `_resolve_project_key(..., "git-root")`
2074
+ # in `_build_projects_envelope`), but `srow.project_path` is the
2075
+ # raw cwd from `_aggregate_claude_sessions` — typically a
2076
+ # subdirectory of the repo root for monorepo sessions. We must
2077
+ # resolve each cwd through the same production resolver so the
2078
+ # lookup hits; otherwise `project_key` stays None and the
2079
+ # cross-nav button degrades to plain text.
2080
+ if projects_envelope_block is not None:
2081
+ try:
2082
+ key_by_bucket_path: dict[str, str] = {}
2083
+ for r in projects_envelope_block.get(
2084
+ "current_week", {}
2085
+ ).get("rows", []):
2086
+ bp = r.get("bucket_path")
2087
+ k = r.get("key")
2088
+ if bp and k:
2089
+ key_by_bucket_path[bp] = k
2090
+ for r in projects_envelope_block.get(
2091
+ "trend", {}
2092
+ ).get("projects", []):
2093
+ bp = r.get("bucket_path")
2094
+ k = r.get("key")
2095
+ if bp and k and bp not in key_by_bucket_path:
2096
+ key_by_bucket_path[bp] = k
2097
+ _resolve = _cctally()._resolve_project_key
2098
+ resolver_cache: dict = {}
2099
+ annotated: list[TuiSessionRow] = []
2100
+ for srow in sessions:
2101
+ pkey = None
2102
+ if srow.project_path:
2103
+ bp = _resolve(
2104
+ srow.project_path, "git-root", resolver_cache,
2105
+ ).bucket_path
2106
+ pkey = key_by_bucket_path.get(bp)
2107
+ if pkey is None:
2108
+ annotated.append(srow)
2109
+ else:
2110
+ annotated.append(
2111
+ dataclasses.replace(srow, project_key=pkey)
2112
+ )
2113
+ sessions = annotated
2114
+ except Exception as exc:
2115
+ errors.append(f"projects-cross-nav-bind: {exc}")
1899
2116
  return DataSnapshot(
1900
2117
  current_week=cw,
1901
2118
  forecast=fc,
@@ -1923,6 +2140,7 @@ def _tui_build_snapshot(
1923
2140
  trend_avg_dollars_per_pct=trend_avg_dpp,
1924
2141
  trend_history_median_dpp=history_median_dpp,
1925
2142
  forecast_view=fc_view,
2143
+ projects_envelope=projects_envelope_block,
1926
2144
  )
1927
2145
  finally:
1928
2146
  conn.close()
@@ -116,10 +116,16 @@ What stays in bin/cctally:
116
116
  write surface in cmd_dashboard works unchanged; moved code
117
117
  reads via ``c.X``.
118
118
  - ``eprint``, ``_now_utc`` (used by moved code via shim/accessor),
119
- ``_release_read_latest_release_version`` (stays in cctally per
120
- spec §6.7 — 6+ external callers, file I/O over CHANGELOG.md),
119
+ ``_release_read_latest_release_version`` (impl moved to
120
+ ``_lib_changelog._read_latest_changelog_version``; cctally
121
+ re-exports the historical name. The shim below routes through
122
+ ``sys.modules['cctally']`` so test ``monkeypatch.setitem(ns, ...)``
123
+ on the historical name still overrides the 5 internal callers
124
+ here),
121
125
  ``_release_parse_semver`` / ``_release_semver_sort_key`` (lives
122
- in ``_lib_semver`` and re-exported by cctally),
126
+ in ``_lib_semver``; shims below route directly to that module —
127
+ the cctally re-exports were removed in the release-command split
128
+ privatization),
123
129
  ``load_config`` (lives in ``_cctally_config``; re-exported),
124
130
  ``_BANNER_SUPPRESSED_COMMANDS`` (lives in ``_cctally_db``;
125
131
  re-exported by cctally — composed with the update-only
@@ -210,17 +216,35 @@ def load_config(*args, **kwargs):
210
216
 
211
217
 
212
218
  def _release_read_latest_release_version(*args, **kwargs):
219
+ """Back-compat shim. The implementation now lives in
220
+ ``bin/_lib_changelog._read_latest_changelog_version``; ``bin/cctally``
221
+ re-exports it under the historical name. This shim routes through
222
+ the ``cctally`` namespace (not directly to ``_lib_changelog``) so
223
+ that ``monkeypatch.setitem(ns, "_release_read_latest_release_version", ...)``
224
+ in tests/test_update.py + tests/test_release_internals.py overrides
225
+ the 5+ internal callers in this module. New code should call
226
+ ``_lib_changelog._read_latest_changelog_version`` directly.
227
+ """
213
228
  return sys.modules["cctally"]._release_read_latest_release_version(
214
229
  *args, **kwargs
215
230
  )
216
231
 
217
232
 
233
+ # The semver shims now resolve via ``_lib_semver`` directly — the
234
+ # cctally re-exports (``_release_parse_semver`` / ``_release_semver_sort_key``)
235
+ # were removed in the release-command split privatization. Kept as
236
+ # call-time shims (vs. module-top ``from _lib_semver import ...``) so
237
+ # tests can still ``monkeypatch.setitem(ns, "_release_parse_semver", ...)``
238
+ # on this module's namespace if needed; the bare-name lookup inside this
239
+ # module resolves here, not at the import site.
218
240
  def _release_parse_semver(*args, **kwargs):
219
- return sys.modules["cctally"]._release_parse_semver(*args, **kwargs)
241
+ import _lib_semver
242
+ return _lib_semver._release_parse_semver(*args, **kwargs)
220
243
 
221
244
 
222
245
  def _release_semver_sort_key(*args, **kwargs):
223
- return sys.modules["cctally"]._release_semver_sort_key(*args, **kwargs)
246
+ import _lib_semver
247
+ return _lib_semver._release_semver_sort_key(*args, **kwargs)
224
248
 
225
249
 
226
250
  def _normalize_alerts_enabled_value(*args, **kwargs):