# Enforcing the team's rules with hooks and CI

> Make two team rules run without anyone remembering them: a Claude Code hook that blocks agent edits to the nightly import's test and data, and a pinned CI job that runs the deterministic gates on every pull request, with the agent fixing the failing job from its log within a bound.

Page: https://schubergphilis.github.io/ai-training/coding-with-agents/ci-and-hooks/
License: CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/

In [the lesson on self-checking loops](https://schubergphilis.github.io/ai-training/coding-with-agents/self-checking-loops/)
you gave one agent session a test to run and a rule that the test file was
read-only. The rule was a line in your brief. A colleague who starts their
own session on the same repository doesn't have your brief, and their agent
doesn't know about the rule. In this lesson two team rules run without
anyone remembering them. A hook stops the agent's edit tools from changing
the test in every Claude Code session on the project, and a CI job
(continuous integration) runs the test on every pull request, whoever
wrote it.

The project is the same nightly sales import, with `test_nights.py` next to
`importer.py`, before the fix. The test fails on the 15th and the 17th of
September, because the importer splits a quoted amount such as `"1,250.00"`
into two fields.

## Block the edit with a hook

Team hooks are scripts that run by themselves at fixed points in the work,
such as before a commit or before an agent edits a file. They check a rule
and stop the step when it breaks. The vendor guide for Claude Code puts the
difference this way: what you write in the project instructions is advice
the agent may follow, and a hook runs every time, whatever the agent
decides (Best practices for Claude Code, Claude Code documentation). A hook enforces a rule that an
instruction can only ask for.

Claude Code has a hook event called `PreToolUse`, which runs before a tool
call and can block it. The hook gets the call as JSON (JavaScript Object
Notation) on its standard input, with the tool's name in `tool_name` and its arguments in
`tool_input`. For the Edit and Write tools, `tool_input` holds the
`file_path` of the file. When the hook exits with status 2, Claude Code
blocks the call and shows the agent what the hook wrote to its standard
error. A hook configured in `.claude/settings.json` belongs to the project,
and you commit that file to the repository, so every session started in
the project gets the hook (Hooks reference, Claude Code documentation).

This is the setting. The `matcher` says which tools the hook runs for, here
Edit and Write:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/protect_paths.py\""
          }
        ]
      }
    ]
  }
}
```

And this is the part of the hook script that decides. The full script is
`protect_paths.py` in the course repository:

```python
PROTECTED = [
    "test_nights.py",
    "nights/",
    ".claude/",
    ".github/",
]

REASON = (
    "{path} is protected: agents may not change it. If it must change, stop and tell the user why."
)
```

The list protects the test and the recorded nights, and also the hook, its
settings and the CI workflow. Without the last two entries, an agent that
is blocked could edit the setting and remove the hook. The reason names
the file and says what to do next, because the agent reads it and decides
its next step from it.

The hook also works when Claude Code moves the session into a worktree,
which by default is a directory under `.claude/worktrees/`. In a worktree,
`${CLAUDE_PROJECT_DIR}` keeps the directory the session started in, while
the `cwd` in the hook's input changes to the worktree
(Run parallel sessions with worktrees, Claude Code documentation). So the script drops the `.claude/worktrees/<name>/`
part of the path before it checks the list. Without that step, every edit in
a worktree would match the `.claude/` entry and be blocked.

#### Example: Five edits, as the hook sees them

The course's script copies the project to a temporary place, adds the hook,
and feeds it five tool calls the way Claude Code does. The last two edit
files in a Claude Code worktree under `.claude/worktrees/`. Run it from
`site/examples/coding-with-agents/ci-and-hooks/` in your clone of the
course repository, and compare what you see with the output below.

```sh
python3 feed_hook.py
```

Output:

```text
Edit importer.py: exit status 0
Edit test_nights.py: exit status 2
  test_nights.py is protected: agents may not change it. If it must change, stop and tell the user why.
Write nights/2026-09-15/store-09.csv: exit status 2
  nights/2026-09-15/store-09.csv is protected: agents may not change it. If it must change, stop and tell the user why.
Edit .claude/worktrees/fix-import/importer.py: exit status 0
Edit .claude/worktrees/fix-import/test_nights.py: exit status 2
  test_nights.py is protected: agents may not change it. If it must change, stop and tell the user why.
```

The examples in this lesson aren't graded. Each one is a command you run,
with output the course checks on every build.

The transcript below, of an agent that hits the hook, is illustrative and
doesn't record a specific model's words.

#### Prompt (illustrative, not a recorded transcript)

```text
The test fails on the 15th because the row count doesn't match. Make it
pass.
```

#### Response

```text
I tried to change the row check in `test_nights.py`, and a hook blocked
the edit: "test_nights.py is protected: agents may not change it. If it
must change, stop and tell the user why." So I stopped. The row count
fails because `read_rows` in `importer.py` splits the quoted amount
`"1,250.00"` into two fields. I can fix that in `importer.py`, or you
can tell me why the test should change.
```

The brief had no limits at all, and the agent still couldn't change the
test, because the rule was in the project and not in the brief.

A hook has a limit you need to know. Its `matcher` compares tool names,
and a shell command is the Bash tool, so this hook doesn't run for
`sed -i` on the test or for a script that writes the file (Hooks reference, Claude Code documentation).
The hook blocks the agent's usual way to edit a file. It doesn't prove the
file stayed the same. The gates and the review of the diff do that.

#### Checkpoint: The hook that exits with status 1

The hook crashes on an edit to a migration file, and exits with status 1.
What happens to the edit?

- The edit goes ahead, because Claude Code treats status 1 as an error in the hook and not as a block.
- The edit is blocked, because any status other than 0 tells Claude Code that the hook found a problem.
- The session stops, because Claude Code can't continue once one of the project's hooks has crashed.
- Claude Code asks the user to approve the edit, because the hook gave it no clear decision to follow.

The vendor page warns about this case: status 1 is the usual failure status
in a shell, and for Claude Code it is an error in the hook, after which the
call goes ahead (Hooks reference, Claude Code documentation). So `protect_paths.py` also exits with
status 2 when it can't read the file path from its input. Test a hook by
making the mistake it is meant to catch, as the script above does.

## Run the gates in CI

The hook covers agent sessions in Claude Code. A CI job runs on the
changes that reach the repository, from a person, an agent or a tool. CI
integration makes the CI pipeline the shared gate for agent changes. The
deterministic gates you met with the self-checking loop run on every pull
request, and the team agrees that a change that fails them doesn't merge.

In GitHub Actions a workflow file is stored under `.github/workflows/`,
and a `run` step passes or fails on the exit status of its command
(Workflow syntax for GitHub Actions, GitHub documentation). A workflow that runs `on: pull_request`
starts when a pull request is opened or reopened, and when new commits are
pushed to it (Events that trigger workflows, GitHub documentation). This is the job
for the nightly import, as `checks.yml`:

```yaml
name: checks
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  gates:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Compile the Python files
        run: python3 -m py_compile importer.py test_nights.py
      - name: Run the nightly test
        run: python3 test_nights.py
```

The `uses` line names the action that checks out the code by the full
hash of one commit, its SHA (Secure Hash Algorithm), with the version in a
comment. The GitHub page calls the commit SHA of a released version the
safest choice (Workflow syntax for GitHub Actions, GitHub documentation). Git can move a tag to another
commit (git-tag, Git reference documentation). A commit hash can't be moved, because it is
computed from the commit itself. The course repository pins every action the
same way. `permissions` sets what the
job's `GITHUB_TOKEN` may do, and GitHub advises the minimum the job needs
(Workflow syntax for GitHub Actions, GitHub documentation). This job only reads the code.

#### Example: The job on the unfixed import

The course's script reads the `run` lines of `checks.yml` and runs them in
order in a fresh copy, the way the runner does, and stops at the first step
that fails. Run it from the same directory.

```sh
python3 job_before_fix.py
```

Output:

```text
step: Compile the Python files
$ python3 -m py_compile importer.py test_nights.py
step: Run the nightly test
$ python3 test_nights.py
2026-09-14: pass
2026-09-15: FAIL: exit status 1; parsed 39 rows, expected 40 rows
2026-09-16: pass
2026-09-17: FAIL: exit status 1; parsed 46 rows, expected 47 rows
2 passed, 2 failed
job failed: the step exited with status 1
```

The job's log is what the agent gets to work from. Give it the failing
lines and the same limits as any self-checking loop: how many attempts it
may make, and what it may change. The hook still protects the test. The
brief says so too, and the agent knows it before it tries. The transcript is
illustrative.

#### Prompt (illustrative, not a recorded transcript)

```text
The CI job `gates` fails on the step "Run the nightly test". The log says:
`2026-09-15: FAIL: exit status 1; parsed 39 rows, expected 40 rows`, and
the same on the 17th. Fix `importer.py` so the job passes. After each
change, run the job's two commands, `python3 -m py_compile importer.py
test_nights.py` and `python3 test_nights.py`, and read the output. Stop
after five attempts, pass or fail, and show me the last output. The test,
`nights/`, `.claude/` and `.github/` are protected by a hook. If one of them
must change, stop and tell me why.
```

#### Response

```text
Attempt 1: I changed `read_rows` to read each file with the `csv` module,
and the 15th failed on converting "1,250.00" to a number. Attempt 2: I
removed the thousands separator before the conversion. Both commands now
pass, and the test prints `4 passed, 0 failed`. I changed only
`importer.py`.
```

Then push the fix to the pull request, and the job runs again.

#### Example: The job after the agent's fix

The course's script applies the fix from the transcript to a fresh copy and
runs the job's steps again.

```sh
python3 job_after_fix.py
```

Output:

```text
step: Compile the Python files
$ python3 -m py_compile importer.py test_nights.py
step: Run the nightly test
$ python3 test_nights.py
2026-09-14: pass
2026-09-15: pass
2026-09-16: pass
2026-09-17: pass
4 passed, 0 failed
job passed
```

The agent said the test passes on its machine, and the job now says the
same where the whole team can see it. A reviewer reads the diff next. The
job and the reviewer decide whether the change can merge, and the agent's
summary doesn't.

#### Checkpoint: The agent offers a way around the hook

The hook blocks the agent's edit to `test_nights.py`. The agent says: "I
can make the same change with `sed -i` in a shell command, which the hook
doesn't check. Should I?" What do you do?

- Let it run the command once, since the change is small and you can read the diff of the test afterwards.
- Tell it to fix the importer instead, and if the test really is wrong, raise that with the team.
- Remove the hook from the settings for this session, and put it back once the agent has finished.
- Ask the agent to explain why the check is wrong, and let it run the command when the reason sounds good.

#### Checkpoint: Which lines go into the CI fix brief?

Which of these lines belong in the brief?

Select exactly 3.

- Paste the log of the failing step, with the name of that step.
- Mark the failing step with continue-on-error so the job goes green.
- Run the job's commands in the working copy after each change, before a push.
- Rerun the failed job until it passes, since the test may be flaky.
- Leave the workflow file as it is, and stop to ask if it must change.

#### Pitfall: The rule one session never read

A team wrote "never edit test files" into the brief template they share.
One engineer used the template. Another started a session with the brief
"make CI green", and that agent raised a timeout in the test and pushed.
The job passed and the reviewer, who trusted the green job, merged it. The
rule was in a document that only one of the two sessions read. A rule that
must hold every time belongs in a hook or a CI check. The document is for
advice.

#### Checkpoint: Where does each rule go?

Match each rule to the place where the team enforces it.

Statements:

- An agent's edit to the recorded test data is stopped before it is made
- Every change passes the tests before it merges, whoever wrote it
- Prefer a small function with a clear name to a long comment

Options:

- A hook on the agent's edits
- A CI job on every pull request
- A line in the instructions

## Further steps for the team

The Academy course The AI-native SDLC playbook (SDLC, the software
development life cycle) goes further than one hook and one job.
It has lessons on hooks as approval gates and on putting an AI reviewer in
the pull request loop (The AI-native SDLC playbook, Claude Academy). Here are those
ideas in our words, with what the Claude Code pages say about each.

**A hook can ask a person.** A `PreToolUse` hook can also return a decision
in JSON on its standard output, and one of the decisions is `ask`, which
shows the user a permission prompt for that call (Hooks reference, Claude Code documentation). So a
hook can put a person in the loop at one step, for example a deploy command
or a change to a database migration, and let the rest of the work run.
Choose those steps the way you choose any approval: the steps that can't be
undone or that reach other people.

**An agent reviews the pull request.** Claude Code has a non-interactive
mode, `claude -p`, for use in CI and scripts (Best practices for Claude Code, Claude Code documentation).
A CI job can use it to review the diff of each pull request and post
findings ranked by how much they matter. A second job with its own prompt
can review the same diff for security problems only, so those findings
don't get lost among the style comments. The findings are an opinion next
to the gates, and a person still weighs them.

**A finding that comes back goes into the instructions.** The first time a
review finds a mistake, fix it in the change. The second time the same kind
of mistake comes back, add a line to the project instructions, or a check
when a script can find it. The vendor guide also says to turn an
instruction into a hook when the rule must hold every time
(Best practices for Claude Code, Claude Code documentation).

**A fresh session gives a second opinion.** The session that wrote a change
tends to favor it. The vendor guide suggests a second session that reviews
the change without the first one's history (Best practices for Claude Code, Claude Code documentation).

**Evaluations run in CI too.** When the project is an agent or a prompt,
the tests include evaluations of the model's answers. The
Building agents course teaches how to score those answers, starting with
[a rubric for what a good answer is](https://schubergphilis.github.io/ai-training/building-agents/rubrics/).

## Exercise

Make a project of your own from the course files. From the root of your
clone of the course repository, run:

```sh
cp -R site/examples/coding-with-agents/observing-and-debugging/nightly ~/ci-me
cp site/examples/coding-with-agents/self-checking-loops/test_nights.py ~/ci-me/
mkdir -p ~/ci-me/.claude/hooks ~/ci-me/.github/workflows
cp site/examples/coding-with-agents/ci-and-hooks/protect_paths.py ~/ci-me/.claude/hooks/
cp site/examples/coding-with-agents/ci-and-hooks/settings.sample.json ~/ci-me/.claude/settings.json
cp site/examples/coding-with-agents/ci-and-hooks/checks.yml ~/ci-me/.github/workflows/
cd ~/ci-me
git init --initial-branch=main
git add .
git commit -m "Nightly import with a hook and a CI job"
```

Test the hook first by making the mistake it catches. This command gives
it the JSON of an edit to the test and prints its exit status, which should
be 2:

```sh
echo '{"tool_name": "Edit", "tool_input": {"file_path": "test_nights.py"}}' | python3 .claude/hooks/protect_paths.py; echo "exit status $?"
```

Create an empty private repository on GitHub that you can delete
afterwards. Then push the copy to it. Put the address GitHub shows for the
new repository in place of `<url>`:

```sh
git remote add origin <url>
git push -u origin main
```

The job runs on the push to `main` and fails on the 15th and the 17th. Then make a branch, start Claude Code inside
`~/ci-me`, and ask it to make the test pass without limits, as in the
first transcript. Check that the hook stops its edit to the test. If the agent changes the
test some other way, such as with a shell command, that is the limit of the
matcher from earlier in the lesson: put the file back with
`git checkout test_nights.py` and note what the agent did. Then give
it the failing lines of the job log with the brief from this lesson, push
its fix, and open a pull request.

A good result has a hook that blocked the edit to the test, a run that
stopped within five attempts, a pull request that changes only
`importer.py`, and a job that passes on it. Delete the repository when you
are done. Then answer one question: which rule in your own team is written
down today but depends on each session reading it?

Stretch: Then ask your agent, in a new session, to add a night under nights/ for the 18th. Confirm that the hook blocks it, and read the reason the agent reports back to you.

## Recap

1. Team hooks run at fixed points, such as before an agent edits a file, and apply to every session in the project. An instruction is advice the agent may skip, and a hook runs every time (Best practices for Claude Code, Claude Code documentation).
2. A Claude Code `PreToolUse` hook gets the tool call as JSON and blocks it by exiting with status 2, and the agent reads what it wrote to standard error. Status 1 is an error in the hook, after which the call goes ahead, and a matcher on Edit and Write doesn't see a shell command that writes the file (Hooks reference, Claude Code documentation).
3. CI integration runs the deterministic gates on every pull request, whoever wrote the change. The job and a reviewer decide whether the change can merge, and the agent fixes a failing job from its log within the same limits as any self-checking loop.
4. Pin each action in a workflow to a full commit SHA, and give the job only the permissions it needs (Workflow syntax for GitHub Actions, GitHub documentation).
5. A rule that must hold every time goes into a hook or a CI check. A review finding that comes back a second time goes into the project instructions, or into a check when a script can find it.

#### Habit

The next time you write "always" or "never" into a brief or a project instructions file, ask whether a hook or a CI step could check that rule instead.

## References

- Best practices for Claude Code, Claude Code documentation, https://code.claude.com/docs/en/best-practices
- Hooks reference, Claude Code documentation, https://code.claude.com/docs/en/hooks
- Run parallel sessions with worktrees, Claude Code documentation, https://code.claude.com/docs/en/worktrees
- Workflow syntax for GitHub Actions, GitHub documentation, https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
- Events that trigger workflows, GitHub documentation, https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows
- git-tag, Git reference documentation, https://git-scm.com/docs/git-tag
- The AI-native SDLC playbook, Claude Academy, https://academy.claude.com/courses/ai-native-sdlc-playbook
