"""Thin CLI dispatchers for Phase 3.3 ``muse code`` commands on vaults. Each function in this module is a single-purpose runner invoked by the corresponding ``muse code `` CLI when the active domain is ``knowtation``. They build the right inputs (commits, snapshot loader, link index) and delegate to :mod:`muse.plugins.knowtation.note_metrics`. Routing pattern in each CLI command (``hotspots.py``, ``gravity.py``, ``dead.py``, ``clones.py``, ``entangle.py``, ``velocity.py``):: from muse.plugins.registry import read_domain from muse.plugins.knowtation.cli_hooks import run__for_vault def run(args): root = require_repo() try: domain = read_domain(root) except Exception: domain = "code" if domain == "knowtation": run__for_vault(root, args) return # … existing code-domain logic … Each runner prints JSON when ``args.as_json`` is True; otherwise emits a brief human-readable rendering. All runners exit via ``SystemExit`` on user-facing errors (e.g. missing vault, malformed args). """ from __future__ import annotations import json import logging import pathlib import sys from typing import Any from muse.core.errors import ExitCode from muse.core.repo import read_repo_id from muse.core.store import ( get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref, ) from muse.core.validation import sanitize_display from muse.plugins.code._query import walk_commits_bfs logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _walk_commits(root: pathlib.Path, max_commits: int = 500) -> list: """BFS-walk the commit DAG from HEAD and return CommitRecord list. Args: root: Repository root. max_commits: BFS safety cap. Returns: Commits sorted newest-first. Empty list on any failure. """ try: repo_id = read_repo_id(root) branch = read_current_branch(root) head = resolve_commit_ref(root, repo_id, branch, "HEAD") if head is None: return [] commits, _ = walk_commits_bfs(root, head.commit_id, max_commits=max_commits) return commits except (Exception, SystemExit) as exc: logger.debug("cli_hooks: walk_commits failed: %s", exc) return [] def _snapshot_loader(root: pathlib.Path): """Return a closure ``commit_id → manifest`` for the metrics helpers.""" def loader(commit_id: str) -> dict[str, str]: try: return get_commit_snapshot_manifest(root, commit_id) or {} except Exception: return {} return loader def _emit(result: dict[str, Any], *, as_json: bool, text_renderer) -> None: """Emit *result* as JSON or via *text_renderer*. Args: result: Result dict (must be JSON-serialisable). as_json: If True, dump to stdout as JSON and return. text_renderer: Callable taking the result dict and printing text. """ if as_json: result["domain"] = "knowtation" print(json.dumps(result, indent=2)) return text_renderer(result) # --------------------------------------------------------------------------- # hotspots # --------------------------------------------------------------------------- def run_hotspots_for_vault(root: pathlib.Path, args) -> None: """muse code hotspots — vault note churn leaderboard.""" from muse.plugins.knowtation.note_metrics import note_hotspots commits = _walk_commits(root) loader = _snapshot_loader(root) top = getattr(args, "top", 20) or 20 min_changes = getattr(args, "min_changes", None) or getattr(args, "min", 1) or 1 result = note_hotspots(commits, loader, top=top, min_changes=min_changes) def text(r: dict[str, Any]) -> None: print(f"\nNote churn — top {top} most-changed notes") print(f"Commits analysed: {r['commits_analysed']}") print("─" * 62) if not r["ranking"]: print(" (no churn detected)") return for i, entry in enumerate(r["ranking"], 1): print(f" {i:>3} {sanitize_display(entry['path']):<48} {entry['changes']:>3} change(s)") _emit(result, as_json=getattr(args, "as_json", False), text_renderer=text) # --------------------------------------------------------------------------- # gravity # --------------------------------------------------------------------------- def run_gravity_for_vault(root: pathlib.Path, args) -> None: """muse code gravity — vault note structural weight.""" from muse.plugins.knowtation.link_index import build_link_index from muse.plugins.knowtation.note_metrics import note_gravity try: index = build_link_index(root) except ValueError as exc: print(f"❌ Could not index vault: {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) top = getattr(args, "top", 20) or 20 result = note_gravity(index, top=top) def text(r: dict[str, Any]) -> None: print(f"\nNote gravity — top {top} most-depended-upon notes") print(f"Total notes in vault: {r['total_notes']}") print("─" * 62) if not r["ranking"]: print(" (no inbound links in vault)") return for i, entry in enumerate(r["ranking"], 1): grav_pct = entry["gravity"] * 100 print( f" {i:>3} {sanitize_display(entry['path']):<48} " f"{entry['inbound']:>3} inbound ({grav_pct:.2f}%)" ) _emit(result, as_json=getattr(args, "as_json", False), text_renderer=text) # --------------------------------------------------------------------------- # dead # --------------------------------------------------------------------------- def run_dead_for_vault(root: pathlib.Path, args) -> None: """muse code dead — vault dead-note detector.""" from muse.plugins.knowtation.link_index import build_link_index from muse.plugins.knowtation.note_metrics import note_dead try: index = build_link_index(root) except ValueError as exc: print(f"❌ Could not index vault: {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) use_mtime = getattr(args, "use_mtime", True) days = getattr(args, "mtime_days", 180) or 180 result = note_dead( index, root=root, use_mtime=use_mtime, mtime_days_threshold=days ) top = getattr(args, "top", None) if top: result["dead_notes"] = result["dead_notes"][:top] def text(r: dict[str, Any]) -> None: print(f"\nDead notes (zero inbound links{'/old' if use_mtime else ''})") print(f"Indexed: {r['total_notes_indexed']} Dead: {r['dead_count']}") print("─" * 62) if not r["dead_notes"]: print(" (no dead notes found)") return for entry in r["dead_notes"]: print(f" {sanitize_display(entry['path'])}") _emit(result, as_json=getattr(args, "as_json", False), text_renderer=text) # --------------------------------------------------------------------------- # clones # --------------------------------------------------------------------------- def run_clones_for_vault(root: pathlib.Path, args) -> None: """muse code clones — vault duplicate-note detector.""" from muse.plugins.knowtation.note_metrics import note_clones try: repo_id = read_repo_id(root) branch = read_current_branch(root) head = resolve_commit_ref(root, repo_id, branch, "HEAD") if head is None: print("❌ No HEAD commit found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) manifest = get_commit_snapshot_manifest(root, head.commit_id) or {} except Exception as exc: print(f"❌ Could not read HEAD manifest: {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) result = note_clones(manifest) def text(r: dict[str, Any]) -> None: print("\nDuplicate notes (exact content hash matches)") print(f"Total notes: {r['total_notes']} Clones: {r['total_clones']}") print("─" * 62) if not r["clone_groups"]: print(" (no duplicate notes found)") return for group in r["clone_groups"]: print(f"\n hash {group['hash'][:12]}…:") for path in group["paths"]: print(f" {sanitize_display(path)}") _emit(result, as_json=getattr(args, "as_json", False), text_renderer=text) # --------------------------------------------------------------------------- # entangle # --------------------------------------------------------------------------- def run_entangle_for_vault(root: pathlib.Path, args) -> None: """muse code entangle — vault note co-change pair detector. Phase 3.5: passes the LinkIndex so mist-attachment co-references add to the entanglement score (notes that share a mist artefact become entangled). """ from muse.plugins.knowtation.link_index import build_link_index from muse.plugins.knowtation.note_metrics import note_entangle commits = _walk_commits(root) loader = _snapshot_loader(root) top = getattr(args, "top", 20) or 20 min_co_changes = getattr(args, "min", 2) or 2 try: link_idx = build_link_index(root) except Exception: # noqa: BLE001 link_idx = None result = note_entangle( commits, loader, min_co_changes=min_co_changes, top=top, link_index=link_idx, ) def text(r: dict[str, Any]) -> None: print(f"\nNote entanglement — top {top} co-changing pairs") print(f"Commits analysed: {r['commits_analysed']}") print("─" * 62) if not r["ranking"]: print(" (no co-changing note pairs found)") return for entry in r["ranking"]: a, b = entry["pair"] print( f" {sanitize_display(a)} ↔ {sanitize_display(b)} " f"({entry['co_changes']} co-changes)" ) _emit(result, as_json=getattr(args, "as_json", False), text_renderer=text) # --------------------------------------------------------------------------- # velocity # --------------------------------------------------------------------------- def run_velocity_for_vault(root: pathlib.Path, args) -> None: """muse code velocity — vault note add/remove/modify velocity.""" from muse.plugins.knowtation.note_metrics import note_velocity commits = _walk_commits(root) loader = _snapshot_loader(root) window = getattr(args, "window", 10) or 10 try: result = note_velocity(commits, loader, window_size=window) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) def text(r: dict[str, Any]) -> None: print(f"\nVault velocity — window size {r['window_size']} commit(s)") print("─" * 62) rw = r["recent_window"] ow = r["older_window"] print(f" Recent window: +{rw['added']} / -{rw['removed']} / ~{rw['modified']} (net {rw['net']:+d})") print(f" Older window: +{ow['added']} / -{ow['removed']} / ~{ow['modified']} (net {ow['net']:+d})") print(f" Acceleration: {r['acceleration']:+d} notes") _emit(result, as_json=getattr(args, "as_json", False), text_renderer=text)