gabriel / musehub public
Closed #192 Enhancement
filed by gabriel human · 34 days ago

New: muse hooks — local pre-commit gate mechanism (MVP)

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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):

  1. 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.
  2. 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.md and ~/ecosystem/musehub/.museagent.md). In ~/ecosystem/musehub, the drift meant CLAUDE.md's own @include pointed 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 --json would 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.

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.toml parser + validator in muse/core/hooks.py — test HK_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) — test HK_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) — test HK_10
  • muse hooks uninstall — clears the local marker — test HK_11
  • muse hooks status — reports both the tracked config and whether this clone has hooks installed, three distinct states (not_defined, defined_not_installed, installed) — test HK_12
  • Idempotency: installing twice, uninstalling when never installed — both no-ops, not errors — test HK_13

Phase 3 — muse commit integration

Deliverables:

  • muse commit runs all pre-commit commands (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 when muse hooks status reports installed — test HK_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 — test HK_22
  • defined_not_installed state: commit proceeds normally (no silent enforcement of an un-activated gate) but muse commit's JSON output includes a warnings entry noting hooks are defined but not installed — test HK_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 clone prints one line to stderr when the cloned repo has a tracked .musehooks.toml and 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. — test HK_30
  • Same notice from muse status for repos that were cloned before this feature existed, or where install was skipped — test HK_31

Phase 5 — Dogfooding: close the actual incident

Deliverables:

  • muse agent-config status gains --fail-if-out-of-sync (exit 1 if any configured adapter reports in_sync: false; exit 0 otherwise, including when no adapters are configured) — test HK_40
  • Add .musehooks.toml to ~/ecosystem (workspace root) and ~/ecosystem/musehub with:
    [pre-commit]
    commands = ["muse agent-config status --fail-if-out-of-sync"]
    
  • muse hooks install run 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 run sync, attempt muse commit with hooks installed → commit is refused, message names muse agent-config status as the failing check. Then muse commit --no-verify on 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 real muse binary — no mocks, matching this workspace's existing tests/test_backup.py/test_sandbox.py pattern from #185).
  • HK_ACCEPT_01 demonstrates the mechanism would have caught today's real incident.
  • No hook ever executes without an explicit, prior muse hooks install in that specific clone.
  • --no-verify always produces visible output — no silent bypass path.

Out of Scope (this ticket)

  • Additional hook points (post-commit, pre-push, post-checkout, etc.) — pre-commit alone 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.
◎ Activity5
●
gabriel opened this issue 34 days ago
○
gabriel 34 days ago

Phase 1 — done ✅

Landed in the muse repo (commit 4802c028, 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) and tests/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 via muse-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).

○
gabriel 34 days ago

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 status CLI 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.

○
gabriel 34 days ago

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 commit now runs installed pre-commit hooks before any object is written. New --no-verify flag (never silent — always adds a warnings entry). defined_not_installed warns 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).

○
gabriel 34 days ago

Phase 4 — done ✅

Landed in muse (commit b5a46a99, pushed to local/staging/production dev):

  • muse/core/hooks.py::install_notice() — shared one-line nudge, None unless state is defined_not_installed.
  • muse clone prints it right after working-tree restore (a fresh clone can never have hooks installed yet, so this is the earliest possible moment).
  • muse status prints it unconditionally before the JSON/text split — fires identically for muse status and muse 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.

○
gabriel 34 days ago

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.
✓
closed this issue 34 days ago