A language model writes most of my production code. I cannot interview it, check references, or watch it work, so instructions were the only lever I had. They are not a lever. Subagents committed to git despite an explicit prohibition in every prompt, because a rule in a prompt is advice weighed against everything else in the context. The fix was to move the rules that matter out of the prompt and into programs that run before a tool call and can refuse it. There are 6 of those on the tool boundary now, and 4 can say no.
Key facts. 28 hooks wired in total, 6 registered against tool calls, 4 of those able to refuse. Standing rules written 11 June 2026, task log appending since 27 June.
Last updated:
01My subagents committed to git with the prohibition in every prompt
My AI coding system is not a chatbot and not autocomplete. It reads files, runs shell commands, calls APIs, and changes things that are hard to change back. Once software can act rather than only answer, the question stops being how good the output sounds and becomes what it is allowed to do when it is wrong.
For a while my answer was instructions. Write the rule into the prompt, in capitals, in every prompt, and repeat it.
It does not work, and I have the incident. Subagents dispatched with an explicit written prohibition against committing to git committed to git. Five commits landed mid-run that nobody asked for.
The reason is structural. A rule you write into a prompt lands in the same context window as the question, the conversation so far, and whatever got fetched along the way. The whole pile is weighed together. Usually the rule wins. Sometimes it loses, and when it loses there is no error, no warning and no log line, because from the inside nothing went wrong.
02Testing the model harder cannot fix this
Evaluating harder does not close this gap, and the reason is structural.
Every evaluation samples a distribution and trusts the sample to stand for the whole. That is the assumption an interview makes about a person, and it is roughly sound for people, because behaviour is roughly stable.
Here it is not stable. Ten minutes after handling a multi-file refactor cleanly, the same model will invent a configuration value that does not exist. Both outputs arrive in the same register, at the same speed, with the same confidence. There is no signal in the tone.
That does not make evaluation useless. It makes it insufficient. The question I stopped asking is whether the model is good. What I ask now is what the surrounding system refuses to let it do.
03The fix is a program that runs before the tool call
Mechanically this is dull, which is exactly the appeal. A control here is a shell script registered against a tool in a config file. Before the tool runs, the harness runs the script. If it refuses, the tool call never happens, and the model does not get to retry more persuasively, because it was never asked.
This shape is old and proven. A git pre-commit hook is the same idea: an ordinary program that runs before an action and refuses it with an exit code. The kernel does the same job for system calls. What is new is pointing it at a model.
The counting matters, and my own copy had it wrong until I checked while writing this. There are 28 hooks wired across eight points in a session. Most only observe or inject context. Six are registered against tool calls, which is the only place a refusal can happen.
Of those six, four can actually refuse. Three do it by exiting with a specific code. One refuses by returning a permission decision instead, which is why an earlier count that grepped for the exit code found three and missed it. Two observe only.
04A guard that blocks everything gets switched off inside a week
The shell guard runs two pattern lists. A wide one of 39 alternatives covering data loss, irreversible git operations and metered resources. A narrow one of 21 covering the genuinely unrecoverable: force pushes, database and table removal, cloud resource deletion.
The narrow list refuses, always. The wide list does not block at all. It logs the hit, injects the reason into context, and allows the call.
That is deliberate, not an oversight. A gate that blocks everything gets bypassed within a week, and a bypassed gate protects nothing. So a broad pattern runs in the open first and earns the right to refuse by proving it is well targeted. It had logged 541 warnings between 11 July and 4 August.
It matches the text of a command, not the intent behind it, which you should know before trusting any number it produces. Writing this page tripped it twice. Once on a command that read the guard's own deny list. Once on a log message where a colon happened to be followed by a word that looks like a table removal to a regular expression.
05Be careful is unenforceable. A sequence is not.
Eight standing rules sit underneath all of this, written on Thursday 11 June 2026, the morning after the system came close to deleting a client's production database.
The heaviest governs anything irreversible, and it does not say be careful. Be careful is unfalsifiable, which makes it unenforceable. It specifies a sequence instead.
Name the operation in plain language. State the blast radius. State what was checked to confirm it is safe. Ask. Stop. Wait for an explicit yes. Then execute, and verify the resulting state.
Ok, continue, a thumbs up, and silence are each written into the rule as not approval. Every one of them is listed because every one of them had been tried. A prior approval does not carry to the next operation either.
06The component that touches every task fails my own bar
The bar I set is that a machine grader's verdicts do not count until it agrees with my own marking at a kappa of 0.7. Kappa measures how often two markers agree beyond what chance would produce.
Three graders clear it on sets I labelled myself: 0.724 on 46 pairs, and 0.7097 on 45. A third scored 1.0 on 35 pairs and I do not trust it, because the grader's own verdicts were visible to me while I marked that set. The unanchored cross-check on it reads 0.768.
The component that touches every single task does not clear it. Its one calibration scored 0.1 against the same bar, over nine comparable pairs, raw agreement three of nine, recorded verdict FAIL, with a note that the labelling method could only have inflated the score.
So the bar is real and met in places, and the piece of machinery with the widest reach is the one that fails it. That is the accurate summary and it is not a flattering one.
07None of this makes the model better, and that is the point
None of this makes the model better. That is the point.
The model improves or drifts on someone else's release schedule and I control none of it. The surrounding system is the part I control, and it accumulates. An incident becomes a rule. A rule that matters enough becomes a program. The programs survive the model being swapped out from under them.
That rulebook stands at 68 numbered lines, 54 days after the first eight were written, in a file that can only be appended to and is read at the start of every session. What I did not expect is that the original eight have not changed. Everything on top is scar tissue from specific incidents.
Each remaining piece in this series has one of those incidents behind it. An alarm that was false and still pointed at a real fire. A scoreboard that quietly erased wins. Backups that failed silently for weeks. Every time, the same pattern: something reported success while doing nothing, and the only reason I know is that a program outside the model wrote down what actually happened.
08How every number here was derived
These commands read live state, so re-running them will give different numbers from the ones quoted here, usually larger. That is the point of printing the commands rather than only the conclusions.
# the hooks, counted by event. 28 total, 6 on the tool boundary
python3 -c "import json,os;h=json.load(open(os.path.expanduser('~/.claude/settings.json')))['hooks'];\
print({k:sum(len(e.get('hooks',[])) for e in v) for k,v in h.items()})"
# which of the six can actually refuse. NOTE two different mechanisms:
# an exit code, and a returned permission decision. Counting only the first
# undercounts, which is exactly what an earlier version of this page did.
for f in ~/.sgnk/bin/sgnk-*-gate.sh ~/.sgnk/bin/sgnk-bash-guard.sh; do
printf '%s exit2=%s deny=%s
' "$(basename $f)" \
"$(grep -cE '\bexit 2\b' $f)" "$(grep -cE '\"deny\"' $f)"
done
# the two pattern lists, and the shadow-mode hit count
grep -cE '\|' ~/.sgnk/bin/sgnk-bash-guard.sh
wc -l < ~/.sgnk/state/bash-guard-warn.log
# the standing rules, and the append-only block they live in
grep -c '^[0-9]\+\. \*\*' ~/.claude/CLAUDE.md
date -j -f "%Y-%m-%d" "2026-06-11" "+%A"
# the grader agreement scores, against my own labelled sets
cat ~/.sgnk/calibration.json | jq -c '.[] | {skill, kappa, n, verdict}'