gabriel / muse public
domain.py python
1,131 lines 43.4 KB
Raw
sha256:8c872e4dffa2db45a9629956256fa1c99a3d2ff33b80c055252e58d94a0e8d1b feat: staged directory renames shown as renamed in muse status Sonnet 4.6 minor ⚠ breaking 51 days ago
1 """MuseDomainPlugin — the six-interface protocol that defines a Muse domain.
2
3 Muse provides the DAG engine, content-addressed object store, branching,
4 lineage walking, topological log graph, and merge base finder. A domain plugin
5 implements these six interfaces and Muse does the rest.
6
7 The MIDI plugin (``muse.plugins.midi``) is the reference implementation.
8 Every other domain — scientific simulation, genomics, 3D spatial design,
9 spacetime — is a new plugin.
10
11 Typed Delta Algebra
12 -------------------
13 ``StateDelta`` is a ``StructuredDelta`` carrying a typed operation list rather
14 than an opaque path list. Each operation knows its kind (insert / delete /
15 move / replace / patch), the address it touched, and a content-addressed ID
16 for the before/after content.
17
18 Op type taxonomy
19 ~~~~~~~~~~~~~~~~
20 Two families of insert/delete ops exist because different domains address their
21 elements differently:
22
23 **Address-keyed ops** (name-addressed, unordered):
24 :class:`AddressedInsertOp` / :class:`AddressedDeleteOp` — used by code,
25 identity, and any domain where elements are identified by a stable name or
26 path rather than by their position in a sequence. ``position`` is absent;
27 two ops commute automatically when their addresses differ.
28
29 **Sequence ops** (position-indexed, ordered):
30 :class:`InsertOp` / :class:`DeleteOp` — used by MIDI and any domain with an
31 ordered sequence where concurrent insertions at the same position require
32 tie-breaking. ``position`` is mandatory.
33
34 Domain Schema
35 -------------
36 ``schema()`` is the sixth protocol method. Plugins return a ``DomainSchema``
37 declaring their data structure. The core engine uses this declaration to drive
38 diff algorithm selection via :func:`~muse.core.diff_algorithms.diff_by_schema`.
39
40 Address-Keyed Map Merge
41 -----------------------
42 Plugins may optionally implement :class:`AddressedMergePlugin`, a sub-protocol
43 that adds ``merge_ops()``. When both branches have produced ``StructuredDelta``
44 from ``diff()``, the merge engine checks
45 ``isinstance(plugin, AddressedMergePlugin)`` and calls ``merge_ops()`` for
46 fine-grained, operation-level conflict detection. Non-supporting plugins fall
47 back to the existing file-level ``merge()`` path.
48
49 This is a **Map CRDT with pluggable conflict resolution** (via Harmony), not
50 classical Operational Transformation. Operations on different addresses
51 commute automatically without any transformation function — the key space
52 partitions concurrent edits. Same-address conflicts surface to Harmony for
53 policy-based or human-guided resolution.
54
55 CRDT Convergent Merge
56 ---------------------
57 Plugins may optionally implement :class:`CRDTPlugin`, a sub-protocol that
58 replaces ``merge()`` with ``join()``. ``join`` always succeeds — no conflict
59 state ever exists. Given any two :class:`CRDTSnapshotManifest` values,
60 ``join`` produces a deterministic merged result regardless of message delivery
61 order.
62
63 The core engine detects ``CRDTPlugin`` via ``isinstance`` at merge time.
64 ``DomainSchema.merge_mode == "crdt"`` signals that the CRDT path should be
65 taken.
66 """
67
68 import pathlib
69 from dataclasses import dataclass, field
70 from typing import TYPE_CHECKING, Literal, NotRequired, Protocol, TypedDict, runtime_checkable
71
72 from muse.core.types import Manifest, Metadata, SemVerBump
73
74 if TYPE_CHECKING:
75 from muse.core.merge_engine import VectorClock, CRDTState
76
77 # Local type aliases
78 type FieldMutationMap = dict[str, "FieldMutation"]
79 type StageIndex = dict[str, "StagedEntry"]
80 type DimensionReports = dict[str, Metadata]
81
82 class ConflictDict(TypedDict):
83 """Serialised form of a :class:`ConflictRecord`."""
84 path: str
85 conflict_type: str
86 ours_summary: str
87 theirs_summary: str
88 addresses: list[str]
89 ours_action: str
90 theirs_action: str
91
92 # Public re-exports so callers can do ``from muse.domain import MutateOp`` etc.
93 __all__ = [
94 "SnapshotManifest",
95 "DomainAddress",
96 "AddressedInsertOp",
97 "AddressedDeleteOp",
98 "InsertOp",
99 "DeleteOp",
100 "MoveOp",
101 "ReplaceOp",
102 "FieldMutation",
103 "MutateOp",
104 "EntityProvenance",
105 "LeafDomainOp",
106 "PatchOp",
107 "RenameOp",
108 "DomainOp",
109 "SemVerBump",
110 "StructuredDelta",
111 "LiveState",
112 "StateSnapshot",
113 "StateDelta",
114 "ConflictRecord",
115 "MergeResult",
116 "DriftReport",
117 "MuseDomainPlugin",
118 "AddressedMergePlugin",
119 "CRDTSnapshotManifest",
120 "CRDTPlugin",
121 "HarmonyPlugin",
122 "StagedEntry",
123 "DirStatus",
124 "StageStatus",
125 "StagePlugin",
126 ]
127
128 if TYPE_CHECKING:
129 from muse.core.schema import CRDTDimensionSpec, DomainSchema
130
131 # ---------------------------------------------------------------------------
132 # Snapshot types (unchanged from pre-Phase-1)
133 # ---------------------------------------------------------------------------
134
135 class SnapshotManifest(TypedDict):
136 """Content-addressed snapshot of domain state.
137
138 ``files`` maps workspace-relative POSIX paths to their SHA-256 content
139 digests. ``domain`` identifies which plugin produced this snapshot.
140 ``directories`` is a sorted list of workspace-relative POSIX directory
141 paths that are explicitly tracked in this snapshot. Empty directories
142 that should persist across commits must contain a ``.musekeep`` marker
143 file; directories that contain at least one tracked file are tracked
144 automatically.
145 """
146
147 files: Manifest
148 domain: str
149 directories: list[str]
150
151 # ---------------------------------------------------------------------------
152 # Typed delta algebra
153 # ---------------------------------------------------------------------------
154
155 #: A domain-specific address identifying a location within the state graph.
156 #: For file-level ops this is a workspace-relative POSIX path.
157 #: For sub-file ops this is a domain-specific coordinate (e.g. "note:42").
158 DomainAddress = str
159
160 class InsertOp(TypedDict):
161 """An element was inserted into a collection.
162
163 For ordered sequences ``position`` is the integer index at which the
164 element was inserted. For unordered sets ``position`` is ``None``.
165 ``content_id`` is the SHA-256 of the inserted content — either a blob
166 already in the object store (for file-level ops) or a deterministic hash
167 of the element's canonical serialisation (for sub-file ops).
168 """
169
170 op: Literal["insert"]
171 address: DomainAddress
172 position: int | None
173 content_id: str
174 content_summary: str
175
176 class DeleteOp(TypedDict):
177 """An element was removed from a collection.
178
179 ``position`` is the integer index that was removed for ordered sequences,
180 or ``None`` for unordered sets. ``content_id`` is the SHA-256 of the
181 deleted content so that the operation can be applied idempotently (already-
182 absent elements can be skipped). ``content_summary`` is the human-readable
183 description of what was removed, for ``muse read``.
184 """
185
186 op: Literal["delete"]
187 address: DomainAddress
188 position: int | None
189 content_id: str
190 content_summary: str
191
192 class AddressedInsertOp(TypedDict):
193 """An element was inserted into a name-addressed (unordered) collection.
194
195 Used by domains where elements are identified by a stable address rather
196 than by position — code symbols, identity entities, and any Map-CRDT domain.
197 Two ``AddressedInsertOp`` entries with different ``address`` values commute
198 automatically; same-address conflicts surface to Harmony.
199
200 ``position`` is intentionally absent: address-keyed domains do not have a
201 meaningful insertion index. Use :class:`InsertOp` for ordered-sequence
202 domains (MIDI notes, text characters) that require position.
203 """
204
205 op: Literal["insert"]
206 address: DomainAddress
207 content_id: str
208 content_summary: str
209
210 class AddressedDeleteOp(TypedDict):
211 """An element was removed from a name-addressed (unordered) collection.
212
213 Symmetric with :class:`AddressedInsertOp`. ``position`` is intentionally
214 absent — use :class:`DeleteOp` for ordered-sequence domains.
215 """
216
217 op: Literal["delete"]
218 address: DomainAddress
219 content_id: str
220 content_summary: str
221
222 class MoveOp(TypedDict):
223 """An element was repositioned within an ordered sequence.
224
225 ``from_position`` is the source index (in the pre-move sequence) and
226 ``to_position`` is the destination index (in the post-move sequence).
227 Both are mandatory — moves are only meaningful in ordered collections.
228 ``content_id`` identifies the element being moved so that the operation
229 can be validated during replay.
230 """
231
232 op: Literal["move"]
233 address: DomainAddress
234 from_position: int
235 to_position: int
236 content_id: str
237
238 class ReplaceOp(TypedDict):
239 """An element's value changed (atomic, leaf-level replacement).
240
241 ``old_content_id`` and ``new_content_id`` are SHA-256 hashes of the
242 before- and after-content. They enable three-way merge engines to detect
243 concurrent conflicting modifications (both changed from the same
244 ``old_content_id`` to different ``new_content_id`` values).
245 ``old_summary`` and ``new_summary`` are human-readable strings for display,
246 analogous to ``content_summary`` on :class:`InsertOp`.
247 ``position`` is the index within the container (``None`` for unordered).
248 """
249
250 op: Literal["replace"]
251 address: DomainAddress
252 position: int | None
253 old_content_id: str
254 new_content_id: str
255 old_summary: str
256 new_summary: str
257
258 class FieldMutation(TypedDict):
259 """The string-serialised before/after of a single field in a :class:`MutateOp`.
260
261 Values are always strings so that typed primitives (int, float, bool) can
262 be compared uniformly without carrying domain-specific type information in
263 the generic delta algebra. Plugins format them according to their domain
264 conventions (e.g. ``"80"`` for a MIDI velocity, ``"C4"`` for a pitch name).
265 """
266
267 old: str
268 new: str
269
270 class MutateOp(TypedDict):
271 """A named entity's specific fields were updated.
272
273 Unlike :class:`ReplaceOp` — which replaces an entire element atomically —
274 ``MutateOp`` records *which* specific fields of a domain entity changed.
275 This enables mutation tracking for domains that maintain stable entity
276 identity separate from content equality.
277
278 Example: a MIDI note's velocity changed from 80 to 100. Under a pure
279 content-hash model that becomes ``DeleteOp + InsertOp`` (two different
280 content hashes). With ``MutateOp`` and a stable ``entity_id`` the diff
281 reports "velocity 80→100 on entity C4@bar4" — lineage is preserved.
282
283 ``entity_id``
284 Stable identifier for the mutated entity, assigned at first insertion
285 and reused across all subsequent mutations (regardless of content
286 changes).
287 ``fields``
288 Mapping from field name (e.g. ``"velocity"``, ``"start_tick"``) to a
289 :class:`FieldMutation` recording the serialised old and new values.
290 ``old_content_id`` / ``new_content_id``
291 SHA-256 of the full element state before and after the mutation,
292 enabling three-way merge conflict detection identical to
293 :class:`ReplaceOp`.
294 ``position``
295 Index within the containing ordered sequence (``None`` for unordered).
296 """
297
298 op: Literal["mutate"]
299 address: DomainAddress
300 entity_id: str
301 old_content_id: str
302 new_content_id: str
303 fields: FieldMutationMap
304 old_summary: str
305 new_summary: str
306 position: int | None
307
308 class EntityProvenance(TypedDict, total=False):
309 """Causal metadata attached to ops that create or modify tracked entities.
310
311 All fields are optional (``total=False``) because entity tracking is an
312 opt-in capability. Plugins that implement stable entity identity populate
313 these fields when constructing :class:`InsertOp`, :class:`MutateOp`, or
314 :class:`DeleteOp` entries. Consumers that do not understand entity
315 provenance can safely ignore them.
316
317 ``entity_id``
318 Stable domain-specific identifier for the entity (e.g. a content-addressed ID assigned
319 at the note's first insertion).
320 ``origin_op_id``
321 The ``op_id`` of the op that first created this entity.
322 ``last_modified_op_id``
323 The ``op_id`` of the most recent op that touched this entity.
324 ``created_at_commit``
325 Short-form commit ID where this entity was first introduced.
326 ``actor_id``
327 The agent or human identity that performed this op.
328 """
329
330 entity_id: str
331 origin_op_id: str
332 last_modified_op_id: str
333 created_at_commit: str
334 actor_id: str
335
336 #: The five non-recursive (leaf) operation types.
337 LeafDomainOp = AddressedInsertOp | AddressedDeleteOp | InsertOp | DeleteOp | MoveOp | ReplaceOp | MutateOp
338
339 class RenameOp(TypedDict):
340 """An entity was renamed (its address changed, content unchanged).
341
342 Applies uniformly to any first-class entity: directories, files, or
343 symbols (as a child op inside a :class:`PatchOp`).
344
345 ``address`` is the new address; ``from_address`` is the old address.
346 Rename and content modification are orthogonal: a moved+edited file
347 emits a ``RenameOp`` followed by a ``PatchOp`` — two distinct events.
348 """
349
350 op: Literal["rename"]
351 address: DomainAddress
352 from_address: DomainAddress
353
354 class PatchOp(TypedDict):
355 """A container element was internally modified.
356
357 ``address`` names the container (e.g. a file path). ``child_ops`` lists
358 the sub-element changes inside that container. These are always
359 leaf ops in the current implementation; true recursion via a nested
360 ``StructuredDelta`` is reserved for a future release.
361
362 ``child_domain`` identifies the sub-element domain (e.g. ``"midi_notes"``
363 for note-level ops inside a ``.mid`` file). ``child_summary`` is a
364 human-readable description of the child changes for ``muse read``.
365 """
366
367 op: Literal["patch"]
368 address: DomainAddress
369 child_ops: list[DomainOp]
370 child_domain: str
371 child_summary: str
372 file_change: NotRequired[Literal["added", "deleted", "modified"]]
373
374 #: Union of all operation types — the atoms of a ``StructuredDelta``.
375 type DomainOp = LeafDomainOp | PatchOp | RenameOp
376
377 # SemVerBump is defined in muse.core.types and re-exported here for
378 # backward compatibility. All core modules import from _types directly.
379
380 class StructuredDelta(TypedDict, total=False):
381 """Rich, composable delta between two domain snapshots.
382
383 ``ops`` is an ordered list of operations that transforms ``base`` into
384 ``target`` when applied in sequence. The core engine stores this alongside
385 commit records so that ``muse read`` and ``muse diff`` can display it
386 without reloading full blobs.
387
388 ``summary`` is a precomputed human-readable string — for example
389 ``"3 notes added, 1 note removed"``. Plugins compute it because only they
390 understand their domain semantics.
391
392 ``sem_ver_bump`` (v2, optional) is the semantic version impact of this
393 delta, computed by :func:`infer_sem_ver_bump`. Absent for legacy records
394 or non-code domains that do not compute it.
395
396 ``breaking_changes`` (v2, optional) lists the symbol addresses whose
397 public interface was removed or incompatibly changed.
398 """
399
400 domain: str
401 ops: list[DomainOp]
402 summary: str
403 sem_ver_bump: SemVerBump
404 breaking_changes: list[str]
405
406 # ---------------------------------------------------------------------------
407 # Type aliases used in the protocol signatures
408 # ---------------------------------------------------------------------------
409
410 #: Live state is either an already-snapshotted manifest dict or a workdir path.
411 #: The MIDI plugin accepts both: a Path (for CLI commit/status) and a
412 #: SnapshotManifest dict (for in-memory merge and diff operations).
413 type LiveState = SnapshotManifest | pathlib.Path
414
415 #: A content-addressed, immutable snapshot of state at a point in time.
416 type StateSnapshot = SnapshotManifest
417
418 #: The minimal change between two snapshots — a list of typed domain operations.
419 type StateDelta = StructuredDelta
420
421 # ---------------------------------------------------------------------------
422 # Merge and drift result types
423 # ---------------------------------------------------------------------------
424
425 @dataclass
426 class ConflictRecord:
427 """Structured conflict record in a merge result (v2 taxonomy).
428
429 ``path`` The workspace-relative file path in conflict.
430 ``conflict_type`` One of: ``symbol_edit_overlap``, ``rename_edit``,
431 ``move_edit``, ``delete_use``, ``dependency_conflict``,
432 ``file_level`` (legacy, no symbol info).
433 ``ours_summary`` Short description of ours-side change.
434 ``theirs_summary`` Short description of theirs-side change.
435 ``addresses`` Symbol addresses involved (empty for file-level).
436 """
437
438 path: str
439 conflict_type: str = "file_level"
440 ours_summary: str = ""
441 theirs_summary: str = ""
442 addresses: list[str] = field(default_factory=list)
443 ours_action: str = ""
444 theirs_action: str = ""
445
446 def to_dict(self) -> ConflictDict:
447 return {
448 "path": self.path,
449 "conflict_type": self.conflict_type,
450 "ours_summary": self.ours_summary,
451 "theirs_summary": self.theirs_summary,
452 "addresses": self.addresses,
453 "ours_action": self.ours_action,
454 "theirs_action": self.theirs_action,
455 }
456
457 @dataclass
458 class MergeResult:
459 """Outcome of a three-way merge between two divergent state lines.
460
461 ``merged`` is the reconciled snapshot. ``conflicts`` is a list of
462 workspace-relative file paths that could not be auto-merged and require
463 manual resolution. An empty ``conflicts`` list means the merge was clean.
464 The CLI is responsible for formatting user-facing messages from these paths.
465
466 ``applied_strategies`` maps each path where a ``.museattributes`` rule
467 overrode the default conflict behaviour to the strategy that was applied.
468
469 ``dimension_reports`` maps conflicting paths to their per-dimension
470 resolution detail.
471
472 ``op_log`` is the ordered list of ``DomainOp`` entries applied to produce
473 the merged snapshot. Empty for file-level merges; populated by plugins
474 that implement address-keyed map merge.
475
476 ``conflict_records`` (v2) provides structured conflict metadata with a
477 semantic taxonomy per conflicting path. Populated by plugins that
478 implement :class:`AddressedMergePlugin`. May be empty even when
479 ``conflicts`` is non-empty (legacy file-level conflict).
480 """
481
482 merged: StateSnapshot
483 conflicts: list[str] = field(default_factory=list)
484 applied_strategies: Metadata = field(default_factory=dict)
485 dimension_reports: DimensionReports = field(default_factory=dict)
486 op_log: list[DomainOp] = field(default_factory=list)
487 conflict_records: list[ConflictRecord] = field(default_factory=list)
488
489 @property
490 def is_clean(self) -> bool:
491 """``True`` when no unresolvable conflicts remain."""
492 return len(self.conflicts) == 0
493
494 @dataclass
495 class DriftReport:
496 """Gap between committed state and current live state.
497
498 ``has_drift`` is ``True`` when the live state differs from the committed
499 snapshot. ``summary`` is a human-readable description of what changed.
500 ``delta`` is the machine-readable structured delta for programmatic consumers.
501 """
502
503 has_drift: bool
504 summary: str = ""
505 delta: StateDelta = field(default_factory=lambda: StructuredDelta(
506 domain="", ops=[], summary="working tree clean",
507 ))
508
509 # ---------------------------------------------------------------------------
510 # The plugin protocol
511 # ---------------------------------------------------------------------------
512
513 @runtime_checkable
514 class MuseDomainPlugin(Protocol):
515 """The six interfaces a domain plugin must implement.
516
517 Muse provides everything else: the DAG, branching, checkout, lineage
518 walking, ASCII log graph, and merge base finder. Implement these six
519 methods and your domain gets the full Muse VCS for free.
520
521 Music is the reference implementation (``muse.plugins.midi``).
522 """
523
524 def snapshot(self, live_state: LiveState) -> StateSnapshot:
525 """Capture current live state as a serialisable, hashable snapshot.
526
527 The returned ``SnapshotManifest`` must be JSON-serialisable. Muse will
528 compute a SHA-256 content address from the canonical JSON form and
529 store the snapshot as a blob in ``.muse/objects/``.
530
531 **``.museignore`` contract** — when *live_state* is a
532 ``pathlib.Path`` (the ``state/`` directory), domain plugin
533 implementations **must** honour ``.museignore`` by calling
534 :func:`muse.core.ignore.load_ignore_config` on the repository root,
535 then :func:`muse.core.ignore.resolve_patterns` with the active domain
536 name, and finally filtering paths with :func:`muse.core.ignore.is_ignored`.
537 Domain-specific patterns (``[domain.<name>]`` sections) are applied
538 only when the active domain matches.
539 """
540 ...
541
542 def diff(
543 self,
544 base: StateSnapshot,
545 target: StateSnapshot,
546 *,
547 repo_root: pathlib.Path | None = None,
548 ) -> StateDelta:
549 """Compute the structured delta between two snapshots.
550
551 Returns a ``StructuredDelta`` where ``ops`` is a minimal list of
552 typed operations that transforms ``base`` into ``target``. Plugins
553 should:
554
555 1. Compute ops at the finest granularity they can interpret.
556 2. Assign meaningful ``content_summary`` strings to each op.
557 3. When ``repo_root`` is provided, load sub-file content from the
558 object store and produce ``PatchOp`` entries with note/element-level
559 ``child_ops`` instead of coarse ``ReplaceOp`` entries.
560 4. Compute a human-readable ``summary`` across all ops.
561
562 The core engine stores this delta alongside the commit record so that
563 ``muse read`` and ``muse diff`` can display it without reloading blobs.
564 """
565 ...
566
567 def merge(
568 self,
569 base: StateSnapshot,
570 left: StateSnapshot,
571 right: StateSnapshot,
572 *,
573 repo_root: pathlib.Path | None = None,
574 ) -> MergeResult:
575 """Three-way merge two divergent state lines against a common base.
576
577 ``base`` is the common ancestor (merge base). ``left`` and ``right``
578 are the two divergent snapshots. Returns a ``MergeResult`` with the
579 reconciled snapshot and any unresolvable conflicts.
580
581 **``.museattributes`` and multidimensional merge contract** — when
582 *repo_root* is provided, domain plugin implementations should:
583
584 1. Load ``.museattributes`` via
585 :func:`muse.core.attributes.load_attributes`.
586 2. For each conflicting path, call
587 :func:`muse.core.attributes.resolve_strategy` with the relevant
588 dimension name (or ``"*"`` for file-level resolution).
589 3. Apply the returned strategy:
590
591 - ``"ours"`` — take the *left* version; remove from conflict list.
592 - ``"theirs"`` — take the *right* version; remove from conflict list.
593 - ``"manual"`` — force into conflict list even if the engine would
594 auto-resolve.
595 - ``"auto"`` / ``"union"`` — defer to the engine's default logic.
596
597 4. For domain formats that support true multidimensional content (e.g.
598 MIDI: notes, pitch_bend, cc_volume, track_structure), attempt
599 sub-file dimension merge before falling back to a file-level conflict.
600 """
601 ...
602
603 def drift(
604 self,
605 committed: StateSnapshot,
606 live: LiveState,
607 ) -> DriftReport:
608 """Compare committed state against current live state.
609
610 Used by ``muse status`` to detect uncommitted changes. Returns a
611 ``DriftReport`` describing whether the live state has diverged from
612 the last committed snapshot and, if so, by how much.
613 """
614 ...
615
616 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
617 """Apply a delta to produce a new live state.
618
619 Used by ``muse checkout`` to reconstruct a historical state. Applies
620 ``delta`` on top of ``live_state`` and returns the resulting state.
621
622 For ``InsertOp`` and ``ReplaceOp``, the new content is identified by
623 ``content_id`` (a SHA-256 hash). When ``live_state`` is a
624 ``pathlib.Path``, the plugin reads the content from the object store.
625 When ``live_state`` is a ``SnapshotManifest``, only ``DeleteOp`` and
626 ``ReplaceOp`` at the file level can be applied in-memory.
627 """
628 ...
629
630 def schema(self) -> DomainSchema:
631 """Declare the structural schema of this domain's state.
632
633 The core engine calls this once at plugin registration time. Plugins
634 must return a stable, deterministic :class:`~muse.core.schema.DomainSchema`
635 describing:
636
637 - ``top_level`` — the primary collection structure (e.g. a set of
638 files, a map of chromosome names to sequences).
639 - ``dimensions`` — the semantic sub-dimensions of state (e.g. notes, pitch_bend, cc_volume, track_structure for MIDI).
640 - ``merge_mode`` — ``"three_way"`` (address-keyed map merge) or ``"crdt"`` (CRDT convergent join).
641
642 The schema drives :func:`~muse.core.diff_algorithms.diff_by_schema`
643 algorithm selection and the merge engine's conflict detection.
644
645 See :mod:`muse.core.schema` for all available element schema types.
646 """
647 ...
648
649 # ---------------------------------------------------------------------------
650 # Address-keyed Map merge — AddressedMergePlugin
651 # ---------------------------------------------------------------------------
652
653 @runtime_checkable
654 class AddressedMergePlugin(MuseDomainPlugin, Protocol):
655 """Optional extension for plugins that use address-keyed Map merge semantics.
656
657 Plugins that implement this sub-protocol gain sub-file auto-merge: two
658 agents editing different symbol addresses never produce a conflict, because
659 the merge engine reasons over ``DomainOp`` trees keyed by address rather
660 than file paths.
661
662 This is a **Map CRDT with pluggable conflict resolution**, not classical
663 Operational Transformation. Operations on different addresses commute
664 without any transformation function. Same-address concurrent edits surface
665 as conflicts and are routed to Harmony.
666
667 The merge engine detects support at runtime via::
668
669 isinstance(plugin, AddressedMergePlugin)
670
671 Plugins that do not implement ``merge_ops`` fall back to the existing
672 file-level ``merge()`` path automatically — no changes required.
673
674 The :class:`~muse.plugins.code.plugin.CodePlugin` is the canonical
675 implementation.
676 """
677
678 def merge_ops(
679 self,
680 base: StateSnapshot,
681 ours_snap: StateSnapshot,
682 theirs_snap: StateSnapshot,
683 ours_ops: list[DomainOp],
684 theirs_ops: list[DomainOp],
685 *,
686 repo_root: pathlib.Path | None = None,
687 ) -> MergeResult:
688 """Merge two op lists against a common base using domain knowledge.
689
690 The core merge engine calls this when both branches have produced
691 ``StructuredDelta`` from ``diff()``. The plugin:
692
693 1. Calls :func:`muse.core.op_merge.merge_op_lists` to detect
694 conflicting ``DomainOp`` pairs.
695 2. For clean pairs, builds the merged ``SnapshotManifest`` by applying
696 the adjusted merged ops to *base*. The plugin uses *ours_snap* and
697 *theirs_snap* to look up the final content IDs for files touched only
698 by one side (necessary for ``PatchOp`` entries, which do not carry a
699 ``new_content_id`` directly).
700 3. For conflicting pairs, consults ``.museattributes`` (when
701 *repo_root* is provided) and either auto-resolves via the declared
702 strategy or adds the address to ``MergeResult.conflicts``.
703
704 Implementations must be domain-aware: a ``.museattributes`` rule of
705 ``merge=ours`` should take this plugin's understanding of "ours" (the
706 left branch content), not a raw file-level copy.
707
708 Args:
709 base: Common ancestor snapshot.
710 ours_snap: Final snapshot of our branch.
711 theirs_snap: Final snapshot of their branch.
712 ours_ops: Operations from our branch delta (base → ours).
713 theirs_ops: Operations from their branch delta (base → theirs).
714 repo_root: Repository root for ``.museattributes`` lookup.
715
716 Returns:
717 A :class:`MergeResult` with the reconciled snapshot and any
718 remaining unresolvable conflicts.
719 """
720 ...
721
722 # ---------------------------------------------------------------------------
723 # CRDT convergent merge — snapshot manifest and CRDTPlugin protocol
724 # ---------------------------------------------------------------------------
725
726 class CRDTSnapshotManifest(TypedDict):
727 """Extended snapshot manifest for CRDT-mode plugins.
728
729 Carries all the fields of a standard snapshot manifest plus CRDT-specific
730 metadata. The ``files`` mapping has the same semantics as
731 :class:`SnapshotManifest` — path → content hash. The additional fields
732 persist CRDT state between commits.
733
734 ``vclock`` records the causal state of the snapshot as a vector clock
735 ``{agent_id: event_count}``. It is used to detect concurrent writes and
736 to resolve LWW tiebreaks when two agents write at the same logical time.
737
738 ``crdt_state`` maps per-file-path CRDT state blobs to their SHA-256 hashes
739 in the object store. CRDT metadata (tombstones, RGA element IDs, OR-Set
740 tokens) lives here, separate from content hashes, so the content-addressed
741 store remains valid.
742
743 ``schema_version`` is the Muse package version (read from ``muse._version``).
744 """
745
746 files: Manifest
747 domain: str
748 vclock: VectorClock
749 crdt_state: CRDTState
750 schema_version: str
751
752 @runtime_checkable
753 class CRDTPlugin(MuseDomainPlugin, Protocol):
754 """Optional extension for plugins that want convergent CRDT merge semantics.
755
756 Plugins implementing this protocol replace the three-way ``merge()`` with
757 a mathematical ``join()`` on a lattice. ``join`` always succeeds:
758
759 - **No conflict state ever exists.**
760 - Any two replicas that have received the same set of writes converge to
761 the same state, regardless of delivery order.
762 - Millions of agents can write concurrently without coordination.
763
764 The three lattice laws guaranteed by ``join``:
765
766 1. **Commutativity**: ``join(a, b) == join(b, a)``
767 2. **Associativity**: ``join(join(a, b), c) == join(a, join(b, c))``
768 3. **Idempotency**: ``join(a, a) == a``
769
770 The core engine detects support at runtime via::
771
772 isinstance(plugin, CRDTPlugin)
773
774 and routes to ``join`` when ``DomainSchema.merge_mode == "crdt"``.
775 Plugins that do not implement ``CRDTPlugin`` fall back to the existing
776 three-way ``merge()`` path.
777
778 Implementation checklist for plugin authors
779 -------------------------------------------
780 1. Override ``schema()`` to return a :class:`~muse.core.schema.DomainSchema`
781 with ``merge_mode="crdt"`` and :class:`~muse.core.schema.CRDTDimensionSpec`
782 for each CRDT dimension.
783 2. Implement ``crdt_schema()`` to declare which CRDT primitive maps to each
784 dimension.
785 3. Implement ``join(a, b)`` using the CRDT primitives in
786 :mod:`muse.core.crdts`.
787 4. Implement ``to_crdt_state(snapshot)`` to lift a plain snapshot into
788 CRDT state.
789 5. Implement ``from_crdt_state(crdt)`` to materialise a CRDT state back to
790 a plain snapshot for ``muse read`` and CLI display.
791 """
792
793 def crdt_schema(self) -> list[CRDTDimensionSpec]:
794 """Declare the CRDT type used for each dimension.
795
796 Returns a list of :class:`~muse.core.schema.CRDTDimensionSpec` — one
797 per dimension that uses CRDT semantics. Dimensions not listed here
798 fall back to three-way merge.
799
800 Returns:
801 List of CRDT dimension declarations.
802 """
803 ...
804
805 def join(
806 self,
807 a: CRDTSnapshotManifest,
808 b: CRDTSnapshotManifest,
809 ) -> CRDTSnapshotManifest:
810 """Merge two CRDT snapshots by computing their lattice join.
811
812 This operation is:
813
814 - Commutative: ``join(a, b) == join(b, a)``
815 - Associative: ``join(join(a, b), c) == join(a, join(b, c))``
816 - Idempotent: ``join(a, a) == a``
817
818 These three properties guarantee convergence regardless of message
819 order or delivery count.
820
821 The implementation should use the CRDT primitives in
822 :mod:`muse.core.crdts` (one primitive per declared CRDT dimension),
823 compute the per-dimension joins, then rebuild the ``files`` manifest
824 and ``vclock`` from the results.
825
826 Args:
827 a: First CRDT snapshot manifest.
828 b: Second CRDT snapshot manifest.
829
830 Returns:
831 A new :class:`CRDTSnapshotManifest` that is the join of *a* and *b*.
832 """
833 ...
834
835 def to_crdt_state(self, snapshot: StateSnapshot) -> CRDTSnapshotManifest:
836 """Lift a plain snapshot into CRDT state representation.
837
838 Called when importing a snapshot that was created before this plugin
839 opted into CRDT mode. The implementation should initialise fresh CRDT
840 primitives from the snapshot content, with an empty vector clock.
841
842 Args:
843 snapshot: A plain :class:`StateSnapshot` to lift.
844
845 Returns:
846 A :class:`CRDTSnapshotManifest` with the same content and empty
847 CRDT metadata (zero vector clock, empty ``crdt_state``).
848 """
849 ...
850
851 def from_crdt_state(self, crdt: CRDTSnapshotManifest) -> StateSnapshot:
852 """Materialise a CRDT state back to a plain snapshot.
853
854 Used by ``muse read``, ``muse status``, and CLI commands that need a
855 standard :class:`StateSnapshot` view of a CRDT-mode snapshot.
856
857 Args:
858 crdt: A :class:`CRDTSnapshotManifest` to materialise.
859
860 Returns:
861 A plain :class:`StateSnapshot` with the visible (non-tombstoned)
862 content.
863 """
864 ...
865
866 # ---------------------------------------------------------------------------
867 # Rerere optional extension — domain-aware conflict fingerprinting
868 # ---------------------------------------------------------------------------
869
870 @runtime_checkable
871 class HarmonyPlugin(MuseDomainPlugin, Protocol):
872 """Optional extension for plugins that provide domain-aware harmony fingerprinting.
873
874 The default harmony fingerprint is::
875
876 SHA-256( min(ours_id, theirs_id) + ":" + max(ours_id, theirs_id) )
877
878 This is content-addressed and commutative, but it can fail to recognise
879 "the same conflict" when the two conflicting blobs differ by surrounding
880 context (e.g. tempo metadata in MIDI, or import ordering in code).
881
882 Plugins implementing this sub-protocol can return a richer fingerprint that
883 captures only the *semantically meaningful* parts of the conflict — allowing
884 harmony to recognise and replay resolutions across superficially different
885 but semantically identical conflicts.
886
887 The core engine detects support at runtime via::
888
889 isinstance(plugin, HarmonyPlugin)
890
891 Plugins that do not implement this fall back to the default content
892 fingerprint automatically — no changes required to the harmony engine.
893
894 Example: a MIDI plugin might compute::
895
896 SHA-256( sorted_note_events(ours) + ":" + sorted_note_events(theirs) )
897
898 so that a re-timed MIDI file that otherwise has the same musical conflict
899 matches an existing harmony pattern.
900 """
901
902 def conflict_fingerprint(
903 self,
904 path: str,
905 ours_id: str,
906 theirs_id: str,
907 repo_root: pathlib.Path,
908 ) -> str:
909 """Return a stable fingerprint identifying this conflict's semantic shape.
910
911 The returned string must be exactly 64 lowercase hex characters (the
912 same format as a SHA-256 digest). If the implementation cannot produce
913 a valid fingerprint (e.g. the blob is not in the local store), it should
914 raise an exception — the caller will fall back to the default fingerprint.
915
916 The result must be:
917
918 - **Deterministic**: the same inputs always produce the same fingerprint.
919 - **Commutative**: ``fingerprint(p, a, b, root) == fingerprint(p, b, a, root)``
920 — the order of ours/theirs must not matter.
921 - **Collision-resistant**: different conflicts should produce different
922 fingerprints with high probability.
923
924 Args:
925 path: Workspace-relative POSIX path of the conflicting file.
926 ours_id: SHA-256 object ID of the "ours" blob.
927 theirs_id: SHA-256 object ID of the "theirs" blob.
928 repo_root: Repository root for loading blob content from the store.
929
930 Returns:
931 64-char lowercase hex fingerprint.
932 """
933 ...
934
935 # ---------------------------------------------------------------------------
936 # Staging optional extension — code-domain selective commit index
937 # ---------------------------------------------------------------------------
938
939 class StagedEntry(TypedDict):
940 """One file's staging record inside the code-domain stage index.
941
942 ``object_id`` is the SHA-256 of the *staged* version of the file (which
943 may differ from the current working-tree version if the file was modified
944 after staging). ``mode`` records why this file is staged:
945
946 - ``"A"`` — added (new file not present in the previous commit)
947 - ``"M"`` — modified (file exists in the previous commit)
948 - ``"D"`` — deleted (file will be removed from the next commit)
949
950 ``staged_at`` is an ISO-8601 timestamp set when the entry was created.
951 """
952
953 object_id: str
954 mode: Literal["A", "M", "D"]
955 staged_at: str
956
957 class DirStatus(TypedDict):
958 """Directory-level changes visible in the working tree.
959
960 Only explicitly-empty directories are tracked here — directories that
961 contain tracked files are implicitly represented by their file entries and
962 do not appear in this structure.
963
964 ``added``
965 Empty directories present on disk that are not committed and not staged.
966 Shown as untracked new directories in ``muse status``.
967
968 ``deleted``
969 Empty directories present in the HEAD commit's ``directories`` list
970 that no longer exist on disk and have not been staged for deletion.
971
972 ``staged_added``
973 Empty directories staged for inclusion in the next commit (mode ``"A"``
974 in the stage index with the ``"dir:"`` sentinel object_id).
975
976 ``staged_deleted``
977 Empty directories staged for removal from the next commit (mode ``"D"``
978 in the stage index with the ``"dir:"`` sentinel object_id). These are
979 committed directories the user has explicitly removed via ``muse mv`` or
980 ``muse rm``.
981
982 ``staged_renamed``
983 Explicit directory renames staged via ``muse mv`` — ``{old_path: new_path}``
984 with no trailing slash. Used by ``muse status`` and ``muse diff`` to
985 display "renamed: old/ → new/" instead of separate delete + add.
986 """
987
988 added: list[str]
989 deleted: list[str]
990 staged_added: list[str]
991 staged_deleted: list[str]
992 staged_renamed: dict[str, str]
993
994
995 class StageStatus(TypedDict):
996 """Three-bucket view of working-tree state when a stage is active.
997
998 Returned by :meth:`StagePlugin.stage_status` and consumed by
999 ``muse status`` to render the familiar Git-style staged/unstaged split.
1000
1001 ``staged``
1002 Files whose next-commit version is pinned in the stage index.
1003 Maps path → ``{"mode": "A"|"M"|"D"}``.
1004
1005 ``unstaged``
1006 Tracked files with working-tree changes that have *not* been staged.
1007 Maps path → human label (``"modified"``, ``"deleted"``).
1008
1009 ``untracked``
1010 Files present on disk but not tracked by the previous commit and not
1011 in the stage.
1012
1013 ``renamed``
1014 Working-tree renames detected by content_id matching: a committed file
1015 disappeared from its original path but an untracked file with an
1016 identical content_id appeared. Maps old_path → new_path. These
1017 paths are removed from ``unstaged`` (deleted) and ``untracked``
1018 respectively so callers see each file in exactly one bucket.
1019
1020 ``directories``
1021 Empty-directory changes. See :class:`DirStatus`.
1022 """
1023
1024 staged: StageIndex
1025 unstaged: Manifest
1026 untracked: list[str]
1027 renamed: Manifest
1028 directories: DirStatus
1029
1030 @runtime_checkable
1031 class StagePlugin(MuseDomainPlugin, Protocol):
1032 """Optional extension for plugins that support selective commit staging.
1033
1034 Implementing this sub-protocol adds a Git-like index layer to a domain:
1035 ``muse code add`` stages specific files, and ``muse commit`` commits only
1036 what is staged, leaving unstaged changes invisible to the commit.
1037
1038 The core engine detects support at runtime via::
1039
1040 isinstance(plugin, StagePlugin)
1041
1042 Plugins that do not implement ``StagePlugin`` commit the full working
1043 tree on every ``muse commit`` (the original Muse behaviour).
1044
1045 The stage index persists at ``.muse/code/stage.json`` (the path is
1046 determined by :meth:`stage_index_path`). Its format is defined by
1047 :class:`~muse.plugins.code.stage.StageIndex`.
1048 """
1049
1050 def stage_index_path(self, root: pathlib.Path) -> pathlib.Path:
1051 """Return the absolute path of the stage index file.
1052
1053 Conventionally ``.muse/<domain>/stage.json``. Exposing this on the
1054 protocol lets ``muse status`` and ``muse commit`` locate the index
1055 without hard-coding a domain-specific path.
1056
1057 Args:
1058 root: Repository root directory (contains ``.muse/``).
1059
1060 Returns:
1061 Absolute :class:`pathlib.Path` of the stage index file.
1062 """
1063 ...
1064
1065 def read_stage(self, root: pathlib.Path) -> StageIndex:
1066 """Read the stage index, returning an empty dict when no stage exists.
1067
1068 Args:
1069 root: Repository root directory.
1070
1071 Returns:
1072 Mapping of workspace-relative POSIX path → :class:`StagedEntry`.
1073 """
1074 ...
1075
1076 def write_stage(
1077 self, root: pathlib.Path, entries: StageIndex
1078 ) -> None:
1079 """Persist *entries* as the stage index.
1080
1081 Passing an empty dict clears the stage (removes the index file).
1082
1083 Args:
1084 root: Repository root directory.
1085 entries: New stage contents.
1086 """
1087 ...
1088
1089 def clear_stage(self, root: pathlib.Path) -> None:
1090 """Remove the stage index, resetting to full-snapshot mode.
1091
1092 Args:
1093 root: Repository root directory.
1094 """
1095 ...
1096
1097 def stage_status(self, root: pathlib.Path) -> StageStatus:
1098 """Return a three-bucket view of the working tree against the stage.
1099
1100 Compares the current working tree against both the stage index and the
1101 most recent commit to produce three disjoint sets:
1102
1103 1. **staged** — files whose committed version will be the staged blob.
1104 2. **unstaged** — tracked files with working-tree changes not yet staged.
1105 3. **untracked** — files on disk that are not tracked and not staged.
1106
1107 Args:
1108 root: Repository root directory.
1109
1110 Returns:
1111 A :class:`StageStatus` TypedDict with the three buckets.
1112 """
1113 ...
1114
1115 def workdir_snapshot(self, root: pathlib.Path) -> SnapshotManifest:
1116 """Capture the raw working tree, bypassing any active stage.
1117
1118 Unlike :meth:`~MuseDomainPlugin.snapshot`, this method always reflects
1119 the actual on-disk state — staged entries are not substituted. This
1120 is used by ``muse diff --working`` to show what has changed in the
1121 working tree relative to HEAD, including files that have not yet been
1122 staged.
1123
1124 Args:
1125 root: Repository root directory.
1126
1127 Returns:
1128 A :class:`SnapshotManifest` mapping workspace-relative POSIX paths
1129 to their content hashes.
1130 """
1131 ...
File History 2 commits
sha256:8c872e4dffa2db45a9629956256fa1c99a3d2ff33b80c055252e58d94a0e8d1b feat: staged directory renames shown as renamed in muse status Sonnet 4.6 minor 51 days ago
sha256:94c593758c9f8d75fc1c8020e7d62a93305ce1478afb82d2db272bd7c1702714 feat: muse mv supports directories; fix staged_deleted dirs… Sonnet 4.6 minor 51 days ago