gabriel / muse public
symlog.md markdown
478 lines 22.3 KB
Raw
sha256:24d9cb81ac1dd071e5d98862c740ad101180588361b4f96a790af79a99aed901 docs: architectural plan for unified-store snapshot-content… Sonnet 5 9 days ago

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

<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_01SymlogEntry dataclass has all seven fields; born_from is parsed from the operation string when it starts with symbol-born-from:, otherwise None
  • [x] SL_02append_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_03read_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_05list_symlog_symbols(repo_root) enumerates all leaf files under .muse/symlogs/, skips symlinks, returns decoded symbol addresses sorted
  • [x] SL_06list_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_07expire_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_08delete_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_11NULL_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_14compute_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_15muse 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: <name>"
  • [x] SL_17 — Modified symbols: append_symlog with both content IDs populated, operation "symbol-modified: <first line of commit message>"
  • [x] SL_18 — Deleted symbols: append_symlog with new_content_id=NULL_CONTENT_ID, operation "symbol-deleted: <name>"
  • [x] SL_19 — Renamed symbols: two append_symlog calls — old path gets symbol-renamed-to: <new_addr> (terminal entry, new_content_id=NULL_CONTENT_ID); new path gets symbol-born-from: <old_addr> (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)

{
  "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_23muse 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": "<prior_addr>"; 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_30muse 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_31muse symlog --all --json returns {"symbols": ["addr1", "addr2", …], "count": N}; addresses are sorted; symlinks excluded
  • [x] SL_32muse 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} <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 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)

{
  "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_36muse 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_37muse symlog expire --file src/billing.py --json applies to all symbols under that file path; symbols_processed lists every address touched
  • [x] SL_38muse 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_40muse config set symlog.expire-days N controls the default TTL; subsequent muse symlog expire without --expire-days reads it
  • [x] SL_41muse symlog delete "file.py::Symbol" @{N} --json removes the entry at that index atomically; reports deleted: 1 and remaining
  • [x] SL_42muse 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

# 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_46resolve_symlog_addr(spec, repo_root) -> SymlogResolution | None in muse/core/symlog.py; matches <addr>@{N} (regex anchored); returns None for non-matching specs; raises FileNotFoundError when log missing; raises IndexError(index, total) when out of range
  • [x] SL_47muse 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_48muse 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_49muse code cat "billing.py::compute_total@{0}" is equivalent to the current HEAD version (same content_id as the working-tree symbol)
  • [x] SL_50muse 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_51muse 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_53muse 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_01SL_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.
File History 1 commit
sha256:24d9cb81ac1dd071e5d98862c740ad101180588361b4f96a790af79a99aed901 docs: architectural plan for unified-store snapshot-content… Sonnet 5 9 days ago