diff --git a/docs/agents/hooks.mdx b/docs/agents/hooks.mdx index e71e4d6bc..25cf86082 100644 --- a/docs/agents/hooks.mdx +++ b/docs/agents/hooks.mdx @@ -103,7 +103,13 @@ Each hook fires on a specific lifecycle event: ## Configuration -Hooks are defined as `[[hooks]]` entries in a [run configuration](/execution/run-configuration) TOML file: +Hooks are defined as `[[hooks]]` entries in any of these TOML config files: + +- **`fabro.toml`** — project-level hooks, apply to all workflows in the project +- **`workflow.toml`** — per-workflow hooks +- **`~/.fabro/cli.toml`** or **`~/.fabro/server.toml`** — global defaults for all runs + +See [Merging hook configs](#merging-hook-configs) for how these layers combine. ```toml title="run.toml" [[hooks]] @@ -157,7 +163,7 @@ Command hooks communicate decisions via exit code and stdout: | Exit code | Behavior | |---|---| | `0` | Proceed. If stdout contains valid JSON decision, use that instead. | -| `2` | Block/skip. If stdout contains valid JSON decision, use that instead. | +| `2` | Block. If stdout contains valid JSON decision (e.g., `skip`), use that instead. | | Any other | Block with reason "hook exited with code N". | To return an explicit decision from a command hook, print JSON to stdout: @@ -186,44 +192,35 @@ If the LLM fails to produce valid JSON, the hook **fails open** (proceeds). This ## Matchers -The `matcher` field is a regex that filters when a hook fires. The regex is tested against five context fields — **if any field matches, the hook fires**. If `matcher` is omitted, the hook fires unconditionally for its event. +The `matcher` field is a regex that filters when a hook fires. Omit `matcher` to match all occurrences of the event. Each event type matches against different context fields: -The five fields are always the same regardless of event, but not all fields are populated for every event: - -| Context field | Populated for events | -|---|---| -| `node_id` — the graph node ID (e.g., `"implement"`, `"test"`) | `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying`, `checkpoint_saved`, `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` | -| `handler_type` — the node's handler (e.g., `"agent"`, `"command"`, `"prompt"`) | `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying` | -| `edge_from` — the source node of the selected edge | `edge_selected` | -| `edge_to` — the target node of the selected edge | `edge_selected` | -| `tool_name` — the agent tool being called | `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` | - -Run-level events (`run_start`, `run_complete`, `run_failed`) and sandbox/parallel events have no matchable fields — a matcher on these events will never match, so omit it. - - -Because the regex is tested against **all populated fields**, a matcher can match unintended fields. For example, `matcher = "write_file"` on a `stage_start` hook would fire if a node happened to be named `"write_file"`. Use anchored patterns like `^write_file$` or scope matchers to tool-specific events to avoid surprises. - - -### Tool names - -For tool-level events, `tool_name` is the agent tool's internal name. The available tool names depend on the LLM provider profile, but the common set is: - -| Tool name | Category | Description | +| Event | What the matcher filters | Example matcher values | |---|---|---| -| `read_file` | read | Read a single file | -| `read_many_files` | read | Read multiple files (Gemini profile) | -| `grep` | read | Search file contents with regex | -| `glob` | read | Find files by glob pattern | -| `list_dir` | read | List directory contents (Gemini profile) | -| `write_file` | write | Create or overwrite a file | -| `edit_file` | write | Apply targeted edits to a file (Anthropic, Gemini profiles) | -| `apply_patch` | write | Apply a unified diff patch (OpenAI profile) | -| `shell` | shell | Execute a shell command | -| `web_search` | shell | Search the web | -| `web_fetch` | shell | Fetch a URL | +| `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying` | node ID and handler type | `implement`, `^agent$`, `test` | +| `edge_selected` | edge source and edge target node IDs | `implement`, `deploy` | +| `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` | tool name and node ID | `write_file\|edit_file`, `^shell$` | +| `checkpoint_saved` | node ID | `implement` | +| `run_start`, `run_complete`, `run_failed`, `parallel_start`, `parallel_complete`, `sandbox_ready`, `sandbox_cleanup` | no matcher support | always fires on every occurrence | + +The matcher is a regex, so `write_file|edit_file` matches either tool and `^agent$` matches exactly the handler type `agent`. When an event has multiple matchable fields (e.g., tool events match against both `tool_name` and `node_id`), the hook fires if **any** field matches the regex. + +For tool events, the matcher is tested against the tool's internal name (e.g., `shell`, `write_file`, `edit_file`). See [Tools](/agents/tools) for the full list. To match all file-writing tools across providers, use `write_file|edit_file|apply_patch`. ### Examples +This example auto-formats Rust code whenever the agent writes or edits a file: + +```toml +[[hooks]] +name = "cargo-fmt" +event = "post_tool_use" +command = "cargo fmt" +matcher = "write_file|edit_file|apply_patch" +blocking = true +``` + +More examples: + ```toml # Only fire for agent handler nodes [[hooks]] @@ -237,13 +234,6 @@ event = "stage_complete" command = "./scripts/report-test.sh" matcher = "test" -# Auto-format Rust after file writes -[[hooks]] -event = "post_tool_use" -command = "cargo fmt" -matcher = "write_file|edit_file|apply_patch" -blocking = true - # Gate shell commands with a guardrail [[hooks]] event = "pre_tool_use" @@ -277,7 +267,7 @@ Command hooks receive these environment variables: ### Hook context -The full event context is available as a JSON payload. For command hooks, it is written to a temp file (path in `FABRO_HOOK_CONTEXT`) and piped to stdin. For HTTP hooks, it is the POST body. +The full event context is available as a JSON payload. For command hooks running in the sandbox, it is written to a temp file (path in `FABRO_HOOK_CONTEXT`). For command hooks running on the host (`sandbox = false`), it is piped to stdin. For HTTP hooks, it is the POST body. ```json @@ -352,7 +342,13 @@ Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) pro ## Merging hook configs -When both the server config (`~/.fabro/server.toml`) and a run config define hooks, they are merged. On name collisions, the run config wins. This lets you define global hooks at the server level and override or extend them per run. +Hooks from multiple config files are merged in this order (later layers win on name collisions): + +1. **`~/.fabro/cli.toml`** or **`~/.fabro/server.toml`** — global defaults +2. **`fabro.toml`** — project-level overrides +3. **`workflow.toml`** — per-workflow overrides + +This lets you define global hooks at the server level, project-wide hooks in `fabro.toml`, and override or extend them per workflow. ## Full example