New: muse hooks — local pre-commit gate mechanism (MVP)
New: muse hooks — local pre-commit gate mechanism (MVP)
Background
Muse has no commit-lifecycle hook mechanism at all today. Confirmed by reading
the source directly: muse commit --help's own documented algorithm has no
hook step, there is no .muse/hooks/ concept, no --no-verify-equivalent
flag, and a full-codebase search for hook only turns up muse hub webhook
(server-side, HTTP, for external CI notifications) and the unrelated
git-bridge export hooks (core/bridge/hooks.py). Nothing lets a repo declare
"run this check before every commit, and refuse the commit if it fails."
This gap surfaced concretely, twice, in the same session (2026-09-10/11):
- musehub#182 — a stale, un-guarded proposal merge silently reverted 47 files. Root-caused and fixed, but the class of failure — a real invariant nobody automatically checks — is the same shape as #2 below.
- This session's own docs drift —
.museagent.md(the canonical, trackable agent-config source) had drifted from its compiled adapter outputs (CLAUDE.md,AGENTS.md) in two separate repos (~/ecosystem/.museagent.mdand~/ecosystem/musehub/.museagent.md). In~/ecosystem/musehub, the drift meantCLAUDE.md's own@includepointed at a path (.muse/agent.md) that can never be valid —.muse/is unconditionally excluded from every snapshot (muse/core/paths.py), so a file there can never be committed, pushed, or survive a clone. That means every Claude Code session rooted at~/ecosystem/musehub, including this one, was silently getting zero content from its own project rules file — HARD RULEs on data loss included — until caught by hand today.muse agent-config status --jsonwould have caught this instantly; nothing ever ran it automatically.
Both incidents are the same underlying pattern: a real, checkable invariant
existed, a command already existed to check it, and nothing forced that
command to run at the moment it mattered. The workspace's own philosophy
(see musehub#185 — "five ambiguous muse resolutions were found and pruned
… belt-and-suspenders even though X never touches canonical") already treats
this class of problem as worth building real guardrails for, not just
documenting a convention and hoping agents remember. This is a natural
continuation of that same philosophy applied to local commit hygiene.
Worth implementing: yes. The cost of the MVP below is small (one new
tracked config file format, three new subcommands, one integration point in
muse commit), and it closes a gap that has already caused real, live
drift twice.
Goal
A repo can declare pre-commit checks in a tracked, shareable file (not
.muse/config.toml — that lives inside the unconditionally-excluded .muse/
directory and, per a related finding below, can never propagate via
clone/push/pull either). A developer or agent explicitly activates those
checks once per clone. Once active, muse commit runs them and refuses the
commit on failure, with an explicit, visible, logged bypass — mirroring the
--no-verify concept this workspace's own .museagent.md already assumes
exists ("Never skip hooks (--no-verify) … unless explicitly asked").
Explicitly not a general git-hooks clone: no arbitrary shell scripts, no
auto-execution on clone (a cloned repo that silently runs code at clone time
is a supply-chain risk — git never does this either, hooks are always an
opt-in local install step). MVP is a declarative list of commands for exactly
one hook point: pre-commit. Other hook points and remote/server-side
enforcement are explicitly out of scope (see below) — this ticket is about
proving the mechanism end-to-end on the one hook point that would have
caught both incidents above.
Related finding (context, not a phase of this ticket)
muse agent-config set --adapters ... (without --global) writes to
<repo>/.muse/config.toml — which, like .muse/agent.md, can never be
committed or shared via clone. Per-repo adapter selection is therefore
silently machine-local only today, the same class of bug as the incident
above, just in a different command. Worth its own follow-up ticket once this
one lands (the fix is likely the same shape: move repo-level config that's
meant to be shared out of .muse/ and into a tracked file).
Phases
Each phase must be fully green (tests passing, verified against a real scratch repo — not mocks, per this workspace's own testing-tier rules) before the next begins.
Phase 1 — Hook definition format
Add a new tracked file convention: .musehooks.toml at repo root (same
naming family as .museignore/.museattributes/.museagent.md — discoverable,
consistent, and crucially not inside .muse/).
[pre-commit]
commands = [
"muse agent-config status --json",
]
Each command's exit code determines pass/fail (fully declarative — no shell scripts, no hashbang files, keeps the MVP's attack surface small).
Deliverables:
.musehooks.tomlparser + validator inmuse/core/hooks.py— testHK_01- Malformed/missing-file handling: absent file → no hooks defined (not an
error); malformed TOML → clear parse error, never a silent no-op — test
HK_02 muse hooks list— prints configured hook points and commands from the tracked file (read-only, no local state) — testHK_03
Phase 2 — Local activation (muse hooks install)
Hooks are never auto-executed on clone or checkout. A human or agent must
explicitly run muse hooks install once per clone to activate them locally.
Installed state itself is legitimately local-machine state (whether this
clone runs the hooks) — that's the one place .muse/ is the correct home,
this is not a repeat of the earlier mistake since installed-state was never
meant to be shared.
Deliverables:
muse hooks install— reads.musehooks.toml, writes an "active" marker to.muse/hooks-installed.toml(local only) — testHK_10muse hooks uninstall— clears the local marker — testHK_11muse hooks status— reports both the tracked config and whether this clone has hooks installed, three distinct states (not_defined,defined_not_installed,installed) — testHK_12- Idempotency: installing twice, uninstalling when never installed — both
no-ops, not errors — test
HK_13
Phase 3 — muse commit integration
Deliverables:
muse commitruns allpre-commitcommands (in order, stopping at the first failure) after step 4 (nothing-to-commit check) and before step 5 (snapshot/commit_id computation) of its documented algorithm, only whenmuse hooks statusreportsinstalled— testHK_20- On failure: commit aborted before any object is written, exit code 1,
JSON envelope names which command failed and its stderr — test
HK_21 muse commit --no-verify: explicit bypass, still logged (stdout/stderr notes hooks were skipped and why) — never silent — testHK_22defined_not_installedstate: commit proceeds normally (no silent enforcement of an un-activated gate) butmuse commit's JSON output includes awarningsentry noting hooks are defined but not installed — testHK_23(this is the discoverability nudge for phase 4's scenario, proven at the point that actually matters: the moment someone commits)
Phase 4 — Discoverability at clone time
Deliverables:
muse cloneprints one line to stderr when the cloned repo has a tracked.musehooks.tomland hooks are (necessarily, since this is a fresh clone) not yet installed:This repo defines pre-commit hooks — run 'muse hooks install' to enable them.— testHK_30- Same notice from
muse statusfor repos that were cloned before this feature existed, or where install was skipped — testHK_31
Phase 5 — Dogfooding: close the actual incident
Deliverables:
muse agent-config statusgains--fail-if-out-of-sync(exit 1 if any configured adapter reportsin_sync: false; exit 0 otherwise, including when no adapters are configured) — testHK_40- Add
.musehooks.tomlto~/ecosystem(workspace root) and~/ecosystem/musehubwith:[pre-commit] commands = ["muse agent-config status --fail-if-out-of-sync"] muse hooks installrun in both repos as part of landing this ticket- Acceptance test (
HK_ACCEPT_01): reproduces this session's exact incident end-to-end against a real scratch repo — edit.museagent.md, do not runsync, attemptmuse commitwith hooks installed → commit is refused, message namesmuse agent-config statusas the failing check. Thenmuse commit --no-verifyon the same dirty state → commit succeeds, output notes the bypass.
Acceptance Criteria
- All
HK_*tests pass against a real scratch repo (subprocess calls to the realmusebinary — no mocks, matching this workspace's existingtests/test_backup.py/test_sandbox.pypattern from #185). HK_ACCEPT_01demonstrates the mechanism would have caught today's real incident.- No hook ever executes without an explicit, prior
muse hooks installin that specific clone. --no-verifyalways produces visible output — no silent bypass path.
Out of Scope (this ticket)
- Additional hook points (
post-commit,pre-push,post-checkout, etc.) —pre-commitalone is sufficient to prove the mechanism and close the incident that motivated it. Follow-up ticket once this lands. - Server-side / CI enforcement (e.g., musehub rejecting a push whose HEAD commit would have failed a hook) — local-only for this MVP.
- Arbitrary shell scripts or hashbang hook files (git's model) — deliberately excluded to keep the attack surface to a declarative command list.
- Fixing
muse agent-config set(non---global) writing to unshareable.muse/config.toml— noted above as a related but separate bug.
Phase 2 — done ✅
Landed in muse (commit d4df453a, pushed to local/staging/production dev):
muse/core/paths.py::hooks_installed_toml_path()→.muse/hooks-installed.toml— the one deliberate exception to "never put shareable state in.muse/": whether this clone has installed hooks is correctly local-only.muse/core/hooks.py:HooksStatus,install_hooks(),uninstall_hooks(),is_installed(),get_status()— three-state model (not_defined/defined_not_installed/installed), both install/uninstall idempotent.muse hooks install/muse hooks uninstall/muse hooks statusCLI subcommands.
TDD: tests/test_core_hooks_install.py (13 tests, HK_10-HK_13) and tests/test_cmd_hooks_install.py (10 tests) written and confirmed red first. 43/43 total across all hooks tests now green, tests/test_app.py (27 tests) still green. Manually smoke-tested install→status→uninstall→status against a real disposable repo via muse-dev.
Note: hooks still don't run yet — install/status only manage local activation state. That's Phase 3 (muse commit integration), next up.
Phase 3 — done ✅
Landed in muse (commit 5f7b3876, pushed to local/staging/production dev). This is the phase that actually closes the gap — nothing ran automatically until now.
muse/core/hooks.py::run_hook_point()— fail-fast execution of a hook point's declared commands, no-op when not installed or nothing declared.muse commitnow runs installedpre-commithooks before any object is written. New--no-verifyflag (never silent — always adds awarningsentry).defined_not_installedwarns but doesn't block. A failing hook aborts with exit 1, naming the exact command + captured output, before any write.- Dry-run deliberately never executes hook commands (a preview shouldn't have side effects from arbitrary shell commands).
TDD: tests/test_core_hooks_run.py (8 tests) + tests/test_cmd_commit_hooks.py (12 tests, HK_20-HK_23) written and confirmed red first. 90/90 across all hooks test files + tests/test_app.py. Full regression run of tests/test_cmd_commit.py + 5 related commit test files (151 tests) still green. Manually smoke-tested against a real disposable repo via muse-dev: failing hook blocks the commit, --no-verify gets it through visibly.
HK_ACCEPT_01 (the full reproduction of this session's real .museagent.md drift incident) is deferred to Phase 5, since it needs muse agent-config status --fail-if-out-of-sync, which doesn't exist yet.
Moving on to Phase 4 (discoverability at clone time).
Phase 4 — done ✅
Landed in muse (commit b5a46a99, pushed to local/staging/production dev):
muse/core/hooks.py::install_notice()— shared one-line nudge,Noneunless state isdefined_not_installed.muse cloneprints it right after working-tree restore (a fresh clone can never have hooks installed yet, so this is the earliest possible moment).muse statusprints it unconditionally before the JSON/text split — fires identically formuse statusandmuse status --json(stdout stays clean either way, notice goes to stderr).
TDD: tests/test_hooks_clone_status_notice.py (9 tests, HK_30/HK_31) written and confirmed red first — the clone test follows this repo's existing clone-test mocking convention (patches _restore_working_tree to write a real .musehooks.toml, rather than mocking the notice logic itself). Full regression: tests/test_cmd_clone_hardening.py + 3 related clone files (59 tests) and 7 status test files (174 tests) all still green. 99/99 across all hooks tests + tests/test_app.py. Manually smoke-tested via muse-dev status before/after install.
Moving on to Phase 5 — the dogfood: --fail-if-out-of-sync on agent-config status, wiring .musehooks.toml into this very workspace and musehub, and the acceptance test that reproduces this session's real incident end-to-end.
Phase 5 — done ✅. All 5 phases shipped and live.
--fail-if-out-of-sync flag (commit 4407cfca): muse agent-config status --fail-if-out-of-sync exits 1 only when an adapter that actually exists on disk is out of sync — never fails on missing/unconfigured adapters (checks drift, not completeness). tests/test_cmd_agent_config.py::TestFailIfOutOfSync (6 tests, HK_40).
HK_ACCEPT_01 (same commit): full end-to-end reproduction of this session's real incident against a real disposable repo — init → agent-config init/set/sync → commit → wire the dogfood hook → install → edit .museagent.md without re-syncing an embed adapter → commit blocked, naming the exact failing command → --no-verify still gets it through, visibly → re-syncing (not bypassing) is what actually fixes it. Had to add a PATH shim + PYTHONPATH so the hook exercises this checkout's source rather than the separately-installed stable muse binary — which doesn't have the new flag. That's not a test artifact, it's the exact same muse-vs-muse-dev distinction this ticket's own investigation surfaced, showing up again at the finish line.
Real deployment (not just tests): since the stable installed muse was still at 0.2.1rc1 — several versions behind everything built this session — I bumped to v0.2.1-rc3, published a real tarball to staging (20/20 smoke test), and reinstalled the local stable muse binary from it (confirmed muse hooks --help and --fail-if-out-of-sync now work on the real CLI, not just muse-dev). Then wired .musehooks.toml + muse hooks install into both ~/ecosystem (workspace root) and ~/ecosystem/musehub for real, confirming out_of_sync_count=0 in both before activating (hooks start clean, don't retroactively block pre-existing drift).
Live-verified the hook actually catches the real incident: reproduced the exact original bug (corrupting CLAUDE.md's include path back to the dead .muse/agent.md) in musehub for real — the hook blocked the commit, naming muse agent-config status --fail-if-out-of-sync as the failing check. Restored correct content immediately after. (Also learned along the way: a plain .museagent.md edit alone doesn't trigger drift in musehub specifically, since musehub only tracks the claude include-style adapter — the hook catches a hand-edited/stale adapter file, which is exactly what happened originally, not a .museagent.md-content edit with no embed adapter present to drift.)
Full regression across everything landed this session: 244/244 (agent-config + envelope + all hooks test files + tests/test_app.py), plus the acceptance test and TestFailIfOutOfSync — all green, no regressions anywhere touched (commit, clone, status, agent-config).
Closing
All 5 phases complete: format (Phase 1), local activation (Phase 2), commit integration (Phase 3), discoverability (Phase 4), dogfood + acceptance (Phase 5). The mechanism is live in both ~/ecosystem and ~/ecosystem/musehub today, running against the real installed muse 0.2.1rc3.
Follow-ups intentionally left open (see "Out of Scope" in the original plan, plus one new finding):
- Additional hook points (
post-commit,pre-push, etc.) and server-side/CI enforcement — separate future ticket. muse agent-config set(without--global) writes to unshareable.muse/config.toml— same class of bug as this ticket's original trigger, in a different command. Worth its own ticket.
Phase 1 — done ✅
Landed in the
muserepo (commit4802c028, pushed to local/staging/production dev):muse/core/hooks.py—HooksFile+load_hooks(), following.museattributes's established conventions (1 MiB cap, explicit isinstance checks, missing file = valid empty state).muse/cli/commands/hooks.py—muse hooks list(read-only, JSON + text).VALID_HOOK_POINTS = {"pre-commit"}— MVP scope enforced at the parser level, not just documented.TDD: wrote
tests/test_core_hooks.py(13 tests,HK_01/HK_02) andtests/test_cmd_hooks.py(7 tests,HK_03) first, confirmed red (ModuleNotFoundError, then command-not-found), then implemented until green. Also manually smoke-tested against a real disposable repo viamuse-dev(not canonical — proper sandbox hygiene).tests/test_app.py(27 tests, CLI registration coverage) still green — no regressions.Moving on to Phase 2 (
muse hooks install/uninstall/status— local activation state).