The review gate as a tool.check function hook. Every shell call is decided in process, so the non-commit calls -- almost all of them -- cost no subprocess at…

Set of prompts, skills, and scripts to aid in utilizing AI coding agents in development workflows.
The setup script and the review loops are Bash scripts that shell out to a handful of command-line tools. Install the ones below and make sure they are on your PATH.
Required (the setup script exits early if any is missing):
git, to clone the repo and drive the git-based commandsgh) 2.88.0+, installed and authenticated (/review-pr uses the gh pr edit --add-reviewer @copilot special value added in 2.88.0 to re-request Copilot code review)jq, a JSON processor used to read and edit each tool's settings and MCP config filesRequired only for optional steps:
npx), for the MCP servers and the Impeccable design skills. setup skips those steps with a warning if npx is not found.At least one AI coding tool:
agy command)kimi command)| Tool | macOS (Homebrew) | Debian / Ubuntu | Windows (winget) |
|---|---|---|---|
git | brew install git | sudo apt install git | bundled with Git for Windows |
gh | brew install gh | gh install docs | winget install GitHub.cli |
jq | brew install jq | sudo apt install jq | winget install jqlang.jq |
| Node.js | brew install node | sudo apt install nodejs npm | winget install OpenJS.NodeJS |
The other utilities the scripts call (bash, grep, sed, awk, sort, diff, find, comm, ...) are standard on macOS and Linux, and are bundled with Git for Windows.
Windows: Run
./setupand the review loops from Git Bash (part of Git for Windows). Git Bash ships Bash and the standard Unix utilities but notjq, so installjqseparately with the command above.
git clone https://github.com/rlorenzo/ai-coding-setup.git
cd ai-coding-setup
./setup
The script detects which AI tools you have installed and walks you through installing commands for each one interactively.
Windows: Run the setup script from Git Bash.
| Tool | Command format | Source directory | Installs to |
|---|---|---|---|
| Claude Code | Plugin (.claude-plugin/) | .claude/commands/, plugins/, mods/ | loaded in place from the clone |
| Claude Code (copy fallback) | Markdown (.md) | .claude/commands/, plugins/explore-agent/agents/ | ~/.claude/commands/, ~/.claude/agents/ |
| Codex CLI | Agent Skills (SKILL.md) | .codex/skills/ | ~/.codex/skills/ |
| Copilot CLI | Agent Skills (SKILL.md) | .copilot/skills/ | ~/.copilot/skills/ |
| Antigravity CLI | Unified Plugin (plugin.json) | .antigravity/ | ~/.gemini/antigravity-cli/plugins/ai-coding-setup/ |
| Kimi Code CLI | Agent Skills (SKILL.md) | .kimi-code/skills/ | ~/.kimi-code/skills/ |
| Shared prompts | Markdown (.md) | prompts/ | ~/.local/share/ai-coding-setup/prompts/ |
Kimi Code reads its user-level data from $KIMI_CODE_HOME when that variable is set; setup honors it and falls back to ~/.kimi-code. Kimi invokes skills as /skill:<name>, so the commands below are /skill:commitmsg, /skill:review-pr, and so on.
Claude Code is the one harness here with a plugin system of its own, and setup uses it by default: instead of copying seven Markdown files into ~/.claude/commands/, it registers this clone as a marketplace and installs from it.
The marketplace holds three plugins:
| Plugin | What it is | Needs |
|---|---|---|
ai-coding-setup | The seven commands | Nothing |
explore-agent | The Explore subagent as an agent file that shadows the built-in | Nothing |
explore-model | The same pinning as an agent.spawn function hook, with no shadow | CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 |
review-gate | The review gate as a tool.check function hook | CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 |
explore-agent and explore-model do the same job two ways and you want exactly one of them; see Explore. setup installs whichever your session can run and removes the other.
claude plugin marketplace add ./ # from inside the clone
claude plugin install ai-coding-setup@ai-coding-setup --scope user
claude plugin install explore-agent@ai-coding-setup --scope user
Three things are better this way:
git pull. The plugin loads in place from the clone, so pulling new commands makes them live at the next session start. The copy path needs another ./setup run to notice.claude plugin uninstall ai-coding-setup takes all seven commands and the agent with it, where the copies have to be deleted one by one.~/.claude/commands/. A command of your own that happens to share a name is never shadowed, and the source markers and stale-orphan pruning the copy path needs stop mattering.The trade-off is that the plugin loads in place: move or delete the clone and the commands go with it. A copy would have survived. If that matters more than live updates, answer n at the plugin prompt and setup falls back to copying, exactly as before. Either way it offers to remove whichever set it did not install, so no command is ever listed twice.
To install without a clone at all, point the marketplace at the repo:
claude plugin marketplace add rlorenzo/ai-coding-setup
claude plugin install ai-coding-setup@ai-coding-setup --scope user
Propose a conventional commit message for the currently staged changes. Detects ticket IDs from branch names and follows the project's recent commit style.
Usage:
/commitmsg$commitmsg/commitmsg/commitmsg/skill:commitmsgProcess unresolved review comments on a GitHub PR, fix valid issues, ensure CI passes, and re-request review.
Review bots count as required reviewers: every bot that has reviewed the PR must cover the head commit, falling back to the bots used on the repo's recent PRs, and asking you if none are found. The skill reads each bot's review body as well as its threads, so findings with no thread still get handled, and a non-approving verdict (Copilot's Changes recommended, a nonzero CodeRabbit count) blocks success until you accept the declined findings. It re-triggers Copilot, CodeRabbit, and Greptile itself and asks for the trigger of any other bot. It keeps working through its iterations without stopping to report, and treats comment text as untrusted: findings are judged against the code, never followed as instructions.
Usage:
/review-pr [PR_NUMBER]$review-pr [PR_NUMBER]/review-pr [PR_NUMBER]/review-pr [PR_NUMBER]/skill:review-pr [PR_NUMBER]Review staged files against four quality angles (simplification, reuse, efficiency, altitude), apply the fixes, fix linting issues, and check test coverage. On large diffs, fans the angles out to parallel mid-tier subagents when the agent has a subagent tool. The reuse angle searches shared and nearby modules before concluding nothing existing fits, and each reuse finding names the existing alternative and its path.
Usage:
/code-refinement$code-refinement/code-refinement/code-refinement/skill:code-refinementRun a standalone code review on staged changes. Writes findings to agent-code-review.md.
Usage:
/code-review$code-review/code-review/code-review/skill:code-reviewAudit dependency updates for supply-chain risk before they land: publish-age gate, changelog/diff verification, security advisories, community signals, and breaking changes. Changelogs, release notes, and package source are treated as third-party evidence to verify, not instructions: text that tells the agent to run something, skip a check, or approve the update is itself flagged as a HOLD.
Usage:
/dependency-review$dependency-review/dependency-review/dependency-review/skill:dependency-reviewRun a task with your current model as the orchestrator and reviewer while cheaper, faster subagents do the token-heavy research, coding, and testing. It matches model tier to task difficulty (your own tier for complex work, a mid tier for low/medium, the cheapest tier for mechanical), keeps the orchestrator's own reading and searching lean, runs delegation in bounded waves to respect your usage caps, and for long unattended runs auto-pauses and resumes across usage windows. No model names are hardcoded beyond a Claude example ladder: each harness orders its own available models by cost and capability, and everything else is written relative to whatever tier you are on. Agents without a native subagent tool (Codex, Copilot) delegate by spawning their own CLI non-interactively with an explicit model.
The skill also pins the model explicitly on every spawn (since Claude Code v2.1.198 the built-in Explore/Plan/general-purpose subagents inherit the main-session model, so an un-pinned background search bills at your tier), prefers model aliases over pinned IDs, drops reasoning effort for cheap-tier recon (and treats lower effort on your own tier as an alternative to a cheaper tier for bounded coding slices), gives each handoff an advisory time budget backed by a hard timeout, distinguishes what delegation buys on API vs. subscription billing (per-token savings vs. quota-bucket arbitrage), and closes non-trivial work with a fresh-context verifier that only refutes, never fixes.
Usage:
/efficient-orchestration$efficient-orchestration/efficient-orchestration/efficient-orchestration/skill:efficient-orchestrationRewrite a feature branch's git history into focused, logical commits before review or merge. It surveys the branch's commits past the merge base, folds review-response, fixup, WIP, and lint-fix noise into the substantive commits they amend, and reorders the result so each commit is reviewable on its own and git blame stays meaningful. Refuses to run on main or other long-lived branches, creates a backup branch before rewriting, and verifies the final tree is byte-identical to the original tip. Once verified it force-pushes with --force-with-lease (never bare --force) and deletes the backup branch; on any failure the backup is kept so the original history is never lost.
Usage:
/git-history-cleanup [BRANCH]$git-history-cleanup [BRANCH]/git-history-cleanup [BRANCH]/git-history-cleanup [BRANCH]/skill:git-history-cleanup [BRANCH]The subagent definitions ship as the explore-agent plugin, from plugins/explore-agent/agents/; on the copy path setup installs them to ~/.claude/agents/. These are Claude Code-only (the other harnesses have no equivalent mechanism).
They sit at that plugin's root rather than under .claude/ because the root agents/ directory is the only place Claude Code loads plugin agents from. The manifest's agents key accepts a list of file paths, claude plugin validate --strict passes, the install succeeds, and yet the agent is silently missing from the loaded plugin. So no manifest here names an agents key at all, and test/plugin-manifest.bats fails if one is added.
Since Claude Code v2.1.198 the built-in Explore subagent inherits your main-session model instead of always running on Haiku (capped at Opus on the Claude API). If your daily driver is Opus or Fable, every background codebase search Claude spontaneously delegates bills at that tier. This agent shadows the built-in (a user-level agent with the same name overrides it, which the docs explicitly support) and pins exploration back to haiku at effort: low with read-only tools.
Trade-off to know about: a custom Explore loads your CLAUDE.md/user memory like any subagent, which the built-in skips for speed. That is the cost the explore-model mod exists to remove. To remove the agent, claude plugin uninstall explore-agent, or delete ~/.claude/agents/Explore.md if you took the copy path.
They pin the same thing two ways, and you want exactly one:
explore-agent | explore-model | |
|---|---|---|
| How | An agent file that shadows the built-in | An agent.spawn hook that sets the spawn's model |
Loads CLAUDE.md on every search | Yes | No |
| Keeps the built-in's definition | No, replaces it | Yes |
| Needs function hooks | No | Yes |
Install both and you get neither's benefit: the spawn resolves to your shadow definition rather than the built-in, so the CLAUDE.md load is back and the hook has nothing left to improve. setup installs one and uninstalls the other, and re-running it after you enable function hooks switches you over.
A mod is a Claude Code plugin whose behavior is a hooks module: TypeScript loaded into the engine, one register(on, options) entry whose hooks are ($, e, next) functions wrapping an engine event, Express-middleware style. Where a classic hook is a shell command spawned after the fact, a function hook can rewrite the event, answer it, or refuse it, in process.
Four ship inside Claude Code (sec-default, diff, telemetry, agents-md). The ones here live in mods/.
Early access. Hooks modules load only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is set, on Claude Code 2.1.260 or newer, and upstream says the API they are written against may change between releases without notice. setup offers to write the flag into ~/.claude/settings.json, and defaults to No: nothing else in this repo depends on an unreleased API, and a mod that stops loading after an update is a worse surprise than an agent file that never does.
Two things are worth knowing when you install one:
claude plugin details <mod> reports Hooks (0). The inventory counts classic hook matchers; a function-hooks module is not one, so the count reads zero even for a module that loads and fires. It is not a sign the mod is broken.claude binary, with no credentials and no home directory: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test mods/explore-model. CI runs them for every mod; test/plugin-manifest.bats runs the same command locally and skips where claude is absent.Pins the model that background recon subagents run on, by hooking agent.spawn and setting the spawn's model.
This is the same goal as the explore-agent plugin and a better way to reach it. The agent file has to replace the built-in Explore definition to change its model, and a replacement is loaded like any other subagent, so it drags in your CLAUDE.md and user memory on every search, which the built-in skips for speed. The hook sets one field on the spawn and leaves the built-in definition running, so there is no shadow and no extra load.
Two spawns are handed straight through. A fork inherits its parent's context and model and ignores model outright, so rewriting it would only misdescribe what happens. A spawn that named its own model was an explicit choice by the caller, and the case the hook is here to decide is the one nobody decided.
It is also the enforcement /efficient-orchestration currently has to ask for: that skill instructs the model to pin a model on every spawn, which is a prompt-level plea the model can forget on exactly the code path where forgetting is expensive. The hook makes it an engine-level invariant instead.
| Option | Default | Effect |
|---|---|---|
model | haiku | The alias or id the named agents run on. Prefer an alias: it tracks whichever model answers to that name, where a dated id stops applying the day the next one ships. inherit registers no hook at all. |
agents | Explore | The subagent types this applies to. Only read-only recon agents belong here: a pinned agent runs on model whatever its own definition asks for. |
notice | false | Attach a one-line notice to each spawn the hook pins. Off by default, since the point is to be invisible; turn it on to confirm the mod is loaded and firing. |
claude plugin install explore-model@ai-coding-setup --scope user \
--config model=haiku --config agents=Explore --config notice=false
Note that this is a floor on cost, not the only one: setup also offers CLAUDE_CODE_SUBAGENT_MODEL=sonnet, which is the fallback tier for every subagent. The mod is the narrower, cheaper pin for the one agent Claude dispatches on its own.
The review gate as a tool.check hook. Same gate, better seat.
bin/review-gate is wired as a PreToolUse shell hook and fires on every command the agent runs (overwhelmingly ls, cat and test runs), each one paying a process start. Measured in this repo: roughly 160ms warm under Git Bash on Windows against 55ms for a bare bash -c true, and over a second on a cold file cache. The script's fast path exits before any git call, but bash still has to start.
The mod answers that same question with a substring test inside the engine's own process, and spawns the script only for a command that could actually be a commit. On the calls that dominate, the cost goes to zero.
The second gain is ask. A PreToolUse hook can only allow or deny, which is why the gate ships in warn and why its own docs describe handing the question back to you as something it cannot do; the closest it gets is denying and asking the agent to ask you. tool.check can answer ask, so a blocked commit becomes your permission prompt, carrying the gate's reason, diff and rubric.
What the mod does not do is decide anything. The rules (which commands commit, what a receipt has to match, when a rewrite is in progress, the git commit -a cases that can never be vouched for) stay in bin/review-gate, which the mod runs with the claude payload it already speaks and reads the JSON it already prints. One implementation, one test suite, no second copy to drift, and both routes read the same receipts under .git/ai-review/, so they can never disagree about whether a change was reviewed.
That last point is why the mod does not keep receipts in the engine's own $.store, which would have been the obvious place: code-review-loop writes the receipts, and a store only the mod can read would mean the loop's clean run no longer cleared the gate.
| Option | Default | Effect |
|---|---|---|
blocked | ask | What a blocked commit does. ask puts the gate's reason to you as a permission prompt. deny refuses outright, matching REVIEW_GATE=block. |
gate | unset | Where bin/review-gate is. Unset, the hook tries AI_REVIEW_GATE_BIN, then ~/.local/bin/review-gate, then review-gate on PATH. |
The script's own modes still decide everything else: REVIEW_GATE=off and the AI_REVIEW_GATE=off bypass work exactly as they do without the mod, and in warn mode the gate's reasoning goes to the transcript instead of a stderr nobody reads.
Install the two together and the script runs twice for one commit, issuing a second single-use nonce that invalidates the first, so setup removes the PreToolUse entry when it installs the mod.
claude plugin install review-gate@ai-coding-setup --scope user \
--config blocked=ask --config gate="$HOME/.local/bin/review-gate"
Two multi-agent feedback loops live in bin/: code-review-loop (for staged code) and plan-review-loop (for plan documents), alongside review-gate, the hook that keeps an agent from committing before the first of those has run. Each loop pairs an editor agent with a different reviewer agent and iterates until the reviewer is satisfied or --max-iterations is hit. Using two different models for editing and reviewing surfaces issues a single agent tends to miss in its own output.
Both scripts are installed onto your PATH by ./setup and rely on the prompts in prompts/ (installed to ~/.local/share/ai-coding-setup/prompts/).
Runs a full review cycle over your staged changes:
code-refinement prompt (four review angles, lint, test coverage). Skip with -s.agent-code-review.md.agent-review-summary.md.A pre-review snapshot of your staged work is saved to the git stash so you can restore the original if the loop mangles something. Files in the review scope that also have unstaged changes are rejected up front, so fully stage or unstage before running.
Pass --branch to widen the scope to the whole branch: the diff under review becomes every commit since the branch left the default branch (its merge-base) plus whatever is staged. The default branch is read from origin/HEAD, falling back to main then master; pass --branch REF to name another. Fixes from the loop are still staged, never committed, so you can fold them into the branch however you like.
Usage:
code-review-loop # default agents, 5 iterations
code-review-loop -m 3 # cap at 3 review cycles
code-review-loop -s # skip the refinement step
code-review-loop --branch # review the branch's commits plus staged changes
code-review-loop --branch develop # same, against a named base branch
code-review-loop --editor claude --reviewer codex
Outputs (project root): agent-code-review.md (latest findings), agent-review-summary.md (narrative).
When a run fails, read the logs. Each agent's full output is written to a per-run directory, printed in the banner at startup and again whenever an agent exits non-zero:
Logs : ~/.cache/code-review-loop/20260807-142516
One file per step, named for the step and the agent that ran it:
1-refinement.claude.log
3-review-initial.antigravity.log
4.1-response.claude.log
6.1-review.antigravity.log
final-summary.claude.log
Each records the agent, the tools it was allowed, its combined stdout and stderr, and its exit code. This is the difference between "it failed" and knowing why: a loop t
hooks/register.ts 177 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import type { Verdict } from './gate'
4import { INSTALLED, ON_PATH, commandOf, mayCommit, payloadOf, read } from './gate'
5
6/**
7 * The tools that run a command. A Windows harness exposes PowerShell alongside
8 * Bash, and a matcher naming Bash alone would let every commit made through
9 * the other one straight past -- the same blind spot `./setup` writes
10 * `Bash|PowerShell` to close for the shell-hook route.
11 */
12const SHELLS: readonly string[] = ['Bash', 'PowerShell']
13
14/**
15 * What a blocked commit does when the option names nothing.
16 */
17const DEFAULT_BLOCKED = 'ask'
18
19/**
20 * How long the script may run before the call is abandoned and the commit
21 * allowed. It is sub-second git plumbing by design; this is only a bound on a
22 * pathological repository.
23 */
24const TIMEOUT_MS = 15_000
25
26/**
27 * Where `bin/review-gate` is, or the bare name for the child's own PATH lookup
28 * when no absolute path answers.
29 *
30 * Declared here rather than beside the rest of the gate because the engine
31 * follows `$` only into a function declared in the file the hook is in.
32 *
33 * @param $ the engine
34 * @param configured the `gate` option, where one is set
35 * @returns the path or command name to run, or undefined to stand down
36 */
37async function locate(
38 $: EngineInterface,
39 configured: string | undefined,
40): Promise<string | undefined> {
41 if (configured !== undefined && configured !== '') {
42 return (await $.fs.exists(configured)) ? configured : undefined
43 }
44
45 const named = await $.env.get('AI_REVIEW_GATE_BIN')
46
47 if (named !== undefined && named !== '' && (await $.fs.exists(named))) {
48 return named
49 }
50
51 // An empty HOME is as good as none: fall through to USERPROFILE rather than
52 // probing /.local/bin at the filesystem root.
53 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
54
55 if (home && (await $.fs.exists(`${home}${INSTALLED}`))) {
56 return `${home}${INSTALLED}`
57 }
58
59 return ON_PATH
60}
61
62/**
63 * Runs the script over one command and reads its answer.
64 *
65 * Nothing here knows how the gate decides -- which commands commit, what a
66 * receipt has to match, when a rewrite is in progress -- only how it reports.
67 * That is the point: one implementation of the rules, in the script, under its
68 * own tests, with no second copy to drift.
69 *
70 * @param $ the engine
71 * @param gate where the script is
72 * @param tool the tool as the model names it
73 * @param command the command the tool is about to run
74 * @param cwd the session's working directory
75 * @returns what to do with the command
76 */
77async function consult(
78 $: EngineInterface,
79 gate: string,
80 tool: string,
81 command: string,
82 cwd: string,
83): Promise<Verdict> {
84 try {
85 const run = await $.process.run([gate, '--format=claude'], {
86 cwd,
87 stdin: payloadOf(tool, command, cwd),
88 timeoutMs: TIMEOUT_MS,
89 })
90
91 return read(run.stdout)
92 } catch {
93 // Fail open. A hook that refuses a commit because a subprocess would not
94 // start enforces nothing and blocks everything.
95 return { kind: 'pass' }
96 }
97}
98
99/**
100 * Registers the gate on the permission decision.
101 *
102 * `bin/review-gate` already does this as a `PreToolUse` shell hook, and still
103 * does for the four harnesses that have no other way. Running it here instead
104 * buys two things a spawned hook cannot have.
105 *
106 * The first is the calls it never sees. The hook is asked about every command
107 * the agent runs, overwhelmingly `ls`, `cat` and test runs, and each one pays
108 * a process start: about 160ms under Git Bash on Windows against 55ms for a
109 * bare `bash -c true`. Here that question is a substring test in the engine's
110 * own process, and the script is spawned only for a command that could
111 * actually be a commit.
112 *
113 * The second is `ask`. A `PreToolUse` hook may allow or deny, so the script
114 * has to choose between refusing a commit outright and letting it through with
115 * a warning -- which is why it ships in `warn`, and why handing the question
116 * back to the person is something its own docs describe as out of reach. A
117 * `tool.check` hook may answer `ask`, so the gate's reason goes to whoever the
118 * commit belongs to.
119 *
120 * @param on the engine's registrar
121 * @param options the plugin's options: `blocked` and `gate`
122 */
123export function register(on: On, options: PluginOptions): void {
124 const blocked = options.blocked === 'deny' ? 'deny' : DEFAULT_BLOCKED
125 const configured =
126 typeof options.gate === 'string' && options.gate !== ''
127 ? options.gate
128 : undefined
129
130 // Looked for once, on the first commit-shaped command rather than at
131 // registration: `register` is synchronous, and where the script lives cannot
132 // change while the session runs. Held as the promise rather than its value,
133 // so two commits in flight at once share the one lookup.
134 let finding: Promise<string | undefined> | undefined
135
136 on('tool.check', { tool: SHELLS }, async ($, e, next) => {
137 // A query is not a call. Answering one would spend the single-use nonce
138 // the script issues with a block on a commit that is not happening; the
139 // real call that follows is decided on its own.
140 if (e.tool_use_id === undefined) {
141 return next(e)
142 }
143
144 const command = commandOf(e.input)
145
146 if (command === undefined || !mayCommit(command)) {
147 return next(e)
148 }
149
150 finding ??= locate($, configured)
151
152 const gate = await finding
153
154 if (gate === undefined) {
155 return next(e)
156 }
157
158 const cwd = await $.session.cwd()
159 const verdict = await consult($, gate, e.tool, command, cwd)
160
161 if (verdict.kind === 'pass') {
162 return next(e)
163 }
164
165 // Warn mode: the script decided not to stop the commit, so neither does
166 // this. Its reasoning still goes somewhere a person can read it, which on
167 // the shell-hook route is a stderr nobody looks at.
168 if (verdict.kind === 'warn') {
169 $.ui.log(verdict.reason, 'transcript')
170
171 return next(e)
172 }
173
174 return { decision: blocked, reason: verdict.reason }
175 })
176}
177hooks/gate.ts 142 lines1/**
2 * The parts of the gate that touch nothing: what a command is, whether it is
3 * worth a subprocess, what to feed the script and how to read what it says.
4 *
5 * None of it takes `$`. The engine follows `$` only into a function declared
6 * in the file the hook is in and never across an import, so everything that
7 * calls a noun lives in register.ts and everything testable on its own lives
8 * here.
9 */
10
11/**
12 * What a shell tool's input looks like where it carries a command. The
13 * permission decision reads Bash as `{ command }`; the PowerShell tool a
14 * Windows harness exposes alongside it is the same shape.
15 */
16type ShellInput = {
17 command?: unknown
18}
19
20/**
21 * The claude-shaped answer `bin/review-gate --format=claude` prints: a deny
22 * carries a decision and a reason, a warn carries context and no decision, and
23 * an allow is an empty stdout.
24 */
25type GateOutput = {
26 hookSpecificOutput?: {
27 permissionDecision?: string
28 permissionDecisionReason?: string
29 additionalContext?: string
30 }
31}
32
33/**
34 * What the hook does with a command, once the gate has spoken.
35 *
36 * `pass` covers every allow, including the ones the gate never saw.
37 */
38export type Verdict =
39 | { kind: 'pass' }
40 | { kind: 'block'; reason: string }
41 | { kind: 'warn'; reason: string }
42
43/**
44 * Where `./setup` installs the script, under the home directory.
45 */
46export const INSTALLED = '/.local/bin/review-gate'
47
48/**
49 * The bare name, left to the child's own PATH lookup when no absolute path
50 * answers: `$.fs.exists` cannot search a PATH.
51 */
52export const ON_PATH = 'review-gate'
53
54/**
55 * The substring every commit command contains. Tested before anything else
56 * runs, because this hook is asked about every shell call the agent makes and
57 * almost none of them commit.
58 *
59 * Deliberately the same crude test the script's own fast path makes: the two
60 * have to agree about what is worth looking at, and a cheap false positive
61 * costs one subprocess where a false negative would let a commit through
62 * unseen.
63 */
64const COMMIT = 'commit'
65
66/**
67 * The command a shell tool call will run, where it has one.
68 *
69 * @param input the tool's arguments as the permission decision reads them
70 * @returns the command, or undefined where the input carries none
71 */
72export function commandOf(input: unknown): string | undefined {
73 const command = (input as ShellInput | null)?.command
74
75 return typeof command === 'string' && command !== '' ? command : undefined
76}
77
78/**
79 * Whether a command could possibly be a commit.
80 *
81 * @param command the command the tool is about to run
82 * @returns false when nothing further need be asked about it
83 */
84export function mayCommit(command: string): boolean {
85 return command.includes(COMMIT)
86}
87
88/**
89 * What the script reads on stdin: the claude PreToolUse shape, which its own
90 * `--format=claude` already speaks.
91 *
92 * The tool name rides along so the script quotes the bypass back in the syntax
93 * the agent will actually type it in -- a bash `VAR=value cmd` prefix is a hard
94 * parse error in PowerShell.
95 *
96 * @param tool the tool as the model names it
97 * @param command the command the tool is about to run
98 * @param cwd the session's working directory
99 * @returns the payload
100 */
101export function payloadOf(tool: string, command: string, cwd: string): string {
102 return JSON.stringify({ cwd, tool_name: tool, tool_input: { command } })
103}
104
105/**
106 * Reads the script's stdout.
107 *
108 * Fails open on anything unexpected, the same way the script does about its
109 * own errors: a gate that refuses a commit because it could not parse
110 * something enforces nothing and blocks everything.
111 *
112 * @param stdout what the script printed
113 * @returns what to do with the command
114 */
115export function read(stdout: string): Verdict {
116 const text = stdout.trim()
117
118 if (text === '') {
119 return { kind: 'pass' }
120 }
121
122 let output: GateOutput
123
124 try {
125 output = JSON.parse(text) as GateOutput
126 } catch {
127 return { kind: 'pass' }
128 }
129
130 const said = output.hookSpecificOutput
131
132 if (said?.permissionDecision === 'deny') {
133 return { kind: 'block', reason: said.permissionDecisionReason ?? '' }
134 }
135
136 if (typeof said?.additionalContext === 'string') {
137 return { kind: 'warn', reason: said.additionalContext }
138 }
139
140 return { kind: 'pass' }
141}
142