DEV Community

Charlie Hu
Charlie Hu

Posted on

Keep the Agent Finite: A Weekend Step Ledger for Side Projects

Weekend agent builds rarely die from a missing feature. They die from an unbounded loop that reopens every skipped path. A local step ledger with a hard maximum, an allowlisted tool set, and a closed skip column keeps the demo finite even when generation runs on another machine.

The rest of this note is a proposed weekend kit. It is not a production agent framework and it is not a benchmark.

Cut the loop, not the repository

A side project has two scopes. Feature scope is the list of files the demo may touch. Loop scope is the number of tool steps the agent may take before the weekend is over.

Feature scope is the usual cut. Loop scope is the one that actually burns the Saturday. An agent that can still inspect one more file will inspect twenty.

The ledger below treats loop scope as the primary budget. Eight steps is a default, not a law. The number is small on purpose. A demo that needs forty tool calls is not a weekend demo.

What "done" means this weekend

The working demo is a single command with a frozen expected line on stdout. No UI. No extra endpoints. No README rewrite.

Proposed demo contract:

  • Command: python demo.py
  • Expected stdout: ok: greeter ready
  • Allowed files: demo.py, tests/test_demo.py, loop_ledger.json
  • Forbidden: new dependencies, git push, refactors outside the allowlist

Anything else goes in the skip column with a reason code. The next prompt is not allowed to reopen a skip without a human edit to the ledger.

The ledger file

Keep the ledger in git. The model may append steps. The model may not raise max_steps, may not delete skips, and may not expand allowed_files.

{
  "goal": "demo.py prints a frozen greeting and tests/test_demo.py asserts it",
  "max_steps": 8,
  "allowed_tools": ["read_file", "write_file", "run_cmd"],
  "allowed_files": ["demo.py", "tests/test_demo.py", "loop_ledger.json"],
  "allowed_cmds": ["python demo.py", "python -m pytest tests/test_demo.py -q"],
  "steps": [],
  "skips": [
    {
      "id": "S1",
      "item": "HTTP server and /health route",
      "reason": "out_of_demo"
    },
    {
      "id": "S2",
      "item": "config file and env loader",
      "reason": "out_of_demo"
    },
    {
      "id": "S3",
      "item": "retry/backoff helper",
      "reason": "reopens_loop"
    }
  ],
  "status": "open"
}
Enter fullscreen mode Exit fullscreen mode

Reason codes stay short. out_of_demo means the item is real work for another weekend. reopens_loop means the item would invite more tool calls than the budget. needs_human means the agent must stop rather than guess.

A tiny runner

The runner is ordinary Python. It does not call a model. It only enforces the ledger so a coding agent, or a tired human, cannot spend the budget on "one more step."

# loop_ledger.py
# Proposed weekend fixture. Not a production orchestrator.
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

LEDGER = Path("loop_ledger.json")
CLOSED_REASONS = {"out_of_demo", "reopens_loop", "needs_human"}


def load() -> dict:
    data = json.loads(LEDGER.read_text(encoding="utf-8"))
    data.setdefault("steps", [])
    data.setdefault("skips", [])
    return data


def save(data: dict) -> None:
    LEDGER.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")


def assert_open(data: dict) -> None:
    if data.get("status") == "closed":
        raise SystemExit("ledger is closed; demo is frozen")
    if len(data["steps"]) >= int(data["max_steps"]):
        raise SystemExit("max_steps reached; record a skip or stop")


def record_step(tool: str, target: str, note: str) -> None:
    data = load()
    assert_open(data)
    if tool not in data["allowed_tools"]:
        raise SystemExit(f"tool not allowed: {tool}")
    if tool in {"read_file", "write_file"} and target not in data["allowed_files"]:
        raise SystemExit(f"file not allowed: {target}")
    if tool == "run_cmd" and target not in data["allowed_cmds"]:
        raise SystemExit(f"cmd not allowed: {target}")
    data["steps"].append({"tool": tool, "target": target, "note": note})
    save(data)
    print(f"step {len(data['steps'])}/{data['max_steps']} {tool} {target}")


def add_skip(item: str, reason: str) -> None:
    data = load()
    if reason not in CLOSED_REASONS:
        raise SystemExit(f"unknown skip reason: {reason}")
    next_id = f"S{len(data['skips']) + 1}"
    data["skips"].append({"id": next_id, "item": item, "reason": reason})
    save(data)
    print(f"skip {next_id} {reason}")


def close_if_demo_passes() -> None:
    data = load()
    result = subprocess.run(
        [sys.executable, "-m", "pytest", "tests/test_demo.py", "-q"],
        check=False,
    )
    if result.returncode != 0:
        raise SystemExit("demo tests failed; ledger stays open")
    data["status"] = "closed"
    save(data)
    print("demo passed; ledger closed")


def main(argv: list[str]) -> None:
    if len(argv) < 2:
        raise SystemExit("usage: record|skip|close ...")
    cmd = argv[1]
    if cmd == "record" and len(argv) == 5:
        record_step(argv[2], argv[3], argv[4])
        return
    if cmd == "skip" and len(argv) == 4:
        add_skip(argv[2], argv[3])
        return
    if cmd == "close":
        close_if_demo_passes()
        return
    raise SystemExit("usage: record|skip|close ...")


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

Commands stay boring on purpose:

python loop_ledger.py record write_file demo.py "print frozen greeting"
python loop_ledger.py record write_file tests/test_demo.py "assert stdout"
python loop_ledger.py record run_cmd "python -m pytest tests/test_demo.py -q" "prove demo"
python loop_ledger.py skip "add FastAPI /health" out_of_demo
python loop_ledger.py close
Enter fullscreen mode Exit fullscreen mode

If a step is rejected, that is the product. The rejection is the scope cut.

The two files the demo may touch

# demo.py
def greeting() -> str:
    return "ok: greeter ready"


if __name__ == "__main__":
    print(greeting())
Enter fullscreen mode Exit fullscreen mode
# tests/test_demo.py
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def test_demo_stdout_is_frozen():
    proc = subprocess.run(
        [sys.executable, str(ROOT / "demo.py")],
        check=True,
        capture_output=True,
        text=True,
    )
    assert proc.stdout.strip() == "ok: greeter ready"
Enter fullscreen mode Exit fullscreen mode

The test pins stdout. A later agent that "improves" the copy will fail closed. That failure is cheaper than arguing with a chat log on Sunday night.

A worked Saturday sequence

Start with an empty steps array and the three skips already filled. The first records are reads. They cost budget. Reading is not free in this kit, because unbounded read_file is how loops pretend to be research.

Proposed sequence:

  1. record read_file demo.py — observe the file is missing.
  2. record write_file demo.py — add greeting().
  3. record write_file tests/test_demo.py — freeze stdout.
  4. record run_cmd for pytest — fail because the print has a trailing period.
  5. record write_file demo.py — match the frozen line.
  6. record run_cmd for pytest — pass.
  7. skip "extract a Greeter class" reopens_loop.
  8. close.

Two steps remain unused. Unused steps are not a dare. They are leftover budget. Closing the ledger spends them on nothing, which is the correct spend.

The runner cannot parse intent inside a file. A write to demo.py that quietly starts a web server would still record if the path is allowlisted. The skip column and the frozen stdout test are the backstop, and they are incomplete on purpose. Honesty about that gap belongs in the weekend log.

Decision table for skips

Candidate change Ledger action Reason code
Extra CLI flag that the demo command never uses skip out_of_demo
New dependency to make the greeting nicer skip reopens_loop
Refactor into a package with __init__ skip reopens_loop
Ambiguous product copy skip needs_human
Fix the frozen greeting so the test passes record_step in budget
Add a test that asserts the same line record_step in budget

The table is the weekend policy. The agent does not get a parallel policy in a system prompt that contradicts it.

Prompt text that defers to the ledger

Proposed system fragment. Unexecuted. Paste it next to the repo. Do not treat it as an evaluated prompt.

You may only change files in allowed_files.
Before any extra feature, add a skip with reason out_of_demo or reopens_loop.
If max_steps is reached, stop. Do not ask to raise it.
Do not implement any item already in skips.
Enter fullscreen mode Exit fullscreen mode

Four lines. The ledger is the source of truth. The prompt is a pointer.

Where generation can run

The ledger is local. The generator does not have to be.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

A laptop that is already compiling tests does not also need to host a long coding session. MonkeyCode's free model access and free server option can run the generator while loop_ledger.json stays in the repo and keeps rejecting out-of-scope tool calls. The interesting part is still the ledger. Remove the remote session and the same budget still applies.

If that remote option is already in reach, point the agent at the two allowed files and the commands above. Do not raise max_steps because the session is free. A free loop with no cap is still an unbounded loop.

What this weekend skips on purpose

  • No streaming UI for token traces.
  • No multi-agent debate over the greeting.
  • No prompt store, vector index, or memory layer.
  • No automatic reopen of S1–S3 when tests pass early.

Those skips are the build. A closed ledger with three skips and a green demo is a finished weekend. An open ledger with an almost-working HTTP stack is not.

Limitations

The kit does not measure model quality. It does not stop a determined editor from changing max_steps by hand. It does not replace code review, secret scanning, or a real merge gate.

JSON is not a workflow engine. Concurrent agents will clobber the ledger. There is no file locking in the script above.

Stdout matching is brittle on purpose. It will reject useful changes that alter the greeting. That is the point for a weekend freeze. It is the wrong tool for a product that must evolve copy every day.

The allowlist is path-based, not AST-based. Scope still depends on a human who will not "just" expand allowed_files at 11 p.m.

Who should not use this

  • Teams shipping production agents with real users or compliance needs.
  • Exploratory research whose goal is to watch the model wander.
  • Anyone who needs the agent to add dependencies or touch infrastructure.
  • Builders who will not keep the skip column honest.

A finite loop is a constraint. Constraints only help when the demo is small enough to finish.

The next weekend can open a new ledger. It should not reopen this one.

Top comments (0)