musehub_divergence.py
python
sha256:73f24973678ceefd56628ee17c6d0535d86e33533828af6c63348f50941515ad
Merge branch 'fix/smoke-test-tag-label' into dev
Human
16 days ago
| 1 | """MuseHub Divergence Engine — commit divergence between two remote branches. |
| 2 | |
| 3 | Computes per-dimension divergence scores by comparing the commit history on |
| 4 | two branches since their common ancestor (merge base), using commit message |
| 5 | keyword classification to determine which areas of the codebase each commit |
| 6 | touches. |
| 7 | |
| 8 | Dimensions analysed |
| 9 | ------------------- |
| 10 | - ``interface`` — api, route, endpoint, handler, request, response, schema, wire |
| 11 | - ``data`` — model, migration, db, database, table, query, schema, orm |
| 12 | - ``logic`` — service, worker, job, queue, task, background, algorithm, core |
| 13 | - ``tests`` — test, spec, fixture, assert, mock, coverage, pytest |
| 14 | - ``infrastructure`` — config, setting, env, deploy, docker, ci, pipeline, release |
| 15 | |
| 16 | Score formula (per dimension) |
| 17 | ------------------------------ |
| 18 | Given the sets of commit messages classified to dimension D on branch A |
| 19 | (``a_dim``) and branch B (``b_dim``) since the merge base: |
| 20 | |
| 21 | score = |symmetric_difference(a_dim, b_dim)| / |union(a_dim, b_dim)| |
| 22 | |
| 23 | Score 0.0 = both branches changed exactly the same things in this dimension. |
| 24 | Score 1.0 = no overlap — completely diverged. |
| 25 | |
| 26 | Boundary rules |
| 27 | -------------- |
| 28 | - Must NOT import StateStore, executor, MCP tools, or handlers. |
| 29 | - May import ORM models from musehub.db domain-specific modules. |
| 30 | """ |
| 31 | |
| 32 | import logging |
| 33 | import re |
| 34 | from dataclasses import dataclass |
| 35 | from enum import StrEnum |
| 36 | |
| 37 | from sqlalchemy.ext.asyncio import AsyncSession |
| 38 | from sqlalchemy.future import select |
| 39 | |
| 40 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef |
| 41 | from musehub.models.musehub import ProposalDiffDimensionScore, ProposalDiffResponse |
| 42 | from musehub.types.json_types import StrDict |
| 43 | |
| 44 | logger = logging.getLogger(__name__) |
| 45 | |
| 46 | # --------------------------------------------------------------------------- |
| 47 | # Constants |
| 48 | # --------------------------------------------------------------------------- |
| 49 | |
| 50 | ALL_DIMENSIONS: tuple[str, ...] = ( |
| 51 | "melodic", |
| 52 | "harmonic", |
| 53 | "rhythmic", |
| 54 | "structural", |
| 55 | "dynamic", |
| 56 | ) |
| 57 | |
| 58 | type _DimensionPatternMap = dict[str, tuple[str, ...]] |
| 59 | |
| 60 | #: Keyword patterns used to classify commit messages into musical dimensions. |
| 61 | _DIMENSION_PATTERNS: _DimensionPatternMap = { |
| 62 | "melodic": ("melody", "lead", "solo", "pitch", "note", "motif", "riff", "lick", "tune", "phrase", "melo"), |
| 63 | "harmonic": ("chord", "harmony", "scale", "mode", "interval", "progression", "voicing", "key"), |
| 64 | "rhythmic": ("tempo", "bpm", "drum", "groove", "beat", "rhythm", "meter", "pulse", "swing", "sync"), |
| 65 | "structural": ("bridge", "section", "verse", "chorus", "intro", "outro", "refrain", "hook", "coda", "form", "arrange", "structure"), |
| 66 | "dynamic": ("reverb", "mix", "master", "level", "dynamic", "velocity", "volume", "expression", "accent", "eq", "filter", "compress", "effect"), |
| 67 | } |
| 68 | |
| 69 | #: Compiled regex matching song-section names in commit messages. |
| 70 | _SECTION_KEYWORDS: tuple[str, ...] = ( |
| 71 | "Verse", "Chorus", "Bridge", "Intro", "Outro", |
| 72 | "Refrain", "Hook", "Coda", "Pre-chorus", "Interlude", |
| 73 | ) |
| 74 | _SECTION_RE: re.Pattern[str] = re.compile( |
| 75 | rf"\b({'|'.join(re.escape(k) for k in _SECTION_KEYWORDS)})\b", |
| 76 | re.IGNORECASE, |
| 77 | ) |
| 78 | |
| 79 | |
| 80 | def extract_affected_sections(messages: tuple[str, ...]) -> list[str]: |
| 81 | """Return the section names mentioned across *messages*, in keyword-definition order. |
| 82 | |
| 83 | Args: |
| 84 | messages: Commit message strings to scan. |
| 85 | |
| 86 | Returns: |
| 87 | Deduplicated list of section names ordered by :data:`_SECTION_KEYWORDS`. |
| 88 | """ |
| 89 | found: set[str] = set() |
| 90 | for msg in messages: |
| 91 | for m in _SECTION_RE.finditer(msg): |
| 92 | found.add(m.group(1).lower()) |
| 93 | return [kw for kw in _SECTION_KEYWORDS if kw.lower() in found] |
| 94 | |
| 95 | |
| 96 | # --------------------------------------------------------------------------- |
| 97 | # Result types |
| 98 | # --------------------------------------------------------------------------- |
| 99 | |
| 100 | |
| 101 | class MuseHubDivergenceLevel(StrEnum): |
| 102 | """Qualitative label for a per-dimension or overall divergence score. |
| 103 | |
| 104 | Thresholds mirror the CLI divergence engine for consistency. |
| 105 | |
| 106 | - ``NONE`` — score < 0.15 |
| 107 | - ``LOW`` — 0.15 ≤ score < 0.40 |
| 108 | - ``MED`` — 0.40 ≤ score < 0.70 |
| 109 | - ``HIGH`` — score ≥ 0.70 |
| 110 | """ |
| 111 | |
| 112 | NONE = "NONE" |
| 113 | LOW = "LOW" |
| 114 | MED = "MED" |
| 115 | HIGH = "HIGH" |
| 116 | |
| 117 | |
| 118 | @dataclass(frozen=True, slots=True) |
| 119 | class MuseHubDimensionDivergence: |
| 120 | """Divergence score and description for a single musical dimension. |
| 121 | |
| 122 | Attributes: |
| 123 | dimension: Dimension name (e.g. ``"melodic"``). |
| 124 | level: Qualitative divergence level label. |
| 125 | score: Normalised divergence score in [0.0, 1.0]. |
| 126 | description: Human-readable divergence summary. |
| 127 | branch_a_commits: Number of commits in this dimension on branch A. |
| 128 | branch_b_commits: Number of commits in this dimension on branch B. |
| 129 | """ |
| 130 | |
| 131 | dimension: str |
| 132 | level: MuseHubDivergenceLevel |
| 133 | score: float |
| 134 | description: str |
| 135 | branch_a_commits: int |
| 136 | branch_b_commits: int |
| 137 | |
| 138 | |
| 139 | @dataclass(frozen=True, slots=True) |
| 140 | class MuseHubDivergenceResult: |
| 141 | """Full musical divergence report between two MuseHub branches. |
| 142 | |
| 143 | Attributes: |
| 144 | repo_id: Repository ID. |
| 145 | branch_a: Name of the first branch. |
| 146 | branch_b: Name of the second branch. |
| 147 | common_ancestor: Commit ID of the merge base, or ``None`` if disjoint. |
| 148 | dimensions: Per-dimension divergence results (always 5 entries). |
| 149 | overall_score: Mean of all per-dimension scores in [0.0, 1.0]. |
| 150 | all_messages: All commit messages from both branches since the merge |
| 151 | base. Used by :func:`build_proposal_diff_response` to derive |
| 152 | ``affected_sections`` from actual commit text. |
| 153 | """ |
| 154 | |
| 155 | repo_id: str |
| 156 | branch_a: str |
| 157 | branch_b: str |
| 158 | common_ancestor: str | None |
| 159 | dimensions: tuple[MuseHubDimensionDivergence, ...] |
| 160 | overall_score: float |
| 161 | all_messages: tuple[str, ...] = () |
| 162 | |
| 163 | |
| 164 | # --------------------------------------------------------------------------- |
| 165 | # Pure helpers |
| 166 | # --------------------------------------------------------------------------- |
| 167 | |
| 168 | |
| 169 | def classify_message(message: str) -> set[str]: |
| 170 | """Return the set of musical dimensions this commit message touches. |
| 171 | |
| 172 | Matching is case-insensitive and keyword-based. A single message may map |
| 173 | to multiple dimensions (e.g. "add jazzy chord melody" → melodic + harmonic). |
| 174 | |
| 175 | Args: |
| 176 | message: Commit message text. |
| 177 | |
| 178 | Returns: |
| 179 | Set of dimension names that the message matches. Empty if unclassified. |
| 180 | """ |
| 181 | lower = message.lower() |
| 182 | return { |
| 183 | dim |
| 184 | for dim, patterns in _DIMENSION_PATTERNS.items() |
| 185 | if any(pat in lower for pat in patterns) |
| 186 | } |
| 187 | |
| 188 | |
| 189 | def score_to_level(score: float) -> MuseHubDivergenceLevel: |
| 190 | """Map a numeric divergence score to a qualitative level label. |
| 191 | |
| 192 | Args: |
| 193 | score: Normalised score in [0.0, 1.0]. |
| 194 | |
| 195 | Returns: |
| 196 | The appropriate :class:`MuseHubDivergenceLevel` enum member. |
| 197 | """ |
| 198 | if score < 0.15: |
| 199 | return MuseHubDivergenceLevel.NONE |
| 200 | if score < 0.40: |
| 201 | return MuseHubDivergenceLevel.LOW |
| 202 | if score < 0.70: |
| 203 | return MuseHubDivergenceLevel.MED |
| 204 | return MuseHubDivergenceLevel.HIGH |
| 205 | |
| 206 | |
| 207 | def compute_hub_dimension_divergence( |
| 208 | dimension: str, |
| 209 | a_commit_ids: set[str], |
| 210 | b_commit_ids: set[str], |
| 211 | a_messages: StrDict, |
| 212 | b_messages: StrDict, |
| 213 | ) -> MuseHubDimensionDivergence: |
| 214 | """Compute divergence for a single musical dimension across two commit sets. |
| 215 | |
| 216 | Score = ``|symmetric_diff| / |union|`` over commit IDs classified into |
| 217 | *dimension*: |
| 218 | |
| 219 | - 0.0 → both branches touched exactly the same commits in this dimension. |
| 220 | - 1.0 → no overlap — completely diverged. |
| 221 | |
| 222 | Args: |
| 223 | dimension: Dimension name (one of :data:`ALL_DIMENSIONS`). |
| 224 | a_commit_ids: Commit IDs for branch A since the merge base. |
| 225 | b_commit_ids: Commit IDs for branch B since the merge base. |
| 226 | a_messages: Mapping of commit_id → message for branch A. |
| 227 | b_messages: Mapping of commit_id → message for branch B. |
| 228 | |
| 229 | Returns: |
| 230 | A :class:`MuseHubDimensionDivergence` with score, level, and summary. |
| 231 | """ |
| 232 | |
| 233 | def _filter_by_dim(ids: set[str], messages: StrDict) -> set[str]: |
| 234 | return {cid for cid in ids if dimension in classify_message(messages.get(cid, ""))} |
| 235 | |
| 236 | a_dim = _filter_by_dim(a_commit_ids, a_messages) |
| 237 | b_dim = _filter_by_dim(b_commit_ids, b_messages) |
| 238 | |
| 239 | union = a_dim | b_dim |
| 240 | sym_diff = a_dim.symmetric_difference(b_dim) |
| 241 | total = len(union) |
| 242 | |
| 243 | if total == 0: |
| 244 | score = 0.0 |
| 245 | desc = f"No {dimension} changes on either branch." |
| 246 | else: |
| 247 | score = len(sym_diff) / total |
| 248 | if score < 0.15: |
| 249 | desc = f"Both branches made similar {dimension} changes." |
| 250 | elif score < 0.40: |
| 251 | desc = f"Minor {dimension} divergence — mostly aligned." |
| 252 | elif score < 0.70: |
| 253 | desc = f"Moderate {dimension} divergence — different directions." |
| 254 | else: |
| 255 | desc = f"High {dimension} divergence — branches took different creative paths." |
| 256 | |
| 257 | level = score_to_level(score) |
| 258 | return MuseHubDimensionDivergence( |
| 259 | dimension=dimension, |
| 260 | level=level, |
| 261 | score=round(score, 4), |
| 262 | description=desc, |
| 263 | branch_a_commits=len(a_dim), |
| 264 | branch_b_commits=len(b_dim), |
| 265 | ) |
| 266 | |
| 267 | |
| 268 | # --------------------------------------------------------------------------- |
| 269 | # Async DB helpers |
| 270 | # --------------------------------------------------------------------------- |
| 271 | |
| 272 | |
| 273 | async def get_branch_commits( |
| 274 | session: AsyncSession, |
| 275 | repo_id: str, |
| 276 | branch: str, |
| 277 | ) -> list[MusehubCommit]: |
| 278 | """Return all commits on *branch* for *repo_id*, newest first. |
| 279 | |
| 280 | Args: |
| 281 | session: Open async DB session. |
| 282 | repo_id: Repository identifier. |
| 283 | branch: Branch name. |
| 284 | |
| 285 | Returns: |
| 286 | List of :class:`MusehubCommit` objects ordered by timestamp descending. |
| 287 | """ |
| 288 | result = await session.execute( |
| 289 | select(MusehubCommit) |
| 290 | .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id) |
| 291 | .where( |
| 292 | MusehubCommitRef.repo_id == repo_id, |
| 293 | MusehubCommit.branch == branch, |
| 294 | ) |
| 295 | .order_by(MusehubCommit.timestamp.desc()) |
| 296 | ) |
| 297 | return list(result.scalars().all()) |
| 298 | |
| 299 | |
| 300 | def find_common_ancestor( |
| 301 | a_commits: list[MusehubCommit], |
| 302 | b_commits: list[MusehubCommit], |
| 303 | ) -> str | None: |
| 304 | """Find the most recent common ancestor commit ID between two branch histories. |
| 305 | |
| 306 | Collects the full ancestry of branch A via walk_dag, then returns the most |
| 307 | recent commit from branch B that appears in that ancestry set. |
| 308 | |
| 309 | Args: |
| 310 | a_commits: All commits on branch A (newest first). |
| 311 | b_commits: All commits on branch B (newest first). |
| 312 | |
| 313 | Returns: |
| 314 | The commit ID of the most recent common ancestor, or ``None`` if the |
| 315 | two histories are completely disjoint. |
| 316 | """ |
| 317 | a_ids = {c.commit_id for c in a_commits} |
| 318 | b_ids = {c.commit_id for c in b_commits} |
| 319 | common = a_ids & b_ids |
| 320 | if not common: |
| 321 | return None |
| 322 | all_a = {c.commit_id: c for c in a_commits} |
| 323 | all_b = {c.commit_id: c for c in b_commits} |
| 324 | all_commits = {**all_a, **all_b} |
| 325 | from muse.core.graph import walk_dag |
| 326 | a_ancestry: set[str] = set(walk_dag( |
| 327 | list(a_ids), |
| 328 | lambda cid: all_commits[cid].parent_ids if cid in all_commits else [], |
| 329 | )) |
| 330 | for commit in sorted(b_commits, key=lambda c: c.timestamp, reverse=True): |
| 331 | if commit.commit_id in a_ancestry: |
| 332 | return commit.commit_id |
| 333 | return None |
| 334 | |
| 335 | |
| 336 | def get_commits_since( |
| 337 | all_commits: list[MusehubCommit], |
| 338 | base_commit_id: str | None, |
| 339 | ) -> list[MusehubCommit]: |
| 340 | """Filter commits to only those introduced after *base_commit_id*. |
| 341 | |
| 342 | When *base_commit_id* is ``None``, all commits are returned (disjoint |
| 343 | histories — no common ancestor). |
| 344 | |
| 345 | Args: |
| 346 | all_commits: All commits for a branch (newest first). |
| 347 | base_commit_id: Common ancestor commit ID to exclude, or ``None``. |
| 348 | |
| 349 | Returns: |
| 350 | Commits introduced after the base, in newest-first order. |
| 351 | """ |
| 352 | if base_commit_id is None: |
| 353 | return all_commits |
| 354 | return [c for c in all_commits if c.commit_id != base_commit_id] |
| 355 | |
| 356 | |
| 357 | # --------------------------------------------------------------------------- |
| 358 | # Public API |
| 359 | # --------------------------------------------------------------------------- |
| 360 | |
| 361 | |
| 362 | async def compute_hub_divergence( |
| 363 | session: AsyncSession, |
| 364 | *, |
| 365 | repo_id: str, |
| 366 | branch_a: str, |
| 367 | branch_b: str, |
| 368 | ) -> MuseHubDivergenceResult: |
| 369 | """Compute musical divergence between two MuseHub branches. |
| 370 | |
| 371 | Finds the common ancestor, collects each branch's commits since that point, |
| 372 | classifies commit messages into musical dimensions, and computes a |
| 373 | per-dimension Jaccard divergence score. |
| 374 | |
| 375 | Args: |
| 376 | session: Open async DB session. |
| 377 | repo_id: Repository ID. |
| 378 | branch_a: Name of the first branch. |
| 379 | branch_b: Name of the second branch. |
| 380 | |
| 381 | Returns: |
| 382 | A :class:`MuseHubDivergenceResult` with per-dimension scores and the |
| 383 | resolved common ancestor commit ID. |
| 384 | |
| 385 | Raises: |
| 386 | ValueError: If *branch_a* or *branch_b* has no commits in *repo_id*. |
| 387 | """ |
| 388 | a_all = await get_branch_commits(session, repo_id, branch_a) |
| 389 | if not a_all: |
| 390 | raise ValueError(f"Branch '{branch_a}' has no commits in repo '{repo_id}'.") |
| 391 | b_all = await get_branch_commits(session, repo_id, branch_b) |
| 392 | if not b_all: |
| 393 | raise ValueError(f"Branch '{branch_b}' has no commits in repo '{repo_id}'.") |
| 394 | |
| 395 | common_ancestor = find_common_ancestor(a_all, b_all) |
| 396 | |
| 397 | logger.info( |
| 398 | "✅ musehub divergence: %r vs %r, base=%s", |
| 399 | branch_a, |
| 400 | branch_b, |
| 401 | common_ancestor[:8] if common_ancestor else "none", |
| 402 | ) |
| 403 | |
| 404 | a_since = get_commits_since(a_all, common_ancestor) |
| 405 | b_since = get_commits_since(b_all, common_ancestor) |
| 406 | |
| 407 | a_ids = {c.commit_id for c in a_since} |
| 408 | b_ids = {c.commit_id for c in b_since} |
| 409 | a_msgs = {c.commit_id: c.message for c in a_since} |
| 410 | b_msgs = {c.commit_id: c.message for c in b_since} |
| 411 | |
| 412 | dimensions = tuple( |
| 413 | compute_hub_dimension_divergence(dim, a_ids, b_ids, a_msgs, b_msgs) |
| 414 | for dim in ALL_DIMENSIONS |
| 415 | ) |
| 416 | |
| 417 | overall = ( |
| 418 | round(sum(d.score for d in dimensions) / len(dimensions), 4) |
| 419 | if dimensions |
| 420 | else 0.0 |
| 421 | ) |
| 422 | |
| 423 | all_messages = tuple(c.message for c in a_since) + tuple(c.message for c in b_since) |
| 424 | |
| 425 | return MuseHubDivergenceResult( |
| 426 | repo_id=repo_id, |
| 427 | branch_a=branch_a, |
| 428 | branch_b=branch_b, |
| 429 | common_ancestor=common_ancestor, |
| 430 | dimensions=dimensions, |
| 431 | overall_score=overall, |
| 432 | all_messages=all_messages, |
| 433 | ) |
| 434 | |
| 435 | |
| 436 | # --------------------------------------------------------------------------- |
| 437 | # ProposalDiffResponse builder helpers (shared by proposals and ui routes) |
| 438 | # --------------------------------------------------------------------------- |
| 439 | |
| 440 | |
| 441 | def _delta_label(score: float) -> str: |
| 442 | """Convert a divergence score to a human-readable delta badge label.""" |
| 443 | pct = round(score * 100, 1) |
| 444 | if pct == 0.0: |
| 445 | return "unchanged" |
| 446 | return f"+{pct}" |
| 447 | |
| 448 | |
| 449 | def build_proposal_diff_response( |
| 450 | proposal_id: str, |
| 451 | from_branch: str, |
| 452 | to_branch: str, |
| 453 | result: MuseHubDivergenceResult, |
| 454 | ) -> ProposalDiffResponse: |
| 455 | """Assemble a :class:`ProposalDiffResponse` from a divergence computation result.""" |
| 456 | dimensions = [ |
| 457 | ProposalDiffDimensionScore( |
| 458 | dimension=d.dimension, |
| 459 | score=d.score, |
| 460 | level=d.level.value, |
| 461 | delta_label=_delta_label(d.score), |
| 462 | description=d.description, |
| 463 | from_branch_commits=d.branch_b_commits, |
| 464 | to_branch_commits=d.branch_a_commits, |
| 465 | ) |
| 466 | for d in result.dimensions |
| 467 | ] |
| 468 | |
| 469 | return ProposalDiffResponse( |
| 470 | proposal_id=proposal_id, |
| 471 | repo_id=result.repo_id, |
| 472 | from_branch=from_branch, |
| 473 | to_branch=to_branch, |
| 474 | dimensions=dimensions, |
| 475 | overall_score=result.overall_score, |
| 476 | common_ancestor=result.common_ancestor, |
| 477 | affected_sections=extract_affected_sections(result.all_messages), |
| 478 | ) |
| 479 | |
| 480 | |
| 481 | def build_zero_diff_response( |
| 482 | proposal_id: str, |
| 483 | repo_id: str, |
| 484 | from_branch: str, |
| 485 | to_branch: str, |
| 486 | include_dimensions: bool = True, |
| 487 | ) -> ProposalDiffResponse: |
| 488 | """Return a zero-score :class:`ProposalDiffResponse` placeholder. |
| 489 | |
| 490 | Used when :func:`compute_hub_divergence` raises :exc:`ValueError` because |
| 491 | one or both branches have no commits yet, or when the repo is a code-domain |
| 492 | repo where musical dimension analysis does not apply. |
| 493 | |
| 494 | Args: |
| 495 | proposal_id: Proposal ID. |
| 496 | repo_id: Repository ID. |
| 497 | from_branch: Source branch name. |
| 498 | to_branch: Target branch name. |
| 499 | include_dimensions: When ``False`` (code-domain repos), return an empty |
| 500 | dimensions list and ``overall_score=None``. The proposal detail template |
| 501 | hides the Musical Analysis panel when dimensions is empty. |
| 502 | |
| 503 | Returns: |
| 504 | A :class:`ProposalDiffResponse` with all dimensions at 0.0 (or empty for |
| 505 | code-domain repos). |
| 506 | """ |
| 507 | if not include_dimensions: |
| 508 | return ProposalDiffResponse( |
| 509 | proposal_id=proposal_id, |
| 510 | repo_id=repo_id, |
| 511 | from_branch=from_branch, |
| 512 | to_branch=to_branch, |
| 513 | dimensions=[], |
| 514 | overall_score=None, |
| 515 | common_ancestor=None, |
| 516 | affected_sections=[], |
| 517 | ) |
| 518 | dimensions = [ |
| 519 | ProposalDiffDimensionScore( |
| 520 | dimension=dim, |
| 521 | score=0.0, |
| 522 | level="NONE", |
| 523 | delta_label="unchanged", |
| 524 | description="No commits on one or both branches yet.", |
| 525 | from_branch_commits=0, |
| 526 | to_branch_commits=0, |
| 527 | ) |
| 528 | for dim in ALL_DIMENSIONS |
| 529 | ] |
| 530 | return ProposalDiffResponse( |
| 531 | proposal_id=proposal_id, |
| 532 | repo_id=repo_id, |
| 533 | from_branch=from_branch, |
| 534 | to_branch=to_branch, |
| 535 | dimensions=dimensions, |
| 536 | overall_score=0.0, |
| 537 | common_ancestor=None, |
| 538 | affected_sections=[], |
| 539 | ) |
File History
12 commits
sha256:73f24973678ceefd56628ee17c6d0535d86e33533828af6c63348f50941515ad
Merge branch 'fix/smoke-test-tag-label' into dev
Human
16 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
16 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
18 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
31 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
35 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
36 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
37 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
37 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
37 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
37 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
49 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
51 days ago