gabriel / muse public
harmony.py python
2,185 lines 79.3 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 days ago
1 """muse harmony — Resolution Intelligence for domain-agnostic conflict resolution.
2
3 Harmony is a three-tier resolution intelligence layer built from the ground up
4 for an agent-first, multi-dimensional VCS.
5
6 Core concepts
7 -------------
8
9 ConflictPattern
10 The semantic identity of a divergence between two branches. Every pattern
11 carries both a *blob fingerprint* (SHA-256 of sorted object-ID pair, for
12 exact matching) and a *semantic fingerprint* (domain-provided, for fuzzy
13 matching across structurally similar conflicts). The two fingerprints
14 allow exact replay as a degenerate case of the richer semantic model.
15
16 Resolution
17 A committed decision: what the resolved state is, who made it, which
18 strategy was applied, and a human-readable rationale. ``applied_count``
19 tracks replay frequency and informs confidence ranking.
20
21 Policy
22 A declarative rule that fires automatically when a conflict pattern matches
23 its condition. Conditions constrain ``conflict_type``, ``domain``, path
24 glob, and minimum confidence. Policies are evaluated in scope order:
25 workspace → repo → domain → file. The first matching policy wins.
26
27 ResolutionProposal
28 A candidate resolution returned by the harmony engine when no exact match
29 exists but a semantic or policy match is available. Always carries a
30 ``confidence`` score so agents can decide whether to auto-accept or escalate.
31
32 Resolution engine (Phase 3)
33 The engine evaluates conflicts in this order:
34 1. Policy match — declarative rule fires automatically.
35 2. Exact replay — blob fingerprint matches a saved resolution.
36 3. Semantic match — domain plugin similarity score ≥ threshold.
37 4. Escalate — create hub issue, flag for human or specialist agent.
38
39 Storage layout
40 --------------
41
42 ::
43
44 .muse/harmony/
45 policies/
46 <policy_id>.json ← one file per policy (Phase 2 migrates to TOML)
47 patterns/
48 <pattern_id>/
49 pattern.json ← ConflictPattern metadata
50 resolutions/
51 <resolution_id>.json ← one Resolution per file
52 audit/
53 <YYYYMMDD>-<sha256-hex-slice>.json ← append-only audit log
54
55 Security model
56 --------------
57
58 Pattern and resolution IDs are validated as exactly 64 lowercase hex characters
59 before any filesystem path is constructed — preventing ``../`` path-traversal.
60 Policy IDs are validated as URL-safe alphanumeric strings. All directory
61 iteration skips symlinks. File reads are capped at :data:`_MAX_PATTERN_BYTES`,
62 :data:`_MAX_RESOLUTION_BYTES`, and :data:`_MAX_POLICY_BYTES` to prevent OOM
63 from maliciously large entries. All writes are atomic (temp file + ``os.replace``).
64
65 Thread safety
66 -------------
67
68 All writes use ``os.replace`` (POSIX rename), which is atomic on all supported
69 platforms. Concurrent readers always see either a complete or absent file —
70 never a partial write.
71
72 Extensibility
73 -------------
74
75 :class:`ConflictType`, :class:`ResolutionStrategy`, :class:`PolicyAction`, and
76 :class:`PolicyScope` are open string-constant namespaces rather than closed
77 enums. Domain plugins may define additional constants (e.g. ``"note_collision"``,
78 ``"tempo_divergence"``) without modifying this module.
79 """
80
81 import datetime
82 import fnmatch
83 import hashlib
84 import json
85 import logging
86 import os
87 import pathlib
88 import re
89 import tempfile
90 from collections.abc import Mapping
91 from dataclasses import dataclass, replace as dc_replace
92 from typing import Literal, TypedDict
93
94 from muse.core.paths import harmony_dir as _harmony_dir
95 from muse.core.types import JsonValue, Manifest, content_hash, load_json_file, long_id, short_id, split_id
96 from muse.core.validation import validate_object_id
97
98 logger = logging.getLogger(__name__)
99
100 # ---------------------------------------------------------------------------
101 # Type aliases
102 # ---------------------------------------------------------------------------
103
104 # Domain-specific conflict description metadata: arbitrary JSON-safe dict.
105 type _ConflictDescription = dict[str, JsonValue]
106
107 # Audit event metadata: event-specific extra fields.
108 type _AuditMetadata = dict[str, JsonValue]
109
110 # AgentProvenance.to_dict() output shape.
111 type _ProvenanceDict = dict[str, str | None]
112
113 # ---------------------------------------------------------------------------
114 # Constants
115 # ---------------------------------------------------------------------------
116
117 _HARMONY = "harmony"
118 _PATTERNS = "patterns"
119 _POLICIES = "policies"
120 _AUDIT = "audit"
121 _ESCALATIONS = "escalations"
122 _RESOLUTIONS = "resolutions"
123 _PATTERN_FILE = "pattern.json"
124
125 #: Maximum bytes read from ``pattern.json``. Patterns carry rich structured
126 #: descriptions but must not be unbounded.
127 _MAX_PATTERN_BYTES: int = 32_768 # 32 KiB
128
129 #: Maximum bytes read from a resolution JSON file.
130 _MAX_RESOLUTION_BYTES: int = 16_384 # 16 KiB
131
132 #: Maximum bytes read from a policy JSON file.
133 _MAX_POLICY_BYTES: int = 8_192 # 8 KiB
134
135 #: Maximum bytes read from a single audit log entry.
136 _MAX_AUDIT_BYTES: int = 4_096 # 4 KiB
137
138 #: Maximum bytes read from a single escalation record file.
139 _MAX_ESCALATION_BYTES: int = 16_384 # 16 KiB
140
141 #: Maximum bytes for a semantic fingerprint string.
142 #: Fingerprints may be rich token-bag strings (code plugin) rather than hex64.
143 _MAX_FINGERPRINT_BYTES: int = 4_096 # 4 KiB
144
145 #: Maximum patterns scanned by :func:`list_patterns` to protect degenerate repos.
146 _MAX_SCAN: int = 100_000
147
148 #: Maximum policies loaded in a single :func:`list_policies` call.
149 _MAX_POLICIES: int = 1_000
150
151 #: Maximum audit entries returned by :func:`list_audit`.
152 _MAX_AUDIT_ENTRIES: int = 10_000
153
154 #: Regex matching a canonical content-addressed ID: ``sha256:`` + 64 lowercase hex chars.
155 _SHA256_ID_RE: re.Pattern[str] = re.compile(r"^sha256:[0-9a-f]{64}$")
156
157 #: Regex matching a bare 64-char lowercase hex string (used for filesystem dir names).
158 _BARE_HEX64_RE: re.Pattern[str] = re.compile(r"^[0-9a-f]{64}$")
159
160 #: Regex matching a valid policy ID: alphanumeric + hyphen + underscore, 1–128 chars.
161 _POLICY_ID_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
162
163 # _id_hex is retired — use split_id() from muse.core.types directly.
164
165 # ---------------------------------------------------------------------------
166 # Open string-constant namespaces (extensible by domain plugins)
167 # ---------------------------------------------------------------------------
168
169 class ConflictType:
170 """Semantic category of a conflict.
171
172 Domain plugins may define additional types beyond these built-in constants.
173 All values are plain strings so plugin-defined types are always valid in
174 :class:`PolicyCondition` and :class:`ConflictPattern` without modifying
175 this module.
176
177 Built-in types
178 --------------
179 ``STRUCTURAL`` — schema, time signature, file structure.
180 ``CONTENT`` — notes, function bodies, actual payload data.
181 ``METADATA`` — tempo, tags, BPM, author annotations.
182 ``RELATIONAL`` — cross-dimensional or cross-file dependencies.
183 ``UNKNOWN`` — conflict type could not be determined.
184 """
185
186 STRUCTURAL = "structural"
187 CONTENT = "content"
188 METADATA = "metadata"
189 RELATIONAL = "relational"
190 UNKNOWN = "unknown"
191
192 class ResolutionStrategy:
193 """How a :class:`Resolution` was produced.
194
195 ``POLICY`` — a declarative :class:`Policy` fired automatically.
196 ``EXACT_REPLAY`` — blob fingerprint matched an existing resolution exactly.
197 ``SEMANTIC_PROPOSAL`` — domain plugin similarity score exceeded threshold.
198 ``MANUAL`` — a human or agent resolved the conflict interactively.
199 """
200
201 POLICY = "policy"
202 EXACT_REPLAY = "exact-replay"
203 SEMANTIC_PROPOSAL = "semantic-proposal"
204 MANUAL = "manual"
205
206 class PolicyAction:
207 """What a matching :class:`Policy` does when it fires.
208
209 ``PREFER_OURS`` — adopt the "ours" version of the conflicting content.
210 ``PREFER_THEIRS`` — adopt the "theirs" version of the conflicting content.
211 ``ESCALATE`` — create a hub issue or notify; see ``Policy.escalate_to``.
212 ``REQUIRE_HUMAN`` — block until a human explicitly resolves.
213 ``DELEGATE`` — route to a specialist agent; see ``Policy.delegate_to``.
214 """
215
216 PREFER_OURS = "prefer-ours"
217 PREFER_THEIRS = "prefer-theirs"
218 ESCALATE = "escalate"
219 REQUIRE_HUMAN = "require-human"
220 DELEGATE = "delegate"
221
222 class PolicyScope:
223 """Scope hierarchy for :class:`Policy` evaluation.
224
225 Policies are evaluated in this order — higher scope takes precedence:
226 ``WORKSPACE`` → ``REPO`` → ``DOMAIN`` → ``FILE``.
227
228 A workspace-level policy fires for all repos in the workspace; a
229 file-level policy fires only for paths matching its ``path_pattern``.
230 """
231
232 WORKSPACE = "workspace"
233 REPO = "repo"
234 DOMAIN = "domain"
235 FILE = "file"
236
237 class AuditEventType:
238 """Event types written to the harmony audit log.
239
240 The audit log is append-only — entries are never modified after writing,
241 creating a tamper-evident record of all harmony engine actions.
242 """
243
244 PATTERN_RECORDED = "pattern_recorded"
245 RESOLUTION_SAVED = "resolution_saved"
246 RESOLUTION_APPLIED = "resolution_applied"
247 ESCALATION_RECORDED = "escalation_recorded"
248 ESCALATION_RESOLVED = "escalation_resolved"
249 PATTERN_FORGOTTEN = "pattern_forgotten"
250 POLICY_SAVED = "policy_saved"
251 POLICY_REMOVED = "policy_removed"
252 GC_RUN = "gc_run"
253 CLEAR_RUN = "clear_run"
254
255 class EscalationStatus:
256 """Status of an :class:`EscalationRecord`.
257
258 ``OPEN`` — the conflict is unresolved; waiting for human or agent.
259 ``RESOLVED`` — a resolution was applied and the escalation was closed.
260 """
261
262 OPEN = "open"
263 RESOLVED = "resolved"
264
265 # ---------------------------------------------------------------------------
266 # Dataclasses
267 # ---------------------------------------------------------------------------
268
269 @dataclass(frozen=True)
270 class AgentProvenance:
271 """Attribution for a resolution or policy action.
272
273 ``type`` is always ``"agent"`` or ``"human"``. Agent actions carry
274 ``agent_id`` and optionally ``model_id``; human actions leave both ``None``.
275
276 Use the convenience constructors :meth:`human` and :meth:`agent` rather
277 than calling the dataclass directly.
278 """
279
280 type: Literal["agent", "human"]
281 agent_id: str | None = None
282 model_id: str | None = None
283
284 @classmethod
285 def human(cls) -> "AgentProvenance":
286 """Return provenance for a direct human action."""
287 return cls(type="human")
288
289 @classmethod
290 def agent(cls, agent_id: str, model_id: str | None = None) -> "AgentProvenance":
291 """Return provenance for an agent action.
292
293 Args:
294 agent_id: Identifies the agent type, e.g. ``"claude-code"``.
295 model_id: Specific model, e.g. ``"claude-sonnet-4-6"``.
296 """
297 return cls(type="agent", agent_id=agent_id, model_id=model_id)
298
299 def to_dict(self) -> _ProvenanceDict:
300 """Serialise to a JSON-compatible dict."""
301 return {"type": self.type, "agent_id": self.agent_id, "model_id": self.model_id}
302
303 @classmethod
304 def from_dict(cls, data: Mapping[str, JsonValue]) -> "AgentProvenance":
305 """Deserialise from a JSON dict, tolerating missing keys."""
306 return cls(
307 type=str(data.get("type", "human")), # type: ignore[arg-type]
308 agent_id=data.get("agent_id") or None,
309 model_id=data.get("model_id") or None,
310 )
311
312 @dataclass(frozen=True)
313 class PolicyCondition:
314 """Predicate evaluated against a :class:`ConflictPattern`.
315
316 ``None`` fields are wildcards — they match any value. All non-``None``
317 fields must match for the condition to be satisfied. ``path_pattern``
318 uses :func:`fnmatch.fnmatch` glob semantics.
319
320 ``min_confidence`` is a proposal-time filter evaluated by the harmony
321 engine against the resolution proposal's confidence score, not against
322 any field of the static pattern.
323 """
324
325 conflict_type: str | None = None # None = any ConflictType value
326 domain: str | None = None # None = any domain
327 path_pattern: str | None = None # fnmatch glob; None = any path
328 min_confidence: float | None = None # minimum confidence for auto-acceptance
329
330 @dataclass(frozen=True)
331 class ConflictPattern:
332 """The semantic identity of a merge conflict.
333
334 Two fingerprints
335 ~~~~~~~~~~~~~~~~
336 ``blob_fingerprint`` — SHA-256 of the lexicographically sorted pair of
337 ``(ours_id, theirs_id)``. Enables exact replay regardless of domain —
338 enables exact blob-level replay.
339
340 ``semantic_fingerprint`` — provided by a domain's ``HarmonyPlugin`` when
341 one is active. For repos without a plugin, falls back to
342 ``blob_fingerprint``. Enables fuzzy matching across structurally similar
343 conflicts even when blob IDs differ.
344
345 ``pattern_id`` — SHA-256 of ``(path + ":" + blob_fingerprint + ":" +
346 semantic_fingerprint)``. Uniquely identifies a conflict for a specific
347 file, so two files with identical content conflicts produce distinct
348 pattern IDs.
349
350 ``description`` — domain-specific structured metadata populated by the
351 active plugin. The schema is domain-defined; generic code should treat
352 it as opaque.
353 """
354
355 pattern_id: str # 64 hex chars — SHA-256 of (path:blob_fp:semantic_fp)
356 path: str # workspace-relative POSIX path
357 domain: str # domain name, e.g. "midi", "code"
358 conflict_type: str # ConflictType constant or plugin-defined string
359 blob_fingerprint: str # 64 hex SHA-256(sorted ours_id + ":" + theirs_id)
360 semantic_fingerprint: str # 64 hex — domain-provided or == blob_fingerprint
361 ours_id: str # SHA-256 object ID of the "ours" version
362 theirs_id: str # SHA-256 object ID of the "theirs" version
363 description: _ConflictDescription # domain-specific structured conflict metadata
364 recorded_at: datetime.datetime # UTC-aware timestamp
365 recorded_by: str # agent_id or "human"
366
367 @dataclass(frozen=True)
368 class Resolution:
369 """A committed decision for a specific :class:`ConflictPattern`.
370
371 ``applied_count`` starts at 0 and is incremented each time this resolution
372 is replayed by the harmony engine. Use :func:`increment_applied_count` to
373 update it atomically — do not modify the dataclass directly.
374
375 ``human_verified`` starts ``False`` and is set ``True`` when a human has
376 explicitly confirmed the resolution is correct. The engine prefers
377 human-verified resolutions over unverified ones at equal confidence.
378 """
379
380 resolution_id: str # 64 hex chars — content-addressed
381 pattern_id: str # which ConflictPattern this resolves
382 strategy: str # ResolutionStrategy constant
383 policy_id: str | None # set when strategy == POLICY
384 outcome_blob: str # SHA-256 object ID of the resolved state
385 resolved_by: AgentProvenance
386 human_verified: bool
387 confidence: float # 0.0–1.0
388 rationale: str # human-readable reasoning
389 resolved_at: datetime.datetime # UTC-aware timestamp
390 applied_count: int = 0 # incremented on each replay
391
392 @dataclass(frozen=True)
393 class Policy:
394 """A declarative resolution rule evaluated against incoming conflict patterns.
395
396 Policies are matched in scope order (workspace → repo → domain → file).
397 The first matching policy fires; subsequent policies are not evaluated.
398
399 For ``action == PolicyAction.ESCALATE``, ``escalate_to`` specifies the
400 target — ``"human"`` or an agent ID. For ``action == PolicyAction.DELEGATE``,
401 ``delegate_to`` specifies the agent to route the conflict to. Both fields
402 are ``None`` for other actions.
403
404 ``confidence`` is the confidence score assigned to resolutions produced by
405 this policy. It affects :func:`best_resolution` ranking and is compared
406 against ``PolicyCondition.min_confidence`` by the harmony engine.
407 """
408
409 policy_id: str # URL-safe alphanumeric, 1–128 chars
410 description: str # human-readable explanation
411 when: PolicyCondition # conditions that must all match
412 action: str # PolicyAction constant
413 confidence: float # 0.0–1.0; used for ranking and min_confidence checks
414 escalate_to: str | None # "human" | agent_id, for ESCALATE action
415 delegate_to: str | None # agent_id, for DELEGATE action
416 scope: str # PolicyScope constant
417 created_at: datetime.datetime # UTC-aware timestamp
418 created_by: str # agent_id or "human"
419
420 @dataclass(frozen=True)
421 class ResolutionProposal:
422 """A candidate resolution produced by the harmony engine.
423
424 Proposals are returned when the engine finds a match (semantic or policy)
425 but cannot apply automatically — for example because ``confidence`` is
426 below the auto-accept threshold or ``requires_confirmation`` is ``True``.
427
428 Agents should auto-accept proposals above their configured threshold and
429 escalate the rest for human or specialist-agent review.
430
431 ``similar_pattern_id`` and ``similarity`` are populated for semantic
432 proposals; ``policy_id`` is populated for policy-driven proposals.
433 """
434
435 pattern_id: str
436 strategy: str # ResolutionStrategy constant
437 proposed_action: str # PolicyAction constant
438 confidence: float # 0.0–1.0
439 rationale: str
440 policy_id: str | None = None # set for policy-driven proposals
441 similar_pattern_id: str | None = None # set for semantic proposals
442 similarity: float | None = None # 0.0–1.0 domain similarity score
443 requires_confirmation: bool = False
444
445 @dataclass(frozen=True)
446 class EscalationRecord:
447 """A persistent record of a conflict pattern escalated for human attention.
448
449 Created when the resolution engine cannot auto-resolve a conflict (Tier 4).
450 Transitions from ``status=OPEN`` to ``status=RESOLVED`` when a human or
451 specialist agent applies a resolution and calls :func:`resolve_escalation`.
452
453 ``escalation_id`` — deterministic hex64 from ``(pattern_id, reason)``.
454 ``pattern_id`` — the pattern that could not be resolved.
455 ``reason`` — human-readable explanation of why escalation occurred.
456 ``escalated_at`` — UTC-aware timestamp of when escalation was created.
457 ``escalated_by`` — agent or human that triggered the escalation.
458 ``resolved_at`` — UTC-aware timestamp of resolution (``None`` if open).
459 ``resolved_by`` — who resolved the escalation (``None`` if open).
460 ``resolution_id`` — which resolution closed this escalation (``None`` if open).
461 ``status`` — :class:`EscalationStatus` constant.
462 """
463
464 escalation_id: str
465 pattern_id: str
466 reason: str
467 escalated_at: datetime.datetime
468 escalated_by: "AgentProvenance"
469 resolved_at: datetime.datetime | None = None
470 resolved_by: "AgentProvenance | None" = None
471 resolution_id: str | None = None
472 status: str = EscalationStatus.OPEN
473
474 # ---------------------------------------------------------------------------
475 # Wire-format TypedDicts for JSON serialisation
476 # ---------------------------------------------------------------------------
477
478 class _PatternDict(TypedDict):
479 pattern_id: str
480 path: str
481 domain: str
482 conflict_type: str
483 blob_fingerprint: str
484 semantic_fingerprint: str
485 ours_id: str
486 theirs_id: str
487 description: _ConflictDescription
488 recorded_at: str # ISO 8601
489 recorded_by: str
490
491 class _ResolutionDict(TypedDict):
492 resolution_id: str
493 pattern_id: str
494 strategy: str
495 policy_id: str | None
496 outcome_blob: str
497 resolved_by: dict[str, str | None]
498 human_verified: bool
499 confidence: float
500 rationale: str
501 resolved_at: str # ISO 8601
502 applied_count: int
503
504 class _PolicyConditionDict(TypedDict):
505 conflict_type: str | None
506 domain: str | None
507 path_pattern: str | None
508 min_confidence: float | None
509
510 class _PolicyDict(TypedDict):
511 policy_id: str
512 description: str
513 when: _PolicyConditionDict
514 action: str
515 confidence: float
516 escalate_to: str | None
517 delegate_to: str | None
518 scope: str
519 created_at: str # ISO 8601
520 created_by: str
521
522 class AuditEvent(TypedDict):
523 """One entry in the harmony audit log — never modified after writing."""
524
525 audit_id: str # sha256:<hex> content-addressed ID
526 event_type: str # AuditEventType constant
527 pattern_id: str | None
528 resolution_id: str | None
529 policy_id: str | None
530 acted_by: dict[str, str | None] # AgentProvenance.to_dict()
531 occurred_at: str # ISO 8601
532 metadata: _AuditMetadata # event-specific extra fields
533
534 class _EscalationDict(TypedDict):
535 escalation_id: str
536 pattern_id: str
537 reason: str
538 escalated_at: str # ISO 8601
539 escalated_by: dict[str, str | None]
540 resolved_at: str | None # ISO 8601 or null
541 resolved_by: dict[str, str | None] | None
542 resolution_id: str | None
543 status: str # EscalationStatus constant
544
545 # ---------------------------------------------------------------------------
546 # Directory helpers
547 # ---------------------------------------------------------------------------
548
549 def patterns_dir(root: pathlib.Path) -> pathlib.Path:
550 """Return ``.muse/harmony/patterns/`` (may not yet exist)."""
551 return _harmony_dir(root) / _PATTERNS
552
553 def policies_dir(root: pathlib.Path) -> pathlib.Path:
554 """Return ``.muse/harmony/policies/`` (may not yet exist)."""
555 return _harmony_dir(root) / _POLICIES
556
557 def audit_dir(root: pathlib.Path) -> pathlib.Path:
558 """Return ``.muse/harmony/audit/`` (may not yet exist)."""
559 return _harmony_dir(root) / _AUDIT
560
561 def escalations_dir(root: pathlib.Path) -> pathlib.Path:
562 """Return ``.muse/harmony/escalations/`` (may not yet exist)."""
563 return _harmony_dir(root) / _ESCALATIONS
564
565 def escalation_path(root: pathlib.Path, escalation_id: str) -> pathlib.Path:
566 """Return the JSON file path for an escalation record.
567
568 Path shape: ``.muse/harmony/escalations/<algo>/<hex>.json``
569
570 Args:
571 root: Repository root.
572 escalation_id: A ``<algo>:<hex>`` escalation ID, or bare 64-char hex.
573 """
574 algo, hex_id = split_id(escalation_id)
575 return escalations_dir(root) / algo / f"{hex_id}.json"
576
577 def pattern_dir(root: pathlib.Path, pattern_id: str) -> pathlib.Path:
578 """Return the entry directory for *pattern_id*.
579
580 Path shape: ``.muse/harmony/patterns/<algo>/<hex>/``
581
582 The algorithm is derived from *pattern_id* itself via :func:`split_id`,
583 so the directory reflects the actual hash algorithm used — no colon ever
584 appears in a path segment.
585
586 Args:
587 root: Repository root.
588 pattern_id: A ``<algo>:<hex>`` pattern ID, or bare 64-char hex.
589
590 Raises:
591 ValueError: If *pattern_id* is malformed or contains path-unsafe chars.
592 """
593 algo, hex_id = split_id(pattern_id)
594 return patterns_dir(root) / algo / hex_id
595
596 # Keep old name as an alias so internal callers migrate gradually.
597 _pattern_entry_dir = pattern_dir
598
599 def _resolutions_dir(root: pathlib.Path, pattern_id: str) -> pathlib.Path:
600 """Return the resolutions subdirectory for *pattern_id*."""
601 return pattern_dir(root, pattern_id) / _RESOLUTIONS
602
603 def resolution_path(root: pathlib.Path, pattern_id: str, resolution_id: str) -> pathlib.Path:
604 """Return the JSON file path for a resolution record.
605
606 Path shape: ``.muse/harmony/patterns/<p-algo>/<p-hex>/resolutions/<r-algo>/<r-hex>.json``
607
608 Both the pattern and the resolution carry independent algorithm segments —
609 they may differ if patterns and resolutions are created at different points
610 in the system's lifecycle. No colon ever appears in a path segment.
611
612 Args:
613 root: Repository root.
614 pattern_id: A ``<algo>:<hex>`` pattern ID, or bare 64-char hex.
615 resolution_id: A ``<algo>:<hex>`` resolution ID, or bare 64-char hex.
616
617 Raises:
618 ValueError: If either ID is malformed or contains path-unsafe chars.
619 """
620 r_algo, r_hex = split_id(resolution_id)
621 return _resolutions_dir(root, pattern_id) / r_algo / f"{r_hex}.json"
622
623 # Keep old name as an alias.
624 _resolution_path = resolution_path
625
626 # ---------------------------------------------------------------------------
627 # Validation
628 # ---------------------------------------------------------------------------
629
630 def _validate_id(value: str, label: str = "id") -> None:
631 """Raise :exc:`ValueError` if *value* is not a valid content-addressed ID.
632
633 Prevents path-traversal attacks: a crafted value like ``"../traversal"`` cannot
634 match ``sha256:[0-9a-f]{64}``, so it is rejected before any filesystem path
635 is constructed from it.
636
637 Args:
638 value: The string to validate — must be ``sha256:<64-hex>``.
639 label: Field name used in the error message.
640
641 Raises:
642 ValueError: When *value* does not match ``sha256:[0-9a-f]{64}``.
643 """
644 if not _SHA256_ID_RE.match(value):
645 raise ValueError(
646 f"Invalid {label} {value!r} — expected sha256:<64-hex> (length 71)."
647 )
648
649 def _validate_fingerprint(value: str, label: str = "semantic_fingerprint") -> None:
650 """Raise :exc:`ValueError` if *value* is not a valid semantic fingerprint.
651
652 Unlike :func:`_validate_id`, this does not require hex64 format — domain
653 plugins may use richer representations such as normalized token-bag
654 strings produced by ``code_fingerprint()``.
655
656 Constraints:
657 - Must be non-empty.
658 - Must not contain null bytes (``\\x00``).
659 - Must not exceed :data:`_MAX_FINGERPRINT_BYTES` bytes (UTF-8 encoded).
660
661 Args:
662 value: The fingerprint string to validate.
663 label: Field name used in the error message.
664
665 Raises:
666 ValueError: When *value* is empty, contains a null byte, or is too large.
667 """
668 if not value:
669 raise ValueError(f"Invalid {label}: must not be empty.")
670 if "\x00" in value:
671 raise ValueError(f"Invalid {label}: must not contain null bytes.")
672 if len(value.encode()) > _MAX_FINGERPRINT_BYTES:
673 raise ValueError(
674 f"Invalid {label}: exceeds {_MAX_FINGERPRINT_BYTES}-byte limit "
675 f"({len(value.encode())} bytes)."
676 )
677
678 def _validate_policy_id(policy_id: str) -> None:
679 """Raise :exc:`ValueError` if *policy_id* is not URL-safe.
680
681 Policy IDs are used directly as filenames. This rejects slashes, spaces,
682 dots, and control characters that could cause path traversal or injection.
683
684 Args:
685 policy_id: The policy identifier to validate.
686
687 Raises:
688 ValueError: When *policy_id* does not match ``[A-Za-z0-9_-]{1,128}``.
689 """
690 if not _POLICY_ID_RE.match(policy_id):
691 raise ValueError(
692 f"Invalid policy_id {policy_id!r} — "
693 "only alphanumeric characters, hyphens, and underscores are allowed "
694 "(1–128 characters)."
695 )
696
697 # ---------------------------------------------------------------------------
698 # Fingerprinting
699 # ---------------------------------------------------------------------------
700
701 def blob_fingerprint(ours_id: str, theirs_id: str) -> str:
702 """Return the content fingerprint for a pair of conflicting object IDs.
703
704 Sorts the pair lexicographically before hashing so the result is
705 commutative — resolving A-vs-B produces the same fingerprint as B-vs-A.
706
707 Args:
708 ours_id: ``sha256:`` object ID of the "ours" version.
709 theirs_id: ``sha256:`` object ID of the "theirs" version.
710
711 Returns:
712 ``sha256:`` content-addressed fingerprint (length 71).
713 """
714 lo, hi = sorted((ours_id, theirs_id))
715 return content_hash({"ids": [lo, hi]})
716
717 def compute_pattern_id(path: str, blob_fp: str, semantic_fp: str) -> str:
718 """Compute the canonical pattern ID for a conflict.
719
720 Incorporates *path* so that two different files with the same conflicting
721 content produce distinct pattern IDs — the resolution for ``track.mid``
722 and ``drums.mid`` are stored and retrieved independently.
723
724 When a domain plugin provides a richer semantic fingerprint (``semantic_fp
725 != blob_fp``), the semantic fingerprint alone determines the pattern
726 identity. This is the mechanism that enables cross-content replay: two
727 merge conflicts with different blob IDs but the same semantic shape
728 (e.g. same musical phrase transposed by a semitone) map to the same
729 pattern and therefore replay the same saved resolution.
730
731 When no semantic plugin is active, ``semantic_fp == blob_fp``, so the
732 formula degenerates to the classic exact-replay model where two conflicts
733 must have identical blob content to share a pattern.
734
735 Args:
736 path: Workspace-relative POSIX path of the conflicting file.
737 blob_fp: ``sha256:`` blob fingerprint from :func:`blob_fingerprint`.
738 semantic_fp: ``sha256:`` semantic fingerprint (from plugin or == blob_fp).
739
740 Returns:
741 ``sha256:`` content-addressed pattern ID (length 71).
742 """
743 if semantic_fp != blob_fp:
744 # Domain plugin provided a richer fingerprint — use it alone so that
745 # conflicts with different blob IDs but the same semantic shape share
746 # a single pattern and replay the same resolution.
747 return content_hash({"path": path, "semantic_fp": semantic_fp})
748 # No plugin (or plugin deferred to blob fingerprint) — include both so
749 # exact-replay is commutative (blob_fp already is) and stable.
750 return content_hash({"blob_fp": blob_fp, "path": path, "semantic_fp": semantic_fp})
751
752 def compute_resolution_id(
753 pattern_id: str,
754 outcome_blob: str,
755 strategy: str,
756 resolved_by: AgentProvenance,
757 resolved_at: datetime.datetime,
758 ) -> str:
759 """Compute a stable content-addressed ID for a resolution.
760
761 The ID is deterministic for the same inputs so duplicate saves are
762 idempotent — calling :func:`save_resolution` twice with the same parameters
763 produces the same file and is a no-op.
764
765 Args:
766 pattern_id: ``sha256:`` pattern ID this resolution belongs to.
767 outcome_blob: ``sha256:`` object ID of the resolved content.
768 strategy: :class:`ResolutionStrategy` constant.
769 resolved_by: Attribution for the resolution.
770 resolved_at: UTC-aware timestamp of the resolution.
771
772 Returns:
773 ``sha256:`` content-addressed resolution ID (length 71).
774 """
775 return content_hash({
776 "actor": resolved_by.agent_id or "human",
777 "outcome_blob": outcome_blob,
778 "pattern_id": pattern_id,
779 "resolved_at": resolved_at.isoformat(),
780 "strategy": strategy,
781 })
782
783 # ---------------------------------------------------------------------------
784 # Internal helpers
785 # ---------------------------------------------------------------------------
786
787 def _now_utc() -> datetime.datetime:
788 """Return the current UTC time as a timezone-aware datetime."""
789 return datetime.datetime.now(datetime.timezone.utc)
790
791 def _parse_dt(value: JsonValue | None) -> datetime.datetime:
792 """Parse *value* as an ISO 8601 datetime, defaulting to now on failure.
793
794 Always returns a UTC-aware datetime regardless of the timezone embedded
795 in *value* — naive datetimes are assumed to be UTC.
796 """
797 try:
798 dt = datetime.datetime.fromisoformat(str(value))
799 if dt.tzinfo is None:
800 dt = dt.replace(tzinfo=datetime.timezone.utc)
801 return dt
802 except (ValueError, TypeError):
803 return _now_utc()
804
805 def _write_atomic(dest: pathlib.Path, content: str) -> None:
806 """Write *content* to *dest* atomically via a temp file and ``os.replace``.
807
808 Creates parent directories as needed. On failure the temp file is removed
809 and the exception re-raised, leaving *dest* unchanged.
810
811 Args:
812 dest: Target path for the final file.
813 content: UTF-8 string content to write.
814 """
815 dest.parent.mkdir(parents=True, exist_ok=True)
816 fd, tmp_str = tempfile.mkstemp(dir=dest.parent, prefix=".harmony-tmp-")
817 tmp = pathlib.Path(tmp_str)
818 try:
819 with os.fdopen(fd, "w", encoding="utf-8") as fh:
820 fh.write(content)
821 os.replace(tmp, dest)
822 except Exception:
823 tmp.unlink(missing_ok=True)
824 raise
825
826 # ---------------------------------------------------------------------------
827 # Serialisation helpers
828 # ---------------------------------------------------------------------------
829
830 def _pattern_to_dict(pattern: ConflictPattern) -> _PatternDict:
831 return _PatternDict(
832 pattern_id=pattern.pattern_id,
833 path=pattern.path,
834 domain=pattern.domain,
835 conflict_type=pattern.conflict_type,
836 blob_fingerprint=pattern.blob_fingerprint,
837 semantic_fingerprint=pattern.semantic_fingerprint,
838 ours_id=pattern.ours_id,
839 theirs_id=pattern.theirs_id,
840 description=pattern.description,
841 recorded_at=pattern.recorded_at.isoformat(),
842 recorded_by=pattern.recorded_by,
843 )
844
845 def _dict_to_pattern(data: Mapping[str, JsonValue]) -> ConflictPattern | None:
846 """Deserialise a JSON dict to :class:`ConflictPattern`, returning ``None`` on error."""
847 try:
848 return ConflictPattern(
849 pattern_id=str(data["pattern_id"]),
850 path=str(data.get("path", "")),
851 domain=str(data.get("domain", "")),
852 conflict_type=str(data.get("conflict_type", ConflictType.UNKNOWN)),
853 blob_fingerprint=str(data.get("blob_fingerprint", "")),
854 semantic_fingerprint=str(data.get("semantic_fingerprint", "")),
855 ours_id=str(data.get("ours_id", "")),
856 theirs_id=str(data.get("theirs_id", "")),
857 description=data.get("description") or {},
858 recorded_at=_parse_dt(data.get("recorded_at")),
859 recorded_by=str(data.get("recorded_by", "unknown")),
860 )
861 except (KeyError, TypeError, ValueError) as exc:
862 logger.warning("⚠️ harmony: failed to deserialise pattern: %s", exc)
863 return None
864
865 def _resolution_to_dict(resolution: Resolution) -> _ResolutionDict:
866 return _ResolutionDict(
867 resolution_id=resolution.resolution_id,
868 pattern_id=resolution.pattern_id,
869 strategy=resolution.strategy,
870 policy_id=resolution.policy_id,
871 outcome_blob=resolution.outcome_blob,
872 resolved_by=resolution.resolved_by.to_dict(),
873 human_verified=resolution.human_verified,
874 confidence=resolution.confidence,
875 rationale=resolution.rationale,
876 resolved_at=resolution.resolved_at.isoformat(),
877 applied_count=resolution.applied_count,
878 )
879
880 def _dict_to_resolution(data: Mapping[str, JsonValue]) -> Resolution | None:
881 """Deserialise a JSON dict to :class:`Resolution`, returning ``None`` on error."""
882 try:
883 return Resolution(
884 resolution_id=str(data["resolution_id"]),
885 pattern_id=str(data.get("pattern_id", "")),
886 strategy=str(data.get("strategy", ResolutionStrategy.MANUAL)),
887 policy_id=data.get("policy_id") or None,
888 outcome_blob=str(data.get("outcome_blob", "")),
889 resolved_by=AgentProvenance.from_dict(data.get("resolved_by") or {}),
890 human_verified=bool(data.get("human_verified", False)),
891 confidence=float(data.get("confidence", 0.0)),
892 rationale=str(data.get("rationale", "")),
893 resolved_at=_parse_dt(data.get("resolved_at")),
894 applied_count=int(data.get("applied_count", 0)),
895 )
896 except (KeyError, TypeError, ValueError) as exc:
897 logger.warning("⚠️ harmony: failed to deserialise resolution: %s", exc)
898 return None
899
900 def _policy_condition_to_dict(cond: PolicyCondition) -> _PolicyConditionDict:
901 return _PolicyConditionDict(
902 conflict_type=cond.conflict_type,
903 domain=cond.domain,
904 path_pattern=cond.path_pattern,
905 min_confidence=cond.min_confidence,
906 )
907
908 def _policy_to_dict(policy: Policy) -> _PolicyDict:
909 return _PolicyDict(
910 policy_id=policy.policy_id,
911 description=policy.description,
912 when=_policy_condition_to_dict(policy.when),
913 action=policy.action,
914 confidence=policy.confidence,
915 escalate_to=policy.escalate_to,
916 delegate_to=policy.delegate_to,
917 scope=policy.scope,
918 created_at=policy.created_at.isoformat(),
919 created_by=policy.created_by,
920 )
921
922 def _dict_to_policy(data: Mapping[str, JsonValue]) -> Policy | None:
923 """Deserialise a JSON dict to :class:`Policy`, returning ``None`` on error."""
924 try:
925 when_data = data.get("when") or {}
926 condition = PolicyCondition(
927 conflict_type=when_data.get("conflict_type") or None,
928 domain=when_data.get("domain") or None,
929 path_pattern=when_data.get("path_pattern") or None,
930 min_confidence=when_data.get("min_confidence"),
931 )
932 return Policy(
933 policy_id=str(data["policy_id"]),
934 description=str(data.get("description", "")),
935 when=condition,
936 action=str(data.get("action", PolicyAction.ESCALATE)),
937 confidence=float(data.get("confidence", 1.0)),
938 escalate_to=data.get("escalate_to") or None,
939 delegate_to=data.get("delegate_to") or None,
940 scope=str(data.get("scope", PolicyScope.REPO)),
941 created_at=_parse_dt(data.get("created_at")),
942 created_by=str(data.get("created_by", "unknown")),
943 )
944 except (KeyError, TypeError, ValueError) as exc:
945 logger.warning("⚠️ harmony: failed to deserialise policy: %s", exc)
946 return None
947
948 # ---------------------------------------------------------------------------
949 # Pattern CRUD
950 # ---------------------------------------------------------------------------
951
952 def record_pattern(root: pathlib.Path, pattern: ConflictPattern) -> str:
953 """Persist a :class:`ConflictPattern` to the harmony store.
954
955 **Idempotent** — if *pattern.pattern_id* already exists in the store the
956 existing ``pattern.json`` is left unchanged and the pattern_id is returned.
957
958 Args:
959 root: Repository root.
960 pattern: Pattern to record.
961
962 Returns:
963 The 64-char hex ``pattern_id`` identifying this conflict.
964
965 Raises:
966 ValueError: If ``pattern.pattern_id`` is not a valid 64-char hex string.
967 """
968 _validate_id(pattern.pattern_id, "pattern_id")
969 meta_p = _pattern_entry_dir(root, pattern.pattern_id) / _PATTERN_FILE
970 if meta_p.exists():
971 logger.debug(
972 "harmony: pattern %s already recorded for '%s'",
973 short_id(pattern.pattern_id),
974 pattern.path,
975 )
976 return pattern.pattern_id
977
978 _write_atomic(meta_p, json.dumps(_pattern_to_dict(pattern), indent=2))
979 logger.debug(
980 "harmony: recorded pattern %s for '%s' (type=%s, domain=%s)",
981 short_id(pattern.pattern_id),
982 pattern.path,
983 pattern.conflict_type,
984 pattern.domain,
985 )
986 return pattern.pattern_id
987
988 def load_pattern(root: pathlib.Path, pattern_id: str) -> ConflictPattern | None:
989 """Load a :class:`ConflictPattern` from the harmony store.
990
991 Returns ``None`` when:
992
993 - *pattern_id* is not a valid 64-char hex string.
994 - No entry exists for *pattern_id*.
995 - The ``pattern.json`` file exceeds :data:`_MAX_PATTERN_BYTES`.
996 - The JSON cannot be parsed or is missing required fields.
997
998 Args:
999 root: Repository root.
1000 pattern_id: 64-char hex pattern ID.
1001 """
1002 try:
1003 _validate_id(pattern_id, "pattern_id")
1004 except ValueError:
1005 return None
1006
1007 meta_p = _pattern_entry_dir(root, pattern_id) / _PATTERN_FILE
1008 if not meta_p.exists():
1009 return None
1010
1011 try:
1012 size = meta_p.stat().st_size
1013 except OSError as exc:
1014 logger.warning("⚠️ harmony: failed to read pattern %s: %s", pattern_id, exc)
1015 return None
1016 if size > _MAX_PATTERN_BYTES:
1017 logger.warning(
1018 "⚠️ harmony: pattern.json for %s is %d bytes — exceeds %d cap; skipping",
1019 pattern_id,
1020 size,
1021 _MAX_PATTERN_BYTES,
1022 )
1023 return None
1024 data = load_json_file(meta_p)
1025 if data is None:
1026 logger.warning("⚠️ harmony: failed to read pattern %s: unreadable or invalid JSON", pattern_id)
1027 return None
1028
1029 return _dict_to_pattern(data)
1030
1031 def list_patterns(root: pathlib.Path) -> list[ConflictPattern]:
1032 """Return all :class:`ConflictPattern` entries in the harmony store.
1033
1034 Scans ``.muse/harmony/patterns/``. Skips symlinks, directories whose
1035 names are not valid 64-char hex strings, and entries that fail to load.
1036 Capped at :data:`_MAX_SCAN` entries to protect degenerate repos.
1037
1038 Returns patterns sorted by ``recorded_at`` descending (most recent first).
1039
1040 Args:
1041 root: Repository root.
1042 """
1043 pdir = patterns_dir(root)
1044 if not pdir.exists():
1045 return []
1046
1047 results: list[ConflictPattern] = []
1048 count = 0
1049
1050 # New layout: patterns/<algo>/<hex>/ — iterate algo dirs then hex dirs.
1051 for algo_dir in pdir.iterdir():
1052 if algo_dir.is_symlink() or not algo_dir.is_dir():
1053 continue
1054 for entry in algo_dir.iterdir():
1055 if count >= _MAX_SCAN:
1056 logger.warning(
1057 "⚠️ harmony: patterns dir has >%d entries — scan truncated",
1058 _MAX_SCAN,
1059 )
1060 break
1061 count += 1
1062
1063 if entry.is_symlink():
1064 logger.debug("harmony: skipping symlink %s in patterns dir", entry.name)
1065 continue
1066 if not entry.is_dir():
1067 continue
1068 if not _BARE_HEX64_RE.match(entry.name):
1069 continue
1070
1071 p = load_pattern(root, long_id(entry.name, algo_dir.name))
1072 if p is not None:
1073 results.append(p)
1074
1075 results.sort(key=lambda p: p.recorded_at, reverse=True)
1076 return results
1077
1078 def forget_pattern(root: pathlib.Path, pattern_id: str) -> bool:
1079 """Remove a conflict pattern and all its resolutions from the harmony store.
1080
1081 Deletes ``pattern.json``, all resolution files under ``resolutions/``,
1082 and the entry directory itself. Returns ``False`` if the pattern does not
1083 exist or *pattern_id* is not a valid sha256: content-addressed ID.
1084
1085 Args:
1086 root: Repository root.
1087 pattern_id: sha256: content-addressed pattern ID.
1088 """
1089 try:
1090 entry = _pattern_entry_dir(root, pattern_id)
1091 except ValueError:
1092 logger.warning(
1093 "⚠️ harmony: invalid pattern_id in forget_pattern: %r", pattern_id
1094 )
1095 return False
1096
1097 if not entry.exists():
1098 return False
1099
1100 # Remove resolutions/<algo>/<hex>.json entries.
1101 res_dir = entry / _RESOLUTIONS
1102 if res_dir.exists() and not res_dir.is_symlink():
1103 for algo_dir in res_dir.iterdir():
1104 if algo_dir.is_symlink():
1105 continue
1106 if algo_dir.is_dir():
1107 for f in algo_dir.iterdir():
1108 if not f.is_symlink():
1109 f.unlink(missing_ok=True)
1110 try:
1111 algo_dir.rmdir()
1112 except OSError:
1113 pass
1114 else:
1115 algo_dir.unlink(missing_ok=True)
1116 try:
1117 res_dir.rmdir()
1118 except OSError:
1119 pass
1120
1121 # Remove remaining files (pattern.json, any plugin-added files).
1122 for child in entry.iterdir():
1123 if not child.is_symlink():
1124 child.unlink(missing_ok=True)
1125 try:
1126 entry.rmdir()
1127 except OSError:
1128 pass # not empty — leave; caller may retry
1129
1130 logger.debug("harmony: forgot pattern %s", short_id(pattern_id))
1131 return True
1132
1133 def clear_all(root: pathlib.Path) -> int:
1134 """Remove every conflict pattern and all its resolutions from the store.
1135
1136 Symlinks in the patterns directory are skipped (only real subdirectories
1137 are cleared).
1138
1139 Args:
1140 root: Repository root.
1141
1142 Returns:
1143 Number of patterns removed.
1144 """
1145 pdir = patterns_dir(root)
1146 if not pdir.exists():
1147 return 0
1148
1149 removed = 0
1150 for algo_dir in pdir.iterdir():
1151 if algo_dir.is_symlink() or not algo_dir.is_dir():
1152 continue
1153 for entry in algo_dir.iterdir():
1154 if entry.is_symlink() or not entry.is_dir():
1155 continue
1156 if forget_pattern(root, long_id(entry.name, algo_dir.name)):
1157 removed += 1
1158
1159 logger.debug("harmony: clear_all removed %d patterns", removed)
1160 return removed
1161
1162 # ---------------------------------------------------------------------------
1163 # Resolution CRUD
1164 # ---------------------------------------------------------------------------
1165
1166 def save_resolution(root: pathlib.Path, resolution: Resolution) -> None:
1167 """Persist a :class:`Resolution` to the harmony store.
1168
1169 Stored at ``.muse/harmony/patterns/<pattern_id>/resolutions/<resolution_id>.json``.
1170
1171 **Idempotent** — saving the same resolution_id twice is a no-op.
1172
1173 Args:
1174 root: Repository root.
1175 resolution: Resolution to save.
1176
1177 Raises:
1178 ValueError: If either ID fails hex validation.
1179 FileNotFoundError: If the parent pattern does not exist.
1180 Call :func:`record_pattern` first.
1181 """
1182 _validate_id(resolution.pattern_id, "pattern_id")
1183 _validate_id(resolution.resolution_id, "resolution_id")
1184
1185 pattern_p = _pattern_entry_dir(root, resolution.pattern_id) / _PATTERN_FILE
1186 if not pattern_p.exists():
1187 raise FileNotFoundError(
1188 f"No harmony pattern found for pattern_id {resolution.pattern_id}. "
1189 "Call record_pattern() before save_resolution()."
1190 )
1191
1192 dest = _resolution_path(root, resolution.pattern_id, resolution.resolution_id)
1193
1194 if dest.exists():
1195 logger.debug(
1196 "harmony: resolution %s already saved for pattern %s",
1197 short_id(resolution.resolution_id),
1198 short_id(resolution.pattern_id),
1199 )
1200 return
1201
1202 _write_atomic(dest, json.dumps(_resolution_to_dict(resolution), indent=2))
1203 logger.debug(
1204 "harmony: saved resolution %s for pattern %s "
1205 "(strategy=%s, confidence=%.2f, verified=%s)",
1206 short_id(resolution.resolution_id),
1207 short_id(resolution.pattern_id),
1208 resolution.strategy,
1209 resolution.confidence,
1210 resolution.human_verified,
1211 )
1212
1213 def load_resolution(
1214 root: pathlib.Path,
1215 pattern_id: str,
1216 resolution_id: str,
1217 ) -> Resolution | None:
1218 """Load a :class:`Resolution` from the harmony store.
1219
1220 Returns ``None`` when either ID fails validation, the file does not exist,
1221 it exceeds :data:`_MAX_RESOLUTION_BYTES`, or JSON parsing fails.
1222
1223 Args:
1224 root: Repository root.
1225 pattern_id: 64-char hex pattern ID.
1226 resolution_id: 64-char hex resolution ID.
1227 """
1228 try:
1229 _validate_id(pattern_id, "pattern_id")
1230 _validate_id(resolution_id, "resolution_id")
1231 except ValueError:
1232 return None
1233
1234 dest = _resolution_path(root, pattern_id, resolution_id)
1235 if not dest.exists():
1236 return None
1237
1238 try:
1239 size = dest.stat().st_size
1240 except OSError as exc:
1241 logger.warning(
1242 "⚠️ harmony: failed to read resolution %s: %s", resolution_id, exc
1243 )
1244 return None
1245 if size > _MAX_RESOLUTION_BYTES:
1246 logger.warning(
1247 "⚠️ harmony: resolution %s is %d bytes — exceeds %d cap; skipping",
1248 resolution_id,
1249 size,
1250 _MAX_RESOLUTION_BYTES,
1251 )
1252 return None
1253 data = load_json_file(dest)
1254 if data is None:
1255 logger.warning(
1256 "⚠️ harmony: failed to read resolution %s: unreadable or invalid JSON", resolution_id
1257 )
1258 return None
1259
1260 return _dict_to_resolution(data)
1261
1262 def list_resolutions(root: pathlib.Path, pattern_id: str) -> list[Resolution]:
1263 """Return all :class:`Resolution` entries for *pattern_id*.
1264
1265 Skips symlinks and files that fail to parse.
1266
1267 Sorted by quality descending:
1268 ``(human_verified, confidence, applied_count)`` — highest quality first.
1269
1270 Args:
1271 root: Repository root.
1272 pattern_id: 64-char hex pattern ID.
1273 """
1274 try:
1275 res_dir = _resolutions_dir(root, pattern_id)
1276 except ValueError:
1277 return []
1278
1279 if not res_dir.exists():
1280 return []
1281
1282 # New layout: resolutions/<algo>/<hex>.json
1283 resolutions: list[Resolution] = []
1284 for algo_dir in res_dir.iterdir():
1285 if algo_dir.is_symlink() or not algo_dir.is_dir():
1286 continue
1287 for f in algo_dir.iterdir():
1288 if f.is_symlink() or not f.is_file():
1289 continue
1290 if not f.name.endswith(".json"):
1291 continue
1292 bare_rid = f.name[:-5]
1293 if not _BARE_HEX64_RE.match(bare_rid):
1294 continue
1295 r = load_resolution(root, pattern_id, long_id(bare_rid, algo_dir.name))
1296 if r is not None:
1297 resolutions.append(r)
1298
1299 resolutions.sort(
1300 key=lambda r: (r.human_verified, r.confidence, r.applied_count),
1301 reverse=True,
1302 )
1303 return resolutions
1304
1305 def increment_applied_count(
1306 root: pathlib.Path,
1307 pattern_id: str,
1308 resolution_id: str,
1309 ) -> bool:
1310 """Atomically increment the ``applied_count`` of a :class:`Resolution`.
1311
1312 Loads the current resolution, creates an updated copy via
1313 :func:`dataclasses.replace`, and writes it back atomically. Returns
1314 ``True`` on success, ``False`` if the resolution does not exist.
1315
1316 Args:
1317 root: Repository root.
1318 pattern_id: 64-char hex pattern ID.
1319 resolution_id: 64-char hex resolution ID.
1320 """
1321 resolution = load_resolution(root, pattern_id, resolution_id)
1322 if resolution is None:
1323 return False
1324
1325 updated = dc_replace(resolution, applied_count=resolution.applied_count + 1)
1326 dest = _resolution_path(root, pattern_id, resolution_id)
1327 _write_atomic(dest, json.dumps(_resolution_to_dict(updated), indent=2))
1328 logger.debug(
1329 "harmony: applied_count → %d for resolution %s",
1330 updated.applied_count,
1331 short_id(resolution_id),
1332 )
1333 return True
1334
1335 def best_resolution(root: pathlib.Path, pattern_id: str) -> Resolution | None:
1336 """Return the highest-quality :class:`Resolution` for *pattern_id*.
1337
1338 Quality ranking: ``human_verified`` > ``confidence`` > ``applied_count``.
1339
1340 Returns ``None`` if no resolutions exist for the pattern.
1341
1342 Args:
1343 root: Repository root.
1344 pattern_id: 64-char hex pattern ID.
1345 """
1346 resolutions = list_resolutions(root, pattern_id)
1347 # list_resolutions already sorts by (human_verified, confidence, applied_count) desc
1348 return resolutions[0] if resolutions else None
1349
1350 # ---------------------------------------------------------------------------
1351 # GC
1352 # ---------------------------------------------------------------------------
1353
1354 def gc_stale(root: pathlib.Path, age_days: int = 90) -> int:
1355 """Remove patterns that have no resolution and are older than *age_days*.
1356
1357 Patterns with at least one saved resolution are retained regardless of age.
1358 Operates on the harmony pattern model — retaining any pattern that has at
1359 least one saved resolution.
1360
1361 Args:
1362 root: Repository root.
1363 age_days: Age threshold in days (default 90).
1364
1365 Returns:
1366 Number of patterns removed.
1367 """
1368 cutoff = _now_utc() - datetime.timedelta(days=age_days)
1369 removed = 0
1370
1371 for p in list_patterns(root):
1372 res_dir = _resolutions_dir(root, p.pattern_id)
1373 has_any_resolution = (
1374 res_dir.exists()
1375 and any(
1376 True
1377 for algo_dir in res_dir.iterdir()
1378 if not algo_dir.is_symlink() and algo_dir.is_dir()
1379 for f in algo_dir.iterdir()
1380 if not f.is_symlink() and f.is_file() and f.name.endswith(".json")
1381 )
1382 )
1383 if has_any_resolution:
1384 continue
1385 if p.recorded_at < cutoff:
1386 if forget_pattern(root, p.pattern_id):
1387 removed += 1
1388
1389 logger.debug(
1390 "harmony gc: removed %d stale unresolved patterns (age_days=%d)",
1391 removed,
1392 age_days,
1393 )
1394 return removed
1395
1396 # ---------------------------------------------------------------------------
1397 # Audit log
1398 # ---------------------------------------------------------------------------
1399
1400 def append_audit(
1401 root: pathlib.Path,
1402 event_type: str,
1403 acted_by: AgentProvenance,
1404 *,
1405 pattern_id: str | None = None,
1406 resolution_id: str | None = None,
1407 policy_id: str | None = None,
1408 metadata: _AuditMetadata | None = None,
1409 ) -> None:
1410 """Append a single entry to the harmony audit log.
1411
1412 Each entry is an independent JSON file named
1413 ``<YYYYMMDD>-<sha256-hex-slice>.json`` in ``.muse/harmony/audit/``. The
1414 append-only design means audit entries are never modified after writing —
1415 they form a tamper-evident record of all harmony engine actions.
1416
1417 Args:
1418 root: Repository root.
1419 event_type: :class:`AuditEventType` constant describing what happened.
1420 acted_by: Attribution for the actor.
1421 pattern_id: Involved pattern (if applicable).
1422 resolution_id: Involved resolution (if applicable).
1423 policy_id: Involved policy (if applicable).
1424 metadata: Additional event-specific fields (arbitrary JSON-safe dict).
1425 """
1426 now = _now_utc()
1427 payload: _AuditMetadata = {
1428 "event_type": event_type,
1429 "pattern_id": pattern_id,
1430 "resolution_id": resolution_id,
1431 "policy_id": policy_id,
1432 "acted_by": acted_by.to_dict(),
1433 "occurred_at": now.isoformat(),
1434 "metadata": metadata or {},
1435 }
1436 audit_id = content_hash(payload)
1437 filename = f"{now.strftime('%Y%m%d')}-{audit_id[7:19]}.json"
1438
1439 entry: AuditEvent = {"audit_id": audit_id, **payload} # type: ignore[misc]
1440 _write_atomic(audit_dir(root) / filename, json.dumps(entry, indent=2))
1441
1442 def list_audit(root: pathlib.Path, limit: int = 100) -> list[AuditEvent]:
1443 """Return up to *limit* recent audit log entries, newest first.
1444
1445 Entries are sorted by filename (``<YYYYMMDD>-<sha256-hex-slice>``), which gives
1446 chronological order. Skips entries exceeding :data:`_MAX_AUDIT_BYTES`
1447 or that fail JSON parsing.
1448
1449 Args:
1450 root: Repository root.
1451 limit: Maximum entries to return (default 100).
1452 """
1453 adir = audit_dir(root)
1454 if not adir.exists():
1455 return []
1456
1457 entries: list[tuple[str, AuditEvent]] = []
1458 count = 0
1459
1460 for f in adir.iterdir():
1461 if count >= _MAX_AUDIT_ENTRIES:
1462 break
1463 count += 1
1464 if f.is_symlink() or not f.is_file():
1465 continue
1466 if not f.name.endswith(".json"):
1467 continue
1468 try:
1469 size = f.stat().st_size
1470 except OSError:
1471 continue
1472 if size > _MAX_AUDIT_BYTES:
1473 logger.warning(
1474 "⚠️ harmony: audit entry %s is too large (%d bytes); skipping",
1475 f.name,
1476 size,
1477 )
1478 continue
1479 data: AuditEvent | None = load_json_file(f)
1480 if data is None:
1481 continue
1482 entries.append((f.name, data))
1483
1484 entries.sort(key=lambda t: t[0], reverse=True)
1485 return [e for _, e in entries[:limit]]
1486
1487 # ---------------------------------------------------------------------------
1488 # Policy CRUD
1489 # ---------------------------------------------------------------------------
1490
1491 def save_policy(root: pathlib.Path, policy: Policy) -> None:
1492 """Persist a :class:`Policy` to the harmony policy store.
1493
1494 Stored at ``.muse/harmony/policies/<policy_id>.json``. **Overwrites**
1495 any existing policy with the same ID — policies are versioned by
1496 replacement, not by append.
1497
1498 Args:
1499 root: Repository root.
1500 policy: Policy to save.
1501
1502 Raises:
1503 ValueError: If ``policy.policy_id`` is not URL-safe.
1504 """
1505 _validate_policy_id(policy.policy_id)
1506 dest = policies_dir(root) / f"{policy.policy_id}.json"
1507 _write_atomic(dest, json.dumps(_policy_to_dict(policy), indent=2))
1508 logger.debug(
1509 "harmony: saved policy %r (action=%s, scope=%s)",
1510 policy.policy_id,
1511 policy.action,
1512 policy.scope,
1513 )
1514
1515 def load_policy(root: pathlib.Path, policy_id: str) -> Policy | None:
1516 """Load a single :class:`Policy` by ID.
1517
1518 Returns ``None`` when *policy_id* is invalid, the file does not exist,
1519 it exceeds :data:`_MAX_POLICY_BYTES`, or JSON parsing fails.
1520
1521 Args:
1522 root: Repository root.
1523 policy_id: URL-safe policy identifier.
1524 """
1525 try:
1526 _validate_policy_id(policy_id)
1527 except ValueError:
1528 return None
1529
1530 dest = policies_dir(root) / f"{policy_id}.json"
1531 if not dest.exists():
1532 return None
1533
1534 try:
1535 size = dest.stat().st_size
1536 except OSError as exc:
1537 logger.warning("⚠️ harmony: failed to read policy %r: %s", policy_id, exc)
1538 return None
1539 if size > _MAX_POLICY_BYTES:
1540 logger.warning(
1541 "⚠️ harmony: policy %r is %d bytes — too large; skipping",
1542 policy_id,
1543 size,
1544 )
1545 return None
1546 data = load_json_file(dest)
1547 if data is None:
1548 logger.warning("⚠️ harmony: failed to read policy %r: unreadable or invalid JSON", policy_id)
1549 return None
1550
1551 return _dict_to_policy(data)
1552
1553 def list_policies(root: pathlib.Path) -> list[Policy]:
1554 """Return all :class:`Policy` entries from the harmony policy store.
1555
1556 Sorted by scope order (workspace → repo → domain → file) then by
1557 ``created_at`` ascending within each scope, so earlier policies take
1558 precedence over later ones at the same scope level.
1559
1560 Args:
1561 root: Repository root.
1562 """
1563 _SCOPE_ORDER = {
1564 PolicyScope.WORKSPACE: 0,
1565 PolicyScope.REPO: 1,
1566 PolicyScope.DOMAIN: 2,
1567 PolicyScope.FILE: 3,
1568 }
1569
1570 pdir = policies_dir(root)
1571 if not pdir.exists():
1572 return []
1573
1574 results: list[Policy] = []
1575 count = 0
1576
1577 for f in pdir.iterdir():
1578 if count >= _MAX_POLICIES:
1579 logger.warning(
1580 "⚠️ harmony: >%d policies — scan truncated", _MAX_POLICIES
1581 )
1582 break
1583 count += 1
1584 if f.is_symlink() or not f.is_file():
1585 continue
1586 if not f.name.endswith(".json"):
1587 continue
1588 pid = f.name[:-5]
1589 p = load_policy(root, pid)
1590 if p is not None:
1591 results.append(p)
1592
1593 results.sort(key=lambda p: (_SCOPE_ORDER.get(p.scope, 99), p.created_at))
1594 return results
1595
1596 def remove_policy(root: pathlib.Path, policy_id: str) -> bool:
1597 """Remove a policy from the harmony store.
1598
1599 Args:
1600 root: Repository root.
1601 policy_id: URL-safe policy identifier.
1602
1603 Returns:
1604 ``True`` if the policy existed and was removed, ``False`` otherwise.
1605 """
1606 try:
1607 _validate_policy_id(policy_id)
1608 except ValueError:
1609 logger.warning(
1610 "⚠️ harmony: invalid policy_id in remove_policy: %r", policy_id
1611 )
1612 return False
1613
1614 dest = policies_dir(root) / f"{policy_id}.json"
1615 if not dest.exists():
1616 return False
1617
1618 dest.unlink(missing_ok=True)
1619 logger.debug("harmony: removed policy %r", policy_id)
1620 return True
1621
1622 # ---------------------------------------------------------------------------
1623 # Policy matching
1624 # ---------------------------------------------------------------------------
1625
1626 def _condition_matches(condition: PolicyCondition, pattern: ConflictPattern) -> bool:
1627 """Return ``True`` if *pattern* satisfies every non-``None`` field of *condition*.
1628
1629 ``None`` fields are wildcards and match any value. ``path_pattern`` uses
1630 :func:`fnmatch.fnmatch` glob semantics (e.g. ``"*.mid"``, ``"src/**"``).
1631
1632 ``min_confidence`` is a proposal-time filter evaluated by the harmony
1633 engine against the resolution confidence — not against any static field
1634 of the pattern — so it is intentionally not checked here.
1635 """
1636 if condition.conflict_type is not None:
1637 if pattern.conflict_type != condition.conflict_type:
1638 return False
1639
1640 if condition.domain is not None:
1641 if pattern.domain != condition.domain:
1642 return False
1643
1644 if condition.path_pattern is not None:
1645 if not fnmatch.fnmatch(pattern.path, condition.path_pattern):
1646 return False
1647
1648 return True
1649
1650 def match_policy(
1651 policies: list[Policy],
1652 pattern: ConflictPattern,
1653 ) -> Policy | None:
1654 """Return the first :class:`Policy` whose condition matches *pattern*.
1655
1656 Evaluates policies in the order given. Callers should pass the list
1657 returned by :func:`list_policies`, which is already sorted by scope
1658 (workspace → repo → domain → file).
1659
1660 Args:
1661 policies: Ordered list of active policies to evaluate.
1662 pattern: Incoming conflict pattern to match against.
1663
1664 Returns:
1665 The first matching :class:`Policy`, or ``None`` if no policy fires.
1666 """
1667 for policy in policies:
1668 if _condition_matches(policy.when, pattern):
1669 return policy
1670 return None
1671
1672 # ---------------------------------------------------------------------------
1673 # Escalation ID computation
1674 # ---------------------------------------------------------------------------
1675
1676 def compute_escalation_id(pattern_id: str, reason: str) -> str:
1677 """Return a deterministic ``sha256:`` escalation ID.
1678
1679 The same (pattern_id, reason) pair always produces the same escalation_id,
1680 enabling idempotent :func:`record_escalation` calls — recording the same
1681 escalation twice returns ``False`` without creating a duplicate file.
1682
1683 Args:
1684 pattern_id: ``sha256:`` ID of the pattern that was escalated.
1685 reason: The human-readable escalation reason string.
1686
1687 Returns:
1688 ``sha256:`` content-addressed escalation ID (length 71).
1689 """
1690 return content_hash({"pattern_id": pattern_id, "reason": reason})
1691
1692 # ---------------------------------------------------------------------------
1693 # Escalation CRUD
1694 # ---------------------------------------------------------------------------
1695
1696 def _escalation_to_dict(rec: EscalationRecord) -> _EscalationDict:
1697 return _EscalationDict(
1698 escalation_id=rec.escalation_id,
1699 pattern_id=rec.pattern_id,
1700 reason=rec.reason,
1701 escalated_at=rec.escalated_at.isoformat(),
1702 escalated_by=rec.escalated_by.to_dict(),
1703 resolved_at=rec.resolved_at.isoformat() if rec.resolved_at is not None else None,
1704 resolved_by=rec.resolved_by.to_dict() if rec.resolved_by is not None else None,
1705 resolution_id=rec.resolution_id,
1706 status=rec.status,
1707 )
1708
1709 def _dict_to_escalation(data: Mapping[str, JsonValue]) -> EscalationRecord:
1710 escalated_by_raw = data["escalated_by"]
1711 escalated_by = AgentProvenance(
1712 type=escalated_by_raw.get("type", "human"),
1713 agent_id=escalated_by_raw.get("agent_id"),
1714 model_id=escalated_by_raw.get("model_id"),
1715 )
1716
1717 resolved_at = (
1718 datetime.datetime.fromisoformat(data["resolved_at"])
1719 if data.get("resolved_at") is not None
1720 else None
1721 )
1722 resolved_by: AgentProvenance | None = None
1723 if data.get("resolved_by") is not None:
1724 rb = data["resolved_by"]
1725 resolved_by = AgentProvenance(
1726 type=rb.get("type", "human"),
1727 agent_id=rb.get("agent_id"),
1728 model_id=rb.get("model_id"),
1729 )
1730
1731 return EscalationRecord(
1732 escalation_id=data["escalation_id"],
1733 pattern_id=data["pattern_id"],
1734 reason=data["reason"],
1735 escalated_at=datetime.datetime.fromisoformat(data["escalated_at"]),
1736 escalated_by=escalated_by,
1737 resolved_at=resolved_at,
1738 resolved_by=resolved_by,
1739 resolution_id=data.get("resolution_id"),
1740 status=data.get("status", EscalationStatus.OPEN),
1741 )
1742
1743 def record_escalation(root: pathlib.Path, record: EscalationRecord) -> bool:
1744 """Persist an :class:`EscalationRecord` to the harmony store.
1745
1746 The file is written atomically with ``os.replace``. If the escalation
1747 already exists (same ``escalation_id``), the call is a no-op and returns
1748 ``False``. Returns ``True`` on first write.
1749
1750 Args:
1751 root: Repository root.
1752 record: The escalation record to persist.
1753
1754 Returns:
1755 ``True`` if the record was newly written; ``False`` if it already
1756 existed.
1757 """
1758 esc_dir = escalations_dir(root)
1759 dest = escalation_path(root, record.escalation_id)
1760 dest.parent.mkdir(parents=True, exist_ok=True)
1761
1762 if dest.exists() and not dest.is_symlink():
1763 return False
1764
1765 payload = json.dumps(_escalation_to_dict(record), indent=2).encode()
1766 fd, tmp = tempfile.mkstemp(dir=esc_dir, suffix=".tmp")
1767 try:
1768 os.write(fd, payload)
1769 os.fsync(fd)
1770 finally:
1771 os.close(fd)
1772 os.replace(tmp, dest)
1773 logger.debug("harmony: recorded escalation %s for pattern %s",
1774 short_id(record.escalation_id), short_id(record.pattern_id))
1775 return True
1776
1777 def load_escalation(root: pathlib.Path, escalation_id: str) -> EscalationRecord | None:
1778 """Load an :class:`EscalationRecord` by ID.
1779
1780 Args:
1781 root: Repository root.
1782 escalation_id: 64-char hex escalation ID.
1783
1784 Returns:
1785 The :class:`EscalationRecord`, or ``None`` if not found.
1786
1787 Raises:
1788 ValueError: If ``escalation_id`` is not a valid 64-char hex string.
1789 """
1790 _validate_id(escalation_id, "escalation_id")
1791 dest = escalation_path(root, escalation_id)
1792
1793 if not dest.exists() or dest.is_symlink():
1794 return None
1795
1796 raw = dest.read_bytes()
1797 if len(raw) > _MAX_ESCALATION_BYTES:
1798 logger.warning("harmony: escalation %s exceeds size cap — skipping", escalation_id)
1799 return None
1800
1801 try:
1802 data = json.loads(raw)
1803 return _dict_to_escalation(data)
1804 except Exception as exc:
1805 logger.warning("harmony: failed to parse escalation %s: %s", escalation_id, exc)
1806 return None
1807
1808 def list_escalations(
1809 root: pathlib.Path,
1810 status: str | None = None,
1811 ) -> list[EscalationRecord]:
1812 """Return all escalation records, optionally filtered by status.
1813
1814 Results are sorted newest-first by ``escalated_at``. Symlinks and files
1815 that exceed :data:`_MAX_ESCALATION_BYTES` are silently skipped.
1816
1817 Args:
1818 root: Repository root.
1819 status: If given, only records with this :class:`EscalationStatus`
1820 value are returned. ``None`` returns all records.
1821
1822 Returns:
1823 List of :class:`EscalationRecord` instances, newest-first.
1824 """
1825 esc_dir = escalations_dir(root)
1826 if not esc_dir.exists():
1827 return []
1828
1829 # New layout: escalations/<algo>/<hex>.json
1830 records: list[EscalationRecord] = []
1831 for algo_dir in esc_dir.iterdir():
1832 if algo_dir.is_symlink() or not algo_dir.is_dir():
1833 continue
1834 for entry in algo_dir.iterdir():
1835 if entry.is_symlink() or not entry.is_file():
1836 continue
1837 if not entry.name.endswith(".json"):
1838 continue
1839
1840 raw = entry.read_bytes()
1841 if len(raw) > _MAX_ESCALATION_BYTES:
1842 logger.warning("harmony: escalation file %s exceeds size cap — skipping", entry.name)
1843 continue
1844
1845 try:
1846 data = json.loads(raw)
1847 rec = _dict_to_escalation(data)
1848 except Exception as exc:
1849 logger.warning("harmony: failed to parse escalation %s: %s", entry.name, exc)
1850 continue
1851
1852 if status is not None and rec.status != status:
1853 continue
1854 records.append(rec)
1855
1856 records.sort(key=lambda r: r.escalated_at, reverse=True)
1857 return records
1858
1859 def resolve_escalation(
1860 root: pathlib.Path,
1861 escalation_id: str,
1862 resolution_id: str,
1863 resolved_by: AgentProvenance,
1864 resolved_at: datetime.datetime,
1865 ) -> bool:
1866 """Transition an :class:`EscalationRecord` from OPEN to RESOLVED.
1867
1868 Reads the existing record, updates status + resolution fields, and writes
1869 atomically. If the escalation does not exist, returns ``False``.
1870
1871 Args:
1872 root: Repository root.
1873 escalation_id: 64-char hex ID of the escalation to close.
1874 resolution_id: 64-char hex ID of the resolution that closes it.
1875 resolved_by: :class:`AgentProvenance` of who resolved it.
1876 resolved_at: UTC-aware timestamp of the resolution.
1877
1878 Returns:
1879 ``True`` if the record was found and updated; ``False`` if not found.
1880
1881 Raises:
1882 ValueError: If ``escalation_id`` is not a valid 64-char hex string.
1883 """
1884 _validate_id(escalation_id, "escalation_id")
1885 existing = load_escalation(root, escalation_id)
1886 if existing is None:
1887 return False
1888
1889 updated = dc_replace(
1890 existing,
1891 status=EscalationStatus.RESOLVED,
1892 resolution_id=resolution_id,
1893 resolved_by=resolved_by,
1894 resolved_at=resolved_at,
1895 )
1896
1897 dest = escalation_path(root, escalation_id)
1898 payload = json.dumps(_escalation_to_dict(updated), indent=2).encode()
1899 fd, tmp = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
1900 try:
1901 os.write(fd, payload)
1902 os.fsync(fd)
1903 finally:
1904 os.close(fd)
1905 os.replace(tmp, dest)
1906 logger.debug("harmony: resolved escalation %s", short_id(escalation_id))
1907 return True
1908
1909 # ---------------------------------------------------------------------------
1910 # High-level integration helpers (merge and commit integration)
1911 # ---------------------------------------------------------------------------
1912
1913 def compute_semantic_fingerprint(
1914 path: str,
1915 ours_id: str,
1916 theirs_id: str,
1917 plugin: "MuseDomainPlugin",
1918 repo_root: pathlib.Path,
1919 ) -> str:
1920 """Return the semantic fingerprint for a conflict.
1921
1922 Uses the plugin's ``conflict_fingerprint()`` method if it implements the
1923 :class:`~muse.domain.HarmonyPlugin` sub-protocol; falls back to
1924 :func:`blob_fingerprint` otherwise.
1925
1926 Args:
1927 path: Workspace-relative POSIX path of the conflicting file.
1928 ours_id: SHA-256 object ID of the "ours" blob.
1929 theirs_id: SHA-256 object ID of the "theirs" blob.
1930 plugin: Active domain plugin instance.
1931 repo_root: Repository root for plugin context.
1932
1933 Returns:
1934 sha256: content-addressed fingerprint.
1935 """
1936 from muse.domain import HarmonyPlugin as _HarmonyPlugin
1937
1938 if isinstance(plugin, _HarmonyPlugin):
1939 try:
1940 fp = plugin.conflict_fingerprint(path, ours_id, theirs_id, repo_root)
1941 if fp and _SHA256_ID_RE.match(fp):
1942 return fp
1943 logger.warning(
1944 "⚠️ harmony: plugin conflict_fingerprint returned invalid value %r — "
1945 "falling back to blob fingerprint",
1946 fp,
1947 )
1948 except Exception as exc:
1949 logger.warning(
1950 "⚠️ harmony: plugin conflict_fingerprint raised %s — "
1951 "falling back to blob fingerprint",
1952 exc,
1953 )
1954 return blob_fingerprint(ours_id, theirs_id)
1955
1956 def auto_apply(
1957 root: pathlib.Path,
1958 conflict_paths: list[str],
1959 ours_manifest: Manifest,
1960 theirs_manifest: Manifest,
1961 domain: str,
1962 plugin: "MuseDomainPlugin",
1963 ) -> tuple[Manifest, list[str]]:
1964 """Attempt to auto-resolve conflicts using saved harmony resolutions.
1965
1966 For each path in *conflict_paths*:
1967
1968 1. Validate the path stays within the repository root (path traversal guard).
1969 2. Compute blob and semantic fingerprints from the ours/theirs object IDs.
1970 3. Look up the best :class:`Resolution` for the computed pattern ID.
1971 4. If found: restore the resolution blob to the working tree and collect
1972 the ``{path: outcome_blob}`` mapping.
1973 5. If not found: record the pattern for future learning and leave the path
1974 for manual resolution.
1975
1976 Args:
1977 root: Repository root.
1978 conflict_paths: Workspace-relative POSIX paths that conflicted.
1979 ours_manifest: ``{path: object_id}`` for the "ours" snapshot.
1980 theirs_manifest: ``{path: object_id}`` for the "theirs" snapshot.
1981 domain: Active domain name string.
1982 plugin: Active domain plugin instance.
1983
1984 Returns:
1985 ``(resolved, remaining)`` where:
1986
1987 - ``resolved`` maps path → outcome_blob for auto-resolved paths.
1988 - ``remaining`` is the list of paths still requiring manual resolution.
1989 """
1990 from muse.core.object_store import restore_object
1991
1992 resolved: dict[str, str] = {}
1993 remaining: list[str] = []
1994
1995 for path in conflict_paths:
1996 # Conflict paths from the code domain are symbol addresses of the form
1997 # "file.py::SymbolName". Manifests are keyed by file path, so extract
1998 # the file portion for all manifest lookups. The full address is kept
1999 # as the canonical path for pattern storage and fingerprinting so that
2000 # two different symbols in the same file produce distinct patterns.
2001 file_path = path.split("::")[0]
2002
2003 # Guard against path traversal from a tampered MERGE_STATE.json.
2004 # Use the file portion for the filesystem check.
2005 # resolve() normalises any ".." components and symlinks before the
2006 # relative_to() check, so "../traversal.py" is caught correctly.
2007 try:
2008 (root / file_path).resolve().relative_to(root.resolve())
2009 except ValueError:
2010 logger.warning(
2011 "⚠️ harmony: path traversal attempt in auto_apply — skipping %r",
2012 path,
2013 )
2014 remaining.append(path)
2015 continue
2016
2017 ours_id = ours_manifest.get(file_path, "")
2018 theirs_id = theirs_manifest.get(file_path, "")
2019
2020 if not ours_id or not theirs_id:
2021 # One side deleted the file — cannot fingerprint, leave for manual.
2022 remaining.append(path)
2023 continue
2024
2025 blob_fp = blob_fingerprint(ours_id, theirs_id)
2026 semantic_fp = compute_semantic_fingerprint(path, ours_id, theirs_id, plugin, root)
2027 pattern_id = compute_pattern_id(path, blob_fp, semantic_fp)
2028
2029 best = best_resolution(root, pattern_id)
2030 if best is not None:
2031 dest = root / file_path
2032 dest.parent.mkdir(parents=True, exist_ok=True)
2033 ok = restore_object(root, best.outcome_blob, dest)
2034 if ok:
2035 resolved[path] = best.outcome_blob
2036 logger.info("✅ harmony: auto-resolved '%s'", path)
2037 try:
2038 increment_applied_count(root, pattern_id, best.resolution_id)
2039 except Exception as exc:
2040 logger.warning(
2041 "⚠️ harmony: increment_applied_count failed for '%s': %s",
2042 path, exc,
2043 )
2044 else:
2045 logger.warning(
2046 "⚠️ harmony: resolution blob %s for '%s' not in local store",
2047 best.outcome_blob, path,
2048 )
2049 remaining.append(path)
2050 else:
2051 # No saved resolution — record the pattern for future learning.
2052 now = _now_utc()
2053 pattern = ConflictPattern(
2054 pattern_id=pattern_id,
2055 path=path,
2056 domain=domain,
2057 conflict_type=ConflictType.CONTENT,
2058 blob_fingerprint=blob_fp,
2059 semantic_fingerprint=semantic_fp,
2060 ours_id=ours_id,
2061 theirs_id=theirs_id,
2062 description={},
2063 recorded_at=now,
2064 recorded_by="auto_apply",
2065 )
2066 record_pattern(root, pattern)
2067 remaining.append(path)
2068
2069 return resolved, remaining
2070
2071 def record_resolutions(
2072 root: pathlib.Path,
2073 conflict_paths: list[str],
2074 ours_manifest: Manifest,
2075 theirs_manifest: Manifest,
2076 new_manifest: Manifest,
2077 domain: str,
2078 plugin: "MuseDomainPlugin",
2079 ) -> list[str]:
2080 """Record how the user resolved each conflict after a merge commit.
2081
2082 Called by ``muse commit`` immediately after writing a merge commit that
2083 resolves a conflicted merge. For each previously conflicting path the
2084 function reads the resolution object ID from the new snapshot manifest and
2085 saves it to the harmony store as a human-verified :class:`Resolution`.
2086
2087 Args:
2088 root: Repository root.
2089 conflict_paths: Paths that were listed as conflicts in MERGE_STATE.
2090 ours_manifest: ``{path: object_id}`` for the "ours" snapshot.
2091 theirs_manifest: ``{path: object_id}`` for the "theirs" snapshot.
2092 new_manifest: ``{path: object_id}`` from the committed merge snapshot.
2093 domain: Active domain name string.
2094 plugin: Active domain plugin instance.
2095
2096 Returns:
2097 List of paths for which a resolution was successfully saved.
2098 """
2099 saved: list[str] = []
2100
2101 for path in conflict_paths:
2102 # Conflict paths from the code domain are symbol addresses of the form
2103 # "file.py::SymbolName". Extract the file portion for manifest lookups;
2104 # keep the full address as the canonical path in the harmony store.
2105 file_path = path.split("::")[0]
2106
2107 ours_id = ours_manifest.get(file_path, "")
2108 theirs_id = theirs_manifest.get(file_path, "")
2109 outcome_blob = new_manifest.get(file_path, "")
2110
2111 if not ours_id or not theirs_id or not outcome_blob:
2112 continue
2113
2114 try:
2115 validate_object_id(outcome_blob)
2116 except ValueError:
2117 continue
2118
2119 blob_fp = blob_fingerprint(ours_id, theirs_id)
2120 semantic_fp = compute_semantic_fingerprint(path, ours_id, theirs_id, plugin, root)
2121 pattern_id = compute_pattern_id(path, blob_fp, semantic_fp)
2122 now = _now_utc()
2123
2124 # Ensure the pattern exists in the harmony store.
2125 if load_pattern(root, pattern_id) is None:
2126 pattern = ConflictPattern(
2127 pattern_id=pattern_id,
2128 path=path,
2129 domain=domain,
2130 conflict_type=ConflictType.CONTENT,
2131 blob_fingerprint=blob_fp,
2132 semantic_fingerprint=semantic_fp,
2133 ours_id=ours_id,
2134 theirs_id=theirs_id,
2135 description={},
2136 recorded_at=now,
2137 recorded_by="record_resolutions",
2138 )
2139 record_pattern(root, pattern)
2140
2141 # Idempotency: skip if a resolution with this outcome_blob already exists.
2142 existing = list_resolutions(root, pattern_id)
2143 if any(r.outcome_blob == outcome_blob for r in existing):
2144 logger.debug(
2145 "harmony: resolution for '%s' (pattern %s) already recorded — skipping",
2146 path, short_id(pattern_id),
2147 )
2148 saved.append(path)
2149 continue
2150
2151 resolution_id = compute_resolution_id(
2152 pattern_id,
2153 outcome_blob,
2154 ResolutionStrategy.MANUAL,
2155 AgentProvenance.human(),
2156 now,
2157 )
2158 resolution = Resolution(
2159 resolution_id=resolution_id,
2160 pattern_id=pattern_id,
2161 strategy=ResolutionStrategy.MANUAL,
2162 policy_id=None,
2163 outcome_blob=outcome_blob,
2164 rationale=f"User resolved conflict in '{path}'",
2165 confidence=1.0,
2166 applied_count=0,
2167 human_verified=True,
2168 resolved_at=now,
2169 resolved_by=AgentProvenance.human(),
2170 )
2171 try:
2172 save_resolution(root, resolution)
2173 saved.append(path)
2174 logger.info(
2175 "✅ harmony: recorded resolution for '%s' (pattern %s)",
2176 path,
2177 pattern_id,
2178 )
2179 except Exception as exc:
2180 logger.warning(
2181 "⚠️ harmony: failed to save resolution for '%s': %s",
2182 path, exc,
2183 )
2184
2185 return saved
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago