SLOPSHOPPER

Review Gate

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…

newguardprocess
★ 1v0.1.0MITupdated 2026-09-24rlorenzo/ai-coding-setup/mods/review-gate
A shopper browsing a rack in a slop shop
README

ai-coding-setup

Set of prompts, skills, and scripts to aid in utilizing AI coding agents in development workflows.

Prerequisites

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 commands
  • GitHub CLI (gh) 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 files

Required only for optional steps:

  • Node.js (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:

Installing the prerequisites

ToolmacOS (Homebrew)Debian / UbuntuWindows (winget)
gitbrew install gitsudo apt install gitbundled with Git for Windows
ghbrew install ghgh install docswinget install GitHub.cli
jqbrew install jqsudo apt install jqwinget install jqlang.jq
Node.jsbrew install nodesudo apt install nodejs npmwinget 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 ./setup and the review loops from Git Bash (part of Git for Windows). Git Bash ships Bash and the standard Unix utilities but not jq, so install jq separately with the command above.

Quick Start

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.

Supported Tools

ToolCommand formatSource directoryInstalls to
Claude CodePlugin (.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 CLIAgent Skills (SKILL.md).codex/skills/~/.codex/skills/
Copilot CLIAgent Skills (SKILL.md).copilot/skills/~/.copilot/skills/
Antigravity CLIUnified Plugin (plugin.json).antigravity/~/.gemini/antigravity-cli/plugins/ai-coding-setup/
Kimi Code CLIAgent Skills (SKILL.md).kimi-code/skills/~/.kimi-code/skills/
Shared promptsMarkdown (.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 Plugin

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:

PluginWhat it isNeeds
ai-coding-setupThe seven commandsNothing
explore-agentThe Explore subagent as an agent file that shadows the built-inNothing
explore-modelThe same pinning as an agent.spawn function hook, with no shadowCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
review-gateThe review gate as a tool.check function hookCLAUDE_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:

  • Updates are a 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.
  • Removal is one command. 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.
  • Nothing is written into ~/.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

Available Commands

/commitmsg

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:

  • Claude Code: /commitmsg
  • Codex CLI: $commitmsg
  • Copilot CLI: /commitmsg
  • Antigravity CLI: /commitmsg
  • Kimi Code CLI: /skill:commitmsg

/review-pr

Process 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:

  • Claude Code: /review-pr [PR_NUMBER]
  • Codex CLI: $review-pr [PR_NUMBER]
  • Copilot CLI: /review-pr [PR_NUMBER]
  • Antigravity CLI: /review-pr [PR_NUMBER]
  • Kimi Code CLI: /skill:review-pr [PR_NUMBER]

/code-refinement

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:

  • Claude Code: /code-refinement
  • Codex CLI: $code-refinement
  • Copilot CLI: /code-refinement
  • Antigravity CLI: /code-refinement
  • Kimi Code CLI: /skill:code-refinement

/code-review

Run a standalone code review on staged changes. Writes findings to agent-code-review.md.

Usage:

  • Claude Code: /code-review
  • Codex CLI: $code-review
  • Copilot CLI: /code-review
  • Antigravity CLI: /code-review
  • Kimi Code CLI: /skill:code-review

/dependency-review

Audit 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:

  • Claude Code: /dependency-review
  • Codex CLI: $dependency-review
  • Copilot CLI: /dependency-review
  • Antigravity CLI: /dependency-review
  • Kimi Code CLI: /skill:dependency-review

/efficient-orchestration

Run 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:

  • Claude Code: /efficient-orchestration
  • Codex CLI: $efficient-orchestration
  • Copilot CLI: /efficient-orchestration
  • Antigravity CLI: /efficient-orchestration
  • Kimi Code CLI: /skill:efficient-orchestration

/git-history-cleanup

Rewrite 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:

  • Claude Code: /git-history-cleanup [BRANCH]
  • Codex CLI: $git-history-cleanup [BRANCH]
  • Copilot CLI: /git-history-cleanup [BRANCH]
  • Antigravity CLI: /git-history-cleanup [BRANCH]
  • Kimi Code CLI: /skill:git-history-cleanup [BRANCH]

Claude Code Agents

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.

Explore

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.

explore-agent or explore-model

They pin the same thing two ways, and you want exactly one:

explore-agentexplore-model
HowAn agent file that shadows the built-inAn agent.spawn hook that sets the spawn's model
Loads CLAUDE.md on every searchYesNo
Keeps the built-in's definitionNo, replaces itYes
Needs function hooksNoYes

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.

Mods

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.
  • A mod's tests run against a mock engine in a child of the 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.

explore-model

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.

OptionDefaultEffect
modelhaikuThe 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.
agentsExploreThe subagent types this applies to. Only read-only recon agents belong here: a pinned agent runs on model whatever its own definition asks for.
noticefalseAttach 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.

review-gate mod

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.

OptionDefaultEffect
blockedaskWhat a blocked commit does. ask puts the gate's reason to you as a permission prompt. deny refuses outright, matching REVIEW_GATE=block.
gateunsetWhere 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"

Review Loops

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/).

code-review-loop

Runs a full review cycle over your staged changes:

  1. Refinement: editor agent runs the code-refinement prompt (four review angles, lint, test coverage). Skip with -s.
  2. Stage: any fixes from refinement are staged.
  3. Initial review: reviewer agent writes findings to agent-code-review.md.
  4. Fix → re-review loop: editor responds to findings, reviewer re-reviews, repeat until clean or max iterations.
  5. Summary: editor writes a narrative summary to 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

Source 2 files
hooks/register.ts 177 lines
1import 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}
177
hooks/gate.ts 142 lines
1/**
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