Skip to content

Hooks

Enforcement is a ladder of two rungs, not a switch. Each catches the lie at a different moment, and the two are deliberately unequal in reach and in strictness.

rungreachwhen it firespolicy
git hooksuniversal — every repo doors touches, every harness, no harness at allpre-commit, pre-push, pre-merge-commitdefault (--strict on push, optionally)
harness hooksonly harnesses that have them — today, claudesession start, after every editdefault

The floor is universal and late. The ceiling is narrow and early. Neither replaces the other: the harness hook catches a lying brain before the agent conceives code on top of it, and the git hook catches everyone else — a human typing git commit, a script, an agent in a harness with no hook support.

Both fire while the session that broke the claim is still open — the only window in which the answer still changes what gets written. The ladder stops there, deliberately: a check that runs once everyone has gone home reports the lie to its next reader, with the code already written on top. A merge request pipeline can back it up with verify --strict --range <base>..<head> --branch <name>, which judges what a hook skipped with --no-verify let through; that is a forge setting multivac cannot arm or read from disk (see Code lands in a change).

Which multivac runs

The shim tries three, most specific first, and the first one available wins:

  1. <repo>/dist/cli.js — the multivac built in this repository, and only when that repository’s package.json names multivac
  2. <repo>/node_modules/multivac — the multivac this repository declares
  3. mvac on PATH — whatever the machine has

The name test on the first rung is not caution, it is identity: dist/cli.js plus node_modules describes an enormous share of Node CLI repositories, and without it a multivac hook executed that project’s binary with verify as its argument. What this order chooses is WHICH multivac runs; it says nothing about whether that build is current, and that ceiling is stated rather than implied away.

A repository that builds or declares a multivac has said which one governs it; what is installed globally is whatever that machine happens to have. The order used to be the exact inverse, and the cost was silent: a global install a year behind enforcing an older law table against a repo that pinned something else.

The repository’s own gates still run before any of them, and their exit code still wins. A repository with none of the three is never blocked — the shim says plainly that nothing was verified and exits 0.

Git hooks — the universal floor

init installs them in the brain. doors installs them in the brain and in each declared repo on disk, regardless of which harness targets you declared — except a read-only one, declared managed: false or a shallow clone, which gets nothing:

$ mvac doors
brain: door + hooks updated
brain: .multivac/flow.md — what your declarations oblige, sorted; generated, binds nothing
api: door + hooks updated
payments: notice: not found at ../payments — run `multivac repos sync` to clone it

Three files, pre-commit, pre-push and pre-merge-commit, all this shim. The third runs verify on a local merge, so a merge of a branch that is no open change is judged like a commit on it:

#!/bin/sh
# multivac hook shim — managed by `multivac doors`; regenerate, do not edit.
# Chains the repo's own .git/hooks hook first; its exit code wins.
# Runner order, most specific first: this repo's build, its declared
# dependency, then mvac on PATH. A repo that builds or declares a multivac
# has said which one governs it; PATH is whatever the machine has. No runnable
# multivac never blocks a commit: it warns loudly and exits 0.
case $0 in */*) hookdir=${0%/*} ;; *) hookdir=. ;; esac
root=$(CDPATH= cd -- "$hookdir/../.." && pwd) || exit 0
prev=$(git rev-parse --git-common-dir 2>/dev/null)/hooks/pre-commit
if [ -x "$prev" ]; then
  "$prev" "$@" || exit $?
elif [ -f "$root/.pre-commit-config.yaml" ]; then
  # fresh clone: `pre-commit install` refuses while core.hooksPath is
  # set, so run the config directly — the gate arms in every order.
  if command -v pre-commit >/dev/null 2>&1; then
    pre-commit run --hook-stage pre-commit || exit $?
  else
    echo "multivac: .pre-commit-config.yaml present but pre-commit is not installed — the project's gate did NOT run. Fix: install pre-commit (pipx install pre-commit, or brew install pre-commit)" >&2
  fi
fi
# One line when nothing is off; the full report otherwise. An env var, not a
# flag: a binary that predates it ignores it and prints in full.
export MULTIVAC_QUIET=1
# The build is used only when this repo IS multivac: `dist/cli.js` plus
# node_modules describes most Node CLI repos, and running THEIR binary as
# multivac is the tool executing somebody else's program under its own name.
if [ -f "$root/dist/cli.js" ] && [ -d "$root/node_modules" ] && \
   grep -q '"name"[[:space:]]*:[[:space:]]*"multivac"' "$root/package.json" 2>/dev/null && \
   command -v node >/dev/null 2>&1; then
  exec node "$root/dist/cli.js" verify
fi
if [ -f "$root/node_modules/multivac/package.json" ] && command -v npx >/dev/null 2>&1; then
  exec npx --no-install multivac verify
fi
if command -v mvac >/dev/null 2>&1; then
  exec mvac verify
fi
echo "multivac: hooks INACTIVE — no runnable multivac, nothing was verified. Fix: install multivac (npm i -g multivac), or build it here (pnpm install && pnpm run build)" >&2
exit 0

The export makes the run quiet: a commit with nothing off prints one line, and anything off prints the whole report — see verify --quiet. It is a variable rather than a flag so that a multivac older than the shim, which does not know it, ignores it and prints in full instead of refusing an unknown flag and blocking the commit. A hook you wire by hand, like the chain line below, keeps the full report.

The prev block is the chain: a repo that already had a .git/hooks/pre-commit — a pre-commit framework install, a lefthook install, a hand-written gate — keeps it. The pre-existing hook runs first, and when it fails, its exit code is the hook’s exit code; verify never runs. The chain resolves at run time, so a manager that installs into .git/hooks/ after multivac is picked up without re-running init.

The elif is the other order — the common one. A fresh clone of a repo that uses the pre-commit framework has .pre-commit-config.yaml but no .git/hooks/pre-commit yet, and pre-commit install refuses to write one while core.hooksPath is set. So when the config exists and the hook does not, the shim runs the config directly — pre-commit run --hook-stage pre-commit (pre-push in the push shim, pre-merge-commit in the merge shim) — and its exit code wins, exactly as if the hook were installed. The chain arms in every order. With the config present but no pre-commit binary on PATH, the shim warns loudly on stderr and never blocks — the same posture it takes for a missing multivac runner — and init and doctor both name the state:

hooks      core.hooksPath ok · pre-commit installed · pre-push installed · .pre-commit-config.yaml with no .git/hooks/pre-commit — the shim runs `pre-commit run --hook-stage <stage>` directly (`pre-commit install` refuses while core.hooksPath is set) · active (mvac on PATH)
hooks      core.hooksPath ok · pre-commit installed · pre-push installed · WARNING .pre-commit-config.yaml present, no .git/hooks/pre-commit and no pre-commit binary — the project's gate cannot run → install pre-commit (pipx install pre-commit, or brew install pre-commit) · active (mvac on PATH)

Husky and lefthook do not have this trap: a .husky/ with core.hooksPath still unset means multivac installs alongside and leaves core.hooksPath for husky’s own prepare to claim, so both gates arm in either order; a lefthook.yml chains through .git/hooks/ the moment lefthook install writes there.

hookrunswhen strict_pre_push: true
.multivac/hooks/pre-commitmvac verifyunchanged — commits stay permissive
.multivac/hooks/pre-pushmvac verifymvac verify --strict
.multivac/hooks/pre-merge-commitmvac verifyunchanged — merges stay permissive

Why they live in .multivac/hooks/

Not .git/hooks/. multivac writes the shims into a versioned directory and points git at it:

git config core.hooksPath .multivac/hooks

.git/hooks/ is not cloned, so hooks installed there are a per-machine setup step somebody always forgets. .multivac/hooks/ is committed like any other file: it travels with the clone, and the only per-machine state is the one core.hooksPath line, which doctor checks.

hooks      core.hooksPath ok · pre-commit installed · pre-push installed · active (mvac on PATH)
hooks      core.hooksPath unset → git config core.hooksPath .multivac/hooks · pre-commit installed · pre-push installed · active (node dist/cli.js)
hooks      core.hooksPath ok · pre-commit installed · pre-commit chains .git/hooks/pre-commit (runs first, its exit code wins) · pre-push installed · active (mvac on PATH)

Bare doctor reports every one of these states and exits 0 — including the disarmed ones (core.hooksPath unset, a pre-commit or pre-push shim missing, no runnable multivac). It reads those two shims only; a missing pre-merge-commit shim goes unreported. That is a report, and a human has to read it. doctor --strict turns the floor into an assertion: it exits 1 when the gate is not armed, so a setup step or a session-start hook running mvac doctor --strict fails the moment the floor is down instead of staying quiet while nothing is enforced. See doctor --strict.

A repo that already has hooks

Taking core.hooksPath over a project’s existing gate would silently disarm it — the failure measurement 2 caught on saleor, where the pre-commit framework (ruff, mypy, semgrep) stopped running and nothing said so. So before touching anything, init detects .git/hooks/<name>, a foreign core.hooksPath, .husky/, lefthook.yml and .pre-commit-config.yaml, picks one of three strategies, and says which one it used:

shape foundstrategywhat happens
nothingfreshshims in .multivac/hooks/, core.hooksPath set to it
.git/hooks/<name>, .pre-commit-config.yaml, lefthook.ymlchainedsame shims; each runs the repo’s own .git/hooks hook first, its exit code wins — and a .pre-commit-config.yaml with no hook installed runs via pre-commit run
core.hooksPath set elsewhere, or .husky/ with core.hooksPath unsetalongsidenever repoint — the shim is written INTO that directory where the name is free

core.hooksPath is read the way git reads it, with git config --path: a leading ~ or ~user expands to the home directory first, and what that leaves names the directory outright if it is absolute, otherwise resolving against the root of the working tree, because that is where git stands when it runs a hook. So a repo that spelled its hooks dir /home/you/proj/.githooks gets the shim in /home/you/proj/.githooks; one that spelled it ~/.githooks gets it in $HOME/.githooks, not in a directory named ~ inside the checkout; and one that spelled multivac’s own dir the long way is recognised as already ours rather than treated as a foreign gate.

A linked git worktree inherits its main checkout’s value verbatim through the shared config, and which branch it lands on follows from the spelling it inherited: relative, it resolves against the worktree’s own root, so the worktree has its own .multivac/hooks; absolute, it names the main checkout’s directory, so the worktree installs alongside into that. init writes into the directory it resolved and doctor reads from the same one, so the report is about the directory git will actually run.

Where a foreign hook name is taken and does not run multivac, init refuses that hook and prints the exact line to add:

init: .githooks/pre-commit exists and does not run multivac — NOT touched; append this line to .githooks/pre-commit: mvac verify || exit 1

doctor reports the coexistence state — and never advises repointing a hooksPath the repo owns:

hooks      core.hooksPath is .githooks (this repo's own gate — multivac installs alongside, never repoints) · WARNING .githooks/pre-commit does not run multivac → append: mvac verify || exit 1 · pre-push runs multivac (.githooks/pre-push)

Installed is not enforcing

The shim never blocks a commit for want of a runner. It tries the three runners under Which multivac runs, most specific first, and with none of them it prints one warning to stderr and exits 0.

That is deliberate and it is the difference between a guard people keep and a guard people delete. Enforcement degrades; it does not lock out. The cost is real and it is paid knowingly: a machine with no runner gets no checking at all, and nothing downstream will catch what it let through — which is why doctor names the runner it found, or says INACTIVE, and why doctor --strict exists to make that state fail out loud.

Repo-local counts only when it can actually run: a dist/ with no node_modules beside it is not a runner, because node exits 1 on its first bare import and that exit would block the commit.

Harness hooks — the early ceiling

The claude registry entry is the only one that currently carries a hook config. doors merges these entries into .claude/settings.json, preserving every key and entry it does not own:

eventmatchercommand
SessionStart—mvac verify 2>&1 || true
PostToolUseEdit|Write|MultiEditmvac verify >&2 || exit 2
{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "mvac verify 2>&1 || true" } ] }
    ],
    "PostToolUse": [
      {
        "hooks": [ { "type": "command", "command": "mvac verify >&2 || exit 2" } ],
        "matcher": "Edit|Write|MultiEdit"
      }
    ]
  }
}

SessionStart is the earliest useful moment: the agent is about to read the brain, and this tells it whether the brain is currently true. PostToolUse re-checks after every write, in the checkout of the file written when a brain governs it (read from the harness payload; the command above is unchanged), so a change that breaks a claim surfaces in the same turn that made it, not three files later — including an edit made in a change worktree from a session that sits at the main checkout.

The wrappers are the delivery, not decoration. Claude Code feeds the model only exit-0 stdout at SessionStart and only exit-2 stderr at PostToolUse; every other exit shows stderr to you and gives the model nothing. A bare mvac verify writes its findings to stdout and exits 1 when it gates — so before the wrappers the gate delivered nothing on the one occasion it had something to say. The session command therefore merges stderr into stdout and always exits 0, because findings are the payload there and the contract has no blocking at session start. The post-edit command sends everything to stderr and turns every failure into exit 2 — a red law, a config the edit itself just broke, a binary that has gone — because after an agent’s edit each of those is the agent’s to answer. The edit is already on disk: the block is a forced read in the same turn, not a revert.

After an edit the command sends the binary’s full report to stderr, but a green run exits 0, so the model is handed none of it; at session start the run is quiet and a green one is one line — summary, header, reads, enact. A gate that speaks at length when it has nothing to say teaches the reader to stop reading it.

The commands carry no switch. Claude Code hands every hook the event and, after an edit, the file written, as JSON on stdin, and sets CLAUDE_PROJECT_DIR only in hook processes. verify reads that payload only when the variable is set and no directory was named: the session event makes the run quiet and starts it at the session’s directory (the payload’s cwd, because a hook the harness forwards starts in your home directory); the edit event roots the run at the edited file when a consumer, a door or a brain that is its own repository governs it, and otherwise at the session’s directory. A brain committed inside another repository — a test fixture, often red on purpose — is never followed into. Putting the switch in the command instead would quote it into every red delivery and, under an older doors, add a second gate beside the first. That the harness delivers the quiet line and follows a worktree edit was read from its own binary with a simulated payload; a live session is the confirmation.

The merge is idempotent — re-running doors does not duplicate entries — and defensive. A settings file that is not valid JSON is left alone and reported:

brain: notice: .claude/settings.json is not valid JSON — fix it, then rerun `multivac doors`

What “preserving” means here

The merge owns only the hook it wrote, and the unit of ownership is the individual command, not the entry around it. An entry is your grouping — your matcher, your list of commands — so:

  • Identity is exact. mvac verify is multivac’s; mvac verify --strict is yours and is never claimed.
  • An update rewrites one command in place, and fills in the type multivac itself writes if the hook was typed by hand without it — a hook missing type never runs. Commands you added beside it stay, in order, and fields multivac does not write — a timeout, say — stay with them.
  • A matcher is written once, when multivac creates its own entry, and is never rewritten afterwards. The matcher on an entry is yours.
  • A post-edit hook an earlier release wrote to refresh a code graph is removed, whatever the config declares: multivac keeps no code graph. It is recognised by the lock preamble multivac generated, which nothing else writes, so a refresh command you typed yourself is never multivac’s. Only that command goes, not the entry: an entry you share with it survives, carrying your commands, and only an entry it leaves empty is dropped. doors says so once per settings file.

Owning a command is not the same as covering an event. If the only mvac verify in PostToolUse sits in an entry of yours on another matcher — Bash, say — the gate is not on the edit tools at all, and multivac will not rewrite your matcher to get there. It adds its own entry beside yours and says so:

brain: notice: .claude/settings.json: hooks.PostToolUse already runs `mvac verify >&2 || exit 2`, but not on matcher `Edit|Write|MultiEdit` — the gate has to cover what it gates, so multivac added its own entry beside yours rather than rewrite a matcher it does not own. Delete whichever you do not want by hand.

Adding is reversible and rewriting your matcher is not, so that is the way it goes — but the part that is not negotiable is the sentence: an edit gate that quietly ends up wired to nothing is the failure this rule exists to prevent.

Earlier versions matched on a substring of the command and then replaced the whole entry, which could eat a hand-written hook and leave a second copy of multivac’s own behind. doors reports that leftover rather than fixing it, because the survivor is byte-identical to what multivac writes and only you know which one you meant to keep:

brain: notice: .claude/settings.json: hooks.PostToolUse runs `mvac verify` 2 times — verify fires once per copy. Delete the entries you do not want by hand; multivac removes no hook entry it did not write, because doing that silently is the defect this notice reports.

What blocks and what informs

Every rung runs the same verify, so the policy is the same everywhere. For a leg, the anchor mode that broke decides whether the exit code gates; the table below is that rule.

leg outcomedefault (hooks)--strict (the push, with strict_pre_push)
broken/vacuous absent — a tombstoneblocksblocks
broken/vacuous countblocksblocks
broken/vacuous each / each! — a universalblocksblocks
broken/vacuous present / uniqueinforms, exit 0blocks
moved — self-healed renameinforms, exit 0informs, exit 0
unevaluated — repo not on diskinforms, exit 0informs, exit 0
a leg of a proposed rowinforms, exit 0informs, exit 0
a leg of a drift row — a recorded findinginforms, exit 0informs, exit 0
a claim an open change declares — pendinginforms, exit 0informs, exit 0
anchor parse error, on a proposed row tooblocksblocks

The blocking set is the blocking: key, default [absent, count, each]. You can widen it; you cannot drop absent. See Configuration.

In a brain checkout, some lines gate whatever the anchor mode, in a default run: an enact line that says REFUSED, the law file removed or a row that was law deleted rather than retired, a stale pin under staleness: block, a change to the config made without an open change, and, where the brain is itself a declared repo that an SDD governs with sdd_auto on, code outside the branch of an open change that declares the repo. --strict adds an open change that is finished and not closed. A consumer repo that reads a mounted brain can carry two such lines, code outside an open change’s branch (on the same condition) and the mounted brain’s SDD refusal. Both gate only under --strict; a default run prints them, except for code on the branch of an open change that does not declare the repo, which gates in any run. A line that gates says blocking in its own text — the config line says instead that it is modified and no change is open — and the exit code reads the same fact.

The asymmetry is the whole design. A mid-refactor commit that moved a file should not die on a presence check — that is noise, and a guard that produces noise gets disabled within a week. Calling a mechanism that was deleted should die immediately — that is damage, and it is exactly what nobody notices by hand.

Recommended ladder

# .multivac/config.yml
blocking: [absent, count, each]   # the default: tombstones and universals gate, renames do not
strict_pre_push: true             # the push gates on present/unique too, the commit stays permissive

A commit is cheap to amend, so it should not die on a presence check the next edit will fix anyway. A push is the last hop out of the machine, and that is the one worth holding to the harder bar.

For a run that must not write — a read-only checkout, a pass over a branch you are only reviewing — add --check:

mvac verify --strict --check

--check reports a moved leg instead of rewriting the glob. The rewrite belongs in a working tree where a human can read the diff, not somewhere with nowhere to commit it.

Nothing here commits

doors and init write working trees. They never git add, never commit, never push, and never clone. Whatever they changed is a diff you review, in the brain and in every consumer repo they reached.