"""HK_ACCEPT_01 — musehub#192 Phase 5 acceptance test. Reproduces this session's real incident end-to-end against a real disposable repo: .museagent.md drifts from its compiled adapter (edited but never re-synced), and confirms the dogfood hook (`muse agent-config status --fail-if-out-of-sync`) catches it at commit time, blocking the commit — and that --no-verify still gets it through, visibly. """ from __future__ import annotations import json import os import pathlib import stat import sys import pytest from tests.cli_test_helper import CliRunner, InvokeResult runner = CliRunner() def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) @pytest.fixture(autouse=True) def _muse_shim_on_path(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None: """Make the ``muse`` name on PATH resolve to *this checkout's* source. The pre-commit hook mechanism (``run_hook_point``) shells out to whatever ``muse`` the environment's PATH resolves — by design, since hooks call the real user-facing tool. In this repo specifically, plain ``muse`` is the separately installer-managed stable venv (see docs/agent-guide.md's "muse vs muse-dev"), which does not yet have the ``--fail-if-out-of-sync`` flag this acceptance test exercises. Without this shim, the test would silently exercise stale installed code instead of the change under test. """ shim_dir = tmp_path_factory.mktemp("muse-shim") shim = shim_dir / "muse" shim.write_text( f"#!{sys.executable}\n" "import sys\n" "from muse.cli.app import main\n" "main(sys.argv[1:])\n", encoding="utf-8", ) shim.chmod(shim.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) monkeypatch.setenv("PATH", f"{shim_dir}{os.pathsep}{os.environ['PATH']}") repo_root = pathlib.Path(__file__).resolve().parents[1] existing_pythonpath = os.environ.get("PYTHONPATH", "") monkeypatch.setenv( "PYTHONPATH", f"{repo_root}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(repo_root), ) class TestAgentConfigDriftAcceptance: def test_reproduces_agent_config_drift_incident(self, tmp_path: pathlib.Path) -> None: assert _invoke(tmp_path, ["init", "--domain", "code"]).exit_code == 0 assert _invoke(tmp_path, ["agent-config", "init"]).exit_code == 0 assert _invoke( tmp_path, ["agent-config", "set", "--adapters", "claude,codex"] ).exit_code == 0 assert _invoke(tmp_path, ["agent-config", "sync"]).exit_code == 0 assert _invoke(tmp_path, ["code", "add", "."]).exit_code == 0 assert _invoke(tmp_path, ["commit", "-m", "initial agent config"]).exit_code == 0 # Wire up the exact dogfood hook from #192's plan. (tmp_path / ".musehooks.toml").write_text( '[pre-commit]\ncommands = ["muse agent-config status --fail-if-out-of-sync"]\n', encoding="utf-8", ) assert _invoke(tmp_path, ["code", "add", ".musehooks.toml"]).exit_code == 0 assert _invoke(tmp_path, ["commit", "-m", "add pre-commit hook"]).exit_code == 0 assert _invoke(tmp_path, ["hooks", "install"]).exit_code == 0 # Reproduce the actual incident: edit .museagent.md, do NOT re-sync # the embed adapter (codex/AGENTS.md) — exactly what happened in # ~/ecosystem/musehub this session. agent_md = tmp_path / ".museagent.md" agent_md.write_text(agent_md.read_text() + "\n## New rule\nSomething new.\n") (tmp_path / "unrelated.py").write_text("x = 1\n") assert _invoke(tmp_path, ["code", "add", "unrelated.py"]).exit_code == 0 r = _invoke(tmp_path, ["commit", "-m", "edit agent config without syncing", "--json"]) assert r.exit_code == 1, r.output data = json.loads(r.output) assert "agent-config" in data["failed_command"] assert data["error"] == "pre_commit_hook_failed" # --no-verify still gets it through, and never silently. r2 = _invoke( tmp_path, ["commit", "-m", "edit agent config without syncing", "--no-verify", "--json"], ) assert r2.exit_code == 0, r2.output data2 = json.loads(r2.output) assert any("no-verify" in w.lower() for w in data2["warnings"]) # Prove the fix actually was "re-sync", not just "commit anyway" — # after syncing, the same edit commits clean without --no-verify. (tmp_path / "unrelated2.py").write_text("y = 2\n") assert _invoke(tmp_path, ["code", "add", "unrelated2.py"]).exit_code == 0 assert _invoke(tmp_path, ["agent-config", "sync"]).exit_code == 0 assert _invoke(tmp_path, ["code", "add", "AGENTS.md"]).exit_code == 0 r3 = _invoke(tmp_path, ["commit", "-m", "re-sync then commit", "--json"]) assert r3.exit_code == 0, r3.output