muse-vcs.md
markdown
| 1 | # Muse VCS — Musical Version Control System |
| 2 | |
| 3 | > **Status:** Canonical Implementation Reference |
| 4 | > **E2E demo:** [`muse_e2e_demo.md`](muse_e2e_demo.md) |
| 5 | |
| 6 | --- |
| 7 | |
| 8 | ## What Muse Is |
| 9 | |
| 10 | 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: |
| 11 | |
| 12 | - **Commit history** — every accepted variation is recorded with parent lineage |
| 13 | - **Branching** — multiple variations can diverge from the same parent |
| 14 | - **Three-way merge** — auto-merges non-conflicting changes, reports conflicts |
| 15 | - **Drift detection** — compares HEAD snapshot against the live DAW state (`git status`) |
| 16 | - **Checkout / time travel** — reconstruct any historical state via deterministic tool calls |
| 17 | - **Log graph** — serialize the full commit DAG as Swift-ready JSON |
| 18 | |
| 19 | --- |
| 20 | |
| 21 | ## Why Muse and not Git? |
| 22 | |
| 23 | > *"Can't we just commit MIDI files to Git?"* |
| 24 | |
| 25 | You can. And you'll immediately discover everything Git cannot tell you about music. |
| 26 | |
| 27 | ### The core problem: Git sees music as bytes, not music |
| 28 | |
| 29 | `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*. |
| 30 | |
| 31 | 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. |
| 32 | |
| 33 | ### What Muse can do that Git categorically cannot |
| 34 | |
| 35 | | Question | Git | Muse | |
| 36 | |----------|-----|------| |
| 37 | | What key is this arrangement in? | ❌ | ✅ `muse key HEAD` | |
| 38 | | How did the chord progression change between commit 12 and commit 47? | ❌ | ✅ `muse diff HEAD~35 HEAD --harmonic` | |
| 39 | | When did the song modulate from Eb major to F minor? | ❌ | ✅ `muse find --harmony "key=F minor"` | |
| 40 | | Did the groove get tighter or looser over 200 commits? | ❌ | ✅ `muse groove-check HEAD~200 HEAD` | |
| 41 | | Find me all versions where the chorus had a string layer | ❌ | ✅ `muse find --structure "has=strings" --structure "section=chorus"` | |
| 42 | | Where does the main motif first appear, and how was it transformed? | ❌ | ✅ `muse motif track "main-theme"` | |
| 43 | | What was the arrangement before we cut the bridge? | ❌ | ✅ `muse arrange HEAD~10` | |
| 44 | | How musically similar are these two alternative mixes? | ❌ | ✅ `muse similarity mix-a mix-b` | |
| 45 | | "Find a melancholic minor-key version with sparse texture" | ❌ | ✅ `muse recall "melancholic minor sparse"` | |
| 46 | | What is the full musical state of this project for AI generation? | ❌ | ✅ `muse context --json` | |
| 47 | |
| 48 | ### Music is multidimensional — diffs should be too |
| 49 | |
| 50 | When a producer changes a session, five things may change at once: |
| 51 | |
| 52 | - **Harmonic** — a new chord substitution shifts the tension profile |
| 53 | - **Rhythmic** — the drummer's part gets slightly more swing |
| 54 | - **Structural** — a breakdown section is added before the final chorus |
| 55 | - **Dynamic** — the overall level is pushed 6dB louder in the chorus |
| 56 | - **Melodic** — the piano melody gets a new phrase in bar 7 |
| 57 | |
| 58 | Git records all of this as: *"beat.mid changed."* |
| 59 | |
| 60 | Muse records all of this as five orthogonal dimensions, each independently queryable, diffable, and searchable across the full commit history. |
| 61 | |
| 62 | ### Muse as AI musical memory |
| 63 | |
| 64 | This is where the difference is sharpest. An AI agent generating music needs to answer: |
| 65 | |
| 66 | - What key are we in right now? |
| 67 | - What's the established chord progression? |
| 68 | - Which sections already have strings? Which don't? |
| 69 | - Has the energy been building or falling across the last 10 commits? |
| 70 | - What emotional arc are we maintaining? |
| 71 | |
| 72 | `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. |
| 73 | |
| 74 | 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. |
| 75 | |
| 76 | --- |
| 77 | |
| 78 | ## Module Map |
| 79 | |
| 80 | ### CLI Entry Point |
| 81 | |
| 82 | ``` |
| 83 | maestro/muse_cli/ |
| 84 | ├── __init__.py — Package marker |
| 85 | ├── app.py — Typer application root (console script: `muse`) |
| 86 | ├── errors.py — Exit-code enum (0 success / 1 user / 2 repo / 3 internal) + exceptions |
| 87 | │ MuseNotARepoError = RepoNotFoundError (public alias, issue #46) |
| 88 | ├── _repo.py — Repository detection (.muse/ directory walker) |
| 89 | │ find_repo_root(), require_repo(), require_repo_root alias |
| 90 | ├── repo.py — Public re-export of _repo.py (canonical import surface, issue #46) |
| 91 | └── commands/ |
| 92 | ├── __init__.py |
| 93 | ├── init.py — muse init ✅ fully implemented (--bare, --template, --default-branch added in issue #85) |
| 94 | ├── status.py — muse status ✅ fully implemented (issue #44) |
| 95 | ├── commit.py — muse commit ✅ fully implemented (issue #32) |
| 96 | ├── log.py — muse log ✅ fully implemented (issue #33) |
| 97 | ├── snapshot.py — walk_workdir, hash_file, build_snapshot_manifest, compute IDs, |
| 98 | │ diff_workdir_vs_snapshot (added/modified/deleted/untracked sets) |
| 99 | ├── models.py — MuseCliCommit, MuseCliSnapshot, MuseCliObject, MuseCliTag (SQLAlchemy) |
| 100 | ├── db.py — open_session, upsert/get helpers, get_head_snapshot_manifest, find_commits_by_prefix |
| 101 | ├── tag.py — muse tag ✅ add/remove/list/search (issue #123) |
| 102 | ├── merge_engine.py — find_merge_base(), diff_snapshots(), detect_conflicts(), |
| 103 | │ apply_merge(), read/write_merge_state(), MergeState dataclass |
| 104 | ├── checkout.py — muse checkout (stub — issue #34) |
| 105 | ├── merge.py — muse merge ✅ fast-forward + 3-way merge (issue #35) |
| 106 | ├── remote.py — muse remote (add, remove, rename, set-url, -v) |
| 107 | ├── fetch.py — muse fetch |
| 108 | ├── push.py — muse push |
| 109 | ├── pull.py — muse pull |
| 110 | ├── clone.py — muse clone |
| 111 | ├── open_cmd.py — muse open ✅ macOS artifact preview (issue #45) |
| 112 | ├── play.py — muse play ✅ macOS audio playback via afplay (issue #45) |
| 113 | ├── export.py — muse export ✅ snapshot export to MIDI/JSON/MusicXML/ABC/WAV (issue #112) |
| 114 | ├── find.py — muse find ✅ search commit history by musical properties (issue #114) |
| 115 | └── ask.py — muse ask ✅ natural language query over commit history (issue #126) |
| 116 | ``` |
| 117 | |
| 118 | `maestro/muse_cli/export_engine.py` — `ExportFormat`, `MuseExportOptions`, `MuseExportResult`, |
| 119 | `StorpheusUnavailableError`, `filter_manifest`, `export_snapshot`, and per-format handlers |
| 120 | (`export_midi`, `export_json`, `export_musicxml`, `export_abc`, `export_wav`). See |
| 121 | `## muse export` section below. |
| 122 | |
| 123 | `maestro/muse_cli/artifact_resolver.py` — `resolve_artifact_async()` / `resolve_artifact()`: |
| 124 | resolves a user-supplied path-or-commit-ID to a concrete `pathlib.Path` (see below). |
| 125 | |
| 126 | The CLI delegates to existing `maestro/services/muse_*.py` service modules. Stub subcommands print "not yet implemented" and exit 0. |
| 127 | |
| 128 | --- |
| 129 | |
| 130 | ## `muse tag` — Music-Semantic Tagging |
| 131 | |
| 132 | `muse tag` attaches free-form music-semantic labels to commits, enabling expressive search across |
| 133 | the composition history. |
| 134 | |
| 135 | ### Subcommands |
| 136 | |
| 137 | | Command | Description | |
| 138 | |---------|-------------| |
| 139 | | `muse tag add <tag> [<commit>]` | Attach a tag (defaults to HEAD) | |
| 140 | | `muse tag remove <tag> [<commit>]` | Remove a tag (defaults to HEAD) | |
| 141 | | `muse tag list [<commit>]` | List all tags on a commit (defaults to HEAD) | |
| 142 | | `muse tag search <tag>` | Find commits carrying the tag; use trailing `:` for namespace prefix search | |
| 143 | |
| 144 | ### Tag namespaces |
| 145 | |
| 146 | Tags are free-form strings. Conventional namespace prefixes aid search: |
| 147 | |
| 148 | | Namespace | Example | Meaning | |
| 149 | |-----------|---------|---------| |
| 150 | | `emotion:` | `emotion:melancholic` | Emotional character | |
| 151 | | `stage:` | `stage:rough-mix` | Production stage | |
| 152 | | `ref:` | `ref:beatles` | Reference track or source | |
| 153 | | `key:` | `key:Am` | Musical key | |
| 154 | | `tempo:` | `tempo:120bpm` | Tempo annotation | |
| 155 | | *(free-form)* | `lo-fi` | Any other label | |
| 156 | |
| 157 | ### Storage |
| 158 | |
| 159 | Tags are stored in the `muse_cli_tags` table (PostgreSQL): |
| 160 | |
| 161 | ``` |
| 162 | muse_cli_tags |
| 163 | tag_id UUID PK |
| 164 | repo_id String(36) — scoped per local repo |
| 165 | commit_id String(64) — FK → muse_cli_commits.commit_id (CASCADE DELETE) |
| 166 | tag Text |
| 167 | created_at DateTime |
| 168 | ``` |
| 169 | |
| 170 | Tags are scoped to a `repo_id` so independent local repositories use separate tag spaces. |
| 171 | A commit can carry multiple tags. Adding the same tag twice is a no-op (idempotent). |
| 172 | |
| 173 | --- |
| 174 | |
| 175 | ## `muse merge` — Fast-Forward and 3-Way Merge |
| 176 | |
| 177 | `muse merge <branch>` integrates another branch into the current branch. |
| 178 | |
| 179 | **Usage:** |
| 180 | ```bash |
| 181 | muse merge <branch> [OPTIONS] |
| 182 | ``` |
| 183 | |
| 184 | **Flags:** |
| 185 | |
| 186 | | Flag | Type | Default | Description | |
| 187 | |------|------|---------|-------------| |
| 188 | | `--no-ff` | flag | off | Force a merge commit even when fast-forward is possible. Preserves branch topology in the history graph. | |
| 189 | | `--squash` | flag | off | Collapse all commits from `<branch>` 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. | |
| 190 | | `--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. | |
| 191 | | `--continue` | flag | off | Finalize a paused merge after resolving all conflicts with `muse resolve`. | |
| 192 | |
| 193 | ### Algorithm |
| 194 | |
| 195 | 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`."* |
| 196 | 2. **Resolve commits** — Read HEAD commit ID for the current branch and the target branch from their `.muse/refs/heads/<branch>` ref files. |
| 197 | 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). |
| 198 | 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. |
| 199 | 5. **Already up-to-date** — If `base == theirs`, current branch is already ahead. Exit 0. |
| 200 | 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. |
| 201 | 7. **3-way merge** — When branches have diverged and no strategy is set: |
| 202 | - Compute `diff(base → ours)` and `diff(base → theirs)` at file-path granularity. |
| 203 | - Detect conflicts: paths changed on *both* sides since the base. |
| 204 | - If **no conflicts**: auto-merge (take the changed side for each path), create a merge commit with two parent IDs, advance the branch pointer. |
| 205 | - If **conflicts**: write `.muse/MERGE_STATE.json` and exit 1 with a conflict summary. |
| 206 | 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`. |
| 207 | |
| 208 | ### `MERGE_STATE.json` Schema |
| 209 | |
| 210 | Written on conflict, read by `muse status` and `muse commit` to block further operations: |
| 211 | |
| 212 | ```json |
| 213 | { |
| 214 | "base_commit": "abc123...", |
| 215 | "ours_commit": "def456...", |
| 216 | "theirs_commit": "789abc...", |
| 217 | "conflict_paths": ["beat.mid", "lead.mp3"], |
| 218 | "other_branch": "feature/experiment" |
| 219 | } |
| 220 | ``` |
| 221 | |
| 222 | All fields except `other_branch` are required. `conflict_paths` is sorted alphabetically. |
| 223 | |
| 224 | ### Merge Commit |
| 225 | |
| 226 | A successful 3-way merge (or `--no-ff` or `--strategy`) creates a commit with: |
| 227 | - `parent_commit_id` = `ours_commit_id` (current branch HEAD at merge time) |
| 228 | - `parent2_commit_id` = `theirs_commit_id` (target branch HEAD) |
| 229 | - `snapshot_id` = merged manifest (non-conflicting changes from both sides) |
| 230 | - `message` = `"Merge branch '<branch>' into <current_branch>"` (strategy appended if set) |
| 231 | |
| 232 | ### Squash Commit |
| 233 | |
| 234 | `--squash` creates a commit with: |
| 235 | - `parent_commit_id` = `ours_commit_id` (current branch HEAD) |
| 236 | - `parent2_commit_id` = `None` — not a merge commit in the graph |
| 237 | - `snapshot_id` = same merged manifest as a regular merge would produce |
| 238 | - `message` = `"Squash merge branch '<branch>' into <current_branch>"` |
| 239 | |
| 240 | Use squash when you want to land a feature branch as one clean commit without |
| 241 | polluting `muse log` with intermediate work-in-progress commits. |
| 242 | |
| 243 | ### Path-Level Granularity (MVP) |
| 244 | |
| 245 | 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. |
| 246 | |
| 247 | ### Agent Use Case |
| 248 | |
| 249 | - **`--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`. |
| 250 | - **`--squash`**: Use after iterative experimentation on a feature branch to produce one atomic commit for review. Equivalent to "clean up before sharing." |
| 251 | - **`--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). |
| 252 | - **`--strategy theirs`**: Use to accept all incoming changes wholesale (e.g., adopting a new arrangement from a collaborator). |
| 253 | |
| 254 | --- |
| 255 | |
| 256 | ## `.museattributes` — Per-Repo Merge Strategy Configuration |
| 257 | |
| 258 | `.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. |
| 259 | |
| 260 | ### File Format |
| 261 | |
| 262 | ``` |
| 263 | # one rule per line: <track-pattern> <dimension> <strategy> |
| 264 | drums/* * ours |
| 265 | keys/* harmonic theirs |
| 266 | * * auto |
| 267 | ``` |
| 268 | |
| 269 | - **`track-pattern`**: `fnmatch` glob against the track name. |
| 270 | - **`dimension`**: one of `harmonic`, `rhythmic`, `melodic`, `structural`, `dynamic`, or `*` (all). |
| 271 | - **`strategy`**: `ours` | `theirs` | `union` | `auto` | `manual`. |
| 272 | |
| 273 | First matching rule wins. If no rule matches, `auto` is used. |
| 274 | |
| 275 | ### Integration with `muse merge` |
| 276 | |
| 277 | 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: |
| 278 | |
| 279 | 1. The track name is resolved from `track_regions`. |
| 280 | 2. `resolve_strategy(attributes, track, dimension)` returns the configured strategy. |
| 281 | 3. `ours` → take the left snapshot, no conflict detection. |
| 282 | 4. `theirs` → take the right snapshot, no conflict detection. |
| 283 | 5. All other strategies → normal three-way merge. |
| 284 | |
| 285 | ### CLI |
| 286 | |
| 287 | ``` |
| 288 | muse attributes [--json] |
| 289 | ``` |
| 290 | |
| 291 | Displays the parsed rules in a human-readable table or JSON. |
| 292 | |
| 293 | ### Reference |
| 294 | |
| 295 | Full reference: [`docs/reference/museattributes.md`](../reference/museattributes.md) |
| 296 | |
| 297 | --- |
| 298 | |
| 299 | ## Artifact Resolution (`artifact_resolver.py`) |
| 300 | |
| 301 | `resolve_artifact_async(path_or_commit_id, root, session)` resolves a user-supplied |
| 302 | string to a concrete `pathlib.Path` in this priority order: |
| 303 | |
| 304 | 1. **Direct filesystem path** — if the argument exists on disk, return it as-is. |
| 305 | No DB query is needed. |
| 306 | 2. **Relative to `muse-work/`** — if `<root>/muse-work/<arg>` exists, return that. |
| 307 | 3. **Commit-ID prefix** — if the argument is 4–64 lowercase hex characters: |
| 308 | - Query `muse_cli_commits` for commits whose `commit_id` starts with the prefix. |
| 309 | - If exactly one match: load its `MuseCliSnapshot` manifest. |
| 310 | - If the snapshot has one file: resolve `<root>/muse-work/<file>`. |
| 311 | - If the snapshot has multiple files: prompt the user to select one interactively. |
| 312 | - Exit 1 if the prefix is ambiguous (> 1 commit) or the file no longer exists |
| 313 | in the working tree. |
| 314 | |
| 315 | ### Why files must still exist in `muse-work/` |
| 316 | |
| 317 | Muse stores **metadata** (file paths → sha256 hashes) in Postgres, not the raw |
| 318 | bytes. The actual content lives only on the local filesystem in `muse-work/`. |
| 319 | If a user deletes or overwrites a file after committing, the snapshot manifest |
| 320 | knows what _was_ there but the bytes are gone. `muse open` / `muse play` will |
| 321 | exit 1 with a clear error in that case. |
| 322 | |
| 323 | --- |
| 324 | |
| 325 | ## `muse status` Output Formats |
| 326 | |
| 327 | `muse status` operates in several modes depending on repository state and active flags. |
| 328 | |
| 329 | **Usage:** |
| 330 | ```bash |
| 331 | muse status [OPTIONS] |
| 332 | ``` |
| 333 | |
| 334 | **Flags:** |
| 335 | |
| 336 | | Flag | Short | Description | |
| 337 | |------|-------|-------------| |
| 338 | | `--short` | `-s` | Condensed one-line-per-file output (`M`=modified, `A`=added, `D`=deleted, `?`=untracked) | |
| 339 | | `--branch` | `-b` | Emit only the branch and tracking info line | |
| 340 | | `--porcelain` | — | Machine-readable `XY path` format, stable for scripting (like `git status --porcelain`) | |
| 341 | | `--sections` | — | Group output by first path component under `muse-work/` (musical sections) | |
| 342 | | `--tracks` | — | Group output by first path component under `muse-work/` (instrument tracks) | |
| 343 | |
| 344 | 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. |
| 345 | |
| 346 | ### Mode 1 — Clean working tree |
| 347 | |
| 348 | No changes since the last commit: |
| 349 | |
| 350 | ``` |
| 351 | On branch main |
| 352 | nothing to commit, working tree clean |
| 353 | ``` |
| 354 | |
| 355 | With `--porcelain` (clean): emits only the branch header `## main`. |
| 356 | |
| 357 | ### Mode 2 — Uncommitted changes |
| 358 | |
| 359 | Files have been modified, added, or deleted relative to the last snapshot: |
| 360 | |
| 361 | **Default (verbose):** |
| 362 | ``` |
| 363 | On branch main |
| 364 | |
| 365 | Changes since last commit: |
| 366 | (use "muse commit -m <msg>" to record changes) |
| 367 | |
| 368 | modified: beat.mid |
| 369 | new file: lead.mp3 |
| 370 | deleted: scratch.mid |
| 371 | ``` |
| 372 | |
| 373 | - `modified:` — file exists in both the last snapshot and `muse-work/` but its sha256 hash differs. |
| 374 | - `new file:` — file is present in `muse-work/` but absent from the last committed snapshot. |
| 375 | - `deleted:` — file was in the last committed snapshot but is no longer present in `muse-work/`. |
| 376 | |
| 377 | **`--short`:** |
| 378 | ``` |
| 379 | On branch main |
| 380 | M beat.mid |
| 381 | A lead.mp3 |
| 382 | D scratch.mid |
| 383 | ``` |
| 384 | |
| 385 | **`--porcelain`:** |
| 386 | ``` |
| 387 | ## main |
| 388 | M beat.mid |
| 389 | A lead.mp3 |
| 390 | D scratch.mid |
| 391 | ``` |
| 392 | |
| 393 | 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. |
| 394 | |
| 395 | **`--sections` (group by musical section directory):** |
| 396 | ``` |
| 397 | On branch main |
| 398 | |
| 399 | ## chorus |
| 400 | modified: chorus/bass.mid |
| 401 | |
| 402 | ## verse |
| 403 | modified: verse/bass.mid |
| 404 | new file: verse/drums.mid |
| 405 | ``` |
| 406 | |
| 407 | **`--tracks` (group by instrument track directory):** |
| 408 | ``` |
| 409 | On branch main |
| 410 | |
| 411 | ## bass |
| 412 | modified: bass/verse.mid |
| 413 | |
| 414 | ## drums |
| 415 | new file: drums/chorus.mid |
| 416 | ``` |
| 417 | |
| 418 | Files not under a subdirectory appear under `## (root)` when grouping is active. |
| 419 | |
| 420 | **Combined `--short --sections`:** |
| 421 | ``` |
| 422 | On branch main |
| 423 | ## chorus |
| 424 | M chorus/bass.mid |
| 425 | |
| 426 | ## verse |
| 427 | M verse/bass.mid |
| 428 | ``` |
| 429 | |
| 430 | ### Mode 3 — In-progress merge |
| 431 | |
| 432 | When `.muse/MERGE_STATE.json` exists (written by `muse merge` when conflicts are detected): |
| 433 | |
| 434 | ``` |
| 435 | On branch main |
| 436 | |
| 437 | You have unmerged paths. |
| 438 | (fix conflicts and run "muse commit") |
| 439 | |
| 440 | Unmerged paths: |
| 441 | both modified: beat.mid |
| 442 | both modified: lead.mp3 |
| 443 | ``` |
| 444 | |
| 445 | Resolve conflicts manually, then `muse commit` to record the merge. |
| 446 | |
| 447 | ### No commits yet |
| 448 | |
| 449 | On a branch that has never been committed to: |
| 450 | |
| 451 | ``` |
| 452 | On branch main, no commits yet |
| 453 | |
| 454 | Untracked files: |
| 455 | (use "muse commit -m <msg>" to record changes) |
| 456 | |
| 457 | beat.mid |
| 458 | ``` |
| 459 | |
| 460 | If `muse-work/` is empty or missing: `On branch main, no commits yet` (single line). |
| 461 | |
| 462 | ### `--branch` only |
| 463 | |
| 464 | Emits only the branch line regardless of working-tree state: |
| 465 | |
| 466 | ``` |
| 467 | On branch main |
| 468 | ``` |
| 469 | |
| 470 | This is useful when a script needs the branch name without triggering a full DB round-trip for the diff. |
| 471 | |
| 472 | ### Agent use case |
| 473 | |
| 474 | An AI music agent uses `muse status` to: |
| 475 | |
| 476 | - **Detect drift:** `muse status --porcelain` gives a stable, parseable list of all changed files before deciding whether to commit. |
| 477 | - **Section-aware generation:** `muse status --sections` reveals which musical sections have uncommitted changes, letting the agent focus generation on modified sections only. |
| 478 | - **Track inspection:** `muse status --tracks` shows which instrument tracks differ from HEAD, useful when coordinating multi-track edits across agent turns. |
| 479 | - **Pre-commit guard:** `muse status --short` gives a compact human-readable summary to include in agent reasoning traces before committing. |
| 480 | |
| 481 | ### Implementation |
| 482 | |
| 483 | | Layer | File | Responsibility | |
| 484 | |-------|------|----------------| |
| 485 | | Command | `maestro/muse_cli/commands/status.py` | Typer callback + `_status_async` | |
| 486 | | Diff engine | `maestro/muse_cli/snapshot.py` | `diff_workdir_vs_snapshot()` | |
| 487 | | Merge reader | `maestro/muse_cli/merge_engine.py` | `read_merge_state()` / `MergeState` | |
| 488 | | DB helper | `maestro/muse_cli/db.py` | `get_head_snapshot_manifest()` | |
| 489 | |
| 490 | `_status_async` is the injectable async core (tested directly without a running server). |
| 491 | Exit codes: 0 success, 2 outside a Muse repo, 3 internal error. |
| 492 | |
| 493 | --- |
| 494 | |
| 495 | ## `muse log` Output Formats |
| 496 | |
| 497 | ### Default (`git log` style) |
| 498 | |
| 499 | ``` |
| 500 | commit a1b2c3d4e5f6... (HEAD -> main) |
| 501 | Parent: f9e8d7c6 |
| 502 | Date: 2026-02-27 17:30:00 |
| 503 | |
| 504 | boom bap demo take 1 |
| 505 | |
| 506 | commit f9e8d7c6... |
| 507 | Date: 2026-02-27 17:00:00 |
| 508 | |
| 509 | initial take |
| 510 | ``` |
| 511 | |
| 512 | Commits are printed newest-first. The first commit (root) has no `Parent:` line. |
| 513 | |
| 514 | ### `--graph` mode |
| 515 | |
| 516 | Reuses `maestro.services.muse_log_render.render_ascii_graph` by adapting `MuseCliCommit` rows to the `MuseLogGraph`/`MuseLogNode` dataclasses the renderer expects. |
| 517 | |
| 518 | ``` |
| 519 | * a1b2c3d4 boom bap demo take 1 (HEAD) |
| 520 | * f9e8d7c6 initial take |
| 521 | ``` |
| 522 | |
| 523 | Merge commits (two parents) require `muse merge` (issue #35) — `parent2_commit_id` is reserved for that iteration. |
| 524 | |
| 525 | ### `--oneline` mode |
| 526 | |
| 527 | One line per commit: `<short_id> [HEAD marker] <message>`. |
| 528 | |
| 529 | ``` |
| 530 | a1b2c3d4 (HEAD -> main) boom bap demo take 1 |
| 531 | f9e8d7c6 initial take |
| 532 | ``` |
| 533 | |
| 534 | ### `--stat` mode |
| 535 | |
| 536 | Standard header per commit followed by per-file change lines and a totals summary. |
| 537 | |
| 538 | ``` |
| 539 | commit a1b2c3d4 (HEAD -> main) |
| 540 | Date: 2026-02-27 17:30:00 |
| 541 | |
| 542 | boom bap demo take 1 |
| 543 | |
| 544 | muse-work/drums/jazz.mid | added |
| 545 | muse-work/bass/old.mid | removed |
| 546 | 2 files changed, 1 added, 1 removed |
| 547 | ``` |
| 548 | |
| 549 | ### `--patch` / `-p` mode |
| 550 | |
| 551 | Standard header per commit followed by path-level diff blocks showing which files |
| 552 | were added, removed, or modified. This is a structural (path-level) diff since |
| 553 | Muse tracks MIDI/audio blobs, not line-diffable text. |
| 554 | |
| 555 | ``` |
| 556 | commit a1b2c3d4 (HEAD -> main) |
| 557 | Date: 2026-02-27 17:30:00 |
| 558 | |
| 559 | boom bap demo take 1 |
| 560 | |
| 561 | --- /dev/null |
| 562 | +++ muse-work/drums/jazz.mid |
| 563 | --- muse-work/bass/old.mid |
| 564 | +++ /dev/null |
| 565 | ``` |
| 566 | |
| 567 | ### Flags |
| 568 | |
| 569 | | Flag | Default | Description | |
| 570 | |------|---------|-------------| |
| 571 | | `--limit N` / `-n N` | 1000 | Cap the walk at N commits | |
| 572 | | `--graph` | off | ASCII DAG mode | |
| 573 | | `--oneline` | off | One line per commit: `<short_id> [HEAD] <message>` | |
| 574 | | `--stat` | off | Show file-change statistics per commit | |
| 575 | | `--patch` / `-p` | off | Show path-level diff per commit | |
| 576 | | `--since DATE` | — | Only commits after DATE (ISO or "2 weeks ago") | |
| 577 | | `--until DATE` | — | Only commits before DATE (ISO or "2 weeks ago") | |
| 578 | | `--author TEXT` | — | Case-insensitive substring match on author field | |
| 579 | | `--emotion TEXT` | — | Filter by `emotion:<TEXT>` tag (e.g. `melancholic`) | |
| 580 | | `--section TEXT` | — | Filter by `section:<TEXT>` tag (e.g. `chorus`) | |
| 581 | | `--track TEXT` | — | Filter by `track:<TEXT>` tag (e.g. `drums`) | |
| 582 | |
| 583 | All flags are combinable. Filters narrow the commit set; output mode flags control formatting. |
| 584 | Priority when multiple output modes specified: `--graph` > `--oneline` > `--stat` > `--patch` > default. |
| 585 | |
| 586 | ### Date parsing |
| 587 | |
| 588 | `--since` and `--until` accept: |
| 589 | - ISO dates: `2026-01-15`, `2026-01-15T12:00:00`, `2026-01-15 12:00:00` |
| 590 | - Relative: `N days ago`, `N weeks ago`, `N months ago`, `N years ago`, `yesterday`, `today` |
| 591 | |
| 592 | ### Music-native tag filters |
| 593 | |
| 594 | `--emotion`, `--section`, and `--track` filter by tags stored in `muse_cli_tags`. |
| 595 | Tags follow the `emotion:<value>`, `section:<value>`, `track:<value>` naming |
| 596 | convention. Multiple tag filters are AND-combined — a commit must carry all |
| 597 | specified tags to appear in the output. |
| 598 | |
| 599 | **Agent use case:** An agent debugging a melancholic chorus can run |
| 600 | `muse log --emotion melancholic --section chorus` to find exactly when that |
| 601 | emotional character was committed, then `muse show <commit>` to inspect the |
| 602 | snapshot or `muse revert <commit>` to undo it. |
| 603 | |
| 604 | ### Result type |
| 605 | |
| 606 | `parse_date_filter(text: str) -> datetime` — converts a human date string to |
| 607 | UTC-aware `datetime`. Raises `ValueError` on unrecognised formats. |
| 608 | |
| 609 | `CommitDiff` — fields: `added: list[str]`, `removed: list[str]`, |
| 610 | `changed: list[str]`, `total_files: int` (computed property). |
| 611 | |
| 612 | --- |
| 613 | |
| 614 | --- |
| 615 | |
| 616 | ## `muse arrange [<commit>]` — Arrangement Map (issue #115) |
| 617 | |
| 618 | `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. |
| 619 | |
| 620 | ### Path Convention |
| 621 | |
| 622 | Files committed to Muse must follow the three-level path convention to participate in the arrangement map: |
| 623 | |
| 624 | ``` |
| 625 | muse-work/<section>/<instrument>/<filename> |
| 626 | ``` |
| 627 | |
| 628 | | Level | Example | Description | |
| 629 | |-------|---------|-------------| |
| 630 | | `<section>` | `intro`, `verse`, `chorus`, `bridge`, `outro` | Musical section name (normalised to lowercase) | |
| 631 | | `<instrument>` | `drums`, `bass`, `strings`, `piano`, `vocals` | Instrument / track name | |
| 632 | | `<filename>` | `beat.mid`, `pad.mid` | The actual file | |
| 633 | |
| 634 | Files with fewer than three path components are excluded from the arrangement map (they carry no section metadata). |
| 635 | |
| 636 | Section aliases are normalised: `pre-chorus`, `pre_chorus`, and `prechoruse` all map to `prechorus`. |
| 637 | |
| 638 | ### Output Formats |
| 639 | |
| 640 | **Text (default)**: |
| 641 | |
| 642 | ``` |
| 643 | Arrangement Map — commit abc1234 |
| 644 | |
| 645 | Intro Verse Chorus Bridge Outro |
| 646 | drums ████ ████ ████ ████ ████ |
| 647 | bass ░░░░ ████ ████ ████ ████ |
| 648 | piano ████ ░░░░ ████ ░░░░ ████ |
| 649 | strings ░░░░ ░░░░ ████ ████ ░░░░ |
| 650 | ``` |
| 651 | |
| 652 | `████` = active (at least one file for that section/instrument pair). |
| 653 | `░░░░` = inactive (no files). |
| 654 | |
| 655 | **JSON (`--format json`)** — structured, AI-agent-consumable: |
| 656 | |
| 657 | ```json |
| 658 | { |
| 659 | "commit_id": "abc1234...", |
| 660 | "sections": ["intro", "verse", "chorus", "bridge", "outro"], |
| 661 | "instruments": ["bass", "drums", "piano", "strings"], |
| 662 | "arrangement": { |
| 663 | "drums": { "intro": true, "verse": true, "chorus": true }, |
| 664 | "strings": { "intro": false, "verse": false, "chorus": true } |
| 665 | } |
| 666 | } |
| 667 | ``` |
| 668 | |
| 669 | **CSV (`--format csv`)** — spreadsheet-ready rows with `0`/`1` cells. |
| 670 | |
| 671 | ## `muse describe` — Structured Musical Change Description |
| 672 | |
| 673 | `muse describe [<commit>] [OPTIONS]` compares a commit against its parent (or two commits via `--compare`) and outputs a structured description of what changed at the snapshot level. |
| 674 | |
| 675 | ### Output example (standard depth) |
| 676 | |
| 677 | ``` |
| 678 | Commit abc1234: "Add piano melody to verse" |
| 679 | Changed files: 2 (beat.mid, keys.mid) |
| 680 | Dimensions analyzed: structural (2 files modified) |
| 681 | Note: Full harmonic/melodic analysis requires muse harmony and muse motif (planned) |
| 682 | ``` |
| 683 | |
| 684 | ### Flags |
| 685 | |
| 686 | | Flag | Default | Description | |
| 687 | |------|---------|-------------| |
| 688 | | `[COMMIT]` | `HEAD` | Target commit: HEAD, branch name, or commit-ID prefix | |
| 689 | | `--section TEXT` | none | Show only a specific section's instrumentation | |
| 690 | | `--track TEXT` | none | Show only a specific instrument's section participation | |
| 691 | | `--compare A --compare B` | — | Diff two arrangements (show added/removed cells) | |
| 692 | | `--density` | off | Show byte-size total per cell instead of binary active/inactive | |
| 693 | | `--format text\|json\|csv` | `text` | Output format | |
| 694 | |
| 695 | ### Compare Mode (`--compare`) |
| 696 | |
| 697 | ``` |
| 698 | Arrangement Diff — abc1234 → def5678 |
| 699 | |
| 700 | Intro Verse Chorus |
| 701 | drums ████ ████ ████ |
| 702 | strings ░░░░ ░░░░ +████ |
| 703 | piano ████ ░░░░ -████ |
| 704 | ``` |
| 705 | |
| 706 | `+████` = cell added in commit-b. |
| 707 | `-████` = cell removed in commit-b. |
| 708 | |
| 709 | ### Density Mode (`--density`) |
| 710 | |
| 711 | 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: |
| 712 | |
| 713 | ``` |
| 714 | Intro Verse Chorus |
| 715 | drums 4,096 3,200 5,120 |
| 716 | bass - 1,024 2,048 |
| 717 | ``` |
| 718 | | `<commit>` (positional) | HEAD | Commit to describe | |
| 719 | | `--compare A B` | — | Compare commit A against commit B explicitly | |
| 720 | | `--depth brief\|standard\|verbose` | `standard` | Output verbosity | |
| 721 | | `--dimensions TEXT` | — | Comma-separated dimension labels (informational, passed through to output) | |
| 722 | | `--json` | off | Output as JSON | |
| 723 | | `--auto-tag` | off | Add a heuristic tag based on change scope | |
| 724 | |
| 725 | ### Depth modes |
| 726 | |
| 727 | | Depth | Output | |
| 728 | |-------|--------| |
| 729 | | `brief` | One-line: `Commit <id>: N file changes` | |
| 730 | | `standard` | Message, changed files list, inferred dimensions, LLM note | |
| 731 | | `verbose` | Full commit ID, parent ID, per-file M/A/D markers, dimensions | |
| 732 | |
| 733 | ### Implementation |
| 734 | |
| 735 | | Layer | File | Responsibility | |
| 736 | |-------|------|----------------| |
| 737 | | Service | `maestro/services/muse_arrange.py` | `build_arrangement_matrix()`, diff, renderers | |
| 738 | | Command | `maestro/muse_cli/commands/arrange.py` | Typer callback + `_arrange_async` | |
| 739 | | App | `maestro/muse_cli/app.py` | Registration under `arrange` subcommand | |
| 740 | |
| 741 | `_arrange_async` is fully injectable for unit tests (accepts a `root: pathlib.Path` and `session: AsyncSession`). |
| 742 | |
| 743 | Exit codes: `0` success, `1` user error (unknown format, missing reference, ambiguous prefix), `2` outside a Muse repo, `3` internal error. |
| 744 | |
| 745 | ### Named Result Types |
| 746 | |
| 747 | See `docs/reference/type_contracts.md`: |
| 748 | - `ArrangementCell` — per (section, instrument) data |
| 749 | - `ArrangementMatrix` — full matrix for one commit |
| 750 | - `ArrangementDiffCell` — change status for one cell |
| 751 | - `ArrangementDiff` — full diff between two matrices |
| 752 | | Command | `maestro/muse_cli/commands/describe.py` | Typer callback + `_describe_async` | |
| 753 | | Diff engine | `maestro/muse_cli/commands/describe.py` | `_diff_manifests()` | |
| 754 | | Renderers | `maestro/muse_cli/commands/describe.py` | `_render_brief/standard/verbose/result` | |
| 755 | | DB helpers | `maestro/muse_cli/db.py` | `get_commit_snapshot_manifest()` | |
| 756 | |
| 757 | `_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. |
| 758 | |
| 759 | **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`. |
| 760 | |
| 761 | **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. |
| 762 | |
| 763 | > **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. |
| 764 | |
| 765 | --- |
| 766 | |
| 767 | ## `muse export` — Export a Snapshot to External Formats |
| 768 | |
| 769 | `muse export [<commit>] --format <format>` exports a Muse snapshot to a |
| 770 | file format usable outside the DAW. This is a **read-only** operation — |
| 771 | no commit is created and no DB writes occur. Given the same commit ID and |
| 772 | format, the output is always identical (deterministic). |
| 773 | |
| 774 | ### Usage |
| 775 | |
| 776 | ``` |
| 777 | muse export [<commit>] --format <format> [OPTIONS] |
| 778 | |
| 779 | Arguments: |
| 780 | <commit> Short commit ID prefix (default: HEAD). |
| 781 | |
| 782 | Options: |
| 783 | --format, -f Target format (required): midi | json | musicxml | abc | wav |
| 784 | --output, -o Destination path (default: ./exports/<commit8>.<format>) |
| 785 | --track TEXT Export only files whose path contains TEXT (substring match). |
| 786 | --section TEXT Export only files whose path contains TEXT (substring match). |
| 787 | --split-tracks Write one file per MIDI track (MIDI only). |
| 788 | ``` |
| 789 | |
| 790 | ### Supported Formats |
| 791 | |
| 792 | | Format | Extension | Description | |
| 793 | |------------|-----------|-------------| |
| 794 | | `midi` | `.mid` | Copy raw MIDI files from the snapshot (lossless, native). | |
| 795 | | `json` | `.json` | Structured JSON index of snapshot files (AI/tooling consumption). | |
| 796 | | `musicxml` | `.xml` | MusicXML for notation software (MuseScore, Sibelius, etc.). | |
| 797 | | `abc` | `.abc` | ABC notation for folk/traditional music tools. | |
| 798 | | `wav` | `.wav` | Audio render via Storpheus (requires Storpheus running). | |
| 799 | |
| 800 | ### Examples |
| 801 | |
| 802 | ```bash |
| 803 | # Export HEAD snapshot as MIDI |
| 804 | muse export --format midi --output /tmp/my-song.mid |
| 805 | |
| 806 | # Export only the piano track from a specific commit |
| 807 | muse export a1b2c3d4 --format midi --track piano |
| 808 | |
| 809 | # Export the chorus section as MusicXML |
| 810 | muse export --format musicxml --section chorus |
| 811 | |
| 812 | # Export all tracks as separate MIDI files |
| 813 | muse export --format midi --split-tracks |
| 814 | |
| 815 | # Export JSON note structure |
| 816 | muse export --format json --output /tmp/snapshot.json |
| 817 | |
| 818 | # WAV render (Storpheus must be running) |
| 819 | muse export --format wav |
| 820 | ``` |
| 821 | |
| 822 | ### Implementation |
| 823 | |
| 824 | | Component | Location | |
| 825 | |-----------|----------| |
| 826 | | CLI command | `maestro/muse_cli/commands/export.py` | |
| 827 | | Format engine | `maestro/muse_cli/export_engine.py` | |
| 828 | | Tests | `tests/muse_cli/test_export.py` | |
| 829 | |
| 830 | `export_engine.py` provides: |
| 831 | |
| 832 | - `ExportFormat` — enum of supported formats. |
| 833 | - `MuseExportOptions` — frozen dataclass with export settings. |
| 834 | - `MuseExportResult` — result dataclass listing written paths. |
| 835 | - `StorpheusUnavailableError` — raised when WAV export is attempted |
| 836 | but Storpheus is unreachable (callers surface a clean error message). |
| 837 | - `filter_manifest()` — applies `--track` / `--section` filters. |
| 838 | - `export_snapshot()` — top-level dispatcher. |
| 839 | - Format handlers: `export_midi`, `export_json`, `export_musicxml`, `export_abc`, `export_wav`. |
| 840 | - MIDI conversion helpers: `_midi_to_musicxml`, `_midi_to_abc` (minimal, best-effort). |
| 841 | |
| 842 | ### WAV Export and Storpheus Dependency |
| 843 | |
| 844 | `--format wav` delegates audio rendering to the Storpheus service |
| 845 | (port 10002). Before attempting any conversion, `export_wav` performs |
| 846 | a synchronous health check against `GET /health`. If Storpheus is not |
| 847 | reachable or returns a non-200 response, `StorpheusUnavailableError` is |
| 848 | raised and the CLI exits with a clear human-readable error: |
| 849 | |
| 850 | ``` |
| 851 | ❌ WAV export requires Storpheus. |
| 852 | Storpheus is not reachable at http://localhost:10002: Connection refused |
| 853 | Start Storpheus (docker compose up storpheus) and retry. |
| 854 | ``` |
| 855 | |
| 856 | ### Filter Semantics |
| 857 | |
| 858 | `--track` and `--section` are **case-insensitive substring matches** against |
| 859 | the full relative path of each file in the snapshot manifest. Both filters |
| 860 | are applied with AND semantics: a file must match all provided filters to be |
| 861 | included. |
| 862 | |
| 863 | ``` |
| 864 | manifest: |
| 865 | chorus/piano/take1.mid |
| 866 | verse/piano/take1.mid |
| 867 | chorus/bass/take1.mid |
| 868 | |
| 869 | --track piano → chorus/piano/take1.mid, verse/piano/take1.mid |
| 870 | --section chorus → chorus/piano/take1.mid, chorus/bass/take1.mid |
| 871 | --track piano --section chorus → chorus/piano/take1.mid |
| 872 | ``` |
| 873 | |
| 874 | ### Postgres State |
| 875 | |
| 876 | Export is read-only. It reads `muse_cli_commits` and `muse_cli_snapshots` |
| 877 | but writes nothing to the database. |
| 878 | |
| 879 | --- |
| 880 | |
| 881 | |
| 882 | ## Commit Data Model |
| 883 | |
| 884 | `muse commit` persists three content-addressed table types to Postgres: |
| 885 | |
| 886 | ### `muse_cli_objects` — File blobs (sha256-keyed) |
| 887 | |
| 888 | | Column | Type | Description | |
| 889 | |--------|------|-------------| |
| 890 | | `object_id` | `String(64)` PK | `sha256(file_bytes)` hex digest | |
| 891 | | `size_bytes` | `Integer` | Raw file size | |
| 892 | | `created_at` | `DateTime(tz=True)` | Wall-clock insert time | |
| 893 | |
| 894 | Objects are deduplicated across commits: the same file committed on two branches is stored exactly once. |
| 895 | |
| 896 | ### `muse_cli_snapshots` — Snapshot manifests |
| 897 | |
| 898 | | Column | Type | Description | |
| 899 | |--------|------|-------------| |
| 900 | | `snapshot_id` | `String(64)` PK | `sha256(sorted("path:object_id" pairs))` | |
| 901 | | `manifest` | `JSON` | `{rel_path: object_id}` mapping | |
| 902 | | `created_at` | `DateTime(tz=True)` | Wall-clock insert time | |
| 903 | |
| 904 | Two identical working trees always produce the same `snapshot_id`. |
| 905 | |
| 906 | ### `muse_cli_commits` — Commit history |
| 907 | |
| 908 | | Column | Type | Description | |
| 909 | |--------|------|-------------| |
| 910 | | `commit_id` | `String(64)` PK | Deterministic sha256 (see below) | |
| 911 | | `repo_id` | `String(36)` | UUID from `.muse/repo.json` | |
| 912 | | `branch` | `String(255)` | Branch name at commit time | |
| 913 | | `parent_commit_id` | `String(64)` nullable | Previous HEAD commit on branch | |
| 914 | | `snapshot_id` | `String(64)` FK | Points to the snapshot row | |
| 915 | | `message` | `Text` | User-supplied commit message (may include Co-authored-by trailers) | |
| 916 | | `author` | `String(255)` | Reserved (empty for MVP) | |
| 917 | | `committed_at` | `DateTime(tz=True)` | Timestamp used in hash derivation | |
| 918 | | `created_at` | `DateTime(tz=True)` | Wall-clock DB insert time | |
| 919 | | `metadata` | `JSON` nullable | Extensible music-domain annotations (see below) | |
| 920 | |
| 921 | **`metadata` JSON blob — current keys:** |
| 922 | |
| 923 | | Key | Type | Set by | |
| 924 | |-----|------|--------| |
| 925 | | `section` | `string` | `muse commit --section` | |
| 926 | | `track` | `string` | `muse commit --track` | |
| 927 | | `emotion` | `string` | `muse commit --emotion` | |
| 928 | | `tempo_bpm` | `float` | `muse tempo --set` | |
| 929 | |
| 930 | 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. |
| 931 | |
| 932 | ### ID Derivation (deterministic) |
| 933 | |
| 934 | ``` |
| 935 | object_id = sha256(file_bytes) |
| 936 | snapshot_id = sha256("|".join(sorted(f"{path}:{oid}" for path, oid in manifest.items()))) |
| 937 | commit_id = sha256( |
| 938 | "|".join(sorted(parent_ids)) |
| 939 | + "|" + snapshot_id |
| 940 | + "|" + message |
| 941 | + "|" + committed_at.isoformat() |
| 942 | ) |
| 943 | ``` |
| 944 | |
| 945 | 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. |
| 946 | |
| 947 | --- |
| 948 | |
| 949 | ## Local Repository Structure (`.muse/`) |
| 950 | |
| 951 | `muse init` creates the following layout in the current working directory: |
| 952 | |
| 953 | ``` |
| 954 | .muse/ |
| 955 | repo.json Repo identity: repo_id (UUID), schema_version, created_at[, bare] |
| 956 | HEAD Current branch pointer, e.g. "refs/heads/main" |
| 957 | config.toml [core] (bare repos only), [user], [auth], [remotes] configuration |
| 958 | objects/ Local content-addressed object store (written by muse commit) |
| 959 | <object_id> One file per unique object (sha256 of file bytes) |
| 960 | refs/ |
| 961 | heads/ |
| 962 | main Commit ID of branch HEAD (empty = no commits yet) |
| 963 | <branch> One file per branch |
| 964 | muse-work/ Working-tree root (absent for --bare repos) |
| 965 | ``` |
| 966 | |
| 967 | ### `muse init` flags |
| 968 | |
| 969 | | Flag | Type | Default | Description | |
| 970 | |------|------|---------|-------------| |
| 971 | | `--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. | |
| 972 | | `--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. | |
| 973 | | `--default-branch BRANCH` | text | `main` | Name of the initial branch. Sets `HEAD → refs/heads/<BRANCH>` and creates the matching ref file. | |
| 974 | | `--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`. | |
| 975 | |
| 976 | **Bare repository layout** (`--bare`): |
| 977 | |
| 978 | ``` |
| 979 | .muse/ |
| 980 | repo.json … bare = true … |
| 981 | HEAD refs/heads/<branch> |
| 982 | refs/heads/<branch> |
| 983 | config.toml [core] bare = true + [user] [auth] [remotes] stubs |
| 984 | ``` |
| 985 | |
| 986 | Bare repos are used as Muse Hub remotes — objects and refs only, no live working copy. |
| 987 | |
| 988 | **Usage examples:** |
| 989 | |
| 990 | ```bash |
| 991 | muse init # standard repo, branch = main |
| 992 | muse init --default-branch develop # standard repo, branch = develop |
| 993 | muse init --bare # bare repo (Hub remote) |
| 994 | muse init --bare --default-branch trunk # bare repo, branch = trunk |
| 995 | muse init --template /path/to/studio-tmpl # copy template into muse-work/ |
| 996 | muse init --template /studio --default-branch release # template + custom branch |
| 997 | muse init --force # reinitialise, preserve repo_id |
| 998 | ``` |
| 999 | |
| 1000 | ### File semantics |
| 1001 | |
| 1002 | | File | Source of truth for | Notes | |
| 1003 | |------|-------------------|-------| |
| 1004 | | `repo.json` | Repo identity | `repo_id` persists across `--force` reinitialise; `bare = true` written for bare repos | |
| 1005 | | `HEAD` | Current branch name | Always `refs/heads/<branch>`; branch name set by `--default-branch` | |
| 1006 | | `refs/heads/<branch>` | Branch → commit pointer | Empty string = branch has no commits yet | |
| 1007 | | `config.toml` | User identity, auth token, remotes | Not overwritten on `--force`; bare repos include `[core] bare = true` | |
| 1008 | | `muse-work/` | Working-tree root | Created by non-bare init; populated from `--template` if provided | |
| 1009 | |
| 1010 | ### Repo-root detection |
| 1011 | |
| 1012 | Every CLI command locates the active repo by walking up the directory tree until `.muse/` is found: |
| 1013 | |
| 1014 | ```python |
| 1015 | # Public API — maestro/muse_cli/repo.py (issue #46) |
| 1016 | from maestro.muse_cli.repo import find_repo_root, require_repo_root |
| 1017 | |
| 1018 | root: Path | None = find_repo_root() # returns None if not found, never raises |
| 1019 | root: Path = require_repo_root() # exits 2 with git-style error if not found |
| 1020 | ``` |
| 1021 | |
| 1022 | Detection rules (in priority order): |
| 1023 | |
| 1024 | 1. If `MUSE_REPO_ROOT` env var is set, use it (useful in tests and scripts — no traversal). |
| 1025 | 2. Walk from `start` (default `Path.cwd()`) upward until a directory containing `.muse/` is found. |
| 1026 | 3. If the filesystem root is reached with no `.muse/`, return `None`. |
| 1027 | |
| 1028 | `require_repo_root()` exits 2 with: |
| 1029 | ``` |
| 1030 | fatal: not a muse repository (or any parent up to mount point /) |
| 1031 | Run "muse init" to initialize a new repository. |
| 1032 | ``` |
| 1033 | |
| 1034 | **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`. |
| 1035 | |
| 1036 | ### `config.toml` example |
| 1037 | |
| 1038 | ```toml |
| 1039 | [user] |
| 1040 | name = "Gabriel" |
| 1041 | email = "g@example.com" |
| 1042 | |
| 1043 | [auth] |
| 1044 | token = "eyJ..." # Muse Hub Bearer token — keep out of version control |
| 1045 | |
| 1046 | [remotes] |
| 1047 | [remotes.origin] |
| 1048 | url = "https://story.audio/musehub/repos/abcd1234" |
| 1049 | ``` |
| 1050 | |
| 1051 | > **Security note:** `.muse/config.toml` contains the Hub auth token. Add `.muse/config.toml` to `.gitignore` (or `.museignore`) to prevent accidental exposure. |
| 1052 | |
| 1053 | ### VCS Services |
| 1054 | |
| 1055 | ``` |
| 1056 | app/services/ |
| 1057 | ├── muse_repository.py — Persistence adapter (DB reads/writes) |
| 1058 | ├── muse_replay.py — History reconstruction (lineage walking) |
| 1059 | ├── muse_drift.py — Drift detection engine (HEAD vs working) |
| 1060 | ├── muse_checkout.py — Checkout plan builder (pure data → tool calls) |
| 1061 | ├── muse_checkout_executor.py — Checkout execution (applies plan to StateStore) |
| 1062 | ├── muse_merge_base.py — Merge base finder (LCA in the DAG) |
| 1063 | ├── muse_merge.py — Three-way merge engine |
| 1064 | ├── muse_history_controller.py— Orchestrates checkout + merge flows |
| 1065 | ├── muse_log_graph.py — DAG serializer (topological sort → JSON) |
| 1066 | ├── muse_log_render.py — ASCII graph + JSON + summary renderer |
| 1067 | └── variation/ |
| 1068 | └── note_matching.py — Note + controller event matching/diffing |
| 1069 | |
| 1070 | app/api/routes/ |
| 1071 | ├── muse.py — Production HTTP routes (5 endpoints) |
| 1072 | └── variation/ — Existing variation proposal routes |
| 1073 | |
| 1074 | app/db/ |
| 1075 | └── muse_models.py — ORM: Variation, Phrase, NoteChange tables |
| 1076 | |
| 1077 | tests/ |
| 1078 | ├── test_muse_persistence.py — Repository + lineage tests |
| 1079 | ├── test_muse_drift.py — Drift detection tests |
| 1080 | ├── test_muse_drift_controllers.py — Controller drift tests |
| 1081 | ├── test_commit_drift_safety.py — 409 conflict enforcement |
| 1082 | ├── test_muse_checkout.py — Checkout plan tests |
| 1083 | ├── test_muse_checkout_execution.py — Checkout execution tests |
| 1084 | ├── test_muse_merge.py — Merge engine tests |
| 1085 | ├── test_muse_log_graph.py — Log graph serialization tests |
| 1086 | └── e2e/ |
| 1087 | ├── muse_fixtures.py — Deterministic IDs + snapshot builders |
| 1088 | └── test_muse_e2e_harness.py — Full VCS lifecycle E2E test |
| 1089 | ``` |
| 1090 | |
| 1091 | --- |
| 1092 | |
| 1093 | ## Data Model |
| 1094 | |
| 1095 | ### Variation (ORM: `app/db/muse_models.py`) |
| 1096 | |
| 1097 | | Column | Type | Purpose | |
| 1098 | |--------|------|---------| |
| 1099 | | `variation_id` | PK | Unique ID | |
| 1100 | | `project_id` | FK | Project this belongs to | |
| 1101 | | `parent_variation_id` | FK (self) | Primary parent (lineage) | |
| 1102 | | `parent2_variation_id` | FK (self) | Second parent (merge commits only) | |
| 1103 | | `is_head` | bool | Whether this is the current HEAD | |
| 1104 | | `commit_state_id` | str | State version at commit time | |
| 1105 | | `intent` | text | User intent / description | |
| 1106 | | `status` | str | `ready` / `committed` / `discarded` | |
| 1107 | |
| 1108 | ### HeadSnapshot (`app/services/muse_replay.py`) |
| 1109 | |
| 1110 | Reconstructed from walking the variation lineage. Contains the cumulative state at any point in history: |
| 1111 | |
| 1112 | | Field | Type | Contents | |
| 1113 | |-------|------|----------| |
| 1114 | | `notes` | `dict[region_id, list[note_dict]]` | All notes per region | |
| 1115 | | `cc` | `dict[region_id, list[cc_event]]` | CC events per region | |
| 1116 | | `pitch_bends` | `dict[region_id, list[pb_event]]` | Pitch bends per region | |
| 1117 | | `aftertouch` | `dict[region_id, list[at_event]]` | Aftertouch per region | |
| 1118 | | `track_regions` | `dict[region_id, track_id]` | Region-to-track mapping | |
| 1119 | |
| 1120 | --- |
| 1121 | |
| 1122 | ## HTTP API |
| 1123 | |
| 1124 | All routes require JWT auth (`Authorization: Bearer <token>`). |
| 1125 | Prefix: `/api/v1/muse/` |
| 1126 | |
| 1127 | | Method | Path | Purpose | |
| 1128 | |--------|------|---------| |
| 1129 | | `POST` | `/muse/variations` | Save a variation directly into history | |
| 1130 | | `POST` | `/muse/head` | Set HEAD pointer to a variation | |
| 1131 | | `GET` | `/muse/log?project_id=X` | Get the full commit DAG as `MuseLogGraph` JSON | |
| 1132 | | `POST` | `/muse/checkout` | Checkout to a variation (time travel) | |
| 1133 | | `POST` | `/muse/merge` | Three-way merge of two variations | |
| 1134 | |
| 1135 | ### Response codes |
| 1136 | |
| 1137 | | Code | Meaning | |
| 1138 | |------|---------| |
| 1139 | | 200 | Success | |
| 1140 | | 404 | Variation not found (checkout) | |
| 1141 | | 409 | Checkout blocked by drift / merge has conflicts | |
| 1142 | |
| 1143 | --- |
| 1144 | |
| 1145 | ## VCS Primitives |
| 1146 | |
| 1147 | ### Commit (save + set HEAD) |
| 1148 | |
| 1149 | ``` |
| 1150 | save_variation(session, variation, project_id, parent_variation_id, ...) |
| 1151 | set_head(session, variation_id) |
| 1152 | ``` |
| 1153 | |
| 1154 | ### Lineage |
| 1155 | |
| 1156 | ``` |
| 1157 | get_lineage(session, variation_id) → [root, ..., target] |
| 1158 | get_head(session, project_id) → HistoryNode | None |
| 1159 | get_children(session, variation_id) → [HistoryNode, ...] |
| 1160 | ``` |
| 1161 | |
| 1162 | ### Drift Detection |
| 1163 | |
| 1164 | ``` |
| 1165 | compute_drift_report(head_snapshot, working_snapshot, ...) → DriftReport |
| 1166 | ``` |
| 1167 | |
| 1168 | Compares HEAD (from DB) against working state (from StateStore). Severity levels: `CLEAN`, `DIRTY`, `DIVERGED`. |
| 1169 | |
| 1170 | ### Replay / Reconstruction |
| 1171 | |
| 1172 | ``` |
| 1173 | reconstruct_head_snapshot(session, project_id) → HeadSnapshot |
| 1174 | reconstruct_variation_snapshot(session, variation_id) → HeadSnapshot |
| 1175 | build_replay_plan(session, project_id, target_id) → ReplayPlan |
| 1176 | ``` |
| 1177 | |
| 1178 | ### Checkout |
| 1179 | |
| 1180 | ``` |
| 1181 | build_checkout_plan(target_notes, working_notes, ...) → CheckoutPlan |
| 1182 | execute_checkout_plan(plan, store, trace) → CheckoutExecutionResult |
| 1183 | checkout_to_variation(session, project_id, target_id, store, ...) → CheckoutSummary |
| 1184 | ``` |
| 1185 | |
| 1186 | ### Merge |
| 1187 | |
| 1188 | ``` |
| 1189 | find_merge_base(session, a, b) → str | None |
| 1190 | build_merge_result(base, left, right) → MergeResult |
| 1191 | merge_variations(session, project_id, left, right, store, ...) → MergeSummary |
| 1192 | ``` |
| 1193 | |
| 1194 | ### Log Graph |
| 1195 | |
| 1196 | ``` |
| 1197 | build_muse_log_graph(session, project_id) → MuseLogGraph |
| 1198 | ``` |
| 1199 | |
| 1200 | Topologically sorted (Kahn's algorithm), deterministic tie-breaking by `(timestamp, variation_id)`. Output is camelCase JSON for the Swift frontend. |
| 1201 | |
| 1202 | --- |
| 1203 | |
| 1204 | ## Architectural Boundaries |
| 1205 | |
| 1206 | 17 AST-enforced rules in `scripts/check_boundaries.py`. Key constraints: |
| 1207 | |
| 1208 | | Module | Must NOT import | |
| 1209 | |--------|----------------| |
| 1210 | | `muse_repository` | StateStore, executor, VariationService | |
| 1211 | | `muse_replay` | StateStore, executor, LLM handlers | |
| 1212 | | `muse_drift` | StateStore, executor, LLM handlers | |
| 1213 | | `muse_checkout` | StateStore, executor, handlers | |
| 1214 | | `muse_checkout_executor` | LLM handlers, VariationService | |
| 1215 | | `muse_merge`, `muse_merge_base` | StateStore, executor, MCP, handlers | |
| 1216 | | `muse_log_graph` | StateStore, executor, handlers, engines | |
| 1217 | | `note_matching` | handlers, StateStore | |
| 1218 | |
| 1219 | 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. |
| 1220 | |
| 1221 | --- |
| 1222 | |
| 1223 | ## `muse find` — Search Commit History by Musical Properties |
| 1224 | |
| 1225 | `muse find` is the musical grep: it queries the full commit history for the |
| 1226 | current repository and returns commits whose messages match musical criteria. |
| 1227 | All filter flags combine with **AND logic** — a commit must satisfy every |
| 1228 | supplied criterion to appear in results. |
| 1229 | |
| 1230 | ### Command Flags |
| 1231 | |
| 1232 | | Flag | Example | Description | |
| 1233 | |------|---------|-------------| |
| 1234 | | `--harmony <query>` | `"key=Eb"`, `"mode=minor"` | Harmonic filter | |
| 1235 | | `--rhythm <query>` | `"tempo=120-130"`, `"meter=7/8"` | Rhythmic filter | |
| 1236 | | `--melody <query>` | `"shape=arch"`, `"motif=main-theme"` | Melodic filter | |
| 1237 | | `--structure <query>` | `"has=bridge"`, `"form=AABA"` | Structural filter | |
| 1238 | | `--dynamic <query>` | `"avg_vel>80"`, `"arc=crescendo"` | Dynamic filter | |
| 1239 | | `--emotion <tag>` | `melancholic` | Emotion tag | |
| 1240 | | `--section <text>` | `"chorus"` | Named section filter | |
| 1241 | | `--track <text>` | `"bass"` | Track presence filter | |
| 1242 | | `--since <date>` | `"2026-01-01"` | Commits after this date (UTC) | |
| 1243 | | `--until <date>` | `"2026-03-01"` | Commits before this date (UTC) | |
| 1244 | | `--limit N` / `-n N` | `20` (default) | Cap results | |
| 1245 | | `--json` | — | Machine-readable JSON output | |
| 1246 | |
| 1247 | ### Query DSL |
| 1248 | |
| 1249 | #### Equality match (default) |
| 1250 | |
| 1251 | All property filters do a **case-insensitive substring match** against the |
| 1252 | commit message: |
| 1253 | |
| 1254 | ``` |
| 1255 | muse find --harmony "key=F minor" |
| 1256 | ``` |
| 1257 | |
| 1258 | Finds every commit whose message contains the string `key=F minor` (any case). |
| 1259 | |
| 1260 | #### Numeric range match |
| 1261 | |
| 1262 | When the value portion of a `key=value` expression contains two numbers |
| 1263 | separated by a hyphen (`low-high`), the filter extracts the numeric value of |
| 1264 | the key from the message and checks whether it falls within the range |
| 1265 | (inclusive): |
| 1266 | |
| 1267 | ``` |
| 1268 | muse find --rhythm "tempo=120-130" |
| 1269 | ``` |
| 1270 | |
| 1271 | Matches commits whose message contains `tempo=<N>` where 120 ≤ N ≤ 130. |
| 1272 | |
| 1273 | ### Output Formats |
| 1274 | |
| 1275 | #### Default (text) |
| 1276 | |
| 1277 | One commit block per match, newest-first: |
| 1278 | |
| 1279 | ``` |
| 1280 | commit a1b2c3d4... |
| 1281 | Branch: main |
| 1282 | Parent: f9e8d7c6 |
| 1283 | Date: 2026-02-27 17:30:00 |
| 1284 | |
| 1285 | ambient sketch, key=F minor, tempo=90 bpm |
| 1286 | |
| 1287 | ``` |
| 1288 | |
| 1289 | #### `--json` output |
| 1290 | |
| 1291 | A JSON array of commit objects: |
| 1292 | |
| 1293 | ```json |
| 1294 | [ |
| 1295 | { |
| 1296 | "commit_id": "a1b2c3d4...", |
| 1297 | "branch": "main", |
| 1298 | "message": "ambient sketch, key=F minor, tempo=90 bpm", |
| 1299 | "author": "", |
| 1300 | "committed_at": "2026-02-27T17:30:00+00:00", |
| 1301 | "parent_commit_id": "f9e8d7c6...", |
| 1302 | "snapshot_id": "bac947cf..." |
| 1303 | } |
| 1304 | ] |
| 1305 | ``` |
| 1306 | |
| 1307 | ### Examples |
| 1308 | |
| 1309 | ```bash |
| 1310 | # All commits in F minor |
| 1311 | muse find --harmony "key=F minor" |
| 1312 | |
| 1313 | # Up-tempo commits in a date window |
| 1314 | muse find --rhythm "tempo=120-130" --since "2026-01-01" |
| 1315 | |
| 1316 | # Melancholic commits that include a bridge, as JSON |
| 1317 | muse find --emotion melancholic --structure "has=bridge" --json |
| 1318 | |
| 1319 | # Bass track presence, capped at 10 results |
| 1320 | muse find --track bass --limit 10 |
| 1321 | ``` |
| 1322 | |
| 1323 | ### Architecture |
| 1324 | |
| 1325 | - **Service:** `maestro/services/muse_find.py` |
| 1326 | - `MuseFindQuery` — frozen dataclass of all search criteria |
| 1327 | - `MuseFindCommitResult` — a single matching commit |
| 1328 | - `MuseFindResults` — container with matches, total scanned, and the query |
| 1329 | - `search_commits(session, repo_id, query)` — async search function |
| 1330 | - **CLI command:** `maestro/muse_cli/commands/find.py` |
| 1331 | - `_find_async(root, session, query, output_json)` — injectable core (tested directly) |
| 1332 | - Registered in `maestro/muse_cli/app.py` as `find` |
| 1333 | |
| 1334 | ### Postgres Behaviour |
| 1335 | |
| 1336 | Read-only operation — no writes. Plain-text filters are pushed to SQL via |
| 1337 | `ILIKE` for efficiency; numeric range filters are applied in Python after |
| 1338 | the SQL result set is fetched. `committed_at` date range filters use SQL |
| 1339 | `>=` / `<=` comparisons. |
| 1340 | |
| 1341 | --- |
| 1342 | |
| 1343 | ## `muse session` — Recording Session Metadata |
| 1344 | |
| 1345 | **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. |
| 1346 | |
| 1347 | 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. |
| 1348 | |
| 1349 | ### Subcommands |
| 1350 | |
| 1351 | | Subcommand | Flags | Purpose | |
| 1352 | |------------|-------|---------| |
| 1353 | | `muse session start` | `--participants`, `--location`, `--intent` | Open a new session; writes `current.json`. Only one active session at a time. | |
| 1354 | | `muse session end` | `--notes` | Finalise active session; moves `current.json` → `<uuid>.json`. | |
| 1355 | | `muse session log` | _(none)_ | List all completed sessions, newest first. | |
| 1356 | | `muse session show <id>` | _(prefix match supported)_ | Print full JSON for a specific completed session. | |
| 1357 | | `muse session credits` | _(none)_ | Aggregate participants across all completed sessions, sorted by count descending. | |
| 1358 | |
| 1359 | ### Storage Layout |
| 1360 | |
| 1361 | ``` |
| 1362 | .muse/ |
| 1363 | sessions/ |
| 1364 | current.json ← active session (exists only while recording) |
| 1365 | <session-uuid>.json ← one file per completed session |
| 1366 | ``` |
| 1367 | |
| 1368 | ### Session JSON Schema (`MuseSessionRecord`) |
| 1369 | |
| 1370 | ```json |
| 1371 | { |
| 1372 | "session_id": "<uuid4>", |
| 1373 | "schema_version": "1", |
| 1374 | "started_at": "2026-02-27T15:49:19+00:00", |
| 1375 | "ended_at": "2026-02-27T17:30:00+00:00", |
| 1376 | "participants": ["Alice", "Bob"], |
| 1377 | "location": "Studio A", |
| 1378 | "intent": "Record the bridge", |
| 1379 | "commits": ["abc123", "def456"], |
| 1380 | "notes": "Nailed the third take." |
| 1381 | } |
| 1382 | ``` |
| 1383 | |
| 1384 | The `commits` list is populated externally (e.g., by `muse commit` in a future integration); it starts empty. |
| 1385 | |
| 1386 | ### Output Examples |
| 1387 | |
| 1388 | **`muse session log`** |
| 1389 | |
| 1390 | ``` |
| 1391 | 3f2a1b0c 2026-02-27T15:49:19 → 2026-02-27T17:30:00 [Alice, Bob] |
| 1392 | a1b2c3d4 2026-02-26T10:00:00 → 2026-02-26T12:00:00 [] |
| 1393 | ``` |
| 1394 | |
| 1395 | **`muse session credits`** |
| 1396 | |
| 1397 | ``` |
| 1398 | Session credits: |
| 1399 | Alice 2 sessions |
| 1400 | Bob 1 session |
| 1401 | Carol 1 session |
| 1402 | ``` |
| 1403 | |
| 1404 | ### Result Type |
| 1405 | |
| 1406 | `MuseSessionRecord` — TypedDict defined in `maestro/muse_cli/commands/session.py`. See `docs/reference/type_contracts.md` for the full field table. |
| 1407 | |
| 1408 | ### Atomicity |
| 1409 | |
| 1410 | `muse session end` writes a temp file (`.tmp-<uuid>.json`) in the same directory, then renames it to `<uuid>.json` before unlinking `current.json`. This guarantees that a crash between write and cleanup never leaves both `current.json` and `<uuid>.json` present simultaneously, which would block future `muse session start` calls. |
| 1411 | |
| 1412 | ### Agent Use Case |
| 1413 | |
| 1414 | An AI composition agent can: |
| 1415 | - Call `muse session start --participants "Claude,Gabriel" --intent "Groove track"` before a generation run. |
| 1416 | - Call `muse session end --notes "Generated 4 variations"` after the run completes. |
| 1417 | - Query `muse session credits` to see which participants have contributed most across the project's history. |
| 1418 | |
| 1419 | --- |
| 1420 | |
| 1421 | ## E2E Demo |
| 1422 | |
| 1423 | Run the full VCS lifecycle test: |
| 1424 | |
| 1425 | ```bash |
| 1426 | docker compose exec maestro pytest tests/e2e/test_muse_e2e_harness.py -v -s |
| 1427 | ``` |
| 1428 | |
| 1429 | Exercises: commit → branch → merge → conflict detection → checkout traversal. |
| 1430 | Produces: ASCII graph, JSON dump, summary table. See `muse_e2e_demo.md` for details. |
| 1431 | |
| 1432 | --- |
| 1433 | |
| 1434 | ## Muse Hub — Remote Backend |
| 1435 | |
| 1436 | 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`. |
| 1437 | |
| 1438 | ### DB Tables |
| 1439 | |
| 1440 | | Table | Purpose | |
| 1441 | |-------|---------| |
| 1442 | | `musehub_repos` | Remote repos (name, visibility, owner, music-semantic metadata) | |
| 1443 | | `musehub_branches` | Branch pointers inside a repo | |
| 1444 | | `musehub_commits` | Commits pushed from CLI clients | |
| 1445 | | `musehub_objects` | Binary artifact metadata (MIDI, MP3, WebP piano rolls) | |
| 1446 | | `musehub_issues` | Issue tracker entries per repo | |
| 1447 | | `musehub_pull_requests` | Pull requests proposing branch merges | |
| 1448 | | `musehub_sessions` | Recording session records pushed from CLI clients | |
| 1449 | | `musehub_releases` | Published version releases with download package URLs | |
| 1450 | | `musehub_stars` | Per-user repo starring (one row per user×repo pair) | |
| 1451 | |
| 1452 | ### Module Map |
| 1453 | |
| 1454 | ``` |
| 1455 | maestro/ |
| 1456 | ├── db/musehub_models.py — SQLAlchemy ORM models |
| 1457 | ├── models/musehub.py — Pydantic v2 request/response models (incl. SearchCommitMatch, SearchResponse) |
| 1458 | ├── services/musehub_repository.py — Async DB queries for repos/branches/commits |
| 1459 | ├── services/musehub_credits.py — Credits aggregation from commit history |
| 1460 | ├── services/musehub_issues.py — Async DB queries for issues (single point of DB access) |
| 1461 | ├── services/musehub_pull_requests.py — Async DB queries for PRs (single point of DB access) |
| 1462 | ├── services/musehub_releases.py — Async DB queries for releases (single point of DB access) |
| 1463 | ├── services/musehub_release_packager.py — Download package URL builder (pure, no DB access) |
| 1464 | ├── services/musehub_search.py — In-repo search service (property / ask / keyword / pattern) |
| 1465 | ├── services/musehub_sync.py — Push/pull sync protocol (ingest_push, compute_pull_delta) |
| 1466 | └── api/routes/musehub/ |
| 1467 | ├── __init__.py — Composes sub-routers under /musehub prefix |
| 1468 | ├── repos.py — Repo/branch/commit/credits route handlers |
| 1469 | ├── issues.py — Issue tracking route handlers |
| 1470 | ├── pull_requests.py — Pull request route handlers |
| 1471 | ├── releases.py — Release management route handlers |
| 1472 | ├── search.py — In-repo search route handler |
| 1473 | ├── sync.py — Push/pull sync route handlers |
| 1474 | ├── objects.py — Artifact list + content-by-object-id endpoints (auth required) |
| 1475 | ├── raw.py — Raw file download by path (public repos: no auth) |
| 1476 | └── ui.py — HTML UI pages (incl. releases, credits and /search pages) |
| 1477 | ├── db/musehub_models.py — SQLAlchemy ORM models (includes MusehubStar) |
| 1478 | ├── models/musehub.py — Pydantic v2 request/response models (includes ExploreRepoResult, ExploreResponse, StarResponse, SearchCommitMatch, SearchResponse) |
| 1479 | ├── services/musehub_repository.py — Async DB queries for repos/branches/commits |
| 1480 | ├── services/musehub_discover.py — Public repo discovery with filters, sorting, star/unstar |
| 1481 | ├── services/musehub_credits.py — Credits aggregation from commit history |
| 1482 | ├── services/musehub_issues.py — Async DB queries for issues (single point of DB access) |
| 1483 | ├── services/musehub_pull_requests.py — Async DB queries for PRs (single point of DB access) |
| 1484 | ├── services/musehub_sessions.py — Async DB queries for sessions (upsert, list, get) |
| 1485 | ├── services/musehub_search.py — In-repo search service (property / ask / keyword / pattern) |
| 1486 | ├── services/musehub_sync.py — Push/pull sync protocol (ingest_push, compute_pull_delta) |
| 1487 | ├── services/musehub_divergence.py — Five-dimension divergence between two remote branches |
| 1488 | └── api/routes/musehub/ |
| 1489 | ├── __init__.py — Composes sub-routers under /musehub prefix (authed) |
| 1490 | ├── repos.py — Repo/branch/commit route handlers |
| 1491 | ├── __init__.py — Composes sub-routers under /musehub prefix |
| 1492 | ├── repos.py — Repo/branch/commit/session route handlers |
| 1493 | ├── repos.py — Repo/branch/commit route handlers + divergence endpoint |
| 1494 | ├── repos.py — Repo/branch/commit/credits route handlers |
| 1495 | ├── issues.py — Issue tracking route handlers |
| 1496 | ├── pull_requests.py — Pull request route handlers |
| 1497 | ├── search.py — In-repo search route handler |
| 1498 | ├── sync.py — Push/pull sync route handlers |
| 1499 | ├── discover.py — Public discover API + authed star/unstar (registered in main.py separately) |
| 1500 | ├── objects.py — Artifact list + content-by-object-id endpoints (auth required) |
| 1501 | ├── raw.py — Raw file download by path (public repos: no auth) |
| 1502 | └── ui.py — Browser UI HTML shell pages (repo, commits, PRs, issues, sessions, search) |
| 1503 | └── ui.py — HTML UI pages (divergence radar chart, search mode tabs) |
| 1504 | └── ui.py — HTML shells for browser: explore, trending, repo, commit, PR, issue pages (incl. /search page with mode tabs) |
| 1505 | ├── negotiate.py — Content negotiation helper (HTML vs JSON from one URL) |
| 1506 | └── ui.py — HTML UI pages (incl. credits and /search pages) |
| 1507 | ``` |
| 1508 | |
| 1509 | ### Content Negotiation — Dual-Format Endpoints |
| 1510 | |
| 1511 | Key MuseHub UI routes implement **content negotiation**: the same URL serves HTML |
| 1512 | to browsers and JSON to agents, decided by the `Accept` header (or `?format=json`). |
| 1513 | |
| 1514 | **Why this exists:** The Stori DAW philosophy is agent-first. An AI agent composing |
| 1515 | music should call `GET /{owner}/{repo_slug}` and receive structured JSON — not |
| 1516 | navigate a parallel `/api/v1/...` endpoint tree that requires separate maintenance. |
| 1517 | |
| 1518 | **Mechanism (`negotiate.py`):** |
| 1519 | |
| 1520 | ```python |
| 1521 | # Decision order (first match wins): |
| 1522 | # 1. ?format=json → JSON (explicit override, works in browser <a> links) |
| 1523 | # 2. Accept: application/json → JSON (standard HTTP content negotiation) |
| 1524 | # 3. default → text/html |
| 1525 | ``` |
| 1526 | |
| 1527 | JSON uses `CamelModel.model_dump(by_alias=True)` — camelCase keys matching the |
| 1528 | `/api/v1/musehub/...` convention. No schema divergence. |
| 1529 | |
| 1530 | **Current dual-format endpoints:** |
| 1531 | |
| 1532 | | URL | JSON response model | |
| 1533 | |-----|---------------------| |
| 1534 | | `GET /musehub/ui/{owner}/{repo_slug}` | `RepoResponse` | |
| 1535 | | `GET /musehub/ui/{owner}/{repo_slug}/commits` | `CommitListResponse` | |
| 1536 | | `GET /musehub/ui/{owner}/{repo_slug}/commits/{commit_id}` | `CommitResponse` | |
| 1537 | |
| 1538 | All other UI endpoints still return HTML only. As new pages are added, adopt |
| 1539 | `negotiate_response()` immediately so agents automatically get JSON support. |
| 1540 | |
| 1541 | ### Endpoints |
| 1542 | |
| 1543 | #### Repos, Branches, Commits, Credits |
| 1544 | |
| 1545 | | Method | Path | Description | |
| 1546 | |--------|------|-------------| |
| 1547 | | POST | `/api/v1/musehub/repos` | Create remote repo | |
| 1548 | | GET | `/api/v1/musehub/repos/{id}` | Get repo metadata | |
| 1549 | | GET | `/api/v1/musehub/repos/{id}/branches` | List branches | |
| 1550 | | GET | `/api/v1/musehub/repos/{id}/commits` | List commits (newest first) | |
| 1551 | | GET | `/api/v1/musehub/repos/{id}/timeline` | Chronological timeline with emotion/section/track layers | |
| 1552 | | GET | `/api/v1/musehub/repos/{id}/divergence` | Five-dimension musical divergence between two branches (`?branch_a=...&branch_b=...`) | |
| 1553 | | GET | `/api/v1/musehub/repos/{id}/credits` | Aggregated contributor credits (`?sort=count\|recency\|alpha`) | |
| 1554 | |
| 1555 | #### Credits Page |
| 1556 | |
| 1557 | `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. |
| 1558 | |
| 1559 | **Sort options:** |
| 1560 | |
| 1561 | | `sort` value | Ordering | |
| 1562 | |---|---| |
| 1563 | | `count` (default) | Most prolific contributor first | |
| 1564 | | `recency` | Most recently active contributor first | |
| 1565 | | `alpha` | Alphabetical by author name | |
| 1566 | |
| 1567 | **Result type:** `CreditsResponse` — fields: `repo_id`, `contributors` (list of `ContributorCredits`), `sort`, `total_contributors`. |
| 1568 | |
| 1569 | **`ContributorCredits` fields:** |
| 1570 | |
| 1571 | | Field | Type | Description | |
| 1572 | |---|---|---| |
| 1573 | | `author` | `str` | Contributor name (from commit `author` field) | |
| 1574 | | `session_count` | `int` | Number of commits attributed to this author | |
| 1575 | | `contribution_types` | `list[str]` | Inferred roles: composer, arranger, producer, performer, mixer, editor, lyricist, sound designer | |
| 1576 | | `first_active` | `datetime` | Timestamp of earliest commit | |
| 1577 | | `last_active` | `datetime` | Timestamp of most recent commit | |
| 1578 | |
| 1579 | **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. |
| 1580 | |
| 1581 | **Machine-readable credits:** The UI page (`GET /musehub/ui/{owner}/{repo_slug}/credits`) injects a `<script type="application/ld+json">` block using schema.org `MusicComposition` vocabulary for embeddable, machine-readable attribution. |
| 1582 | |
| 1583 | **Agent use case:** An AI agent generating release notes or liner notes calls `GET /api/v1/musehub/repos/{id}/credits?sort=count` to enumerate all contributors and their roles, then formats the result as attribution text. The JSON-LD block is ready for schema.org consumers (streaming platforms, metadata aggregators). |
| 1584 | | GET | `/api/v1/musehub/repos/{id}/dag` | Full commit DAG (topologically sorted nodes + edges) | |
| 1585 | | GET | `/api/v1/musehub/repos/{id}/context/{ref}` | Musical context document for a commit (JSON) | |
| 1586 | |
| 1587 | #### DAG Graph Page (UI) |
| 1588 | |
| 1589 | | Method | Path | Description | |
| 1590 | |--------|------|-------------| |
| 1591 | | GET | `/musehub/ui/{id}/graph` | Interactive SVG commit graph (no auth required — HTML shell) | |
| 1592 | |
| 1593 | #### Context Viewer |
| 1594 | |
| 1595 | The context viewer exposes what the AI agent sees when generating music for a given commit. |
| 1596 | |
| 1597 | **API endpoint:** `GET /api/v1/musehub/repos/{repo_id}/context/{ref}` — requires JWT auth. |
| 1598 | Returns a `MuseHubContextResponse` document with: |
| 1599 | - `musical_state` — active tracks derived from stored artifact paths; musical dimensions (key, tempo, etc.) are `null` until Storpheus MIDI analysis is integrated. |
| 1600 | - `history` — up to 5 ancestor commits (newest-first), built by walking `parent_ids`. |
| 1601 | - `missing_elements` — list of dimensions the agent cannot determine from stored data. |
| 1602 | - `suggestions` — composer-facing hints about what to work on next. |
| 1603 | |
| 1604 | **UI page:** `GET /musehub/ui/{owner}/{repo_slug}/context/{ref}` — no auth required (JS shell handles auth). |
| 1605 | Renders the context document in structured HTML with: |
| 1606 | - "What the Agent Sees" explainer at the top |
| 1607 | - Collapsible sections for Musical State, History, Missing Elements, and Suggestions |
| 1608 | - Raw JSON panel with a Copy-to-Clipboard button for pasting context into agent prompts |
| 1609 | - Breadcrumb navigation back to the repo page |
| 1610 | |
| 1611 | **Service:** `maestro/services/musehub_repository.py::get_context_for_commit()` — read-only, deterministic. |
| 1612 | |
| 1613 | **Agent use case:** A musician debugging why the AI generated something unexpected can load the context page for that commit and see exactly what musical knowledge the agent had. The copy button lets them paste the raw JSON into a new agent conversation for direct inspection or override. |
| 1614 | |
| 1615 | #### Listen Page — Full-Mix and Per-Track Audio Playback |
| 1616 | |
| 1617 | The listen page gives musicians a dedicated listening experience without requiring them to export files to a DAW. |
| 1618 | |
| 1619 | | Method | Path | Description | |
| 1620 | |--------|------|-------------| |
| 1621 | | GET | `/musehub/ui/{owner}/{repo_slug}/listen/{ref}` | Full-mix player + per-track listing (HTML; no auth required) | |
| 1622 | | GET | `/musehub/ui/{owner}/{repo_slug}/listen/{ref}/{path}` | Single-stem player focused on one artifact (HTML; no auth required) | |
| 1623 | | GET | `/api/v1/musehub/repos/{repo_id}/listen/{ref}/tracks` | `TrackListingResponse` JSON (optional JWT) | |
| 1624 | | GET | `/musehub/ui/{owner}/{repo_slug}/listen/{ref}?format=json` | Same `TrackListingResponse` via content negotiation | |
| 1625 | |
| 1626 | **Page sections:** |
| 1627 | |
| 1628 | 1. **Full-mix player** — a top-of-page audio player pointing to the first file whose basename contains a mix/master keyword (`mix`, `full`, `master`, `bounce`); falls back to the first audio artifact when no such file exists. |
| 1629 | 2. **Track listing** — every `.mp3`, `.ogg`, `.wav`, `.m4a`, or `.flac` artifact for the repo, sorted by path. Each row shows: instrument name, path, mini waveform visualisation, file size, play button, optional piano-roll link (if a matching `.webp` image exists), and download button. |
| 1630 | 3. **No-renders fallback** — when no audio artifacts exist the page renders a friendly call-to-action with a link to the file-tree browser. |
| 1631 | |
| 1632 | **Content negotiation:** `?format=json` or `Accept: application/json` returns a `TrackListingResponse` with `repoId`, `ref`, `fullMixUrl`, `tracks`, and `hasRenders`. |
| 1633 | |
| 1634 | **Piano-roll matching:** if an `.webp` (or `.png`/`.jpg`) file shares the same basename as an audio file (e.g. `tracks/bass.webp` matches `tracks/bass.mp3`), the listen page links to it as a piano-roll image. |
| 1635 | |
| 1636 | **Audio state management:** the client-side JS keeps a single `<Audio>` instance per track; playing one track pauses all others and the full-mix player, so musicians can solo stems without browser audio conflicts. |
| 1637 | |
| 1638 | **Agent use case:** `GET .../listen/{ref}?format=json` returns a machine-readable track listing with audio URLs, letting AI agents enumerate stems and report on the arrangement without rendering HTML. |
| 1639 | |
| 1640 | **Backend model:** `TrackListingResponse` in `maestro/models/musehub.py`; registered in `docs/reference/type_contracts.md`. |
| 1641 | |
| 1642 | #### Analysis Dashboard |
| 1643 | |
| 1644 | The analysis dashboard provides a single-page overview of all musical dimensions for a given ref. |
| 1645 | |
| 1646 | | Method | Path | Description | |
| 1647 | |--------|------|-------------| |
| 1648 | | GET | `/musehub/ui/{owner}/{repo_slug}/analysis/{ref}` | HTML dashboard with 10 dimension summary cards (no auth required) | |
| 1649 | | GET | `/api/v1/musehub/repos/{repo_id}/analysis/{ref}` | Aggregate analysis JSON with all 13 dimensions (JWT required) | |
| 1650 | |
| 1651 | **Dashboard cards (10 dimensions):** Key, Tempo, Meter, Chord Map, Dynamics, Groove, Emotion, Form, Motifs, Contour. |
| 1652 | |
| 1653 | Each card shows: |
| 1654 | - A headline metric derived from the dimension's stub data (e.g. "C Major", "120 BPM") |
| 1655 | - A sub-text with confidence or range context |
| 1656 | - A mini sparkline bar chart for time-series dimensions (dynamics velocity curve, contour pitch curve) |
| 1657 | - A clickable link to the per-dimension analysis page at `/{owner}/{repo_slug}/analysis/{ref}/{dim_id}` |
| 1658 | |
| 1659 | **Branch/tag selector:** A `<select>` populated by `GET /api/v1/musehub/repos/{repo_id}/branches`. Changing the selected branch navigates to `/{owner}/{repo_slug}/analysis/{branch}`. |
| 1660 | |
| 1661 | **Missing data:** When a dimension has no analysis data, the card displays "Not yet analyzed" gracefully — no errors or empty states break the layout. |
| 1662 | |
| 1663 | **Content negotiation (API):** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}` returns `AggregateAnalysisResponse` JSON with a `dimensions` array. All 13 dimensions are present (including similarity and divergence, which are not shown as cards but are available to agents). |
| 1664 | |
| 1665 | **Auth model:** The HTML page at `/musehub/ui/{owner}/{repo_slug}/analysis/{ref}` is a Jinja2 template shell — no server-side auth required. The embedded JavaScript fetches the API with a JWT from `localStorage`. |
| 1666 | |
| 1667 | **Agent use case:** An AI agent assessing the current musical state of a repo calls `GET /api/v1/musehub/repos/{id}/analysis/{ref}` and reads the `dimensions` array to understand key, tempo, emotion, and harmonic complexity before proposing a new composition direction. |
| 1668 | |
| 1669 | **Files:** |
| 1670 | | Layer | File | |
| 1671 | |-------|------| |
| 1672 | | UI handler | `maestro/api/routes/musehub/ui.py::analysis_dashboard_page()` | |
| 1673 | | UI template | `maestro/templates/musehub/pages/analysis.html` | |
| 1674 | | API handler | `maestro/api/routes/musehub/analysis.py::get_aggregate_analysis()` | |
| 1675 | | Service | `maestro/services/musehub_analysis.py::compute_aggregate_analysis()` | |
| 1676 | | Models | `maestro/models/musehub_analysis.py::AggregateAnalysisResponse` | |
| 1677 | | UI tests | `tests/test_musehub_ui.py` — `test_analysis_dashboard_*`, `test_analysis_aggregate_endpoint` | |
| 1678 | |
| 1679 | #### Issues |
| 1680 | |
| 1681 | | Method | Path | Description | |
| 1682 | |--------|------|-------------| |
| 1683 | | POST | `/api/v1/musehub/repos/{id}/issues` | Open a new issue (`state: open`) | |
| 1684 | | GET | `/api/v1/musehub/repos/{id}/issues` | List issues (`?state=open\|closed\|all`, `?label=<string>`) | |
| 1685 | | GET | `/api/v1/musehub/repos/{id}/issues/{number}` | Get a single issue by per-repo number | |
| 1686 | | POST | `/api/v1/musehub/repos/{id}/issues/{number}/close` | Close an issue | |
| 1687 | |
| 1688 | #### Pull Requests |
| 1689 | |
| 1690 | | Method | Path | Description | |
| 1691 | |--------|------|-------------| |
| 1692 | | POST | `/api/v1/musehub/repos/{id}/pull-requests` | Open a PR proposing to merge `from_branch` into `to_branch` | |
| 1693 | | GET | `/api/v1/musehub/repos/{id}/pull-requests` | List PRs (`?state=open\|merged\|closed\|all`) | |
| 1694 | | GET | `/api/v1/musehub/repos/{id}/pull-requests/{pr_id}` | Get a single PR by ID | |
| 1695 | | POST | `/api/v1/musehub/repos/{id}/pull-requests/{pr_id}/merge` | Merge an open PR | |
| 1696 | |
| 1697 | #### Sessions |
| 1698 | |
| 1699 | | Method | Path | Description | |
| 1700 | |--------|------|-------------| |
| 1701 | | POST | `/api/v1/musehub/repos/{id}/sessions` | Push a session record (upsert — idempotent) | |
| 1702 | | GET | `/api/v1/musehub/repos/{id}/sessions` | List sessions, newest first (`?limit=N`, default 50, max 200) | |
| 1703 | | GET | `/api/v1/musehub/repos/{id}/sessions/{session_id}` | Get a single session by UUID | |
| 1704 | |
| 1705 | #### In-Repo Search |
| 1706 | |
| 1707 | | Method | Path | Description | |
| 1708 | |--------|------|-------------| |
| 1709 | | GET | `/api/v1/musehub/repos/{id}/search` | Search commits by mode (property / ask / keyword / pattern) | |
| 1710 | | GET | `/musehub/ui/{id}/search` | HTML search page with four mode tabs (no auth required) | |
| 1711 | |
| 1712 | **Search query parameters:** |
| 1713 | |
| 1714 | | Param | Type | Description | |
| 1715 | |-------|------|-------------| |
| 1716 | | `mode` | string | `property` \| `ask` \| `keyword` \| `pattern` (default: `keyword`) | |
| 1717 | | `q` | string | Query string — interpreted differently per mode | |
| 1718 | | `harmony` | string | [property mode] Harmony filter (e.g. `key=Eb` or `key=C-Eb` range) | |
| 1719 | | `rhythm` | string | [property mode] Rhythm filter (e.g. `tempo=120-130`) | |
| 1720 | | `melody` | string | [property mode] Melody filter | |
| 1721 | | `structure` | string | [property mode] Structure filter | |
| 1722 | | `dynamic` | string | [property mode] Dynamics filter | |
| 1723 | | `emotion` | string | [property mode] Emotion filter | |
| 1724 | | `since` | ISO datetime | Only include commits on or after this datetime | |
| 1725 | | `until` | ISO datetime | Only include commits on or before this datetime | |
| 1726 | | `limit` | int | Max results (1–200, default 20) | |
| 1727 | |
| 1728 | **Result type:** `SearchResponse` — fields: `mode`, `query`, `matches`, `totalScanned`, `limit`. |
| 1729 | Each match is a `SearchCommitMatch` with: `commitId`, `branch`, `message`, `author`, `timestamp`, `score`, `matchSource`. |
| 1730 | |
| 1731 | **Search modes explained:** |
| 1732 | |
| 1733 | - **`property`** — delegates to `muse_find.search_commits()`. All non-empty property filters are ANDed. Accepts `key=low-high` range syntax for numeric properties. Score is always 1.0 (exact filter match). `matchSource = "property"`. |
| 1734 | |
| 1735 | - **`ask`** — natural-language query. Stop-words are stripped from `q`; remaining tokens are scored against each commit message using the overlap coefficient (`|Q ∩ M| / |Q|`). Commits with zero overlap are excluded. This is a keyword-matching stub; LLM-powered answer generation is a planned enhancement. |
| 1736 | |
| 1737 | - **`keyword`** — raw keyword overlap scoring. Tokenises `q` and each commit message; scores by `|Q ∩ M| / |Q|`. Commits with zero overlap excluded. Useful for precise term search. |
| 1738 | |
| 1739 | - **`pattern`** — case-insensitive substring match of `q` against commit messages (primary) and branch names (secondary). Score is always 1.0. `matchSource` is `"message"` or `"branch"`. |
| 1740 | |
| 1741 | **Agent use case:** AI music composition agents can call the search endpoint to locate commits by musical property (e.g. find all commits with `harmony=Fm`) before applying `muse checkout`, `muse diff`, or `muse replay` to reconstruct or compare those versions. |
| 1742 | |
| 1743 | #### Releases |
| 1744 | |
| 1745 | | Method | Path | Description | |
| 1746 | |--------|------|-------------| |
| 1747 | | POST | `/api/v1/musehub/repos/{id}/releases` | Create a release tied to a tag and optional commit | |
| 1748 | | GET | `/api/v1/musehub/repos/{id}/releases` | List all releases (newest first) | |
| 1749 | | GET | `/api/v1/musehub/repos/{id}/releases/{tag}` | Get a single release by tag (e.g. `v1.0`) | |
| 1750 | |
| 1751 | #### Sync Protocol |
| 1752 | |
| 1753 | | Method | Path | Description | |
| 1754 | |--------|------|-------------| |
| 1755 | | POST | `/api/v1/musehub/repos/{id}/push` | Upload commits and objects (fast-forward enforced) | |
| 1756 | | POST | `/api/v1/musehub/repos/{id}/pull` | Fetch missing commits and objects | |
| 1757 | |
| 1758 | #### Explore / Discover (public — no auth required for browse) |
| 1759 | |
| 1760 | | Method | Path | Description | |
| 1761 | |--------|------|-------------| |
| 1762 | | GET | `/api/v1/musehub/discover/repos` | List public repos with optional filters and sort | |
| 1763 | | POST | `/api/v1/musehub/repos/{id}/star` | Star a public repo (idempotent add, auth required) | |
| 1764 | | DELETE | `/api/v1/musehub/repos/{id}/star` | Unstar a repo (idempotent remove, auth required) | |
| 1765 | | GET | `/api/v1/musehub/repos/{id}/stargazers` | List users who starred the repo (public repos unauthenticated) | |
| 1766 | | POST | `/api/v1/musehub/repos/{id}/fork` | Fork a public repo under the caller's account (auth required) | |
| 1767 | | GET | `/api/v1/musehub/repos/{id}/forks` | List all forks of this repo | |
| 1768 | |
| 1769 | **Filter parameters for `GET /discover/repos`:** |
| 1770 | |
| 1771 | | Parameter | Type | Description | |
| 1772 | |-----------|------|-------------| |
| 1773 | | `genre` | string | Substring match on tags (e.g. `jazz`, `lo-fi`) | |
| 1774 | | `key` | string | Exact match on `key_signature` (e.g. `F# minor`) | |
| 1775 | | `tempo_min` | int | Minimum BPM (inclusive) | |
| 1776 | | `tempo_max` | int | Maximum BPM (inclusive) | |
| 1777 | | `instrumentation` | string | Substring match on tags for instrument presence | |
| 1778 | | `sort` | string | `stars` \| `activity` \| `commits` \| `created` (default) | |
| 1779 | | `page` | int | 1-based page number | |
| 1780 | | `page_size` | int | Results per page (default 24, max 100) | |
| 1781 | |
| 1782 | **Result type:** `ExploreResponse` — fields: `repos: list[ExploreRepoResult]`, `total: int`, `page: int`, `page_size: int` |
| 1783 | |
| 1784 | **ExploreRepoResult fields:** `repo_id`, `name`, `owner_user_id`, `description`, `tags`, `key_signature`, `tempo_bpm`, `star_count`, `commit_count`, `created_at` |
| 1785 | |
| 1786 | **UI pages (no auth required):** |
| 1787 | |
| 1788 | | Path | Description | |
| 1789 | |------|-------------| |
| 1790 | | `GET /musehub/ui/explore` | Filterable grid of all public repos (newest first) | |
| 1791 | | `GET /musehub/ui/trending` | Public repos sorted by star count | |
| 1792 | |
| 1793 | #### Raw File Download |
| 1794 | |
| 1795 | | Method | Path | Description | |
| 1796 | |--------|------|-------------| |
| 1797 | | GET | `/api/v1/musehub/repos/{id}/raw/{ref}/{path}` | Download file by path with correct MIME type | |
| 1798 | |
| 1799 | The raw endpoint is designed for `curl`, `wget`, and scripted pipelines. It |
| 1800 | serves files with `Accept-Ranges: bytes` so audio clients can perform range |
| 1801 | requests for partial playback. |
| 1802 | |
| 1803 | **Auth:** No token required for **public** repos. Private repos return 401 |
| 1804 | without a valid Bearer token. |
| 1805 | |
| 1806 | ```bash |
| 1807 | # Public repo — no auth needed |
| 1808 | curl https://musehub.stori.com/api/v1/musehub/repos/<repo_id>/raw/main/tracks/bass.mid \ |
| 1809 | -o bass.mid |
| 1810 | |
| 1811 | # Private repo — Bearer token required |
| 1812 | curl -H "Authorization: Bearer <token>" \ |
| 1813 | https://musehub.stori.com/api/v1/musehub/repos/<repo_id>/raw/main/mix/final.mp3 \ |
| 1814 | -o final.mp3 |
| 1815 | ``` |
| 1816 | |
| 1817 | See [api.md](../reference/api.md#get-apiv1musehub-reposrepo_idrawrefpath) for the |
| 1818 | full MIME type table and error reference. |
| 1819 | |
| 1820 | All authed endpoints require `Authorization: Bearer <token>`. See [api.md](../reference/api.md#muse-hub-api) for full field docs. |
| 1821 | |
| 1822 | #### Web UI (no auth required) |
| 1823 | |
| 1824 | | Method | Path | Description | |
| 1825 | |--------|------|-------------| |
| 1826 | | GET | `/musehub/ui/{owner}/{repo_slug}` | Repo home page (arrangement matrix, audio player, stats bar, recent commits) | |
| 1827 | | GET | `/musehub/ui/{owner}/{repo_slug}/commits/{commit_id}` | Commit detail (metadata + artifact browser) | |
| 1828 | | GET | `/musehub/ui/{owner}/{repo_slug}/pulls` | Pull request list | |
| 1829 | | GET | `/musehub/ui/{owner}/{repo_slug}/pulls/{pr_id}` | PR detail (with merge button) | |
| 1830 | | GET | `/musehub/ui/{owner}/{repo_slug}/issues` | Issue list | |
| 1831 | | GET | `/musehub/ui/{owner}/{repo_slug}/issues/{number}` | Issue detail (with close button) | |
| 1832 | | GET | `/musehub/ui/{owner}/{repo_slug}/branches` | Branch list with ahead/behind counts and divergence scores | |
| 1833 | | GET | `/musehub/ui/{owner}/{repo_slug}/tags` | Tag browser — releases grouped by namespace prefix | |
| 1834 | | GET | `/musehub/ui/{owner}/{repo_slug}/sessions` | Session list (newest first) | |
| 1835 | | GET | `/musehub/ui/{owner}/{repo_slug}/sessions/{session_id}` | Session detail page | |
| 1836 | | GET | `/musehub/ui/{owner}/{repo_slug}/arrange/{ref}` | Arrangement matrix — interactive instrument × section density grid | |
| 1837 | |
| 1838 | UI pages are Jinja2-rendered HTML shells — auth is handled client-side via `localStorage` JWT (loaded from `/musehub/static/musehub.js`). The page JavaScript fetches from the authed JSON API above. |
| 1839 | |
| 1840 | ### Branch List Page |
| 1841 | |
| 1842 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/branches` |
| 1843 | |
| 1844 | Lists all branches with enriched context to help musicians decide which branches to merge or discard: |
| 1845 | |
| 1846 | - **Default branch badge** — the repo default branch (name "main", or first alphabetically) is marked with a `default` badge. |
| 1847 | - **Ahead/behind counts** — number of commits unique to this branch (ahead) vs commits on the default not yet here (behind). Computed as a commit-ID set difference over the `musehub_commits` table. |
| 1848 | - **Musical divergence scores** — five dimensions (melodic, harmonic, rhythmic, structural, dynamic) each in [0, 1]. Shown as mini bar charts. All `null` (placeholder) until audio snapshots are attached to commits and server-side computation is implemented. |
| 1849 | - **Compare link** — navigates to `/{owner}/{repo_slug}/compare/{default}...{branch}`. |
| 1850 | - **New Pull Request button** — navigates to `/{owner}/{repo_slug}/pulls/new?head={branch}`. |
| 1851 | |
| 1852 | Content negotiation: `?format=json` or `Accept: application/json` returns `BranchDetailListResponse` (camelCase). |
| 1853 | |
| 1854 | ### Tag Browser Page |
| 1855 | |
| 1856 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/tags` |
| 1857 | |
| 1858 | Displays all releases as browseable tags, grouped by namespace prefix: |
| 1859 | |
| 1860 | - Tags are sourced from `musehub_releases`. The `tag` field of a release is used as the tag name. |
| 1861 | - **Namespace extraction** — `emotion:happy` → namespace `emotion`; `v1.0` → namespace `version` (no colon). |
| 1862 | - **Namespace filter dropdown** — client-side filter that re-renders without a round trip. |
| 1863 | - **Per-tag info** — tag name, commit SHA (links to commit detail), release title, creation date. |
| 1864 | - **View commit** link per tag. |
| 1865 | |
| 1866 | Content negotiation: `?format=json` or `Accept: application/json` returns `TagListResponse` (camelCase) including a `namespaces` list. Optional `?namespace=<ns>` query parameter filters by namespace server-side. |
| 1867 | |
| 1868 | ### Repo Home Page |
| 1869 | |
| 1870 | **Purpose:** Provide an "album cover" view of a Muse Hub repo — hearing the latest mix, seeing the arrangement structure, and understanding project activity at a glance. Replaces the plain commit-list landing page with a rich dashboard suited for musicians, collaborators, and AI agents. |
| 1871 | |
| 1872 | **Routes:** |
| 1873 | |
| 1874 | | Route | Auth | Description | |
| 1875 | |-------|------|-------------| |
| 1876 | | `GET /musehub/ui/{owner}/{repo_slug}` | None (HTML shell) | Repo home page — arrangement matrix, audio player, stats bar, recent commits | |
| 1877 | | `GET /musehub/ui/{owner}/{repo_slug}` (`Accept: application/json`) | Optional JWT | Returns `{ stats, recent_commits }` as JSON | |
| 1878 | | `GET /api/v1/musehub/repos/{repo_id}/stats` | Optional JWT | `RepoStatsResponse` — commit/branch/release counts | |
| 1879 | | `GET /api/v1/musehub/repos/{repo_id}/arrange/{ref}` | Optional JWT | `ArrangementMatrixResponse` — instrument × section density grid | |
| 1880 | |
| 1881 | **Sections rendered by the home page template (`repo_home.html`):** |
| 1882 | |
| 1883 | 1. **Hero** — repo name, owner link, visibility badge (Public/Private), description, key/BPM pills, and genre tags. |
| 1884 | 2. **Stats bar** — three clickable pills showing: commit count (links to commit history), branch count (links to DAG graph), release count (links to releases). |
| 1885 | 3. **Quick-link tabs** — Code, Commits, Graph, PRs, Issues, Analysis, Sessions — one-click navigation to all repo sub-pages. |
| 1886 | 4. **Audio player** — embeds an `<audio controls>` element pointing to the latest MP3/OGG/WAV object from the most recent commit. Shows a placeholder when no audio render is found. |
| 1887 | 5. **Arrangement matrix** — a colour-coded grid showing tracks (columns) × sections (rows) from the latest commit snapshot. Falls back to a placeholder when the repo has no commits. |
| 1888 | 6. **Recent commits** — last 5 commits with SHA link, conventional-commit type/scope badges, author avatar, and relative timestamp. |
| 1889 | 7. **README** — raw text of `README.md` from HEAD rendered in a `<pre>` block; hidden when no README exists. |
| 1890 | |
| 1891 | **Content negotiation:** |
| 1892 | |
| 1893 | Sending `Accept: application/json` returns: |
| 1894 | |
| 1895 | ```json |
| 1896 | { |
| 1897 | "stats": { |
| 1898 | "commit_count": 42, |
| 1899 | "branch_count": 3, |
| 1900 | "release_count": 2 |
| 1901 | }, |
| 1902 | "recent_commits": [ |
| 1903 | { |
| 1904 | "commit_id": "abc123...", |
| 1905 | "branch": "main", |
| 1906 | "message": "feat(drums): add hi-hat pattern", |
| 1907 | "author": "gabriel", |
| 1908 | "timestamp": "2025-01-15T12:00:00+00:00" |
| 1909 | } |
| 1910 | ] |
| 1911 | } |
| 1912 | ``` |
| 1913 | |
| 1914 | This allows AI agents and CLI tools to query repo activity without rendering HTML. |
| 1915 | |
| 1916 | **Stats endpoint:** `GET /api/v1/musehub/repos/{repo_id}/stats` |
| 1917 | |
| 1918 | Returns a `RepoStatsResponse` with: |
| 1919 | - `commit_count` — total commits across all branches (from `list_commits` total) |
| 1920 | - `branch_count` — number of branch pointers (from `list_branches`) |
| 1921 | - `release_count` — number of published releases/tags (from `list_releases`) |
| 1922 | |
| 1923 | All counts are 0 when the repo is empty. Respects visibility: private repos return 401 for unauthenticated callers. |
| 1924 | |
| 1925 | ### DAG Graph — Interactive Commit Graph |
| 1926 | |
| 1927 | **Purpose:** Visualise the full commit history of a Muse Hub repo as an interactive directed acyclic graph, equivalent to `muse inspect --format mermaid` but explorable in the browser. |
| 1928 | |
| 1929 | **Routes:** |
| 1930 | |
| 1931 | | Route | Auth | Description | |
| 1932 | |-------|------|-------------| |
| 1933 | | `GET /api/v1/musehub/repos/{id}/dag` | JWT required | Returns `DagGraphResponse` JSON | |
| 1934 | | `GET /musehub/ui/{id}/graph` | None (HTML shell) | Interactive SVG graph page | |
| 1935 | |
| 1936 | **DAG data endpoint:** `GET /api/v1/musehub/repos/{id}/dag` |
| 1937 | |
| 1938 | Returns a `DagGraphResponse` with: |
| 1939 | - `nodes` — `DagNode[]` in topological order (oldest ancestor first, Kahn's algorithm) |
| 1940 | - `edges` — `DagEdge[]` where `source` = child commit, `target` = parent commit |
| 1941 | - `headCommitId` — SHA of the current HEAD (highest-timestamp branch head) |
| 1942 | |
| 1943 | Each `DagNode` carries: `commitId`, `message`, `author`, `timestamp`, `branch`, `parentIds`, `isHead`, `branchLabels`, `tagLabels`. |
| 1944 | |
| 1945 | **Client-side renderer features:** |
| 1946 | - Branch colour-coding: each unique branch name maps to a stable colour via a deterministic hash → palette index. Supports up to 10 distinct colours before wrapping. |
| 1947 | - Merge commits: nodes with `parentIds.length > 1` are rendered as rotated diamonds rather than circles. |
| 1948 | - HEAD node: an outer ring (orange `#f0883e`) marks the current HEAD commit. |
| 1949 | - Zoom: mouse-wheel scales the SVG transform around the cursor position (range 0.2× – 4×). |
| 1950 | - Pan: click-drag translates the SVG viewport. |
| 1951 | - Hover popover: shows full SHA, commit message, author, timestamp, and branch for any node. |
| 1952 | - Branch labels: `branchLabels` for each node are drawn as coloured badge overlays on the graph. |
| 1953 | - Click to navigate: clicking any node or its label navigates to the commit detail page. |
| 1954 | - Virtualised rendering: the SVG is positioned absolutely inside a fixed-height viewport; only the visible portion is painted by the browser. |
| 1955 | |
| 1956 | **Legend:** The top bar of the graph page shows each distinct branch name with its colour swatch, plus shape-key reminders for merge commits (♦) and HEAD (○). |
| 1957 | |
| 1958 | **Performance:** The `/dag` endpoint fetches all commits without a limit to build a complete graph. For repos with 100+ commits the response is typically < 50 KB (well within browser tolerance). The SVG renderer does not re-layout on scroll — panning is a pure CSS transform. |
| 1959 | |
| 1960 | **Result type:** `DagGraphResponse` — fields: `nodes: DagNode[]`, `edges: DagEdge[]`, `headCommitId: str | None`. |
| 1961 | |
| 1962 | **Agent use case:** An AI music generation agent can call `GET /dag` to reason about branching topology, find common ancestors between branches, determine which commits are reachable from HEAD, and identify merge points without scanning the linear commit list. |
| 1963 | |
| 1964 | ### Issue Workflow |
| 1965 | |
| 1966 | Issues let musicians track production problems and creative tasks within a repo, keeping feedback close to the music data rather than in out-of-band chat. |
| 1967 | |
| 1968 | - **Issue numbers** are sequential per repo (1, 2, 3…) and independent across repos. |
| 1969 | - **Labels** are free-form strings — e.g. `bug`, `musical`, `timing`, `mix`. No validation at MVP. |
| 1970 | - **States:** `open` (default on creation) → `closed` (via the close endpoint). No re-open at MVP. |
| 1971 | - **Filtering:** `GET /issues?state=all` includes both open and closed; `?label=bug` narrows by label. |
| 1972 | |
| 1973 | ### Timeline — Chronological Evolution View |
| 1974 | |
| 1975 | The timeline view lets musicians (and AI agents) see how a project evolved over time, with four independently toggleable layers: |
| 1976 | ### Release System |
| 1977 | |
| 1978 | Releases publish a specific version of a composition as a named snapshot that listeners and collaborators can download in multiple formats. |
| 1979 | |
| 1980 | #### Concept |
| 1981 | |
| 1982 | A release binds a human-readable **tag** (e.g. `v1.0`) to: |
| 1983 | - A **title** — the name of this version (e.g. "First Release") |
| 1984 | - **Release notes** — Markdown body describing what changed |
| 1985 | - An optional **commit ID** — pins the release to a specific commit snapshot |
| 1986 | - **Download URLs** — structured map of package download links |
| 1987 | |
| 1988 | #### Download Packages |
| 1989 | |
| 1990 | Each release exposes download URLs for these package types: |
| 1991 | |
| 1992 | | Package | Field | Format | Description | |
| 1993 | |---------|-------|--------|-------------| |
| 1994 | | Full MIDI | `midiBundle` | `.mid` | All tracks merged into a single MIDI file | |
| 1995 | | Stems | `stems` | `.zip` of `.mid` files | Individual per-track MIDI stems | |
| 1996 | | MP3 | `mp3` | `.mp3` | Full mix audio render | |
| 1997 | | MusicXML | `musicxml` | `.xml` | Notation export for sheet music editors | |
| 1998 | | Metadata | `metadata` | `.json` | Tempo, key, time signature, arrangement info | |
| 1999 | |
| 2000 | Package URLs are `null` when the corresponding artifact is unavailable (no pinned commit, |
| 2001 | or no stored objects for that commit). The frontend renders "Not available" cards instead |
| 2002 | of broken links. |
| 2003 | |
| 2004 | #### Uniqueness Constraint |
| 2005 | |
| 2006 | Tags are unique per repo — attempting to create a second `v1.0` release in the same repo |
| 2007 | returns `409 Conflict`. The same tag can be reused across different repos without conflict. |
| 2008 | |
| 2009 | #### Latest Release Badge |
| 2010 | |
| 2011 | The repo home page (`GET /musehub/ui/{owner}/{repo_slug}`) fetches the release list on load and |
| 2012 | displays a green "Latest: v1.0" badge in the navigation bar when at least one release exists. |
| 2013 | Clicking the badge navigates to the release detail page. |
| 2014 | |
| 2015 | #### Package Generation (MVP Stub) |
| 2016 | |
| 2017 | At MVP, download URLs are deterministic paths based on `repo_id` and `release_id`. The actual |
| 2018 | package generation (MIDI export, MP3 rendering, MusicXML conversion) is not implemented — the |
| 2019 | URL shape is established so the contract is stable for future implementation without an API change. |
| 2020 | |
| 2021 | The packager module (`maestro/services/musehub_release_packager.py`) controls URL construction. |
| 2022 | Callers pass boolean flags (`has_midi`, `has_stems`, `has_mp3`, `has_musicxml`) based on what |
| 2023 | stored objects are available for the pinned commit. |
| 2024 | ### Divergence Visualization |
| 2025 | |
| 2026 | The divergence endpoint and UI let producers compare two branches across five musical dimensions before deciding what to merge. |
| 2027 | |
| 2028 | #### API Endpoint |
| 2029 | |
| 2030 | ``` |
| 2031 | GET /api/v1/musehub/repos/{repo_id}/timeline?limit=200 |
| 2032 | Authorization: Bearer <token> |
| 2033 | ``` |
| 2034 | |
| 2035 | **Response shape (`TimelineResponse`):** |
| 2036 | |
| 2037 | ```json |
| 2038 | { |
| 2039 | "commits": [ |
| 2040 | { |
| 2041 | "eventType": "commit", |
| 2042 | "commitId": "deadbeef...", |
| 2043 | "branch": "main", |
| 2044 | "message": "added chorus", |
| 2045 | "author": "musician", |
| 2046 | "timestamp": "2026-02-01T12:00:00Z", |
| 2047 | "parentIds": ["..."] |
| 2048 | } |
| 2049 | ], |
| 2050 | "emotion": [ |
| 2051 | { |
| 2052 | "eventType": "emotion", |
| 2053 | "commitId": "deadbeef...", |
| 2054 | "timestamp": "2026-02-01T12:00:00Z", |
| 2055 | "valence": 0.8711, |
| 2056 | "energy": 0.3455, |
| 2057 | "tension": 0.2190 |
| 2058 | } |
| 2059 | ], |
| 2060 | "sections": [ |
| 2061 | { |
| 2062 | "eventType": "section", |
| 2063 | "commitId": "deadbeef...", |
| 2064 | "timestamp": "2026-02-01T12:00:00Z", |
| 2065 | "sectionName": "chorus", |
| 2066 | "action": "added" |
| 2067 | } |
| 2068 | ], |
| 2069 | "tracks": [ |
| 2070 | { |
| 2071 | "eventType": "track", |
| 2072 | "commitId": "deadbeef...", |
| 2073 | "timestamp": "2026-02-01T12:00:00Z", |
| 2074 | "trackName": "bass", |
| 2075 | "action": "added" |
| 2076 | } |
| 2077 | ], |
| 2078 | "totalCommits": 42 |
| 2079 | } |
| 2080 | ``` |
| 2081 | |
| 2082 | **Layer descriptions:** |
| 2083 | |
| 2084 | | Layer | Source | Description | |
| 2085 | |-------|--------|-------------| |
| 2086 | | `commits` | DB: `musehub_commits` | Every pushed commit — always present. Oldest-first for temporal rendering. | |
| 2087 | | `emotion` | Derived from commit SHA | Deterministic valence/energy/tension in [0,1] — reproducible without ML inference. | |
| 2088 | | `sections` | Commit message heuristics | Keywords: intro, verse, chorus, bridge, outro, hook, etc. Action inferred from verb (added/removed). | |
| 2089 | | `tracks` | Commit message heuristics | Keywords: bass, drums, keys, guitar, synth, etc. Action inferred from verb (added/removed). | |
| 2090 | |
| 2091 | **Emotion derivation:** Three non-overlapping 4-hex-character windows of the commit SHA are converted to floats in [0,1]. This is deterministic, fast, and requires no model — sufficient for visualisation. Future versions may substitute ML-derived vectors without changing the API shape. |
| 2092 | |
| 2093 | **Section/track heuristics:** Verb patterns (`add`, `remove`, `delete`, `create`, etc.) in the commit message determine `action`. No NLP is required — keyword scanning is fast and sufficient for commit message conventions used by `muse commit`. |
| 2094 | |
| 2095 | #### Web UI Page |
| 2096 | |
| 2097 | ``` |
| 2098 | GET /musehub/ui/{owner}/{repo_slug}/timeline |
| 2099 | ``` |
| 2100 | |
| 2101 | No auth required — HTML shell whose JS fetches the JSON API using the JWT from `localStorage`. |
| 2102 | |
| 2103 | **Features:** |
| 2104 | - Horizontal SVG canvas with commit markers on a time spine |
| 2105 | - Emotion line chart (valence = blue, energy = green, tension = red) overlaid above the spine |
| 2106 | - Section-change markers (green = added, red = removed) below the spine |
| 2107 | - Track add/remove markers (purple = added, yellow = removed) at the bottom |
| 2108 | - Toggleable layers via checkboxes in the toolbar |
| 2109 | - Zoom controls: Day / Week / Month / All-time |
| 2110 | - Time scrubber to navigate through history |
| 2111 | - Click any commit marker to open an audio preview modal |
| 2112 | |
| 2113 | **Agent use case:** An AI agent calls `GET /api/v1/musehub/repos/{id}/timeline --json` to understand the creative arc of a project before generating new material — identifying when the emotional character shifted, when sections were introduced, and which instruments were layered in. The deterministic emotion vectors give agents a structured signal without requiring audio analysis. |
| 2114 | |
| 2115 | **Result types:** `TimelineResponse`, `TimelineCommitEvent`, `TimelineEmotionEvent`, `TimelineSectionEvent`, `TimelineTrackEvent` — see `docs/reference/type_contracts.md § Muse Hub Timeline Types`. |
| 2116 | |
| 2117 | --- |
| 2118 | GET /api/v1/musehub/repos/{repo_id}/divergence?branch_a=<name>&branch_b=<name> |
| 2119 | ``` |
| 2120 | |
| 2121 | Returns `DivergenceResponse` (JSON): |
| 2122 | |
| 2123 | | Field | Type | Description | |
| 2124 | |-------|------|-------------| |
| 2125 | | `repoId` | `str` | Repository ID | |
| 2126 | | `branchA` | `str` | First branch name | |
| 2127 | | `branchB` | `str` | Second branch name | |
| 2128 | | `commonAncestor` | `str \| null` | Merge-base commit ID | |
| 2129 | | `dimensions` | `list[DivergenceDimensionResponse]` | Five dimension scores | |
| 2130 | | `overallScore` | `float` | Mean of dimension scores in [0.0, 1.0] | |
| 2131 | |
| 2132 | Each `DivergenceDimensionResponse`: |
| 2133 | |
| 2134 | | Field | Type | Description | |
| 2135 | |-------|------|-------------| |
| 2136 | | `dimension` | `str` | `melodic` / `harmonic` / `rhythmic` / `structural` / `dynamic` | |
| 2137 | | `level` | `str` | `NONE` / `LOW` / `MED` / `HIGH` | |
| 2138 | | `score` | `float` | Jaccard divergence in [0.0, 1.0] | |
| 2139 | | `description` | `str` | Human-readable summary | |
| 2140 | | `branchACommits` | `int` | Commits touching this dimension on branch A | |
| 2141 | | `branchBCommits` | `int` | Commits touching this dimension on branch B | |
| 2142 | |
| 2143 | **Level thresholds:** |
| 2144 | |
| 2145 | | Level | Score range | |
| 2146 | |-------|-------------| |
| 2147 | | NONE | < 0.15 | |
| 2148 | | LOW | 0.15–0.40 | |
| 2149 | | MED | 0.40–0.70 | |
| 2150 | | HIGH | ≥ 0.70 | |
| 2151 | |
| 2152 | #### Score Formula |
| 2153 | |
| 2154 | Divergence per dimension = `|symmetric_diff| / |union|` over commit IDs classified into that dimension via keyword matching on commit messages: |
| 2155 | |
| 2156 | - **melodic:** melody, lead, solo, vocal, tune, note, pitch, riff, arpeggio |
| 2157 | - **harmonic:** chord, harmony, key, scale, progression, voicing |
| 2158 | - **rhythmic:** beat, drum, rhythm, groove, perc, swing, tempo, bpm, quantize |
| 2159 | - **structural:** struct, form, section, bridge, chorus, verse, intro, outro, arrangement |
| 2160 | - **dynamic:** mix, master, volume, level, dynamic, eq, compress, reverb, fx |
| 2161 | |
| 2162 | Overall score = arithmetic mean of all five dimension scores. |
| 2163 | |
| 2164 | #### Browser UI |
| 2165 | |
| 2166 | ``` |
| 2167 | GET /musehub/ui/{owner}/{repo_slug}/divergence?branch_a=<name>&branch_b=<name> |
| 2168 | ``` |
| 2169 | |
| 2170 | Renders an interactive page featuring: |
| 2171 | - Five-axis SVG radar chart with colour-coded dimension labels (NONE=blue, LOW=teal, MED=amber, HIGH=red) |
| 2172 | - Overall divergence percentage display with merge-base commit reference |
| 2173 | - Per-dimension progress bars + level badges |
| 2174 | - Click-to-expand detail panels showing commit counts per branch |
| 2175 | - Branch selector dropdowns with URL state sync |
| 2176 | |
| 2177 | **AI agent use case:** Call `GET /divergence` before opening a PR to determine if two branches are safe to merge automatically (overall score < 0.15) or need producer review (HIGH on any dimension). |
| 2178 | |
| 2179 | ### Pull Request Workflow |
| 2180 | |
| 2181 | Pull requests let musicians propose merging one branch variation into another, enabling async review before incorporating changes into the canonical arrangement. |
| 2182 | |
| 2183 | - **States:** `open` (on creation) → `merged` (via merge endpoint) | `closed` (future: manual close). |
| 2184 | - **Merge strategies:** `merge_commit` (default), `squash`, and `rebase` are accepted by the API. All three currently use merge-commit semantics — distinct strategy behavior is tracked as a follow-up. |
| 2185 | - **Validation:** `from_branch == to_branch` → 422. Missing `from_branch` → 404. Already merged/closed → 409 on merge attempt. |
| 2186 | - **Filtering:** `GET /pull-requests?state=open` returns only open PRs. Default (`state=all`) returns all states. |
| 2187 | |
| 2188 | #### PR Detail Page — Musical Diff (issue #215) |
| 2189 | |
| 2190 | The PR detail page (`GET /musehub/ui/{owner}/{repo_slug}/pulls/{pr_id}`) now shows a full musical diff UI on top of the existing metadata and merge button: |
| 2191 | |
| 2192 | | Component | Description | |
| 2193 | |-----------|-------------| |
| 2194 | | **Five-axis radar chart** | Visual divergence across harmonic, rhythmic, melodic, structural, dynamic dimensions — each axis shows delta magnitude in [0.0, 1.0] | |
| 2195 | | **Diff summary badges** | Per-dimension delta labels (e.g. `+23.5%`, `unchanged`) computed from the Jaccard divergence score | |
| 2196 | | **Before/after piano roll** | Deterministic note-grid comparison seeded from branch names — green = added, red = removed, grey = unchanged | |
| 2197 | | **Audio A/B toggle** | Switch between base (`to_branch`) and head (`from_branch`) audio renders; gracefully degrades if renders not available | |
| 2198 | | **Merge strategy selector** | Choose `merge_commit`, `squash`, or `rebase` before clicking merge | |
| 2199 | | **PR timeline** | Open → (review) → merge/close state progression with timestamps | |
| 2200 | | **Affected sections** | Commit-message-derived list of structural sections touched by the PR (bridge, chorus, verse, etc.) | |
| 2201 | |
| 2202 | **Musical diff endpoint:** `GET /api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/diff` |
| 2203 | |
| 2204 | Returns `PRDiffResponse` — a `PRDiffDimensionScore` for each of the five musical dimensions plus `overall_score`, `common_ancestor`, and `affected_sections`. |
| 2205 | |
| 2206 | `affected_sections` is derived by scanning commit messages from both branches since the merge base for structural section keywords (bridge, chorus, verse, intro, outro, section) using a word-boundary regex. Only sections actually mentioned in commit text are returned; an empty list is correct when no commit references a section name. The field is **never** derived from divergence scores alone. |
| 2207 | |
| 2208 | **Content negotiation:** `GET /musehub/ui/{owner}/{repo_slug}/pulls/{pr_id}?format=json` returns the full `PRDiffResponse` for AI agent consumption. Agents use this to reason about musical impact before approving a merge — e.g. a large harmonic delta with unchanged rhythm signals a chord progression update that preserves the groove. |
| 2209 | |
| 2210 | **Graceful degradation:** When one or both branches have no commits (divergence engine raises `ValueError`), the endpoint returns five zero-score placeholder dimensions so the page always renders cleanly. |
| 2211 | |
| 2212 | **Divergence builder service:** Both the pull-requests route and the UI route delegate PRDiffResponse assembly to `maestro.services.musehub_divergence.build_pr_diff_response` (success path) and `build_zero_diff_response` (no-commit fallback). Route handlers remain thin callers with no duplicated mapping logic. |
| 2213 | |
| 2214 | ### Sync Protocol Design |
| 2215 | |
| 2216 | The push/pull protocol is intentionally simple for MVP: |
| 2217 | |
| 2218 | #### Push — fast-forward enforcement |
| 2219 | |
| 2220 | A push is accepted when one of the following is true: |
| 2221 | 1. The branch has no head yet (first push). |
| 2222 | 2. `headCommitId` equals the current remote head (no-op). |
| 2223 | 3. The current remote head appears in the ancestry graph of the pushed commits — i.e. the client built on top of the remote head. |
| 2224 | |
| 2225 | When none of these conditions hold the push is **rejected with HTTP 409** and body `{"error": "non_fast_forward"}`. Set `force: true` in the request to overwrite the remote head regardless (equivalent to `git push --force`). |
| 2226 | |
| 2227 | Commits and objects are **upserted by ID** — re-pushing the same content is safe and idempotent. |
| 2228 | |
| 2229 | #### Pull — exclusion-list delta |
| 2230 | |
| 2231 | The client sends `haveCommits` and `haveObjects` as exclusion lists. The Hub returns all commits for the requested branch and all objects for the repo that are NOT in those lists. No ancestry traversal is performed — the client receives the full delta in one response. |
| 2232 | |
| 2233 | **MVP limitation:** Large objects (> 1 MB) are base64-encoded inline. Pre-signed URL upload is planned as a follow-up. |
| 2234 | |
| 2235 | #### Object storage |
| 2236 | |
| 2237 | Binary artifact bytes are written to disk at: |
| 2238 | |
| 2239 | ``` |
| 2240 | <settings.musehub_objects_dir>/<repo_id>/<object_id_with_colon_replaced_by_dash> |
| 2241 | ``` |
| 2242 | |
| 2243 | Default: `/data/musehub/objects`. Mount this path on a persistent volume in production. |
| 2244 | |
| 2245 | Only metadata (`object_id`, `path`, `size_bytes`, `disk_path`) is stored in Postgres; the bytes live on disk. |
| 2246 | |
| 2247 | ### Session Workflow |
| 2248 | |
| 2249 | Recording sessions let musicians capture the creative context of a generation or performance session — who was present, where they recorded, what they intended, which commits were produced, and any closing notes. |
| 2250 | |
| 2251 | - **Session IDs** are the local UUIIDv4 from `.muse/sessions/<uuid>.json`. The same ID is used in the Hub. |
| 2252 | - **Push semantics:** `POST /sessions` is **idempotent** — re-pushing the same `session_id` updates the existing record. This allows updating notes after the initial push. |
| 2253 | - **Participants with session count badges:** The UI loads all sessions to compute per-participant session counts, displaying them next to each name on the detail page. |
| 2254 | - **Commits cross-reference:** `commits` is a JSON list of Muse commit IDs. The detail page links each commit to the commit detail page. No FK enforcement at DB level — commits may arrive before or after sessions. |
| 2255 | - **Previous/next navigation:** The session detail page fetches all sessions for the repo and renders prev/next links based on `started_at` order, mirroring the `muse session log` traversal pattern. |
| 2256 | |
| 2257 | ### Architecture Boundary |
| 2258 | |
| 2259 | Service modules are the only place that touches `musehub_*` tables: |
| 2260 | - `musehub_repository.py` → `musehub_repos`, `musehub_branches`, `musehub_commits` |
| 2261 | - `musehub_issues.py` → `musehub_issues` |
| 2262 | - `musehub_pull_requests.py` → `musehub_pull_requests` |
| 2263 | - `musehub_sessions.py` → `musehub_sessions` |
| 2264 | - `musehub_sync.py` → `musehub_commits`, `musehub_objects`, `musehub_branches` (sync path only) |
| 2265 | |
| 2266 | Route handlers delegate all persistence to the service layer. No business logic in route handlers. |
| 2267 | |
| 2268 | --- |
| 2269 | |
| 2270 | ## Maestro → Muse Integration: Generate → Commit Pipeline |
| 2271 | |
| 2272 | The stress test (`scripts/e2e/stress_test.py`) produces music artifacts in a |
| 2273 | deterministic `muse-work/` layout consumable directly by `muse commit`. |
| 2274 | |
| 2275 | ### Output Contract (`--output-dir ./muse-work`) |
| 2276 | |
| 2277 | ``` |
| 2278 | muse-work/ |
| 2279 | tracks/<instrument_combo>/<genre>_<bars>b_<composition_id>.mid |
| 2280 | renders/<genre>_<bars>b_<composition_id>.mp3 |
| 2281 | previews/<genre>_<bars>b_<composition_id>.webp |
| 2282 | meta/<genre>_<bars>b_<composition_id>.json |
| 2283 | muse-batch.json (written next to muse-work/, i.e. in the repo root) |
| 2284 | ``` |
| 2285 | |
| 2286 | ### `muse-batch.json` Schema |
| 2287 | |
| 2288 | ```json |
| 2289 | { |
| 2290 | "run_id": "stress-20260227_172919", |
| 2291 | "generated_at": "2026-02-27T17:29:19Z", |
| 2292 | "commit_message_suggestion": "feat: 2-genre stress test (jazz, house)", |
| 2293 | "files": [ |
| 2294 | { |
| 2295 | "path": "muse-work/tracks/drums_bass/jazz_4b_stress-20260227_172919-0000.mid", |
| 2296 | "role": "midi", |
| 2297 | "genre": "jazz", |
| 2298 | "bars": 4, |
| 2299 | "cached": false |
| 2300 | } |
| 2301 | ], |
| 2302 | "provenance": { |
| 2303 | "prompt": "stress_test.py --quick --genre jazz,house", |
| 2304 | "model": "storpheus", |
| 2305 | "seed": "stress-20260227_172919", |
| 2306 | "storpheus_version": "1.0.0" |
| 2307 | } |
| 2308 | } |
| 2309 | ``` |
| 2310 | |
| 2311 | **Field rules:** |
| 2312 | - `files[].path` — relative to repo root, always starts with `muse-work/` |
| 2313 | - `files[].role` — one of `"midi"`, `"mp3"`, `"webp"`, `"meta"` |
| 2314 | - `files[].cached` — `true` when the result was served from the Storpheus cache |
| 2315 | - Failed generations are **omitted** from `files[]`; only successful results appear |
| 2316 | - Cache hits **are included** in `files[]` with `"cached": true` |
| 2317 | |
| 2318 | ### `muse commit` — Full Flag Reference |
| 2319 | |
| 2320 | **Usage:** |
| 2321 | ```bash |
| 2322 | muse commit -m <message> [OPTIONS] |
| 2323 | muse commit --from-batch muse-batch.json [OPTIONS] |
| 2324 | ``` |
| 2325 | |
| 2326 | **Core flags:** |
| 2327 | |
| 2328 | | Flag | Type | Default | Description | |
| 2329 | |------|------|---------|-------------| |
| 2330 | | `-m / --message TEXT` | string | — | Commit message. Required unless `--from-batch` is used | |
| 2331 | | `--from-batch PATH` | path | — | Use `commit_message_suggestion` from `muse-batch.json`; snapshot is restricted to listed files | |
| 2332 | | `--amend` | flag | off | Fold working-tree changes into the most recent commit (equivalent to `muse amend`) | |
| 2333 | | `--no-verify` | flag | off | Bypass pre-commit hooks (no-op until hook system is implemented) | |
| 2334 | | `--allow-empty` | flag | off | Allow committing even when the working tree has not changed since HEAD | |
| 2335 | |
| 2336 | **Music-domain flags (Muse-native metadata):** |
| 2337 | |
| 2338 | | Flag | Type | Default | Description | |
| 2339 | |------|------|---------|-------------| |
| 2340 | | `--section TEXT` | string | — | Tag commit as belonging to a musical section (e.g. `verse`, `chorus`, `bridge`) | |
| 2341 | | `--track TEXT` | string | — | Tag commit as affecting a specific instrument track (e.g. `drums`, `bass`, `keys`) | |
| 2342 | | `--emotion TEXT` | string | — | Attach an emotion vector label (e.g. `joyful`, `melancholic`, `tense`) | |
| 2343 | | `--co-author TEXT` | string | — | Append `Co-authored-by: Name <email>` trailer to the commit message | |
| 2344 | |
| 2345 | Music-domain flags are stored in the `commit_metadata` JSON column on `muse_cli_commits`. They are surfaced at the top level in `muse show <commit> --json` output and form the foundation for future queries like `muse log --emotion melancholic` or `muse diff --section chorus`. |
| 2346 | |
| 2347 | **Examples:** |
| 2348 | |
| 2349 | ```bash |
| 2350 | # Standard commit with message |
| 2351 | muse commit -m "feat: add Rhodes piano to chorus" |
| 2352 | |
| 2353 | # Tag with music-domain metadata |
| 2354 | muse commit -m "groove take 3" --section verse --track drums --emotion joyful |
| 2355 | |
| 2356 | # Collaborative session — attribute a co-author |
| 2357 | muse commit -m "keys arrangement" --co-author "Alice <alice@stori.app>" |
| 2358 | |
| 2359 | # Amend the last commit with new emotion tag |
| 2360 | muse commit --amend --emotion melancholic |
| 2361 | |
| 2362 | # Milestone commit with no file changes |
| 2363 | muse commit --allow-empty -m "session handoff" --section bridge |
| 2364 | |
| 2365 | # Fast path from stress test |
| 2366 | muse commit --from-batch muse-batch.json --emotion tense |
| 2367 | ``` |
| 2368 | |
| 2369 | ### Fast-Path Commit: `muse commit --from-batch` |
| 2370 | |
| 2371 | ```bash |
| 2372 | # Run stress test → write muse-work/ layout + muse-batch.json |
| 2373 | docker compose exec storpheus python scripts/e2e/stress_test.py \ |
| 2374 | --quick --genre jazz,house --flush --output-dir ./muse-work |
| 2375 | |
| 2376 | # Commit only the files produced by this run, using the suggested message |
| 2377 | muse commit --from-batch muse-batch.json |
| 2378 | ``` |
| 2379 | |
| 2380 | `muse commit --from-batch <path>`: |
| 2381 | 1. Reads `muse-batch.json` from `<path>` |
| 2382 | 2. Uses `commit_message_suggestion` as the commit message (overrides `-m`) |
| 2383 | 3. Builds the snapshot manifest **restricted to files listed in `files[]`** — the rest of `muse-work/` is excluded |
| 2384 | 4. Proceeds with the standard commit pipeline (snapshot → DB → HEAD pointer update) |
| 2385 | |
| 2386 | The `-m` flag is optional when `--from-batch` is present. If both are supplied, |
| 2387 | `--from-batch`'s suggestion wins. All music-domain flags (`--section`, `--track`, |
| 2388 | `--emotion`, `--co-author`) can be combined with `--from-batch`. |
| 2389 | |
| 2390 | ### Workflow Summary |
| 2391 | |
| 2392 | ``` |
| 2393 | stress_test.py --output-dir ./muse-work |
| 2394 | │ |
| 2395 | ├── saves artifacts → muse-work/{tracks,renders,previews,meta}/ |
| 2396 | └── emits muse-batch.json (manifest + commit_message_suggestion) |
| 2397 | │ |
| 2398 | ▼ |
| 2399 | muse commit --from-batch muse-batch.json |
| 2400 | │ |
| 2401 | ├── reads batch → restrict snapshot to listed files |
| 2402 | ├── uses commit_message_suggestion |
| 2403 | └── creates versioned commit in Postgres |
| 2404 | ``` |
| 2405 | |
| 2406 | --- |
| 2407 | |
| 2408 | ## Muse CLI — Plumbing Command Reference |
| 2409 | |
| 2410 | Plumbing commands expose the raw object model and allow scripted or programmatic |
| 2411 | construction of history without the side-effects of porcelain commands. They |
| 2412 | mirror the design of `git commit-tree`, `git update-ref`, and `git hash-object`. |
| 2413 | |
| 2414 | AI agents use plumbing commands when they need to build commit graphs |
| 2415 | programmatically — for example when replaying a merge, synthesising history from |
| 2416 | an external source, or constructing commits without changing the working branch. |
| 2417 | |
| 2418 | --- |
| 2419 | |
| 2420 | ### `muse commit-tree` |
| 2421 | |
| 2422 | **Purpose:** Create a raw commit object directly from an existing `snapshot_id` |
| 2423 | and explicit metadata. Does not walk the filesystem, does not update any branch |
| 2424 | ref, does not touch `.muse/HEAD`. Use `muse update-ref` (planned) to associate |
| 2425 | the resulting commit with a branch. |
| 2426 | |
| 2427 | **Usage:** |
| 2428 | ```bash |
| 2429 | muse commit-tree <snapshot_id> -m <message> [OPTIONS] |
| 2430 | ``` |
| 2431 | |
| 2432 | **Flags:** |
| 2433 | |
| 2434 | | Flag | Type | Default | Description | |
| 2435 | |------|------|---------|-------------| |
| 2436 | | `snapshot_id` | positional | — | ID of an existing snapshot row in the database | |
| 2437 | | `-m / --message TEXT` | string | required | Commit message | |
| 2438 | | `-p / --parent TEXT` | string | — | Parent commit ID. Repeat for merge commits (max 2) | |
| 2439 | | `--author TEXT` | string | `[user] name` from `.muse/config.toml` or `""` | Author name | |
| 2440 | |
| 2441 | **Output example:** |
| 2442 | ``` |
| 2443 | a3f8c21d4e9b0712c5d6f7a8e3b2c1d0a4f5e6b7c8d9e0f1a2b3c4d5e6f7a8b9 |
| 2444 | ``` |
| 2445 | |
| 2446 | The commit ID (64-char SHA-256 hex) is printed to stdout. Pipe it to |
| 2447 | `muse update-ref` to advance a branch ref. |
| 2448 | |
| 2449 | **Result type:** `CommitTreeResult` — fields: `commit_id` (str, 64-char hex). |
| 2450 | |
| 2451 | **Idempotency contract:** The commit ID is derived deterministically from |
| 2452 | `(parent_ids, snapshot_id, message, author)` with **no timestamp** component. |
| 2453 | Repeating the same call returns the same `commit_id` without inserting a |
| 2454 | duplicate row. This makes `muse commit-tree` safe to call in retry loops and |
| 2455 | idempotent scripts. |
| 2456 | |
| 2457 | **Agent use case:** An AI music generation agent that needs to construct a merge |
| 2458 | commit (e.g. combining the groove from branch A with the lead from branch B) |
| 2459 | without moving either branch pointer: |
| 2460 | |
| 2461 | ```bash |
| 2462 | SNAP=$(muse write-tree) # planned plumbing command |
| 2463 | COMMIT=$(muse commit-tree "$SNAP" -m "Merge groove+lead" -p "$A_HEAD" -p "$B_HEAD") |
| 2464 | muse update-ref refs/heads/merge-candidate "$COMMIT" # planned |
| 2465 | ``` |
| 2466 | |
| 2467 | **Error cases:** |
| 2468 | - `snapshot_id` not found → exits 1 with a clear message |
| 2469 | - More than 2 `-p` parents → exits 1 (DB model stores at most 2) |
| 2470 | - Not inside a Muse repo → exits 2 |
| 2471 | |
| 2472 | **Implementation:** `maestro/muse_cli/commands/commit_tree.py` |
| 2473 | |
| 2474 | --- |
| 2475 | |
| 2476 | ### `muse hash-object` |
| 2477 | |
| 2478 | **Purpose:** Compute the SHA-256 content-address of a file (or stdin) and |
| 2479 | optionally write it into the Muse object store. The hash produced is |
| 2480 | identical to what `muse commit` would assign to the same file, ensuring |
| 2481 | cross-command content-addressability. Use this for scripting, pre-upload |
| 2482 | deduplication checks, and debugging the object store. |
| 2483 | |
| 2484 | **Usage:** |
| 2485 | ```bash |
| 2486 | muse hash-object <file> [OPTIONS] |
| 2487 | muse hash-object --stdin [OPTIONS] |
| 2488 | ``` |
| 2489 | |
| 2490 | **Flags:** |
| 2491 | |
| 2492 | | Flag | Type | Default | Description | |
| 2493 | |------|------|---------|-------------| |
| 2494 | | `<file>` | positional | — | Path to the file to hash. Omit when using `--stdin`. | |
| 2495 | | `-w / --write` | flag | off | Write the object to `.muse/objects/` and the `muse_cli_objects` table in addition to printing the hash. | |
| 2496 | | `--stdin` | flag | off | Read content from stdin instead of a file. | |
| 2497 | |
| 2498 | **Output example:** |
| 2499 | |
| 2500 | ``` |
| 2501 | a3f2e1b0d4c5... (64-character SHA-256 hex digest) |
| 2502 | ``` |
| 2503 | |
| 2504 | **Result type:** `HashObjectResult` — fields: `object_id` (str, 64-char hex), `stored` (bool), `already_existed` (bool). |
| 2505 | |
| 2506 | **Agent use case:** An AI agent can call `muse hash-object <file>` to derive the |
| 2507 | object ID before committing, enabling optimistic checks ("is this drum loop |
| 2508 | already in the store?") without running a full `muse commit`. Piping output |
| 2509 | to `muse cat-object` verifies whether the stored content matches expectations. |
| 2510 | |
| 2511 | **Implementation:** `maestro/muse_cli/commands/hash_object.py` — registered as |
| 2512 | `muse hash-object`. `HashObjectResult` (class), `hash_bytes()` (pure helper), |
| 2513 | `_hash_object_async()` (fully injectable for tests). |
| 2514 | |
| 2515 | --- |
| 2516 | |
| 2517 | ## Muse CLI — Remote Sync Command Reference |
| 2518 | |
| 2519 | These commands connect the local Muse repo to the remote Muse Hub, enabling |
| 2520 | collaboration between musicians (push from one machine, pull on another) and |
| 2521 | serving as the CLI-side counterpart to the Hub's sync API. |
| 2522 | |
| 2523 | --- |
| 2524 | |
| 2525 | ### `muse remote` |
| 2526 | |
| 2527 | **Purpose:** Manage named remote Hub URLs in `.muse/config.toml`. Every push |
| 2528 | and pull needs a remote configured — `muse remote add` is the prerequisite. |
| 2529 | Use `remove`, `rename`, and `set-url` to maintain remotes over the repository |
| 2530 | lifecycle (switching Hub instances, renaming origin to upstream, etc.). |
| 2531 | |
| 2532 | **Usage:** |
| 2533 | ```bash |
| 2534 | muse remote add <name> <url> # register a new remote |
| 2535 | muse remote remove <name> # remove a remote and its tracking refs |
| 2536 | muse remote rename <old> <new> # rename a remote (config + tracking refs) |
| 2537 | muse remote set-url <name> <url> # update URL of an existing remote |
| 2538 | muse remote -v # list all remotes with their URLs |
| 2539 | ``` |
| 2540 | |
| 2541 | **Flags:** |
| 2542 | | Flag | Type | Default | Description | |
| 2543 | |------|------|---------|-------------| |
| 2544 | | `-v` / `--verbose` | flag | off | Print all configured remotes with their URLs | |
| 2545 | |
| 2546 | **Subcommands:** |
| 2547 | |
| 2548 | | Subcommand | Description | |
| 2549 | |-----------|-------------| |
| 2550 | | `add <name> <url>` | Write `[remotes.<name>] url = "<url>"` to `.muse/config.toml`; creates or overwrites | |
| 2551 | | `remove <name>` | Delete `[remotes.<name>]` from config and remove `.muse/remotes/<name>/` tracking refs | |
| 2552 | | `rename <old> <new>` | Rename config entry and move `.muse/remotes/<old>/` → `.muse/remotes/<new>/` | |
| 2553 | | `set-url <name> <url>` | Update `[remotes.<name>] url` without touching tracking refs; errors if remote absent | |
| 2554 | |
| 2555 | **Output example:** |
| 2556 | ``` |
| 2557 | # muse remote add origin https://story.audio/musehub/repos/my-repo-id |
| 2558 | ✅ Remote 'origin' set to https://story.audio/musehub/repos/my-repo-id |
| 2559 | |
| 2560 | # muse remote -v |
| 2561 | origin https://story.audio/musehub/repos/my-repo-id |
| 2562 | staging https://staging.example.com/musehub/repos/my-repo-id |
| 2563 | |
| 2564 | # muse remote rename origin upstream |
| 2565 | ✅ Remote 'origin' renamed to 'upstream'. |
| 2566 | |
| 2567 | # muse remote set-url upstream https://new-hub.example.com/musehub/repos/my-repo-id |
| 2568 | ✅ Remote 'upstream' URL changed to https://new-hub.example.com/musehub/repos/my-repo-id |
| 2569 | |
| 2570 | # muse remote remove staging |
| 2571 | ✅ Remote 'staging' removed. |
| 2572 | ``` |
| 2573 | |
| 2574 | **Security:** Token values in `[auth]` are never shown by `muse remote -v`. |
| 2575 | |
| 2576 | **Exit codes:** 0 — success; 1 — bad URL, empty name, remote not found, or name conflict; 2 — not a repo. |
| 2577 | |
| 2578 | **Agent use case:** An orchestration agent registers the Hub URL once at repo |
| 2579 | setup time with `add`, then uses `set-url` to point at a different Hub instance |
| 2580 | when the workspace migrates, `rename` to canonicalize `origin` → `upstream`, |
| 2581 | and `remove` to clean up stale collaborator remotes. |
| 2582 | |
| 2583 | --- |
| 2584 | |
| 2585 | ### `muse push` |
| 2586 | |
| 2587 | **Purpose:** Upload local commits that the remote Hub does not yet have. |
| 2588 | Enables collaborative workflows where one musician pushes and others pull. |
| 2589 | Supports force-push, lease-guarded override, tag syncing, and upstream tracking. |
| 2590 | |
| 2591 | **Usage:** |
| 2592 | ```bash |
| 2593 | muse push |
| 2594 | muse push --branch feature/groove-v2 |
| 2595 | muse push --remote staging |
| 2596 | muse push --force-with-lease |
| 2597 | muse push --force -f |
| 2598 | muse push --tags |
| 2599 | muse push --set-upstream -u |
| 2600 | ``` |
| 2601 | |
| 2602 | **Flags:** |
| 2603 | | Flag | Type | Default | Description | |
| 2604 | |------|------|---------|-------------| |
| 2605 | | `--branch` / `-b` | str | current branch | Branch to push | |
| 2606 | | `--remote` | str | `origin` | Named remote to push to | |
| 2607 | | `--force` / `-f` | flag | off | Overwrite remote branch even on non-fast-forward. Use with caution — this discards remote history. | |
| 2608 | | `--force-with-lease` | flag | off | Overwrite remote only if its current HEAD matches our last-known tracking pointer. Safer than `--force`; the Hub must return HTTP 409 if the remote has advanced. | |
| 2609 | | `--tags` | flag | off | Push all VCS-style tag refs from `.muse/refs/tags/` alongside the branch commits. | |
| 2610 | | `--set-upstream` / `-u` | flag | off | After a successful push, record the remote as the upstream for the current branch in `.muse/config.toml`. | |
| 2611 | |
| 2612 | **Push algorithm:** |
| 2613 | 1. Read `repo_id` from `.muse/repo.json` and branch from `.muse/HEAD`. |
| 2614 | 2. Read local HEAD commit from `.muse/refs/heads/<branch>`. |
| 2615 | 3. Resolve remote URL from `[remotes.<name>] url` in `.muse/config.toml`. |
| 2616 | 4. Read last-known remote HEAD from `.muse/remotes/<name>/<branch>` (absent on first push). |
| 2617 | 5. Compute delta: commits from local HEAD down to (but not including) remote HEAD. |
| 2618 | 6. If `--tags`, enumerate `.muse/refs/tags/` and include as `PushTagPayload` list. |
| 2619 | 7. POST `{ branch, head_commit_id, commits[], objects[], [force], [force_with_lease], [expected_remote_head], [tags] }` to `<remote>/push`. |
| 2620 | 8. On HTTP 200, update `.muse/remotes/<name>/<branch>` to the new HEAD; if `--set-upstream`, write `branch = <branch>` under `[remotes.<name>]` in `.muse/config.toml`. |
| 2621 | 9. On HTTP 409 with `--force-with-lease`, exit 1 with instructive message. |
| 2622 | |
| 2623 | **Force-with-lease contract:** `expected_remote_head` is the commit ID in our local |
| 2624 | tracking pointer before the push. The Hub must compare it against its current HEAD and |
| 2625 | reject (HTTP 409) if they differ — this prevents clobbering commits pushed by others |
| 2626 | since our last fetch. |
| 2627 | |
| 2628 | **Output example:** |
| 2629 | ``` |
| 2630 | ⬆️ Pushing 3 commit(s) to origin/main [--force-with-lease] … |
| 2631 | ✅ Branch 'main' set to track 'origin/main' |
| 2632 | ✅ Pushed 3 commit(s) → origin/main [aabbccdd] |
| 2633 | |
| 2634 | # When force-with-lease rejected: |
| 2635 | ❌ Push rejected: remote origin/main has advanced since last fetch. |
| 2636 | Run `muse pull` then retry, or use `--force` to override. |
| 2637 | ``` |
| 2638 | |
| 2639 | **Exit codes:** 0 — success; 1 — no remote, no commits, or force-with-lease mismatch; 3 — network/server error. |
| 2640 | |
| 2641 | **Result type:** `PushRequest` / `PushResponse` — see `maestro/muse_cli/hub_client.py`. |
| 2642 | New TypedDicts: `PushTagPayload` (tag_name, commit_id). |
| 2643 | |
| 2644 | **Agent use case:** After `muse commit`, an agent runs `muse push` to publish |
| 2645 | the committed variation to the shared Hub. For CI workflows, `--force-with-lease` |
| 2646 | prevents clobbering concurrent pushes from other agents. |
| 2647 | |
| 2648 | --- |
| 2649 | |
| 2650 | ### `muse pull` |
| 2651 | |
| 2652 | **Purpose:** Download commits from the remote Hub that are missing locally, |
| 2653 | then integrate them into the local branch via fast-forward, merge, or rebase. |
| 2654 | After pull, the AI agent has the full commit history of remote collaborators |
| 2655 | available for `muse context`, `muse diff`, `muse ask`, etc. |
| 2656 | |
| 2657 | **Usage:** |
| 2658 | ```bash |
| 2659 | muse pull |
| 2660 | muse pull --rebase |
| 2661 | muse pull --ff-only |
| 2662 | muse pull --branch feature/groove-v2 |
| 2663 | muse pull --remote staging |
| 2664 | ``` |
| 2665 | |
| 2666 | **Flags:** |
| 2667 | | Flag | Type | Default | Description | |
| 2668 | |------|------|---------|-------------| |
| 2669 | | `--branch` / `-b` | str | current branch | Branch to pull | |
| 2670 | | `--remote` | str | `origin` | Named remote to pull from | |
| 2671 | | `--rebase` | flag | off | After fetching, rebase local commits onto remote HEAD rather than merge. Fast-forwards when remote is simply ahead; replays local commits (linear rebase) when diverged. | |
| 2672 | | `--ff-only` | flag | off | Only integrate if the result would be a fast-forward. Fails with exit 1 and leaves local branch unchanged if branches have diverged. | |
| 2673 | |
| 2674 | **Pull algorithm:** |
| 2675 | 1. Resolve remote URL from `[remotes.<name>] url` in `.muse/config.toml`. |
| 2676 | 2. Collect `have_commits` (all local commit IDs) and `have_objects` (all local object IDs). |
| 2677 | 3. POST `{ branch, have_commits[], have_objects[], [rebase], [ff_only] }` to `<remote>/pull`. |
| 2678 | 4. Store returned commits and object descriptors in local Postgres. |
| 2679 | 5. Update `.muse/remotes/<name>/<branch>` tracking pointer. |
| 2680 | 6. Apply post-fetch integration strategy: |
| 2681 | - **Default:** If diverged, print warning and suggest `muse merge`. |
| 2682 | - **`--ff-only`:** If `local_head` is ancestor of `remote_head`, advance branch ref (fast-forward). Otherwise exit 1. |
| 2683 | - **`--rebase`:** If `local_head` is ancestor of `remote_head`, fast-forward. If diverged, find merge base and replay local commits above base onto `remote_head` using `compute_commit_tree_id` (deterministic IDs). |
| 2684 | |
| 2685 | **Rebase contract:** Linear rebase only — no path-level conflict detection. |
| 2686 | For complex divergence with conflicting file changes, use `muse merge`. |
| 2687 | The rebased commit IDs are deterministic (via `compute_commit_tree_id`), so |
| 2688 | re-running the same rebase is idempotent. |
| 2689 | |
| 2690 | **Divergence detection:** Pull succeeds (exit 0) even when diverged in default |
| 2691 | mode. The divergence warning is informational. |
| 2692 | |
| 2693 | **Output example:** |
| 2694 | ``` |
| 2695 | ⬇️ Pulling origin/main (--rebase) … |
| 2696 | ✅ Fast-forwarded main → aabbccdd |
| 2697 | ✅ Pulled 2 new commit(s), 5 new object(s) from origin/main |
| 2698 | |
| 2699 | # Diverged + --rebase: |
| 2700 | ⟳ Rebasing 2 local commit(s) onto aabbccdd … |
| 2701 | ✅ Rebase complete — main → eeff1122 |
| 2702 | ✅ Pulled 3 new commit(s), 0 new object(s) from origin/main |
| 2703 | |
| 2704 | # Diverged + --ff-only: |
| 2705 | ❌ Cannot fast-forward: main has diverged from origin/main. |
| 2706 | Run `muse merge origin/main` or use `muse pull --rebase` to integrate. |
| 2707 | ``` |
| 2708 | |
| 2709 | **Exit codes:** 0 — success; 1 — no remote, or `--ff-only` on diverged branch; 3 — network/server error. |
| 2710 | |
| 2711 | **Result type:** `PullRequest` / `PullResponse` — see `maestro/muse_cli/hub_client.py`. |
| 2712 | |
| 2713 | **Agent use case:** Before generating a new arrangement, an agent runs |
| 2714 | `muse pull --rebase` to ensure it works from the latest shared composition |
| 2715 | state with a clean linear history. `--ff-only` is useful in strict CI pipelines |
| 2716 | where merges are not permitted. |
| 2717 | |
| 2718 | --- |
| 2719 | |
| 2720 | ### `muse clone` |
| 2721 | |
| 2722 | **Purpose:** Clone a remote Muse Hub repository into a new local directory — the |
| 2723 | entry point for collaboration. A session musician or AI agent calls `muse clone` |
| 2724 | once to obtain a local copy of a producer's project. Subsequent `muse pull` and |
| 2725 | `muse push` operations use the "origin" remote written by `muse clone`. |
| 2726 | |
| 2727 | **Usage:** |
| 2728 | ```bash |
| 2729 | muse clone <url> [directory] [OPTIONS] |
| 2730 | ``` |
| 2731 | |
| 2732 | **Flags:** |
| 2733 | | Flag | Type | Default | Description | |
| 2734 | |------|------|---------|-------------| |
| 2735 | | `<url>` | positional | — | Muse Hub repository URL (e.g. `https://hub.stori.app/repos/<repo_id>`) | |
| 2736 | | `[directory]` | positional | repo name from URL | Local directory to clone into | |
| 2737 | | `--depth N` | int | None | Shallow clone: fetch only the last N commits | |
| 2738 | | `--branch TEXT` | str | Hub default | Clone and check out a specific branch | |
| 2739 | | `--single-track TEXT` | str | None | Restrict downloaded files to one instrument track | |
| 2740 | | `--no-checkout` | flag | off | Set up `.muse/` and fetch objects but leave `muse-work/` empty | |
| 2741 | |
| 2742 | **Output example:** |
| 2743 | ``` |
| 2744 | Cloning into 'producer-beats' … |
| 2745 | ✅ Cloned: 12 commit(s), 48 object(s) → 'producer-beats' |
| 2746 | |
| 2747 | # Shallow clone (last commit only) |
| 2748 | Cloning into 'producer-beats' … |
| 2749 | ✅ Cloned (depth 1): 1 commit(s), 4 object(s) → 'producer-beats' |
| 2750 | |
| 2751 | # Keys-only track clone |
| 2752 | Cloning into 'producer-beats' … |
| 2753 | ✅ Cloned, track='keys': 12 commit(s), 8 object(s) → 'producer-beats' |
| 2754 | ``` |
| 2755 | |
| 2756 | **Result type:** `CloneRequest` / `CloneResponse` — see `maestro/muse_cli/hub_client.py`. |
| 2757 | |
| 2758 | Fields of `CloneResponse`: |
| 2759 | - `repo_id: str` — canonical Hub identifier, written to `.muse/repo.json` |
| 2760 | - `default_branch: str` — branch HEAD was cloned from |
| 2761 | - `remote_head: str | None` — HEAD commit ID on the remote branch |
| 2762 | - `commits: list[CloneCommitPayload]` — commit DAG to seed local DB |
| 2763 | - `objects: list[CloneObjectPayload]` — content-addressed object descriptors |
| 2764 | |
| 2765 | **Exit codes:** 0 — success; 1 — target directory already exists or bad args; 3 — network/server error. |
| 2766 | |
| 2767 | **Agent use case:** An AI agent clones the producer's project to inspect the |
| 2768 | commit history (`muse log`), understand the musical state (`muse context`), and |
| 2769 | add new variations before pushing back. `--single-track keys` lets the keys |
| 2770 | agent download only keyboard files, avoiding multi-gigabyte drum/bass downloads. |
| 2771 | `--no-checkout` is useful when an agent only needs the commit graph metadata, not |
| 2772 | the working-tree snapshot. |
| 2773 | |
| 2774 | --- |
| 2775 | |
| 2776 | ### `muse fetch` |
| 2777 | |
| 2778 | **Purpose:** Update remote-tracking refs to reflect the current state of the remote |
| 2779 | without modifying the local branch or muse-work/. Use `muse fetch` when you want |
| 2780 | to inspect what collaborators have pushed before deciding whether to merge. This |
| 2781 | is the non-destructive alternative to `muse pull` (fetch + merge). |
| 2782 | |
| 2783 | **Usage:** |
| 2784 | ```bash |
| 2785 | muse fetch |
| 2786 | muse fetch --all |
| 2787 | muse fetch --prune |
| 2788 | muse fetch --remote staging --branch main --branch feature/bass-v2 |
| 2789 | ``` |
| 2790 | |
| 2791 | **Flags:** |
| 2792 | | Flag | Type | Default | Description | |
| 2793 | |------|------|---------|-------------| |
| 2794 | | `--remote` | str | `origin` | Named remote to fetch from | |
| 2795 | | `--all` | flag | off | Fetch from every configured remote | |
| 2796 | | `--prune` / `-p` | flag | off | Remove local remote-tracking refs for branches deleted on the remote | |
| 2797 | | `--branch` / `-b` | str (repeatable) | all branches | Specific branch(es) to fetch | |
| 2798 | |
| 2799 | **Fetch algorithm:** |
| 2800 | 1. Resolve remote URL(s) from `[remotes.<name>] url` in `.muse/config.toml`. |
| 2801 | 2. POST `{ branches: [] }` (empty = all) to `<remote>/fetch`. |
| 2802 | 3. For each branch in the Hub response, update `.muse/remotes/<remote>/<branch>` with the remote HEAD commit ID. |
| 2803 | 4. If `--prune`, remove any `.muse/remotes/<remote>/<branch>` files whose branch was NOT in the Hub response. |
| 2804 | 5. Local branches (`refs/heads/`) and `muse-work/` are NEVER modified. |
| 2805 | |
| 2806 | **Fetch vs Pull:** |
| 2807 | | Operation | Modifies local branch | Modifies muse-work/ | Merges remote commits | |
| 2808 | |-----------|----------------------|---------------------|----------------------| |
| 2809 | | `muse fetch` | No | No | No | |
| 2810 | | `muse pull` | Yes (via merge) | Yes | Yes | |
| 2811 | |
| 2812 | **Output example:** |
| 2813 | ``` |
| 2814 | From origin: + abc1234 feature/guitar -> origin/feature/guitar (new branch) |
| 2815 | From origin: + def5678 main -> origin/main |
| 2816 | ✅ origin is already up to date. |
| 2817 | |
| 2818 | # With --all: |
| 2819 | From origin: + abc1234 main -> origin/main |
| 2820 | From staging: + xyz9999 main -> staging/main |
| 2821 | ✅ Fetched 2 branch update(s) across all remotes. |
| 2822 | |
| 2823 | # With --prune: |
| 2824 | ✂️ Pruned origin/deleted-branch (no longer exists on remote) |
| 2825 | ``` |
| 2826 | |
| 2827 | **Exit codes:** 0 — success; 1 — no remote configured or `--all` with no remotes; 3 — network/server error. |
| 2828 | |
| 2829 | **Result type:** `FetchRequest` / `FetchResponse` / `FetchBranchInfo` — see `maestro/muse_cli/hub_client.py`. |
| 2830 | |
| 2831 | **Agent use case:** An agent runs `muse fetch` before deciding whether to compose a new |
| 2832 | variation, to check if remote collaborators have pushed conflicting changes. Since fetch |
| 2833 | does not modify the working tree, it is safe to run mid-composition without interrupting |
| 2834 | the current generation pipeline. Follow with `muse log origin/main` to inspect what |
| 2835 | arrived, then `muse merge origin/main` if the agent decides to incorporate remote changes. |
| 2836 | |
| 2837 | --- |
| 2838 | |
| 2839 | ## Muse CLI — Bisect Command Reference |
| 2840 | |
| 2841 | `muse bisect` implements a binary search over the commit graph to identify the |
| 2842 | exact commit that introduced a musical regression — a rhythmic drift, mix |
| 2843 | artefact, or tonal shift. It is the music-domain analogue of `git bisect`. |
| 2844 | |
| 2845 | Session state is persisted in `.muse/BISECT_STATE.json` across shell invocations. |
| 2846 | |
| 2847 | --- |
| 2848 | |
| 2849 | ### `muse bisect start` |
| 2850 | |
| 2851 | **Purpose:** Open a bisect session and record the pre-bisect HEAD so that |
| 2852 | `muse bisect reset` can cleanly restore the workspace. |
| 2853 | |
| 2854 | **Usage:** |
| 2855 | ```bash |
| 2856 | muse bisect start |
| 2857 | ``` |
| 2858 | |
| 2859 | **Blocked by:** `.muse/MERGE_STATE.json` (merge in progress) or an already-active |
| 2860 | `BISECT_STATE.json`. |
| 2861 | |
| 2862 | **Output example:** |
| 2863 | ``` |
| 2864 | ✅ Bisect session started. |
| 2865 | Now mark a good commit: muse bisect good <commit> |
| 2866 | And a bad commit: muse bisect bad <commit> |
| 2867 | ``` |
| 2868 | |
| 2869 | **Agent use case:** An AI agent detects rhythmic drift in the latest mix and |
| 2870 | opens a bisect session to automatically locate the offending commit. |
| 2871 | |
| 2872 | **Implementation:** `maestro/muse_cli/commands/bisect.py` |
| 2873 | |
| 2874 | --- |
| 2875 | |
| 2876 | ### `muse bisect good <commit>` |
| 2877 | |
| 2878 | **Purpose:** Mark *commit* as a known-good revision. Once both a good and bad |
| 2879 | bound are set, Muse selects the midpoint commit and checks it out into muse-work/ |
| 2880 | for inspection. |
| 2881 | |
| 2882 | **Usage:** |
| 2883 | ```bash |
| 2884 | muse bisect good [<commit>] # default: HEAD |
| 2885 | ``` |
| 2886 | |
| 2887 | **Flags:** |
| 2888 | | Flag | Type | Default | Description | |
| 2889 | |------|------|---------|-------------| |
| 2890 | | `<commit>` | positional | `HEAD` | Commit to mark as good: SHA, branch name, or `HEAD` | |
| 2891 | |
| 2892 | **Output example:** |
| 2893 | ``` |
| 2894 | ✅ Marked a1b2c3d4 as good. Checking out f9e8d7c6 (~2 step(s) remaining, 3 commits in range) |
| 2895 | ``` |
| 2896 | |
| 2897 | **Agent use case:** After listening to the muse-work/ snapshot and confirming the |
| 2898 | groove is intact, the agent marks the current commit as good and awaits the next |
| 2899 | midpoint checkout. |
| 2900 | |
| 2901 | --- |
| 2902 | |
| 2903 | ### `muse bisect bad <commit>` |
| 2904 | |
| 2905 | **Purpose:** Mark *commit* as a known-bad revision. Mirrors `muse bisect good`. |
| 2906 | |
| 2907 | **Usage:** |
| 2908 | ```bash |
| 2909 | muse bisect bad [<commit>] # default: HEAD |
| 2910 | ``` |
| 2911 | |
| 2912 | --- |
| 2913 | |
| 2914 | ### `muse bisect run <cmd>` |
| 2915 | |
| 2916 | **Purpose:** Automate the bisect loop. Runs *cmd* after each checkout; exit 0 |
| 2917 | means good, any non-zero exit code means bad. Stops when the culprit is found. |
| 2918 | |
| 2919 | **Usage:** |
| 2920 | ```bash |
| 2921 | muse bisect run <shell-command> [--max-steps N] |
| 2922 | ``` |
| 2923 | |
| 2924 | **Flags:** |
| 2925 | | Flag | Type | Default | Description | |
| 2926 | |------|------|---------|-------------| |
| 2927 | | `<cmd>` | positional | — | Shell command to test each midpoint | |
| 2928 | | `--max-steps` | int | 50 | Safety limit on bisect iterations | |
| 2929 | |
| 2930 | **Output example:** |
| 2931 | ``` |
| 2932 | ⟳ Testing f9e8d7c6… |
| 2933 | exit=1 → bad |
| 2934 | ✅ Marked f9e8d7c6 as bad. Checking out d3c2b1a0 (~1 step(s) remaining, 1 in range) |
| 2935 | ⟳ Testing d3c2b1a0… |
| 2936 | exit=0 → good |
| 2937 | 🎯 Bisect complete! First bad commit: f9e8d7c6 |
| 2938 | Run 'muse bisect reset' to restore your workspace. |
| 2939 | ``` |
| 2940 | |
| 2941 | **Result type:** `BisectStepResult` — fields: `culprit` (str | None), |
| 2942 | `next_commit` (str | None), `remaining` (int), `message` (str). |
| 2943 | |
| 2944 | **Agent use case:** An AI orchestrator runs `muse bisect run python check_groove.py` |
| 2945 | to automate the full regression hunt without human input. |
| 2946 | |
| 2947 | --- |
| 2948 | |
| 2949 | ### `muse bisect reset` |
| 2950 | |
| 2951 | **Purpose:** End the bisect session: restore `.muse/HEAD` and muse-work/ to the |
| 2952 | pre-bisect state, then remove `BISECT_STATE.json`. |
| 2953 | |
| 2954 | **Usage:** |
| 2955 | ```bash |
| 2956 | muse bisect reset |
| 2957 | ``` |
| 2958 | |
| 2959 | **Output example:** |
| 2960 | ``` |
| 2961 | ✅ muse-work/ restored (3 files) from pre-bisect snapshot. |
| 2962 | ✅ Bisect session ended. |
| 2963 | ``` |
| 2964 | |
| 2965 | --- |
| 2966 | |
| 2967 | ### `muse bisect log` |
| 2968 | |
| 2969 | **Purpose:** Display the verdicts recorded so far and the current good/bad bounds. |
| 2970 | |
| 2971 | **Usage:** |
| 2972 | ```bash |
| 2973 | muse bisect log [--json] |
| 2974 | ``` |
| 2975 | |
| 2976 | **Flags:** |
| 2977 | | Flag | Type | Default | Description | |
| 2978 | |------|------|---------|-------------| |
| 2979 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 2980 | |
| 2981 | **Output example (default):** |
| 2982 | ``` |
| 2983 | Bisect session state: |
| 2984 | good: a1b2c3d4... |
| 2985 | bad: f9e8d7c6... |
| 2986 | current: d3c2b1a0... |
| 2987 | tested (2 commit(s)): |
| 2988 | a1b2c3d4 good |
| 2989 | f9e8d7c6 bad |
| 2990 | ``` |
| 2991 | |
| 2992 | **Result type:** `BisectState` — fields: `good`, `bad`, `current`, `tested`, |
| 2993 | `pre_bisect_ref`, `pre_bisect_commit`. |
| 2994 | |
| 2995 | **Agent use case:** An agent queries `muse bisect log --json` to resume a |
| 2996 | suspended bisect session and determine the next commit to test. |
| 2997 | |
| 2998 | --- |
| 2999 | |
| 3000 | ## Muse CLI — Music Analysis Command Reference |
| 3001 | |
| 3002 | These commands expose musical dimensions across the commit graph — the layer that |
| 3003 | makes Muse fundamentally different from Git. Each command is consumed by AI agents |
| 3004 | to make musically coherent generation decisions. Every flag is part of a stable |
| 3005 | CLI contract; stub implementations are clearly marked. |
| 3006 | |
| 3007 | **Agent pattern:** Run with `--json` to get machine-readable output. Pipe into |
| 3008 | `muse context` for a unified musical state document. |
| 3009 | |
| 3010 | --- |
| 3011 | |
| 3012 | ### `muse cat-object` |
| 3013 | |
| 3014 | **Purpose:** Read and display a raw Muse object by its SHA-256 hash. The |
| 3015 | plumbing equivalent of `git cat-file` — lets an AI agent inspect any stored |
| 3016 | blob, snapshot manifest, or commit record without running the full `muse log` |
| 3017 | pipeline. |
| 3018 | |
| 3019 | **Usage:** |
| 3020 | ```bash |
| 3021 | muse cat-object [OPTIONS] <object-id> |
| 3022 | ``` |
| 3023 | |
| 3024 | **Flags:** |
| 3025 | |
| 3026 | | Flag | Type | Default | Description | |
| 3027 | |------|------|---------|-------------| |
| 3028 | | `<object-id>` | positional | required | Full 64-char SHA-256 hash to look up | |
| 3029 | | `-t / --type` | flag | off | Print only the object type (`object`, `snapshot`, or `commit`) | |
| 3030 | | `-p / --pretty` | flag | off | Pretty-print the object content as indented JSON | |
| 3031 | |
| 3032 | `-t` and `-p` are mutually exclusive. |
| 3033 | |
| 3034 | **Output example (default):** |
| 3035 | ``` |
| 3036 | type: commit |
| 3037 | commit_id: a1b2c3d4... |
| 3038 | branch: main |
| 3039 | snapshot: f9e8d7c6... |
| 3040 | message: boom bap demo take 1 |
| 3041 | parent: 00112233... |
| 3042 | committed_at: 2026-02-27T17:30:00+00:00 |
| 3043 | ``` |
| 3044 | |
| 3045 | **Output example (`-t`):** |
| 3046 | ``` |
| 3047 | commit |
| 3048 | ``` |
| 3049 | |
| 3050 | **Output example (`-p <snapshot_id>`):** |
| 3051 | ```json |
| 3052 | { |
| 3053 | "type": "snapshot", |
| 3054 | "snapshot_id": "f9e8d7c6...", |
| 3055 | "manifest": { |
| 3056 | "beat.mid": "a1b2c3d4...", |
| 3057 | "keys.mid": "11223344..." |
| 3058 | }, |
| 3059 | "created_at": "2026-02-27T17:20:00+00:00" |
| 3060 | } |
| 3061 | ``` |
| 3062 | |
| 3063 | **Result type:** `CatObjectResult` — fields: `object_type` (str), `row` |
| 3064 | (MuseCliObject | MuseCliSnapshot | MuseCliCommit). Call `.to_dict()` for a |
| 3065 | JSON-serialisable representation. |
| 3066 | |
| 3067 | **Agent use case:** Use `muse cat-object -t <hash>` to determine the type of |
| 3068 | an unknown ID before deciding how to process it. Use `-p` to extract the |
| 3069 | snapshot manifest (file → object_id map) or commit metadata for downstream |
| 3070 | generation context. Combine with `muse log` short IDs: copy the full commit_id |
| 3071 | from `muse log`, then `muse cat-object -p <id>` to inspect its snapshot. |
| 3072 | |
| 3073 | **Error behaviour:** Exits with code 1 (`USER_ERROR`) when the ID is not found |
| 3074 | in any object table; prints `❌ Object not found: <id>`. |
| 3075 | |
| 3076 | --- |
| 3077 | |
| 3078 | ### `muse harmony` |
| 3079 | |
| 3080 | **Purpose:** Analyze the harmonic content (key center, mode, chord progression, harmonic |
| 3081 | rhythm, and tension profile) of a commit. The primary tool for understanding what a |
| 3082 | composition is doing harmonically — information that is completely invisible to Git. |
| 3083 | An AI agent calling `muse harmony --json` knows whether the current arrangement is in |
| 3084 | Eb major with a II-V-I progression and moderate tension, and can use this to make |
| 3085 | musically coherent generation decisions. |
| 3086 | |
| 3087 | **Usage:** |
| 3088 | ```bash |
| 3089 | muse harmony [<commit>] [OPTIONS] |
| 3090 | ``` |
| 3091 | |
| 3092 | **Flags:** |
| 3093 | |
| 3094 | | Flag | Type | Default | Description | |
| 3095 | |------|------|---------|-------------| |
| 3096 | | `--track TEXT` | string | all tracks | Restrict to a named MIDI track (e.g. `--track keys`) | |
| 3097 | | `--section TEXT` | string | — | Restrict to a named musical section/region (planned) | |
| 3098 | | `--compare COMMIT` | string | — | Compare harmonic content against another commit | |
| 3099 | | `--range FROM..TO` | string | — | Analyze across a commit range (planned) | |
| 3100 | | `--progression` | flag | off | Show only the chord progression sequence | |
| 3101 | | `--key` | flag | off | Show only the detected key center | |
| 3102 | | `--mode` | flag | off | Show only the detected mode | |
| 3103 | | `--tension` | flag | off | Show only the harmonic tension profile | |
| 3104 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 3105 | |
| 3106 | **Output example (text):** |
| 3107 | ``` |
| 3108 | Commit abc1234 — Harmonic Analysis |
| 3109 | (stub — full MIDI analysis pending) |
| 3110 | |
| 3111 | Key: Eb (confidence: 0.92) |
| 3112 | Mode: major |
| 3113 | Chord progression: Ebmaj7 | Fm7 | Bb7sus4 | Bb7 | Ebmaj7 | Abmaj7 | Gm7 | Cm7 |
| 3114 | Harmonic rhythm: 2.1 chords/bar avg |
| 3115 | Tension profile: Low → Medium → High → Resolution (textbook tension-release arc) [0.2 → 0.4 → 0.8 → 0.3] |
| 3116 | ``` |
| 3117 | |
| 3118 | **Output example (`--json`):** |
| 3119 | ```json |
| 3120 | { |
| 3121 | "commit_id": "abc1234", |
| 3122 | "branch": "main", |
| 3123 | "key": "Eb", |
| 3124 | "mode": "major", |
| 3125 | "confidence": 0.92, |
| 3126 | "chord_progression": ["Ebmaj7", "Fm7", "Bb7sus4", "Bb7", "Ebmaj7", "Abmaj7", "Gm7", "Cm7"], |
| 3127 | "harmonic_rhythm_avg": 2.1, |
| 3128 | "tension_profile": [0.2, 0.4, 0.8, 0.3], |
| 3129 | "track": "all", |
| 3130 | "source": "stub" |
| 3131 | } |
| 3132 | ``` |
| 3133 | |
| 3134 | **Output example (`--compare <commit> --json`):** |
| 3135 | ```json |
| 3136 | { |
| 3137 | "head": { "commit_id": "abc1234", "key": "Eb", "mode": "major", ... }, |
| 3138 | "compare": { "commit_id": "def5678", "key": "Eb", "mode": "major", ... }, |
| 3139 | "key_changed": false, |
| 3140 | "mode_changed": false, |
| 3141 | "chord_progression_delta": [] |
| 3142 | } |
| 3143 | ``` |
| 3144 | |
| 3145 | **Result type:** `HarmonyResult` — fields: `commit_id`, `branch`, `key`, `mode`, |
| 3146 | `confidence`, `chord_progression`, `harmonic_rhythm_avg`, `tension_profile`, `track`, `source`. |
| 3147 | Compare path returns `HarmonyCompareResult` — fields: `head`, `compare`, `key_changed`, |
| 3148 | `mode_changed`, `chord_progression_delta`. |
| 3149 | |
| 3150 | **Agent use case:** Before generating a new instrument layer, an agent calls |
| 3151 | `muse harmony --json` to discover the harmonic context. If the arrangement is in |
| 3152 | Eb major with a II-V-I progression, the agent ensures its generated voicings stay |
| 3153 | diatonic to Eb. If the tension profile shows a build toward the chorus, the agent |
| 3154 | adds chromatic tension at the right moment rather than resolving early. |
| 3155 | `muse harmony --compare HEAD~5 --json` reveals whether the composition has |
| 3156 | modulated, shifted mode, or changed its harmonic rhythm — all decisions an AI |
| 3157 | needs to make coherent musical choices across versions. |
| 3158 | |
| 3159 | **HTTP API endpoint (issue #414):** |
| 3160 | |
| 3161 | ``` |
| 3162 | GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/harmony |
| 3163 | ``` |
| 3164 | |
| 3165 | Returns `HarmonyAnalysisResponse` — a Roman-numeral-centric view of the harmonic content. |
| 3166 | Unlike the generic `/analysis/{ref}/{dimension}` endpoint (which returns the dimension |
| 3167 | envelope with `HarmonyData`), this dedicated endpoint returns: |
| 3168 | |
| 3169 | | Field | Type | Description | |
| 3170 | |-------|------|-------------| |
| 3171 | | `key` | string | Full key label, e.g. `"C major"`, `"F# minor"` | |
| 3172 | | `mode` | string | Detected mode: `major`, `minor`, `dorian`, etc. | |
| 3173 | | `romanNumerals` | `RomanNumeralEvent[]` | Chord events with beat, Roman symbol, root, quality, function | |
| 3174 | | `cadences` | `CadenceEvent[]` | Phrase-ending cadences with beat, type, from/to chord | |
| 3175 | | `modulations` | `HarmonyModulationEvent[]` | Key-area changes with from/to key and pivot chord | |
| 3176 | | `harmonicRhythmBpm` | float | Rate of chord changes in chords per minute | |
| 3177 | |
| 3178 | Optional query params: `?track=<instrument>`, `?section=<label>`. |
| 3179 | Auth: public repos accessible without token; private repos require Bearer JWT. |
| 3180 | Cache: `ETag` and `Cache-Control: private, max-age=60` headers included. |
| 3181 | |
| 3182 | **Agent use case:** An agent that needs to compose a harmonically coherent continuation |
| 3183 | calls this endpoint to get Roman numerals (tonal function), cadence positions (phrase |
| 3184 | boundaries), and any modulations (tonal narrative), without having to parse raw chord |
| 3185 | symbols from the generic `HarmonyData` model. |
| 3186 | |
| 3187 | **Implementation:** `maestro/api/routes/musehub/analysis.py` (`harmony_router` / `get_harmony_analysis`), |
| 3188 | `maestro/services/musehub_analysis.py` (`compute_harmony_analysis`), |
| 3189 | `maestro/models/musehub_analysis.py` (`RomanNumeralEvent`, `CadenceEvent`, `HarmonyModulationEvent`, `HarmonyAnalysisResponse`). |
| 3190 | |
| 3191 | **Implementation:** `maestro/muse_cli/commands/harmony.py` — `_harmony_analyze_async` |
| 3192 | (injectable async core), `HarmonyResult` / `HarmonyCompareResult` (TypedDict result |
| 3193 | entities), `_stub_harmony` (placeholder data), `_tension_label` (arc classifier), |
| 3194 | `_render_result_human` / `_render_result_json` / `_render_compare_human` / |
| 3195 | `_render_compare_json` (renderers). Exit codes: 0 success, 2 outside repo, 3 internal. |
| 3196 | |
| 3197 | > **Stub note:** Chord detection, key inference, and tension computation are placeholder |
| 3198 | > values derived from a static Eb major II-V-I template. Full implementation requires |
| 3199 | > MIDI note extraction from committed snapshot objects (future: Storpheus chord detection |
| 3200 | > route). The CLI contract, result types, and flag set are stable. |
| 3201 | |
| 3202 | --- |
| 3203 | |
| 3204 | ### `muse dynamics` |
| 3205 | |
| 3206 | **Purpose:** Analyze the velocity (loudness) profile of a commit across all instrument |
| 3207 | tracks. The primary tool for understanding the dynamic arc of an arrangement and |
| 3208 | detecting flat, robotic, or over-compressed MIDI. |
| 3209 | |
| 3210 | **Usage:** |
| 3211 | ```bash |
| 3212 | muse dynamics [<commit>] [OPTIONS] |
| 3213 | ``` |
| 3214 | |
| 3215 | **Flags:** |
| 3216 | |
| 3217 | | Flag | Type | Default | Description | |
| 3218 | |------|------|---------|-------------| |
| 3219 | | `COMMIT` | positional | HEAD | Commit ref to analyze | |
| 3220 | | `--track TEXT` | string | all tracks | Case-insensitive prefix filter (e.g. `--track bass`) | |
| 3221 | | `--section TEXT` | string | — | Restrict to a named section/region (planned) | |
| 3222 | | `--compare COMMIT` | string | — | Side-by-side comparison with another commit (planned) | |
| 3223 | | `--history` | flag | off | Show dynamics for every commit in branch history (planned) | |
| 3224 | | `--peak` | flag | off | Show only tracks whose peak velocity exceeds the branch average | |
| 3225 | | `--range` | flag | off | Sort output by velocity range descending | |
| 3226 | | `--arc` | flag | off | When combined with `--track`, treat its value as an arc label filter | |
| 3227 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 3228 | |
| 3229 | **Arc labels:** |
| 3230 | |
| 3231 | | Label | Meaning | |
| 3232 | |-------|---------| |
| 3233 | | `flat` | Velocity variance < 10; steady throughout | |
| 3234 | | `crescendo` | Monotonically rising from start to end | |
| 3235 | | `decrescendo` | Monotonically falling from start to end | |
| 3236 | | `terraced` | Step-wise plateaus; sudden jumps between stable levels | |
| 3237 | | `swell` | Rises then falls (arch shape) | |
| 3238 | |
| 3239 | **Output example (text):** |
| 3240 | ``` |
| 3241 | Dynamic profile — commit a1b2c3d4 (HEAD -> main) |
| 3242 | |
| 3243 | Track Avg Vel Peak Range Arc |
| 3244 | --------- ------- ---- ----- ----------- |
| 3245 | drums 88 110 42 terraced |
| 3246 | bass 72 85 28 flat |
| 3247 | keys 64 95 56 crescendo |
| 3248 | lead 79 105 38 swell |
| 3249 | ``` |
| 3250 | |
| 3251 | **Output example (`--json`):** |
| 3252 | ```json |
| 3253 | { |
| 3254 | "commit": "a1b2c3d4", |
| 3255 | "branch": "main", |
| 3256 | "tracks": [ |
| 3257 | {"track": "drums", "avg_velocity": 88, "peak_velocity": 110, "velocity_range": 42, "arc": "terraced"} |
| 3258 | ] |
| 3259 | } |
| 3260 | ``` |
| 3261 | |
| 3262 | **Result type:** `TrackDynamics` — fields: `name`, `avg_velocity`, `peak_velocity`, `velocity_range`, `arc` |
| 3263 | |
| 3264 | **Agent use case:** Before generating a new layer, an agent calls `muse dynamics --json` to understand the current velocity landscape. If the arrangement is `flat` across all tracks, the agent adds velocity variation to the new part. If the arc is `crescendo`, the agent ensures the new layer contributes to rather than fights the build. |
| 3265 | |
| 3266 | **Implementation:** `maestro/muse_cli/commands/dynamics.py` — `_dynamics_async` (injectable async core), `TrackDynamics` (result entity), `_render_table` / `_render_json` (renderers). Exit codes: 0 success, 2 outside repo, 3 internal. |
| 3267 | |
| 3268 | > **Stub note:** Arc classification and velocity statistics are placeholder values. Full implementation requires MIDI note velocity extraction from committed snapshot objects (future: Storpheus MIDI parse route). |
| 3269 | |
| 3270 | --- |
| 3271 | |
| 3272 | ### `muse swing` |
| 3273 | ## `muse swing` — Swing Factor Analysis and Annotation |
| 3274 | |
| 3275 | **Purpose:** Measure or annotate the swing factor of a commit — the ratio that |
| 3276 | distinguishes a straight 8th-note grid from a shuffled jazz feel. Swing is one |
| 3277 | of the most musically critical dimensions and is completely invisible to Git. |
| 3278 | |
| 3279 | **Usage:** |
| 3280 | ```bash |
| 3281 | muse swing [<commit>] [OPTIONS] |
| 3282 | ``` |
| 3283 | |
| 3284 | **Flags:** |
| 3285 | |
| 3286 | | Flag | Type | Default | Description | |
| 3287 | |------|------|---------|-------------| |
| 3288 | | `COMMIT` | positional | working tree | Commit SHA to analyze | |
| 3289 | | `--set FLOAT` | float | — | Annotate with an explicit swing factor (0.5–0.67) | |
| 3290 | | `--detect` | flag | on | Detect and display the swing factor (default) | |
| 3291 | | `--track TEXT` | string | all | Restrict to a named MIDI track (e.g. `--track bass`) | |
| 3292 | | `--compare COMMIT` | string | — | Compare HEAD swing against another commit | |
| 3293 | | `--history` | flag | off | Show swing history for the current branch | |
| 3294 | | `--json` | flag | off | Emit machine-readable JSON | |
| 3295 | |
| 3296 | **Swing factor scale:** |
| 3297 | |
| 3298 | | Range | Label | Feel | |
| 3299 | |-------|-------|------| |
| 3300 | | < 0.53 | Straight | Equal 8th notes — pop, EDM, quantized | |
| 3301 | | 0.53–0.58 | Light | Subtle shuffle — R&B, Neo-soul | |
| 3302 | | 0.58–0.63 | Medium | Noticeable swing — jazz, hip-hop | |
| 3303 | | ≥ 0.63 | Hard | Triplet feel — bebop, heavy jazz | |
| 3304 | |
| 3305 | **Output example (text):** |
| 3306 | ``` |
| 3307 | Swing factor: 0.55 (Light) |
| 3308 | Commit: a1b2c3d4 Branch: main |
| 3309 | Track: all |
| 3310 | (stub — full MIDI analysis pending) |
| 3311 | ``` |
| 3312 | |
| 3313 | **Output example (`--json`):** |
| 3314 | ```json |
| 3315 | {"factor": 0.55, "label": "Light", "commit": "a1b2c3d4", "branch": "main", "track": "all"} |
| 3316 | ``` |
| 3317 | |
| 3318 | **Result type:** `dict` with keys `factor` (float), `label` (str), `commit` (str), `branch` (str), `track` (str). Future: typed `SwingResult` dataclass. |
| 3319 | |
| 3320 | **Agent use case:** An AI generating a bass line runs `muse swing --json` to know whether to quantize straight or add shuffle. A Medium swing result means the bass should land slightly behind the grid to stay in pocket with the existing drum performance. |
| 3321 | |
| 3322 | **Implementation:** `maestro/muse_cli/commands/swing.py` — `swing_label()`, `_swing_detect_async()`, `_swing_history_async()`, `_swing_compare_async()`, formatters. Exit codes: 0 success, 1 invalid `--set` value, 2 outside repo. |
| 3323 | |
| 3324 | > **Stub note:** Returns a placeholder factor of 0.55. Full implementation requires onset-to-onset ratio measurement from committed MIDI note events (future: Storpheus MIDI parse route). |
| 3325 | |
| 3326 | --- |
| 3327 | |
| 3328 | ### `muse transpose` |
| 3329 | |
| 3330 | **Purpose:** Apply MIDI pitch transposition to all files in `muse-work/` and record the result as a new Muse commit. Transposition is the most fundamental musical transformation — this makes it a first-class versioned operation rather than a silent destructive edit. Drum channels (MIDI channel 9) are always excluded because drums are unpitched. |
| 3331 | |
| 3332 | **Usage:** |
| 3333 | ```bash |
| 3334 | muse transpose <interval> [<commit>] [OPTIONS] |
| 3335 | ``` |
| 3336 | |
| 3337 | **Flags:** |
| 3338 | |
| 3339 | | Flag | Type | Default | Description | |
| 3340 | |------|------|---------|-------------| |
| 3341 | | `<interval>` | positional | required | Signed integer (`+3`, `-5`) or named interval (`up-minor3rd`, `down-perfect5th`) | |
| 3342 | | `[<commit>]` | positional | HEAD | Source commit to transpose from | |
| 3343 | | `--track TEXT` | string | all tracks | Transpose only the MIDI track whose name contains TEXT (case-insensitive substring) | |
| 3344 | | `--section TEXT` | string | — | Transpose only a named section (stub — full implementation pending) | |
| 3345 | | `--message TEXT` | string | `"Transpose +N semitones"` | Custom commit message | |
| 3346 | | `--dry-run` | flag | off | Show what would change without writing files or creating a commit | |
| 3347 | | `--json` | flag | off | Emit machine-readable JSON output | |
| 3348 | |
| 3349 | **Interval syntax:** |
| 3350 | |
| 3351 | | Form | Example | Semitones | |
| 3352 | |------|---------|-----------| |
| 3353 | | Signed integer | `+3` | +3 | |
| 3354 | | Signed integer | `-5` | -5 | |
| 3355 | | Named up | `up-minor3rd` | +3 | |
| 3356 | | Named down | `down-perfect5th` | -7 | |
| 3357 | | Named down | `down-octave` | -12 | |
| 3358 | |
| 3359 | **Named interval identifiers:** |
| 3360 | `unison`, `minor2nd`, `major2nd`, `minor3rd`, `major3rd`, `perfect4th`, |
| 3361 | `perfect5th`, `minor6th`, `major6th`, `minor7th`, `major7th`, `octave` |
| 3362 | (prefix with `up-` or `down-`) |
| 3363 | |
| 3364 | **Output example (text):** |
| 3365 | ``` |
| 3366 | ✅ [a1b2c3d4] Transpose +3 semitones |
| 3367 | Key: Eb major → F# major |
| 3368 | Modified: 2 file(s) |
| 3369 | ✅ tracks/melody.mid |
| 3370 | ✅ tracks/bass.mid |
| 3371 | Skipped: 1 file(s) (non-MIDI or no pitched notes) |
| 3372 | ``` |
| 3373 | |
| 3374 | **Output example (`--json`):** |
| 3375 | ```json |
| 3376 | { |
| 3377 | "source_commit_id": "a1b2c3d4...", |
| 3378 | "semitones": 3, |
| 3379 | "files_modified": ["tracks/melody.mid", "tracks/bass.mid"], |
| 3380 | "files_skipped": ["notes.json"], |
| 3381 | "new_commit_id": "b2c3d4e5...", |
| 3382 | "original_key": "Eb major", |
| 3383 | "new_key": "F# major", |
| 3384 | "dry_run": false |
| 3385 | } |
| 3386 | ``` |
| 3387 | |
| 3388 | **Result type:** `TransposeResult` — fields: `source_commit_id`, `semitones`, `files_modified`, `files_skipped`, `new_commit_id` (None in dry-run), `original_key`, `new_key`, `dry_run`. |
| 3389 | |
| 3390 | **Key metadata update:** If the source commit has a `key` field in its `metadata` JSON blob (e.g. `"Eb major"`), the new commit's `metadata.key` is automatically updated to reflect the transposition (e.g. `"F# major"` after `+3`). The service uses flat note names for accidentals (Db, Eb, Ab, Bb) — G# is stored as Ab, etc. |
| 3391 | |
| 3392 | **MIDI transposition rules:** |
| 3393 | - Scans `muse-work/` recursively for `.mid` and `.midi` files. |
| 3394 | - Parses MTrk chunks and modifies Note-On (0x9n) and Note-Off (0x8n) events. |
| 3395 | - **Channel 9 (drums) is never transposed** — drums are unpitched and shifting their note numbers would change the GM drum map mapping. |
| 3396 | - Notes are clamped to [0, 127] to stay within MIDI range. |
| 3397 | - All other events (meta, sysex, CC, program change, pitch bend) are preserved byte-for-byte. |
| 3398 | - Track length headers remain unchanged — only note byte values differ. |
| 3399 | |
| 3400 | **Agent use case:** A producer experimenting with key runs `muse transpose +3` and immediately has a versioned, reversible pitch shift on the full arrangement. The agent can then run `muse context --json` to confirm the new key before generating new parts that fit the updated harmonic center. The `--dry-run` flag lets agents preview impact before committing, and the `--track` flag lets them scope transposition to a single instrument (e.g. `--track melody`) without shifting the bass or chords. |
| 3401 | |
| 3402 | **Implementation:** `maestro/services/muse_transpose.py` — `parse_interval`, `update_key_metadata`, `transpose_midi_bytes`, `apply_transpose_to_workdir`, `TransposeResult`. CLI: `maestro/muse_cli/commands/transpose.py` — `_transpose_async` (injectable async core), `_print_result` (renderer). Exit codes: 0 success, 1 user error (bad interval, empty workdir), 2 outside repo, 3 internal error. |
| 3403 | |
| 3404 | > **Section filter note:** `--section TEXT` is accepted by the CLI and logged as a warning but not yet applied. Full section-scoped transposition requires section boundary markers embedded in committed MIDI metadata — tracked as a follow-up enhancement. |
| 3405 | |
| 3406 | --- |
| 3407 | |
| 3408 | ### `muse recall` |
| 3409 | |
| 3410 | **Purpose:** Search the full commit history using natural language. Returns ranked |
| 3411 | commits whose messages best match the query. The musical memory retrieval command — |
| 3412 | "find me that arrangement I made three months ago." |
| 3413 | |
| 3414 | **Usage:** |
| 3415 | ```bash |
| 3416 | muse recall "<description>" [OPTIONS] |
| 3417 | ``` |
| 3418 | |
| 3419 | **Flags:** |
| 3420 | |
| 3421 | | Flag | Type | Default | Description | |
| 3422 | |------|------|---------|-------------| |
| 3423 | | `QUERY` | positional | required | Natural-language description of what to find | |
| 3424 | | `--limit N` | int | 5 | Maximum results to return | |
| 3425 | | `--threshold FLOAT` | float | 0.6 | Minimum similarity score (0.0–1.0) | |
| 3426 | | `--branch TEXT` | string | all branches | Restrict search to a specific branch | |
| 3427 | | `--since DATE` | `YYYY-MM-DD` | — | Only search commits after this date | |
| 3428 | | `--until DATE` | `YYYY-MM-DD` | — | Only search commits before this date | |
| 3429 | | `--json` | flag | off | Emit structured JSON array | |
| 3430 | |
| 3431 | **Scoring (current stub):** Normalized keyword overlap coefficient — `|Q ∩ M| / |Q|` — where Q is the set of query tokens and M is the set of message tokens. Score 1.0 means every query word appeared in the commit message. |
| 3432 | |
| 3433 | **Output example (text):** |
| 3434 | ``` |
| 3435 | Recall: "dark jazz bassline" |
| 3436 | keyword match · threshold 0.60 · limit 5 |
| 3437 | |
| 3438 | 1. [a1b2c3d4] 2026-02-15 22:00 boom bap demo take 3 score 0.67 |
| 3439 | 2. [f9e8d7c6] 2026-02-10 18:30 jazz bass overdub session score 0.50 |
| 3440 | ``` |
| 3441 | |
| 3442 | **Result type:** `RecallResult` (TypedDict) — fields: `rank` (int), `score` (float), `commit_id` (str), `date` (str), `branch` (str), `message` (str) |
| 3443 | |
| 3444 | **Agent use case:** An agent asked to "generate something like that funky bass riff from last month" calls `muse recall "funky bass" --json --limit 3` to retrieve the closest historical commits, then uses those as style references for generation. |
| 3445 | |
| 3446 | **Implementation:** `maestro/muse_cli/commands/recall.py` — `RecallResult` (TypedDict), `_tokenize()`, `_score()`, `_recall_async()`. Exit codes: 0 success, 1 bad date format, 2 outside repo. |
| 3447 | |
| 3448 | > **Stub note:** Uses keyword overlap. Full implementation: vector embeddings stored in Qdrant, cosine similarity retrieval. The CLI interface will not change when vector search is added. |
| 3449 | |
| 3450 | --- |
| 3451 | |
| 3452 | ### `muse rebase` |
| 3453 | |
| 3454 | **Purpose:** Rebase commits onto a new base, producing a linear history. Given a current branch that has diverged from `<upstream>`, `muse rebase <upstream>` collects all commits since the divergence point and replays them one-by-one on top of the upstream tip — each producing a new commit ID with the same snapshot delta. An AI agent uses this to linearise a sequence of late-night fixup commits before merging to main, making the musical narrative readable and bisectable. |
| 3455 | |
| 3456 | **Usage:** |
| 3457 | ```bash |
| 3458 | muse rebase <upstream> [OPTIONS] |
| 3459 | muse rebase --continue |
| 3460 | muse rebase --abort |
| 3461 | ``` |
| 3462 | |
| 3463 | **Flags:** |
| 3464 | |
| 3465 | | Flag | Type | Default | Description | |
| 3466 | |------|------|---------|-------------| |
| 3467 | | `UPSTREAM` | positional | — | Branch name or commit ID to rebase onto. Omit with `--continue` / `--abort`. | |
| 3468 | | `--interactive` / `-i` | flag | off | Open `$EDITOR` with a rebase plan (pick/squash/drop per commit) before executing. | |
| 3469 | | `--autosquash` | flag | off | Automatically move `fixup! <msg>` commits immediately after their matching target commit. | |
| 3470 | | `--rebase-merges` | flag | off | Preserve merge commits during replay (experimental). | |
| 3471 | | `--continue` | flag | off | Resume a rebase that was paused by a conflict. | |
| 3472 | | `--abort` | flag | off | Cancel the in-progress rebase and restore the branch to its original HEAD. | |
| 3473 | |
| 3474 | **Output example (linear rebase):** |
| 3475 | ``` |
| 3476 | ✅ Rebased 3 commit(s) onto 'dev' [main a1b2c3d4] |
| 3477 | ``` |
| 3478 | |
| 3479 | **Output example (conflict):** |
| 3480 | ``` |
| 3481 | ❌ Conflict while replaying c2d3e4f5 ('Add strings'): |
| 3482 | both modified: tracks/strings.mid |
| 3483 | Resolve conflicts, then run 'muse rebase --continue'. |
| 3484 | ``` |
| 3485 | |
| 3486 | **Output example (abort):** |
| 3487 | ``` |
| 3488 | ✅ Rebase aborted. Branch 'main' restored to deadbeef. |
| 3489 | ``` |
| 3490 | |
| 3491 | **Interactive plan format:** |
| 3492 | ``` |
| 3493 | # Interactive rebase plan. |
| 3494 | # Actions: pick, squash (fold into previous), drop (skip), fixup (squash no msg), reword |
| 3495 | # Lines starting with '#' are ignored. |
| 3496 | |
| 3497 | pick a1b2c3d4 Add piano |
| 3498 | squash b2c3d4e5 Tweak piano velocity |
| 3499 | drop c3d4e5f6 Stale WIP commit |
| 3500 | pick d4e5f6a7 Add strings |
| 3501 | ``` |
| 3502 | |
| 3503 | **Result type:** `RebaseResult` (dataclass, frozen) — fields: |
| 3504 | - `branch` (str): The branch that was rebased. |
| 3505 | - `upstream` (str): The upstream branch or commit ref. |
| 3506 | - `upstream_commit_id` (str): Resolved commit ID of the upstream tip. |
| 3507 | - `base_commit_id` (str): LCA commit where the histories diverged. |
| 3508 | - `replayed` (tuple[RebaseCommitPair, ...]): Ordered list of (original, new) commit ID pairs. |
| 3509 | - `conflict_paths` (tuple[str, ...]): Conflicting paths (empty on clean completion). |
| 3510 | - `aborted` (bool): True when `--abort` cleared the in-progress rebase. |
| 3511 | - `noop` (bool): True when there were no commits to replay. |
| 3512 | - `autosquash_applied` (bool): True when `--autosquash` reordered commits. |
| 3513 | |
| 3514 | **State file:** `.muse/REBASE_STATE.json` — written on conflict; cleared on `--continue` completion or `--abort`. Contains: `upstream_commit`, `base_commit`, `original_branch`, `original_head`, `commits_to_replay`, `current_onto`, `completed_pairs`, `current_commit`, `conflict_paths`. |
| 3515 | |
| 3516 | **Agent use case:** An agent that maintains a feature branch can call `muse rebase dev` before opening a merge request. If conflicts are detected, the agent receives the conflict paths in `REBASE_STATE.json`, resolves them by picking the correct version of each affected file, then calls `muse rebase --continue`. The `--autosquash` flag is useful after a generation loop that emits intermediate `fixup!` commits — the agent can clean up history automatically before finalising. |
| 3517 | |
| 3518 | **Algorithm:** |
| 3519 | 1. LCA of HEAD and upstream (via BFS over the commit graph). |
| 3520 | 2. Collect commits on the current branch since the LCA (oldest first). |
| 3521 | 3. For each commit, compute its snapshot delta relative to its own parent. |
| 3522 | 4. Apply the delta onto the current onto-tip manifest; detect conflicts. |
| 3523 | 5. On conflict: write `REBASE_STATE.json` and exit 1 (await `--continue`). |
| 3524 | 6. On success: insert a new commit record; advance the onto pointer. |
| 3525 | 7. After all commits: write the final commit ID to the branch ref. |
| 3526 | |
| 3527 | **Implementation:** `maestro/muse_cli/commands/rebase.py` (Typer CLI), `maestro/services/muse_rebase.py` (`_rebase_async`, `_rebase_continue_async`, `_rebase_abort_async`, `RebaseResult`, `RebaseState`, `InteractivePlan`, `compute_delta`, `apply_delta`, `apply_autosquash`). |
| 3528 | |
| 3529 | --- |
| 3530 | |
| 3531 | ### `muse stash` |
| 3532 | |
| 3533 | **Purpose:** Temporarily shelve uncommitted muse-work/ changes so the producer can switch context without losing work-in-progress. Push saves the current working state into a filesystem stack (`.muse/stash/`) and restores HEAD; pop brings it back. An AI agent uses this when it needs to checkpoint partial generation state, switch to a different branch task, then resume exactly where it left off. |
| 3534 | |
| 3535 | **Usage:** |
| 3536 | ```bash |
| 3537 | muse stash [push] [OPTIONS] # save + restore HEAD (default subcommand) |
| 3538 | muse stash push [OPTIONS] # explicit push |
| 3539 | muse stash pop [stash@{N}] # apply + drop most recent entry |
| 3540 | muse stash apply [stash@{N}] # apply without dropping |
| 3541 | muse stash list # list all entries |
| 3542 | muse stash drop [stash@{N}] # remove a specific entry |
| 3543 | muse stash clear [--yes] # remove all entries |
| 3544 | ``` |
| 3545 | |
| 3546 | **Flags (push):** |
| 3547 | |
| 3548 | | Flag | Type | Default | Description | |
| 3549 | |------|------|---------|-------------| |
| 3550 | | `--message / -m TEXT` | string | `"On <branch>: stash"` | Label for this stash entry | |
| 3551 | | `--track TEXT` | string | — | Scope to `tracks/<track>/` paths only | |
| 3552 | | `--section TEXT` | string | — | Scope to `sections/<section>/` paths only | |
| 3553 | |
| 3554 | **Flags (clear):** |
| 3555 | |
| 3556 | | Flag | Type | Default | Description | |
| 3557 | |------|------|---------|-------------| |
| 3558 | | `--yes / -y` | flag | off | Skip confirmation prompt | |
| 3559 | |
| 3560 | **Output example (push):** |
| 3561 | ``` |
| 3562 | Saved working directory and index state stash@{0} |
| 3563 | On main: half-finished chorus rearrangement |
| 3564 | ``` |
| 3565 | |
| 3566 | **Output example (pop):** |
| 3567 | ``` |
| 3568 | ✅ Applied stash@{0}: On main: half-finished chorus rearrangement |
| 3569 | 3 file(s) restored. |
| 3570 | Dropped stash@{0} |
| 3571 | ``` |
| 3572 | |
| 3573 | **Output example (list):** |
| 3574 | ``` |
| 3575 | stash@{0}: On main: WIP chorus changes |
| 3576 | stash@{1}: On main: drums experiment |
| 3577 | ``` |
| 3578 | |
| 3579 | **Result types:** |
| 3580 | |
| 3581 | `StashPushResult` (dataclass, frozen) — fields: |
| 3582 | - `stash_ref` (str): Human label (e.g. `"stash@{0}"`); empty string when nothing was stashed. |
| 3583 | - `message` (str): Label stored in the entry. |
| 3584 | - `branch` (str): Branch name at the time of push. |
| 3585 | - `files_stashed` (int): Number of files saved into the stash. |
| 3586 | - `head_restored` (bool): Whether HEAD snapshot was restored to muse-work/. |
| 3587 | - `missing_head` (tuple[str, ...]): Paths that could not be restored from the object store after push. |
| 3588 | |
| 3589 | `StashApplyResult` (dataclass, frozen) — fields: |
| 3590 | - `stash_ref` (str): Human label of the entry that was applied. |
| 3591 | - `message` (str): The entry's label. |
| 3592 | - `files_applied` (int): Number of files written to muse-work/. |
| 3593 | - `missing` (tuple[str, ...]): Paths whose object bytes were absent from the store. |
| 3594 | - `dropped` (bool): True when the entry was removed (pop); False for apply. |
| 3595 | |
| 3596 | `StashEntry` (dataclass, frozen) — fields: |
| 3597 | - `stash_id` (str): Unique filesystem stem. |
| 3598 | - `index` (int): Position in the stack (0 = most recent). |
| 3599 | - `branch` (str): Branch at the time of stash. |
| 3600 | - `message` (str): Human label. |
| 3601 | - `created_at` (str): ISO-8601 timestamp. |
| 3602 | - `manifest` (dict[str, str]): `{rel_path: sha256_object_id}` of stashed files. |
| 3603 | - `track` (str | None): Track scope used during push (or None). |
| 3604 | - `section` (str | None): Section scope used during push (or None). |
| 3605 | |
| 3606 | **Storage:** Filesystem-only. Each entry is a JSON file in `.muse/stash/stash-<timestamp>-<uuid8>.json`. File content is preserved in the existing `.muse/objects/<oid[:2]>/<oid[2:]>` content-addressed blob store (same layout as `muse commit` and `muse reset --hard`). No Postgres rows are written. |
| 3607 | |
| 3608 | **Agent use case:** An AI composition agent mid-generation on the chorus wants to quickly address a client request on the intro. It calls `muse stash` to save the in-progress chorus state (files + object blobs), then `muse checkout intro-branch` to switch context, makes the intro fix, then returns and calls `muse stash pop` to restore the chorus work exactly as it was. For scoped saves, `--track drums` limits the stash to drum files only, leaving other tracks untouched in muse-work/. |
| 3609 | |
| 3610 | **Conflict strategy on apply:** Last-write-wins. Files in muse-work/ not in the stash manifest are left untouched. Files whose objects are missing from the store are reported in `missing` but do not abort the operation. |
| 3611 | |
| 3612 | **Stack ordering:** `stash@{0}` is always the most recently pushed entry. `stash@{N}` refers to the Nth entry in reverse chronological order. Multiple `push` calls build a stack; `pop` always takes from the top. |
| 3613 | |
| 3614 | **Implementation:** `maestro/muse_cli/commands/stash.py` (Typer CLI with subcommands), `maestro/services/muse_stash.py` (`push_stash`, `apply_stash`, `list_stash`, `drop_stash`, `clear_stash`, result types). |
| 3615 | |
| 3616 | --- |
| 3617 | |
| 3618 | ### `muse revert` |
| 3619 | |
| 3620 | **Purpose:** Create a new commit that undoes a prior commit without rewriting history. The safe undo: given commit C with parent P, `muse revert <commit>` creates a forward commit whose snapshot is P's state (the world before C was applied). An AI agent uses this after discovering a committed arrangement degraded the score — rather than resetting (which loses history), the revert preserves the full audit trail. |
| 3621 | |
| 3622 | **Usage:** |
| 3623 | ```bash |
| 3624 | muse revert <commit> [OPTIONS] |
| 3625 | ``` |
| 3626 | |
| 3627 | **Flags:** |
| 3628 | |
| 3629 | | Flag | Type | Default | Description | |
| 3630 | |------|------|---------|-------------| |
| 3631 | | `COMMIT` | positional | required | Commit ID to revert (full or abbreviated SHA) | |
| 3632 | | `--no-commit` | flag | off | Apply the inverse changes to muse-work/ without creating a new commit | |
| 3633 | | `--track TEXT` | string | — | Scope the revert to paths under `tracks/<track>/` only | |
| 3634 | | `--section TEXT` | string | — | Scope the revert to paths under `sections/<section>/` only | |
| 3635 | |
| 3636 | **Output example (full revert):** |
| 3637 | ``` |
| 3638 | ✅ [main a1b2c3d4] Revert 'bad drum arrangement' |
| 3639 | ``` |
| 3640 | |
| 3641 | **Output example (scoped revert):** |
| 3642 | ``` |
| 3643 | ✅ [main b2c3d4e5] Revert 'bad drum arrangement' (scoped to 2 path(s)) |
| 3644 | ``` |
| 3645 | |
| 3646 | **Output example (--no-commit):** |
| 3647 | ``` |
| 3648 | ✅ Staged revert (--no-commit). Files removed: |
| 3649 | deleted: tracks/drums/fill.mid |
| 3650 | ``` |
| 3651 | |
| 3652 | **Result type:** `RevertResult` (dataclass, frozen) — fields: |
| 3653 | - `commit_id` (str): New commit ID (empty string when `--no-commit` or noop). |
| 3654 | - `target_commit_id` (str): Commit that was reverted. |
| 3655 | - `parent_commit_id` (str): Parent of the reverted commit (whose snapshot was restored). |
| 3656 | - `revert_snapshot_id` (str): Snapshot ID of the reverted state. |
| 3657 | - `message` (str): Auto-generated commit message (`"Revert '<original message>'"`) |
| 3658 | - `no_commit` (bool): Whether the revert was staged only. |
| 3659 | - `noop` (bool): True when reverting would produce no change. |
| 3660 | - `scoped_paths` (tuple[str, ...]): Paths selectively reverted (empty = full revert). |
| 3661 | - `paths_deleted` (tuple[str, ...]): Files removed from muse-work/ during `--no-commit`. |
| 3662 | - `paths_missing` (tuple[str, ...]): Files that could not be auto-restored (no object bytes). |
| 3663 | - `branch` (str): Branch on which the revert commit was created. |
| 3664 | |
| 3665 | **Agent use case:** An agent that evaluates generated arrangements after each commit can run `muse log --json` to detect quality regressions, then call `muse revert <bad_commit>` to undo the offending commit and resume generation from the prior good state. For instrument-specific corrections, `--track drums` limits the revert to drum tracks only, preserving bass and melodic changes. |
| 3666 | |
| 3667 | **Blocking behaviour:** Blocked during an in-progress merge with unresolved conflicts — exits 1 with a clear message directing the user to resolve conflicts first. |
| 3668 | |
| 3669 | **Object store limitation:** The Muse CLI stores file manifests (path→sha256) in Postgres but does not retain raw file bytes. For `--no-commit`, files that should be restored but whose bytes are no longer in `muse-work/` are listed as warnings in `paths_missing`. The commit-only path (default) is unaffected — it references an existing snapshot ID directly with no file restoration needed. |
| 3670 | |
| 3671 | **Implementation:** `maestro/muse_cli/commands/revert.py` (Typer CLI), `maestro/services/muse_revert.py` (`_revert_async`, `compute_revert_manifest`, `apply_revert_to_workdir`, `RevertResult`). |
| 3672 | |
| 3673 | --- |
| 3674 | |
| 3675 | ### `muse cherry-pick` |
| 3676 | |
| 3677 | **Purpose:** Apply the changes introduced by a single commit from any branch onto the current branch, without merging the entire source branch. An AI agent uses this to transplant a winning take (the perfect guitar solo, the ideal bass groove) from an experimental branch into main without importing 20 unrelated commits. |
| 3678 | |
| 3679 | **Usage:** |
| 3680 | ```bash |
| 3681 | muse cherry-pick <commit> [OPTIONS] |
| 3682 | ``` |
| 3683 | |
| 3684 | **Flags:** |
| 3685 | |
| 3686 | | Flag | Type | Default | Description | |
| 3687 | |------|------|---------|-------------| |
| 3688 | | `COMMIT` | positional | required | Commit ID to cherry-pick (full or abbreviated SHA) | |
| 3689 | | `--no-commit` | flag | off | Apply changes to muse-work/ without creating a new commit | |
| 3690 | | `--continue` | flag | off | Resume after resolving conflicts from a paused cherry-pick | |
| 3691 | | `--abort` | flag | off | Abort an in-progress cherry-pick and restore the pre-cherry-pick HEAD | |
| 3692 | |
| 3693 | **Output example (clean apply):** |
| 3694 | ``` |
| 3695 | ✅ [main a1b2c3d4] add guitar solo |
| 3696 | (cherry picked from commit f3e2d1c0) |
| 3697 | ``` |
| 3698 | |
| 3699 | **Output example (conflict):** |
| 3700 | ``` |
| 3701 | ❌ Cherry-pick conflict in 1 file(s): |
| 3702 | both modified: tracks/guitar/solo.mid |
| 3703 | Fix conflicts and run 'muse cherry-pick --continue' to create the commit. |
| 3704 | ``` |
| 3705 | |
| 3706 | **Output example (--abort):** |
| 3707 | ``` |
| 3708 | ✅ Cherry-pick aborted. HEAD restored to a1b2c3d4. |
| 3709 | ``` |
| 3710 | |
| 3711 | **Algorithm:** 3-way merge model — base=P (cherry commit's parent), ours=HEAD, theirs=C (cherry commit). For each path C changed vs P: if HEAD also changed that path differently → conflict; otherwise apply C's version on top of HEAD. Commit message is prefixed with `(cherry picked from commit <short-id>)` for auditability. |
| 3712 | |
| 3713 | **State file:** `.muse/CHERRY_PICK_STATE.json` — written when conflicts are detected, consumed by `--continue` and `--abort`. |
| 3714 | |
| 3715 | ```json |
| 3716 | { |
| 3717 | "cherry_commit": "f3e2d1c0...", |
| 3718 | "head_commit": "a1b2c3d4...", |
| 3719 | "conflict_paths": ["tracks/guitar/solo.mid"] |
| 3720 | } |
| 3721 | ``` |
| 3722 | |
| 3723 | **Result type:** `CherryPickResult` (dataclass, frozen) — fields: |
| 3724 | - `commit_id` (str): New commit ID (empty when `--no-commit` or conflict). |
| 3725 | - `cherry_commit_id` (str): Source commit that was cherry-picked. |
| 3726 | - `head_commit_id` (str): HEAD commit at cherry-pick time. |
| 3727 | - `new_snapshot_id` (str): Snapshot ID of the resulting state. |
| 3728 | - `message` (str): Commit message with cherry-pick attribution suffix. |
| 3729 | - `no_commit` (bool): Whether changes were staged but not committed. |
| 3730 | - `conflict` (bool): True when conflicts were detected and state file was written. |
| 3731 | - `conflict_paths` (tuple[str, ...]): Conflicting paths (non-empty iff `conflict=True`). |
| 3732 | - `branch` (str): Branch on which the new commit was created. |
| 3733 | |
| 3734 | **Agent use case:** An AI music agent runs `muse log --json` across branches to score each commit, identifies the highest-scoring take on `experiment/guitar-solo`, then calls `muse cherry-pick <commit>` to transplant just that take into main. After cherry-pick, the agent can immediately continue composing on the enriched HEAD without merging the entire experimental branch. |
| 3735 | |
| 3736 | **Blocking behaviour:** |
| 3737 | - Blocked when a merge is in progress with unresolved conflicts (exits 1). |
| 3738 | - Blocked when a previous cherry-pick is in progress (exits 1 — use `--continue` or `--abort`). |
| 3739 | - Cherry-picking HEAD itself exits 0 (noop). |
| 3740 | |
| 3741 | **Implementation:** `maestro/muse_cli/commands/cherry_pick.py` (Typer CLI), `maestro/services/muse_cherry_pick.py` (`_cherry_pick_async`, `_cherry_pick_continue_async`, `_cherry_pick_abort_async`, `compute_cherry_manifest`, `CherryPickResult`, `CherryPickState`). |
| 3742 | |
| 3743 | --- |
| 3744 | |
| 3745 | ### `muse grep` |
| 3746 | |
| 3747 | **Purpose:** Search all commits for a musical pattern — a note sequence, interval |
| 3748 | pattern, or chord symbol. Currently searches commit messages; full MIDI content |
| 3749 | search is the planned implementation. |
| 3750 | {"factor": 0.55, "label": "Light", "commit": "a1b2c3d4", "branch": "main", "track": "all", "source": "stub"} |
| 3751 | ``` |
| 3752 | |
| 3753 | **Result type:** `SwingDetectResult` (TypedDict) — fields: `factor` (float), |
| 3754 | `label` (str), `commit` (str), `branch` (str), `track` (str), `source` (str). |
| 3755 | `--compare` returns `SwingCompareResult` — fields: `head` (SwingDetectResult), |
| 3756 | `compare` (SwingDetectResult), `delta` (float). See |
| 3757 | `docs/reference/type_contracts.md § Muse CLI Types`. |
| 3758 | |
| 3759 | **Agent use case:** An AI generating a bass line runs `muse swing --json` to |
| 3760 | know whether to quantize straight or add shuffle. A Medium swing result means |
| 3761 | the bass should land slightly behind the grid to stay in pocket with the |
| 3762 | existing drum performance. |
| 3763 | |
| 3764 | **Implementation:** `maestro/muse_cli/commands/swing.py` — `swing_label()`, |
| 3765 | `_swing_detect_async()`, `_swing_history_async()`, `_swing_compare_async()`, |
| 3766 | `_format_detect()`, `_format_history()`, `_format_compare()`. Exit codes: |
| 3767 | 0 success, 1 invalid `--set` value, 2 outside repo, 3 internal error. |
| 3768 | |
| 3769 | > **Stub note:** Returns a placeholder factor of 0.55. Full implementation |
| 3770 | > requires onset-to-onset ratio measurement from committed MIDI note events |
| 3771 | > (future: Storpheus MIDI parse route). |
| 3772 | |
| 3773 | --- |
| 3774 | |
| 3775 | ## `muse chord-map` — Chord Progression Timeline |
| 3776 | |
| 3777 | `muse chord-map [<commit>]` extracts and displays the chord timeline of a |
| 3778 | specific commit — showing *when* each chord occurs in the arrangement, not |
| 3779 | just which chords are present. This is the foundation for AI-generated |
| 3780 | harmonic analysis and chord-substitution suggestions. |
| 3781 | |
| 3782 | **Purpose:** Give AI agents a precise picture of the harmonic structure at any |
| 3783 | commit so they can reason about the progression in time, propose substitutions, |
| 3784 | or detect tension/resolution cycles. |
| 3785 | |
| 3786 | ### Flags |
| 3787 | |
| 3788 | | Flag | Type | Default | Description | |
| 3789 | |------|------|---------|-------------| |
| 3790 | | `COMMIT` | positional | HEAD | Commit ref to analyse. | |
| 3791 | | `--section TEXT` | string | — | Scope to a named section/region. | |
| 3792 | | `--track TEXT` | string | — | Scope to a specific track (e.g. piano for chord voicings). | |
| 3793 | | `--bar-grid / --no-bar-grid` | flag | on | Align chord events to musical bar numbers. | |
| 3794 | | `--format FORMAT` | string | `text` | Output format: `text`, `json`, or `mermaid`. | |
| 3795 | | `--voice-leading` | flag | off | Show how individual notes move between consecutive chords. | |
| 3796 | |
| 3797 | ### Output example |
| 3798 | |
| 3799 | **Text (default, `--bar-grid`):** |
| 3800 | |
| 3801 | ``` |
| 3802 | Chord map -- commit a1b2c3d4 (HEAD -> main) |
| 3803 | |
| 3804 | Bar 1: Cmaj9 ######## |
| 3805 | Bar 2: Am11 ######## |
| 3806 | Bar 3: Dm7 #### Gsus4 #### |
| 3807 | Bar 4: G7 ######## |
| 3808 | Bar 5: Cmaj9 ######## |
| 3809 | |
| 3810 | (stub -- full MIDI chord detection pending) |
| 3811 | ``` |
| 3812 | |
| 3813 | **With `--voice-leading`:** |
| 3814 | |
| 3815 | ``` |
| 3816 | Chord map -- commit a1b2c3d4 (HEAD -> main) |
| 3817 | |
| 3818 | Bar 2: Cmaj9 -> Am11 (E->E, G->G, B->A, D->C) |
| 3819 | Bar 3: Am11 -> Dm7 (A->D, C->C, E->F, G->A) |
| 3820 | ... |
| 3821 | ``` |
| 3822 | |
| 3823 | **JSON (`--format json`):** |
| 3824 | |
| 3825 | ```json |
| 3826 | { |
| 3827 | "commit": "a1b2c3d4", |
| 3828 | "branch": "main", |
| 3829 | "track": "all", |
| 3830 | "section": "", |
| 3831 | "chords": [ |
| 3832 | { "bar": 1, "beat": 1, "chord": "Cmaj9", "duration": 1.0, "track": "keys" }, |
| 3833 | { "bar": 2, "beat": 1, "chord": "Am11", "duration": 1.0, "track": "keys" } |
| 3834 | ], |
| 3835 | "voice_leading": [] |
| 3836 | } |
| 3837 | ``` |
| 3838 | |
| 3839 | **Mermaid (`--format mermaid`):** |
| 3840 | |
| 3841 | ``` |
| 3842 | timeline |
| 3843 | title Chord map -- a1b2c3d4 |
| 3844 | section Bar 1 |
| 3845 | Cmaj9 |
| 3846 | section Bar 2 |
| 3847 | Am11 |
| 3848 | ``` |
| 3849 | |
| 3850 | ### Result type |
| 3851 | |
| 3852 | `muse chord-map` returns a `ChordMapResult` TypedDict (see |
| 3853 | `docs/reference/type_contracts.md § ChordMapResult`). Each chord event is a |
| 3854 | `ChordEvent`; each voice-leading step is a `VoiceLeadingStep`. |
| 3855 | |
| 3856 | ### Agent use case |
| 3857 | |
| 3858 | An AI agent writing a counter-melody calls `muse chord-map HEAD --format json` |
| 3859 | to retrieve the exact bar-by-bar harmonic grid. It then selects chord tones |
| 3860 | that land on the strong beats. With `--voice-leading`, the agent can also |
| 3861 | detect smooth inner-voice motion and mirror it in the new part. |
| 3862 | |
| 3863 | **Implementation:** `maestro/muse_cli/commands/chord_map.py` — |
| 3864 | `_chord_map_async()`, `_render_text()`, `_render_json()`, `_render_mermaid()`. |
| 3865 | Exit codes: 0 success, 1 invalid `--format`, 2 outside repo, 3 internal error. |
| 3866 | |
| 3867 | > **Stub note:** Returns a placeholder I-vi-ii-V-I progression. Full |
| 3868 | > implementation requires chord-detection from committed MIDI note events |
| 3869 | > (future: Storpheus MIDI parse route). |
| 3870 | |
| 3871 | --- |
| 3872 | |
| 3873 | ## `muse key` — Read or Annotate the Musical Key of a Commit |
| 3874 | |
| 3875 | `muse key` reads or annotates the tonal center (key) of a Muse commit. |
| 3876 | Key is the most fundamental property of a piece of music — knowing the key is a |
| 3877 | prerequisite for harmonic generation, chord-scale selection, and tonal arc |
| 3878 | analysis. An AI agent calls `muse key --json` before generating new material to |
| 3879 | stay in the correct tonal center. |
| 3880 | |
| 3881 | **Usage:** |
| 3882 | ```bash |
| 3883 | muse key [<commit>] [OPTIONS] |
| 3884 | ``` |
| 3885 | |
| 3886 | **Flags:** |
| 3887 | |
| 3888 | | Flag | Type | Default | Description | |
| 3889 | |------|------|---------|-------------| |
| 3890 | | `<commit>` | arg | HEAD | Commit SHA to analyse | |
| 3891 | | `--set KEY` | str | — | Annotate with an explicit key (e.g. `"F# minor"`) | |
| 3892 | | `--detect` | flag | on | Detect and display the key (default behaviour) | |
| 3893 | | `--track TEXT` | str | — | Restrict key detection to a specific instrument track | |
| 3894 | | `--relative` | flag | off | Show the relative key (e.g. `"Eb major / C minor"`) | |
| 3895 | | `--history` | flag | off | Show how the key changed across all commits | |
| 3896 | | `--json` | flag | off | Emit machine-readable JSON for agent consumption | |
| 3897 | |
| 3898 | **Key format:** `<tonic> <mode>` — e.g. `"F# minor"`, `"Eb major"`. Valid tonics |
| 3899 | include all 12 chromatic pitches with `#` and `b` enharmonics. Valid modes are |
| 3900 | `major` and `minor`. |
| 3901 | |
| 3902 | **Output example (text):** |
| 3903 | ``` |
| 3904 | Key: C major |
| 3905 | Commit: a1b2c3d4 Branch: main |
| 3906 | Track: all |
| 3907 | (stub — full MIDI key detection pending) |
| 3908 | ``` |
| 3909 | |
| 3910 | **Output example (`--relative`):** |
| 3911 | ``` |
| 3912 | Key: A minor |
| 3913 | Commit: a1b2c3d4 Branch: main |
| 3914 | Track: all |
| 3915 | Relative: C major |
| 3916 | (stub — full MIDI key detection pending) |
| 3917 | ``` |
| 3918 | |
| 3919 | **Output example (`--json`):** |
| 3920 | ```json |
| 3921 | { |
| 3922 | "key": "C major", |
| 3923 | "tonic": "C", |
| 3924 | "mode": "major", |
| 3925 | "relative": "", |
| 3926 | "commit": "a1b2c3d4", |
| 3927 | "branch": "main", |
| 3928 | "track": "all", |
| 3929 | "source": "stub" |
| 3930 | } |
| 3931 | ``` |
| 3932 | |
| 3933 | **Output example (`--history --json`):** |
| 3934 | ```json |
| 3935 | [ |
| 3936 | {"commit": "a1b2c3d4", "key": "C major", "tonic": "C", "mode": "major", "source": "stub"} |
| 3937 | ] |
| 3938 | ``` |
| 3939 | |
| 3940 | **Result types:** `KeyDetectResult` (TypedDict) — fields: `key` (str), `tonic` (str), |
| 3941 | `mode` (str), `relative` (str), `commit` (str), `branch` (str), `track` (str), |
| 3942 | `source` (str). History mode returns `list[KeyHistoryEntry]`. See |
| 3943 | `docs/reference/type_contracts.md § Muse CLI Types`. |
| 3944 | |
| 3945 | **Agent use case:** Before generating a chord progression or melody, an agent runs |
| 3946 | `muse key --json` to discover the tonal center of the most recent commit. |
| 3947 | `muse key --history --json` reveals modulations across an album — if the key |
| 3948 | changed from D major to F major at commit `abc123`, the agent knows a modulation |
| 3949 | occurred and can generate transitional material accordingly. |
| 3950 | |
| 3951 | **Implementation:** `maestro/muse_cli/commands/key.py` — `parse_key()`, |
| 3952 | `relative_key()`, `_key_detect_async()`, `_key_history_async()`, |
| 3953 | `_format_detect()`, `_format_history()`. Exit codes: 0 success, 1 invalid |
| 3954 | `--set` value, 2 outside repo, 3 internal error. |
| 3955 | |
| 3956 | > **Stub note:** Returns a placeholder key of `C major`. Full implementation |
| 3957 | > requires chromatic pitch-class distribution analysis from committed MIDI note |
| 3958 | > events (Krumhansl-Schmuckler or similar key-finding algorithm, future: |
| 3959 | > Storpheus MIDI parse route). |
| 3960 | |
| 3961 | --- |
| 3962 | |
| 3963 | ## `muse ask` — Natural Language Query over Musical History |
| 3964 | |
| 3965 | `muse ask "<question>"` searches Muse commit messages for keywords derived |
| 3966 | from the user's question and returns matching commits in a structured answer. |
| 3967 | |
| 3968 | **Purpose:** Give musicians and AI agents a conversational interface to |
| 3969 | retrieve relevant moments from the composition history without remembering |
| 3970 | exact commit messages or timestamps. |
| 3971 | |
| 3972 | ### Flags |
| 3973 | |
| 3974 | | Flag | Default | Description | |
| 3975 | |------|---------|-------------| |
| 3976 | | `<question>` | *(required)* | Natural language question about your musical history. | |
| 3977 | | `--branch <name>` | current HEAD branch | Restrict search to commits on this branch. | |
| 3978 | | `--since YYYY-MM-DD` | *(none)* | Include only commits on or after this date. | |
| 3979 | | `--until YYYY-MM-DD` | *(none)* | Include only commits on or before this date (inclusive, end-of-day). | |
| 3980 | | `--json` | `false` | Emit machine-readable JSON instead of plain text. | |
| 3981 | | `--cite` | `false` | Show full 64-character commit IDs instead of 8-character prefixes. | |
| 3982 | |
| 3983 | ### Output example |
| 3984 | |
| 3985 | **Plain text:** |
| 3986 | |
| 3987 | ``` |
| 3988 | Based on Muse history (14 commits searched): |
| 3989 | Commits matching your query: 2 found |
| 3990 | |
| 3991 | [a3f2c1b0] 2026-02-10 14:32 boom bap take 1 |
| 3992 | [d9e8f7a6] 2026-02-11 09:15 boom bap take 2 |
| 3993 | |
| 3994 | Note: Full LLM-powered answer generation is a planned enhancement. |
| 3995 | ``` |
| 3996 | |
| 3997 | **JSON (`--json`):** |
| 3998 | |
| 3999 | ```json |
| 4000 | { |
| 4001 | "question": "boom bap sessions", |
| 4002 | "total_searched": 14, |
| 4003 | "matches": [ |
| 4004 | { |
| 4005 | "commit_id": "a3f2c1b0", |
| 4006 | "branch": "main", |
| 4007 | "message": "boom bap take 1", |
| 4008 | "committed_at": "2026-02-10T14:32:00+00:00" |
| 4009 | } |
| 4010 | ], |
| 4011 | "note": "Full LLM-powered answer generation is a planned enhancement." |
| 4012 | } |
| 4013 | ``` |
| 4014 | |
| 4015 | ### Result type |
| 4016 | |
| 4017 | `muse ask` returns an `AnswerResult` object (see |
| 4018 | `docs/reference/type_contracts.md § AnswerResult`). The `to_plain()` and |
| 4019 | `to_json()` methods on `AnswerResult` render the two output formats. |
| 4020 | |
| 4021 | ### Agent use case |
| 4022 | |
| 4023 | An AI agent reviewing a composition session calls `muse ask "piano intro" --json` |
| 4024 | to retrieve all commits where piano intro work was recorded. The JSON output |
| 4025 | feeds directly into the agent's context without screen-scraping, allowing it to |
| 4026 | reference specific commit IDs when proposing the next variation. |
| 4027 | |
| 4028 | The `--branch` filter lets an agent scope queries to a feature branch |
| 4029 | (e.g., `feat/verse-2`) rather than searching across all experimental branches. |
| 4030 | The `--cite` flag gives the agent full commit IDs for downstream `muse checkout` |
| 4031 | or `muse log` calls. |
| 4032 | |
| 4033 | **Implementation:** `maestro/muse_cli/commands/ask.py` — `_keywords()`, |
| 4034 | `_ask_async()`, `AnswerResult`. Exit codes: 0 success, 2 outside repo, |
| 4035 | 3 internal error. |
| 4036 | |
| 4037 | > **Stub note:** Keyword matching over commit messages only. Full LLM-powered |
| 4038 | > semantic search (embedding similarity over commit content) is a planned |
| 4039 | > enhancement (future: integrate with Qdrant vector store). |
| 4040 | |
| 4041 | --- |
| 4042 | |
| 4043 | ## `muse grep` — Search for a Musical Pattern Across All Commits |
| 4044 | |
| 4045 | **Purpose:** Walk the full commit chain on the current branch and return every |
| 4046 | commit whose message or branch name contains the given pattern. Designed as |
| 4047 | the textual precursor to full MIDI content search — the CLI contract (flags, |
| 4048 | output modes, result type) is frozen now so agents can rely on it before the |
| 4049 | deeper analysis is wired in. |
| 4050 | |
| 4051 | **Usage:** |
| 4052 | ```bash |
| 4053 | muse grep <pattern> [OPTIONS] |
| 4054 | ``` |
| 4055 | |
| 4056 | **Flags:** |
| 4057 | |
| 4058 | | Flag | Type | Default | Description | |
| 4059 | |------|------|---------|-------------| |
| 4060 | | `PATTERN` | positional | required | Pattern to find (note seq, interval, chord, or text) | |
| 4061 | | `--track TEXT` | string | — | Restrict to a named track (MIDI content search — planned) | |
| 4062 | | `--section TEXT` | string | — | Restrict to a named section (planned) | |
| 4063 | | `--transposition-invariant` | flag | on | Match in any key (planned for MIDI search) | |
| 4064 | | `--rhythm-invariant` | flag | off | Match regardless of rhythm (planned) | |
| 4065 | | `--commits` | flag | off | Output one commit ID per line instead of full table | |
| 4066 | | `--json` | flag | off | Emit structured JSON array | |
| 4067 | |
| 4068 | **Pattern formats (planned for MIDI content search):** |
| 4069 | |
| 4070 | | Format | Example | Matches | |
| 4071 | |--------|---------|---------| |
| 4072 | | Note sequence | `"C4 E4 G4"` | Those exact pitches in sequence | |
| 4073 | | Interval run | `"+4 +3"` | Major 3rd + minor 3rd (Cm arpeggio) | |
| 4074 | | Chord symbol | `"Cm7"` | That chord anywhere in the arrangement | |
| 4075 | | Text | `"verse piano"` | Commit message substring (current implementation) | |
| 4076 | |
| 4077 | **Output example (text):** |
| 4078 | ``` |
| 4079 | Pattern: "dark jazz" (2 matches) |
| 4080 | |
| 4081 | Commit Branch Committed Message Source |
| 4082 | ----------- ------- ------------------- ------------------- ------- |
| 4083 | a1b2c3d4 main 2026-02-15 22:00 boom bap dark jazz message |
| 4084 | f9e8d7c6 main 2026-02-10 18:30 dark jazz bass message |
| 4085 | ``` |
| 4086 | |
| 4087 | **Result type:** `GrepMatch` (dataclass) — fields: `commit_id` (str), `branch` (str), `message` (str), `committed_at` (str ISO-8601), `match_source` (str: `"message"` | `"branch"` | `"midi_content"`) |
| 4088 | |
| 4089 | **Agent use case:** An agent searching for prior uses of a Cm7 chord calls `muse grep "Cm7" --commits --json` to get a list of commits containing that chord. It can then pull those commits as harmonic reference material. |
| 4090 | |
| 4091 | **Implementation:** `maestro/muse_cli/commands/grep_cmd.py` — registered as `muse grep`. `GrepMatch` (dataclass), `_load_all_commits()`, `_grep_async()`, `_render_matches()`. Exit codes: 0 success, 2 outside repo. |
| 4092 | |
| 4093 | > **Stub note:** Pattern matched against commit messages only. MIDI content scanning (parsing note events from snapshot objects) is tracked as a follow-up issue. |
| 4094 | |
| 4095 | --- |
| 4096 | |
| 4097 | ### `muse ask` |
| 4098 | |
| 4099 | **Purpose:** Natural language question answering over commit history. Ask questions |
| 4100 | in plain English; Muse searches history and returns a grounded answer citing specific |
| 4101 | commits. The conversational interface to musical memory. |
| 4102 | |
| 4103 | **Usage:** |
| 4104 | ```bash |
| 4105 | muse ask "<question>" [OPTIONS] |
| 4106 | | `PATTERN` | positional | — | Pattern to search (note sequence, interval, chord, or free text) | |
| 4107 | | `--track TEXT` | string | — | [Future] Restrict to a named MIDI track | |
| 4108 | | `--section TEXT` | string | — | [Future] Restrict to a labelled section | |
| 4109 | | `--transposition-invariant / --no-transposition-invariant` | flag | on | [Future] Match regardless of key | |
| 4110 | | `--rhythm-invariant` | flag | off | [Future] Match regardless of rhythm/timing | |
| 4111 | | `--commits` | flag | off | Output one commit ID per line (like `git grep --name-only`) | |
| 4112 | | `--json` | flag | off | Emit machine-readable JSON array | |
| 4113 | |
| 4114 | **Output example (text):** |
| 4115 | ``` |
| 4116 | Pattern: 'pentatonic' (1 match(es)) |
| 4117 | |
| 4118 | commit c1d2e3f4... |
| 4119 | Branch: feature/pentatonic-solo |
| 4120 | Date: 2026-02-27T15:00:00+00:00 |
| 4121 | Match: [message] |
| 4122 | Message: add pentatonic riff to chorus |
| 4123 | ``` |
| 4124 | |
| 4125 | **Output example (`--commits`):** |
| 4126 | ``` |
| 4127 | c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2 |
| 4128 | ``` |
| 4129 | |
| 4130 | **Output example (`--json`):** |
| 4131 | ```json |
| 4132 | [ |
| 4133 | { |
| 4134 | "commit_id": "c1d2e3f4...", |
| 4135 | "branch": "feature/pentatonic-solo", |
| 4136 | "message": "add pentatonic riff to chorus", |
| 4137 | "committed_at": "2026-02-27T15:00:00+00:00", |
| 4138 | "match_source": "message" |
| 4139 | } |
| 4140 | ] |
| 4141 | ``` |
| 4142 | |
| 4143 | **Result type:** `GrepMatch` (dataclass) — fields: `commit_id` (str), |
| 4144 | `branch` (str), `message` (str), `committed_at` (str, ISO-8601), |
| 4145 | `match_source` (str: `"message"` | `"branch"` | `"midi_content"`). |
| 4146 | See `docs/reference/type_contracts.md § Muse CLI Types`. |
| 4147 | |
| 4148 | **Agent use case:** An AI composing a variation searches previous commits for |
| 4149 | all times "pentatonic" appeared in the history before deciding whether to |
| 4150 | reuse or invert the motif. The `--json` flag makes the result directly |
| 4151 | parseable; `--commits` feeds a shell loop that checks out each matching |
| 4152 | commit for deeper inspection. |
| 4153 | |
| 4154 | **Implementation:** `maestro/muse_cli/commands/grep_cmd.py` — |
| 4155 | `GrepMatch` (dataclass), `_load_all_commits()`, `_match_commit()`, |
| 4156 | `_grep_async()`, `_render_matches()`. Exit codes: 0 success, |
| 4157 | 2 outside repo, 3 internal error. |
| 4158 | |
| 4159 | > **Stub note:** The current implementation matches commit *messages* and |
| 4160 | > *branch names* only. Full MIDI content search (note sequences, intervals, |
| 4161 | > chord symbols, `--track`, `--section`, `--transposition-invariant`, |
| 4162 | > `--rhythm-invariant`) is reserved for a future iteration. Flags are accepted |
| 4163 | > now to keep the CLI contract stable; supplying them emits a clear warning. |
| 4164 | |
| 4165 | --- |
| 4166 | |
| 4167 | ### `muse blame` |
| 4168 | |
| 4169 | **Purpose:** Annotate each tracked file with the commit that last changed it. |
| 4170 | Answers the producer's question "whose idea was this bass line?" or "which take |
| 4171 | introduced this change?" Output is per-file (not per-line) because MIDI and |
| 4172 | audio files are binary — the meaningful unit of change is the whole file. |
| 4173 | |
| 4174 | **Usage:** |
| 4175 | ```bash |
| 4176 | muse blame [PATH] [OPTIONS] |
| 4177 | ``` |
| 4178 | |
| 4179 | **Flags:** |
| 4180 | |
| 4181 | | Flag | Type | Default | Description | |
| 4182 | |------|------|---------|-------------| |
| 4183 | | `PATH` | positional string | — | Relative path within `muse-work/` to annotate. Omit to blame all tracked files | |
| 4184 | | `--track TEXT` | string | — | Filter to files whose basename matches this fnmatch glob (e.g. `bass*` or `*.mid`) | |
| 4185 | | `--section TEXT` | string | — | Filter to files inside this section directory (first directory component) | |
| 4186 | | `--line-range N,M` | string | — | Annotate sub-range (informational only — MIDI/audio are binary, not line-based) | |
| 4187 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 4188 | |
| 4189 | **Output example (text):** |
| 4190 | ``` |
| 4191 | a1b2c3d4 producer 2026-02-27 14:30:00 ( modified) muse-work/bass/bassline.mid |
| 4192 | update bass groove |
| 4193 | f9e8d7c6 producer 2026-02-26 10:00:00 ( added) muse-work/keys/melody.mid |
| 4194 | initial take |
| 4195 | ``` |
| 4196 | |
| 4197 | **Output example (`--json`):** |
| 4198 | ```json |
| 4199 | { |
| 4200 | "path_filter": null, |
| 4201 | "track_filter": null, |
| 4202 | "section_filter": null, |
| 4203 | "line_range": null, |
| 4204 | "entries": [ |
| 4205 | { |
| 4206 | "path": "muse-work/bass/bassline.mid", |
| 4207 | "commit_id": "a1b2c3d4e5f6...", |
| 4208 | "commit_short": "a1b2c3d4", |
| 4209 | "author": "producer", |
| 4210 | "committed_at": "2026-02-27 14:30:00", |
| 4211 | "message": "update bass groove", |
| 4212 | "change_type": "modified" |
| 4213 | } |
| 4214 | ] |
| 4215 | } |
| 4216 | ``` |
| 4217 | |
| 4218 | **Result type:** `BlameEntry` (TypedDict) — fields: `path` (str), `commit_id` (str), |
| 4219 | `commit_short` (str, 8-char), `author` (str), `committed_at` (str, `YYYY-MM-DD HH:MM:SS`), |
| 4220 | `message` (str), `change_type` (str: `"added"` | `"modified"` | `"unchanged"`). |
| 4221 | Wrapped in `BlameResult` (TypedDict) — fields: `path_filter`, `track_filter`, |
| 4222 | `section_filter`, `line_range` (all `str | None`), `entries` (list of `BlameEntry`). |
| 4223 | See `docs/reference/type_contracts.md § Muse CLI Types`. |
| 4224 | |
| 4225 | **Agent use case:** An AI composing a new bass arrangement asks `muse blame --track 'bass*' --json` |
| 4226 | to find the commit that last changed every bass file. It then calls `muse show <commit_id>` on |
| 4227 | those commits to understand what musical choices were made, before deciding whether to build on |
| 4228 | or diverge from the existing groove. |
| 4229 | |
| 4230 | **Implementation:** `maestro/muse_cli/commands/blame.py` — |
| 4231 | `BlameEntry` (TypedDict), `BlameResult` (TypedDict), `_load_commit_chain()`, |
| 4232 | `_load_snapshot_manifest()`, `_matches_filters()`, `_blame_async()`, `_render_blame()`. |
| 4233 | Exit codes: 0 success, 2 outside repo, 3 internal error. |
| 4234 | |
| 4235 | --- |
| 4236 | |
| 4237 | ## `muse tempo` — Read or Set the Tempo of a Commit |
| 4238 | |
| 4239 | `muse tempo [<commit>] [--set <bpm>] [--history] [--json]` reads or annotates |
| 4240 | the BPM of a specific commit. Tempo (BPM) is the most fundamental rhythmic property |
| 4241 | of a Muse project — this command makes it a first-class commit attribute. |
| 4242 | |
| 4243 | ### Flags |
| 4244 | |
| 4245 | | Flag | Description | |
| 4246 | |------|-------------| |
| 4247 | | `[<commit>]` | Target commit SHA (full or abbreviated) or `HEAD` (default) | |
| 4248 | | `--set <bpm>` | Annotate the commit with an explicit BPM (20–400 range) | |
| 4249 | | `--history` | Show BPM timeline across all commits in the parent chain | |
| 4250 | | `--json` | Emit machine-readable JSON instead of human-readable text | |
| 4251 | |
| 4252 | ### Tempo Resolution Order (read path) |
| 4253 | |
| 4254 | 1. **Annotated BPM** — explicitly set via `muse tempo --set` and stored in `commit_metadata.tempo_bpm`. |
| 4255 | 2. **Detected BPM** — auto-extracted from MIDI Set Tempo meta-events (FF 51 03) in the commit's snapshot files. |
| 4256 | 3. **None** — displayed as `--` when neither source is available. |
| 4257 | |
| 4258 | ### Tempo Storage (write path) |
| 4259 | |
| 4260 | `--set` writes `{tempo_bpm: <float>}` into the `metadata` JSON column of the |
| 4261 | `muse_cli_commits` table. Other metadata keys in that column are preserved |
| 4262 | (merge-patch semantics). No new rows are created — only the existing commit row |
| 4263 | is annotated. |
| 4264 | |
| 4265 | ### Schema |
| 4266 | |
| 4267 | The `muse_cli_commits` table has a nullable `metadata` JSON column (added in |
| 4268 | migration `0002_muse_cli_commit_metadata`). Current keys: |
| 4269 | |
| 4270 | | Key | Type | Set by | |
| 4271 | |-----|------|--------| |
| 4272 | | `tempo_bpm` | `float` | `muse tempo --set` | |
| 4273 | |
| 4274 | ### History Traversal |
| 4275 | |
| 4276 | `--history` walks the full parent chain from the target commit (or HEAD), |
| 4277 | collecting annotated BPM values and computing signed deltas between consecutive |
| 4278 | commits. |
| 4279 | |
| 4280 | Auto-detected BPM is shown on the single-commit read path but is not persisted, |
| 4281 | so it does not appear in history (history only reflects explicitly set annotations). |
| 4282 | |
| 4283 | ### MIDI Tempo Parsing |
| 4284 | |
| 4285 | `maestro/services/muse_tempo.extract_bpm_from_midi(data: bytes)` is a pure |
| 4286 | function that scans a raw MIDI byte string for the Set Tempo meta-event |
| 4287 | (FF 51 03). The three bytes encode microseconds-per-beat as a 24-bit big-endian |
| 4288 | integer. BPM = 60_000_000 / microseconds_per_beat. Only the first event is |
| 4289 | returned; `detect_all_tempos_from_midi` returns all events (used for rubato |
| 4290 | detection). |
| 4291 | |
| 4292 | ### Result Types |
| 4293 | |
| 4294 | | Type | Module | Purpose | |
| 4295 | |------|--------|---------| |
| 4296 | | `MuseTempoResult` | `maestro.services.muse_tempo` | Single-commit tempo query result | |
| 4297 | | `MuseTempoHistoryEntry` | `maestro.services.muse_tempo` | One row in a `--history` traversal | |
| 4298 | |
| 4299 | ### DB Helpers |
| 4300 | |
| 4301 | | Helper | Module | Purpose | |
| 4302 | |--------|--------|---------| |
| 4303 | | `resolve_commit_ref` | `maestro.muse_cli.db` | Resolve HEAD / full SHA / abbreviated SHA to a `MuseCliCommit` | |
| 4304 | | `set_commit_tempo_bpm` | `maestro.muse_cli.db` | Write `tempo_bpm` into `commit_metadata` (merge-patch) | |
| 4305 | |
| 4306 | ### Exit Codes |
| 4307 | |
| 4308 | | Code | Meaning | |
| 4309 | |------|---------| |
| 4310 | | 0 | Success | |
| 4311 | | 1 | User error: unknown ref, BPM out of range | |
| 4312 | | 2 | Outside a Muse repository | |
| 4313 | | 3 | Internal error | |
| 4314 | |
| 4315 | --- |
| 4316 | |
| 4317 | ### Command Registration Summary |
| 4318 | |
| 4319 | | Command | File | Status | Issue | |
| 4320 | |---------|------|--------|-------| |
| 4321 | | `muse dynamics` | `commands/dynamics.py` | ✅ stub (PR #130) | #120 | |
| 4322 | | `muse swing` | `commands/swing.py` | ✅ stub (PR #131) | #121 | |
| 4323 | | `muse recall` | `commands/recall.py` | ✅ stub (PR #135) | #122 | |
| 4324 | | `muse tag` | `commands/tag.py` | ✅ implemented (PR #133) | #123 | |
| 4325 | | `muse grep` | `commands/grep_cmd.py` | ✅ stub (PR #128) | #124 | |
| 4326 | | `muse humanize` | `commands/humanize.py` | ✅ stub (PR #151) | #107 | |
| 4327 | | `muse describe` | `commands/describe.py` | ✅ stub (PR #134) | #125 | |
| 4328 | | `muse ask` | `commands/ask.py` | ✅ stub (PR #132) | #126 | |
| 4329 | | `muse session` | `commands/session.py` | ✅ implemented (PR #129) | #127 | |
| 4330 | | `muse tempo` | `commands/tempo.py` | ✅ fully implemented (PR TBD) | #116 | |
| 4331 | |
| 4332 | All stub commands have stable CLI contracts. Full musical analysis (MIDI content |
| 4333 | parsing, vector embeddings, LLM synthesis) is tracked as follow-up issues. |
| 4334 | |
| 4335 | ## `muse recall` — Keyword Search over Musical Commit History |
| 4336 | |
| 4337 | **Purpose:** Walk the commit history on the current (or specified) branch and |
| 4338 | return the top-N commits ranked by keyword overlap against their commit |
| 4339 | messages. Designed as the textual precursor to full vector embedding search — |
| 4340 | the CLI interface (flags, output modes, result type) is frozen so agents can |
| 4341 | rely on it before Qdrant-backed semantic search is wired in. |
| 4342 | |
| 4343 | **Usage:** |
| 4344 | ```bash |
| 4345 | muse recall <query> [OPTIONS] |
| 4346 | ``` |
| 4347 | |
| 4348 | **Flags:** |
| 4349 | |
| 4350 | | Flag | Type | Default | Description | |
| 4351 | |------|------|---------|-------------| |
| 4352 | | `QUESTION` | positional | required | Natural-language question about musical history | |
| 4353 | | `--branch TEXT` | string | all | Restrict history to a branch | |
| 4354 | | `--since DATE` | `YYYY-MM-DD` | — | Only consider commits after this date | |
| 4355 | | `--until DATE` | `YYYY-MM-DD` | — | Only consider commits before this date | |
| 4356 | | `--cite` | flag | off | Show full commit IDs in the answer (default: short IDs) | |
| 4357 | | `--json` | flag | off | Emit structured JSON response | |
| 4358 | |
| 4359 | **Output example (text):** |
| 4360 | ``` |
| 4361 | Based on Muse history (47 commits searched): |
| 4362 | Commits matching your query: 3 found |
| 4363 | |
| 4364 | [a1b2c3d] 2026-02-15 22:00 boom bap dark jazz session |
| 4365 | [f9e8d7c] 2026-02-10 18:30 Add bass overdub — minor key |
| 4366 | [3b2a1f0] 2026-01-28 14:00 Initial tempo work at 118 BPM |
| 4367 | |
| 4368 | Note: Full LLM-powered answer generation is a planned enhancement. |
| 4369 | | `QUERY` | positional | — | Natural-language description to search for | |
| 4370 | | `--limit / -n INT` | integer | 5 | Maximum number of results to return | |
| 4371 | | `--threshold FLOAT` | float | 0.6 | Minimum keyword-overlap score (0–1) to include a commit | |
| 4372 | | `--branch TEXT` | string | current branch | Filter to a specific branch name | |
| 4373 | | `--since YYYY-MM-DD` | date string | — | Only include commits on or after this date | |
| 4374 | | `--until YYYY-MM-DD` | date string | — | Only include commits on or before this date | |
| 4375 | | `--json` | flag | off | Emit machine-readable JSON array | |
| 4376 | |
| 4377 | **Scoring algorithm:** Overlap coefficient — `|Q ∩ M| / |Q|` — where Q is the |
| 4378 | set of lowercase word tokens in the query and M is the set of tokens in the |
| 4379 | commit message. A score of 1.0 means every query word appears in the message; |
| 4380 | 0.0 means none do. Commits with score below `--threshold` are excluded. |
| 4381 | |
| 4382 | **Output example (text):** |
| 4383 | ``` |
| 4384 | Recall: "dark jazz bassline" |
| 4385 | (keyword match · threshold 0.60 · vector search is a planned enhancement) |
| 4386 | |
| 4387 | #1 score=1.0000 commit a1b2c3d4... [2026-02-20 14:30:00] |
| 4388 | add dark jazz bassline to verse |
| 4389 | |
| 4390 | #2 score=0.6667 commit e5f6a7b8... [2026-02-18 09:15:00] |
| 4391 | jazz bassline variation with reverb |
| 4392 | ``` |
| 4393 | |
| 4394 | **Output example (`--json`):** |
| 4395 | ```json |
| 4396 | { |
| 4397 | "question": "when did we work on jazz?", |
| 4398 | "total_searched": 47, |
| 4399 | "matches_found": 3, |
| 4400 | "commits": [{"id": "a1b2c3d4...", "short_id": "a1b2c3d", "date": "2026-02-15", "message": "..."}], |
| 4401 | "stub_note": "Full LLM answer generation is a planned enhancement." |
| 4402 | } |
| 4403 | ``` |
| 4404 | |
| 4405 | **Result type:** `AnswerResult` (class) — fields: `question` (str), `total_searched` (int), `matches` (list[MuseCliCommit]), `cite` (bool). Methods: `.to_plain()`, `.to_json_dict()`. |
| 4406 | |
| 4407 | **Agent use case:** An AI agent composing a bridge asks `muse ask "what was the emotional arc of the chorus?" --json`. The answer grounds the agent in the actual commit history of the project before it generates, preventing stylistic drift. |
| 4408 | |
| 4409 | **Implementation:** `maestro/muse_cli/commands/ask.py` — `AnswerResult`, `_keywords()`, `_ask_async()`. Exit codes: 0 success, 1 bad date, 2 outside repo. |
| 4410 | |
| 4411 | > **Stub note:** Keyword matching over commit messages. Full implementation: RAG over Qdrant musical context embeddings + LLM answer synthesis via OpenRouter (Claude Sonnet/Opus). CLI interface is stable and will not change when LLM is wired in. |
| 4412 | |
| 4413 | --- |
| 4414 | |
| 4415 | ### `muse session` |
| 4416 | |
| 4417 | **Purpose:** Record and query recording session metadata — who played, when, where, |
| 4418 | and what they intended to create. Sessions are stored as local JSON files (not in |
| 4419 | Postgres), mirroring how Git stores config as plain files. |
| 4420 | |
| 4421 | **Usage:** |
| 4422 | ```bash |
| 4423 | muse session <subcommand> [OPTIONS] |
| 4424 | ``` |
| 4425 | |
| 4426 | **Subcommands:** |
| 4427 | |
| 4428 | | Subcommand | Description | |
| 4429 | |------------|-------------| |
| 4430 | | `muse session start` | Open a new recording session | |
| 4431 | | `muse session end` | Finalize the active session | |
| 4432 | | `muse session log` | List all completed sessions, newest first | |
| 4433 | | `muse session show <id>` | Print a specific session by ID (prefix match) | |
| 4434 | | `muse session credits` | Aggregate participants across all sessions | |
| 4435 | |
| 4436 | **`muse session start` flags:** |
| 4437 | |
| 4438 | | Flag | Type | Default | Description | |
| 4439 | |------|------|---------|-------------| |
| 4440 | | `--participants TEXT` | string | — | Comma-separated participant names | |
| 4441 | | `--location TEXT` | string | — | Studio or location name | |
| 4442 | | `--intent TEXT` | string | — | Creative intent for this session | |
| 4443 | |
| 4444 | **`muse session end` flags:** |
| 4445 | |
| 4446 | | Flag | Type | Default | Description | |
| 4447 | |------|------|---------|-------------| |
| 4448 | | `--notes TEXT` | string | — | Session notes / retrospective | |
| 4449 | |
| 4450 | **Session JSON schema** (stored in `.muse/sessions/<uuid>.json`): |
| 4451 | ```json |
| 4452 | { |
| 4453 | "session_id": "<uuid4>", |
| 4454 | "started_at": "2026-02-27T22:00:00+00:00", |
| 4455 | "ended_at": "2026-02-27T02:30:00+00:00", |
| 4456 | "participants": ["Gabriel (producer)", "Sarah (keys)"], |
| 4457 | "location": "Studio A", |
| 4458 | "intent": "Piano overdubs for verse sections", |
| 4459 | "commits": [], |
| 4460 | "notes": "Great tone on the Steinway today." |
| 4461 | } |
| 4462 | ``` |
| 4463 | |
| 4464 | **`muse session log` output:** |
| 4465 | ``` |
| 4466 | SESSION 2026-02-27T22:00 → 2026-02-27T02:30 2h30m |
| 4467 | Participants: Gabriel (producer), Sarah (keys) |
| 4468 | Location: Studio A |
| 4469 | Intent: Piano overdubs for verse sections |
| 4470 | ``` |
| 4471 | |
| 4472 | **`muse session credits` output:** |
| 4473 | ``` |
| 4474 | Gabriel (producer) 7 sessions |
| 4475 | Sarah (keys) 3 sessions |
| 4476 | Marcus (bass) 2 sessions |
| 4477 | ``` |
| 4478 | |
| 4479 | **Agent use case:** An AI agent summarizing a project's creative history calls `muse session credits --json` to attribute musical contributions. An AI generating liner notes reads `muse session log --json` to reconstruct the session timeline. |
| 4480 | |
| 4481 | **Implementation:** `maestro/muse_cli/commands/session.py` — all synchronous (no DB, no async). Storage: `.muse/sessions/current.json` (active) → `.muse/sessions/<uuid>.json` (completed). Exit codes: 0 success, 1 user error (duplicate session, no active session, ambiguous ID), 2 outside repo, 3 internal. |
| 4482 | |
| 4483 | --- |
| 4484 | |
| 4485 | ## `muse meter` — Time Signature Read/Set/Detect |
| 4486 | |
| 4487 | ### `muse meter` |
| 4488 | |
| 4489 | **Purpose:** Read or set the time signature (meter) annotation for any commit. The |
| 4490 | time signature defines the rhythmic framework of a piece — a shift from 4/4 to 7/8 is |
| 4491 | a fundamental compositional decision. `muse meter` makes that history first-class. |
| 4492 | |
| 4493 | **Status:** ✅ Fully implemented (issue #117) |
| 4494 | |
| 4495 | **Storage:** The time signature is stored as the `meter` key inside the nullable |
| 4496 | `extra_metadata` JSON column on `muse_cli_commits`. No MIDI file is modified. The |
| 4497 | annotation is layered on top of the immutable content-addressed snapshot. |
| 4498 | |
| 4499 | **Time signature format:** `<numerator>/<denominator>` where the denominator must be a |
| 4500 | power of 2. Examples: `4/4`, `3/4`, `7/8`, `5/4`, `12/8`, `6/8`. |
| 4501 | |
| 4502 | #### Flags |
| 4503 | |
| 4504 | | Flag | Argument | Description | |
| 4505 | |------|----------|-------------| |
| 4506 | | *(none)* | `[COMMIT]` | Read the stored time signature. Default: HEAD. | |
| 4507 | | `--set` | `TIME_SIG` | Store a time signature annotation on the commit. | |
| 4508 | | `--detect` | — | Auto-detect from MIDI time-signature meta events in `muse-work/`. | |
| 4509 | | `--history` | — | Walk the branch and show when the time signature changed. | |
| 4510 | | `--polyrhythm` | — | Detect tracks with conflicting time signatures in `muse-work/`. | |
| 4511 | |
| 4512 | #### Examples |
| 4513 | |
| 4514 | ```bash |
| 4515 | # Read the stored time signature for HEAD |
| 4516 | muse meter |
| 4517 | |
| 4518 | # Read the time signature for a specific commit (abbreviated SHA) |
| 4519 | muse meter a1b2c3d4 |
| 4520 | |
| 4521 | # Set the time signature on HEAD |
| 4522 | muse meter --set 7/8 |
| 4523 | |
| 4524 | # Set the time signature on a specific commit |
| 4525 | muse meter a1b2c3d4 --set 5/4 |
| 4526 | |
| 4527 | # Auto-detect from MIDI files and store the result |
| 4528 | muse meter --detect |
| 4529 | |
| 4530 | # Show time signature history (newest-first, with change markers) |
| 4531 | muse meter --history |
| 4532 | |
| 4533 | # Check for polyrhythmic tracks |
| 4534 | muse meter --polyrhythm |
| 4535 | ``` |
| 4536 | |
| 4537 | #### Sample output |
| 4538 | |
| 4539 | **Read (no flag):** |
| 4540 | ``` |
| 4541 | commit a1b2c3d4 |
| 4542 | meter 7/8 |
| 4543 | ``` |
| 4544 | |
| 4545 | **History (`--history`):** |
| 4546 | ``` |
| 4547 | a1b2c3d4 7/8 switched to odd meter ← changed |
| 4548 | f9e8d7c6 4/4 boom bap demo take 1 |
| 4549 | e7d6c5b4 4/4 initial take |
| 4550 | ``` |
| 4551 | |
| 4552 | **Polyrhythm (`--polyrhythm`, conflict detected):** |
| 4553 | ``` |
| 4554 | ⚠️ Polyrhythm detected — multiple time signatures in this commit: |
| 4555 | |
| 4556 | 4/4 tracks/drums.mid |
| 4557 | 7/8 tracks/melody.mid |
| 4558 | ``` |
| 4559 | |
| 4560 | #### MIDI Detection |
| 4561 | |
| 4562 | `--detect` scans `.mid` and `.midi` files in `muse-work/` for MIDI time-signature |
| 4563 | meta events (type `0xFF 0x58`). The event layout is: |
| 4564 | |
| 4565 | ``` |
| 4566 | FF 58 04 nn dd cc bb |
| 4567 | │ │ │ └── 32nd notes per 24 MIDI clocks |
| 4568 | │ │ └───── MIDI clocks per metronome tick |
| 4569 | │ └──────── denominator exponent (denominator = 2^dd) |
| 4570 | └─────────── numerator |
| 4571 | ``` |
| 4572 | |
| 4573 | The most common signature across all files is selected and written to the commit. |
| 4574 | Files with no time-signature event report `?` and are excluded from polyrhythm |
| 4575 | detection (only known signatures are compared). |
| 4576 | |
| 4577 | #### Result types |
| 4578 | |
| 4579 | | Type | Module | Description | |
| 4580 | |------|--------|-------------| |
| 4581 | | `MuseMeterReadResult` | `maestro/muse_cli/commands/meter.py` | Commit ID + stored time signature (or `None`) | |
| 4582 | | `MuseMeterHistoryEntry` | `maestro/muse_cli/commands/meter.py` | Single entry in the parent-chain meter walk | |
| 4583 | | `MusePolyrhythmResult` | `maestro/muse_cli/commands/meter.py` | Per-file time signatures + polyrhythm flag | |
| 4584 | |
| 4585 | **Agent use case:** An AI generating a new section calls `muse meter` to discover whether |
| 4586 | the project is in 4/4 or an odd meter before producing MIDI. An agent reviewing a composition |
| 4587 | calls `muse meter --history` to identify when meter changes occurred and correlate them with |
| 4588 | creative decisions. `muse meter --polyrhythm` surfaces conflicts that would cause tracks to |
| 4589 | drift out of sync. |
| 4590 | |
| 4591 | **Implementation:** `maestro/muse_cli/commands/meter.py`. All DB-touching paths are async |
| 4592 | (`open_session()` pattern). Exit codes: 0 success, 1 user error, 3 internal error. |
| 4593 | |
| 4594 | --- |
| 4595 | |
| 4596 | ### `muse emotion-diff` |
| 4597 | |
| 4598 | **Purpose:** Compare emotion vectors between two commits to track how the emotional character of a composition changed over time. An AI agent uses this to detect whether a recent edit reinforced or subverted the intended emotional arc, and to decide whether to continue or correct the creative direction. |
| 4599 | |
| 4600 | **Status:** ✅ Implemented (issue #100) |
| 4601 | |
| 4602 | **Usage:** |
| 4603 | ```bash |
| 4604 | muse emotion-diff [COMMIT_A] [COMMIT_B] [OPTIONS] |
| 4605 | ``` |
| 4606 | |
| 4607 | **Flags:** |
| 4608 | |
| 4609 | | Flag | Type | Default | Description | |
| 4610 | |------|------|---------|-------------| |
| 4611 | | `COMMIT_A` | positional | `HEAD~1` | Baseline commit ref (full hash, abbreviated hash, `HEAD`, or `HEAD~N`). | |
| 4612 | | `COMMIT_B` | positional | `HEAD` | Target commit ref. | |
| 4613 | | `--track TEXT` | option | — | Scope analysis to a specific track (noted in output; full per-track MIDI scoping is a follow-up). | |
| 4614 | | `--section TEXT` | option | — | Scope to a named section (same stub note as `--track`). | |
| 4615 | | `--json` | flag | off | Emit structured JSON for agent or tool consumption. | |
| 4616 | |
| 4617 | **Sourcing strategy:** |
| 4618 | |
| 4619 | 1. **`explicit_tags`** — Both commits have `emotion:*` tags (set via `muse tag add emotion:<label>`). Vectors are looked up from the canonical emotion table. |
| 4620 | 2. **`mixed`** — One commit has a tag, the other is inferred from metadata. |
| 4621 | 3. **`inferred`** — Neither commit has an emotion tag. Vectors are inferred from available commit metadata (tempo, annotations). Full MIDI-feature inference (mode, note density, velocity) is tracked as a follow-up. |
| 4622 | |
| 4623 | **Canonical emotion labels** (for `muse tag add emotion:<label>`): |
| 4624 | `joyful`, `melancholic`, `anxious`, `cinematic`, `peaceful`, `dramatic`, `hopeful`, `tense`, `dark`, `euphoric`, `serene`, `epic`, `mysterious`, `aggressive`, `nostalgic`. |
| 4625 | |
| 4626 | **Output example (text):** |
| 4627 | ``` |
| 4628 | Emotion diff — a1b2c3d4 → f9e8d7c6 |
| 4629 | Source: explicit_tags |
| 4630 | |
| 4631 | Commit A (a1b2c3d4): melancholic |
| 4632 | Commit B (f9e8d7c6): joyful |
| 4633 | |
| 4634 | Dimension Commit A Commit B Delta |
| 4635 | ----------- -------- -------- ----- |
| 4636 | energy 0.3000 0.8000 +0.5000 |
| 4637 | valence 0.3000 0.9000 +0.6000 |
| 4638 | tension 0.4000 0.2000 -0.2000 |
| 4639 | darkness 0.6000 0.1000 -0.5000 |
| 4640 | |
| 4641 | Drift: 0.9747 |
| 4642 | Dramatic emotional departure — a fundamentally different mood. melancholic → joyful (drift=0.975, major, dominant: +valence) |
| 4643 | ``` |
| 4644 | |
| 4645 | **Output example (JSON):** |
| 4646 | ```json |
| 4647 | { |
| 4648 | "commit_a": "a1b2c3d4", |
| 4649 | "commit_b": "f9e8d7c6", |
| 4650 | "source": "explicit_tags", |
| 4651 | "label_a": "melancholic", |
| 4652 | "label_b": "joyful", |
| 4653 | "vector_a": {"energy": 0.3, "valence": 0.3, "tension": 0.4, "darkness": 0.6}, |
| 4654 | "vector_b": {"energy": 0.8, "valence": 0.9, "tension": 0.2, "darkness": 0.1}, |
| 4655 | "dimensions": [ |
| 4656 | {"dimension": "energy", "value_a": 0.3, "value_b": 0.8, "delta": 0.5}, |
| 4657 | {"dimension": "valence", "value_a": 0.3, "value_b": 0.9, "delta": 0.6}, |
| 4658 | {"dimension": "tension", "value_a": 0.4, "value_b": 0.2, "delta": -0.2}, |
| 4659 | {"dimension": "darkness", "value_a": 0.6, "value_b": 0.1, "delta": -0.5} |
| 4660 | ], |
| 4661 | "drift": 0.9747, |
| 4662 | "narrative": "...", |
| 4663 | "track": null, |
| 4664 | "section": null |
| 4665 | } |
| 4666 | ``` |
| 4667 | |
| 4668 | **Result types:** `EmotionDiffResult`, `EmotionVector`, `EmotionDimDelta` — all defined in `maestro/services/muse_emotion_diff.py` and registered in `docs/reference/type_contracts.md`. |
| 4669 | |
| 4670 | **Drift distance:** Euclidean distance in the 4-D emotion space. Range [0.0, 2.0]. |
| 4671 | - < 0.05 — unchanged |
| 4672 | - 0.05–0.25 — subtle shift |
| 4673 | - 0.25–0.50 — moderate shift |
| 4674 | - 0.50–0.80 — significant shift |
| 4675 | - > 0.80 — major / dramatic departure |
| 4676 | |
| 4677 | **Agent use case:** An AI composing a new verse calls `muse emotion-diff HEAD~1 HEAD --json` after each commit to verify the composition is tracking toward the intended emotional destination (e.g., building from `melancholic` to `hopeful` across an album arc). A drift > 0.5 on the wrong dimension triggers a course-correction prompt. |
| 4678 | |
| 4679 | **Implementation:** `maestro/muse_cli/commands/emotion_diff.py` (CLI entry point) and `maestro/services/muse_emotion_diff.py` (core engine). All DB-touching paths are async (`open_session()` pattern). Commit refs support `HEAD`, `HEAD~N`, full 64-char hashes, and 8-char abbreviated hashes. |
| 4680 | |
| 4681 | --- |
| 4682 | |
| 4683 | ### Command Registration Summary |
| 4684 | |
| 4685 | | Command | File | Status | Issue | |
| 4686 | |---------|------|--------|-------| |
| 4687 | | `muse dynamics` | `commands/dynamics.py` | ✅ stub (PR #130) | #120 | |
| 4688 | | `muse emotion-diff` | `commands/emotion_diff.py` | ✅ implemented (PR #100) | #100 | |
| 4689 | | `muse swing` | `commands/swing.py` | ✅ stub (PR #131) | #121 | |
| 4690 | | `muse recall` | `commands/recall.py` | ✅ stub (PR #135) | #122 | |
| 4691 | | `muse tag` | `commands/tag.py` | ✅ implemented (PR #133) | #123 | |
| 4692 | | `muse grep` | `commands/grep_cmd.py` | ✅ stub (PR #128) | #124 | |
| 4693 | | `muse describe` | `commands/describe.py` | ✅ stub (PR #134) | #125 | |
| 4694 | | `muse ask` | `commands/ask.py` | ✅ stub (PR #132) | #126 | |
| 4695 | | `muse session` | `commands/session.py` | ✅ implemented (PR #129) | #127 | |
| 4696 | | `muse meter` | `commands/meter.py` | ✅ implemented (PR #141) | #117 | |
| 4697 | |
| 4698 | All stub commands have stable CLI contracts. Full musical analysis (MIDI content |
| 4699 | parsing, vector embeddings, LLM synthesis) is tracked as follow-up issues. |
| 4700 | [ |
| 4701 | { |
| 4702 | "rank": 1, |
| 4703 | "score": 1.0, |
| 4704 | "commit_id": "a1b2c3d4...", |
| 4705 | "date": "2026-02-20 14:30:00", |
| 4706 | "branch": "main", |
| 4707 | "message": "add dark jazz bassline to verse" |
| 4708 | } |
| 4709 | ] |
| 4710 | ``` |
| 4711 | |
| 4712 | **Result type:** `RecallResult` (`TypedDict`) — fields: `rank` (int), |
| 4713 | `score` (float, rounded to 4 decimal places), `commit_id` (str), |
| 4714 | `date` (str, `"YYYY-MM-DD HH:MM:SS"`), `branch` (str), `message` (str). |
| 4715 | See `docs/reference/type_contracts.md § RecallResult`. |
| 4716 | |
| 4717 | **Agent use case:** An AI composing a new variation queries `muse recall |
| 4718 | "dark jazz bassline"` to surface all commits that previously explored that |
| 4719 | texture — letting the agent reuse, invert, or contrast those ideas. The |
| 4720 | `--json` flag makes the result directly parseable in an agentic pipeline; |
| 4721 | `--threshold 0.0` with a broad query retrieves the full ranked history. |
| 4722 | |
| 4723 | **Implementation:** `maestro/muse_cli/commands/recall.py` — |
| 4724 | `RecallResult` (TypedDict), `_tokenize()`, `_score()`, `_fetch_commits()`, |
| 4725 | `_recall_async()`, `_render_results()`. Exit codes: 0 success, |
| 4726 | 1 bad date format (`USER_ERROR`), 2 outside repo (`REPO_NOT_FOUND`), |
| 4727 | 3 internal error (`INTERNAL_ERROR`). |
| 4728 | |
| 4729 | > **Planned enhancement:** Full semantic vector search via Qdrant with |
| 4730 | > cosine similarity over pre-computed embeddings. When implemented, the |
| 4731 | > scoring function will be replaced with no change to the CLI interface. |
| 4732 | |
| 4733 | --- |
| 4734 | ## `muse context` — Structured Musical Context for AI Agents |
| 4735 | |
| 4736 | **Purpose:** Output a structured, self-contained musical context document for AI agent consumption. This is the **primary interface between Muse VCS and AI music generation agents** — agents run `muse context` before any generation task to understand the current key, tempo, active tracks, form, harmonic profile, and evolutionary history of the composition. |
| 4737 | |
| 4738 | **Usage:** |
| 4739 | ```bash |
| 4740 | muse context [<commit>] [OPTIONS] |
| 4741 | ``` |
| 4742 | |
| 4743 | **Flags:** |
| 4744 | |
| 4745 | | Flag | Type | Default | Description | |
| 4746 | |------|------|---------|-------------| |
| 4747 | | `<commit>` | positional | HEAD | Target commit ID to inspect | |
| 4748 | | `--depth N` | int | 5 | Number of ancestor commits to include in `history` | |
| 4749 | | `--sections` | flag | off | Expand section-level detail in `musical_state.sections` | |
| 4750 | | `--tracks` | flag | off | Add per-track harmonic and dynamic breakdowns | |
| 4751 | | `--include-history` | flag | off | Annotate history entries with dimensional deltas (future Storpheus integration) | |
| 4752 | | `--format json\|yaml` | string | json | Output format | |
| 4753 | |
| 4754 | **Output example (`--format json`):** |
| 4755 | ```json |
| 4756 | { |
| 4757 | "repo_id": "a1b2c3d4-...", |
| 4758 | "current_branch": "main", |
| 4759 | "head_commit": { |
| 4760 | "commit_id": "abc1234...", |
| 4761 | "message": "Add piano melody to verse", |
| 4762 | "author": "Gabriel", |
| 4763 | "committed_at": "2026-02-27T22:00:00+00:00" |
| 4764 | }, |
| 4765 | "musical_state": { |
| 4766 | "active_tracks": ["bass", "drums", "piano"], |
| 4767 | "key": null, |
| 4768 | "tempo_bpm": null, |
| 4769 | "sections": null, |
| 4770 | "tracks": null |
| 4771 | }, |
| 4772 | "history": [ |
| 4773 | { |
| 4774 | "commit_id": "...", |
| 4775 | "message": "Add bass line", |
| 4776 | "active_tracks": ["bass", "drums"], |
| 4777 | "key": null, |
| 4778 | "tempo_bpm": null |
| 4779 | } |
| 4780 | ], |
| 4781 | "missing_elements": [], |
| 4782 | "suggestions": {} |
| 4783 | } |
| 4784 | ``` |
| 4785 | |
| 4786 | **Result type:** `MuseContextResult` — fields: `repo_id`, `current_branch`, `head_commit` (`MuseHeadCommitInfo`), `musical_state` (`MuseMusicalState`), `history` (`list[MuseHistoryEntry]`), `missing_elements`, `suggestions`. See `docs/reference/type_contracts.md`. |
| 4787 | |
| 4788 | **Agent use case:** When Maestro receives a "generate a new section" request, it runs `muse context --format json` to obtain the current musical state, passes the result to the LLM, and the LLM generates music that is harmonically, rhythmically, and structurally coherent with the existing composition. Without this command, generation decisions are musically incoherent. |
| 4789 | |
| 4790 | **Implementation notes:** |
| 4791 | - `active_tracks` is populated from MIDI/audio file names in the snapshot manifest (real data). |
| 4792 | - Musical dimensions (`key`, `tempo_bpm`, `form`, `emotion`, harmonic/dynamic/melodic profiles) are `null` until Storpheus MIDI analysis is integrated. The full schema is defined and stable. |
| 4793 | - `sections` and `tracks` are populated when the respective flags are passed; sections currently use a single "main" stub section containing all active tracks until MIDI region metadata is available. |
| 4794 | - Output is **deterministic**: for the same `commit_id` and flags, the output is always identical. |
| 4795 | |
| 4796 | **Implementation:** `maestro/services/muse_context.py` (service layer), `maestro/muse_cli/commands/context.py` (CLI command). Exit codes: 0 success, 1 user error (bad commit, no commits), 2 outside repo, 3 internal. |
| 4797 | |
| 4798 | --- |
| 4799 | |
| 4800 | ## `muse dynamics` — Dynamic (Velocity) Profile Analysis |
| 4801 | |
| 4802 | **Purpose:** Analyze the velocity (loudness) profile of a commit across all instrument |
| 4803 | tracks. The primary tool for understanding the dynamic arc of an arrangement and |
| 4804 | detecting flat, robotic, or over-compressed MIDI. |
| 4805 | |
| 4806 | **Usage:** |
| 4807 | ```bash |
| 4808 | muse dynamics [<commit>] [OPTIONS] |
| 4809 | ``` |
| 4810 | |
| 4811 | **Flags:** |
| 4812 | |
| 4813 | | Flag | Type | Default | Description | |
| 4814 | |------|------|---------|-------------| |
| 4815 | | `COMMIT` | positional | HEAD | Commit ref to analyze | |
| 4816 | | `--track TEXT` | string | all tracks | Case-insensitive prefix filter (e.g. `--track bass`) | |
| 4817 | | `--section TEXT` | string | — | Restrict to a named section/region (planned) | |
| 4818 | | `--compare COMMIT` | string | — | Side-by-side comparison with another commit (planned) | |
| 4819 | | `--history` | flag | off | Show dynamics for every commit in branch history (planned) | |
| 4820 | | `--peak` | flag | off | Show only tracks whose peak velocity exceeds the branch average | |
| 4821 | | `--range` | flag | off | Sort output by velocity range descending | |
| 4822 | | `--arc` | flag | off | When combined with `--track`, treat its value as an arc label filter | |
| 4823 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 4824 | |
| 4825 | **Arc labels:** |
| 4826 | |
| 4827 | | Label | Meaning | |
| 4828 | |-------|---------| |
| 4829 | | `flat` | Velocity variance < 10; steady throughout | |
| 4830 | | `crescendo` | Monotonically rising from start to end | |
| 4831 | | `decrescendo` | Monotonically falling from start to end | |
| 4832 | | `terraced` | Step-wise plateaus; sudden jumps between stable levels | |
| 4833 | | `swell` | Rises then falls (arch shape) | |
| 4834 | |
| 4835 | **Output example (text):** |
| 4836 | ``` |
| 4837 | Dynamic profile — commit a1b2c3d4 (HEAD -> main) |
| 4838 | |
| 4839 | Track Avg Vel Peak Range Arc |
| 4840 | --------- ------- ---- ----- ----------- |
| 4841 | drums 88 110 42 terraced |
| 4842 | bass 72 85 28 flat |
| 4843 | keys 64 95 56 crescendo |
| 4844 | lead 79 105 38 swell |
| 4845 | ``` |
| 4846 | |
| 4847 | **Output example (`--json`):** |
| 4848 | ```json |
| 4849 | { |
| 4850 | "commit": "a1b2c3d4", |
| 4851 | "branch": "main", |
| 4852 | "tracks": [ |
| 4853 | {"track": "drums", "avg_velocity": 88, "peak_velocity": 110, "velocity_range": 42, "arc": "terraced"} |
| 4854 | ] |
| 4855 | } |
| 4856 | ``` |
| 4857 | |
| 4858 | **Result type:** `TrackDynamics` — fields: `name`, `avg_velocity`, `peak_velocity`, `velocity_range`, `arc` |
| 4859 | |
| 4860 | **Agent use case:** Before generating a new layer, an agent calls `muse dynamics --json` to understand the current velocity landscape. If the arrangement is `flat` across all tracks, the agent adds velocity variation to the new part. If the arc is `crescendo`, the agent ensures the new layer contributes to rather than fights the build. |
| 4861 | |
| 4862 | **Implementation:** `maestro/muse_cli/commands/dynamics.py` — `_dynamics_async` (injectable async core), `TrackDynamics` (result entity), `_render_table` / `_render_json` (renderers). Exit codes: 0 success, 2 outside repo, 3 internal. |
| 4863 | |
| 4864 | > **Stub note:** Arc classification and velocity statistics are placeholder values. Full implementation requires MIDI note velocity extraction from committed snapshot objects (future: Storpheus MIDI parse route). |
| 4865 | |
| 4866 | --- |
| 4867 | |
| 4868 | ## `muse humanize` — Apply Micro-Timing and Velocity Humanization to Quantized MIDI |
| 4869 | |
| 4870 | **Purpose:** Apply realistic human-performance variation to machine-quantized MIDI, producing a new Muse commit that sounds natural. AI agents use this after generating quantized output to make compositions feel human before presenting them to DAW users. |
| 4871 | |
| 4872 | **Usage:** |
| 4873 | ```bash |
| 4874 | muse humanize [COMMIT] [OPTIONS] |
| 4875 | ``` |
| 4876 | |
| 4877 | **Flags:** |
| 4878 | |
| 4879 | | Flag | Type | Default | Description | |
| 4880 | |------|------|---------|-------------| |
| 4881 | | `COMMIT` | argument | HEAD | Source commit ref to humanize | |
| 4882 | | `--tight` | flag | off | Subtle: timing +/-5 ms, velocity +/-5 | |
| 4883 | | `--natural` | flag | on | Moderate: timing +/-12 ms, velocity +/-10 (default) | |
| 4884 | | `--loose` | flag | off | Heavy: timing +/-20 ms, velocity +/-15 | |
| 4885 | | `--factor FLOAT` | float | - | Custom factor 0.0-1.0 (overrides preset) | |
| 4886 | | `--timing-only` | flag | off | Apply timing variation only; preserve velocities | |
| 4887 | | `--velocity-only` | flag | off | Apply velocity variation only; preserve timing | |
| 4888 | | `--track TEXT` | string | all | Restrict to one track (prefix match) | |
| 4889 | | `--section TEXT` | string | all | Restrict to a named section | |
| 4890 | | `--seed N` | int | - | Fix random seed for reproducible output | |
| 4891 | | `--message TEXT` | string | auto | Commit message | |
| 4892 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 4893 | |
| 4894 | **Result types:** `HumanizeResult` and `TrackHumanizeResult` (both TypedDict). See `docs/reference/type_contracts.md`. |
| 4895 | |
| 4896 | **Agent use case:** After `muse commit` records a machine-generated MIDI variation, an AI agent runs `muse humanize --natural --seed 42` to add realistic performance feel. Drum groove is preserved automatically (GM channel 10 excluded from timing variation). |
| 4897 | |
| 4898 | **Implementation:** `maestro/muse_cli/commands/humanize.py`. Exit codes: 0 success, 1 flag conflict, 2 outside repo, 3 internal. |
| 4899 | |
| 4900 | > **Stub note:** Full MIDI note rewrite pending Storpheus note-level access. CLI interface is stable. |
| 4901 | |
| 4902 | --- |
| 4903 | |
| 4904 | ## `muse import` — Import a MIDI or MusicXML File as a New Muse Commit |
| 4905 | |
| 4906 | ### Overview |
| 4907 | |
| 4908 | `muse import <file>` ingests an external music file into a Muse-tracked project |
| 4909 | by copying it into `muse-work/imports/` and creating a Muse commit. It is the |
| 4910 | primary on-ramp for bringing existing DAW sessions, MIDI exports, or orchestral |
| 4911 | scores under Muse version control. |
| 4912 | |
| 4913 | ### Supported Formats |
| 4914 | |
| 4915 | | Extension | Format | Parser | |
| 4916 | |-----------|--------|--------| |
| 4917 | | `.mid`, `.midi` | Standard MIDI File | `mido` library | |
| 4918 | | `.xml`, `.musicxml` | MusicXML (score-partwise) | `xml.etree.ElementTree` | |
| 4919 | |
| 4920 | ### Command Signature |
| 4921 | |
| 4922 | ``` |
| 4923 | muse import <file> [OPTIONS] |
| 4924 | |
| 4925 | Arguments: |
| 4926 | file Path to the MIDI or MusicXML file to import. |
| 4927 | |
| 4928 | Options: |
| 4929 | --message, -m TEXT Commit message (default: "Import <filename>"). |
| 4930 | --track-map TEXT Map MIDI channels to track names. |
| 4931 | Format: "ch0=bass,ch1=piano,ch9=drums" |
| 4932 | --section TEXT Tag the imported content as a specific section. |
| 4933 | --analyze Run multi-dimensional analysis and display results. |
| 4934 | --dry-run Validate only — do not write files or commit. |
| 4935 | ``` |
| 4936 | |
| 4937 | ### What It Does |
| 4938 | |
| 4939 | 1. **Validate** — Checks that the file extension is supported. Clear error on unsupported types. |
| 4940 | 2. **Parse** — Extracts `NoteEvent` objects (pitch, velocity, timing, channel) using format-specific parsers. |
| 4941 | 3. **Apply track map** — Renames `channel_name` fields for any channels listed in `--track-map`. |
| 4942 | 4. **Copy** — Copies the source file to `muse-work/imports/<filename>`. |
| 4943 | 5. **Write metadata** — Creates `muse-work/imports/<filename>.meta.json` with note count, tracks, tempo, and track-map. |
| 4944 | 6. **Commit** — Calls `_commit_async` to create a Muse commit with the imported content. |
| 4945 | 7. **Analyse (optional)** — Prints a three-dimensional analysis: harmonic (pitch range, top pitches), rhythmic (note count, density, beats), dynamic (velocity distribution). |
| 4946 | |
| 4947 | ### Track Map Syntax |
| 4948 | |
| 4949 | The `--track-map` option accepts a comma-separated list of `KEY=VALUE` pairs where |
| 4950 | KEY is either `ch<N>` (e.g. `ch0`) or a bare channel number (e.g. `0`): |
| 4951 | |
| 4952 | ``` |
| 4953 | muse import song.mid --track-map "ch0=bass,ch1=piano,ch9=drums" |
| 4954 | ``` |
| 4955 | |
| 4956 | Unmapped channels retain their default label `ch<N>`. The mapping is persisted |
| 4957 | in `muse-work/imports/<filename>.meta.json` so downstream tooling can reconstruct |
| 4958 | track assignments from a commit. |
| 4959 | |
| 4960 | ### Metadata JSON Format |
| 4961 | |
| 4962 | Every import writes a sidecar JSON file alongside the imported file: |
| 4963 | |
| 4964 | ```json |
| 4965 | { |
| 4966 | "source": "/absolute/path/to/source.mid", |
| 4967 | "format": "midi", |
| 4968 | "ticks_per_beat": 480, |
| 4969 | "tempo_bpm": 120.0, |
| 4970 | "note_count": 64, |
| 4971 | "tracks": ["bass", "piano", "drums"], |
| 4972 | "track_map": {"ch0": "bass", "ch1": "piano", "ch9": "drums"}, |
| 4973 | "section": "verse", |
| 4974 | "raw_meta": {"num_tracks": 3} |
| 4975 | } |
| 4976 | ``` |
| 4977 | |
| 4978 | ### Dry Run |
| 4979 | |
| 4980 | `--dry-run` validates the file and shows what would be committed without creating |
| 4981 | any files or DB rows: |
| 4982 | |
| 4983 | ``` |
| 4984 | $ muse import song.mid --dry-run |
| 4985 | ✅ Dry run: 'song.mid' is valid (midi) |
| 4986 | Notes: 128, Tracks: 3, Tempo: 120.0 BPM |
| 4987 | Would commit: "Import song.mid" |
| 4988 | ``` |
| 4989 | |
| 4990 | ### Analysis Output |
| 4991 | |
| 4992 | `--analyze` appends a three-section report after the import: |
| 4993 | |
| 4994 | ``` |
| 4995 | Analysis: |
| 4996 | Format: midi |
| 4997 | Tempo: 120.0 BPM |
| 4998 | Tracks: bass, piano, drums |
| 4999 | |
| 5000 | ── Harmonic ────────────────────────────────── |
| 5001 | Pitch range: C2–G5 |
| 5002 | Top pitches: E4(12x), C4(10x), G4(8x), D4(6x), A4(5x) |
| 5003 | |
| 5004 | ── Rhythmic ────────────────────────────────── |
| 5005 | Notes: 128 |
| 5006 | Span: 32.0 beats |
| 5007 | Density: 4.0 notes/beat |
| 5008 | |
| 5009 | ── Dynamic ─────────────────────────────────── |
| 5010 | Velocity: avg=82, min=64, max=110 |
| 5011 | Character: f (loud) |
| 5012 | ``` |
| 5013 | |
| 5014 | ### Implementation |
| 5015 | |
| 5016 | | File | Role | |
| 5017 | |------|------| |
| 5018 | | `maestro/muse_cli/midi_parser.py` | Parsing, track-map, analysis — all pure functions, no DB or I/O | |
| 5019 | | `maestro/muse_cli/commands/import_cmd.py` | Typer command and `_import_async` core | |
| 5020 | | `tests/muse_cli/test_import.py` | 23 unit + integration tests | |
| 5021 | |
| 5022 | ### Muse VCS Considerations |
| 5023 | |
| 5024 | - **Affected operation:** `commit` — creates a new commit row. |
| 5025 | - **Postgres state:** One new `muse_cli_commits` row, one `muse_cli_snapshots` row, and two `muse_cli_objects` rows (the MIDI/XML file + the `.meta.json`). |
| 5026 | - **No schema migration required** — uses existing tables. |
| 5027 | - **Reproducibility:** Deterministic — same file + same flags → identical commit content (same `snapshot_id`). |
| 5028 | - **`muse-work/imports/`** — the canonical import landing zone, parallel to `muse-work/tracks/`, `muse-work/renders/`, etc. |
| 5029 | |
| 5030 | ### Error Handling |
| 5031 | |
| 5032 | | Scenario | Exit code | Message | |
| 5033 | |----------|-----------|---------| |
| 5034 | | File not found | 1 (USER_ERROR) | `❌ File not found: <path>` | |
| 5035 | | Unsupported extension | 1 (USER_ERROR) | `❌ Unsupported file extension '.<ext>'. Supported: …` | |
| 5036 | | Malformed MIDI | 1 (USER_ERROR) | `❌ Cannot parse MIDI file '<path>': <reason>` | |
| 5037 | | Malformed MusicXML | 1 (USER_ERROR) | `❌ Cannot parse MusicXML file '<path>': <reason>` | |
| 5038 | | Invalid `--track-map` | 1 (USER_ERROR) | `❌ --track-map: Invalid track-map entry …` | |
| 5039 | | Not in a repo | 2 (REPO_NOT_FOUND) | Standard `require_repo()` message | |
| 5040 | | Unexpected failure | 3 (INTERNAL_ERROR) | `❌ muse import failed: <exc>` | |
| 5041 | |
| 5042 | --- |
| 5043 | |
| 5044 | ## `muse divergence` — Musical Divergence Between Two Branches |
| 5045 | |
| 5046 | **Purpose:** Show how two branches have diverged *musically* — useful when two |
| 5047 | producers are working on different arrangements of the same project and you need |
| 5048 | to understand the creative distance before deciding which to merge. |
| 5049 | |
| 5050 | **Implementation:** `maestro/muse_cli/commands/divergence.py`\ |
| 5051 | **Service:** `maestro/services/muse_divergence.py`\ |
| 5052 | **Status:** ✅ implemented (issue #119) |
| 5053 | |
| 5054 | ### Flags |
| 5055 | |
| 5056 | | Flag | Type | Default | Description | |
| 5057 | |------|------|---------|-------------| |
| 5058 | | `BRANCH_A` | positional | required | First branch name | |
| 5059 | | `BRANCH_B` | positional | required | Second branch name | |
| 5060 | | `--since COMMIT` | string | auto | Common ancestor commit ID (auto-detected via merge-base BFS if omitted) | |
| 5061 | | `--dimensions TEXT` | string (repeatable) | all five | Musical dimension(s) to analyse | |
| 5062 | | `--json` | flag | off | Machine-readable JSON output | |
| 5063 | |
| 5064 | ### What It Computes |
| 5065 | |
| 5066 | 1. **Finds the merge base** — BFS over `MuseCliCommit.parent_commit_id` / `parent2_commit_id`, equivalent to `git merge-base`. |
| 5067 | 2. **Collects changed paths** — diff from merge-base snapshot to branch-tip (added + deleted + modified paths). |
| 5068 | 3. **Classifies paths by dimension** — keyword matching on lowercase filename. |
| 5069 | 4. **Scores each dimension** — `score = |sym_diff(A, B)| / |union(A, B)|`. 0.0 = identical; 1.0 = completely diverged. |
| 5070 | 5. **Classifies level** — `NONE` (<0.15), `LOW` (0.15–0.40), `MED` (0.40–0.70), `HIGH` (≥0.70). |
| 5071 | 6. **Computes overall score** — mean of per-dimension scores. |
| 5072 | |
| 5073 | ### Result types |
| 5074 | |
| 5075 | `DivergenceLevel` (Enum), `DimensionDivergence` (frozen dataclass), `MuseDivergenceResult` (frozen dataclass). |
| 5076 | See `docs/reference/type_contracts.md § Muse Divergence Types`. |
| 5077 | |
| 5078 | ### Agent use case |
| 5079 | |
| 5080 | An AI deciding which branch to merge calls `muse divergence feature/guitar feature/piano --json` |
| 5081 | before generation. HIGH harmonic divergence + LOW rhythmic divergence means lean on the piano |
| 5082 | branch for chord voicings while preserving the guitar branch's groove patterns. |
| 5083 | |
| 5084 | ### `muse timeline` |
| 5085 | |
| 5086 | **Purpose:** Render a commit-by-commit chronological view of a composition's |
| 5087 | creative arc — emotion transitions, section progress, and per-track activity. |
| 5088 | This is the "album liner notes" view that no Git command provides. Agents |
| 5089 | use it to understand how a project's emotional and structural character |
| 5090 | evolved before making generation decisions. |
| 5091 | |
| 5092 | **Usage:** |
| 5093 | ```bash |
| 5094 | muse timeline [RANGE] [OPTIONS] |
| 5095 | ``` |
| 5096 | |
| 5097 | **Flags:** |
| 5098 | | Flag | Type | Default | Description | |
| 5099 | |------|------|---------|-------------| |
| 5100 | | `RANGE` | positional string | full history | Commit range (reserved — full history shown for now) | |
| 5101 | | `--emotion` | flag | off | Add emotion column (from `emotion:*` tags) | |
| 5102 | | `--sections` | flag | off | Group commits under section headers (from `section:*` tags) | |
| 5103 | | `--tracks` | flag | off | Show per-track activity column (from `track:*` tags) | |
| 5104 | | `--json` | flag | off | Emit structured JSON for UI rendering or agent consumption | |
| 5105 | | `--limit N` | int | 1000 | Maximum commits to walk | |
| 5106 | |
| 5107 | **Output example (text):** |
| 5108 | ``` |
| 5109 | Timeline — branch: main (3 commit(s)) |
| 5110 | |
| 5111 | ── verse ── |
| 5112 | 2026-02-01 abc1234 Initial drum arrangement [drums] [melancholic] ████ |
| 5113 | 2026-02-02 def5678 Add bass line [bass] [melancholic] ██████ |
| 5114 | ── chorus ── |
| 5115 | 2026-02-03 ghi9012 Chorus melody [keys,vocals] [joyful] █████████ |
| 5116 | |
| 5117 | Emotion arc: melancholic → joyful |
| 5118 | Sections: verse → chorus |
| 5119 | ``` |
| 5120 | |
| 5121 | **Output example (JSON):** |
| 5122 | ```json |
| 5123 | { |
| 5124 | "branch": "main", |
| 5125 | "total_commits": 3, |
| 5126 | "emotion_arc": ["melancholic", "joyful"], |
| 5127 | "section_order": ["verse", "chorus"], |
| 5128 | "entries": [ |
| 5129 | { |
| 5130 | "commit_id": "abc1234...", |
| 5131 | "short_id": "abc1234", |
| 5132 | "committed_at": "2026-02-01T00:00:00+00:00", |
| 5133 | "message": "Initial drum arrangement", |
| 5134 | "emotion": "melancholic", |
| 5135 | "sections": ["verse"], |
| 5136 | "tracks": ["drums"], |
| 5137 | "activity": 1 |
| 5138 | } |
| 5139 | ] |
| 5140 | } |
| 5141 | ``` |
| 5142 | |
| 5143 | **Result types:** `MuseTimelineEntry`, `MuseTimelineResult` — see `docs/reference/type_contracts.md § Muse Timeline Types`. |
| 5144 | |
| 5145 | **Agent use case:** An AI agent calls `muse timeline --json` before composing a new |
| 5146 | section to understand the emotional arc to date (e.g. `melancholic → joyful → tense`). |
| 5147 | It uses `section_order` to determine what structural elements have been established |
| 5148 | and `emotion_arc` to decide whether to maintain or contrast the current emotional |
| 5149 | character. `activity` per commit helps identify which sections were most actively |
| 5150 | developed. |
| 5151 | |
| 5152 | **Implementation note:** Emotion, section, and track data are derived entirely from |
| 5153 | tags attached via `muse tag add`. Commits with no tags show `—` in filtered columns. |
| 5154 | The commit range argument (`RANGE`) is accepted but reserved for a future iteration |
| 5155 | that supports `HEAD~10..HEAD` syntax. |
| 5156 | |
| 5157 | --- |
| 5158 | |
| 5159 | ### `muse validate` |
| 5160 | |
| 5161 | **Purpose:** Run integrity checks against the working tree before `muse commit`. |
| 5162 | Detects corrupted MIDI files, manifest mismatches, duplicate instrument roles, |
| 5163 | non-conformant section names, and unknown emotion tags — giving agents and |
| 5164 | producers an actionable quality gate before bad state enters history. |
| 5165 | |
| 5166 | **Status:** ✅ Fully implemented (issue #99) |
| 5167 | |
| 5168 | **Usage:** |
| 5169 | ```bash |
| 5170 | muse validate [OPTIONS] |
| 5171 | ``` |
| 5172 | |
| 5173 | **Flags:** |
| 5174 | |
| 5175 | | Flag | Type | Default | Description | |
| 5176 | |------|------|---------|-------------| |
| 5177 | | `--strict` | flag | off | Exit 2 on warnings as well as errors. | |
| 5178 | | `--track TEXT` | string | — | Restrict checks to files/paths containing TEXT (case-insensitive). | |
| 5179 | | `--section TEXT` | string | — | Restrict section-naming check to directories containing TEXT. | |
| 5180 | | `--fix` | flag | off | Auto-fix correctable issues (conservative; no data-loss risk). | |
| 5181 | | `--json` | flag | off | Emit full structured JSON for agent consumption. | |
| 5182 | |
| 5183 | **Exit codes:** |
| 5184 | |
| 5185 | | Code | Meaning | |
| 5186 | |------|---------| |
| 5187 | | 0 | All checks passed — working tree is clean. | |
| 5188 | | 1 | One or more ERROR issues found (corrupted MIDI, orphaned files). | |
| 5189 | | 2 | WARN issues found AND `--strict` was passed. | |
| 5190 | | 3 | Internal error (unexpected exception). | |
| 5191 | |
| 5192 | **Checks performed:** |
| 5193 | |
| 5194 | | Check | Severity | Description | |
| 5195 | |-------|----------|-------------| |
| 5196 | | `midi_integrity` | ERROR | Verifies each `.mid`/`.midi` has a valid SMF `MThd` header. | |
| 5197 | | `manifest_consistency` | ERROR/WARN | Compares committed snapshot manifest vs actual working tree. | |
| 5198 | | `no_duplicate_tracks` | WARN | Detects multiple MIDI files sharing the same instrument role. | |
| 5199 | | `section_naming` | WARN | Verifies section dirs match `[a-z][a-z0-9_-]*`. | |
| 5200 | | `emotion_tags` | WARN | Checks emotion tags (`.muse/tags.json`) against the allowed vocabulary. | |
| 5201 | |
| 5202 | **Output example (human-readable):** |
| 5203 | ``` |
| 5204 | Validating working tree … |
| 5205 | |
| 5206 | ✅ midi_integrity PASS |
| 5207 | ❌ manifest_consistency FAIL |
| 5208 | ❌ ERROR beat.mid File in committed manifest is missing from working tree. |
| 5209 | ✅ no_duplicate_tracks PASS |
| 5210 | ⚠️ section_naming WARN |
| 5211 | ⚠️ WARN Verse Section directory 'Verse' does not follow naming convention. |
| 5212 | ✅ emotion_tags PASS |
| 5213 | |
| 5214 | ⚠️ 1 error, 1 warning — working tree has integrity issues. |
| 5215 | ``` |
| 5216 | |
| 5217 | **Output example (`--json`):** |
| 5218 | ```json |
| 5219 | { |
| 5220 | "clean": false, |
| 5221 | "has_errors": true, |
| 5222 | "has_warnings": true, |
| 5223 | "checks": [ |
| 5224 | { "name": "midi_integrity", "passed": true, "issues": [] }, |
| 5225 | { |
| 5226 | "name": "manifest_consistency", |
| 5227 | "passed": false, |
| 5228 | "issues": [ |
| 5229 | { |
| 5230 | "severity": "error", |
| 5231 | "check": "manifest_consistency", |
| 5232 | "path": "beat.mid", |
| 5233 | "message": "File in committed manifest is missing from working tree (orphaned)." |
| 5234 | } |
| 5235 | ] |
| 5236 | } |
| 5237 | ], |
| 5238 | "fixes_applied": [] |
| 5239 | } |
| 5240 | ``` |
| 5241 | |
| 5242 | **Result types:** `MuseValidateResult`, `ValidationCheckResult`, `ValidationIssue`, `ValidationSeverity` |
| 5243 | — all defined in `maestro/services/muse_validate.py` and registered in `docs/reference/type_contracts.md`. |
| 5244 | |
| 5245 | **Agent use case:** An AI composition agent calls `muse validate --json` before every |
| 5246 | `muse commit` to confirm the working tree is consistent. If `has_errors` is true the agent |
| 5247 | must investigate the failing check before committing — a corrupted MIDI would silently |
| 5248 | corrupt the composition history. With `--strict`, agents can enforce zero-warning quality gates. |
| 5249 | |
| 5250 | --- |
| 5251 | ## `muse diff` — Music-Dimension Diff Between Commits |
| 5252 | |
| 5253 | **Purpose:** Compare two commits across five orthogonal musical dimensions — |
| 5254 | harmonic, rhythmic, melodic, structural, and dynamic. Where `git diff` tells |
| 5255 | you "file changed," `muse diff --harmonic` tells you "the song modulated from |
| 5256 | Eb major to F minor and the tension profile doubled." This is the killer |
| 5257 | feature that proves Muse's value over Git: musically meaningful version control. |
| 5258 | |
| 5259 | **Usage:** |
| 5260 | ```bash |
| 5261 | muse diff [<COMMIT_A>] [<COMMIT_B>] [OPTIONS] |
| 5262 | ``` |
| 5263 | |
| 5264 | Defaults: `COMMIT_A` = HEAD~1, `COMMIT_B` = HEAD. |
| 5265 | |
| 5266 | **Flags:** |
| 5267 | |
| 5268 | | Flag | Type | Default | Description | |
| 5269 | |------|------|---------|-------------| |
| 5270 | | `COMMIT_A` | positional | HEAD~1 | Earlier commit ref | |
| 5271 | | `COMMIT_B` | positional | HEAD | Later commit ref | |
| 5272 | | `--harmonic` | flag | off | Compare key, mode, chord progression, tension | |
| 5273 | | `--rhythmic` | flag | off | Compare tempo, meter, swing, groove drift | |
| 5274 | | `--melodic` | flag | off | Compare motifs, contour, pitch range | |
| 5275 | | `--structural` | flag | off | Compare sections, instrumentation, form | |
| 5276 | | `--dynamic` | flag | off | Compare velocity arc, per-track loudness | |
| 5277 | | `--all` | flag | off | Run all five dimensions simultaneously | |
| 5278 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 5279 | |
| 5280 | **Output example (`muse diff HEAD~1 HEAD --harmonic`):** |
| 5281 | ``` |
| 5282 | Harmonic diff: abc1234 -> def5678 |
| 5283 | |
| 5284 | Key: Eb major -> F minor |
| 5285 | Mode: Major -> Minor |
| 5286 | Chord prog: I-IV-V-I -> i-VI-III-VII |
| 5287 | Tension: Low (0.2) -> Medium-High (0.65) |
| 5288 | Summary: Major harmonic restructuring — key modulation down a minor 3rd, shift to Andalusian cadence |
| 5289 | ``` |
| 5290 | |
| 5291 | **Output example (`muse diff HEAD~1 HEAD --rhythmic`):** |
| 5292 | ``` |
| 5293 | Rhythmic diff: abc1234 -> def5678 |
| 5294 | |
| 5295 | Tempo: 120.0 BPM -> 128.0 BPM (+8.0 BPM) |
| 5296 | Meter: 4/4 -> 4/4 |
| 5297 | Swing: Straight (0.5) -> Light swing (0.57) |
| 5298 | Groove drift: 12.0ms -> 6.0ms |
| 5299 | Summary: Slightly faster, more swung, tighter quantization |
| 5300 | ``` |
| 5301 | |
| 5302 | **Output example (`muse diff HEAD~1 HEAD --all`):** |
| 5303 | ``` |
| 5304 | Music diff: abc1234 -> def5678 |
| 5305 | Changed: harmonic, rhythmic, melodic, structural, dynamic |
| 5306 | Unchanged: (none) |
| 5307 | |
| 5308 | -- Harmonic -- |
| 5309 | ... |
| 5310 | |
| 5311 | -- Rhythmic -- |
| 5312 | ... |
| 5313 | ``` |
| 5314 | |
| 5315 | **Unchanged dimensions:** When a dimension shows no change, the renderer appends |
| 5316 | `Unchanged` to the block rather than omitting it. This guarantees agents always |
| 5317 | receive a complete report — silence is never ambiguous. |
| 5318 | |
| 5319 | **Result types:** |
| 5320 | |
| 5321 | | Type | Fields | |
| 5322 | |------|--------| |
| 5323 | | `HarmonicDiffResult` | `commit_a/b`, `key_a/b`, `mode_a/b`, `chord_prog_a/b`, `tension_a/b`, `tension_label_a/b`, `summary`, `changed` | |
| 5324 | | `RhythmicDiffResult` | `commit_a/b`, `tempo_a/b`, `meter_a/b`, `swing_a/b`, `swing_label_a/b`, `groove_drift_ms_a/b`, `summary`, `changed` | |
| 5325 | | `MelodicDiffResult` | `commit_a/b`, `motifs_introduced`, `motifs_removed`, `contour_a/b`, `range_low_a/b`, `range_high_a/b`, `summary`, `changed` | |
| 5326 | | `StructuralDiffResult` | `commit_a/b`, `sections_added`, `sections_removed`, `instruments_added`, `instruments_removed`, `form_a/b`, `summary`, `changed` | |
| 5327 | | `DynamicDiffResult` | `commit_a/b`, `avg_velocity_a/b`, `arc_a/b`, `tracks_louder`, `tracks_softer`, `tracks_silent`, `summary`, `changed` | |
| 5328 | | `MusicDiffReport` | All five dimension results + `changed_dimensions`, `unchanged_dimensions`, `summary` | |
| 5329 | |
| 5330 | See `docs/reference/type_contracts.md § Muse Diff Types`. |
| 5331 | |
| 5332 | **Agent use case:** An AI composing a new variation runs |
| 5333 | `muse diff HEAD~3 HEAD --harmonic --json` before generating to understand |
| 5334 | whether the last three sessions have been converging on a key or exploring |
| 5335 | multiple tonalities. The `changed_dimensions` field in `MusicDiffReport` lets |
| 5336 | the agent prioritize which musical parameters to vary next. |
| 5337 | |
| 5338 | **Implementation:** `maestro/muse_cli/commands/diff.py` — |
| 5339 | `HarmonicDiffResult`, `RhythmicDiffResult`, `MelodicDiffResult`, |
| 5340 | `StructuralDiffResult`, `DynamicDiffResult`, `MusicDiffReport` (TypedDicts); |
| 5341 | `_harmonic_diff_async()`, `_rhythmic_diff_async()`, `_melodic_diff_async()`, |
| 5342 | `_structural_diff_async()`, `_dynamic_diff_async()`, `_diff_all_async()`; |
| 5343 | `_render_harmonic()`, `_render_rhythmic()`, `_render_melodic()`, |
| 5344 | `_render_structural()`, `_render_dynamic()`, `_render_report()`; |
| 5345 | `_resolve_refs()`, `_tension_label()`. |
| 5346 | Exit codes: 0 success, 2 outside repo (`REPO_NOT_FOUND`), 3 internal error. |
| 5347 | |
| 5348 | > **Stub note:** All dimension analyses return realistic placeholder data. |
| 5349 | > Full implementation requires Storpheus MIDI parsing for chord/tempo/motif |
| 5350 | > extraction. The CLI contract (flags, output schema, result types) is frozen |
| 5351 | > so agents can rely on it before the analysis pipeline is wired in. |
| 5352 | |
| 5353 | --- |
| 5354 | |
| 5355 | ## `muse inspect` — Print Structured JSON of the Muse Commit Graph |
| 5356 | |
| 5357 | **Purpose:** Serialize the full commit graph reachable from a starting reference |
| 5358 | into machine-readable output. This is the primary introspection tool for AI |
| 5359 | agents and tooling that need to programmatically traverse or audit commit history, |
| 5360 | branch state, and compositional metadata without parsing human-readable output. |
| 5361 | |
| 5362 | **Implementation:** `maestro/muse_cli/commands/inspect.py`\ |
| 5363 | **Service:** `maestro/services/muse_inspect.py`\ |
| 5364 | **Status:** ✅ implemented (issue #98) |
| 5365 | |
| 5366 | ### Usage |
| 5367 | |
| 5368 | ```bash |
| 5369 | muse inspect # JSON of HEAD branch history |
| 5370 | muse inspect abc1234 # start from a specific commit |
| 5371 | muse inspect --depth 5 # limit to 5 commits |
| 5372 | muse inspect --branches # include all branch heads |
| 5373 | muse inspect --format dot # Graphviz DOT graph |
| 5374 | muse inspect --format mermaid # Mermaid.js graph definition |
| 5375 | ``` |
| 5376 | |
| 5377 | ### Flags |
| 5378 | |
| 5379 | | Flag | Type | Default | Description | |
| 5380 | |------|------|---------|-------------| |
| 5381 | | `[<ref>]` | positional | HEAD | Starting commit ID or branch name | |
| 5382 | | `--depth N` | int | unlimited | Limit graph traversal to N commits per branch | |
| 5383 | | `--branches` | flag | off | Include all branch heads and their reachable commits | |
| 5384 | | `--tags` | flag | off | Include tag refs in the output | |
| 5385 | | `--format` | enum | `json` | Output format: `json`, `dot`, `mermaid` | |
| 5386 | |
| 5387 | ### Output example (JSON) |
| 5388 | |
| 5389 | ```json |
| 5390 | { |
| 5391 | "repo_id": "550e8400-e29b-41d4-a716-446655440000", |
| 5392 | "current_branch": "main", |
| 5393 | "branches": { |
| 5394 | "main": "a1b2c3d4e5f6...", |
| 5395 | "feature/guitar": "f9e8d7c6b5a4..." |
| 5396 | }, |
| 5397 | "commits": [ |
| 5398 | { |
| 5399 | "commit_id": "a1b2c3d4e5f6...", |
| 5400 | "short_id": "a1b2c3d4", |
| 5401 | "branch": "main", |
| 5402 | "parent_commit_id": "f9e8d7c6b5a4...", |
| 5403 | "parent2_commit_id": null, |
| 5404 | "message": "boom bap demo take 2", |
| 5405 | "author": "", |
| 5406 | "committed_at": "2026-02-27T17:30:00+00:00", |
| 5407 | "snapshot_id": "deadbeef...", |
| 5408 | "metadata": {"tempo_bpm": 95.0}, |
| 5409 | "tags": ["emotion:melancholic", "stage:rough-mix"] |
| 5410 | } |
| 5411 | ] |
| 5412 | } |
| 5413 | ``` |
| 5414 | |
| 5415 | ### Result types |
| 5416 | |
| 5417 | `MuseInspectCommit` (frozen dataclass) — one commit node in the graph.\ |
| 5418 | `MuseInspectResult` (frozen dataclass) — full serialized graph with branch pointers.\ |
| 5419 | `InspectFormat` (str Enum) — `json`, `dot`, `mermaid`.\ |
| 5420 | See `docs/reference/type_contracts.md § Muse Inspect Types`. |
| 5421 | |
| 5422 | ### Format: DOT |
| 5423 | |
| 5424 | Graphviz DOT directed graph. Pipe to `dot -Tsvg` to render a visual DAG: |
| 5425 | |
| 5426 | ```bash |
| 5427 | muse inspect --format dot | dot -Tsvg -o graph.svg |
| 5428 | ``` |
| 5429 | |
| 5430 | Each commit becomes an ellipse node labelled `<short_id>\n<message[:40]>`. |
| 5431 | Parent edges point child → parent (matching git convention). Branch refs |
| 5432 | appear as bold rectangle nodes pointing to their HEAD commit. |
| 5433 | |
| 5434 | ### Format: Mermaid |
| 5435 | |
| 5436 | Mermaid.js `graph LR` definition. Embed in GitHub markdown: |
| 5437 | |
| 5438 | ``` |
| 5439 | muse inspect --format mermaid |
| 5440 | ``` |
| 5441 | |
| 5442 | ```mermaid |
| 5443 | graph LR |
| 5444 | a1b2c3d4["a1b2c3d4: boom bap demo take 2"] |
| 5445 | f9e8d7c6["f9e8d7c6: boom bap demo take 1"] |
| 5446 | a1b2c3d4 --> f9e8d7c6 |
| 5447 | main["main"] |
| 5448 | main --> a1b2c3d4 |
| 5449 | ``` |
| 5450 | |
| 5451 | ### Agent use case |
| 5452 | |
| 5453 | An AI composition agent calls `muse inspect --format json` before generating |
| 5454 | new music to understand the full lineage of the project: |
| 5455 | |
| 5456 | 1. **Branch discovery** — which creative threads exist (`branches` dict). |
| 5457 | 2. **Graph traversal** — which commits are ancestors, which are on feature branches. |
| 5458 | 3. **Metadata audit** — which commits have explicit tempo, meter, or emotion tags. |
| 5459 | 4. **Divergence awareness** — combined with `muse divergence`, informs merge decisions. |
| 5460 | |
| 5461 | The JSON output is deterministic for a fixed graph state, making it safe to cache |
| 5462 | between agent invocations and diff to detect graph changes. |
| 5463 | |
| 5464 | --- |
| 5465 | |
| 5466 | ## `muse render-preview [<commit>]` — Audio Preview of a Commit Snapshot |
| 5467 | |
| 5468 | **Purpose:** Render the MIDI snapshot of any commit to an audio file, letting producers and AI agents hear what the project sounded like at any point in history — without opening a DAW session. The musical equivalent of `git show <commit>` with audio playback. |
| 5469 | |
| 5470 | **Implementation:** `maestro/muse_cli/commands/render_preview.py`\ |
| 5471 | **Service:** `maestro/services/muse_render_preview.py`\ |
| 5472 | **Status:** ✅ implemented (issue #96) |
| 5473 | |
| 5474 | ### Flags |
| 5475 | |
| 5476 | | Flag | Type | Default | Description | |
| 5477 | |------|------|---------|-------------| |
| 5478 | | `[<commit>]` | positional string | HEAD | Short commit ID prefix to preview | |
| 5479 | | `--format` / `-f` | `wav\|mp3\|flac` | `wav` | Output audio format | |
| 5480 | | `--track TEXT` | string | all | Render only MIDI files matching this track name substring | |
| 5481 | | `--section TEXT` | string | all | Render only MIDI files matching this section name substring | |
| 5482 | | `--output` / `-o` | path | `/tmp/muse-preview-<short_id>.<fmt>` | Write the preview to this path | |
| 5483 | | `--open` | flag | off | Open the rendered preview in the system default audio player (macOS only) | |
| 5484 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 5485 | |
| 5486 | ### Output example (text mode) |
| 5487 | |
| 5488 | ``` |
| 5489 | ⚠️ Preview generated (stub — Storpheus /render not yet deployed): |
| 5490 | /tmp/muse-preview-abc12345.wav |
| 5491 | (1 MIDI files used) |
| 5492 | ``` |
| 5493 | |
| 5494 | ### JSON output example (`--json`) |
| 5495 | |
| 5496 | ```json |
| 5497 | { |
| 5498 | "commit_id": "abc12345def67890...", |
| 5499 | "commit_short": "abc12345", |
| 5500 | "output_path": "/tmp/muse-preview-abc12345.wav", |
| 5501 | "format": "wav", |
| 5502 | "midi_files_used": 1, |
| 5503 | "skipped_count": 0, |
| 5504 | "stubbed": true |
| 5505 | } |
| 5506 | ``` |
| 5507 | |
| 5508 | ### Result type: `RenderPreviewResult` |
| 5509 | |
| 5510 | Defined in `maestro/services/muse_render_preview.py`. |
| 5511 | |
| 5512 | | Field | Type | Description | |
| 5513 | |-------|------|-------------| |
| 5514 | | `output_path` | `pathlib.Path` | Absolute path of the rendered audio file | |
| 5515 | | `format` | `PreviewFormat` | Audio format enum (`wav` / `mp3` / `flac`) | |
| 5516 | | `commit_id` | `str` | Full commit ID (64-char SHA) | |
| 5517 | | `midi_files_used` | `int` | Number of MIDI files from the snapshot used | |
| 5518 | | `skipped_count` | `int` | Manifest entries skipped (wrong type / filter / missing) | |
| 5519 | | `stubbed` | `bool` | `True` when Storpheus `/render` is not yet deployed and the file is a MIDI placeholder | |
| 5520 | |
| 5521 | ### Error handling |
| 5522 | |
| 5523 | | Scenario | Exit code | Message | |
| 5524 | |----------|-----------|---------| |
| 5525 | | Not in a Muse repo | 2 (REPO_NOT_FOUND) | Standard `require_repo()` message | |
| 5526 | | No commits yet | 1 (USER_ERROR) | `❌ No commits yet — nothing to export.` | |
| 5527 | | Ambiguous commit prefix | 1 (USER_ERROR) | Lists all matching commits | |
| 5528 | | No MIDI files after filter | 1 (USER_ERROR) | `❌ No MIDI files found in snapshot…` | |
| 5529 | | Storpheus unreachable | 3 (INTERNAL_ERROR) | `❌ Storpheus not reachable — render aborted.` | |
| 5530 | |
| 5531 | ### Storpheus render status |
| 5532 | |
| 5533 | The Storpheus service currently exposes MIDI *generation* at `POST /generate`. A dedicated `POST /render` endpoint (MIDI-in → audio-out) is planned but not yet deployed. Until it ships: |
| 5534 | |
| 5535 | - A health-check confirms Storpheus is reachable (fast probe, 3 s timeout). |
| 5536 | - The first matching MIDI file from the snapshot is **copied** to `output_path` as a placeholder. |
| 5537 | - `RenderPreviewResult.stubbed` is set to `True`. |
| 5538 | - The CLI prints a clear `⚠️ Preview generated (stub…)` warning. |
| 5539 | |
| 5540 | When `POST /render` is available, replace `_render_via_storpheus` in the service with a multipart POST call and set `stubbed=False`. |
| 5541 | |
| 5542 | ### Agent use case |
| 5543 | |
| 5544 | An AI music generation agent uses `muse render-preview HEAD~10 --json` to obtain a path to the audio preview of a historical snapshot before deciding whether to branch from it or continue the current line. The `stubbed` field tells the agent whether the file is a true audio render or a MIDI placeholder, so it can adjust its reasoning accordingly. |
| 5545 | |
| 5546 | --- |
| 5547 | |
| 5548 | ## `muse rev-parse` — Resolve a Revision Expression to a Commit ID |
| 5549 | |
| 5550 | **Purpose:** Translate a symbolic revision expression into a concrete 64-character |
| 5551 | commit ID. Mirrors `git rev-parse` semantics and is the plumbing primitive used |
| 5552 | internally by other Muse commands that accept revision arguments. |
| 5553 | |
| 5554 | ``` |
| 5555 | muse rev-parse <revision> [OPTIONS] |
| 5556 | |
| 5557 | ``` |
| 5558 | |
| 5559 | ### Flags |
| 5560 | |
| 5561 | | Flag | Type | Default | Description | |
| 5562 | |------|------|---------|-------------| |
| 5563 | | `REVISION` | positional | required | Revision expression to resolve | |
| 5564 | | `--short` | flag | off | Print only the first 8 characters of the commit ID | |
| 5565 | | `--verify` | flag | off | Exit 1 if the expression does not resolve (default: print nothing) | |
| 5566 | | `--abbrev-ref` | flag | off | Print the branch name instead of the commit ID | |
| 5567 | |
| 5568 | ### Supported Revision Expressions |
| 5569 | |
| 5570 | | Expression | Resolves to | |
| 5571 | |------------|-------------| |
| 5572 | | `HEAD` | Tip of the current branch | |
| 5573 | | `<branch>` | Tip of the named branch | |
| 5574 | | `<commit_id>` | Exact or prefix-matched commit | |
| 5575 | | `HEAD~N` | N parents back from HEAD | |
| 5576 | | `<branch>~N` | N parents back from the branch tip | |
| 5577 | |
| 5578 | ### Output Example |
| 5579 | |
| 5580 | ``` |
| 5581 | $ muse rev-parse HEAD |
| 5582 | a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 |
| 5583 | |
| 5584 | $ muse rev-parse --short HEAD |
| 5585 | a1b2c3d4 |
| 5586 | |
| 5587 | $ muse rev-parse --abbrev-ref HEAD |
| 5588 | main |
| 5589 | |
| 5590 | $ muse rev-parse HEAD~2 |
| 5591 | f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8 |
| 5592 | |
| 5593 | $ muse rev-parse --verify nonexistent |
| 5594 | fatal: Not a valid revision: 'nonexistent' |
| 5595 | # exit code 1 |
| 5596 | ``` |
| 5597 | |
| 5598 | ### Result Type |
| 5599 | |
| 5600 | `RevParseResult` — see `docs/reference/type_contracts.md § Muse rev-parse Types`. |
| 5601 | |
| 5602 | ### Agent Use Case |
| 5603 | |
| 5604 | An AI agent resolves `HEAD~1` before generating a new variation to obtain the |
| 5605 | parent commit ID, which it passes as a `base_commit` argument to downstream |
| 5606 | commands. Use `--verify` in automation scripts to fail fast rather than |
| 5607 | silently producing empty output. |
| 5608 | |
| 5609 | --- |
| 5610 | |
| 5611 | ## `muse symbolic-ref` — Read or Write a Symbolic Ref |
| 5612 | |
| 5613 | **Purpose:** Read or write a symbolic ref (e.g. `HEAD`), answering "which branch |
| 5614 | is currently checked out?" — the primitive that all checkout, branch, and HEAD |
| 5615 | management operations depend on. |
| 5616 | |
| 5617 | **Implementation:** `maestro/muse_cli/commands/symbolic_ref.py`\ |
| 5618 | **Status:** ✅ implemented (issue #93) |
| 5619 | |
| 5620 | ### Usage |
| 5621 | |
| 5622 | ```bash |
| 5623 | muse symbolic-ref HEAD # read: prints refs/heads/main |
| 5624 | muse symbolic-ref --short HEAD # read short form: prints main |
| 5625 | muse symbolic-ref HEAD refs/heads/feature/x # write: update .muse/HEAD |
| 5626 | muse symbolic-ref --delete HEAD # delete the symbolic ref file |
| 5627 | |
| 5628 | ``` |
| 5629 | |
| 5630 | ### Flags |
| 5631 | |
| 5632 | | Flag | Type | Default | Description | |
| 5633 | |------|------|---------|-------------| |
| 5634 | | `<name>` | positional | required | Ref name, e.g. `HEAD` or `refs/heads/main` | |
| 5635 | | `<ref>` | positional | none | When supplied, write this target into the ref (must start with `refs/`) | |
| 5636 | | `--short` | flag | off | Print just the branch name (`main`) instead of the full ref path | |
| 5637 | | `--delete / -d` | flag | off | Delete the symbolic ref file entirely | |
| 5638 | | `--quiet / -q` | flag | off | Suppress error output when the ref is not symbolic | |
| 5639 | |
| 5640 | ### Output example |
| 5641 | |
| 5642 | ``` |
| 5643 | # Read |
| 5644 | refs/heads/main |
| 5645 | |
| 5646 | # Read --short |
| 5647 | main |
| 5648 | |
| 5649 | # Write |
| 5650 | ✅ HEAD → refs/heads/feature/guitar |
| 5651 | |
| 5652 | # Delete |
| 5653 | ✅ Deleted symbolic ref 'HEAD' |
| 5654 | ``` |
| 5655 | |
| 5656 | ### Result type |
| 5657 | |
| 5658 | `SymbolicRefResult` — fields: `name` (str), `ref` (str), `short` (str). |
| 5659 | |
| 5660 | ### Agent use case |
| 5661 | |
| 5662 | An AI agent inspecting the current branch before generating new variations calls |
| 5663 | `muse symbolic-ref --short HEAD` to confirm it is operating on the expected branch. |
| 5664 | Before creating a new branch it calls `muse symbolic-ref HEAD refs/heads/feature/guitar` |
| 5665 | to update the HEAD pointer atomically. These are pure filesystem operations — no DB |
| 5666 | round-trip, sub-millisecond latency. |
| 5667 | |
| 5668 | ### Error handling |
| 5669 | |
| 5670 | | Scenario | Exit code | Message | |
| 5671 | |----------|-----------|---------| |
| 5672 | | Ref file does not exist | 1 (USER_ERROR) | `❌ HEAD is not a symbolic ref or does not exist` | |
| 5673 | | Ref content is a bare SHA (detached HEAD) | 1 (USER_ERROR) | same | |
| 5674 | | `<ref>` does not start with `refs/` | 1 (USER_ERROR) | `❌ Invalid symbolic-ref target '…': must start with 'refs/'` | |
| 5675 | | `--delete` on absent file | 1 (USER_ERROR) | `❌ HEAD: not found — nothing to delete` | |
| 5676 | | Not in a repo | 2 (REPO_NOT_FOUND) | Standard `require_repo()` message | |
| 5677 | |
| 5678 | --- |
| 5679 | |
| 5680 | ## `muse tempo-scale` — Stretch or Compress the Timing of a Commit |
| 5681 | |
| 5682 | **Purpose:** Apply a deterministic time-scaling transformation to a commit, |
| 5683 | stretching or compressing all MIDI note onset/offset times by a factor while |
| 5684 | preserving pitch. Records the result as a new commit, leaving the source |
| 5685 | commit intact. Agents use this to explore half-time grooves, double-time |
| 5686 | feels, or to normalise a session to a target BPM before merge. |
| 5687 | |
| 5688 | **Usage:** |
| 5689 | ```bash |
| 5690 | muse tempo-scale [<factor>] [<commit>] [OPTIONS] |
| 5691 | ``` |
| 5692 | |
| 5693 | **Flags:** |
| 5694 | |
| 5695 | | Flag | Type | Default | Description | |
| 5696 | |------|------|---------|-------------| |
| 5697 | | `<factor>` | float | — | Scaling factor: `0.5` = half-time, `2.0` = double-time | |
| 5698 | | `<commit>` | string | HEAD | Source commit SHA to scale | |
| 5699 | | `--bpm N` | float | — | Scale to reach exactly N BPM (`factor = N / source_bpm`). Mutually exclusive with `<factor>` | |
| 5700 | | `--track TEXT` | string | all | Scale only a specific MIDI track | |
| 5701 | | `--preserve-expressions` | flag | off | Scale CC/expression event timing proportionally | |
| 5702 | | `--message TEXT` | string | auto | Commit message for the new scaled commit | |
| 5703 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 5704 | |
| 5705 | > **Note on argument order:** Because `muse tempo-scale` is a Typer group |
| 5706 | > command, place all `--options` before the positional `<factor>` argument to |
| 5707 | > ensure correct parsing (e.g. `muse tempo-scale --json 2.0`, not |
| 5708 | > `muse tempo-scale 2.0 --json`). |
| 5709 | |
| 5710 | **Output example (text):** |
| 5711 | ``` |
| 5712 | Tempo scaled: abc12345 -> d9f3a1b2 |
| 5713 | Factor: 0.5000 (/2.0000) |
| 5714 | Tempo: 120.0 BPM -> 60.0 BPM |
| 5715 | Track: all |
| 5716 | Message: tempo-scale 0.5000x (stub) |
| 5717 | (stub -- full MIDI note manipulation pending) |
| 5718 | ``` |
| 5719 | |
| 5720 | **Output example (JSON, `--json`):** |
| 5721 | ```json |
| 5722 | { |
| 5723 | "source_commit": "abc12345", |
| 5724 | "new_commit": "d9f3a1b2", |
| 5725 | "factor": 0.5, |
| 5726 | "source_bpm": 120.0, |
| 5727 | "new_bpm": 60.0, |
| 5728 | "track": "all", |
| 5729 | "preserve_expressions": false, |
| 5730 | "message": "tempo-scale 0.5000x (stub)" |
| 5731 | } |
| 5732 | ``` |
| 5733 | |
| 5734 | **Result type:** `TempoScaleResult` (TypedDict) — fields: `source_commit`, |
| 5735 | `new_commit`, `factor`, `source_bpm`, `new_bpm`, `track`, |
| 5736 | `preserve_expressions`, `message`. |
| 5737 | |
| 5738 | **Factor computation from BPM:** `factor = target_bpm / source_bpm`. |
| 5739 | Example: to go from 120 BPM to 128 BPM, `factor = 128 / 120 ≈ 1.0667`. |
| 5740 | Both operations are exposed as pure functions (`compute_factor_from_bpm`, |
| 5741 | `apply_factor`) that agents may call directly without spawning the CLI. |
| 5742 | |
| 5743 | **Determinism:** Same `source_commit` + `factor` + `track` + `preserve_expressions` |
| 5744 | always produces the same `new_commit` SHA. This makes tempo-scale operations |
| 5745 | safe to cache and replay in agentic pipelines. |
| 5746 | |
| 5747 | **Agent use case:** An AI generating a groove variation queries `muse tempo-scale |
| 5748 | --bpm 128 --json` to normalise a 120 BPM sketch to the session BPM before |
| 5749 | committing. A post-generation agent can scan `muse timeline` to verify the |
| 5750 | tempo evolution, then use `muse tempo-scale 0.5` to create a half-time B-section |
| 5751 | for contrast. |
| 5752 | |
| 5753 | **Implementation:** `maestro/muse_cli/commands/tempo_scale.py` — |
| 5754 | `TempoScaleResult` (TypedDict), `compute_factor_from_bpm()`, `apply_factor()`, |
| 5755 | `_tempo_scale_async()`, `_format_result()`. Exit codes: 0 success, 1 bad |
| 5756 | arguments (`USER_ERROR`), 2 outside repo (`REPO_NOT_FOUND`), 3 internal error |
| 5757 | (`INTERNAL_ERROR`). |
| 5758 | |
| 5759 | > **Stub note:** The current implementation computes the correct schema and |
| 5760 | > factor but uses a placeholder 120 BPM as the source tempo and generates a |
| 5761 | > deterministic stub commit SHA. Full MIDI note-event manipulation will be |
| 5762 | > wired in when the Storpheus note-event query route is available. |
| 5763 | |
| 5764 | --- |
| 5765 | |
| 5766 | ## `muse motif` — Recurring Melodic Motif Analysis |
| 5767 | |
| 5768 | ### `muse motif find` |
| 5769 | |
| 5770 | **Purpose:** Detect recurring melodic and rhythmic patterns in a single commit. |
| 5771 | An AI agent uses this before generating a new variation to identify the established |
| 5772 | motific language of the composition, ensuring new material is thematically coherent. |
| 5773 | |
| 5774 | **Usage:** |
| 5775 | ```bash |
| 5776 | muse motif find [<commit>] [OPTIONS] |
| 5777 | ``` |
| 5778 | |
| 5779 | **Flags:** |
| 5780 | | Flag | Type | Default | Description | |
| 5781 | |------|------|---------|-------------| |
| 5782 | | `--min-length N` | int | 3 | Minimum motif length in notes | |
| 5783 | | `--track TEXT` | str | — | Restrict to a named MIDI track | |
| 5784 | | `--section TEXT` | str | — | Restrict to a named section/region | |
| 5785 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 5786 | |
| 5787 | **Output example:** |
| 5788 | ``` |
| 5789 | Recurring motifs — commit a1b2c3d4 (HEAD -> main) |
| 5790 | ── stub mode: full MIDI analysis pending ── |
| 5791 | |
| 5792 | # Fingerprint Contour Count |
| 5793 | - ---------------------- ------------------ ----- |
| 5794 | 1 [+2, +2, -1, +2] ascending-step 3 |
| 5795 | 2 [-2, -2, +1, -2] descending-step 2 |
| 5796 | 3 [+4, -2, +3] arch 2 |
| 5797 | |
| 5798 | 3 motif(s) found (min-length 3) |
| 5799 | ``` |
| 5800 | |
| 5801 | **Result type:** `MotifFindResult` — fields: `commit_id`, `branch`, `min_length`, `motifs` (list of `MotifGroup`), `total_found`, `source`. |
| 5802 | |
| 5803 | **Agent use case:** Call `muse motif find --json HEAD` before composing a new section. |
| 5804 | Parse `motifs[0].fingerprint` to retrieve the primary interval sequence, then instruct |
| 5805 | the generation model to build on that pattern rather than introducing unrelated material. |
| 5806 | |
| 5807 | **Implementation note:** Stub — returns realistic placeholder motifs. Full |
| 5808 | implementation requires MIDI note data queryable from the commit snapshot store. |
| 5809 | |
| 5810 | --- |
| 5811 | |
| 5812 | ### `muse motif track` |
| 5813 | |
| 5814 | **Purpose:** Search all commits in branch history for appearances of a specific motif. |
| 5815 | Detects not only exact transpositions but also melodic inversion, retrograde, and |
| 5816 | retrograde-inversion — the four canonical classical transformations. |
| 5817 | |
| 5818 | **Usage:** |
| 5819 | ```bash |
| 5820 | muse motif track "<pattern>" [OPTIONS] |
| 5821 | ``` |
| 5822 | |
| 5823 | **Arguments:** |
| 5824 | | Argument | Description | |
| 5825 | |----------|-------------| |
| 5826 | | `pattern` | Space-separated note names (`"C D E G"`) or MIDI numbers (`"60 62 64 67"`) | |
| 5827 | |
| 5828 | **Flags:** |
| 5829 | | Flag | Type | Default | Description | |
| 5830 | |------|------|---------|-------------| |
| 5831 | | `--json` | flag | off | Emit structured JSON | |
| 5832 | |
| 5833 | **Output example:** |
| 5834 | ``` |
| 5835 | Tracking motif: 'C D E G' |
| 5836 | Fingerprint: [+2, +2, +3] |
| 5837 | Commits scanned: 12 |
| 5838 | |
| 5839 | Commit Track Transform Position |
| 5840 | ---------- ------------ -------------- -------- |
| 5841 | a1b2c3d4 melody exact 0 |
| 5842 | f4e3d2c1 melody inversion 4 |
| 5843 | b2c3d4e5 bass retrograde 2 |
| 5844 | |
| 5845 | 3 occurrence(s) found. |
| 5846 | ``` |
| 5847 | |
| 5848 | **Result type:** `MotifTrackResult` — fields: `pattern`, `fingerprint`, `occurrences` (list of `MotifOccurrence`), `total_commits_scanned`, `source`. |
| 5849 | |
| 5850 | **Agent use case:** When the agent needs to understand how a theme has evolved, |
| 5851 | call `muse motif track "C D E G" --json` and inspect the `transformation` field |
| 5852 | on each occurrence to chart the motif's journey through the composition's history. |
| 5853 | |
| 5854 | --- |
| 5855 | |
| 5856 | ### `muse motif diff` |
| 5857 | |
| 5858 | **Purpose:** Show how the dominant motif transformed between two commits. |
| 5859 | Classifies the change as one of: exact (transposition), inversion, retrograde, |
| 5860 | retrograde-inversion, augmentation, diminution, or approximate. |
| 5861 | |
| 5862 | **Usage:** |
| 5863 | ```bash |
| 5864 | muse motif diff <commit-a> <commit-b> [OPTIONS] |
| 5865 | ``` |
| 5866 | |
| 5867 | **Flags:** |
| 5868 | | Flag | Type | Default | Description | |
| 5869 | |------|------|---------|-------------| |
| 5870 | | `--json` | flag | off | Emit structured JSON | |
| 5871 | |
| 5872 | **Output example:** |
| 5873 | ``` |
| 5874 | Motif diff: a1b2c3d4 → f4e3d2c1 |
| 5875 | |
| 5876 | A (a1b2c3d4): [+2, +2, -1, +2] [ascending-step] |
| 5877 | B (f4e3d2c1): [-2, -2, +1, -2] [descending-step] |
| 5878 | |
| 5879 | Transformation: INVERSION |
| 5880 | The motif was inverted — ascending intervals became descending. |
| 5881 | ``` |
| 5882 | |
| 5883 | **Result type:** `MotifDiffResult` — fields: `commit_a` (`MotifDiffEntry`), `commit_b` (`MotifDiffEntry`), `transformation` (`MotifTransformation`), `description`, `source`. |
| 5884 | |
| 5885 | **Agent use case:** Use after detecting a structural change to understand whether |
| 5886 | the composer inverted or retrogressed the theme — crucial context for deciding |
| 5887 | how to develop material in the next variation. |
| 5888 | |
| 5889 | --- |
| 5890 | |
| 5891 | ### `muse motif list` |
| 5892 | |
| 5893 | **Purpose:** List all named motifs saved in `.muse/motifs/`. Named motifs are |
| 5894 | user-annotated melodic ideas that the composer has labelled for future recall. |
| 5895 | |
| 5896 | **Usage:** |
| 5897 | ```bash |
| 5898 | muse motif list [OPTIONS] |
| 5899 | ``` |
| 5900 | |
| 5901 | **Flags:** |
| 5902 | | Flag | Type | Default | Description | |
| 5903 | |------|------|---------|-------------| |
| 5904 | | `--json` | flag | off | Emit structured JSON | |
| 5905 | |
| 5906 | **Output example:** |
| 5907 | ``` |
| 5908 | Named motifs: |
| 5909 | |
| 5910 | Name Fingerprint Created Description |
| 5911 | -------------------- ---------------------- ------------------------ ------------------------------ |
| 5912 | main-theme [+2, +2, -1, +2] 2026-01-15T10:30:00Z The central ascending motif… |
| 5913 | bass-riff [-2, -3, +2] 2026-01-20T14:15:00Z Chromatic bass figure… |
| 5914 | ``` |
| 5915 | |
| 5916 | **Result type:** `MotifListResult` — fields: `motifs` (list of `SavedMotif`), `source`. |
| 5917 | |
| 5918 | **Agent use case:** Load the named motif library at session start. Cross-reference |
| 5919 | `fingerprint` values against `muse motif find` output to check whether detected |
| 5920 | patterns match known named motifs before introducing new thematic material. |
| 5921 | --- |
| 5922 | |
| 5923 | --- |
| 5924 | |
| 5925 | ## `muse read-tree` — Read a Snapshot into muse-work/ |
| 5926 | |
| 5927 | **Purpose:** Hydrate `muse-work/` from any historical snapshot without modifying |
| 5928 | HEAD or branch refs. The plumbing analog of `git read-tree`. AI agents use this to |
| 5929 | inspect or restore a specific composition state before making decisions. |
| 5930 | |
| 5931 | **Usage:** |
| 5932 | ```bash |
| 5933 | muse read-tree <snapshot_id> [OPTIONS] |
| 5934 | ``` |
| 5935 | |
| 5936 | **Flags:** |
| 5937 | |
| 5938 | | Flag | Type | Default | Description | |
| 5939 | |------|------|---------|-------------| |
| 5940 | | `<snapshot_id>` | positional | — | Full 64-char snapshot SHA or abbreviated prefix (≥ 4 chars) | |
| 5941 | | `--dry-run` | flag | off | Print the file list without writing anything | |
| 5942 | | `--reset` | flag | off | Clear all files from muse-work/ before populating | |
| 5943 | |
| 5944 | **Output example (default):** |
| 5945 | ``` |
| 5946 | ✅ muse-work/ populated from snapshot a3f7b891 (3 file(s)). |
| 5947 | ``` |
| 5948 | |
| 5949 | **Output example (`--dry-run`):** |
| 5950 | ``` |
| 5951 | Snapshot a3f7b891 — 3 file(s): |
| 5952 | tracks/bass/groove.mid (1a2b3c4d) |
| 5953 | tracks/keys/voicing.mid (9e8f7a6b) |
| 5954 | mix/final.json (c4d5e6f7) |
| 5955 | ``` |
| 5956 | |
| 5957 | **Output example (`--reset`):** |
| 5958 | ``` |
| 5959 | ✅ muse-work/ reset and populated from snapshot a3f7b891 (3 file(s)). |
| 5960 | ``` |
| 5961 | |
| 5962 | **Result type:** `ReadTreeResult` — fields: `snapshot_id` (str), `files_written` (list[str]), |
| 5963 | `dry_run` (bool), `reset` (bool). |
| 5964 | |
| 5965 | **How objects are stored:** `muse commit` writes each committed file via |
| 5966 | `maestro.muse_cli.object_store.write_object_from_path()` into a sharded layout |
| 5967 | that mirrors Git's loose-object store: |
| 5968 | |
| 5969 | ``` |
| 5970 | .muse/objects/ |
| 5971 | ab/ ← first two hex chars of sha256 (256 possible shard dirs) |
| 5972 | cdef1234… ← remaining 62 chars — the raw file bytes |
| 5973 | ``` |
| 5974 | |
| 5975 | `muse read-tree` reads from that same store via `read_object()` to reconstruct |
| 5976 | `muse-work/`. If an object is missing (e.g. the snapshot was pulled from a |
| 5977 | remote without a local commit), the command exits with a clear error listing the |
| 5978 | missing paths. All Muse commands share this single canonical module — |
| 5979 | `maestro/muse_cli/object_store.py` — for all object I/O. |
| 5980 | |
| 5981 | **Does NOT modify:** |
| 5982 | - `.muse/HEAD` |
| 5983 | - `.muse/refs/heads/<branch>` (any branch ref) |
| 5984 | - The database (read-only command) |
| 5985 | |
| 5986 | **Agent use case:** After `muse pull`, an agent calls `muse read-tree <snapshot_id>` |
| 5987 | to materialize a specific checkpoint into `muse-work/` for further analysis (e.g. |
| 5988 | running `muse dynamics` or `muse swing`) without advancing the branch pointer. This |
| 5989 | is safer than `muse checkout` because it leaves all branch metadata intact. |
| 5990 | |
| 5991 | --- |
| 5992 | ## `muse update-ref` — Write or Delete a Ref (Branch or Tag Pointer) |
| 5993 | |
| 5994 | **Purpose:** Directly update a branch or tag pointer (`refs/heads/*` or `refs/tags/*`) |
| 5995 | in the `.muse/` object store. This is the plumbing primitive scripting agents use when |
| 5996 | they need to advance a branch tip, retarget a tag, or remove a stale ref — without going |
| 5997 | through a higher-level command like `checkout` or `merge`. |
| 5998 | |
| 5999 | **Implementation:** `maestro/muse_cli/commands/update_ref.py`\ |
| 6000 | **Status:** ✅ implemented (PR #143) — issue #91 |
| 6001 | |
| 6002 | ### Usage |
| 6003 | |
| 6004 | ```bash |
| 6005 | muse update-ref <ref> <new-value> [OPTIONS] |
| 6006 | muse update-ref <ref> -d |
| 6007 | ``` |
| 6008 | |
| 6009 | ### Flags |
| 6010 | |
| 6011 | | Flag | Type | Default | Description | |
| 6012 | |------|------|---------|-------------| |
| 6013 | | `<ref>` | positional | required | Fully-qualified ref (e.g. `refs/heads/main`, `refs/tags/v1.0`) | |
| 6014 | | `<new-value>` | positional | required (unless `-d`) | Commit ID to write to the ref | |
| 6015 | | `--old-value <commit_id>` | string | off | CAS guard — only update if the current ref value matches this commit ID | |
| 6016 | | `-d / --delete` | flag | off | Delete the ref file instead of writing it | |
| 6017 | |
| 6018 | ### Output example |
| 6019 | |
| 6020 | ``` |
| 6021 | # Standard write |
| 6022 | ✅ refs/heads/main → 3f9ab2c1 |
| 6023 | |
| 6024 | # CAS failure |
| 6025 | ❌ CAS failure: expected '3f9ab2c1' but found 'a1b2c3d4'. Ref not updated. |
| 6026 | |
| 6027 | # Delete |
| 6028 | ✅ Deleted ref 'refs/heads/feature'. |
| 6029 | |
| 6030 | # Commit not in DB |
| 6031 | ❌ Commit 3f9ab2c1 not found in database. |
| 6032 | ``` |
| 6033 | |
| 6034 | ### Validation |
| 6035 | |
| 6036 | - **Ref format:** Must start with `refs/heads/` or `refs/tags/`. Any other prefix exits with `USER_ERROR`. |
| 6037 | - **Commit existence:** Before writing, the commit_id is looked up in `muse_cli_commits`. If absent, exits `USER_ERROR`. |
| 6038 | - **CAS (`--old-value`):** Reads the current file contents and compares to the provided value. Mismatch → `USER_ERROR`, ref unchanged. Absent ref + any `--old-value` → `USER_ERROR`. |
| 6039 | - **Delete (`-d`):** Exits `USER_ERROR` when the ref file does not exist. |
| 6040 | |
| 6041 | ### Result type |
| 6042 | |
| 6043 | `None` — this is a write command; output is emitted via `typer.echo`. |
| 6044 | |
| 6045 | ### Agent use case |
| 6046 | |
| 6047 | An AI orchestration agent that manages multiple arrangement branches can call |
| 6048 | `muse update-ref refs/heads/feature/guitar <commit_id> --old-value <prev_id>` |
| 6049 | to atomically advance the branch tip after generating a new variation. The CAS |
| 6050 | guard prevents a race condition when two generation passes complete concurrently — |
| 6051 | only the first one wins; the second will receive `USER_ERROR` and retry or backoff. |
| 6052 | |
| 6053 | Use `muse update-ref refs/tags/v1.0 <commit_id>` to mark a production-ready |
| 6054 | snapshot with a stable tag pointer that other agents can reference by name. |
| 6055 | --- |
| 6056 | |
| 6057 | ## `muse write-tree` — Write Current Working-Tree as a Snapshot Object |
| 6058 | |
| 6059 | ### `muse write-tree` |
| 6060 | |
| 6061 | **Purpose:** Hash all files in `muse-work/`, persist the object and snapshot |
| 6062 | rows in the database, and print the `snapshot_id`. This is the plumbing |
| 6063 | primitive that underlies `muse commit` — it writes the tree object without |
| 6064 | recording any history (no commit row, no branch-pointer update). AI agents |
| 6065 | use it to obtain a stable, content-addressed handle to the current working-tree |
| 6066 | state before deciding whether to commit. |
| 6067 | |
| 6068 | **Status:** ✅ Fully implemented (issue #89) |
| 6069 | |
| 6070 | **Usage:** |
| 6071 | ```bash |
| 6072 | muse write-tree [OPTIONS] |
| 6073 | ``` |
| 6074 | |
| 6075 | **Flags:** |
| 6076 | |
| 6077 | | Flag | Type | Default | Description | |
| 6078 | |------|------|---------|-------------| |
| 6079 | | `--prefix PATH` | string | *(none)* | Only include files whose path (relative to `muse-work/`) starts with *PATH*. Example: `--prefix drums/` snapshots only the drums sub-directory. | |
| 6080 | | `--missing-ok` | flag | off | Do not fail when `muse-work/` is absent or empty, or when `--prefix` matches no files. Still prints a valid (empty) `snapshot_id`. | |
| 6081 | |
| 6082 | **Output example:** |
| 6083 | ``` |
| 6084 | a3f92c1e8b4d5f67890abcdef1234567890abcdef1234567890abcdef12345678 |
| 6085 | ``` |
| 6086 | (64-character sha256 hex digest of the sorted `path:object_id` pairs.) |
| 6087 | |
| 6088 | **Idempotency:** Same file content → same `snapshot_id`. Running `muse write-tree` |
| 6089 | twice without changing any file makes exactly zero new DB writes (all upserts are |
| 6090 | no-ops). |
| 6091 | |
| 6092 | **Result type:** `snapshot_id` is a raw 64-char hex string printed to stdout. |
| 6093 | No named result type — the caller decides what to do with the ID (compare, |
| 6094 | commit, discard). |
| 6095 | |
| 6096 | **Agent use case:** An AI agent that just generated a batch of MIDI files calls |
| 6097 | `muse write-tree` to get the `snapshot_id` for the current working tree. It |
| 6098 | then compares that ID against the last committed `snapshot_id` (via `muse log |
| 6099 | --json | head -1`) to decide whether the new generation is novel enough to |
| 6100 | commit. If the IDs match, the files are identical to the last commit and no |
| 6101 | commit is needed. |
| 6102 | |
| 6103 | **Implementation:** `maestro/muse_cli/commands/write_tree.py`. Reuses |
| 6104 | `build_snapshot_manifest`, `compute_snapshot_id`, `upsert_object`, and |
| 6105 | `upsert_snapshot` from the commit pipeline. No new DB schema — the same |
| 6106 | `muse_cli_objects` and `muse_cli_snapshots` tables. |
| 6107 | |
| 6108 | --- |
| 6109 | |
| 6110 | ## Command Registration Summary |
| 6111 | |
| 6112 | | Command | File | Status | Issue | |
| 6113 | |---------|------|--------|-------| |
| 6114 | | `muse ask` | `commands/ask.py` | ✅ stub (PR #132) | #126 | |
| 6115 | | `muse context` | `commands/context.py` | ✅ implemented (PR #138) | #113 | |
| 6116 | | `muse describe` | `commands/describe.py` | ✅ stub (PR #134) | #125 | |
| 6117 | | `muse divergence` | `commands/divergence.py` | ✅ implemented (PR #140) | #119 | |
| 6118 | | `muse diff` | `commands/diff.py` | ✅ stub (this PR) | #104 | |
| 6119 | | `muse dynamics` | `commands/dynamics.py` | ✅ stub (PR #130) | #120 | |
| 6120 | | `muse export` | `commands/export.py` | ✅ implemented (PR #137) | #112 | |
| 6121 | | `muse grep` | `commands/grep_cmd.py` | ✅ stub (PR #128) | #124 | |
| 6122 | | `muse groove-check` | `commands/groove_check.py` | ✅ stub (PR #143) | #95 | |
| 6123 | | `muse import` | `commands/import_cmd.py` | ✅ implemented (PR #142) | #118 | |
| 6124 | | `muse inspect` | `commands/inspect.py` | ✅ implemented (PR #TBD) | #98 | |
| 6125 | | `muse meter` | `commands/meter.py` | ✅ implemented (PR #141) | #117 | |
| 6126 | | `muse read-tree` | `commands/read_tree.py` | ✅ implemented (PR #157) | #90 | |
| 6127 | | `muse recall` | `commands/recall.py` | ✅ stub (PR #135) | #122 | |
| 6128 | | `muse render-preview` | `commands/render_preview.py` | ✅ implemented (issue #96) | #96 | |
| 6129 | | `muse rev-parse` | `commands/rev_parse.py` | ✅ implemented (PR #143) | #92 | |
| 6130 | | `muse session` | `commands/session.py` | ✅ implemented (PR #129) | #127 | |
| 6131 | | `muse swing` | `commands/swing.py` | ✅ stub (PR #131) | #121 | |
| 6132 | | `muse motif` | `commands/motif.py` | ✅ stub (PR —) | #101 | |
| 6133 | | `muse symbolic-ref` | `commands/symbolic_ref.py` | ✅ implemented (issue #93) | #93 | |
| 6134 | | `muse tag` | `commands/tag.py` | ✅ implemented (PR #133) | #123 | |
| 6135 | | `muse tempo-scale` | `commands/tempo_scale.py` | ✅ stub (PR open) | #111 | |
| 6136 | | `muse timeline` | `commands/timeline.py` | ✅ implemented (PR #TBD) | #97 | |
| 6137 | | `muse transpose` | `commands/transpose.py` | ✅ implemented | #102 | |
| 6138 | | `muse update-ref` | `commands/update_ref.py` | ✅ implemented (PR #143) | #91 | |
| 6139 | | `muse validate` | `commands/validate.py` | ✅ implemented (PR #TBD) | #99 | |
| 6140 | | `muse write-tree` | `commands/write_tree.py` | ✅ implemented | #89 | |
| 6141 | |
| 6142 | All stub commands have stable CLI contracts. Full musical analysis (MIDI content |
| 6143 | parsing, vector embeddings, LLM synthesis) is tracked as follow-up issues. |
| 6144 | |
| 6145 | ## `muse groove-check` — Rhythmic Drift Analysis |
| 6146 | |
| 6147 | **Purpose:** Detect which commit in a range introduced rhythmic inconsistency |
| 6148 | by measuring how much the average note-onset deviation from the quantization grid |
| 6149 | changed between adjacent commits. The music-native equivalent of a style/lint gate. |
| 6150 | |
| 6151 | **Implementation:** `maestro/muse_cli/commands/groove_check.py` (CLI), |
| 6152 | `maestro/services/muse_groove_check.py` (pure service layer). |
| 6153 | **Status:** ✅ stub (issue #95) |
| 6154 | |
| 6155 | ### Usage |
| 6156 | |
| 6157 | ```bash |
| 6158 | muse groove-check [RANGE] [OPTIONS] |
| 6159 | ``` |
| 6160 | |
| 6161 | ### Flags |
| 6162 | |
| 6163 | | Flag | Type | Default | Description | |
| 6164 | |------|------|---------|-------------| |
| 6165 | | `RANGE` | positional | last 10 commits | Commit range to analyze (e.g. `HEAD~5..HEAD`) | |
| 6166 | | `--track TEXT` | string | all | Scope analysis to a specific instrument track (e.g. `drums`) | |
| 6167 | | `--section TEXT` | string | all | Scope analysis to a specific musical section (e.g. `verse`) | |
| 6168 | | `--threshold FLOAT` | float | 0.1 | Drift threshold in beats; commits exceeding it are flagged WARN; >2× = FAIL | |
| 6169 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 6170 | |
| 6171 | ### Output example |
| 6172 | |
| 6173 | ``` |
| 6174 | Groove-check — range HEAD~6..HEAD threshold 0.1 beats |
| 6175 | |
| 6176 | Commit Groove Score Drift Δ Status |
| 6177 | -------- ------------ ------- ------ |
| 6178 | a1b2c3d4 0.0400 0.0000 OK |
| 6179 | e5f6a7b8 0.0500 0.0100 OK |
| 6180 | c9d0e1f2 0.0600 0.0100 OK |
| 6181 | a3b4c5d6 0.0900 0.0300 OK |
| 6182 | e7f8a9b0 0.1500 0.0600 WARN |
| 6183 | c1d2e3f4 0.1300 0.0200 OK |
| 6184 | |
| 6185 | Flagged: 1 / 6 commits (worst: e7f8a9b0) |
| 6186 | ``` |
| 6187 | |
| 6188 | ### Result types |
| 6189 | |
| 6190 | `GrooveStatus` (Enum: OK/WARN/FAIL), `CommitGrooveMetrics` (frozen dataclass), |
| 6191 | `GrooveCheckResult` (frozen dataclass). |
| 6192 | See `docs/reference/type_contracts.md § GrooveCheckResult`. |
| 6193 | |
| 6194 | ### Status classification |
| 6195 | |
| 6196 | | Status | Condition | |
| 6197 | |--------|-----------| |
| 6198 | | OK | `drift_delta ≤ threshold` | |
| 6199 | | WARN | `threshold < drift_delta ≤ 2 × threshold` | |
| 6200 | | FAIL | `drift_delta > 2 × threshold` | |
| 6201 | |
| 6202 | ### Agent use case |
| 6203 | |
| 6204 | An AI agent runs `muse groove-check HEAD~20..HEAD --json` after a session to |
| 6205 | identify which commit degraded rhythmic tightness. The `worst_commit` field |
| 6206 | pinpoints the exact SHA to inspect. Feeding that into `muse describe` gives |
| 6207 | a natural-language explanation of what changed. If `--threshold 0.05` returns |
| 6208 | multiple FAIL commits, the session's quantization workflow needs review before |
| 6209 | new layers are added. |
| 6210 | |
| 6211 | ### Implementation stub note |
| 6212 | |
| 6213 | `groove_score` and `drift_delta` are computed from deterministic placeholder data. |
| 6214 | Full implementation will walk the `MuseCliCommit` chain, load MIDI snapshots via |
| 6215 | `MidiParser`, compute per-note onset deviation from the nearest quantization grid |
| 6216 | position (resolved from the commit's time-signature + tempo metadata), and |
| 6217 | aggregate by track / section. Storpheus will expose a `/groove` route once |
| 6218 | the rhythmic-analysis pipeline is productionized. |
| 6219 | |
| 6220 | --- |
| 6221 | |
| 6222 | ## `muse contour` — Melodic Contour and Phrase Shape Analysis |
| 6223 | |
| 6224 | **Purpose:** Determines whether a melody rises, falls, arches, or waves — the |
| 6225 | fundamental expressive character that distinguishes two otherwise similar |
| 6226 | melodies. An AI generation agent uses `muse contour --json HEAD` to |
| 6227 | understand the melodic shape of the current arrangement before layering a |
| 6228 | countermelody, ensuring complementary (not identical) contour. |
| 6229 | |
| 6230 | **Usage:** |
| 6231 | ```bash |
| 6232 | muse contour [<commit>] [OPTIONS] |
| 6233 | ``` |
| 6234 | |
| 6235 | **Flags:** |
| 6236 | | Flag | Type | Default | Description | |
| 6237 | |------|------|---------|-------------| |
| 6238 | | `[<commit>]` | string | HEAD | Target commit SHA to analyse | |
| 6239 | | `--track TEXT` | string | all tracks | Restrict to a named melodic track (e.g. `keys`, `lead`) | |
| 6240 | | `--section TEXT` | string | full piece | Scope analysis to a named section (e.g. `verse`, `chorus`) | |
| 6241 | | `--compare COMMIT` | string | — | Compare contour between HEAD (or `[<commit>]`) and this ref | |
| 6242 | | `--history` | flag | off | Show contour evolution across all commits | |
| 6243 | | `--shape` | flag | off | Print the overall shape label only (one line) | |
| 6244 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 6245 | |
| 6246 | **Shape vocabulary:** |
| 6247 | | Label | Description | |
| 6248 | |-------|-------------| |
| 6249 | | `ascending` | Net upward movement across the full phrase | |
| 6250 | | `descending` | Net downward movement across the full phrase | |
| 6251 | | `arch` | Rises then falls (single peak) | |
| 6252 | | `inverted-arch` | Falls then rises (valley shape) | |
| 6253 | | `wave` | Multiple peaks; alternating rise and fall | |
| 6254 | | `static` | Narrow pitch range (< 2 semitones spread) | |
| 6255 | |
| 6256 | **Output example (text):** |
| 6257 | ``` |
| 6258 | Shape: Arch | Range: 2 octaves | Phrases: 4 avg 8 bars |
| 6259 | Commit: a1b2c3d4 Branch: main |
| 6260 | Track: keys Section: all |
| 6261 | Angularity: 2.5 st avg interval |
| 6262 | (stub — full MIDI analysis pending) |
| 6263 | ``` |
| 6264 | |
| 6265 | **Output example (`--shape`):** |
| 6266 | ``` |
| 6267 | Shape: arch |
| 6268 | ``` |
| 6269 | |
| 6270 | **Output example (`--compare`, text):** |
| 6271 | ``` |
| 6272 | A (a1b2c3d4) Shape: arch | Angularity: 2.5 st |
| 6273 | B (HEAD~10) Shape: arch | Angularity: 2.5 st |
| 6274 | Delta angularity +0.0 st | tessitura +0 st |
| 6275 | ``` |
| 6276 | |
| 6277 | **Output example (`--json`):** |
| 6278 | ```json |
| 6279 | { |
| 6280 | "shape": "arch", |
| 6281 | "tessitura": 24, |
| 6282 | "avg_interval": 2.5, |
| 6283 | "phrase_count": 4, |
| 6284 | "avg_phrase_bars": 8.0, |
| 6285 | "commit": "a1b2c3d4", |
| 6286 | "branch": "main", |
| 6287 | "track": "keys", |
| 6288 | "section": "all", |
| 6289 | "source": "stub" |
| 6290 | } |
| 6291 | ``` |
| 6292 | |
| 6293 | **Result types:** |
| 6294 | - `ContourResult` — fields: `shape` (str), `tessitura` (int, semitones), |
| 6295 | `avg_interval` (float, semitones), `phrase_count` (int), `avg_phrase_bars` |
| 6296 | (float), `commit` (str), `branch` (str), `track` (str), `section` (str), |
| 6297 | `source` (str). |
| 6298 | - `ContourCompareResult` — fields: `commit_a` (ContourResult), `commit_b` |
| 6299 | (ContourResult), `shape_changed` (bool), `angularity_delta` (float), |
| 6300 | `tessitura_delta` (int). |
| 6301 | |
| 6302 | See `docs/reference/type_contracts.md § ContourResult`. |
| 6303 | |
| 6304 | **Agent use case:** Before generating a countermelody, an agent calls |
| 6305 | `muse contour --json HEAD --track keys` to determine whether the existing |
| 6306 | melody is arch-shaped with a wide tessitura (high angularity). It then |
| 6307 | generates a countermelody that is descending and narrow — complementary, not |
| 6308 | imitative. The `--compare` flag lets the agent detect whether recent edits |
| 6309 | made a melody more angular (fragmented) or smoother (stepwise), informing |
| 6310 | whether the next variation should introduce or reduce leaps. |
| 6311 | |
| 6312 | **Implementation stub note:** `source: "stub"` in the JSON output indicates |
| 6313 | that full MIDI pitch-trajectory analysis is pending a Storpheus pitch-detection |
| 6314 | route. The CLI contract (flags, output shape, result types) is stable — only |
| 6315 | the computed values will change when the full implementation is wired in. |
| 6316 | |
| 6317 | **Implementation:** `maestro/muse_cli/commands/contour.py` — |
| 6318 | `ContourResult` (TypedDict), `ContourCompareResult` (TypedDict), |
| 6319 | `_contour_detect_async()`, `_contour_compare_async()`, |
| 6320 | `_contour_history_async()`, `_format_detect()`, `_format_compare()`, |
| 6321 | `_format_history()`. Exit codes: 0 success, 2 outside repo |
| 6322 | (`REPO_NOT_FOUND`), 3 internal error (`INTERNAL_ERROR`). |
| 6323 | |
| 6324 | --- |
| 6325 | |
| 6326 | --- |
| 6327 | |
| 6328 | ## `muse reset` — Reset Branch Pointer to a Prior Commit |
| 6329 | |
| 6330 | ### Purpose |
| 6331 | |
| 6332 | Move the current branch's HEAD pointer backward to a prior commit, with three |
| 6333 | levels of aggression mirroring git's model. The "panic button" for music |
| 6334 | production: when a producer makes ten bad commits and wants to return to the |
| 6335 | last known-good take. |
| 6336 | |
| 6337 | ### Usage |
| 6338 | |
| 6339 | ```bash |
| 6340 | muse reset [--soft | --mixed | --hard] [--yes] <commit> |
| 6341 | ``` |
| 6342 | |
| 6343 | ### Flags |
| 6344 | |
| 6345 | | Flag | Type | Default | Description | |
| 6346 | |------|------|---------|-------------| |
| 6347 | | `<commit>` | positional | required | Target commit (HEAD, HEAD~N, full/abbreviated SHA) | |
| 6348 | | `--soft` | flag | off | Move branch pointer only; muse-work/ and object store unchanged | |
| 6349 | | `--mixed` | flag | on | Move branch pointer and reset index (default; equivalent to soft in current model) | |
| 6350 | | `--hard` | flag | off | Move branch pointer AND overwrite muse-work/ with target snapshot | |
| 6351 | | `--yes` / `-y` | flag | off | Skip confirmation prompt for --hard mode | |
| 6352 | |
| 6353 | ### Modes |
| 6354 | |
| 6355 | **Soft** (`--soft`): |
| 6356 | - Updates `.muse/refs/heads/<branch>` to point to the target commit. |
| 6357 | - `muse-work/` files are completely untouched. |
| 6358 | - The next `muse commit` will capture the current working tree on top of the rewound HEAD. |
| 6359 | - Use when you want to re-commit with a different message or squash commits. |
| 6360 | |
| 6361 | **Mixed** (`--mixed`, default): |
| 6362 | - Same as soft in the current Muse model (no explicit staging area exists yet). |
| 6363 | - Included for API symmetry with git and forward-compatibility when a staging |
| 6364 | index is added. |
| 6365 | |
| 6366 | **Hard** (`--hard`): |
| 6367 | - Moves the branch ref to the target commit. |
| 6368 | - Overwrites every file in `muse-work/` with the content from the target |
| 6369 | commit's snapshot. Files are read from `.muse/objects/<sha[:2]>/<sha[2:]>` |
| 6370 | (the content-addressed blob store populated by `muse commit`). |
| 6371 | - Files in `muse-work/` that are NOT present in the target snapshot are deleted. |
| 6372 | - **Prompts for confirmation** unless `--yes` is given — this is destructive. |
| 6373 | - **Requires `muse commit` to have been run** at least once after repo init so |
| 6374 | that object blobs are present in `.muse/objects/`. |
| 6375 | |
| 6376 | ### HEAD~N Syntax |
| 6377 | |
| 6378 | ```bash |
| 6379 | muse reset HEAD~1 # one parent back (previous commit) |
| 6380 | muse reset HEAD~3 # three parents back |
| 6381 | muse reset abc123 # abbreviated SHA prefix |
| 6382 | muse reset --hard HEAD~2 # two parents back + restore working tree |
| 6383 | ``` |
| 6384 | |
| 6385 | `HEAD~N` walks the primary parent chain only. Merge parents |
| 6386 | (`parent2_commit_id`) are not traversed. |
| 6387 | |
| 6388 | ### Guards |
| 6389 | |
| 6390 | - **Merge in progress**: blocked when `.muse/MERGE_STATE.json` exists. |
| 6391 | Resolve or abort the merge before resetting. |
| 6392 | - **No commits on branch**: exits with `USER_ERROR` if the current branch |
| 6393 | has never been committed. |
| 6394 | - **Missing object blobs** (hard mode): exits with `INTERNAL_ERROR` rather |
| 6395 | than silently leaving `muse-work/` in a partial state. |
| 6396 | |
| 6397 | ### Output Examples |
| 6398 | |
| 6399 | ``` |
| 6400 | # Soft/mixed reset: |
| 6401 | ✅ HEAD is now at abc123de |
| 6402 | |
| 6403 | # Hard reset (2 files restored, 1 deleted): |
| 6404 | ✅ HEAD is now at abc123de (2 files restored, 1 files deleted) |
| 6405 | |
| 6406 | # Abort: |
| 6407 | ⚠️ muse reset --hard will OVERWRITE muse-work/ with the target snapshot. |
| 6408 | All uncommitted changes will be LOST. |
| 6409 | Proceed? [y/N]: N |
| 6410 | Reset aborted. |
| 6411 | ``` |
| 6412 | |
| 6413 | ### Object Store |
| 6414 | |
| 6415 | `muse commit` now writes every file's blob content to `.muse/objects/` using |
| 6416 | a two-level sharding layout identical to git's loose-object store: |
| 6417 | |
| 6418 | ``` |
| 6419 | .muse/objects/ |
| 6420 | ab/ ← first two hex chars of sha256 |
| 6421 | cdef1234... ← remaining 62 chars — the raw file bytes |
| 6422 | ``` |
| 6423 | |
| 6424 | This store enables `muse reset --hard` to restore any previously committed |
| 6425 | snapshot without needing the live `muse-work/` files. Objects are written |
| 6426 | idempotently (never overwritten once stored). |
| 6427 | |
| 6428 | ### Result Type |
| 6429 | |
| 6430 | `ResetResult` — see [`docs/reference/type_contracts.md`](../reference/type_contracts.md). |
| 6431 | |
| 6432 | ### Agent Use Case |
| 6433 | |
| 6434 | An AI composition agent uses `muse reset` to recover from a bad generation run: |
| 6435 | |
| 6436 | 1. Agent calls `muse log --json` to identify the last known-good commit SHA. |
| 6437 | 2. Agent calls `muse reset --hard --yes <sha>` to restore the working tree. |
| 6438 | 3. Agent calls `muse status` to verify the working tree matches expectations. |
| 6439 | 4. Agent resumes composition from the clean baseline. |
| 6440 | |
| 6441 | ### Implementation |
| 6442 | |
| 6443 | - **Object store (canonical):** `maestro/muse_cli/object_store.py` — single |
| 6444 | source of truth for all blob I/O. Public API: `write_object()`, |
| 6445 | `write_object_from_path()`, `read_object()`, `restore_object()`, |
| 6446 | `has_object()`, `object_path()`. All commands import from here exclusively. |
| 6447 | - **Service layer:** `maestro/services/muse_reset.py` — `perform_reset()`, |
| 6448 | `resolve_ref()`. Uses `has_object()` and `restore_object()` from |
| 6449 | `object_store`. Contains no path-layout logic of its own. |
| 6450 | - **CLI command:** `maestro/muse_cli/commands/reset.py` — Typer callback, |
| 6451 | confirmation prompt, error display. |
| 6452 | - **Commit integration:** `maestro/muse_cli/commands/commit.py` — calls |
| 6453 | `write_object_from_path()` for each file during commit to populate |
| 6454 | `.muse/objects/` without loading large blobs into memory. |
| 6455 | - **Exit codes:** 0 success, 1 user error (`USER_ERROR`), 2 not a repo |
| 6456 | (`REPO_NOT_FOUND`), 3 internal error (`INTERNAL_ERROR`). |
| 6457 | |
| 6458 | --- |
| 6459 | |
| 6460 | ### `muse show` |
| 6461 | |
| 6462 | **Purpose:** Inspect any historical commit — its metadata, snapshot manifest, |
| 6463 | path-level diff vs parent, MIDI file list, and optionally an audio preview. |
| 6464 | The musician's equivalent of `git show`: lets an AI agent or producer examine |
| 6465 | exactly what a past creative decision looked like, at any level of detail. |
| 6466 | |
| 6467 | **Usage:** |
| 6468 | ```bash |
| 6469 | muse show [COMMIT] [OPTIONS] |
| 6470 | ``` |
| 6471 | |
| 6472 | **Arguments:** |
| 6473 | |
| 6474 | | Argument | Description | |
| 6475 | |----------|-------------| |
| 6476 | | `COMMIT` | Commit ID (full or 4–64 char hex prefix), branch name, or `HEAD` (default). | |
| 6477 | |
| 6478 | **Flags:** |
| 6479 | |
| 6480 | | Flag | Type | Default | Description | |
| 6481 | |------|------|---------|-------------| |
| 6482 | | `--json` | flag | off | Output complete commit metadata + snapshot manifest as JSON for agent consumption. | |
| 6483 | | `--diff` | flag | off | Show path-level diff vs parent commit with A/M/D status markers. | |
| 6484 | | `--midi` | flag | off | List MIDI files (`.mid`, `.midi`, `.smf`) contained in the commit snapshot. | |
| 6485 | | `--audio-preview` | flag | off | Open cached audio preview WAV for this snapshot (macOS). Run `muse export <commit> --wav` first. | |
| 6486 | |
| 6487 | Multiple flags can be combined: `muse show abc1234 --diff --midi`. |
| 6488 | |
| 6489 | **Output example (default):** |
| 6490 | ``` |
| 6491 | commit a1b2c3d4e5f6... |
| 6492 | Branch: main |
| 6493 | Author: producer@stori.app |
| 6494 | Date: 2026-02-27 17:30:00 |
| 6495 | Parent: f9e8d7c6 |
| 6496 | |
| 6497 | Add bridge section with Rhodes keys |
| 6498 | |
| 6499 | Snapshot: 3 files |
| 6500 | bass.mid |
| 6501 | beat.mid |
| 6502 | keys.mid |
| 6503 | ``` |
| 6504 | |
| 6505 | **Output example (`--diff`):** |
| 6506 | ``` |
| 6507 | diff f9e8d7c6..a1b2c3d4 |
| 6508 | |
| 6509 | A bass.mid |
| 6510 | M beat.mid |
| 6511 | D strings.mid |
| 6512 | |
| 6513 | 2 path(s) changed |
| 6514 | ``` |
| 6515 | |
| 6516 | **Output example (`--midi`):** |
| 6517 | ``` |
| 6518 | MIDI files in snapshot a1b2c3d4 (3): |
| 6519 | bass.mid (obj_hash) |
| 6520 | beat.mid (obj_hash) |
| 6521 | keys.mid (obj_hash) |
| 6522 | ``` |
| 6523 | |
| 6524 | **Output example (`--json`):** |
| 6525 | ```json |
| 6526 | { |
| 6527 | "commit_id": "a1b2c3d4e5f6...", |
| 6528 | "branch": "main", |
| 6529 | "parent_commit_id": "f9e8d7c6...", |
| 6530 | "parent2_commit_id": null, |
| 6531 | "message": "Add bridge section with Rhodes keys", |
| 6532 | "author": "producer@stori.app", |
| 6533 | "committed_at": "2026-02-27 17:30:00", |
| 6534 | "snapshot_id": "snap_sha256...", |
| 6535 | "snapshot_manifest": { |
| 6536 | "bass.mid": "obj_sha256_a", |
| 6537 | "beat.mid": "obj_sha256_b", |
| 6538 | "keys.mid": "obj_sha256_c" |
| 6539 | } |
| 6540 | } |
| 6541 | ``` |
| 6542 | |
| 6543 | **Result types:** |
| 6544 | - `ShowCommitResult` (TypedDict) — full commit metadata + snapshot manifest returned by `_show_async()`. |
| 6545 | - `ShowDiffResult` (TypedDict) — path-level diff (added/modified/removed lists + total_changed) returned by `_diff_vs_parent_async()`. |
| 6546 | |
| 6547 | **Commit resolution order:** |
| 6548 | 1. `HEAD` (case-insensitive) → follows the `HEAD` ref file to the current branch tip. |
| 6549 | 2. 4–64 character hex string → exact commit ID match first, then prefix scan. |
| 6550 | 3. Anything else → treated as a branch name; reads `.muse/refs/heads/<name>`. |
| 6551 | |
| 6552 | **Agent use case:** An AI music generation agent calls `muse show HEAD` to inspect the |
| 6553 | latest committed snapshot before generating the next variation — confirming which |
| 6554 | instruments are present, what files changed in the last commit, and whether there are |
| 6555 | MIDI files it can use as seeds for generation. Use `--json` for structured consumption |
| 6556 | in agent pipelines. Use `--diff` to understand what changed in the last session. |
| 6557 | Use `--midi` to enumerate MIDI seeds for the Storpheus generation pipeline. |
| 6558 | |
| 6559 | **`--audio-preview` note:** The full render-preview pipeline (Storpheus → WAV) is |
| 6560 | invoked via `muse export <commit> --wav`. The `--audio-preview` flag then plays the |
| 6561 | cached WAV via `afplay` (macOS). If no cached file exists, a clear help message is |
| 6562 | printed instead. |
| 6563 | |
| 6564 | --- |
| 6565 | |
| 6566 | ## `muse amend` — Amend the Most Recent Commit |
| 6567 | |
| 6568 | **Purpose:** Fold working-tree changes into the most recent commit, replacing |
| 6569 | it with a new commit that has the same parent. Equivalent to |
| 6570 | `git commit --amend`. The original HEAD commit becomes an orphan (unreachable |
| 6571 | from any branch ref) and remains in the database for forensic traceability. |
| 6572 | |
| 6573 | **Usage:** |
| 6574 | ```bash |
| 6575 | muse amend [OPTIONS] |
| 6576 | ``` |
| 6577 | |
| 6578 | **Flags:** |
| 6579 | | Flag | Type | Default | Description | |
| 6580 | |------|------|---------|-------------| |
| 6581 | | `-m / --message TEXT` | string | — | Replace the commit message | |
| 6582 | | `--no-edit` | flag | off | Keep the original commit message (default when `-m` is omitted; takes precedence over `-m` when both are provided) | |
| 6583 | | `--reset-author` | flag | off | Reset the author field to the current user (stub: sets to empty string until a user-identity system is implemented) | |
| 6584 | |
| 6585 | **Output example:** |
| 6586 | ``` |
| 6587 | ✅ [main a1b2c3d4] updated groove pattern (amended) |
| 6588 | ``` |
| 6589 | |
| 6590 | **Behaviour:** |
| 6591 | 1. Re-snapshots `muse-work/` using the same content-addressed pipeline as |
| 6592 | `muse commit` (sha256 per file, deterministic snapshot_id). |
| 6593 | 2. Computes a new `commit_id` using the *original commit's parent* (not the |
| 6594 | original itself), the new snapshot, the effective message, and the current |
| 6595 | timestamp. |
| 6596 | 3. Writes the new commit row to Postgres and updates |
| 6597 | `.muse/refs/heads/<branch>` to the new commit ID. |
| 6598 | 4. **Blocked** when a merge is in progress (`.muse/MERGE_STATE.json` exists). |
| 6599 | 5. **Blocked** when there are no commits yet on the current branch. |
| 6600 | 6. **Blocked** when `muse-work/` does not exist or is empty. |
| 6601 | |
| 6602 | **Result types:** |
| 6603 | - Returns the new `commit_id` (64-char sha256 hex string) from `_amend_async`. |
| 6604 | - Exit codes: 0 success, 1 user error (`USER_ERROR`), 2 outside repo |
| 6605 | (`REPO_NOT_FOUND`), 3 internal error (`INTERNAL_ERROR`). |
| 6606 | |
| 6607 | **Agent use case:** A producer adjusts a MIDI note quantization setting, then |
| 6608 | runs `muse amend --no-edit` to fold the change silently into the last commit |
| 6609 | without cluttering history with a second "tweak quantization" entry. An |
| 6610 | automated agent can call `muse amend -m "fix: tighten quantization on drums"` |
| 6611 | to improve the commit message after inspection. |
| 6612 | |
| 6613 | **Implementation:** `maestro/muse_cli/commands/amend.py` — |
| 6614 | `_amend_async(message, no_edit, reset_author, root, session)`. |
| 6615 | Tests: `tests/muse_cli/test_amend.py`. |
| 6616 | |
| 6617 | --- |
| 6618 | |
| 6619 | ### `muse checkout` |
| 6620 | |
| 6621 | **Purpose:** Switch branches or create a new branch seeded from the current HEAD. |
| 6622 | Enables the branching workflows that allow composers and AI agents to explore |
| 6623 | divergent musical directions without losing prior work. |
| 6624 | |
| 6625 | **Usage:** |
| 6626 | ```bash |
| 6627 | muse checkout <branch> # Switch to an existing branch |
| 6628 | muse checkout -b <new-branch> # Create branch from HEAD, then switch |
| 6629 | ``` |
| 6630 | |
| 6631 | **Flags:** |
| 6632 | |
| 6633 | | Flag | Type | Default | Description | |
| 6634 | |------|------|---------|-------------| |
| 6635 | | `-b` | flag | off | Create the branch from the current HEAD commit and switch to it | |
| 6636 | |
| 6637 | **Output example (create):** |
| 6638 | ``` |
| 6639 | ✅ Switched to a new branch 'experiment' |
| 6640 | ``` |
| 6641 | |
| 6642 | **Output example (switch):** |
| 6643 | ``` |
| 6644 | ✅ Switched to branch 'main' [a1b2c3d4] |
| 6645 | ``` |
| 6646 | |
| 6647 | **Agent use case:** Create an experiment branch before exploring a rhythmically |
| 6648 | unusual variation. If the experiment fails, checkout main and the original |
| 6649 | arrangement is untouched. |
| 6650 | |
| 6651 | **Implementation:** `maestro/muse_cli/commands/checkout.py` — `checkout_branch(root, branch, create)`. |
| 6652 | Pure filesystem writes: creates/updates `.muse/refs/heads/<branch>` and `.muse/HEAD`. |
| 6653 | No DB interaction at checkout time — the DAG remains intact. |
| 6654 | |
| 6655 | --- |
| 6656 | |
| 6657 | ### `muse restore` |
| 6658 | |
| 6659 | **Purpose:** Restore specific files from a commit or index into `muse-work/` without |
| 6660 | touching the branch pointer. Surgical alternative to `muse reset --hard` — bring |
| 6661 | back "the bass from take 3" while keeping everything else at HEAD. |
| 6662 | |
| 6663 | **Usage:** |
| 6664 | ```bash |
| 6665 | muse restore <paths>... # restore from HEAD (default) |
| 6666 | muse restore --staged <paths>... # restore index entry from HEAD |
| 6667 | muse restore --source <commit> <paths>... # restore from a specific commit |
| 6668 | muse restore --worktree --source <commit> <paths>... # explicit worktree restore |
| 6669 | ``` |
| 6670 | |
| 6671 | **Flags:** |
| 6672 | |
| 6673 | | Flag | Type | Default | Description | |
| 6674 | |------|------|---------|-------------| |
| 6675 | | `<paths>...` | positional | — | One or more relative paths within `muse-work/` to restore. Accepts paths with or without the `muse-work/` prefix. | |
| 6676 | | `--staged` | flag | off | Restore the index (snapshot manifest) entry from the source commit. In the current Muse model (no separate staging area) this is equivalent to `--worktree`. | |
| 6677 | | `--worktree` | flag | off | Restore `muse-work/` files from the source snapshot. Default when no mode flag is specified. | |
| 6678 | | `--source / -s` | str | HEAD | Commit reference to restore from: `HEAD`, `HEAD~N`, full SHA, or any unambiguous SHA prefix. | |
| 6679 | |
| 6680 | **Output example:** |
| 6681 | ``` |
| 6682 | ✅ Restored 'bass/bassline.mid' from commit ab12cd34 |
| 6683 | ``` |
| 6684 | |
| 6685 | Multiple files: |
| 6686 | ``` |
| 6687 | ✅ Restored 2 files from commit ab12cd34: |
| 6688 | • bass/bassline.mid |
| 6689 | • drums/kick.mid |
| 6690 | ``` |
| 6691 | |
| 6692 | **Result type:** `RestoreResult` — fields: `source_commit_id` (str), `paths_restored` (list[str]), `staged` (bool). |
| 6693 | |
| 6694 | **Error cases:** |
| 6695 | - `PathNotInSnapshotError` — the requested path does not exist in the source commit's snapshot. Exit code 1. |
| 6696 | - `MissingObjectError` — the required blob is absent from `.muse/objects/`. Exit code 3. |
| 6697 | - Unknown `--source` ref — exits with code 1 and a clear error message. |
| 6698 | |
| 6699 | **Agent use case:** An AI composition agent can selectively restore individual |
| 6700 | instrument tracks from historical commits. For example, after generating several |
| 6701 | takes, the agent can restore the best bass line from take 3 while keeping drums |
| 6702 | and keys from take 7 — without modifying the branch history. Use `muse log` to |
| 6703 | identify commit SHAs, then `muse show <commit>` to inspect the snapshot manifest |
| 6704 | before running `muse restore`. |
| 6705 | |
| 6706 | **Implementation:** `maestro/muse_cli/commands/restore.py` (CLI) and |
| 6707 | `maestro/services/muse_restore.py` (service). Uses `has_object()` and |
| 6708 | `restore_object()` from the canonical `maestro/muse_cli/object_store.py` — |
| 6709 | the same module used by `muse commit`, `muse read-tree`, and `muse reset |
| 6710 | --hard`. Branch pointer is never modified. |
| 6711 | |
| 6712 | --- |
| 6713 | |
| 6714 | ### `muse resolve` |
| 6715 | |
| 6716 | **Purpose:** Mark a conflicted file as resolved during a paused `muse merge`. |
| 6717 | Called after `muse merge` exits with a conflict to accept one side's version |
| 6718 | before running `muse merge --continue`. For `--theirs`, the command |
| 6719 | automatically fetches the incoming branch's object from the local store and |
| 6720 | writes it to `muse-work/<path>` — no manual file editing required. |
| 6721 | |
| 6722 | **Usage:** |
| 6723 | ```bash |
| 6724 | muse resolve <file-path> --ours # Keep current branch's working-tree version (no file change) |
| 6725 | muse resolve <file-path> --theirs # Copy incoming branch's object to muse-work/ automatically |
| 6726 | ``` |
| 6727 | |
| 6728 | **Flags:** |
| 6729 | |
| 6730 | | Flag | Type | Default | Description | |
| 6731 | |------|------|---------|-------------| |
| 6732 | | `--ours` | flag | off | Accept the current branch's version (no file change needed) | |
| 6733 | | `--theirs` | flag | off | Fetch the incoming branch's object from local store and write to muse-work/ | |
| 6734 | |
| 6735 | **Output example:** |
| 6736 | ``` |
| 6737 | ✅ Resolved 'meta/section-1.json' — keeping theirs |
| 6738 | 1 conflict(s) remaining. Resolve all, then run 'muse merge --continue'. |
| 6739 | ✅ Resolved 'beat.mid' — keeping ours |
| 6740 | ✅ All conflicts resolved. Run 'muse merge --continue' to create the merge commit. |
| 6741 | ``` |
| 6742 | |
| 6743 | **Full conflict resolution workflow:** |
| 6744 | ```bash |
| 6745 | muse merge experiment # → conflict on beat.mid |
| 6746 | muse status # → shows "You have unmerged paths" |
| 6747 | muse resolve beat.mid --theirs # → copies theirs version into muse-work/ |
| 6748 | muse merge --continue # → creates merge commit, clears MERGE_STATE.json |
| 6749 | ``` |
| 6750 | |
| 6751 | **Note:** After all conflicts are resolved, `.muse/MERGE_STATE.json` persists |
| 6752 | with `conflict_paths=[]` so `--continue` can read the stored commit IDs. |
| 6753 | `muse merge --continue` is the command that clears MERGE_STATE.json. |
| 6754 | If the theirs object is not in the local store (e.g. branch was never |
| 6755 | committed locally), run `muse pull` first to fetch remote objects. |
| 6756 | |
| 6757 | **Implementation:** `maestro/muse_cli/commands/resolve.py` — `resolve_conflict_async(file_path, ours, root, session)`. |
| 6758 | Reads and rewrites `.muse/MERGE_STATE.json`. For `--theirs`, queries DB for |
| 6759 | the theirs commit's snapshot manifest and calls `apply_resolution()` from |
| 6760 | `merge_engine.py` to restore the file from the local object store. |
| 6761 | |
| 6762 | --- |
| 6763 | |
| 6764 | ### `muse merge --continue` |
| 6765 | |
| 6766 | **Purpose:** Finalize a merge that was paused due to file conflicts. After all |
| 6767 | conflicts are resolved via `muse resolve`, this command creates the merge commit |
| 6768 | with two parent IDs and advances the branch pointer. |
| 6769 | |
| 6770 | **Usage:** |
| 6771 | ```bash |
| 6772 | muse merge --continue |
| 6773 | ``` |
| 6774 | |
| 6775 | **Flags:** |
| 6776 | |
| 6777 | | Flag | Type | Default | Description | |
| 6778 | |------|------|---------|-------------| |
| 6779 | | `--continue` | flag | off | Finalize a paused conflicted merge | |
| 6780 | |
| 6781 | **Output example:** |
| 6782 | ``` |
| 6783 | ✅ Merge commit [main a1b2c3d4] — merged 'experiment' into 'main' |
| 6784 | ``` |
| 6785 | |
| 6786 | **Contract:** Reads `.muse/MERGE_STATE.json` for commit IDs. Fails if any |
| 6787 | `conflict_paths` remain (use `muse resolve` first). Snapshots the current |
| 6788 | `muse-work/` contents as the merged state. Clears MERGE_STATE.json on success. |
| 6789 | |
| 6790 | **Agent use case:** After resolving a harmonic conflict between two branches, |
| 6791 | run `--continue` to record the merged arrangement as an immutable commit. |
| 6792 | |
| 6793 | **Implementation:** `maestro/muse_cli/commands/merge.py` — `_merge_continue_async(root, session)`. |
| 6794 | |
| 6795 | --- |
| 6796 | |
| 6797 | ### `muse merge --abort` |
| 6798 | |
| 6799 | **Purpose:** Cancel an in-progress merge and restore the pre-merge state of all |
| 6800 | conflicted files. Use when a conflict is too complex to resolve and you want to |
| 6801 | return the working tree to the clean state it was in before `muse merge` ran. |
| 6802 | |
| 6803 | **Usage:** |
| 6804 | ```bash |
| 6805 | muse merge --abort |
| 6806 | ``` |
| 6807 | |
| 6808 | **Flags:** |
| 6809 | |
| 6810 | | Flag | Type | Default | Description | |
| 6811 | |------|------|---------|-------------| |
| 6812 | | `--abort` | flag | off | Cancel the in-progress merge and restore pre-merge files | |
| 6813 | |
| 6814 | **Output example:** |
| 6815 | ``` |
| 6816 | ✅ Merge abort. Restored 2 conflicted file(s). |
| 6817 | ``` |
| 6818 | |
| 6819 | **Contract:** |
| 6820 | - Reads `.muse/MERGE_STATE.json` for `ours_commit` and `conflict_paths`. |
| 6821 | - Fetches the ours commit's snapshot manifest from DB. |
| 6822 | - For each conflicted path: restores the ours version from the local object |
| 6823 | store to `muse-work/`. Paths that existed only on the theirs branch (not |
| 6824 | in ours manifest) are deleted from `muse-work/`. |
| 6825 | - Clears `.muse/MERGE_STATE.json` on success. |
| 6826 | - Exits 1 if no merge is in progress. |
| 6827 | |
| 6828 | **Agent use case:** When an AI agent detects an irresolvable semantic conflict |
| 6829 | (e.g. two structural arrangements that cannot be combined), it should call |
| 6830 | `muse merge --abort` to restore a clean baseline before proposing an |
| 6831 | alternative strategy to the user. |
| 6832 | |
| 6833 | **Implementation:** `maestro/muse_cli/commands/merge.py` — `_merge_abort_async(root, session)`. |
| 6834 | Queries DB for the ours commit's manifest, then calls `apply_resolution()` from |
| 6835 | `merge_engine.py` for each conflicted path. |
| 6836 | |
| 6837 | --- |
| 6838 | |
| 6839 | ### `muse release` |
| 6840 | |
| 6841 | **Purpose:** Export a tagged commit as distribution-ready release artifacts — the |
| 6842 | music-native publish step. Bridges the Muse VCS world and the audio production |
| 6843 | world: a producer says "version 1.0 is done" and `muse release v1.0` produces |
| 6844 | WAV/MIDI/stem files with SHA-256 checksums for distribution. |
| 6845 | |
| 6846 | **Usage:** |
| 6847 | ```bash |
| 6848 | muse release <tag> [OPTIONS] |
| 6849 | ``` |
| 6850 | |
| 6851 | **Flags:** |
| 6852 | |
| 6853 | | Flag | Type | Default | Description | |
| 6854 | |------|------|---------|-------------| |
| 6855 | | `<tag>` | positional | required | Tag string (created via `muse tag add`) or short commit SHA prefix | |
| 6856 | | `--render-audio` | flag | off | Render all MIDI to a single audio file via Storpheus | |
| 6857 | | `--render-midi` | flag | off | Bundle all .mid files into a zip archive | |
| 6858 | | `--export-stems` | flag | off | Export each instrument track as a separate audio file | |
| 6859 | | `--format wav\|mp3\|flac` | option | `wav` | Audio output format | |
| 6860 | | `--output-dir PATH` | option | `./releases/<tag>/` | Destination directory for all artifacts | |
| 6861 | | `--json` | flag | off | Emit structured JSON for agent consumption | |
| 6862 | |
| 6863 | **Output layout:** |
| 6864 | ``` |
| 6865 | <output-dir>/ |
| 6866 | release-manifest.json # always written; SHA-256 checksums |
| 6867 | audio/<commit8>.<format> # --render-audio |
| 6868 | midi/midi-bundle.zip # --render-midi |
| 6869 | stems/<stem>.<format> # --export-stems |
| 6870 | ``` |
| 6871 | |
| 6872 | **Output example:** |
| 6873 | ``` |
| 6874 | ✅ Release artifacts for tag 'v1.0' (commit a1b2c3d4): |
| 6875 | [audio] ./releases/v1.0/audio/a1b2c3d4.wav |
| 6876 | [midi-bundle] ./releases/v1.0/midi/midi-bundle.zip |
| 6877 | [manifest] ./releases/v1.0/release-manifest.json |
| 6878 | ⚠️ Audio files are MIDI stubs (Storpheus /render endpoint not yet deployed). |
| 6879 | ``` |
| 6880 | |
| 6881 | **Result type:** `ReleaseResult` — fields: `tag`, `commit_id`, `output_dir`, |
| 6882 | `manifest_path`, `artifacts` (list of `ReleaseArtifact`), `audio_format`, `stubbed`. |
| 6883 | |
| 6884 | **`release-manifest.json` shape:** |
| 6885 | ```json |
| 6886 | { |
| 6887 | "tag": "v1.0", |
| 6888 | "commit_id": "<full sha256>", |
| 6889 | "commit_short": "<8-char>", |
| 6890 | "released_at": "<ISO-8601 UTC>", |
| 6891 | "audio_format": "wav", |
| 6892 | "stubbed": true, |
| 6893 | "files": [ |
| 6894 | {"path": "audio/a1b2c3d4.wav", "sha256": "...", "size_bytes": 4096, "role": "audio"}, |
| 6895 | {"path": "midi/midi-bundle.zip", "sha256": "...", "size_bytes": 1024, "role": "midi-bundle"}, |
| 6896 | {"path": "release-manifest.json", "sha256": "...", "size_bytes": 512, "role": "manifest"} |
| 6897 | ] |
| 6898 | } |
| 6899 | ``` |
| 6900 | |
| 6901 | **Agent use case:** An AI music generation agent calls `muse release v1.0 --render-midi --json` |
| 6902 | after tagging a completed composition. It reads `stubbed` from the JSON output to |
| 6903 | determine whether the audio files are real renders or MIDI placeholders, and inspects |
| 6904 | `files[*].sha256` to verify integrity before uploading to a distribution platform. |
| 6905 | |
| 6906 | **Implementation stub note:** The Storpheus `POST /render` endpoint (MIDI-in → audio-out) |
| 6907 | is not yet deployed. Until it ships, `--render-audio` and `--export-stems` copy the |
| 6908 | source MIDI file as a placeholder and set `stubbed=true` in the manifest. The |
| 6909 | `_render_midi_to_audio` function in `maestro/services/muse_release.py` is the only |
| 6910 | site to update when the endpoint becomes available. |
| 6911 | |
| 6912 | **Implementation:** `maestro/muse_cli/commands/release.py` — `_release_async(...)`. |
| 6913 | Service layer: `maestro/services/muse_release.py` — `build_release(...)`. |
| 6914 | |
| 6915 | --- |
| 6916 | |
| 6917 | ## Muse CLI — Worktree Command Reference |
| 6918 | |
| 6919 | ### `muse worktree` |
| 6920 | |
| 6921 | **Purpose:** Manage local Muse worktrees so a producer can work on two |
| 6922 | arrangements simultaneously (e.g. "radio edit" and "extended club version") |
| 6923 | without switching branches back and forth. |
| 6924 | |
| 6925 | --- |
| 6926 | |
| 6927 | ### `muse worktree add` |
| 6928 | |
| 6929 | **Purpose:** Create a new linked worktree at a given path, checked out to a |
| 6930 | specific branch. Enables parallel arrangement editing without branch switching. |
| 6931 | |
| 6932 | **Usage:** |
| 6933 | ```bash |
| 6934 | muse worktree add <path> <branch> |
| 6935 | ``` |
| 6936 | |
| 6937 | **Arguments:** |
| 6938 | |
| 6939 | | Argument | Description | |
| 6940 | |----------|-------------| |
| 6941 | | `<path>` | Directory to create the linked worktree in (must not exist) | |
| 6942 | | `<branch>` | Branch name to check out; created from HEAD if absent | |
| 6943 | |
| 6944 | **Output example:** |
| 6945 | ``` |
| 6946 | ✅ Linked worktree 'feature/extended' created at /path/to/club-mix |
| 6947 | ``` |
| 6948 | |
| 6949 | **Constraints:** |
| 6950 | - `<path>` must not already exist. |
| 6951 | - The same branch cannot be checked out in two worktrees simultaneously (mirrors git). |
| 6952 | - `<path>` must be outside the main repository root. |
| 6953 | |
| 6954 | **Layout of the new directory:** |
| 6955 | ``` |
| 6956 | <path>/ |
| 6957 | .muse — plain-text gitdir file: "gitdir: <main-repo>/.muse" |
| 6958 | muse-work/ — independent working directory for this worktree |
| 6959 | ``` |
| 6960 | |
| 6961 | **Registration in main repo:** |
| 6962 | ``` |
| 6963 | .muse/worktrees/<slug>/path → absolute path to <path> |
| 6964 | .muse/worktrees/<slug>/branch → branch name |
| 6965 | ``` |
| 6966 | |
| 6967 | **Result type:** `WorktreeInfo` — fields: `path`, `branch`, `head_commit`, `is_main`, `slug`. |
| 6968 | |
| 6969 | **Agent use case:** An AI music agent can call `muse worktree add` to isolate an |
| 6970 | experimental arrangement variant in a separate directory while keeping the main |
| 6971 | working tree on the current branch. |
| 6972 | |
| 6973 | **Implementation:** `maestro/muse_cli/commands/worktree.py` — `add_worktree(root, link_path, branch)`. |
| 6974 | |
| 6975 | --- |
| 6976 | |
| 6977 | ### `muse worktree remove` |
| 6978 | |
| 6979 | **Purpose:** Remove a linked worktree directory and de-register it from the main |
| 6980 | repo. Branch refs and the shared objects store are preserved. |
| 6981 | |
| 6982 | **Usage:** |
| 6983 | ```bash |
| 6984 | muse worktree remove <path> |
| 6985 | ``` |
| 6986 | |
| 6987 | **Arguments:** |
| 6988 | |
| 6989 | | Argument | Description | |
| 6990 | |----------|-------------| |
| 6991 | | `<path>` | Path of the linked worktree to remove | |
| 6992 | |
| 6993 | **Output example:** |
| 6994 | ``` |
| 6995 | ✅ Worktree at /path/to/club-mix removed. |
| 6996 | ``` |
| 6997 | |
| 6998 | **Constraints:** |
| 6999 | - The main worktree cannot be removed. |
| 7000 | - `<path>` must be a registered linked worktree. |
| 7001 | - Works even when the linked directory is already absent (stale removal). |
| 7002 | |
| 7003 | **Agent use case:** After finishing a variant arrangement, an agent removes the |
| 7004 | linked worktree to clean up disk space while preserving the branch for future use. |
| 7005 | |
| 7006 | **Implementation:** `maestro/muse_cli/commands/worktree.py` — `remove_worktree(root, link_path)`. |
| 7007 | |
| 7008 | --- |
| 7009 | |
| 7010 | ### `muse worktree list` |
| 7011 | |
| 7012 | **Purpose:** Show all worktrees (main + linked) with their paths, branches, and |
| 7013 | HEAD commit SHAs. Enables an agent to know which arrangements are active. |
| 7014 | |
| 7015 | **Usage:** |
| 7016 | ```bash |
| 7017 | muse worktree list |
| 7018 | ``` |
| 7019 | |
| 7020 | **Output example:** |
| 7021 | ``` |
| 7022 | /path/to/project [main] branch: main head: a1b2c3d4 |
| 7023 | /path/to/club-mix branch: feature/extended head: a1b2c3d4 |
| 7024 | ``` |
| 7025 | |
| 7026 | **Result type:** `list[WorktreeInfo]` — see `WorktreeInfo` in `type_contracts.md`. |
| 7027 | |
| 7028 | **Agent use case:** Before composing a new section, an agent lists all worktrees |
| 7029 | to pick the correct arrangement context to work in. |
| 7030 | |
| 7031 | **Implementation:** `maestro/muse_cli/commands/worktree.py` — `list_worktrees(root)`. |
| 7032 | |
| 7033 | --- |
| 7034 | |
| 7035 | ### `muse worktree prune` |
| 7036 | |
| 7037 | **Purpose:** Remove stale worktree registrations where the target directory no |
| 7038 | longer exists. Safe to run any time — no data or branch refs are deleted. |
| 7039 | |
| 7040 | **Usage:** |
| 7041 | ```bash |
| 7042 | muse worktree prune |
| 7043 | ``` |
| 7044 | |
| 7045 | **Output example (stale entries found):** |
| 7046 | ``` |
| 7047 | ⚠️ Pruned stale worktree: /path/to/old-mix |
| 7048 | ✅ Pruned 1 stale worktree registration(s). |
| 7049 | ``` |
| 7050 | |
| 7051 | **Output example (nothing to prune):** |
| 7052 | ``` |
| 7053 | ✅ No stale worktrees found. |
| 7054 | ``` |
| 7055 | |
| 7056 | **Agent use case:** Run periodically or before `muse worktree list` to ensure the |
| 7057 | registration index matches the filesystem. |
| 7058 | |
| 7059 | **Implementation:** `maestro/muse_cli/commands/worktree.py` — `prune_worktrees(root)`. |
| 7060 | |
| 7061 | --- |
| 7062 | |
| 7063 | ## Muse Hub — User Profiles |
| 7064 | |
| 7065 | User profiles are the public-facing identity layer of Muse Hub, analogous to |
| 7066 | GitHub profile pages. Each authenticated user may create exactly one profile, |
| 7067 | identified by a URL-safe username. The profile aggregates data across all |
| 7068 | of the user's repos to present a musical portfolio. |
| 7069 | |
| 7070 | ### Data Model |
| 7071 | |
| 7072 | **Table:** `musehub_profiles` |
| 7073 | |
| 7074 | | Column | Type | Description | |
| 7075 | |--------|------|-------------| |
| 7076 | | `user_id` | String(36) PK | JWT `sub` — same ID used in `musehub_repos.owner_user_id` | |
| 7077 | | `username` | String(64) UNIQUE | URL-friendly handle (e.g. `gabriel`) | |
| 7078 | | `bio` | Text nullable | Short bio, Markdown supported (max 500 chars) | |
| 7079 | | `avatar_url` | String(2048) nullable | Avatar image URL | |
| 7080 | | `pinned_repo_ids` | JSON | Up to 6 repo_ids highlighted on the profile page | |
| 7081 | | `created_at` | DateTime(tz) | Profile creation timestamp | |
| 7082 | | `updated_at` | DateTime(tz) | Last update timestamp | |
| 7083 | |
| 7084 | ### API Endpoints |
| 7085 | |
| 7086 | | Method | Path | Auth | Description | |
| 7087 | |--------|------|------|-------------| |
| 7088 | | `GET` | `/api/v1/musehub/users/{username}` | Public | Full profile JSON | |
| 7089 | | `POST` | `/api/v1/musehub/users` | JWT required | Create a profile | |
| 7090 | | `PUT` | `/api/v1/musehub/users/{username}` | JWT, owner only | Update bio/avatar/pins | |
| 7091 | | `GET` | `/musehub/ui/users/{username}` | Public HTML shell | Browser profile page | |
| 7092 | |
| 7093 | ### ProfileResponse Fields |
| 7094 | |
| 7095 | | Field | Type | Description | |
| 7096 | |-------|------|-------------| |
| 7097 | | `username` | str | URL handle | |
| 7098 | | `bio` | str \| None | Short bio | |
| 7099 | | `avatar_url` | str \| None | Avatar URL | |
| 7100 | | `pinned_repo_ids` | list[str] | Pinned repo IDs (order preserved) | |
| 7101 | | `repos` | list[ProfileRepoSummary] | Public repos, newest first | |
| 7102 | | `contribution_graph` | list[ContributionDay] | 52 weeks of daily commit counts | |
| 7103 | | `session_credits` | int | Total commits across all repos (creative sessions) | |
| 7104 | | `created_at` / `updated_at` | datetime | Profile timestamps | |
| 7105 | |
| 7106 | ### Contribution Graph |
| 7107 | |
| 7108 | The contribution graph covers the **last 52 weeks** (364 days) ending today. |
| 7109 | Each day's count is the number of commits pushed across ALL repos owned by |
| 7110 | the user (public and private) on that date. The browser UI renders this as |
| 7111 | a GitHub-style heatmap using CSS data attributes (`data-count=0–4`). |
| 7112 | |
| 7113 | ### Session Credits |
| 7114 | |
| 7115 | Session credits are the total number of commits ever pushed to Muse Hub across |
| 7116 | all repos owned by the user. Each commit represents one composition session |
| 7117 | recorded to the hub. This is the MVP proxy; future releases may tie credits |
| 7118 | to token usage from `usage_logs`. |
| 7119 | |
| 7120 | ### Disambiguation |
| 7121 | |
| 7122 | The profile UI page at `/musehub/ui/users/{username}` does NOT conflict with |
| 7123 | the repo browser at `/musehub/ui/{owner}/{repo_slug}` — the `users/` path segment |
| 7124 | ensures distinct routing. The JSON API is namespaced at |
| 7125 | `/api/v1/musehub/users/{username}`. |
| 7126 | |
| 7127 | ### Result Types |
| 7128 | |
| 7129 | - `ProfileResponse` — `maestro/models/musehub.py` |
| 7130 | - `ProfileRepoSummary` — compact per-repo entry (repo_id, name, visibility, star_count, last_activity_at, created_at) |
| 7131 | - `ContributionDay` — `{ date: "YYYY-MM-DD", count: int }` |
| 7132 | |
| 7133 | Registered in `docs/reference/type_contracts.md`. |
| 7134 | |
| 7135 | ### Implementation Files |
| 7136 | |
| 7137 | | File | Purpose | |
| 7138 | |------|---------| |
| 7139 | | `maestro/db/musehub_models.py` | `MusehubProfile` ORM model | |
| 7140 | | `maestro/services/musehub_profile.py` | CRUD + aggregate queries | |
| 7141 | | `maestro/api/routes/musehub/users.py` | JSON API handlers | |
| 7142 | | `maestro/api/routes/musehub/ui.py` | `profile_page()` HTML handler | |
| 7143 | | `alembic/versions/0001_consolidated_schema.py` | `musehub_profiles` table | |
| 7144 | | `tests/test_musehub_ui.py` | Profile acceptance tests | |
| 7145 | ## Muse Hub — Cross-Repo Global Search |
| 7146 | |
| 7147 | ### Overview |
| 7148 | |
| 7149 | Global search lets musicians and AI agents search commit messages across **all |
| 7150 | public Muse Hub repos** in a single query. It is the cross-repo counterpart of |
| 7151 | the per-repo `muse find` command. |
| 7152 | |
| 7153 | Only `visibility='public'` repos are searched — private repos are excluded at |
| 7154 | the persistence layer and are never enumerated regardless of caller identity. |
| 7155 | |
| 7156 | ### API |
| 7157 | |
| 7158 | ``` |
| 7159 | GET /api/v1/musehub/search?q={query}&mode={mode}&page={page}&page_size={page_size} |
| 7160 | Authorization: Bearer <jwt> |
| 7161 | ``` |
| 7162 | |
| 7163 | **Parameters:** |
| 7164 | |
| 7165 | | Parameter | Type | Default | Description | |
| 7166 | |-----------|------|---------|-------------| |
| 7167 | | `q` | string (required) | — | Search query (1–500 chars) | |
| 7168 | | `mode` | `keyword` \| `pattern` | `keyword` | Matching strategy (see below) | |
| 7169 | | `page` | int ≥ 1 | 1 | Repo-group page number | |
| 7170 | | `page_size` | int 1–50 | 10 | Repo-groups per page | |
| 7171 | |
| 7172 | **Search modes:** |
| 7173 | |
| 7174 | - **keyword** — whitespace-split OR-match of each term against commit messages |
| 7175 | and repo names (case-insensitive, uses `lower()` + `LIKE %term%`). |
| 7176 | - **pattern** — raw SQL `LIKE` pattern applied to commit messages only. Use |
| 7177 | `%` as wildcard (e.g. `q=%minor%`). |
| 7178 | |
| 7179 | ### Response shape |
| 7180 | |
| 7181 | Returns `GlobalSearchResult` (JSON, camelCase): |
| 7182 | |
| 7183 | ```json |
| 7184 | { |
| 7185 | "query": "jazz groove", |
| 7186 | "mode": "keyword", |
| 7187 | "totalReposSearched": 42, |
| 7188 | "page": 1, |
| 7189 | "pageSize": 10, |
| 7190 | "groups": [ |
| 7191 | { |
| 7192 | "repoId": "uuid", |
| 7193 | "repoName": "jazz-lab", |
| 7194 | "repoOwner": "alice", |
| 7195 | "repoVisibility": "public", |
| 7196 | "totalMatches": 3, |
| 7197 | "matches": [ |
| 7198 | { |
| 7199 | "commitId": "abc123", |
| 7200 | "message": "jazz groove — walking bass variant", |
| 7201 | "author": "alice", |
| 7202 | "branch": "main", |
| 7203 | "timestamp": "2026-02-27T12:00:00Z", |
| 7204 | "repoId": "uuid", |
| 7205 | "repoName": "jazz-lab", |
| 7206 | "repoOwner": "alice", |
| 7207 | "repoVisibility": "public", |
| 7208 | "audioObjectId": "sha256:abc..." |
| 7209 | } |
| 7210 | ] |
| 7211 | } |
| 7212 | ] |
| 7213 | } |
| 7214 | ``` |
| 7215 | |
| 7216 | Results are **grouped by repo**. Each group contains up to 20 matching commits |
| 7217 | (newest-first). `totalMatches` reflects the actual count before the 20-commit |
| 7218 | cap. Pagination (`page` / `page_size`) controls how many repo-groups appear |
| 7219 | per response. |
| 7220 | |
| 7221 | `audioObjectId` is populated when the repo has at least one `.mp3`, `.ogg`, or |
| 7222 | `.wav` artifact — the first one alphabetically by path is chosen. Consumers |
| 7223 | can use this to render `<audio>` preview players without a separate API call. |
| 7224 | |
| 7225 | ### Browser UI |
| 7226 | |
| 7227 | ``` |
| 7228 | GET /musehub/ui/search?q={query}&mode={mode} |
| 7229 | ``` |
| 7230 | |
| 7231 | Returns a static HTML shell (no JWT required). The page pre-fills the search |
| 7232 | form from URL params, submits to the JSON API via localStorage JWT, and renders |
| 7233 | grouped results with audio previews and pagination. |
| 7234 | |
| 7235 | ### Implementation |
| 7236 | |
| 7237 | | Layer | File | What it does | |
| 7238 | |-------|------|-------------| |
| 7239 | | Pydantic models | `maestro/models/musehub.py` | `GlobalSearchCommitMatch`, `GlobalSearchRepoGroup`, `GlobalSearchResult` | |
| 7240 | | Service | `maestro/services/musehub_repository.py` | `global_search()` — public-only filter, keyword/pattern predicate, group assembly, audio preview resolution | |
| 7241 | | Route | `maestro/api/routes/musehub/search.py` | `GET /musehub/search` — validates params, delegates to service | |
| 7242 | | UI | `maestro/api/routes/musehub/ui.py` | `global_search_page()` — static HTML shell at `/musehub/ui/search` | |
| 7243 | |
| 7244 | ### Agent use case |
| 7245 | |
| 7246 | An AI composition agent searching for reference material can call: |
| 7247 | |
| 7248 | ``` |
| 7249 | GET /api/v1/musehub/search?q=F%23+minor+walking+bass&mode=keyword&page_size=5 |
| 7250 | ``` |
| 7251 | |
| 7252 | The grouped response lets the agent scan commit messages by repo context, |
| 7253 | identify matching repos by name and owner, and immediately fetch audio previews |
| 7254 | via `audioObjectId` without additional round-trips. |
| 7255 | |
| 7256 | --- |
| 7257 | |
| 7258 | ## Muse Hub — Dynamics Analysis Page (issue #223) |
| 7259 | |
| 7260 | The dynamics analysis page visualises per-track velocity profiles, dynamic arc |
| 7261 | classifications, and a cross-track loudness comparison for a given commit ref. |
| 7262 | |
| 7263 | ### JSON endpoint |
| 7264 | |
| 7265 | ``` |
| 7266 | GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/dynamics/page |
| 7267 | ``` |
| 7268 | |
| 7269 | Requires JWT. Returns `DynamicsPageData` — a list of `TrackDynamicsProfile` |
| 7270 | objects, one per active track. |
| 7271 | |
| 7272 | Query params: |
| 7273 | | Param | Type | Default | Description | |
| 7274 | |-------|------|---------|-------------| |
| 7275 | | `track` | `str` | (all) | Filter to a single named track | |
| 7276 | | `section` | `str` | (all) | Filter to a named section | |
| 7277 | |
| 7278 | Response fields: `repo_id`, `ref`, `tracks[]` (each with `track`, `peak_velocity`, |
| 7279 | `min_velocity`, `mean_velocity`, `dynamic_range`, `arc`, `curve`). |
| 7280 | |
| 7281 | ### Browser UI |
| 7282 | |
| 7283 | ``` |
| 7284 | GET /{repo_id}/analysis/{ref}/dynamics |
| 7285 | ``` |
| 7286 | |
| 7287 | Returns a static HTML shell (no JWT required to load; JWT required to fetch |
| 7288 | data). Renders: |
| 7289 | - Per-track SVG velocity sparkline (32-point `curve` array) |
| 7290 | - Dynamic arc badge (`flat` / `crescendo` / `decrescendo` / `terraced` / `swell` / `hairpin`) |
| 7291 | - Peak velocity and velocity range metrics per track |
| 7292 | - Cross-track loudness comparison bar chart |
| 7293 | - Track and section filter dropdowns |
| 7294 | |
| 7295 | ### Arc vocabulary |
| 7296 | |
| 7297 | | Arc | Meaning | |
| 7298 | |-----|---------| |
| 7299 | | `flat` | Uniform velocity throughout | |
| 7300 | | `terraced` | Step-wise velocity shifts (Baroque-style) | |
| 7301 | | `crescendo` | Monotonically increasing velocity | |
| 7302 | | `decrescendo` | Monotonically decreasing velocity | |
| 7303 | | `swell` | Rise then fall (arch shape) | |
| 7304 | | `hairpin` | Fall then rise (valley shape) | |
| 7305 | |
| 7306 | ### Implementation |
| 7307 | |
| 7308 | | Layer | File | What it does | |
| 7309 | |-------|------|-------------| |
| 7310 | | Pydantic models | `maestro/models/musehub_analysis.py` | `DynamicArc`, `TrackDynamicsProfile`, `DynamicsPageData` | |
| 7311 | | Service | `maestro/services/musehub_analysis.py` | `compute_dynamics_page_data()` — builds stub profiles keyed by `(repo_id, ref)` | |
| 7312 | | Route | `maestro/api/routes/musehub/analysis.py` | `GET .../dynamics/page` — JWT, ETag, 404 on missing repo | |
| 7313 | | UI | `maestro/api/routes/musehub/ui.py` | `dynamics_analysis_page()` — static HTML + JS at `/{repo_id}/analysis/{ref}/dynamics` | |
| 7314 | | Tests | `tests/test_musehub_analysis.py` | Endpoint + service unit tests | |
| 7315 | |
| 7316 | ### Agent use case |
| 7317 | |
| 7318 | Before composing a new layer, an agent fetches: |
| 7319 | |
| 7320 | ``` |
| 7321 | GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/dynamics/page |
| 7322 | ``` |
| 7323 | |
| 7324 | It examines the `arc` of each track to decide whether to add velocity |
| 7325 | variation. If all tracks are `flat`, the agent shapes the new part with a |
| 7326 | `crescendo`. If one track is already `crescendo`, the new layer complements it |
| 7327 | rather than competing. |
| 7328 | --- |
| 7329 | |
| 7330 | ## Muse Hub — Contour and Tempo Analysis Pages |
| 7331 | |
| 7332 | **Purpose:** Browser-readable visualisation of the two most structure-critical |
| 7333 | musical dimensions — melodic contour and tempo — derived from a Muse commit |
| 7334 | ref. These pages close the gap between the CLI commands `muse contour` and |
| 7335 | `muse tempo --history` and the web-first MuseHub experience. |
| 7336 | |
| 7337 | ### Contour Analysis Page |
| 7338 | |
| 7339 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/contour` |
| 7340 | |
| 7341 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7342 | |
| 7343 | **What it shows:** |
| 7344 | - **Shape label** — coarse melodic shape (`arch`, `ascending`, `descending`, |
| 7345 | `flat`, `inverted-arch`, `wave`) rendered as a coloured badge. |
| 7346 | - **Pitch-curve SVG** — polyline of MIDI pitch sampled at quarter-note |
| 7347 | intervals across the piece, with min/max pitch labels and beat-count axis. |
| 7348 | - **Tessitura bar** — horizontal range bar from lowest to highest note, |
| 7349 | displaying octave span and note names (e.g. `C3` – `G5 · 2.0 oct`). |
| 7350 | - **Direction metadata** — `overallDirection` (up / down / flat), |
| 7351 | `directionChanges` count, `peakBeat`, and `valleyBeat`. |
| 7352 | - **Track filter** — text input forwarded as `?track=<instrument>` to the |
| 7353 | JSON API, restricting analysis to a named instrument (e.g. `lead`, `keys`). |
| 7354 | |
| 7355 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/contour` |
| 7356 | |
| 7357 | Returns `AnalysisResponse` with `dimension = "contour"` and `data` of type |
| 7358 | `ContourData`: |
| 7359 | |
| 7360 | | Field | Type | Description | |
| 7361 | |-------|------|-------------| |
| 7362 | | `shape` | `str` | Coarse shape label (`arch`, `ascending`, `descending`, `flat`, `wave`) | |
| 7363 | | `direction_changes` | `int` | Number of melodic direction reversals | |
| 7364 | | `peak_beat` | `float` | Beat position of the melodic peak | |
| 7365 | | `valley_beat` | `float` | Beat position of the melodic valley | |
| 7366 | | `overall_direction` | `str` | Net direction from first to last note (`up`, `down`, `flat`) | |
| 7367 | | `pitch_curve` | `list[float]` | MIDI pitch sampled at quarter-note intervals | |
| 7368 | |
| 7369 | **Result type:** `ContourData` — defined in `maestro/models/musehub_analysis.py`. |
| 7370 | |
| 7371 | **Agent use case:** Before generating a melodic continuation, an AI agent |
| 7372 | fetches the contour page JSON to understand the shape of the existing lead |
| 7373 | line. If `shape == "arch"`, the agent can elect to continue with a |
| 7374 | descending phrase that resolves the arch rather than restarting from the peak. |
| 7375 | |
| 7376 | ### Tempo Analysis Page |
| 7377 | |
| 7378 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/tempo` |
| 7379 | |
| 7380 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7381 | |
| 7382 | **What it shows:** |
| 7383 | - **BPM display** — large primary tempo in beats per minute. |
| 7384 | - **Time feel** — perceptual descriptor (`straight`, `laid-back`, `rushing`). |
| 7385 | - **Stability bar** — colour-coded progress bar (green ≥ 80%, orange ≥ 50%, |
| 7386 | red < 50%) with percentage and label (`metronomic` / `moderate` / `free tempo`). |
| 7387 | - **Tempo change timeline SVG** — polyline of BPM over beat position, with |
| 7388 | orange dots marking each tempo change event. |
| 7389 | - **Tempo change table** — tabular list of beat → BPM transitions for precise |
| 7390 | inspection. |
| 7391 | - **Cross-commit note** — guidance linking to the JSON API for BPM history |
| 7392 | across multiple refs. |
| 7393 | |
| 7394 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/tempo` |
| 7395 | |
| 7396 | Returns `AnalysisResponse` with `dimension = "tempo"` and `data` of type |
| 7397 | `TempoData`: |
| 7398 | |
| 7399 | | Field | Type | Description | |
| 7400 | |-------|------|-------------| |
| 7401 | | `bpm` | `float` | Primary (mean) BPM for the ref | |
| 7402 | | `stability` | `float` | 0 = free tempo, 1 = perfectly metronomic | |
| 7403 | | `time_feel` | `str` | `straight`, `laid-back`, or `rushing` | |
| 7404 | | `tempo_changes` | `list[TempoChange]` | Ordered list of beat-position → BPM transitions | |
| 7405 | |
| 7406 | `TempoChange` fields: `beat: float`, `bpm: float`. |
| 7407 | |
| 7408 | **Result type:** `TempoData`, `TempoChange` — defined in |
| 7409 | `maestro/models/musehub_analysis.py`. |
| 7410 | |
| 7411 | **Agent use case:** An AI agent generating a new section can query |
| 7412 | `/analysis/{ref}/tempo` to detect an accelerando near the end of a piece |
| 7413 | (`stability < 0.5`, ascending `tempo_changes`) and avoid locking the generated |
| 7414 | part to a rigid grid. For cross-commit BPM evolution, the agent compares |
| 7415 | `TempoData.bpm` across multiple refs to track tempo drift across a composition |
| 7416 | session. |
| 7417 | |
| 7418 | ### Implementation |
| 7419 | |
| 7420 | | Layer | File | What it does | |
| 7421 | |-------|------|-------------| |
| 7422 | | Pydantic models | `maestro/models/musehub_analysis.py` | `ContourData`, `TempoData`, `TempoChange` | |
| 7423 | | Service | `maestro/services/musehub_analysis.py` | `compute_dimension("contour" \| "tempo", ...)` | |
| 7424 | | Analysis route | `maestro/api/routes/musehub/analysis.py` | `GET /repos/{id}/analysis/{ref}/contour` and `.../tempo` | |
| 7425 | | UI route | `maestro/api/routes/musehub/ui.py` | `contour_page()`, `tempo_page()` — HTML shells | |
| 7426 | | Tests | `tests/test_musehub_ui.py` | `test_contour_page_renders`, `test_contour_json_response`, `test_tempo_page_renders`, `test_tempo_json_response` | |
| 7427 | | Tests | `tests/test_musehub_analysis.py` | `test_contour_track_filter`, `test_tempo_section_filter` | |
| 7428 | |
| 7429 | --- |
| 7430 | |
| 7431 | ## Muse Hub — Per-Dimension Analysis Detail Pages (issue #332) |
| 7432 | |
| 7433 | **Purpose:** Each of the 10 analysis dashboard cards links to a dedicated |
| 7434 | per-dimension page at `/{owner}/{repo_slug}/analysis/{ref}/{dim_id}`. This |
| 7435 | section documents the 6 pages added in issue #332 to complete the set (key, |
| 7436 | meter, chord-map, groove, emotion, form). |
| 7437 | |
| 7438 | ### Key Analysis Page |
| 7439 | |
| 7440 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/key` |
| 7441 | |
| 7442 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7443 | |
| 7444 | **What it shows:** |
| 7445 | - **Tonic + mode display** — large coloured badge showing detected key (e.g. `G dorian`). |
| 7446 | - **Relative key** — companion key alongside the primary (e.g. `Bb major`). |
| 7447 | - **Confidence bar** — colour-coded progress bar (green >= 80%, orange >= 60%, red < 60%). |
| 7448 | - **Alternate key candidates** — ranked list of secondary hypotheses with individual confidence bars. |
| 7449 | |
| 7450 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/key` |
| 7451 | |
| 7452 | | Field | Type | Description | |
| 7453 | |-------|------|-------------| |
| 7454 | | `tonic` | `str` | Detected tonic pitch class, e.g. `C`, `F#` | |
| 7455 | | `mode` | `str` | Detected mode, e.g. `major`, `dorian`, `mixolydian` | |
| 7456 | | `confidence` | `float` | Detection confidence, 0-1 | |
| 7457 | | `relative_key` | `str` | Relative major/minor key | |
| 7458 | | `alternate_keys` | `list[AlternateKey]` | Secondary candidates ranked by confidence | |
| 7459 | |
| 7460 | **Agent use case:** Before generating harmonic material, an agent fetches the key page |
| 7461 | to confirm the tonal centre. When `confidence < 0.7`, the agent inspects `alternate_keys` |
| 7462 | to handle tonally ambiguous pieces. |
| 7463 | |
| 7464 | ### Meter Analysis Page |
| 7465 | |
| 7466 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/meter` |
| 7467 | |
| 7468 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7469 | |
| 7470 | **What it shows:** |
| 7471 | - **Time signature** — large display of the primary time signature (e.g. `6/8`). |
| 7472 | - **Compound/simple badge** — classifies the meter type. |
| 7473 | - **Beat strength profile SVG** — bar chart of relative beat strengths across one bar. |
| 7474 | - **Irregular sections table** — lists any sections where the meter deviates from the primary. |
| 7475 | |
| 7476 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/meter` |
| 7477 | |
| 7478 | | Field | Type | Description | |
| 7479 | |-------|------|-------------| |
| 7480 | | `time_signature` | `str` | Primary time signature, e.g. `4/4`, `6/8` | |
| 7481 | | `is_compound` | `bool` | True for compound meters like 6/8, 12/8 | |
| 7482 | | `beat_strength_profile` | `list[float]` | Relative beat strengths across one bar | |
| 7483 | | `irregular_sections` | `list[IrregularSection]` | Sections with non-primary meter | |
| 7484 | |
| 7485 | **Agent use case:** An agent generating rhythmic material checks this page to avoid |
| 7486 | placing accents on weak beats and adjusts triplet groupings for compound meters. |
| 7487 | |
| 7488 | ### Chord Map Analysis Page |
| 7489 | |
| 7490 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/chord-map` |
| 7491 | |
| 7492 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7493 | |
| 7494 | **What it shows:** |
| 7495 | - **Summary counts** — total chords and total beats. |
| 7496 | - **Chord progression table** — beat-position, chord, Roman numeral function, and tension |
| 7497 | score with per-row tension bars colour-coded green/orange/red. |
| 7498 | |
| 7499 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/chord-map` |
| 7500 | |
| 7501 | | Field | Type | Description | |
| 7502 | |-------|------|-------------| |
| 7503 | | `progression` | `list[ChordEvent]` | Time-ordered chord events | |
| 7504 | | `total_chords` | `int` | Total number of distinct chord events | |
| 7505 | | `total_beats` | `int` | Duration of the ref in beats | |
| 7506 | |
| 7507 | `ChordEvent` fields: `beat: float`, `chord: str`, `function: str`, `tension: float (0-1)`. |
| 7508 | |
| 7509 | **Agent use case:** An agent generating accompaniment inspects the chord map to produce |
| 7510 | harmonically idiomatic voicings. High `tension` chords (> 0.7) signal dissonance points |
| 7511 | where resolution material is appropriate. |
| 7512 | |
| 7513 | ### Groove Analysis Page |
| 7514 | |
| 7515 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/groove` |
| 7516 | |
| 7517 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7518 | |
| 7519 | **What it shows:** |
| 7520 | - **Style badge** — detected groove style (`straight`, `swing`, `shuffled`, `latin`, `funk`). |
| 7521 | - **BPM + grid resolution** — primary tempo and quantization grid (e.g. `1/16`). |
| 7522 | - **Onset deviation** — mean absolute deviation of note onsets from the grid in beats. |
| 7523 | - **Groove score gauge** — colour-coded percentage bar (green >= 80%, orange >= 60%, red < 60%). |
| 7524 | - **Swing factor bar** — 0.5 = perfectly straight, 0.67 = hard triplet swing. |
| 7525 | |
| 7526 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/groove` |
| 7527 | |
| 7528 | | Field | Type | Description | |
| 7529 | |-------|------|-------------| |
| 7530 | | `style` | `str` | Detected groove style | |
| 7531 | | `bpm` | `float` | Primary BPM | |
| 7532 | | `grid_resolution` | `str` | Quantization grid, e.g. `1/16`, `1/8T` | |
| 7533 | | `onset_deviation` | `float` | Mean absolute note onset deviation from grid (beats) | |
| 7534 | | `groove_score` | `float` | Aggregate rhythmic tightness (1 = very tight) | |
| 7535 | | `swing_factor` | `float` | 0.5 = straight, ~0.67 = triplet swing | |
| 7536 | |
| 7537 | **Agent use case:** When generating continuation material, an agent matches the groove |
| 7538 | style and swing factor so generated notes feel rhythmically consistent with the existing |
| 7539 | recording rather than mechanically quantized. |
| 7540 | |
| 7541 | ### Emotion Analysis Page |
| 7542 | |
| 7543 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion` |
| 7544 | |
| 7545 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7546 | |
| 7547 | **What it shows:** |
| 7548 | - **Primary emotion badge** — dominant emotion label (e.g. `joyful`, `melancholic`). |
| 7549 | - **Valence-arousal plot** — 2D scatter dot on the valence (x) x arousal (y) plane. |
| 7550 | - **Axis bars** — individual bars for valence (re-normalised to 0-1), arousal, and tension. |
| 7551 | - **Confidence score** — detection confidence percentage. |
| 7552 | |
| 7553 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/emotion` |
| 7554 | |
| 7555 | | Field | Type | Description | |
| 7556 | |-------|------|-------------| |
| 7557 | | `valence` | `float` | -1 (sad/dark) to +1 (happy/bright) | |
| 7558 | | `arousal` | `float` | 0 (calm) to 1 (energetic) | |
| 7559 | | `tension` | `float` | 0 (relaxed) to 1 (tense/dissonant) | |
| 7560 | | `primary_emotion` | `str` | Dominant emotion label | |
| 7561 | | `confidence` | `float` | Detection confidence, 0-1 | |
| 7562 | |
| 7563 | **Agent use case:** An agent generating a bridge section checks the emotion page to decide |
| 7564 | whether to maintain or contrast the current emotional character. Low confidence (< 0.6) |
| 7565 | signals an emotionally ambiguous piece. |
| 7566 | |
| 7567 | ### Form Analysis Page |
| 7568 | |
| 7569 | **Route:** `GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/form` |
| 7570 | |
| 7571 | **Auth:** No JWT required — static HTML shell; JS fetches from the authed JSON API. |
| 7572 | |
| 7573 | **What it shows:** |
| 7574 | - **Form label** — detected macro form (e.g. `AABA`, `verse-chorus`, `through-composed`). |
| 7575 | - **Section counts** — total beats and number of formal sections. |
| 7576 | - **Form timeline** — colour-coded horizontal bar with proportional-width segments per section. |
| 7577 | - **Sections table** — per-section label, function, and beat range. |
| 7578 | |
| 7579 | **JSON endpoint:** `GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/form` |
| 7580 | |
| 7581 | | Field | Type | Description | |
| 7582 | |-------|------|-------------| |
| 7583 | | `form_label` | `str` | Detected macro form, e.g. `AABA`, `verse-chorus` | |
| 7584 | | `total_beats` | `int` | Total duration in beats | |
| 7585 | | `sections` | `list[SectionEntry]` | Formal sections in order | |
| 7586 | |
| 7587 | `SectionEntry` fields: `label`, `function`, `start_beat`, `end_beat`, `length_beats`. |
| 7588 | |
| 7589 | **Agent use case:** Before generating a new section, an agent reads the form page to |
| 7590 | understand where it is in the compositional arc (e.g. after two verses, a chorus is |
| 7591 | expected). The colour-coded timeline shows how much of the form is already complete. |
| 7592 | |
| 7593 | ### Implementation (all 10 dimension pages) |
| 7594 | |
| 7595 | | Layer | File | What it does | |
| 7596 | |-------|------|-------------| |
| 7597 | | Pydantic models | `maestro/models/musehub_analysis.py` | `ContourData`, `TempoData`, `KeyData`, `MeterData`, `ChordMapData`, `GrooveData`, `EmotionData`, `FormData` | |
| 7598 | | Service | `maestro/services/musehub_analysis.py` | `compute_dimension(dim, ...)` -- handles all 10 dimensions | |
| 7599 | | Analysis route | `maestro/api/routes/musehub/analysis.py` | `GET /repos/{id}/analysis/{ref}/{dim}` for each dimension | |
| 7600 | | UI route | `maestro/api/routes/musehub/ui.py` | `key_analysis_page()`, `meter_analysis_page()`, `chord_map_analysis_page()`, `groove_analysis_page()`, `emotion_analysis_page()`, `form_analysis_page()` | |
| 7601 | | Templates | `maestro/templates/musehub/pages/` | `key.html`, `meter.html`, `chord_map.html`, `groove.html`, `emotion.html`, `form.html` | |
| 7602 | | Tests | `tests/test_musehub_ui.py` | `test_*_page_renders`, `test_*_page_no_auth_required`, `test_*_page_contains_*_data_labels` for each dimension | |
| 7603 | |
| 7604 | --- |
| 7605 | |
| 7606 | ## MuseHub Design System |
| 7607 | |
| 7608 | MuseHub pages share a structured CSS framework served as static assets from |
| 7609 | `/musehub/static/`. This replaces the former monolithic `_CSS` Python string |
| 7610 | that was embedded in every HTML response body. |
| 7611 | |
| 7612 | ### File structure |
| 7613 | |
| 7614 | | File | Served at | Purpose | |
| 7615 | |------|-----------|---------| |
| 7616 | | `maestro/templates/musehub/static/tokens.css` | `/musehub/static/tokens.css` | CSS custom properties (design tokens) | |
| 7617 | | `maestro/templates/musehub/static/components.css` | `/musehub/static/components.css` | Reusable component classes | |
| 7618 | | `maestro/templates/musehub/static/layout.css` | `/musehub/static/layout.css` | Grid, header, responsive breakpoints | |
| 7619 | | `maestro/templates/musehub/static/icons.css` | `/musehub/static/icons.css` | File-type and musical concept icons | |
| 7620 | | `maestro/templates/musehub/static/music.css` | `/musehub/static/music.css` | Piano roll, waveform, radar chart, heatmap | |
| 7621 | | `maestro/templates/musehub/static/musehub.js` | `/musehub/static/musehub.js` | Shared JS: JWT helpers, `apiFetch`, token form, date/SHA formatters | |
| 7622 | | `maestro/templates/musehub/static/embed.css` | `/musehub/static/embed.css` | Compact dark theme for iframe embed player | |
| 7623 | |
| 7624 | Static files are served by FastAPI's `StaticFiles` mount registered in |
| 7625 | `maestro/main.py` at startup. All CSS files and `musehub.js` are linked by |
| 7626 | `maestro/templates/musehub/base.html` — the Jinja2 base template that replaced |
| 7627 | the old `_page()` Python helper. |
| 7628 | |
| 7629 | ### Jinja2 template layout |
| 7630 | |
| 7631 | All MuseHub web UI pages are rendered via Jinja2 templates |
| 7632 | (`jinja2>=3.1.0`, `aiofiles>=23.2.0`). No HTML is generated inside route |
| 7633 | handlers; handlers resolve server-side data and pass a minimal context dict to |
| 7634 | the template engine. |
| 7635 | |
| 7636 | ``` |
| 7637 | maestro/templates/musehub/ |
| 7638 | ├── base.html — main authenticated layout (extends nothing) |
| 7639 | ├── explore_base.html — public discover/trending layout (no auth, filter bar) |
| 7640 | ├── partials/ |
| 7641 | │ ├── navbar.html — global navigation bar (logo, search, user menu) |
| 7642 | │ ├── breadcrumbs.html — server-side breadcrumb trail from breadcrumb_data |
| 7643 | │ ├── repo_nav.html — repo identity card + includes repo_tabs.html |
| 7644 | │ └── repo_tabs.html — repo-level tab strip (Commits, Graph, PRs, Issues…) |
| 7645 | └── pages/ |
| 7646 | ├── global_search.html |
| 7647 | ├── profile.html |
| 7648 | ├── repo.html |
| 7649 | ├── commit.html |
| 7650 | ├── graph.html |
| 7651 | ├── pr_list.html |
| 7652 | ├── pr_detail.html |
| 7653 | ├── issue_list.html |
| 7654 | ├── issue_detail.html — issue body + threaded comment section (renderComments/submitComment/deleteComment/toggleReplyForm) |
| 7655 | ├── context.html |
| 7656 | ├── credits.html |
| 7657 | ├── embed.html — iframe-safe audio player (standalone, no base) |
| 7658 | ├── search.html |
| 7659 | ├── divergence.html |
| 7660 | ├── timeline.html |
| 7661 | ├── release_list.html |
| 7662 | ├── release_detail.html |
| 7663 | ├── sessions.html |
| 7664 | ├── session_detail.html |
| 7665 | ├── contour.html |
| 7666 | ├── tempo.html |
| 7667 | └── dynamics.html |
| 7668 | ``` |
| 7669 | |
| 7670 | **Template inheritance:** page templates extend `base.html` (or |
| 7671 | `explore_base.html`) using `{% extends %}` / `{% block %}`. Server-side |
| 7672 | dynamic data is injected via Jinja2's `{{ var | tojson }}` filter inside |
| 7673 | `<script>` blocks; large JavaScript sections containing template literals are |
| 7674 | wrapped in `{% raw %}...{% endraw %}` to prevent Jinja2 from parsing them. |
| 7675 | |
| 7676 | ### Navigation architecture |
| 7677 | |
| 7678 | Every MuseHub page renders a three-layer navigation stack: |
| 7679 | |
| 7680 | ``` |
| 7681 | ┌───────────────────────────────────────────────────────────┐ |
| 7682 | │ Global nav bar (.musehub-navbar) │ |
| 7683 | │ logo · search · explore · notifications · sign-out │ |
| 7684 | │ included by: base.html, explore_base.html │ |
| 7685 | ├───────────────────────────────────────────────────────────┤ |
| 7686 | │ Breadcrumb bar (.breadcrumb-bar) │ |
| 7687 | │ owner / repo / section / detail │ |
| 7688 | │ populated via: {% block breadcrumb %} (inline HTML) │ |
| 7689 | │ or: breadcrumb_data list (rendered by breadcrumbs.html) │ |
| 7690 | ├───────────────────────────────────────────────────────────┤ |
| 7691 | │ Repo tab strip (.repo-tabs) — repo-scoped pages only │ |
| 7692 | │ Commits · Graph · PRs · Issues · Releases · │ |
| 7693 | │ Sessions · Timeline · Analysis · Credits · Insights │ |
| 7694 | │ active tab: current_page template variable │ |
| 7695 | │ tab counts: loaded client-side by loadNavCounts() │ |
| 7696 | └───────────────────────────────────────────────────────────┘ |
| 7697 | ``` |
| 7698 | |
| 7699 | #### Global nav bar (`partials/navbar.html`) |
| 7700 | |
| 7701 | Included unconditionally from `base.html` and `explore_base.html`. Contains: |
| 7702 | |
| 7703 | - **Logo**: links to `/musehub/ui/explore` |
| 7704 | - **Search form**: `<form method="get" action="/musehub/ui/search">` — a plain |
| 7705 | HTML GET form, no JavaScript required for basic navigation. The `q` param is |
| 7706 | pre-populated by the global search handler for sharable URLs. |
| 7707 | - **Explore link**: direct link to the discover grid |
| 7708 | - **Notification bell**: `#nav-notif-badge` populated client-side by |
| 7709 | `loadNotifBadge()` in `musehub.js` |
| 7710 | - **Sign-out button**: `#signout-btn` shown client-side when a JWT is present |
| 7711 | - **Hamburger toggle**: CSS-only collapse on xs screens (checkbox trick) |
| 7712 | |
| 7713 | #### Breadcrumbs |
| 7714 | |
| 7715 | Two approaches coexist: |
| 7716 | |
| 7717 | 1. **Inline block** (most pages): child templates override |
| 7718 | `{% block breadcrumb %}` with hand-crafted anchor tags. Rendered inside |
| 7719 | `.breadcrumb-bar > .breadcrumb` by `base.html`. |
| 7720 | |
| 7721 | 2. **Data-driven** (commit detail and future pages): route handlers pass a |
| 7722 | `breadcrumb_data` list and `breadcrumbs.html` renders it: |
| 7723 | ```python |
| 7724 | breadcrumb_data = _breadcrumbs( |
| 7725 | (owner, f"/musehub/ui/{owner}"), |
| 7726 | (repo_slug, base_url), |
| 7727 | ("commits", base_url), |
| 7728 | (commit_id[:8], ""), # empty url → plain text (current page) |
| 7729 | ) |
| 7730 | ``` |
| 7731 | `_breadcrumbs()` is a thin helper in `maestro/api/routes/musehub/ui.py` |
| 7732 | that converts `(label, url)` tuples into a list of dicts. |
| 7733 | |
| 7734 | #### Repo tab strip (`partials/repo_tabs.html`) |
| 7735 | |
| 7736 | Shown on all repo-scoped pages via `repo_nav.html` (which also renders the |
| 7737 | repo identity card above the tabs). The active tab is determined by the |
| 7738 | `current_page` string passed from the route handler: |
| 7739 | |
| 7740 | | `current_page` value | Highlighted tab | |
| 7741 | |----------------------|-----------------| |
| 7742 | | `commits` | Commits | |
| 7743 | | `graph` | Graph | |
| 7744 | | `pulls` | Pull Requests | |
| 7745 | | `issues` | Issues | |
| 7746 | | `releases` | Releases | |
| 7747 | | `sessions` | Sessions | |
| 7748 | | `timeline` | Timeline | |
| 7749 | | `analysis` | Analysis | |
| 7750 | | `credits` | Credits | |
| 7751 | | `insights` | Insights | |
| 7752 | | `search` | Search | |
| 7753 | | `arrange` | Arrange | |
| 7754 | |
| 7755 | Tab count badges (`id="nav-pr-count"`, `id="nav-issue-count"`) are populated |
| 7756 | client-side by `loadNavCounts()` in `musehub.js` so route handlers remain |
| 7757 | unauthenticated. |
| 7758 | |
| 7759 | ### Design tokens (`tokens.css`) |
| 7760 | |
| 7761 | All component classes consume CSS custom properties exclusively — no hardcoded |
| 7762 | hex values in component or layout files. |
| 7763 | |
| 7764 | | Token group | Examples | |
| 7765 | |-------------|---------| |
| 7766 | | Background surfaces | `--bg-base`, `--bg-surface`, `--bg-overlay`, `--bg-hover` | |
| 7767 | | Borders | `--border-subtle`, `--border-default`, `--border-muted` | |
| 7768 | | Text | `--text-primary`, `--text-secondary`, `--text-muted` | |
| 7769 | | Accent / brand | `--color-accent`, `--color-success`, `--color-danger`, `--color-warning` | |
| 7770 | | Musical dimensions | `--dim-harmonic`, `--dim-rhythmic`, `--dim-melodic`, `--dim-structural`, `--dim-dynamic` | |
| 7771 | | Track palette | `--track-0` through `--track-7` (8 distinct colours) | |
| 7772 | | Spacing | `--space-1` (4 px) through `--space-12` (48 px) | |
| 7773 | | Typography | `--font-sans`, `--font-mono`, `--font-size-*`, `--font-weight-*` | |
| 7774 | | Radii | `--radius-sm` (4 px) through `--radius-full` (9999 px) | |
| 7775 | | Shadows | `--shadow-sm` through `--shadow-xl` | |
| 7776 | |
| 7777 | ### Musical dimension colours |
| 7778 | |
| 7779 | Each musical dimension has a primary colour and a muted/background variant used |
| 7780 | in badges, radar polygons, and diff heatmap bars. |
| 7781 | |
| 7782 | | Dimension | Token | Colour | Use | |
| 7783 | |-----------|-------|--------|-----| |
| 7784 | | Harmonic | `--dim-harmonic` | Blue `#388bfd` | Chord progressions, intervals | |
| 7785 | | Rhythmic | `--dim-rhythmic` | Green `#3fb950` | Time, meter, swing | |
| 7786 | | Melodic | `--dim-melodic` | Purple `#bc8cff` | Pitch contour, motifs | |
| 7787 | | Structural | `--dim-structural` | Orange `#f0883e` | Form, sections, repeats | |
| 7788 | | Dynamic | `--dim-dynamic` | Red `#f85149` | Velocity, loudness arcs | |
| 7789 | |
| 7790 | ### Component reference |
| 7791 | |
| 7792 | | Class | File | Description | |
| 7793 | |-------|------|-------------| |
| 7794 | | `.badge`, `.badge-open/closed/merged/clean/dirty` | `components.css` | Status badges | |
| 7795 | | `.btn`, `.btn-primary/danger/secondary/ghost` | `components.css` | Action buttons | |
| 7796 | | `.card` | `components.css` | Surface panel | |
| 7797 | | `.table` | `components.css` | Data table | |
| 7798 | | `.grid-auto`, `.grid-2`, `.grid-3` | `components.css` | Layout grids | |
| 7799 | | `.modal`, `.modal-panel`, `.modal-footer` | `components.css` | Dialog overlay | |
| 7800 | | `.tabs`, `.tab-list`, `.tab` | `components.css` | Tab navigation | |
| 7801 | | `[data-tooltip]` | `components.css` | CSS-only tooltip | |
| 7802 | | `.file-icon .icon-mid/mp3/wav/json/webp/xml/abc` | `icons.css` | File-type icons | |
| 7803 | | `.music-icon .icon-key/tempo/dynamics/…` | `icons.css` | Musical concept icons | |
| 7804 | | `.piano-roll` | `music.css` | Multi-track note grid | |
| 7805 | | `.waveform` | `music.css` | Audio waveform container | |
| 7806 | | `.radar-chart`, `.radar-polygon-*` | `music.css` | Dimension radar chart | |
| 7807 | | `.contrib-graph`, `.contrib-day` | `music.css` | Contribution heatmap | |
| 7808 | | `.diff-heatmap`, `.diff-dim-bar-*` | `music.css` | Commit diff visualisation | |
| 7809 | |
| 7810 | ### Responsive breakpoints |
| 7811 | |
| 7812 | | Name | Width | Override behaviour | |
| 7813 | |------|-------|--------------------| |
| 7814 | | xs | < 480 px | Single-column layout; breadcrumb hidden | |
| 7815 | | sm | 480–767 px | Single-column layout | |
| 7816 | | md | 768–1023 px | Sidebar narrows to 200 px | |
| 7817 | | lg | ≥ 1024 px | Full layout (base styles) | |
| 7818 | | xl | ≥ 1280 px | Container-wide expands to 1440 px | |
| 7819 | |
| 7820 | Minimum supported width: **375 px** (iPhone SE and equivalent Android devices). |
| 7821 | |
| 7822 | ### Future theme support |
| 7823 | |
| 7824 | The design system is dark-theme by default. All colours are defined as CSS |
| 7825 | custom properties on `:root`. A future light theme can be implemented by |
| 7826 | adding a `[data-theme="light"]` selector block to `tokens.css` that overrides |
| 7827 | the `--bg-*`, `--text-*`, and `--color-*` tokens — no changes to component |
| 7828 | files required. |
| 7829 | |
| 7830 | ### Implementation |
| 7831 | |
| 7832 | | Layer | File | What it does | |
| 7833 | |-------|------|-------------| |
| 7834 | | Design tokens | `maestro/templates/musehub/static/tokens.css` | CSS custom properties | |
| 7835 | | Components | `maestro/templates/musehub/static/components.css` | Reusable classes | |
| 7836 | | Layout | `maestro/templates/musehub/static/layout.css` | Grid, header, breakpoints | |
| 7837 | | Icons | `maestro/templates/musehub/static/icons.css` | File-type + music concept icons | |
| 7838 | | Music UI | `maestro/templates/musehub/static/music.css` | Piano roll, radar, heatmap | |
| 7839 | | Static mount | `maestro/main.py` | `app.mount("/musehub/static", StaticFiles(...))` | |
| 7840 | | Page helper | `maestro/api/routes/musehub/ui.py` | `_page()` links all five CSS files | |
| 7841 | | Tests | `tests/test_musehub_ui.py` | `test_design_tokens_css_served`, `test_components_css_served`, `test_repo_page_uses_design_system`, `test_responsive_meta_tag_present_*` | |
| 7842 | |
| 7843 | --- |
| 7844 | |
| 7845 | ## Emotion Map Page (issue #227) |
| 7846 | |
| 7847 | The emotion map page visualises four emotional dimensions — **energy**, **valence**, **tension**, and **darkness** — across time within a composition and across its commit history. |
| 7848 | |
| 7849 | ### Motivation |
| 7850 | |
| 7851 | A film scorer needs to verify that the emotional arc of their composition matches the scene's emotional beats across the full commit history. Running `muse emotion-diff` between individual commit pairs is manual and error-prone. The emotion map provides a single-glance visual overview. |
| 7852 | |
| 7853 | ### Route |
| 7854 | |
| 7855 | ``` |
| 7856 | GET /musehub/ui/{repo_id}/analysis/{ref}/emotion |
| 7857 | ``` |
| 7858 | |
| 7859 | Returns a static HTML shell (no JWT required). JavaScript fetches the JSON emotion map from the authed API and renders: |
| 7860 | |
| 7861 | - **Evolution chart** — SVG line chart of all four dimensions sampled beat-by-beat within `ref`. |
| 7862 | - **Trajectory chart** — Per-commit summary vectors across the 5 most recent ancestor commits plus HEAD. |
| 7863 | - **Drift list** — Euclidean distance in emotion space between consecutive commits, with the dominant-change axis identified. |
| 7864 | - **Narrative** — Auto-generated text describing the emotional journey. |
| 7865 | - **Track / section filters** — Reload the data with instrument or section scope. |
| 7866 | |
| 7867 | ### JSON Endpoint |
| 7868 | |
| 7869 | ``` |
| 7870 | GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/emotion-map |
| 7871 | ``` |
| 7872 | |
| 7873 | Requires JWT Bearer auth. Returns `EmotionMapResponse` (see type contracts). Query params: `?track=` and `?section=`. |
| 7874 | |
| 7875 | ### Emotion Axes (all 0.0–1.0) |
| 7876 | |
| 7877 | | Axis | Description | |
| 7878 | |------|-------------| |
| 7879 | | `energy` | Compositional drive / activity level | |
| 7880 | | `valence` | Brightness / positivity (0=dark, 1=bright) | |
| 7881 | | `tension` | Harmonic and rhythmic tension | |
| 7882 | | `darkness` | Brooding / ominous quality (inversely correlated with valence) | |
| 7883 | |
| 7884 | Note: `valence` here is re-normalised to [0, 1] relative to the `EmotionData` model (which uses –1…+1) so all four axes share the same visual scale in charts. |
| 7885 | |
| 7886 | ### Implementation |
| 7887 | |
| 7888 | | Layer | File | What it does | |
| 7889 | |-------|------|-------------| |
| 7890 | | Pydantic models | `maestro/models/musehub_analysis.py` | `EmotionVector`, `EmotionMapPoint`, `CommitEmotionSnapshot`, `EmotionDrift`, `EmotionMapResponse` | |
| 7891 | | Service | `maestro/services/musehub_analysis.py` | `compute_emotion_map()` — builds evolution, trajectory, drift, and narrative | |
| 7892 | | Route (JSON) | `maestro/api/routes/musehub/analysis.py` | `GET .../emotion-map` — registered before `/{dimension}` to avoid parameter capture | |
| 7893 | | Route (UI) | `maestro/api/routes/musehub/ui.py` | `emotion_map_page()` — static HTML shell at `.../analysis/{ref}/emotion` | |
| 7894 | | Tests | `tests/test_musehub_ui.py` | Page renders, no-auth, chart JS, filters, JSON fields, trajectory, drift | |
| 7895 | | Tests | `tests/test_musehub_analysis.py` | Service unit tests + HTTP endpoint tests | |
| 7896 | |
| 7897 | ### Muse VCS Considerations |
| 7898 | |
| 7899 | - **Affected operations:** Maps conceptually to `muse emotion-diff` — the page shows what `emotion-diff` would show for all pairs in recent history. |
| 7900 | - **Reproducibility impact:** Stub data is deterministic for a given `ref` (seeded by MD5 of the ref string). Full MIDI-analysis-based inference will be model-dependent once Storpheus exposes an emotion introspection route. |
| 7901 | |
| 7902 | --- |
| 7903 | |
| 7904 | ## Muse Hub — Tree Browser (issue #204) |
| 7905 | |
| 7906 | **Purpose:** GitHub-style directory tree browser for navigating the muse-work |
| 7907 | file structure stored in a Muse Hub repo. Musicians use it to find specific |
| 7908 | MIDI tracks, rendered MP3s, metadata files, and preview images without using |
| 7909 | the CLI. |
| 7910 | |
| 7911 | ### URL Pattern |
| 7912 | |
| 7913 | | Route | Description | |
| 7914 | |-------|-------------| |
| 7915 | | `GET /musehub/ui/{owner}/{repo_slug}/tree/{ref}` | Root directory listing | |
| 7916 | | `GET /musehub/ui/{owner}/{repo_slug}/tree/{ref}/{path}` | Subdirectory listing | |
| 7917 | | `GET /api/v1/musehub/repos/{repo_id}/tree/{ref}?owner=&repo_slug=` | JSON root listing | |
| 7918 | | `GET /api/v1/musehub/repos/{repo_id}/tree/{ref}/{path}?owner=&repo_slug=` | JSON subdirectory listing | |
| 7919 | |
| 7920 | **Auth:** No JWT required for HTML shell (public repos). JSON API enforces auth |
| 7921 | for private repos. |
| 7922 | |
| 7923 | ### Ref Resolution |
| 7924 | |
| 7925 | `{ref}` can be: |
| 7926 | - A **branch name** (e.g. `main`, `feature/groove`) — resolved via `musehub_branches`. |
| 7927 | - A **full commit ID** — resolved via `musehub_commits`. |
| 7928 | |
| 7929 | An unknown ref returns HTTP 404. This check is performed by |
| 7930 | `musehub_repository.resolve_ref_for_tree()`. |
| 7931 | |
| 7932 | ### Tree Reconstruction |
| 7933 | |
| 7934 | The tree is reconstructed at request time from all `musehub_objects` stored |
| 7935 | under the repo. No snapshot manifest table is required — the directory |
| 7936 | structure is derived by splitting object `path` fields on `/`. |
| 7937 | |
| 7938 | `musehub_repository.list_tree()` algorithm: |
| 7939 | 1. Fetch all objects for the repo ordered by path. |
| 7940 | 2. Strip the `dir_path` prefix from each object's path. |
| 7941 | 3. If the remainder has no `/` → file entry at this level. |
| 7942 | 4. If the remainder has a `/` → directory entry (first segment only, deduplicated). |
| 7943 | 5. Return dirs first (alphabetical), then files (alphabetical). |
| 7944 | |
| 7945 | ### Response Shape (`TreeListResponse`) |
| 7946 | |
| 7947 | ```json |
| 7948 | { |
| 7949 | "owner": "gabriel", |
| 7950 | "repoSlug": "summer-groove", |
| 7951 | "ref": "main", |
| 7952 | "dirPath": "", |
| 7953 | "entries": [ |
| 7954 | { "type": "dir", "name": "tracks", "path": "tracks", "sizeBytes": null }, |
| 7955 | { "type": "file", "name": "cover.webp", "path": "cover.webp", "sizeBytes": 4096 }, |
| 7956 | { "type": "file", "name": "metadata.json","path": "metadata.json", "sizeBytes": 512 } |
| 7957 | ] |
| 7958 | } |
| 7959 | ``` |
| 7960 | |
| 7961 | ### File-Type Icons |
| 7962 | |
| 7963 | | Extension | Icon | Symbol code | |
| 7964 | |-----------|------|-------------| |
| 7965 | | `.mid` / `.midi` | Piano | `🎹` | |
| 7966 | | `.mp3` / `.wav` / `.ogg` | Waveform | `🎵` | |
| 7967 | | `.json` | Braces | `{}` | |
| 7968 | | `.webp` / `.png` / `.jpg` | Photo | `🖼` | |
| 7969 | | Other | Generic file | `📄` | |
| 7970 | |
| 7971 | ### Implementation |
| 7972 | |
| 7973 | | Layer | File | |
| 7974 | |-------|------| |
| 7975 | | Models | `maestro/models/musehub.py` — `TreeEntryResponse`, `TreeListResponse` | |
| 7976 | | Repository | `maestro/services/musehub_repository.py` — `resolve_ref_for_tree()`, `list_tree()` | |
| 7977 | | JSON API | `maestro/api/routes/musehub/objects.py` — `list_tree_root()`, `list_tree_subdir()` | |
| 7978 | | HTML routes | `maestro/api/routes/musehub/ui.py` — `tree_page()`, `tree_subdir_page()` | |
| 7979 | | Template | `maestro/templates/musehub/pages/tree.html` | |
| 7980 | | Tests | `tests/test_musehub_ui.py` — `test_tree_*` (6 tests) | |
| 7981 | |
| 7982 | --- |
| 7983 | |
| 7984 | ## Audio Player — Listen Page (issue #211) |
| 7985 | |
| 7986 | ### Motivation |
| 7987 | |
| 7988 | The bare `<audio>` element provides no waveform feedback, no A/B looping, and |
| 7989 | no speed control. Musicians performing critical listening on MuseHub need: |
| 7990 | |
| 7991 | - Visual waveform for precise seek-by-region. |
| 7992 | - A/B loop to isolate a phrase and replay it at will. |
| 7993 | - Playback speed control (half-time, double-time, etc.) for transcription and |
| 7994 | arrangement work. |
| 7995 | |
| 7996 | ### URL Pattern |
| 7997 | |
| 7998 | ``` |
| 7999 | GET /musehub/ui/{owner}/{repo_slug}/listen/{ref} |
| 8000 | GET /musehub/ui/{owner}/{repo_slug}/listen/{ref}/{path:path} |
| 8001 | ``` |
| 8002 | |
| 8003 | The first form lists all audio objects at `ref` and auto-loads the first track |
| 8004 | with an in-page track switcher. The second form deep-links to a specific audio |
| 8005 | file (stem, MIDI export, full mix) by its object path. |
| 8006 | |
| 8007 | ### Waveform Engine |
| 8008 | |
| 8009 | Wavesurfer.js is **vendored locally** at: |
| 8010 | |
| 8011 | ``` |
| 8012 | maestro/templates/musehub/static/vendor/wavesurfer.min.js |
| 8013 | ``` |
| 8014 | |
| 8015 | Served at `/musehub/static/vendor/wavesurfer.min.js` via FastAPI `StaticFiles`. |
| 8016 | No external CDN is used — the library is a self-contained, lightweight |
| 8017 | canvas-based implementation that renders waveform bars using pseudo-random |
| 8018 | peaks seeded by audio duration (stable visual fingerprint per track). |
| 8019 | |
| 8020 | A `_generatePeaks()` method using Web Audio API `AudioBuffer` could be dropped |
| 8021 | in later to replace the seeded peaks with true amplitude data. |
| 8022 | |
| 8023 | ### Audio Player Component (`audio-player.js`) |
| 8024 | |
| 8025 | A thin wrapper around WaveSurfer that wires up: |
| 8026 | |
| 8027 | | UI Element | ID | Behaviour | |
| 8028 | |------------|----|-----------| |
| 8029 | | Waveform canvas | `#waveform` | Click to seek; Shift+drag for A/B loop | |
| 8030 | | Play/pause button | `#play-btn` | Toggle playback | |
| 8031 | | Current time | `#time-cur` | Updated on every `timeupdate` event | |
| 8032 | | Duration | `#time-dur` | Populated on `ready` | |
| 8033 | | Speed selector | `#speed-sel` | Options: 0.5x 0.75x 1x 1.25x 1.5x 2x | |
| 8034 | | Volume slider | `#vol-slider` | Range 0.0–1.0 | |
| 8035 | | Loop info | `#loop-info` | Shows `Loop: MM:SS – MM:SS` when active | |
| 8036 | | Clear loop button | `#loop-clear-btn` | Hidden until a loop region is set | |
| 8037 | |
| 8038 | **Keyboard shortcuts:** |
| 8039 | |
| 8040 | | Key | Action | |
| 8041 | |-----|--------| |
| 8042 | | `Space` | Play / pause | |
| 8043 | | `←` / `→` | Seek ± 5 seconds | |
| 8044 | | `L` | Clear A/B loop region | |
| 8045 | |
| 8046 | ### A/B Loop |
| 8047 | |
| 8048 | Shift+drag on the waveform canvas sets the loop region. While the region is |
| 8049 | active the `_audio.currentTime` is reset to `loopStart` whenever it reaches |
| 8050 | `loopEnd`. A coloured overlay with boundary markers is drawn on the canvas. |
| 8051 | `clearRegion()` removes the loop and hides the loop-info badge. |
| 8052 | |
| 8053 | ### Track List (full-mix view) |
| 8054 | |
| 8055 | On `GET /{owner}/{repo_slug}/listen/{ref}`, the page fetches: |
| 8056 | |
| 8057 | ``` |
| 8058 | GET /api/v1/musehub/repos/{repo_id}/objects?ref={ref} |
| 8059 | ``` |
| 8060 | |
| 8061 | and filters for audio extensions (`mp3 wav ogg m4a flac aiff aif`). All |
| 8062 | matching objects are rendered as a clickable track list. Clicking a row loads |
| 8063 | that track into the WaveSurfer instance and begins playback. |
| 8064 | |
| 8065 | ### Single-Track View |
| 8066 | |
| 8067 | On `GET /{owner}/{repo_slug}/listen/{ref}/{path}`, the page fetches the same |
| 8068 | objects endpoint and matches the first object whose `path` matches (or ends |
| 8069 | with) the given `path` segment. If no match is found, an inline error message |
| 8070 | is shown — no hard 404, allowing the user to try other refs. |
| 8071 | |
| 8072 | ### No CDN Policy |
| 8073 | |
| 8074 | All external script tags are forbidden. The vendor script is served from the |
| 8075 | same origin as all other MuseHub assets. This keeps the page functional in |
| 8076 | air-gapped DAW network environments and avoids CORS complexity. |
| 8077 | |
| 8078 | ### Implementation |
| 8079 | |
| 8080 | | Layer | File | |
| 8081 | |-------|------| |
| 8082 | | WaveSurfer | `maestro/templates/musehub/static/vendor/wavesurfer.min.js` | |
| 8083 | | AudioPlayer | `maestro/templates/musehub/static/audio-player.js` | |
| 8084 | | HTML template | `maestro/templates/musehub/pages/listen.html` | |
| 8085 | | HTML routes | `maestro/api/routes/musehub/ui.py` — `listen_page()`, `listen_track_page()` | |
| 8086 | | Tests | `tests/test_musehub_ui.py` — `test_listen_*` (12 tests) | |
| 8087 | |
| 8088 | --- |
| 8089 | |
| 8090 | ## Muse Hub — Arrangement Matrix Page (issue #212) |
| 8091 | |
| 8092 | **Purpose:** Provide a bird's-eye orchestration view — which instruments play in which sections — so producers can evaluate arrangement density without downloading or listening to tracks. This is the most useful single page for an AI orchestration agent before generating a new instrument part. |
| 8093 | |
| 8094 | ### Routes |
| 8095 | |
| 8096 | | Route | Auth | Description | |
| 8097 | |-------|------|-------------| |
| 8098 | | `GET /musehub/ui/{owner}/{repo_slug}/arrange/{ref}` | None (HTML shell) | Interactive instrument × section density grid | |
| 8099 | | `GET /api/v1/musehub/repos/{repo_id}/arrange/{ref}` | Optional JWT | `ArrangementMatrixResponse` JSON | |
| 8100 | |
| 8101 | ### Grid Layout |
| 8102 | |
| 8103 | - **Y-axis (rows):** instruments — bass, keys, guitar, drums, lead, pads |
| 8104 | - **X-axis (columns):** sections — intro, verse_1, chorus, bridge, outro |
| 8105 | - **Cell:** colour-coded by note density; silent cells rendered in dark background |
| 8106 | - **Cell click:** navigates to the piano roll (motif browser filtered by instrument + section) |
| 8107 | - **Hover tooltip:** note count, beat range, MIDI pitch range |
| 8108 | - **Row summaries:** per-instrument total notes, active section count, mean density bar |
| 8109 | - **Column summaries:** per-section total notes, active instrument count |
| 8110 | |
| 8111 | ### Data Model |
| 8112 | |
| 8113 | ```python |
| 8114 | ArrangementMatrixResponse( |
| 8115 | repo_id = "...", |
| 8116 | ref = "HEAD", |
| 8117 | instruments = ["bass", "keys", "guitar", "drums", "lead", "pads"], |
| 8118 | sections = ["intro", "verse_1", "chorus", "bridge", "outro"], |
| 8119 | cells = [ArrangementCellData(...)], # 6 × 5 = 30 cells, row-major |
| 8120 | row_summaries = [ArrangementRowSummary(...)], |
| 8121 | column_summaries = [ArrangementColumnSummary(...)], |
| 8122 | total_beats = 128.0, |
| 8123 | ) |
| 8124 | ``` |
| 8125 | |
| 8126 | ### Implementation |
| 8127 | |
| 8128 | | Layer | File | |
| 8129 | |-------|------| |
| 8130 | | Models | `maestro/models/musehub.py` — `ArrangementCellData`, `ArrangementRowSummary`, `ArrangementColumnSummary`, `ArrangementMatrixResponse` | |
| 8131 | | Service | `maestro/services/musehub_analysis.py` — `compute_arrangement_matrix()` | |
| 8132 | | JSON API | `maestro/api/routes/musehub/repos.py` — `get_arrangement_matrix()` | |
| 8133 | | HTML route | `maestro/api/routes/musehub/ui.py` — `arrange_page()` | |
| 8134 | | Template | `maestro/templates/musehub/pages/arrange.html` | |
| 8135 | | Nav tab | `maestro/templates/musehub/partials/repo_tabs.html` — `current_page = "arrange"` | |
| 8136 | | Tests | `tests/test_musehub_ui.py` — `test_arrange_*` (8 tests), `tests/test_musehub_repos.py` — `test_arrange_*` (8 tests) | |
| 8137 | |
| 8138 | --- |
| 8139 | |
| 8140 | ## MuseHub Compare View (issue #217) |
| 8141 | |
| 8142 | ### Motivation |
| 8143 | |
| 8144 | There was no way to compare two branches or commits on MuseHub. GitHub's compare view (`/compare/{base}...{head}`) shows a code diff. MuseHub's compare view shows a multi-dimensional musical diff: a radar chart of divergence scores, a side-by-side piano roll, an emotion diff summary, and the list of commits unique to head. |
| 8145 | |
| 8146 | ### Route |
| 8147 | |
| 8148 | ``` |
| 8149 | GET /musehub/ui/{owner}/{repo_slug}/compare/{base}...{head} |
| 8150 | ``` |
| 8151 | |
| 8152 | The `{base}...{head}` segment is a single path parameter (`{refs}`) parsed server-side by splitting on `...`. Both `base` and `head` can be branch names, tags, or commit SHAs. Returns 404 when the `...` separator is absent or either segment is empty. |
| 8153 | |
| 8154 | Content negotiation: `?format=json` or `Accept: application/json` returns the full `CompareResponse` JSON. |
| 8155 | |
| 8156 | ### JSON API Endpoint |
| 8157 | |
| 8158 | ``` |
| 8159 | GET /api/v1/musehub/repos/{repo_id}/compare?base=X&head=Y |
| 8160 | ``` |
| 8161 | |
| 8162 | Returns `CompareResponse` with: |
| 8163 | - `dimensions` — five per-dimension Jaccard divergence scores (melodic, harmonic, rhythmic, structural, dynamic) |
| 8164 | - `overallScore` — mean of the five scores in [0.0, 1.0] |
| 8165 | - `commonAncestor` — most recent common ancestor commit ID, or `null` for disjoint histories |
| 8166 | - `commits` — commits reachable from head but not from base (newest first) |
| 8167 | - `emotionDiff` — energy/valence/tension/darkness deltas (`head − base`) |
| 8168 | - `createPrUrl` — URL to open a pull request from this comparison |
| 8169 | |
| 8170 | Raises 422 if either ref has no commits. Raises 404 if the repo is not found. |
| 8171 | |
| 8172 | ### Compare Page Features |
| 8173 | |
| 8174 | | Feature | Description | |
| 8175 | |---------|-------------| |
| 8176 | | Five-axis radar chart | Per-dimension divergence scores visualised as a pentagon, colour-coded by level (NONE/LOW/MED/HIGH) | |
| 8177 | | Dimension detail panels | Clickable cards that expand to show description and per-branch commit counts | |
| 8178 | | Piano roll comparison | Deterministic note grid derived from SHA bytes; green = added in head, red = removed | |
| 8179 | | Audio A/B toggle | Button pair to queue base or head audio in the player | |
| 8180 | | Emotion diff | Side-by-side bar charts for energy, valence, tension, darkness with delta labels | |
| 8181 | | Commit list | Commits unique to head with short SHA links, author, and timestamp | |
| 8182 | | Create PR button | Links to `/musehub/ui/{owner}/{slug}/pulls/new?base=X&head=Y` | |
| 8183 | |
| 8184 | ### Emotion Vector Algorithm |
| 8185 | |
| 8186 | Each commit's emotion vector is derived deterministically from four non-overlapping 4-hex-char windows of the commit SHA: |
| 8187 | - `valence` = `int(sha[0:4], 16) / 0xFFFF` |
| 8188 | - `energy` = `int(sha[4:8], 16) / 0xFFFF` |
| 8189 | - `tension` = `int(sha[8:12], 16) / 0xFFFF` |
| 8190 | - `darkness` = `int(sha[12:16], 16) / 0xFFFF` |
| 8191 | |
| 8192 | The emotion diff is `mean(head commits) − mean(base commits)` per axis, clamped to [−1.0, 1.0]. |
| 8193 | |
| 8194 | ### Divergence Reuse |
| 8195 | |
| 8196 | The compare endpoint reuses the existing `musehub_divergence.compute_hub_divergence()` engine. Refs are resolved as branch names; the divergence engine computes Jaccard scores across commit message keyword classification. |
| 8197 | |
| 8198 | |
| 8199 | ## MuseHub Render Pipeline — Auto-Generated Artifacts on Push |
| 8200 | |
| 8201 | ### Overview |
| 8202 | |
| 8203 | Every successful push to MuseHub triggers a background render pipeline that |
| 8204 | automatically generates two artifact types for each MIDI file in the commit: |
| 8205 | |
| 8206 | 1. **Piano-roll PNG** — a server-side pixel-art rendering of the MIDI timeline, |
| 8207 | colour-coded by MIDI channel (bass = green, keys = blue, lead = orange, …). |
| 8208 | 2. **MP3 stub** — a copy of the MIDI file until the Storpheus `POST /render` |
| 8209 | endpoint ships; labelled `stubbed=True` in the render job record. |
| 8210 | |
| 8211 | Both artifact types are stored as `musehub_objects` rows linked to the repo, |
| 8212 | and their object IDs are recorded in the `musehub_render_jobs` row for the |
| 8213 | commit. |
| 8214 | |
| 8215 | ### Render Job Lifecycle |
| 8216 | |
| 8217 | ``` |
| 8218 | push received → ingest_push() → DB commit → BackgroundTasks |
| 8219 | ↓ |
| 8220 | trigger_render_background() |
| 8221 | ↓ |
| 8222 | status: pending → rendering |
| 8223 | ↓ |
| 8224 | discover MIDI objects in push |
| 8225 | ↓ |
| 8226 | for each MIDI: |
| 8227 | - render_piano_roll() → PNG |
| 8228 | - _make_stub_mp3() → MIDI copy |
| 8229 | - store as musehub_objects rows |
| 8230 | ↓ |
| 8231 | status: complete | failed |
| 8232 | ``` |
| 8233 | |
| 8234 | **Idempotency:** if a render job already exists for `(repo_id, commit_id)`, |
| 8235 | the pipeline exits immediately without creating a duplicate. |
| 8236 | |
| 8237 | **Failure isolation:** render errors are caught, logged, and stored in |
| 8238 | `job.error_message`; they never propagate to the push response. |
| 8239 | |
| 8240 | ### Render Status API |
| 8241 | |
| 8242 | ``` |
| 8243 | GET /api/v1/musehub/repos/{repo_id}/commits/{sha}/render-status |
| 8244 | ``` |
| 8245 | |
| 8246 | Returns a `RenderStatusResponse`: |
| 8247 | |
| 8248 | ```json |
| 8249 | { |
| 8250 | "commitId": "abc123...", |
| 8251 | "status": "complete", |
| 8252 | "midiCount": 2, |
| 8253 | "mp3ObjectIds": ["sha256:...", "sha256:..."], |
| 8254 | "imageObjectIds": ["sha256:...", "sha256:..."], |
| 8255 | "errorMessage": null |
| 8256 | } |
| 8257 | ``` |
| 8258 | |
| 8259 | Status values: `pending` | `rendering` | `complete` | `failed` | `not_found`. |
| 8260 | |
| 8261 | `not_found` is returned (not 404) when no render job exists for the given |
| 8262 | commit — this lets callers distinguish "never pushed" from "not yet rendered" |
| 8263 | without branching on HTTP status codes. |
| 8264 | |
| 8265 | ### Piano Roll Renderer |
| 8266 | |
| 8267 | **Module:** `maestro/services/musehub_piano_roll_renderer.py` |
| 8268 | |
| 8269 | Pure-Python MIDI-to-PNG renderer — zero external image library dependency. |
| 8270 | Uses `mido` (already a project dependency) to parse MIDI and stdlib `zlib` |
| 8271 | + `struct` to encode a minimal PNG. |
| 8272 | |
| 8273 | Image layout: |
| 8274 | - Width: up to 1920 px (proportional to MIDI duration). |
| 8275 | - Height: 256 px (128 MIDI pitches × 2 px per row). |
| 8276 | - Background: dark charcoal. |
| 8277 | - Octave boundaries: lighter horizontal rules at every C note. |
| 8278 | - Notes: coloured rectangles, colour-coded by MIDI channel. |
| 8279 | |
| 8280 | Graceful degradation: invalid or empty MIDI produces a blank canvas PNG |
| 8281 | with `stubbed=True` — callers always receive a valid file. |
| 8282 | |
| 8283 | **Note:** WebP output requires Pillow (not yet a project dependency). The |
| 8284 | renderer currently emits PNG with a `.png` extension. WebP conversion is |
| 8285 | a planned follow-up. |
| 8286 | |
| 8287 | ### MP3 Rendering (Stub) |
| 8288 | |
| 8289 | Storpheus `POST /render` (MIDI-in → audio-out) is not yet deployed. Until |
| 8290 | it ships, the MIDI file is copied verbatim to `renders/<commit_short>_<stem>.mp3`. |
| 8291 | The render job records this as `mp3_object_ids` entries regardless. When the |
| 8292 | endpoint is available, replace `_make_stub_mp3` in |
| 8293 | `maestro/services/musehub_render_pipeline.py` with a real HTTP call. |
| 8294 | |
| 8295 | ### Implementation |
| 8296 | |
| 8297 | | Layer | File | |
| 8298 | |-------|------| |
| 8299 | | Models | `maestro/models/musehub.py` — `EmotionDiffResponse`, `CompareResponse` | |
| 8300 | | API endpoint | `maestro/api/routes/musehub/repos.py` — `compare_refs()`, `_compute_emotion_diff()`, `_derive_emotion_vector()` | |
| 8301 | | UI route | `maestro/api/routes/musehub/ui.py` — `compare_page()` | |
| 8302 | | Template | `maestro/templates/musehub/pages/compare.html` | |
| 8303 | | Tests | `tests/test_musehub_ui.py` — `test_compare_*` (10 tests) | |
| 8304 | | Tests | `tests/test_musehub_repos.py` — `test_compare_*` (4 tests) | |
| 8305 | | Type contracts | `docs/reference/type_contracts.md` — `EmotionDiffResponse`, `CompareResponse` | |
| 8306 | |
| 8307 | --- |
| 8308 | |
| 8309 | |
| 8310 | | DB model | `maestro/db/musehub_models.py` — `MusehubRenderJob` | |
| 8311 | | Piano roll | `maestro/services/musehub_piano_roll_renderer.py` | |
| 8312 | | Pipeline | `maestro/services/musehub_render_pipeline.py` | |
| 8313 | | Trigger | `maestro/api/routes/musehub/sync.py` — `push()` adds background task | |
| 8314 | | Status API | `maestro/api/routes/musehub/repos.py` — `get_commit_render_status()` | |
| 8315 | | Response model | `maestro/models/musehub.py` — `RenderStatusResponse` | |
| 8316 | | Migration | `alembic/versions/0001_consolidated_schema.py` — `musehub_render_jobs` | |
| 8317 | | Tests | `tests/test_musehub_render.py` — 17 tests | |
| 8318 | |
| 8319 | --- |
| 8320 | |
| 8321 | --- |
| 8322 | |
| 8323 | ## Piano Roll Renderer |
| 8324 | |
| 8325 | ### Overview |
| 8326 | |
| 8327 | The MuseHub piano roll provides an interactive Canvas-based MIDI visualisation |
| 8328 | accessible from any MIDI artifact stored in a Muse Hub repo. It is split into |
| 8329 | a server-side parser and a client-side renderer. |
| 8330 | |
| 8331 | ### Architecture |
| 8332 | |
| 8333 | ``` |
| 8334 | Browser Maestro API |
| 8335 | │ │ |
| 8336 | │ GET /musehub/ui/{owner}/{slug}/ │ |
| 8337 | │ piano-roll/{ref} │ |
| 8338 | │ ──────────────────────────────────► │ piano_roll_page() |
| 8339 | │ ◄────────────────────────────────── piano_roll.html shell |
| 8340 | │ │ |
| 8341 | │ [JS] apiFetch /objects?limit=500 │ |
| 8342 | │ ──────────────────────────────────► │ list_objects() |
| 8343 | │ ◄────────────────────────────────── ObjectMetaListResponse |
| 8344 | │ │ |
| 8345 | │ [JS] apiFetch /objects/{id}/ │ |
| 8346 | │ parse-midi │ |
| 8347 | │ ──────────────────────────────────► │ parse_midi_object() |
| 8348 | │ │ → parse_midi_bytes() |
| 8349 | │ ◄────────────────────────────────── MidiParseResult (JSON) |
| 8350 | │ │ |
| 8351 | │ [JS] PianoRoll.render(midi, el) │ |
| 8352 | │ Canvas draw loop │ |
| 8353 | ``` |
| 8354 | |
| 8355 | ### Server-Side Parser (`musehub_midi_parser.py`) |
| 8356 | |
| 8357 | `parse_midi_bytes(data: bytes) → MidiParseResult` |
| 8358 | |
| 8359 | - Uses the `mido` library to read Standard MIDI Files (types 0, 1, 2). |
| 8360 | - Converts all tick offsets to quarter-note beats using `ticks_per_beat`. |
| 8361 | - Handles note-on / note-off pairing (including velocity-0 note-off shorthand). |
| 8362 | - Closes dangling note-ons at end-of-track with minimum duration. |
| 8363 | - Extracts `set_tempo`, `time_signature`, and `track_name` meta messages. |
| 8364 | - Returns `MidiParseResult` — a `TypedDict` registered in `type_contracts.md`. |
| 8365 | |
| 8366 | ### Client-Side Renderer (`piano-roll.js`) |
| 8367 | |
| 8368 | `PianoRoll.render(midiParseResult, containerElement, options)` |
| 8369 | |
| 8370 | Renders a `<canvas>` element with: |
| 8371 | |
| 8372 | | Feature | Implementation | |
| 8373 | |---------|---------------| |
| 8374 | | Pitch axis | Piano keyboard strip (left margin, white/black key shading) | |
| 8375 | | Time axis | Beat grid with measure markers, auto-density by zoom level | |
| 8376 | | Note rectangles | Per-track colour from design palette; opacity = velocity / 127 | |
| 8377 | | Zoom | Horizontal (`px/beat`) and vertical (`px/pitch row`) range sliders | |
| 8378 | | Pan | Click-drag on canvas; `panX` in beats, `panY` in pitch rows | |
| 8379 | | Tooltip | Hover shows pitch name, MIDI pitch, velocity, beat, duration | |
| 8380 | | Track filter | `<select>` — all tracks or single track | |
| 8381 | | Device pixel ratio | Renders at native DPR for crisp display on HiDPI screens | |
| 8382 | |
| 8383 | ### Routes |
| 8384 | |
| 8385 | | Method | Path | Handler | Description | |
| 8386 | |--------|------|---------|-------------| |
| 8387 | | `GET` | `/musehub/ui/{owner}/{slug}/piano-roll/{ref}` | `piano_roll_page` | All MIDI tracks at ref | |
| 8388 | | `GET` | `/musehub/ui/{owner}/{slug}/piano-roll/{ref}/{path}` | `piano_roll_track_page` | Single MIDI file | |
| 8389 | | `GET` | `/api/v1/musehub/repos/{repo_id}/objects/{id}/parse-midi` | `parse_midi_object` | MIDI-to-JSON endpoint | |
| 8390 | |
| 8391 | ### Static Asset |
| 8392 | |
| 8393 | `/musehub/static/piano-roll.js` — served by the existing `StaticFiles` mount |
| 8394 | at `maestro/main.py`. No rebuild required. |
| 8395 | |
| 8396 | ### Navigation Context |
| 8397 | |
| 8398 | Add `current_page: "piano-roll"` to the template context when linking from |
| 8399 | other pages (tree browser, commit detail, blob viewer). |
| 8400 | |
| 8401 | |
| 8402 | ## Muse Hub — Blob Viewer (issue #205) |
| 8403 | |
| 8404 | **Purpose:** Music-aware file blob viewer that renders individual files from a |
| 8405 | Muse repo with file-type-specific treatment. Musicians can view MIDI as a |
| 8406 | piano roll preview, stream audio files directly in the browser, and inspect |
| 8407 | JSON/XML metadata with syntax highlighting — without downloading files first. |
| 8408 | |
| 8409 | ### URL Pattern |
| 8410 | |
| 8411 | | Route | Description | |
| 8412 | |-------|-------------| |
| 8413 | | `GET /musehub/ui/{owner}/{repo_slug}/blob/{ref}/{path}` | HTML blob viewer page | |
| 8414 | | `GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path}` | JSON blob metadata + text content | |
| 8415 | |
| 8416 | **Auth:** No JWT required for public repos (HTML shell). Private repos require |
| 8417 | a Bearer token passed via `Authorization` header (API) or `localStorage` JWT (UI). |
| 8418 | |
| 8419 | ### File-Type Dispatch |
| 8420 | |
| 8421 | The viewer selects a rendering mode based on the file extension: |
| 8422 | |
| 8423 | | Extension | `file_type` | Rendering | |
| 8424 | |-----------|-------------|-----------| |
| 8425 | | `.mid`, `.midi` | `midi` | Piano roll placeholder + "View in Piano Roll" quick link | |
| 8426 | | `.mp3`, `.wav`, `.flac`, `.ogg` | `audio` | `<audio>` player + "Listen" quick link | |
| 8427 | | `.json` | `json` | Syntax-highlighted JSON (keys blue, strings teal, numbers gold, bools red, nulls grey) | |
| 8428 | | `.webp`, `.png`, `.jpg`, `.jpeg` | `image` | Inline `<img>` on checkered background | |
| 8429 | | `.xml` | `xml` | Syntax-highlighted XML (MusicXML support) | |
| 8430 | | all others | `other` | Hex dump preview (first 512 bytes via Range request) + raw download | |
| 8431 | |
| 8432 | ### File Metadata |
| 8433 | |
| 8434 | Every blob response includes: |
| 8435 | |
| 8436 | - `filename` — basename of the file (e.g. `bass.mid`) |
| 8437 | - `size_bytes` — file size in bytes |
| 8438 | - `sha` — content-addressed ID (e.g. `sha256:abc123...`) |
| 8439 | - `created_at` — timestamp of the most-recently-pushed version |
| 8440 | - `raw_url` — direct link to `/{owner}/{repo_slug}/raw/{ref}/{path}` |
| 8441 | - `file_type` — rendering hint (see table above) |
| 8442 | - `content_text` — UTF-8 content for JSON/XML files ≤ 256 KB; `null` for binary/oversized |
| 8443 | |
| 8444 | ### Quick Links |
| 8445 | |
| 8446 | The blob viewer exposes contextual action links: |
| 8447 | |
| 8448 | - **Raw** — always present; links to raw download endpoint (`Content-Disposition: attachment`) |
| 8449 | - **View in Piano Roll** — MIDI files only; links to `/{owner}/{repo_slug}/piano-roll/{ref}/{path}` |
| 8450 | - **Listen** — audio files only; links to `/{owner}/{repo_slug}/listen/{ref}/{path}` |
| 8451 | |
| 8452 | ### Object Resolution |
| 8453 | |
| 8454 | Object resolution uses `musehub_repository.get_object_by_path()`, which returns |
| 8455 | the most-recently-pushed object matching the path in the repo. The `ref` |
| 8456 | parameter is validated for URL construction but does not currently filter by |
| 8457 | branch HEAD (MVP scope — consistent with raw and tree endpoints). |
| 8458 | |
| 8459 | ### Response Shape (`BlobMetaResponse`) |
| 8460 | |
| 8461 | ```json |
| 8462 | { |
| 8463 | "objectId": "sha256:abc123...", |
| 8464 | "path": "tracks/bass.mid", |
| 8465 | "filename": "bass.mid", |
| 8466 | "sizeBytes": 2048, |
| 8467 | "sha": "sha256:abc123...", |
| 8468 | "createdAt": "2025-01-15T12:00:00Z", |
| 8469 | "rawUrl": "/musehub/repos/{repo_id}/raw/main/tracks/bass.mid", |
| 8470 | "fileType": "midi", |
| 8471 | "contentText": null |
| 8472 | } |
| 8473 | ``` |
| 8474 | |
| 8475 | ### Implementation Map |
| 8476 | |
| 8477 | | Component | File | |
| 8478 | |-----------|------| |
| 8479 | | Template | `maestro/templates/musehub/pages/blob.html` | |
| 8480 | | UI handler | `maestro/api/routes/musehub/ui.py` → `blob_page()` | |
| 8481 | | API endpoint | `maestro/api/routes/musehub/objects.py` → `get_blob_meta()` | |
| 8482 | | Pydantic model | `maestro/models/musehub.py` → `BlobMetaResponse` | |
| 8483 | | Tests | `tests/test_musehub_ui.py` — `test_blob_*` (7 tests) | |
| 8484 | |
| 8485 | --- |
| 8486 | |
| 8487 | |
| 8488 | ## Score / Notation Renderer (issue #210) |
| 8489 | |
| 8490 | ### Motivation |
| 8491 | |
| 8492 | Musicians who read traditional notation cannot visualize Muse compositions as |
| 8493 | sheet music without exporting to MusicXML and opening a separate application. |
| 8494 | The score renderer bridges this gap by rendering standard music notation |
| 8495 | directly in the browser from quantized MIDI data. |
| 8496 | |
| 8497 | ### Routes |
| 8498 | |
| 8499 | | Route | Handler | Description | |
| 8500 | |-------|---------|-------------| |
| 8501 | | `GET /musehub/ui/{owner}/{repo_slug}/score/{ref}` | `score_page()` | Full score — all instrument parts | |
| 8502 | | `GET /musehub/ui/{owner}/{repo_slug}/score/{ref}/{path}` | `score_part_page()` | Single-part view filtered by instrument name | |
| 8503 | |
| 8504 | No JWT is required to render the HTML shell. Auth is handled client-side via |
| 8505 | localStorage JWT, matching all other UI pages. |
| 8506 | |
| 8507 | ### Notation Data Pipeline |
| 8508 | |
| 8509 | ``` |
| 8510 | convert_ref_to_notation(ref) # musehub_notation.py |
| 8511 | └─ _seed_from_ref(ref) # SHA-256 → deterministic int seed |
| 8512 | └─ _notes_for_track(seed, ...) # LCG pseudo-random note generation |
| 8513 | └─ NotationResult(tracks, tempo, key, time_sig) |
| 8514 | │ |
| 8515 | └─ score.html (Jinja2 template) |
| 8516 | └─ renderScore() JS |
| 8517 | └─ drawStaffLines() + drawNote() → SVG |
| 8518 | ``` |
| 8519 | |
| 8520 | ### Server-Side Quantization (`musehub_notation.py`) |
| 8521 | |
| 8522 | Key types: |
| 8523 | |
| 8524 | | Type | Kind | Description | |
| 8525 | |------|------|-------------| |
| 8526 | | `NotationNote` | `TypedDict` | Single quantized note: `pitch_name`, `octave`, `duration`, `start_beat`, `velocity`, `track_id` | |
| 8527 | | `NotationTrack` | `TypedDict` | One instrument part: `track_id`, `clef`, `key_signature`, `time_signature`, `instrument`, `notes` | |
| 8528 | | `NotationDict` | `TypedDict` | JSON-serialisable form: `tracks`, `tempo`, `key`, `timeSig` | |
| 8529 | | `NotationResult` | `NamedTuple` | Internal result: `tracks`, `tempo`, `key`, `time_sig` | |
| 8530 | |
| 8531 | Public API: |
| 8532 | |
| 8533 | ```python |
| 8534 | from maestro.services.musehub_notation import convert_ref_to_notation, notation_result_to_dict |
| 8535 | |
| 8536 | result = convert_ref_to_notation("main", num_tracks=3, num_bars=8) |
| 8537 | payload = notation_result_to_dict(result) |
| 8538 | # {"tracks": [...], "tempo": 120, "key": "C major", "timeSig": "4/4"} |
| 8539 | ``` |
| 8540 | |
| 8541 | `convert_ref_to_notation` is **deterministic** — the same `ref` always returns |
| 8542 | the same notation. Distinct refs produce different keys, tempos, and time |
| 8543 | signatures. |
| 8544 | |
| 8545 | ### Client-Side SVG Renderer (`score.html`) |
| 8546 | |
| 8547 | The template's `{% block page_script %}` implements a lightweight SVG renderer |
| 8548 | that draws staff lines, note heads, stems, flags, ledger lines, and accidentals |
| 8549 | without any external library dependency. |
| 8550 | |
| 8551 | Key renderer functions: |
| 8552 | |
| 8553 | | Function | Description | |
| 8554 | |----------|-------------| |
| 8555 | | `drawStaffLines(beatsPerBar, numBars)` | Draws 5 staff lines + bar lines, returns SVG prefix | |
| 8556 | | `drawClef(clef)` | Renders treble/bass clef label | |
| 8557 | | `drawTimeSig(timeSig, x)` | Renders time signature numerals | |
| 8558 | | `drawNote(note, clef, beatsPerBar, barWidth)` | Renders note head, stem, flag, ledger lines, accidental | |
| 8559 | | `renderTrackStaff(track)` | Assembles full staff SVG for one instrument part | |
| 8560 | | `renderScore()` | Wires track selector + meta panel + all staves | |
| 8561 | | `setTrack(id)` | Switches part selector and re-renders | |
| 8562 | |
| 8563 | Note head style: filled ellipse (quarter/eighth), open ellipse (half/whole). |
| 8564 | Stem direction: up when note is at or below the middle staff line, down above. |
| 8565 | |
| 8566 | ### VexFlow Decision |
| 8567 | |
| 8568 | VexFlow.js was evaluated but not vendored in this implementation. The full |
| 8569 | minified library (~2 MB) would have increased page payload significantly for a |
| 8570 | feature used by a minority of users. The lightweight SVG renderer covers the |
| 8571 | required use cases (clefs, key/time signatures, notes, rests, beams, dynamics). |
| 8572 | If VexFlow integration is needed in future, the notation JSON endpoint |
| 8573 | (`/api/v1/musehub/repos/{id}/notation/{ref}`) is already designed to supply the |
| 8574 | quantized data that VexFlow's `EasyScore` API expects. |
| 8575 | |
| 8576 | ### JSON Content Negotiation |
| 8577 | |
| 8578 | The score page requests: |
| 8579 | ``` |
| 8580 | GET /api/v1/musehub/repos/{repo_id}/notation/{ref} |
| 8581 | ``` |
| 8582 | Response: `{ "tracks": [...], "tempo": int, "key": str, "timeSig": str }` |
| 8583 | |
| 8584 | If the notation endpoint is unavailable, the renderer falls back to a minimal |
| 8585 | client-side stub (C-major chord, 4 beats) so the page always displays |
| 8586 | something meaningful. |
| 8587 | |
| 8588 | ### Implementation |
| 8589 | |
| 8590 | | Layer | File | |
| 8591 | |-------|------| |
| 8592 | | Service | `maestro/services/musehub_notation.py` — `convert_ref_to_notation()`, `notation_result_to_dict()` | |
| 8593 | | HTML routes | `maestro/api/routes/musehub/ui.py` — `score_page()`, `score_part_page()` | |
| 8594 | | Template | `maestro/templates/musehub/pages/score.html` | |
| 8595 | | Tests (service) | `tests/test_musehub_notation.py` — 19 tests | |
| 8596 | | Tests (UI) | `tests/test_musehub_ui.py` — `test_score_*` (9 tests) | |
| 8597 | |
| 8598 | --- |
| 8599 | |
| 8600 | ## Issue Tracker Enhancements (Phase 9 — Issue #218) |
| 8601 | |
| 8602 | ### Overview |
| 8603 | |
| 8604 | The Muse Hub issue tracker supports full collaborative workflows beyond the initial title/body/close MVP. This section documents the enhanced issue detail capabilities added in phase 9. |
| 8605 | |
| 8606 | ### Features |
| 8607 | |
| 8608 | **Threaded Discussion** |
| 8609 | |
| 8610 | Issues support threaded comments via `POST /issues/{number}/comments`. Top-level comments have `parentId: null`; replies set `parentId` to the target comment UUID. The UI builds the thread tree client-side from the flat chronological list. |
| 8611 | |
| 8612 | **Musical Context Linking** |
| 8613 | |
| 8614 | Comment bodies are scanned at write time for musical context references using the pattern `type:value`: |
| 8615 | |
| 8616 | | Syntax | Type | Example | |
| 8617 | |--------|------|---------| |
| 8618 | | `track:bass` | track | References the bass track | |
| 8619 | | `section:chorus` | section | References the chorus section | |
| 8620 | | `beats:16-24` | beats | References a beat range | |
| 8621 | |
| 8622 | Parsed refs are stored in `musehub_issue_comments.musical_refs` (JSON array) and returned in `IssueCommentResponse.musicalRefs`. The issue detail UI renders them as coloured badges. |
| 8623 | |
| 8624 | **Assignees** |
| 8625 | |
| 8626 | A single collaborator can be assigned per issue via `POST /issues/{number}/assign`. The `assignee` field is a free-form display name. Pass `"assignee": null` to unassign. |
| 8627 | |
| 8628 | **Milestones** |
| 8629 | |
| 8630 | Milestones group issues into named goals (e.g. "Album v1.0"). Create milestones at `POST /milestones`, then link issues via `POST /issues/{number}/milestone?milestone_id=<uuid>`. Each milestone response includes live `openIssues` and `closedIssues` counts computed from the current state of linked issues. |
| 8631 | |
| 8632 | **State Transitions** |
| 8633 | |
| 8634 | Issues may be reopened after closing: `POST /issues/{number}/reopen`. Both close and reopen fire webhook events with `action: "opened"/"closed"`. |
| 8635 | |
| 8636 | **Edit Capability** |
| 8637 | |
| 8638 | `PATCH /issues/{number}` allows partial updates to title, body, and labels. |
| 8639 | |
| 8640 | ### Database Schema |
| 8641 | |
| 8642 | | Table | Purpose | |
| 8643 | |-------|---------| |
| 8644 | | `musehub_milestones` | Per-repo milestone definitions | |
| 8645 | | `musehub_issues` | Extended with `assignee`, `milestone_id`, `updated_at` | |
| 8646 | | `musehub_issue_comments` | Threaded comments with `parent_id` and `musical_refs` | |
| 8647 | |
| 8648 | ### Implementation |
| 8649 | |
| 8650 | | Layer | File | |
| 8651 | |-------|------| |
| 8652 | | DB models | `maestro/db/musehub_models.py` — `MusehubMilestone`, `MusehubIssueComment`, extended `MusehubIssue` | |
| 8653 | | Pydantic models | `maestro/models/musehub.py` — `MilestoneCreate/Response`, `IssueCommentCreate/Response`, `IssueUpdate`, `IssueAssignRequest`, `MusicalRef` | |
| 8654 | | Service | `maestro/services/musehub_issues.py` — comment CRUD, milestone CRUD, assign, reopen, update | |
| 8655 | | Routes | `maestro/api/routes/musehub/issues.py` — 12 endpoints | |
| 8656 | | UI template | `maestro/templates/musehub/pages/issue_detail.html` — threaded UI | |
| 8657 | | Migration | `alembic/versions/0001_consolidated_schema.py` — new tables and columns | |
| 8658 | | Tests | `tests/test_musehub_issues.py` — 12 new tests covering all acceptance criteria | |
| 8659 | |
| 8660 | --- |
| 8661 | |
| 8662 | |
| 8663 | ## muse rerere |
| 8664 | |
| 8665 | ### Purpose |
| 8666 | |
| 8667 | In parallel multi-branch Muse workflows, identical merge conflicts appear repeatedly — the same MIDI region modified in the same structural way on two independent branches. `muse rerere` (reuse recorded resolutions) records conflict shapes and their resolutions so they can be applied automatically on subsequent merges. |
| 8668 | |
| 8669 | The fingerprint is **transposition-invariant**: two conflicts with the same structural shape but different absolute pitches are treated as the same conflict, allowing a resolution recorded in one key to be applied in another. |
| 8670 | |
| 8671 | ### Cache Layout |
| 8672 | |
| 8673 | ``` |
| 8674 | .muse/rr-cache/<sha256-hash>/ |
| 8675 | conflict — serialised conflict fingerprint (JSON) |
| 8676 | postimage — serialised resolution (JSON, written only after resolve) |
| 8677 | ``` |
| 8678 | |
| 8679 | Entries marked `[R]` (in `muse rerere list`) have a `postimage`; entries marked `[C]` are conflict-only (awaiting resolution). |
| 8680 | |
| 8681 | ### Commands |
| 8682 | |
| 8683 | | Command | Description | |
| 8684 | |---------|-------------| |
| 8685 | | `muse rerere` | Auto-apply any cached resolution for current merge conflicts | |
| 8686 | | `muse rerere list` | Show all conflict fingerprints in the rr-cache (`[R]` = resolved, `[C]` = conflict-only) | |
| 8687 | | `muse rerere forget <hash>` | Remove a single cached entry from the rr-cache | |
| 8688 | | `muse rerere clear` | Purge the entire rr-cache | |
| 8689 | |
| 8690 | ### Example Output |
| 8691 | |
| 8692 | ``` |
| 8693 | $ muse merge feature/harmony-rework |
| 8694 | CONFLICT (note): Both sides modified note at pitch=64 beat=3.0 in region r1 |
| 8695 | ✅ muse rerere: resolved 1 conflict(s) using rerere (hash a3f7c2e1…) |
| 8696 | |
| 8697 | $ muse rerere list |
| 8698 | rr-cache (2 entries): |
| 8699 | [R] a3f7c2e1d9b4... |
| 8700 | [C] 88f02c3a71e4... |
| 8701 | |
| 8702 | $ muse rerere forget a3f7c2e1d9b4... |
| 8703 | ✅ Forgot rerere entry a3f7c2e1d9b4… |
| 8704 | |
| 8705 | $ muse rerere clear |
| 8706 | ✅ Cleared 1 rr-cache entry. |
| 8707 | ``` |
| 8708 | |
| 8709 | ### Hook Integration |
| 8710 | |
| 8711 | `build_merge_checkout_plan()` in `maestro/services/muse_merge.py` automatically calls `record_conflict()` and `apply_rerere()` whenever conflicts are detected and a `repo_root` is provided. On a cache hit it logs: |
| 8712 | |
| 8713 | ``` |
| 8714 | ✅ muse rerere: resolved N conflict(s) using rerere. |
| 8715 | ``` |
| 8716 | |
| 8717 | ### Result Types |
| 8718 | |
| 8719 | | Function | Return Type | Description | |
| 8720 | |----------|-------------|-------------| |
| 8721 | | `record_conflict(repo_root, conflicts)` | `str` | SHA-256 hex fingerprint identifying the conflict shape | |
| 8722 | | `record_resolution(repo_root, hash, resolution)` | `None` | Writes `postimage` to cache; raises `FileNotFoundError` if hash not found | |
| 8723 | | `apply_rerere(repo_root, conflicts)` | `tuple[int, JSONObject \| None]` | `(n_resolved, resolution)` — `n_resolved` is 0 on miss or `len(conflicts)` on hit | |
| 8724 | | `list_rerere(repo_root)` | `list[str]` | Sorted list of all fingerprint hashes in the cache | |
| 8725 | | `forget_rerere(repo_root, hash)` | `bool` | `True` if removed, `False` if not found (idempotent) | |
| 8726 | | `clear_rerere(repo_root)` | `int` | Count of entries removed | |
| 8727 | |
| 8728 | The conflict dict type (`ConflictDict`) has fields: `region_id: str`, `type: str`, `description: str`. The resolution type is `JSONObject` (`dict[str, JSONValue]`) — intentionally opaque to support arbitrary resolution strategies. |
| 8729 | |
| 8730 | ### Agent Use Case |
| 8731 | |
| 8732 | `muse rerere` is designed for AI agents running parallel Muse branch workflows. When an agent resolves a merge conflict (e.g. choosing the harmonic arrangement from `feature/harmony` over `feature/rhythm` for a contested region), it records the resolution via `record_resolution()`. Subsequent agents encountering structurally identical conflicts — even in a different key — receive the cached resolution automatically through `apply_rerere()`, eliminating repeated human intervention. |
| 8733 | |
| 8734 | **Typical agent workflow:** |
| 8735 | 1. `build_merge_checkout_plan()` encounters conflicts → `record_conflict()` is called automatically. |
| 8736 | 2. Agent examines conflicts and calls `record_resolution()` with the chosen strategy. |
| 8737 | 3. On the next parallel merge with the same conflict shape: `apply_rerere()` returns the resolution, the agent logs a single `✅ resolved N conflict(s) using rerere` line, and proceeds. |
| 8738 | 4. Use `muse rerere list` to audit cached resolutions; `muse rerere forget <hash>` or `muse rerere clear` when resolutions are stale. |
| 8739 | |
| 8740 | ### Implementation |
| 8741 | |
| 8742 | | Layer | File | |
| 8743 | |-------|------| |
| 8744 | | Service | `maestro/services/muse_rerere.py` — fingerprinting, cache storage, all CRUD functions | |
| 8745 | | CLI commands | `maestro/muse_cli/commands/rerere.py` — `rerere`, `rerere list`, `rerere forget`, `rerere clear` | |
| 8746 | | CLI app | `maestro/muse_cli/app.py` — registered as `muse rerere` sub-app | |
| 8747 | | Merge hook | `maestro/services/muse_merge.py` — auto-apply after conflict detection | |
| 8748 | | Tests | `tests/test_muse_rerere.py` — full unit test coverage | |
| 8749 | |
| 8750 | --- |
| 8751 |