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
- [x]
SL_01—SymlogEntrydataclass has all seven fields;born_fromis parsed from the operation string when it starts withsymbol-born-from:, otherwiseNone - [x]
SL_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 automatically - [x]
SL_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) - [x]
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 raiseValueError - [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/<file_path>/— O(1) directory listing, no manifest scan - [x]
SL_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 deleted - [x]
SL_08—delete_symlog_entry(repo_root, symbol_addr, index)removes entry at@{N}index or all entries whenindex=None; same atomic guarantee asexpire_symlog - [x]
SL_09— File size cap_MAX_SYMLOG_BYTES(10 MiB) enforced before read with a warning, same defence asread_reflog - [x]
SL_10— Author and operation are sanitized (newlines stripped, tab stripped from author) before any write; same rules as_sanitize_author/_sanitize_operationin reflog - [x]
SL_11—NULL_CONTENT_IDconstant (64 zero hex chars prefixedsha256:) used for born/deleted sentinels; helperis_null_content_id(id)returnsTrueiff 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, 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
- [x]
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 errors - [x]
SL_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+create - [x]
SL_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 caller - [x]
SL_16— Created symbols:append_symlogwithold_content_id=NULL_CONTENT_ID, operation"symbol-created: <name>" - [x]
SL_17— Modified symbols:append_symlogwith both content IDs populated, operation"symbol-modified: <first line of commit message>" - [x]
SL_18— Deleted symbols:append_symlogwithnew_content_id=NULL_CONTENT_ID, operation"symbol-deleted: <name>" - [x]
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) - [x]
SL_20— Initial commit (no parent): all symbols in the new snapshot getsymbol-createdentries withold_content_id=NULL_CONTENT_ID - [x]
SL_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-reference - [x]
SL_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
- [x]
SL_23—muse symlog "file.py::Symbol" --jsonreturns the schema above with all fields always present;followedisfalseunless--followwas passed - [x]
SL_24—--limit Ncaps the entry list after all filters are applied - [x]
SL_25—--operation PATTERNfilters entries whose operation contains the pattern (case-insensitive substring match) - [x]
SL_26—--author PATTERNfilters by author field (case-insensitive) - [x]
SL_27—--since YYYY-MM-DDand--until YYYY-MM-DDfilter by timestamp;--sinceafter--untilexitsUSER_ERROR - [x]
SL_28—--followflag:followed: truein JSON; entries from prior symbol addresses include"from_symbol": "<prior_addr>"; entries are merged newest-first across the rename boundary - [x]
SL_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 null - [x]
SL_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 omitted - [x]
SL_31—muse symlog --all --jsonreturns{"symbols": ["addr1", "addr2", …], "count": N}; addresses are sorted; symlinks excluded - [x]
SL_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) - [x]
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) - [x]
SL_34— Symbol address is validated before any repo access; path traversal (..), empty symbol name, and missing::separator all exitUSER_ERRORwith a specific message - [x]
SL_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
- [x]
SL_36—muse symlog expire "file.py::Symbol" --expire-days 90 --jsonprunes entries older than 90 days; JSON reportsexpiredandkept; empty log → file deleted - [x]
SL_37—muse symlog expire --file src/billing.py --jsonapplies to all symbols under that file path;symbols_processedlists every address touched - [x]
SL_38—muse symlog expire --all --jsonapplies to every symbol log in the repo;--expire-daysdefaults tosymlog.expire-daysconfig key, then 90 - [x]
SL_39—--dry-runreports counts without writing;"dry_run": truein JSON - [x]
SL_40—muse config set symlog.expire-days Ncontrols the default TTL; subsequentmuse symlog expirewithout--expire-daysreads it - [x]
SL_41—muse symlog delete "file.py::Symbol" @{N} --jsonremoves the entry at that index atomically; reportsdeleted: 1andremaining - [x]
SL_42—muse symlog delete "file.py::Symbol" --allremoves all entries; log file deleted;deletedequals prior entry count - [x]
SL_43— Out-of-bounds@{N}exitsUSER_ERRORwith the valid range in the message - [x]
SL_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_expiredfield - [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-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
- [x]
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 range - [x]
SL_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 range - [x]
SL_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"today - [x]
SL_49—muse code cat "billing.py::compute_total@{0}"is equivalent to the current HEAD version (samecontent_idas the working-tree symbol) - [x]
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 block - [x]
SL_51—muse symlog diff "billing.py::compute_total@{2}" HEAD --jsonwhereHEADresolves to the current working-tree version of that symbol - [x]
SL_52— Out-of-range@{N}in any of the above commands exitsUSER_ERRORwith"valid range: 0–N"in the error message - [x]
SL_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 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 —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.