commit.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """``muse commit`` β record the current workspace state as a new version. |
| 2 | |
| 3 | Algorithm |
| 4 | --------- |
| 5 | 1. Resolve repo root (walk up for ``.muse/``). |
| 6 | 2. Read ``repo_id`` from ``.muse/repo.json`` and the current branch from |
| 7 | ``.muse/HEAD``. |
| 8 | 3. Invoke ``plugin.snapshot(root)`` to collect the workspace manifest |
| 9 | (domain-specific; the code plugin walks tracked source files). |
| 10 | 4. If the computed ``snapshot_id`` matches HEAD β "nothing to commit". |
| 11 | 5. Compute a deterministic ``commit_id`` = SHA-256 of (parents | snapshot | |
| 12 | message | timestamp). |
| 13 | 6. Write content-addressed blob objects to ``.muse/objects/``. |
| 14 | 7. Write snapshot JSON to ``.muse/snapshots/<snapshot_id>.json``. |
| 15 | 8. Write commit JSON to ``.muse/commits/<commit_id>.json``. |
| 16 | 9. Advance ``.muse/refs/heads/<branch>`` to the new ``commit_id``. |
| 17 | |
| 18 | ``--dry-run`` |
| 19 | Perform steps 1β5 (compute snapshot and commit_id) without writing |
| 20 | anything. Exits 0 when changes are present, 1 when the tree is clean. |
| 21 | Combine with ``--json`` for structured preflight output in agent pipelines. |
| 22 | |
| 23 | ``--meta`` |
| 24 | Attach an arbitrary JSON object to the commit as structured metadata. |
| 25 | Accepts a JSON-encoded dict string. The payload is validated (must be a |
| 26 | dict, no sensitive key patterns, β€ 64 KiB canonical, no NaN/Infinity) |
| 27 | and stored verbatim under the ``"meta"`` key of the commit's metadata |
| 28 | dict. This is a *permanent* schema decision: once written to the commit |
| 29 | graph the shape is read by all downstream tooling that walks |
| 30 | ``commit.metadata["meta"]``. |
| 31 | |
| 32 | Reserved top-level keys: ``meta`` and ``event_type`` inside the ``--meta`` |
| 33 | payload conflict with the CLI-controlled metadata slots of the same name |
| 34 | and would create two sources of truth for the same logical field in the |
| 35 | permanent commit graph. Including either as a top-level key in ``--meta`` |
| 36 | is a hard error β use the dedicated ``--event-type`` flag instead, and |
| 37 | do not nest ``"meta"`` at the top level of your payload. |
| 38 | |
| 39 | ``--event-type`` |
| 40 | Tag the commit with a knowtation memory-event kind string. Validated |
| 41 | against the canonical 15-kind taxonomy defined in |
| 42 | ``muse/plugins/knowtation/events.py`` (falls back to a built-in frozenset |
| 43 | when that module is not yet installed, e.g. during bootstrapping). |
| 44 | Stored as ``commit.metadata["event_type"]``. |
| 45 | |
| 46 | Exit codes:: |
| 47 | |
| 48 | 0 β commit created, OR nothing to commit (clean tree) |
| 49 | 1 β validation error (no message, unresolved conflicts, clean tree with --dry-run) |
| 50 | 3 β I/O error |
| 51 | """ |
| 52 | |
| 53 | from __future__ import annotations |
| 54 | |
| 55 | import argparse |
| 56 | import datetime |
| 57 | import json |
| 58 | import logging |
| 59 | import os |
| 60 | import pathlib |
| 61 | import re |
| 62 | import sys |
| 63 | from typing import Any |
| 64 | |
| 65 | from muse.core.attestation import ( |
| 66 | AttestationError, |
| 67 | AttestationRequiredError, |
| 68 | get_attestation_provider, |
| 69 | ) |
| 70 | from muse.core.errors import ExitCode |
| 71 | from muse.core.merge_engine import clear_merge_state, read_merge_state |
| 72 | from muse.core.object_store import write_object_from_path |
| 73 | from muse.core.provenance import ( |
| 74 | make_agent_identity, |
| 75 | provenance_payload, |
| 76 | sign_commit_record, |
| 77 | ) |
| 78 | from muse.core.reflog import append_reflog |
| 79 | from muse.core.repo import read_repo_id, require_repo |
| 80 | from muse.core.rerere import record_resolutions as rerere_record_resolutions |
| 81 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id |
| 82 | from muse.core.store import ( |
| 83 | Metadata, |
| 84 | Manifest, |
| 85 | CommitRecord, |
| 86 | SnapshotRecord, |
| 87 | get_head_commit_id, |
| 88 | get_head_snapshot_id, |
| 89 | read_commit, |
| 90 | read_current_branch, |
| 91 | read_snapshot, |
| 92 | write_branch_ref, |
| 93 | write_commit, |
| 94 | write_snapshot, |
| 95 | ) |
| 96 | from muse.core.validation import sanitize_display, sanitize_provenance, validate_branch_name |
| 97 | from muse.core.semver_classifier import classify_delta |
| 98 | from muse.domain import SemVerBump, SnapshotManifest, StagePlugin, StructuredDelta |
| 99 | from muse.plugins.registry import read_domain, resolve_plugin |
| 100 | |
| 101 | logger = logging.getLogger(__name__) |
| 102 | |
| 103 | # Maximum length for author and agent-provenance fields. |
| 104 | # Prevents DoS via arbitrarily long values and keeps commit records bounded. |
| 105 | _MAX_FIELD_LEN = 256 |
| 106 | |
| 107 | # ββ --meta payload validation constants ββββββββββββββββββββββββββββββββββββββ |
| 108 | |
| 109 | #: Maximum size in bytes of the serialised ``--meta`` JSON payload. 64 KiB is |
| 110 | #: generous for commit annotations while bounding memory and storage growth. |
| 111 | _MAX_META_BYTES: int = 65_536 |
| 112 | |
| 113 | #: Pre-parse hard cap on the *raw* ``--meta`` argument string (in characters, |
| 114 | #: which is a lower bound on UTF-8 byte length). Set to twice the canonical |
| 115 | #: byte cap so that pretty-printed (whitespace-padded) JSON whose canonical |
| 116 | #: form fits β€ 64 KiB is still accepted, while bounding the worst-case |
| 117 | #: ``json.loads`` and ``_has_sensitive_keys`` cost on adversarial input. This |
| 118 | #: defends future ``--meta @file`` or stdin paths even though the current CLI |
| 119 | #: route is already bounded by the OS argv limit (~256 KiB on Darwin/Linux). |
| 120 | _MAX_META_RAW_BYTES: int = _MAX_META_BYTES * 2 |
| 121 | |
| 122 | #: Top-level keys in the ``--meta`` payload that conflict with CLI-controlled |
| 123 | #: metadata slots of the same name. Using either as a top-level key in |
| 124 | #: ``--meta`` would create two sources of truth for the same logical field in |
| 125 | #: the permanent commit graph (e.g. ``metadata["event_type"]`` *and* |
| 126 | #: ``json.loads(metadata["meta"])["event_type"]``), so it is rejected as a |
| 127 | #: hard error rather than silently shadowed with a warning. |
| 128 | _RESERVED_META_KEYS: frozenset[str] = frozenset({"event_type", "meta"}) |
| 129 | |
| 130 | #: Compiled regex matching sensitive-data key patterns. Mirrors the |
| 131 | #: ``SENSITIVE_VALUE`` pattern in ``knowtation/lib/memory-event.mjs`` so both |
| 132 | #: sides of the Muse/Knowtation boundary reject the same class of key names. |
| 133 | _SENSITIVE_KEY_RE: re.Pattern[str] = re.compile( |
| 134 | r"(api[_-]?key|secret|password|token|credential|authorization|bearer|private[_-]?key)", |
| 135 | re.IGNORECASE, |
| 136 | ) |
| 137 | |
| 138 | # ββ --event-type validation βββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 139 | |
| 140 | #: The 15 canonical memory-event kinds mirrored from |
| 141 | #: ``knowtation/lib/memory-event.mjs``. Used as a fallback when |
| 142 | #: ``muse.plugins.knowtation.events`` is not yet installed. |
| 143 | _FALLBACK_EVENT_KINDS: frozenset[str] = frozenset({ |
| 144 | "search", "export", "write", "import", "index", "propose", |
| 145 | "agent_interaction", "capture", "error", "session_summary", |
| 146 | "user", "consolidation", "consolidation_pass", "maintenance", "insight", |
| 147 | }) |
| 148 | |
| 149 | # Try to import the canonical validator from the events module (Phase 4.1). |
| 150 | # Fall back to the inline frozenset during bootstrapping / before that module |
| 151 | # is committed, so that Phase 4.2 tests pass independently of Phase 4.1. |
| 152 | try: |
| 153 | from muse.plugins.knowtation.events import is_valid_event_kind as _is_valid_event_kind # type: ignore[import-not-found] |
| 154 | except ImportError: # pragma: no cover β removed once Phase 4.1 is committed |
| 155 | def _is_valid_event_kind(kind: str) -> bool: # type: ignore[misc] |
| 156 | """Return True iff *kind* is a valid memory-event kind (fallback path).""" |
| 157 | return kind in _FALLBACK_EVENT_KINDS |
| 158 | |
| 159 | |
| 160 | def _has_sensitive_keys(obj: Any, depth: int = 0) -> bool: |
| 161 | """Return True if *obj* contains any key matching :data:`_SENSITIVE_KEY_RE`. |
| 162 | |
| 163 | Mirrors the ``hasSensitiveKeys`` function in ``knowtation/lib/memory-event.mjs``: |
| 164 | recursive scan up to depth 8; beyond that depth the scan stops and returns |
| 165 | False (consistent with the JS implementation). |
| 166 | |
| 167 | Args: |
| 168 | obj: Any Python value (dict, list, scalar, β¦). |
| 169 | depth: Current recursion depth (callers pass 0). |
| 170 | |
| 171 | Returns: |
| 172 | ``True`` if a sensitive key pattern is found at or below *depth* β€ 8; |
| 173 | ``False`` otherwise. |
| 174 | """ |
| 175 | if depth > 8 or obj is None: |
| 176 | return False |
| 177 | if isinstance(obj, list): |
| 178 | return any(_has_sensitive_keys(v, depth + 1) for v in obj) |
| 179 | if isinstance(obj, dict): |
| 180 | for k, v in obj.items(): |
| 181 | if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k): |
| 182 | return True |
| 183 | if _has_sensitive_keys(v, depth + 1): |
| 184 | return True |
| 185 | return False |
| 186 | |
| 187 | |
| 188 | def _reject_nonjson_constant(token: str) -> Any: |
| 189 | """``json.loads(parse_constant=β¦)`` callback that rejects ``NaN``/``Infinity``. |
| 190 | |
| 191 | CPython's ``json`` module accepts ``NaN``, ``Infinity``, and ``-Infinity`` |
| 192 | as a non-spec extension by default, and ``json.dumps`` would re-emit them |
| 193 | as bare tokens β producing stored ``--meta`` blobs that strict JSON |
| 194 | parsers (e.g. the future Rust port, MuseHub UI) would reject. Rejecting |
| 195 | here keeps the v1 stored form spec-compliant forever. |
| 196 | """ |
| 197 | raise ValueError( |
| 198 | f"--meta value {token!r} is not valid JSON " |
| 199 | "(NaN/Infinity/-Infinity are not part of the JSON spec and would " |
| 200 | "produce stored payloads that strict readers reject)." |
| 201 | ) |
| 202 | |
| 203 | |
| 204 | def _validate_meta_payload(raw: str) -> str: |
| 205 | """Parse and validate a ``--meta`` JSON string, returning canonical bytes. |
| 206 | |
| 207 | The returned value is a canonical compact JSON string (``sort_keys=True``, |
| 208 | no extra whitespace, ``allow_nan=False``) suitable for storage in |
| 209 | ``commit.metadata["meta"]``. |
| 210 | |
| 211 | Validation rules (all permanent β part of the v1 schema contract): |
| 212 | |
| 213 | 1. Raw input length β€ :data:`_MAX_META_RAW_BYTES` characters (cheap |
| 214 | pre-parse cap that bounds ``json.loads`` and recursive-scan cost). |
| 215 | 2. Must be valid JSON; ``NaN``/``Infinity``/``-Infinity`` are rejected. |
| 216 | 3. Top-level value must be a JSON object (dict), not an array or scalar. |
| 217 | 4. All keys must be strings. |
| 218 | 5. No key at any level may match :data:`_SENSITIVE_KEY_RE` (depth β€ 8). |
| 219 | 6. No top-level key may appear in :data:`_RESERVED_META_KEYS` β those |
| 220 | conflict with CLI-controlled metadata slots and would create two |
| 221 | sources of truth for the same logical field in the permanent commit |
| 222 | graph. |
| 223 | 7. Serialised canonical form must be β€ :data:`_MAX_META_BYTES` bytes. |
| 224 | |
| 225 | Args: |
| 226 | raw: The raw string passed to ``--meta`` on the CLI. |
| 227 | |
| 228 | Returns: |
| 229 | Canonical compact JSON string ready for storage. |
| 230 | |
| 231 | Raises: |
| 232 | ValueError: With a human-readable message describing the first |
| 233 | validation failure encountered. Error messages never echo |
| 234 | user-supplied values that match the sensitive-key pattern. |
| 235 | """ |
| 236 | if len(raw) > _MAX_META_RAW_BYTES: |
| 237 | raise ValueError( |
| 238 | f"--meta raw input exceeds the {_MAX_META_RAW_BYTES // 1024} KiB " |
| 239 | f"pre-parse cap (length: {len(raw)} chars). " |
| 240 | "Reduce whitespace or split the payload." |
| 241 | ) |
| 242 | |
| 243 | try: |
| 244 | payload = json.loads(raw, parse_constant=_reject_nonjson_constant) |
| 245 | except json.JSONDecodeError as exc: |
| 246 | raise ValueError( |
| 247 | f"--meta must be valid JSON: {exc}" |
| 248 | ) from exc |
| 249 | |
| 250 | if not isinstance(payload, dict): |
| 251 | raise ValueError( |
| 252 | f"--meta must be a JSON object (got {type(payload).__name__}). " |
| 253 | "Example: --meta '{{\"topic\": \"agents\", \"confidence\": 0.9}}'" |
| 254 | ) |
| 255 | |
| 256 | for k in payload: |
| 257 | if not isinstance(k, str): |
| 258 | raise ValueError( |
| 259 | f"--meta JSON object keys must be strings (got key of type " |
| 260 | f"{type(k).__name__}: {k!r})" |
| 261 | ) |
| 262 | |
| 263 | reserved_present = sorted(k for k in payload if k in _RESERVED_META_KEYS) |
| 264 | if reserved_present: |
| 265 | raise ValueError( |
| 266 | f"--meta payload contains reserved top-level key(s) " |
| 267 | f"{reserved_present!r} that conflict with CLI-controlled " |
| 268 | "metadata slot(s) of the same name. Use the dedicated " |
| 269 | "--event-type flag for event_type, and do not place 'meta' at " |
| 270 | "the top level of your --meta payload." |
| 271 | ) |
| 272 | |
| 273 | if _has_sensitive_keys(payload): |
| 274 | raise ValueError( |
| 275 | "--meta payload contains a key matching a sensitive-data pattern " |
| 276 | "(api_key, secret, password, token, credential, authorization, " |
| 277 | "bearer, private_key). Remove secrets before committing." |
| 278 | ) |
| 279 | |
| 280 | canonical = json.dumps( |
| 281 | payload, |
| 282 | sort_keys=True, |
| 283 | separators=(",", ":"), |
| 284 | ensure_ascii=False, |
| 285 | allow_nan=False, |
| 286 | ) |
| 287 | if len(canonical.encode("utf-8")) > _MAX_META_BYTES: |
| 288 | raise ValueError( |
| 289 | f"--meta payload exceeds the {_MAX_META_BYTES // 1024} KiB size limit " |
| 290 | f"(serialised size: {len(canonical.encode('utf-8'))} bytes)." |
| 291 | ) |
| 292 | |
| 293 | return canonical |
| 294 | |
| 295 | |
| 296 | def _attestation_path_for_commit( |
| 297 | manifest: Manifest, |
| 298 | parent_manifest: Manifest, |
| 299 | ) -> str: |
| 300 | """Pick a representative path to bind into the commit's attestation. |
| 301 | |
| 302 | The ``MuseAttestationProvider.compute_attestation`` interface requires a |
| 303 | ``path`` field in ``commit_meta``. For a multi-file commit there is no |
| 304 | single canonical path, so we pick deterministically: |
| 305 | |
| 306 | 1. The first added or modified path in canonical (sorted) order, OR |
| 307 | 2. The first path in the new manifest if the commit is purely additive |
| 308 | and the parent manifest is empty, OR |
| 309 | 3. ``""`` (repo-scoped attestation) when the commit changed nothing |
| 310 | resolvable β used by the inbox-bypass path to short-circuit. |
| 311 | |
| 312 | Determinism matters: this function is part of the attestation ID input |
| 313 | via :func:`muse.plugins.knowtation.attestation.compute_attestation_id`, |
| 314 | so the same commit on a fresh node MUST produce the same path so the |
| 315 | canister rejects the duplicate write with the documented "already exists" |
| 316 | error. |
| 317 | |
| 318 | Args: |
| 319 | manifest: The new snapshot's manifest (path β object_id). |
| 320 | parent_manifest: The parent snapshot's manifest, possibly empty. |
| 321 | |
| 322 | Returns: |
| 323 | A vault-relative path string suitable for the provider's |
| 324 | ``commit_meta["path"]`` field. Empty string is a valid result for |
| 325 | repo-scoped attestations. |
| 326 | """ |
| 327 | added = sorted(set(manifest) - set(parent_manifest)) |
| 328 | modified = sorted( |
| 329 | p for p in set(manifest) & set(parent_manifest) |
| 330 | if manifest[p] != parent_manifest[p] |
| 331 | ) |
| 332 | changed = sorted(set(added) | set(modified)) |
| 333 | if changed: |
| 334 | return changed[0] |
| 335 | if manifest: |
| 336 | return sorted(manifest)[0] |
| 337 | return "" |
| 338 | |
| 339 | |
| 340 | def _resolve_quorum_member_key( |
| 341 | handle: str, |
| 342 | overrides: dict[str, pathlib.Path], |
| 343 | ) -> "Any | None": |
| 344 | """Load the Ed25519 private key for a quorum member *handle*. |
| 345 | |
| 346 | Resolution order (first hit wins): |
| 347 | |
| 348 | 1. ``overrides[handle]`` (from ``--quorum-key handle=path``). |
| 349 | 2. ``$MUSE_QUORUM_KEY_<HANDLE>`` env var holding raw PEM bytes. |
| 350 | 3. ``$MUSE_QUORUM_KEY_PATH_<HANDLE>`` env var holding a PEM path. |
| 351 | 4. ``~/.muse/keys/<handle>.pem``. |
| 352 | |
| 353 | The handle is uppercased and ``@`` / hyphens are translated to ``_`` |
| 354 | for env-var lookup so org and member handles map to valid identifier |
| 355 | names (env vars cannot start with ``@`` or contain ``-``). |
| 356 | |
| 357 | Args: |
| 358 | handle: Member handle (validated by the caller). |
| 359 | overrides: Mapping of handle β explicit PEM path from the |
| 360 | ``--quorum-key`` flag. |
| 361 | |
| 362 | Returns: |
| 363 | The :class:`cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey` |
| 364 | for the member, or ``None`` if no key was found. |
| 365 | |
| 366 | Raises: |
| 367 | ValueError: If a configured PEM file or env var contains malformed |
| 368 | key material. Distinct from "key not found" so the CLI can |
| 369 | abort the commit (signature stripping prevention). |
| 370 | """ |
| 371 | from muse.core.keypair import load_private_key_from_pem |
| 372 | |
| 373 | env_key_name = ( |
| 374 | "MUSE_QUORUM_KEY_" |
| 375 | + handle.lstrip("@").upper().replace("-", "_").replace(".", "_") |
| 376 | ) |
| 377 | env_path_name = env_key_name + "_PATH" |
| 378 | |
| 379 | if handle in overrides: |
| 380 | path = overrides[handle] |
| 381 | try: |
| 382 | data = path.read_bytes() |
| 383 | except OSError as exc: |
| 384 | raise ValueError( |
| 385 | f"--quorum-key {handle}={path}: cannot read PEM ({exc})" |
| 386 | ) from exc |
| 387 | key = load_private_key_from_pem(data) |
| 388 | if key is None: |
| 389 | raise ValueError( |
| 390 | f"--quorum-key {handle}={path}: PEM did not parse as an " |
| 391 | "Ed25519 private key" |
| 392 | ) |
| 393 | return key |
| 394 | |
| 395 | pem_env = os.environ.get(env_key_name, "").strip() |
| 396 | if pem_env: |
| 397 | key = load_private_key_from_pem(pem_env.encode()) |
| 398 | if key is None: |
| 399 | raise ValueError( |
| 400 | f"${env_key_name} did not parse as an Ed25519 private key" |
| 401 | ) |
| 402 | return key |
| 403 | |
| 404 | path_env = os.environ.get(env_path_name, "").strip() |
| 405 | if path_env: |
| 406 | path = pathlib.Path(path_env) |
| 407 | try: |
| 408 | data = path.read_bytes() |
| 409 | except OSError as exc: |
| 410 | raise ValueError( |
| 411 | f"${env_path_name}={path}: cannot read PEM ({exc})" |
| 412 | ) from exc |
| 413 | key = load_private_key_from_pem(data) |
| 414 | if key is None: |
| 415 | raise ValueError( |
| 416 | f"${env_path_name}={path}: PEM did not parse as an Ed25519 " |
| 417 | "private key" |
| 418 | ) |
| 419 | return key |
| 420 | |
| 421 | default_path = pathlib.Path.home() / ".muse" / "keys" / f"{handle}.pem" |
| 422 | if default_path.exists(): |
| 423 | try: |
| 424 | data = default_path.read_bytes() |
| 425 | except OSError as exc: |
| 426 | raise ValueError( |
| 427 | f"~/.muse/keys/{handle}.pem: cannot read PEM ({exc})" |
| 428 | ) from exc |
| 429 | key = load_private_key_from_pem(data) |
| 430 | if key is None: |
| 431 | raise ValueError( |
| 432 | f"~/.muse/keys/{handle}.pem did not parse as an Ed25519 " |
| 433 | "private key" |
| 434 | ) |
| 435 | return key |
| 436 | |
| 437 | return None |
| 438 | |
| 439 | |
| 440 | def _parse_quorum_key_overrides(raw: list[str] | None) -> dict[str, pathlib.Path]: |
| 441 | """Parse ``--quorum-key handle=path`` repeats into a dict. |
| 442 | |
| 443 | Args: |
| 444 | raw: List of ``handle=path`` strings, or ``None``. |
| 445 | |
| 446 | Returns: |
| 447 | Mapping of handle β path. Empty dict when *raw* is None/empty. |
| 448 | |
| 449 | Raises: |
| 450 | ValueError: For malformed entries. |
| 451 | """ |
| 452 | out: dict[str, pathlib.Path] = {} |
| 453 | if not raw: |
| 454 | return out |
| 455 | for entry in raw: |
| 456 | if "=" not in entry: |
| 457 | raise ValueError( |
| 458 | f"--quorum-key entry {entry!r} must be 'handle=/path/to/key.pem'" |
| 459 | ) |
| 460 | handle, path = entry.split("=", 1) |
| 461 | handle = handle.strip() |
| 462 | path_str = path.strip() |
| 463 | if not handle or not path_str: |
| 464 | raise ValueError( |
| 465 | f"--quorum-key entry {entry!r}: handle and path must be non-empty" |
| 466 | ) |
| 467 | if handle in out: |
| 468 | raise ValueError( |
| 469 | f"--quorum-key handle {handle!r} specified more than once" |
| 470 | ) |
| 471 | out[handle] = pathlib.Path(path_str).expanduser() |
| 472 | return out |
| 473 | |
| 474 | |
| 475 | def _validate_event_type(kind: str) -> str: |
| 476 | """Validate a ``--event-type`` string against the canonical taxonomy. |
| 477 | |
| 478 | Delegates to :func:`muse.plugins.knowtation.events.is_valid_event_kind` |
| 479 | when that module is available, otherwise uses the built-in fallback |
| 480 | frozenset :data:`_FALLBACK_EVENT_KINDS`. |
| 481 | |
| 482 | Args: |
| 483 | kind: The raw event-type string supplied on the CLI. |
| 484 | |
| 485 | Returns: |
| 486 | The validated kind string (unchanged). |
| 487 | |
| 488 | Raises: |
| 489 | ValueError: If *kind* is not a recognised memory-event kind. |
| 490 | """ |
| 491 | if not _is_valid_event_kind(kind): |
| 492 | valid = sorted(_FALLBACK_EVENT_KINDS) |
| 493 | raise ValueError( |
| 494 | f"--event-type {kind!r} is not a valid memory-event kind. " |
| 495 | f"Valid kinds: {', '.join(valid)}" |
| 496 | ) |
| 497 | return kind |
| 498 | |
| 499 | |
| 500 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 501 | """Register the ``muse commit`` subcommand and its flags.""" |
| 502 | parser = subparsers.add_parser( |
| 503 | "commit", |
| 504 | help="Record the current state as a new version.", |
| 505 | description=__doc__, |
| 506 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 507 | ) |
| 508 | parser.add_argument( |
| 509 | "-m", "--message", default=None, |
| 510 | help="Commit message (required unless --allow-empty is set).", |
| 511 | ) |
| 512 | parser.add_argument( |
| 513 | "--allow-empty", action="store_true", |
| 514 | help="Allow committing with no changes (empty-message commits still warn).", |
| 515 | ) |
| 516 | parser.add_argument( |
| 517 | "--dry-run", "-n", action="store_true", dest="dry_run", |
| 518 | help=( |
| 519 | "Compute snapshot and commit_id without writing anything. " |
| 520 | "Exits 0 when changes exist, 1 when the working tree is clean. " |
| 521 | "Combine with --json for structured preflight output in agent pipelines." |
| 522 | ), |
| 523 | ) |
| 524 | parser.add_argument( |
| 525 | "--section", default=None, |
| 526 | help="Tag this commit with a section label (verse, chorus, bridgeβ¦).", |
| 527 | ) |
| 528 | parser.add_argument( |
| 529 | "--track", default=None, |
| 530 | help="Tag this commit with an instrument track (drums, bass, keysβ¦).", |
| 531 | ) |
| 532 | parser.add_argument( |
| 533 | "--emotion", default=None, |
| 534 | help="Attach an emotion label (joyful, melancholic, tenseβ¦).", |
| 535 | ) |
| 536 | parser.add_argument( |
| 537 | "--author", default=None, |
| 538 | help="Override the commit author.", |
| 539 | ) |
| 540 | parser.add_argument( |
| 541 | "--agent-id", default=None, dest="agent_id", |
| 542 | help="Agent identity string (overrides MUSE_AGENT_ID env var).", |
| 543 | ) |
| 544 | parser.add_argument( |
| 545 | "--model-id", default=None, dest="model_id", |
| 546 | help="Model identifier for AI agents (overrides MUSE_MODEL_ID env var).", |
| 547 | ) |
| 548 | parser.add_argument( |
| 549 | "--toolchain-id", default=None, dest="toolchain_id", |
| 550 | help="Toolchain string (overrides MUSE_TOOLCHAIN_ID env var).", |
| 551 | ) |
| 552 | parser.add_argument( |
| 553 | "--event-type", default=None, dest="event_type", |
| 554 | help=( |
| 555 | "Tag this commit with a knowtation memory-event kind " |
| 556 | "(search, export, write, import, index, propose, agent_interaction, " |
| 557 | "capture, error, session_summary, user, consolidation, " |
| 558 | "consolidation_pass, maintenance, insight). " |
| 559 | "Stored as metadata[\"event_type\"] on the commit record." |
| 560 | ), |
| 561 | ) |
| 562 | parser.add_argument( |
| 563 | "--meta", default=None, dest="meta", |
| 564 | help=( |
| 565 | "Attach structured metadata to this commit as a JSON object string. " |
| 566 | "Example: --meta '{\"topic\": \"agents\", \"confidence\": 0.9}'. " |
| 567 | "The payload is validated (must be a dict, no sensitive keys, β€ 64 KiB) " |
| 568 | "and stored as metadata[\"meta\"] on the commit record. " |
| 569 | "This is a permanent schema decision: the stored shape is part of the " |
| 570 | "v1 commit-graph contract." |
| 571 | ), |
| 572 | ) |
| 573 | parser.add_argument( |
| 574 | "--sign", action="store_true", |
| 575 | help="HMAC-sign the commit using the agent's stored key (requires --agent-id or MUSE_AGENT_ID).", |
| 576 | ) |
| 577 | parser.add_argument( |
| 578 | "--attest", action="store_true", |
| 579 | help=( |
| 580 | "Attach an attestation record to this commit using the domain's " |
| 581 | "registered MuseAttestationProvider (Phase 7.1). Stored under " |
| 582 | "commit.metadata['attestation']. Silently skipped (DEBUG log) " |
| 583 | "when no provider is registered, UNLESS the provider's " |
| 584 | "air.required=True β in which case the commit is aborted with " |
| 585 | "exit code 4 (ATTESTATION_REQUIRED)." |
| 586 | ), |
| 587 | ) |
| 588 | parser.add_argument( |
| 589 | "--attest-config", default=None, dest="attest_config", |
| 590 | help=( |
| 591 | "Optional path to a YAML config file for the attestation provider. " |
| 592 | "Defaults: $MUSE_AIR_CONFIG, then .muse/air.yaml (relative to repo " |
| 593 | "root). Ignored when --attest is not set." |
| 594 | ), |
| 595 | ) |
| 596 | parser.add_argument( |
| 597 | "--quorum-signers", default=None, dest="quorum_signers", |
| 598 | help=( |
| 599 | "Comma-separated list of additional org member handles that must " |
| 600 | "co-sign this commit (Phase 7.7 org quorum). Each member's key " |
| 601 | "is loaded from MUSE_QUORUM_KEY_<HANDLE> (PEM bytes) or " |
| 602 | "~/.muse/keys/<handle>.pem. Each member signs the SAME " |
| 603 | "provenance payload as the primary signer. Use --quorum-org to " |
| 604 | "name the org (defaults to --agent-id). Aborts the commit if " |
| 605 | "any listed signer's key cannot be loaded β signature stripping " |
| 606 | "is detected at commit time, not just verify time." |
| 607 | ), |
| 608 | ) |
| 609 | parser.add_argument( |
| 610 | "--quorum-org", default=None, dest="quorum_org", |
| 611 | help=( |
| 612 | "Org handle (must start with '@') the quorum signs on behalf of. " |
| 613 | "Required when --quorum-signers is set. Stored verbatim in " |
| 614 | "commit.metadata['quorum']['org_handle']." |
| 615 | ), |
| 616 | ) |
| 617 | parser.add_argument( |
| 618 | "--quorum-threshold", default=None, dest="quorum_threshold", type=int, |
| 619 | help=( |
| 620 | "Quorum threshold to embed in commit metadata (audit only β " |
| 621 | "verifiers re-fetch the live threshold from the org record). " |
| 622 | "Defaults to len(--quorum-signers)." |
| 623 | ), |
| 624 | ) |
| 625 | parser.add_argument( |
| 626 | "--quorum-key", action="append", default=None, dest="quorum_keys", |
| 627 | help=( |
| 628 | "Repeatable: explicit override for a member key path " |
| 629 | "(format: handle=/path/to/handle.pem). Bypasses both the " |
| 630 | "MUSE_QUORUM_KEY_<HANDLE> env var and the ~/.muse/keys/ " |
| 631 | "default lookup. Useful in tests and CI." |
| 632 | ), |
| 633 | ) |
| 634 | parser.add_argument( |
| 635 | "--format", "-f", default="text", dest="fmt", |
| 636 | help="Output format: text (default) or json.", |
| 637 | ) |
| 638 | parser.add_argument( |
| 639 | "--json", action="store_const", const="json", dest="fmt", |
| 640 | help="Shorthand for --format json.", |
| 641 | ) |
| 642 | parser.set_defaults(func=run) |
| 643 | |
| 644 | |
| 645 | def run(args: argparse.Namespace) -> None: |
| 646 | """Record the current state as a new version. |
| 647 | |
| 648 | Agents should pass ``--json`` to receive a machine-readable result:: |
| 649 | |
| 650 | { |
| 651 | "commit_id": "<sha256>", |
| 652 | "branch": "main", |
| 653 | "snapshot_id": "<sha256>", |
| 654 | "message": "Add verse melody", |
| 655 | "parent_commit_id": "<sha256> | null", |
| 656 | "parent2_commit_id": "<sha256> | null", |
| 657 | "committed_at": "2026-03-21T12:00:00+00:00", |
| 658 | "author": "gabriel", |
| 659 | "agent_id": "", |
| 660 | "sem_ver_bump": "minor", |
| 661 | "breaking_changes": [], |
| 662 | "files_changed": {"added": 1, "modified": 0, "deleted": 0}, |
| 663 | "dry_run": false |
| 664 | } |
| 665 | |
| 666 | ``--dry-run`` output has the same schema but ``dry_run`` is ``true`` |
| 667 | and no data is written to disk. The ``commit_id`` is the deterministic |
| 668 | ID that *would* be created. |
| 669 | |
| 670 | Exit codes: |
| 671 | 0 β commit created successfully. |
| 672 | 0 β nothing to commit (clean tree, no ``--dry-run``). |
| 673 | 1 β dry-run: working tree is clean (no changes to commit). |
| 674 | 1 β validation error (missing message, unresolved conflicts, empty tree). |
| 675 | 3 β I/O error or repository not found. |
| 676 | """ |
| 677 | message: str | None = args.message |
| 678 | allow_empty: bool = args.allow_empty |
| 679 | dry_run: bool = args.dry_run |
| 680 | section: str | None = args.section |
| 681 | track: str | None = args.track |
| 682 | emotion: str | None = args.emotion |
| 683 | raw_author: str | None = args.author |
| 684 | agent_id: str | None = args.agent_id |
| 685 | model_id: str | None = args.model_id |
| 686 | toolchain_id: str | None = args.toolchain_id |
| 687 | sign: bool = args.sign |
| 688 | attest: bool = bool(getattr(args, "attest", False)) |
| 689 | attest_config_path: str | None = getattr(args, "attest_config", None) |
| 690 | quorum_signers_raw: str | None = getattr(args, "quorum_signers", None) |
| 691 | quorum_org: str | None = getattr(args, "quorum_org", None) |
| 692 | quorum_threshold: int | None = getattr(args, "quorum_threshold", None) |
| 693 | quorum_key_overrides: list[str] | None = getattr(args, "quorum_keys", None) |
| 694 | fmt: str = args.fmt |
| 695 | raw_meta: str | None = args.meta |
| 696 | raw_event_type: str | None = args.event_type |
| 697 | |
| 698 | if fmt not in ("text", "json"): |
| 699 | print( |
| 700 | f"β Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", |
| 701 | file=sys.stderr, |
| 702 | ) |
| 703 | raise SystemExit(ExitCode.USER_ERROR) |
| 704 | |
| 705 | # Validate --meta payload early so we surface errors before doing any I/O. |
| 706 | validated_meta: str | None = None |
| 707 | if raw_meta is not None: |
| 708 | try: |
| 709 | validated_meta = _validate_meta_payload(raw_meta) |
| 710 | except ValueError as exc: |
| 711 | print(f"β {exc}", file=sys.stderr) |
| 712 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 713 | |
| 714 | # Validate --event-type early for the same reason. |
| 715 | validated_event_type: str | None = None |
| 716 | if raw_event_type is not None: |
| 717 | try: |
| 718 | validated_event_type = _validate_event_type(raw_event_type) |
| 719 | except ValueError as exc: |
| 720 | print(f"β {exc}", file=sys.stderr) |
| 721 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 722 | |
| 723 | if message is None and not allow_empty: |
| 724 | print("β Provide a commit message with -m MESSAGE.", file=sys.stderr) |
| 725 | raise SystemExit(ExitCode.USER_ERROR) |
| 726 | |
| 727 | if message is None and allow_empty: |
| 728 | logger.warning( |
| 729 | "β οΈ --allow-empty used without -m: commit will have an empty message." |
| 730 | ) |
| 731 | |
| 732 | # Sanitize and cap the author field. An explicit --author override is a |
| 733 | # potential impersonation vector (an agent could supply a human's name). |
| 734 | # We strip all C0/DEL/C1 control chars and cap at _MAX_FIELD_LEN. |
| 735 | # A warning is emitted when the caller explicitly passes --author so the |
| 736 | # act is always visible in logs. |
| 737 | author: str | None = ( |
| 738 | sanitize_provenance(raw_author[:_MAX_FIELD_LEN]) if raw_author else None |
| 739 | ) |
| 740 | if raw_author is not None: |
| 741 | logger.warning( |
| 742 | "β οΈ --author override supplied: %r β this is not verified against " |
| 743 | "the stored identity and may allow impersonation.", |
| 744 | author, |
| 745 | ) |
| 746 | |
| 747 | root = require_repo() |
| 748 | |
| 749 | # Read merge state before any writes β needed for conflict check and |
| 750 | # rerere recording later. |
| 751 | merge_state = read_merge_state(root) |
| 752 | if merge_state is not None and merge_state.conflict_paths: |
| 753 | print( |
| 754 | "β You have unresolved merge conflicts. Resolve them before committing.", |
| 755 | file=sys.stderr, |
| 756 | ) |
| 757 | for p in sorted(merge_state.conflict_paths): |
| 758 | print(f" both modified: {sanitize_display(p)}", file=sys.stderr) |
| 759 | raise SystemExit(ExitCode.USER_ERROR) |
| 760 | |
| 761 | repo_id = read_repo_id(root) |
| 762 | branch = read_current_branch(root) |
| 763 | parent_id = get_head_commit_id(root, branch) |
| 764 | |
| 765 | plugin = resolve_plugin(root) |
| 766 | snap = plugin.snapshot(root) |
| 767 | manifest = snap["files"] |
| 768 | directories = list(snap.get("directories") or []) |
| 769 | if not manifest and not allow_empty: |
| 770 | print("β οΈ Nothing tracked β working tree is empty.", file=sys.stderr) |
| 771 | raise SystemExit(ExitCode.USER_ERROR) |
| 772 | |
| 773 | snapshot_id = compute_snapshot_id(manifest, directories) |
| 774 | |
| 775 | if not allow_empty: |
| 776 | head_snapshot = get_head_snapshot_id(root, repo_id, branch) |
| 777 | if head_snapshot == snapshot_id: |
| 778 | if dry_run: |
| 779 | if fmt == "json": |
| 780 | print(json.dumps({"dry_run": True, "clean": True, "message": "Nothing to commit, working tree clean"})) |
| 781 | else: |
| 782 | print("Nothing to commit, working tree clean") |
| 783 | raise SystemExit(1) |
| 784 | print("Nothing to commit, working tree clean") |
| 785 | raise SystemExit(ExitCode.SUCCESS) |
| 786 | |
| 787 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 788 | parent_ids = [parent_id] if parent_id else [] |
| 789 | |
| 790 | # When completing a conflicted merge, include the second parent so that |
| 791 | # the merge is recorded as a true two-parent merge commit. This ensures |
| 792 | # subsequent merge --dry-run correctly computes the LCA as the resolved |
| 793 | # merge commit rather than the pre-merge common ancestor, preventing the |
| 794 | # same conflicts from re-appearing on the next merge attempt. |
| 795 | merge_parent2: str | None = None |
| 796 | if merge_state is not None and merge_state.theirs_commit: |
| 797 | merge_parent2 = merge_state.theirs_commit |
| 798 | if merge_parent2 not in parent_ids: |
| 799 | parent_ids = parent_ids + [merge_parent2] |
| 800 | |
| 801 | commit_id = compute_commit_id( |
| 802 | parent_ids=parent_ids, |
| 803 | snapshot_id=snapshot_id, |
| 804 | message=message or "", |
| 805 | committed_at_iso=committed_at.isoformat(), |
| 806 | ) |
| 807 | |
| 808 | metadata: Metadata = {} |
| 809 | if section: |
| 810 | metadata["section"] = section |
| 811 | if track: |
| 812 | metadata["track"] = track |
| 813 | if emotion: |
| 814 | metadata["emotion"] = emotion |
| 815 | if validated_event_type is not None: |
| 816 | metadata["event_type"] = validated_event_type |
| 817 | if validated_meta is not None: |
| 818 | metadata["meta"] = validated_meta |
| 819 | |
| 820 | # Load the parent snapshot manifest once and reuse it for both |
| 821 | # structured_delta computation and file-count output. Previously the |
| 822 | # manifest was loaded independently in each section β two separate |
| 823 | # read_snapshot() calls per commit. |
| 824 | parent_manifest: Manifest = {} |
| 825 | parent_directories: list[str] = [] |
| 826 | if parent_id is not None: |
| 827 | parent_commit_rec = read_commit(root, parent_id) |
| 828 | if parent_commit_rec is not None: |
| 829 | parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id) |
| 830 | if parent_snap_record is not None: |
| 831 | parent_manifest = dict(parent_snap_record.manifest) |
| 832 | parent_directories = list(parent_snap_record.directories) |
| 833 | |
| 834 | # Compute a structured delta against the parent snapshot so muse show |
| 835 | # can display note-level changes without reloading blobs. |
| 836 | structured_delta: StructuredDelta | None = None |
| 837 | sem_ver_bump: SemVerBump = "none" |
| 838 | breaking_changes: list[str] = [] |
| 839 | if parent_id is not None and parent_manifest: |
| 840 | domain = read_domain(root) |
| 841 | base_snap = SnapshotManifest(files=parent_manifest, domain=domain, directories=parent_directories) |
| 842 | try: |
| 843 | structured_delta = plugin.diff(base_snap, snap, repo_root=root) |
| 844 | except Exception as exc: |
| 845 | # plugin.diff() is domain-specific and may fail on unsupported |
| 846 | # file types. The commit proceeds without a structured delta; |
| 847 | # sem_ver_bump defaults to "none". |
| 848 | logger.debug("plugin.diff() failed β structured delta omitted: %s", exc) |
| 849 | structured_delta = None |
| 850 | |
| 851 | # Classify the structured delta into a semver bump and breaking-change list. |
| 852 | if structured_delta is not None: |
| 853 | classification = classify_delta(structured_delta, repo_root=root) |
| 854 | sem_ver_bump = classification.bump |
| 855 | breaking_changes = classification.breaking_addresses |
| 856 | structured_delta["sem_ver_bump"] = sem_ver_bump |
| 857 | structured_delta["breaking_changes"] = breaking_changes |
| 858 | |
| 859 | # Resolve agent provenance: CLI flags take priority over environment vars. |
| 860 | # 1. Truncate to _MAX_FIELD_LEN chars β prevents DoS via arbitrarily long values. |
| 861 | # 2. Strip all C0/DEL/C1 control characters β prevents terminal injection |
| 862 | # when provenance fields are rendered in display paths (muse log, muse show, |
| 863 | # agent dashboards), log-line splitting, and visual spoofing. |
| 864 | resolved_agent_id = sanitize_provenance( |
| 865 | (agent_id or os.environ.get("MUSE_AGENT_ID", ""))[:_MAX_FIELD_LEN] |
| 866 | ) |
| 867 | resolved_model_id = sanitize_provenance( |
| 868 | (model_id or os.environ.get("MUSE_MODEL_ID", ""))[:_MAX_FIELD_LEN] |
| 869 | ) |
| 870 | resolved_toolchain_id = sanitize_provenance( |
| 871 | (toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", ""))[:_MAX_FIELD_LEN] |
| 872 | ) |
| 873 | resolved_prompt_hash = sanitize_provenance( |
| 874 | os.environ.get("MUSE_PROMPT_HASH", "")[:_MAX_FIELD_LEN] |
| 875 | ) |
| 876 | |
| 877 | # Compute file-level change counts from the (now single-read) parent manifest. |
| 878 | files_added = len(set(manifest) - set(parent_manifest)) |
| 879 | files_deleted = len(set(parent_manifest) - set(manifest)) |
| 880 | files_modified = sum( |
| 881 | 1 for p in set(manifest) & set(parent_manifest) |
| 882 | if manifest[p] != parent_manifest[p] |
| 883 | ) |
| 884 | |
| 885 | # ββ Dry-run path β no writes beyond this point ββββββββββββββββββββββββββββ |
| 886 | if dry_run: |
| 887 | if fmt == "json": |
| 888 | print(json.dumps({ |
| 889 | "dry_run": True, |
| 890 | "clean": False, |
| 891 | "commit_id": commit_id, |
| 892 | "branch": branch, |
| 893 | "snapshot_id": snapshot_id, |
| 894 | "message": message or "", |
| 895 | "parent_commit_id": parent_id, |
| 896 | "parent2_commit_id": merge_parent2, |
| 897 | "committed_at": committed_at.isoformat(), |
| 898 | "author": author or "", |
| 899 | "agent_id": resolved_agent_id, |
| 900 | "sem_ver_bump": sem_ver_bump, |
| 901 | "breaking_changes": breaking_changes, |
| 902 | "files_changed": { |
| 903 | "added": files_added, |
| 904 | "modified": files_modified, |
| 905 | "deleted": files_deleted, |
| 906 | }, |
| 907 | })) |
| 908 | else: |
| 909 | total = files_added + files_deleted + files_modified |
| 910 | print(f"[dry-run] [{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}") |
| 911 | if total: |
| 912 | parts: list[str] = [] |
| 913 | if files_modified: |
| 914 | parts.append(f"{files_modified} modified") |
| 915 | if files_added: |
| 916 | parts.append(f"{files_added} added") |
| 917 | if files_deleted: |
| 918 | parts.append(f"{files_deleted} removed") |
| 919 | print(f" {total} file{'s' if total != 1 else ''} changed ({', '.join(parts)})") |
| 920 | print(" (dry-run: nothing written)") |
| 921 | return |
| 922 | |
| 923 | # ββ Actual writes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 924 | for rel_path, object_id in manifest.items(): |
| 925 | write_object_from_path(root, object_id, root / rel_path) |
| 926 | |
| 927 | write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=directories)) |
| 928 | |
| 929 | signature = "" |
| 930 | signer_public_key = "" |
| 931 | signer_key_id = "" |
| 932 | if sign and resolved_agent_id: |
| 933 | from muse.cli.config import get_signing_identity |
| 934 | signing = get_signing_identity(root, agent_id=resolved_agent_id) |
| 935 | if signing is not None: |
| 936 | result = sign_commit_record( |
| 937 | commit_id, |
| 938 | resolved_agent_id, |
| 939 | signing.private_key, |
| 940 | author=author or "", |
| 941 | model_id=resolved_model_id, |
| 942 | toolchain_id=resolved_toolchain_id, |
| 943 | prompt_hash=resolved_prompt_hash, |
| 944 | committed_at=committed_at.isoformat(), |
| 945 | ) |
| 946 | if result is not None: |
| 947 | signature, signer_public_key, signer_key_id = result |
| 948 | else: |
| 949 | logger.warning( |
| 950 | "No signing identity found for agent %r β commit will be unsigned. " |
| 951 | "Run `muse auth keygen && muse auth register` to set up a keypair.", |
| 952 | resolved_agent_id, |
| 953 | ) |
| 954 | |
| 955 | # ββ Attestation (Phase 7.3) βββββββββββββββββββββββββββββββββββββββββββββββ |
| 956 | # Runs AFTER commit_id is computed and BEFORE write_commit so the |
| 957 | # attestation record can be embedded in commit.metadata["attestation"]. |
| 958 | # Failures fall into two buckets: |
| 959 | # 1. Provider not registered for this domain β silent DEBUG-level skip. |
| 960 | # 2. Provider raises AttestationRequiredError β abort the commit before |
| 961 | # writing anything to disk; surface ExitCode.ATTESTATION_REQUIRED. |
| 962 | if attest: |
| 963 | domain = read_domain(root) |
| 964 | provider = get_attestation_provider(domain) |
| 965 | if provider is None: |
| 966 | logger.debug( |
| 967 | "commit --attest: no attestation provider registered for " |
| 968 | "domain %r β skipping (no record attached).", |
| 969 | domain, |
| 970 | ) |
| 971 | else: |
| 972 | attestation_commit_meta: dict[str, object] = { |
| 973 | "action": "write", |
| 974 | "path": _attestation_path_for_commit(manifest, parent_manifest), |
| 975 | "content_hash": snapshot_id, |
| 976 | "timestamp": committed_at.isoformat(), |
| 977 | "agent_id": resolved_agent_id, |
| 978 | } |
| 979 | try: |
| 980 | attestation_record = provider.compute_attestation( |
| 981 | snapshot_id, attestation_commit_meta |
| 982 | ) |
| 983 | except AttestationRequiredError as exc: |
| 984 | print( |
| 985 | f"β Attestation required but unavailable: {exc}", |
| 986 | file=sys.stderr, |
| 987 | ) |
| 988 | raise SystemExit(ExitCode.ATTESTATION_REQUIRED) from exc |
| 989 | except AttestationError as exc: |
| 990 | logger.warning( |
| 991 | "commit --attest: provider raised %s: %s β commit proceeding " |
| 992 | "without attestation record.", |
| 993 | type(exc).__name__, exc, |
| 994 | ) |
| 995 | attestation_record = None |
| 996 | if attestation_record is not None: |
| 997 | metadata["attestation"] = json.dumps( |
| 998 | attestation_record, |
| 999 | sort_keys=True, |
| 1000 | separators=(",", ":"), |
| 1001 | ensure_ascii=False, |
| 1002 | ) |
| 1003 | |
| 1004 | # ββ Quorum co-signing (Phase 7.7) βββββββββββββββββββββββββββββββββββββββββ |
| 1005 | # Each member signs the SAME provenance_payload that the primary signer |
| 1006 | # signed. Failure to load a listed signer's key aborts the commit with |
| 1007 | # ExitCode.USER_ERROR β this is the "signature stripping" defence: an |
| 1008 | # attacker cannot drop a member from the list at sign time and pretend |
| 1009 | # the missing sig was "optional". |
| 1010 | if quorum_signers_raw: |
| 1011 | from muse.core.provenance import ( |
| 1012 | encode_public_key as _qencode_pubkey, |
| 1013 | sign_commit_ed25519 as _qsign, |
| 1014 | ) |
| 1015 | from muse.plugins.knowtation.quorum import ( |
| 1016 | build_quorum_metadata, |
| 1017 | is_valid_handle as _is_valid_quorum_handle, |
| 1018 | ) |
| 1019 | |
| 1020 | signer_handles = [h.strip() for h in quorum_signers_raw.split(",") if h.strip()] |
| 1021 | if not signer_handles: |
| 1022 | print( |
| 1023 | "β --quorum-signers was empty after parsing; supply at least one handle.", |
| 1024 | file=sys.stderr, |
| 1025 | ) |
| 1026 | raise SystemExit(ExitCode.USER_ERROR) |
| 1027 | for h in signer_handles: |
| 1028 | if not _is_valid_quorum_handle(h): |
| 1029 | print( |
| 1030 | f"β --quorum-signers: {h!r} is not a valid handle.", |
| 1031 | file=sys.stderr, |
| 1032 | ) |
| 1033 | raise SystemExit(ExitCode.USER_ERROR) |
| 1034 | if len(set(signer_handles)) != len(signer_handles): |
| 1035 | print( |
| 1036 | "β --quorum-signers contains duplicates; each handle must appear once.", |
| 1037 | file=sys.stderr, |
| 1038 | ) |
| 1039 | raise SystemExit(ExitCode.USER_ERROR) |
| 1040 | |
| 1041 | if not quorum_org or not quorum_org.startswith("@"): |
| 1042 | print( |
| 1043 | "β --quorum-signers requires --quorum-org @<org-handle>.", |
| 1044 | file=sys.stderr, |
| 1045 | ) |
| 1046 | raise SystemExit(ExitCode.USER_ERROR) |
| 1047 | if not _is_valid_quorum_handle(quorum_org): |
| 1048 | print( |
| 1049 | f"β --quorum-org {quorum_org!r} is not a valid handle.", |
| 1050 | file=sys.stderr, |
| 1051 | ) |
| 1052 | raise SystemExit(ExitCode.USER_ERROR) |
| 1053 | |
| 1054 | embedded_threshold = ( |
| 1055 | quorum_threshold if quorum_threshold is not None else len(signer_handles) |
| 1056 | ) |
| 1057 | if embedded_threshold < 1 or embedded_threshold > len(signer_handles): |
| 1058 | print( |
| 1059 | f"β --quorum-threshold {embedded_threshold} must be between 1 and " |
| 1060 | f"{len(signer_handles)} (signer count).", |
| 1061 | file=sys.stderr, |
| 1062 | ) |
| 1063 | raise SystemExit(ExitCode.USER_ERROR) |
| 1064 | |
| 1065 | try: |
| 1066 | overrides = _parse_quorum_key_overrides(quorum_key_overrides) |
| 1067 | except ValueError as exc: |
| 1068 | print(f"β {exc}", file=sys.stderr) |
| 1069 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 1070 | |
| 1071 | # Recompute the SAME payload the primary signer signs β guarantees |
| 1072 | # member sigs cover the identical bytes (replay defence). |
| 1073 | from muse.core.provenance import provenance_payload as _qpayload |
| 1074 | quorum_payload = _qpayload( |
| 1075 | commit_id, |
| 1076 | author=author or "", |
| 1077 | agent_id=resolved_agent_id, |
| 1078 | model_id=resolved_model_id, |
| 1079 | toolchain_id=resolved_toolchain_id, |
| 1080 | prompt_hash=resolved_prompt_hash, |
| 1081 | committed_at=committed_at.isoformat(), |
| 1082 | ) |
| 1083 | |
| 1084 | member_entries: list[dict[str, str]] = [] |
| 1085 | for h in signer_handles: |
| 1086 | try: |
| 1087 | priv_key = _resolve_quorum_member_key(h, overrides) |
| 1088 | except ValueError as exc: |
| 1089 | print( |
| 1090 | f"β Quorum signer {h!r} key invalid: {exc}", |
| 1091 | file=sys.stderr, |
| 1092 | ) |
| 1093 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 1094 | if priv_key is None: |
| 1095 | print( |
| 1096 | f"β No key found for quorum signer {h!r}. Set " |
| 1097 | f"MUSE_QUORUM_KEY_<HANDLE>, --quorum-key {h}=path, or " |
| 1098 | f"place ~/.muse/keys/{h}.pem. Aborting commit " |
| 1099 | "(signature stripping prevention).", |
| 1100 | file=sys.stderr, |
| 1101 | ) |
| 1102 | raise SystemExit(ExitCode.USER_ERROR) |
| 1103 | sig = _qsign(quorum_payload, priv_key) |
| 1104 | _, pub_b64 = _qencode_pubkey(priv_key) |
| 1105 | member_entries.append({ |
| 1106 | "handle": h, |
| 1107 | "public_key": pub_b64, |
| 1108 | "signature": sig, |
| 1109 | }) |
| 1110 | |
| 1111 | try: |
| 1112 | quorum_blob = build_quorum_metadata( |
| 1113 | quorum_org, embedded_threshold, member_entries |
| 1114 | ) |
| 1115 | except ValueError as exc: |
| 1116 | print(f"β Failed to build quorum metadata: {exc}", file=sys.stderr) |
| 1117 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 1118 | metadata["quorum"] = json.dumps( |
| 1119 | quorum_blob, sort_keys=True, separators=(",", ":"), ensure_ascii=False, |
| 1120 | ) |
| 1121 | |
| 1122 | write_commit(root, CommitRecord( |
| 1123 | commit_id=commit_id, |
| 1124 | repo_id=repo_id, |
| 1125 | branch=branch, |
| 1126 | snapshot_id=snapshot_id, |
| 1127 | message=message or "", |
| 1128 | committed_at=committed_at, |
| 1129 | parent_commit_id=parent_id, |
| 1130 | parent2_commit_id=merge_parent2, |
| 1131 | author=author or "", |
| 1132 | metadata=metadata, |
| 1133 | structured_delta=structured_delta, |
| 1134 | sem_ver_bump=sem_ver_bump, |
| 1135 | breaking_changes=breaking_changes, |
| 1136 | agent_id=resolved_agent_id, |
| 1137 | model_id=resolved_model_id, |
| 1138 | toolchain_id=resolved_toolchain_id, |
| 1139 | prompt_hash=resolved_prompt_hash, |
| 1140 | signature=signature, |
| 1141 | signer_public_key=signer_public_key, |
| 1142 | signer_key_id=signer_key_id, |
| 1143 | )) |
| 1144 | |
| 1145 | write_branch_ref(root, branch, commit_id) |
| 1146 | |
| 1147 | # Clear the stage after a successful commit so the next muse commit |
| 1148 | # returns to full-snapshot mode unless the user runs muse code add again. |
| 1149 | if isinstance(plugin, StagePlugin): |
| 1150 | plugin.clear_stage(root) |
| 1151 | |
| 1152 | append_reflog( |
| 1153 | root, |
| 1154 | branch, |
| 1155 | old_id=parent_id, |
| 1156 | new_id=commit_id, |
| 1157 | author=author or "unknown", |
| 1158 | operation=f"commit: {sanitize_display(message or '(no message)')}", |
| 1159 | ) |
| 1160 | |
| 1161 | # If this commit completed a conflicted merge, record how each conflict |
| 1162 | # was resolved so rerere can replay it on future identical conflicts. |
| 1163 | if merge_state is not None and merge_state.ours_commit and merge_state.theirs_commit: |
| 1164 | def _manifest_for(cid: str) -> Manifest: |
| 1165 | cr = read_commit(root, cid) |
| 1166 | if cr is None: |
| 1167 | return {} |
| 1168 | snap_rec = read_snapshot(root, cr.snapshot_id) |
| 1169 | return snap_rec.manifest if snap_rec else {} |
| 1170 | |
| 1171 | ours_manifest = _manifest_for(merge_state.ours_commit) |
| 1172 | theirs_manifest = _manifest_for(merge_state.theirs_commit) |
| 1173 | domain = read_domain(root) |
| 1174 | rerere_record_resolutions( |
| 1175 | root, |
| 1176 | list(merge_state.conflict_paths), |
| 1177 | ours_manifest, |
| 1178 | theirs_manifest, |
| 1179 | manifest, |
| 1180 | domain, |
| 1181 | plugin, |
| 1182 | ) |
| 1183 | clear_merge_state(root) |
| 1184 | |
| 1185 | # ββ Output ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 1186 | if fmt == "json": |
| 1187 | print(json.dumps({ |
| 1188 | "dry_run": False, |
| 1189 | "commit_id": commit_id, |
| 1190 | "branch": branch, |
| 1191 | "snapshot_id": snapshot_id, |
| 1192 | "message": message or "", |
| 1193 | "parent_commit_id": parent_id, |
| 1194 | "parent2_commit_id": merge_parent2, |
| 1195 | "committed_at": committed_at.isoformat(), |
| 1196 | "author": author or "", |
| 1197 | "agent_id": resolved_agent_id, |
| 1198 | "sem_ver_bump": sem_ver_bump, |
| 1199 | "breaking_changes": breaking_changes, |
| 1200 | "files_changed": { |
| 1201 | "added": files_added, |
| 1202 | "modified": files_modified, |
| 1203 | "deleted": files_deleted, |
| 1204 | }, |
| 1205 | })) |
| 1206 | else: |
| 1207 | print(f"[{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}") |
| 1208 | total_files = files_added + files_deleted + files_modified |
| 1209 | if total_files: |
| 1210 | stat_parts: list[str] = [] |
| 1211 | if files_modified: |
| 1212 | stat_parts.append(f"{files_modified} modified") |
| 1213 | if files_added: |
| 1214 | stat_parts.append(f"{files_added} added") |
| 1215 | if files_deleted: |
| 1216 | stat_parts.append(f"{files_deleted} removed") |
| 1217 | print(f" {total_files} file{'s' if total_files != 1 else ''} changed ({', '.join(stat_parts)})") |