"""muse code symbol-log -- track a single semantic symbol through commit history. This command is impossible in Git: Git's ``git log -p src/utils.py`` shows every line that changed in a file; it has no concept of a *function*. ``muse code symbol-log`` tracks the full lifecycle of a single named symbol -- when it was created, when its implementation changed, when it was renamed, and when it was deleted -- across the entire commit DAG, including all branches that were ever merged in. For **knowtation vaults**, the symbol address is a note section:: muse code symbol-log "notes/memory.md::section:2:Background#0" When the commit carries Phase-4.2 memory-event metadata (``--event-type``, ``--agent-id``, ``--model-id``), each event in the output includes those fields. This turns ``symbol-log`` into a **memory replay** — for a given note section you can see what type of vault activity triggered each change (e.g. ``consolidation``, ``agent_interaction``) and which agent model wrote it. Usage:: muse code symbol-log "src/utils.py::calculate_total" muse code symbol-log "src/models.py::User.save" muse code symbol-log "notes/session-2026-05-13.md::section:1:Summary#0" Output (code domain):: Symbol: src/utils.py::calculate_total ---------------------------------------------------------------------- * a3f2c9 2026-03-14 "Refactor: extract validation logic" created function calculate_total * cb4afa 2026-03-15 "Perf: optimise total calculation" modified implementation changed * 1d2e3f 2026-03-16 "Rename: calculate_total -> compute_total" renamed calculate_total -> compute_total (tracking continues as src/utils.py::compute_total) * 4a5b6c 2026-03-17 "Move: refactor to helpers module" moved src/utils.py::compute_total -> src/helpers.py::compute_total (tracking continues at src/helpers.py::compute_total) 4 events (created: 1 modified: 1 renamed: 1 moved: 1) Output (knowtation domain — memory replay):: Symbol: notes/session.md::section:1:Summary#0 ────────────────────────────────────────────────────────────────────── ● a3f2c9 2026-05-13 "consolidation pass: merge session notes" created first section content written event_type: consolidation agent_id: knowtation-daemon model_id: claude-opus-4-7 ● cb4afa 2026-05-14 "write: updated summary" modified implementation changed event_type: write agent_id: aaronrene model_id: claude-sonnet-4-6 2 events (created: 1, modified: 1) Flags:: --from Start walking from this commit instead of HEAD. Accepts a full or abbreviated commit SHA or a branch name. --max Cap the number of commits inspected (default: 500). When the cap is reached a warning is shown; increase it with --max to see the full history. --json Emit a structured JSON object:: { "address": "::", "start_ref": "HEAD", "total_commits_scanned": 282, "truncated": false, "events": [ { "event": "created", "commit_id": "", "message": "...", "committed_at": "2026-03-14T...", "address": "::", "detail": "...", "new_address": null, "event_type": "consolidation", "agent_id": "knowtation-daemon", "model_id": "claude-opus-4-7" } ] } ``event_type``, ``agent_id``, and ``model_id`` are ``null`` when the commit does not carry Phase-4.2 memory metadata. """ from __future__ import annotations import argparse import json import logging import pathlib import sys from typing import Literal from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import ( CommitRecord, read_current_branch, resolve_commit_ref, ) from muse.domain import DomainOp from muse.plugins.code._query import walk_commits_bfs from muse.core.validation import clamp_int, sanitize_display type _IntMap = dict[str, int] type _SymbolLogDict = dict[str, str | None] logger = logging.getLogger(__name__) _EventKind = Literal["created", "modified", "renamed", "moved", "deleted", "signature"] # --------------------------------------------------------------------------- # Repository helpers # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Event extraction # --------------------------------------------------------------------------- def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]: """Flatten PatchOp children into a single list for address scanning.""" result: list[DomainOp] = [] for op in ops: if op["op"] == "patch": result.extend(op["child_ops"]) else: result.append(op) return result class SymbolEvent: """A single event in a symbol's lifecycle, with optional memory metadata. When the commit carrying this event was recorded with Phase-4.2 memory metadata (``--event-type``, ``--agent-id``, ``--model-id``), the corresponding fields are populated. They are ``None`` for commits that pre-date Phase 4.2 or that were not made by an agent using those flags. Attributes: kind: The lifecycle event kind (created / modified / renamed / …). commit: The full CommitRecord that introduced the change. address: The symbol address at the time of this event. detail: Human-readable description of what changed. new_address: Forwarding address after a rename or cross-file move. event_type: Knowtation memory-event kind from ``commit.metadata["event_type"]``, or ``None`` if not present. agent_id: The agent identity from ``commit.agent_id``, or ``None`` if empty. model_id: The model identity from ``commit.model_id``, or ``None`` if empty. """ __slots__ = ( "kind", "commit", "address", "detail", "new_address", "event_type", "agent_id", "model_id", ) def __init__( self, kind: _EventKind, commit: CommitRecord, address: str, detail: str, new_address: str | None = None, event_type: str | None = None, agent_id: str | None = None, model_id: str | None = None, ) -> None: self.kind = kind self.commit = commit self.address = address self.detail = detail self.new_address = new_address self.event_type = event_type self.agent_id = agent_id self.model_id = model_id def to_dict(self) -> _SymbolLogDict: """Serialise to a dict suitable for JSON output. Returns: A flat dict containing all event fields. ``event_type``, ``agent_id``, and ``model_id`` are always present; they are ``null`` when the commit carries no memory metadata. """ return { "event": self.kind, "commit_id": self.commit.commit_id, "message": self.commit.message, "committed_at": self.commit.committed_at.isoformat(), "address": self.address, "detail": self.detail, "new_address": self.new_address, "event_type": self.event_type, "agent_id": self.agent_id, "model_id": self.model_id, } def _memory_meta_from_commit(commit: CommitRecord) -> tuple[str | None, str | None, str | None]: """Extract Phase-4.2 memory metadata from a CommitRecord. Reads ``commit.metadata["event_type"]``, ``commit.agent_id``, and ``commit.model_id``, normalising empty strings to ``None`` so callers can use a simple truthiness check. Args: commit: The CommitRecord to extract from. Returns: Tuple of ``(event_type, agent_id, model_id)`` where each element is either a non-empty string or ``None``. """ event_type: str | None = commit.metadata.get("event_type") or None agent_id: str | None = (commit.agent_id or None) if commit.agent_id else None model_id: str | None = (commit.model_id or None) if commit.model_id else None return event_type, agent_id, model_id def _find_events_in_commit( commit: CommitRecord, address: str, ) -> tuple[list[SymbolEvent], str]: """Scan *commit*'s structured delta for events touching *address*. For each matching op, the resulting :class:`SymbolEvent` is populated with any Phase-4.2 memory metadata present on the commit (``event_type``, ``agent_id``, ``model_id``). This makes the symbol-log a memory replay when the repo was committed with ``--event-type`` etc. Args: commit: The CommitRecord to scan. address: Current symbol address to look for in the delta. Returns: Tuple ``(events, next_address)`` where *next_address* is the address to track in older commits (updated on rename events so history is continuous). """ events: list[SymbolEvent] = [] next_address = address if commit.structured_delta is None: return events, next_address event_type, agent_id, model_id = _memory_meta_from_commit(commit) all_ops = _flat_ops(commit.structured_delta["ops"]) for op in all_ops: op_address = op["address"] if op["op"] == "insert" and op_address == address: events.append(SymbolEvent( kind="created", commit=commit, address=address, detail=op.get("content_summary", "created"), event_type=event_type, agent_id=agent_id, model_id=model_id, )) elif op["op"] == "delete" and op_address == address: detail = op.get("content_summary", "deleted") if "moved to" in detail: events.append(SymbolEvent( kind="moved", commit=commit, address=address, detail=detail, new_address=None, event_type=event_type, agent_id=agent_id, model_id=model_id, )) else: events.append(SymbolEvent( kind="deleted", commit=commit, address=address, detail=detail, event_type=event_type, agent_id=agent_id, model_id=model_id, )) elif op["op"] == "replace" and op_address == address: new_summary: str = op.get("new_summary", "") if new_summary.startswith("renamed to "): new_name = new_summary.removeprefix("renamed to ").strip() file_prefix = address.rsplit("::", 1)[0] new_addr = f"{file_prefix}::{new_name}" events.append(SymbolEvent( kind="renamed", commit=commit, address=address, detail=f"{address.rsplit('::', 1)[-1]} → {new_name}", new_address=new_addr, event_type=event_type, agent_id=agent_id, model_id=model_id, )) next_address = new_addr elif new_summary.startswith("moved to "): events.append(SymbolEvent( kind="moved", commit=commit, address=address, detail=new_summary, new_address=None, event_type=event_type, agent_id=agent_id, model_id=model_id, )) elif "signature" in new_summary: events.append(SymbolEvent( kind="signature", commit=commit, address=address, detail=new_summary, event_type=event_type, agent_id=agent_id, model_id=model_id, )) else: events.append(SymbolEvent( kind="modified", commit=commit, address=address, detail=new_summary or "modified", event_type=event_type, agent_id=agent_id, model_id=model_id, )) return events, next_address # --------------------------------------------------------------------------- # Output # --------------------------------------------------------------------------- def _print_human( address: str, events: list[SymbolEvent], total_commits: int, truncated: bool, ) -> None: """Render the event list in human-readable text form. When any event carries memory metadata (``event_type``, ``agent_id``, ``model_id``), those fields are printed as indented lines below the event kind line so the output reads as a memory replay. Args: address: The original symbol address requested. events: Collected events, newest-first. total_commits: Number of commits scanned during the walk. truncated: True if the walk was capped by ``--max``. """ print(f"\nSymbol: {sanitize_display(address)}") print("─" * 62) if truncated: print( f"\n⚠️ History may be incomplete — scanned {total_commits:,} commits " f"(use --max to increase the limit).", ) if not events: print(" (no events found — symbol may not exist in this range)") return # Collected newest-first; reverse for chronological (oldest-first) display. chrono = list(reversed(events)) counts: _IntMap = {} for ev in chrono: counts[ev.kind] = counts.get(ev.kind, 0) + 1 date_str = ev.commit.committed_at.strftime("%Y-%m-%d") short_id = ev.commit.commit_id[:8] print(f'\n● {short_id} {date_str} "{sanitize_display(ev.commit.message)}"') print(f" {ev.kind:<14} {sanitize_display(ev.detail)}") if ev.new_address: print(f" (tracking continues as {sanitize_display(ev.new_address)})") # Memory-replay metadata: shown when the commit carries Phase-4.2 fields. if ev.event_type: print(f" event_type: {sanitize_display(ev.event_type)}") if ev.agent_id: print(f" agent_id: {sanitize_display(ev.agent_id)}") if ev.model_id: print(f" model_id: {sanitize_display(ev.model_id)}") total = len(events) summary_parts = [f"{k}: {v}" for k, v in sorted(counts.items())] print(f"\n{total} event(s) ({', '.join(summary_parts)})") # --------------------------------------------------------------------------- # Argument parser registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the symbol-log subcommand.""" parser = subparsers.add_parser( "symbol-log", help="Track a single symbol through the entire commit history.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "address", metavar="ADDRESS", help=( 'Fully-qualified symbol address, e.g. "src/utils.py::calculate_total" ' 'or "src/models.py::User.save". Must contain "::".' ), ) parser.add_argument( "--from", dest="from_ref", default=None, metavar="REF", help="Start walking from this commit / branch (default: HEAD).", ) parser.add_argument( "--max", dest="max_commits", type=int, default=500, metavar="N", help="Maximum number of commits to inspect (default: 500).", ) parser.add_argument( "--json", dest="as_json", action="store_true", help="Emit the full event list as structured JSON.", ) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # Command entry point # --------------------------------------------------------------------------- def run(args: argparse.Namespace) -> None: """Track a single symbol through the entire commit history. ``muse code symbol-log`` is impossible in Git: Git tracks file lines, not semantic symbols. This command follows a function, class, or method across every commit — detecting creation, implementation changes, renames, cross-file moves, and deletion. Unlike a plain ``git log``, Muse's walk crosses merge commit boundaries: events from feature branches that were merged in are included, not hidden behind the merge commit. ADDRESS must be a fully-qualified symbol address:: muse code symbol-log "src/utils.py::calculate_total" muse code symbol-log "src/models.py::User.save" muse code symbol-log "api/handlers.go::Server.HandleRequest" """ address: str = args.address from_ref: str | None = args.from_ref max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits') as_json: bool = args.as_json # ── Input validation ────────────────────────────────────────────────────── if "::" not in address: print( f"❌ '{address}' is not a valid symbol address.\n" " Expected 'file_path::SymbolName', " "e.g. 'src/utils.py::calculate_total'.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if max_commits < 1: print("❌ --max must be at least 1.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) # ── Repo / commit resolution ────────────────────────────────────────────── root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) start_commit = resolve_commit_ref(root, repo_id, branch, from_ref) if start_commit is None: label = from_ref or "HEAD" print(f"❌ Commit '{label}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) # ── DAG walk ────────────────────────────────────────────────────────────── commits, truncated = walk_commits_bfs(root, start_commit.commit_id, max_commits) # ── Event scan ──────────────────────────────────────────────────────────── # Walk commits newest-first, tracking address changes on rename events. current_address = address all_events: list[SymbolEvent] = [] for commit in commits: evs, current_address = _find_events_in_commit(commit, current_address) all_events.extend(evs) # ── Output ──────────────────────────────────────────────────────────────── if as_json: print(json.dumps({ "address": address, "start_ref": from_ref or "HEAD", "total_commits_scanned": len(commits), "truncated": truncated, "events": [e.to_dict() for e in reversed(all_events)], }, indent=2)) return _print_human(address, all_events, total_commits=len(commits), truncated=truncated)