Chapter 05 · Section II · 12 min read
5.2 A complete loop: the assemble-it-yourself version
The same morning-review job, built by wiring the six parts together yourself. More work, more control, and the same shape as the batteries-included version.
Now the same job — 6am, review any 24-hour-old open PR with passing CI, escalate anything non-stylistic — but built from parts. A cron entry, a Python script, a model API, some GitHub API calls, a JSON file. Every one of the six parts is something you wired up yourself.
Same six parts, different implementations
| Part | Batteries-included (5.1) | Assemble-your-own (this section) |
|---|---|---|
| Trigger | Tool’s scheduler feature | Linux cron / GitHub Actions cron |
| Sandbox | Auto-created worktree | Script creates its own tempdir per PR |
| Reusable knowledge | Loaded from CLAUDE.md by the tool | Script reads .review.md at start of each cycle |
| Tools | Tool router + plugins | Direct API calls: OpenAI/Anthropic + GitHub SDK |
| Do-and-verify | Subagent framework | Two separate model calls: reviewer prompt, then verifier prompt |
| Memory | Tool’s built-in state, or a JSON file | Explicit JSON file the script reads and writes |
Nothing conceptual changes. What changes is that you now own every wire.
Sketch of the script
Not real, runnable code — a shape:
def cycle():
# ---- MEMORY: load state
state = load_memory(".loop-state.json")
# ---- REUSABLE KNOWLEDGE: load once per cycle
checklist = read_file(".review.md")
conventions = read_file("CLAUDE.md")
# ---- TOOLS + TRIGGER LOGIC: find work
prs = github.list_open_prs()
candidates = [p for p in prs
if p.age_hours > 24
and p.ci_status == "success"
and not p.reviewed
and p.number not in state.processed_today]
for pr in candidates[:MAX_PER_CYCLE]:
# ---- SANDBOX: fresh workspace per PR
with temp_worktree(pr) as ws:
diff = ws.pr_diff()
# ---- DO: reviewer produces comments
reviewer_out = model.call(
system=REVIEWER_PROMPT,
user=[checklist, conventions, diff],
tools=[read_file, run_tests],
)
# ---- VERIFY: separate call, only sees output
verifier_out = model.call(
system=VERIFIER_PROMPT,
user=[checklist, reviewer_out.comments],
)
# ---- ACT + ESCALATE via tools
if verifier_out.status == "approved":
github.post_pr_comments(pr.number, reviewer_out.comments)
github.mark_pr_reviewed(pr.number, reviewer="loop-bot")
state.processed_today.append(pr.number)
else:
github.tag_user(pr.number, ONCALL_HANDLE,
summary=verifier_out.rejection_reason)
state.escalated.append(pr.number)
# ---- MEMORY: save state atomically
save_memory_atomic(".loop-state.json", state)
The whole loop is maybe 100–150 lines of real code, plus the two prompts. What makes it work is not the code — it’s that every one of the six parts is deliberately handled.
The cron entry
For “runs at 6am even if my laptop is off,” the trigger doesn’t live on your laptop. Two common choices:
- A tiny cron VM you own. A $5/month box with one crontab entry:
0 6 * * * /usr/bin/python3 /opt/morning-review/cycle.py. - GitHub Actions cron. A workflow file with a
schedule: [cron: "0 6 * * *"]trigger that runs the same script.
Either way, the script runs, reads memory, does the work, writes memory. If it fails, the trigger fires again tomorrow — the append-only memory means yesterday’s crash didn’t corrupt anything.
The sandbox, in detail
temp_worktree(pr) is worth expanding:
def temp_worktree(pr):
tmpdir = mkdtemp(prefix=f"pr-{pr.number}-")
subprocess.run(["git", "worktree", "add", tmpdir, pr.head_sha])
try:
yield Workspace(tmpdir)
finally:
subprocess.run(["git", "worktree", "remove", "--force", tmpdir])
Each PR gets its own worktree checked out at that PR’s head. Two PRs being reviewed in the same cycle don’t share state. A crash mid-review leaves an orphan worktree that a periodic cleanup pass can garbage-collect. This is what your tool did for you in 5.1; here, you write it once.
The two prompts
The reviewer prompt is task-shaped:
You are a code reviewer for the team's repository.
Given the diff below, apply the review checklist in `.review.md` and produce a list of comments.
Rules:
- Each comment cites the specific file and line.
- Do not comment on style if the linter already covers it.
- If you find an architectural, security, or unclear-intent issue, mark it "escalate: true" and stop — do not attempt to fix.
Return JSON: { comments: [...], overall: "review" | "escalate" }
The verifier prompt is grading-shaped:
You are the verifier. Given the reviewer's proposed comments and the checklist, check:
1. Every comment cites a real file + line from the diff.
2. No comment is stylistic (linter covers style).
3. No comment invents a file or a line number.
4. If the reviewer flagged escalate, is the escalation reason genuinely non-stylistic?
Return JSON: { status: "approved" | "rejected", rejection_reason?: string }
Two separate calls. The verifier does not see the reviewer’s reasoning — only the output. That’s the whole point of 3.4.
Where this version shines
- Exact fit. Nothing works around the tool; the tool is your script.
- Cost transparency. You see every model call, every token, every dollar.
- Debugging. Everything is your code. When something breaks at 6am,
grepandprintare enough. - Portability. No tool lock-in. If the model provider changes, you swap one function.
Where this version hurts
- Boilerplate. Retries, logging, error handling, rate limits — all yours to write.
- No ecosystem. No pre-built GitHub plugin; you write the SDK calls. No pre-built subagent framework; you invoke two model calls yourself.
- Slower first run. Days, not hours. Especially if you’re building the first one and haven’t done it before.
Which one is “right”
For the first useful loop you build, batteries-included wins on speed almost every time. Reach for assemble-your-own when the batteries-included version can’t do what you need, or when you already have production loops and the incremental cost of a new one is low because you have the infrastructure.
Most mature teams end up with a portfolio: some jobs on a batteries-included tool (because they fit the tool’s shape), some assembled from scratch (because they don’t). Neither replaces the other.
Check your understanding
Quick check
—