"""Hängt verwaiste Notizen einmalig an ihren Hub an: nur anhängen, nie ersetzen. Ersteller: Pascal Michel Erstellt: 2026-09-29 Plan: docs/plans/2026-09-29-waisen/ im selben Ordner Ohne --anwenden ist es ein Probelauf: Er listet je Zieldatei den Text, der angehängt würde, und schreibt nichts. Mit --anwenden kopiert das Skript jede Zieldatei zuerst in den Sicherungsordner außerhalb des Vaults, prüft jede Kopie Byte für Byte und hängt erst danach an. Das Zeilenende jeder Datei bleibt, wie es war. Kein Commit, kein git add. REGELN, alle über Muster, ohne Namen einzelner Projekte oder Kunden Code Projekte/

/docs/plans//.md 00-uebersicht.md desselben Ordners bekommt eine Zeile mit relativem Link aufs Projekt-README, wenn sie es nicht schon verlinkt, und den Abschnitt "Dateien in diesem Ordner" mit relativen Links auf jede .md im Ordner. Taskdateien fasst das Skript nie an. Code Projekte/

/CHANGELOG.md und jede Datei unter Code Projekte/

/docs/ außerhalb von Planordnern eine Zeile mit relativem Link aufs Projekt-README. docs/plans//.md an der Vault-Wurzel wie ein Planordner in Code Projekte, aber die Zeile heißt "Vault:" und verlinkt README.md der Vault-Wurzel. jede andere Datei unter docs/ an der Vault-Wurzel eine Zeile mit relativem Link auf README.md der Vault-Wurzel. IT Workspace/**/_arbeitsstand/** Planordner wie oben, aber der Link nach oben ist ein Wikilink auf die einzige _MOC-*.md im Ordner über _arbeitsstand; jede andere Datei bekommt eine Zeile mit diesem Wikilink. IT Workspace/**/findings/.md der Bericht im Ordner über findings (ABSCHLUSSBERICHT.md, sonst FULL-AUDIT-REPORT.md) bekommt den Abschnitt "Befunddateien dieses Laufs" mit einem Wikilink mit vollem Pfad je Datei. AI/secondbrain//JJJJ-MM-TT-.md eine Zeile "Tagesnotiz: [[Inbox/JJJJ-MM-TT]]", nur wenn die Tagesnotiz existiert. Alles andere kommt auf die Liste für den Koordinator. Geraten wird nichts. ÜBERSPRUNGEN UND AUFGELISTET wird eine Zieldatei, die * in einem Git-Repo im Vault liegt, auch dem der Vault-Wurzel, und dort in irgendeinem Arbeitsbaum uncommittet geändert oder neu ist oder auf einem anderen lokalen Zweig seit dem gemeinsamen Stand geändert wurde; was das Repo ignoriert, zählt nicht als geändert, * nicht als UTF-8 lesbar ist oder gemischte Zeilenenden hat, * die anzuhängende Überschrift oder Linkzeile schon enthält. Ein Git-Repo, das sich nicht prüfen lässt, sperrt alle seine Zieldateien. BENANNTE LÜCKEN * Welche Notiz eine Waise ist, entscheidet waisen.py mit dessen Lücken. * Nur lokale Zweige zählen, entfernte nicht. * Eine Datei, die zwischen dem Lesen und dem Anhängen geändert wird, bricht den Lauf ab; was bis dahin angehängt war, nennt die Ausgabe. Exit 0: Lauf durch. Exit 2: abgebrochen, die Ursache steht in der Ausgabe. Aufruf: python .vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude python .vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude --anwenden --sicherung """ import argparse import os import re import shutil import subprocess import sys import traceback from dataclasses import dataclass, field from pathlib import Path from typing import Optional from urllib.parse import quote HIER = Path(__file__).resolve().parent if str(HIER) not in sys.path: sys.path.insert(0, str(HIER)) try: import waisen _IMPORTFEHLER = "" except Exception as _fehler: waisen = None _IMPORTFEHLER = f"{type(_fehler).__name__}: {_fehler}" ORDNER_KOPF = "## Dateien in diesem Ordner" BEFUND_KOPF = "## Befunddateien dieses Laufs" BERICHTE = ("ABSCHLUSSBERICHT.md", "FULL-AUDIT-REPORT.md") _DATUM = re.compile(r"^(\d{4}-\d{2}-\d{2})") GESCHRIEBEN = [] class Abbruch(Exception): """Der Lauf kann nicht sicher weiter.""" @dataclass class Ziel: rel: str zeilen: list = field(default_factory=list) ordner: Optional[str] = None befunde: Optional[str] = None ausloeser: list = field(default_factory=list) hubs: set = field(default_factory=set) gelistet: list = field(default_factory=list) def existiert(vault: Path, rel: str) -> bool: return (vault / rel).is_file() def rel_link(von: str, zu: str) -> str: start = von.split("/")[:-1] ziel = zu.split("/") gemeinsam = 0 while gemeinsam < min(len(start), len(ziel) - 1) and start[gemeinsam] == ziel[gemeinsam]: gemeinsam += 1 teile = [".."] * (len(start) - gemeinsam) + ziel[gemeinsam:] return quote("/".join(teile), safe="/") def _code_projekt(rel: str, teile: list, vault: Path) -> tuple: readme = "/".join(teile[:2]) + "/README.md" innen = teile[2:] if innen[:2] == ["docs", "plans"] and len(innen) == 4: uebersicht = "/".join(teile[:-1]) + "/00-uebersicht.md" if not existiert(vault, uebersicht): return ("rest", "", "Planordner ohne 00-uebersicht.md", "") if not existiert(vault, readme): return ("rest", "", f"{readme} fehlt", "") return ("ordner", uebersicht, f"Projekt: [README]({rel_link(uebersicht, readme)})", readme) if innen[:2] == ["docs", "plans"] and len(innen) > 4: return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "") if innen == ["CHANGELOG.md"] or (innen[:1] == ["docs"] and len(innen) >= 2): if not existiert(vault, readme): return ("rest", "", f"{readme} fehlt", "") return ("zeile", rel, f"Projekt: [README]({rel_link(rel, readme)})", readme) return ("rest", "", "keine Regel passt", "") def _arbeitsstand(rel: str, teile: list, vault: Path) -> tuple: stelle = teile.index("_arbeitsstand") projekt = "/".join(teile[:stelle]) try: mocs = sorted( name for name in os.listdir(vault / projekt) if name.startswith("_MOC-") and name.lower().endswith(".md") and (vault / projekt / name).is_file() ) except OSError as fehler: return ("rest", "", f"Projektordner nicht lesbar ({fehler})", "") if len(mocs) != 1: return ("rest", "", f"{len(mocs)} _MOC-Dateien in {projekt}, erwartet genau eine", "") moc = f"{projekt}/{mocs[0]}" zeile = f"Projekt: [[{moc[:-3]}]]" innen = teile[stelle + 1:] if innen[:2] == ["docs", "plans"] and len(innen) == 4: uebersicht = "/".join(teile[:-1]) + "/00-uebersicht.md" if not existiert(vault, uebersicht): return ("rest", "", "Planordner ohne 00-uebersicht.md", "") return ("ordner", uebersicht, zeile, moc) if innen[:2] == ["docs", "plans"] and len(innen) > 4: return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "") return ("zeile", rel, zeile, moc) def _befund(rel: str, teile: list, vault: Path) -> tuple: lauf = "/".join(teile[:-2]) for name in BERICHTE: if existiert(vault, f"{lauf}/{name}"): bericht = f"{lauf}/{name}" return ("befund", bericht, "/".join(teile[:-1]), bericht) return ("rest", "", f"kein Bericht ({' oder '.join(BERICHTE)}) in {lauf}", "") def _dump(rel: str, teile: list, vault: Path) -> tuple: treffer = _DATUM.match(teile[3]) if not treffer: return ("rest", "", "Dump ohne Datum am Anfang des Dateinamens", "") tag = treffer.group(1) if not existiert(vault, f"Inbox/{tag}.md"): return ("rest", "", f"Tagesnotiz Inbox/{tag}.md fehlt", "") return ("zeile", rel, f"Tagesnotiz: [[Inbox/{tag}]]", f"Inbox/{tag}.md") def _wurzel_docs(rel: str, teile: list, vault: Path) -> tuple: # Pläne und Specs unter docs/ an der Vault-Wurzel: Hub ist README.md dort. hub = "README.md" if not existiert(vault, hub): return ("rest", "", "README.md der Vault-Wurzel fehlt", "") if teile[1] == "plans" and len(teile) == 4: kopf = "/".join(teile[:-1]) + "/00-uebersicht.md" if not existiert(vault, kopf): return ("rest", "", "Planordner ohne 00-uebersicht.md", "") return ("ordner", kopf, f"Vault: [README]({rel_link(kopf, hub)})", hub) if teile[1] == "plans" and len(teile) > 4: return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "") return ("zeile", rel, f"Vault: [README]({rel_link(rel, hub)})", hub) def klassifiziere(rel: str, vault: Path) -> tuple: """Liefert (art, ziel, wert, hub). art ist zeile, ordner, befund oder rest.""" teile = rel.split("/") if teile[0] == "Code Projekte" and len(teile) >= 3: return _code_projekt(rel, teile, vault) if teile[0] == "IT Workspace" and "_arbeitsstand" in teile[1:-1]: return _arbeitsstand(rel, teile, vault) if teile[0] == "IT Workspace" and len(teile) >= 3 and teile[-2] == "findings": return _befund(rel, teile, vault) if teile[:2] == ["AI", "secondbrain"] and len(teile) == 4: return _dump(rel, teile, vault) if teile[0] == "docs" and len(teile) >= 2: return _wurzel_docs(rel, teile, vault) return ("rest", "", "keine Regel passt", "") def plane(vault: Path, ergebnis) -> tuple: ziele = {} rest = [] for rel in ergebnis.waisen: art, ziel_rel, wert, hub = klassifiziere(rel, vault) if art == "rest": rest.append((rel, wert)) continue ziel = ziele.setdefault(ziel_rel, Ziel(ziel_rel)) ziel.ausloeser.append(rel) ziel.hubs.add(hub) if art == "befund": ziel.befunde = wert continue if art == "ordner": ziel.ordner = ziel_rel.rsplit("/", 1)[0] if hub in ergebnis.ausgehend.get(ziel_rel, set()): continue if wert not in ziel.zeilen: ziel.zeilen.append(wert) return ziele, rest def md_dateien(vault: Path, ordner: str) -> list: pfad = vault / ordner return sorted( name for name in os.listdir(pfad) if name.lower().endswith(".md") and not name.startswith(".") and (pfad / name).is_file() ) def anhang(vault: Path, ziel: Ziel) -> list: zeilen = list(ziel.zeilen) if ziel.ordner is not None: namen = [n for n in md_dateien(vault, ziel.ordner) if n != "00-uebersicht.md"] if namen: if zeilen: zeilen.append("") zeilen += [ORDNER_KOPF, ""] zeilen += [f"- [{n}]({quote(n, safe='')})" for n in namen] ziel.gelistet += [f"{ziel.ordner}/{n}" for n in namen] if ziel.befunde is not None: namen = md_dateien(vault, ziel.befunde) if namen: if zeilen: zeilen.append("") zeilen += [BEFUND_KOPF, ""] zeilen += [f"- [[{ziel.befunde}/{n[:-3]}]]" for n in namen] ziel.gelistet += [f"{ziel.befunde}/{n}" for n in namen] return zeilen def pruefe_ziel(vault: Path, rel: str, zeilen: list) -> tuple: """Liefert (Grund zum Überspringen oder None, alter Inhalt, Zeilenende).""" alt = (vault / rel).read_bytes() try: text = alt.decode("utf-8") except UnicodeDecodeError: return "nicht als UTF-8 lesbar", alt, "" crlf = alt.count(b"\r\n") lf = alt.count(b"\n") - crlf if crlf and lf: return "gemischte Zeilenenden", alt, "" ende = "\r\n" if crlf else "\n" vorhanden = text.replace("\r\n", "\n").split("\n") for zeile in zeilen: if zeile and not zeile.startswith("- "): if zeile in vorhanden: return f"enthält schon die Zeile {zeile!r}", alt, ende return None, alt, ende def anhang_bytes(alt: bytes, zeilen: list, ende: str) -> bytes: vorne = ende if alt and not alt.endswith(b"\n") else "" return (vorne + ende + ende.join(zeilen) + ende).encode("utf-8") def repo_von(vault: Path, rel: str) -> Optional[Path]: ordner = (vault / rel).parent # Auch die Vault-Wurzel kann ein Repo sein; über ihr wird nicht gesucht. while ordner == vault or vault in ordner.parents: if (ordner / ".git").exists(): return ordner if ordner == vault: return None ordner = ordner.parent return None def git(ordner: Path, *argumente: str) -> bytes: umgebung = dict(os.environ) # Kein Index-Auffrischen: der Probelauf schreibt auch in .git nichts. umgebung["GIT_OPTIONAL_LOCKS"] = "0" try: lauf = subprocess.run( ["git", "-C", str(ordner), *argumente], capture_output=True, stdin=subprocess.DEVNULL, timeout=120, env=umgebung, ) except (OSError, subprocess.TimeoutExpired) as fehler: raise Abbruch(f"git {' '.join(argumente)} in {ordner}: {fehler}") from fehler if lauf.returncode != 0: meldung = lauf.stderr.decode("utf-8", errors="replace").strip() raise Abbruch(f"git {' '.join(argumente)} in {ordner}: Exit {lauf.returncode}: {meldung}") return lauf.stdout def geaenderte_pfade(repo: Path) -> dict: """Pfad relativ zum Repo, klein geschrieben, und wo er geändert ist.""" wo = {} liste = git(repo, "worktree", "list", "--porcelain").decode("utf-8", errors="replace") baeume = [z[len("worktree "):].strip() for z in liste.split("\n") if z.startswith("worktree ")] if not baeume: raise Abbruch("git worktree list nennt keinen Arbeitsbaum") for baum in baeume: roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=all") eintraege = roh.decode("utf-8", errors="replace").split("\0") i = 0 while i < len(eintraege): eintrag = eintraege[i] i += 1 if len(eintrag) < 4: continue wo.setdefault(eintrag[3:].lower(), f"Arbeitsbaum {baum}") if "R" in eintrag[:2] or "C" in eintrag[:2]: if i < len(eintraege) and eintraege[i]: wo.setdefault(eintraege[i].lower(), f"Arbeitsbaum {baum}") i += 1 aktuell = git(repo, "rev-parse", "--abbrev-ref", "HEAD").decode("utf-8", errors="replace").strip() zweige = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/").decode("utf-8", errors="replace") for zweig in (z.strip() for z in zweige.split("\n")): if not zweig or zweig == aktuell: continue diff = git(repo, "diff", "--name-only", "-z", f"HEAD...{zweig}", "--") for pfad in diff.decode("utf-8", errors="replace").split("\0"): if pfad: wo.setdefault(pfad.lower(), f"Zweig {zweig}") return wo def pruefe_alle(vault: Path, ziele: dict) -> tuple: repos = {} fertig = [] uebersprungen = [] for rel in sorted(ziele): ziel = ziele[rel] zeilen = anhang(vault, ziel) repo = repo_von(vault, rel) if repo is not None: if repo not in repos: try: repos[repo] = geaenderte_pfade(repo) except Abbruch as fehler: repos[repo] = str(fehler) stand = repos[repo] if isinstance(stand, str): uebersprungen.append((ziel, f"Git-Repo nicht prüfbar: {stand}")) continue innen = (vault / rel).relative_to(repo).as_posix().lower() if innen in stand: uebersprungen.append((ziel, f"in Git geändert ({stand[innen]})")) continue grund, alt, ende = pruefe_ziel(vault, rel, zeilen) if grund: uebersprungen.append((ziel, grund)) continue fertig.append((ziel, zeilen, alt, ende)) return fertig, uebersprungen def zeige(vault: Path, anwenden: bool, vorher: int, fertig: list, uebersprungen: list, rest: list, mitverbunden: int) -> None: print(f"# Bestandslauf - {'Anwenden' if anwenden else 'Probelauf'} - {vault}") print(f"Waisen außerhalb der Ausnahmen vorher: {vorher}") print() print(f"## Anhänge ({len(fertig)} Dateien)") for ziel, zeilen, _alt, ende in fertig: art = "CRLF" if ende == "\r\n" else "LF" print(f"ZIEL {ziel.rel} [{art}] wegen {len(ziel.ausloeser)} Waise(n)") for zeile in zeilen: print(f" | {zeile}") print() print(f"## Übersprungen ({len(uebersprungen)})") for ziel, grund in uebersprungen: print(f"UEBERSPRUNGEN {ziel.rel}: {grund} (betrifft {len(ziel.ausloeser)} Waise(n))") print() print(f"## Liste für den Koordinator ({len(rest)})") for rel, grund in rest: print(f"REST {rel}: {grund}") print(f"Durch Anhänge mitverbunden, ohne eigene Regel: {mitverbunden}") def sichern_und_anhaengen(vault: Path, sicherung: Path, fertig: list) -> None: sicherung.mkdir(parents=True) for ziel, _zeilen, alt, _ende in fertig: kopie = sicherung / ziel.rel kopie.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(vault / ziel.rel, kopie) if kopie.read_bytes() != alt: raise Abbruch(f"Sicherung weicht vom Original ab: {kopie}") for ziel, zeilen, alt, ende in fertig: pfad = vault / ziel.rel if pfad.read_bytes() != alt: raise Abbruch(f"{ziel.rel} hat sich seit dem Lesen geändert") neu = anhang_bytes(alt, zeilen, ende) with open(pfad, "ab") as datei: datei.write(neu) GESCHRIEBEN.append(ziel.rel) if pfad.read_bytes() != alt + neu: raise Abbruch(f"{ziel.rel}: Inhalt nach dem Anhängen weicht ab") (sicherung / "GEAENDERT.txt").write_text("\n".join(GESCHRIEBEN) + "\n", encoding="utf-8", newline="\n") def lauf(vault: Path, ausnahmen: Path, anwenden: bool, sicherung) -> int: GESCHRIEBEN.clear() if waisen is None: raise Abbruch(f"waisen.py nicht importierbar ({_IMPORTFEHLER})") sicherung_pfad = None if anwenden: if not sicherung: raise Abbruch("--anwenden braucht --sicherung mit einem Ordner außerhalb des Vaults") sicherung_pfad = Path(os.path.abspath(sicherung)).resolve() if sicherung_pfad == vault or vault in sicherung_pfad.parents: raise Abbruch(f"Sicherungsordner liegt im Vault: {sicherung_pfad}") if sicherung_pfad.exists(): raise Abbruch(f"Sicherungsordner existiert schon: {sicherung_pfad}") ergebnis = waisen.analysiere(vault, ausnahmen) ziele, rest = plane(vault, ergebnis) fertig, uebersprungen = pruefe_alle(vault, ziele) verbunden = set() for ziel, _zeilen, _alt, _ende in fertig: verbunden.add(ziel.rel) verbunden.update(ziel.hubs) verbunden.update(ziel.gelistet) anzahl = len(rest) rest = [(rel, grund) for rel, grund in rest if rel not in verbunden] zeige(vault, anwenden, len(ergebnis.waisen), fertig, uebersprungen, rest, anzahl - len(rest)) if anwenden: sichern_und_anhaengen(vault, sicherung_pfad, fertig) nachher = waisen.analysiere(vault, ausnahmen) print() print(f"Angehängt an {len(GESCHRIEBEN)} Dateien. Sicherung und GEAENDERT.txt: {sicherung_pfad}") print(f"Waisen außerhalb der Ausnahmen nachher: {len(nachher.waisen)}") return 0 def main(argv=None) -> int: zerleger = argparse.ArgumentParser(description="Hängt verwaiste Notizen einmalig an ihren Hub an") zerleger.add_argument("--vault", required=True) zerleger.add_argument("--ausnahmen", default=str(HIER / "waisen-ausnahmen.txt")) zerleger.add_argument("--anwenden", action="store_true") zerleger.add_argument("--sicherung") args = zerleger.parse_args(argv) vault = Path(os.path.abspath(args.vault)).resolve() try: return lauf(vault, Path(args.ausnahmen), args.anwenden, args.sicherung) except Exception as fehler: erwartet = isinstance(fehler, Abbruch) or (waisen is not None and isinstance(fehler, waisen.Abbruch)) art = "" if erwartet else f"{type(fehler).__name__}: " print(f"Bestandslauf - ABGEBROCHEN: {art}{fehler}") if not erwartet: print(traceback.format_exc()) if GESCHRIEBEN: print(f"Vor dem Abbruch schon angehängt ({len(GESCHRIEBEN)}), die Sicherung liegt vor:") for rel in GESCHRIEBEN: print(f" - {rel}") return 2 if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except AttributeError: pass sys.exit(main())