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.
| rung | reach | when it fires | policy |
|---|---|---|---|
| git hooks | universal — every repo doors touches, every harness, no harness at all | pre-commit, pre-push, pre-merge-commit | default (--strict on push, optionally) |
| harness hooks | only harnesses that have them — today, claude | session start, after every edit | default |
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:
<repo>/dist/cli.js— the multivac built in this repository, and only when that repository’spackage.jsonnames multivac<repo>/node_modules/multivac— the multivac this repository declaresmvacon 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 itThree 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 0The 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.
| hook | runs | when strict_pre_push: true |
|---|---|---|
.multivac/hooks/pre-commit | mvac verify | unchanged — commits stay permissive |
.multivac/hooks/pre-push | mvac verify | mvac verify --strict |
.multivac/hooks/pre-merge-commit | mvac verify | unchanged — 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 found | strategy | what happens |
|---|---|---|
| nothing | fresh | shims in .multivac/hooks/, core.hooksPath set to it |
.git/hooks/<name>, .pre-commit-config.yaml, lefthook.yml | chained | same 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 unset | alongside | never 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 1doctor 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:
| event | matcher | command |
|---|---|---|
SessionStart | — | mvac verify 2>&1 || true |
PostToolUse | Edit|Write|MultiEdit | mvac 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 verifyis multivac’s;mvac verify --strictis yours and is never claimed. - An update rewrites one command in place, and fills in the
typemultivac itself writes if the hook was typed by hand without it — a hook missingtypenever runs. Commands you added beside it stay, in order, and fields multivac does not write — atimeout, 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.
doorssays 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 outcome | default (hooks) | --strict (the push, with strict_pre_push) |
|---|---|---|
broken/vacuous absent — a tombstone | blocks | blocks |
broken/vacuous count | blocks | blocks |
broken/vacuous each / each! — a universal | blocks | blocks |
broken/vacuous present / unique | informs, exit 0 | blocks |
moved — self-healed rename | informs, exit 0 | informs, exit 0 |
unevaluated — repo not on disk | informs, exit 0 | informs, exit 0 |
a leg of a proposed row | informs, exit 0 | informs, exit 0 |
a leg of a drift row — a recorded finding | informs, exit 0 | informs, exit 0 |
a claim an open change declares — pending | informs, exit 0 | informs, exit 0 |
anchor parse error, on a proposed row too | blocks | blocks |
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 permissiveA 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.