gabriel / muse public
_handlers.py python
1,286 lines 39.9 KB
Raw
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