Lesson 3 · Agentic Loop Engineering

The Verification Subagent, End to End

Form 4 made fully concrete: the actual files on disk, then the complete session dialogue — worker implements, grader fails it, worker fixes, grader passes it, loop closes.

⏱ ~10 min🎯 See the whole Gate-4 picture🔗 Builds on Lessons 1–2

1 · The cast

Two roles, deliberately separated:

The worker — your main Claude Code conversation. It reads the task, writes the code, runs the tests. It is also, left alone, the one who decides "looks done" — which is exactly the conflict of interest we're removing.

The grader — a subagent defined in .claude/agents/diff-grader.md. Per the docs, it runs in "its own context window with a custom system prompt" and "does not see your conversation history… or the files Claude has already read." (Sub-agents) It can't be swayed by the worker's reasoning because it never sees it. The only bridge between them is a short delegation message the worker composes when handing off.

2 · The files

A small real project. Three files matter for the verification loop (highlighted); the rest is ordinary code.

my-project/
├── CLAUDE.md ← the rule that triggers grading every time
├── TASK.md ← acceptance criteria (what "done" means)
├── .claude/agents/diff-grader.md ← the rubric, as a subagent
├── src/duration.js
├── test/duration.test.js
└── package.json
TASK.md
# Task: parseDuration
Add parseDuration(str) to src/duration.js. Parses "1h30m" → milliseconds.

## Acceptance criteria
1. Supports units h, m, s — alone and in combination ("2h", "1h30m", "1h30m15s").
2. Returns milliseconds as an integer.
3. Invalid input throws an Error that names the offending token AND lists
   the valid units (actionable, not "invalid input").
4. New behavior is covered by tests; all tests pass.
.claude/agents/diff-grader.md
---
name: diff-grader
description: Grades the current diff against TASK.md acceptance criteria.
  Use proactively after implementing a task, before declaring work done.
tools: Read, Grep, Glob, Bash
---
You are an adversarial grader. You did NOT write this code and you do not
trust the implementer's claims. Your job is to try to FAIL the diff.

Procedure:
1. Run `git diff` to see the change. Read TASK.md for the criteria.
2. Gather your own evidence — run the code and tests yourself. Never take
   the diff's word for behavior you can execute.
3. For EACH acceptance criterion: write a 1–2 sentence critique, then
   grade PASS / FAIL / UNKNOWN (UNKNOWN = not enough info to decide).

Report ONLY correctness and requirement gaps — no style or refactor notes.
End with exactly one line:
VERDICT: PASS        (only if every criterion is PASS)
VERDICT: FAIL — <failing criteria numbers and one-line reasons>
CLAUDE.md (the relevant rule)
## Definition of done
After implementing any task, delegate to the diff-grader subagent.
Do NOT declare work complete until it returns VERDICT: PASS.
Quote its verdict in your summary.
How the three connect: CLAUDE.md makes grading non-optional (the gate). The description field tells Claude when to delegate — "Claude uses each subagent's description to decide when to delegate." The markdown body is the rubric — everything from Lesson 2: binary, decomposed, critique-then-verdict, UNKNOWN escape hatch, bounded to correctness only.

3 · The dialogue — round 1

What follows is an annotated session. Numbered notes ⬇ point at the load-bearing moments.

You
Implement the task in TASK.md.
Claude — main agent (the worker)
⏺ Read(TASK.md)
⏺ Write(src/duration.js) — regex /(\d+)([hms])/g, sum the matches
⏺ Write(test/duration.test.js) — 5 tests: "2h", "1h30m", "45s", "1h30m15s", "abc" throws
⏺ Bash(npm test) → 5 passing
Tests pass. Per CLAUDE.md, handing the diff to diff-grader before declaring done.
⏺ Task(diff-grader): "Grade the current uncommitted diff against the acceptance criteria in TASK.md. The change adds parseDuration to src/duration.js with tests."
1
The delegation message (the Task(...) line) is the only thing the grader receives from this conversation — a one-sentence task summary. Not the chat history, not the worker's reasoning, not its claims about test coverage. (Docs: "Claude composes a delegation message that summarizes the task.")

🔮 Predict before you peek

The worker's 5 tests all pass. The grader is about to grade the four criteria. What does it find? (Hint: look at that regex — /(\d+)([hms])/g — and ask what happens to input like "1h30x".)

diff-grader
FRESH CONTEXT WINDOW
⏺ Bash(git diff) — reads the change
⏺ Read(TASK.md) — reads the criteria
⏺ Bash(node -e "…parseDuration('1h30m15s')") → 5415000 ✓
⏺ Bash(node -e "…parseDuration('1h30x')") → 3600000 — returned a number, did not throw
Criterion 1 — units & combinations: "2h", "1h30m", "1h30m15s" all parse correctly. PASS

Criterion 2 — integer milliseconds: Verified integer output for all valid cases. PASS

Criterion 3 — actionable errors: I executed parseDuration("1h30x"): it returned 3600000 — the global regex skipped the unmatched token "30x" and silently half-parsed the input. And when it does throw (no matches at all), the message is "Invalid duration", which names neither the offending token nor the valid units. FAIL

Criterion 4 — coverage: No test exercises partially-invalid input; the bug above ships green. FAIL
VERDICT: FAIL — #3 (silent half-parse of "1h30x"; error message not actionable), #4 (no test for partially-invalid input)
2
The grader gathered its own evidence. It didn't trust the diff or the green tests — it ran parseDuration("1h30x") itself. This is "show evidence, don't assert success" applied to the judge. The worker's blind spot (it wrote the tests around its own regex) is exactly what a fresh context catches.
3
The verdict line is machine-parseable. VERDICT: FAIL — … is one line, fixed format. That's what lets the loop act on it mechanically instead of interpreting an essay.

4 · Round 2 — fix, re-grade, close the loop

Claude — main agent (the worker)
The grader found a real gap: trailing invalid tokens are silently dropped. Rewriting the parser to consume the entire string and fail loudly.
⏺ Edit(src/duration.js) — tokenize whole input; on bad token: throw Error(`Unknown unit "x" in token "30x" at position 2 — valid units: h, m, s`)
⏺ Edit(test/duration.test.js) — +3 tests: "1h30x" throws · message names token · message lists units
⏺ Bash(npm test) → 8 passing
⏺ Task(diff-grader): "Re-grade the current diff against TASK.md. Previous verdict failed #3/#4; both addressed."
diff-grader
NEW INSTANCE — FRESH AGAIN
⏺ Bash(git diff) · Read(TASK.md) · Bash(node -e "…'1h30x'") → throws: Unknown unit "x" in token "30x"… valid units: h, m, s
1: Valid forms parse. PASS   2: Integers. PASS
3: "1h30x" now throws; message names the token and lists valid units. PASS
4: Three new tests cover the failure mode; suite green. PASS
VERDICT: PASS
Claude — main agent
Done. 8/8 tests passing and diff-grader returned VERDICT: PASS (criterion-by-criterion above). parseDuration handles h/m/s combinations and rejects partially-invalid input with an actionable error.
4
Each invocation is a brand-new instance. "Each subagent invocation creates a new instance with fresh context" (docs) — so the re-grade isn't anchored to its earlier verdict. It re-derives everything, which is what makes the second PASS meaningful.

5 · What the grader saw — and pointedly didn't

✓ In its context

  • Its own system prompt — the rubric (the markdown body of diff-grader.md)
  • The one-line delegation message from the worker
  • CLAUDE.md / memory hierarchy (loaded for custom subagents)
  • Whatever it gathers itself: git diff, TASK.md, its own test runs

✗ Not in its context

  • The conversation history — the worker's plan, claims, excuses
  • Files the worker read (it must re-read what it needs)
  • The worker's reasoning about why the code is correct
  • Its own previous grading run (new instance each time)

That right-hand column is the point. Independence isn't a vibe — it's enforced by the context architecture. (Sub-agents docs: "fresh, isolated context window… does not see your conversation history.")

6 · Hardening the gate

In this example the gate is a CLAUDE.md rule — strong, but advisory. Escalation path when the stakes rise:

CLAUDE.md rule (what you saw) → a /grade slash command so you can demand a grade at any moment → a Stop hook that runs a script checking for VERDICT: PASS and blocks the turn from ending without it — deterministic, "for actions that must happen every time with zero exceptions." (Hooks) That Stop-hook version is a great Lesson 4.

💬 Want this running for real? Say the word and I'll scaffold this exact demo — TASK.md, diff-grader.md, the buggy-regex starting point — as a tiny repo on your machine so you can run the loop yourself and watch the grader fail it live. Or I'll adapt diff-grader.md to one of your actual projects.

Where to go next