# Two-Agent Build and Test Loop, free sample: the builder and tester contract plus a red-then-green check

Version 1.0, 2026-10-28. By PaperChaseWebb. Works with Grok Bot, Claude Code, Codex and Hermes. Free. One Python 3 script, standard library only, tested on the worked example below.

This is the part of Two-Agent Build and Test Loop for paid apps (pp-two-agent-build-test-loop, $29) you can use today: the six rules that keep a coding agent from grading its own work, a story card, and proof_check.py, which reads a run's ledger and says whether the roles stayed apart and the test really went red before it went green. The full pack adds the runner that records every exit code itself, the revert proof, sandbox-only payment rules, a fifteen-check read-only audit, a seeded fixture, the four owner approval steps, twelve prompts and setup for all four harnesses.

## The six rules

1. **Two sessions, two jobs.** One agent builds. A different session tests and gives the verdict. Same model in two tabs is the minimum; a different harness or model for the tester is better.
2. **The tester never edits code.** It writes stories and tests and uses the app like a buyer would. If it can change source, it will "just fix" things and stop being a tester.
3. **The builder never touches tests and never says passed.** It changes source until the test passes and reports the files. If it thinks the test is wrong, it stops and says so.
4. **Red first.** The test runs and fails before the build. A test that never failed may test nothing.
5. **A script runs the tests, not an agent.** Record the exit code from the command itself. Never take it from an agent's message; "all tests pass" is a claim.
6. **On paid apps, test keys and sandboxes only, and the owner says yes.** Plan, anything touching payments or prices, each merge, and each release. Silence is not a yes.

## A story card

```json
{"id": "S2", "behavior": "a declined card keeps the free tier and shows the retry message",
 "test_file": "tests/test_decline.py",
 "command": "python3 -m unittest tests.test_decline",
 "payment": true}
```

The tester writes the card and the test. The builder sees the card and the failing test, nothing else from the plan.

## proof_check.py

Save as proof_check.py. It reads loop.json and ledger.csv in a run folder and writes nothing. Exit 0 means no findings, 1 means findings, 2 means a file is missing.

The ledger has one row per turn: at, story, role (tester, builder or runner), session, action, files, exit, sha, result. Testers log test and verdict rows, builders log build and claim rows, and runner rows (red and green) come from whatever runs the test command.

| Code | Finding |
|---|---|
| P1 | builder and tester share a session, or a row was logged by the wrong session |
| P2 | a verdict written by anyone but the tester |
| P3 | a passed story with no failing red run before the first build |
| P4 | a passed story with no passing green run after the last build |
| P5 | the builder changed a test file |

```python
#!/usr/bin/env python3
"""proof_check.py: read-only check that a builder and tester run kept its roles apart and proved red, then green.

Usage: python3 proof_check.py RUN_DIR      (reads loop.json and ledger.csv)
Exit 0 = no findings, 1 = findings, 2 = file missing. Writes nothing. Standard library only.
PaperChaseWebb, version 1.0, 2026-10-28.
"""
import csv, json, os, sys

def main():
    if len(sys.argv) != 2: print("usage: proof_check.py RUN_DIR"); sys.exit(2)
    run = sys.argv[1]
    for n in ("loop.json", "ledger.csv"):
        if not os.path.isfile(os.path.join(run, n)): print("ERROR: missing", n); sys.exit(2)
    loop = json.load(open(os.path.join(run, "loop.json")))
    ledger = list(csv.DictReader(open(os.path.join(run, "ledger.csv"), newline="")))
    roles, tests = loop.get("roles", {}), loop.get("test_paths", ["tests/"])
    out = []
    # P1 two sessions, and each row from its own role's session
    if roles.get("builder", {}).get("session") == roles.get("tester", {}).get("session"):
        out.append("P1 builder and tester share one session")
    for r in ledger:
        want = roles.get(r["role"], {}).get("session")
        if r["role"] in ("builder", "tester") and r["session"] != want:
            out.append(f"P1 {r['at']} {r['story']} {r['action']} logged as {r['role']} by session {r['session']!r}")
    # P2 only the tester gives verdicts
    for r in ledger:
        if r["action"] == "verdict" and r["role"] != "tester":
            out.append(f"P2 {r['at']} {r['story']} verdict written by {r['role']}")
    # P3 and P4: red before the first build, green after the last build, both from the runner
    for sid in sorted({r["story"] for r in ledger}):
        rs = [r for r in ledger if r["story"] == sid]
        v = [r for r in rs if r["action"] == "verdict" and r["role"] == "tester"]
        if not v or v[-1]["result"] != "pass": continue
        builds = [r["at"] for r in rs if r["role"] == "builder" and r["action"] == "build"]
        fb, lb = min(builds, default=""), max(builds, default="")
        if not any(r["role"] == "runner" and r["action"] == "red" and r["exit"] not in ("0", "") and (not fb or r["at"] <= fb) for r in rs):
            out.append(f"P3 {sid} passed with no failing red run before the first build")
        if not any(r["role"] == "runner" and r["action"] == "green" and r["exit"] == "0" and r["at"] >= lb for r in rs):
            out.append(f"P4 {sid} passed with no passing green run after the last build")
    # P5 the builder never changes a test file
    for r in ledger:
        if r["role"] == "builder" and r["action"] == "build":
            tf = [x for x in r["files"].split(";") if x and any(x.startswith(p) for p in tests)]
            if tf: out.append(f"P5 {r['at']} builder changed test files {tf} on {r['story']}")
    print(f"proof_check {run}: {len(out)} finding(s)")
    for line in out: print(" ", line)
    sys.exit(1 if out else 0)

if __name__ == "__main__":
    main()
```

## Worked example: 5 findings, then 0

Make a folder ex/ with these two files.

loop.json:

```json
{"owner": "owner", "test_paths": ["tests/"],
 "roles": {"builder": {"session": "build-a"}, "tester": {"session": "build-a"}}}
```

ledger.csv:

```
at,story,role,session,action,files,exit,sha,result
2026-10-28T09:00,S1,tester,build-a,test,tests/test_trial.py,,,
2026-10-28T09:02,S1,runner,runner,red,tests/test_trial.py,0,a1f0c3d29b10,
2026-10-28T09:10,S1,builder,build-a,build,src/billing.py;tests/test_trial.py,,,
2026-10-28T09:11,S1,builder,build-a,verdict,,,,pass
2026-10-28T09:12,S1,tester,build-a,verdict,,,,pass
```

Run `python3 proof_check.py ex`:

```
proof_check ex: 5 finding(s)
  P1 builder and tester share one session
  P2 2026-10-28T09:11 S1 verdict written by builder
  P3 S1 passed with no failing red run before the first build
  P4 S1 passed with no passing green run after the last build
  P5 2026-10-28T09:10 builder changed test files ['tests/test_trial.py'] on S1
```

What the run should have done:

- give the tester its own session, test-b (P1)
- leave verdicts to the tester; the builder logs a claim instead (P2)
- fix the test until the red run fails on the old code (P3)
- run the test again after the build and record exit 0 (P4)
- keep the builder on src/billing.py; the test file is the tester's (P5)

The corrected run, as the two files:

loop.json:

```json
{"owner": "owner", "test_paths": ["tests/"],
 "roles": {"builder": {"session": "build-a"}, "tester": {"session": "test-b"}}}
```

ledger.csv:

```
at,story,role,session,action,files,exit,sha,result
2026-10-28T09:00,S1,tester,test-b,test,tests/test_trial.py,,,
2026-10-28T09:02,S1,runner,runner,red,tests/test_trial.py,1,a1f0c3d29b10,
2026-10-28T09:10,S1,builder,build-a,build,src/billing.py,,,
2026-10-28T09:11,S1,builder,build-a,claim,,,,done
2026-10-28T09:12,S1,runner,runner,green,tests/test_trial.py,0,a1f0c3d29b10,
2026-10-28T09:14,S1,tester,test-b,verdict,,,,pass
```

Run it again:

```
proof_check ex: 0 finding(s)
```

We ran this on 2026-10-28 with Python 3 and got 5 findings (exit 1), then 0 (exit 0).

In a real run, do the steps again instead of editing old rows. The ledger is a record; rewriting it to pass the check is the same move as deleting a failing test.

## What the full pack adds

- run_test.py, which runs each story's test itself and records red, green and a revert proof with a hash of the test file
- loop_check.py, fifteen read-only checks on a finished run, including a test that still passes with the change taken out, live payment keys in the project, payment events outside test mode, payment work built with no yes, merges and store submissions without a yes, and a report that does not match the ledger
- card_check.py, a preflight on roles, cards and keys before the builder starts
- a seeded fixture with fifteen planted faults and a tiny app whose tests really run: 15 findings, then 0 on the fixed run
- eleven paid-app stories worth writing first, with where to test each in Stripe test mode, StoreKit, Google Play and RevenueCat sandboxes
- the four owner approval steps, rules blocks, twelve prompts, setup for Grok Bot, Claude Code, Codex and Hermes, an install plan and a failure table

If you want this installed and run for you, the Install sprint and Manage plans are on the home page at paperchasewebb.ai, where you can book a call.

Source credit: the loop of one agent testing a live paid app and writing specs while another builds, with the owner's merge as the gate, is from Sean Miller (@milhaushd, https://x.com/milhaushd/status/2113607149079404859). The red-then-green habit for agent workers is from u/QueasyCat3096 on r/ClaudeCode (https://www.reddit.com/r/ClaudeCode/comments/1x9oxfg/my_claude_code_workers_got_170_prs_merged_in_five/).

PaperChaseWebb 💸
