_handlers.py
python
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839
chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm…
Sonnet 5
patch
15 days ago
| 1 | """Handler functions for the harmony CLI subcommands.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import argparse |
| 6 | import datetime |
| 7 | import json |
| 8 | import logging |
| 9 | import sys |
| 10 | from typing import Any |
| 11 | |
| 12 | from muse.core.envelope import make_envelope |
| 13 | from muse.core.errors import ExitCode |
| 14 | from muse.core.harmony import ( |
| 15 | AgentProvenance, |
| 16 | AuditEventType, |
| 17 | ConflictPattern, |
| 18 | EscalationRecord, |
| 19 | Policy, |
| 20 | PolicyCondition, |
| 21 | Resolution, |
| 22 | _validate_fingerprint, |
| 23 | _validate_id, |
| 24 | _validate_policy_id, |
| 25 | append_audit, |
| 26 | best_resolution, |
| 27 | blob_fingerprint, |
| 28 | clear_all, |
| 29 | compute_escalation_id, |
| 30 | compute_pattern_id, |
| 31 | compute_resolution_id, |
| 32 | forget_pattern, |
| 33 | gc_stale, |
| 34 | list_audit, |
| 35 | list_escalations, |
| 36 | list_patterns, |
| 37 | list_policies, |
| 38 | list_resolutions, |
| 39 | load_pattern, |
| 40 | patterns_dir, |
| 41 | record_escalation, |
| 42 | record_pattern, |
| 43 | remove_policy, |
| 44 | resolve_escalation, |
| 45 | save_policy, |
| 46 | save_resolution, |
| 47 | ) |
| 48 | from muse.core.harmony.engine import EngineConfig, find_similar as _engine_find_similar, resolve as _engine_resolve |
| 49 | from muse.core.repo import require_repo |
| 50 | from muse.core.timing import start_timer |
| 51 | from muse.core.types import blob_id, short_id |
| 52 | from muse.core.validation import clamp_int, sanitize_display |
| 53 | from ._shapes import ( |
| 54 | _HarmonyAuditJson, |
| 55 | _HarmonyBestJson, |
| 56 | _HarmonyEngineJson, |
| 57 | _HarmonyEscalateJson, |
| 58 | _HarmonyEscalationEntryJson, |
| 59 | _HarmonyEscalationsJson, |
| 60 | _HarmonyForgetJson, |
| 61 | _HarmonyGcJson, |
| 62 | _HarmonyListEntryJson, |
| 63 | _HarmonyListJson, |
| 64 | _HarmonyPolicyAddJson, |
| 65 | _HarmonyPolicyListJson, |
| 66 | _HarmonyPolicyRemoveJson, |
| 67 | _HarmonyProposalJson, |
| 68 | _HarmonyRecordJson, |
| 69 | _HarmonyResolveEscalationJson, |
| 70 | _HarmonyResolveJson, |
| 71 | _HarmonyScalarJson, |
| 72 | _HarmonySimilarJson, |
| 73 | _HarmonyShowJson, |
| 74 | _escalation_to_entry, |
| 75 | _pattern_to_detail, |
| 76 | _pattern_to_list_entry, |
| 77 | _policy_to_entry, |
| 78 | _resolution_to_detail, |
| 79 | ) |
| 80 | |
| 81 | logger = logging.getLogger(__name__) |
| 82 | |
| 83 | # --------------------------------------------------------------------------- |
| 84 | # record |
| 85 | # --------------------------------------------------------------------------- |
| 86 | |
| 87 | def run_record(args: argparse.Namespace) -> None: |
| 88 | """Record a new conflict pattern in the harmony store. |
| 89 | |
| 90 | Computes a blob fingerprint from the sorted (ours_id, theirs_id) pair. |
| 91 | If ``--semantic-fingerprint`` is not supplied the blob fingerprint is reused |
| 92 | as the semantic fingerprint. Recording the same pattern twice is idempotent |
| 93 | — the existing entry is returned unchanged. Writes an audit entry on first record. |
| 94 | |
| 95 | Agent quickstart |
| 96 | ---------------- |
| 97 | :: |
| 98 | |
| 99 | muse harmony record <path> --ours-id <hex> --theirs-id <hex> --format json |
| 100 | muse harmony record config.py --ours-id <hex> --theirs-id <hex> --domain code --format json |
| 101 | |
| 102 | JSON fields |
| 103 | ----------- |
| 104 | pattern_id Hex-64 fingerprint identifying the pattern. |
| 105 | already_existed ``true`` if the pattern was already in the store. |
| 106 | |
| 107 | Exit codes |
| 108 | ---------- |
| 109 | 0 Success. |
| 110 | 1 Invalid IDs, fingerprints, or description. |
| 111 | 2 Not inside a Muse repository. |
| 112 | """ |
| 113 | elapsed = start_timer() |
| 114 | path: str = args.path |
| 115 | domain: str = args.domain |
| 116 | conflict_type: str = args.conflict_type |
| 117 | ours_id: str = args.ours_id |
| 118 | theirs_id: str = args.theirs_id |
| 119 | semantic_fp_arg: str | None = args.semantic_fingerprint |
| 120 | description_raw: str | None = args.description |
| 121 | recorded_by: str = args.recorded_by |
| 122 | json_out: bool = args.json_out |
| 123 | |
| 124 | # Validate hex IDs before touching the filesystem. |
| 125 | for label, value in (("ours_id", ours_id), ("theirs_id", theirs_id)): |
| 126 | try: |
| 127 | _validate_id(value, label) |
| 128 | except ValueError as exc: |
| 129 | if json_out: |
| 130 | print(json.dumps({"error": str(exc)})) |
| 131 | else: |
| 132 | print(f"❌ {exc}", file=sys.stderr) |
| 133 | raise SystemExit(ExitCode.USER_ERROR) |
| 134 | |
| 135 | if semantic_fp_arg is not None: |
| 136 | try: |
| 137 | _validate_fingerprint(semantic_fp_arg, "semantic_fingerprint") |
| 138 | except ValueError as exc: |
| 139 | if json_out: |
| 140 | print(json.dumps({"error": str(exc)})) |
| 141 | else: |
| 142 | print(f"❌ {exc}", file=sys.stderr) |
| 143 | raise SystemExit(ExitCode.USER_ERROR) |
| 144 | |
| 145 | description = {} |
| 146 | if description_raw is not None: |
| 147 | try: |
| 148 | description = json.loads(description_raw) |
| 149 | if not isinstance(description, dict): |
| 150 | raise ValueError("description must be a JSON object") |
| 151 | except (json.JSONDecodeError, ValueError) as exc: |
| 152 | if json_out: |
| 153 | print(json.dumps({"error": f"Invalid --description: {exc}"})) |
| 154 | else: |
| 155 | print(f"❌ Invalid --description: {exc}", file=sys.stderr) |
| 156 | raise SystemExit(ExitCode.USER_ERROR) |
| 157 | |
| 158 | root = require_repo() |
| 159 | |
| 160 | blob_fp = blob_fingerprint(ours_id, theirs_id) |
| 161 | semantic_fp = semantic_fp_arg if semantic_fp_arg is not None else blob_fp |
| 162 | pattern_id = compute_pattern_id(path, blob_fp, semantic_fp) |
| 163 | |
| 164 | # Check idempotency before building the full object. |
| 165 | existing = load_pattern(root, pattern_id) |
| 166 | already_existed = existing is not None |
| 167 | |
| 168 | if not already_existed: |
| 169 | pattern = ConflictPattern( |
| 170 | pattern_id=pattern_id, |
| 171 | path=path, |
| 172 | domain=domain, |
| 173 | conflict_type=conflict_type, |
| 174 | blob_fingerprint=blob_fp, |
| 175 | semantic_fingerprint=semantic_fp, |
| 176 | ours_id=ours_id, |
| 177 | theirs_id=theirs_id, |
| 178 | description=description, |
| 179 | recorded_at=datetime.datetime.now(datetime.timezone.utc), |
| 180 | recorded_by=recorded_by, |
| 181 | ) |
| 182 | record_pattern(root, pattern) |
| 183 | append_audit( |
| 184 | root, |
| 185 | AuditEventType.PATTERN_RECORDED, |
| 186 | AgentProvenance.human() if recorded_by == "human" |
| 187 | else AgentProvenance.agent(recorded_by), |
| 188 | pattern_id=pattern_id, |
| 189 | ) |
| 190 | logger.debug("harmony: recorded pattern %s for '%s'", short_id(pattern_id), path) |
| 191 | |
| 192 | if json_out: |
| 193 | print(json.dumps(_HarmonyRecordJson( |
| 194 | **make_envelope(elapsed), |
| 195 | pattern_id=pattern_id, |
| 196 | already_existed=already_existed, |
| 197 | ))) |
| 198 | return |
| 199 | |
| 200 | if already_existed: |
| 201 | print(f" ℹ pattern {pattern_id} already recorded for '{sanitize_display(path)}'") |
| 202 | else: |
| 203 | print(f" ✅ recorded pattern {pattern_id} '{sanitize_display(path)}'") |
| 204 | |
| 205 | # --------------------------------------------------------------------------- |
| 206 | # list |
| 207 | # --------------------------------------------------------------------------- |
| 208 | |
| 209 | def run_list(args: argparse.Namespace) -> None: |
| 210 | """List all conflict patterns in the harmony store. |
| 211 | |
| 212 | Returns patterns sorted newest-first. Use ``--domain`` and |
| 213 | ``--conflict-type`` to narrow the results. |
| 214 | |
| 215 | Agent quickstart |
| 216 | ---------------- |
| 217 | :: |
| 218 | |
| 219 | muse harmony patterns --format json |
| 220 | muse harmony patterns --domain code --format json |
| 221 | |
| 222 | JSON fields |
| 223 | ----------- |
| 224 | total Total number of patterns matching the filter. |
| 225 | patterns List of pattern objects: ``pattern_id``, ``path``, ``domain``, |
| 226 | ``conflict_type``, ``resolution_count``, ``recorded_at``. |
| 227 | |
| 228 | Exit codes |
| 229 | ---------- |
| 230 | 0 Success (may be empty). |
| 231 | 2 Not inside a Muse repository. |
| 232 | """ |
| 233 | elapsed = start_timer() |
| 234 | domain_filter: str | None = args.domain |
| 235 | ct_filter: str | None = args.conflict_type |
| 236 | json_out: bool = args.json_out |
| 237 | |
| 238 | root = require_repo() |
| 239 | patterns = list_patterns(root) |
| 240 | |
| 241 | if domain_filter is not None: |
| 242 | patterns = [p for p in patterns if p.domain == domain_filter] |
| 243 | if ct_filter is not None: |
| 244 | patterns = [p for p in patterns if p.conflict_type == ct_filter] |
| 245 | |
| 246 | entries: list[_HarmonyListEntryJson] = [] |
| 247 | for p in patterns: |
| 248 | resolutions = list_resolutions(root, p.pattern_id) |
| 249 | entries.append(_pattern_to_list_entry(p, len(resolutions))) |
| 250 | |
| 251 | if json_out: |
| 252 | print(json.dumps(_HarmonyListJson( |
| 253 | **make_envelope(elapsed), |
| 254 | total=len(entries), |
| 255 | patterns=entries, |
| 256 | ))) |
| 257 | return |
| 258 | |
| 259 | if not entries: |
| 260 | print("No harmony conflict patterns recorded.") |
| 261 | return |
| 262 | |
| 263 | print(f"{'pattern_id':14} {'domain':8} {'type':12} {'res':>3} path") |
| 264 | print("─" * 70) |
| 265 | for e in entries: |
| 266 | pid_short = short_id(e["pattern_id"]) |
| 267 | print( |
| 268 | f" {pid_short} {e['domain']:8} {e['conflict_type']:12} " |
| 269 | f"{e['resolution_count']:>3} {e['path']}" |
| 270 | ) |
| 271 | |
| 272 | # --------------------------------------------------------------------------- |
| 273 | # show |
| 274 | # --------------------------------------------------------------------------- |
| 275 | |
| 276 | def run_show(args: argparse.Namespace) -> None: |
| 277 | """Show a conflict pattern and all its resolutions. |
| 278 | |
| 279 | Exits 1 if the pattern_id is invalid or the pattern does not exist. |
| 280 | |
| 281 | JSON schema:: |
| 282 | |
| 283 | {"pattern": {...}, "resolutions": [{...}, ...]} |
| 284 | |
| 285 | Exit codes: |
| 286 | 0 — pattern found |
| 287 | 1 — invalid pattern_id or pattern not found |
| 288 | 2 — not inside a Muse repository |
| 289 | """ |
| 290 | elapsed = start_timer() |
| 291 | pattern_id: str = args.pattern_id |
| 292 | json_out: bool = args.json_out |
| 293 | |
| 294 | try: |
| 295 | _validate_id(pattern_id, "pattern_id") |
| 296 | except ValueError as exc: |
| 297 | if json_out: |
| 298 | print(json.dumps({"error": str(exc)})) |
| 299 | else: |
| 300 | print(f"❌ {exc}", file=sys.stderr) |
| 301 | raise SystemExit(ExitCode.USER_ERROR) |
| 302 | |
| 303 | root = require_repo() |
| 304 | pattern = load_pattern(root, pattern_id) |
| 305 | if pattern is None: |
| 306 | msg = f"Pattern {pattern_id} not found." |
| 307 | if json_out: |
| 308 | print(json.dumps({"error": msg})) |
| 309 | else: |
| 310 | print(f"❌ {msg}", file=sys.stderr) |
| 311 | raise SystemExit(ExitCode.USER_ERROR) |
| 312 | |
| 313 | resolutions = list_resolutions(root, pattern_id) |
| 314 | |
| 315 | if json_out: |
| 316 | print(json.dumps(_HarmonyShowJson( |
| 317 | **make_envelope(elapsed), |
| 318 | pattern=_pattern_to_detail(pattern), |
| 319 | resolutions=[_resolution_to_detail(r) for r in resolutions], |
| 320 | ))) |
| 321 | return |
| 322 | |
| 323 | print(f"\nPattern {pattern_id} '{sanitize_display(pattern.path)}'") |
| 324 | print(f" domain: {pattern.domain}") |
| 325 | print(f" type: {pattern.conflict_type}") |
| 326 | print(f" blob_fp: {short_id(pattern.blob_fingerprint)}…") |
| 327 | print(f" recorded_at: {pattern.recorded_at.strftime('%Y-%m-%d %H:%M')}") |
| 328 | print(f" recorded_by: {sanitize_display(pattern.recorded_by)}") |
| 329 | if resolutions: |
| 330 | print(f"\n Resolutions ({len(resolutions)}):") |
| 331 | for r in resolutions: |
| 332 | hv = " ✅ verified" if r.human_verified else "" |
| 333 | print( |
| 334 | f" {short_id(r.resolution_id)} {r.strategy:18} " |
| 335 | f"conf={r.confidence:.2f} applied={r.applied_count}{hv}" |
| 336 | ) |
| 337 | else: |
| 338 | print("\n No resolutions saved yet.") |
| 339 | |
| 340 | # --------------------------------------------------------------------------- |
| 341 | # resolve |
| 342 | # --------------------------------------------------------------------------- |
| 343 | |
| 344 | def run_resolve(args: argparse.Namespace) -> None: |
| 345 | """Save a resolution for a conflict pattern. |
| 346 | |
| 347 | The parent pattern must already exist. Saving the same resolution_id |
| 348 | twice is idempotent. Writes an audit entry on first save. |
| 349 | |
| 350 | JSON schema:: |
| 351 | |
| 352 | {"resolution_id": "<hex64>", "pattern_id": "<hex64>", |
| 353 | "already_existed": <bool>} |
| 354 | |
| 355 | Exit codes: |
| 356 | 0 — resolution saved (or already existed) |
| 357 | 1 — invalid ID, pattern not found, or confidence out of range |
| 358 | 2 — not inside a Muse repository |
| 359 | """ |
| 360 | elapsed = start_timer() |
| 361 | pattern_id: str = args.pattern_id |
| 362 | strategy: str = args.strategy |
| 363 | outcome_blob: str = args.outcome_blob |
| 364 | confidence: float = args.confidence |
| 365 | rationale: str = args.rationale |
| 366 | agent_id: str | None = args.agent_id |
| 367 | model_id: str | None = args.model_id |
| 368 | human_verified: bool = args.human_verified |
| 369 | policy_id: str | None = args.policy_id |
| 370 | json_out: bool = args.json_out |
| 371 | |
| 372 | # Validate IDs. |
| 373 | for label, value in (("pattern_id", pattern_id), ("outcome_blob", outcome_blob)): |
| 374 | try: |
| 375 | _validate_id(value, label) |
| 376 | except ValueError as exc: |
| 377 | if json_out: |
| 378 | print(json.dumps({"error": str(exc)})) |
| 379 | else: |
| 380 | print(f"❌ {exc}", file=sys.stderr) |
| 381 | raise SystemExit(ExitCode.USER_ERROR) |
| 382 | |
| 383 | if not (0.0 <= confidence <= 1.0): |
| 384 | msg = f"--confidence must be between 0.0 and 1.0, got {confidence}" |
| 385 | if json_out: |
| 386 | print(json.dumps({"error": msg})) |
| 387 | else: |
| 388 | print(f"❌ {msg}", file=sys.stderr) |
| 389 | raise SystemExit(ExitCode.USER_ERROR) |
| 390 | |
| 391 | root = require_repo() |
| 392 | |
| 393 | provenance = ( |
| 394 | AgentProvenance.agent(agent_id, model_id) |
| 395 | if agent_id is not None |
| 396 | else AgentProvenance.human() |
| 397 | ) |
| 398 | |
| 399 | actor = provenance.agent_id or "human" |
| 400 | |
| 401 | # Check idempotency: scan existing resolutions for one that matches the |
| 402 | # key fields (outcome_blob, strategy, actor). resolution_id encodes |
| 403 | # resolved_at so two calls at different times would otherwise produce |
| 404 | # distinct IDs for semantically identical resolutions. |
| 405 | existing_resolutions = list_resolutions(root, pattern_id) |
| 406 | already_existed = False |
| 407 | resolution_id: str | None = None |
| 408 | for existing in existing_resolutions: |
| 409 | existing_actor = existing.resolved_by.agent_id or "human" |
| 410 | if ( |
| 411 | existing.outcome_blob == outcome_blob |
| 412 | and existing.strategy == strategy |
| 413 | and existing_actor == actor |
| 414 | ): |
| 415 | resolution_id = existing.resolution_id |
| 416 | already_existed = True |
| 417 | break |
| 418 | |
| 419 | resolved_at = datetime.datetime.now(datetime.timezone.utc) |
| 420 | if resolution_id is None: |
| 421 | resolution_id = compute_resolution_id( |
| 422 | pattern_id, outcome_blob, strategy, provenance, resolved_at |
| 423 | ) |
| 424 | |
| 425 | if not already_existed: |
| 426 | try: |
| 427 | resolution = Resolution( |
| 428 | resolution_id=resolution_id, |
| 429 | pattern_id=pattern_id, |
| 430 | strategy=strategy, |
| 431 | policy_id=policy_id, |
| 432 | outcome_blob=outcome_blob, |
| 433 | resolved_by=provenance, |
| 434 | human_verified=human_verified, |
| 435 | confidence=confidence, |
| 436 | rationale=rationale, |
| 437 | resolved_at=resolved_at, |
| 438 | ) |
| 439 | save_resolution(root, resolution) |
| 440 | except FileNotFoundError as exc: |
| 441 | msg = str(exc) |
| 442 | if json_out: |
| 443 | print(json.dumps({"error": msg})) |
| 444 | else: |
| 445 | print(f"❌ {msg}", file=sys.stderr) |
| 446 | raise SystemExit(ExitCode.USER_ERROR) |
| 447 | |
| 448 | append_audit( |
| 449 | root, |
| 450 | AuditEventType.RESOLUTION_SAVED, |
| 451 | provenance, |
| 452 | pattern_id=pattern_id, |
| 453 | resolution_id=resolution_id, |
| 454 | ) |
| 455 | |
| 456 | if json_out: |
| 457 | print(json.dumps(_HarmonyResolveJson( |
| 458 | **make_envelope(elapsed), |
| 459 | resolution_id=resolution_id, |
| 460 | pattern_id=pattern_id, |
| 461 | already_existed=already_existed, |
| 462 | ))) |
| 463 | return |
| 464 | |
| 465 | if already_existed: |
| 466 | print(f" ℹ resolution {resolution_id} already saved.") |
| 467 | else: |
| 468 | print(f" ✅ saved resolution {resolution_id} for pattern {pattern_id}") |
| 469 | |
| 470 | # --------------------------------------------------------------------------- |
| 471 | # best |
| 472 | # --------------------------------------------------------------------------- |
| 473 | |
| 474 | def run_best(args: argparse.Namespace) -> None: |
| 475 | """Show the highest-quality resolution for a pattern. |
| 476 | |
| 477 | Quality ranking: human_verified > confidence > applied_count. |
| 478 | Returns null if no resolutions exist. |
| 479 | |
| 480 | JSON schema:: |
| 481 | |
| 482 | {"pattern_id": "<hex64>", "resolution": {<resolution>|null}} |
| 483 | |
| 484 | Exit codes: |
| 485 | 0 — result returned |
| 486 | 1 — invalid pattern_id |
| 487 | 2 — not inside a Muse repository |
| 488 | """ |
| 489 | elapsed = start_timer() |
| 490 | pattern_id: str = args.pattern_id |
| 491 | json_out: bool = args.json_out |
| 492 | |
| 493 | try: |
| 494 | _validate_id(pattern_id, "pattern_id") |
| 495 | except ValueError as exc: |
| 496 | if json_out: |
| 497 | print(json.dumps({"error": str(exc)})) |
| 498 | else: |
| 499 | print(f"❌ {exc}", file=sys.stderr) |
| 500 | raise SystemExit(ExitCode.USER_ERROR) |
| 501 | |
| 502 | root = require_repo() |
| 503 | best = best_resolution(root, pattern_id) |
| 504 | |
| 505 | if json_out: |
| 506 | print(json.dumps(_HarmonyBestJson( |
| 507 | **make_envelope(elapsed), |
| 508 | pattern_id=pattern_id, |
| 509 | resolution=_resolution_to_detail(best) if best is not None else None, |
| 510 | ))) |
| 511 | return |
| 512 | |
| 513 | if best is None: |
| 514 | print(f" ℹ No resolutions for pattern {pattern_id}.") |
| 515 | return |
| 516 | |
| 517 | hv = " ✅ verified" if best.human_verified else "" |
| 518 | print( |
| 519 | f" best for {pattern_id}:\n" |
| 520 | f" {best.resolution_id} {best.strategy} " |
| 521 | f"conf={best.confidence:.2f} applied={best.applied_count}{hv}\n" |
| 522 | f" rationale: {sanitize_display(best.rationale)}" |
| 523 | ) |
| 524 | |
| 525 | # --------------------------------------------------------------------------- |
| 526 | # forget |
| 527 | # --------------------------------------------------------------------------- |
| 528 | |
| 529 | def run_forget(args: argparse.Namespace) -> None: |
| 530 | """Remove a conflict pattern and all its resolutions. |
| 531 | |
| 532 | JSON schema:: |
| 533 | |
| 534 | {"pattern_id": "<hex64>", "removed": <bool>} |
| 535 | |
| 536 | Exit codes: |
| 537 | 0 — result returned |
| 538 | 1 — invalid pattern_id |
| 539 | 2 — not inside a Muse repository |
| 540 | """ |
| 541 | elapsed = start_timer() |
| 542 | pattern_id: str = args.pattern_id |
| 543 | json_out: bool = args.json_out |
| 544 | |
| 545 | try: |
| 546 | _validate_id(pattern_id, "pattern_id") |
| 547 | except ValueError as exc: |
| 548 | if json_out: |
| 549 | print(json.dumps({"error": str(exc)})) |
| 550 | else: |
| 551 | print(f"❌ {exc}", file=sys.stderr) |
| 552 | raise SystemExit(ExitCode.USER_ERROR) |
| 553 | |
| 554 | root = require_repo() |
| 555 | removed = forget_pattern(root, pattern_id) |
| 556 | |
| 557 | if json_out: |
| 558 | print(json.dumps(_HarmonyForgetJson( |
| 559 | **make_envelope(elapsed), |
| 560 | pattern_id=pattern_id, |
| 561 | removed=removed, |
| 562 | ))) |
| 563 | return |
| 564 | |
| 565 | if removed: |
| 566 | print(f" 🗑 forgot pattern {pattern_id}") |
| 567 | else: |
| 568 | print(f" ℹ pattern {pattern_id} not found.") |
| 569 | |
| 570 | # --------------------------------------------------------------------------- |
| 571 | # clear |
| 572 | # --------------------------------------------------------------------------- |
| 573 | |
| 574 | def run_clear(args: argparse.Namespace) -> None: |
| 575 | """Remove all conflict patterns and their resolutions. |
| 576 | |
| 577 | JSON schema:: |
| 578 | |
| 579 | {"removed": <int>} |
| 580 | |
| 581 | Exit codes: |
| 582 | 0 — clear completed (removed may be 0) |
| 583 | 2 — not inside a Muse repository |
| 584 | """ |
| 585 | elapsed = start_timer() |
| 586 | yes: bool = args.yes |
| 587 | json_out: bool = args.json_out |
| 588 | |
| 589 | root = require_repo() |
| 590 | |
| 591 | if not yes: |
| 592 | pdir = patterns_dir(root) |
| 593 | count = ( |
| 594 | sum(1 for e in pdir.iterdir() if not e.is_symlink() and e.is_dir()) |
| 595 | if pdir.exists() |
| 596 | else 0 |
| 597 | ) |
| 598 | if count == 0: |
| 599 | if json_out: |
| 600 | print(json.dumps(_HarmonyScalarJson(**make_envelope(elapsed), removed=0))) |
| 601 | else: |
| 602 | print("Harmony store is already empty.") |
| 603 | return |
| 604 | confirmed = input( |
| 605 | f"This will permanently delete {count} harmony pattern(s). Continue? [y/N]: " |
| 606 | ).strip().lower() in ("y", "yes") |
| 607 | if not confirmed: |
| 608 | print("Aborted.") |
| 609 | return |
| 610 | |
| 611 | removed = clear_all(root) |
| 612 | |
| 613 | if json_out: |
| 614 | print(json.dumps(_HarmonyScalarJson(**make_envelope(elapsed), removed=removed))) |
| 615 | return |
| 616 | |
| 617 | print(f"✅ Cleared {removed} harmony pattern(s).") |
| 618 | |
| 619 | # --------------------------------------------------------------------------- |
| 620 | # gc |
| 621 | # --------------------------------------------------------------------------- |
| 622 | |
| 623 | def run_gc(args: argparse.Namespace) -> None: |
| 624 | """Remove stale unresolved patterns older than --age days. |
| 625 | |
| 626 | Patterns with at least one resolution are always kept. |
| 627 | |
| 628 | JSON schema:: |
| 629 | |
| 630 | {"removed": <int>, "age_days": <int>} |
| 631 | |
| 632 | Exit codes: |
| 633 | 0 — gc completed (removed may be 0) |
| 634 | 1 — invalid --age value |
| 635 | 2 — not inside a Muse repository |
| 636 | """ |
| 637 | elapsed = start_timer() |
| 638 | json_out: bool = args.json_out |
| 639 | try: |
| 640 | age_days: int = clamp_int(args.age, 1, 36_500, "age") |
| 641 | except ValueError as exc: |
| 642 | if json_out: |
| 643 | print(json.dumps({"error": str(exc)})) |
| 644 | else: |
| 645 | print(f"❌ {exc}", file=sys.stderr) |
| 646 | raise SystemExit(ExitCode.USER_ERROR) |
| 647 | |
| 648 | root = require_repo() |
| 649 | removed = gc_stale(root, age_days=age_days) |
| 650 | |
| 651 | if json_out: |
| 652 | print(json.dumps(_HarmonyGcJson( |
| 653 | **make_envelope(elapsed), |
| 654 | removed=removed, |
| 655 | age_days=age_days, |
| 656 | ))) |
| 657 | return |
| 658 | |
| 659 | if removed: |
| 660 | print( |
| 661 | f"✅ gc: removed {removed} stale unresolved pattern(s) " |
| 662 | f"older than {age_days} day(s)." |
| 663 | ) |
| 664 | else: |
| 665 | print(f"gc: nothing to remove (threshold: {age_days} day(s)).") |
| 666 | |
| 667 | # --------------------------------------------------------------------------- |
| 668 | # policy-add |
| 669 | # --------------------------------------------------------------------------- |
| 670 | |
| 671 | def run_policy_add(args: argparse.Namespace) -> None: |
| 672 | """Add or replace a declarative resolution policy. |
| 673 | |
| 674 | Overwrites any existing policy with the same policy_id. |
| 675 | |
| 676 | JSON schema:: |
| 677 | |
| 678 | {"policy_id": "<str>", "action": "<str>", "scope": "<str>"} |
| 679 | |
| 680 | Exit codes: |
| 681 | 0 — policy saved |
| 682 | 1 — invalid policy_id |
| 683 | 2 — not inside a Muse repository |
| 684 | """ |
| 685 | elapsed = start_timer() |
| 686 | policy_id: str = args.policy_id |
| 687 | description: str = args.description |
| 688 | scope: str = args.scope |
| 689 | action: str = args.action |
| 690 | confidence: float = args.confidence |
| 691 | conflict_type: str | None = args.conflict_type |
| 692 | domain: str | None = args.domain |
| 693 | path_pattern: str | None = args.path_pattern |
| 694 | escalate_to: str | None = args.escalate_to |
| 695 | delegate_to: str | None = args.delegate_to |
| 696 | created_by: str = args.created_by |
| 697 | json_out: bool = args.json_out |
| 698 | |
| 699 | try: |
| 700 | _validate_policy_id(policy_id) |
| 701 | except ValueError as exc: |
| 702 | if json_out: |
| 703 | print(json.dumps({"error": str(exc)})) |
| 704 | else: |
| 705 | print(f"❌ {exc}", file=sys.stderr) |
| 706 | raise SystemExit(ExitCode.USER_ERROR) |
| 707 | |
| 708 | root = require_repo() |
| 709 | |
| 710 | policy = Policy( |
| 711 | policy_id=policy_id, |
| 712 | description=description, |
| 713 | when=PolicyCondition( |
| 714 | conflict_type=conflict_type, |
| 715 | domain=domain, |
| 716 | path_pattern=path_pattern, |
| 717 | ), |
| 718 | action=action, |
| 719 | confidence=confidence, |
| 720 | escalate_to=escalate_to, |
| 721 | delegate_to=delegate_to, |
| 722 | scope=scope, |
| 723 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 724 | created_by=created_by, |
| 725 | ) |
| 726 | save_policy(root, policy) |
| 727 | append_audit( |
| 728 | root, |
| 729 | AuditEventType.POLICY_SAVED, |
| 730 | AgentProvenance.human() if created_by == "human" |
| 731 | else AgentProvenance.agent(created_by), |
| 732 | policy_id=policy_id, |
| 733 | ) |
| 734 | |
| 735 | if json_out: |
| 736 | print(json.dumps(_HarmonyPolicyAddJson( |
| 737 | **make_envelope(elapsed), |
| 738 | policy_id=policy_id, |
| 739 | action=action, |
| 740 | scope=scope, |
| 741 | ))) |
| 742 | return |
| 743 | |
| 744 | print(f" ✅ saved policy '{policy_id}' scope={scope} action={action}") |
| 745 | |
| 746 | # --------------------------------------------------------------------------- |
| 747 | # policy-list |
| 748 | # --------------------------------------------------------------------------- |
| 749 | |
| 750 | def run_policy_list(args: argparse.Namespace) -> None: |
| 751 | """List all policies from the harmony store, scope-sorted. |
| 752 | |
| 753 | JSON schema:: |
| 754 | |
| 755 | {"total": <int>, "policies": [{...}, ...]} |
| 756 | |
| 757 | Exit codes: |
| 758 | 0 — list returned (may be empty) |
| 759 | 2 — not inside a Muse repository |
| 760 | """ |
| 761 | elapsed = start_timer() |
| 762 | json_out: bool = args.json_out |
| 763 | |
| 764 | root = require_repo() |
| 765 | policies = list_policies(root) |
| 766 | |
| 767 | entries = [_policy_to_entry(p) for p in policies] |
| 768 | |
| 769 | if json_out: |
| 770 | print(json.dumps(_HarmonyPolicyListJson( |
| 771 | **make_envelope(elapsed), |
| 772 | total=len(entries), |
| 773 | policies=entries, |
| 774 | ))) |
| 775 | return |
| 776 | |
| 777 | if not entries: |
| 778 | print("No harmony policies defined.") |
| 779 | return |
| 780 | |
| 781 | print(f"{'policy_id':24} {'scope':10} {'action':14} conf") |
| 782 | print("─" * 60) |
| 783 | for e in entries: |
| 784 | print( |
| 785 | f" {e['policy_id']:<22} {e['scope']:10} " |
| 786 | f"{e['action']:14} {e['confidence']:.2f}" |
| 787 | ) |
| 788 | |
| 789 | # --------------------------------------------------------------------------- |
| 790 | # policy-remove |
| 791 | # --------------------------------------------------------------------------- |
| 792 | |
| 793 | def run_policy_remove(args: argparse.Namespace) -> None: |
| 794 | """Remove a policy from the harmony store. |
| 795 | |
| 796 | JSON schema:: |
| 797 | |
| 798 | {"policy_id": "<str>", "removed": <bool>} |
| 799 | |
| 800 | Exit codes: |
| 801 | 0 — result returned |
| 802 | 1 — invalid policy_id |
| 803 | 2 — not inside a Muse repository |
| 804 | """ |
| 805 | elapsed = start_timer() |
| 806 | policy_id: str = args.policy_id |
| 807 | json_out: bool = args.json_out |
| 808 | |
| 809 | try: |
| 810 | _validate_policy_id(policy_id) |
| 811 | except ValueError as exc: |
| 812 | if json_out: |
| 813 | print(json.dumps({"error": str(exc)})) |
| 814 | else: |
| 815 | print(f"❌ {exc}", file=sys.stderr) |
| 816 | raise SystemExit(ExitCode.USER_ERROR) |
| 817 | |
| 818 | root = require_repo() |
| 819 | removed = remove_policy(root, policy_id) |
| 820 | |
| 821 | if json_out: |
| 822 | print(json.dumps(_HarmonyPolicyRemoveJson( |
| 823 | **make_envelope(elapsed), |
| 824 | policy_id=policy_id, |
| 825 | removed=removed, |
| 826 | ))) |
| 827 | return |
| 828 | |
| 829 | if removed: |
| 830 | print(f" 🗑 removed policy '{policy_id}'") |
| 831 | else: |
| 832 | print(f" ℹ policy '{policy_id}' not found.") |
| 833 | |
| 834 | # --------------------------------------------------------------------------- |
| 835 | # audit |
| 836 | # --------------------------------------------------------------------------- |
| 837 | |
| 838 | def run_audit(args: argparse.Namespace) -> None: |
| 839 | """Show the harmony audit log (newest first). |
| 840 | |
| 841 | JSON schema:: |
| 842 | |
| 843 | {"total": <int>, "entries": [{<AuditEvent>}, ...]} |
| 844 | |
| 845 | Exit codes: |
| 846 | 0 — audit log returned (may be empty) |
| 847 | 2 — not inside a Muse repository |
| 848 | """ |
| 849 | elapsed = start_timer() |
| 850 | limit: int = args.limit |
| 851 | json_out: bool = args.json_out |
| 852 | |
| 853 | root = require_repo() |
| 854 | entries = list_audit(root, limit=limit) |
| 855 | |
| 856 | if json_out: |
| 857 | print(json.dumps(_HarmonyAuditJson( |
| 858 | **make_envelope(elapsed), |
| 859 | total=len(entries), |
| 860 | entries=entries, |
| 861 | ))) |
| 862 | return |
| 863 | |
| 864 | if not entries: |
| 865 | print("Harmony audit log is empty.") |
| 866 | return |
| 867 | |
| 868 | print(f"{'occurred_at':22} {'event_type':28} {'pattern_id':14}") |
| 869 | print("─" * 72) |
| 870 | for e in entries: |
| 871 | pid_short = short_id(e["pattern_id"] or "") or "-" |
| 872 | ts = e["occurred_at"][:19] |
| 873 | print(f" {ts} {e['event_type']:28} {pid_short}") |
| 874 | |
| 875 | # --------------------------------------------------------------------------- |
| 876 | # engine helpers |
| 877 | # --------------------------------------------------------------------------- |
| 878 | |
| 879 | def _proposal_to_json(p: "Any") -> _HarmonyProposalJson: |
| 880 | """Serialise a ResolutionProposal to its JSON TypedDict form.""" |
| 881 | from muse.core.harmony import ResolutionProposal # local to avoid circular |
| 882 | return _HarmonyProposalJson( |
| 883 | pattern_id=p.pattern_id, |
| 884 | strategy=p.strategy, |
| 885 | proposed_action=p.proposed_action, |
| 886 | confidence=p.confidence, |
| 887 | rationale=p.rationale, |
| 888 | policy_id=p.policy_id, |
| 889 | similar_pattern_id=p.similar_pattern_id, |
| 890 | similarity=p.similarity, |
| 891 | requires_confirmation=p.requires_confirmation, |
| 892 | ) |
| 893 | |
| 894 | def _stub_id(seed: str) -> str: |
| 895 | """Return a deterministic ``sha256:``-prefixed ID derived from seed (for stub patterns).""" |
| 896 | return blob_id(seed.encode()) |
| 897 | |
| 898 | # --------------------------------------------------------------------------- |
| 899 | # engine |
| 900 | # --------------------------------------------------------------------------- |
| 901 | |
| 902 | def run_engine(args: argparse.Namespace) -> None: |
| 903 | """Run the three-tier resolution engine for a conflict pattern. |
| 904 | |
| 905 | The engine evaluates the pattern against policies, saved resolutions, and |
| 906 | semantically similar patterns. Returns status=applied|proposed|escalated. |
| 907 | |
| 908 | JSON schema:: |
| 909 | |
| 910 | { |
| 911 | "status": "applied|proposed|escalated", |
| 912 | "pattern_id": "<hex64>", |
| 913 | "proposal": {<proposal>|null}, |
| 914 | "applied_resolution_id": "<hex64>|null", |
| 915 | "escalation_reason": "<str>|null" |
| 916 | } |
| 917 | |
| 918 | Exit codes: |
| 919 | 0 — engine ran successfully |
| 920 | 1 — invalid pattern_id or invalid threshold |
| 921 | 2 — not inside a Muse repository |
| 922 | """ |
| 923 | elapsed = start_timer() |
| 924 | |
| 925 | pattern_id: str = args.pattern_id |
| 926 | threshold: float | None = args.auto_apply_threshold |
| 927 | auto_escalate: bool = getattr(args, "auto_escalate", False) |
| 928 | json_out: bool = args.json_out |
| 929 | |
| 930 | try: |
| 931 | _validate_id(pattern_id, "pattern_id") |
| 932 | except ValueError as exc: |
| 933 | if json_out: |
| 934 | print(json.dumps({"error": str(exc)})) |
| 935 | else: |
| 936 | print(f"❌ {exc}", file=sys.stderr) |
| 937 | raise SystemExit(ExitCode.USER_ERROR) |
| 938 | |
| 939 | if threshold is not None and not (0.0 <= threshold <= 1.0): |
| 940 | msg = f"--auto-apply-threshold must be between 0.0 and 1.0, got {threshold}" |
| 941 | if json_out: |
| 942 | print(json.dumps({"error": msg})) |
| 943 | else: |
| 944 | print(f"❌ {msg}", file=sys.stderr) |
| 945 | raise SystemExit(ExitCode.USER_ERROR) |
| 946 | |
| 947 | root = require_repo() |
| 948 | |
| 949 | # Build a ConflictPattern from the stored pattern, or synthesize a |
| 950 | # minimal stub if it does not exist (engine escalates gracefully). |
| 951 | pattern = load_pattern(root, pattern_id) |
| 952 | if pattern is None: |
| 953 | blob_fp = _stub_id(pattern_id) |
| 954 | pattern = ConflictPattern( |
| 955 | pattern_id=pattern_id, |
| 956 | path="<unknown>", |
| 957 | domain="<unknown>", |
| 958 | conflict_type="<unknown>", |
| 959 | blob_fingerprint=blob_fp, |
| 960 | semantic_fingerprint=blob_fp, |
| 961 | ours_id=blob_fp, |
| 962 | theirs_id=blob_fp, |
| 963 | description={}, |
| 964 | recorded_at=datetime.datetime.now(datetime.timezone.utc), |
| 965 | recorded_by="engine-cli", |
| 966 | ) |
| 967 | |
| 968 | cfg_kwargs = {} |
| 969 | if threshold is not None: |
| 970 | cfg_kwargs["auto_apply_threshold"] = threshold |
| 971 | cfg = EngineConfig(**cfg_kwargs) |
| 972 | |
| 973 | result = _engine_resolve(root, pattern, cfg) |
| 974 | |
| 975 | proposal_json: _HarmonyProposalJson | None = ( |
| 976 | _proposal_to_json(result.proposal) if result.proposal is not None else None |
| 977 | ) |
| 978 | |
| 979 | # Auto-escalate: record an EscalationRecord when the engine escalates. |
| 980 | if auto_escalate and result.status == "escalated": |
| 981 | reason = result.escalation_reason or "Engine escalated — no automatic resolution found" |
| 982 | esc_id = compute_escalation_id(pattern_id, reason) |
| 983 | esc_rec = EscalationRecord( |
| 984 | escalation_id=esc_id, |
| 985 | pattern_id=pattern_id, |
| 986 | reason=reason, |
| 987 | escalated_at=datetime.datetime.now(datetime.timezone.utc), |
| 988 | escalated_by=AgentProvenance.agent("harmony-engine"), |
| 989 | ) |
| 990 | record_escalation(root, esc_rec) |
| 991 | append_audit( |
| 992 | root, |
| 993 | AuditEventType.ESCALATION_RECORDED, |
| 994 | AgentProvenance.agent("harmony-engine"), |
| 995 | pattern_id=pattern_id, |
| 996 | metadata={"escalation_id": esc_id}, |
| 997 | ) |
| 998 | |
| 999 | if json_out: |
| 1000 | print(json.dumps(_HarmonyEngineJson( |
| 1001 | **make_envelope(elapsed), |
| 1002 | status=result.status, |
| 1003 | pattern_id=result.pattern_id, |
| 1004 | proposal=proposal_json, |
| 1005 | applied_resolution_id=result.applied_resolution_id, |
| 1006 | escalation_reason=result.escalation_reason, |
| 1007 | ))) |
| 1008 | return |
| 1009 | |
| 1010 | status_icon = {"applied": "✅", "proposed": "💡", "escalated": "⚠️"}.get(result.status, "") |
| 1011 | print(f" {status_icon} [{result.status.upper()}] pattern {pattern_id}") |
| 1012 | if result.proposal is not None: |
| 1013 | p = result.proposal |
| 1014 | print(f" strategy: {p.strategy}") |
| 1015 | print(f" confidence: {p.confidence:.3f}") |
| 1016 | print(f" rationale: {sanitize_display(p.rationale)}") |
| 1017 | if result.escalation_reason: |
| 1018 | print(f" reason: {sanitize_display(result.escalation_reason)}") |
| 1019 | |
| 1020 | # --------------------------------------------------------------------------- |
| 1021 | # similar |
| 1022 | # --------------------------------------------------------------------------- |
| 1023 | |
| 1024 | def run_similar(args: argparse.Namespace) -> None: |
| 1025 | """Find conflict patterns semantically similar to the given pattern. |
| 1026 | |
| 1027 | Uses DefaultPlugin (exact fingerprint match) at the CLI level. |
| 1028 | |
| 1029 | JSON schema:: |
| 1030 | |
| 1031 | { |
| 1032 | "pattern_id": "<hex64>", |
| 1033 | "total": <int>, |
| 1034 | "proposals": [{<proposal>}, ...] |
| 1035 | } |
| 1036 | |
| 1037 | Exit codes: |
| 1038 | 0 — result returned (proposals may be empty) |
| 1039 | 1 — invalid pattern_id |
| 1040 | 2 — not inside a Muse repository |
| 1041 | """ |
| 1042 | elapsed = start_timer() |
| 1043 | |
| 1044 | pattern_id: str = args.pattern_id |
| 1045 | limit: int | None = args.limit |
| 1046 | json_out: bool = args.json_out |
| 1047 | |
| 1048 | try: |
| 1049 | _validate_id(pattern_id, "pattern_id") |
| 1050 | except ValueError as exc: |
| 1051 | if json_out: |
| 1052 | print(json.dumps({"error": str(exc)})) |
| 1053 | else: |
| 1054 | print(f"❌ {exc}", file=sys.stderr) |
| 1055 | raise SystemExit(ExitCode.USER_ERROR) |
| 1056 | |
| 1057 | root = require_repo() |
| 1058 | |
| 1059 | pattern = load_pattern(root, pattern_id) |
| 1060 | if pattern is None: |
| 1061 | # Pattern not found — return empty results gracefully. |
| 1062 | if json_out: |
| 1063 | print(json.dumps(_HarmonySimilarJson(**make_envelope(elapsed), pattern_id=pattern_id, total=0, proposals=[]))) |
| 1064 | else: |
| 1065 | print(f" ℹ pattern {pattern_id} not found — no similar patterns.") |
| 1066 | return |
| 1067 | |
| 1068 | cfg_kwargs = {} |
| 1069 | if limit is not None: |
| 1070 | cfg_kwargs["max_proposals"] = limit |
| 1071 | cfg = EngineConfig(**cfg_kwargs) |
| 1072 | |
| 1073 | proposals = _engine_find_similar(root, pattern, config=cfg) |
| 1074 | proposal_jsons = [_proposal_to_json(p) for p in proposals] |
| 1075 | |
| 1076 | if json_out: |
| 1077 | print(json.dumps(_HarmonySimilarJson( |
| 1078 | **make_envelope(elapsed), |
| 1079 | pattern_id=pattern_id, |
| 1080 | total=len(proposal_jsons), |
| 1081 | proposals=proposal_jsons, |
| 1082 | ))) |
| 1083 | return |
| 1084 | |
| 1085 | if not proposal_jsons: |
| 1086 | print(f" ℹ no similar patterns found for {pattern_id}.") |
| 1087 | return |
| 1088 | |
| 1089 | print(f" Similar patterns for {pattern_id} ({len(proposal_jsons)} proposal(s)):") |
| 1090 | for p in proposal_jsons: |
| 1091 | sim_str = f"{p['similarity']:.2f}" if p["similarity"] is not None else "n/a" |
| 1092 | print( |
| 1093 | f" {short_id(p['similar_pattern_id'] or '')} " |
| 1094 | f"sim={sim_str} conf={p['confidence']:.3f} {p['strategy']}" |
| 1095 | ) |
| 1096 | |
| 1097 | # --------------------------------------------------------------------------- |
| 1098 | # escalate |
| 1099 | # --------------------------------------------------------------------------- |
| 1100 | |
| 1101 | def run_escalate(args: argparse.Namespace) -> None: |
| 1102 | """Record an escalation for a conflict pattern. |
| 1103 | |
| 1104 | Creates an EscalationRecord with status=open. Idempotent — the same |
| 1105 | (pattern_id, reason) pair always maps to the same escalation_id. |
| 1106 | |
| 1107 | JSON schema:: |
| 1108 | |
| 1109 | {"escalation_id": "<hex64>", "pattern_id": "<hex64>", |
| 1110 | "already_existed": <bool>} |
| 1111 | |
| 1112 | Exit codes: |
| 1113 | 0 — escalation recorded (or already existed) |
| 1114 | 1 — invalid pattern_id |
| 1115 | 2 — not inside a Muse repository |
| 1116 | """ |
| 1117 | elapsed = start_timer() |
| 1118 | |
| 1119 | pattern_id: str = args.pattern_id |
| 1120 | reason: str = args.reason |
| 1121 | agent_id: str | None = args.agent_id |
| 1122 | json_out: bool = args.json_out |
| 1123 | |
| 1124 | try: |
| 1125 | _validate_id(pattern_id, "pattern_id") |
| 1126 | except ValueError as exc: |
| 1127 | if json_out: |
| 1128 | print(json.dumps({"error": str(exc)})) |
| 1129 | else: |
| 1130 | print(f"❌ {exc}", file=sys.stderr) |
| 1131 | raise SystemExit(ExitCode.USER_ERROR) |
| 1132 | |
| 1133 | root = require_repo() |
| 1134 | |
| 1135 | provenance = ( |
| 1136 | AgentProvenance.agent(agent_id) if agent_id is not None |
| 1137 | else AgentProvenance.human() |
| 1138 | ) |
| 1139 | |
| 1140 | esc_id = compute_escalation_id(pattern_id, reason) |
| 1141 | rec = EscalationRecord( |
| 1142 | escalation_id=esc_id, |
| 1143 | pattern_id=pattern_id, |
| 1144 | reason=reason, |
| 1145 | escalated_at=datetime.datetime.now(datetime.timezone.utc), |
| 1146 | escalated_by=provenance, |
| 1147 | ) |
| 1148 | newly_created = record_escalation(root, rec) |
| 1149 | already_existed = not newly_created |
| 1150 | |
| 1151 | if newly_created: |
| 1152 | append_audit( |
| 1153 | root, |
| 1154 | AuditEventType.ESCALATION_RECORDED, |
| 1155 | provenance, |
| 1156 | pattern_id=pattern_id, |
| 1157 | metadata={"escalation_id": esc_id}, |
| 1158 | ) |
| 1159 | |
| 1160 | if json_out: |
| 1161 | print(json.dumps(_HarmonyEscalateJson( |
| 1162 | **make_envelope(elapsed), |
| 1163 | escalation_id=esc_id, |
| 1164 | pattern_id=pattern_id, |
| 1165 | already_existed=already_existed, |
| 1166 | ))) |
| 1167 | return |
| 1168 | |
| 1169 | if already_existed: |
| 1170 | print(f" ℹ escalation {esc_id} already open for pattern {pattern_id}") |
| 1171 | else: |
| 1172 | print(f" ⚠️ escalated pattern {pattern_id} (esc={esc_id})") |
| 1173 | |
| 1174 | # --------------------------------------------------------------------------- |
| 1175 | # escalations |
| 1176 | # --------------------------------------------------------------------------- |
| 1177 | |
| 1178 | def run_escalations(args: argparse.Namespace) -> None: |
| 1179 | """List conflict pattern escalations. |
| 1180 | |
| 1181 | The operator dashboard: lists all open (or resolved, or all) escalations. |
| 1182 | |
| 1183 | JSON schema:: |
| 1184 | |
| 1185 | {"total": <int>, "escalations": [{...}, ...]} |
| 1186 | |
| 1187 | Exit codes: |
| 1188 | 0 — list returned (may be empty) |
| 1189 | 2 — not inside a Muse repository |
| 1190 | """ |
| 1191 | elapsed = start_timer() |
| 1192 | status_filter: str | None = args.status |
| 1193 | json_out: bool = args.json_out |
| 1194 | |
| 1195 | root = require_repo() |
| 1196 | records = list_escalations(root, status=status_filter) |
| 1197 | entries = [_escalation_to_entry(r) for r in records] |
| 1198 | |
| 1199 | if json_out: |
| 1200 | print(json.dumps(_HarmonyEscalationsJson( |
| 1201 | **make_envelope(elapsed), |
| 1202 | total=len(entries), |
| 1203 | escalations=entries, |
| 1204 | ))) |
| 1205 | return |
| 1206 | |
| 1207 | if not entries: |
| 1208 | label = f" ({status_filter})" if status_filter else "" |
| 1209 | print(f"No harmony escalations{label}.") |
| 1210 | return |
| 1211 | |
| 1212 | print(f"{'escalation_id':14} {'status':8} {'pattern_id':14} reason") |
| 1213 | print("─" * 76) |
| 1214 | for e in entries: |
| 1215 | eid_short = short_id(e["escalation_id"]) |
| 1216 | pid_short = short_id(e["pattern_id"]) |
| 1217 | print( |
| 1218 | f" {eid_short} {e['status']:8} {pid_short} " |
| 1219 | f"{e['reason'][:40]}" |
| 1220 | ) |
| 1221 | |
| 1222 | # --------------------------------------------------------------------------- |
| 1223 | # resolve-escalation |
| 1224 | # --------------------------------------------------------------------------- |
| 1225 | |
| 1226 | def run_resolve_escalation(args: argparse.Namespace) -> None: |
| 1227 | """Close an escalation after a conflict is manually resolved. |
| 1228 | |
| 1229 | JSON schema:: |
| 1230 | |
| 1231 | {"escalation_id": "<hex64>", "resolved": <bool>} |
| 1232 | |
| 1233 | Exit codes: |
| 1234 | 0 — result returned (resolved may be false if escalation not found) |
| 1235 | 1 — invalid escalation_id or resolution_id |
| 1236 | 2 — not inside a Muse repository |
| 1237 | """ |
| 1238 | elapsed = start_timer() |
| 1239 | |
| 1240 | escalation_id: str = args.escalation_id |
| 1241 | resolution_id: str = args.resolution_id |
| 1242 | agent_id: str | None = args.agent_id |
| 1243 | json_out: bool = args.json_out |
| 1244 | |
| 1245 | for label, value in (("escalation_id", escalation_id), ("resolution_id", resolution_id)): |
| 1246 | try: |
| 1247 | _validate_id(value, label) |
| 1248 | except ValueError as exc: |
| 1249 | if json_out: |
| 1250 | print(json.dumps({"error": str(exc)})) |
| 1251 | else: |
| 1252 | print(f"❌ {exc}", file=sys.stderr) |
| 1253 | raise SystemExit(ExitCode.USER_ERROR) |
| 1254 | |
| 1255 | root = require_repo() |
| 1256 | |
| 1257 | provenance = ( |
| 1258 | AgentProvenance.agent(agent_id) if agent_id is not None |
| 1259 | else AgentProvenance.human() |
| 1260 | ) |
| 1261 | now = datetime.datetime.now(datetime.timezone.utc) |
| 1262 | resolved = resolve_escalation(root, escalation_id, resolution_id, provenance, now) |
| 1263 | |
| 1264 | if resolved: |
| 1265 | append_audit( |
| 1266 | root, |
| 1267 | AuditEventType.ESCALATION_RESOLVED, |
| 1268 | provenance, |
| 1269 | metadata={ |
| 1270 | "escalation_id": escalation_id, |
| 1271 | "resolution_id": resolution_id, |
| 1272 | }, |
| 1273 | ) |
| 1274 | |
| 1275 | if json_out: |
| 1276 | print(json.dumps(_HarmonyResolveEscalationJson( |
| 1277 | **make_envelope(elapsed), |
| 1278 | escalation_id=escalation_id, |
| 1279 | resolved=resolved, |
| 1280 | ))) |
| 1281 | return |
| 1282 | |
| 1283 | if resolved: |
| 1284 | print(f" ✅ resolved escalation {escalation_id}") |
| 1285 | else: |
| 1286 | print(f" ℹ escalation {escalation_id} not found.") |
File History
1 commit
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839
chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm…
Sonnet 5
patch
15 days ago