forecast.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """``muse coord forecast`` β predict merge conflicts before they happen. |
| 2 | |
| 3 | Reads all active reservations and intents across branches, then uses the |
| 4 | reverse call graph to compute *likely* conflicts before any code is written. |
| 5 | |
| 6 | This turns merge conflict resolution from a reactive ("it broke") problem into |
| 7 | a proactive ("we predicted it") workflow β essential when many agents operate |
| 8 | on a codebase simultaneously. |
| 9 | |
| 10 | Conflict types detected |
| 11 | ----------------------- |
| 12 | ``address_overlap`` |
| 13 | Two agents have reserved the same symbol address. Direct collision. |
| 14 | Confidence: **1.00** β this will definitely conflict. |
| 15 | |
| 16 | ``blast_radius_overlap`` |
| 17 | Agent A's reserved symbol is in the call chain of Agent B's target, or |
| 18 | vice versa. A change to A's symbol will affect B's symbol. |
| 19 | Requires the code index to be built (``muse code index``). |
| 20 | Confidence: **0.75** β likely to conflict. |
| 21 | |
| 22 | ``operation_conflict`` |
| 23 | Agent A intends to delete/rename a symbol that Agent B intends to modify. |
| 24 | Classic use-after-free / use-after-rename semantic conflict. |
| 25 | Confidence: **0.90** β will almost certainly conflict. |
| 26 | |
| 27 | Incomplete forecasts |
| 28 | -------------------- |
| 29 | When the code index has not been built, or when the index cannot be read |
| 30 | (e.g. the repo has no commits yet), blast-radius analysis is skipped. The |
| 31 | output includes a ``warnings`` field (JSON) or a visible notice (text) so you |
| 32 | know the forecast is partial. **A partial forecast is still reported** β it |
| 33 | is never silently presented as complete. Agents should check |
| 34 | ``partial_forecast`` (JSON) to gate on whether the full analysis ran. |
| 35 | |
| 36 | Usage:: |
| 37 | |
| 38 | muse coord forecast |
| 39 | muse coord forecast --branch feature-x |
| 40 | muse coord forecast --run-id agent-42 |
| 41 | muse coord forecast --min-confidence 0.9 |
| 42 | muse coord forecast --format json |
| 43 | muse coord forecast --json |
| 44 | |
| 45 | JSON output schema:: |
| 46 | |
| 47 | { |
| 48 | "schema_version": str, |
| 49 | "current_branch": str, |
| 50 | "branch_filter": str | null, |
| 51 | "run_id_filter": str | null, |
| 52 | "min_confidence": float, |
| 53 | "active_reservations": int, |
| 54 | "intents_count": int, |
| 55 | "call_graph_available": bool, |
| 56 | "partial_forecast": bool, |
| 57 | "warnings": [str, ...], |
| 58 | "conflicts": [ |
| 59 | { |
| 60 | "conflict_type": "address_overlap" | "blast_radius_overlap" | "operation_conflict", |
| 61 | "addresses": [str, ...], |
| 62 | "agents": [str, ...], |
| 63 | "confidence": float, |
| 64 | "description": str |
| 65 | }, |
| 66 | ... |
| 67 | ], |
| 68 | "high_risk": int, |
| 69 | "medium_risk": int, |
| 70 | "low_risk": int, |
| 71 | "elapsed_seconds": float |
| 72 | } |
| 73 | |
| 74 | Text output:: |
| 75 | |
| 76 | Conflict forecast β 3 active reservation(s), 1 intent(s) |
| 77 | ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 78 | |
| 79 | β οΈ blast_radius_overlap (confidence 0.75) |
| 80 | src/billing.py::compute_total |
| 81 | agent-41 (branch: main) β agent-42 (branch: feature/billing) |
| 82 | β compute_total is in the transitive call chain of process_payment |
| 83 | |
| 84 | π΄ operation_conflict (confidence 0.90) |
| 85 | ... |
| 86 | |
| 87 | 1 high-risk, 1 medium-risk, 0 low-risk conflict(s) predicted |
| 88 | |
| 89 | Flags:: |
| 90 | |
| 91 | --branch BRANCH Restrict to reservations/intents on this branch. |
| 92 | --run-id ID Show only conflicts involving a specific agent. |
| 93 | Maximum 256 characters. |
| 94 | --min-confidence FLOAT Hide conflicts below this confidence threshold |
| 95 | (default: 0.0 β show all). Range: [0.0, 1.0]. |
| 96 | --format / -f Output format: text (default) or json. |
| 97 | --json Shorthand for --format json. |
| 98 | |
| 99 | Exit codes:: |
| 100 | |
| 101 | 0 β success (zero conflicts is still success) |
| 102 | 1 β bad arguments (--min-confidence out of range, --run-id too long) |
| 103 | 2 β unexpected error during analysis |
| 104 | """ |
| 105 | |
| 106 | from __future__ import annotations |
| 107 | |
| 108 | import argparse |
| 109 | import json |
| 110 | import logging |
| 111 | import sys |
| 112 | import time |
| 113 | |
| 114 | from muse._version import __version__ |
| 115 | from muse.core.coordination import active_reservations, load_all_intents |
| 116 | from muse.core.errors import ExitCode |
| 117 | from muse.core.repo import read_repo_id, require_repo |
| 118 | from muse.core.store import ( |
| 119 | get_commit_snapshot_manifest, |
| 120 | read_current_branch, |
| 121 | resolve_commit_ref, |
| 122 | ) |
| 123 | from muse.core.validation import sanitize_display |
| 124 | from muse.plugins.code._callgraph import build_reverse_graph, transitive_callers |
| 125 | |
| 126 | type _ConflictDict = dict[str, str | float | list[str]] |
| 127 | type _AgentListMap = dict[str, list[str]] |
| 128 | |
| 129 | logger = logging.getLogger(__name__) |
| 130 | |
| 131 | # ββ Input constraints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 132 | |
| 133 | #: Maximum byte-length of the ``--run-id`` filter value. Matches the cap |
| 134 | #: applied to run-id in all other coordination commands. |
| 135 | _MAX_RUN_ID_LEN: int = 256 |
| 136 | |
| 137 | |
| 138 | # ββ Data model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 139 | |
| 140 | |
| 141 | class _ConflictPrediction: |
| 142 | """A single predicted merge conflict between two or more agents. |
| 143 | |
| 144 | Attributes |
| 145 | ---------- |
| 146 | conflict_type: |
| 147 | One of ``"address_overlap"``, ``"blast_radius_overlap"``, or |
| 148 | ``"operation_conflict"``. |
| 149 | addresses: |
| 150 | The symbol address(es) at the centre of the conflict. |
| 151 | agents: |
| 152 | List of ``"run_id@branch"`` strings identifying the conflicting agents. |
| 153 | confidence: |
| 154 | Float in ``[0, 1]``. 1.0 = certain conflict; < 0.5 = speculative. |
| 155 | description: |
| 156 | Human-readable explanation of why this is a conflict. |
| 157 | """ |
| 158 | |
| 159 | def __init__( |
| 160 | self, |
| 161 | conflict_type: str, |
| 162 | addresses: list[str], |
| 163 | agents: list[str], |
| 164 | confidence: float, |
| 165 | description: str, |
| 166 | ) -> None: |
| 167 | self.conflict_type = conflict_type |
| 168 | self.addresses = addresses |
| 169 | self.agents = agents |
| 170 | self.confidence = confidence |
| 171 | self.description = description |
| 172 | |
| 173 | def to_dict(self) -> _ConflictDict: |
| 174 | """Serialise to a JSON-safe dict. |
| 175 | |
| 176 | Returns |
| 177 | ------- |
| 178 | dict |
| 179 | Keys: ``conflict_type``, ``addresses``, ``agents``, |
| 180 | ``confidence`` (rounded to 3 d.p.), ``description``. |
| 181 | """ |
| 182 | return { |
| 183 | "conflict_type": self.conflict_type, |
| 184 | "addresses": self.addresses, |
| 185 | "agents": self.agents, |
| 186 | "confidence": round(self.confidence, 3), |
| 187 | "description": self.description, |
| 188 | } |
| 189 | |
| 190 | |
| 191 | # ββ CLI registration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 192 | |
| 193 | |
| 194 | def register( |
| 195 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 196 | ) -> None: |
| 197 | """Register the ``forecast`` subcommand on *subparsers* (under ``muse coord``). |
| 198 | |
| 199 | Wires all flags with their defaults, choices, and help text so that |
| 200 | ``--help`` output is accurate. Sets ``func`` to :func:`run`. |
| 201 | """ |
| 202 | parser = subparsers.add_parser( |
| 203 | "forecast", |
| 204 | help="Predict merge conflicts from active reservations and intents.", |
| 205 | description=__doc__, |
| 206 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 207 | ) |
| 208 | parser.add_argument( |
| 209 | "--branch", "-b", |
| 210 | dest="branch_filter", |
| 211 | default=None, |
| 212 | metavar="BRANCH", |
| 213 | help="Restrict to reservations/intents on this branch.", |
| 214 | ) |
| 215 | parser.add_argument( |
| 216 | "--run-id", |
| 217 | dest="run_id_filter", |
| 218 | default=None, |
| 219 | metavar="ID", |
| 220 | help=( |
| 221 | "Show only conflicts involving this agent run-id. " |
| 222 | f"Maximum {_MAX_RUN_ID_LEN} characters." |
| 223 | ), |
| 224 | ) |
| 225 | parser.add_argument( |
| 226 | "--min-confidence", |
| 227 | dest="min_confidence", |
| 228 | type=float, |
| 229 | default=0.0, |
| 230 | metavar="FLOAT", |
| 231 | help=( |
| 232 | "Hide conflicts below this confidence threshold " |
| 233 | "(default: 0.0 β show all). Range: [0.0, 1.0]." |
| 234 | ), |
| 235 | ) |
| 236 | parser.add_argument( |
| 237 | "--format", "-f", |
| 238 | default="text", |
| 239 | dest="fmt", |
| 240 | choices=("text", "json"), |
| 241 | help="Output format: text (default) or json.", |
| 242 | ) |
| 243 | parser.add_argument( |
| 244 | "--json", |
| 245 | action="store_const", |
| 246 | const="json", |
| 247 | dest="fmt", |
| 248 | help="Shorthand for --format json.", |
| 249 | ) |
| 250 | parser.set_defaults(func=run) |
| 251 | |
| 252 | |
| 253 | # ββ Command implementation ββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 254 | |
| 255 | |
| 256 | def run(args: argparse.Namespace) -> None: |
| 257 | """Predict merge conflicts from active reservations and intents. |
| 258 | |
| 259 | Execution order |
| 260 | --------------- |
| 261 | 1. **Validate inputs** β ``--run-id`` length and ``--min-confidence`` |
| 262 | range. Fires before any file I/O; any failure exits |
| 263 | :attr:`~muse.core.errors.ExitCode.USER_ERROR` (1). |
| 264 | 2. **Load state** β calls :func:`~muse.core.coordination.active_reservations` |
| 265 | and :func:`~muse.core.coordination.load_all_intents`, then applies |
| 266 | ``--branch`` and ``--run-id`` filters in memory. |
| 267 | 3. **Run three analysis passes** (see below). |
| 268 | 4. **Apply ``--min-confidence`` filter** to the conflict list. |
| 269 | 5. **Emit output** β compact JSON to *stdout* when ``--format json``, |
| 270 | or human-readable text otherwise. |
| 271 | |
| 272 | Analysis passes |
| 273 | --------------- |
| 274 | **Pass 1 β Direct address overlap** (always runs) |
| 275 | Scans all active reservations and finds symbol addresses that more |
| 276 | than one agent has reserved simultaneously. Confidence 1.0. |
| 277 | |
| 278 | **Pass 2 β Blast-radius overlap** (runs when code index is available) |
| 279 | Builds the reverse call graph for the current commit and checks |
| 280 | whether any two reserved addresses lie in each other's transitive |
| 281 | call chains. Confidence 0.75. |
| 282 | |
| 283 | When the code index is unavailable (no commits yet, or ``muse code |
| 284 | index`` has not been run), this pass is skipped. The reason is |
| 285 | recorded in ``warnings`` (JSON) or printed as a notice (text). |
| 286 | ``partial_forecast`` is ``true`` whenever any pass was skipped. |
| 287 | The forecast is **never silently presented as complete** β agents |
| 288 | should check ``partial_forecast`` before treating an empty |
| 289 | ``conflicts`` list as authoritative. |
| 290 | |
| 291 | **Pass 3 β Operation conflicts** (always runs) |
| 292 | Scans intents and finds addresses where one agent intends to |
| 293 | ``delete`` and another intends to ``modify``, ``rename``, or |
| 294 | ``extract``. Confidence 0.9. |
| 295 | |
| 296 | Filtering |
| 297 | --------- |
| 298 | * ``--branch`` filters reservations and intents to a single branch before |
| 299 | any analysis pass runs, reducing the O(nΒ²) cost of Pass 2. |
| 300 | * ``--run-id`` keeps only conflicts where the named agent appears in the |
| 301 | ``agents`` list. Applied after all passes so the agent sees its full |
| 302 | blast radius. |
| 303 | * ``--min-confidence`` hides low-signal conflicts. Applied last so |
| 304 | ``high_risk`` / ``medium_risk`` / ``low_risk`` counts reflect the |
| 305 | *filtered* list. |
| 306 | |
| 307 | Error handling |
| 308 | -------------- |
| 309 | * ``(OSError, KeyError, ValueError, AttributeError)`` from the call |
| 310 | graph build are treated as "index not available" β a warning is |
| 311 | emitted and the blast-radius pass is skipped. |
| 312 | * Any other exception propagates so that the caller sees a real error |
| 313 | rather than a false-clean forecast. |
| 314 | |
| 315 | Security |
| 316 | -------- |
| 317 | All agent-supplied strings (``run_id``, ``branch``, ``addresses``) are |
| 318 | passed through :func:`~muse.core.validation.sanitize_display` before |
| 319 | printing to strip ANSI escape sequences and control characters. |
| 320 | ``--run-id`` is capped at :data:`_MAX_RUN_ID_LEN` to bound memory use. |
| 321 | No user-supplied string is ever used to construct a file path. |
| 322 | |
| 323 | Performance |
| 324 | ----------- |
| 325 | Pass 1 and Pass 3 are O(n Γ m) where n = reservations and m = intents. |
| 326 | Pass 2 is O(nΒ² Γ D) where D is the BFS depth limit (default 5) β on |
| 327 | large swarms with many reservations, use ``--branch`` to reduce n. |
| 328 | |
| 329 | Args: |
| 330 | args: Parsed ``argparse.Namespace`` with attributes ``branch_filter``, |
| 331 | ``run_id_filter``, ``min_confidence``, and ``fmt``. |
| 332 | |
| 333 | Exit codes: |
| 334 | 0 β success (zero conflicts is still success). |
| 335 | 1 β bad arguments. |
| 336 | """ |
| 337 | t0 = time.monotonic() |
| 338 | |
| 339 | branch_filter: str | None = args.branch_filter |
| 340 | run_id_filter: str | None = args.run_id_filter |
| 341 | min_confidence: float = args.min_confidence |
| 342 | fmt: str = args.fmt |
| 343 | as_json = fmt == "json" |
| 344 | |
| 345 | # ββ Input validation (before any file I/O) ββββββββββββββββββββββββββββββββ |
| 346 | |
| 347 | if run_id_filter is not None and len(run_id_filter) > _MAX_RUN_ID_LEN: |
| 348 | msg = f"--run-id is too long ({len(run_id_filter)} chars; max {_MAX_RUN_ID_LEN})" |
| 349 | if as_json: |
| 350 | print(json.dumps({"error": msg, "status": "bad_args"})) |
| 351 | else: |
| 352 | print(f"β {msg}", file=sys.stderr) |
| 353 | raise SystemExit(ExitCode.USER_ERROR) |
| 354 | |
| 355 | if not (0.0 <= min_confidence <= 1.0): |
| 356 | msg = f"--min-confidence must be in [0.0, 1.0], got {min_confidence}" |
| 357 | if as_json: |
| 358 | print(json.dumps({"error": msg, "status": "bad_args"})) |
| 359 | else: |
| 360 | print(f"β {msg}", file=sys.stderr) |
| 361 | raise SystemExit(ExitCode.USER_ERROR) |
| 362 | |
| 363 | root = require_repo() |
| 364 | branch = read_current_branch(root) |
| 365 | |
| 366 | reservations = active_reservations(root) |
| 367 | intents = load_all_intents(root) |
| 368 | |
| 369 | if branch_filter: |
| 370 | reservations = [r for r in reservations if r.branch == branch_filter] |
| 371 | intents = [i for i in intents if i.branch == branch_filter] |
| 372 | |
| 373 | conflicts: list[_ConflictPrediction] = [] |
| 374 | warnings: list[str] = [] |
| 375 | call_graph_available = False |
| 376 | |
| 377 | # ββ Pass 1: Direct address overlap ββββββββββββββββββββββββββββββββββββββββ |
| 378 | # Build: address β list of "run_id@branch" labels. |
| 379 | addr_agents: _AgentListMap = {} |
| 380 | for res in reservations: |
| 381 | for addr in res.addresses: |
| 382 | addr_agents.setdefault(addr, []).append(f"{res.run_id}@{res.branch}") |
| 383 | |
| 384 | for addr, agents in sorted(addr_agents.items()): |
| 385 | unique_agents = list(dict.fromkeys(agents)) |
| 386 | if len(unique_agents) > 1: |
| 387 | conflicts.append(_ConflictPrediction( |
| 388 | conflict_type="address_overlap", |
| 389 | addresses=[addr], |
| 390 | agents=unique_agents, |
| 391 | confidence=1.0, |
| 392 | description=( |
| 393 | f"{addr} reserved by {len(unique_agents)} agents simultaneously" |
| 394 | ), |
| 395 | )) |
| 396 | |
| 397 | # ββ Pass 2: Blast-radius overlap ββββββββββββββββββββββββββββββββββββββββββ |
| 398 | # Use the reverse call graph to detect indirect dependencies between |
| 399 | # reserved addresses. Skip silently (but warn) when the index is |
| 400 | # unavailable β do NOT catch all exceptions, as unexpected errors |
| 401 | # indicate real problems that agents must see. |
| 402 | repo_id = read_repo_id(root) |
| 403 | commit = resolve_commit_ref(root, repo_id, branch, None) |
| 404 | if commit is not None: |
| 405 | manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} |
| 406 | try: |
| 407 | reverse = build_reverse_graph(root, manifest) |
| 408 | call_graph_available = True |
| 409 | all_addresses = list(addr_agents.keys()) |
| 410 | for i, addr_a in enumerate(all_addresses): |
| 411 | callers_a = transitive_callers(addr_a, reverse, max_depth=5) |
| 412 | callers_set: set[str] = {c for lvl in callers_a.values() for c in lvl} |
| 413 | for addr_b in all_addresses[i + 1:]: |
| 414 | if addr_b in callers_set: |
| 415 | agents_a = addr_agents.get(addr_a, []) |
| 416 | agents_b = addr_agents.get(addr_b, []) |
| 417 | if set(agents_a) != set(agents_b): |
| 418 | conflicts.append(_ConflictPrediction( |
| 419 | conflict_type="blast_radius_overlap", |
| 420 | addresses=[addr_a, addr_b], |
| 421 | agents=list(set(agents_a) | set(agents_b)), |
| 422 | confidence=0.75, |
| 423 | description=( |
| 424 | f"{addr_b} is in the transitive call chain of {addr_a}" |
| 425 | ), |
| 426 | )) |
| 427 | except (OSError, KeyError, ValueError, AttributeError) as exc: |
| 428 | # Expected: index not built, object not found, or malformed data. |
| 429 | # Record as a warning β the caller must know blast-radius analysis |
| 430 | # was skipped rather than receiving a false-clean forecast. |
| 431 | warn = f"blast_radius_overlap skipped β call graph unavailable: {exc}" |
| 432 | warnings.append(warn) |
| 433 | logger.debug("Call graph unavailable for forecast: %s", exc) |
| 434 | else: |
| 435 | warnings.append( |
| 436 | "blast_radius_overlap skipped β no commits on this branch yet" |
| 437 | ) |
| 438 | |
| 439 | # ββ Pass 3: Operation conflicts βββββββββββββββββββββββββββββββββββββββββββ |
| 440 | # Detect intents where one agent intends delete and another modify/rename. |
| 441 | intent_ops: _AgentListMap = {} |
| 442 | intent_agents: _AgentListMap = {} |
| 443 | for it in intents: |
| 444 | for addr in it.addresses: |
| 445 | intent_ops.setdefault(addr, []).append(it.operation) |
| 446 | intent_agents.setdefault(addr, []).append(f"{it.run_id}@{it.branch}") |
| 447 | |
| 448 | for addr, ops in sorted(intent_ops.items()): |
| 449 | if len(set(ops)) <= 1 and len(set(intent_agents.get(addr, []))) <= 1: |
| 450 | continue # Same op by same agent β not a conflict. |
| 451 | has_delete = "delete" in ops |
| 452 | has_modify = any(op in ("modify", "rename", "extract") for op in ops) |
| 453 | if has_delete and has_modify: |
| 454 | agents = list(dict.fromkeys(intent_agents.get(addr, []))) |
| 455 | conflicts.append(_ConflictPrediction( |
| 456 | conflict_type="operation_conflict", |
| 457 | addresses=[addr], |
| 458 | agents=agents, |
| 459 | confidence=0.9, |
| 460 | description=f"delete vs modify conflict on {addr}", |
| 461 | )) |
| 462 | |
| 463 | # ββ Post-pass filters βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 464 | |
| 465 | # --run-id: keep only conflicts that mention this agent. |
| 466 | if run_id_filter is not None: |
| 467 | tag = run_id_filter # agents are stored as "run_id@branch" |
| 468 | conflicts = [ |
| 469 | c for c in conflicts |
| 470 | if any(a.split("@")[0] == tag for a in c.agents) |
| 471 | ] |
| 472 | |
| 473 | # --min-confidence: suppress low-signal predictions. |
| 474 | if min_confidence > 0.0: |
| 475 | conflicts = [c for c in conflicts if c.confidence >= min_confidence] |
| 476 | |
| 477 | partial_forecast = bool(warnings) |
| 478 | elapsed = round(time.monotonic() - t0, 4) |
| 479 | |
| 480 | # ββ JSON output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 481 | if as_json: |
| 482 | print(json.dumps({ |
| 483 | "schema_version": __version__, |
| 484 | "current_branch": branch, |
| 485 | "branch_filter": branch_filter, |
| 486 | "run_id_filter": run_id_filter, |
| 487 | "min_confidence": min_confidence, |
| 488 | "active_reservations": len(reservations), |
| 489 | "intents_count": len(intents), |
| 490 | "call_graph_available": call_graph_available, |
| 491 | "partial_forecast": partial_forecast, |
| 492 | "warnings": warnings, |
| 493 | "conflicts": [c.to_dict() for c in conflicts], |
| 494 | "high_risk": sum(1 for c in conflicts if c.confidence >= 0.9), |
| 495 | "medium_risk": sum(1 for c in conflicts if 0.5 <= c.confidence < 0.9), |
| 496 | "low_risk": sum(1 for c in conflicts if c.confidence < 0.5), |
| 497 | "elapsed_seconds": elapsed, |
| 498 | })) |
| 499 | return |
| 500 | |
| 501 | # ββ Text output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 502 | print( |
| 503 | f"\nConflict forecast β " |
| 504 | f"{len(reservations)} active reservation(s), {len(intents)} intent(s)" |
| 505 | ) |
| 506 | print("β" * 62) |
| 507 | |
| 508 | if warnings: |
| 509 | for w in warnings: |
| 510 | print(f"\n β Note: {w}") |
| 511 | |
| 512 | if not conflicts: |
| 513 | print("\n β No conflicts predicted.") |
| 514 | if not reservations: |
| 515 | print(" (no active reservations β run 'muse coord reserve' first)") |
| 516 | else: |
| 517 | for c in conflicts: |
| 518 | icon = "π΄" if c.confidence >= 0.9 else "β οΈ " |
| 519 | print(f"\n{icon} {c.conflict_type} (confidence {c.confidence:.2f})") |
| 520 | for addr in c.addresses: |
| 521 | print(f" {sanitize_display(addr)}") |
| 522 | for agent in c.agents: |
| 523 | print(f" agent: {sanitize_display(agent)}") |
| 524 | print(f" β {sanitize_display(c.description)}") |
| 525 | |
| 526 | high = sum(1 for c in conflicts if c.confidence >= 0.9) |
| 527 | med = sum(1 for c in conflicts if 0.5 <= c.confidence < 0.9) |
| 528 | print(f"\n {high} high-risk, {med} medium-risk conflict(s) predicted") |
| 529 | print(" Run 'muse coord plan-merge' for a detailed merge strategy.") |
| 530 | |
| 531 | print(f"\n ({elapsed:.3f}s)") |