harmony.py
python
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