# Betrieb und Übergabe

Diese Datei ist die Betriebssicht auf SkillSafe. `README.md` erklärt, was das
Projekt ist, `wissenstresor/SKILL.md` ist der Vertrag für das Modell, und hier
steht, was jemand wissen muss, der es weiterführt.

Sie gehört bewusst **nicht** in den Skill-Ordner: sie handelt von der Arbeit am
Repository, nicht vom Tresor, und hat im ausgelieferten `.skill` nichts zu
suchen.

## Voraussetzungen

Nur die Python-Standardbibliothek. Kein pip, kein virtualenv, kein Fremdpaket,
auch nicht für die Tests.

Gemessen mit CPython **3.9.6** und **3.13.13**. Beide bauen dasselbe Paket,
Byte für Byte, mit demselben SHA-256. Ältere Fassungen als 3.9 sind nicht
geprüft und werden nicht zugesagt.

Schreibende Kommandos brauchen POSIX-`dir_fd`-Semantik. Unter Windows heißt
das WSL; ohne sie bricht `release` fail-closed ab, statt unsicher zu schreiben.

## Die eine Regel, die man kennen muss

> **`unittest` ist nach jeder Änderung unter `wissenstresor/` erst nach
> `checksum` oder `release` aussagekräftig.**

`tests/test_packaged_vault.py` ruft `doctor`, und `doctor` wird bei jeder
geänderten manifestierten Datei rot. Gemessen: eine einzige angehängte
Kommentarzeile in `vault.py` ergibt `2 failed, 66 passed`. Nach `checksum`
wieder `68 passed`.

Wer das nicht weiß, hält ein intaktes Projekt für kaputt und sucht an der
falschen Stelle.

Daraus folgt zweierlei:

* **Manifestierte Doku vor dem Release ändern.** `KONZEPT.md`, `SKILL.md`,
  `schema/profil.md`, `references/*.md` und `notes/dead-ends.md` liegen im
  Manifest. Wer sie nach dem Release anfasst, macht `checksum --verify` sofort
  rot. `README.md`, `index.html`, `BETRIEB.md` und diese Werkzeuge liegen
  außerhalb und dürfen danach.
* **Im Fenster zwischen Änderung und Release ist der Tresor fail-closed.**
  `query` liefert `invalid_vault`, `doctor` ist rot. Das ist richtig so. In
  diesem Fenster nicht abfragen und nicht paketieren.

## Release-Checkliste

Abzuarbeiten ohne Nachdenken, in dieser Reihenfolge. Eine Vorbemerkung:
`.vault-extern.json` ist host-lokal, gitignoriert und gehört in keinen
Commit — sie enthält absolute Pfade dieser Maschine.


```bash
# 1. Inhalt und manifestierte Doku ändern
# 2. Inhaltliches Gate
cd wissenstresor
python3 scripts/vault.py validate

# 3. Historie schreiben, dann freigeben
python3 scripts/vault.py log <ingest|update|lint|release|note|onboarding|extern> "<text>"
python3 scripts/vault.py release <major|minor|patch>

# 4. Erst jetzt prüfen
cd ..
python3 -m unittest discover -s tests -t .
cd wissenstresor && python3 scripts/vault.py doctor && python3 scripts/vault.py checksum --verify

# 5. Paket bauen und veröffentlichte Zahlen nachziehen
cd .. && python3 tools/build_skill_package.py
#    README.md und index.html anpassen, dann:
python3 tools/check_docs.py

# 6. Alles gemeinsam committen
git add -A && git commit
```

Schritt 5 ist der, den man vergisst. `tools/check_docs.py` fängt das ab: es
zieht Version, Seiten, Claims, Quellen, manifestierte Dateien, Paketeinträge
und Paket-SHA aus der echten Ausgabe und vergleicht sie mit `README.md` und
`index.html`. Es schreibt nichts und baut das Prüfpaket in ein
Temporärverzeichnis.

**Stufenwahl:** `patch` bei Korrekturen, `minor` bei neuem Wissen oder einer
additiven Profiländerung, `major` erst, wenn ein vorhandener Bestand ohne
Nacharbeit rot würde. Ein neues optionales Feld ist additiv und bleibt `minor`,
auch wenn die Profil-Nummer dabei steigt.

## Was die CI prüft

`.github/workflows/gates.yml` fährt dieselbe Kette über Python 3.9 und 3.13
auf Ubuntu und 3.13 auf macOS.

Die Matrix ist an ihrem Ertrag gemessen, nicht an einem Gefühl von Gründlichkeit.
Über die ersten 14 Läufe gab es genau einen roten Lauf, und dort war **nur**
`macos-latest` rot: 18 Testfehler, alle Ubuntu-Jobs grün, Ursache `/var` als
Symlink auf `/private/var`. Der Sicherheitsteil des Tresors ist Pfadbehandlung
— Symlinks, `dir_fd`, `O_NOFOLLOW`, `realpath` —, und genau dort laufen die
Plattformen auseinander; 71 der 97 Tests hängen daran. Deshalb bleibt macOS.
Gestrichen wurde stattdessen `ubuntu / 3.11`: nie ein Eigenbefund, eingeklemmt
zwischen der Untergrenze 3.9 und dem aktuellen 3.13. Wer eine Zeile zurück in
die Matrix holen will, nennt den Lauf, in dem sie allein rot war.

Ausgelöst wird sie bei Pushes auf `main`, `agent/**` und `claude/**` sowie bei
jedem Pull Request gegen `main`. **Die Präfix-Liste ist die eigentliche
Zusage**, nicht „jeder Push": Ein Branch außerhalb dieser Muster läuft
ungegated, und das fällt niemandem auf, weil ein fehlender Lauf kein roter
Lauf ist. Genau das ist einmal passiert, als das Namensschema von `agent/`
auf `claude/` wechselte. Wer ein neues Präfix einführt, trägt es dort nach —
oder arbeitet über einen Pull Request, der immer greift.

Die Kette ist außerdem netzfrei: `SKILLSAFE_OFFLINE=1` steht global im
Workflow, damit ein Gate nicht an der Verfügbarkeit einer aufgeführten
Netzquelle hängt. Ein Gate soll den Bestand messen, nicht das Wetter.

### Ein fehlender Lauf ist kein grüner Lauf

Der Trigger oben ist nur einer von mehreren Wegen, auf denen ein ungeprüfter
Stand nach `main` kommt. Sie sehen alle gleich aus, nämlich **grau**.

Beobachtet am 6. August 2026, ohne Deutung:

| Commit | Anlass | gates-Lauf |
|---|---|---|
| `e659a2d` | Merge PR #3, 14:34, Weboberfläche | Lauf 10, grün |
| `9452a89` | Merge PR #4, 18:23, Connector | **keiner** — Lauf 14 erst von Hand angestoßen |
| `6f69801` | Merge PR #5, 18:38, Weboberfläche | **keiner** |

Dazu am selben Abend: Lauf 14 bekam für alle vier Jobs keinen Runner
(`runner_name` blieb über die gesamte Laufzeit leer) und wurde um 18:40:31
abgeräumt — exakt 15:02 nach dem Start, also vom eigenen `timeout-minutes: 15`
dieses Workflows. Der Pages-Lauf auf `e659a2d` baute erfolgreich und scheiterte
danach im Deploy mit `Timeout reached, aborting!`.

**Eine Ursache steht damit nicht fest.** Die naheliegende Erklärung — Merges
mit einem App-Token lösen bewusst keine Läufe aus, damit sich Automatisierung
nicht rekursiv auslöst — deckt Zeile zwei und scheitert an Zeile drei: die hat
ein Mensch über die Oberfläche gemergt, und auch dort kam nichts. Sie steht
hier als das, was sie ist: eine Hypothese, die die Beobachtung nicht erklärt.

Genau das ist der Eintrag, der bleiben muss. Der Tresor verlangt für jede
Behauptung eine Fundstelle; für Betriebsbefunde gilt dasselbe. An diesem Tag
wurde die Ursache zweimal benannt, bevor sie belegt war. **Eine unbelegte
Ursache ist schlimmer als keine, weil sie das Suchen beendet.**

**Was unabhängig von der Ursache trägt — und das Verfahren ist:**

* Vor „betriebsbereit" prüfen, ob für den fraglichen Commit ein Lauf
  *existiert*: `Actions` → `gates`, Filter `Branch: main`, Commit suchen.
  Kein Eintrag ist ein Befund, kein Nichts.
* Fehlt er, `gates.yml` per `workflow_dispatch` auf `main` anstoßen
  (`Actions` → `gates` → `Run workflow`, Branch `main`).
* Bleiben die Jobs danach im Status `queued`, ohne dass ein Runner zugeteilt
  wird, liegt es nicht an diesem Repository. Dann hilft nur ein erneuter
  Anstoß später; `timeout-minutes` räumt den hängenden Lauf von selbst ab.
  Ein so abgeräumter Lauf zählt als *kein* Lauf, nicht als roter.

Die Regel dahinter, einmal formuliert statt jedes Mal neu entdeckt: Wer
Betriebsbereitschaft daran festmacht, dass nichts rot ist, misst die
Abwesenheit von Information. Rot ist ein Befund, **grau ist keiner**. Die
Frage lautet nie „ist etwas rot", sondern „hat die Kette diesen Stand
tatsächlich gesehen".

Ein Unterschied zur Handarbeit ist beabsichtigt: **die CI ruft niemals
`checksum` oder `release`.** Beide schreiben und würden genau den Fehler
zudecken, den die Pipeline finden soll, nämlich einen Commit ohne mitgezogenes
Manifest. Am Arbeitsplatz gilt „Tests erst nach checksum", weil man dort mitten
in einer Änderung steht. Im Repository ist der committete Stand bereits
freigegeben, also gilt die Umkehrung.

Der Wochenlauf (`schedule`) fängt Verrottung ohne Commit: neues Runner-Image,
neue Python-Patchversion, geändertes Stdlib-Verhalten. **GitHub schaltet
zeitgesteuerte Workflows nach 60 Tagen ohne Repository-Aktivität ab.** Wer das
Projekt übernimmt und länger nichts committet, muss den Lauf über
`Actions → gates → Enable workflow` wieder anschalten.

## Störfälle

**`.vault-release.lock` liegt herum.** Der Lock bleibt nur bei einem
unvollständigen Rollback liegen; ein gewöhnlicher Abbruch räumt ihn selbst weg.
Liegt er trotzdem da, ist der Bestand möglicherweise in einem Mischstand:
nichts weiter schreiben, `git status` und `checksum --verify` ansehen, den
Zustand mit `git diff` bewerten, notfalls den letzten Commit wiederherstellen.
Erst danach den Lock bewusst löschen.

**`checksum --verify` ist rot und niemand weiß warum.** Die Ausgabe nennt die
Datei und die Art der Abweichung (`GEÄNDERT`, `NEU`, `FEHLT`). `GEÄNDERT` heißt
in der Regel: jemand hat eine manifestierte Datei bearbeitet und kein Release
gefahren. `NEU` heißt: eine Datei liegt im Skill-Ordner, die dort nicht
hingehört, oder sie gehört hin und braucht ein Release. `git diff` zeigt, ob
die Änderung gewollt war. Gewollt: Release fahren. Ungewollt: zurücknehmen.

**Eine Quelle soll wieder raus.** Nicht die Registerzeile löschen und hoffen.
Erst alle Claims finden, die sie referenzieren (`grep -n "S-nnnn"
knowledge/`), diese Claims entfernen oder auf eine andere Quelle umhängen,
danach das `sources`-Feld der betroffenen Seiten bereinigen, dann die
Registerzeile und die Ablage. `validate` fängt jede vergessene Referenz. Die
S-ID wird **nicht** wiederverwendet: `cmd_source` rechnet max+1, und eine
recycelte ID macht die Historie im Log unlesbar.

**Eine C-ID wurde falsch vergeben.** Nicht still umbenennen. Claim-IDs stehen
in Antworten, in `schema/begriffswelten.json` als `definition_claim` und
möglicherweise in fremden Notizen. Die richtige Reaktion ist dieselbe wie bei
Wissen, das sich ändert: den alten Claim stehen lassen, den neuen daneben
setzen, und wenn beide dieselbe Sache meinen, die Seite über `status` und die
`ersetzt`-Kante ordnen. Eine ID, die einmal veröffentlicht war, bedeutet für
immer dasselbe.

**Merge-Konflikt nach zwei parallelen Releases.** Kollidieren wird zuerst
`MANIFEST.sha256`, oft auch `VERSION` und `log.md`. Den Konflikt nicht von Hand
auflösen: einen Zweig als Basis nehmen, die inhaltlichen Änderungen des anderen
darauf anwenden, `VERSION` auf die höhere Stufe setzen und **ein** neues
Release fahren. Das Manifest ist ein Ergebnis, kein Text, den man mischt.

## Externe Bezugsquellen im Betrieb

### Binden

`sources/EXTERN.md` sagt, **was** erreicht werden darf; `.vault-extern.json`
sagt, **wo** diese Maschine eine lokale Quelle findet. Netzquellen brauchen
keine Bindung — ihre URL ist auf jedem Host dieselbe.

```bash
cd wissenstresor
python3 scripts/vault.py extern list
python3 scripts/vault.py extern bind X-0001 /absoluter/pfad
python3 scripts/vault.py extern unbind X-0001
```

**`.vault-extern.json` wird nie committet** und ist gitignoriert. Sie steht
außerhalb des Manifests und außerhalb des Pakets, gehalten von zwei
unabhängigen Regeln (Engine und Paketbau). Rutscht sie in eine davon hinein,
wird die reproduzierbare Paket-SHA-256 maschinenabhängig und `check_docs.py`
schlägt überall fehl. Genau dafür gibt es zwei Tests.

### Warnung: `--extern` ist kein Regelbetrieb

Ohne `--extern` verlässt kein Byte den Skill-Ordner. Wer das Flag
gewohnheitsmäßig setzt, hat den Tresor faktisch in eine Live-Suche über einen
ungefrorenen Baum verwandelt und Regel 2 (Vertrauen zur Abfragezeit)
ausgehebelt. Es gehört ans Ende des Antwort-Workflows: erst der lokale
Bestand, dann die vollständige Fallback-Prüfung, dann extern.

`SKILLSAFE_OFFLINE=1` schaltet jeden Netzabruf hart ab, vor dem ersten Socket.
Die CI-Kette setzt die Variable global — ein Gate darf nicht an fremder
Verfügbarkeit hängen, sonst misst es das Wetter statt den Bestand.

### Weitergabe

Wer ein `.skill`-Paket weitergibt, gibt seine aufgeführten **Netzquellen** mit:
Sie sind durch ihre Registrierung gebunden, beim Empfänger greift kein
zusätzlicher lokaler Schalter außer `SKILLSAFE_OFFLINE`. Lokale Quellen sind
beim Empfänger ungebunden und damit wirkungslos, bis er sie bewusst bindet.
Das ist die gewollte Folge der Gleichbehandlung — vor der Weitergabe die
Registerzeilen durchsehen.

### Störfälle

| Symptom | Ursache | Behebung |
|---|---|---|
| `extern list` zeigt „ungebunden" | Normal nach Installation oder Pfad hat sich geändert | `extern bind` — nur wenn hier tatsächlich nachgeschlagen werden soll |
| `Bindungswurzel und Tresor dürfen einander nicht enthalten` | Die Wurzel liegt im Tresor oder umgekehrt | Externen Baum als Geschwisterordner ablegen, nie im Skill-Ordner |
| `external.state = unreachable` | Netz weg, Ziel weg, oder `SKILLSAFE_OFFLINE` gesetzt | Erreichbarkeit prüfen; fail-closed ist hier gewollt, es wird nichts geraten |
| `doctor`: „zitierter Satz nicht mehr gefunden" | Die fremde Quelle wurde umformuliert | Beleg prüfen, Anker neu setzen oder Claim zurückziehen. **Kein Release-Blocker.** |
| `doctor`: „Satz steht jetzt an Position n" | Text davor eingefügt | Nichts Dringendes; `sentence_index` beim nächsten Anfassen nachziehen |
| Paket-SHA weicht zwischen Rechnern ab | Host-lokale Datei ist ins Archiv gerutscht | Paketinhalt prüfen; `.vault-extern.json` darf nicht enthalten sein |

## Fallen im Bestand

**Fünf Tabu-Tokens.** `tests/test_vault_retrieval.py` prüft mit der
Paraphrasenfrage „Welche Adresse fungiert als dauerhafter Schlüssel einer
Wissenseinheit?", dass `query` bei fehlender Deckung `no_candidates` liefert.
Die Sonde lebt davon, dass keines ihrer Tokens im Bestand vorkommt. Wirksam
sind `adresse`, `fungiert`, `dauerhafter`, `schluessel`, `wissenseinheit`. Sie
dürfen in keinem Claim-Text, keinem Seitentitel, keinem Tag und in keinem
`preferred` oder `aliases` einer Begriffswelt stehen. Auf Deutsch heißt das
etwa „Pflichtfeld" statt „Pflichtschlüssel".

**Fixture-Anker.** Zwei Zeilen in `knowledge/demo-okf/okf.md` sind
Testfixtures: die `concepts`-Zeile und die `formalisiert`-Relation. Mehrere
Tests ersetzen sie per Textsuche. Wer sie umformuliert, macht Tests rot, ohne
dass am Bestand etwas falsch wäre.

**Begriffs-IDs.** `tests/test_vault_retrieval.py` legt eine Fixture-Welt mit
`B-0900` an, bewusst weit oberhalb des Bestands. Neue Begriffe fortlaufend
vergeben und diese Zahl nicht erreichen.

## Einen eigenen Tresor aufsetzen

Der ausgelieferte Bestand `knowledge/demo-okf/` dokumentiert SkillSafe selbst.
Für den praktischen Einsatz gehört er ersetzt. Das Rezept steht in
`wissenstresor/references/mehrere-tresore.md`, Abschnitt 4, und ist mit v0.10.0
Schritt für Schritt nachgefahren worden: es läuft durch und endet grün.

Zwei Dinge, die dort noch nicht stehen:

* **Zwischendurch ist der Tresor zwangsläufig rot.** Seiten, Begriffe und
  Router bedingen sich gegenseitig. Wer die Demo-Seiten löscht, hat gebrochene
  `definition_claim`-Verweise, bis die Begriffs-Registry mit geleert ist, und
  einen Router, der auf nichts zeigt. Das ist kein Fehler, sondern der
  Übergang. Reihenfolge: Seiten löschen, `schema/begriffswelten.json` auf
  leere `concepts`-Liste setzen, `ROUTER.md`-Abschnitt ersetzen,
  `sources/REGISTER.md` und `sources/raw/` leeren, dann erst `validate`.
* **`checksum` ohne `--verify` pinnt auch einen kaputten Bestand.** Es prüft
  nicht, es schreibt. Immer erst `validate`, dann pinnen.

Die neue Instanz bekommt `tests/` und `tools/` nicht mit, wenn nur der
Skill-Ordner kopiert wird. Wer dort weiterentwickelt, kopiert beides mit.

## Host-Smoke-Test

Nicht geprüft und deshalb ausdrücklich offen: ob ein Agent den entpackten Skill
wirklich findet und lädt. `tests/test_packaged_vault.py` beweist, dass der
Ordner nach dem Entpacken an einem fremden Pfad funktioniert, nicht dass ein
angemeldeter Host ihn entdeckt.

Wer das schließen will, arbeitet diese Liste einmal ab und trägt das Ergebnis
in `README.md` nach:

1. `python3 tools/build_skill_package.py`, Archiv nach
   `~/.claude/skills/wissenstresor` entpacken.
2. Claude Code in einem **fremden** Projektordner starten, nicht in diesem.
3. Fragen: „Was steht im Wissenstresor zu OKF?" Beobachten, ob der Skill ohne
   Nennung des Pfades geladen wird.
4. Prüfen, ob die Antwort Claim-IDs und Fundstellen nennt und nicht aus
   Modellwissen ergänzt.
5. Dasselbe für Codex unter `$CODEX_HOME/skills/wissenstresor`.

Beobachtung notieren, auch wenn sie negativ ist. Ein „funktioniert nicht, weil
X" ist mehr wert als eine offene Annahme.

## Veröffentlichen

```bash
git fetch --all --prune --tags
git status --short --branch
git log --oneline --graph --decorate origin/main..HEAD HEAD..origin/main
```

Die letzte Zeile zeigt beide Richtungen. Erst danach mergen und taggen. Jeder
Release bekommt ein Tag, sonst ist kein Stand adressierbar:

```bash
git tag -a v<version> -m "SkillSafe v<version>: <kurz>"
git push origin main --follow-tags
```

`index.html` liegt im Repository-Root und wird von GitHub Pages aus `main`
ausgeliefert. Ein Merge nach `main` veröffentlicht also zugleich die
Landingpage. `.nojekyll` verhindert die Jekyll-Verarbeitung und muss bleiben.

`dist/` ist per `.gitignore` ausgeschlossen und wird nie committet. Der
SHA-256 in `README.md` ist ein Reproduktionsversprechen: wer das Paket neu
baut, muss denselben Hash bekommen.
