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.
Mirrored from staging#192.