ailiteracynepal 🇳🇵
Text size

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

PartBatteries-included (5.1)Assemble-your-own (this section)
TriggerTool’s scheduler featureLinux cron / GitHub Actions cron
SandboxAuto-created worktreeScript creates its own tempdir per PR
Reusable knowledgeLoaded from CLAUDE.md by the toolScript reads .review.md at start of each cycle
ToolsTool router + pluginsDirect API calls: OpenAI/Anthropic + GitHub SDK
Do-and-verifySubagent frameworkTwo separate model calls: reviewer prompt, then verifier prompt
MemoryTool’s built-in state, or a JSON fileExplicit 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, grep and print are 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

Comparing 5.1 (batteries-included) and 5.2 (assemble-your-own) for the same morning-review job, which statement is MOST accurate?