feat: muse symlog — live per-symbol journal
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_totalrenamed tobilling.py::compute_invoice_totalin commit C produces aborn-frompointer so--followcan 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 inmuse code cat,muse symlog diff, andmuse 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
<old_content_id> <new_content_id> <commit_id> <author> <ts_unix> <tz_offset>\t<operation>
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 <commit_id>.
Sentinels for lifecycle events:
# Symbol created
000...000 <new_content_id> <commit_id> author ts +0000\tsymbol-created: compute_total
# Symbol deleted
<old_content_id> 000...000 <commit_id> author ts +0000\tsymbol-deleted: compute_total
# Rename — written to the OLD path (terminal entry)
<old_content_id> 000...000 <commit_id> author ts +0000\tsymbol-renamed-to: billing.py::compute_invoice_total
# Rename — written to the NEW path (born-from entry, enables --follow)
000...000 <new_content_id> <commit_id> 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
@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
SL_01—SymlogEntrydataclass has all seven fields;born_fromis parsed from the operation string when it starts withsymbol-born-from:, otherwiseNoneSL_02—append_symlog(repo_root, symbol_addr, old_content_id, new_content_id, commit_id, author, operation)creates.muse/symlogs/<encoded_path>on first write, appends one line; parent dirs created automaticallySL_03—read_symlog(repo_root, symbol_addr, limit, follow)returns entries newest-first, up tolimit; whenfollow=Trueand the oldest entry hasborn_from, recursively reads the prior symbol's log and appends those entries (annotated with the prior address)SL_04— Path encoding:src/billing.py::compute_totalmaps to.muse/symlogs/src/billing.py/compute_total; any character outside[a-zA-Z0-9._-]in the symbol name is percent-encoded;..components raiseValueErrorSL_05—list_symlog_symbols(repo_root)enumerates all leaf files under.muse/symlogs/, skips symlinks, returns decoded symbol addresses sortedSL_06—list_symlog_symbols_for_file(repo_root, file_path)returns addresses for all symbols under.muse/symlogs/<file_path>/— O(1) directory listing, no manifest scanSL_07—expire_symlog(repo_root, symbol_addr, expire_days, dry_run)prunes entries older thanexpire_days; atomic write (tmp sibling +os.replace); empty log → file deletedSL_08—delete_symlog_entry(repo_root, symbol_addr, index)removes entry at@{N}index or all entries whenindex=None; same atomic guarantee asexpire_symlogSL_09— File size cap_MAX_SYMLOG_BYTES(10 MiB) enforced before read with a warning, same defence asread_reflogSL_10— Author and operation are sanitized (newlines stripped, tab stripped from author) before any write; same rules as_sanitize_author/_sanitize_operationin reflogSL_11—NULL_CONTENT_IDconstant (64 zero hex chars prefixedsha256:) used for born/deleted sentinels; helperis_null_content_id(id)returnsTrueiff it matchesSL_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, verifylist_symlog_symbols_for_filereturns 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
SL_13— Newextract_symbols(repo_root, file_object_id) -> dict[str, str]function inmuse/core/symlog.pyreads 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 errorsSL_14—compute_symbol_diff(old_symbols, new_symbols) -> SymbolDiffreturns a dataclass withcreated,deleted,modifiedsets andrenames: 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+createSL_15—muse commitcalls_write_symlogs(repo_root, parent_commit_id, new_snapshot_id, new_commit_id, author)after the commit record is finalised; wrapped intry/except Exceptionwithlogger.warning; a raised exception never propagates to the callerSL_16— Created symbols:append_symlogwithold_content_id=NULL_CONTENT_ID, operation"symbol-created: <name>"SL_17— Modified symbols:append_symlogwith both content IDs populated, operation"symbol-modified: <first line of commit message>"SL_18— Deleted symbols:append_symlogwithnew_content_id=NULL_CONTENT_ID, operation"symbol-deleted: <name>"SL_19— Renamed symbols: twoappend_symlogcalls — old path getssymbol-renamed-to: <new_addr>(terminal entry,new_content_id=NULL_CONTENT_ID); new path getssymbol-born-from: <old_addr>(withold_content_id=NULL_CONTENT_IDandnew_content_idset to the symbol's actual new content ID)SL_20— Initial commit (no parent): all symbols in the new snapshot getsymbol-createdentries withold_content_id=NULL_CONTENT_IDSL_21— Integration test: two-commit sequence on a file with 3 functions; commit 1 creates all three → 3symbol-createdentries; commit 2 modifies one, deletes one, adds one new → verify exact entries appear in each symbol's log with correctcommit_idcross-referenceSL_22— Integration test: rename scenario — functionfoorenamed tobarin a single commit →foo's log ends withsymbol-renamed-to;bar's log starts withsymbol-born-from: …::foo;read_symlog("…::bar", follow=True)returns the full history including entries fromfoo'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)
{
"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
SL_23—muse symlog "file.py::Symbol" --jsonreturns the schema above with all fields always present;followedisfalseunless--followwas passedSL_24—--limit Ncaps the entry list after all filters are appliedSL_25—--operation PATTERNfilters entries whose operation contains the pattern (case-insensitive substring match)SL_26—--author PATTERNfilters by author field (case-insensitive)SL_27—--since YYYY-MM-DDand--until YYYY-MM-DDfilter by timestamp;--sinceafter--untilexitsUSER_ERRORSL_28—--followflag:followed: truein JSON; entries from prior symbol addresses include"from_symbol": "<prior_addr>"; entries are merged newest-first across the rename boundarySL_29—--diffflag: each entry gains a"diff"field in JSON containing the symbol body diff betweenold_content_idandnew_content_id(unified diff format, same asmuse diffoutput);nullfor created/deleted entries where one side is nullSL_30—muse symlog --file src/billing.py --jsonreturns a list of per-symbol result objects (same schema, one per symbol with a log under that file path); symbols with no log are omittedSL_31—muse symlog --all --jsonreturns{"symbols": ["addr1", "addr2", …], "count": N}; addresses are sorted; symlinks excludedSL_32—muse symlog exists "file.py::Symbol" --jsonreturns{"exists": true/false, "count": N, "symbol": "…"}with exit 0/1; no--jsonalso exits 0/1 (same scriptable pattern asmuse reflog exists)SL_33— Human text output: one line per entry formatted as@{N} <new_sha12> (<old_sha12>) <when> <author> <operation>; null content IDs rendered as"initial"(for created) or"deleted"(for terminal entries)SL_34— Symbol address is validated before any repo access; path traversal (..), empty symbol name, and missing::separator all exitUSER_ERRORwith a specific messageSL_35— Integration test: 4-commit history with filters applied in combination; verify--followstitches 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)
{
"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)
{
"exit_code": 0,
"duration_ms": 0.8,
"deleted": 1,
"remaining": 7,
"symbol": "src/billing.py::compute_total"
}
Deliverables
SL_36—muse symlog expire "file.py::Symbol" --expire-days 90 --jsonprunes entries older than 90 days; JSON reportsexpiredandkept; empty log → file deletedSL_37—muse symlog expire --file src/billing.py --jsonapplies to all symbols under that file path;symbols_processedlists every address touchedSL_38—muse symlog expire --all --jsonapplies to every symbol log in the repo;--expire-daysdefaults tosymlog.expire-daysconfig key, then 90SL_39—--dry-runreports counts without writing;"dry_run": truein JSONSL_40—muse config set symlog.expire-days Ncontrols the default TTL; subsequentmuse symlog expirewithout--expire-daysreads itSL_41—muse symlog delete "file.py::Symbol" @{N} --jsonremoves the entry at that index atomically; reportsdeleted: 1andremainingSL_42—muse symlog delete "file.py::Symbol" --allremoves all entries; log file deleted;deletedequals prior entry countSL_43— Out-of-bounds@{N}exitsUSER_ERRORwith the valid range in the messageSL_44— GC integration:GcResultgainssymlog_expired: int = 0;_run_gc_innercalls_expire_all_symlogs(repo_root, result, symlog_expire_days, dry_run=dry_run)after object GC;muse gc --jsonoutput gains thesymlog_expiredfieldSL_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-runreports 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
# 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
{
"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
SL_46—resolve_symlog_addr(spec, repo_root) -> SymlogResolution | Noneinmuse/core/symlog.py; matches<addr>@{N}(regex anchored); returnsNonefor non-matching specs; raisesFileNotFoundErrorwhen log missing; raisesIndexError(index, total)when out of rangeSL_47—muse symlog resolve "file.py::Symbol@{0}" --jsonreturns the schema above; exit 0 on success;USER_ERRORfor missing log or out-of-range index with a message stating the valid rangeSL_48—muse code cat "billing.py::compute_total@{2}" --jsonreads the function body from the object store usingcontent_idat symlog index 2; output is identical in shape tomuse code cat "billing.py::compute_total"todaySL_49—muse code cat "billing.py::compute_total@{0}"is equivalent to the current HEAD version (samecontent_idas the working-tree symbol)SL_50—muse symlog diff "billing.py::compute_total@{1}" "billing.py::compute_total@{0}" --jsonshows the unified diff between the two object IDs;added,removed,contextline counts in JSON; human text is a standard unified diff blockSL_51—muse symlog diff "billing.py::compute_total@{2}" HEAD --jsonwhereHEADresolves to the current working-tree version of that symbolSL_52— Out-of-range@{N}in any of the above commands exitsUSER_ERRORwith"valid range: 0–N"in the error messageSL_53—muse symlog resolve "file.py::compute_invoice_total@{5}" --followwhen index 5 crosses a rename boundary (entry issymbol-born-from: file.py::compute_total); resolves the entry fromcompute_total's log, annotated with"followed_from": "file.py::compute_total"in JSONSL_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 CSL_55— Integration test: rename pipeline —foocreated in commit A, modified in commit B, renamed tobarin commit C;muse symlog resolve "file.py::bar@{0}"returns commit C's content_id;muse symlog resolve "file.py::bar@{2}" --followreturns commit A's content_id offoo; the full--followchain has 3 entries
Test file: tests/test_cmd_symlog_refs.py
Acceptance criteria
muse commitwrites 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.--followtraverses 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 gcprunes symlog entries older thansymlog.expire-daysand reportssymlog_expiredin JSON.- All 55 test IDs (
SL_01–SL_55) are green. muse code test --jsonfor changed files reports no regressions.
Out of scope
- Remote symlog sharing — symlogs are local-only, never pushed.
muse symlog coupling/muse symlog hotspotsfrom the live journal — analytics commands that read across all symbol logs; deferred to a follow-up issue.muse blameintegration with symlog data — deferred.muse code narrativerewrite 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/--untilinstead.
All 55 deliverables (SL_01–SL_55) verified against the implementation across all 5 phases. 201 tests pass across test_core_symlog.py, test_cmd_commit_symlog.py, test_cmd_symlog_read.py, test_cmd_symlog_lifecycle.py, and test_cmd_symlog_refs.py.
Phase 1 (SL_01–12): SymlogEntry dataclass, append_symlog, read_symlog (follow chain), symlog_path (encoding + traversal guard), list_symlog_symbols/for_file (symlink-safe), expire_symlog (atomic os.replace), delete_symlog_entry (atomic, IndexError(index,total)), 10 MiB cap, author/op sanitization, NULL_CONTENT_ID + is_null_content_id. All confirmed in muse/core/symlog.py.
Phase 2 (SL_13–22): extract_symbols, compute_symbol_diff (SymbolDiff with renames list), _write_symlogs called in commit.py inside try/except with logger.warning. Created/modified/deleted/renamed emit correct operation strings. Initial commit (no parent) correctly uses old_manifest={} so all symbols land in diff.created. All confirmed in muse/core/symlog.py + muse/cli/commands/commit.py.
Phase 3 (SL_23–35): All JSON fields always present. --limit, --operation, --author, --since/--until filters implemented; since > until exits USER_ERROR. --follow wired with from_symbol map. --diff returns None for created/deleted (null content ID guard). --file returns per-symbol result list. --all returns {"symbols": [...], "count": N}. exists returns exit_code 0/1 + {exists, count, symbol}. _validate_symbol_addr catches empty addr, missing ::, empty name, .. traversal. All confirmed in muse/cli/commands/symlog.py.
Phase 4 (SL_36–45): expire CLI (single/--file/--all), symlog.expire-days config key read when --expire-days absent, --dry-run computes without writing, delete atomic with out-of-range USER_ERROR. GcResult.symlog_expired field, _expire_all_symlogs called in muse/core/gc.py after object GC. All confirmed.
Phase 5 (SL_46–55): SymlogResolution dataclass, resolve_symlog_addr (None/FileNotFoundError/IndexError), run_resolve CLI, @{N} in muse code cat via _SYMLOG_AT_RE in cat.py (source_ref = "symlog@{N}: commit <sha8>"), run_diff with unified_diff + added/removed/context counts + HEAD workdir read, out-of-range error includes valid range, --follow sets followed_from at rename boundary. All confirmed.
Follow-up items tracked in #53.