Back to blog
Series · Day 12
Using AI Day to Day
View all lessons →
One prompt, one check

Day 12: One Prompt, One Check

You asked for three things. You got back a diff that touches all three, a green test suite and a confident summary. One of the three is quietly gone, and nothing on your screen tells you which one. Putting several requests into one prompt feels efficient. In practice it usually means something gets lost where you won't think to look. Today's rule is about where a prompt should stop, and you can start using it tomorrow morning.

The hook: the deleted lock

Here's a two-line prompt. It looks fine, and most of us have sent something close to it to Claude Code, Cursor or Copilot agent mode:

text
Fix the race condition in reserveSeat() where two requests can book the same seat.
Then clean up bookings.ts: remove unused code and simplify where possible.

Step one comes back correct. Then step two runs, and it looks at step one's work and sees clutter:

diff
// Step 1: fix the race condition
 export async function reserveSeat(seatId: string, userId: string) {
+  const lock = await locks.acquire(`seat:${seatId}`);
+  try {
     const seat = await db.seats.find(seatId);
     if (seat.reservedBy) throw new SeatTakenError(seatId);
     await db.seats.update(seatId, { reservedBy: userId });
+  } finally {
+    await lock.release();
+  }
 }

// Step 2: "remove unused code and simplify where possible"
 export async function reserveSeat(seatId: string, userId: string) {
-  const lock = await locks.acquire(`seat:${seatId}`);
-  try {
     const seat = await db.seats.find(seatId);
     if (seat.reservedBy) throw new SeatTakenError(seatId);
     await db.seats.update(seatId, { reservedBy: userId });
-  } finally {
-    await lock.release();
-  }
 }

Every test passes. The suite sends one request at a time, so nothing ever exercised the lock. The agent's summary still says it added a per-seat lock and cleaned up the file. That's an accurate account of what happened during the run, but the final code has no lock. The AI wasn't confused, either. It did exactly what you asked, in an order that set it up to fail.

Why it happened, in plain words

Partway through a long run, the AI stops treating its earlier edits as its own work. They become existing code, and it's free to tidy existing code. When the cleanup instruction arrives, the lock is just one more try/finally block around a helper that nothing else in the file calls. "Simplify where possible" gives it permission to delete it.

A human developer would remember adding that ten minutes ago for a reason. The model only has the tokens in front of it, and the reason is rarely written down there. It won't keep that intent unless you give it a boundary: a commit, a review, or a new prompt that starts from a fixed state.

  • ▹Fixes add constraints: a lock, a guard clause, a null check, an extra validation.
  • ▹Cleanups remove whatever looks unnecessary.
  • ▹To a cleanup step, a new constraint that comes with no explanation looks exactly like something unnecessary.

The quieter version you've probably already shipped: averaging

The deleted lock is the dramatic case. The common case is quieter. Ask for "fix the pagination bug, refactor the query builder, and update the README" and you'll often get back:

  • ▹A solid fix. A failing test told the model exactly what "done" meant.
  • ▹A "refactor" that mostly renames things, because nothing said when the structure was good enough.
  • ▹Docs that describe the code as it was before the refactor, because that's the version they were written from.

Each part looks finished. The diff is big and touches everything you asked for, so it looks complete and nobody reviews it properly. Three things that are each three-quarters right are harder to catch than one obvious failure. A red test gets fixed. A mediocre refactor and slightly stale docs get merged.

The reason is simple. The model works toward whatever signal tells it it's done. A test gives a sharp signal. "Cleaner" and "well documented" are vague. Put both kinds in one prompt and the sharp check gets most of the effort, while the vague ones get done just enough to look finished.

The rule for today: count the checks, not the verbs

The unit of a prompt isn't a task or a verb. It's one way of checking the result. Before you hit enter, ask how you'll know each part is done. There are three common answers:

  • ▹Mechanical: the compiler, type checker or linter passes.
  • ▹Behavioural: a specific test goes from red to green, or an existing suite stays green.
  • ▹Judgment: you read it and decide whether it's good. Naming, structure, docs, error message wording and UX copy all fall here.

If every part uses the same kind of check, send it as one prompt. If there's more than one kind, split it. The number of verbs doesn't matter. "Rename, move, and update imports" is three verbs and one check. "Fix and tidy" is two verbs and two checks.

Quick-fire: guess before you read the answer

For each prompt, decide whether to bundle or split before you read the answer.

  • ▹"Rename UserDTO to AccountDTO across 30 files and fix the imports." Bundle. There's one check: the compiler plus the existing tests. If it builds and stays green, it's done.
  • ▹"Fix the off-by-one in paginate() and add a regression test." Bundle. The fix and the test share one check: the new test fails before the fix and passes after it.
  • ▹"Fix the null crash in parseInvoice() and tidy up the error handling." Split, even though it's the same 20 lines. A test proves the crash fix. 'Tidy' comes down to taste, and taste-driven edits are exactly the kind that remove the guard you just added.
  • ▹"Fix the timezone bug and write the docs for the scheduling module." Split. A test proves the fix, but you have to read the docs to judge them. The docs should also describe the code after the fix, not partway through it.

The pattern: bundles of mechanical work are safe, and so is a fix paired with its own test. If a prompt pairs a change checked by a test with a change checked by your judgment, split it.

Before and after: a prompt pair to copy

Before: one bundled prompt with three kinds of check.

text
Fix the race condition in reserveSeat(), clean up bookings.ts,
and update docs/bookings.md to explain how reservations work.

After: the same work as three short prompts, run in order. Each one ends with a "Done when…" line, and there's a checkpoint between each:

text
PROMPT 1 — behavioural check
Fix the race condition in reserveSeat() in bookings.ts where two concurrent
requests can reserve the same seat. Add a test that fires two reservations
for the same seat in parallel and expects exactly one to succeed.
Do not change anything else in the file.
Done when: the new concurrency test fails on the current code and passes
after your change, and the existing suite stays green.

>>> CHECKPOINT: run tests, read the diff, commit "fix: lock seat reservation"

PROMPT 2 — judgment check (with a mechanical guardrail)
Clean up bookings.ts: remove dead code, simplify control flow, improve names.
The per-seat lock in reserveSeat() is a deliberate concurrency fix from the
previous commit. Do not remove or weaken it.
Done when: behaviour is unchanged (all tests, including the concurrency test,
still pass) and the diff contains only structural changes.

>>> CHECKPOINT: review the diff for taste, commit "refactor: tidy bookings.ts"

PROMPT 3 — judgment check
Update docs/bookings.md to describe how reservations work in the CURRENT
bookings.ts, including the per-seat lock and why it exists.
Done when: every function and behaviour mentioned in the doc exists in the
code as it is now, and the locking behaviour is explained in one paragraph.

>>> CHECKPOINT: read it, commit "docs: explain seat reservation locking"

It's three times the typing, and it saves far more than that in review. Each commit is small enough to review properly and to revert on its own. The checkpoint matters as much as the split. It turns "code I just wrote" into "code that was committed on purpose", and you can tell the next prompt to leave committed code alone.

When you really have to bundle

Sometimes you won't be there for every checkpoint. Maybe you're handing a long task to a background agent or starting an overnight run. If you have to bundle, order the work so later steps can't undo earlier ones:

  • ▹Do destructive work first. Run refactors, cleanups, renames and deletions at the start, while there's nothing new for them to break.
  • ▹Add constraints last. Fixes, guards, validations and locks go at the end, so no later step gets a chance to tidy them away.
  • ▹Write explicit "do not remove X" lines. Name the thing and say why it's there, for example: "The retry wrapper around sendWebhook() is intentional; keep it."
  • ▹Ask for one commit per subtask, with messages that say which part each one is. Then you can revert a bad refactor and keep a good fix.
  • ▹Leave docs for the very end, and say they must describe the final code.
text
Do these in order, one commit per step:
1. Refactor bookings.ts for readability. No behaviour change.
2. Fix the race condition in reserveSeat() with a per-seat lock,
   plus a concurrency test.
3. Update docs/bookings.md to match the final code.
After step 2, do not modify reserveSeat() again for any reason.

This is damage control, not best practice. You're still reviewing three kinds of change in one sitting. Having one commit per step just means you can review them one at a time.

Common pitfalls

  • ▹Counting files instead of checks. A 40-file rename is one prompt. A 20-line fix-and-tidy is two.
  • ▹Splitting the prompt but skipping the checkpoint. If prompt 2 runs on uncommitted work from prompt 1, the agent still treats the lock as fair game.
  • ▹Treating "tests are green" as the check for everything. Green tests show that behaviour didn't break. They tell you nothing about whether the refactor is any good or whether the docs are accurate.
  • ▹Leaving "done" vague for judgment work. "Make it cleaner" has no stopping point, so the model stops once the result looks plausible. Judgment prompts need a concrete "Done when" too, such as "no function over 30 lines" or "every public function has a docstring".

Where this fits in the series

Earlier days covered giving the AI the right context and asking for a plan before it writes code. Today adds the third piece: where one prompt should stop. Context tells the model what's true, the plan tells it what to do, and the check tells it when it's finished. One check per prompt keeps that last part sharp.

Next time: once each prompt has exactly one check, you can start handing that check to the AI itself. It runs the test, reads the compiler output and keeps going until the "done when" line is actually true. That only works if the check is clear, which is why today comes first.

The one line to remember

Split the prompt wherever you'd split the PR. If a reviewer on your team would ask you to separate the fix from the cleanup, the AI needs the same separation for the same reason.

Flashcards
Check yourself

Extend your knowledge

  • ▹Kent Beck, 'Tidy First?' (O'Reilly, 2023): argues for keeping structural changes (tidyings) and behaviour changes in separate PRs. It's the same idea as today's rule, written for human developers.
  • ▹Martin Fowler, 'Refactoring' (2nd ed.): read the section on the 'two hats' (a metaphor Fowler credits to Kent Beck), adding function versus refactoring. You only wear one hat at a time, and an AI agent needs that discipline written into the prompt.
  • ▹Google's Engineering Practices documentation, 'Small CLs': its advice on splitting changelists maps almost directly onto splitting prompts.
  • ▹Try it this week: take your last three bundled prompts, write down how each part was checked, and count the kinds of check. For any bundle with more than one kind, look at which parts got averaged.
Test yourself on this lesson →

Discussion

Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.

Ask me anything about “One prompt, one check” — trade-offs, decisions, or the story behind it.