Type to search the docs and updates.

Browse documentation

Hooks

hooks/ holds commands the AI tool runs at fixed points: when a session starts, before a tool call, after a file edit, when the agent stops. An instruction asks the model to do something; a hook makes it happen every time.

  • Safety checks. Block a force push or a write to a generated file before it happens.
  • Automatic follow-up. Format or lint a file right after the agent edits it.
  • Fresh context. Print the branch, open issues, or service status when a session starts.
  • One script, many tools. Tools that share event names, such as Claude Code and Codex, run one spec. AGNOSTIC_AI_TARGET tells a shared script which tool called it.

Sync never runs a hook. The configured tools do. agnostic-ai hook run runs one when you ask. Review hook specs like code.

Write one

Portable events

Write on and match instead of one tool's own event name. Sync writes each tool's native event and matcher, so one spec runs on every tool that has a mapping.

name: no-force-push
description: Block git push --force.
on: before-tool
match: shell
command: .agnostic-ai/scripts/no-force-push.sh

on takes session-start, prompt-submit, before-tool, after-tool, after-edit, stop, or session-end. after-edit runs after a file edit, so it takes no match.

match takes a tool kind: shell, edit, read, web, any, or mcp:<server>. It applies only to before-tool and after-tool. Leave it out, or write any, to run on every tool. edit names every edit tool, including ones an older or newer version of the tool lacks, so a guard does not miss one.

Each tool below gets the events and tool kinds it reads the way Claude Code does: exit 0 lets the call go on, and exit 2 blocks it with stderr as the reason. before-tool blocks the tool call, prompt-submit blocks the prompt, and stop keeps the agent working. after-tool and after-edit map to the tool's own after-tool event, on the edit matcher for after-edit. Codex reads Edit|Write as apply_patch. any writes no matcher on every tool. Copilot only warns on exit 2 outside before-tool, so it maps the session events and before-tool.

Toolonmatch
Claude Codesession-start, prompt-submit, before-tool, after-tool, after-edit, stop, session-endshell Bash, edit Edit|MultiEdit|Write|NotebookEdit, read Read, web WebFetch|WebSearch, mcp:<server> mcp__<server>__.*
Codexsession-start, prompt-submit, before-tool, after-tool, after-edit, stop, session-endshell Bash, edit Edit|Write, mcp:<server> mcp__<server>__.*
Geminisession-start, prompt-submit, before-tool, after-tool, after-edit, stop, session-endshell ^run_shell_command$, edit ^(write_file|replace)$, read ^(read_file|read_many_files)$, web ^(web_fetch|google_web_search)$
Factorysession-start, prompt-submit, before-tool, after-tool, after-edit, stop, session-endshell ^Execute$, edit ^(Create|Edit|ApplyPatch)$, read ^Read$, web ^(FetchUrl|WebSearch)$
Qodersession-start, prompt-submit, before-tool, stop, session-endshell Bash, edit Edit|Write|NotebookEdit, read Read, web WebFetch|WebSearch, mcp:<server> mcp__<server>__.*
OpenHandssession-start, prompt-submit, before-tool, stop, session-endshell terminal
Goosesession-start, before-tool, stop, session-endshell ^shell$, edit ^(write|edit)$
Augmentsession-start, before-tool, session-endshell ^launch-process$, edit ^(str-replace-editor|save-file)$, web ^(web-fetch|web-search)$
Crushbefore-toolshell ^bash$, edit ^(edit|multiedit|write)$
Windsurfprompt-submit, before-tool, stopshell ^exec$, edit ^(edit|write|apply_patch)$, read ^read$, web ^(webfetch|web_search)$
Cursorsession-start, prompt-submit, before-tool, session-endshell ^Shell$, edit ^Write$, read ^Read$
Copilotsession-start, before-tool, session-endshell Bash, edit Edit|Write, read Read, web WebFetch|WebSearch
Clinebefore-toolshell run_commands|execute_command, edit editor|apply_patch|replace_in_file|write_to_file, read read_files|read_file, web fetch_web_content|web_fetch|web_search

Cursor, Copilot, and Cline read a block another way, so sync runs each portable before-tool command, and on Cursor each prompt-submit command, through a wrapper. Exit 2 becomes the tool's deny reply, with stderr as the reason:

  • Cursor gets "permission": "deny", with stderr as user_message and agent_message. At exit 0 the wrapper replies "permission": "allow", or passes the JSON reply the command prints. On prompt-submit (beforeSubmitPrompt) it replies "continue": false with stderr as user_message, or "continue": true. Exit 1 stays, and Cursor goes on as Claude Code does. The wrapper is .cursor/hooks/agnostic-ai-portable-hook.sh.
  • Copilot gets exit 2 with "permissionDecision": "deny" and stderr as permissionDecisionReason. Exit 1 and any other failure stay, and Copilot denies the call, where Claude Code reports an error and goes on. That difference is on purpose: a guard with a typo or a missing dependency keeps blocking on Copilot instead of letting every call through. The wrapper is .github/hooks/scripts/agnostic-ai-portable-hook.sh.
  • Cline gets {"cancel": true} with stderr as errorMessage, written into its event script. A cancel also stops the run, where Claude Code keeps working with the reason. Cline reads no exit 1, so the call goes on unreported. Cline has no matcher, so the script runs the command only when the payload names one of the kind's tools, in the Cline CLI and the VS Code extension alike.

The wrappers are bash scripts. On Windows, where a tool runs hook commands through PowerShell, it cannot start them: Cursor goes on with an error, and Copilot denies the call. A hook written with event syncs as written, with no wrapper. sync --global writes the wrapper beside the user hooks file, such as ~/.cursor/hooks/agnostic-ai-portable-hook.sh.

Cursor's other events stay unmapped. Its postToolUse and afterFileEdit (for after-tool and after-edit) and its stop cannot block: exit 2 there is a failure Cursor moves past, where Claude Code feeds stderr to the model or keeps the agent working. Its stop can only send a follow-up message, which is not the same as a block (Cursor hooks). session-start and session-end map without a wrapper, since neither tool can block there. Cursor runs both fire-and-forget, so it does not wait for them, and it does not add plain stdout to the session as Claude Code does on session-start.

Decision on stdout

Set decision: stdout on a before-tool hook to decide with a JSON object instead of an exit code:

name: no-force-push
on: before-tool
match: shell
decision: stdout
command: .agnostic-ai/scripts/no-force-push.sh
echo '{"decision": "deny", "reason": "Use --force-with-lease."}'

With decision: stdout, stdout carries only the decision. At exit 0, "decision": "deny" blocks the call with reason as the message, the same as exit 2 with that message on stderr. "ask" blocks too, since not every tool can ask the user. "allow", or empty stdout, goes on as a plain exit 0, so it never grants more than the tool's own permission rules do. Anything else blocks, so a broken guard never lets a call through: stdout that is not one JSON object, an object with no top-level decision or more than one, or a decision of another value. Only the top level counts, so a decision inside a nested object never overrides the verdict. Escaped control characters stay distinct in keys and values; raw NUL and SOH bytes block. Print logs to stderr. Any exit code other than 0 keeps its usual meaning.

No tool reads this object natively, so sync runs the command through the same wrapper on every tool, including each x-gemini.hooks command handler, which turns the decision into that tool's block. Augment runs a hook command only as a bare script path, so it cannot run the wrapper: validate names a decision: stdout hook that reaches it. The wrapper needs bash, so decision: stdout cannot go with commandWindows or shell: powershell. Parsing blocks on stdout over 1,000,000 bytes, more than 10,000 values, or more than 64 nested containers. A \u escape outside ASCII reads as ?. Sync writes the wrapper again for a hook imported from a synced file that calls it.

Hooks Cursor and Copilot also read

Cursor and Copilot also run .claude/settings.json hooks. When cursor is a target, sync puts a check before the Claude Code copy of each portable hook that reaches Cursor too: [ "$AGNOSTIC_AI_TARGET" = cursor ] && exit 0;. Cursor's sessionStart hook sets that variable, so the Claude Code copy exits under Cursor and the hook runs once there, as Cursor's own copy. Claude Code sets the variable to claude, and an unset variable runs the hook, so Claude Code never skips it.

  • A hook that fires before Cursor's sessionStart hook returns still runs twice.
  • A hook with args or shell: powershell has no POSIX shell to read the check, so it runs twice on Cursor.
  • Copilot gives the Claude Code copy no variable, so a hook synced to claude and copilot still runs twice on Copilot. Both copies block on exit 2.
  • sync --global adds no check, so a user hook synced to both runs twice on Cursor.

A spec sets on or event, never both. match goes with on, and matcher with event. validate and lint (LINT032) report an unknown value, a mixed form, an event that a target the hook reaches does not read the same way, such as on: stop on Crush, and a tool kind it has no tool for, such as match: read on Codex.

A portable hook does not reach Kiro or Trae yet. Sync prints a note with the count, and hook run lists them as not run with the reason. Write event for those tools, or scope the hook with targets.

agnostic-ai migrate --only hooks rewrites event and matcher as on and match when the portable form gives every target the hook reaches the same event and matcher, so synced files stay the same. It leaves every other hook as written and says why. A Claude Code hook on Edit|Write stays native, since match: edit there also runs on MultiEdit and NotebookEdit.

lint warns on each hook the migration would rewrite (LINT034) and names the on and match to write. agnostic-ai import follows the same rule: it writes on and match when they give every target the imported hook reaches the same event and matcher, and the native names otherwise. Sync then writes the imported tool's file back unchanged.

Native events

agnostic-ai new hook session-status creates hooks/session-status.yaml. Pure YAML, no markdown body.

name: session-status
description: Show repository status when a session starts.
targets: [claude, codex]
event: SessionStart
command: "git status --short"

Format Go files after the agent edits them, on Claude Code and Codex alike. agnostic-ai hook paths reads the edited files from the event JSON on stdin, whichever tool sent it.

name: gofmt-on-edit
description: Format the Go files the agent edits.
targets: [claude, codex]
event: PostToolUse
matcher: Edit|Write
command: 'files=$(agnostic-ai hook paths) || exit 1; printf "%s\n" "$files" | grep "\.go$" | while IFS= read -r f; do gofmt -w "$f"; done'

Codex takes Edit and Write as aliases for apply_patch, so the one matcher fires on both tools. agnostic-ai must be on the hook's PATH. The || exit 1 fails the hook when hook paths fails, for example on a missing target, bad JSON, or a missing binary. A plain pipe into the loop would exit 0.

Run a script with exact arguments and no shell, so spaces and $ pass through untouched:

name: guard-commands
event: PreToolUse
matcher: Bash
target: claude
command: scripts/hooks/guard.sh
args: ["--deny", "git push --force"]
timeout: 10

Command hooks receive event JSON on stdin. Read the shell command from the target's tool_input fields. Read the edited paths with agnostic-ai hook paths. AGNOSTIC_AI_TARGET names the target that ran the hook; see which target ran a hook.

Fields

FieldRequiredDefaultDescription
namenofilenameHook identifier.
descriptionnoemptyFree-form documentation.
onon or eventnonePortable event, translated per target. See portable events.
matchnoevery toolTool kind for on: before-tool or after-tool. See portable events.
decisionnononestdout reads a {"decision": ..., "reason": ...} object at exit 0 on on: before-tool. See decision on stdout.
eventon or eventnoneHook event, written verbatim. See events.
matchernoemptyRegex on the tool name, or another event-specific selector.
commandcommand handlers onlynoneShell command, or a list where each entry becomes its own handler.
argsnoemptySwitches to exec form: command runs as an executable with args as its argument vector and no shell, so spaces, $, and backticks pass verbatim. Leave unset when the command needs a pipe or &&. Claude Code, Qoder, and Copilot keep args apart. The other targets have no exec form and run command in a shell, so they get the args folded into it, each quoted for a POSIX shell (echo 'shared' 'two words'). On Windows, Trae's PowerShell and OpenHands' cmd.exe can read those quotes differently, and sync notes it. Augment runs only a script path, so it skips a hook with args, with a note.
typenocommandcommand, http, mcp_tool, or prompt, where the target supports it.
timeoutnononeSeconds before the tool cancels the hook. Some targets convert to milliseconds or apply their own default.
disablednofalseKeep the hook defined but stop it running. Antigravity and Kiro write enabled: false; OpenCode and Kilo write no plugin module; other targets emit the hook unchanged.

Handler-specific fields emit only where the target's schema defines them:

  • server, tool, input (type: mcp_tool): Claude Code, Codex.
  • url, headers, allowedEnvVars (HTTP handler): Claude Code, Qoder, Copilot.
  • prompt, model (prompt handler): Claude Code, Qoder, Cursor, Copilot (sessionStart only).
  • statusMessage, async: Claude Code, Codex, Qoder.
  • asyncRewake, shell, if: Claude Code, Qoder.
  • continueOnBlock: Claude Code. commandWindows, additionalContextLimit: Codex. failClosed: Cursor. loop_limit: Cursor, Trae.
  • x-goose.on_failure (Goose), x-kiro.action (Kiro), x-gemini.hooks, x-gemini.sequential, x-gemini.name, x-gemini.env (Gemini).

command is not needed for a non-command handler, a valid x-kiro.action, or a hook that sets x-gemini.hooks. Scope a non-command hook to the targets that support it with target or targets.

Events

event is written verbatim. Names are never translated between tools; for one spec across tools, use portable events.

  • Claude Code and Codex share PreToolUse, PostToolUse, and UserPromptSubmit, so one spec feeds both.
  • Other tools need their own names, such as Cursor's beforeShellExecution or Gemini's BeforeTool.
  • agnostic-ai validate flags an event a target does not recognize.
  • sync and sync --check stop before writing on an event that is a likely typo of a known one, such as PreToolUze, and name the closest.
  • Targets without hook support log a warning and skip.

Shared hook scripts

Keep one script under .agnostic-ai/scripts/ and reference that path in command. agnostic-ai init --demo seeds this guard, which blocks git push --force and allows --force-with-lease on Claude Code and Codex:

name: no-force-push
description: Block git push --force and allow --force-with-lease.
targets: [claude, codex]
event: PreToolUse
matcher: Bash
command: '"$CLAUDE_PROJECT_DIR/.agnostic-ai/scripts/no-force-push.sh"'
commandWindows: '$s = "$CLAUDE_PROJECT_DIR/.agnostic-ai/scripts/no-force-push.sh"; if (-not (Get-Command sh -ErrorAction SilentlyContinue)) { exit 1 }; sh $s; exit $LASTEXITCODE'
timeout: 10

The script reads tool_input.command from the event JSON on stdin, with no jq. It splits the command into words the way sh does, so a git push --force in quotes, a comment, or a heredoc body passes, and a push with a +main refspec or --mirror counts as forced unless it uses --force-with-lease. A quoted command inside bash -c or eval is text too, so it passes. --force or -f blocks even next to --force-with-lease, since git lets it override the lease. A force push spelled outside quotes blocks even behind a wrapper the script does not parse, such as xargs. On a force push it prints the reason on stderr and exits 2, which both tools read as a block.

Both tools start a hook in the session directory, which can be below the project root, so the path starts at the root. Claude Code keeps $CLAUDE_PROJECT_DIR; Codex gets $(git rev-parse --show-toplevel), in both commands, plus the project's path below the Git root.

On Windows, Codex runs commandWindows with powershell.exe -Command:

  • PowerShell ignores the script's #!/bin/sh line, so the command names sh. That needs sh on PATH, as Git for Windows provides.
  • PowerShell reports a failed native command as exit 1, which Codex reads as a hook error and lets the push run. exit $LASTEXITCODE passes on the script's exit 2.
  • The Git root lookup runs first and resets $LASTEXITCODE, so the command checks for sh with Get-Command and exits 1 when it is missing. Codex reports that as a failed hook instead of passing it.

Claude Code runs hooks with Git Bash on Windows and needs no commandWindows. Without Git Bash it runs them with PowerShell, where this guard cannot run.

.agnostic-ai/scripts/no-force-push.sh
#!/bin/sh
# Blocks git push --force, -f, --mirror, or a +refspec, and lets
# --force-with-lease through. It catches a mistake. It is not a sandbox.

Reads tool_input.command from the hook JSON on stdin and splits it into

words the way sh would: quotes, backslashes, line continuations,

comments, redirections, heredoc bodies, and $( ) or backtick

substitutions. It checks each command between unquoted ; & | ( ) and

newlines, past reserved words such as if and then. A heredoc it cannot

read ends the check, since a missed push beats blocking text. A coarse

pass over the unquoted text then blocks a force push the parse missed.

awk -v q='"' -v sq="'" ' function flush() { if (inword && redirect) redirect = 0 else if (inword) { words[++n] = w quoted[n] = wq } w = "" inword = 0 wq = 0 }

Drops a file descriptor number written right before a redirection.

function redirect_from() { if (inword && !wq && w ~ /^[0-9]+$/) { w = "" inword = 0 } flush() redirect = 1 }

Returns the index of the word that names the program, past reserved

words, assignments, and wrappers such as env and sudo.

function program( i, a, wrapper) { for (i = 1; i <= n; i++) { a = words[i] if (!quoted[i] && a ~ /^(if|then|else|elif|do|while|until|!|time|{)$/) continue if (a ~ /^[A-Za-z_][A-Za-z0-9_]*=/) continue if (a ~ /^(command|exec|env|nohup|nice|sudo|xargs)$/) { wrapper = a; continue } if (wrapper != "" && a ~ /^-/) { if ((wrapper == "env" && a ~ /^(-[uCS]|--(unset|chdir|split-string))$/) || (wrapper == "exec" && a == "-a") || (wrapper == "nice" && a ~ /^(-n|--adjustment)$/) || (wrapper == "xargs" && a ~ /^-[InLPsEda]$/) || (wrapper == "sudo" && a ~ /^(-[ugCDpUrtTR]|--(user|group|close-from|chdir|prompt|other-user|role|type|command-timeout|host))$/)) i++ continue } if (i > 1 && words[i - 1] == "time" && a == "-p") continue return i } return n + 1 }

function check( i, j, k, a, plus, lease, positional) { i = program() if (i > n || words[i] !~ /(^|/)git(.exe)?$/) return for (i++; i <= n && words[i] ~ /^-/; i++) if (words[i] ~ /^(-C|-c|--git-dir|--work-tree|--namespace)$/) i++ if (i > n || words[i] != "push") return for (j = i + 1; j <= n; j++) { a = words[j] if (positional || a !~ /^-./) { if (a ~ /^+/) plus = 1 continue } if (a == "--") { positional = 1; continue } if (a == "--force") { blocked = 1; return } if (a ~ /^--force-with-lease(=|$)/) { lease = 1; continue } if (a == "--mirror") { plus = 1; continue } if (a ~ /^--(repo|receive-pack|exec|push-option)$/) { j++; continue } if (a ~ /^--/) continue for (k = 2; k <= length(a); k++) { if (substr(a, k, 1) == "f") { blocked = 1; return } if (substr(a, k, 1) == "o") { if (k == length(a)) j++; break } } } if (plus && !lease) blocked = 1 }

function end_command() { flush() check() n = 0 redirect = 0 }

Starts a subshell or substitution. A substitution keeps the words of the

command around it, which continue once it closes.

function open_nested(kind, back, i) { depth++ kinds[depth] = kind backs[depth] = back if (kind == "(") { end_command(); return } saved_n[depth] = n for (i = 1; i <= n; i++) { saved_words[depth, i] = words[i] saved_quoted[depth, i] = quoted[i] } saved_w[depth] = w saved_wq[depth] = wq saved_redirect[depth] = redirect n = 0 w = "" inword = 0 wq = 0 redirect = 0 }

function close_nested( i) { end_command() mode = backs[depth] if (kinds[depth] != "(") { n = saved_n[depth] for (i = 1; i <= n; i++) { words[i] = saved_words[depth, i] quoted[i] = saved_quoted[depth, i] } w = saved_w[depth] "$()" inword = 1 wq = saved_wq[depth] redirect = saved_redirect[depth] } depth-- }

Queues the delimiter of the heredoc whose word starts at p and returns

the position of its last character, or 0 for a form it cannot read.

function heredoc_word(p, strip, word, c, closing) { strip = 0 if (substr(cmd, p, 1) == "-") { strip = 1; p++ } while (substr(cmd, p, 1) == " " || substr(cmd, p, 1) == "\t") p++ word = "" for (; p <= size; p++) { c = substr(cmd, p, 1) if (c == sq || c == q) { closing = index(substr(cmd, p + 1), c) if (!closing) return 0 word = word substr(cmd, p + 1, closing - 1) p += closing continue } if (c == "\") { p++; word = word substr(cmd, p, 1); continue } if (index(" \t\n;&|()<>", c)) break word = word c } if (word == "") return 0 heredocs[++pending] = word strips[pending] = strip return p - 1 }

Skips the queued heredoc bodies that start after the newline at p and

returns the position of the newline that ends the last terminator.

function skip_bodies(p, h, rest, end, line) { for (h = 1; h <= pending; h++) { do { if (p >= size) { pending = 0; return size } rest = substr(cmd, p + 1) end = index(rest, "\n") line = end ? substr(rest, 1, end - 1) : rest p = end ? p + end : size if (strips[h]) sub(/^\t+/, "", line) } while (line != heredocs[h]) } pending = 0 return p }

Backs up the parse with a coarse look at the unquoted text, so a wrapper

or syntax the parse does not follow still blocks: within a span between

separators, git, then push as its subcommand, then --force, -f, or a

--mirror or +refspec without --force-with-lease.

function coarse( spans, count, s, nw, ws, i, j, a, plus, lease) { count = split(raw, spans, /[;&|\n]/) for (s = 1; s <= count; s++) { nw = split(spans[s], ws, /[ \t\r]+/) for (i = 1; i <= nw; i++) { if (ws[i] !~ /(^|/)git(.exe)?$/) continue for (j = i + 1; j <= nw && ws[j] ~ /^-/; j++) if (ws[j] ~ /^(-C|-c|--git-dir|--work-tree|--namespace)$/) j++ if (ws[j] != "push") continue plus = 0 lease = 0 for (j++; j <= nw; j++) { a = ws[j] if (a == "--force" || a ~ /^-[A-Za-z]f[A-Za-z]$/) return 1 if (a ~ /^--force-with-lease(=|$)/) lease = 1 else if (a ~ /^+/ || a == "--mirror") plus = 1 } if (plus && !lease) return 1 } } return 0 }

{ json = json $0 "\n" }

END { i = index(json, q "command" q) if (i == 0) exit 0 s = substr(json, i + 9) if (!match(s, /^[ \t\r\n]:[ \t\r\n]/)) exit 0 s = substr(s, RLENGTH + 1) if (substr(s, 1, 1) != q) exit 0 s = substr(s, 2)

cmd = "" while (length(s) > 0) { c = substr(s, 1, 1) if (c == q) break if (c != "\") { cmd = cmd c; s = substr(s, 2); continue } e = substr(s, 2, 1) if (e == "u") { code = tolower(substr(s, 3, 4)) if (code == "0026") cmd = cmd "&" else if (code == "003c") cmd = cmd "<" else if (code == "003e") cmd = cmd ">" else if (code == "0027") cmd = cmd sq else cmd = cmd " " s = substr(s, 7) continue } if (e == "n") cmd = cmd "\n" else if (e == "t") cmd = cmd "\t" else if (e == "r" || e == "b" || e == "f") cmd = cmd " " else cmd = cmd e s = substr(s, 3) }

n = 0 w = "" inword = 0 mode = "" raw = "" size = length(cmd) for (p = 1; p <= size && !blocked; p++) { c = substr(cmd, p, 1) e = substr(cmd, p + 1, 1) if (mode == "single") { if (c == sq) mode = "" else w = w c continue } if (c == "\") { p++ if (e == "\n") continue if (mode == "double" && e != q && e != "\" && e != "$" && e != "&quot;) w = w c w = w e wq = 1 inword = 1 if (mode == &quot;&quot;) raw = raw &quot; &quot; continue } if (mode == &quot;double&quot;) { if (c == q) mode = &quot;&quot; else if (c == &quot;$&quot; &amp;&amp; e == &quot;(&quot;) { p++; open_nested(&quot;$(&quot;, &quot;double&quot;); mode = &quot;&quot; } else if (c == &quot;") { open_nested("&quot;, &quot;double&quot;); mode = &quot;&quot; } else w = w c continue } raw = raw &quot; &quot; if (c == q) { mode = &quot;double&quot;; inword = 1; wq = 1; continue } if (c == sq) { mode = &quot;single&quot;; inword = 1; wq = 1; continue } if (c == &quot;#&quot; &amp;&amp; !inword) { while (p &lt; size &amp;&amp; substr(cmd, p + 1, 1) != &quot;\n&quot;) p++ continue } if (c == &quot;$&quot; &amp;&amp; e == &quot;(&quot; &amp;&amp; substr(cmd, p + 2, 1) != &quot;(&quot;) { p++; open_nested(&quot;$(&quot;, &quot;&quot;); continue } if ((c == &quot;&lt;&quot; || c == &quot;&gt;&quot;) &amp;&amp; e == &quot;(&quot;) { p++; open_nested(&quot;$(&quot;, &quot;&quot;); continue } if (c == &quot;") { if (depth && kinds[depth] == "&quot;) close_nested() else open_nested(&quot;", "") continue } if (c == "(") { open_nested("(", ""); continue } if (c == ")") { if (depth) close_nested() else end_command() continue } if (c == "<" && substr(cmd, p, 3) == "<<<") { redirect_from(); p += 2; continue } if (c == "<" && e == "<") { redirect_from() redirect = 0 p = heredoc_word(p + 2) if (!p) { stopped = 1; break } continue } if (c == "&" && e == ">") { flush() redirect = 1 p += (substr(cmd, p + 2, 1) == ">") ? 2 : 1 continue } if (c == ">" || c == "<") { redirect_from() if ((c == ">" && (e == ">" || e == "&" || e == "|")) || (c == "<" && (e == "&" || e == ">"))) p++ continue } if (c == " " || c == "\t" || c == "\r") { flush(); continue } if (index(";&|\n", c)) { raw = raw c end_command() if (c == "\n" && pending) p = skip_bodies(p) continue } raw = substr(raw, 1, length(raw) - 1) c w = w c inword = 1 } if (!blocked && !stopped) end_command() if (!blocked && coarse()) blocked = 1 exit (blocked ? 3 : 0) }'

awk exits 2 on its own errors, so a block comes back as 3.

case $? in 0) exit 0 ;; 3) echo "Blocked: git push --force rewrites the remote branch. Use --force-with-lease, or run the push yourself." >&2 exit 2 ;; *) exit 1 ;; esac

Check it before a session does:

agnostic-ai hook run no-force-push --bash 'git push --force origin main' --expect block
agnostic-ai hook run no-force-push --bash 'git status' --expect allow

A guard like this catches a mistake. It is not a sandbox: a command written to get around it, such as a push from a script file or an alias, gets through. To stop a command for good, use permissions.deny and each tool's own sandbox: Claude Code, Codex.

sync copies the script into each target's script directory and rewrites the command to run that copy.

  • Script bytes, permissions, and subdirectories under scripts/ stay intact.
  • Make the source executable when the command runs it directly.
  • A missing referenced script fails sync.
  • A file at .agnostic-ai/scripts/<target>/guard.sh overrides the shared body for that target.

For a shell command that starts an interpreter, quote the path: command: 'node ".agnostic-ai/scripts/guard.mjs"'. The reference can also follow a project-root variable. Existing .claude/hooks/, .codex/hooks/, and .gemini/hooks/ references keep their current behavior.

sync --global reads bodies from the global source root's scripts/ directory and copies them into the selected tools' user hook directories. Its commands use absolute paths to those user copies. Script sharing does not translate event names or reply formats, so choose ones each target supports.

Where each target keeps its scripts

Most targets use .<target>/hooks/. The exceptions:

TargetScript directory
Gooseits plugin's hooks/ directory, following outputs.goose.hooks-file
Windsurf.devin/hooks/
Antigravity.agents/hooks/
Zed.zed/hooks/, when outputs.zed.tasks-file is set
Cline.cline/hooks/scripts/, apart from native hook definitions
Copilot.github/hooks/scripts/, apart from native hook definitions
Kiro.kiro/scripts/, outside its hook-definition directory

Imported project-root paths

A shell-form command imported from Claude Code can use $CLAUDE_PROJECT_DIR or ${CLAUDE_PROJECT_DIR} to name a script:

name: guard
event: PreToolUse
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh"'

What sync writes for the variable:

  • Claude Code, Cursor, and Trae provide it, so sync keeps it.
  • Gemini, Qoder, and Factory use their native project-root variable.
  • Other targets use $(git rev-parse --show-toplevel) in a POSIX shell. Codex uses it in commandWindows too, since PowerShell reads $(...) the same way.

If the configuration lives in a subdirectory of a Git worktree, the emitted path includes that subdirectory. A hook started from a descendant still resolves to the configured project. The sibling hook-directory rewrite applies too, such as .claude/hooks/ to .codex/hooks/. Custom script paths keep their directory.

Global sync

sync --global uses the runtime Git root for those targets. It never binds a command to the global specs checkout. This fallback needs Git and a hook running inside the intended Git worktree. It cannot locate a project outside Git, or tell apart multiple configured projects within one worktree.

These stay literal: exec-form args, escaped dollars, and single-quoted variables.

These get a note naming the hook when the target cannot preserve them:

  • parameter operators such as ${CLAUDE_PROJECT_DIR:-fallback} and ${#CLAUDE_PROJECT_DIR}
  • nested substitutions and here-documents
  • non-POSIX root syntax
  • a project without a Git worktree

on-unsupported: error fails the sync; silent hides the note. Use a target-specific command or target: claude for these cases. For Windows, set the target's Windows command explicitly.

Which target ran a hook

A shared script reads AGNOSTIC_AI_TARGET to pick the reply protocol (Claude Code and Codex read exit code 2 and stderr; Cursor reads JSON).

case "$AGNOSTIC_AI_TARGET" in
  cursor) echo '{"permission":"deny","agent_message":"Blocked."}' ;;
  *) echo "Blocked." >&2; exit 2 ;;
esac

A spec that sets AGNOSTIC_AI_TARGET in its own env keeps that value. Where sync cannot set it, the parent process's value stays, such as claude for a tool started from Claude Code. Sync sets it per target:

  • Claude Code: env in .claude/settings.json (~/.claude/settings.json for sync --global). Set for the whole session, so the Bash tool sees it too.
  • Cursor: a sessionStart hook returns the variable. sessionStart hooks and hooks that fire before it returns do not see it.
  • Codex: export AGNOSTIC_AI_TARGET=codex; before command. Not set on Windows (commandWindows), and needs a POSIX session shell (a pwsh or nu login shell breaks it).
  • Gemini, Qoder, Copilot: env on each command handler.
  • Goose, Crush, Cline: export prefix or line.
  • OpenCode, Kilo: .env() on each plugin command. Zed: env on each task.

Trae, Factory, OpenHands, Antigravity, Kiro, Windsurf, and Augment do not get the variable. None has a per-hook env, and a prefix would break hooks that work today. Tell them apart by their own variables, such as TRAE_PROJECT_DIR, FACTORY_PROJECT_DIR, OPENHANDS_PROJECT_DIR, DEVIN_PROJECT_DIR, or AUGMENT_PROJECT_DIR. CLAUDE_PROJECT_DIR does not identify Claude Code, because Cursor and Trae provide it too.

sync --global leaves a matching hand-written hook alone, so an adopted Codex, Gemini, or Qoder entry does not get the variable. Cursor and Copilot also run .claude/settings.json hooks but read no env from it. Cursor still gets cursor from sessionStart. Copilot gets nothing.

Read the edited paths

agnostic-ai hook paths reads a hook payload on stdin and prints each file the edit leaves on disk, one per line.

  • Paths print relative to the directory the hook runs in. A path outside it prints in full.
  • A tool call that is no edit prints nothing.
  • The target comes from AGNOSTIC_AI_TARGET, or from --target, which wins. Codex on Windows (commandWindows) gets no AGNOSTIC_AI_TARGET, so pass --target codex there.
  • With no target, a payload whose tool_input object holds file_path, edits, or notebook_path reads as Claude Code. Cursor and Copilot run .claude/settings.json hooks with that payload and no variable.
  • Any other payload with no target fails. Invalid JSON, and an edit tool's tool_input that is not an object, fail too (exit 1).
agnostic-ai hook paths            # src/app.go
agnostic-ai hook paths --action   # update<TAB>src/app.go
agnostic-ai hook paths --json     # [{"action": "update", "path": "src/app.go"}]

A plain run skips deleted files and the source of a move, so a formatter sees only files that exist. --action and --json list every change as add, update, delete, or move.

  • A move reads as a delete of its source and a move of its destination. --json gives the destination a from.
  • When a tool does not say whether a write created the file, the change reads as update.
  • A hook that runs before the edit sees no new file on disk yet, so run formatters after the edit.
TargetWhat it readsVendor docs
Claude Codetool_input.file_path of Edit, Write, and MultiEdit; tool_input.notebook_path of NotebookEditHooks guide
CodexThe *** Add File:, *** Update File:, *** Delete File:, and *** Move to: headers of the apply_patch body in tool_input.command, every file in the patchHooks
Cursorfile_path of afterFileEdit and afterTabFileEdit only; other Cursor events print nothingHooks
Geminitool_input.file_path of write_file and replace, a relative path starting at cwdHooks reference, file system tools
Factorytool_input.file_path of Create and EditHooks
Qodertool_input.file_path of Write; Edit fails, since the docs show none of its tool_input fieldsCLI hooks
Augmentfile_changes[].path with its changeType; before the edit, tool_input.path of str-replace-editor and save-fileHooks

Factory and Augment do not get AGNOSTIC_AI_TARGET, so give their hooks their own spec with --target. Factory documents no input for ApplyPatch, so a Factory patch prints nothing.

Targets the command does not read

The command fails for these targets:

  • Windsurf: sync writes Devin CLI hooks, and the Devin CLI docs name the edit, write, and apply_patch tools but not their tool_input fields.
  • Trae: the docs list no tool_input fields for the edit tools.
  • Copilot: the docs list no toolArgs keys for edit, create, or apply_patch.
  • Goose, Antigravity: the docs name the edit tools' arguments but show no edit hook payload.
  • OpenHands: the docs name no file edit tool.
  • Kiro: the docs list no fs_write input. A PostFileSave command can use its filePath template variable.
  • Cline: the current docs do not describe the script payload.
  • Crush: only PreToolUse runs, and it sets CRUSH_TOOL_INPUT_FILE_PATH.
  • OpenCode and Kilo: plugins get tool arguments as JavaScript objects, not a payload on stdin.
  • Zed: no hook fires on an edit.

Spec guard hook

agnostic-ai init --demo seeds two hooks that check your specs while the agent works, on Claude Code, Codex, and Gemini:

  • spec-guard-edit runs after each edit. When the agent edits a spec, the agent sees the lint errors in that file on its next turn. Any other edit prints nothing.
  • spec-guard-stop runs when the agent stops. When specs changed without a sync, the agent sees one line: Specs changed since the last sync: run agnostic-ai sync. It never runs sync itself.
name: spec-guard-edit
targets: [claude, codex, gemini]
on: after-edit
command: 'command -v agnostic-ai >/dev/null 2>&1 || exit 0; agnostic-ai hook guard after-edit'

Both fail open: with agnostic-ai missing from PATH, or a payload hook guard cannot read, they exit 0 with no output. Other tools keep the commit-time check of install-hook.

Limits:

  • Gemini on Windows runs hook commands in Windows PowerShell, which cannot parse the command -v line, so each run there fails with a non-blocking error. Remove gemini from the hooks' targets on Windows.
  • Factory runs after-edit and stop too, but it does not document the shell its hooks run in, so the hooks leave it out.
  • The stop notice covers specs that changed without a sync. A hand edit to a generated file is left to sync --check.

Test a hook

agnostic-ai hook run <hook> runs a hook spec the way each target would, before a session fires it. A hook that blocks on Claude Code and does nothing on Codex shows up here, not in a live session:

$ agnostic-ai hook run protect-files --edit .github/workflows/tests.yml
claude: block (exit 2, 12ms)
  event: PreToolUse (Write)
  command: .claude/hooks/protect-files.sh
  stderr: Blocked: .github/workflows/tests.yml is protected.
codex: allow (exit 0, 9ms)
  event: PreToolUse (apply_patch)
  command: export AGNOSTIC_AI_TARGET=codex; .codex/hooks/protect-files.sh
hook protect-files: targets decide differently: claude block, codex allow

For each configured target the hook reaches, it runs every command sync wrote for that target, from the project root, or from the handler's cwd on Copilot.

  • Payload. --edit <path> and --bash <command> build a PreToolUse or PostToolUse tool call (BeforeTool or AfterTool on Gemini). --prompt <text> builds UserPromptSubmit (BeforeAgent on Gemini). SessionStart takes its source from the matcher, or startup when the matcher is empty. --payload <file> sends a JSON file as is, for any event.
  • Matcher. If the matcher does not match the tool or source, the target would not run the hook. That target reports allow and why.
  • Matcher on --payload. A tool call from --payload matches its tool_name with the target's matcher rules, including Codex's Edit and Write aliases for apply_patch. On Cursor, the matcher tests what Cursor documents for the event: the command, tool_name, or subagent_type, or a fixed name such as Read for beforeReadFile. Goose uses the supplied matcher_context on every event: the tool name, shell command, file path, or prompt text. On Copilot, the matcher tests toolName, notification_type, trigger, or agentName, and a PascalCase PreToolUse or PermissionRequest uses Claude-format matchers on tool_name. On Kiro, it tests tool_name or its documented alias, such as shell for execute_bash, and the prompt on UserPromptSubmit.
  • Missing event. A target without the hook's event, such as Codex for Notification, is listed as not run.
  • Timeout. The timeout sync writes (Gemini's is in milliseconds), or the tool's default.
  • Async. An async: true hook, or an asyncRewake: true one on Claude Code and Qoder, runs and prints its output. Its result is not judged and stays out of --expect and the comparison, because Claude Code, Codex, OpenHands, and Qoder do not wait for it. Cursor sessionStart and sessionEnd hooks, Copilot notification hooks, and Cline hooks on any event but PreToolUse and PostToolUse are not judged too, because the tool runs them fire-and-forget.
  • Background commands. On macOS and Linux, a command the hook leaves running is killed once the hook exits.
  • Stale native file. Commands come from the spec, so the run works before a sync. The target prints a warning naming the file when the synced file is missing, or when no handler under the event runs the command the spec produces. It also warns when that handler's group matcher, timeout, or (on Gemini) env differs from the spec. An env warning names the keys that differ, never their values. A warning does not fail the run; sync clears it. The files are .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .trae/hooks.json, .openhands/hooks.json, the Goose plugin hooks/hooks.json, .augment/settings.json, .cursor/hooks.json, .github/hooks/agnostic-ai.json, crush.json, .factory/hooks.json, .qoder/settings.json, .agents/hooks.json (Antigravity), .kiro/hooks/<name>.json, .devin/hooks.v1.json (Windsurf), .clinerules/hooks/<Event>, or the path outputs sets. On Cursor it also compares failClosed, on Copilot cwd, and on Kiro confirm.
Codex sync checks

A Codex matcher that joins this spec's segments with another spec's counts as in sync, since sync merges them. So does a Codex timeout when the spec sets none. On Windows, Codex compares commandWindows.

Environment variables per target

Variables listed here are removed from the calling shell's env first. Other variables pass through.

TargetVariables set
Claude CodeAGNOSTIC_AI_TARGET=claude, CLAUDE_PROJECT_DIR
Codexnone; its command sets the target itself
GeminiGEMINI_PROJECT_DIR, GEMINI_CWD, GEMINI_SESSION_ID, CLAUDE_PROJECT_DIR, then the handler's env, which holds AGNOSTIC_AI_TARGET=gemini
TraeTRAE_PROJECT_DIR, CLAUDE_PROJECT_DIR
OpenHandsOPENHANDS_PROJECT_DIR, OPENHANDS_SESSION_ID, OPENHANDS_EVENT_TYPE, OPENHANDS_TOOL_NAME
GoosePLUGIN_ROOT, with AGNOSTIC_AI_TARGET=goose from its command
AugmentAUGMENT_PROJECT_DIR, AUGMENT_CONVERSATION_ID, AUGMENT_HOOK_EVENT, AUGMENT_TOOL_NAME
CursorCURSOR_PROJECT_DIR, CURSOR_VERSION (empty), CLAUDE_PROJECT_DIR, and AGNOSTIC_AI_TARGET=cursor, which the sessionStart entry that sync adds sets for later hooks
CrushCRUSH=1, AGENT=crush, AI_AGENT=crush, CRUSH_EVENT, CRUSH_TOOL_NAME, CRUSH_SESSION_ID, CRUSH_CWD, CRUSH_PROJECT_DIR, CRUSH_TOOL_INPUT_COMMAND or CRUSH_TOOL_INPUT_FILE_PATH when the tool input has one, and TERM, EDITOR, VISUAL, GIT_EDITOR, PAGER, GIT_PAGER, JJ_EDITOR, and JJ_PAGER set so nothing waits for a terminal; AGNOSTIC_AI_TARGET=crush comes from its command
Copilotthe handler's env, which holds AGNOSTIC_AI_TARGET=copilot. A value with $ is listed as not run, since Copilot does not document its expansion syntax.
FactoryFACTORY_PROJECT_DIR
QoderQODER_PROJECT_DIR, then the handler's env, which holds AGNOSTIC_AI_TARGET=qoder
Antigravitynone; the docs name no hook variables
Clinenone; the script exports AGNOSTIC_AI_TARGET=cline
KiroUSER_PROMPT on UserPromptSubmit, which the Kiro IDE documents
WindsurfDEVIN_PROJECT_DIR
Shell and default timeout per target
TargetShellDefault timeout
Claude Codebash -c, on Windows with Git Bash (CLAUDE_CODE_GIT_BASH_PATH from the shell that runs hook run, else the Git install that holds git.exe), or PowerShell when there is none and the spec sets no shell; exec-form args with no shell; shell: powershell with PowerShell600 seconds, except 30 on UserPromptSubmit, PreModelSwitch, and PostModelSwitch, and 10 on MessageDisplay
Codexsh -c; on Windows, commandWindows with powershell.exe -Command600 seconds
Geminibash -c, or Windows PowerShell on Windows, after Gemini's own replacement of $GEMINI_PROJECT_DIR, $GEMINI_CWD, $GEMINI_SESSION_ID, and $CLAUDE_PROJECT_DIR with the quoted root60 seconds
Traebash -c, or PowerShell on Windows30 seconds
OpenHands/bin/sh -c, or cmd.exe /c on Windows, as Python's shell=True does60 seconds
Goosesh -c on every platform30 seconds
AugmentOnly a .sh, .ps1, .cmd, or .bat script path: the script itself on macOS and Linux, a .ps1 with powershell.exe -Command and a .cmd or .bat with cmd.exe /c on Windows. An inline command is listed as not run.60 seconds
CursorAssumed sh -c, for a script path and plain or single-quoted arguments only. A command with shell syntax, and any command on Windows, is listed as not run.Assumed 30 seconds
CrushCrush's embedded POSIX shell (mvdan.cc/sh v3.14.1, in process) on every platform, Windows included. A path starting with ./, ../, or / runs through its shebang interpreter, found on PATH by name when the literal path is missing, or, with no shebang, as shell source in the same shell. Sync writes synced scripts as ./.crush/hooks/<name>. A path without that prefix, such as hooks/guard.sh, starts as a program; on macOS and Linux one without a shebang then fails with exit 1.30 seconds
CopilotExec-form args with no shell. A command runs under an assumed sh -c, for a script path and plain or single-quoted arguments only; a command with shell syntax, and a command on Windows, is listed as not run.30 seconds
FactoryAssumed sh -c, for a script path and plain or single-quoted arguments only, where "$FACTORY_PROJECT_DIR" counts as a plain word. A command with shell syntax, and any command on Windows, is listed as not run.60 seconds
QoderExec-form args with no shell; shell: bash with bash -c on macOS and Linux. Without shell, an assumed sh -c, for a script path and plain or single-quoted arguments only, where "$QODER_PROJECT_DIR" or "${QODER_PROJECT_DIR}" counts as a plain word. A command with shell syntax, shell: powershell, exec form with a $ in command or args, and a shell-form command on Windows are listed as not run.600 seconds, from the Qoder CLI docs; the IDE's 30 seconds does not apply to .qoder/settings.json
AntigravityAssumed sh -c, for a script path and plain or single-quoted arguments only. A command with shell syntax, and any command on Windows, is listed as not run.30 seconds
Clinebash, as Cline runs bash <file>: the event script sync writes, holding this spec's commands only, with the script's path as $0. Windows is listed as not run.120 seconds on PreToolUse and PostToolUse, whatever the spec sets, since sync drops timeout
KiroAssumed sh -c, for a script path and plain or single-quoted arguments only. A command with shell syntax, and any command on Windows, is listed as not run.60 seconds. timeout: 0 turns Kiro's limit off; hook run still stops the command after 60 seconds and marks the timeout assumed.
WindsurfAssumed sh -c, for a script path and plain or single-quoted arguments only. A command with shell syntax, and any command on Windows, is listed as not run.Assumed 30 seconds
Claude Code `if` rules

A handler's if permission rule decides whether it runs, as in Claude Code. It applies only on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied, never on other events.

  • Bash(git *) matches any subcommand of &&, ||, ;, |, and &, and commands inside $() and backticks, after leading VAR=value assignments and wrappers such as timeout 30 are stripped.
  • A trailing * also matches the bare command. :* is the same as *.
  • A pattern naming more than the command runs on any $(), backtick, or $VAR. A command that does not parse always runs.
  • Edit(path) covers Edit, Write, MultiEdit, and NotebookEdit, with gitignore patterns. //path is from the filesystem root, ~/path from home, and /path and path from the project root.
  • A bare name such as .env matches at any depth. A single directory such as src/** matches only under the project root, as in Claude Code v2.1.214 and later.
  • Tool(param:value) matches a top-level input field.

See the if field and permission rule syntax.

Each command reports one decision.

  • block: exit 2, or exit 0 with a JSON reply that has "permissionDecision": "deny", "decision": "block", or "continue": false.
  • allow: exit 0 otherwise.
  • error: any other non-zero exit.
  • timeout: the command ran past its timeout.

Exit 2 cannot stop anything on SessionStart, SessionEnd, Notification, PreCompact, and PostCompact, so it reads as error there. On PostToolUse the tool already ran, so block sends stderr back to the model. A context line marks output the target adds to the session: plain stdout on SessionStart and UserPromptSubmit, or a JSON reply's additionalContext.

How other targets read replies
  • Gemini. It reads stdout, or stderr when stdout is empty. A JSON reply with "decision": "deny" or "block", or "continue": false, is block. Plain text with an exit other than 0 and 1 is block. With no text at all, Gemini decides nothing and the run reads error. Exit 1 is error. SessionStart, SessionEnd, Notification, and PreCompress ignore decisions, so a block there reads as error. Only a JSON additionalContext reaches the model; plain stdout is a message for the user.
  • Trae. It reads replies as Claude Code does.
  • OpenHands. It blocks on exit 2, or on a JSON "decision": "deny" or "continue": false whatever the exit code. It acts on a block only on PreToolUse, UserPromptSubmit, and Stop.
  • Goose. It blocks on PreToolUse and Stop only: on exit 2, or on stdout starting with { whose decision is "block", whatever the exit code. Exit 0 with empty stdout or "decision": "allow" allows. Anything else is no decision, read as error, or as block when the action sets x-goose.on_failure: block.
  • Cursor. It blocks on exit 2 on the permission events (beforeShellExecution, beforeMCPExecution, beforeReadFile, beforeTabFileRead, subagentStart, preToolUse) and beforeSubmitPrompt. At exit 0, a permission event blocks on "permission": "deny", on "ask" (except on preToolUse, which does not enforce it; a note says Cursor asks the user), and on output that is not a JSON reply, names another permission, or gives a listed field the wrong type, such as a user_message that is not a string. No output at all is a failure, which fails open. beforeSubmitPrompt blocks on "continue": false. Any other failure fails open as error, or blocks with failClosed: true.
  • Crush. Exit 2 blocks the tool call. Exit 49 blocks too and halts the whole turn, which a note says. Any other non-zero exit, a timeout, and a command that does not parse do not block. At exit 0, a JSON reply with "decision": "deny" (any case) or "halt": true blocks. A reply with a hookSpecificOutput key, even null, is read as Claude Code's instead: "permissionDecision": "deny" blocks, and the top-level fields are ignored. A field of the wrong type, such as a reason that is not a string, voids the reply, which then allows. A context line marks a reply's context or additionalContext, which Crush appends to the tool result.
  • Factory. It blocks on exit 2, except on Notification, SubagentStop, PreCompact, SessionStart, and SessionEnd, where exit 2 only shows stderr and reads as error. At exit 0, PreToolUse blocks on permissionDecision "deny" or "ask" (a note says Factory asks the user); PostToolUse, UserPromptSubmit, Stop, and SubagentStop block on "decision": "block"; and every event but SessionEnd blocks on "continue": false.
  • Copilot. It drops {"type": "progress"} lines from stdout and parses the rest as one JSON reply; text that does not parse, or two objects, is no reply. preToolUse blocks on any non-zero exit, a command that did not start, "permissionDecision": "deny", or "ask" (a note says Copilot CLI asks the user). A failure other than exit 2 still blocks but also counts as error, so --expect block fails on a hook that never ran. permissionRequest blocks on exit 2 or "behavior": "deny", after merging the commands' replies in order, so a later "behavior": "allow" overrides an earlier deny. agentStop and subagentStop block on "decision": "block". Other events cannot block: exit 2 and other failures read as error, except exit 2 on postToolUseFailure, which adds context. A timeout fails open on every event. PascalCase events decide as their camelCase twins.
  • Qoder. It blocks on exit 2 on UserPromptSubmit, PreToolUse, Stop, SubagentStop, PreCompact, ConfigChange, Elicitation, and ElicitationResult, and on any non-zero exit on WorktreeCreate. Other failures read as error, and StopFailure and InstructionsLoaded ignore the result. At exit 0, a JSON reply blocks on "continue": false, on "decision": "deny" where exit 2 blocks, on PreToolUse on permissionDecision "deny" or "ask" (a note says Qoder asks the user), which takes precedence over decision, on PermissionRequest on a decision.behavior of "deny", and on the elicitation events on an action of "decline" or "cancel". A hookSpecificOutput without hookEventName voids the reply and reads as error. A ConfigChange with source: policy_settings cannot block, so a block there reads as error with a note. TaskCreated, TaskCompleted, TeammateIdle, and Setup have no documented decision rules, so Qoder is listed as not run for them. A handler's if runs it only when the tool name matches, with the matcher's rules, and the glob in parentheses matches the command of Bash or the file_path of a file tool; another tool with a glob is listed as not run.
  • Antigravity. Only exit 0 with a JSON reply of the documented shape counts. On PreToolUse, "decision": "allow" allows; "deny" blocks; "force_ask" blocks (a note says Antigravity asks the user). "ask" and "deny_unless_prior_grant" read as block but are not counted, since a saved Always Allow setting or a prior grant lets the call run at once, and hook run cannot see either. On Stop, "decision": "continue" keeps the agent running and reads as block; any other value allows. PostToolUse, PreInvocation, and PostInvocation allow on a JSON object. A missing or unlisted decision, a field of the wrong type, output that is not a JSON object, a non-zero exit, a timeout, and a command that did not start read as error or timeout and are not counted.
  • Cline. It never reads the exit code. It reads stdout as JSON: the last line starting HOOK_CONTROL and a tab, else the whole stdout. On PreToolUse and PostToolUse, an object with "cancel": true blocks, and Cline stops the run; on PostToolUse the tool already ran. Empty stdout and any other JSON allow, whatever the exit code; a non-zero exit adds a note that Cline ignores it. Stdout that is not JSON reads as error, and a timeout as timeout, each with a note that Cline logs it and goes on as if the hook allowed. A context line marks a reply's context, contextModification, or errorMessage. When other specs reach Cline on the same event, sync writes them into one script that shares stdout, so the result is not counted, even with --include-assumed, and a note names those specs.
  • Kiro. It blocks on exit 2 on PreToolUse and UserPromptSubmit. Another non-zero exit there reads as error and is not counted, since Kiro's docs disagree on whether it blocks. On PostToolUse and Stop, any non-zero exit is error. At exit 0, Stop reads "decision": "block" from a JSON reply as block, since Kiro keeps the agent running (a note says so); other events read no reply. A context line marks stdout on UserPromptSubmit and a Stop block's reason.
  • Windsurf. Devin CLI blocks on exit 2, or on exit 0 with "decision": "block", on PreToolUse, PermissionRequest, UserPromptSubmit, and Stop. "decision": "approve", no decision, and plain stdout allow. Any other non-zero exit is error. A block on PostToolUse, PostCompaction, SessionStart, or SessionEnd, a reply on a non-zero exit (except exit 2 with "decision": "block"), and a decision other than "approve" or "block" are not counted, since the docs do not say what Devin CLI does with them. A PreToolUse reply with hookSpecificOutput.updatedInput gets a note, since Devin CLI merges it into the tool's arguments. A context line marks hookSpecificOutput.additionalContext on UserPromptSubmit, SessionStart, and PostToolUse. Both count only when hookSpecificOutput.hookEventName names the event that ran.
  • Augment. It blocks on exit 2 on PreToolUse only. It also blocks on exit 0 with permissionDecision: "deny", a decision: "block" (inside hookSpecificOutput on Stop and PostToolUse), or "continue": false.

The run exits 1 when a counted command times out or errors, when two counted targets decide differently, or, with --expect allow or --expect block, when a counted target decides otherwise. That makes it a CI check.

Assumed results

Cursor, Copilot, Factory, Qoder, Antigravity, Kiro, and Windsurf (Devin CLI) document their payloads and reply rules, but each leaves out part of how a command runs. hook run runs them on stated assumptions instead of leaving them out:

  • Shell (Cursor, Factory, Antigravity, Kiro, Windsurf, Copilot's command form, and Qoder without shell): sh -c on macOS and Linux, only for a script path with plain or single-quoted arguments, as sync writes args for Cursor, which every POSIX shell reads the same way. A command with shell syntax, such as a pipe, is listed as not run with "Cursor does not document its shell; use a script path", or the same for Copilot, Factory, Qoder, Antigravity, Kiro, or Devin CLI. Windows is not run. Copilot's and Qoder's exec form, which sync writes when the spec sets args, runs with no shell and assumes none. Qoder documents shell: bash as bash -c, which assumes none on macOS and Linux.
  • Timeout (Cursor, Windsurf): 30 seconds when the spec sets none. Set timeout in the spec to remove this assumption. Copilot and Antigravity document their 30 second default, and Factory and Kiro 60 seconds.
  • Timeout (Kiro): 60 seconds for timeout: 0, which Kiro documents as no limit.
  • Working directory (Copilot): the project root, for a hook without cwd. Set cwd in the spec to remove this assumption; it runs relative to the project root, as Copilot documents.
  • Working directory (Factory): the project root. Factory runs hooks from "Droid's current working directory, which can differ from your repository root". A command written as the guide says, "$FACTORY_PROJECT_DIR"/path/to/script.sh, counts as a script path. It runs as written, with FACTORY_PROJECT_DIR set to the project root, so the shell expands it.
  • Working directory (Qoder): the project root. Qoder documents QODER_PROJECT_DIR as the project's directory but not where a hook runs. A command written as the guide says, "${QODER_PROJECT_DIR}"/path/to/script.sh, counts as a script path and runs as written, with QODER_PROJECT_DIR set to the project root.
  • Working directory (Antigravity): the project root. The docs do not say where a hook command runs.
  • Working directory (Windsurf): the project root, with DEVIN_PROJECT_DIR set to it as Devin CLI documents. The docs do not say where a hook command runs.
  • Exit codes (Antigravity): the docs give no exit code a meaning. Only exit 0 with a reply of the documented shape is counted. Any other result is shown with a not counted note and its reason, such as "Antigravity does not document exit codes", and stays out of --expect and the comparison even with --include-assumed. So do the "ask" and "deny_unless_prior_grant" replies, whose result "depends on Antigravity's saved permissions".
  • Exit codes (Kiro): exit 2 blocks PreToolUse and UserPromptSubmit. Kiro's hook actions page says any other non-zero exit blocks them too, while its hook types, IDE 1.0, and troubleshooting pages say it does not. That result reads as error with a not counted note, "Kiro's docs disagree on whether a non-zero exit other than 2 blocks", and stays out of --expect and the comparison even with --include-assumed.
  • Not run (Kiro): an agent action, which runs no command; a hook with confirm, which asks the user first; and every trigger but UserPromptSubmit, PreToolUse, PostToolUse, and Stop, whose payload the docs do not show.
  • Replies (Windsurf): results the Devin CLI docs give no meaning are shown with a not counted note, such as "Devin CLI does not document whether it reads a reply on a non-zero exit", and stay out of --expect and the comparison even with --include-assumed.
  • Exec path (Copilot): with cwd set, a relative exec path resolves from cwd, as a process started there reads it and as sync writes it.

Sources: Cursor hooks, the Copilot hooks reference, using hooks with Copilot CLI for the bash tool's toolArgs, the Factory hooks guide, Qoder CLI hooks and its hooks reference, Antigravity hooks, Kiro hooks, hook types, hook actions, and Devin CLI hooks and lifecycle hooks.

The result line ends in (assumed: shell, timeout), followed by one line per assumption and the docs link. An assumed result is shown but not counted: it stays out of --expect and the comparison unless you pass --include-assumed. When it disagrees with the counted results, a warning says so, and a summary such as 0 checked, 1 assumed (not counted; --include-assumed to count) shows what was left out. With --expect, a run where only assumed results ran fails and asks for --include-assumed, so a CI check never passes on nothing. A result with a not counted note stays out of --expect, but with --include-assumed a timeout or error from another of its commands, one the target documents, still fails the run. Without --expect, it exits 0. In JSON, each target has assumptions (item, value, reason) and counted.

A Cursor hook spec uses Cursor's own event names, so it runs only on Cursor; other targets are listed as not run. A Copilot hook spec with a PascalCase event such as PreToolUse runs on Claude Code too, which counts, so --expect checks Claude Code and shows Copilot beside it.

Cline's contract comes from its source at 39ff235, from the SDK hook runtime the Cline CLI runs. A Cline run assumes only the working directory: the project root, while Cline uses the directory the CLI started in. The VS Code extension runs the same script but does not read stdout when it exits non-zero, so a reply printed before a failing exit blocks in the CLI and not in the extension.

Crush's contract comes from its source, so a Crush run assumes nothing until a command reaches one of Crush's own Go programs. Its shell runs jq as a built-in gojq, and on Windows cat, ls, rm, find, and the other core utilities in Go too, unless CRUSH_CORE_UTILS=false. hook run runs the ones on PATH instead, and adds a jq or coreutils assumption for each it reached. A script that starts through a shebang runs its own shell, so this applies only to inline commands and to ./ scripts without a shebang.

--format json prints the same results as one JSON object, for a CI job to read. Each target has a decision (allow, block, error, timeout, or not run with a reason), its warnings, its assumptions, whether it is counted, and one entry per command. exit_code is null after a timeout or a command that did not start. error holds the reason the run fails, and the exit code is the same as in text:

{
  "hook": "protect-files",
  "targets": [
    {
      "target": "claude",
      "decision": "block",
      "event": "PreToolUse",
      "trigger": "Write",
      "async": false,
      "commands": [
        {
          "command": ".claude/hooks/protect-files.sh",
          "decision": "block",
          "exit_code": 2,
          "elapsed_ms": 12,
          "timed_out": false,
          "stdout": "",
          "stderr": "Blocked: .github/workflows/tests.yml is protected.\n",
          "adds_context": false
        }
      ],
      "notes": [],
      "warnings": [],
      "assumptions": [],
      "counted": true
    }
  ]
}
TargetPayloadVendor docs
Claude CodeDocumented shape. --edit calls the first of Write, Edit, and MultiEdit the matcher matches, with an absolute tool_input.file_path.Hooks reference
CodexDocumented shape. --edit calls apply_patch with an *** Add File: or *** Update File: patch in tool_input.command, and Edit and Write match it.Hooks
GeminiShape from the source. --edit calls the first of write_file and replace the matcher matches, with an absolute tool_input.file_path; --bash calls run_shell_command. The tool matcher is an unanchored regular expression, and a SessionStart source must match exactly.Hooks reference, gemini-cli c6bccb7
TraeDocumented shape. --bash calls RunCommand; llm_tool_name repeats the tool name. Trae lists no edit tool input, so with --edit Trae is listed as not run and the other targets still run. The matcher is an unanchored regular expression.Hook reference, automate actions with hooks
OpenHandsDocumented HookEvent: event_type, tool_name, tool_input, message, session_id, working_dir. --bash calls terminal; the file editor's input is undocumented, so with --edit OpenHands is listed as not run and the other targets still run. A * or empty matcher matches all; one with a regex character, or written /re/, must match the whole tool name; any other is an exact name. A matcher on an event with no tool never runs.Hooks, software-agent-sdk fad6377
GooseDocumented shape: event, session_id, matcher_context, tool_name, tool_input, working_dir, tool_call_id. --bash calls shell; --edit calls the first of write and edit the matcher matches, with an absolute path. BeforeShellExecution and AfterShellExecution take --bash, and AfterFileEdit and BeforeReadFile take --edit. The matcher is an unanchored regular expression on matcher_context; one that does not compile, such as *, skips the rule.Hooks, goose bab8ff6 docs and runner
CursorDocumented shape: the common fields (conversation_id, hook_event_name, workspace_roots, ...) plus the event's own. --bash builds beforeShellExecution and afterShellExecution (command, cwd), and preToolUse and postToolUse on the Shell tool. --edit builds afterFileEdit only, since the Write tool's input is undocumented; on preToolUse and postToolUse Cursor is listed as not run and the other targets still run. --prompt builds beforeSubmitPrompt. The matcher is an unanchored regular expression, tested against the command on the shell events, the tool type on the tool events, and a fixed name on the rest.Hooks
CrushShape from the source: event, session_id, cwd, tool_name, tool_input, with event always PreToolUse, the only event Crush runs. --bash calls bash; --edit calls the first of edit and write the matcher matches, with an absolute file_path. Crush has no prompt event. The matcher is an unanchored regular expression on the lowercase tool name; one that does not compile makes Crush skip the hook, so hook run reports it.crush 76cc5c5: hooks/runner.go, hooks/input.go, shell/run.go, shell/dispatch.go, config/config.go
FactoryDocumented shape: session_id, transcript_path, cwd, permission_mode, hook_event_name, plus the event's own. --bash calls Execute with tool_input.command. --edit calls Create with file_path and content; the docs show no input for Edit and ApplyPatch, so when the matcher picks one of them Factory is listed as not run and the other targets still run. --prompt builds UserPromptSubmit with has_images. Empty or * matches everything, letters and | list exact names, and anything else is a case-sensitive regular expression.Hooks guide
AntigravityDocumented camelCase shape: conversationId, workspacePaths, transcriptPath, artifactDirectoryPath, modelName, stepIdx, and toolCall with name and args; PostToolUse adds error. --bash calls run_command with CommandLine, Cwd, and WaitMsBeforeAsync. --edit calls the first of write_to_file, replace_file_content, and multi_replace_file_content the matcher matches, with an absolute TargetFile and the other arguments the docs list. Antigravity has no prompt event; PreInvocation, PostInvocation, and Stop take --payload. The matcher is a regular expression matched against the whole tool name; empty or * matches everything.Hooks
CopilotDocumented shape, camelCase for a camelCase event and the VS Code compatible snake_case for a PascalCase one. --bash builds preToolUse and postToolUse on bash, with toolArgs as a JSON string, and PreToolUse on Bash, with a tool_input object. --prompt builds userPromptSubmitted or UserPromptSubmit. --edit is refused, since toolArgs for edit, create, and apply_patch are undocumented, and so is --bash on PostToolUse, whose tool_name is undocumented; Copilot is listed as not run and the other targets still run. A camelCase matcher is a regex anchored as ^(?:PATTERN)$. Copilot runs JavaScript regexes, so a matcher with inline flags, lookaround, backreferences, (?P<name>), POSIX classes, or Go-only escapes such as \A is listed as not run.Hooks reference, using hooks
QoderDocumented shape: session_id, transcript_path, cwd, hook_event_name, permission_mode, plus the event's own. --bash calls Bash with tool_input.command. --edit calls Write with file_path and content; the docs show no input for Edit, so when the matcher picks it Qoder is listed as not run and the other targets still run. SessionStart sources include new. Empty or * matches everything, letters and | list exact names, and anything else is a regular expression.Hooks, hooks reference
ClineShape from the source: clineVersion, timestamp, taskId, sessionContext, workspaceRoots, userId, agent_id, parent_agent_id, and hookName (tool_call, tool_result, or prompt_submit). --bash calls run_commands with {"commands": [...]}; --edit calls editor with an absolute path and an empty new_text (a model whose id holds gpt or codex calls apply_patch instead, which a note says). preToolUse and postToolUse repeat the input as parameters, a string map. --prompt builds UserPromptSubmit, with a note that the CLI may not send that event, since its source says orchestrated sessions seed the prompt without it. Cline has no matcher, so every hook fires; a portable match kind runs the script's tool name check, which allows a call to another tool. PreCompact is listed as not run.cline 39ff235: hook-file-hooks.ts, subprocess-runner.ts, tool schemas
KiroDocumented shape: hook_event_name in camelCase, cwd, and session_id, plus prompt on userPromptSubmit and an empty assistant_response on stop. --prompt builds UserPromptSubmit, and Stop needs no input. The docs show tool_input only for the read tool and MCP tools, so --bash and --edit are refused and PreToolUse and PostToolUse take --payload; Kiro is listed as not run and the other targets still run. The matcher is an unanchored regular expression on the tool name or its alias, and on the prompt text for UserPromptSubmit; Stop ignores it. @mcp, @powers, and @builtin are listed as not run, since the docs do not list their tools.Hooks, hook types
WindsurfDocumented Devin CLI shape: hook_event_name, session_id, prompt_id, plus the event's own. --bash builds PreToolUse, PostToolUse, and PermissionRequest on exec with tool_input.command and shell_id, and PostToolUse adds tool_response. --prompt builds UserPromptSubmit; Stop and PostCompaction need no input. --edit is refused, since the docs name edit, write, and apply_patch but not their tool_input; Windsurf is listed as not run and the other targets still run. SessionStart and SessionEnd list no source or reason values, so they take --payload. The matcher is an unanchored regular expression on tool_name, so a Claude-style Bash matcher does not fire; a matcher on another event is listed as not run.Hooks, lifecycle hooks
AugmentDocumented shape. --bash calls launch-process; --edit calls the first of str-replace-editor and save-file the matcher matches, with a path relative to the workspace root, and PostToolUse adds file_changes. Augment has no prompt event. The matcher is an unanchored regular expression.Hooks

On each, tool_response holds placeholder values, and session IDs and transcript paths are made up. Gemini matchers compile as Go regular expressions, which reject a few JavaScript forms such as lookahead; Gemini CLI would run those, and hook run compares them as a literal name. GEMINI_PLANS_DIR is not set.

OpenCode and Kilo run hooks as plugins and Zed as tasks, with no payload on stdin, so hook run lists them as not run.