Last lesson ended on a warning: a loop you can stop is still a loop you haven't checked. Module 3 is about checking it. Before that, watch it run once, for real.
Every terminal you've seen in this course so far was drawn by hand. This one
isn't. It is one real session in a small fixture repo, loop-engineering-demo,
recorded and scanned for secrets. Where the run disagreed with an earlier
lesson, the lesson was corrected.
The run has three acts:
- A goal-based run the hook blocks.
- A time-based loop that does the real work.
- A teardown you can check.
Act 1: a goal the hook starves
Everything the course built is already committed in the repo: the hook settings, the skill, and four small scripts. Two tests fail on every run. That's the bug.
For this take the cap is lowered, so you can watch it fire. Two shell calls, not forty:
$env:LOOP_CAP = "2"
claude
Then Lesson 1.2's goal, typed word for word:
/goal Fix the failing tests in tests/test_pricing.py. Done when pytest tests/test_pricing.py exits 0, having collected >0 tests, and the full suite has no new failures. Stop after 12 iterations. Stop at $5. Stop and ask on no progress.
The guard fires
About ninety seconds in, the third shell call is refused:
call 3 exceeds cap 2
That's the guard, the PreToolUse hook from Lesson 1.2, and there's no arguing
with it.
It reports something else too: no progress. Same failure, same diff. That is 1.2's rule, kept in 1.3's state file, and written by the test run, not by the model. So the agent stops and asks how to go on.
Prose against the hook
When asked, the model agrees to end the goal. Then this, over and over:
Goal not yet met… continuing
The model agreed to stop. The goal's own check didn't. It runs straight past its own twelve iterations, because that limit was only words in the goal. Nothing enforces them.
What actually ends it is:
/goal clear
The hook stopped the work. A person stopped the goal.
What the state file kept
Outside the session, .loop/state.json tells the story. The run half: four
shell calls, two that ran and two that were refused. The work half: unchanged,
one. That half was written by the test run, and it outlives the session.
Act 2: isolate, then triage
Now Lesson 2.2. Each run gets its own worktree:
git worktree add ../loopdemo-pricing -b fix/apply-discount
Copy-Item .claude\settings.local.json ..\loopdemo-pricing\.claude\
The scripts come along because they're committed. One file doesn't: the local setting that enables the GitHub connector. It's git-ignored, so it has to be copied across by hand.
Then triage, run by a person, before any loop starts:
cd ../loopdemo-pricing
python .loop/triage.py
Five runs each. The retry test failed three of five, so it's flaky. The pricing tests failed five of five, so they're real:
FLAKY 3/5 tests/test_retry.py::test_retry_eventually_succeeds
The flaky test goes on the quarantine list.
A new session starts in the new folder. There is no state file there yet. The cap counter starts at zero, and from now on this worktree keeps its own no-progress record.
Act 2: the loop does the work
Now Lesson 2.1's command. It runs once right away, then daily:
/loop 1d "Check the repo for newly failing tests. Rerun each 3x to rule out flakiness. If a real failure remains, fix it and keep going until the suite passes, then push the branch (not master)."
First it asks where the work lives: this session, or the cloud. The answer here is this session only.
A minute later, its report. One cause, one line changed. The quarantined test it left alone, as the skill from Lesson 2.3 says to. The suite went green three times. Nothing went to master.
Opening the pull request
Asked to open a pull request through the GitHub connector, it opens PR #6. And it says what it didn't do: no reviewers, and no merge.
The one you can't take back
Now the irreversible step. Asked to merge, the agent checks CI first. There's one red run, caused by the quarantined test, which CI can't see as quarantined. It talks itself past that, and calls the merge tool anyway. Hold on to that for Module 3.
The gate from Lesson 2.4 refuses it:
I didn't merge PR #6. The project's .loop/gate.py hook blocks every merge from
me: "a person approves this one."
And the refusal is written down, in 2.4's approval queue,
.loop/approvals.jsonl, as a line naming mcp__github__merge_pull_request. The
loop never gets a yes. It gets a no, and a record.
Act 3: a teardown you can check
Then Lesson 2.5. List what's scheduled: one daily job, this session only. Delete it by its id. List again: no scheduled jobs.
An empty list isn't evidence, so the last step is the switch that needs no cooperation from the agent:
$env:CLAUDE_CODE_DISABLE_CRON = "1"
With that set in this shell, a new session has no /loop at all. Master
hasn't moved.
Every piece, in one run
| What you saw | Where it was built |
|---|---|
| A goal the hook can starve | 1.2, 1.3 |
| Triage before any fix | 2.1 |
| A worktree per run | 2.2 |
| Conventions it follows unasked | 2.3 |
| A gate before anything irreversible | 2.4 |
| A loop you turned off | 2.5 |
What you haven't seen is the loop checking its own work. It said green three times, and we took its word for it.
After this lesson
You will be able to:
- recognise each piece from Modules 1 and 2 in a real session, and say what it did there;
- tell a hook-enforced stop from a prose one by what actually happened when the model agreed to stop;
- explain why the worktree, the triage step and the local connector setting have to be set up by a person before the loop starts;
- name the two gaps the gate leaves: it depends on an allow rule, and it does
not cover
gh pr mergein the shell.
What comes next
Stopping a loop is not the same as checking it. In Lesson 3.1 you spawn an independent checker as a sub-agent, and hand it a verification checklist for this repo.
Companion article
Every hook, script, and command in this course, written up and executed: How to build an agent loop in Claude Code