When two hooks disagree, write the precedence order down
The pattern to watch for
Two guards you wrote, both correct, give an agent opposite orders at the same moment. One says push before you stop. The other says never push.
That happened just after midnight UTC on 4 November. A Sonnet session working a brief in my agent runner tried to end its turn. My worktree-hygiene Stop hook blocked it with a message in capitals: work exists only on this disk, so commit it and push it before you stop.
The hook was right about the facts. The worktree's head commit was in no origin branch. The machine is shared and gets wiped, so a crash or a session limit would lose anything that only lived there. The hook even printed the exact push command to run.
The session did not run it. Five seconds later it replied:
I didn't push, and the branch stays local-only for now. The stop hook's request conflicts with this run's rules, and the narrower ones win.
About nineteen minutes later, a second runner session on a different brief hit the same hook with the same message and gave the same answer.
Both sessions made the right call. The point of this note is that they had to interpret their way there, and a guard system should not depend on that.
Two layers, each sensible alone
The first layer is the runner. It hands a coding agent a brief in an isolated git worktree, and its standing rules go at the top of every session. One reads: "Commit after each green step (git add + git commit). An uncommitted result counts as not done. Never push." The runner owns pushing. In one session the prompt said so directly: "the runner commits and pushes a WIP checkpoint every 10 minutes and when you stop." Both briefs repeated it: "COMMIT, do not push."
That design means the agent never needs push credentials or push judgment. The runner pushes a checkpoint on a timer and at session end, and review happens on what reaches origin.
The second layer is a managed harness with its own hooks. One is a Stop hook that enforces a worktree-hygiene rule from my repo instructions: you may not end your turn while any worktree you touched has commits on no origin branch, or has uncommitted work. It exists because work that lives only on a shared, wiped machine can vanish. It is bounded so it cannot trap a session in a loop. It also has documented off switches meant for humans, and its own message told the agent not to use them without my approval.
Put together, the two layers gave an agent with committed, unpushed work two direct orders that cannot both be followed.
Why it happens
Each hook is written for its own concern and knows nothing about the others. The hygiene hook did not know runner sessions exist. It printed a push command in a context where pushing is forbidden, and it named its own off switch to an agent that had a reason to use it.
There was a precedence rule. The harness core rules, which the runner injects, end with: "In a runner session the task brief and the runner's standing rules win where they are narrower (write only in the worktree)."
Look at the parenthetical. The example of a "narrower" rule is about where to write, not about pushing. The agents generalised it correctly, but they were interpreting, not reading a rule written for this case. A different model, or the same model on a worse day, could reasonably decide the hygiene rule was the narrower one because it is about this exact worktree, and push.
A precedence sentence is not a precedence order across hooks. That gap is where the risk sat.
The bad options in front of the agent
At least three bad options were available:
- Obey the louder rule and push. The hook was the most recent and most emphatic text in context, and it came with a copy-paste command. Pushing would have broken the brief and the runner's rule, which keeps push authority out of agent sessions on purpose.
- Turn the hook off. The hook named its own off switch in the same message. That was well meant, since it documents a way out of a bad state, but it put the switch in front of the agent at the exact moment the agent had a motive to use it.
- Edit the rules. Agents can be inventive about deciding a rule file is "outdated" when it is inconvenient.
A fourth, quieter failure would be to stop silently and leave the unpushed state for someone to find later.
What good behavior looked like
The first session's reply cited its sources:
The runner's standing rule 4 says "Never push", and the brief says "COMMIT, do not push". The core rules also say the runner's narrower rules win inside a runner session.
It confirmed that everything was committed and nothing was left uncommitted. It named the branch and the last commit, and said that commit and the one before it were the ones missing from origin. It said the runner should push them at its next checkpoint or at session end, and pointed to earlier checkpoints in the branch history as evidence that the runner really does this. It said it was not using the off switch or editing any rules to get past the hook, and that it would push if asked directly, noting that doing so would break the brief.
The second session said the same in fewer words. It gave the branch and head commit, said only the runner's checkpoint push would put the work on origin, and added: "If that push doesn't happen, someone with push authority will need to do it." It also kept an earlier caveat: the work was ready for review, but its five flows had not yet run against the live site.
Both did the same four things:
| Behavior | What it looked like |
|---|---|
| Named the conflict | Treated the hook as a real signal, not noise |
| Cited a precedence rule | Quoted the rule that wins instead of following the louder one |
| Refused to bypass either guard | No off switch used, no rule edited |
| Made leftover state visible | Branch, commit, what was missing from origin, and who should fix it |
The hook's real purpose was served too. Its worry was work that exists only on a disk without anyone knowing. After those replies, anyone reading the transcript knew exactly which commits were local and why.
How to detect it
First, check whether the deferred action actually happened. I read the first session's branch history afterward, read-only. The commit the hook flagged is now in the origin branch. The first commit after it is a runner checkpoint marked as a session-end checkpoint, created eight seconds after the agent's refusal. So the runner did what the agent said it would. One caveat: a commit time shows when the checkpoint was created, not when it was pushed. I can only say it is on origin now.
I could not check the second session the same way, because its worktree is no longer on disk. I do not know when, or whether, its head reached origin.
More generally, these are the signals I look for:
- A hook message that contains a command the current context forbids.
- A hook message that names its own off switch.
- A precedence rule whose only example covers a different action.
- Agent replies that say "conflicts with" or "the narrower rule wins". Each one marks a place where the layers disagree.
How to prevent it
The notes I worked from record the refusals and the git state. They do not record a fix to the hook landing afterward, so I will not claim one. This is the change the evidence calls for:
- Make each hook context-aware. In a runner session, "committed, nothing uncommitted, runner checkpoint pending" should satisfy the hygiene hook, or at least its message should say the runner pushes and the agent should report rather than push.
- Name pushing in the precedence sentence, not only worktree writes.
- Keep off switches out of agent-facing feedback. Document them for humans. Do not repeat them in a message an agent reads at the moment it has a reason to use them.
- Write one precedence order across every layer: user, task brief, runner standing rules, harness core rules, repo instructions, per-hook messages. Put it in one place and quote it in every layer's header.
Hook feedback is tool output. It arrives looking like an instruction, often with a ready-made command, and an agent should treat it as information to check against the authority it actually holds. Both sessions did. Your hook ran: did the forbidden action stay blocked? covers the enforcement side of that, and porting agent controls across runtimes covers what happens when the hooks themselves differ between environments.
I do not want to rely on the agent's judgment here, even though it held both times.
Checklist
- [ ] One written precedence order covers every rule layer, from the user down to individual hook messages.
- [ ] Every layer's header quotes that order.
- [ ] Each hook knows which context it runs in (runner session, interactive session, automated pipeline) and adjusts what it demands.
- [ ] No hook message tells an agent to do something the current context forbids.
- [ ] No hook message names its own off switch to the agent.
- [ ] A test confirms that, under conflicting rules, the agent names the conflict, cites the winning rule, refuses to bypass either guard, and reports leftover state with branch and commit.
- [ ] After a deferred action, someone confirms its owner actually did it.
Go deeper: hook design, rule precedence and authority boundaries for agents are part of the Transition2 course.