> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Hooks

> What each agent hook denies or reminds, how settings wire them, and how they are tested.

Hooks are scripts the agent harness runs around tool calls. They are the one part of agent governance that cannot be forgotten, because the harness executes them, not the model. They live in `.agents/hooks/` and are wired in `.claude/settings.json`. Hooks have no dependencies beyond Node.

A hook either **denies** a tool call with a reason the agent sees, or **adds context** to the conversation. None of them edits files.

## What runs today

| Hook | Event and matcher | Effect |
| - | - | - |
| `session-context.mjs` | `SessionStart` | Restates the invariants that must survive compaction, plus the branch and up to 15 lines of `git status` |
| `guard-bash.mjs` | `PreToolUse`, `Bash` | Denies a command that destroys history, prints or writes secrets, or bypasses the release process |
| `guard-paths.mjs` | `PreToolUse`, `Write\|Edit\|MultiEdit\|NotebookEdit` | Refuses writes to generated, vendored or secret paths |
| `post-write-checks.mjs` | `PostToolUse`, `Write\|Edit\|MultiEdit` | Reports (never fixes, never blocks) a written file that oxfmt would reformat |

`_io.mjs` holds the shared stdin and output helpers. It is not a hook.

### `session-context`

Runs on session start, resume, compaction and clear. It prints seven invariants: package location and naming, the tier tag and presets every member declares, versions only from catalogs, tests beside the code, docs in `apps/docs`, the secrets rule, and what done means. The point is that compaction cannot silently drop the laws. If an invariant changes on a docs page, change it here in the same commit.

### `guard-bash`

Each rule is scoped to the operand of a command rather than a bare keyword. Heredoc bodies are stripped before matching, so a file whose prose mentions `rm -rf` does not trip a rule.

| Denied | Why |
| - | - |
| `infisical export` | Dumps every secret value into the transcript. Use `infisical run -- <cmd>` or `tools/scripts/mcp.sh` |
| `infisical secrets get` without `--silent` | Prints the value. Only the `--silent` form is allowed |
| `encore secret set`, `encore secret get`, `encore secret list --values` | Secret values are set by a human. Declare `secret("NAME")` and name it in the report |
| `wrangler secret put`, `wrangler secret bulk` | The same rule for Worker secrets |
| Reading `.env` with `cat`, `less`, `head`, `tail`, `bat`, `sed`, `awk`, `grep`, `strings` | There are no `.env` files here; a token value never enters the transcript |
| Creating a `.env*` file (`>`, `>>`, `tee`, `touch`, `cp`, `mv`, `ln`) | Secrets only come from Infisical; application secrets use Encore `secret()` |
| The same reads of `.dev.vars` | Wrangler's local secret file |
| `printenv`, bare `env`, `env \|` | Dumping the environment leaks injected tokens |
| `encore db reset` | Destroys the local database; a human-confirmed reset, never run by an agent |
| `encore deploy` | Ships to a shared environment; a human / CI decision |
| `git clean` | Removes untracked and ignored files silently |
| `git reset --hard`, and `git checkout` or `restore` of `.` | Discards uncommitted work across the whole tree |
| `rm -rf` in any flag order | Delete named paths without `-f`, so a wrong path fails loudly |
| `git push --force` (not `--force-with-lease`) | Rewrites shared history |
| `git tag <package>@<version>` | Release tags are created by CI, never by hand |
| `DROP DATABASE`, `DROP SCHEMA` | A human decision. Reset a local database with the Encore command |
| ORM `push`, `migrate`, `migration:up`, `schema:update` | Migrations are applied by the app, not pushed to a database |
| `changeset version` | Runs in CI. Locally, add a changeset with `pnpm changeset` |

### `guard-paths`

Refuses writes to paths a tool other than the agent owns: `.env*` and secret-named files, `pnpm-lock.yaml`, `CHANGELOG.md` (Changesets owns it), `dist/`, Encore's generated `encore.gen/` and `.encore/` (the `~encore/*` clients), `apps/mobile/{ios,android}` (Expo prebuild), `uniwind-types.d.ts`, the `.claude/agents` and `.opencode/agents` projections, and the generated rules block in `AGENTS.md`. Edit the source instead and re-run the generator.

### `post-write-checks`

Reports a written TS/JS/JSON/MD/CSS/YAML file that oxfmt would reformat. Best-effort and never blocking: the write already happened.

## Wiring

`.claude/settings.json` registers each hook by command with a five second timeout (ten for the formatter):

```json theme={"system"}
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/session-context.mjs\"",
            "timeout": 5
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/guard-bash.mjs\"",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Write|Edit|MultiEdit|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/guard-paths.mjs\"",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/post-write-checks.mjs\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
```

The path is `.agents/hooks/`, the one source. A hook is never copied into `.claude/`. The settings file is currently wired by hand, and the one canonical `.agents/` source projected to tool folders (see the [adoption plan](/adr/0016-agents-source-of-truth)) is what will keep it in step.

### `prompt-hints`

`UserPromptSubmit`: one-line reminders keyed on what the prompt is about. Cheap, never blocks. Registered in `.claude/settings.json` beside the hooks above.

## Testing a hook

A hook that no test exercises is a comment with a runtime cost. Every hook has a test in `.agents/hooks/__tests__/` (`pnpm test:hooks`, part of `pnpm validate`). The pattern to follow:

1. Pipe a fixture into the script as the harness would: `{"tool_input":{"command":"git reset --hard"}}` on stdin.
2. Assert the output is a `deny` decision for every rule, and empty for a near miss. Include the near misses: `git reset --soft`, `rm -r` without `-f`, a heredoc that mentions a forbidden command.
3. Run the test inside `pnpm verify`, so a regex edit that loosens a rule fails the gate.

Add a case in the same change as every new rule.

## Hooks versus permissions

A hook denies by pattern, which is easy to get subtly wrong. Treat it as a seatbelt, not a boundary. The boundary is the human owning secrets and merges, the gitleaks scan in `lefthook.yml`, and `pnpm verify` in CI. Never write a hook that turns a denial into a prompt for the agent to retry with different quoting.

Related: [Secrets](/constitution/secrets), [Concurrent sessions](/agents/concurrent-sessions), [Rules that don't rot](/agents/rules-that-dont-rot).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.