# `muse symlog` — Live Per-Symbol Journal ## Background `git log -L` is the closest analogue in the git world and it reveals the gap clearly. To track a function's history you write either a regex (`/^def my_function/,/^def /`) or a line range (`10,50`) — both of which are heuristics that break on any formatting change, function reorder, or rename. Neither is native to git's data model. Git sees files; the function boundary is reverse-engineered on every query by rescanning the entire commit history. Muse already knows symbol boundaries at write time. Every commit records symbol-level content IDs alongside the file manifest. `muse code symbols --json` returns the exact `content_id` for every function, class, and variable in the working tree. The only thing missing is a live journal — written once at commit time, readable in O(1) forever — that captures what changed at the symbol level, who changed it, why, and what it used to be. That journal is symlog. ### Why git genuinely cannot do this The git model has no symbol identity. A function is not a first-class object — it is a region of bytes inside a blob, reconstructed by heuristic on every read. Muse symbols have stable content IDs that persist across commits and survive renames via a born-from lineage chain. A symlog entry for `billing.py::compute_total` records both the old and new content IDs, making it possible to retrieve the exact body of that function at any historical index without scanning commit history at all. ## Goal After this issue is complete: - Every commit that touches a symbol writes a symlog entry for that symbol — including created, modified, deleted, and renamed events. - Rename lineage is traversable: `billing.py::compute_total` renamed to `billing.py::compute_invoice_total` in commit C produces a `born-from` pointer so `--follow` can traverse the full history across the rename boundary. - `muse symlog "src/billing.py::compute_total"` shows a per-symbol history with operation, author, timestamp, and both old and new content IDs — newest first. - `@{N}` resolves to a symbol content ID at that symlog index, usable in `muse code cat`, `muse symlog diff`, and `muse symlog resolve`. - Lifecycle management mirrors reflog: per-symbol expiry, bulk expiry by file or repo, atomic writes, GC integration. ## Storage layout ``` .muse/symlogs/ src/ billing.py/ compute_total ← one file per symbol validate_invoice _apply_discount tests/ test_billing.py/ test_compute_total ``` The source tree is mirrored as a directory hierarchy. The symbol name is the leaf filename, percent-encoded for any character outside `[a-zA-Z0-9._-]`. Symlinks are skipped on enumeration. `..` path components are rejected before any filesystem access. ## Entry format ``` \t ``` `old_content_id` and `new_content_id` are the symbol-level content IDs from the code intelligence layer — not file object IDs, not commit IDs. They let you read the exact body of the function at any index without touching commit history. `commit_id` ties each entry to its cause, enabling cross-referencing with `muse read `. Sentinels for lifecycle events: ``` # Symbol created 000...000 author ts +0000\tsymbol-created: compute_total # Symbol deleted 000...000 author ts +0000\tsymbol-deleted: compute_total # Rename — written to the OLD path (terminal entry) 000...000 author ts +0000\tsymbol-renamed-to: billing.py::compute_invoice_total # Rename — written to the NEW path (born-from entry, enables --follow) 000...000 author ts +0000\tsymbol-born-from: billing.py::compute_total ``` ## Phases --- ### Phase 1 — Core storage layer (`muse/core/symlog.py`) New module, no dependencies on the CLI. The write and read primitives that all later phases build on. **Data shape** ```python @dataclass(frozen=True) class SymlogEntry: old_content_id: str # sha256:… or NULL_CONTENT_ID new_content_id: str # sha256:… or NULL_CONTENT_ID commit_id: str # sha256:… of the commit that caused this entry author: str # sanitized author / agent_id timestamp: datetime operation: str # "symbol-created", "symbol-modified: …", etc. born_from: str | None # prior symbol address when operation starts with "symbol-born-from:" ``` **Deliverables** - [x] `SL_01` — `SymlogEntry` dataclass has all seven fields; `born_from` is parsed from the operation string when it starts with `symbol-born-from:`, otherwise `None` - [x] `SL_02` — `append_symlog(repo_root, symbol_addr, old_content_id, new_content_id, commit_id, author, operation)` creates `.muse/symlogs/` on first write, appends one line; parent dirs created automatically - [x] `SL_03` — `read_symlog(repo_root, symbol_addr, limit, follow)` returns entries newest-first, up to `limit`; when `follow=True` and the oldest entry has `born_from`, recursively reads the prior symbol's log and appends those entries (annotated with the prior address) - [x] `SL_04` — Path encoding: `src/billing.py::compute_total` maps to `.muse/symlogs/src/billing.py/compute_total`; any character outside `[a-zA-Z0-9._-]` in the symbol name is percent-encoded; `..` components raise `ValueError` - [x] `SL_05` — `list_symlog_symbols(repo_root)` enumerates all leaf files under `.muse/symlogs/`, skips symlinks, returns decoded symbol addresses sorted - [x] `SL_06` — `list_symlog_symbols_for_file(repo_root, file_path)` returns addresses for all symbols under `.muse/symlogs//` — O(1) directory listing, no manifest scan - [x] `SL_07` — `expire_symlog(repo_root, symbol_addr, expire_days, dry_run)` prunes entries older than `expire_days`; atomic write (tmp sibling + `os.replace`); empty log → file deleted - [x] `SL_08` — `delete_symlog_entry(repo_root, symbol_addr, index)` removes entry at `@{N}` index or all entries when `index=None`; same atomic guarantee as `expire_symlog` - [x] `SL_09` — File size cap `_MAX_SYMLOG_BYTES` (10 MiB) enforced before read with a warning, same defence as `read_reflog` - [x] `SL_10` — Author and operation are sanitized (newlines stripped, tab stripped from author) before any write; same rules as `_sanitize_author` / `_sanitize_operation` in reflog - [x] `SL_11` — `NULL_CONTENT_ID` constant (64 zero hex chars prefixed `sha256:`) used for born/deleted sentinels; helper `is_null_content_id(id)` returns `True` iff it matches - [x] `SL_12` — Integration test: write 5 entries for one symbol address, read back newest-first, verify all fields round-trip; write entries for two different symbols in the same file, verify `list_symlog_symbols_for_file` returns both **Test file:** `tests/test_core_symlog.py` --- ### Phase 2 — Commit integration Wire `append_symlog` into the commit path. Every commit that changes a symbol's content ID produces a symlog entry for that symbol. Non-fatal: a write failure logs a warning and never aborts the commit. **Symbol diff algorithm (runs inside `muse commit`)** ``` old_manifest = get_commit_snapshot_manifest(repo_root, parent_commit_id) new_manifest = new snapshot manifest (just written) for each file_path where old_manifest[file_path] != new_manifest[file_path]: old_symbols = extract_symbols(repo_root, old_manifest[file_path]) # {name: content_id} new_symbols = extract_symbols(repo_root, new_manifest[file_path]) # {name: content_id} created = new_symbols.keys() - old_symbols.keys() deleted = old_symbols.keys() - new_symbols.keys() modified = {n for n in old_symbols if n in new_symbols and old_symbols[n] != new_symbols[n]} # Rename detection: deleted symbol whose content_id appears in a created symbol # → emit symbol-renamed-to on old path, symbol-born-from on new path ``` **Deliverables** - [x] `SL_13` — New `extract_symbols(repo_root, file_object_id) -> dict[str, str]` function in `muse/core/symlog.py` reads a file object from the store and returns `{symbol_name: content_id}` using the existing code intelligence extractor; returns `{}` for non-code files or parse errors - [x] `SL_14` — `compute_symbol_diff(old_symbols, new_symbols) -> SymbolDiff` returns a dataclass with `created`, `deleted`, `modified` sets and `renames: list[(old_name, new_name)]`; rename detection: a deleted name whose content_id is a prefix-match of a created name's content_id (same object) is classified as a rename, not a delete+create - [x] `SL_15` — `muse commit` calls `_write_symlogs(repo_root, parent_commit_id, new_snapshot_id, new_commit_id, author)` after the commit record is finalised; wrapped in `try/except Exception` with `logger.warning`; a raised exception never propagates to the caller - [x] `SL_16` — Created symbols: `append_symlog` with `old_content_id=NULL_CONTENT_ID`, operation `"symbol-created: "` - [x] `SL_17` — Modified symbols: `append_symlog` with both content IDs populated, operation `"symbol-modified: "` - [x] `SL_18` — Deleted symbols: `append_symlog` with `new_content_id=NULL_CONTENT_ID`, operation `"symbol-deleted: "` - [x] `SL_19` — Renamed symbols: two `append_symlog` calls — old path gets `symbol-renamed-to: ` (terminal entry, `new_content_id=NULL_CONTENT_ID`); new path gets `symbol-born-from: ` (with `old_content_id=NULL_CONTENT_ID` and `new_content_id` set to the symbol's actual new content ID) - [x] `SL_20` — Initial commit (no parent): all symbols in the new snapshot get `symbol-created` entries with `old_content_id=NULL_CONTENT_ID` - [x] `SL_21` — Integration test: two-commit sequence on a file with 3 functions; commit 1 creates all three → 3 `symbol-created` entries; commit 2 modifies one, deletes one, adds one new → verify exact entries appear in each symbol's log with correct `commit_id` cross-reference - [x] `SL_22` — Integration test: rename scenario — function `foo` renamed to `bar` in a single commit → `foo`'s log ends with `symbol-renamed-to`; `bar`'s log starts with `symbol-born-from: …::foo`; `read_symlog("…::bar", follow=True)` returns the full history including entries from `foo`'s log **Test file:** `tests/test_cmd_commit_symlog.py` --- ### Phase 3 — `muse symlog` read CLI The read surface. All subcommands accept `--json`. Human text follows the reflog display convention: `@{N}` index column, short content IDs, relative timestamps. **JSON schema (single-symbol query)** ```json { "exit_code": 0, "duration_ms": 1.2, "symbol": "src/billing.py::compute_total", "total": 12, "limit": 20, "followed": false, "entries": [ { "index": 0, "old_content_id": "sha256:<64-hex>", "new_content_id": "sha256:<64-hex>", "commit_id": "sha256:<64-hex>", "author": "claude-code", "timestamp": "2026-06-28T14:22:11+00:00", "operation": "symbol-modified: fix off-by-one in total", "born_from": null, "from_symbol": null } ] } ``` `from_symbol` is non-null on entries that came from a prior symbol address via `--follow`. **CLI surface** ``` muse symlog "src/billing.py::compute_total" # human text, limit 20 muse symlog "src/billing.py::compute_total" --json # machine-readable muse symlog "src/billing.py::compute_total" --limit 50 muse symlog "src/billing.py::compute_total" --follow # traverse rename chain muse symlog "src/billing.py::compute_total" --diff # include symbol body diff per entry muse symlog "src/billing.py::compute_total" --operation symbol-modified muse symlog "src/billing.py::compute_total" --author claude-code muse symlog "src/billing.py::compute_total" --since 2026-06-01 muse symlog "src/billing.py::compute_total" --until 2026-07-01 muse symlog --file src/billing.py # all symbols in file muse symlog --all # all symbols with symlogs muse symlog exists "src/billing.py::compute_total" # exit 0/1 ``` **Deliverables** - [x] `SL_23` — `muse symlog "file.py::Symbol" --json` returns the schema above with all fields always present; `followed` is `false` unless `--follow` was passed - [x] `SL_24` — `--limit N` caps the entry list after all filters are applied - [x] `SL_25` — `--operation PATTERN` filters entries whose operation contains the pattern (case-insensitive substring match) - [x] `SL_26` — `--author PATTERN` filters by author field (case-insensitive) - [x] `SL_27` — `--since YYYY-MM-DD` and `--until YYYY-MM-DD` filter by timestamp; `--since` after `--until` exits `USER_ERROR` - [x] `SL_28` — `--follow` flag: `followed: true` in JSON; entries from prior symbol addresses include `"from_symbol": ""`; entries are merged newest-first across the rename boundary - [x] `SL_29` — `--diff` flag: each entry gains a `"diff"` field in JSON containing the symbol body diff between `old_content_id` and `new_content_id` (unified diff format, same as `muse diff` output); `null` for created/deleted entries where one side is null - [x] `SL_30` — `muse symlog --file src/billing.py --json` returns a list of per-symbol result objects (same schema, one per symbol with a log under that file path); symbols with no log are omitted - [x] `SL_31` — `muse symlog --all --json` returns `{"symbols": ["addr1", "addr2", …], "count": N}`; addresses are sorted; symlinks excluded - [x] `SL_32` — `muse symlog exists "file.py::Symbol" --json` returns `{"exists": true/false, "count": N, "symbol": "…"}` with exit 0/1; no `--json` also exits 0/1 (same scriptable pattern as `muse reflog exists`) - [x] `SL_33` — Human text output: one line per entry formatted as `@{N} () `; null content IDs rendered as `"initial"` (for created) or `"deleted"` (for terminal entries) - [x] `SL_34` — Symbol address is validated before any repo access; path traversal (`..`), empty symbol name, and missing `::` separator all exit `USER_ERROR` with a specific message - [x] `SL_35` — Integration test: 4-commit history with filters applied in combination; verify `--follow` stitches entries from two symbol addresses into the correct newest-first merged order **Test file:** `tests/test_cmd_symlog_read.py` --- ### Phase 4 — Lifecycle management Per-symbol expiry, bulk expiry by file or repo, GC integration. Mirrors the reflog lifecycle surface exactly so agents already familiar with `muse reflog expire` need no re-learning. **CLI surface** ``` muse symlog expire "file.py::Symbol" [--expire-days N] [--dry-run] [--json] muse symlog expire --file file.py [--expire-days N] [--dry-run] [--json] muse symlog expire --all [--expire-days N] [--dry-run] [--json] muse symlog delete "file.py::Symbol" @{N} [--json] muse symlog delete "file.py::Symbol" --all [--json] muse config set symlog.expire-days 60 muse gc --json # gains symlog_expired field ``` **JSON schema (expire)** ```json { "exit_code": 0, "duration_ms": 3.1, "expired": 34, "kept": 18, "dry_run": false, "symbols_processed": ["src/billing.py::compute_total", "src/billing.py::validate_invoice"] } ``` **JSON schema (delete)** ```json { "exit_code": 0, "duration_ms": 0.8, "deleted": 1, "remaining": 7, "symbol": "src/billing.py::compute_total" } ``` **Deliverables** - [x] `SL_36` — `muse symlog expire "file.py::Symbol" --expire-days 90 --json` prunes entries older than 90 days; JSON reports `expired` and `kept`; empty log → file deleted - [x] `SL_37` — `muse symlog expire --file src/billing.py --json` applies to all symbols under that file path; `symbols_processed` lists every address touched - [x] `SL_38` — `muse symlog expire --all --json` applies to every symbol log in the repo; `--expire-days` defaults to `symlog.expire-days` config key, then 90 - [x] `SL_39` — `--dry-run` reports counts without writing; `"dry_run": true` in JSON - [x] `SL_40` — `muse config set symlog.expire-days N` controls the default TTL; subsequent `muse symlog expire` without `--expire-days` reads it - [x] `SL_41` — `muse symlog delete "file.py::Symbol" @{N} --json` removes the entry at that index atomically; reports `deleted: 1` and `remaining` - [x] `SL_42` — `muse symlog delete "file.py::Symbol" --all` removes all entries; log file deleted; `deleted` equals prior entry count - [x] `SL_43` — Out-of-bounds `@{N}` exits `USER_ERROR` with the valid range in the message - [x] `SL_44` — GC integration: `GcResult` gains `symlog_expired: int = 0`; `_run_gc_inner` calls `_expire_all_symlogs(repo_root, result, symlog_expire_days, dry_run=dry_run)` after object GC; `muse gc --json` output gains the `symlog_expired` field - [x] `SL_45` — Integration test: 100-entry symlog for one symbol, 50 entries older than threshold; `muse gc` → 50 removed, 50 kept; all-expire path → file deleted; `muse gc --dry-run` reports but does not write **Test file:** `tests/test_cmd_symlog_lifecycle.py` --- ### Phase 5 — `@{N}` ref resolution for symbols `@{N}` in a symbol address resolves to the symbol content ID at that symlog index. This makes the index column in `muse symlog` output directly actionable — the same number you see is the number you use to read, diff, or recover a prior version of a function. **Resolution forms** | Syntax | Resolves from | |--------|--------------| | `billing.py::compute_total@{0}` | symlog index 0 (newest) | | `billing.py::compute_total@{3}` | symlog index 3 | | `billing.py::compute_total@{0}` with `--follow` | traverses rename chain | **New commands** ```bash # Resolve @{N} to content_id + commit_id muse symlog resolve "src/billing.py::compute_total@{2}" --json # Read the exact body of the function at that index muse code cat "billing.py::compute_total@{2}" --json # Diff two symlog versions of the same symbol muse symlog diff "billing.py::compute_total@{1}" "billing.py::compute_total@{0}" --json # Diff a symlog version against HEAD muse symlog diff "billing.py::compute_total@{3}" HEAD --json ``` **`muse symlog resolve` JSON schema** ```json { "exit_code": 0, "duration_ms": 0.4, "symbol": "src/billing.py::compute_total", "index": 2, "content_id": "sha256:<64-hex>", "commit_id": "sha256:<64-hex>", "operation": "symbol-modified: add rounding", "timestamp": "2026-06-20T09:11:04+00:00" } ``` **Deliverables** - [x] `SL_46` — `resolve_symlog_addr(spec, repo_root) -> SymlogResolution | None` in `muse/core/symlog.py`; matches `@{N}` (regex anchored); returns `None` for non-matching specs; raises `FileNotFoundError` when log missing; raises `IndexError(index, total)` when out of range - [x] `SL_47` — `muse symlog resolve "file.py::Symbol@{0}" --json` returns the schema above; exit 0 on success; `USER_ERROR` for missing log or out-of-range index with a message stating the valid range - [x] `SL_48` — `muse code cat "billing.py::compute_total@{2}" --json` reads the function body from the object store using `content_id` at symlog index 2; output is identical in shape to `muse code cat "billing.py::compute_total"` today - [x] `SL_49` — `muse code cat "billing.py::compute_total@{0}"` is equivalent to the current HEAD version (same `content_id` as the working-tree symbol) - [x] `SL_50` — `muse symlog diff "billing.py::compute_total@{1}" "billing.py::compute_total@{0}" --json` shows the unified diff between the two object IDs; `added`, `removed`, `context` line counts in JSON; human text is a standard unified diff block - [x] `SL_51` — `muse symlog diff "billing.py::compute_total@{2}" HEAD --json` where `HEAD` resolves to the current working-tree version of that symbol - [x] `SL_52` — Out-of-range `@{N}` in any of the above commands exits `USER_ERROR` with `"valid range: 0–N"` in the error message - [x] `SL_53` — `muse symlog resolve "file.py::compute_invoice_total@{5}" --follow` when index 5 crosses a rename boundary (entry is `symbol-born-from: file.py::compute_total`); resolves the entry from `compute_total`'s log, annotated with `"followed_from": "file.py::compute_total"` in JSON - [x] `SL_54` — Integration test: 3-commit history — create symbol (commit A), modify it (commit B), modify again (commit C); `muse code cat "file.py::Symbol@{2}"` returns the body from commit A; `muse symlog diff "file.py::Symbol@{2}" "file.py::Symbol@{0}"` shows all lines added across commits B and C - [x] `SL_55` — Integration test: rename pipeline — `foo` created in commit A, modified in commit B, renamed to `bar` in commit C; `muse symlog resolve "file.py::bar@{0}"` returns commit C's content_id; `muse symlog resolve "file.py::bar@{2}" --follow` returns commit A's content_id of `foo`; the full `--follow` chain has 3 entries **Test file:** `tests/test_cmd_symlog_refs.py` --- ## Acceptance criteria - `muse commit` writes symlog entries for every symbol that changes; a write failure never aborts the commit. - `muse symlog "file.py::Symbol"` returns O(1) per-symbol history regardless of total commit count — no history scan. - `--follow` traverses rename chains and returns a unified newest-first entry list. - `muse code cat "file.py::Symbol@{N}"` reads the exact symbol body at symlog index N from the object store. - `muse symlog diff "@{N}" "@{M}"` produces a correct unified diff between two historical versions of a symbol. - `muse gc` prunes symlog entries older than `symlog.expire-days` and reports `symlog_expired` in JSON. - All 55 test IDs (`SL_01`–`SL_55`) are green. - `muse code test --json` for changed files reports no regressions. ## Out of scope - Remote symlog sharing — symlogs are local-only, never pushed. - `muse symlog coupling` / `muse symlog hotspots` from the live journal — analytics commands that read across all symbol logs; deferred to a follow-up issue. - `muse blame` integration with symlog data — deferred. - `muse code narrative` rewrite to use symlog as primary source — deferred. - Symlog entries for non-code domains (MIDI, binary) — the extractor returns `{}` for non-parseable files; those files produce no symlog entries. - Relative time syntax (`@{1.day.ago}`) — use `--since`/`--until` instead.