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 c064c729ed
82 files changed
+13428

No files matched your search

@@ -0,0 +1,110 @@
# Frontmatter-Schema
Obsidian nennt Frontmatter „Properties". Es ist YAML zwischen zwei `---`-Zeilen
am Dateianfang. Der Index liest genau diese Felder — was hier fehlt, muss der
Agent sich durch Öffnen der Datei erarbeiten, und genau das kostet Kontext.
## Pflichtfelder (alle Notiztypen)
| Feld | Typ | Bedeutung |
|---|---|---|
| `uid` | String | Stabile ID, Format `YYYYMMDD-slug`. Überlebt Umbenennen und Verschieben; Verweise in Skripten hängen daran. |
| `type` | Enum | Steuert Gruppierung, Templates und Abfragen. Gewinnt gegen die Ableitung aus dem Ordner. |
| `title` | String | Anzeigename. Fehlt er, nimmt der Index die erste `#`-Überschrift, sonst den Dateinamen. |
| `status` | Enum | Lebenszyklus, siehe unten. |
| `summary` | String | **Ein Satz, max. 160 Zeichen.** Worum geht es und wofür macht man die Notiz auf. |
| `tags` | Liste | Kleingeschrieben, ohne `#`, Technologie/Thema. 3–6 Stück, nicht 20. |
| `created` | Datum | `YYYY-MM-DD` |
| `updated` | Datum | `YYYY-MM-DD`, bei inhaltlichen Änderungen mitziehen. |
### Erlaubte `type`-Werte
`project-code`, `template-project`, `note-template`, `research`, `area`,
`resource`, `moc`, `decision`, `inbox`, `archive`
Neue Typen nur bewusst einführen und dann in `scripts/vaultlib.py` unter
`KNOWN_TYPES` eintragen, sonst meldet der Doctor sie als unbekannt.
### Erlaubte `status`-Werte
| Wert | Bedeutung |
|---|---|
| `idea` | notiert, noch nicht begonnen |
| `active` | wird gerade bearbeitet |
| `paused` | bewusst liegengelassen, kommt zurück |
| `done` | abgeschlossen, noch am Platz |
| `archived` | abgeschlossen und nach `90_Archive/` verschoben |
| `reference` | Dauerhaftes ohne Fortschritt (Areas, Ressourcen, Templates) |
## Zusätzliche Felder je Typ
### `project-code`
| Feld | Beispiel | Zweck |
|---|---|---|
| `repo` | `[email protected]:me/auth-service.git` | Sprung ins Repo ohne Suche |
| `lang` | `go` | Hauptsprache |
| `stack` | `[postgres, keycloak, docker]` | Umgebung auf einen Blick |
| `template_of` | `go-service-starter` | aus welchem Template-Projekt entstanden |
| `area` | `infrastruktur` | zugehöriger Dauerbereich |
### `research`
| Feld | Beispiel | Zweck |
|---|---|---|
| `question` | `Welche Rate-Limiting-Strategie passt zu Gateway X?` | die eine Frage |
| `sources` | `[https://…, "Buch, S. 40"]` | Belege |
| `confidence` | `low` / `medium` / `high` | wie belastbar die Antwort ist |
| `project` | `auth-service` | auslösendes Projekt |
### `template-project`
| Feld | Beispiel | Zweck |
|---|---|---|
| `use_when` | `Neuer Go-Service mit HTTP-API und Postgres` | Auswahl ohne Öffnen |
| `stack` | `[go, postgres, docker]` | |
| `template_of` | — | bleibt leer; wird in den abgeleiteten Projekten gesetzt |
### `decision` (ADR)
| Feld | Beispiel |
|---|---|
| `project` | `auth-service` |
| `decided` | `2026-08-03` |
| `supersedes` | `20260101-mongo-als-store` |
### `area`, `resource`, `moc`, `note-template`
Nur die Pflichtfelder. Bei `moc` ist `summary` besonders wichtig, weil MOCs
Einstiegspunkte sind.
## Beispiel
```yaml
---
uid: 20260803-auth-service
type: project-code
title: Auth Service
status: active
summary: OIDC-Auth-Service für die interne Toolchain, Go + Keycloak, löst die verteilten Basic-Auth-Setups ab.
tags: [go, oidc, keycloak, infrastruktur]
repo: [email protected]:me/auth-service.git
lang: go
stack: [postgres, keycloak, docker]
template_of: go-service-starter
area: infrastruktur
created: 2026-08-03
updated: 2026-08-03
---
```
## Was nicht ins Frontmatter gehört
- Fließtext oder mehrzeilige Beschreibungen — dafür ist der Body da.
- Werte, die sich täglich ändern (Fortschritt in Prozent, laufende Zahlen).
- Secrets, Tokens, Passwörter, Zugangsdaten. Nie, auch nicht „nur kurz".
- Duplikate von Wikilinks — Links stehen im Body unter `## Referenzen`.
## Massenpflege
`vault_doctor.py --fix-missing` ergänzt fehlende Felder konservativ: `type` aus
dem Ordner, `title` aus der ersten Überschrift, `summary` aus dem ersten Absatz,
`uid` aus Datum + Slug. Es überschreibt nie vorhandene Werte und rührt den Body
nicht an. Standard ist ein Trockenlauf; erst `--apply` schreibt. Danach die
generierten `summary`-Werte durchsehen — automatisch abgeleitete Sätze sind
brauchbar, aber selten so gut wie ein bewusst geschriebener.
@@ -0,0 +1,128 @@
# Die Referenzschicht
## Warum überhaupt ein Index
Ein Agent, der eine Frage an einen Vault mit 500 Notizen stellt, hat drei
Möglichkeiten: alles lesen (sprengt den Kontext), grep (findet Wortformen, aber
nicht Bedeutung, und liefert Treffer ohne Einordnung), oder eine
Zwischenschicht, die pro Notiz genau so viel weiß, dass sich entscheiden lässt,
ob man sie aufmacht. Die dritte Variante ist der Index.
Größenordnung: 500 Notizen à 800 Wörter sind rund 500.000 Token. Der Index
derselben 500 Notizen liegt bei etwa 25.000 Token — und eine typische Abfrage
gibt davon 10–40 Zeilen aus, also ein paar hundert Token. Deshalb: nie den Index
komplett lesen, immer `vault_query.py` fragen.
## Erzeugte Dateien
### `_index/vault-index.json`
Kompaktes JSON ohne Einrückung, Kurzschlüssel zur Platzersparnis:
| Schlüssel | Bedeutung |
|---|---|
| `p` | Pfad relativ zum Vault |
| `u` | uid |
| `t` | type |
| `s` | status |
| `n` | Titel |
| `m` | summary |
| `g` | tags |
| `r` | Projekt-Slug |
| `c` / `d` | created / updated |
| `h` | Überschriften (max. 12) |
| `l` | ausgehende Links |
| `w` | Wortzahl (bei Assets: Dateigröße) |
Leere Felder fehlen ganz. Der `meta`-Block enthält Zählungen, Typverteilung,
Statusverteilung, Top-Tags und die Zahl toter Links.
Das Feld `h` ist der zweite Hebel nach `m`: Bei einer langen Notiz zeigen die
Überschriften, welcher Abschnitt gebraucht wird — statt 3.000 Wörter zu lesen,
liest man 300.
### `_index/INDEX.md`
Menschenlesbare Übersicht, nach Typ gruppiert, jede Zeile ein Wikilink plus
Zusammenfassung. In Obsidian direkt navigierbar. Bei über 200 Notizen enthält
die Datei selbst den Hinweis, sie nicht komplett zu lesen.
### `_index/backlinks.json`
`{ "pfad/zur/notiz.md": ["pfad/quelle1.md", …] }`. Basis für `--links-to` und
die Waisen-Erkennung.
## Wann neu bauen
- nach dem Anlegen, Verschieben, Umbenennen oder Löschen von Notizen
- nach Frontmatter-Änderungen
- vor einer Arbeitssitzung, wenn zwischendurch in Obsidian gearbeitet wurde
`vault_query.py` vergleicht die Änderungszeiten und warnt auf stderr, wenn
Notizen neuer sind als der Index. Der Lauf über einige tausend Dateien dauert
Sekunden, also im Zweifel neu bauen.
Automatisieren geht per Git-Hook (`.git/hooks/pre-commit`) oder Cron:
```bash
python3 .claude/skills/obsidian-vault-navigator/scripts/build_index.py --vault . --quiet
```
## Abfrage-Rezepte
```bash
Q=".claude/skills/obsidian-vault-navigator/scripts/vault_query.py"
# Womit habe ich mich zu Thema X beschäftigt?
python3 $Q --text x
# Laufende Code-Projekte, als fertige Wikilink-Liste für eine Antwort
python3 $Q --type project-code --status active --format md
# Alles zu einem Projekt, inklusive Unternotizen und Anhängen
python3 $Q --project auth-service
# Passendes Template-Projekt finden, ohne eines zu öffnen
python3 $Q --type template-project
# Was hat sich diese Woche getan?
python3 $Q --since 7 --sort updated
# Kandidaten fürs Archiv
python3 $Q --status active --stale 180
# Wer verweist auf diese Notiz?
python3 $Q --links-to 30_Research/rate-limiting.md
# Pflegeliste: Notizen ohne summary
python3 $Q --no-summary --limit 100 --format paths
# Struktur einer langen Notiz sehen, bevor man sie liest
python3 $Q --text "kubernetes netzwerk" --format headings
```
Mehrere `--text` werden UND-verknüpft, mehrere `--type`/`--tag`/`--status`
ODER-verknüpft (bei `--tag` müssen alle angegebenen Tags vorhanden sein).
## Wenn der Index nichts findet
1. Filter lockern — oft ist `--type` zu eng gesetzt.
2. Anderes Wort probieren; der Index kennt nur Titel, Summary, Tags,
Überschriften und Pfad, keinen Fließtext.
3. Erst dann Volltext: `grep -ril "begriff" --include="*.md" .`
4. Findet grep etwas, das der Index nicht hatte, ist das ein Befund: Der Notiz
fehlt ein Tag oder eine bessere `summary`. Gleich nachziehen — der Index wird
dadurch mit jedem Miss besser.
## Grenzen
Der Index ist Metadaten, keine Semantik. Er findet nichts, was nirgends benannt
ist, und er versteht keine Synonyme. Für semantische Suche im großen Vault gibt
es lokale Werkzeuge (z. B. Obsidians eigene Suche mit Operatoren oder externe
Embedding-Indizes) — die ersetzen diese Schicht nicht, sondern ergänzen sie: Der
Index bleibt die billige erste Stufe.
## Verhältnis zu Obsidian Bases
Obsidian Bases (`.base`-Dateien) erzeugen Tabellen- und Kartenansichten aus
Properties — dieselben Felder, die auch der Index nutzt. Wer im Obsidian-UI
dieselben Übersichten will, legt Bases an; für den Agenten bleibt der
JSON-Index der billigere Weg. Für die korrekte `.base`-Syntax den offiziellen
Skill `obsidian-bases` verwenden.
@@ -0,0 +1,96 @@
# Vault-Struktur
## Ordner im Detail
### `00_Inbox/`
Alles, was schnell reinmuss: Fundstücke, Ideen, Gesprächsnotizen. Frontmatter
darf hier minimal sein (`type: inbox`, `title`, `created`). Der Ordner ist ein
Durchlauferhitzer, kein Lager — was länger als zwei Wochen liegt, meldet
`vault_doctor.py`.
### `10_Projects/<slug>/`
Ein Vorhaben mit Ziel und absehbarem Ende. Jedes Projekt ist ein **Ordner**,
kein einzelnes File, weil Projekte fast immer mehrere Notizen und Anhänge
sammeln:
```
10_Projects/auth-service/
├── index.md Projektnotiz — der Einstieg, type: project-code
├── decisions/ ADRs, type: decision
├── notes/ Arbeitsnotizen, Meetings, Logs
└── assets/ Diagramme, Exporte, PDFs
```
`index.md` ist Pflicht und trägt die Projekt-Zusammenfassung. Alle Unternotizen
tragen `project: <slug>` im Frontmatter — daran hängt `vault_query.py --project`.
Slug-Regel: kleingeschrieben, Bindestriche, keine Umlaute, so kurz wie
eindeutig (`auth-service`, nicht `Auth Service (neu, 2026)`).
### `20_Areas/`
Verantwortungsbereiche ohne Enddatum: Infrastruktur, Security, Homelab,
Weiterbildung, Kunde XY. Eine Area-Notiz ist gleichzeitig Landkarte für ihren
Bereich. Projekte verlinken auf ihre Area, nicht umgekehrt hart verdrahtet —
die Projektliste einer Area entsteht per Abfrage.
### `30_Research/`
Recherche, die über ein einzelnes Projekt hinaus Bestand hat. Eine Notiz = eine
Frage. Ist die Recherche nur für ein Projekt relevant und danach erledigt,
gehört sie in `10_Projects/<slug>/notes/`. Faustregel: Würde man das in einem
Jahr auch ohne dieses Projekt nochmal aufmachen? Dann `30_Research/`.
### `40_Templates/`
- `note-templates/` — Vorlagen für einzelne Notizen. Auch in Obsidian selbst
als Template-Ordner einstellbar (Einstellungen → Templates).
- `project-templates/` — **Template-Projekte**: vollständige Boilerplates, aus
denen neue Projekte entstehen (Starter-Repo-Struktur, Standard-Setups,
wiederkehrende Auftragsformate). Jedes Template-Projekt hat `index.md` mit
`use_when` im Frontmatter, damit die Auswahl später ohne Öffnen funktioniert.
Templates werden vom Doctor nicht als verwaist gemeldet und tauchen im Index
als eigene Gruppe auf.
### `50_Resources/`
Wissen ohne Projektbezug: Snippets, Runbooks, Cheatsheets, Konfigurations-
Notizen, Tool-Vergleiche. Der Unterschied zu `30_Research/`: Ressourcen sind
Nachschlagewerk, Research beantwortet eine Frage.
### `90_Archive/`
Abgeschlossenes bleibt lesbar und indexiert, ist aber aus dem Blick. Beim
Archivieren `status: archived` setzen und den kompletten Projektordner
verschieben, damit relative Verweise erhalten bleiben.
### `_index/`
Generiert von `build_index.py`. Enthält `vault-index.json` (Maschine),
`INDEX.md` (Mensch), `backlinks.json`. Nie von Hand editieren. Wer den Ordner
in Obsidian nicht sehen will: Einstellungen → Dateien und Links → „Ausgeschlossene
Dateien" auf `_index` setzen (er bleibt auf der Platte und für die Skripte
nutzbar).
## Wohin gehört diese Notiz?
1. Gehört sie zu einem konkreten Vorhaben mit Ende? → `10_Projects/<slug>/`
2. Beantwortet sie eine Frage, die auch ohne das Vorhaben interessant bleibt?
→ `30_Research/`
3. Ist es eine Vorlage oder ein Boilerplate? → `40_Templates/…`
4. Ist es Nachschlagewissen ohne Frage und ohne Vorhaben? → `50_Resources/`
5. Ist es ein Dauerbereich, unter dem mehrere Projekte hängen? → `20_Areas/`
6. Weiß man es nicht? → `00_Inbox/` und beim nächsten Aufräumen entscheiden.
Das ist kein Versagen, sondern der vorgesehene Weg.
## Dateinamen
- Dateiname = sprechender Titel, keine Datumspräfixe (Datum steht im
Frontmatter und ist dort auch sortierbar).
- Ausnahme: Daily-/Log-Notizen, dort `YYYY-MM-DD.md`.
- Projekt- und Template-Ordner tragen Slugs, einzelne Notizen dürfen Leerzeichen
und Großschreibung haben — Obsidian-Wikilinks kommen damit klar.
- Keine Umlaute in Ordnernamen; in Dateinamen sind sie unkritisch, aber Slugs
bleiben ASCII.
## Anpassung der Struktur
Die Ordnernamen sind nicht heilig, die Zuordnung Pfad → Typ schon: Sie steht in
`scripts/vaultlib.py` unter `FOLDER_TYPES`. Wer umbenennt, passt dort an —
sonst raten die Skripte falsch, und `type` im Frontmatter muss überall explizit
gesetzt sein. Frontmatter gewinnt immer gegen die Ordner-Ableitung.
@@ -0,0 +1,128 @@
# Workflows
Alle Pfade unten gehen davon aus, dass der Skill unter
`.claude/skills/obsidian-vault-navigator/` im Vault liegt.
## Frage beantworten („was habe ich zu X?")
1. `vault_query.py --text x` — bei vielen Treffern mit `--type` oder `--path`
nachschärfen.
2. Trefferliste dem Nutzer als Wikilink-Liste zeigen (`--format md`), damit er
in Obsidian direkt springen kann.
3. Reicht die Zusammenfassung für die Antwort, ist hier Schluss.
4. Sonst die 1–3 relevantesten Notizen öffnen. Bei langen Notizen vorher
`--format headings` und nur den passenden Abschnitt lesen.
5. Kommt bei der Antwort etwas Neues heraus, das später wieder gebraucht wird:
als Notiz ablegen oder an die bestehende anhängen.
## Neues Code-Projekt
1. Prüfen, ob es das schon gibt: `vault_query.py --text "<name>"`.
2. Passendes Template-Projekt suchen: `vault_query.py --type template-project`.
Die `use_when`-Zeilen reichen zur Auswahl.
3. Anlegen:
`new_note.py --type project-code --title "Auth Service" --tags go,oidc --summary "…"`
4. Wenn ein Template genutzt wird: dessen Ordner nach
`10_Projects/<slug>/` kopieren, dann `template_of: <template-slug>` ins
Frontmatter des neuen Projekts, und im Template-Projekt unter „Abgeleitete
Projekte" den Rückverweis eintragen.
5. Area verlinken, `repo`/`lang`/`stack` füllen.
6. `build_index.py`.
## Neue Recherche
1. Frage formulieren — eine Frage, eine Notiz. Passt die Frage nicht in einen
Satz, sind es zwei Notizen.
2. `new_note.py --type research --title "…" --project <slug>`
3. `question` ins Frontmatter, Antwort zuerst schreiben, Belege darunter.
4. `confidence` ehrlich setzen; `low` ist ein nützlicher Wert, kein Makel.
5. Vom auslösenden Projekt aus verlinken, damit die Recherche nicht verwaist.
6. `build_index.py`.
## Template-Projekt anlegen
Ein Template-Projekt entsteht meist rückwärts: Ein Projekt lief gut, die
Struktur soll wiederverwendet werden.
1. Projektordner nach `40_Templates/project-templates/<slug>/` kopieren.
2. Projektspezifisches entfernen: Namen, Secrets, Kundendaten, Logs,
Zwischenstände.
3. Platzhalter einführen und im Template `index.md` unter „Anwendung"
dokumentieren, welche ersetzt werden müssen.
4. `type: template-project`, `use_when` und `stack` setzen.
5. `build_index.py`.
## Inbox aufräumen
1. `vault_query.py --path 00_Inbox --sort updated`
2. Pro Notiz einen Zielort vorschlagen — mit der Regel aus
`references/vault-structure.md` als Begründung.
3. Die gesamte Liste als Vorschlag zeigen und **auf Freigabe warten**. Erst dann
verschieben, in Gruppen, nicht alles auf einmal.
4. Beim Verschieben Frontmatter vervollständigen (`type`, `summary`, `tags`).
5. `build_index.py`, danach `vault_doctor.py`, um tote Links zu finden, die
durchs Verschieben entstanden sind.
## Projekt abschließen
1. `status: done`, `updated` aktualisieren.
2. Im `index.md` einen Abschnitt „Ergebnis" ergänzen: was rauskam, was man
beim nächsten Mal anders machen würde. Das ist der Teil, der später gelesen
wird.
3. Lohnt sich ein Template-Projekt daraus? Siehe oben.
4. Kompletten Ordner nach `90_Archive/<slug>/` verschieben, `status: archived`.
5. `build_index.py`, dann `vault_doctor.py` gegen tote Links.
## Wöchentliche Pflege
```bash
python3 .claude/skills/obsidian-vault-navigator/scripts/build_index.py --vault .
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_doctor.py --vault .
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_query.py --since 7 --sort updated
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_query.py --status active --stale 90
```
Daraus wird eine kurze Lage: was ist neu, was liegt fest, was fehlt an
Metadaten, was sollte ins Archiv. Änderungsvorschläge sammeln und gebündelt
vorlegen, nicht einzeln durchführen.
## Migration eines bestehenden Vaults
Der heikelste Fall — hier geht es um Dateien, an denen jahrelang gearbeitet
wurde. Nie im Bulk, nie ohne Freigabe, und immer mit einer Rückfahrkarte.
1. **Sicherung**: Vault in Git nehmen (`git init && git add -A && git commit`)
oder eine Kopie anlegen. Ohne das nicht anfangen — und den Nutzer das
bestätigen lassen.
2. **Bestandsaufnahme**: `build_index.py` auf dem Vault, so wie er ist. Der
Index läuft auch ohne Frontmatter; Typen werden dann aus Pfaden geraten oder
auf `note` gesetzt. Danach `vault_doctor.py` für den Ist-Zustand.
3. **Lage zeigen**: Anzahl Notizen, Typverteilung, wie viele ohne Frontmatter,
wie viele Waisen, größte Ordner. Erst danach über Struktur reden.
4. **Struktur anlegen**, ohne Bestehendes anzufassen: die Zielordner erzeugen,
`CLAUDE.md` in die Wurzel, Templates einsortieren.
5. **Frontmatter zuerst, Verschieben später.** `vault_doctor.py --fix-missing`
erst im Trockenlauf zeigen, dann auf einem Teilbereich mit `--apply`
ausprobieren, Ergebnis gemeinsam ansehen, dann den Rest.
6. **In Wellen verschieben**, thematisch sortiert: erst die eindeutigen Fälle
(klar erkennbare Projekte), dann Research, zuletzt der Rest. Nach jeder Welle
`build_index.py` + `vault_doctor.py`, tote Links reparieren, Nutzer draufschauen
lassen.
7. **Restposten** landen in `00_Inbox/` und werden nach und nach entschieden.
Ein Vault muss nicht an einem Tag fertig sortiert sein.
8. **Zusammenfassungen nachziehen**: `vault_query.py --no-summary` liefert die
Pflegeliste. Automatisch abgeleitete Summaries sind ein Start, kein Ergebnis
— die 30 wichtigsten Notizen von Hand nachschärfen bringt mehr als 300
maschinell erzeugte Sätze.
## Wenn der Nutzer in Claude Code arbeitet
Der Vault als Arbeitsverzeichnis öffnen, damit `CLAUDE.md` greift und die
Skript-Pfade stimmen. Ist das Code-Repo eines Projekts das Arbeitsverzeichnis
und der Vault liegt woanders, immer `--vault <pfad>` mitgeben — die Skripte
brauchen kein Arbeitsverzeichnis im Vault.
Ein Muster, das sich lohnt: Am Ende einer Coding-Session die Projektnotiz
aktualisieren (Stand, offene Punkte, getroffene Entscheidungen) und
`build_index.py` laufen lassen. Die nächste Session beginnt dann mit einer
Abfrage statt mit dem Wiederlesen des Repos.
@@ -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