Handbücher · Loop Engineering

Build: Der Nachtlauf

Lektion 8/912 Min. LesezeitProfi

Lernziele

  • Du baust einen Loop, der ohne Aufsicht läuft: Budget an Iterationen, eine Ratchet auf einem Messwert und ein Bericht in Markdown.
  • Du sicherst den Lauf mit Git ab: eigener Branch, Commit nur bei Fortschritt, Reset bei Rückschritt. Gepusht wird nie.
  • Du liest den Morgenbericht so, dass du weißt, was besser wurde, warum der Loop stoppte und was du entscheiden musst.

Der letzte Build läuft unbeaufsichtigt. Die Regeln aus Build: Der Zieltest und Build: Der Kritiker gelten weiter: Der Zieltest läuft im Skript, jeder Versuch ist eine neue Session, Exit-Codes und Bericht sind ehrlich. Neu ist, dass niemand zusieht. Die Leitplanken müssen deshalb den schlimmsten Fall langweilig machen: ein Budget an Iterationen, eine Ratchet · engl. ratchetEine Sperrklinke für Fortschritt: Jeder Versuch muss den besten bisherigen Messwert schlagen, etwa die Zahl bestandener Tests. Sonst setzt der Loop den Versuch zurück und hält an.Im Glossar, die den Lauf stoppt, sobald der Fortschritt ausbleibt, ein Morgenbericht in Markdown und Git als Sicherheitsnetz. Der Loop · engl. agent loopDie Schleife eines Agenten: denken, handeln, beobachten, wiederholen. Zwischen den Durchläufen wird der Kontext des Modells jedes Mal neu gebaut.Im Glossar arbeitet auf einem eigenen Branch · engl. branchEin Seitenzweig in Git, auf dem du Änderungen ausprobierst, ohne den Hauptstand zu berühren. Danach führst du ihn zusammen oder löschst ihn.Im Glossar, setzt bei Rückschritt hart zurück und pusht nie.

Die Ratchet ist das Herzstück. Jede Iteration muss den besten bisherigen Messwert schlagen, hier die Zahl der bestandenen Tests. Sonst setzt der Loop den Versuch zurück und hält an. Er geht also nur vorwärts oder gar nicht. Ein Loop, der sich die ganze Nacht seitwärts bewegen darf, tut das nach unserer Einschätzung auch. Die Begründung steht in Stoppbedingungen.

Du brauchst claude, pytest, ruff und ein Repository mit Tests und einer Datei TODO.md. Getestet haben wir mit Claude Code 2.1.323 und Haiku (Stand: Nov. 2026).

Das Skript

Der Nachtlauf ist in Python geschrieben, weil er mehr Zustand verwaltet als die beiden Bash-Skripte: einen Messwert, einen Branch und einen Bericht. Speichere ihn als ~/bin/overnight.py, starte ihn abends mit python3 ~/bin/overnight.py im Ordner des Repositorys und lies morgens MORNING-REPORT.md.

#!/usr/bin/env python3
"""overnight.py: unbeaufsichtigter Agenten-Loop mit Budget, Ratchet und
Morgenbericht. Liegt außerhalb des Repos und startet im Repo-Ordner:
python3 ~/bin/overnight.py"""
import datetime
import json
import pathlib
import re
import subprocess
import sys

TASK = ("Open TODO.md. Implement the top unchecked item, add tests for it "
        "in a new file under tests/, and tick the item off. "
        "Do not change, delete or skip any existing test.")
MAX_ITERATIONS = 10
BUDGET_PER_ITERATION = "0.50"   # USD, Schätzung des Clients
MODEL = "haiku"
RATCHET = pathlib.Path("ratchet.json")
REPORT = pathlib.Path("MORNING-REPORT.md")


def run(cmd, timeout=2400):
    return subprocess.run(cmd, capture_output=True, text=True,
                          stdin=subprocess.DEVNULL, timeout=timeout)


def git(*args):
    return run(["git", *args]).stdout


def score():
    """Messwert: bestandene Tests, nur wenn Tests und Linter grün sind.
    Bei Rot: 0 und die letzte Zeile der Ausgabe für den Bericht."""
    tests = run(["python3", "-m", "pytest", "-q", "--tb=no"])
    if tests.returncode != 0:
        return 0, tests.stdout.strip().splitlines()[-1]
    lint = run(["ruff", "check", "."])
    if lint.returncode != 0:
        return 0, "ruff: " + lint.stdout.strip().splitlines()[-1]
    m = re.search(r"(\d+) passed", tests.stdout)
    return (int(m.group(1)) if m else 0), ""


def touched_old_tests():
    """Bestehende Testdateien, die der Versuch geändert oder gelöscht hat."""
    return git("diff", "--name-only", "--diff-filter=MD", "HEAD",
               "--", "tests/").split()


def parse(result):
    """JSON-Ausgabe von claude -p, leer, wenn der Aufruf abgebrochen ist."""
    try:
        return json.loads(result.stdout)
    except ValueError:
        return {}


def main():
    if git("status", "--porcelain"):
        sys.exit("Arbeitsbaum nicht sauber: erst committen oder stashen.")
    exclude = pathlib.Path(".git/info/exclude")   # Ratchet und Bericht nie committen
    if str(REPORT) not in exclude.read_text():
        exclude.write_text(exclude.read_text() + f"\n{RATCHET}\n{REPORT}\n")

    stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M")
    branch = f"overnight/{stamp}"
    git("checkout", "-b", branch)          # Leitplanke 1: eigener Branch, kein Push
    best, _ = score()
    RATCHET.write_text(json.dumps({"best": best}))
    lines = [f"# Nachtbericht {stamp}", "",
             f"Ausgangslage: {best} Tests grün.", ""]
    spent = 0.0

    for i in range(1, MAX_ITERATIONS + 1):
        result = run(["claude", "-p", TASK, "--model", MODEL,
                      "--allowedTools",
                      "Read,Edit,Write,Bash(python3 -m pytest *),Bash(ruff *)",
                      "--permission-mode", "acceptEdits",
                      "--permission-prompts", "none",
                      "--max-budget-usd", BUDGET_PER_ITERATION,
                      "--setting-sources", "project,local",
                      "--output-format", "json"])
        data = parse(result)
        cost = data.get("total_cost_usd", 0.0)
        spent += cost
        (now, red), touched = score(), touched_old_tests()
        if now > best and not touched:
            best = now
            RATCHET.write_text(json.dumps({"best": best}))
            git("add", "-A")
            git("commit", "-m", f"overnight {i}: {now} Tests grün")
            lines.append(f"- Iteration {i}: {now} Tests grün, committet "
                         f"({cost:.3f} USD)")
            continue
        git("reset", "--hard")             # Leitplanke 2: ein Rückschritt
        git("clean", "-fd")                # überlebt nicht, auch keine neue Datei
        if touched:
            why = "bestehende Tests geändert: " + ", ".join(touched)
        elif red:
            why = f"Zieltest rot ({red})"
        else:
            why = f"{now} Tests grün, keine Verbesserung"
        lines.append(f"- Iteration {i}: {why}. Zurückgesetzt, Stopp "
                     f"({cost:.3f} USD)")
        lines += ["", "Letzte Antwort des Agenten:", ""]
        lines += ["> " + line for line in data.get("result", "").splitlines()]
        break
    else:
        lines.append(f"- Budget von {MAX_ITERATIONS} Iterationen verbraucht")

    lines += ["", f"Ende: {best} Tests grün auf {branch}, "
                  f"geschätzt {spent:.3f} USD.",
              "Nichts wurde gepusht. Prüfen und von Hand mergen."]
    REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()

Zeilen 12-19

Jede Iteration bekommt denselben Auftrag: den obersten offenen Punkt aus TODO.md umsetzen, Tests dafür in einer neuen Datei anlegen, den Punkt abhaken. Bestehende Tests sind tabu. Höchstens zehn Iterationen zu je 50 Cent geschätztem Budget.

Was im Test-Repo passierte

Für den Nachtlauf haben wir das reparierte Test-Repo aus den ersten beiden Builds genommen, sechs grüne Tests. Dazu kam eine TODO.md mit drei Punkten: eine Funktion skonto, eine Funktion runden, die kaufmännisch auf zwei Stellen rundet (runden(2.675) == 2.68), und eine Funktion rechnungsnummer. Der Lauf dauerte gut eine Minute. Der Bericht danach:

# Nachtbericht 20261113-1751

Ausgangslage: 6 Tests grün.

- Iteration 1: 10 Tests grün, committet (0.015 USD)
- Iteration 2: 17 Tests grün, committet (0.020 USD)
- Iteration 3: 20 Tests grün, committet (0.018 USD)
- Iteration 4: 20 Tests grün, keine Verbesserung. Zurückgesetzt, Stopp (0.012 USD)

Letzte Antwort des Agenten:

> There is no unchecked item in `TODO.md`, so I made no changes.
> ...

Ende: 20 Tests grün auf overnight/20261113-1751, geschätzt 0.065 USD.
Nichts wurde gepusht. Prüfen und von Hand mergen.

Drei Punkte, drei Commits, alle abgehakt. In Iteration 4 war nichts mehr zu tun. Der Agent schrieb das auch so, der Messwert blieb stehen, und die Ratchet hielt den Lauf an. git diff --stat zeigte am Morgen fünf geänderte Dateien: rechnung.py, TODO.md und drei neue Testdateien. runden rechnet über Decimal mit ROUND_HALF_UP und vermeidet so den Rundungsfehler von Gleitkommazahlen.

Ein früherer Lauf auf demselben Repo verlief anders. Iteration 1 erledigte skonto, Iteration 2 scheiterte an runden, der Zieltest war rot. Das Skript setzte zurück und hielt an. Auf dem Branch lag nur der gute Commit. Derselbe Auftrag kann also beim nächsten Mal anders ausgehen, und genau dafür ist der Reset da.

Für den dritten Lauf stand oben in TODO.md ein Punkt, der einem bestehenden Test widerspricht: Rabatte über 100 Prozent sollten gedeckelt werden, der Test verlangt einen ValueError. Haiku änderte nichts und erklärte den Konflikt. Der Bericht (gekürzt):

- Iteration 1: 6 Tests grün, keine Verbesserung. Zurückgesetzt, Stopp (0.015 USD)

Letzte Antwort des Agenten:

> I stopped without changing anything, because the top TODO item conflicts
> with an existing test.
> ...
> How to proceed, your choice:
> 1. Keep the ValueError behavior and remove or rewrite the TODO item.
> 2. Change the behavior to cap at 100 and replace test_rabatt_ueber_100.
> 3. Move on to rechnungsnummer(jahr, nummer), which doesn't conflict.

Die erste Fassung unseres Skripts schrieb nur „keine Verbesserung“ in den Bericht. Die Frage des Agenten ging dabei verloren, obwohl sie die wichtigste Zeile der Nacht war. Seitdem zitiert der Bericht beim Stopp die letzte Antwort.

Die Leitplanken im Einzelnen

Eigener Branch ohne Push. Jede Verbesserung landet als Commit auf overnight/<Zeitstempel>. Die Entscheidung am Morgen ist damit ein normales Review eines normalen Branches. Das Skript enthält keinen Push. Der AgentEin System, in dem ein Sprachmodell seinen nächsten Schritt selbst wählt: Es ruft Werkzeuge auf, wertet das Ergebnis aus und macht weiter, bis das Ziel erreicht ist.Im Glossar hat Freigaben für Lesen, Schreiben, pytest und ruff. Ein git push bräuchte eine Rückfrage, und --permission-prompts none sagt Claude Code, dass niemand antworten kann. Alles, was nachfragen würde, wird abgelehnt und bleibt nicht bis zum Morgen hängen. In unseren Läufen traf das einen Aufruf cd … && git …, mit dem der Agent den Stand des Repos ansehen wollte. Er las danach mit dem Werkzeug Read weiter.

Reset bei Rückschritt. Verbessert sich der Messwert nicht, verwirft git reset --hard die Änderungen an versionierten Dateien. Neue Dateien bleiben davon unberührt, deshalb folgt git clean -fd. Das ist sicher, weil der Lauf nur auf einem sauberen Arbeitsbaum startet und Ratchet und Bericht über .git/info/exclude ignoriert sind. Zusammen mit der Ratchet gilt eine starke Regel: Auf dem Branch liegen nur Stände, die messbar besser sind als der vorige. Darum kannst du dem Bericht trauen, ohne jeden Diff neu zu lesen.

Der Messwert muss ehrlich sein. Die Zahl der bestandenen Tests ist leicht zu steigern. Ein Agent kann einen alten Test kaputt machen und zwei triviale neue schreiben. Das Skript zählt deshalb nur grüne Stände und verwirft jeden Versuch, der eine bestehende Testdatei ändert. Was es nicht erkennt, sind schwache neue Tests. Dafür setzt du den Kritiker aus dem zweiten Build vor den Commit oder liest die neuen Testdateien am Morgen selbst.

Der Bericht ist der Briefkasten. Ein Loop ohne Aufsicht macht Fehler ohne Aufsicht. Im Bericht tauchen sie auf: pro Iteration, mit der Bremse, die gegriffen hat, und mit der letzten Antwort des Agenten, wenn er gestoppt hat. Zwischen den Iterationen überleben nur TODO.md und die Ratchet-Datei. Das ist Absicht und folgt Kontext-Hygiene. Jede Session · engl. sessionEin einzelnes Gespräch mit einem Agenten: der Verlauf aller Nachrichten und Tool-Ergebnisse plus der Arbeitszustand. Endet das Gespräch, endet die Session.Im Glossar startet kalt, liest die Todo-Liste und erledigt eine begrenzte Sache.

Das Budget ist doppelt begrenzt. Zehn Iterationen zu je höchstens 50 Cent ergeben eine geschätzte Obergrenze von fünf Dollar pro Nacht. Unsere Läufe kosteten zwischen 1,5 und 6,5 Cent. Die Grenze pro Iteration ist eine Schätzung des Clients, wie im ersten Build. Ein Ausgabenlimit beim Anbieter bleibt die harte Grenze.

Welche Leitplanke fängt das?

Ordne jeden Vorfall der Nacht der Leitplanke zu, die ihn abfängt.

Noch offen (6)

  • Iteration 5 macht drei Tests rot
  • Der Agent legt eine halbfertige Hilfsdatei an und scheitert dann
  • Der Agent „vereinfacht“ einen alten Test und schreibt zwei neue
  • Der Agent versucht, den Branch auf GitHub zu pushen
  • Ein Todo-Punkt widerspricht einem bestehenden Test
  • Du willst wissen, was die Nacht gekostet hat

Ratchet und Reset (0)

    Schutz der alten Tests (0)

      Rechte und kein Push (0)

        Morgenbericht (0)

          Merksätze

          • Die Ratchet lässt nur Stände durch, die messbar besser sind als der vorige. Alles andere wird verworfen, und der Lauf hält an.
          • Git ist das Sicherheitsnetz: eigener Branch, Commit bei Fortschritt und reset --hard plus clean -fd bei Rückschritt, ohne jeden Push.
          • Ein Messwert wie „bestandene Tests“ braucht Schutz: nur grüne Stände zählen, alte Tests bleiben unberührt.
          • Der Morgenbericht nennt Fortschritt, Bremse, Kosten und die letzte Antwort des Agenten, damit offene Entscheidungen bei dir ankommen.
          • Zwischen den Iterationen überleben nur TODO.md und die Ratchet-Datei. Jede Session startet kalt.

          Prüf dich selbst

          Denkfrage

          Warum hält der Nachtlauf beim ersten Versuch ohne Verbesserung an und probiert es nicht weiter?

          Denkfrage

          Der Bericht meldet zehn Commits und null Stopps. Kannst du den Branch ungelesen mergen?

          Prüf dich selbst

          1. 1Warum zählt der Messwert nur, wenn pytest und ruff grün sind?
          2. 2Was entfernt git clean -fd, das git reset --hard stehen lässt?
          3. 3Was bewirkt --permission-prompts none im Nachtlauf?
          4. 4Ein Todo-Punkt widerspricht einem bestehenden Test. Wo erfährst du das?

          Selbst nachweisen

          0 von 3 erledigt

          Quellen

          1. Run Claude Code programmatically · Anthropic, Stand: Nov. 2026

            --permission-prompts none für unbeaufsichtigte Läufe, --output-format json mit result und total_cost_usd.

          2. CLI reference · Anthropic, Stand: Nov. 2026

            --max-budget-usd als Schätzung des Clients, --setting-sources.

          3. git-clean · Git-Projekt, Stand: Nov. 2026

            -d, -f und -x. Ohne -x gelten die Ignorier-Regeln.

          4. gitignore · Git-Projekt, Stand: Nov. 2026

            info/exclude für Muster, die nur in einem Repository gelten und nicht geteilt werden.

          Dein Fortschritt bleibt nur in diesem Browser gespeichert.