cgcardona / muse public
muse-protocol.md markdown
284 lines 10.3 KB
7fd3e008 Fix JS syntax errors in tour_de_force.html; update type-contracts docs Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 # MuseDomainPlugin Protocol — Language-Agnostic Specification
2
3 > **Status:** Canonical · **Version:** v1.0
4 > **Audience:** Anyone implementing a Muse domain plugin in any language.
5
6 ---
7
8 ## 0. Purpose
9
10 This document specifies the five-method contract a domain plugin must satisfy to
11 integrate with the Muse VCS engine. It is intentionally language-agnostic.
12
13 Muse provides the DAG, object store, branching, lineage, merge state machine, log,
14 and CLI. A plugin provides domain knowledge. This document defines the boundary
15 between them.
16
17 ---
18
19 ## 1. Design Principles
20
21 1. **Plugins are pure transformations.** A plugin method takes state in, returns state
22 out. Side effects (writing to disk, calling APIs) belong to the CLI layer, not
23 the plugin.
24 2. **All state is JSON-serializable.** Snapshots must be serializable to a
25 content-addressable string. No opaque blobs inside snapshot values.
26 3. **Content-addressed identity.** The same state must always produce the same
27 snapshot. Snapshots are compared by their SHA-256 digest — not by object identity.
28 4. **Idempotent writes.** Writing an object or snapshot that already exists is a
29 no-op. The store never overwrites existing content.
30 5. **Conflicts are data, not exceptions.** A conflicted merge returns a `MergeResult`
31 with a non-empty `conflicts` list. It does not raise.
32 6. **Drift is always relative.** `drift()` compares committed state against live
33 state. It never modifies either.
34
35 ---
36
37 ## 2. Type Definitions
38
39 All types use Python as the reference notation. Implementations in other languages
40 should map to equivalent constructs.
41
42 ```python
43 # A workspace-relative path mapped to its SHA-256 content digest.
44 # Plugins are free to add top-level keys alongside "files" and "domain".
45 StateSnapshot = dict # must contain "files": dict[str, str] and "domain": str
46
47 # The "live" input to snapshot() and drift().
48 # Either a filesystem path to the working directory,
49 # or an existing StateSnapshot (used for in-memory operations).
50 LiveState = Path | StateSnapshot
51
52 # Output of diff(): three sorted lists of workspace-relative paths.
53 StateDelta = dict # must contain "added", "removed", "modified": list[str] and "domain": str
54
55 # Output of merge(): the reconciled snapshot + conflict + strategy metadata.
56 # "conflicts" — workspace-relative paths that could not be auto-resolved.
57 # Empty list means the merge was clean.
58 # "applied_strategies" — path → strategy string applied by .museattributes
59 # (e.g. {"drums/kick.mid": "ours"}). Empty if no rules fired.
60 # "dimension_reports" — path → {dimension: winner} for files that went through
61 # dimension-level merge (e.g. {"keys.mid": {"notes": "left"}}).
62 MergeResult = dataclass(
63 merged: StateSnapshot,
64 conflicts: list[str],
65 applied_strategies: dict[str, str],
66 dimension_reports: dict[str, dict[str, str]],
67 )
68
69 # Output of drift(): summary of how live state diverges from committed state.
70 DriftReport = dataclass(has_drift: bool, summary: str, delta: StateDelta)
71 ```
72
73 ---
74
75 ## 3. The Five Methods
76
77 ### 3.1 `snapshot(live_state: LiveState) → StateSnapshot`
78
79 Capture the current live state as a serializable, content-addressable snapshot.
80
81 **Contract:**
82 - The return value MUST be JSON-serializable.
83 - The return value MUST contain a `"files"` key mapping workspace-relative path
84 strings to their SHA-256 hex digests.
85 - The return value MUST contain a `"domain"` key matching the plugin's domain name.
86 - Given identical input, the output MUST be identical (deterministic).
87 - If `live_state` is already a `StateSnapshot` dict, return it unchanged.
88
89 **Called by:** `muse commit`, `muse stash`
90
91 ---
92
93 ### 3.2 `diff(base: StateSnapshot, target: StateSnapshot) → StateDelta`
94
95 Compute the minimal delta between two snapshots.
96
97 **Contract:**
98 - Return MUST contain `"added"`: sorted list of paths present in `target` but not `base`.
99 - Return MUST contain `"removed"`: sorted list of paths present in `base` but not `target`.
100 - Return MUST contain `"modified"`: sorted list of paths present in both with different digests.
101 - Return MUST contain `"domain"` matching the plugin's domain name.
102 - All three lists MUST be sorted.
103 - A path that appears in `added` MUST NOT appear in `removed` or `modified`.
104
105 **Called by:** `muse diff`, `muse checkout`
106
107 ---
108
109 ### 3.3 `merge(base, left, right: StateSnapshot, *, repo_root: Path | None = None) → MergeResult`
110
111 Three-way merge two divergent state lines against a common ancestor.
112
113 **Contract:**
114 - `base` is the common ancestor (merge base).
115 - `left` is the current branch's snapshot (ours).
116 - `right` is the incoming branch's snapshot (theirs).
117 - `repo_root`, when provided, is the filesystem root of the repository.
118 Implementations SHOULD use it to load `.museattributes` and apply
119 file-level or dimension-level merge strategies before falling back to
120 conflict reporting.
121 - `result.merged` MUST be a valid `StateSnapshot`.
122 - `result.conflicts` MUST be a list of workspace-relative path strings.
123 - An empty list means the merge was clean.
124 - Paths in `result.conflicts` MUST also appear in `result.merged` (placeholder state).
125 - `result.applied_strategies` maps paths where a `.museattributes` rule overrode
126 the default conflict behaviour to the strategy string that was used.
127 Plugins SHOULD populate this for observability; it MAY be empty.
128 - `result.dimension_reports` maps paths that received dimension-level merge to
129 a `{dimension: winner}` dict for each resolved dimension.
130 Plugins that do not support dimension merge MAY always return `{}`.
131 - **Consensus deletion** (both sides deleted the same path) is NOT a conflict.
132 - This method MUST NOT raise on conflict — it returns the conflict list instead.
133
134 **Called by:** `muse merge`, `muse cherry-pick`
135
136 ---
137
138 ### 3.4 `drift(committed: StateSnapshot, live: LiveState) → DriftReport`
139
140 Detect how far the live state has diverged from the last committed snapshot.
141
142 **Contract:**
143 - `result.has_drift` is `True` if and only if `delta` is non-empty.
144 - `result.summary` is a human-readable string (e.g. `"2 added, 1 modified"`
145 or `"working tree clean"`).
146 - `result.delta` is a valid `StateDelta`.
147 - This method MUST NOT modify any state.
148
149 **Called by:** `muse status`
150
151 ---
152
153 ### 3.5 `apply(delta: StateDelta, live_state: LiveState) → LiveState`
154
155 Apply a delta to produce a new live state. Serves as the domain-level
156 post-checkout hook.
157
158 **Contract:**
159 - When `live_state` is a filesystem `Path`: the caller has already applied the
160 delta physically (removed deleted files, restored added/modified from the object
161 store). The plugin SHOULD rescan the directory and return the authoritative new
162 state as a `StateSnapshot`.
163 - When `live_state` is a `StateSnapshot` dict: apply removals to the in-memory dict.
164 Added/modified paths SHOULD be noted as limitations — the delta does not carry
165 content hashes, so the caller must supply them through another path.
166 - The return value MUST be a valid `LiveState`.
167
168 **Called by:** `muse checkout`
169
170 ---
171
172 ## 4. Snapshot Format (Normative)
173
174 The minimum required shape for a `StateSnapshot`:
175
176 ```json
177 {
178 "files": {
179 "path/to/file-a": "sha256-hex-64-chars",
180 "path/to/file-b": "sha256-hex-64-chars"
181 },
182 "domain": "my_domain_name"
183 }
184 ```
185
186 Plugins MAY add additional top-level keys for domain-specific metadata:
187
188 ```json
189 {
190 "files": { ... },
191 "domain": "music",
192 "tempo_bpm": 120,
193 "key": "Am"
194 }
195 ```
196
197 Additional keys MUST be JSON-serializable. The core engine ignores them; they
198 are available to domain-specific CLI commands via `plugin.snapshot()`.
199
200 ---
201
202 ## 5. Naming Conventions
203
204 | Scope | Convention |
205 |---|---|
206 | Wire format (JSON) | `camelCase` |
207 | Python internals | `snake_case` |
208 | Plugin domain name in `repo.json` | `snake_case` |
209 | Workspace-relative paths in snapshots | POSIX forward-slash separators |
210
211 ---
212
213 ## 6. Implementing a Plugin
214
215 Minimum viable implementation in Python:
216
217 ```python
218 from muse.domain import (
219 DeltaManifest, DriftReport, LiveState, MergeResult,
220 MuseDomainPlugin, SnapshotManifest, StateDelta, StateSnapshot,
221 )
222
223 class MyDomainPlugin:
224 def snapshot(self, live_state: LiveState) -> StateSnapshot:
225 if isinstance(live_state, pathlib.Path):
226 files = {
227 f.relative_to(live_state).as_posix(): _hash(f)
228 for f in sorted(live_state.rglob("*"))
229 if f.is_file()
230 }
231 return SnapshotManifest(files=files, domain="my_domain")
232 return live_state # already a snapshot dict
233
234 def diff(self, base: StateSnapshot, target: StateSnapshot) -> StateDelta:
235 b, t = base["files"], target["files"]
236 return DeltaManifest(
237 domain="my_domain",
238 added=sorted(set(t) - set(b)),
239 removed=sorted(set(b) - set(t)),
240 modified=sorted(p for p in set(b) & set(t) if b[p] != t[p]),
241 )
242
243 def merge(
244 self,
245 base: StateSnapshot,
246 left: StateSnapshot,
247 right: StateSnapshot,
248 *,
249 repo_root: pathlib.Path | None = None,
250 ) -> MergeResult:
251 # ... domain-specific reconciliation ...
252 # Load .museattributes if repo_root is provided and apply strategies.
253
254 def drift(self, committed, live) -> DriftReport:
255 live_snap = self.snapshot(live)
256 delta = self.diff(committed, live_snap)
257 has_drift = any([delta["added"], delta["removed"], delta["modified"]])
258 return DriftReport(has_drift=has_drift, summary="...", delta=delta)
259
260 def apply(self, delta, live_state) -> LiveState:
261 if isinstance(live_state, pathlib.Path):
262 return self.snapshot(live_state)
263 files = dict(live_state["files"])
264 for p in delta["removed"]:
265 files.pop(p, None)
266 return SnapshotManifest(files=files, domain="my_domain")
267 ```
268
269 See `muse/plugins/music/plugin.py` for the complete reference implementation.
270
271 ---
272
273 ## 7. Invariants the Core Engine Relies On
274
275 The core engine assumes:
276
277 1. `snapshot(snapshot_dict)` returns the dict unchanged.
278 2. `diff(s, s)` returns empty `added`, `removed`, `modified` for identical snapshots.
279 3. `merge(base, s, s)` returns `s` with an empty `conflicts` list.
280 4. `drift(s, path_to_workdir_matching_s)` returns `has_drift=False`.
281 5. Object IDs in `StateSnapshot["files"]` are valid SHA-256 hex strings (64 chars).
282
283 Violating these invariants will cause incorrect behavior in `checkout`, `status`,
284 and merge state detection.