I built an OSS tool called markgate that mechanically catches and enforces two kinds of gaps: when a Coding Agent (Claude Code, etc.) accidentally forgets to call a Skill that runs build or tests after implementation and commits anyway, and when AI tasks that can't be reduced to a command — like a /check-docs Skill — can't be enforced through hooks.
NOTE: markgate is meant to run inside a git repository.
markgate
markgate is a CLI tool that acts as an enforcement layer wedged between the Coding Agent and your hooks. By mechanically catching and enforcing the Coding Agent's mistakes and omissions, it addresses the following three failure patterns.
| Pattern | Failure pattern it addresses | Solution |
|---|---|---|
| 1 | Catching forgotten calls and avoiding duplicate execution | markgate run |
| 2 | Enforcing non-command tasks |
markgate set + markgate verify
|
| 3 | Scoping multiple tasks + aggregate verify |
composes in .markgate.yml
|
Below, we'll walk through each pattern in turn.
Pattern 1: Catching Forgotten Calls and Avoiding Duplicate Execution (markgate run)
Suppose you have an implementation skill, and a check skill (/check) that runs after it.
/check runs verifications against the implemented code. The things you want to guarantee against your implementation will vary by project — tests, typecheck, lint, format, build, documentation consistency, coverage generation, image scans, and so on.
However, even if you tell the Coding Agent "run /check after implementing", the Coding Agent can forget and silently skip the step. When that happens, your code gets committed without the checks you wanted to run.
So, you set up a pre-commit hook to forcibly run the equivalent of /check.
But the Coding Agent doesn't always forget. In fact, forgetting is rare — usually it runs /check just fine.
That leads to "the skill already ran it → the hook runs it again", a duplicate execution.
If /check is heavy, this slows down what should be a fast Coding Agent development cycle. If it's light you might not notice, but running it frequently still wastes a non-trivial amount of time.
Furthermore, if you skip the check after the implementation skill and put all check logic only in the hook, you can't run checks when you're not yet ready to commit. Running a post-edit hook on every file change isn't a great answer either, since the checks then run on every edit and that takes time.
Solved with markgate run
markgate keeps both the /check skill and the hook in place, and makes it so that the hook fires only when the Coding Agent forgets /check.
In other words, when the Coding Agent runs /check properly, the hook is skipped, so duplicate execution doesn't happen.
Usage
The basic flow is no config file required — just call the command.
For example, suppose your /check skill and pre-commit hook from above both run pnpm build. And suppose the pre-commit hook is wired up via Claude Code's PreToolUse hook firing on git commit*.
1. /check skill
In the /check skill that runs first, wrap the pnpm build command using markgate run -- <command>.
// ... inside the skill definition
- pnpm build
+ markgate run -- pnpm build
2. hook
In the pre-commit hook, rewrite pnpm build to markgate run -- pnpm build in the same way.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"if": "Bash(git commit*)",
- "hooks": [{ "type": "command", "command": "pnpm build" }]
+ "hooks": [{ "type": "command", "command": "markgate run -- pnpm build" }]
}]
}
}
With just this, you get the behavior where the hook fires only when the Coding Agent forgets /check, running pnpm build. When the Coding Agent runs /check properly, the latter hook is skipped, so duplicate execution doesn't happen.
Pattern 2: Enforcing Non-command Tasks (set + verify)
Up to this point, markgate run has meant writing the target command (pnpm build, etc.) directly into the hook configuration.
In practice, though, there are also tasks that can't be reduced to a command. For example, "is the documentation consistent with src?", "does the PR description match the diff?", "is naming consistent with the existing code?" — these are reviews that need LLM judgment and can't be expressed as a shell command.
You want to run this kind of LLM task in the Coding Agent's own session, because you want to leverage the context the Coding Agent has built up (conversation history, open files, past decisions). Technically, you could launch a separate session from a hook via claude -p, but since the context isn't shared, it doesn't act as a continuation of the work so far. As a result, the hook alone can't tell whether the Coding Agent ran an LLM task like /check-docs, which means there's no way to enforce it.
Building a Gate with set + verify
That's where the gate pattern — combining markgate set and markgate verify — comes in. The check itself stays on the skill side; set records "the moment the check passed" as a marker, and on the hook side, verify acts as a gate that judges whether "the recorded marker matches the current marker".
-
markgate set: records the moment the check passed as a marker -
markgate verify: exits 0 if the recorded marker matches the current marker, and exits 1 to stop things if they don't match
Since the marker records the code state at the time of set, when the code changes afterward the recorded marker and the current marker no longer match, and verify stops things (we'll cover what's inside a marker later).
Usage
At the end of where the check actually runs (a skill like /check-docs), call markgate set. On the hook side, just call markgate verify.
# At the end of the /check-docs skill definition
markgate set
// .claude/settings.json (hook side)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git commit*)",
"hooks": [
{ "type": "command", "command": "markgate verify || { echo 'run /check-docs' >&2; exit 1; }" }
]
}
]
}
}
The whole chain of check operations stays defined on the skill side, and the hook just looks at the marker. If the marker is missing or doesn't match the code, the hook blocks with exit 1, and the Coding Agent gets both the error and a hint (run /check-docs), so when the gate blocks things, recovery can be left to the Coding Agent and it can loop again.
Catching Both "First-time Non-command Triggers" and "Slip-ups"
This gate pattern is effective in both of the following cases.
-
First-time non-command trigger: The Coding Agent doesn't even know
/check-docsexists and tries to commit. → Since there's no marker, the hook blocks, and after reading the error message (run /check-docs), the Coding Agent runs/check-docsfor the first time. -
Slip-up: The Coding Agent runs
/check-docsand the marker is set. But later, when the code is edited again and you want to re-check, the Coding Agent forgets to re-run/check-docs. → Since the marker no longer matches the code, the hook blocks and prompts the Coding Agent to re-run.
In short, markgate uniformly catches both never-run-before cases and failure to re-run after a slip-up, using the same logic: "does the marker match the current code state?".
Pattern 3: Scoping and Aggregate Verify (.markgate.yml + composes)
So far we've assumed "one skill, one hook", but in practice you often have multiple checks lined up.
For example, suppose at pre-commit you want to run light checks (check: typecheck / lint / build / unit test) plus a heavier check that uses an LLM to verify documentation consistency (docs).
The problem here is wasted runs. If you bundle them into a single gate, even on a commit that only touches tests, the LLM consistency check runs even though nothing in the documentation-related files changed. That would mean re-running the heavy LLM check every time you change any code, which isn't practical.
Giving Each Task Its Own Scope
That's where .markgate.yml comes in: give each check its "own scope" (= the set of files specified in include). Whether a re-run is needed is judged by whether the code change falls within that scope.
On top of that, using composes to create an aggregate gate that bundles multiple gates means the hook side can judge "do all the bundled gates match the current code?" with just a single line of verify.
Usage
Create a .markgate.yml (markgate init generates a template).
# .markgate.yml
gates:
check:
hash: files # only the files specified in include are hashed
include:
- "src/**"
- "tests/**"
- "package.json"
docs:
hash: files
include:
- "src/**" # changes in src also affect docs, so include in both
- "docs/**"
- "README.md"
pre-commit:
composes: [check, docs] # aggregate gate: AND of check and docs
From the skill side, specify the gate name as an argument and set each gate individually.
# At the end of the /check skill definition
pnpm typecheck && pnpm lint && pnpm test && markgate set check
# At the end of the /check-docs skill definition
markgate set docs
Then, in the pre-commit hook, verify the pre-commit aggregate gate in a single line. When it doesn't match, markgate status pre-commit shows "which gate doesn't match", and the Coding Agent reads that and re-loops the corresponding skill.
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git commit*)",
"hooks": [
{ "type": "command", "command": "markgate verify pre-commit || { markgate status pre-commit >&2; exit 1; }" }
]
}
]
}
}
The key point is that only the checks needed for the edited scope get re-run. Each gate's marker only hashes the files in its own include, so on a commit that only touches one side's scope, the other gate's marker stays the same as when it was set and still matches, meaning no re-run is needed.
For example, a commit that only modifies tests re-runs only the light checks; a commit that only touches files outside any include (CI config, etc.) skips both.
| Edited files | check |
docs |
Checks that need to re-run |
|---|---|---|---|
tests/** only |
stale | fresh | light checks only |
docs/** / README.md only |
fresh | stale | heavy check only |
src/** |
stale | stale | both |
| None of the above (CI config, editor config, etc.) | fresh | fresh | none (commit passes) |
Note that the pre-commit aggregate gate in this example is designed without a command of its own; it just performs the AND judgment — fresh if all bundled gates are fresh, stale if any one is stale. This shape — without its own command — can't be written with markgate run (which wraps a single command); only the gate pattern (set + verify) can express it.
Other Use Case Examples
Beyond the basic usage above, markgate has various advanced patterns.
- Check documentation consistency before creating a PR (
/check-docs→gh pr createhook) - Vulnerability scan before pushing an image (
trivy image→docker pushhook) - Generate a coverage report before pushing (
go test -cover→git pushhook) - Ensure E2E tests have just been re-run before merging a PR (
pnpm test:e2e→gh pr mergehook)
How It Works: Managing State with Markers
Let's look at what the "marker" we've been mentioning is actually made of.
markgate works by saving the "state" at the time the target command (or task) succeeded as a marker, and on the next call of the same command (or task), if the marker matches the current state, the run is skipped; if not, it runs (markgate run) / blocks (markgate verify).
In practice, this "state" is a hash of the code content. You can choose between two hash targets: "a specific set of files" (hash: files + include) or "the repository's git status as a whole" (hash: git-tree, the default). Either way, as long as the code content hasn't changed, the command is skipped.
# First run: no marker yet, so pnpm build runs and a marker is saved on success
$ markgate run -- pnpm build
building...
passed in 7.2s
# Second run: no file changes → execution is skipped
$ markgate run -- pnpm build
# After editing a file: the marker is invalidated, so the command runs again
$ echo '// fix typo' >> src/foo.ts
$ markgate run -- pnpm build
building...
passed in 7.1s
By default, the marker file is stored inside .git (.git/markgate/default.json), so you don't need to create a marker file yourself, and it isn't tracked by git even without an explicit .gitignore entry. (There's also an option to explicitly change the marker file path and share markers between local and CI; see the README for that.)
Installation
# Homebrew
brew install go-to-k/tap/markgate
# shell script
curl -fsSL https://raw.githubusercontent.com/go-to-k/markgate/main/install.sh | bash
# go install
go install github.com/go-to-k/markgate/cmd/markgate@latest
# mise (ubi backend)
mise use "ubi:go-to-k/markgate@0.3.3"
Using markgate from Other Hook Managers
So far I've used the Claude Code hook as the example, but markgate is hook-manager agnostic. With husky / lefthook / the pre-commit framework, or any other hook manager, you can use it just by writing a single line of markgate run -- <command>.
# .husky/pre-commit
markgate run -- pnpm test
# lefthook.yml
pre-commit:
commands:
check:
run: markgate run -- pnpm test
# .pre-commit-config.yaml (pre-commit framework)
repos:
- repo: local
hooks:
- id: markgate-check
name: markgate check
entry: markgate run -- pnpm test
language: system
pass_filenames: false
Final Thoughts
Personally, I think this has turned into a tool I'll lean on in the AI era. In particular, the idea of "mechanically catching AI mistakes and omissions" feels like one of the important points when working with AI.
For the other features (exclude / state_dir settings in .markgate.yml, sharing markers between local and CI, strict gate dependencies via requires, etc.), see the README in the GitHub repository. If you like it, I'd be glad if you'd give the repository a ⭐.
Also, as a project that actually uses markgate in practice, there's cdkd (CDK Direct) — a from-scratch rewrite of the AWS CDK CLI that's made it blazingly fast. Nearly all of the implementation is delegated to a Coding Agent, and markgate mechanically backs that up against missed runs and non-command tasks. Take a look if you're interested.







Top comments (2)
The duplicate run problem is very real. I went the other way on forgotten checks: in my setup the orchestrator never takes the agent's word that tests pass, the checks run separately after the subagent finishes, and then auditor agents review the result. It works, but it means exactly what you describe, checks often run twice when the agent already did its job. Hashing the scope and skipping when nothing changed is a neat way out, and the per-gate include is the part I'd take first, because an LLM doc review is the heavy one that shouldn't run on a test-only commit.
One question: what stops the agent from calling markgate set without really running the check? For commands you chain it with &&, but for /check-docs the set is just a command the agent can run on its own.
I'm building this kind of orchestration in Swarmery, if you're curious: atretyak1985.github.io/swarmery/
@swarmery
Nothing stops it, honestly. markgate is built for an agent that forgets, not one that lies. A marker only proves that
markgate setran against this exact state, not that the check behind it was any good. For/check-docs, an agent that callssetwithout doing the review passes the gate, and that's a real limit. Signing markers wouldn't fix it either, because whatever can sign can also skip the work.In practice, the failure I kept hitting was forgetting:
setsits at the end of the skill, so an agent that runs the skill usually gets there. A stale marker catches that, and the&&chain closes the gap for commands. For LLM tasks, you're trusting the agent as much as you trust its review anyway.To fit markgate into your setup, you could have only the orchestrator call
markgate set, after the auditors pass. That keeps the guarantee from your audit and removes the duplicate runs.