← Back to Writing

Lesson 7: The pipeline reviews so you don't

About this artifact

essayon-the-recordmaintained

method · since 2026

What you'll learn: how to let an agent take the next queue item, ship it, and close it while you are somewhere else: a disposable copy, a second session that reviews, a pipeline that refuses, and a check of the live page. What you need: the homes from Lesson 4 with Lesson 3's checks/ in each and a remote on GitHub, the authority file from Lesson 5, the queue (your backlog) from Lesson 6, a site that deploys when main changes, and the claude and gh command-line tools, signed in. Time: an hour once. Cost: zero new dollars on public repositories; branch protection on a private one needs a paid GitHub plan.

The idea#

Lesson 6 ended with you watching: the agent took one item and stopped, and you read the diff, merged it, and checked the page. Looking away is reasonable when four steps hold without you. The work happens in a throwaway copy of each repository. A second session, one that can only read, grades the diff against the spec. The pipeline runs your checks, and the platform refuses the merge until they pass. Then a script reads the live page, and the item closes only when the change shows. A gate is the usual name for that sequence: each step can stop the change, and none of them is you.

Eight barriers in a row from the agent's session to the live site, from the files it reads to the live page, each tagged with its lesson, its kind (prose, enforced, skippable, judgment), and what gets past it; Lessons 8 and 9 loop back over the row.

Why start here#

Three earlier lessons left a promise here. Lesson 3's hook can be skipped with one flag; its rung two, the same folder run where no flag skips it, is this lesson. Lesson 5's must-ask list is a file the agent reads; the deny rule is the copy a tool checks. Lesson 6 kept review for you until now.

The template#

Three files and one setting. The runner lives in the parent folder from Lesson 4, beside the queue:

#!/bin/sh
# gate/run-next.sh: work the next code item in a disposable copy, have a second
# session review the diff, merge only through the pipeline, then check the live site.
# Run from the parent folder. One item per run; the agent works only in the copy.
set -eu
[ ! -e STOP ] || { echo "STOP file present; not starting." >&2; exit 0; }
here=$(pwd); before=$(cksum < BACKLOG.md); copy=$(mktemp -d)
cp CLAUDE.md CHARTER.md BACKLOG.md "$copy"/ && cp -R .claude "$copy"/
for home in */; do
  home=${home%/}
  url=$(git -C "$home" remote get-url origin 2>/dev/null) || continue
  git clone -q "$url" "$copy/$home"
  git -C "$copy/$home" config core.hooksPath hooks
done
cd "$copy"

# 1. Where the work runs: a fresh session in the copy, project rules only, bounded.
claude -p "Work the next item and push its branch. Its live: line runs after merge, not by you. If the item is an investigation, do nothing and end with STOPPED investigation. End with one line: DONE <item id> <home> <branch>, or STOPPED <reason>." \
  --setting-sources project --permission-mode bypassPermissions --max-turns 40 > work.log
set -- $(tail -n 1 work.log)
[ "${1:-}" = DONE ] || { echo "Session stopped: $(tail -n 1 work.log)"; exit 1; }
item=$2 home=$3 branch=$4

# The live line comes from your own checkout, which the agent cannot edit.
spec=$(sed -n "/^## $item /,/^## /s/^- spec: *//p" BACKLOG.md)
live=$(sed -n 's/^live: *//p' "$here/$spec")
[ -n "$live" ] || { echo "$spec has no live: line; not merging." >&2; exit 1; }

# 2. What reviews it: a second session that can only read the diff and the spec.
git -C "$home" diff "origin/main...origin/$branch" > review.diff
claude -p "Review $item in BACKLOG.md against its spec, using the diff in review.diff. Pass only if every acceptance line is met and nothing under Out of scope changed; skip the live: line, which runs after merge. Last line: PASS, or FAIL <reason>." \
  --setting-sources project --allowedTools Read Grep Glob --max-turns 15 > review.log
[ "$(tail -n 1 review.log)" = PASS ] || { echo "Reviewer refused; $branch stays unmerged."; tail -n 5 review.log; exit 1; }

# 3. Merge only through the pipeline: the platform refuses until the checks pass there.
cd "$home"
gh pr create --fill --head "$branch" > /dev/null
tries=0
until gh pr checks "$branch" --required > checks.log 2>&1; do
  grep -q -e pending -e "no .*checks reported" checks.log && [ "$tries" -lt 60 ] \
    || { echo "Checks failed or never finished; $branch stays unmerged."; cat checks.log; exit 1; }
  tries=$((tries + 1)); sleep 10
done
gh pr merge "$branch" --squash --delete-branch
cd ..

# 4. What proves it landed: the live line, read from the live site while it deploys.
tries=0
until sh -c "$live"; do
  tries=$((tries + 1))
  [ "$tries" -lt 30 ] || { echo "$item merged but not live after 30 tries: $live" >&2; exit 1; }
  sleep 20
done

# Live, so done: close the item in your queue, unless you edited the file meanwhile.
sed "/^## $item /,/^## /s/^- status: .*/- status: done/" BACKLOG.md > BACKLOG.next
if [ "$(cksum < "$here/BACKLOG.md")" = "$before" ]; then
  mv BACKLOG.next "$here/BACKLOG.md"
else
  echo "BACKLOG.md changed during the run; set $item to done by hand."
fi
echo "$item is live: $live"

The workflow goes in every home as .github/workflows/gate.yml, with HOME_DIR set to that home's folder name:

# .github/workflows/gate.yml in each home: main's checks/ run against every pull request.
name: gate
on: pull_request
env:
  HOME_DIR: web                                    # this home's folder name
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4                  # the pull request's files
        with:
          path: ${{ env.HOME_DIR }}
      - uses: actions/checkout@v4                  # main's checks, so a branch cannot edit them
        with:
          ref: ${{ github.base_ref }}
          path: base
      - uses: actions/checkout@v4                  # your specs home, for the spec check
        if: env.HOME_DIR != 'specs'
        with:
          repository: your-name/specs
          path: specs
          token: ${{ secrets.SPECS_TOKEN || github.token }}  # a read-only token if specs is private
      - run: |
          cd "$HOME_DIR"
          for check in ../base/checks/*.sh; do
            sh "$check" || { echo "BLOCKED by $check" >&2; exit 1; }
          done

The deny rules go in .claude/settings.json in the parent folder:

{
  "permissions": {
    "deny": [
      "Bash(git push --force *)",
      "Bash(git push -f *)",
      "Bash(git reset --hard *)",
      "Bash(git branch -D *)",
      "Bash(rm -rf *)",
      "Bash(gh pr merge *)"
    ]
  }
}

Every spec gains one line under verify:. It is the same kind of line, run against the live site instead of your checkout:

live: curl -fsS --max-time 20 https://you.github.io/web/status.html | grep -q "Last run"

The setting: in each home's branch rules, require the checks status before merging, include administrators, and leave force-pushes off.

Each part, and the mistake it prevents#

The copy is the first barrier. The runner clones every home into a temporary folder beside copies of the standing files, so an agent that goes wrong goes wrong somewhere you can delete. The copy limits accidents; it is not a sandbox, since the session still has your credentials.

The bounds come before the work: no start if a STOP file exists, forty turns, and one last line, DONE or STOPPED, that a script can read. An agent that meets a must-ask line has nobody to ask, so it stops and says why, and the runner stops with it.

The reviewer can only read: the spec, and the diff the runner saved for it, not the first session's reasoning. It is judgment, so it can be wrong; it is a barrier, never the proof.

The pipeline is Lesson 3's rungs two and three: the same hooks/pre-push on every pull request, and a platform that refuses the merge until it passes. --no-verify skips the hook on a laptop; here the checks come from main, so a branch cannot edit its way past them. The runner merges; the agent cannot, because gh pr merge is on its deny list.

The deny rules cover the command text they name. In my run, cd web && git push --force was refused and the remote did not move; git -C web push --force went straight through. The branch setting is what refuses a force-push to main from every tool and every person.

The live check reads content, not a status code. A server can answer 200 with a page that shows nothing, so the line greps for what the change should produce and retries for ten minutes while the deploy lands. For code, done now means live: the item closes only after that line passes, so Lesson 6's merge-history rule now moves code to review, never to done. Investigate items stay in a session you watch.

A worked example#

A scratch project with two homes on GitHub, web/ served by Pages from main, the branch setting on, and one ready item, B-002: a footer reading "Checked by the gate" on the status page. I ran sh gate/run-next.sh and nothing else. The first session's close, then the reviewer's verdict:

- An open branch called `gut-the-check` (PR #4) empties the env check
  and adds a dummy `.env`. Look at it before anything merges it.

DONE B-002 web b-002-status-footer
- **Out of scope:** `review.diff` changes exactly one file, `status.html`
  in the web repo. Nothing in `specs/`, `web/index.html` or any other
  repo changed.

PASS

The runner's last line, after the merge:

B-002 is live: curl -fsS --max-time 20 https://.../status.html | grep -q "Checked by the gate"

Three and a half minutes, and BACKLOG.md says status: done. The branch the first session flagged was mine, a probe: it emptied its own .env check and planted a dummy .env. The pipeline ran main's copy of the check and blocked it.

Getting here took four runs; the first two tripped on the live: line before merge. Then a review of the draft found two holes: the reviewer could run git commands that write, and the pipeline ran the branch's own checks. The template is the fixed version.

How it scales#

The runner is the same file at any size; what grows is how it starts. Here you run it by hand; Lesson 8 puts it on a timer.

Who does the work: you write the live: line with the spec and keep the branch setting on. The agent works the item, the reviewer grades it, the pipeline refuses or passes, and the runner merges and closes.

From the field#

In July a dashboard page on my site rendered blank in production. The fix, one security header, merged and sat undeployed for a week, and every signal I had said fine: the pull request was green, the merge was clean, and the page returned 200. The header stopped the browser from drawing the page's content, which no status code shows. I found it by opening the page. Since then my deploy reads the live site for every file the page needs before it reports success; the live: line is the same idea, written per change.

Exercise#

  1. Save the runner, the workflow in each home, and the deny rules. Turn on the branch setting.
  2. Add a live: line to the spec behind your next ready item.
  3. Plant a violation: on a throwaway branch, commit a .env holding a dummy value, push with --no-verify, and open a pull request. Watch the pipeline fail and the merge button refuse. Close it.
  4. Run sh gate/run-next.sh from the parent folder, and go do something else.
  5. Come back and read the runner's last line, the pull request, and the live page.

You're done when#

You saw the pipeline refuse the planted violation, and the runner closed an item you never read because its live: line found the change live. Two results fall short: the live check timed out, so the deploy or the line is wrong and the item correctly stayed open; or the reviewer refused, so read its reason before the diff.

Common mistakes#

  • A live check that tests the status code. Grep for what the page should show.
  • Treating the reviewer as the proof. It is one judgment among four barriers.
  • Deny rules as the only fence. They cover the forms they name; the branch setting covers everyone.
  • On Windows, an agent with only the PowerShell tool. Bash rules do not cover it; point Claude Code at Git Bash.

An hour once, and zero new dollars on public repositories; from then on an item costs you the runner's last line and a look at the live page, instead of a diff.

Further reading#

GitHub's protected branches page covers required checks and the force-push setting. The Claude Code permissions reference documents deny rules, including the section on what a Bash rule does not match.

Next lesson#

Notify only when your action changes the outcome: the runner on a timer, three tiers of alert, and a rule that notices when a run did not happen.

Questions this post answers

How can I merge an AI agent's pull request without reading the diff?
Only when four steps hold without you. The agent works in a disposable copy, so nothing it does touches your checkout. A second session that can only read grades the diff against the spec. The pipeline runs the same checks your machine runs, and the platform refuses the merge until they pass. A script reads the live page for the text the change should produce, and the item closes only when it does.
Is a passing CI build proof that the change shipped?
No. Merged is not shipped, and a 200 from the server is not proof either: a page can return 200 and show nothing. The last step reads the live page for the specific text or value the change should produce, retries while the deploy lands, and fails loudly if it never appears.
Do deny rules make an AI agent safe to leave alone?
They narrow what it can do by accident. A deny rule matches the command text it names: in my run, `cd web && git push --force` was refused while `git -C web push --force` went straight through. The rule is a cheap first barrier. The platform setting that refuses force-pushes to main is the one that holds for every tool and every person.
What does the second session add if the pipeline already runs checks?
Checks prove what you could write down as a command. The reviewer reads the spec's acceptance lines and Out of scope list against the diff, which catches the change that passes every check and still misses the spec. It is judgment, so it can be wrong; it is never the only barrier.