| name | setup-pre-commit-gate |
| description | Use this skill when the user wants to wire the `audit-staged` CLI into their repo's pre-commit framework so blocking complexity violations fail `git commit` BEFORE the AI can land them — i.e. set up the post-gate that catches what the in-session PostToolUse hook missed (model retries, `--no-verify` attempts, tool-call shape drift). Triggers on "set up pre-commit", "wire audit-staged", "block AI commits", "audit before commit", "post-gate", "pre-commit gate", "install the commit-time complexity gate", "stop Claude bypassing the hook". This skill PRINTS snippets for the user to copy — it does NOT mutate the consumer's repo. |
setup-pre-commit-gate
Why pre-commit-time gating matters
The in-session PostToolUse hook (claude-code-complexity-guard) is the
first line of defence: when Claude edits a .rs or .py file, the hook
runs CCS + HSI on the post-edit content and exits 2 to block the edit
on a threshold breach. That covers the common case but not every case:
- Model retry loops. A persistent agent can keep editing until it
finds a shape the hook accepts, then commit anyway.
--no-verify bypass. git commit --no-verify skips both
client-side hooks (Husky / pre-commit / Lefthook) AND silently
bypasses any in-session check that runs as a hook. If the model
knows the flag, the gate is open.
- Tool-call shape drift. A future Claude Code release could change
the PostToolUse contract; the in-session hook would silently stop
firing, leaving no enforcement at all.
- Out-of-session edits.
git commit -am from the terminal, IDE
refactors, or any non-Claude commit path bypasses the in-session
hook by construction.
A pre-commit-framework gate that calls audit-staged closes all four
gaps: it runs against git diff --cached so it catches whatever
content is about to land regardless of who staged it, and it lives in
the consumer's pre-commit configuration (not the Claude Code
configuration) so a model retry cannot edit it away within a session.
This skill is the post-gate setup helper. It detects which pre-commit
framework your repo already uses (or recommends one if you have none),
emits the framework-specific snippet that wires audit-staged in,
and separately emits a Claude Code settings.json snippet that denies
the model the --no-verify bypass while leaving humans free to use
the same flag.
Usage
/claude-code-complexity-guard:setup-pre-commit-gate [light|balanced|strict]
The optional argument picks one of the three locked setups (table
under Step 2). Default is balanced if you do not specify one.
Step 1 — detect the consumer's framework
Walk the consumer's repo root (the directory git rev-parse --show-toplevel resolves to) and check for one of these markers, in
order:
from pathlib import Path
def detect_framework(repo_root: Path) -> str:
"""Return the first matched framework name, or 'none'."""
if (repo_root / ".pre-commit-config.yaml").is_file():
return "pre-commit"
if (repo_root / "lefthook.yml").is_file() or (
repo_root / "lefthook.yaml"
).is_file():
return "lefthook"
if (repo_root / ".husky").is_dir():
return "husky"
if (repo_root / ".git" / "hooks" / "pre-commit").is_file():
return "raw-git"
return "none"
The matching rules:
| Marker | Framework |
|---|
.pre-commit-config.yaml at repo root | pre-commit (Python framework, https://pre-commit.com) |
lefthook.yml or lefthook.yaml at repo root | Lefthook |
.husky/ directory at repo root | Husky |
.git/hooks/pre-commit (executable file, no framework above) | raw git hook |
| none of the above | recommend installing pre-commit (see below) |
If multiple markers exist (e.g. .pre-commit-config.yaml AND a
.husky/ directory), use the first match in the table — pre-commit
takes precedence because it is the framework most commonly already
wired into Python toolchains, and the plugin's runtime is Python.
If NO marker is found, recommend installing pre-commit by default.
Rationale: pre-commit is Python, matches this plugin's runtime, has
the widest install base for repos that ship Python code, and its
config file (.pre-commit-config.yaml) is the format most contributor
docs already reference. Print the install command AND the config stub
together:
pip install pre-commit && pre-commit install
…then drop the pre-commit snippet from Step 3 into a new
.pre-commit-config.yaml at the repo root.
Step 2 — pick a setup
Three locked setups. Do NOT invent new ones. The numbers are derived
from the existing default and strict presets and have been
calibrated for the post-gate use case.
| Setup | CCS per_function | CCS new_function | HSI threshold | Source |
|---|
light | 28 | 18 | 0.009 | default minus ~10% headroom |
balanced | 25 | 16 | 0.008 | midway between default and strict |
strict | 20 | 15 | 0.005 | reuses existing strict preset |
How they are emitted into the snippet:
light → explicit CLI flags
--ccs-per-function 28 --ccs-new-function 18 --hsi-threshold 0.009.
balanced → explicit CLI flags
--ccs-per-function 25 --ccs-new-function 16 --hsi-threshold 0.008.
strict → --preset strict (no per-flag overrides; the strict
preset already encodes 20 / 15 / 0.005).
If the user does not pass an argument, default to balanced. The
post-gate is expected to run on EVERY commit, so balanced keeps the
false-positive rate below strict while still tightening the
in-session default thresholds.
When in doubt, start with light, observe how often it fires across
a few weeks of commits, then ratchet to balanced or strict.
Step 3 — emit the framework snippet
Step 3a — resolve the plugin path ONCE and inline it
audit-staged is shipped as a module inside this plugin's lib/
package, NOT as a pip-installable distribution. The consumer's repo
will NOT have lib.cli.audit_staged on its PYTHONPATH by default,
so a bare python -m lib.cli.audit_staged will fail with
ModuleNotFoundError. The hook must point Python at the plugin root.
Resolve the plugin root ONCE at install time and write the absolute
path directly into each snippet. Do NOT require the consumer's
contributors to maintain a CLAUDE_PLUGIN_ROOT environment variable
in their shell rc — that pushes plugin-location complexity onto every
project and every machine that clones the repo.
The plugin root MUST be resolved from one of the following sources, in
this priority order. Do NOT use __file__ — the skill body is
markdown, not an executing Python module, and __file__ would either
be undefined (REPL / python -c / stdin) or point to whatever runner
script imported the skill (wrong directory entirely).
CLAUDE_PLUGIN_ROOT environment variable. Claude Code exports
this when the skill is invoked in-session. Use it directly:
echo "$CLAUDE_PLUGIN_ROOT"
- Standard plugin install locations, checked in order. If
CLAUDE_PLUGIN_ROOT is unset (e.g. the skill is being read outside
Claude), probe these bounded paths and pick the first one that
contains lib/cli/audit_staged.py:
for candidate in \
"$HOME/.claude/plugins/claude-code-complexity-guard" \
"$HOME/.config/claude/plugins/claude-code-complexity-guard" \
"/usr/local/share/claude/plugins/claude-code-complexity-guard" \
"/opt/claude/plugins/claude-code-complexity-guard"; do
[ -f "$candidate/lib/cli/audit_staged.py" ] && { echo "$candidate"; break; }
done
- Explicit user input. If neither of the above finds the plugin
(custom clone location, monorepo vendoring, etc.), ASK the user
for the absolute path of the directory that contains
lib/,
hooks/, and .claude-plugin/. Do not run an unbounded
filesystem walk — it is slow, noisy, and unreliable in the
presence of multiple plugin clones.
Once the path is resolved, substitute it for every <PLUGIN_ROOT>
token in the snippets below. Each emitted snippet is therefore
self-contained: no environment variable needs to exist outside the
hook file itself, and no runtime path-resolution magic runs at
commit time.
The Python runtime invoked by the hook must also have the plugin's
runtime dependencies available (PyYAML, tree-sitter,
tree-sitter-python, tree-sitter-rust). Install them into whatever
interpreter the framework will run:
pip install pyyaml tree_sitter tree_sitter_python tree_sitter_rust
If the consumer's repo uses a venv (.venv/), use that venv's
python in every snippet rather than the system python — and
install the dependencies into that venv.
Each snippet below also embeds a defensive guard that fails loudly
with a clear error message if the inlined path is missing
(misconfiguration, plugin uninstall, etc.) rather than degrading into
ModuleNotFoundError.
Step 3b — pick the snippet
Pick the snippet that matches the framework detected in Step 1.
The snippets below all show the balanced setup with the literal
token <PLUGIN_ROOT> standing in for the absolute plugin path
resolved in Step 3a. Replace <PLUGIN_ROOT> with that absolute path
(e.g. /home/alice/.claude/plugins/claude-code-complexity-guard)
before applying the snippet. Substitute the correct CLI fragment from
Step 2 if the user picked light or strict (for strict, replace
the explicit flags with --preset strict).
pre-commit (.pre-commit-config.yaml)
pre-commit's language: python sandboxes hooks in their own venv
and would not see lib.cli.audit_staged even with PYTHONPATH wiring,
which would defeat the gate. Use language: system so the hook runs
in the consumer's own interpreter:
- repo: local
hooks:
- id: complexity-guard-staged
name: claude-code-complexity-guard staged audit
entry: bash -c 'PLUGIN_ROOT="<PLUGIN_ROOT>"; [ -d "$PLUGIN_ROOT/lib" ] || { echo "complexity-guard-staged: plugin root $PLUGIN_ROOT does not contain lib/; re-run setup-pre-commit-gate" >&2; exit 2; }; PYTHONPATH="$PLUGIN_ROOT" python -m lib.cli.audit_staged --ccs-per-function 25 --ccs-new-function 16 --hsi-threshold 0.008'
language: system
pass_filenames: false
always_run: true
The [ -d "$PLUGIN_ROOT/lib" ] || { … exit 2; } clause is the
explicit unset / misconfigured-path guard: it fails the hook with a
clear stderr message before invoking Python rather than letting a
broken PLUGIN_ROOT degrade into ModuleNotFoundError halfway through
the import.
pass_filenames: false is required: audit-staged reads the staged
delta itself via git diff --cached; pre-commit's own filename
forwarding would conflict. always_run: true ensures the hook fires
even when the diff is empty of Python / Rust files (it will exit 0
quickly in that case via the engine's filter list). language: system
is required because audit-staged is not pip-installable; the hook
runs in the consumer's own Python environment so the dependencies
listed in Step 3a must be installed there.
After editing the file, run:
pre-commit install
…to register the hook with your local .git/hooks/pre-commit shim.
Lefthook (lefthook.yml)
Append under the top-level config (or merge into an existing
pre-commit: block). Inline the absolute path as PLUGIN_ROOT so
contributors never need to set the variable in their own shell:
pre-commit:
commands:
complexity-guard-staged:
run: |
PLUGIN_ROOT="<PLUGIN_ROOT>"
[ -d "$PLUGIN_ROOT/lib" ] || {
echo "complexity-guard-staged: plugin root $PLUGIN_ROOT does not contain lib/; re-run setup-pre-commit-gate" >&2
exit 2
}
PYTHONPATH="$PLUGIN_ROOT" python -m lib.cli.audit_staged --ccs-per-function 25 --ccs-new-function 16 --hsi-threshold 0.008
Then run:
lefthook install
Husky (.husky/pre-commit)
If .husky/pre-commit already exists, append the body below
(everything after the husky.sh source line). Otherwise create the
file with:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
PLUGIN_ROOT="<PLUGIN_ROOT>"
[ -d "$PLUGIN_ROOT/lib" ] || {
echo "complexity-guard-staged: plugin root $PLUGIN_ROOT does not contain lib/; re-run setup-pre-commit-gate" >&2
exit 2
}
PYTHONPATH="$PLUGIN_ROOT" python -m lib.cli.audit_staged --ccs-per-function 25 --ccs-new-function 16 --hsi-threshold 0.008
…and chmod +x .husky/pre-commit. Husky v9+ does not require the
. "$(dirname -- "$0")/_/husky.sh" line; keep it for v8 compatibility.
Raw git (.git/hooks/pre-commit)
If your repo has no framework and you do NOT want to install one,
edit .git/hooks/pre-commit directly:
#!/usr/bin/env sh
PLUGIN_ROOT="<PLUGIN_ROOT>"
[ -d "$PLUGIN_ROOT/lib" ] || {
echo "complexity-guard-staged: plugin root $PLUGIN_ROOT does not contain lib/; re-run setup-pre-commit-gate" >&2
exit 2
}
exec env PYTHONPATH="$PLUGIN_ROOT" python -m lib.cli.audit_staged --ccs-per-function 25 --ccs-new-function 16 --hsi-threshold 0.008
…and chmod +x .git/hooks/pre-commit. Caveats: this file lives inside
.git/, which is NOT version-controlled, so every contributor must
re-create it on clone (and re-run setup-pre-commit-gate to refresh
<PLUGIN_ROOT> if the plugin was reinstalled at a different path).
Prefer pre-commit / Lefthook / Husky for any repo with more than one
contributor.
No framework detected
If Step 1 returned none, install pre-commit and use its snippet:
pip install pre-commit pyyaml tree_sitter tree_sitter_python tree_sitter_rust
Drop the pre-commit snippet above (with <PLUGIN_ROOT> substituted)
into a new .pre-commit-config.yaml at the repo root, then:
pre-commit install
Step 4 — close the bypass (two-pronged)
This is a SEPARATE step from Step 3. The Step 3 snippet wires the
gate into the consumer's pre-commit framework; this step denies the
model the git commit --no-verify flag that would skip the gate
entirely. The two snippets are independent — apply both.
Add the following to your project-level Claude Code configuration —
typically .claude/settings.json at the repo root, or your user-level
~/.claude/settings.json if you want the deny rule to apply to every
project. If a permissions.deny array already exists, append the new
entries; do not replace existing entries.
{
"permissions": {
"deny": [
{"tool": "Bash", "command": "git commit --no-verify"},
{"tool": "Bash", "command": "git commit -n"}
]
}
}
Both entries are required. git commit -n is the documented short
alias of git commit --no-verify and skips the same hooks; denying
only the long form leaves the bypass open via the short form.
Known limitation: this is a substring deny, not a parser
Claude Code's permissions.deny matches the model's literal Bash
command string. The two entries above cover the canonical forms but a
sufficiently determined model could still construct equivalent
invocations the substring matcher does not catch, for example:
- Whitespace-mangled variants (
git commit --no-verify with extra
spaces, trailing tabs, etc.).
- Flag reordering with other
git-level options
(git -c hooks.allownonascii=true commit --no-verify).
- Shell wrappers (
sh -c 'git commit --no-verify',
bash -lc 'git commit -n', here-documents, command substitution).
git -C <other-repo> commit --no-verify to escape into a sibling
repo whose hook is not configured.
- Direct manipulation of
.git/hooks/pre-commit (renaming or
truncating the file) followed by a normal git commit.
Treat the deny list as a friction speedbump for casual bypass attempts,
NOT as a watertight sandbox. The authoritative enforcement still lives
in Step 3 — the pre-commit framework gate runs against git diff --cached regardless of which flags the commit was launched with, and
it is the reason a determined bypass would ALSO have to remove or
neuter the framework hook itself. If your threat model requires
defeating a model that actively rewrites .git/hooks/ on disk, you
need a server-side check (e.g. a CI pipeline or a server-side update
hook) — those are outside the scope of this skill.
If your project already maintains a permissions.deny list, merge the
new entries rather than overwriting:
{
"permissions": {
"deny": [
{"tool": "Bash", "command": "rm -rf /"},
{"tool": "Bash", "command": "git commit --no-verify"},
{"tool": "Bash", "command": "git commit -n"}
]
}
}
Why two snippets, not one
The Step 3 snippet and the Step 4 snippet target different layers and
must remain independent (the pattern is documented in Appendix B.6 of
the plan as the "two-pronged" pre-commit gate):
- Step 3 lives in the consumer's pre-commit framework. It runs on
every
git commit from any source — the model, a human, an IDE, a
CI pre-commit cache. It is the authoritative enforcement point.
- Step 4 lives in Claude Code's permission engine. It only
affects the model. It exists because pre-commit frameworks are
bypassable by design —
--no-verify is a feature, not a bug — and
the model knows the flag. Without Step 4, a model that hits a
blocking violation in Step 3 can simply re-run with --no-verify
and land the change.
Applying ONLY Step 3 leaves the bypass open. Applying ONLY Step 4
removes the bypass but leaves no enforcement to bypass — humans can
still land any change, and out-of-Claude-session commits go through
unchecked. The two together are the minimum coherent post-gate.
Verification after applying both snippets
- From the repo root, stage a file with a deliberately complex
function (e.g. CCS > 25):
git add path/to/over_complex.py
git commit -m "test: should be blocked"
Expect a non-zero exit and the JSON violation report on stderr.
- Try the bypass from a Claude session:
git commit --no-verify -m "test: should be denied"
Expect Claude Code to refuse the Bash call (Step 4 working).
- Run the same
--no-verify command from your own terminal and
confirm it still works (the deny rule does not affect humans).
If step 1 does not block, re-check Step 3: is the hook entry path
correct, is python on PATH, is the framework's install command
run? If step 2 does not deny, re-check Step 4: is the settings file at
the right location, did you merge into permissions.deny correctly?
Related skills
configure-budget — adjust the in-session .cogcomp.yml preset
that the PostToolUse hook reads. The post-gate set up here is
independent of .cogcomp.yml: the CLI flags emitted by this skill
override whatever preset is on disk, so the post-gate thresholds and
the in-session thresholds can differ on purpose.
audit-patch — analyse an arbitrary git rev-range (e.g. before
opening a PR). Complementary to audit-staged; does not replace
the pre-commit gate.
explain-metrics — what CCS / HSI mean, how thresholds are picked.