cgcardona / muse public
type-contracts.md markdown
1239 lines 43.4 KB
7fd3e008 Fix JS syntax errors in tour_de_force.html; update type-contracts docs Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 # Muse VCS — Type Contracts Reference
2
3 > Updated: 2026-03-17 | Reflects every named entity in the Muse VCS surface:
4 > domain protocol types, store wire-format TypedDicts, in-memory dataclasses,
5 > merge/config/stash types, MIDI import types, error hierarchy, and CLI enums.
6 > `Any` and `object` do not exist in any production file. Every type boundary
7 > is named. The typing audit ratchet enforces zero violations on every CI run.
8
9 This document is the single source of truth for every named entity —
10 `TypedDict`, `dataclass`, `Protocol`, `Enum`, `TypeAlias` — in the Muse
11 codebase. It covers the full contract of each type: fields, types,
12 optionality, and intended use.
13
14 ---
15
16 ## Table of Contents
17
18 1. [Design Philosophy](#design-philosophy)
19 2. [Domain Protocol Types (`muse/domain.py`)](#domain-protocol-types)
20 - [Snapshot and Delta TypedDicts](#snapshot-and-delta-typeddicts)
21 - [Type Aliases](#type-aliases)
22 - [MergeResult and DriftReport Dataclasses](#mergeresult-and-driftreport-dataclasses)
23 - [MuseDomainPlugin Protocol](#musedomainplugin-protocol)
24 3. [Store Types (`muse/core/store.py`)](#store-types)
25 - [Wire-Format TypedDicts](#wire-format-typeddicts)
26 - [In-Memory Dataclasses](#in-memory-dataclasses)
27 4. [Merge Engine Types (`muse/core/merge_engine.py`)](#merge-engine-types)
28 5. [Attributes Types (`muse/core/attributes.py`)](#attributes-types)
29 6. [MIDI Dimension Merge Types (`muse/plugins/music/midi_merge.py`)](#midi-dimension-merge-types)
30 7. [Configuration Types (`muse/cli/config.py`)](#configuration-types)
31 8. [MIDI / MusicXML Import Types (`muse/cli/midi_parser.py`)](#midi--musicxml-import-types)
32 9. [Stash Types (`muse/cli/commands/stash.py`)](#stash-types)
33 10. [Error Hierarchy (`muse/core/errors.py`)](#error-hierarchy)
34 11. [Entity Hierarchy](#entity-hierarchy)
35 12. [Entity Graphs (Mermaid)](#entity-graphs-mermaid)
36
37 ---
38
39 ## Design Philosophy
40
41 Every entity in this codebase follows five rules:
42
43 1. **No `Any`. No `object`. Ever.** Both collapse type safety for downstream
44 callers. Every boundary is typed with a concrete named entity — `TypedDict`,
45 `dataclass`, `Protocol`, or a specific union. The CI typing audit enforces
46 this with a ratchet of zero violations.
47
48 2. **No covariance in collection aliases.** `dict[str, str]` and
49 `list[str]` are used directly. If a function's return mixes value types,
50 create a `TypedDict` for that shape instead of using `dict[str, str | int]`.
51
52 3. **Boundaries own coercion.** When external data arrives (JSON from disk,
53 TOML from config, MIDI bytes from disk), the boundary module coerces it
54 to the canonical internal type using `isinstance` narrowing. Downstream
55 code always sees clean types.
56
57 4. **Wire-format TypedDicts for serialisation, dataclasses for in-memory
58 logic.** `CommitDict`, `SnapshotDict`, `TagDict` are JSON-serialisable
59 and used by `to_dict()` / `from_dict()`. `CommitRecord`, `SnapshotRecord`,
60 `TagRecord` are rich dataclasses with typed `datetime` fields used in
61 business logic.
62
63 5. **The plugin protocol is the extension point.** All domain-specific logic
64 lives behind `MuseDomainPlugin`. The core DAG engine, branching, and
65 merge machinery know nothing about music, genomics, or any other domain.
66 Swapping domains is a one-file operation.
67
68 ### What to use instead
69
70 | Banned | Use instead |
71 |--------|-------------|
72 | `Any` | `TypedDict`, `dataclass`, specific union |
73 | `object` | The actual type or a constrained union |
74 | `list` (bare) | `list[X]` with concrete element type |
75 | `dict` (bare) | `dict[K, V]` with concrete key/value types |
76 | `dict[str, X]` with known keys | `TypedDict` — name the keys |
77 | `Optional[X]` | `X \| None` |
78 | Legacy `List`, `Dict`, `Set`, `Tuple` | Lowercase builtins |
79 | `cast(T, x)` | Fix the callee to return `T` |
80 | `# type: ignore` | Fix the underlying type error |
81
82 ---
83
84 ## Domain Protocol Types
85
86 **Path:** `muse/domain.py`
87
88 The five-interface contract that every domain plugin must satisfy. The core
89 engine implements the DAG, branching, merge-base finding, and lineage walking.
90 A domain plugin provides the five methods and gets the full VCS for free.
91
92 ### Snapshot and Delta TypedDicts
93
94 #### `SnapshotManifest`
95
96 `TypedDict` — Content-addressed snapshot of domain state. Used as the
97 canonical representation of a point-in-time capture. JSON-serialisable and
98 content-addressable via SHA-256.
99
100 | Field | Type | Description |
101 |-------|------|-------------|
102 | `files` | `dict[str, str]` | Workspace-relative POSIX paths → SHA-256 content digests |
103 | `domain` | `str` | Plugin identifier that produced this snapshot (e.g. `"music"`) |
104
105 **Example:**
106 ```json
107 {
108 "files": {
109 "tracks/drums.mid": "a3f8...",
110 "tracks/bass.mid": "b291..."
111 },
112 "domain": "music"
113 }
114 ```
115
116 #### `DeltaManifest`
117
118 `TypedDict` — Minimal change description between two snapshots. Each list
119 contains workspace-relative POSIX paths. Produced by `MuseDomainPlugin.diff()`.
120
121 | Field | Type | Description |
122 |-------|------|-------------|
123 | `domain` | `str` | Plugin identifier that produced this delta |
124 | `added` | `list[str]` | Paths present in target but absent from base |
125 | `removed` | `list[str]` | Paths present in base but absent from target |
126 | `modified` | `list[str]` | Paths present in both with differing digests |
127
128 ### Type Aliases
129
130 | Alias | Definition | Description |
131 |-------|-----------|-------------|
132 | `LiveState` | `SnapshotManifest \| pathlib.Path` | Current domain state — either an in-memory snapshot dict or a `muse-work/` directory path |
133 | `StateSnapshot` | `SnapshotManifest` | A content-addressed, immutable capture of state at a point in time |
134 | `StateDelta` | `DeltaManifest` | The minimal change between two snapshots |
135
136 `LiveState` carries two forms intentionally: the CLI path is used when commands
137 interact with the filesystem (`muse commit`, `muse status`); the snapshot form
138 is used when the engine constructs merges and diffs entirely in memory.
139
140 ### MergeResult and DriftReport Dataclasses
141
142 #### `MergeResult`
143
144 `@dataclass` — Outcome of a three-way merge between two divergent state lines.
145 An empty `conflicts` list means the merge was clean.
146
147 | Field | Type | Default | Description |
148 |-------|------|---------|-------------|
149 | `merged` | `StateSnapshot` | required | The reconciled snapshot |
150 | `conflicts` | `list[str]` | `[]` | Workspace-relative paths that could not be auto-merged |
151 | `applied_strategies` | `dict[str, str]` | `{}` | Path → strategy applied by `.museattributes` (e.g. `{"drums/kick.mid": "ours"}`) |
152 | `dimension_reports` | `dict[str, dict[str, str]]` | `{}` | Path → per-dimension winner map; only populated for MIDI files that went through dimension-level merge (e.g. `{"keys/piano.mid": {"notes": "left", "harmonic": "right"}}`) |
153
154 **Property:**
155
156 | Name | Returns | Description |
157 |------|---------|-------------|
158 | `is_clean` | `bool` | `True` when `conflicts` is empty |
159
160 #### `DriftReport`
161
162 `@dataclass` — Gap between committed state and current live state. Produced by
163 `MuseDomainPlugin.drift()` and consumed by `muse status`.
164
165 | Field | Type | Default | Description |
166 |-------|------|---------|-------------|
167 | `has_drift` | `bool` | required | `True` when live state differs from committed snapshot |
168 | `summary` | `str` | `""` | Human-readable description (e.g. `"2 added, 1 modified"`) |
169 | `delta` | `StateDelta` | empty `DeltaManifest` | Machine-readable diff for programmatic consumers |
170
171 ### MuseDomainPlugin Protocol
172
173 `@runtime_checkable Protocol` — The five interfaces a domain plugin must
174 implement. Runtime-checkable so that `assert isinstance(plugin, MuseDomainPlugin)`
175 works as a module-load sanity check.
176
177 | Method | Signature | Description |
178 |--------|-----------|-------------|
179 | `snapshot` | `(live_state: LiveState) -> StateSnapshot` | Capture current state as a content-addressed dict; must honour `.museignore` |
180 | `diff` | `(base: StateSnapshot, target: StateSnapshot) -> StateDelta` | Compute the minimal delta between two snapshots |
181 | `merge` | `(base, left, right: StateSnapshot, *, repo_root: pathlib.Path \| None = None) -> MergeResult` | Three-way merge; when `repo_root` is provided, load `.museattributes` and perform dimension-level merge for supported formats |
182 | `drift` | `(committed: StateSnapshot, live: LiveState) -> DriftReport` | Compare committed state vs current live state |
183 | `apply` | `(delta: StateDelta, live_state: LiveState) -> LiveState` | Apply a delta to produce a new live state |
184
185 The music plugin (`muse.plugins.music.plugin`) is the reference implementation.
186 Every other domain — scientific simulation, genomics, 3D spatial design,
187 spacetime — implements these five methods and registers itself as a plugin.
188
189 ---
190
191 ## Store Types
192
193 **Path:** `muse/core/store.py`
194
195 All commit and snapshot metadata is stored as JSON files under `.muse/`.
196 Wire-format `TypedDict`s are the JSON-serialisable shapes used in `to_dict()`
197 and `from_dict()`. In-memory `dataclass`es are the rich representations used
198 in business logic throughout the CLI commands.
199
200 ### Wire-Format TypedDicts
201
202 These types appear at the boundary between Python objects and JSON on disk.
203 `json.loads()` returns an untyped result; `from_dict()` methods consume it and
204 return typed dataclasses. `to_dict()` methods produce these TypedDicts for
205 `json.dumps()`.
206
207 #### `CommitDict`
208
209 `TypedDict` — JSON-serialisable representation of a commit record. All datetime
210 values are ISO-8601 strings; callers convert to `datetime` inside `from_dict()`.
211
212 | Field | Type | Description |
213 |-------|------|-------------|
214 | `commit_id` | `str` | SHA-256 hex digest of the commit's canonical inputs |
215 | `repo_id` | `str` | UUID identifying the repository |
216 | `branch` | `str` | Branch name at time of commit |
217 | `snapshot_id` | `str` | SHA-256 hex digest of the attached snapshot |
218 | `message` | `str` | Commit message |
219 | `committed_at` | `str` | ISO-8601 UTC timestamp |
220 | `parent_commit_id` | `str \| None` | First parent commit ID; `None` for initial commit |
221 | `parent2_commit_id` | `str \| None` | Second parent commit ID; non-`None` only for merge commits |
222 | `author` | `str` | Author name string |
223 | `metadata` | `dict[str, str]` | Extensible string→string metadata bag |
224
225 #### `SnapshotDict`
226
227 `TypedDict` — JSON-serialisable representation of a snapshot record.
228
229 | Field | Type | Description |
230 |-------|------|-------------|
231 | `snapshot_id` | `str` | SHA-256 hex digest of the manifest |
232 | `manifest` | `dict[str, str]` | POSIX path → SHA-256 object digest |
233 | `created_at` | `str` | ISO-8601 UTC timestamp |
234
235 #### `TagDict`
236
237 `TypedDict` — JSON-serialisable representation of a semantic tag.
238
239 | Field | Type | Description |
240 |-------|------|-------------|
241 | `tag_id` | `str` | UUID identifying the tag |
242 | `repo_id` | `str` | UUID identifying the repository |
243 | `commit_id` | `str` | SHA-256 commit ID this tag points to |
244 | `tag` | `str` | Tag name string (e.g. `"v1.0"`) |
245 | `created_at` | `str` | ISO-8601 UTC timestamp |
246
247 #### `RemoteCommitPayload`
248
249 `TypedDict (total=False)` — Wire format received from a remote during push/pull.
250 All fields are optional because the remote payload may omit fields unknown to
251 older protocol versions. Callers validate required fields before constructing
252 a `CommitRecord`.
253
254 | Field | Type | Description |
255 |-------|------|-------------|
256 | `commit_id` | `str` | Commit identifier |
257 | `repo_id` | `str` | Repository UUID |
258 | `branch` | `str` | Branch name |
259 | `snapshot_id` | `str` | Snapshot identifier |
260 | `message` | `str` | Commit message |
261 | `committed_at` | `str` | ISO-8601 timestamp |
262 | `parent_commit_id` | `str \| None` | First parent |
263 | `parent2_commit_id` | `str \| None` | Second parent (merge commits) |
264 | `author` | `str` | Author name |
265 | `metadata` | `dict[str, str]` | Metadata bag |
266 | `manifest` | `dict[str, str]` | Inline snapshot manifest (remote optimisation) |
267
268 ### In-Memory Dataclasses
269
270 These rich types are constructed from wire-format TypedDicts after loading from
271 disk. They carry typed `datetime` values and are used throughout CLI command
272 implementations.
273
274 #### `CommitRecord`
275
276 `@dataclass` — In-memory representation of a commit.
277
278 | Field | Type | Default | Description |
279 |-------|------|---------|-------------|
280 | `commit_id` | `str` | required | SHA-256 hex digest |
281 | `repo_id` | `str` | required | Repository UUID |
282 | `branch` | `str` | required | Branch name |
283 | `snapshot_id` | `str` | required | Attached snapshot digest |
284 | `message` | `str` | required | Commit message |
285 | `committed_at` | `datetime.datetime` | required | UTC commit timestamp |
286 | `parent_commit_id` | `str \| None` | `None` | First parent; `None` for root commits |
287 | `parent2_commit_id` | `str \| None` | `None` | Second parent for merge commits |
288 | `author` | `str` | `""` | Author name |
289 | `metadata` | `dict[str, str]` | `{}` | Extensible string→string metadata |
290
291 **Methods:**
292
293 | Method | Returns | Description |
294 |--------|---------|-------------|
295 | `to_dict()` | `CommitDict` | Serialise to JSON-ready TypedDict |
296 | `from_dict(d: CommitDict)` | `CommitRecord` | Deserialise from JSON-loaded TypedDict |
297
298 #### `SnapshotRecord`
299
300 `@dataclass` — In-memory representation of a content-addressed snapshot.
301
302 | Field | Type | Default | Description |
303 |-------|------|---------|-------------|
304 | `snapshot_id` | `str` | required | SHA-256 hex digest of the manifest |
305 | `manifest` | `dict[str, str]` | required | POSIX path → SHA-256 object digest |
306 | `created_at` | `datetime.datetime` | UTC now | Creation timestamp |
307
308 **Methods:** `to_dict() -> SnapshotDict`, `from_dict(d: SnapshotDict) -> SnapshotRecord`
309
310 #### `TagRecord`
311
312 `@dataclass` — In-memory representation of a semantic tag.
313
314 | Field | Type | Default | Description |
315 |-------|------|---------|-------------|
316 | `tag_id` | `str` | required | UUID |
317 | `repo_id` | `str` | required | Repository UUID |
318 | `commit_id` | `str` | required | Tagged commit's SHA-256 digest |
319 | `tag` | `str` | required | Tag name |
320 | `created_at` | `datetime.datetime` | UTC now | Creation timestamp |
321
322 **Methods:** `to_dict() -> TagDict`, `from_dict(d: TagDict) -> TagRecord`
323
324 ---
325
326 ## Merge Engine Types
327
328 **Path:** `muse/core/merge_engine.py`
329
330 #### `MergeStatePayload`
331
332 `TypedDict (total=False)` — JSON-serialisable form of an in-progress merge
333 state. Written to `.muse/MERGE_STATE.json` when a merge has unresolved
334 conflicts. All fields are optional in the TypedDict because `other_branch` is
335 only set when the merge has a named second branch.
336
337 | Field | Type | Description |
338 |-------|------|-------------|
339 | `base_commit` | `str` | Common ancestor commit ID |
340 | `ours_commit` | `str` | Current branch HEAD at merge start |
341 | `theirs_commit` | `str` | Incoming branch HEAD at merge start |
342 | `conflict_paths` | `list[str]` | POSIX paths with unresolved conflicts |
343 | `other_branch` | `str` | Name of the branch being merged in (optional) |
344
345 #### `MergeState`
346
347 `@dataclass (frozen=True)` — Loaded in-memory representation of
348 `MERGE_STATE.json`. Immutable so it can be passed around without accidental
349 mutation.
350
351 | Field | Type | Default | Description |
352 |-------|------|---------|-------------|
353 | `conflict_paths` | `list[str]` | `[]` | Paths with unresolved conflicts |
354 | `base_commit` | `str \| None` | `None` | Common ancestor commit ID |
355 | `ours_commit` | `str \| None` | `None` | Our HEAD at merge start |
356 | `theirs_commit` | `str \| None` | `None` | Their HEAD at merge start |
357 | `other_branch` | `str \| None` | `None` | Name of the incoming branch |
358
359 ---
360
361 ## Attributes Types
362
363 **Path:** `muse/core/attributes.py`
364
365 Parse and resolve `.museattributes` merge-strategy rules. The parser produces
366 a typed `AttributeRule` list; `resolve_strategy` does first-match lookup with
367 `fnmatch` path patterns and dimension name matching.
368
369 #### `VALID_STRATEGIES`
370
371 `frozenset[str]` — The set of legal strategy strings:
372 `{"ours", "theirs", "union", "auto", "manual"}`.
373
374 #### `AttributeRule`
375
376 `@dataclass (frozen=True)` — A single parsed rule from `.museattributes`.
377
378 | Field | Type | Description |
379 |-------|------|-------------|
380 | `path_pattern` | `str` | `fnmatch` glob matched against workspace-relative POSIX paths |
381 | `dimension` | `str` | Domain axis name (e.g. `"melodic"`, `"harmonic"`) or `"*"` to match all |
382 | `strategy` | `str` | One of the `VALID_STRATEGIES` strings |
383 | `source_line` | `int` | 1-based line number in the file (for diagnostics); defaults to `0` |
384
385 **Contracts:**
386
387 - `load_attributes(root: pathlib.Path) -> list[AttributeRule]` — reads
388 `.museattributes`, returns rules in file order; raises `ValueError` for bad
389 strategy or wrong field count.
390 - `resolve_strategy(rules: list[AttributeRule], path: str, dimension: str = "*") -> str` —
391 first-match lookup; returns `"auto"` when no rule matches.
392
393 ---
394
395 ## MIDI Dimension Merge Types
396
397 **Path:** `muse/plugins/music/midi_merge.py`
398
399 The multidimensional merge engine for the music domain. MIDI events are
400 bucketed into four orthogonal dimension slices; each slice has a content hash
401 for fast change detection. A three-way merge resolves each dimension
402 independently using `.museattributes` strategies, then reconstructs a valid
403 MIDI file from the winning slices.
404
405 #### Constants
406
407 | Name | Type | Value / Description |
408 |------|------|---------------------|
409 | `INTERNAL_DIMS` | `list[str]` | `["notes", "harmonic", "dynamic", "structural"]` — the four internal dimension bucket names |
410 | `DIM_ALIAS` | `dict[str, str]` | Maps user-facing names to internal buckets: `"melodic" → "notes"`, `"rhythmic" → "notes"`, `"harmonic" → "harmonic"`, `"dynamic" → "dynamic"`, `"structural" → "structural"` |
411
412 #### `_MsgVal`
413
414 `TypeAlias = int | str | list[int]` — The set of value types that can appear
415 in the serialised form of a MIDI message field. Used by `_msg_to_dict` to
416 avoid `dict[str, object]`.
417
418 #### `DimensionSlice`
419
420 `@dataclass` — All MIDI events belonging to one dimension of a parsed file.
421
422 | Field | Type | Default | Description |
423 |-------|------|---------|-------------|
424 | `name` | `str` | required | Internal dimension name (e.g. `"notes"`, `"harmonic"`) |
425 | `events` | `list[tuple[int, mido.Message]]` | `[]` | `(abs_tick, message)` pairs sorted by ascending absolute tick |
426 | `content_hash` | `str` | `""` | SHA-256 digest of the canonical JSON serialisation; computed in `__post_init__` when not provided |
427
428 #### `MidiDimensions`
429
430 `@dataclass` — All four dimension slices extracted from one MIDI file, plus
431 file-level metadata.
432
433 | Field | Type | Description |
434 |-------|------|-------------|
435 | `ticks_per_beat` | `int` | MIDI timing resolution (pulses per quarter note) from the source file |
436 | `file_type` | `int` | MIDI file type (0 = single-track, 1 = multi-track synchronous) |
437 | `slices` | `dict[str, DimensionSlice]` | Internal dimension name → slice |
438
439 **Method:**
440
441 | Name | Signature | Description |
442 |------|-----------|-------------|
443 | `get` | `(user_dim: str) -> DimensionSlice` | Resolve a user-facing alias (`"melodic"`, `"rhythmic"`) or internal name to the correct slice |
444
445 **Public functions:**
446
447 | Function | Signature | Description |
448 |----------|-----------|-------------|
449 | `extract_dimensions` | `(midi_bytes: bytes) -> MidiDimensions` | Parse MIDI bytes and bucket events by dimension |
450 | `dimension_conflict_detail` | `(base, left, right: MidiDimensions) -> dict[str, str]` | Per-dimension change report: `"unchanged"`, `"left_only"`, `"right_only"`, or `"both"` |
451 | `merge_midi_dimensions` | `(base_bytes, left_bytes, right_bytes: bytes, attrs_rules: list[AttributeRule], path: str) -> tuple[bytes, dict[str, str]] \| None` | Three-way dimension merge; returns `(merged_bytes, dimension_report)` or `None` on unresolvable conflict |
452
453 ---
454
455 ## Configuration Types
456
457 **Path:** `muse/cli/config.py`
458
459 The structured view of `.muse/config.toml`. Loading from TOML uses `isinstance`
460 narrowing from `tomllib`'s untyped output — no `Any` annotation is ever written
461 in source. All mutation functions read the current config, modify the specific
462 section, and write back.
463
464 #### `AuthEntry`
465
466 `TypedDict (total=False)` — `[auth]` section in `.muse/config.toml`.
467
468 | Field | Type | Description |
469 |-------|------|-------------|
470 | `token` | `str` | Bearer token for Muse Hub authentication. **Never logged.** |
471
472 #### `RemoteEntry`
473
474 `TypedDict (total=False)` — `[remotes.<name>]` section in `.muse/config.toml`.
475
476 | Field | Type | Description |
477 |-------|------|-------------|
478 | `url` | `str` | Remote Hub URL (e.g. `"https://hub.example.com/repos/my-repo"`) |
479 | `branch` | `str` | Upstream branch tracked by this remote (set by `--set-upstream`) |
480
481 #### `MuseConfig`
482
483 `TypedDict (total=False)` — Structured view of the entire `.muse/config.toml`
484 file. All sections are optional; an empty dict is a valid `MuseConfig`.
485
486 | Field | Type | Description |
487 |-------|------|-------------|
488 | `auth` | `AuthEntry` | Authentication credentials section |
489 | `remotes` | `dict[str, RemoteEntry]` | Named remote sections |
490
491 #### `RemoteConfig`
492
493 `TypedDict` — Public-facing remote descriptor returned by `list_remotes()`.
494 A lightweight projection of `RemoteEntry` that always has both required fields.
495
496 | Field | Type | Description |
497 |-------|------|-------------|
498 | `name` | `str` | Remote name (e.g. `"origin"`) |
499 | `url` | `str` | Remote URL |
500
501 ---
502
503 ## MIDI / MusicXML Import Types
504
505 **Path:** `muse/cli/midi_parser.py`
506
507 Types used by `muse import` to parse Standard MIDI Files and MusicXML documents
508 into Muse's internal note representation.
509
510 #### `MidiMeta`
511
512 `TypedDict` — Format-specific metadata for Standard MIDI Files (`.mid`, `.midi`).
513
514 | Field | Type | Description |
515 |-------|------|-------------|
516 | `num_tracks` | `int` | Number of MIDI tracks in the file |
517
518 #### `MusicXMLMeta`
519
520 `TypedDict` — Format-specific metadata for MusicXML files (`.xml`, `.musicxml`).
521
522 | Field | Type | Description |
523 |-------|------|-------------|
524 | `num_parts` | `int` | Number of parts (instruments) in the score |
525 | `part_names` | `list[str]` | Display names of each part |
526
527 #### `RawMeta`
528
529 `TypeAlias = MidiMeta | MusicXMLMeta` — Discriminated union of all
530 format-specific metadata shapes. The `MuseImportData.raw_meta` field carries
531 one of these two named types depending on the source file's format.
532
533 #### `NoteEvent`
534
535 `@dataclass` — A single sounding note extracted from an imported file.
536
537 | Field | Type | Description |
538 |-------|------|-------------|
539 | `pitch` | `int` | MIDI pitch number (0–127) |
540 | `velocity` | `int` | MIDI velocity (0–127; 0 = note-off) |
541 | `start_tick` | `int` | Onset tick relative to file start |
542 | `duration_ticks` | `int` | Note length in MIDI ticks |
543 | `channel` | `int` | MIDI channel (0–15) |
544 | `channel_name` | `str` | Track/part name for this channel |
545
546 #### `MuseImportData`
547
548 `@dataclass` — All data extracted from a single imported music file. The
549 complete parsed result returned by `parse_file()`.
550
551 | Field | Type | Description |
552 |-------|------|-------------|
553 | `source_path` | `pathlib.Path` | Absolute path to the source file |
554 | `format` | `str` | `"midi"` or `"musicxml"` |
555 | `ticks_per_beat` | `int` | MIDI timing resolution (pulses per quarter note) |
556 | `tempo_bpm` | `float` | Tempo in beats per minute |
557 | `notes` | `list[NoteEvent]` | All sounding notes, in onset order |
558 | `tracks` | `list[str]` | Track/part names present in the file |
559 | `raw_meta` | `RawMeta` | Format-specific metadata (`MidiMeta` or `MusicXMLMeta`) |
560
561 ---
562
563 ## Stash Types
564
565 **Path:** `muse/cli/commands/stash.py`
566
567 #### `StashEntry`
568
569 `TypedDict` — A single entry in the stash stack, persisted to
570 `.muse/stash.json` as one element of a JSON array.
571
572 | Field | Type | Description |
573 |-------|------|-------------|
574 | `snapshot_id` | `str` | SHA-256 content digest of the stashed snapshot |
575 | `manifest` | `dict[str, str]` | POSIX path → SHA-256 object digest of stashed files |
576 | `branch` | `str` | Branch name that was active when the stash was saved |
577 | `stashed_at` | `str` | ISO-8601 UTC timestamp of the stash operation |
578
579 ---
580
581 ## Error Hierarchy
582
583 **Path:** `muse/core/errors.py`
584
585 #### `ExitCode`
586
587 `IntEnum` — Standardised CLI exit codes. Used throughout the CLI commands via
588 `raise typer.Exit(code=ExitCode.USER_ERROR)`.
589
590 | Value | Integer | Meaning |
591 |-------|---------|---------|
592 | `SUCCESS` | `0` | Command completed successfully |
593 | `USER_ERROR` | `1` | Bad arguments or invalid user input |
594 | `REPO_NOT_FOUND` | `2` | Not inside a Muse repository |
595 | `INTERNAL_ERROR` | `3` | Unexpected internal failure |
596
597 #### `MuseCLIError`
598
599 `Exception` — Base exception for all Muse CLI errors. Carries an exit code
600 so that top-level handlers can produce the correct process exit.
601
602 | Field | Type | Description |
603 |-------|------|-------------|
604 | `exit_code` | `ExitCode` | Exit code to use when this exception terminates the process |
605
606 #### `RepoNotFoundError`
607
608 `MuseCLIError` — Raised by `find_repo_root()` callers when a command is invoked
609 outside a Muse repository. Default message: `"Not a Muse repository. Run muse init."` Default exit code: `ExitCode.REPO_NOT_FOUND`.
610
611 **Alias:** `MuseNotARepoError = RepoNotFoundError`
612
613 ---
614
615 ## Entity Hierarchy
616
617 ```
618 Muse VCS
619
620 ├── Domain Protocol (muse/domain.py)
621 │ │
622 │ ├── Snapshot and Delta
623 │ │ ├── SnapshotManifest — TypedDict: {files: dict[str,str], domain: str}
624 │ │ └── DeltaManifest — TypedDict: {domain, added, removed, modified}
625 │ │
626 │ ├── Type Aliases
627 │ │ ├── LiveState — SnapshotManifest | pathlib.Path
628 │ │ ├── StateSnapshot — SnapshotManifest
629 │ │ └── StateDelta — DeltaManifest
630 │ │
631 │ ├── Result Types
632 │ │ ├── MergeResult — dataclass: merged + conflicts + applied_strategies + dimension_reports
633 │ │ └── DriftReport — dataclass: has_drift + summary + delta
634 │ │
635 │ └── MuseDomainPlugin — Protocol (runtime_checkable): 5 methods
636 │ merge() accepts repo_root kwarg for attribute-aware merge
637
638 ├── Store (muse/core/store.py)
639 │ │
640 │ ├── Wire-Format TypedDicts
641 │ │ ├── CommitDict — TypedDict: all commit fields (str timestamps)
642 │ │ ├── SnapshotDict — TypedDict: snapshot_id + manifest + created_at
643 │ │ ├── TagDict — TypedDict: tag identity fields
644 │ │ └── RemoteCommitPayload — TypedDict (total=False): wire format + manifest
645 │ │
646 │ └── In-Memory Dataclasses
647 │ ├── CommitRecord — dataclass: typed datetime, to_dict/from_dict
648 │ ├── SnapshotRecord — dataclass: manifest + datetime
649 │ └── TagRecord — dataclass: tag metadata + datetime
650
651 ├── Merge Engine (muse/core/merge_engine.py)
652 │ ├── MergeStatePayload — TypedDict (total=False): MERGE_STATE.json shape
653 │ └── MergeState — dataclass (frozen): loaded in-memory merge state
654
655 ├── Attributes (muse/core/attributes.py)
656 │ ├── VALID_STRATEGIES — frozenset[str]: {ours, theirs, union, auto, manual}
657 │ └── AttributeRule — dataclass (frozen): path_pattern + dimension + strategy + source_line
658
659 ├── MIDI Dimension Merge (muse/plugins/music/midi_merge.py)
660 │ ├── INTERNAL_DIMS — list[str]: [notes, harmonic, dynamic, structural]
661 │ ├── DIM_ALIAS — dict[str, str]: user-facing names → internal buckets
662 │ ├── _MsgVal — TypeAlias: int | str | list[int]
663 │ ├── DimensionSlice — dataclass: name + events list + content_hash
664 │ └── MidiDimensions — dataclass: ticks_per_beat + file_type + slices dict
665
666 ├── Configuration (muse/cli/config.py)
667 │ ├── AuthEntry — TypedDict (total=False): [auth] section
668 │ ├── RemoteEntry — TypedDict (total=False): [remotes.<name>] section
669 │ ├── MuseConfig — TypedDict (total=False): full config.toml shape
670 │ └── RemoteConfig — TypedDict: public remote descriptor
671
672 ├── MIDI / MusicXML Import (muse/cli/midi_parser.py)
673 │ ├── MidiMeta — TypedDict: num_tracks
674 │ ├── MusicXMLMeta — TypedDict: num_parts + part_names
675 │ ├── RawMeta — TypeAlias: MidiMeta | MusicXMLMeta
676 │ ├── NoteEvent — dataclass: pitch, velocity, timing, channel
677 │ └── MuseImportData — dataclass: full parsed file result
678
679 ├── Stash (muse/cli/commands/stash.py)
680 │ └── StashEntry — TypedDict: snapshot_id + manifest + branch + stashed_at
681
682 └── Errors (muse/core/errors.py)
683 ├── ExitCode — IntEnum: SUCCESS=0 USER_ERROR=1 REPO_NOT_FOUND=2 INTERNAL_ERROR=3
684 ├── MuseCLIError — Exception base: carries ExitCode
685 ├── RepoNotFoundError — MuseCLIError: default exit REPO_NOT_FOUND
686 └── MuseNotARepoError — alias for RepoNotFoundError
687 ```
688
689 ---
690
691 ## Entity Graphs (Mermaid)
692
693 Arrow conventions:
694 - `*--` composition (owns, lifecycle-coupled)
695 - `-->` association (references)
696 - `..>` dependency (uses)
697 - `..>` with label: produces / implements
698
699 ---
700
701 ### Diagram 1 — Domain Protocol and Plugin Contract
702
703 The `MuseDomainPlugin` protocol and the types that flow through its five methods. `MusicPlugin` is the reference implementation that proves the abstraction.
704
705 ```mermaid
706 classDiagram
707 class SnapshotManifest {
708 <<TypedDict>>
709 +files : dict~str, str~
710 +domain : str
711 }
712 class DeltaManifest {
713 <<TypedDict>>
714 +domain : str
715 +added : list~str~
716 +removed : list~str~
717 +modified : list~str~
718 }
719 class MergeResult {
720 <<dataclass>>
721 +merged : StateSnapshot
722 +conflicts : list~str~
723 +applied_strategies : dict~str, str~
724 +dimension_reports : dict~str, dict~str, str~~
725 +is_clean : bool
726 }
727 class DriftReport {
728 <<dataclass>>
729 +has_drift : bool
730 +summary : str
731 +delta : StateDelta
732 }
733 class MuseDomainPlugin {
734 <<Protocol runtime_checkable>>
735 +snapshot(live_state: LiveState) StateSnapshot
736 +diff(base, target: StateSnapshot) StateDelta
737 +merge(base, left, right, *, repo_root) MergeResult
738 +drift(committed: StateSnapshot, live: LiveState) DriftReport
739 +apply(delta: StateDelta, live_state: LiveState) LiveState
740 }
741 class MusicPlugin {
742 <<reference implementation>>
743 +snapshot(live_state) StateSnapshot
744 +diff(base, target) StateDelta
745 +merge(base, left, right, *, repo_root) MergeResult
746 +drift(committed, live) DriftReport
747 +apply(delta, live_state) LiveState
748 }
749
750 MuseDomainPlugin ..> SnapshotManifest : StateSnapshot alias
751 MuseDomainPlugin ..> DeltaManifest : StateDelta alias
752 MuseDomainPlugin --> MergeResult : merge() returns
753 MuseDomainPlugin --> DriftReport : drift() returns
754 MusicPlugin ..|> MuseDomainPlugin : implements
755 MergeResult --> SnapshotManifest : merged
756 DriftReport --> DeltaManifest : delta
757 ```
758
759 ---
760
761 ### Diagram 2 — Store Wire-Format TypedDicts and Dataclasses
762
763 The two-layer design: wire-format TypedDicts for JSON serialisation, rich
764 dataclasses for in-memory logic. Every `from_dict` consumes the TypedDict
765 shape produced by `json.loads()`; every `to_dict` produces it for
766 `json.dumps()`.
767
768 ```mermaid
769 classDiagram
770 class CommitDict {
771 <<TypedDict wire format>>
772 +commit_id : str
773 +repo_id : str
774 +branch : str
775 +snapshot_id : str
776 +message : str
777 +committed_at : str
778 +parent_commit_id : str | None
779 +parent2_commit_id : str | None
780 +author : str
781 +metadata : dict~str, str~
782 }
783 class SnapshotDict {
784 <<TypedDict wire format>>
785 +snapshot_id : str
786 +manifest : dict~str, str~
787 +created_at : str
788 }
789 class TagDict {
790 <<TypedDict wire format>>
791 +tag_id : str
792 +repo_id : str
793 +commit_id : str
794 +tag : str
795 +created_at : str
796 }
797 class RemoteCommitPayload {
798 <<TypedDict total=False>>
799 +commit_id : str
800 +repo_id : str
801 +branch : str
802 +snapshot_id : str
803 +message : str
804 +committed_at : str
805 +parent_commit_id : str | None
806 +parent2_commit_id : str | None
807 +author : str
808 +metadata : dict~str, str~
809 +manifest : dict~str, str~
810 }
811 class CommitRecord {
812 <<dataclass>>
813 +commit_id : str
814 +repo_id : str
815 +branch : str
816 +snapshot_id : str
817 +message : str
818 +committed_at : datetime
819 +parent_commit_id : str | None
820 +parent2_commit_id : str | None
821 +author : str
822 +metadata : dict~str, str~
823 +to_dict() CommitDict
824 +from_dict(d: CommitDict) CommitRecord
825 }
826 class SnapshotRecord {
827 <<dataclass>>
828 +snapshot_id : str
829 +manifest : dict~str, str~
830 +created_at : datetime
831 +to_dict() SnapshotDict
832 +from_dict(d: SnapshotDict) SnapshotRecord
833 }
834 class TagRecord {
835 <<dataclass>>
836 +tag_id : str
837 +repo_id : str
838 +commit_id : str
839 +tag : str
840 +created_at : datetime
841 +to_dict() TagDict
842 +from_dict(d: TagDict) TagRecord
843 }
844
845 CommitRecord ..> CommitDict : to_dict produces
846 CommitDict ..> CommitRecord : from_dict produces
847 SnapshotRecord ..> SnapshotDict : to_dict produces
848 SnapshotDict ..> SnapshotRecord : from_dict produces
849 TagRecord ..> TagDict : to_dict produces
850 TagDict ..> TagRecord : from_dict produces
851 RemoteCommitPayload ..> CommitDict : store_pulled_commit builds
852 CommitRecord --> SnapshotRecord : snapshot_id reference
853 CommitRecord --> TagRecord : commit_id reference (via TagRecord)
854 ```
855
856 ---
857
858 ### Diagram 3 — Merge Engine State
859
860 The in-progress merge state written to disk on conflict and loaded on
861 continuation. `MergeStatePayload` is the JSON shape; `MergeState` is the
862 loaded, immutable in-memory form.
863
864 ```mermaid
865 classDiagram
866 class MergeStatePayload {
867 <<TypedDict total=False>>
868 +base_commit : str
869 +ours_commit : str
870 +theirs_commit : str
871 +conflict_paths : list~str~
872 +other_branch : str
873 }
874 class MergeState {
875 <<dataclass frozen>>
876 +conflict_paths : list~str~
877 +base_commit : str | None
878 +ours_commit : str | None
879 +theirs_commit : str | None
880 +other_branch : str | None
881 }
882 class CommitRecord {
883 <<dataclass>>
884 +commit_id : str
885 +branch : str
886 +parent_commit_id : str | None
887 }
888
889 MergeStatePayload ..> MergeState : read_merge_state() produces
890 MergeState ..> MergeStatePayload : write_merge_state() serialises
891 MergeState --> CommitRecord : base_commit, ours_commit, theirs_commit
892 ```
893
894 ---
895
896 ### Diagram 4 — Configuration Type Hierarchy
897
898 The structured config.toml types. `MuseConfig` is the root; mutation functions
899 read, modify a specific section, and write back. `isinstance` narrowing converts
900 `tomllib`'s untyped output to the typed structure at the load boundary.
901
902 ```mermaid
903 classDiagram
904 class MuseConfig {
905 <<TypedDict total=False>>
906 +auth : AuthEntry
907 +remotes : dict~str, RemoteEntry~
908 }
909 class AuthEntry {
910 <<TypedDict total=False>>
911 +token : str
912 }
913 class RemoteEntry {
914 <<TypedDict total=False>>
915 +url : str
916 +branch : str
917 }
918 class RemoteConfig {
919 <<TypedDict public API>>
920 +name : str
921 +url : str
922 }
923
924 MuseConfig *-- AuthEntry : auth
925 MuseConfig *-- RemoteEntry : remotes (by name)
926 RemoteEntry ..> RemoteConfig : list_remotes() projects to
927 ```
928
929 ---
930
931 ### Diagram 5 — MIDI / MusicXML Import Types
932
933 The parser output types for `muse import`. `RawMeta` is a discriminated union
934 of two named shapes; no dict with mixed value types is exposed.
935
936 ```mermaid
937 classDiagram
938 class MidiMeta {
939 <<TypedDict>>
940 +num_tracks : int
941 }
942 class MusicXMLMeta {
943 <<TypedDict>>
944 +num_parts : int
945 +part_names : list~str~
946 }
947 class NoteEvent {
948 <<dataclass>>
949 +pitch : int
950 +velocity : int
951 +start_tick : int
952 +duration_ticks : int
953 +channel : int
954 +channel_name : str
955 }
956 class MuseImportData {
957 <<dataclass>>
958 +source_path : Path
959 +format : str
960 +ticks_per_beat : int
961 +tempo_bpm : float
962 +notes : list~NoteEvent~
963 +tracks : list~str~
964 +raw_meta : RawMeta
965 }
966 class RawMeta {
967 <<TypeAlias>>
968 MidiMeta | MusicXMLMeta
969 }
970
971 MuseImportData *-- NoteEvent : notes
972 MuseImportData --> RawMeta : raw_meta
973 RawMeta ..> MidiMeta : MIDI files
974 RawMeta ..> MusicXMLMeta : MusicXML files
975 ```
976
977 ---
978
979 ### Diagram 6 — Error Hierarchy
980
981 Exit codes, base exception, and concrete error types. Every CLI command raises
982 a typed exception or calls `raise typer.Exit(code=ExitCode.X)`.
983
984 ```mermaid
985 classDiagram
986 class ExitCode {
987 <<IntEnum>>
988 SUCCESS = 0
989 USER_ERROR = 1
990 REPO_NOT_FOUND = 2
991 INTERNAL_ERROR = 3
992 }
993 class MuseCLIError {
994 <<Exception>>
995 +exit_code : ExitCode
996 }
997 class RepoNotFoundError {
998 <<MuseCLIError>>
999 default exit_code = REPO_NOT_FOUND
1000 default message = Not a Muse repository
1001 }
1002
1003 MuseCLIError --> ExitCode : exit_code
1004 RepoNotFoundError --|> MuseCLIError
1005 ```
1006
1007 ---
1008
1009 ### Diagram 7 — Stash Stack
1010
1011 The stash is a JSON array of `StashEntry` TypedDicts persisted to
1012 `.muse/stash.json`. Push prepends; pop removes index 0.
1013
1014 ```mermaid
1015 classDiagram
1016 class StashEntry {
1017 <<TypedDict>>
1018 +snapshot_id : str
1019 +manifest : dict~str, str~
1020 +branch : str
1021 +stashed_at : str
1022 }
1023 class SnapshotRecord {
1024 <<dataclass>>
1025 +snapshot_id : str
1026 +manifest : dict~str, str~
1027 }
1028
1029 StashEntry ..> SnapshotRecord : snapshot_id + manifest mirror
1030 ```
1031
1032 ---
1033
1034 ### Diagram 8 — Full Entity Overview
1035
1036 All named entities grouped by layer, showing the dependency flow from the
1037 domain protocol down through the store, CLI, and plugin layers.
1038
1039 ```mermaid
1040 classDiagram
1041 class MuseDomainPlugin {
1042 <<Protocol>>
1043 snapshot / diff / merge / drift / apply
1044 }
1045 class SnapshotManifest {
1046 <<TypedDict>>
1047 files: dict~str,str~ · domain: str
1048 }
1049 class DeltaManifest {
1050 <<TypedDict>>
1051 domain · added · removed · modified
1052 }
1053 class MergeResult {
1054 <<dataclass>>
1055 merged · conflicts · applied_strategies · dimension_reports
1056 }
1057 class DriftReport {
1058 <<dataclass>>
1059 has_drift · summary · delta
1060 }
1061 class CommitRecord {
1062 <<dataclass>>
1063 commit_id · branch · snapshot_id · metadata
1064 }
1065 class SnapshotRecord {
1066 <<dataclass>>
1067 snapshot_id · manifest: dict~str,str~
1068 }
1069 class TagRecord {
1070 <<dataclass>>
1071 tag_id · commit_id · tag
1072 }
1073 class CommitDict {
1074 <<TypedDict wire>>
1075 all str fields · committed_at: str
1076 }
1077 class SnapshotDict {
1078 <<TypedDict wire>>
1079 snapshot_id · manifest · created_at
1080 }
1081 class TagDict {
1082 <<TypedDict wire>>
1083 tag_id · repo_id · commit_id · tag
1084 }
1085 class RemoteCommitPayload {
1086 <<TypedDict total=False>>
1087 wire format + manifest
1088 }
1089 class MergeState {
1090 <<dataclass frozen>>
1091 conflict_paths · base/ours/theirs commits
1092 }
1093 class MergeStatePayload {
1094 <<TypedDict total=False>>
1095 MERGE_STATE.json shape
1096 }
1097 class MuseConfig {
1098 <<TypedDict total=False>>
1099 auth · remotes
1100 }
1101 class AuthEntry {
1102 <<TypedDict total=False>>
1103 token: str
1104 }
1105 class RemoteEntry {
1106 <<TypedDict total=False>>
1107 url · branch
1108 }
1109 class RemoteConfig {
1110 <<TypedDict>>
1111 name · url
1112 }
1113 class StashEntry {
1114 <<TypedDict>>
1115 snapshot_id · manifest · branch · stashed_at
1116 }
1117 class MuseImportData {
1118 <<dataclass>>
1119 notes · tracks · raw_meta
1120 }
1121 class NoteEvent {
1122 <<dataclass>>
1123 pitch · velocity · timing · channel
1124 }
1125 class MidiMeta {
1126 <<TypedDict>>
1127 num_tracks: int
1128 }
1129 class MusicXMLMeta {
1130 <<TypedDict>>
1131 num_parts · part_names
1132 }
1133 class AttributeRule {
1134 <<dataclass frozen>>
1135 path_pattern · dimension · strategy
1136 }
1137 class DimensionSlice {
1138 <<dataclass>>
1139 name · events · content_hash
1140 }
1141 class MidiDimensions {
1142 <<dataclass>>
1143 ticks_per_beat · slices: dict~str, DimensionSlice~
1144 }
1145 class ExitCode {
1146 <<IntEnum>>
1147 SUCCESS=0 · USER_ERROR=1 · REPO_NOT_FOUND=2 · INTERNAL_ERROR=3
1148 }
1149 class MuseCLIError {
1150 <<Exception>>
1151 exit_code: ExitCode
1152 }
1153 class RepoNotFoundError {
1154 <<MuseCLIError>>
1155 }
1156
1157 MuseDomainPlugin ..> SnapshotManifest : StateSnapshot
1158 MuseDomainPlugin ..> DeltaManifest : StateDelta
1159 MuseDomainPlugin --> MergeResult : merge() returns
1160 MuseDomainPlugin --> DriftReport : drift() returns
1161 MergeResult --> SnapshotManifest : merged
1162 DriftReport --> DeltaManifest : delta
1163
1164 CommitRecord ..> CommitDict : to_dict / from_dict
1165 SnapshotRecord ..> SnapshotDict : to_dict / from_dict
1166 TagRecord ..> TagDict : to_dict / from_dict
1167 RemoteCommitPayload ..> CommitDict : store_pulled_commit
1168 CommitRecord --> SnapshotRecord : snapshot_id
1169 MergeState ..> MergeStatePayload : serialise / deserialise
1170
1171 MuseConfig *-- AuthEntry : auth
1172 MuseConfig *-- RemoteEntry : remotes
1173 RemoteEntry ..> RemoteConfig : list_remotes()
1174
1175 StashEntry ..> SnapshotRecord : snapshot_id
1176 MuseImportData *-- NoteEvent : notes
1177 MuseImportData --> MidiMeta : raw_meta (MIDI)
1178 MuseImportData --> MusicXMLMeta : raw_meta (XML)
1179
1180 AttributeRule ..> MergeResult : applied_strategies reflects
1181 MidiDimensions *-- DimensionSlice : slices
1182 MidiDimensions ..> MergeResult : dimension_reports reflects
1183
1184 RepoNotFoundError --|> MuseCLIError
1185 MuseCLIError --> ExitCode : exit_code
1186 ```
1187
1188 ---
1189
1190 ### Diagram 9 — Attributes and MIDI Dimension Merge
1191
1192 The attribute rule pipeline and how it flows into the multidimensional MIDI
1193 merge engine. `AttributeRule` objects are produced by `load_attributes()` and
1194 consumed by both `MusicPlugin.merge()` and `merge_midi_dimensions()`.
1195 `DimensionSlice` is the core bucket type; `MidiDimensions` groups the four
1196 slices for one file.
1197
1198 ```mermaid
1199 classDiagram
1200 class AttributeRule {
1201 <<dataclass frozen>>
1202 +path_pattern : str
1203 +dimension : str
1204 +strategy : str
1205 +source_line : int
1206 }
1207 class DimensionSlice {
1208 <<dataclass>>
1209 +name : str
1210 +events : list~tuple~int, Message~~
1211 +content_hash : str
1212 }
1213 class MidiDimensions {
1214 <<dataclass>>
1215 +ticks_per_beat : int
1216 +file_type : int
1217 +slices : dict~str, DimensionSlice~
1218 +get(user_dim: str) DimensionSlice
1219 }
1220 class MergeResult {
1221 <<dataclass>>
1222 +merged : StateSnapshot
1223 +conflicts : list~str~
1224 +applied_strategies : dict~str, str~
1225 +dimension_reports : dict~str, dict~str, str~~
1226 +is_clean : bool
1227 }
1228 class MusicPlugin {
1229 <<MuseDomainPlugin>>
1230 +merge(base, left, right, *, repo_root) MergeResult
1231 }
1232
1233 MusicPlugin ..> AttributeRule : load_attributes()
1234 MusicPlugin ..> MidiDimensions : extract_dimensions()
1235 MusicPlugin --> MergeResult : returns
1236 MergeResult --> AttributeRule : applied_strategies reflects rules used
1237 MidiDimensions *-- DimensionSlice : slices (4 buckets)
1238 AttributeRule ..> DimensionSlice : resolve_strategy selects winner
1239 ```