Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen

Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-09-17 22:38:15 +02:00
commit 2c9a4475fc
97 files changed
+14627

No files matched your search

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Scannt den Vault und erzeugt die Referenzschicht in `_index/`.
Erzeugt:
_index/vault-index.json kompakter Maschinen-Index (Kurzschlüssel, siehe
references/indexing.md) — wird von vault_query.py
gelesen, nicht direkt vom Agenten
_index/INDEX.md menschenlesbare Übersicht mit Wikilinks
_index/backlinks.json Rückwärtsverweise pro Notiz
Beispiel:
python3 scripts/build_index.py --vault ~/Vault
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
TYPE_ORDER = [
"moc", "area", "project-code", "research", "decision", "template-project",
"resource", "note-template", "inbox", "note", "archive", "asset",
]
TYPE_LABEL = {
"moc": "Maps of Content",
"area": "Areas",
"project-code": "Projekte (Code)",
"research": "Research",
"decision": "Entscheidungen",
"template-project": "Template-Projekte",
"resource": "Ressourcen",
"note-template": "Notiz-Vorlagen",
"inbox": "Inbox",
"note": "Sonstige Notizen",
"archive": "Archiv",
"asset": "Dateien",
}
def normalize_link(target: str, notes_by_key: dict) -> str | None:
"""Wikilink-Ziel auf einen Index-Pfad abbilden."""
t = target.strip().lstrip("./")
for cand in (t, t + ".md", t.replace(".md", "") + ".md"):
if cand in notes_by_key:
return notes_by_key[cand]
base = os.path.basename(t)
for cand in (base, base + ".md"):
if cand in notes_by_key:
return notes_by_key[cand]
return None
def build(vault: str, include_assets: bool, excludes) -> dict:
notes, assets = [], []
for ap, rp in V.walk_vault(vault, excludes):
ext = os.path.splitext(rp)[1].lower()
if ext == ".md":
try:
notes.append(V.read_note(ap, rp))
except OSError as exc:
print(f" übersprungen: {rp} ({exc})", file=sys.stderr)
elif include_assets and ext in V.ASSET_EXTS:
assets.append({
"p": rp,
"t": "asset",
"n": os.path.basename(rp),
"r": rp.split("/")[1] if rp.startswith("10_Projects/") and rp.count("/") > 1 else "",
"w": os.path.getsize(ap),
})
notes.sort(key=lambda n: n["p"])
assets.sort(key=lambda n: n["p"])
all_entries = notes + assets
lookup = {}
for n in notes:
lookup[n["p"]] = n["p"]
lookup[os.path.basename(n["p"])] = n["p"]
lookup[os.path.splitext(n["p"])[0]] = n["p"]
lookup[os.path.splitext(os.path.basename(n["p"]))[0]] = n["p"]
if n.get("n"):
lookup.setdefault(n["n"], n["p"])
if n.get("u"):
lookup.setdefault(n["u"], n["p"])
backlinks = defaultdict(list)
broken = 0
for n in notes:
for raw in n.get("l", []):
tgt = normalize_link(raw, lookup)
if tgt:
if n["p"] not in backlinks[tgt]:
backlinks[tgt].append(n["p"])
else:
broken += 1
meta = {
"v": 1,
"generated": V.iso_now(),
"vault": os.path.abspath(vault),
"notes": len(notes),
"assets": len(assets),
"words": sum(n.get("w", 0) for n in notes),
"types": dict(Counter(n.get("t", "note") for n in notes).most_common()),
"status": dict(Counter(n["s"] for n in notes if n.get("s")).most_common()),
"tags": dict(Counter(t for n in notes for t in n.get("g", [])).most_common(60)),
"broken_links": broken,
}
return {"meta": meta, "notes": all_entries, "backlinks": dict(backlinks)}
def render_markdown(index: dict) -> str:
meta = index["meta"]
by_type = defaultdict(list)
for n in index["notes"]:
by_type[n.get("t", "note")].append(n)
out = [
"---",
"type: moc",
"title: Vault-Index",
f"summary: Generierte Übersicht aller Notizen ({meta['notes']} Stück). "
"Nicht von Hand bearbeiten.",
f"updated: {V.today()}",
"---",
"",
"# Vault-Index",
"",
f"> [!info] Generiert am {meta['generated']} · {meta['notes']} Notizen · "
f"{meta['assets']} Dateien",
"> Diese Datei wird von `build_index.py` erzeugt. Änderungen gehen verloren.",
"",
]
if meta["notes"] > 200:
out += [
"> [!warning] Großer Vault",
"> Diese Datei nicht komplett lesen. Stattdessen gezielt abfragen:",
"> `python3 scripts/vault_query.py --text \"…\"`",
"",
]
ordered = [t for t in TYPE_ORDER if t in by_type]
ordered += sorted(t for t in by_type if t not in TYPE_ORDER)
for t in ordered:
entries = by_type[t]
out.append(f"## {TYPE_LABEL.get(t, t)} ({len(entries)})")
out.append("")
if t == "asset":
for n in entries[:200]:
out.append(f"- `{n['p']}`")
if len(entries) > 200:
out.append(f"- … {len(entries) - 200} weitere")
out.append("")
continue
for n in entries:
link = os.path.splitext(n["p"])[0]
line = f"- [[{link}|{n.get('n', link)}]]"
bits = []
if n.get("s"):
bits.append(f"`{n['s']}`")
if n.get("g"):
bits.append(" ".join(f"#{g}" for g in n["g"][:5]))
if bits:
line += " " + " ".join(bits)
if n.get("m"):
line += f" — {n['m']}"
out.append(line)
out.append("")
if meta["tags"]:
out.append("## Top-Tags")
out.append("")
out.append(" ".join(f"#{t} ({c})" for t, c in list(meta["tags"].items())[:30]))
out.append("")
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description="Referenzschicht für den Vault bauen")
ap.add_argument("--vault", default=".", help="Pfad zum Vault (Standard: aktuelles Verzeichnis)")
ap.add_argument("--no-assets", action="store_true", help="Nicht-Markdown-Dateien nicht indexieren")
ap.add_argument("--exclude", action="append", default=[], help="Zusätzlicher Ordnername zum Überspringen (mehrfach möglich)")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
if not os.path.isdir(vault):
print(f"Vault-Pfad existiert nicht: {vault}", file=sys.stderr)
return 2
index = build(vault, not args.no_assets, args.exclude)
outdir = os.path.join(vault, V.INDEX_DIR)
os.makedirs(outdir, exist_ok=True)
with open(os.path.join(outdir, V.INDEX_JSON), "w", encoding="utf-8") as fh:
json.dump({"meta": index["meta"], "notes": index["notes"]}, fh,
ensure_ascii=False, separators=(",", ":"))
with open(os.path.join(outdir, "backlinks.json"), "w", encoding="utf-8") as fh:
json.dump(index["backlinks"], fh, ensure_ascii=False, separators=(",", ":"))
with open(os.path.join(outdir, V.INDEX_MD), "w", encoding="utf-8") as fh:
fh.write(render_markdown(index))
if not args.quiet:
m = index["meta"]
print(f"Index gebaut: {m['notes']} Notizen, {m['assets']} Dateien, "
f"{m['words']} Wörter")
print("Typen: " + ", ".join(f"{k}={v}" for k, v in m["types"].items()))
missing = [n["p"] for n in index["notes"]
if n.get("t") != "asset" and not n.get("m")]
if missing:
print(f"Ohne summary: {len(missing)} Notizen "
f"(vault_doctor.py zeigt welche)")
if m["broken_links"]:
print(f"Tote Wikilinks: {m['broken_links']} (vault_doctor.py --links)")
print(f"Geschrieben nach {os.path.join(V.INDEX_DIR, '')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Legt eine neue Notiz aus einem Template an — mit korrektem Frontmatter,
korrektem Ordner und stabiler uid.
Von Hand geschriebenes Frontmatter driftet; deshalb dieser Weg. Bestehende
Dateien werden nie überschrieben.
Beispiele:
python3 scripts/new_note.py --type project-code --title "Auth Service" \\
--tags go,oidc --summary "OIDC-Auth für die interne Toolchain"
python3 scripts/new_note.py --type research --title "Rate Limiting Strategien" \\
--project auth-service
python3 scripts/new_note.py --type decision --title "Postgres statt Mongo" \\
--project auth-service
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE_DIR = os.path.join(SKILL_DIR, "assets", "templates")
# type -> (Zielpfad-Muster, eigener Ordner?)
TARGETS = {
"project-code": ("10_Projects/{slug}/index.md", True),
"template-project": ("40_Templates/project-templates/{slug}/index.md", True),
"research": ("30_Research/{slug}.md", False),
"area": ("20_Areas/{slug}.md", False),
"resource": ("50_Resources/{slug}.md", False),
"moc": ("20_Areas/{slug}.md", False),
"decision": ("10_Projects/{project}/decisions/{slug}.md", False),
"inbox": ("00_Inbox/{slug}.md", False),
}
DEFAULT_STATUS = {
"project-code": "active", "research": "active", "area": "reference",
"resource": "reference", "moc": "reference", "decision": "done",
"template-project": "reference", "inbox": "idea",
}
def main() -> int:
ap = argparse.ArgumentParser(description="Notiz aus Template anlegen")
ap.add_argument("--vault", default=".")
ap.add_argument("--type", required=True, choices=sorted(TARGETS))
ap.add_argument("--title", required=True)
ap.add_argument("--summary", default="")
ap.add_argument("--tags", default="")
ap.add_argument("--status", default="")
ap.add_argument("--project", default="", help="Projekt-Slug (Pflicht bei --type decision)")
ap.add_argument("--out", default="", help="Zielpfad relativ zum Vault überschreiben")
ap.add_argument("--print-only", action="store_true", help="Inhalt ausgeben statt schreiben")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
slug = V.slugify(args.title)
pattern, _ = TARGETS[args.type]
if args.out:
rel = args.out
else:
if "{project}" in pattern and not args.project:
print("--project ist bei diesem Typ erforderlich", file=sys.stderr)
return 2
rel = pattern.format(slug=slug, project=V.slugify(args.project or "misc"))
dest = os.path.join(vault, rel)
if os.path.exists(dest) and not args.print_only:
print(f"Existiert bereits: {rel} — nichts geschrieben.", file=sys.stderr)
return 1
tpl_path = os.path.join(TEMPLATE_DIR, f"{args.type}.md")
if not os.path.exists(tpl_path):
tpl_path = os.path.join(TEMPLATE_DIR, "resource.md")
with open(tpl_path, "r", encoding="utf-8") as fh:
tpl = fh.read()
today = V.today()
values = {
"{{uid}}": f"{today.replace('-', '')}-{slug[:40]}",
"{{title}}": args.title,
"{{summary}}": args.summary or "TODO: ein Satz, worum es geht.",
"{{status}}": args.status or DEFAULT_STATUS.get(args.type, "active"),
"{{tags}}": ", ".join(V.as_list(args.tags)),
"{{project}}": args.project,
"{{date}}": today,
"{{slug}}": slug,
}
content = tpl
for k, v in values.items():
content = content.replace(k, v)
if args.print_only:
print(content)
return 0
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w", encoding="utf-8") as fh:
fh.write(content)
print(rel)
print("Angelegt. Danach: summary prüfen, Referenzen ergänzen, "
"build_index.py laufen lassen.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Prüft den Vault auf alles, was die Referenzschicht unbrauchbar macht.
Standard ist ein reiner Bericht. `--fix-missing --apply` ergänzt ausschließlich
fehlende Frontmatter-Felder (uid, type, created, updated, status) und fasst
nie bestehende Werte oder Fließtext an.
Beispiele:
python3 scripts/vault_doctor.py --vault ~/Vault
python3 scripts/vault_doctor.py --stale-days 180
python3 scripts/vault_doctor.py --fix-missing # Trockenlauf
python3 scripts/vault_doctor.py --fix-missing --apply
"""
from __future__ import annotations
import argparse
import os
import sys
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
REQUIRED = ["type", "title", "status", "summary", "updated"]
def collect(vault: str):
notes = []
for ap, rp in V.walk_vault(vault):
if rp.endswith(".md"):
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
fm, body = V.parse_frontmatter(text)
notes.append({"abs": ap, "rel": rp, "fm": fm, "body": body, "raw": text})
return notes
def section(title: str, items, formatter=str, limit=25):
if not items:
return []
out = [f"\n## {title} ({len(items)})"]
for it in items[:limit]:
out.append(" - " + formatter(it))
if len(items) > limit:
out.append(f" … {len(items) - limit} weitere")
return out
def main() -> int:
ap = argparse.ArgumentParser(description="Vault-Gesundheitscheck")
ap.add_argument("--vault", default=".")
ap.add_argument("--stale-days", type=int, default=365)
ap.add_argument("--inbox-days", type=int, default=14)
ap.add_argument("--fix-missing", action="store_true",
help="fehlende Frontmatter-Felder ergänzen (Trockenlauf ohne --apply)")
ap.add_argument("--apply", action="store_true", help="Änderungen wirklich schreiben")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
notes = collect(vault)
if not notes:
print("Keine Markdown-Dateien gefunden.")
return 1
lookup = set()
for n in notes:
rel = n["rel"]
lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel),
os.path.splitext(os.path.basename(rel))[0]}
if n["fm"].get("title"):
lookup.add(str(n["fm"]["title"]))
if n["fm"].get("uid"):
lookup.add(str(n["fm"]["uid"]))
no_fm, missing_fields, bad_type, bad_status = [], [], [], []
broken, dup_uid, dup_title, stale, old_inbox = [], defaultdict(list), defaultdict(list), [], []
inbound = defaultdict(int)
import datetime as dt
today = dt.date.today()
for n in notes:
fm, rel = n["fm"], n["rel"]
if not fm:
no_fm.append(rel)
else:
miss = [f for f in REQUIRED if not str(fm.get(f, "")).strip()]
if miss:
missing_fields.append((rel, miss))
t = str(fm.get("type", "")).strip()
if t and t not in V.KNOWN_TYPES:
bad_type.append((rel, t))
s = str(fm.get("status", "")).strip()
if s and s not in V.KNOWN_STATUS:
bad_status.append((rel, s))
if fm.get("uid"):
dup_uid[str(fm["uid"])].append(rel)
title = V.derive_title(fm, n["body"], rel)
dup_title[title.lower()].append(rel)
for raw in V.extract_links(n["body"]):
t = raw.strip().lstrip("./")
if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup:
inbound[t] += 1
else:
broken.append((rel, raw))
mtime = dt.date.fromtimestamp(os.path.getmtime(n["abs"]))
upd = str(fm.get("updated", "")).strip()
try:
ref = dt.date.fromisoformat(upd[:10]) if upd else mtime
except ValueError:
ref = mtime
age = (today - ref).days
if age > args.stale_days:
stale.append((rel, age))
if rel.startswith("00_Inbox/") and age > args.inbox_days:
old_inbox.append((rel, age))
linked = set()
for n in notes:
for raw in V.extract_links(n["body"]):
t = raw.strip().lstrip("./")
for cand in (t, t + ".md", os.path.basename(t), os.path.basename(t) + ".md"):
linked.add(cand)
orphans = [
n["rel"] for n in notes
if not (set([n["rel"], os.path.splitext(n["rel"])[0],
os.path.basename(n["rel"]),
os.path.splitext(os.path.basename(n["rel"]))[0]]) & linked)
and not n["rel"].startswith(("_index/", "40_Templates/"))
]
report = [f"# Vault-Doctor — {len(notes)} Notizen in {vault}"]
report += section("Ohne Frontmatter", no_fm)
report += section("Pflichtfelder fehlen", missing_fields,
lambda x: f"{x[0]} → fehlt: {', '.join(x[1])}")
report += section("Unbekannter type", bad_type, lambda x: f"{x[0]} → '{x[1]}'")
report += section("Unbekannter status", bad_status, lambda x: f"{x[0]} → '{x[1]}'")
report += section("Tote Wikilinks", broken, lambda x: f"{x[0]} → [[{x[1]}]]")
report += section("Doppelte uid", [(k, v) for k, v in dup_uid.items() if len(v) > 1],
lambda x: f"{x[0]}: {', '.join(x[1])}")
report += section("Doppelte Titel", [(k, v) for k, v in dup_title.items() if len(v) > 1],
lambda x: f"{x[0]}: {', '.join(x[1])}")
report += section("Verwaist (keine eingehenden Links)", orphans)
report += section(f"Liegt länger als {args.inbox_days} Tage in der Inbox", old_inbox,
lambda x: f"{x[0]} ({x[1]} Tage)")
report += section(f"Seit über {args.stale_days} Tagen unangetastet", stale,
lambda x: f"{x[0]} ({x[1]} Tage)")
problems = (len(no_fm) + len(missing_fields) + len(bad_type) + len(bad_status)
+ len(broken) + len(orphans))
if problems == 0:
report.append("\nKeine strukturellen Probleme gefunden.")
else:
report.append(f"\nBefunde gesamt: {problems}")
print("\n".join(report))
if args.fix_missing:
changed = []
for n in notes:
fm, rel = dict(n["fm"]), n["rel"]
before = dict(fm)
if not str(fm.get("type", "")).strip():
fm["type"] = V.infer_type(rel)
if not str(fm.get("title", "")).strip():
fm["title"] = V.derive_title(n["fm"], n["body"], rel)
if not str(fm.get("uid", "")).strip():
import datetime as _d
stamp = _d.date.fromtimestamp(os.path.getmtime(n["abs"])).strftime("%Y%m%d")
fm["uid"] = f"{stamp}-{V.slugify(fm['title'])[:40]}"
if not str(fm.get("status", "")).strip():
if rel.startswith("90_Archive/"):
fm["status"] = "archived"
elif rel.startswith("00_Inbox/"):
fm["status"] = "idea"
else:
fm["status"] = "active"
if not str(fm.get("summary", "")).strip():
s = V.derive_summary(n["body"])
if s:
fm["summary"] = s
if not str(fm.get("created", "")).strip():
import datetime as _d
fm["created"] = _d.date.fromtimestamp(os.path.getmtime(n["abs"])).isoformat()
if not str(fm.get("updated", "")).strip():
fm["updated"] = fm["created"]
ordered = {}
for k in ("uid", "type", "title", "status", "summary", "tags",
"project", "created", "updated"):
if k in fm:
ordered[k] = fm[k]
for k, v in fm.items():
ordered.setdefault(k, v)
if ordered != before:
changed.append((n, ordered))
print(f"\n## Frontmatter-Ergänzungen: {len(changed)} Datei(en)")
for n, fm in changed[:15]:
added = [k for k in fm if k not in n["fm"]]
print(f" - {n['rel']}: + {', '.join(added) or 'Feldreihenfolge'}")
if len(changed) > 15:
print(f" … {len(changed) - 15} weitere")
if not args.apply:
print("\n(Trockenlauf — mit --apply schreiben)")
else:
for n, fm in changed:
body = n["body"] if n["fm"] else n["raw"]
with open(n["abs"], "w", encoding="utf-8") as fh:
fh.write(V.dump_frontmatter(fm) + "\n" + body.lstrip("\n"))
print(f"\n{len(changed)} Datei(en) geschrieben. "
"Jetzt build_index.py erneut laufen lassen.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Fragt die Referenzschicht ab und gibt kompakte Treffer aus.
Das ist der Standardeinstieg in den Vault: erst hier suchen, dann nur die
Treffer öffnen. Nie `vault-index.json` am Stück lesen.
Beispiele:
python3 scripts/vault_query.py --text "rate limiting"
python3 scripts/vault_query.py --type project-code --status active
python3 scripts/vault_query.py --tag k8s --since 30 --format md
python3 scripts/vault_query.py --links-to 10_Projects/auth-service/index.md
python3 scripts/vault_query.py --orphans --type research
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
def matches_text(note: dict, needles) -> bool:
hay = " ".join([
note.get("n", ""), note.get("m", ""), note.get("p", ""),
" ".join(note.get("g", [])), " ".join(note.get("h", [])),
note.get("r", ""), note.get("u", ""),
]).lower()
return all(n in hay for n in needles)
def days_ago(iso_date: str) -> int | None:
import datetime as dt
try:
d = dt.date.fromisoformat(iso_date[:10])
except (ValueError, TypeError):
return None
return (dt.date.today() - d).days
def fmt_compact(n: dict) -> str:
bits = [n["p"], n.get("n", "")]
tail = n.get("m", "")
meta = []
if n.get("t"):
meta.append(n["t"])
if n.get("s"):
meta.append(n["s"])
head = " :: ".join(b for b in bits if b)
if meta:
head += f" [{'/'.join(meta)}]"
return head + (f" :: {tail}" if tail else "")
def fmt_md(n: dict) -> str:
link = os.path.splitext(n["p"])[0]
line = f"- [[{link}|{n.get('n', link)}]]"
if n.get("s"):
line += f" `{n['s']}`"
if n.get("m"):
line += f" — {n['m']}"
return line
def main() -> int:
ap = argparse.ArgumentParser(description="Vault-Index abfragen")
ap.add_argument("--vault", default=".")
ap.add_argument("--text", action="append", default=[],
help="Suchbegriff in Titel/Summary/Pfad/Tags/Überschriften (mehrfach = UND)")
ap.add_argument("--type", action="append", default=[])
ap.add_argument("--status", action="append", default=[])
ap.add_argument("--tag", action="append", default=[])
ap.add_argument("--project", default=None)
ap.add_argument("--path", default=None, help="Pfadpräfix, z. B. 30_Research")
ap.add_argument("--since", type=int, default=None, help="nur Notizen, die in den letzten N Tagen aktualisiert wurden")
ap.add_argument("--stale", type=int, default=None, help="nur Notizen, die seit N Tagen nicht angefasst wurden")
ap.add_argument("--links-to", default=None, help="Notizen, die auf dieses Ziel verlinken (Pfad oder uid)")
ap.add_argument("--orphans", action="store_true", help="nur Notizen ohne eingehende Links")
ap.add_argument("--no-summary", action="store_true", help="nur Notizen ohne summary (Pflegeliste)")
ap.add_argument("--limit", type=int, default=40)
ap.add_argument("--format", choices=["compact", "paths", "md", "json", "headings"],
default="compact")
ap.add_argument("--sort", choices=["path", "updated", "title"], default="path")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
index = V.load_index(vault)
notes = index["notes"]
meta = index.get("meta", {})
backlinks = {}
bl_path = os.path.join(vault, V.INDEX_DIR, "backlinks.json")
if os.path.exists(bl_path):
with open(bl_path, "r", encoding="utf-8") as fh:
backlinks = json.load(fh)
needles = [t.lower() for t in args.text]
types = {t.lower() for t in args.type}
stati = {s.lower() for s in args.status}
tags = {t.lower().lstrip("#") for t in args.tag}
hits = []
for n in notes:
if types and n.get("t", "").lower() not in types:
continue
if stati and n.get("s", "").lower() not in stati:
continue
if tags and not tags.issubset({g.lower().lstrip("#") for g in n.get("g", [])}):
continue
if args.project and n.get("r", "").lower() != args.project.lower():
continue
if args.path and not n["p"].startswith(args.path.rstrip("/")):
continue
if needles and not matches_text(n, needles):
continue
is_asset = n.get("t") == "asset"
if args.no_summary and (n.get("m") or (is_asset and "asset" not in types)):
continue
age = days_ago(n.get("d", ""))
if args.since is not None and (age is None or age > args.since):
continue
if args.stale is not None and (age is None or age < args.stale):
continue
if args.orphans and (backlinks.get(n["p"]) or (is_asset and "asset" not in types)):
continue
if args.links_to:
target = args.links_to
resolved = None
for cand in notes:
if target in (cand.get("p"), cand.get("u"), cand.get("n")):
resolved = cand["p"]
break
resolved = resolved or target
if n["p"] not in backlinks.get(resolved, []):
continue
hits.append(n)
if args.sort == "updated":
hits.sort(key=lambda n: n.get("d", ""), reverse=True)
elif args.sort == "title":
hits.sort(key=lambda n: n.get("n", "").lower())
total = len(hits)
hits = hits[: args.limit]
if args.format == "json":
print(json.dumps(hits, ensure_ascii=False, indent=1))
elif args.format == "paths":
for n in hits:
print(n["p"])
elif args.format == "md":
for n in hits:
print(fmt_md(n))
elif args.format == "headings":
for n in hits:
print(fmt_compact(n))
for h in n.get("h", []):
print(f" # {h}")
else:
for n in hits:
print(fmt_compact(n))
if not hits:
print("(keine Treffer — Filter lockern oder als Fallback grep über den Vault)",
file=sys.stderr)
if total > len(hits):
print(f"… {total - len(hits)} weitere Treffer (--limit erhöhen)", file=sys.stderr)
newer = V.count_newer_than(vault, meta.get("generated", ""))
if newer:
print(f"[Hinweis] {newer} Notiz(en) sind neuer als der Index — "
"build_index.py erneut laufen lassen.", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,323 @@
"""Gemeinsame Helfer für den obsidian-vault-navigator.
Bewusst ohne externe Abhängigkeiten (kein PyYAML), damit die Skripte überall
laufen: lokal, in Claude Code, in CI. Der YAML-Parser deckt genau die Teilmenge
ab, die Obsidian-Properties benutzen: Skalare, Inline-Listen und Block-Listen.
"""
from __future__ import annotations
import datetime as _dt
import json
import os
import re
import unicodedata
# --- Konfiguration -----------------------------------------------------------
INDEX_DIR = "_index"
INDEX_JSON = "vault-index.json"
INDEX_MD = "INDEX.md"
SKIP_DIRS = {
".obsidian", ".git", ".trash", ".claude", ".agents", ".venv",
"node_modules", "__pycache__", ".svelte-kit", "dist", "build",
INDEX_DIR,
}
ASSET_EXTS = {
".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".csv", ".json",
".yaml", ".yml", ".toml", ".png", ".jpg", ".jpeg", ".svg", ".webp",
".canvas", ".base", ".sql", ".sh", ".py", ".ts", ".js", ".go", ".rs",
}
# Pfadpräfix -> Typ. Frontmatter `type` gewinnt immer gegen diese Ableitung.
FOLDER_TYPES = [
("40_Templates/note-templates", "note-template"),
("40_Templates/project-templates", "template-project"),
("40_Templates", "note-template"),
("00_Inbox", "inbox"),
("10_Projects", "project-code"),
("20_Areas", "area"),
("30_Research", "research"),
("50_Resources", "resource"),
("90_Archive", "archive"),
]
KNOWN_TYPES = {
"project-code", "template-project", "note-template", "research", "area",
"resource", "moc", "decision", "inbox", "archive", "asset", "note",
}
KNOWN_STATUS = {"idea", "active", "paused", "done", "archived", "reference"}
SUMMARY_MAX = 160
MAX_HEADINGS = 12
MAX_LINKS = 40
# --- Kleiner YAML-Parser -----------------------------------------------------
_FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", re.DOTALL)
_WIKILINK_RE = re.compile(r"\[\[([^\]\|#\^]+)")
_MDLINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)")
_HEADING_RE = re.compile(r"^(#{1,3})\s+(.+?)\s*$", re.MULTILINE)
def _scalar(raw: str):
v = raw.strip()
if not v:
return ""
if v[0] in "\"'" and len(v) > 1 and v[-1] == v[0]:
return v[1:-1]
if v.startswith("[") and v.endswith("]"):
inner = v[1:-1].strip()
if not inner:
return []
return [_scalar(x) for x in _split_inline(inner)]
low = v.lower()
if low in ("true", "yes"):
return True
if low in ("false", "no"):
return False
if low in ("null", "~", "none"):
return None
if re.fullmatch(r"-?\d+", v):
try:
return int(v)
except ValueError:
return v
return v
def _split_inline(s: str):
out, buf, quote = [], "", None
for ch in s:
if quote:
if ch == quote:
quote = None
buf += ch
elif ch in "\"'":
quote = ch
buf += ch
elif ch == ",":
out.append(buf)
buf = ""
else:
buf += ch
if buf.strip():
out.append(buf)
return [x for x in (i.strip() for i in out) if x]
def parse_frontmatter(text: str):
"""Gibt (dict, body) zurück. Ohne Frontmatter: ({}, text)."""
m = _FM_RE.match(text)
if not m:
return {}, text
block, body = m.group(1), text[m.end():]
data, key, pending = {}, None, None
for line in block.split("\n"):
if not line.strip() or line.lstrip().startswith("#"):
continue
stripped = line.strip()
if stripped.startswith("- ") and key is not None:
if pending is None:
pending = []
data[key] = pending
pending.append(_scalar(stripped[2:]))
continue
if ":" in line and not line.startswith(" "):
k, _, v = line.partition(":")
key = k.strip()
pending = None
val = _scalar(v)
data[key] = val
if val == "":
pending = None
return data, body
def dump_frontmatter(data: dict) -> str:
lines = ["---"]
for k, v in data.items():
if isinstance(v, list):
inner = ", ".join(str(x) for x in v)
lines.append(f"{k}: [{inner}]")
elif isinstance(v, bool):
lines.append(f"{k}: {'true' if v else 'false'}")
elif v is None:
lines.append(f"{k}:")
else:
s = str(v)
if ":" in s or s.strip() != s:
s = '"' + s.replace('"', "'") + '"'
lines.append(f"{k}: {s}")
lines.append("---")
return "\n".join(lines) + "\n"
# --- Ableitungen -------------------------------------------------------------
def slugify(text: str) -> str:
text = unicodedata.normalize("NFKD", str(text))
text = text.replace("ß", "ss")
text = "".join(c for c in text if not unicodedata.combining(c))
text = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower()
return text or "note"
def infer_type(rel_path: str) -> str:
p = rel_path.replace(os.sep, "/")
for prefix, t in FOLDER_TYPES:
if p.startswith(prefix + "/") or p == prefix:
return t
return "note"
def clean_inline(text: str) -> str:
text = re.sub(r"!?\[\[([^\]\|]*\|)?([^\]]+)\]\]", r"\2", text)
text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_`>]+", "", text)
return re.sub(r"\s+", " ", text).strip()
def derive_summary(body: str) -> str:
for para in re.split(r"\n\s*\n", body):
para = para.strip()
if not para or para.startswith("#") or para.startswith("---"):
continue
if para.startswith(("- ", "* ", "|", "```", ">")):
continue
s = clean_inline(para)
if not s:
continue
if len(s) > SUMMARY_MAX:
s = s[: SUMMARY_MAX - 1].rsplit(" ", 1)[0] + "…"
return s
return ""
def derive_title(fm: dict, body: str, rel_path: str) -> str:
if fm.get("title"):
return str(fm["title"])
m = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
if m:
return clean_inline(m.group(1))
return os.path.splitext(os.path.basename(rel_path))[0]
def extract_headings(body: str):
out = []
for m in _HEADING_RE.finditer(body):
h = clean_inline(m.group(2))
if h and h not in out:
out.append(h[:60])
if len(out) >= MAX_HEADINGS:
break
return out
def extract_links(body: str):
out = []
for m in _WIKILINK_RE.finditer(body):
t = m.group(1).strip()
if t and t not in out:
out.append(t)
for m in _MDLINK_RE.finditer(body):
t = m.group(1).strip()
if t and t not in out:
out.append(t)
return out[:MAX_LINKS]
def as_list(v):
if v is None or v == "":
return []
if isinstance(v, list):
return [str(x) for x in v if str(x).strip()]
return [x.strip() for x in str(v).replace(";", ",").split(",") if x.strip()]
def today() -> str:
return _dt.date.today().isoformat()
def iso_now() -> str:
return _dt.datetime.now().replace(microsecond=0).isoformat()
# --- Vault-Scan --------------------------------------------------------------
def walk_vault(vault: str, extra_excludes=()):
"""Liefert (abs_path, rel_path) für alle Dateien außerhalb der Skip-Ordner."""
excludes = set(SKIP_DIRS) | set(extra_excludes)
for root, dirs, files in os.walk(vault):
dirs[:] = [
d for d in dirs
if d not in excludes and not d.startswith(".")
]
for f in sorted(files):
if f.startswith("."):
continue
ap = os.path.join(root, f)
rp = os.path.relpath(ap, vault).replace(os.sep, "/")
yield ap, rp
def read_note(abs_path: str, rel_path: str) -> dict:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
fm, body = parse_frontmatter(text)
ntype = str(fm.get("type") or "").strip() or infer_type(rel_path)
summary = str(fm.get("summary") or "").strip() or derive_summary(body)
if len(summary) > SUMMARY_MAX:
summary = summary[: SUMMARY_MAX - 1] + "…"
mtime = _dt.date.fromtimestamp(os.path.getmtime(abs_path)).isoformat()
project = str(fm.get("project") or "").strip()
if not project and rel_path.startswith("10_Projects/"):
parts = rel_path.split("/")
if len(parts) > 2:
project = parts[1]
note = {
"p": rel_path,
"u": str(fm.get("uid") or "").strip(),
"t": ntype,
"s": str(fm.get("status") or "").strip(),
"n": derive_title(fm, body, rel_path),
"m": summary,
"g": as_list(fm.get("tags")),
"r": project,
"c": str(fm.get("created") or "").strip(),
"d": str(fm.get("updated") or "").strip() or mtime,
"h": extract_headings(body),
"l": extract_links(body),
"w": len(body.split()),
}
return {k: v for k, v in note.items() if v not in ("", [], None)}
def load_index(vault: str) -> dict:
path = os.path.join(vault, INDEX_DIR, INDEX_JSON)
if not os.path.exists(path):
raise SystemExit(
f"Kein Index gefunden unter {path}.\n"
"Zuerst bauen: python3 scripts/build_index.py --vault "
f"{vault}"
)
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def count_newer_than(vault: str, iso_ts: str) -> int:
"""Wie viele .md-Dateien wurden nach dem Indexlauf geändert?"""
try:
ts = _dt.datetime.fromisoformat(iso_ts).timestamp()
except (ValueError, TypeError):
return 0
n = 0
for ap, rp in walk_vault(vault):
if rp.endswith(".md") and os.path.getmtime(ap) > ts + 1:
n += 1
return n