gabriel / muse public
engine.py python
827 lines 31.8 KB
Raw
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47 feat(merge): add --explain flag with per-path decision trac… Sonnet 4.6 minor ⚠ breaking 36 days ago
1 """muse harmony engine — three-tier resolution intelligence pipeline.
2
3 The engine evaluates incoming :class:`~muse.core.harmony.ConflictPattern`
4 instances against the harmony store and produces an :class:`EngineResult`
5 indicating what action to take. The four tiers are evaluated in order and
6 the first matching tier wins:
7
8 Tier 1 — **Policy match**
9 Load all :class:`~muse.core.harmony.Policy` entries (scope-sorted:
10 workspace → repo → domain → file) and evaluate each condition against
11 the pattern. The first matching policy determines the action:
12
13 - ``PREFER_OURS`` / ``PREFER_THEIRS`` at or above
14 :attr:`EngineConfig.auto_apply_threshold` → ``status="applied"``.
15 - Same actions *below* threshold → ``status="proposed"`` with
16 ``requires_confirmation=True``.
17 - ``ESCALATE`` / ``REQUIRE_HUMAN`` / ``DELEGATE`` → ``status="escalated"``.
18
19 Tier 2 — **Exact replay**
20 Look up the best :class:`~muse.core.harmony.Resolution` for the pattern
21 (ranked by ``human_verified`` > ``confidence`` > ``applied_count``):
22
23 - ``human_verified=True`` OR ``confidence`` ≥ threshold → ``status="applied"``,
24 :func:`~muse.core.harmony.increment_applied_count` called atomically.
25 - Below threshold → ``status="proposed"`` (``requires_confirmation=True``).
26
27 Tier 3 — **Semantic match**
28 Call :func:`find_similar` to discover patterns with semantically similar
29 fingerprints. The :class:`HarmonyPlugin` ``similarity()`` method scores
30 each pair; matches at or above :attr:`EngineConfig.semantic_threshold`
31 are returned as proposals with ``requires_confirmation=True``. Semantic
32 matches are always proposed — never auto-applied — because they are
33 structural analogies, not exact replays.
34
35 Tier 4 — **Escalate**
36 No match found in any tier. Returns ``status="escalated"`` with a
37 human-readable reason string. An audit entry of type
38 :attr:`~muse.core.harmony.AuditEventType.ESCALATION_RECORDED` is written.
39
40 Plugin protocol
41 ---------------
42
43 :class:`HarmonyPlugin` is a structural :class:`typing.Protocol` — any class
44 with a ``similarity(fp_a, fp_b) -> float`` method satisfies it without
45 explicit inheritance. :class:`DefaultPlugin` is the no-op baseline: it
46 returns ``1.0`` for identical fingerprints and ``0.0`` for all others,
47 which degrades cleanly to exact-fingerprint semantic matching.
48
49 Plugin errors (exceptions from ``similarity()``) are caught and logged;
50 the engine falls back to :class:`DefaultPlugin` behaviour for that pair.
51 Plugin-returned similarity values outside ``[0.0, 1.0]`` are clamped before
52 comparison.
53
54 Audit
55 -----
56
57 The engine writes an audit entry for every resolution applied or escalated.
58 The :data:`~muse.core.harmony.AuditEventType.RESOLUTION_APPLIED` event
59 records which resolution was replayed; the
60 :data:`~muse.core.harmony.AuditEventType.ESCALATION_RECORDED` event records
61 why no automatic resolution was possible.
62
63 Thread safety
64 -------------
65
66 All writes delegate to :mod:`muse.core.harmony` functions which use atomic
67 ``os.replace`` — safe for concurrent engine calls on the same repo.
68 """
69
70 import logging
71 import pathlib
72 from dataclasses import dataclass, field
73 from typing import Protocol, runtime_checkable
74
75 from muse.core.types import JsonObject, JsonValue, Manifest, short_id
76 from muse.core.validation import validate_object_id
77
78 from .audit import append_audit
79 from .fingerprint import (
80 _now_utc,
81 blob_fingerprint,
82 compute_pattern_id,
83 compute_resolution_id,
84 compute_semantic_fingerprint,
85 )
86 from .patterns import list_patterns, load_pattern, record_pattern
87 from .policies import list_policies, match_policy
88 from .resolutions import best_resolution, increment_applied_count, list_resolutions, save_resolution
89 from .types import (
90 AgentProvenance,
91 AuditEventType,
92 ConflictPattern,
93 ConflictType,
94 PolicyAction,
95 Resolution,
96 ResolutionProposal,
97 ResolutionStrategy,
98 )
99
100 logger = logging.getLogger(__name__)
101
102 # ---------------------------------------------------------------------------
103 # Open string-constant namespace
104 # ---------------------------------------------------------------------------
105
106 class EngineStatus:
107 """Possible outcomes of a :func:`resolve` call.
108
109 ``APPLIED`` — a resolution was auto-applied (confidence ≥ threshold or
110 human_verified, or policy fired at or above threshold).
111 ``PROPOSED`` — a candidate was found but requires confirmation before
112 applying (confidence below threshold, or semantic match).
113 ``ESCALATED`` — no match found in any tier; human or specialist-agent
114 intervention is required.
115 """
116
117 APPLIED = "applied"
118 PROPOSED = "proposed"
119 ESCALATED = "escalated"
120
121 # ---------------------------------------------------------------------------
122 # Plugin protocol
123 # ---------------------------------------------------------------------------
124
125 @runtime_checkable
126 class HarmonyPlugin(Protocol):
127 """Structural protocol for domain-specific similarity scoring.
128
129 Any class implementing ``similarity(fp_a, fp_b) -> float`` satisfies this
130 protocol without explicit inheritance. The method must return a value in
131 ``[0.0, 1.0]``; values outside this range are clamped by the engine.
132
133 Domain plugins (MIDI, code, …) override ``similarity`` to implement
134 richer comparisons — e.g. structural graph edit distance on AST
135 fingerprints, or pitch-class vector cosine similarity for MIDI tracks.
136 """
137
138 def similarity(self, fp_a: str, fp_b: str) -> float:
139 """Return a similarity score in ``[0.0, 1.0]`` between two fingerprints."""
140 ...
141
142 class DefaultPlugin:
143 """No-op plugin — exact fingerprint match only.
144
145 Returns ``1.0`` when the two fingerprints are identical (same semantic
146 content), ``0.0`` otherwise. This degrades semantic matching to exact
147 fingerprint replay, which is always safe.
148 """
149
150 def similarity(self, fp_a: str, fp_b: str) -> float:
151 """Return 1.0 for identical fingerprints, 0.0 for all others."""
152 return 1.0 if fp_a == fp_b else 0.0
153
154 # ---------------------------------------------------------------------------
155 # Engine configuration
156 # ---------------------------------------------------------------------------
157
158 @dataclass(frozen=True)
159 class EngineConfig:
160 """Thresholds and limits that govern engine behaviour.
161
162 ``auto_apply_threshold``
163 Minimum confidence required to auto-apply a resolution without
164 confirmation. Resolutions below this threshold are *proposed*.
165 Human-verified resolutions bypass this check and are always applied.
166
167 ``semantic_threshold``
168 Minimum :class:`HarmonyPlugin` similarity score for a pattern to be
169 considered a semantic match. Pairs below this threshold are ignored.
170
171 ``max_proposals``
172 Maximum number of semantic proposals returned by :func:`find_similar`.
173 Higher values increase recall at the cost of latency on large stores.
174 """
175
176 auto_apply_threshold: float = 0.85
177 semantic_threshold: float = 0.70
178 max_proposals: int = 5
179
180 # ---------------------------------------------------------------------------
181 # Engine result
182 # ---------------------------------------------------------------------------
183
184 @dataclass(frozen=True)
185 class EngineResult:
186 """Outcome of a single :func:`resolve` call.
187
188 ``status``
189 One of :class:`EngineStatus` constants — ``"applied"``, ``"proposed"``,
190 or ``"escalated"``.
191
192 ``pattern_id``
193 The ``pattern_id`` of the :class:`~muse.core.harmony.ConflictPattern`
194 that was evaluated.
195
196 ``proposal``
197 Populated for ``"applied"`` and ``"proposed"`` statuses. Contains the
198 :class:`~muse.core.harmony.ResolutionProposal` that the engine produced.
199 ``None`` for ``"escalated"``.
200
201 ``applied_resolution_id``
202 Set when an existing :class:`~muse.core.harmony.Resolution` was replayed
203 exactly (exact-replay tier). ``None`` for policy-driven applications
204 (no pre-existing resolution) and all other statuses.
205
206 ``escalation_reason``
207 Human-readable explanation populated for ``"escalated"`` results.
208 ``None`` for other statuses.
209 """
210
211 status: str
212 pattern_id: str
213 proposal: ResolutionProposal | None = None
214 applied_resolution_id: str | None = None
215 escalation_reason: str | None = None
216
217 # ---------------------------------------------------------------------------
218 # find_similar
219 # ---------------------------------------------------------------------------
220
221 def find_similar(
222 root: pathlib.Path,
223 pattern: ConflictPattern,
224 *,
225 plugin: HarmonyPlugin | None = None,
226 config: EngineConfig | None = None,
227 ) -> list[ResolutionProposal]:
228 """Search the harmony store for patterns semantically similar to *pattern*.
229
230 Iterates all stored patterns, calls *plugin*``similarity()`` on each pair
231 of semantic fingerprints, and returns proposals for patterns whose
232 similarity score meets :attr:`EngineConfig.semantic_threshold` and that
233 have at least one saved resolution.
234
235 Results are sorted by ``confidence`` descending (best proposal first) and
236 capped at :attr:`EngineConfig.max_proposals`.
237
238 Args:
239 root: Repository root.
240 pattern: The incoming conflict pattern to match against.
241 plugin: Domain plugin for similarity scoring. Defaults to
242 :class:`DefaultPlugin`.
243 config: Engine configuration. Defaults to :class:`EngineConfig`.
244
245 Returns:
246 List of :class:`~muse.core.harmony.ResolutionProposal` instances,
247 sorted by confidence descending. Empty if no matches exceed the
248 threshold.
249 """
250 cfg = config or EngineConfig()
251 plug = plugin or DefaultPlugin()
252
253 try:
254 all_patterns = list_patterns(root)
255 except Exception:
256 return []
257
258 proposals: list[ResolutionProposal] = []
259
260 for p in all_patterns:
261 if p.pattern_id == pattern.pattern_id:
262 continue # skip self
263
264 try:
265 raw_sim = plug.similarity(pattern.semantic_fingerprint, p.semantic_fingerprint)
266 sim = max(0.0, min(1.0, float(raw_sim)))
267 except Exception as exc:
268 logger.warning("⚠️ harmony engine: plugin.similarity() raised %s — skipping", exc)
269 sim = 0.0
270
271 if sim < cfg.semantic_threshold:
272 continue
273
274 best = best_resolution(root, p.pattern_id)
275 if best is None:
276 continue
277
278 proposals.append(
279 ResolutionProposal(
280 pattern_id=pattern.pattern_id,
281 strategy=ResolutionStrategy.SEMANTIC_PROPOSAL,
282 proposed_action=PolicyAction.PREFER_OURS,
283 confidence=round(best.confidence * sim, 4),
284 rationale=(
285 f"Semantically similar to pattern {p.pattern_id} "
286 f"(similarity={sim:.2f}): {best.rationale}"
287 ),
288 similar_pattern_id=p.pattern_id,
289 similarity=sim,
290 requires_confirmation=True,
291 )
292 )
293
294 proposals.sort(key=lambda pr: pr.confidence, reverse=True)
295 return proposals[: cfg.max_proposals]
296
297 # ---------------------------------------------------------------------------
298 # Core engine
299 # ---------------------------------------------------------------------------
300
301 def resolve(
302 root: pathlib.Path,
303 pattern: ConflictPattern,
304 config: EngineConfig | None = None,
305 *,
306 plugin: HarmonyPlugin | None = None,
307 actor: AgentProvenance | None = None,
308 ) -> EngineResult:
309 """Run the three-tier resolution pipeline for *pattern*.
310
311 Evaluates tiers in order — policy → exact replay → semantic → escalate —
312 and returns the first successful outcome.
313
314 Args:
315 root: Repository root (need not contain the pattern — the engine
316 escalates gracefully if the store is absent or empty).
317 pattern: Incoming conflict pattern to resolve.
318 config: Engine configuration overrides. Defaults to
319 :class:`EngineConfig`.
320 plugin: Domain plugin for semantic similarity. Defaults to
321 :class:`DefaultPlugin`.
322 actor: Attribution for audit entries. Defaults to
323 :func:`~muse.core.harmony.AgentProvenance.human`.
324
325 Returns:
326 :class:`EngineResult` with ``status``, ``proposal``, and optional
327 ``applied_resolution_id`` / ``escalation_reason``.
328 """
329 cfg = config or EngineConfig()
330 plug = plugin or DefaultPlugin()
331 act = actor or AgentProvenance.human()
332
333 # ------------------------------------------------------------------
334 # Tier 1 — Policy
335 # ------------------------------------------------------------------
336 try:
337 policies = list_policies(root)
338 matched = match_policy(policies, pattern)
339 except Exception as exc:
340 logger.warning("⚠️ harmony engine: policy load failed (%s) — skipping tier 1", exc)
341 matched = None
342
343 if matched is not None:
344 action = matched.action
345 confidence = matched.confidence
346
347 if action in (PolicyAction.PREFER_OURS, PolicyAction.PREFER_THEIRS):
348 proposal = ResolutionProposal(
349 pattern_id=pattern.pattern_id,
350 strategy=ResolutionStrategy.POLICY,
351 proposed_action=action,
352 confidence=confidence,
353 rationale=(
354 f"Policy '{matched.policy_id}' fired: {matched.description}"
355 ),
356 policy_id=matched.policy_id,
357 requires_confirmation=confidence < cfg.auto_apply_threshold,
358 )
359 if confidence >= cfg.auto_apply_threshold:
360 _write_applied_audit(root, pattern.pattern_id, None, act)
361 return EngineResult(
362 status=EngineStatus.APPLIED,
363 pattern_id=pattern.pattern_id,
364 proposal=proposal,
365 )
366 else:
367 return EngineResult(
368 status=EngineStatus.PROPOSED,
369 pattern_id=pattern.pattern_id,
370 proposal=proposal,
371 )
372
373 # ESCALATE / REQUIRE_HUMAN / DELEGATE → escalate
374 if action == PolicyAction.DELEGATE:
375 reason = (
376 f"Policy '{matched.policy_id}' delegates to agent "
377 f"'{matched.delegate_to or 'unknown'}'"
378 )
379 elif action == PolicyAction.REQUIRE_HUMAN:
380 reason = f"Policy '{matched.policy_id}' requires human resolution"
381 else:
382 target = matched.escalate_to or "unspecified"
383 reason = (
384 f"Policy '{matched.policy_id}' requires escalation to {target}"
385 )
386 _write_escalation_audit(root, pattern.pattern_id, act, reason)
387 return EngineResult(
388 status=EngineStatus.ESCALATED,
389 pattern_id=pattern.pattern_id,
390 escalation_reason=reason,
391 )
392
393 # ------------------------------------------------------------------
394 # Tier 2 — Exact replay
395 # ------------------------------------------------------------------
396 try:
397 best = best_resolution(root, pattern.pattern_id)
398 except Exception as exc:
399 logger.warning("⚠️ harmony engine: exact-replay lookup failed (%s)", exc)
400 best = None
401
402 if best is not None:
403 proposal = ResolutionProposal(
404 pattern_id=pattern.pattern_id,
405 strategy=ResolutionStrategy.EXACT_REPLAY,
406 proposed_action=PolicyAction.PREFER_OURS,
407 confidence=best.confidence,
408 rationale=f"Exact replay of resolution {best.resolution_id}",
409 requires_confirmation=not (
410 best.human_verified or best.confidence >= cfg.auto_apply_threshold
411 ),
412 )
413 if best.human_verified or best.confidence >= cfg.auto_apply_threshold:
414 try:
415 increment_applied_count(root, pattern.pattern_id, best.resolution_id)
416 except Exception as exc:
417 logger.warning("⚠️ harmony engine: increment_applied_count failed: %s", exc)
418 _write_applied_audit(root, pattern.pattern_id, best.resolution_id, act)
419 return EngineResult(
420 status=EngineStatus.APPLIED,
421 pattern_id=pattern.pattern_id,
422 proposal=proposal,
423 applied_resolution_id=best.resolution_id,
424 )
425 else:
426 return EngineResult(
427 status=EngineStatus.PROPOSED,
428 pattern_id=pattern.pattern_id,
429 proposal=proposal,
430 )
431
432 # ------------------------------------------------------------------
433 # Tier 3 — Semantic match
434 # ------------------------------------------------------------------
435 try:
436 similar = find_similar(root, pattern, plugin=plug, config=cfg)
437 except Exception as exc:
438 logger.warning("⚠️ harmony engine: semantic search failed (%s)", exc)
439 similar = []
440
441 if similar:
442 best_proposal = similar[0]
443 return EngineResult(
444 status=EngineStatus.PROPOSED,
445 pattern_id=pattern.pattern_id,
446 proposal=best_proposal,
447 )
448
449 # ------------------------------------------------------------------
450 # Tier 4 — Escalate
451 # ------------------------------------------------------------------
452 reason = (
453 f"No policy, exact resolution, or semantic match found for "
454 f"pattern {pattern.pattern_id} "
455 f"(path='{pattern.path}', domain='{pattern.domain}')"
456 )
457 _write_escalation_audit(root, pattern.pattern_id, act, reason)
458 return EngineResult(
459 status=EngineStatus.ESCALATED,
460 pattern_id=pattern.pattern_id,
461 escalation_reason=reason,
462 )
463
464 # ---------------------------------------------------------------------------
465 # Audit helpers
466 # ---------------------------------------------------------------------------
467
468 def _write_applied_audit(
469 root: pathlib.Path,
470 pattern_id: str,
471 resolution_id: str | None,
472 actor: AgentProvenance,
473 ) -> None:
474 """Write a RESOLUTION_APPLIED audit entry, swallowing any IO errors."""
475 try:
476 append_audit(
477 root,
478 AuditEventType.RESOLUTION_APPLIED,
479 actor,
480 pattern_id=pattern_id,
481 resolution_id=resolution_id,
482 )
483 except Exception as exc:
484 logger.warning("⚠️ harmony engine: audit write (applied) failed: %s", exc)
485
486 def _write_escalation_audit(
487 root: pathlib.Path,
488 pattern_id: str,
489 actor: AgentProvenance,
490 reason: str,
491 ) -> None:
492 """Write an ESCALATION_RECORDED audit entry, swallowing any IO errors."""
493 try:
494 append_audit(
495 root,
496 AuditEventType.ESCALATION_RECORDED,
497 actor,
498 pattern_id=pattern_id,
499 metadata={"reason": reason},
500 )
501 except Exception as exc:
502 logger.warning("⚠️ harmony engine: audit write (escalation) failed: %s", exc)
503
504 # ---------------------------------------------------------------------------
505 # Config helpers
506 # ---------------------------------------------------------------------------
507
508 def _read_config_float(root: pathlib.Path, key: str, default: float) -> float:
509 """Read a float from .muse/config.toml by dotted key; return *default* if absent.
510
511 Traverses nested TOML tables using dot-separated *key* components.
512 Silently returns *default* on any parse or type error.
513 """
514 config_path = root / ".muse" / "config.toml"
515 if not config_path.is_file():
516 return default
517 try:
518 import tomllib
519 with config_path.open("rb") as fh:
520 raw = tomllib.load(fh)
521 val: JsonValue = raw
522 for part in key.split("."):
523 if not isinstance(val, dict):
524 return default
525 val = val.get(part, ...) # type: ignore[union-attr]
526 if val is ...:
527 return default
528 return float(val) if isinstance(val, (int, float)) else default # type: ignore[arg-type]
529 except Exception:
530 return default
531
532
533 # ---------------------------------------------------------------------------
534 # High-level integration helpers (merge and commit integration)
535 # ---------------------------------------------------------------------------
536
537 def auto_apply(
538 root: pathlib.Path,
539 conflict_paths: list[str],
540 ours_manifest: Manifest,
541 theirs_manifest: Manifest,
542 domain: str,
543 plugin: "MuseDomainPlugin",
544 *,
545 explain_decisions: JsonObject | None = None,
546 ) -> tuple[Manifest, list[str]]:
547 """Attempt to auto-resolve conflicts using saved harmony resolutions.
548
549 For each path in *conflict_paths*:
550
551 1. Validate the path stays within the repository root (path traversal guard).
552 2. Compute blob and semantic fingerprints from the ours/theirs object IDs.
553 3. Look up the best :class:`~muse.core.harmony.Resolution` for the computed
554 pattern ID.
555 4. If found: restore the resolution blob to the working tree and collect
556 the ``{path: outcome_blob}`` mapping.
557 5. If not found: record the pattern for future learning and leave the path
558 for manual resolution.
559
560 Args:
561 root: Repository root.
562 conflict_paths: Workspace-relative POSIX paths that conflicted.
563 ours_manifest: ``{path: object_id}`` for the "ours" snapshot.
564 theirs_manifest: ``{path: object_id}`` for the "theirs" snapshot.
565 domain: Active domain name string.
566 plugin: Active domain plugin instance.
567
568 Returns:
569 ``(resolved, remaining)`` where:
570
571 - ``resolved`` maps path → outcome_blob for auto-resolved paths.
572 - ``remaining`` is the list of paths still requiring manual resolution.
573 """
574 from muse.core.object_store import restore_object
575
576 resolved: dict[str, str] = {}
577 remaining: list[str] = []
578
579 for path in conflict_paths:
580 # Conflict paths from the code domain are symbol addresses of the form
581 # "file.py::SymbolName". Manifests are keyed by file path, so extract
582 # the file portion for all manifest lookups. The full address is kept
583 # as the canonical path for pattern storage and fingerprinting so that
584 # two different symbols in the same file produce distinct patterns.
585 file_path = path.split("::")[0]
586
587 # Guard against path traversal from a tampered MERGE_STATE.json.
588 # Use the file portion for the filesystem check.
589 # resolve() normalises any ".." components and symlinks before the
590 # relative_to() check, so "../traversal.py" is caught correctly.
591 try:
592 (root / file_path).resolve().relative_to(root.resolve())
593 except ValueError:
594 logger.warning(
595 "⚠️ harmony: path traversal attempt in auto_apply — skipping %r",
596 path,
597 )
598 remaining.append(path)
599 continue
600
601 ours_id = ours_manifest.get(file_path, "")
602 theirs_id = theirs_manifest.get(file_path, "")
603
604 if not ours_id or not theirs_id:
605 # One side deleted the file — cannot fingerprint, leave for manual.
606 remaining.append(path)
607 continue
608
609 blob_fp = blob_fingerprint(ours_id, theirs_id)
610 semantic_fp = compute_semantic_fingerprint(path, ours_id, theirs_id, plugin, root)
611 pattern_id = compute_pattern_id(path, blob_fp, semantic_fp)
612
613 best = best_resolution(root, pattern_id)
614 if best is not None:
615 min_confidence = _read_config_float(
616 root, "harmony.min_auto_apply_confidence", default=0.85
617 )
618 if best.confidence < min_confidence:
619 logger.info(
620 "⚠️ harmony: skipping auto-apply for '%s' — "
621 "confidence %.2f < threshold %.2f",
622 path, best.confidence, min_confidence,
623 )
624 if explain_decisions is not None:
625 explain_decisions[file_path] = {
626 "result": "below_confidence_threshold",
627 "pattern_id": pattern_id,
628 }
629 remaining.append(path)
630 continue
631
632 dest = root / file_path
633 dest.parent.mkdir(parents=True, exist_ok=True)
634 ok = restore_object(root, best.outcome_blob, dest)
635 if ok:
636 resolved[path] = best.outcome_blob
637 logger.info("✅ harmony: auto-resolved '%s'", path)
638 if explain_decisions is not None:
639 explain_decisions[file_path] = {
640 "result": "auto_resolved",
641 "pattern_id": pattern_id,
642 }
643 try:
644 increment_applied_count(root, pattern_id, best.resolution_id)
645 except Exception as exc:
646 logger.warning(
647 "⚠️ harmony: increment_applied_count failed for '%s': %s",
648 path, exc,
649 )
650 else:
651 logger.warning(
652 "⚠️ harmony: resolution blob %s for '%s' not in local store",
653 best.outcome_blob, path,
654 )
655 if explain_decisions is not None:
656 explain_decisions[file_path] = {
657 "result": "no_pattern",
658 "pattern_id": pattern_id,
659 }
660 remaining.append(path)
661 else:
662 # No saved resolution — record the pattern for future learning.
663 now = _now_utc()
664 pattern = ConflictPattern(
665 pattern_id=pattern_id,
666 path=path,
667 domain=domain,
668 conflict_type=ConflictType.CONTENT,
669 blob_fingerprint=blob_fp,
670 semantic_fingerprint=semantic_fp,
671 ours_id=ours_id,
672 theirs_id=theirs_id,
673 description={},
674 recorded_at=now,
675 recorded_by="auto_apply",
676 )
677 record_pattern(root, pattern)
678 if explain_decisions is not None:
679 explain_decisions[file_path] = {
680 "result": "no_pattern",
681 "pattern_id": None,
682 }
683 remaining.append(path)
684
685 return resolved, remaining
686
687
688 def record_resolutions(
689 root: pathlib.Path,
690 conflict_paths: list[str],
691 ours_manifest: Manifest,
692 theirs_manifest: Manifest,
693 new_manifest: Manifest,
694 domain: str,
695 plugin: "MuseDomainPlugin",
696 manually_resolved: set[str] | None = None,
697 ) -> list[str]:
698 """Record how the user resolved each conflict after a merge commit.
699
700 Called by ``muse commit`` immediately after writing a merge commit that
701 resolves a conflicted merge. For each previously conflicting path the
702 function reads the resolution object ID from the new snapshot manifest and
703 saves it to the harmony store as a :class:`~muse.core.harmony.Resolution`.
704
705 Paths present in *manually_resolved* (those explicitly confirmed via
706 ``muse resolve`` after manual editing) are recorded with
707 ``confidence=1.0, human_verified=True``. All other paths (resolved by
708 accepting one side with ``muse checkout --ours/--theirs``) are recorded
709 with ``confidence=0.8, human_verified=False`` — a deliberate choice, but
710 not a careful manual merge.
711
712 When *manually_resolved* is ``None`` (legacy callers / no MERGE_STATE
713 tracking) every path is recorded with ``confidence=1.0,
714 human_verified=True`` to preserve the pre-Phase-4 behaviour.
715
716 Args:
717 root: Repository root.
718 conflict_paths: Paths that were listed as conflicts in MERGE_STATE.
719 ours_manifest: ``{path: object_id}`` for the "ours" snapshot.
720 theirs_manifest: ``{path: object_id}`` for the "theirs" snapshot.
721 new_manifest: ``{path: object_id}`` from the committed merge snapshot.
722 domain: Active domain name string.
723 plugin: Active domain plugin instance.
724 manually_resolved: Set of conflict addresses explicitly confirmed via
725 ``muse resolve``. ``None`` → treat all as manual.
726
727 Returns:
728 List of paths for which a resolution was successfully saved.
729 """
730 saved: list[str] = []
731
732 for path in conflict_paths:
733 # Conflict paths from the code domain are symbol addresses of the form
734 # "file.py::SymbolName". Extract the file portion for manifest lookups;
735 # keep the full address as the canonical path in the harmony store.
736 file_path = path.split("::")[0]
737
738 ours_id = ours_manifest.get(file_path, "")
739 theirs_id = theirs_manifest.get(file_path, "")
740 outcome_blob = new_manifest.get(file_path, "")
741
742 if not ours_id or not theirs_id or not outcome_blob:
743 continue
744
745 try:
746 validate_object_id(outcome_blob)
747 except ValueError:
748 continue
749
750 blob_fp = blob_fingerprint(ours_id, theirs_id)
751 semantic_fp = compute_semantic_fingerprint(path, ours_id, theirs_id, plugin, root)
752 pattern_id = compute_pattern_id(path, blob_fp, semantic_fp)
753 now = _now_utc()
754
755 # Ensure the pattern exists in the harmony store.
756 if load_pattern(root, pattern_id) is None:
757 pattern = ConflictPattern(
758 pattern_id=pattern_id,
759 path=path,
760 domain=domain,
761 conflict_type=ConflictType.CONTENT,
762 blob_fingerprint=blob_fp,
763 semantic_fingerprint=semantic_fp,
764 ours_id=ours_id,
765 theirs_id=theirs_id,
766 description={},
767 recorded_at=now,
768 recorded_by="record_resolutions",
769 )
770 record_pattern(root, pattern)
771
772 # Idempotency: skip if a resolution with this outcome_blob already exists.
773 existing = list_resolutions(root, pattern_id)
774 if any(r.outcome_blob == outcome_blob for r in existing):
775 logger.debug(
776 "harmony: resolution for '%s' (pattern %s) already recorded — skipping",
777 path, short_id(pattern_id),
778 )
779 saved.append(path)
780 continue
781
782 # Paths in manually_resolved were carefully edited by the user via
783 # `muse resolve` — high confidence, human-verified. Paths resolved via
784 # `muse checkout --ours/--theirs` (side-pick) get lower confidence and
785 # human_verified=False. When manually_resolved is None (legacy callers)
786 # treat everything as human-verified to preserve pre-Phase-4 behaviour.
787 is_manual = manually_resolved is None or path in manually_resolved
788 confidence = 1.0 if is_manual else 0.8
789 resolution_id = compute_resolution_id(
790 pattern_id,
791 outcome_blob,
792 ResolutionStrategy.MANUAL,
793 AgentProvenance.human(),
794 now,
795 )
796 resolution = Resolution(
797 resolution_id=resolution_id,
798 pattern_id=pattern_id,
799 strategy=ResolutionStrategy.MANUAL,
800 policy_id=None,
801 outcome_blob=outcome_blob,
802 rationale=(
803 f"User manually resolved conflict in '{path}' via muse resolve"
804 if is_manual
805 else f"Conflict in '{path}' resolved by accepting one side (checkout --ours/--theirs)"
806 ),
807 confidence=confidence,
808 applied_count=0,
809 human_verified=is_manual,
810 resolved_at=now,
811 resolved_by=AgentProvenance.human(),
812 )
813 try:
814 save_resolution(root, resolution)
815 saved.append(path)
816 logger.info(
817 "✅ harmony: recorded resolution for '%s' (pattern %s)",
818 path,
819 pattern_id,
820 )
821 except Exception as exc:
822 logger.warning(
823 "⚠️ harmony: failed to save resolution for '%s': %s",
824 path, exc,
825 )
826
827 return saved
File History 2 commits
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47 feat(merge): add --explain flag with per-path decision trac… Sonnet 4.6 minor 36 days ago
sha256:f0cc3f3fe40f1df9a488585b5b2aef6aa4dcb0b22b5fdbf7bab7071a5b0c7f7c feat(merge): Phase 7 — independence merge correctness + Har… Sonnet 4.6 minor 36 days ago