Subagent-Driven Development — Datenmodell parallel bauen

Subagent-Driven Development — Datenmodell parallel bauen

Artikel 5 · Serie: Agentic Coding mit Claude Code

v0.4 produziert drei JSON-Dateien: Epl01, Epl11 und Epl16. Das ist kein Datenmodell. Spaltennamen existieren, aber Typen sind nicht garantiert, die Dateien haben unterschiedliche Formate, und es gibt keine einzige Quelle, mit der ein Frontend arbeiten kann. Drei Aufgaben fallen jetzt an: Schema definieren, JSON normalisieren, Validierung aufbauen. Subagent-Driven Development.

Was Subagent-Driven Development ist — und was nicht

Ein Subagent ist kein separater Prozess und keine eigene Claude-Instanz. Im Kontext von Claude Code ist ein Subagent ein Haupt-Agent, der Claude per Skill oder Prompt anweist, einen Teil einer Aufgabe in isolierter Verantwortung zu erledigen. Der Unterschied zum direkten Prompt: Der Subagent bekommt einen Plan, ein klares Ziel, eine Verifikationsbedingung und einen begrenzten Scope. Er ist nicht für den Gesamtkontext zuständig.

Das Abgrenzung zu Slash Commands und Skills:

  • Slash Command — definiert was ausgeführt wird (fest, deterministisch, mit Argument)
  • Skill — definiert wie eine Aufgabe anzugehen ist (Ablauf, Kontext, Kriterien)
  • Subagent — führt einen Schritt eines Plans aus, mit isoliertem Scope und eigenem Verifikations-Kriterium

Subagents sind kein universelles Allheilmittel. Sie lohnen sich, wenn Aufgaben wirklich unabhängig sind, jede Aufgabe eine klare Definition of Done hat, und der Overhead der Koordination kleiner ist als der Gewinn durch parallele Bearbeitung.

Plan-File als Briefing

Bevor ein Subagent etwas anfasst, braucht er ein Briefing. Der Unterschied zwischen einem Plan-File und einem Wunschzettel:

Wunschzettel: „Baue ein Polars-Schema für den Haushalt."

Briefing:

Task 1 — schema.py (sequenziell zuerst)
Eingabe:   keine (rein deklarativ)
Ausgabe:   parser/src/parser/schema.py mit HAUSHALT_SCHEMA: dict[str, pl.DataType]
Verifikation: python -c "from parser.schema import HAUSHALT_SCHEMA; assert len(HAUSHALT_SCHEMA) >= 6"
Definition of Done: Schema importierbar, alle Pflicht-Spalten definiert.

Jeder Task im Plan hat: Eingabe, Ausgabe, Testkommando, Definition of Done. Das Testkommando ist entscheidend: ohne es ist das Ergebnis des Subagents nicht verifizierbar. Ein Subagent, der „fertig" ist aber keine Verifikation produziert, hat das Briefing nicht erfüllt.

Das Plan-File liegt im Repo, versioniert, nicht im Prompt:

Erstelle plans/v0.5-datenmodell.md als Briefing für drei Tasks:

Task 1 (sequenziell): schema.py — Polars-Schema HAUSHALT_SCHEMA, 11 Spalten.
  Verifikation: python -c "from parser.schema import HAUSHALT_SCHEMA; assert len(HAUSHALT_SCHEMA) >= 6"

Task 2a (nach Task 1, parallel zu 2b): normalize.py — alle output/*.json einlesen,
  gegen Schema casten, haushalt.parquet + manifest.json schreiben.
  Verifikation: uv run python -m parser.normalize

Task 2b (nach Task 1, parallel zu 2a): .claude/skills/validate-totals/SKILL.md —
  Hypothesis-Properties und Aufruf-Kommando dokumentieren.
  Verifikation: Datei existiert und enthält uv run pytest Kommando.

Abhängigkeit: 2a und 2b erst nach Abschluss von Task 1 starten.

Claude Code schreibt das Plan-File nach Prompt. Damit ist das Briefing Teil des Commits, nicht nur Kontext einer Session.

Task 1 — schema.py: Das Schema ist eine Konstante

Schema gehört nicht in den Code der es benutzt. Es gehört in eine eigene Datei, aus der es importiert werden kann. Das ist der erste Subagent-Task, sequenziell: alle anderen Tasks hängen von diesem Schema ab.

Führe Task 1 aus plans/v0.5-datenmodell.md aus.

Claude Code liest das Briefing, schreibt schema.py und führt die Verifikation aus:

→ Task 1: parser/src/parser/schema.py
python -c "from parser.schema import HAUSHALT_SCHEMA; assert len(HAUSHALT_SCHEMA) >= 6"
ok — 11 Spalten definiert. Task 1 abgeschlossen.

Das Schema selbst:

# parser/src/parser/schema.py
import polars as pl

HAUSHALT_SCHEMA: dict[str, pl.DataType] = {
    "epl_nr": pl.Utf8,
    "kap_nr": pl.Utf8,
    "titel_nr": pl.Utf8,
    "fkz": pl.Utf8,
    "zweckbestimmung": pl.Utf8,
    "soll_2026_tsd": pl.Float64,
    "soll_2027_tsd": pl.Float64,
    "soll_2025_tsd": pl.Float64,
    "ist_2024_tsd": pl.Float64,
    "ist_2023_tsd": pl.Float64,
    "source_file": pl.Utf8,
}

REQUIRED_COLUMNS = frozenset({"epl_nr", "kap_nr", "titel_nr", "zweckbestimmung", "source_file"})
AMOUNT_COLUMNS = frozenset({"soll_2026_tsd", "soll_2027_tsd", "soll_2025_tsd", "ist_2024_tsd", "ist_2023_tsd"})

Historische Spalten (soll_2025_tsd, ist_2024_tsd, ist_2023_tsd) sind in v0.5 noch immer null, aber sie stehen im Schema. Das Parquet bleibt damit rückwärts-kompatibel, wenn v0.6 diese Felder befüllt.

source_file ist eine Traceability-Spalte: jede Zeile weiß, aus welchem JSON sie stammt. Diese Information kostet nichts beim Speichern und ist beim Debugging unschätzbar.

Verifikation:

python -c "from parser.schema import HAUSHALT_SCHEMA; assert len(HAUSHALT_SCHEMA) >= 6; print('ok')"
ok

Zwei Subagents parallel: normalize + validate-totals

Sobald schema.py existiert, können zwei Tasks parallel laufen:

  • Task 2anormalize.py: liest alle EPL-JSONs, castet gegen das Schema, schreibt Parquet + Manifest
  • Task 2bvalidate-totals Skill: definiert die Hypothesis-Properties und den Test-Aufruf

Diese beiden haben keine Abhängigkeit voneinander: der Skill braucht kein Parquet, und normalize.py braucht den Skill nicht. Genau hier lohnt sich Parallelisierung.

Task 1 abgeschlossen. Führe Task 2a und 2b aus plans/v0.5-datenmodell.md aus.
Reihenfolge egal, beide sind unabhängig.

Claude Code arbeitet beide Tasks ab und führt je die Verifikation aus:

→ Task 2b: .claude/skills/validate-totals/SKILL.md erstellt. ✓

→ Task 2a: parser/src/parser/normalize.py
uv run python -m parser.normalize

Was normalize.py entdeckt

Beim Schreiben von normalize.py taucht das erste Problem auf: epl11.json hat ein anderes Format als die anderen beiden JSON-Dateien.

// epl11.json (v0.3-Format) — Dict-Wrapper
{
  "epl_nr": "11",
  "epl_name": "Bayerischer Oberster Rechnungshof",
  "titel_count": 81,
  "titel": [ {...}, {...} ]
}

// Epl01.json (v0.4-Format) — direkte Liste
[
  { "titel_nr": "112 01-0", "epl_nr": "01", ... },
  ...
]

Der /parse-epl-Command aus v0.4 schreibt Listen. Das Skript aus v0.3, das epl11.json erzeugt hat, schrieb einen Dict-Wrapper. normalize.py muss beide Formate handlen:

def _load_json(path: Path) -> list[dict]:
    raw = json.loads(path.read_text(encoding="utf-8"))
    if isinstance(raw, list):
        return raw
    if isinstance(raw, dict) and "titel" in raw:
        return raw["titel"]
    raise ValueError(f"{path.name}: unbekanntes JSON-Format")

Keine elegante, aber eine pragmatische Lösung. Das Datenmodell absorbiert Format-Inkonsistenz; die Quelle bleibt wie sie ist.

Das zweite Problem: beim ersten Testlauf liest normalize.py auch manifest.json ein, die wir selbst gerade erzeugt haben. Die Datei ist kein EPL-JSON, hat kein titel-Key und keinen list-Typ. Die Funktion wirft einen ValueError.

# Fix: manifest.json explizit exkludieren
json_files = [f for f in sorted(output_dir.glob("*.json")) if f.name != "manifest.json"]

Diese zwei Zeilen sind ein Muster: generierte Artefakte, die im selben Verzeichnis wie Eingabe-Dateien landen, brauchen explizite Exklusion. Alternativ könnten EPL-JSONs in ein Unterverzeichnis (output/epls/). Das wäre sauberer, ist aber eine Architektur-Entscheidung für v0.6.

Ergebnis nach dem Fix:

Parquet: output/haushalt.parquet (377 rows)
Manifest: output/manifest.json
  Epl01.json: 138 Titel, Σ=184234.3 Tsd. €
  Epl16.json: 158 Titel, Σ=122046.4 Tsd. €
  epl11.json: 81 Titel, Σ=47390.9 Tsd. €

377 Zeilen, drei EPL, ein Parquet.

Property-based Tests — Invarianten statt Beispiele

Mit Parquet und Skill vorhanden folgt der letzte Prompt:

Schreibe parser/tests/test_normalize.py und parser/tests/test_properties.py.
test_normalize.py: Smoke-Tests — Parquet existiert, Schema stimmt, Manifest vollständig.
test_properties.py: Hypothesis-Properties aus validate-totals/SKILL.md.
  Property "Σ EPL = Gesamthaushalt" als xfail markieren — nur 3 von 16 EPL vorhanden.
Danach: uv run pytest -v

test_all_epls.py aus v0.4 prüft konkrete Summen gegen Referenzwerte. Das ist ein Beispiel-Test: er prüft einen bekannten Input gegen eine bekannte Ausgabe. Property-based Tests mit Hypothesis drehen das um: sie beschreiben, was für jeden möglichen Input gelten muss, und überlassen die Testdaten-Generierung dem Framework.

Für das Datenmodell sind die Invarianten klar:

  1. Jeder EPL-Block hat mindestens einen Titel mit soll_2026_tsd != null
  2. Alle extrahierten Beträge sind ≥ 0
  3. Jede Zeile hat source_file != null (Traceability)
def test_every_epl_has_titles_with_values(df: pl.DataFrame) -> None:
    for epl_nr in df["epl_nr"].unique().to_list():
        subset = df.filter(pl.col("epl_nr") == epl_nr)
        non_null = subset["soll_2026_tsd"].drop_nulls()
        assert len(non_null) > 0, f"EPL {epl_nr}: keine soll_2026_tsd-Werte"

def test_amounts_non_negative(df: pl.DataFrame) -> None:
    vals = df["soll_2026_tsd"].drop_nulls()
    neg = vals.filter(vals < 0)
    assert len(neg) == 0, f"{len(neg)} negative Beträge gefunden"

Die vierte Property, Σ aller EPL-Ausgaben == 84.647.400 Tsd. € (Gesamthaushalt Bayern 2026), ist als xfail markiert:

@pytest.mark.xfail(
    reason="Nur 3 von 16 EPL geparst — Σ EPL != Gesamthaushalt bis v0.6",
    strict=False,
)
def test_gesamthaushalt_ausgaben(df: pl.DataFrame) -> None:
    ausgaben = df.filter(
        pl.col("soll_2026_tsd").is_not_null()
        & ~pl.col("titel_nr").str.starts_with("1")
    )["soll_2026_tsd"].sum()
    assert abs(ausgaben - 84_647_400.0) <= 1.0

strict=False bedeutet: wenn der Test überraschend grün wird, ist das kein Fehler. Wenn er rot wird, ist das bekannt und dokumentiert. Das ist der Unterschied zwischen einem xfail, der einen Regressions-Schutz bildet, und einem, der einfach wegignoriert.

Das Manifest — warum ein Sidecar Pflicht ist

haushalt.parquet enthält 377 Zeilen. Ohne Sidecar weiß niemand, welche PDF-Version dieser Parquet entspricht, wann er erzeugt wurde und welche EPL drin sind. Das Manifest löst das:

{
  "generated_at": "2026-05-13T18:00:00+00:00",
  "parser_version": "v0.5",
  "total_rows": 377,
  "source_files": ["Epl01.json", "Epl16.json", "epl11.json"],
  "epl_count": 3,
  "soll_2026_tsd_total": 353671.6,
  "checksum_sha256_16": "a1b2c3d4e5f60718"
}

Das Frontend (Art 6) liest das Manifest, bevor es den Parquet öffnet. So weiß es, ob die Datei aktuell ist oder neu generiert werden muss. Der Checksum-Hash prüft, ob der Parquet nach dem letzten normalize-Lauf verändert wurde.

Parquet und Manifest kommen ins .gitignore: generierte Artefakte, keine Quelle. Die Quelle ist normalize.py + die EPL-JSONs.

Wann nicht parallelisieren

Task 1 (schema.py) lief sequenziell. Das war keine Zufallsentscheidung. normalize.py importiert HAUSHALT_SCHEMA aus schema.py. Laufen beide parallel, schreibt der normalize-Subagent gegen ein Schema, das noch nicht existiert.

Parallelisierung lohnt sich genau dann, wenn:

  • Tasks keine Import-Abhängigkeiten haben
  • Tasks keinen geteilten State schreiben (zwei Subagents, die dieselbe Datei schreiben, produzieren Race-Conditions)
  • Der Koordinations-Overhead kleiner ist als der Gewinn

Ein 10-Zeilen-Skript, ein einzelnes Konfigurationsfeld: dafür ist ein Subagent Overhead ohne Gewinn. Parallelisierung ist kein Ziel, sie ist ein Mittel.

Stand am Ende dieses Artikels

git clone https://codeberg.org/rotecodefraktion/byhaushalt.git
cd byhaushalt
git checkout v0.5

Vollständiger Stand unter byhaushalt @ v0.5.

v0.5 enthält: schema.py, normalize.py, validate-totals-Skill, Plan-File, Hypothesis-Tests, .gitignore-Update. 19 Tests grün, 3 xfail (Parser-Lücken Epl01/Epl16 aus v0.4 + Gesamthaushalt-Property).

cd parser && uv run pytest -v
# 19 passed, 3 xfailed in 1.55s

Wie es weitergeht

Artikel 6 behandelt Worktrees. Das Frontend für byhaushalt bekommt drei Visualisierungs-Varianten: Treemap, Sunburst und Sankey. Alle drei sollen parallel entwickelt werden, ohne dass sie sich in die Quere kommen. Git-Worktrees bieten genau das: drei isolierte Arbeitsverzeichnisse, ein Repository.

Wie Slash Commands den Parser wiederholbar gemacht haben, beschreibt Artikel 4. Den Custom Skill und TDD-Loop aus v0.3 zeigt Artikel 3.