These are Claude Code PreToolUse hooks. They refuse a tool call before it runs. The hooks refuse
textual queries over structured artifacts, composed shell commands, gate bypasses, shell that
shellcheck flags, and Python edits that ruff or mypy flags. Each hook reads the harness's JSON
payload on stdin and answers with a permissionDecision on stdout.
mikemol-hook-structural-query(Bash). It refusesgrep,catand similar over a file kind that has a structural reader. The routing comes from the edited repo'sstruct-toolsskill.mikemol-hook-no-chaining(Bash). It refuses&&,;,|andfor. It parses withshlex, so a quoted|stays inside its token.mikemol-hook-no-verify(Bash). It refuses--no-verify,-n,core.hooksPath=and the other measured ways around the pre-commit gate.mikemol-hook-shellcheck(Bash, Edit and Write). It refuses shell that shellcheck flags.mikemol-hook-pycheck(Edit and Write). It refuses a.pyedit when ruff or mypy flags the result.mikemol-shellcheckis the same linter run by hand. It has nomikemol-hook-prefix because it is not a hook.
Here are two real runs of the built entries against crafted payloads. chain.json carries
"tool_input": {"command": "ls -l | tail -3"} and noverify.json carries
git commit -n -m wip. The bazel-built entries have no shebang, so the command runs them through
the venv's python3, the same way the launcher does:
$ NOCHAIN_HOOK_BLOCK=1 .venv/bin/python3 .venv/bin/mikemol-hook-no-chaining < chain.json
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": "no-chaining: this command COMPOSES where one tool call belongs.\n
`|` a pipe — the tool should have the mode that produces this directly\n ..."}}
$ NOVERIFY_HOOK_BLOCK=1 .venv/bin/python3 .venv/bin/mikemol-hook-no-verify < noverify.json
{"hookSpecificOutput": {..., "permissionDecision": "deny", "permissionDecisionReason":
"no-verify: this command BYPASSES the pre-commit gate.\n ⚑ `git commit -n` skips the gate ..."}}When a command is allowed, the hook prints nothing. The same hook given a plain ls -l printed
nothing and exited 0.
Each hook reads a switch that you set inline in its command line. The switches are
STRUCT_HOOK_BLOCK, NOCHAIN_HOOK_BLOCK, NOVERIFY_HOOK_BLOCK, SHELLCHECK_HOOK_BLOCK and
PYCHECK_HOOK_BLOCK. This is how this repository wires one of them (.claude/settings.json):
{"type": "command",
"command": "NOCHAIN_HOOK_BLOCK=1 \"$CLAUDE_PROJECT_DIR/hooks/bin/mikemol-hook-no-chaining\""}Put the switch on the command line so it cannot drift from the hook it arms. no_chaining treats
NOCHAIN_HOOK_BLOCK=0 as a stand-down. When the switch is unset, the shared STRUCT_HOOK_BLOCK
governs (no_chaining.py).
mikemol-hook-inbound-asks is the one hook here that is not a PreToolUse gate. It tells a session
about the peer asks that wait on its repo, without a peer's nudge. It runs
mikemol-paths-forward --state <project>/.claude/paths-forward.json --inbound and, when that
prints rows, hands them to the model as additionalContext:
{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext":
"inbound: peer ask(s) wait on this repo with no claiming waypoint:\nUNCLAIMED peer:W3 :: ...\n
to claim a row, run its --add command; to decline, send the peer a letter ..."}}- It never refuses and has no Stop behaviour. A Stop block on an unanswered row would trap a session that cannot satisfy it: the ask may not be theirs, and declining is a letter, not a queue state a hook can read. It always exits 0.
- Events.
SessionStartandUserPromptSubmitare served. Any other event, and a payload that is not JSON, produces no output. - Silent cases. The project has no
.claude/paths-forward.json, or the reader printed nothing. - The reader.
<project>/.venv/bin/mikemol-paths-forwardfirst, thenPATH. If neither exists, it exits non-zero, or it runs longer than 20 s, the hook says so in the context text and on stderr and does not claim there are no asks. The argv is constants plus the state path as one word, never through a shell. - No flood. One digest file per session,
<tmp>/mikemol-inbound-asks/<session_id>.SessionStartalways emits what it finds.UserPromptSubmitemits only when the text differs from the last emission, so a failure notice is also said once per session. The cost is one reader run per prompt, bounded by the timeout, and one small file write per change. - The project is
CLAUDE_PROJECT_DIR, else the working directory.
To arm it, a repo owner adds the following to the repo's .claude/settings.json. No arming variable
is needed, because the hook has no deny mode for a switch to select; a *_HOOK_BLOCK=1 prefix
would be decoration. The command is the installed console script, so it fails soft: a repo whose
venv lacks it gets a missing-command error in the transcript and no context.
"SessionStart": [
{"hooks": [{"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.venv/bin/mikemol-hook-inbound-asks\""}]}
],
"UserPromptSubmit": [
{"hooks": [{"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.venv/bin/mikemol-hook-inbound-asks\""}]}
]mikemol-hook-inbound-asks --check asks whether a project has everything the hook needs. It takes no
stdin payload. Run it from the project (the project is CLAUDE_PROJECT_DIR, else the working
directory):
$ .venv/bin/mikemol-hook-inbound-asks --checkThe hooks/bin/mikemol-hook-inbound-asks launcher forwards its arguments ("$@"; it did not until
W635), so in mtools itself the check runs through it:
CLAUDE_PROJECT_DIR=$PWD hooks/bin/mikemol-hook-inbound-asks --check.
It prints three lines, one per piece, each starting OK <piece>: or MISSING <piece>::
reader:mikemol-paths-forwardis in<project>/.venv/binor onPATH, and its--helpnames--inbound. MISSING means either there is no reader (installmikemol-pathsforward, as adopting-a-hook.md says), or the reader is too old, or its--helpprobe timed out or could not start (move the pin to a newer sha).queue:<project>/.claude/paths-forward.jsonexists. MISSING means the repo has no queue, so the hook would stay silent; create one with the reader.settings:<project>/.claude/settings.jsonnamesmikemol-hook-inbound-asksunder bothSessionStartandUserPromptSubmit. MISSING says why: the file could not be read, is not valid JSON, or lacks the hook for the named event or events. Add the blocks shown above.
The exit status is 0 only when all three are OK, 1 otherwise, and 2 for any other argument. The
only thing it runs is one bounded <reader> --help probe (20 s, no shell). It never denies and
writes nothing.
A green --check shows the pieces are present. It does not show that the model sees the context:
the envelope is confirmed live only at UserPromptSubmit.
mikemol-hook-nemik-check is the sibling of the inbound-asks hook. It runs the fleet reader
nemik-check --root <project parent> and hands the model only this project's rows (the repo is the
basename of CLAUDE_PROJECT_DIR) as additionalContext: the repo's OK/VIOLATES header line and
the indented Warning/VIOLATES rows beneath it, quoted verbatim, with one line saying these are
nemik's shapes, to fix them (the remedy is in each row), and that a Warning is not cosmetic.
- It never refuses and has no Stop behaviour. It always exits 0, like inbound-asks.
- Events.
SessionStartandUserPromptSubmit; any other event, and a payload that is not JSON, produces no output. - Silent cases. This repo's block has no indented rows, or the repo is absent from the output.
- The reader. The executable named by
NEMIK_CHECK, elsenemik-checkonPATH, else<project parent>/nemik/.venv/bin/nemik-check. If none exists, it times out (60 s), or it exits non-zero with no block for this repo, the hook says COULD NOT RUN in the context and on stderr and does not claim the queue is clean. A non-zero exit that still prints this repo's block is read as output. The argv is constants plus the root as one word, never through a shell. - No flood. One digest file per session,
<tmp>/mikemol-nemik-check/<session_id>, with the same rule as inbound-asks. - Cost. The reader took 12.3 s wall over 19 repos when measured, so
UserPromptSubmitskips it (silent, no spawn) when the project's.claude/paths-forward.jsonhas the same sha256 as at the last successful run, kept in<session_id>.statebeside the emission digest.SessionStartalways runs it. A missing or unreadable queue file, no session id, an unwritable temp directory or a failed last run means the reader runs; the skip is never taken on doubt.
To arm it, add the same two blocks as for inbound-asks to the repo's .claude/settings.json, naming
mikemol-hook-nemik-check. No arming variable is needed, because the hook has no deny mode.
mikemol-githook-pre-push, mikemol-githook-post-commit and
mikemol-githook-prepare-commit-msg are run by git, not by the harness.
They have no mikemol-hook- prefix, so no settings.json wiring is needed. A repo installs each as
a one-line stub, for example .githooks/post-commit: exec <venv>/bin/mikemol-githook-post-commit.
mikemol-githook-post-commit is the shared form of substrate's post-commit. It prints an advisory
and amends the commit just made so its message carries post-commit advisory (auto-captured) and
the advisory beneath it. That text is the marker that substrate's pre-push.local checks, and
el-openglo's own post-commit writes the same one.
- The repo's advisory comes from
<toplevel>/.githooks/post-commit.local, when it is executable. Its stdout is the advisory body, its stderr passes through, and a non-zero exit is recorded in the advisory. Without one, the advisory is the header line alone and the marker is still written. - Skipped, with the advisory still printed: while a rebase, merge, cherry-pick or revert is in
flight, and when the message already carries the marker. The amend sets
_POST_COMMIT_AMENDING, so the hook it re-fires does nothing. - A failure is reported. The shell version ended its amend in
|| true. Here a failed amend, a missing git and a run outside a repository each write a reason to stderr and exit 1. Git ignores a post-commit status, so no commit is harmed. - It never pushes. Auto-push is repo policy and belongs in the repo's own stub, after the
mikemol-githook-post-commitcall returns.
mikemol-githook-prepare-commit-msg is the shared form of substrate's prepare-commit-msg. Its
argv is git's own, <message-file> [<source>], so the stub must pass it on:
exec <venv>/bin/mikemol-githook-prepare-commit-msg "$@". It appends the pre-commit gate report
(<git dir>/precommit-report.txt, minus [N/total] progress and blank lines, indented) to the
message under pre-commit gate report (auto-captured): and deletes the report. A merge or
squash source, or a message that already carries the marker, only deletes the report; no report
is a quiet no-op. The shell version swallowed every failed step, and its rm -f consumed the report
even when the append had failed. Here a missing argument, missing git, a non-repository, an
unreadable report and an unwritable message each write a reason to stderr and exit 1, and a failed
append keeps the report. Git aborts the commit on that exit.
Install from git by subdirectory, pinned to a sha. See the repo-root INSTALL.md:
"mikemol-hooks @ git+https://github.com/mikemol/mtools.git@<sha>#subdirectory=hooks"If your repo runs mtools' built hooks and does not install them, symlink tools/hook to
hooks/adopt/tools-hook and write each hook command as
STRUCT_HOOK_BLOCK=1 tools/hook mikemol-hook-structural-query. The launcher gets its mtools root
from MTOOLS_ROOT or from its own realpath. It does not guess $HOME/github/mtools. It accepts
only entry names of the form mikemol-hook-<name>. If the root is missing or the venv is not
built, it answers with an explicit deny. The one command it lets through is
env -C <root> bazel build //hooks:.venv, which repairs the venv (hooks/adopt/tools-hook).
- Every hook fails closed. If a hook exits 0 with an empty stdout, the harness reads that as
allow. ⚑ When linux-sources measured advisory mode, the advisory text reached nobody. So an
armed hook that cannot check anything returns a deny that names the repair (
no_chaining.py,shellcheck.py,pycheck.py). The deny cannot deadlock:pychecknever sees Bash, andshellcheckallows the one command that repairs it. - Adoption is a console script, not a symlink. A symlinked script works out its root with
abspath(__file__), and that does not resolve the link. ⚑ In one peer repo, an adopted tool failed on every call withModuleNotFoundErrorfor this reason (pyproject.toml). - There is no exception list. A category of question that no reader covers yet is a gap in
the toolkit. The fix is to add the reader, not to carve out an exception (
structural_query.py). - The hook refuses any spelling that gets around the gate. Git accepts
--no-ver, andcore.hooksPath=works as well as/dev/null.no_verifyrefuses every measured spelling. The post-commit witness catches any spelling nobody listed (no_verify.py). - A checker has three verdicts: clean, findings and could-not-run. A missing module also exits
1, so
No module namedis read before the exit code (verdict.py). - The checkers are runtime dependencies. ruff has a floor,
>=0.16.6, and no exact pin. ⚑ An exact pin madeuv add mikemol-hooksunsolvable against substrate'sruff>=0.16.8(2026-09-22).shellcheckis a system binary. When it is absent, the hook says so; it never passes silently (pyproject.toml).
Requirements: Python ≥ 3.13, ruff and mypy (installed as dependencies), and shellcheck on PATH.