AI Engineering · Process, Not Prompts

An Instruction Is Not a Guarantee

Specs tell an agent what to build. They don't stop it from drifting on what it should never do — not one hundred thousand tokens into a session. Here's the process I ended up building around Claude Code instead of trusting it to remember.

A little while back I left a comment on a post by Dr. Maryam Miradi about spec-driven AI engineering — the practice of writing down, in real detail, what an agent is supposed to build before it writes a single line of code. I meant every word of that comment. Writing the spec first stopped being optional a while ago. It's the difference between directing an engineer and hoping a competent one shows up in the diff.

But somewhere over the last year, Claude Code stopped being something I reviewed line by line and became something that ran for hours at a stretch — reading a spec, breaking it into tasks, writing the code, running its own tests, deciding for itself whether to move on to the next step. Once that happened, I noticed the spec was answering the wrong question for the failure modes I was actually seeing. A spec is very good at telling a model what to build. It has almost nothing to say about how the model should behave while it's building it — what it's allowed to skip, what "done" is allowed to mean, whether it gets to grade its own homework.

So I wrote instructions for that too. Never commit straight to the main branch. Never mark a test as passing without actually running it. Never skip a step because it looks trivial this time. All reasonable rules, all correct — and all, in the end, just sentences in a markdown file. A model reads them, agrees with them, and then, deep into a long autonomous run, quietly stops acting like it ever read them. Not out of defiance. Attention isn't memory, and a rule that isn't in front of the model right now has to compete for space with everything that is.

"An instruction like ‘never do X’ in a markdown file is still just an instruction."

That's the part I think gets missed in most of the spec-driven conversation. A better-written spec does not fix an attention problem. You can phrase "never do X" as clearly as language allows, and it is still a sentence the model has to remember to apply, every single time, for the rest of the session. If I really mean never, wanting isn't a mechanism 😄. So at some point I stopped trying to word the instruction better and started building the thing that would enforce it whether the model remembered or not.

In practice that turned into four pieces of process wrapped around the model — not inside its prompt, around its work.

Skills

A skill is how I tell the model how a specific kind of work gets done, not just what the end result should look like. A database change follows a different discipline than a security review, which follows a different discipline again from a UI change. A skill writes that discipline down once and loads it automatically when the task matches, so the model isn't improvising a process it should already know cold.

Hooks

Hooks are where "never" actually becomes never. A hook doesn't live in the model's context window — it runs in code, entirely outside the conversation, and it can block an action before it happens: a destructive command, a deploy that skips a required check, a commit that touches something it shouldn't. The model doesn't have to remember the rule, because the model was never the one enforcing it. An instruction competes for attention. A hook has no attention to lose.

Gates

A gate is a checkpoint between phases of work that doesn't open on the model's say-so. A phase isn't done because the model says it's done — it's done when whatever the gate checks for is actually true: the artifact exists, the test genuinely ran, the criteria are met in fact, not in a status update. No stub passes as finished. No skipping ahead because a step looked easy this time.

Independent review

The last piece: the model that wrote the code is never the model that approves it. Review goes to a different model entirely. A model checking its own output has every incentive — structural, not moral — to see what it meant to do rather than what it actually did. A second model with no stake in the first draft doesn't carry that blind spot.

Put together, that's a small closed loop, and it's worth drawing out properly, because the loop is the actual point:

FAIL / REJECTED → SENT BACK TO BUILD TASK SKILL defines how BUILD HOOK always-on enforcement GATE pass / fail checkpoint REVIEWER a different model APPROVED FAIL / REJECTED → SENT BACK TO BUILD TASK SKILL defines how BUILD HOOK always-on enforcement GATE pass / fail REVIEWER different model APPROVED

A task doesn't reach Approved by the model's word alone. A failed gate or a rejected review isn't debated — it's sent back to Build, automatically, every time.

None of this needed custom infrastructure. It's built entirely out of Claude Code's own extension points — two of them apply to your whole machine, the rest are just habits you wire into them.

GLOBAL ~/.claude/

Applies to every project on the machine. This is where a rule earns its keep once you've written it twice.

PROJECT .claude/

Applies to one repo, and can be committed so a team shares it. Both layers merge — a project only adds to global, it doesn't replace it.

~/.claude/skills/database-migration/SKILL.md Global
---
name: database-migration
description: How schema changes get made safely
---

Before writing a migration:
1. Read the current schema. Don't reconstruct it from memory.
2. Every migration ships an up and a down — no one-way doors.
3. Run it against a copy of real data, not an empty test database.
4. One migration touches one concern. Never bundle two unrelated table changes.

This loads automatically the moment a task looks like a schema change — you don't type the skill's name, you just ask for the migration.

~/.claude/settings.json Global
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/block-force-push.sh" }
        ]
      }
    ]
  }
}

↳ ~/.claude/hooks/block-force-push.sh

#!/bin/bash
# Claude Code passes the pending call as JSON on stdin
cmd=$(cat | jq -r '.tool_input.command')

if echo "$cmd" | grep -qE '\-\-force.*(main|master)'; then
  echo "Force-push to main is blocked. Do it on purpose, not by muscle memory." 1>&2
  exit 2
fi
exit 0

Exit code 2 is the whole trick — it's the one code Claude Code treats as an actual block, not a warning. The model never adjudicates this; it's just told the command didn't run, and why.

.claude/hooks/tests-gate.sh Project
#!/bin/bash
# Runs when Claude finishes a turn — checks evidence, not the summary
if ! grep -q "0 failed" test_output.txt 2>/dev/null; then
  echo "Gate failed: no test_output.txt with a real 0-failed line." 1>&2
  exit 2
fi
exit 0

A gate isn't a separate primitive — it's the same hook mechanism, just wired to Stop (end of turn) instead of PreToolUse. It doesn't read the model's summary. It reads the file the tests actually wrote.

.claude/agents/reviewer.md Project
---
name: reviewer
description: Reviews a diff. Never invoked to write one.
model: opus
---

You did not write this code. Assume it's wrong until you find evidence it isn't.
Check the diff against the stated requirement, not against what you'd guess the author meant.
Flag anything you can't verify — don't fill the gap with good faith.

The point was never which model reviews — it's that whichever one does had no hand in the first draft. Swap model: for whatever your setup treats as a second opinion, including a second Claude session that never saw this one's context.

Every one of those is a plain text file. None of it is exotic — the discipline is in wiring it up before you need it, not after a run has already gone sideways at 3am.

Anything that fails a gate or gets rejected on review doesn't get argued with. It goes back to be rebuilt. Nobody has to catch it by re-reading the whole transcript, because the transcript was never the thing doing the catching in the first place.

The honest way to describe where this leaves me: Claude Code has stopped being an assistant I supervise and started being an engineer operating inside a factory that has its own inspectors. I still write the spec. I still decide what gets built. I just stopped assuming that writing "and never do X" at the top of a file was the same thing as making it true.

Spec-driven engineering tells an agent what good looks like. It was never going to be the thing that stops an agent from deciding, quietly, three hours into a run, that this one time doesn't count. That job belongs to something that isn't a sentence — a hook, a gate, a second set of eyes that didn't write the first draft.

Skip that part and you haven't automated engineering. You've automated the part where nobody was watching.