api_surface.py
python
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
70 days ago
| 1 | """muse code api-surface — public API surface tracking. |
| 2 | |
| 3 | Shows which symbols in a snapshot are part of the public API, and how the |
| 4 | public API changed between two commits. |
| 5 | |
| 6 | A symbol is **public** when all of the following hold: |
| 7 | |
| 8 | * ``kind`` is one of: ``function``, ``async_function``, ``class``, |
| 9 | ``method``, ``async_method`` |
| 10 | * ``name`` does not start with ``_`` (Python convention for private/internal) |
| 11 | * ``kind`` is not ``import`` |
| 12 | |
| 13 | Muse answers "what changed in the public API between v1.0 and v1.1?" in O(1) |
| 14 | against committed snapshots — no checkout required, no working-tree parsing. |
| 15 | The diff output also produces a ``semver_impact`` field (``MAJOR``/``MINOR``/ |
| 16 | ``PATCH``) that feeds directly into ``muse commit``'s bump proposal. |
| 17 | |
| 18 | Usage:: |
| 19 | |
| 20 | muse code api-surface |
| 21 | muse code api-surface --commit HEAD~5 |
| 22 | muse code api-surface --diff main |
| 23 | muse code api-surface --diff main --breaking |
| 24 | muse code api-surface --language Python |
| 25 | muse code api-surface --file src/billing.py |
| 26 | muse code api-surface --count |
| 27 | muse code api-surface --json |
| 28 | |
| 29 | With ``--diff REF``, shows a three-section report:: |
| 30 | |
| 31 | Public API surface — commit a1b2c3d4 vs commit e5f6a7b8 |
| 32 | ────────────────────────────────────────────────────────────── |
| 33 | |
| 34 | Added (3): |
| 35 | + src/billing.py::compute_tax function |
| 36 | + src/auth.py::refresh_token function |
| 37 | + src/models.py::User.to_json method |
| 38 | |
| 39 | Removed (1): |
| 40 | - src/billing.py::compute_total function ⚠ BREAKING |
| 41 | |
| 42 | Changed (2): |
| 43 | ~ src/billing.py::Invoice.pay method (signature_change) ⚠ BREAKING |
| 44 | ~ src/auth.py::validate_token function (impl_only) |
| 45 | |
| 46 | semver impact: MAJOR · stability: 75% · 3 breaking change(s) |
| 47 | |
| 48 | Flags: |
| 49 | |
| 50 | ``--commit, -c REF`` |
| 51 | Show or compare from this commit (default: HEAD). |
| 52 | |
| 53 | ``--diff REF`` |
| 54 | Compare the commit from ``--commit`` against this ref. |
| 55 | |
| 56 | ``--breaking`` |
| 57 | In diff mode, show only breaking changes (removed + signature_change/ |
| 58 | signature+impl). Exits non-zero when any breaking changes exist. |
| 59 | |
| 60 | ``--language LANG`` |
| 61 | Filter to symbols in files of this language. |
| 62 | |
| 63 | ``--file PATH`` |
| 64 | Filter to symbols in this file path (substring match). |
| 65 | |
| 66 | ``--count`` |
| 67 | Print only the total symbol count (or change count in diff mode). |
| 68 | Scriptable — exits non-zero when breaking changes exist with ``--diff``. |
| 69 | |
| 70 | ``--json`` |
| 71 | Emit results as JSON with ``commit_id`` (full SHA), ``semver_impact``, |
| 72 | ``stability_pct``, and ``breaking_count`` fields. |
| 73 | """ |
| 74 | |
| 75 | import argparse |
| 76 | import json |
| 77 | import logging |
| 78 | import pathlib |
| 79 | import sys |
| 80 | from collections.abc import Callable |
| 81 | from typing import Literal, TypedDict |
| 82 | |
| 83 | from muse.core.errors import ExitCode |
| 84 | from muse.core.repo import require_repo |
| 85 | from muse.core.types import Manifest |
| 86 | from muse.core.refs import read_current_branch |
| 87 | from muse.core.commits import resolve_commit_ref |
| 88 | from muse.core.snapshots import get_commit_snapshot_manifest |
| 89 | from muse.core.symbol_cache import SymbolCache, load_symbol_cache |
| 90 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 91 | from muse.core.timing import start_timer |
| 92 | from muse.plugins.code._query import language_of, symbols_for_snapshot |
| 93 | from muse.plugins.code.ast_parser import SymbolRecord |
| 94 | from muse.core.validation import sanitize_display |
| 95 | |
| 96 | logger = logging.getLogger(__name__) |
| 97 | |
| 98 | type FlatSymbolMap = dict[str, SymbolRecord] # address → symbol |
| 99 | type ChangedSymbolMap = dict[str, tuple[SymbolRecord, SymbolRecord, str]] # address → (base, cur, summary) |
| 100 | |
| 101 | class _PublicSymbolDict(TypedDict): |
| 102 | """JSON-serialisable form of a :class:`_PublicSymbol`.""" |
| 103 | |
| 104 | address: str |
| 105 | kind: str |
| 106 | name: str |
| 107 | qualified_name: str |
| 108 | language: str |
| 109 | content_id: str |
| 110 | signature_id: str |
| 111 | body_hash: str |
| 112 | |
| 113 | class _ChangedSymbolDict(_PublicSymbolDict, total=False): |
| 114 | change: str |
| 115 | breaking: bool |
| 116 | |
| 117 | class _ListJson(EnvelopeJson): |
| 118 | """JSON envelope for list mode (no --diff).""" |
| 119 | |
| 120 | commit_id: str |
| 121 | language_filter: str | None |
| 122 | file_filter: str | None |
| 123 | total: int |
| 124 | results: list[_PublicSymbolDict] |
| 125 | |
| 126 | class _DiffJson(EnvelopeJson): |
| 127 | """JSON envelope for diff mode (--diff REF).""" |
| 128 | |
| 129 | commit_id: str |
| 130 | base_commit_id: str |
| 131 | language_filter: str | None |
| 132 | file_filter: str | None |
| 133 | semver_impact: str |
| 134 | stability_pct: int |
| 135 | breaking_count: int |
| 136 | added: list[_PublicSymbolDict] |
| 137 | removed: list[_PublicSymbolDict] |
| 138 | changed: list[_ChangedSymbolDict] |
| 139 | |
| 140 | SemverImpact = Literal["MAJOR", "MINOR", "PATCH", "NONE"] |
| 141 | |
| 142 | _PUBLIC_KINDS: frozenset[str] = frozenset({ |
| 143 | "function", "async_function", "class", "method", "async_method", |
| 144 | }) |
| 145 | |
| 146 | _BREAKING_CHANGES: frozenset[str] = frozenset({ |
| 147 | "signature_change", "signature+impl", |
| 148 | }) |
| 149 | |
| 150 | # --------------------------------------------------------------------------- |
| 151 | # Domain helpers |
| 152 | # --------------------------------------------------------------------------- |
| 153 | |
| 154 | def _is_public(name: str, kind: str) -> bool: |
| 155 | """Return True when the symbol meets the public-API criteria.""" |
| 156 | return kind in _PUBLIC_KINDS and not name.split(".")[-1].startswith("_") |
| 157 | |
| 158 | def _public_symbols( |
| 159 | root: pathlib.Path, |
| 160 | manifest: Manifest, |
| 161 | language_filter: str | None, |
| 162 | file_filter: str | None, |
| 163 | cache: SymbolCache, |
| 164 | ) -> FlatSymbolMap: |
| 165 | """Return all public symbols from *manifest* as a flat ``address → SymbolRecord`` dict. |
| 166 | |
| 167 | Shares *cache* with the caller — no extra disk I/O. |
| 168 | """ |
| 169 | result: FlatSymbolMap = {} |
| 170 | sym_map = symbols_for_snapshot( |
| 171 | root, manifest, |
| 172 | language_filter=language_filter, |
| 173 | cache=cache, |
| 174 | ) |
| 175 | for file_path, tree in sym_map.items(): |
| 176 | if file_filter and file_filter not in file_path: |
| 177 | continue |
| 178 | for address, rec in tree.items(): |
| 179 | if _is_public(rec["name"], rec["kind"]): |
| 180 | result[address] = rec |
| 181 | return result |
| 182 | |
| 183 | def _classify_change(old: SymbolRecord, new: SymbolRecord) -> str: |
| 184 | """Classify what changed between two versions of the same public symbol.""" |
| 185 | if old["content_id"] == new["content_id"]: |
| 186 | return "unchanged" |
| 187 | if old["signature_id"] != new["signature_id"]: |
| 188 | if old["body_hash"] != new["body_hash"]: |
| 189 | return "signature+impl" |
| 190 | return "signature_change" |
| 191 | return "impl_only" |
| 192 | |
| 193 | def _semver_impact( |
| 194 | added: FlatSymbolMap, |
| 195 | removed: FlatSymbolMap, |
| 196 | changed: ChangedSymbolMap, |
| 197 | ) -> SemverImpact: |
| 198 | """Infer the minimum semver bump required by the observed API changes. |
| 199 | |
| 200 | Rules (in priority order): |
| 201 | - Any removal → MAJOR |
| 202 | - Any signature_change or signature+impl → MAJOR |
| 203 | - Any addition → MINOR |
| 204 | - Any impl_only change → PATCH |
| 205 | - No changes → NONE |
| 206 | """ |
| 207 | if removed: |
| 208 | return "MAJOR" |
| 209 | for _, (_, _, cls) in changed.items(): |
| 210 | if cls in _BREAKING_CHANGES: |
| 211 | return "MAJOR" |
| 212 | if added: |
| 213 | return "MINOR" |
| 214 | if changed: |
| 215 | return "PATCH" |
| 216 | return "NONE" |
| 217 | |
| 218 | def _stability_pct( |
| 219 | base_size: int, |
| 220 | removed: FlatSymbolMap, |
| 221 | changed: ChangedSymbolMap, |
| 222 | ) -> int: |
| 223 | """Percentage of the base API surface that survived unchanged.""" |
| 224 | if base_size == 0: |
| 225 | return 100 |
| 226 | disturbed = len(removed) + len(changed) |
| 227 | return round((base_size - disturbed) / base_size * 100) |
| 228 | |
| 229 | # --------------------------------------------------------------------------- |
| 230 | # Output helper |
| 231 | # --------------------------------------------------------------------------- |
| 232 | |
| 233 | class _ApiEntry: |
| 234 | """Wraps one public symbol for display or JSON serialisation.""" |
| 235 | |
| 236 | __slots__ = ("address", "rec", "language") |
| 237 | |
| 238 | def __init__(self, address: str, rec: SymbolRecord, language: str) -> None: |
| 239 | self.address = address |
| 240 | self.rec = rec |
| 241 | self.language = language |
| 242 | |
| 243 | def to_dict(self) -> _PublicSymbolDict: |
| 244 | """Return a JSON-serialisable dict with full (untruncated) IDs.""" |
| 245 | return { |
| 246 | "address": self.address, |
| 247 | "kind": self.rec["kind"], |
| 248 | "name": self.rec["name"], |
| 249 | "qualified_name": self.rec["qualified_name"], |
| 250 | "language": self.language, |
| 251 | "content_id": self.rec["content_id"], |
| 252 | "signature_id": self.rec["signature_id"], |
| 253 | "body_hash": self.rec["body_hash"], |
| 254 | } |
| 255 | |
| 256 | # --------------------------------------------------------------------------- |
| 257 | # CLI registration |
| 258 | # --------------------------------------------------------------------------- |
| 259 | |
| 260 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 261 | """Register the api-surface subcommand.""" |
| 262 | parser = subparsers.add_parser( |
| 263 | "api-surface", |
| 264 | help="Show the public API surface and how it changed between two commits.", |
| 265 | description=__doc__, |
| 266 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 267 | ) |
| 268 | parser.add_argument( |
| 269 | "--commit", "-c", default=None, metavar="REF", dest="ref", |
| 270 | help="Show surface at this commit (default: HEAD).", |
| 271 | ) |
| 272 | parser.add_argument( |
| 273 | "--diff", default=None, metavar="REF", dest="diff_ref", |
| 274 | help="Compare HEAD (or --commit) against this ref.", |
| 275 | ) |
| 276 | parser.add_argument( |
| 277 | "--breaking", action="store_true", dest="breaking_only", |
| 278 | help="Show only breaking changes; exit non-zero when any exist.", |
| 279 | ) |
| 280 | parser.add_argument( |
| 281 | "--language", "-l", default=None, metavar="LANG", dest="language", |
| 282 | help="Filter to this language (Python, Go, Rust, …).", |
| 283 | ) |
| 284 | parser.add_argument( |
| 285 | "--file", default=None, metavar="PATH", dest="file_filter", |
| 286 | help="Filter to symbols in this file (substring match).", |
| 287 | ) |
| 288 | parser.add_argument( |
| 289 | "--count", action="store_true", dest="count_only", |
| 290 | help="Print only the total symbol count (scriptable).", |
| 291 | ) |
| 292 | parser.add_argument( |
| 293 | "--json", "-j", |
| 294 | action="store_true", dest="json_out", |
| 295 | help="Emit results as JSON (agent-friendly; -j is a shorthand alias).", |
| 296 | ) |
| 297 | parser.set_defaults(func=run) |
| 298 | |
| 299 | # --------------------------------------------------------------------------- |
| 300 | # Command entry point |
| 301 | # --------------------------------------------------------------------------- |
| 302 | |
| 303 | def run(args: argparse.Namespace) -> None: |
| 304 | """Show the public API surface and how it changed between two commits. |
| 305 | |
| 306 | Without ``--diff``, lists all public symbols at the given ref. |
| 307 | With ``--diff``, shows added, removed, and changed symbols between two refs. |
| 308 | Use ``--breaking`` (requires ``--diff``) to show only breaking changes. |
| 309 | |
| 310 | Agent quickstart |
| 311 | ---------------- |
| 312 | :: |
| 313 | |
| 314 | muse api-surface --json # surface at HEAD |
| 315 | muse api-surface --diff main --json # changes vs main |
| 316 | muse api-surface --diff main --breaking --json # breaking changes only |
| 317 | |
| 318 | JSON fields |
| 319 | ----------- |
| 320 | symbols List of public symbol records at the target ref (without --diff). |
| 321 | added Symbols added since --diff ref. |
| 322 | removed Symbols removed since --diff ref. |
| 323 | changed Symbols whose signature changed since --diff ref. |
| 324 | breaking Subset of ``changed`` that are backward-incompatible. |
| 325 | total Total count of public symbols. |
| 326 | |
| 327 | Exit codes |
| 328 | ---------- |
| 329 | 0 Success. |
| 330 | 1 ``--breaking`` without ``--diff``, or commit not found. |
| 331 | 2 Not inside a Muse repository. |
| 332 | """ |
| 333 | elapsed = start_timer() |
| 334 | ref: str | None = args.ref |
| 335 | diff_ref: str | None = args.diff_ref |
| 336 | language: str | None = args.language |
| 337 | file_filter: str | None = args.file_filter |
| 338 | breaking_only: bool = args.breaking_only |
| 339 | count_only: bool = args.count_only |
| 340 | json_out: bool = args.json_out |
| 341 | |
| 342 | if breaking_only and diff_ref is None: |
| 343 | print("❌ --breaking requires --diff REF.", file=sys.stderr) |
| 344 | raise SystemExit(ExitCode.USER_ERROR) |
| 345 | |
| 346 | root = require_repo() |
| 347 | |
| 348 | |
| 349 | branch = read_current_branch(root) |
| 350 | cache = load_symbol_cache(root) |
| 351 | |
| 352 | commit = resolve_commit_ref(root, branch, ref) |
| 353 | if commit is None: |
| 354 | print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr) |
| 355 | raise SystemExit(ExitCode.USER_ERROR) |
| 356 | |
| 357 | manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} |
| 358 | current_surface = _public_symbols(root, manifest, language, file_filter, cache) |
| 359 | |
| 360 | if diff_ref is None: |
| 361 | _run_list_mode( |
| 362 | root, commit.commit_id, current_surface, |
| 363 | language, file_filter, count_only, json_out, elapsed, |
| 364 | ) |
| 365 | cache.save() |
| 366 | return |
| 367 | |
| 368 | base_commit = resolve_commit_ref(root, branch, diff_ref) |
| 369 | if base_commit is None: |
| 370 | print(f"❌ Diff ref '{diff_ref}' not found.", file=sys.stderr) |
| 371 | raise SystemExit(ExitCode.USER_ERROR) |
| 372 | |
| 373 | base_manifest = get_commit_snapshot_manifest(root, base_commit.commit_id) or {} |
| 374 | base_surface = _public_symbols(root, base_manifest, language, file_filter, cache) |
| 375 | cache.save() |
| 376 | |
| 377 | added = {a: r for a, r in current_surface.items() if a not in base_surface} |
| 378 | removed = {a: r for a, r in base_surface.items() if a not in current_surface} |
| 379 | changed: ChangedSymbolMap = {} |
| 380 | for addr in current_surface: |
| 381 | if addr in base_surface: |
| 382 | cls = _classify_change(base_surface[addr], current_surface[addr]) |
| 383 | if cls != "unchanged": |
| 384 | changed[addr] = (base_surface[addr], current_surface[addr], cls) |
| 385 | |
| 386 | if breaking_only: |
| 387 | added = {} |
| 388 | removed_breaking = removed # all removals are breaking |
| 389 | changed = {a: v for a, v in changed.items() if v[2] in _BREAKING_CHANGES} |
| 390 | _run_diff_mode( |
| 391 | commit.commit_id, base_commit.commit_id, language, file_filter, |
| 392 | added, removed_breaking, changed, count_only, json_out, base_surface, elapsed, |
| 393 | ) |
| 394 | else: |
| 395 | _run_diff_mode( |
| 396 | commit.commit_id, base_commit.commit_id, language, file_filter, |
| 397 | added, removed, changed, count_only, json_out, base_surface, elapsed, |
| 398 | ) |
| 399 | |
| 400 | has_breaking = bool(removed) or any( |
| 401 | v[2] in _BREAKING_CHANGES for v in changed.values() |
| 402 | ) |
| 403 | if has_breaking: |
| 404 | raise SystemExit(ExitCode.USER_ERROR if breaking_only else 0) |
| 405 | |
| 406 | def _run_list_mode( |
| 407 | root: pathlib.Path, |
| 408 | commit_id: str, |
| 409 | surface: FlatSymbolMap, |
| 410 | language: str | None, |
| 411 | file_filter: str | None, |
| 412 | count_only: bool, |
| 413 | as_json: bool, |
| 414 | elapsed: Callable[[], float], |
| 415 | ) -> None: |
| 416 | """Render the simple list (no --diff) output.""" |
| 417 | entries = [ |
| 418 | _ApiEntry(addr, rec, language_of(addr.split("::")[0])) |
| 419 | for addr, rec in sorted(surface.items()) |
| 420 | ] |
| 421 | |
| 422 | if count_only and not as_json: |
| 423 | print(len(entries)) |
| 424 | return |
| 425 | |
| 426 | if as_json: |
| 427 | print(json.dumps(_ListJson( |
| 428 | **make_envelope(elapsed), |
| 429 | commit_id=commit_id, |
| 430 | language_filter=language, |
| 431 | file_filter=file_filter, |
| 432 | total=len(entries), |
| 433 | results=[e.to_dict() for e in entries], |
| 434 | ))) |
| 435 | return |
| 436 | |
| 437 | print(f"\nPublic API surface — {commit_id}") |
| 438 | if language: |
| 439 | print(f" (language: {language})") |
| 440 | if file_filter: |
| 441 | print(f" (file: {file_filter})") |
| 442 | print("─" * 62) |
| 443 | if not entries: |
| 444 | print(" (no public symbols found)") |
| 445 | return |
| 446 | max_addr = max(len(e.address) for e in entries) |
| 447 | for e in entries: |
| 448 | print(f" {sanitize_display(e.address):<{max_addr}} {e.rec['kind']}") |
| 449 | print(f"\n {len(entries)} public symbol(s)") |
| 450 | |
| 451 | def _run_diff_mode( |
| 452 | commit_id: str, |
| 453 | base_commit_id: str, |
| 454 | language: str | None, |
| 455 | file_filter: str | None, |
| 456 | added: FlatSymbolMap, |
| 457 | removed: FlatSymbolMap, |
| 458 | changed: ChangedSymbolMap, |
| 459 | count_only: bool, |
| 460 | as_json: bool, |
| 461 | base_surface: FlatSymbolMap, |
| 462 | elapsed: Callable[[], float], |
| 463 | ) -> None: |
| 464 | """Render the diff output.""" |
| 465 | impact = _semver_impact(added, removed, changed) |
| 466 | stability = _stability_pct(len(base_surface), removed, changed) |
| 467 | breaking_count = len(removed) + sum( |
| 468 | 1 for _, (_, _, cls) in changed.items() if cls in _BREAKING_CHANGES |
| 469 | ) |
| 470 | |
| 471 | total_changes = len(added) + len(removed) + len(changed) |
| 472 | |
| 473 | if count_only and not as_json: |
| 474 | print(total_changes) |
| 475 | return |
| 476 | |
| 477 | if as_json: |
| 478 | print(json.dumps(_DiffJson( |
| 479 | **make_envelope(elapsed), |
| 480 | commit_id=commit_id, |
| 481 | base_commit_id=base_commit_id, |
| 482 | language_filter=language, |
| 483 | file_filter=file_filter, |
| 484 | semver_impact=impact, |
| 485 | stability_pct=stability, |
| 486 | breaking_count=breaking_count, |
| 487 | added=[ |
| 488 | _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict() |
| 489 | for a, r in sorted(added.items()) |
| 490 | ], |
| 491 | removed=[ |
| 492 | _ApiEntry(a, r, language_of(a.split("::")[0])).to_dict() |
| 493 | for a, r in sorted(removed.items()) |
| 494 | ], |
| 495 | changed=[ |
| 496 | { |
| 497 | **_ApiEntry(a, new, language_of(a.split("::")[0])).to_dict(), |
| 498 | "change": cls, |
| 499 | "breaking": cls in _BREAKING_CHANGES, |
| 500 | } |
| 501 | for a, (_, new, cls) in sorted(changed.items()) |
| 502 | ], |
| 503 | ))) |
| 504 | return |
| 505 | |
| 506 | print(f"\nPublic API surface — {commit_id} vs {base_commit_id}") |
| 507 | if language: |
| 508 | print(f" (language: {language})") |
| 509 | if file_filter: |
| 510 | print(f" (file: {file_filter})") |
| 511 | print("─" * 62) |
| 512 | |
| 513 | all_addrs = sorted(set(list(added) + list(removed) + list(changed))) |
| 514 | max_addr = max((len(a) for a in all_addrs), default=40) |
| 515 | |
| 516 | if added: |
| 517 | print(f"\nAdded ({len(added)}):") |
| 518 | for addr, rec in sorted(added.items()): |
| 519 | print(f" + {sanitize_display(addr):<{max_addr}} {rec['kind']}") |
| 520 | |
| 521 | if removed: |
| 522 | print(f"\nRemoved ({len(removed)}):") |
| 523 | for addr, rec in sorted(removed.items()): |
| 524 | print(f" - {sanitize_display(addr):<{max_addr}} {rec['kind']} ⚠ BREAKING") |
| 525 | |
| 526 | if changed: |
| 527 | print(f"\nChanged ({len(changed)}):") |
| 528 | for addr, (_, new, cls) in sorted(changed.items()): |
| 529 | breaking_tag = " ⚠ BREAKING" if cls in _BREAKING_CHANGES else "" |
| 530 | print(f" ~ {sanitize_display(addr):<{max_addr}} {new['kind']} ({cls}){breaking_tag}") |
| 531 | |
| 532 | if not added and not removed and not changed: |
| 533 | print("\n ✅ No public API changes detected.") |
| 534 | else: |
| 535 | print(f"\n semver impact: {impact} · stability: {stability}% · {breaking_count} breaking change(s)") |
File History
3 commits
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
70 days ago
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f
chore: bump version to 0.2.0rc15 to match musehub#113 fix release
Sonnet 4.6
patch
70 days ago
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70
feat: migrate invariants cache to .muse/cache/invariants.ms…
Sonnet 4.6
patch
137 days ago