# Muse VCS — Musical Version Control System > **Status:** Canonical Implementation Reference > **E2E demo:** [`muse_e2e_demo.md`](muse_e2e_demo.md) --- ## What Muse Is Muse is a persistent, Git-style version control system for musical compositions. It tracks every committed change as a variation in a DAG (directed acyclic graph), enabling: - **Commit history** — every accepted variation is recorded with parent lineage - **Branching** — multiple variations can diverge from the same parent - **Three-way merge** — auto-merges non-conflicting changes, reports conflicts - **Drift detection** — compares HEAD snapshot against the live DAW state (`git status`) - **Checkout / time travel** — reconstruct any historical state via deterministic tool calls - **Log graph** — serialize the full commit DAG as Swift-ready JSON --- ## Why Muse and not Git? > *"Can't we just commit MIDI files to Git?"* You can. And you'll immediately discover everything Git cannot tell you about music. ### The core problem: Git sees music as bytes, not music `git diff` on a MIDI file produces binary noise. `git log` tells you "file changed." That's it. Git is a filesystem historian — it records *which bytes* changed, not *what happened musically*. Music is **multidimensional** and **happens in time**. A single session commit might simultaneously change the key, the groove, the instrumentation, the dynamic arc, and the emotional character — dimensions that share zero representation in Git's diff model. ### What Muse can do that Git categorically cannot | Question | Git | Muse | |----------|-----|------| | What key is this arrangement in? | ❌ | ✅ `muse key HEAD` | | How did the chord progression change between commit 12 and commit 47? | ❌ | ✅ `muse diff HEAD~35 HEAD --harmonic` | | When did the song modulate from Eb major to F minor? | ❌ | ✅ `muse find --harmony "key=F minor"` | | Did the groove get tighter or looser over 200 commits? | ❌ | ✅ `muse groove-check HEAD~200 HEAD` | | Find me all versions where the chorus had a string layer | ❌ | ✅ `muse find --structure "has=strings" --structure "section=chorus"` | | Where does the main motif first appear, and how was it transformed? | ❌ | ✅ `muse motif track "main-theme"` | | What was the arrangement before we cut the bridge? | ❌ | ✅ `muse arrange HEAD~10` | | How musically similar are these two alternative mixes? | ❌ | ✅ `muse similarity mix-a mix-b` | | "Find a melancholic minor-key version with sparse texture" | ❌ | ✅ `muse recall "melancholic minor sparse"` | | What is the full musical state of this project for AI generation? | ❌ | ✅ `muse context --json` | ### Music is multidimensional — diffs should be too When a producer changes a session, five things may change at once: - **Harmonic** — a new chord substitution shifts the tension profile - **Rhythmic** — the drummer's part gets slightly more swing - **Structural** — a breakdown section is added before the final chorus - **Dynamic** — the overall level is pushed 6dB louder in the chorus - **Melodic** — the piano melody gets a new phrase in bar 7 Git records all of this as: *"beat.mid changed."* Muse records all of this as five orthogonal dimensions, each independently queryable, diffable, and searchable across the full commit history. ### Muse as AI musical memory This is where the difference is sharpest. An AI agent generating music needs to answer: - What key are we in right now? - What's the established chord progression? - Which sections already have strings? Which don't? - Has the energy been building or falling across the last 10 commits? - What emotional arc are we maintaining? `muse context --json` answers all of this in one call — a structured document containing the key, tempo, mode, chord progression, arrangement matrix, dynamic arc, emotional state, and 10-commit evolutionary history. An agent with this context makes musically coherent decisions. An agent without it is generating blind. Git provides zero of this. Muse was built because musical creativity is multidimensional, happens in time, and deserves version control that understands music — not just files. --- ## Module Map ### CLI Entry Point ``` maestro/muse_cli/ ├── __init__.py — Package marker ├── app.py — Typer application root (console script: `muse`) ├── errors.py — Exit-code enum (0 success / 1 user / 2 repo / 3 internal) + exceptions │ MuseNotARepoError = RepoNotFoundError (public alias, issue #46) ├── _repo.py — Repository detection (.muse/ directory walker) │ find_repo_root(), require_repo(), require_repo_root alias ├── repo.py — Public re-export of _repo.py (canonical import surface, issue #46) └── commands/ ├── __init__.py ├── init.py — muse init ✅ fully implemented (--bare, --template, --default-branch added in issue #85) ├── status.py — muse status ✅ fully implemented (issue #44) ├── commit.py — muse commit ✅ fully implemented (issue #32) ├── log.py — muse log ✅ fully implemented (issue #33) ├── snapshot.py — walk_workdir, hash_file, build_snapshot_manifest, compute IDs, │ diff_workdir_vs_snapshot (added/modified/deleted/untracked sets) ├── models.py — MuseCliCommit, MuseCliSnapshot, MuseCliObject, MuseCliTag (SQLAlchemy) ├── db.py — open_session, upsert/get helpers, get_head_snapshot_manifest, find_commits_by_prefix ├── tag.py — muse tag ✅ add/remove/list/search (issue #123) ├── merge_engine.py — find_merge_base(), diff_snapshots(), detect_conflicts(), │ apply_merge(), read/write_merge_state(), MergeState dataclass ├── checkout.py — muse checkout (stub — issue #34) ├── merge.py — muse merge ✅ fast-forward + 3-way merge (issue #35) ├── remote.py — muse remote (add, remove, rename, set-url, -v) ├── fetch.py — muse fetch ├── push.py — muse push ├── pull.py — muse pull ├── clone.py — muse clone ├── open_cmd.py — muse open ✅ macOS artifact preview (issue #45) ├── play.py — muse play ✅ macOS audio playback via afplay (issue #45) ├── export.py — muse export ✅ snapshot export to MIDI/JSON/MusicXML/ABC/WAV (issue #112) ├── find.py — muse find ✅ search commit history by musical properties (issue #114) └── ask.py — muse ask ✅ natural language query over commit history (issue #126) ``` `maestro/muse_cli/export_engine.py` — `ExportFormat`, `MuseExportOptions`, `MuseExportResult`, `StorpheusUnavailableError`, `filter_manifest`, `export_snapshot`, and per-format handlers (`export_midi`, `export_json`, `export_musicxml`, `export_abc`, `export_wav`). See `## muse export` section below. `maestro/muse_cli/artifact_resolver.py` — `resolve_artifact_async()` / `resolve_artifact()`: resolves a user-supplied path-or-commit-ID to a concrete `pathlib.Path` (see below). The CLI delegates to existing `maestro/services/muse_*.py` service modules. Stub subcommands print "not yet implemented" and exit 0. --- ## `muse tag` — Music-Semantic Tagging `muse tag` attaches free-form music-semantic labels to commits, enabling expressive search across the composition history. ### Subcommands | Command | Description | |---------|-------------| | `muse tag add []` | Attach a tag (defaults to HEAD) | | `muse tag remove []` | Remove a tag (defaults to HEAD) | | `muse tag list []` | List all tags on a commit (defaults to HEAD) | | `muse tag search ` | Find commits carrying the tag; use trailing `:` for namespace prefix search | ### Tag namespaces Tags are free-form strings. Conventional namespace prefixes aid search: | Namespace | Example | Meaning | |-----------|---------|---------| | `emotion:` | `emotion:melancholic` | Emotional character | | `stage:` | `stage:rough-mix` | Production stage | | `ref:` | `ref:beatles` | Reference track or source | | `key:` | `key:Am` | Musical key | | `tempo:` | `tempo:120bpm` | Tempo annotation | | *(free-form)* | `lo-fi` | Any other label | ### Storage Tags are stored in the `muse_cli_tags` table (PostgreSQL): ``` muse_cli_tags tag_id UUID PK repo_id String(36) — scoped per local repo commit_id String(64) — FK → muse_cli_commits.commit_id (CASCADE DELETE) tag Text created_at DateTime ``` Tags are scoped to a `repo_id` so independent local repositories use separate tag spaces. A commit can carry multiple tags. Adding the same tag twice is a no-op (idempotent). --- ## `muse merge` — Fast-Forward and 3-Way Merge `muse merge ` integrates another branch into the current branch. **Usage:** ```bash muse merge [OPTIONS] ``` **Flags:** | Flag | Type | Default | Description | |------|------|---------|-------------| | `--no-ff` | flag | off | Force a merge commit even when fast-forward is possible. Preserves branch topology in the history graph. | | `--squash` | flag | off | Collapse all commits from `` into one new commit on the current branch. The result has a single parent and no `parent2_commit_id` — not a merge commit in the DAG. | | `--strategy TEXT` | string | none | Resolution shortcut: `ours` keeps all files from the current branch; `theirs` takes all files from the target branch. Both skip conflict detection. | | `--continue` | flag | off | Finalize a paused merge after resolving all conflicts with `muse resolve`. | ### Algorithm 1. **Guard** — If `.muse/MERGE_STATE.json` exists, a merge is already in progress. Exit 1 with: *"Merge in progress. Resolve conflicts and run `muse merge --continue`."* 2. **Resolve commits** — Read HEAD commit ID for the current branch and the target branch from their `.muse/refs/heads/` ref files. 3. **Find merge base** — BFS over the commit graph to find the LCA (Lowest Common Ancestor) of the two HEAD commits. Both `parent_commit_id` and `parent2_commit_id` are traversed (supporting existing merge commits). 4. **Fast-forward** — If `base == ours` *and* `--no-ff` is not set *and* `--squash` is not set, the target is strictly ahead of current HEAD. Move the current branch pointer to `theirs` without creating a new commit. 5. **Already up-to-date** — If `base == theirs`, current branch is already ahead. Exit 0. 6. **Strategy shortcut** — If `--strategy ours` or `--strategy theirs` is set, apply the resolution immediately before conflict detection and proceed to create a merge commit. No conflict state is written. 7. **3-way merge** — When branches have diverged and no strategy is set: - Compute `diff(base → ours)` and `diff(base → theirs)` at file-path granularity. - Detect conflicts: paths changed on *both* sides since the base. - If **no conflicts**: auto-merge (take the changed side for each path), create a merge commit with two parent IDs, advance the branch pointer. - If **conflicts**: write `.muse/MERGE_STATE.json` and exit 1 with a conflict summary. 8. **Squash** — If `--squash` is set, create a single commit with a combined tree but only `parent_commit_id` = current HEAD. `parent2_commit_id` is `None`. ### `MERGE_STATE.json` Schema Written on conflict, read by `muse status` and `muse commit` to block further operations: ```json { "base_commit": "abc123...", "ours_commit": "def456...", "theirs_commit": "789abc...", "conflict_paths": ["beat.mid", "lead.mp3"], "other_branch": "feature/experiment" } ``` All fields except `other_branch` are required. `conflict_paths` is sorted alphabetically. ### Merge Commit A successful 3-way merge (or `--no-ff` or `--strategy`) creates a commit with: - `parent_commit_id` = `ours_commit_id` (current branch HEAD at merge time) - `parent2_commit_id` = `theirs_commit_id` (target branch HEAD) - `snapshot_id` = merged manifest (non-conflicting changes from both sides) - `message` = `"Merge branch '' into "` (strategy appended if set) ### Squash Commit `--squash` creates a commit with: - `parent_commit_id` = `ours_commit_id` (current branch HEAD) - `parent2_commit_id` = `None` — not a merge commit in the graph - `snapshot_id` = same merged manifest as a regular merge would produce - `message` = `"Squash merge branch '' into "` Use squash when you want to land a feature branch as one clean commit without polluting `muse log` with intermediate work-in-progress commits. ### Path-Level Granularity (MVP) This merge implementation operates at **file-path level**. Two commits that modify the same file path (even if the changes are disjoint within the file) are treated as a conflict. Note-level merging (music-aware diffs inside MIDI files) is a future enhancement reserved for the existing `maestro/services/muse_merge.py` engine. ### Agent Use Case - **`--no-ff`**: Use when building a structured session history is important (e.g., preserving that a feature branch existed). The branch topology is visible in `muse log --graph`. - **`--squash`**: Use after iterative experimentation on a feature branch to produce one atomic commit for review. Equivalent to "clean up before sharing." - **`--strategy ours`**: Use to quickly resolve a conflict situation where the current branch's version is definitively correct (e.g., a hotfix already applied to main). - **`--strategy theirs`**: Use to accept all incoming changes wholesale (e.g., adopting a new arrangement from a collaborator). --- ## `.museattributes` — Per-Repo Merge Strategy Configuration `.museattributes` is an optional configuration file placed in the repository root (next to `.muse/`). It encodes per-track, per-dimension merge strategy rules so that `muse merge` can skip conflict detection for well-understood cases. ### File Format ``` # one rule per line: drums/* * ours keys/* harmonic theirs * * auto ``` - **`track-pattern`**: `fnmatch` glob against the track name. - **`dimension`**: one of `harmonic`, `rhythmic`, `melodic`, `structural`, `dynamic`, or `*` (all). - **`strategy`**: `ours` | `theirs` | `union` | `auto` | `manual`. First matching rule wins. If no rule matches, `auto` is used. ### Integration with `muse merge` When `build_merge_checkout_plan` is called with a `repo_path`, it loads `.museattributes` automatically and passes the parsed rules to `build_merge_result`. For each region: 1. The track name is resolved from `track_regions`. 2. `resolve_strategy(attributes, track, dimension)` returns the configured strategy. 3. `ours` → take the left snapshot, no conflict detection. 4. `theirs` → take the right snapshot, no conflict detection. 5. All other strategies → normal three-way merge. ### CLI ``` muse attributes [--json] ``` Displays the parsed rules in a human-readable table or JSON. ### Reference Full reference: [`docs/reference/museattributes.md`](../reference/museattributes.md) --- ## Artifact Resolution (`artifact_resolver.py`) `resolve_artifact_async(path_or_commit_id, root, session)` resolves a user-supplied string to a concrete `pathlib.Path` in this priority order: 1. **Direct filesystem path** — if the argument exists on disk, return it as-is. No DB query is needed. 2. **Relative to `muse-work/`** — if `/muse-work/` exists, return that. 3. **Commit-ID prefix** — if the argument is 4–64 lowercase hex characters: - Query `muse_cli_commits` for commits whose `commit_id` starts with the prefix. - If exactly one match: load its `MuseCliSnapshot` manifest. - If the snapshot has one file: resolve `/muse-work/`. - If the snapshot has multiple files: prompt the user to select one interactively. - Exit 1 if the prefix is ambiguous (> 1 commit) or the file no longer exists in the working tree. ### Why files must still exist in `muse-work/` Muse stores **metadata** (file paths → sha256 hashes) in Postgres, not the raw bytes. The actual content lives only on the local filesystem in `muse-work/`. If a user deletes or overwrites a file after committing, the snapshot manifest knows what _was_ there but the bytes are gone. `muse open` / `muse play` will exit 1 with a clear error in that case. --- ## `muse status` Output Formats `muse status` operates in several modes depending on repository state and active flags. **Usage:** ```bash muse status [OPTIONS] ``` **Flags:** | Flag | Short | Description | |------|-------|-------------| | `--short` | `-s` | Condensed one-line-per-file output (`M`=modified, `A`=added, `D`=deleted, `?`=untracked) | | `--branch` | `-b` | Emit only the branch and tracking info line | | `--porcelain` | — | Machine-readable `XY path` format, stable for scripting (like `git status --porcelain`) | | `--sections` | — | Group output by first path component under `muse-work/` (musical sections) | | `--tracks` | — | Group output by first path component under `muse-work/` (instrument tracks) | Flags are combinable where it makes sense: `--short --sections` emits short-format codes grouped under section headers; `--porcelain --tracks` emits porcelain codes grouped under track headers. ### Mode 1 — Clean working tree No changes since the last commit: ``` On branch main nothing to commit, working tree clean ``` With `--porcelain` (clean): emits only the branch header `## main`. ### Mode 2 — Uncommitted changes Files have been modified, added, or deleted relative to the last snapshot: **Default (verbose):** ``` On branch main Changes since last commit: (use "muse commit -m " to record changes) modified: beat.mid new file: lead.mp3 deleted: scratch.mid ``` - `modified:` — file exists in both the last snapshot and `muse-work/` but its sha256 hash differs. - `new file:` — file is present in `muse-work/` but absent from the last committed snapshot. - `deleted:` — file was in the last committed snapshot but is no longer present in `muse-work/`. **`--short`:** ``` On branch main M beat.mid A lead.mp3 D scratch.mid ``` **`--porcelain`:** ``` ## main M beat.mid A lead.mp3 D scratch.mid ``` The two-character code column follows the git porcelain convention: first char = index, second = working tree. Since Muse tracks working-tree changes only, the first char is always a space. **`--sections` (group by musical section directory):** ``` On branch main ## chorus modified: chorus/bass.mid ## verse modified: verse/bass.mid new file: verse/drums.mid ``` **`--tracks` (group by instrument track directory):** ``` On branch main ## bass modified: bass/verse.mid ## drums new file: drums/chorus.mid ``` Files not under a subdirectory appear under `## (root)` when grouping is active. **Combined `--short --sections`:** ``` On branch main ## chorus M chorus/bass.mid ## verse M verse/bass.mid ``` ### Mode 3 — In-progress merge When `.muse/MERGE_STATE.json` exists (written by `muse merge` when conflicts are detected): ``` On branch main You have unmerged paths. (fix conflicts and run "muse commit") Unmerged paths: both modified: beat.mid both modified: lead.mp3 ``` Resolve conflicts manually, then `muse commit` to record the merge. ### No commits yet On a branch that has never been committed to: ``` On branch main, no commits yet Untracked files: (use "muse commit -m " to record changes) beat.mid ``` If `muse-work/` is empty or missing: `On branch main, no commits yet` (single line). ### `--branch` only Emits only the branch line regardless of working-tree state: ``` On branch main ``` This is useful when a script needs the branch name without triggering a full DB round-trip for the diff. ### Agent use case An AI music agent uses `muse status` to: - **Detect drift:** `muse status --porcelain` gives a stable, parseable list of all changed files before deciding whether to commit. - **Section-aware generation:** `muse status --sections` reveals which musical sections have uncommitted changes, letting the agent focus generation on modified sections only. - **Track inspection:** `muse status --tracks` shows which instrument tracks differ from HEAD, useful when coordinating multi-track edits across agent turns. - **Pre-commit guard:** `muse status --short` gives a compact human-readable summary to include in agent reasoning traces before committing. ### Implementation | Layer | File | Responsibility | |-------|------|----------------| | Command | `maestro/muse_cli/commands/status.py` | Typer callback + `_status_async` | | Diff engine | `maestro/muse_cli/snapshot.py` | `diff_workdir_vs_snapshot()` | | Merge reader | `maestro/muse_cli/merge_engine.py` | `read_merge_state()` / `MergeState` | | DB helper | `maestro/muse_cli/db.py` | `get_head_snapshot_manifest()` | `_status_async` is the injectable async core (tested directly without a running server). Exit codes: 0 success, 2 outside a Muse repo, 3 internal error. --- ## `muse log` Output Formats ### Default (`git log` style) ``` commit a1b2c3d4e5f6... (HEAD -> main) Parent: f9e8d7c6 Date: 2026-02-27 17:30:00 boom bap demo take 1 commit f9e8d7c6... Date: 2026-02-27 17:00:00 initial take ``` Commits are printed newest-first. The first commit (root) has no `Parent:` line. ### `--graph` mode Reuses `maestro.services.muse_log_render.render_ascii_graph` by adapting `MuseCliCommit` rows to the `MuseLogGraph`/`MuseLogNode` dataclasses the renderer expects. ``` * a1b2c3d4 boom bap demo take 1 (HEAD) * f9e8d7c6 initial take ``` Merge commits (two parents) require `muse merge` (issue #35) — `parent2_commit_id` is reserved for that iteration. ### `--oneline` mode One line per commit: ` [HEAD marker] `. ``` a1b2c3d4 (HEAD -> main) boom bap demo take 1 f9e8d7c6 initial take ``` ### `--stat` mode Standard header per commit followed by per-file change lines and a totals summary. ``` commit a1b2c3d4 (HEAD -> main) Date: 2026-02-27 17:30:00 boom bap demo take 1 muse-work/drums/jazz.mid | added muse-work/bass/old.mid | removed 2 files changed, 1 added, 1 removed ``` ### `--patch` / `-p` mode Standard header per commit followed by path-level diff blocks showing which files were added, removed, or modified. This is a structural (path-level) diff since Muse tracks MIDI/audio blobs, not line-diffable text. ``` commit a1b2c3d4 (HEAD -> main) Date: 2026-02-27 17:30:00 boom bap demo take 1 --- /dev/null +++ muse-work/drums/jazz.mid --- muse-work/bass/old.mid +++ /dev/null ``` ### Flags | Flag | Default | Description | |------|---------|-------------| | `--limit N` / `-n N` | 1000 | Cap the walk at N commits | | `--graph` | off | ASCII DAG mode | | `--oneline` | off | One line per commit: ` [HEAD] ` | | `--stat` | off | Show file-change statistics per commit | | `--patch` / `-p` | off | Show path-level diff per commit | | `--since DATE` | — | Only commits after DATE (ISO or "2 weeks ago") | | `--until DATE` | — | Only commits before DATE (ISO or "2 weeks ago") | | `--author TEXT` | — | Case-insensitive substring match on author field | | `--emotion TEXT` | — | Filter by `emotion:` tag (e.g. `melancholic`) | | `--section TEXT` | — | Filter by `section:` tag (e.g. `chorus`) | | `--track TEXT` | — | Filter by `track:` tag (e.g. `drums`) | All flags are combinable. Filters narrow the commit set; output mode flags control formatting. Priority when multiple output modes specified: `--graph` > `--oneline` > `--stat` > `--patch` > default. ### Date parsing `--since` and `--until` accept: - ISO dates: `2026-01-15`, `2026-01-15T12:00:00`, `2026-01-15 12:00:00` - Relative: `N days ago`, `N weeks ago`, `N months ago`, `N years ago`, `yesterday`, `today` ### Music-native tag filters `--emotion`, `--section`, and `--track` filter by tags stored in `muse_cli_tags`. Tags follow the `emotion:`, `section:`, `track:` naming convention. Multiple tag filters are AND-combined — a commit must carry all specified tags to appear in the output. **Agent use case:** An agent debugging a melancholic chorus can run `muse log --emotion melancholic --section chorus` to find exactly when that emotional character was committed, then `muse show ` to inspect the snapshot or `muse revert ` to undo it. ### Result type `parse_date_filter(text: str) -> datetime` — converts a human date string to UTC-aware `datetime`. Raises `ValueError` on unrecognised formats. `CommitDiff` — fields: `added: list[str]`, `removed: list[str]`, `changed: list[str]`, `total_files: int` (computed property). --- --- ## `muse arrange []` — Arrangement Map (issue #115) `muse arrange` displays the **arrangement matrix**: which instruments are active in which musical sections for a given commit. This is the single most useful command for an AI orchestration agent — before generating a new string part, the agent can run `muse arrange --format json HEAD` to see exactly which sections already have strings, preventing doubling mistakes and enabling coherent orchestration decisions. ### Path Convention Files committed to Muse must follow the three-level path convention to participate in the arrangement map: ``` muse-work/
// ``` | Level | Example | Description | |-------|---------|-------------| | `
` | `intro`, `verse`, `chorus`, `bridge`, `outro` | Musical section name (normalised to lowercase) | | `` | `drums`, `bass`, `strings`, `piano`, `vocals` | Instrument / track name | | `` | `beat.mid`, `pad.mid` | The actual file | Files with fewer than three path components are excluded from the arrangement map (they carry no section metadata). Section aliases are normalised: `pre-chorus`, `pre_chorus`, and `prechoruse` all map to `prechorus`. ### Output Formats **Text (default)**: ``` Arrangement Map — commit abc1234 Intro Verse Chorus Bridge Outro drums ████ ████ ████ ████ ████ bass ░░░░ ████ ████ ████ ████ piano ████ ░░░░ ████ ░░░░ ████ strings ░░░░ ░░░░ ████ ████ ░░░░ ``` `████` = active (at least one file for that section/instrument pair). `░░░░` = inactive (no files). **JSON (`--format json`)** — structured, AI-agent-consumable: ```json { "commit_id": "abc1234...", "sections": ["intro", "verse", "chorus", "bridge", "outro"], "instruments": ["bass", "drums", "piano", "strings"], "arrangement": { "drums": { "intro": true, "verse": true, "chorus": true }, "strings": { "intro": false, "verse": false, "chorus": true } } } ``` **CSV (`--format csv`)** — spreadsheet-ready rows with `0`/`1` cells. ## `muse describe` — Structured Musical Change Description `muse describe [] [OPTIONS]` compares a commit against its parent (or two commits via `--compare`) and outputs a structured description of what changed at the snapshot level. ### Output example (standard depth) ``` Commit abc1234: "Add piano melody to verse" Changed files: 2 (beat.mid, keys.mid) Dimensions analyzed: structural (2 files modified) Note: Full harmonic/melodic analysis requires muse harmony and muse motif (planned) ``` ### Flags | Flag | Default | Description | |------|---------|-------------| | `[COMMIT]` | `HEAD` | Target commit: HEAD, branch name, or commit-ID prefix | | `--section TEXT` | none | Show only a specific section's instrumentation | | `--track TEXT` | none | Show only a specific instrument's section participation | | `--compare A --compare B` | — | Diff two arrangements (show added/removed cells) | | `--density` | off | Show byte-size total per cell instead of binary active/inactive | | `--format text\|json\|csv` | `text` | Output format | ### Compare Mode (`--compare`) ``` Arrangement Diff — abc1234 → def5678 Intro Verse Chorus drums ████ ████ ████ strings ░░░░ ░░░░ +████ piano ████ ░░░░ -████ ``` `+████` = cell added in commit-b. `-████` = cell removed in commit-b. ### Density Mode (`--density`) Each cell shows the total byte size of all files for that (section, instrument) pair. Byte size correlates with note density for MIDI files and serves as a useful heuristic for AI orchestration agents: ``` Intro Verse Chorus drums 4,096 3,200 5,120 bass - 1,024 2,048 ``` | `` (positional) | HEAD | Commit to describe | | `--compare A B` | — | Compare commit A against commit B explicitly | | `--depth brief\|standard\|verbose` | `standard` | Output verbosity | | `--dimensions TEXT` | — | Comma-separated dimension labels (informational, passed through to output) | | `--json` | off | Output as JSON | | `--auto-tag` | off | Add a heuristic tag based on change scope | ### Depth modes | Depth | Output | |-------|--------| | `brief` | One-line: `Commit : N file changes` | | `standard` | Message, changed files list, inferred dimensions, LLM note | | `verbose` | Full commit ID, parent ID, per-file M/A/D markers, dimensions | ### Implementation | Layer | File | Responsibility | |-------|------|----------------| | Service | `maestro/services/muse_arrange.py` | `build_arrangement_matrix()`, diff, renderers | | Command | `maestro/muse_cli/commands/arrange.py` | Typer callback + `_arrange_async` | | App | `maestro/muse_cli/app.py` | Registration under `arrange` subcommand | `_arrange_async` is fully injectable for unit tests (accepts a `root: pathlib.Path` and `session: AsyncSession`). Exit codes: `0` success, `1` user error (unknown format, missing reference, ambiguous prefix), `2` outside a Muse repo, `3` internal error. ### Named Result Types See `docs/reference/type_contracts.md`: - `ArrangementCell` — per (section, instrument) data - `ArrangementMatrix` — full matrix for one commit - `ArrangementDiffCell` — change status for one cell - `ArrangementDiff` — full diff between two matrices | Command | `maestro/muse_cli/commands/describe.py` | Typer callback + `_describe_async` | | Diff engine | `maestro/muse_cli/commands/describe.py` | `_diff_manifests()` | | Renderers | `maestro/muse_cli/commands/describe.py` | `_render_brief/standard/verbose/result` | | DB helpers | `maestro/muse_cli/db.py` | `get_commit_snapshot_manifest()` | `_describe_async` is the injectable async core (tested directly without a running server). Exit codes: 0 success, 1 user error (bad commit ID or wrong `--compare` count), 2 outside a Muse repo, 3 internal error. **Result type:** `DescribeResult` (class) — fields: `commit_id` (str), `message` (str), `depth` (DescribeDepth), `parent_id` (str | None), `compare_commit_id` (str | None), `changed_files` (list[str]), `added_files` (list[str]), `removed_files` (list[str]), `dimensions` (list[str]), `auto_tag` (str | None). Methods: `.file_count()` → int, `.to_dict()` → dict[str, object]. See `docs/reference/type_contracts.md § DescribeResult`. **Agent use case:** Before generating new material, an agent calls `muse describe --json` to understand what changed in the most recent commit. If a bass and melody file were both modified, the agent knows a harmonic rewrite occurred and adjusts generation accordingly. `--auto-tag` provides a quick `minor-revision` / `major-revision` signal without full MIDI analysis. > **Planned enhancement:** Full harmonic, melodic, and rhythmic analysis (chord progression diffs, motif tracking, groove scoring) is tracked as a follow-up. Current output is purely structural — file-level snapshot diffs with no MIDI parsing. --- ## `muse export` — Export a Snapshot to External Formats `muse export [] --format ` exports a Muse snapshot to a file format usable outside the DAW. This is a **read-only** operation — no commit is created and no DB writes occur. Given the same commit ID and format, the output is always identical (deterministic). ### Usage ``` muse export [] --format [OPTIONS] Arguments: Short commit ID prefix (default: HEAD). Options: --format, -f Target format (required): midi | json | musicxml | abc | wav --output, -o Destination path (default: ./exports/.) --track TEXT Export only files whose path contains TEXT (substring match). --section TEXT Export only files whose path contains TEXT (substring match). --split-tracks Write one file per MIDI track (MIDI only). ``` ### Supported Formats | Format | Extension | Description | |------------|-----------|-------------| | `midi` | `.mid` | Copy raw MIDI files from the snapshot (lossless, native). | | `json` | `.json` | Structured JSON index of snapshot files (AI/tooling consumption). | | `musicxml` | `.xml` | MusicXML for notation software (MuseScore, Sibelius, etc.). | | `abc` | `.abc` | ABC notation for folk/traditional music tools. | | `wav` | `.wav` | Audio render via Storpheus (requires Storpheus running). | ### Examples ```bash # Export HEAD snapshot as MIDI muse export --format midi --output /tmp/my-song.mid # Export only the piano track from a specific commit muse export a1b2c3d4 --format midi --track piano # Export the chorus section as MusicXML muse export --format musicxml --section chorus # Export all tracks as separate MIDI files muse export --format midi --split-tracks # Export JSON note structure muse export --format json --output /tmp/snapshot.json # WAV render (Storpheus must be running) muse export --format wav ``` ### Implementation | Component | Location | |-----------|----------| | CLI command | `maestro/muse_cli/commands/export.py` | | Format engine | `maestro/muse_cli/export_engine.py` | | Tests | `tests/muse_cli/test_export.py` | `export_engine.py` provides: - `ExportFormat` — enum of supported formats. - `MuseExportOptions` — frozen dataclass with export settings. - `MuseExportResult` — result dataclass listing written paths. - `StorpheusUnavailableError` — raised when WAV export is attempted but Storpheus is unreachable (callers surface a clean error message). - `filter_manifest()` — applies `--track` / `--section` filters. - `export_snapshot()` — top-level dispatcher. - Format handlers: `export_midi`, `export_json`, `export_musicxml`, `export_abc`, `export_wav`. - MIDI conversion helpers: `_midi_to_musicxml`, `_midi_to_abc` (minimal, best-effort). ### WAV Export and Storpheus Dependency `--format wav` delegates audio rendering to the Storpheus service (port 10002). Before attempting any conversion, `export_wav` performs a synchronous health check against `GET /health`. If Storpheus is not reachable or returns a non-200 response, `StorpheusUnavailableError` is raised and the CLI exits with a clear human-readable error: ``` ❌ WAV export requires Storpheus. Storpheus is not reachable at http://localhost:10002: Connection refused Start Storpheus (docker compose up storpheus) and retry. ``` ### Filter Semantics `--track` and `--section` are **case-insensitive substring matches** against the full relative path of each file in the snapshot manifest. Both filters are applied with AND semantics: a file must match all provided filters to be included. ``` manifest: chorus/piano/take1.mid verse/piano/take1.mid chorus/bass/take1.mid --track piano → chorus/piano/take1.mid, verse/piano/take1.mid --section chorus → chorus/piano/take1.mid, chorus/bass/take1.mid --track piano --section chorus → chorus/piano/take1.mid ``` ### Postgres State Export is read-only. It reads `muse_cli_commits` and `muse_cli_snapshots` but writes nothing to the database. --- ## Commit Data Model `muse commit` persists three content-addressed table types to Postgres: ### `muse_cli_objects` — File blobs (sha256-keyed) | Column | Type | Description | |--------|------|-------------| | `object_id` | `String(64)` PK | `sha256(file_bytes)` hex digest | | `size_bytes` | `Integer` | Raw file size | | `created_at` | `DateTime(tz=True)` | Wall-clock insert time | Objects are deduplicated across commits: the same file committed on two branches is stored exactly once. ### `muse_cli_snapshots` — Snapshot manifests | Column | Type | Description | |--------|------|-------------| | `snapshot_id` | `String(64)` PK | `sha256(sorted("path:object_id" pairs))` | | `manifest` | `JSON` | `{rel_path: object_id}` mapping | | `created_at` | `DateTime(tz=True)` | Wall-clock insert time | Two identical working trees always produce the same `snapshot_id`. ### `muse_cli_commits` — Commit history | Column | Type | Description | |--------|------|-------------| | `commit_id` | `String(64)` PK | Deterministic sha256 (see below) | | `repo_id` | `String(36)` | UUID from `.muse/repo.json` | | `branch` | `String(255)` | Branch name at commit time | | `parent_commit_id` | `String(64)` nullable | Previous HEAD commit on branch | | `snapshot_id` | `String(64)` FK | Points to the snapshot row | | `message` | `Text` | User-supplied commit message (may include Co-authored-by trailers) | | `author` | `String(255)` | Reserved (empty for MVP) | | `committed_at` | `DateTime(tz=True)` | Timestamp used in hash derivation | | `created_at` | `DateTime(tz=True)` | Wall-clock DB insert time | | `metadata` | `JSON` nullable | Extensible music-domain annotations (see below) | **`metadata` JSON blob — current keys:** | Key | Type | Set by | |-----|------|--------| | `section` | `string` | `muse commit --section` | | `track` | `string` | `muse commit --track` | | `emotion` | `string` | `muse commit --emotion` | | `tempo_bpm` | `float` | `muse tempo --set` | All keys are optional and co-exist in the same blob. Absent keys are simply not present (not `null`). Future music-domain annotations extend this blob without schema migrations. ### ID Derivation (deterministic) ``` object_id = sha256(file_bytes) snapshot_id = sha256("|".join(sorted(f"{path}:{oid}" for path, oid in manifest.items()))) commit_id = sha256( "|".join(sorted(parent_ids)) + "|" + snapshot_id + "|" + message + "|" + committed_at.isoformat() ) ``` Given the same working tree state, message, and timestamp two machines produce identical IDs. `sorted()` ensures insertion-order independence for both snapshot manifests and parent lists. --- ## Local Repository Structure (`.muse/`) `muse init` creates the following layout in the current working directory: ``` .muse/ repo.json Repo identity: repo_id (UUID), schema_version, created_at[, bare] HEAD Current branch pointer, e.g. "refs/heads/main" config.toml [core] (bare repos only), [user], [auth], [remotes] configuration objects/ Local content-addressed object store (written by muse commit) One file per unique object (sha256 of file bytes) refs/ heads/ main Commit ID of branch HEAD (empty = no commits yet) One file per branch muse-work/ Working-tree root (absent for --bare repos) ``` ### `muse init` flags | Flag | Type | Default | Description | |------|------|---------|-------------| | `--bare` | flag | off | Initialise as a bare repository — no `muse-work/` checkout. Writes `bare = true` into `repo.json` and `[core] bare = true` into `config.toml`. Used for Muse Hub remote/server-side repos. | | `--template PATH` | path | — | Copy the contents of *PATH* into `muse-work/` after initialisation. Lets studios pre-populate a standard folder structure (e.g. `drums/`, `bass/`, `keys/`, `vocals/`) for every new project. Ignored when `--bare` is set. | | `--default-branch BRANCH` | text | `main` | Name of the initial branch. Sets `HEAD → refs/heads/` and creates the matching ref file. | | `--force` | flag | off | Re-initialise even if `.muse/` already exists. Preserves the existing `repo_id` so remote-tracking metadata stays coherent. Does not overwrite `config.toml`. | **Bare repository layout** (`--bare`): ``` .muse/ repo.json … bare = true … HEAD refs/heads/ refs/heads/ config.toml [core] bare = true + [user] [auth] [remotes] stubs ``` Bare repos are used as Muse Hub remotes — objects and refs only, no live working copy. **Usage examples:** ```bash muse init # standard repo, branch = main muse init --default-branch develop # standard repo, branch = develop muse init --bare # bare repo (Hub remote) muse init --bare --default-branch trunk # bare repo, branch = trunk muse init --template /path/to/studio-tmpl # copy template into muse-work/ muse init --template /studio --default-branch release # template + custom branch muse init --force # reinitialise, preserve repo_id ``` ### File semantics | File | Source of truth for | Notes | |------|-------------------|-------| | `repo.json` | Repo identity | `repo_id` persists across `--force` reinitialise; `bare = true` written for bare repos | | `HEAD` | Current branch name | Always `refs/heads/`; branch name set by `--default-branch` | | `refs/heads/` | Branch → commit pointer | Empty string = branch has no commits yet | | `config.toml` | User identity, auth token, remotes | Not overwritten on `--force`; bare repos include `[core] bare = true` | | `muse-work/` | Working-tree root | Created by non-bare init; populated from `--template` if provided | ### Repo-root detection Every CLI command locates the active repo by walking up the directory tree until `.muse/` is found: ```python # Public API — maestro/muse_cli/repo.py (issue #46) from maestro.muse_cli.repo import find_repo_root, require_repo_root root: Path | None = find_repo_root() # returns None if not found, never raises root: Path = require_repo_root() # exits 2 with git-style error if not found ``` Detection rules (in priority order): 1. If `MUSE_REPO_ROOT` env var is set, use it (useful in tests and scripts — no traversal). 2. Walk from `start` (default `Path.cwd()`) upward until a directory containing `.muse/` is found. 3. If the filesystem root is reached with no `.muse/`, return `None`. `require_repo_root()` exits 2 with: ``` fatal: not a muse repository (or any parent up to mount point /) Run "muse init" to initialize a new repository. ``` **Import path:** prefer the public `maestro.muse_cli.repo` module for new code; existing commands use `maestro.muse_cli._repo` which is kept for compatibility. Both expose the same functions. `MuseNotARepoError` in `errors.py` is the canonical alias for `RepoNotFoundError`. ### `config.toml` example ```toml [user] name = "Gabriel" email = "g@example.com" [auth] token = "eyJ..." # Muse Hub Bearer token — keep out of version control [remotes] [remotes.origin] url = "https://story.audio/musehub/repos/abcd1234" ``` > **Security note:** `.muse/config.toml` contains the Hub auth token. Add `.muse/config.toml` to `.gitignore` (or `.museignore`) to prevent accidental exposure. ### VCS Services ``` app/services/ ├── muse_repository.py — Persistence adapter (DB reads/writes) ├── muse_replay.py — History reconstruction (lineage walking) ├── muse_drift.py — Drift detection engine (HEAD vs working) ├── muse_checkout.py — Checkout plan builder (pure data → tool calls) ├── muse_checkout_executor.py — Checkout execution (applies plan to StateStore) ├── muse_merge_base.py — Merge base finder (LCA in the DAG) ├── muse_merge.py — Three-way merge engine ├── muse_history_controller.py— Orchestrates checkout + merge flows ├── muse_log_graph.py — DAG serializer (topological sort → JSON) ├── muse_log_render.py — ASCII graph + JSON + summary renderer └── variation/ └── note_matching.py — Note + controller event matching/diffing app/api/routes/ ├── muse.py — Production HTTP routes (5 endpoints) └── variation/ — Existing variation proposal routes app/db/ └── muse_models.py — ORM: Variation, Phrase, NoteChange tables tests/ ├── test_muse_persistence.py — Repository + lineage tests ├── test_muse_drift.py — Drift detection tests ├── test_muse_drift_controllers.py — Controller drift tests ├── test_commit_drift_safety.py — 409 conflict enforcement ├── test_muse_checkout.py — Checkout plan tests ├── test_muse_checkout_execution.py — Checkout execution tests ├── test_muse_merge.py — Merge engine tests ├── test_muse_log_graph.py — Log graph serialization tests └── e2e/ ├── muse_fixtures.py — Deterministic IDs + snapshot builders └── test_muse_e2e_harness.py — Full VCS lifecycle E2E test ``` --- ## Data Model ### Variation (ORM: `app/db/muse_models.py`) | Column | Type | Purpose | |--------|------|---------| | `variation_id` | PK | Unique ID | | `project_id` | FK | Project this belongs to | | `parent_variation_id` | FK (self) | Primary parent (lineage) | | `parent2_variation_id` | FK (self) | Second parent (merge commits only) | | `is_head` | bool | Whether this is the current HEAD | | `commit_state_id` | str | State version at commit time | | `intent` | text | User intent / description | | `status` | str | `ready` / `committed` / `discarded` | ### HeadSnapshot (`app/services/muse_replay.py`) Reconstructed from walking the variation lineage. Contains the cumulative state at any point in history: | Field | Type | Contents | |-------|------|----------| | `notes` | `dict[region_id, list[note_dict]]` | All notes per region | | `cc` | `dict[region_id, list[cc_event]]` | CC events per region | | `pitch_bends` | `dict[region_id, list[pb_event]]` | Pitch bends per region | | `aftertouch` | `dict[region_id, list[at_event]]` | Aftertouch per region | | `track_regions` | `dict[region_id, track_id]` | Region-to-track mapping | --- ## HTTP API All routes require JWT auth (`Authorization: Bearer `). Prefix: `/api/v1/muse/` | Method | Path | Purpose | |--------|------|---------| | `POST` | `/muse/variations` | Save a variation directly into history | | `POST` | `/muse/head` | Set HEAD pointer to a variation | | `GET` | `/muse/log?project_id=X` | Get the full commit DAG as `MuseLogGraph` JSON | | `POST` | `/muse/checkout` | Checkout to a variation (time travel) | | `POST` | `/muse/merge` | Three-way merge of two variations | ### Response codes | Code | Meaning | |------|---------| | 200 | Success | | 404 | Variation not found (checkout) | | 409 | Checkout blocked by drift / merge has conflicts | --- ## VCS Primitives ### Commit (save + set HEAD) ``` save_variation(session, variation, project_id, parent_variation_id, ...) set_head(session, variation_id) ``` ### Lineage ``` get_lineage(session, variation_id) → [root, ..., target] get_head(session, project_id) → HistoryNode | None get_children(session, variation_id) → [HistoryNode, ...] ``` ### Drift Detection ``` compute_drift_report(head_snapshot, working_snapshot, ...) → DriftReport ``` Compares HEAD (from DB) against working state (from StateStore). Severity levels: `CLEAN`, `DIRTY`, `DIVERGED`. ### Replay / Reconstruction ``` reconstruct_head_snapshot(session, project_id) → HeadSnapshot reconstruct_variation_snapshot(session, variation_id) → HeadSnapshot build_replay_plan(session, project_id, target_id) → ReplayPlan ``` ### Checkout ``` build_checkout_plan(target_notes, working_notes, ...) → CheckoutPlan execute_checkout_plan(plan, store, trace) → CheckoutExecutionResult checkout_to_variation(session, project_id, target_id, store, ...) → CheckoutSummary ``` ### Merge ``` find_merge_base(session, a, b) → str | None build_merge_result(base, left, right) → MergeResult merge_variations(session, project_id, left, right, store, ...) → MergeSummary ``` ### Log Graph ``` build_muse_log_graph(session, project_id) → MuseLogGraph ``` Topologically sorted (Kahn's algorithm), deterministic tie-breaking by `(timestamp, variation_id)`. Output is camelCase JSON for the Swift frontend. --- ## Architectural Boundaries 17 AST-enforced rules in `scripts/check_boundaries.py`. Key constraints: | Module | Must NOT import | |--------|----------------| | `muse_repository` | StateStore, executor, VariationService | | `muse_replay` | StateStore, executor, LLM handlers | | `muse_drift` | StateStore, executor, LLM handlers | | `muse_checkout` | StateStore, executor, handlers | | `muse_checkout_executor` | LLM handlers, VariationService | | `muse_merge`, `muse_merge_base` | StateStore, executor, MCP, handlers | | `muse_log_graph` | StateStore, executor, handlers, engines | | `note_matching` | handlers, StateStore | The boundary philosophy: Muse VCS modules are **pure data** — they consume snapshots and produce plans/reports. StateStore mutation only happens in `muse_checkout_executor` (via duck-typed store parameter) and the history controller. --- ## `muse find` — Search Commit History by Musical Properties `muse find` is the musical grep: it queries the full commit history for the current repository and returns commits whose messages match musical criteria. All filter flags combine with **AND logic** — a commit must satisfy every supplied criterion to appear in results. ### Command Flags | Flag | Example | Description | |------|---------|-------------| | `--harmony ` | `"key=Eb"`, `"mode=minor"` | Harmonic filter | | `--rhythm ` | `"tempo=120-130"`, `"meter=7/8"` | Rhythmic filter | | `--melody ` | `"shape=arch"`, `"motif=main-theme"` | Melodic filter | | `--structure ` | `"has=bridge"`, `"form=AABA"` | Structural filter | | `--dynamic ` | `"avg_vel>80"`, `"arc=crescendo"` | Dynamic filter | | `--emotion ` | `melancholic` | Emotion tag | | `--section ` | `"chorus"` | Named section filter | | `--track ` | `"bass"` | Track presence filter | | `--since ` | `"2026-01-01"` | Commits after this date (UTC) | | `--until ` | `"2026-03-01"` | Commits before this date (UTC) | | `--limit N` / `-n N` | `20` (default) | Cap results | | `--json` | — | Machine-readable JSON output | ### Query DSL #### Equality match (default) All property filters do a **case-insensitive substring match** against the commit message: ``` muse find --harmony "key=F minor" ``` Finds every commit whose message contains the string `key=F minor` (any case). #### Numeric range match When the value portion of a `key=value` expression contains two numbers separated by a hyphen (`low-high`), the filter extracts the numeric value of the key from the message and checks whether it falls within the range (inclusive): ``` muse find --rhythm "tempo=120-130" ``` Matches commits whose message contains `tempo=` where 120 ≤ N ≤ 130. ### Output Formats #### Default (text) One commit block per match, newest-first: ``` commit a1b2c3d4... Branch: main Parent: f9e8d7c6 Date: 2026-02-27 17:30:00 ambient sketch, key=F minor, tempo=90 bpm ``` #### `--json` output A JSON array of commit objects: ```json [ { "commit_id": "a1b2c3d4...", "branch": "main", "message": "ambient sketch, key=F minor, tempo=90 bpm", "author": "", "committed_at": "2026-02-27T17:30:00+00:00", "parent_commit_id": "f9e8d7c6...", "snapshot_id": "bac947cf..." } ] ``` ### Examples ```bash # All commits in F minor muse find --harmony "key=F minor" # Up-tempo commits in a date window muse find --rhythm "tempo=120-130" --since "2026-01-01" # Melancholic commits that include a bridge, as JSON muse find --emotion melancholic --structure "has=bridge" --json # Bass track presence, capped at 10 results muse find --track bass --limit 10 ``` ### Architecture - **Service:** `maestro/services/muse_find.py` - `MuseFindQuery` — frozen dataclass of all search criteria - `MuseFindCommitResult` — a single matching commit - `MuseFindResults` — container with matches, total scanned, and the query - `search_commits(session, repo_id, query)` — async search function - **CLI command:** `maestro/muse_cli/commands/find.py` - `_find_async(root, session, query, output_json)` — injectable core (tested directly) - Registered in `maestro/muse_cli/app.py` as `find` ### Postgres Behaviour Read-only operation — no writes. Plain-text filters are pushed to SQL via `ILIKE` for efficiency; numeric range filters are applied in Python after the SQL result set is fetched. `committed_at` date range filters use SQL `>=` / `<=` comparisons. --- ## `muse session` — Recording Session Metadata **Purpose:** Track who was in the room, where you recorded, and why — purely as local JSON files. Sessions are decoupled from VCS commits: they capture the human context around a recording block and can later reference commit IDs that were created during that time. Sessions live in `.muse/sessions/` as plain JSON files — no database tables, no Alembic migrations. This mirrors git's philosophy of storing metadata as plain files rather than in a relational store. ### Subcommands | Subcommand | Flags | Purpose | |------------|-------|---------| | `muse session start` | `--participants`, `--location`, `--intent` | Open a new session; writes `current.json`. Only one active session at a time. | | `muse session end` | `--notes` | Finalise active session; moves `current.json` → `.json`. | | `muse session log` | _(none)_ | List all completed sessions, newest first. | | `muse session show ` | _(prefix match supported)_ | Print full JSON for a specific completed session. | | `muse session credits` | _(none)_ | Aggregate participants across all completed sessions, sorted by count descending. | ### Storage Layout ``` .muse/ sessions/ current.json ← active session (exists only while recording) .json ← one file per completed session ``` ### Session JSON Schema (`MuseSessionRecord`) ```json { "session_id": "", "schema_version": "1", "started_at": "2026-02-27T15:49:19+00:00", "ended_at": "2026-02-27T17:30:00+00:00", "participants": ["Alice", "Bob"], "location": "Studio A", "intent": "Record the bridge", "commits": ["abc123", "def456"], "notes": "Nailed the third take." } ``` The `commits` list is populated externally (e.g., by `muse commit` in a future integration); it starts empty. ### Output Examples **`muse session log`** ``` 3f2a1b0c 2026-02-27T15:49:19 → 2026-02-27T17:30:00 [Alice, Bob] a1b2c3d4 2026-02-26T10:00:00 → 2026-02-26T12:00:00 [] ``` **`muse session credits`** ``` Session credits: Alice 2 sessions Bob 1 session Carol 1 session ``` ### Result Type `MuseSessionRecord` — TypedDict defined in `maestro/muse_cli/commands/session.py`. See `docs/reference/type_contracts.md` for the full field table. ### Atomicity `muse session end` writes a temp file (`.tmp-.json`) in the same directory, then renames it to `.json` before unlinking `current.json`. This guarantees that a crash between write and cleanup never leaves both `current.json` and `.json` present simultaneously, which would block future `muse session start` calls. ### Agent Use Case An AI composition agent can: - Call `muse session start --participants "Claude,Gabriel" --intent "Groove track"` before a generation run. - Call `muse session end --notes "Generated 4 variations"` after the run completes. - Query `muse session credits` to see which participants have contributed most across the project's history. --- ## E2E Demo Run the full VCS lifecycle test: ```bash docker compose exec maestro pytest tests/e2e/test_muse_e2e_harness.py -v -s ``` Exercises: commit → branch → merge → conflict detection → checkout traversal. Produces: ASCII graph, JSON dump, summary table. See `muse_e2e_demo.md` for details. --- ## Muse Hub — Remote Backend The Muse Hub is a lightweight GitHub-equivalent that lives inside the Maestro FastAPI app. It provides remote repo hosting for CLI clients using `muse push` and `muse pull`. ### DB Tables | Table | Purpose | |-------|---------| | `musehub_repos` | Remote repos (name, visibility, owner, music-semantic metadata) | | `musehub_branches` | Branch pointers inside a repo | | `musehub_commits` | Commits pushed from CLI clients | | `musehub_objects` | Binary artifact metadata (MIDI, MP3, WebP piano rolls) | | `musehub_issues` | Issue tracker entries per repo | | `musehub_pull_requests` | Pull requests proposing branch merges | | `musehub_sessions` | Recording session records pushed from CLI clients | | `musehub_releases` | Published version releases with download package URLs | | `musehub_stars` | Per-user repo starring (one row per user×repo pair) | ### Module Map ``` maestro/ ├── db/musehub_models.py — SQLAlchemy ORM models ├── models/musehub.py — Pydantic v2 request/response models (incl. SearchCommitMatch, SearchResponse) ├── services/musehub_repository.py — Async DB queries for repos/branches/commits ├── services/musehub_credits.py — Credits aggregation from commit history ├── services/musehub_issues.py — Async DB queries for issues (single point of DB access) ├── services/musehub_pull_requests.py — Async DB queries for PRs (single point of DB access) ├── services/musehub_releases.py — Async DB queries for releases (single point of DB access) ├── services/musehub_release_packager.py — Download package URL builder (pure, no DB access) ├── services/musehub_search.py — In-repo search service (property / ask / keyword / pattern) ├── services/musehub_sync.py — Push/pull sync protocol (ingest_push, compute_pull_delta) └── api/routes/musehub/ ├── __init__.py — Composes sub-routers under /musehub prefix ├── repos.py — Repo/branch/commit/credits route handlers ├── issues.py — Issue tracking route handlers ├── pull_requests.py — Pull request route handlers ├── releases.py — Release management route handlers ├── search.py — In-repo search route handler ├── sync.py — Push/pull sync route handlers ├── objects.py — Artifact list + content-by-object-id endpoints (auth required) ├── raw.py — Raw file download by path (public repos: no auth) └── ui.py — HTML UI pages (incl. releases, credits and /search pages) ├── db/musehub_models.py — SQLAlchemy ORM models (includes MusehubStar) ├── models/musehub.py — Pydantic v2 request/response models (includes ExploreRepoResult, ExploreResponse, StarResponse, SearchCommitMatch, SearchResponse) ├── services/musehub_repository.py — Async DB queries for repos/branches/commits ├── services/musehub_discover.py — Public repo discovery with filters, sorting, star/unstar ├── services/musehub_credits.py — Credits aggregation from commit history ├── services/musehub_issues.py — Async DB queries for issues (single point of DB access) ├── services/musehub_pull_requests.py — Async DB queries for PRs (single point of DB access) ├── services/musehub_sessions.py — Async DB queries for sessions (upsert, list, get) ├── services/musehub_search.py — In-repo search service (property / ask / keyword / pattern) ├── services/musehub_sync.py — Push/pull sync protocol (ingest_push, compute_pull_delta) ├── services/musehub_divergence.py — Five-dimension divergence between two remote branches └── api/routes/musehub/ ├── __init__.py — Composes sub-routers under /musehub prefix (authed) ├── repos.py — Repo/branch/commit route handlers ├── __init__.py — Composes sub-routers under /musehub prefix ├── repos.py — Repo/branch/commit/session route handlers ├── repos.py — Repo/branch/commit route handlers + divergence endpoint ├── repos.py — Repo/branch/commit/credits route handlers ├── issues.py — Issue tracking route handlers ├── pull_requests.py — Pull request route handlers ├── search.py — In-repo search route handler ├── sync.py — Push/pull sync route handlers ├── discover.py — Public discover API + authed star/unstar (registered in main.py separately) ├── objects.py — Artifact list + content-by-object-id endpoints (auth required) ├── raw.py — Raw file download by path (public repos: no auth) └── ui.py — Browser UI HTML shell pages (repo, commits, PRs, issues, sessions, search) └── ui.py — HTML UI pages (divergence radar chart, search mode tabs) └── ui.py — HTML shells for browser: explore, trending, repo, commit, PR, issue pages (incl. /search page with mode tabs) ├── negotiate.py — Content negotiation helper (HTML vs JSON from one URL) └── ui.py — HTML UI pages (incl. credits and /search pages) ``` ### Content Negotiation — Dual-Format Endpoints Key MuseHub UI routes implement **content negotiation**: the same URL serves HTML to browsers and JSON to agents, decided by the `Accept` header (or `?format=json`). **Why this exists:** The Stori DAW philosophy is agent-first. An AI agent composing music should call `GET /{owner}/{repo_slug}` and receive structured JSON — not navigate a parallel `/api/v1/...` endpoint tree that requires separate maintenance. **Mechanism (`negotiate.py`):** ```python # Decision order (first match wins): # 1. ?format=json → JSON (explicit override, works in browser links) # 2. Accept: application/json → JSON (standard HTTP content negotiation) # 3. default → text/html ``` JSON uses `CamelModel.model_dump(by_alias=True)` — camelCase keys matching the `/api/v1/musehub/...` convention. No schema divergence. **Current dual-format endpoints:** | URL | JSON response model | |-----|---------------------| | `GET /musehub/ui/{owner}/{repo_slug}` | `RepoResponse` | | `GET /musehub/ui/{owner}/{repo_slug}/commits` | `CommitListResponse` | | `GET /musehub/ui/{owner}/{repo_slug}/commits/{commit_id}` | `CommitResponse` | All other UI endpoints still return HTML only. As new pages are added, adopt `negotiate_response()` immediately so agents automatically get JSON support. ### Endpoints #### Repos, Branches, Commits, Credits | Method | Path | Description | |--------|------|-------------| | POST | `/api/v1/musehub/repos` | Create remote repo | | GET | `/api/v1/musehub/repos/{id}` | Get repo metadata | | GET | `/api/v1/musehub/repos/{id}/branches` | List branches | | GET | `/api/v1/musehub/repos/{id}/commits` | List commits (newest first) | | GET | `/api/v1/musehub/repos/{id}/timeline` | Chronological timeline with emotion/section/track layers | | GET | `/api/v1/musehub/repos/{id}/divergence` | Five-dimension musical divergence between two branches (`?branch_a=...&branch_b=...`) | | GET | `/api/v1/musehub/repos/{id}/credits` | Aggregated contributor credits (`?sort=count\|recency\|alpha`) | #### Credits Page `GET /api/v1/musehub/repos/{repo_id}/credits` returns a `CreditsResponse` — the full contributor roll aggregated from commit history, analogous to dynamic album liner notes. **Sort options:** | `sort` value | Ordering | |---|---| | `count` (default) | Most prolific contributor first | | `recency` | Most recently active contributor first | | `alpha` | Alphabetical by author name | **Result type:** `CreditsResponse` — fields: `repo_id`, `contributors` (list of `ContributorCredits`), `sort`, `total_contributors`. **`ContributorCredits` fields:** | Field | Type | Description | |---|---|---| | `author` | `str` | Contributor name (from commit `author` field) | | `session_count` | `int` | Number of commits attributed to this author | | `contribution_types` | `list[str]` | Inferred roles: composer, arranger, producer, performer, mixer, editor, lyricist, sound designer | | `first_active` | `datetime` | Timestamp of earliest commit | | `last_active` | `datetime` | Timestamp of most recent commit | **Contribution type inference:** Roles are inferred from commit message keywords using `_ROLE_KEYWORDS` in `musehub_credits.py`. No role matched → falls back to `["contributor"]`. The list evolves as musicians describe their work more richly in commit messages. **Machine-readable credits:** The UI page (`GET /musehub/ui/{owner}/{repo_slug}/credits`) injects a `