A loop that refactors code until a check passes is a few lines of shell. Most guardrails beside such a loop only watch what an agent already wrote; the one worth building refuses a bad edit before it lands, and that is what this piece constructs.

The rule it enforces: production code under src/ must not import a mock library. That rule can live in two places. Put it in the prompt, do not import mock, and it is clear and still only a request: text loaded into a model is a lever on probability, not a switch, so it leaves the model free to write the import anyway. Put it in a gate that fires before a write lands, and it refuses the edit outright. Both state the same rule; only one can make it hold every time.

The ask channel has a ceiling: a prompt pushes the odds up, never to one. How close a prompt gets to that ceiling is measurable work. Reporails reads the asking channel and shows you, with measured evidence, which instructions pull the lever and which are text the model is free to ignore, so you can write the ask against evidence instead of on faith. Even a well-measured prompt tops out below certainty, because the model underneath stays probabilistic. Closing that last gap is what the gate is for.

The loop, and where it can only ask

This is the second component of the loop taken on its own: the gate. The series opener named the loop (generate, check, steer, retry, stop) and posed three questions about the components it turns on. The first is the check, the arm that decides good enough, stop. This piece takes the second: the rules that can refuse a diff, and the difference between a channel that can ask and a channel that can say no.

Here is the loop the series is dissecting. It refactors src/ until a guard holds. The agent never decides it is done; the guard does.

#!/usr/bin/env bash
# work-until-checked: refactor src/ until the guard holds.
# The steering rule lives in the agent's own instructions (CLAUDE.md),
# stated once; the loop feeds back only what changed, the guard's output.
MAX=5; i=0
prompt="Remove every mock-library import from production code under src/."
while [ "$i" -lt "$MAX" ]; do
    run_agent --task "$prompt"                      # GENERATE (rule already in context)
    if bash no-mocks.sh; then                       # CHECK
        echo "stop: guard holds after $i retries"; exit 0
    fi
    prompt="The last attempt still tripped the guard; fix it:
$(bash no-mocks.sh 2>&1)"                            # STEER: only the new signal
    i=$((i + 1))
done
echo "stop: budget exhausted, guard still red"; exit 1

Read the arms one at a time. The steering rule lives once in the agent's own instructions, its CLAUDE.md, so run_agent --task "$prompt" never re-ships it. That first $prompt is the ask, and the rule already in context is text the model reads as a lever on probability, not a switch: Remove every mock-library import moves the odds that the next diff is clean without setting them to one. The STEER arm rewrites $prompt to carry only what changed, the guard's latest output, and asks again against that. A tighter, more specific ask, but an ask.

no-mocks.sh is the only arm that is not asking. It scans src/ after run_agent returns, so it is a detector: it reads the diff the agent already wrote and returns a verdict on it. When the verdict is red, the loop retries. Nothing here stops the bad write from landing in the first place. The guard catches after the fact, and the loop re-rolls. Between the write and that catch, a forbidden import can sit in production code for a whole iteration before the detector sees it.

For a refactor loop that converges, the window is usually harmless, because the next iteration overwrites it. But "usually harmless" is doing a lot of work, and the cases where it is not are exactly the constraints you cared enough to write down twice.

Two channels: one asks, one refuses

A prompt is the channel that can only ask. Everything on the context surface, the task, the agent's instructions, the system prompt, is an input to a probabilistic process. It shifts the distribution of what the model produces, which is why prompt and context engineering are real disciplines. It is also, by construction, unable to force any single outcome. The prompt asks, and any given iteration is free to ignore it.

A gate is the channel that can refuse. It is a deterministic function that fires at a fixed point and returns a hard verdict the model does not get to route around. It does not shift odds; it either lets the diff through or it does not.

The base loop has an asker (the task), a steerer (the retry), and a detector (the guard). It has no refuser. Nothing in it can stop a write before the write happens, so a loop that checks for exactly the thing you forbade can still let it land and catch it a beat too late. Adding the refuser is the rest of this piece.

Build the missing arm: a hook that refuses

Claude Code fires hooks at named transitions in the harness: SessionStart, PreToolUse, PostToolUse, Stop. A PreToolUse hook runs before a tool call executes and can block it. That is the transition to key on, because it is the one point where you can refuse a write before it happens rather than detect it after. Here is where the gate sits in the lifecycle of a single write:

flowchart LR
    A["SessionStart"] --> B["Agent calls<br/>Write / Edit"]
    B --> C{"PreToolUse<br/>gate"}
    C -->|"refuse: exit 2"| B
    C -->|"allow: exit 0"| D["Tool executes<br/>(write lands)"]
    D --> E["PostToolUse"]
    E --> F["Stop"]

The gate is the diamond, and everything downstream of the allow edge is the write reaching disk. Refuse there and the write never reaches the Tool executes node.

Here is a hook that blocks any Write or Edit whose content adds a mock import under src/. Save it as .claude/hooks/block-mock-imports.sh:

#!/usr/bin/env bash
# block-mock-imports.sh: refuse any Write/Edit that adds a mock import to src/.
# Runs as a Claude Code PreToolUse hook. Exit 2 blocks the tool call.
payload=$(cat)   # the tool call arrives as JSON on stdin
path=$(jq -r '.tool_input.file_path // ""' <<<"$payload")
content=$(jq -r '.tool_input.content // .tool_input.new_string // ""' <<<"$payload")

case "$path" in src/*) ;; *) exit 0 ;; esac   # only guard production code

if grep -qE '^(import mock|from mock|from unittest import mock)\b' <<<"$content"; then
    echo "blocked: '$path' would add a mock import to production code. \
Use a real dependency or a constructor-injected fake." >&2
    exit 2   # exit 2 == hard block; the message on stderr goes back to the model
fi
exit 0

Wire it up as a PreToolUse hook matched to the write tools, in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-mock-imports.sh" }
        ]
      }
    ]
  }
}

Now the agent tries to write import mock into src/badcache.py. The transition fires before the write:

● Write(src/badcache.py)
  ⎿  Blocked by PreToolUse hook:
     blocked: 'src/badcache.py' would add a mock import to production code.
     Use a real dependency or a constructor-injected fake.

A tool-call card for Write(src/badcache.py) stamped 'Blocked by PreToolUse hook' with the hook's refusal message, and the file shown untouched.

The write never happened. src/badcache.py is untouched. The hook read the pending tool call, matched ^import mock in the content the agent was about to write, and returned exit code 2, which Claude Code treats as a block with the stderr message handed back to the model. The model sees the refusal in the same turn and has to try something else. There was no window, because there was no write to catch.

Notice the pattern is anchored: ^import mock, not a bare substring. A docstring that merely names the rule does not start a line with an import, so it does not trip the hook. Anchoring matters more here than in a detector that only reports: a match keyed too broadly refuses clean work, and that carries higher stakes at a gate, which is the next section.

Compare the hook to no-mocks.sh. They match nearly the same rule. What differs is where they sit. The guard sits after the write and reports; the hook sits before the write and refuses. Same constraint, two channels, and only one of them closes the window between the write and the catch.

Before/after of one iteration's timeline: top, prompt-only loop with a shaded exposure window where the forbidden import sits in src/ between write and detector catch; bottom, gate added, the write is refused before it lands and the window is gone.

flowchart LR
    P["Prompt + rules<br/>(asks)"] --> G["Agent proposes a write"]
    G --> H{"PreToolUse hook<br/>(refuses)"}
    H -->|"banned import"| X["Blocked, message back to agent"]
    X --> G
    H -->|"diff clean"| W["Write lands in src/"]
    W --> C{"no-mocks.sh<br/>(detects)"}
    C -->|red| G
    C -->|clean| S["Stop"]

Which rule goes in which channel

Not every rule belongs in the refusing channel, and filing one in the wrong channel fails in a specific, quiet way in each direction.

A must-hold constraint filed as mere steering gets ignored the one time it matters. The prompt asks, the model is free to decline, and on the iteration where it declines, the diff lands. A must-hold rule in an ask-only channel holds on every iteration except the one where the model declines, and that iteration is the case you filed it for.

A rule that only needed to steer, but is hard-gated, causes friction and false blocks. This is the over-matching failure pointed at a gate instead of a detector, and it costs more here. A gate keyed too broadly refuses clean work: the developer who names the rule in a docstring, the test file that legitimately imports a mock, the production helper named mock_response that a bare substring match trips on. A false alarm from a detector is noise you can ignore for one iteration. A false block from a gate is work that cannot proceed until someone fixes the gate. Refusal is load-bearing in the moment, so an over-broad refuser has a larger blast radius than an over-broad detector.

A 2x2: must-hold rule as steering (silent miss, the diff landed) vs must-hold rule as gate (held); flexible rule as steering (fine) vs flexible rule as gate (false block on clean work).

The heuristic: gate what must hold every time and is cheaply, deterministically checkable; steer what needs judgment or flexibility. no mock import in production code is a fixed, must-hold, one-line-of-grep constraint, so gate it. prefer constructor injection over a service locator is a judgment call with real exceptions, so steer it and let a check observe rather than refuse. A criterion you cannot express as a deterministic match at a transition is a criterion you cannot gate; if it needs a model to judge it, it belongs in the asking channel, with all the probability that implies.

flowchart TD
    A["A rule to place"] --> B{"Must it hold<br/>every time?"}
    B -->|no| S["Steer: put it in the prompt"]
    B -->|yes| C{"Deterministic,<br/>cheap check?"}
    C -->|no| O["Steer + observe:<br/>prompt, plus a detector that reports"]
    C -->|yes| G["Gate: a PreToolUse hook that refuses"]

The generator's "done" is a claim to re-derive

Underneath both channels is the posture the series keeps returning to. An agent's done is a claim to re-derive, not a fact to relay. The loop already refuses to trust the generator's report that the work is finished; that is what the check is for. The gate extends the same distrust one step earlier, to the moment of the write. The agent believes its edit is fine. The gate does not accept the belief; it re-derives the verdict from the diff, deterministically, before the edit is allowed to exist. Steering is where you tell the generator what you want. Gating is where you stop taking its word for it.

What holds, and what it still costs to keep loaded

With the hook wired in, the rule you needed to hold every time does. do not import mock holds on every iteration now, on the ones you read and the ones you never open, because a PreToolUse gate does not depend on your attention or the model's cooperation. That is what "hold every time" costs: a channel that can refuse. No amount of rewording the ask gets you there. The prompt still does its job, moving the odds toward a good diff, and the gate makes the one diff you cannot afford impossible. You want both, filed correctly.

One component the opener flagged sits underneath both channels: the context surface. Every rule you steer with is text loaded into the model, and the whole surface is paid for on every turn, whether the model needs a given rule that turn or not. The loop here states its one rule once, but most agents carry far more. What does an instruction cost to keep loaded, and what changes when you load it only where it applies instead of keeping the whole surface in front of the model every turn? That is the question the next piece takes up.


I work on Reporails, deterministic diagnostics and governance for the instruction files, rules, and prompts that steer coding agents. It reads the steering channel, the text you use to ask, and tells you, with measured evidence, where it drifts. The hooks here are the enforcing channel that sits beside it: what a rule refuses at a transition, not what it asks for on the surface.