DEV Community

Dakota Wu
Dakota Wu

Posted on

An Iteration Cap That Keeps Weekend AI Loops Off the Paid Stack

A coding agent without a stop condition does not ship an MVP. It accumulates files, SDKs, and cloud assumptions until the weekend is gone. Solo founders who need a same-day slice should cap iterations, cap files, and write a receipt before any merge. The loop can stay useful. It cannot stay unbounded.

This article treats the popular “agent loop” as a local if-statement with a clock. That is enough for an indie weekend. It is not a platform. The artifact is a three-number contract plus a small Python checker that refuses a patch when the loop overruns those numbers.

The failure mode that burns a free weekend

Unconstrained coding loops fail in a predictable order. First they touch too many files. Then they add a client library for a billed API. Then they invent environment variables the founder never agreed to fund. The product idea is still a waitlist or a single form. The repo no longer matches that idea.

Indie timelines do not survive that drift. A founder shipping today needs a halt condition that is cheaper than taste and faster than a full review board. Eight iterations. Three files. An allowlist of imports. Those three numbers are enough to keep a free box honest.

A three-number contract

The contract is a file, not a meeting. Put it at the repo root so every coding pass can read it. Keep the numbers small enough that a human can still open the diff on a phone.

# loop_budget.toml — example contract for a same-day indie slice
max_iterations = 8
max_files_changed = 3
max_new_lines = 250

[allow.imports]
python = ["sqlite3", "json", "os", "pathlib", "http.server", "html"]

[deny.imports]
python = ["boto3", "stripe", "openai", "anthropic", "redis", "psycopg2", "firebase_admin"]

[allow.env]
names = ["APP_PORT", "APP_DB_PATH", "LOOP_RECEIPT_PATH"]

[deny.env_prefixes]
prefixes = ["AWS_", "STRIPE_", "OPENAI_", "ANTHROPIC_", "GOOGLE_APPLICATION_"]
Enter fullscreen mode Exit fullscreen mode

The allowlist is the product. Everything else is a patch the loop must not land. A denied import is a future invoice. A denied env prefix is the same invoice in a different font.

Artifact: a fail-closed receipt checker

The checker below is a local example. It is not a published package. Run it on the working tree after each agent iteration. Exit status 2 means the loop must stop and the founder must write the next slice by hand or start a new contract.

#!/usr/bin/env python3
"""loop_receipt.py — fail-closed budget for weekend AI coding loops."""
from __future__ import annotations

import ast
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path.cwd()
BUDGET = {
    "max_iterations": 8,
    "max_files_changed": 3,
    "max_new_lines": 250,
    "allow_imports": {"sqlite3", "json", "os", "pathlib", "http.server", "html"},
    "deny_imports": {"boto3", "stripe", "openai", "anthropic", "redis", "psycopg2", "firebase_admin"},
    "deny_env_prefixes": ("AWS_", "STRIPE_", "OPENAI_", "ANTHROPIC_", "GOOGLE_APPLICATION_"),
}
STATE_PATH = ROOT / ".loop_state.json"
RECEIPT_PATH = ROOT / "LOOP_RECEIPT.md"


def git(*args: str) -> str:
    out = subprocess.check_output(["git", *args], cwd=ROOT, text=True)
    return out.strip()


def changed_files() -> list[str]:
    raw = git("diff", "--name-only", "HEAD")
    return [line for line in raw.splitlines() if line.endswith(".py")]


def added_lines() -> int:
    numstat = git("diff", "--numstat", "HEAD")
    total = 0
    for line in numstat.splitlines():
        added, _removed, path = line.split("\t", 2)
        if path.endswith(".py") and added.isdigit():
            total += int(added)
    return total


def load_state() -> dict:
    if not STATE_PATH.exists():
        return {"iteration": 0, "halts": []}
    return json.loads(STATE_PATH.read_text())


def scan_imports(paths: list[str]) -> list[str]:
    hits: list[str] = []
    for rel in paths:
        tree = ast.parse(Path(rel).read_text(encoding="utf-8"), filename=rel)
        for node in ast.walk(tree):
            names: list[str] = []
            if isinstance(node, ast.Import):
                names = [alias.name.split(".")[0] for alias in node.names]
            elif isinstance(node, ast.ImportFrom) and node.module:
                names = [node.module.split(".")[0]]
            for name in names:
                if name in BUDGET["deny_imports"]:
                    hits.append(f"{rel}: denied import {name}")
    return hits


def scan_env_literals(paths: list[str]) -> list[str]:
    hits: list[str] = []
    for rel in paths:
        tree = ast.parse(Path(rel).read_text(encoding="utf-8"), filename=rel)
        for node in ast.walk(tree):
            if isinstance(node, ast.Constant) and isinstance(node.value, str):
                for prefix in BUDGET["deny_env_prefixes"]:
                    if node.value.startswith(prefix):
                        hits.append(f"{rel}: denied env prefix {prefix}")
    return hits


def write_receipt(state: dict, files: list[str], added: int, violations: list[str]) -> None:
    status = "HALT" if violations else "OK"
    body = [
        f"# Loop receipt ({status})",
        f"- time: {datetime.now(timezone.utc).isoformat()}",
        f"- iteration: {state['iteration']}",
        f"- files_changed: {len(files)}",
        f"- lines_added: {added}",
        "- files:",
    ]
    body.extend(f"  - {path}" for path in files or ["(none)"])
    body.append("- violations:")
    body.extend(f"  - {item}" for item in violations or ["(none)"])
    RECEIPT_PATH.write_text("\n".join(body) + "\n", encoding="utf-8")


def main() -> int:
    state = load_state()
    state["iteration"] += 1
    files = changed_files()
    added = added_lines()
    violations: list[str] = []

    if state["iteration"] > BUDGET["max_iterations"]:
        violations.append("iteration cap exceeded")
    if len(files) > BUDGET["max_files_changed"]:
        violations.append("file cap exceeded")
    if added > BUDGET["max_new_lines"]:
        violations.append("line cap exceeded")
    violations.extend(scan_imports(files))
    violations.extend(scan_env_literals(files))

    write_receipt(state, files, added, violations)
    STATE_PATH.write_text(json.dumps(state, indent=2), encoding="utf-8")
    if violations:
        print("LOOP HALT")
        print("\n".join(violations))
        print(f"see {RECEIPT_PATH}")
        return 2
    print(f"LOOP OK iteration={state['iteration']} files={len(files)} added={added}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The script is deliberately boring. Boring is the point. An agent loop that needs a vector store to know when to stop is already the wrong loop for a zero-bill slice.

Workflow: eight steps, one halt

  1. Freeze the product slice in one sentence in SLICE.md. Example: “email capture, SQLite file, single HTML page, no accounts.” That sentence is the only feature the loop may implement.
  2. Commit a clean tree. The checker diffs against HEAD, so a dirty baseline makes the file cap lie.
  3. Copy loop_budget.toml values into the script or load them later. Keep max_files_changed at three until the slice is live.
  4. Start the coding pass. One iteration equals one patch proposal, not one token stream. Apply the patch to a branch, not to main.
  5. Run python3 loop_receipt.py. Read the exit code. Do not negotiate with a 2.
  6. Open LOOP_RECEIPT.md. If the receipt lists a denied import, revert the patch. Do not “just this once.”
  7. Repeat until the slice runs locally with python3 -m http.server or a single-file app module. Stop at the first green path, not at polish.
  8. Delete .loop_state.json only after a tagged commit. The next weekend starts at iteration zero with the same caps.

Commands for that sequence stay small.

git status --short
python3 loop_receipt.py; echo $?
cat LOOP_RECEIPT.md
git restore -s HEAD -- .   # use only when the receipt says HALT
Enter fullscreen mode Exit fullscreen mode

A founder who skips step 5 will rediscover billed SDKs the hard way. The receipt is the review. There is no second reviewer on a Sunday night.

Makefile pin so the loop cannot “upgrade” itself

Agents like to add tooling. Pin the only legal commands. Anything else is out of contract.

.PHONY: receipt serve halt-if-dirty

receipt:
    python3 loop_receipt.py

serve:
    python3 -m http.server 8080

halt-if-dirty:
    @test -z "$$(git status --porcelain)" || (echo "dirty tree; refuse loop start"; exit 1)
Enter fullscreen mode Exit fullscreen mode

Run make halt-if-dirty receipt after every patch. The agent does not get a deploy target. Deploy is a founder action after the receipt is OK and the slice sentence still matches the UI.

Decision table for halt vs continue

Signal in the working tree Action
Iteration count above 8 Halt. Ship or cut scope.
More than 3 Python files in the diff Halt. Split the slice.
More than 250 added lines Halt. The loop is rewriting, not shipping.
Import of boto3, stripe, or a hosted model SDK Halt. That is a bill.
String literal with AWS_, STRIPE_, or OPENAI_ Halt. Same bill, earlier.
New markdown, HTML, and one app.py only Continue if the slice sentence still holds.
Tests that need a network Halt. Local SQLite or nothing.

The table is the agent. Founders who want a richer planner can add one later. They should not add it before the first paying user, and they should not add it on a free weekend box.

Where a free model path fits

The checker does not care which editor proposed the patch. It cares that the patch stayed inside the contract. Local inference is optional. A hosted free-model path is optional. Either path still needs the receipt, because a free endpoint can still emit a paid architecture.

Some founders prefer not to stand up local inference at all. MonkeyCode is an open-source coding assistant that currently offers free model access and a free server option for this kind of bounded weekend loop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two availability claims are the only product facts used here. This article does not assign model names, hardware, quotas, or duration. Founders who try a hosted path should still run loop_receipt.py on their own tree so a free server cannot smuggle a billed SDK into main.

Token burn is a separate problem from architectural drift. A free model does not make an unbounded loop safe. It only removes one invoice while the loop invents the next one. The iteration cap remains the control.

Limitations

The checker reads Python. It will not catch a shell script that curls a paid API. It will not catch a dependency pulled in a lockfile but never imported. It will not catch infrastructure clicked in a cloud console. AST allowlists are a tripwire, not a security boundary.

Git is required. A copy-paste project without commits has no HEAD and cannot count files honestly. The line cap also ignores generated HTML assets if they are not in the diff. Founders who let an agent dump a frontend bundle will sail under the Python file cap and still waste the day.

Free model access and a free server option can change. Do not plan a company on an unpublished SLA. Do not store customer PII on a shared free host. Do not treat a weekend receipt as an audit log for a regulated workload.

Who should not use this approach

Teams with more than one active committer should use real review, not a three-number file. Products that already bill through Stripe or a public cloud should encode those vendors in an allowlist on purpose, not pretend the deny list still applies. Anyone handling health, payments, or school records needs a threat model this script does not provide.

Founders chasing model quality charts should also skip this. The method optimizes for a shippable slice on a quiet machine. It does not optimize for agent autonomy, long-horizon planning, or multi-tool orchestration. Those loops are a different product. They are rarely a same-day MVP.

Ship the slice, then stop

The useful end state is a running page, a SQLite file, and a receipt that says OK. Close the editor. Tag the commit. Leave .loop_state.json in the tag so the next weekend cannot pretend it is still iteration two. An agent that still wants to “just add auth” has already lost the contract. The founder who honors the halt is the one who still has a zero bill on Monday.

Top comments (0)