A small, generic framework for a "focus gate" hook: something that blocks a prompt (or any other action) while independent, decoupled checks have something outstanding. gatekit itself knows nothing about what those checks are -- it only merges and renders JSON files that modules write, on whatever schedule, in whatever language, from whatever repo they live in.
Three ways out, from broadest to narrowest:
- Kill switch -- unconditional, no rate limit.
gatekit offstops gating entirely until yougatekit on.gatekit off 1hstops it for a fixed duration instead of indefinitely. This is checked before anything else in the engine runs, so a broken module, a corrupt state file, or a bug in gatekit's own tiering logic can never stand between you and this working. If the binary itself is ever broken,touch ~/.cache/gatekit/state/disabledby hand does the same thing -- no parsing required. - Snooze -- rate limited, scoped.
gatekit snooze 2hsnapshots whichever items are overdue right now and holds exactly those for two hours. Anything not in that snapshot -- including an item that comes due later -- still blocks normally.gatekit unsnoozeends it early. - Bypass -- rate limited, one-shot.
gatekit bypasswaves through whatever's currently overdue, once, for the current invocation only.
Snooze and bypass share one rate-limit budget (bypass_rate_limit per
bypass_window, with a bypass_cooldown between uses -- 2/hour and 5
minutes by default). The kill switch shares nothing with them and has no
limit of its own, on purpose: a rate-limited escape hatch that runs out is
not really an escape hatch, and gatekit's own design started from watching
that happen to someone using an earlier, single-purpose version of this
idea.
gatekit reload is not an escape valve -- it never lets an overdue item
through. It asks every module to re-poll its own upstream data right now,
instead of waiting out its own schedule, for the case where you've already
resolved something (replied, closed the ticket, whatever) and don't want to
wait for the next scheduled tick to see it drop off.
gatekit itself has nothing to reload; Evaluate already reads sources/
fresh on every call. This writes a marker file and nothing more --
responding to it is opt-in per module (see docs/module-authoring.md).
A module that never checks loses nothing: it catches up on its own next
run either way, and until then gatekit status/a block message reports a
warning naming which module hasn't refreshed since the request.
go install github.com/312-dev/gatekit/cmd/gatekit@latest
go install github.com/312-dev/gatekit/cmd/gatekit-stalefiles@latest # the reference module, optional
Or grab a prebuilt binary from GitHub Releases if you don't have a Go toolchain handy.
sources/*.json <- module-owned; gatekit never writes here, only reads
|
v
gatekit <- pure library: tiering, degrade policy, snooze, bypass,
kill switch. No network calls, no domain knowledge.
|
+--> adapter/claudecode Claude Code's UserPromptSubmit block JSON
+--> adapter/exitcode the portable exit 0/2 + stderr contract
cmd/gatekit is the installed binary. gatekit hook runs the portable
exit-code adapter; gatekit cc-hook runs the Claude Code adapter. Point
either one at your hook runtime of choice.
A module is anything that writes one JSON file to
sources/<module>.json, atomically (temp file + rename), on its own
schedule. gatekit polls nothing and calls nothing -- it just reads that
directory every time it evaluates. See
docs/module-authoring.md for the full
contract, and cmd/gatekit-stalefiles for a complete, working example (it
flags files in a watched directory older than N days -- deliberately
trivial, included only to prove the contract end to end).
Wire your module in however fits your platform -- a systemd timer, a cron entry, a long-running daemon's own loop. gatekit doesn't care.
Each item is in exactly one of these -- mutually exclusive states, not slices of one ranked list:
- pending -- before the item's
sla_secondsgrace period has elapsed. Never blocks. Shown with a countdown to when it becomes overdue. - held -- due, but excused: either an active snooze names it, or its
source is degraded under
on_degraded: "open". Visible, doesn't hold anything up. - overdue -- due, with nothing excusing it. Every overdue item blocks
at once; there's no cap and no front-N. Whether something's due is
decided entirely by the module, through
first_seen_atplussla_seconds-- gatekit itself never ranks or rations.
Each source file's freshness (engine-measured mtime) and its own
self-reported ok field combine into a health state. A module that goes
stale or reports ok: false defaults to fail-open (on_degraded: "open"):
its due items are held instead of blocking. A module can opt into
on_degraded: "closed" if not knowing is itself the problem it exists to
report -- in that case a degraded module's due items still block, always
with a warning attached explaining why.
~/.config/gatekit/config.json (or $GATEKIT_CONFIG):
{
"stale_after": "15m",
"unknown_after": "2h",
"module_policies": {
"slow-cron-module": { "stale_after": "6h" }
},
"bypass_rate_limit": 2,
"bypass_window": "1h",
"bypass_cooldown": "5m"
}sources/ and state/ default to ~/.cache/gatekit/{sources,state},
overridable via $GATEKIT_SOURCES_DIR / $GATEKIT_STATE_DIR.
MIT -- see LICENSE.