Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .empire/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1795,6 +1795,95 @@ Der größte Rauschposten war nicht GitHub, sondern **Munirs eigene Systeme**, d

Der Zahlungs-Block liefert zusätzlich ein 24-Stunden-Fenster (`last24_total`, `last24_paid`, `last24_paid_eur`, `last24_failed_after_method`, `last24_paid_methods`, `payments_window_complete`). Grund: der Morgenbrief fragt „was hat sich seit gestern bewegt", nicht „wie war der Monat" — und ohne eigenes Fenster hätte er das aus `last_payments` raten müssen, also aus den letzten DREI Zahlungen. `payments_window_complete` sagt, ob die 50 abgeholten Zahlungen 24 h überhaupt abdecken. Der Morgenbrief ruft `lagebild.sh` jetzt mit `--amounts` auf (privater Kanal); `--redact` nimmt die Beträge wieder heraus.

---

## Der `claude`-Agent erbt Munirs Vollkonfiguration — gemessen, und warum die Trennung (noch) nicht kommt (buzz#84)

**Kurzfassung:** Ja, er erbt alles. Das kostet **17,8 s pro `session/new`** und schleppt **56 MCP-Server**
mit, 12 davon dauerhaft kaputt. Der Hebel dagegen ist gemessen und wirkt (**2,2 s / 19 Server, 8,1×**) —
er ist trotzdem **nicht verdrahtet**, weil die Auth dabei auseinanderliefe. Das ist kein Zögern, das ist
der Guardrail des Tickets: *kein zweiter Auth-Zustand, bevor irgendetwas getrennt wird.*

### Warum er erbt: es steht im Adapter, nicht in Buzz

`@agentclientprotocol/claude-agent-acp@0.64.0` (`dist/acp-agent.js`) baut die SDK-Optionen mit

```js
const options = { systemPrompt, settingSources: ["user", "project", "local"], ... }
```

Das ist eine bewusste Opt-in-Entscheidung des Adapters: das Claude Agent SDK lädt **ohne** diese Zeile
gar keine Filesystem-Settings. Mit ihr bekommt der Agent Munirs kompletten User-Scope — 28 MCP-Server
aus `~/.claude.json`, 39 Plugins aus `~/.claude/settings.json`, dazu Skills und Hooks. Buzz injiziert
dabei nichts; der Agent-Record hatte bis heute keine Env-Overrides.

### Messung auf dem echten Agentenpfad

`%APPDATA%\Buzz\node-tools\claude-agent-acp.cmd` direkt über stdio, `initialize` → `session/new`
(`{cwd: ~/.buzz, mcpServers: []}`), **kein `session/prompt`** — kontingentunabhängig, wie bei buzz#40.
Server-Zahlen aus `claude mcp list` im selben `cwd` (verbindet jeden Server wirklich, meldet Fehler).

| Messgröße | geerbt (Ist-Zustand) | `CLAUDE_CONFIG_DIR=~/.claude-buzz` |
|---|---|---|
| `session/new`, warm, direkt hintereinander | **17 756 ms** | **2 192 ms** |
| Wall bis nutzbarer Session | 18 789 ms | **2 962 ms** |
| erste Session nach Ruhe (kalt) | 25 059 ms | — |
| MCP-Server im `cwd` `~/.buzz` | **56** | **19** |
| davon kaputt / unauthentifiziert | **12** (u. a. `obsidian`, `magic`, `coolify`, `n8n-mcp`, `spaceship-mcp`, `gbp`, `unreal-mcp`, 2 Plugin-Server) | 4 (nur Account-Connectors) |

Zwei Nebenbefunde aus derselben Messung:

- **Der Nest-`cwd` kostet fast nichts.** Gleiche Bedingungen, `cwd` = leeres Verzeichnis: 10 972 ms.
Der Unterschied zu `~/.buzz` sind ~1,7 s für die fünf Nest-Server — die Last liegt praktisch
vollständig im User-Scope, nicht im Projekt-Scope.
- **`enableAllProjectMcpServers: true` in `~/.buzz/.claude/settings.local.json` trägt.** Auch mit einem
fremden, schlanken Config-Dir verbinden sich alle fünf Nest-Server (`google-mcp`, `telegram-mcp`,
`espo-mcp`, `obsidian-mcp-tools`, `n8n-api`) sauber. Die Dispatcher-Ausstattung aus buzz#4 hängt
nicht am User-Scope.
- **Scope-Konflikt gemeldet:** `n8n-api` ist in `user` und `project` mit verschiedenen Endpunkten
definiert; OAuth-Token werden pro Endpunkt gespeichert.

### Warum `CLAUDE_CONFIG_DIR` trotzdem nicht verdrahtet wird

Bei codex (buzz#40) hielt ein **Hardlink** auf `auth.json` beide Seiten auf einem Token. Für Claude Code
gilt das **nicht** — zweimal reproduziert:

```
# frisch verlinkt
inode=4503599632054867 links=2 ~/.claude/.credentials.json
inode=4503599632054867 links=2 ~/.claude-buzz/.credentials.json
# EIN claude-Aufruf spaeter
inode=6755399445748017 links=1 ~/.claude/.credentials.json <- neue Datei
inode=4503599632054867 links=1 ~/.claude-buzz/.credentials.json <- alter Stand, eingefroren
```

Claude Code **ersetzt** die Credentials-Datei (neu schreiben + umbenennen) statt sie zu überschreiben.
Ein Hardlink zeigt auf den Inode, nicht auf den Pfad — er überlebt das Umbenennen nicht und zerfällt
lautlos in zwei Dateien. Danach refresht jede Seite ihr eigenes Token auf demselben Konto: genau der
zweite Auth-Zustand, den das Ticket verbietet, und ein Kandidat dafür, dass eine Seite die andere
ausloggt. **Zur Kontrolle nachgemessen:** `~/.codex/auth.json` ↔ `~/.codex-buzz/auth.json` stehen
weiterhin auf `links=2`, gleicher Inode, inhaltsgleich — die Codex-Engine schreibt **in place**.
Der Unterschied liegt in der Schreibweise der jeweiligen Engine, nicht im Mechanismus.

Ein **Symlink** (der nach Pfad auflöst und das Ersetzen überlebt) wäre die richtige Form, scheitert hier
an Windows: `New-Item -ItemType SymbolicLink` → `NewItemSymbolicLinkElevationRequired`. Junctions gibt
es nur für Verzeichnisse.

**Entscheidung:** Messung steht, Hebel ist belegt, Verdrahtung bleibt aus. Der Agent-Record wurde
testweise gesetzt und wieder zurückgenommen (`managed-agents.json.bak-buzz84-*` liegt daneben); das
Experiment-Verzeichnis `~/.claude-buzz` wurde restlos entfernt, damit kein eingefrorenes Token
herumliegt. Munirs interaktives Setup wurde nicht beschnitten.

### Der Weg, der die Auth gar nicht erst anfasst

Nicht der Config-Dir muss weg, sondern **eine Zeile im Adapter**: wäre `settingSources` konfigurierbar
(z. B. `["project","local"]` per Env), bliebe der Config-Dir — und damit **ein** Auth-Zustand —
unangetastet, und die 28 User-Server plus 39 Plugins fielen trotzdem weg. Das ist eine upstream-fähige
Änderung an `@agentclientprotocol/claude-agent-acp`, kein Buzz-Fork-Feature. Folge-Ticket: buzz#123.

**Merksatz für den nächsten Agenten:** Ein Hardlink teilt einen *Inode*. Wer ihn als „geteilte Datei"
benutzt, muss vorher wissen, ob der Schreiber in place schreibt oder ersetzt — sonst hat man nach dem
ersten Schreibvorgang zwei Wahrheiten und merkt es nicht.

## buzz#69 — Rechte-Rückfall im CRM stillgelegt (2026-08-01)

Expand Down Expand Up @@ -1832,3 +1921,23 @@ Die frühere Notiz „Stripe fehlt bewusst — als Lücke benannt statt als 0
| Morgenbrief ohne Key | „Stripe kann ich nicht sehen — dort könnte Geld eingegangen sein, das in dieser Zeile fehlt." |

**Noch offen (braucht den Key):** Stichprobe gegen echte Stripe-Daten und die Schreibschutz-Probe (ein Schreibaufruf mit dem Key muss abgelehnt werden). **Derselbe Handgriff hängt an agency-infra#130** (`STRIPE_INVOICE_EXPORT_KEY`, Invoices:read) — ein Restricted Key mit den drei Lese-Rechten Invoices/Charges/Subscriptions bedient beide Tickets.

### Nachtrag zu #106: drei Fehler, die erst der Datumswechsel und die Rot-Probe zeigten

Der Brief lief um 20:50 sauber und um 00:35 nicht mehr. Alle drei Befunde sind gemessen, keiner war vorher sichtbar:

1. **Termine, die vor Mitternacht begonnen haben, standen als „morgen" im Brief.** Google liefert im Fenster auch Termine, die vor `timeMin` begonnen haben und noch laufen. Der Vergleich `start == heute → "Um", sonst "Morgen um"` warf damit Vergangenes in die Zukunft: der gestrige 23:00-Block erschien als „Morgen um 23:00". Jetzt wird **dreifach** unterschieden (`< heute` → „Läuft noch, seit", `== heute` → „Um", `> heute` → „Morgen um") und die Liste ist nach `start` sortiert. **Regel: bei Kalenderfenstern ist „nicht heute" nicht dasselbe wie „morgen".**
2. **Die Kopfzeile brach mitten im Satz ab, sobald zwei Monitore rot waren.** Eine Kopfzeile, die auf „… meldet backup-offsite, mollie-recurring als …." endet, ist schlechter als eine kürzere vollständige. Jetzt: passt die volle Fassung nicht in eine Zeile, wird sie aus den Messdaten neu gebaut („backup-offsite und 1 weitere Überwachung ist ausgefallen"), nicht abgeschnitten. **Regel: eine Zeile, die allein tragen soll, darf nie ein Fragment sein.**
3. **Eine Lücke ohne Abschnitt ist wieder eine stille Null.** Seit #106 hat der Morgenbrief keinen Entscheidungs-Block mehr — Lücken der Kategorie `entscheidungen` wurden also erhoben, kategorisiert und dann **von keinem Renderer ausgegeben**. In der Rot-Probe fiel die Empfehlung sichtbar zurück, aber niemand erfuhr warum. Jetzt rendert der „Zwei-Minuten"-Abschnitt sie mit. **Regel: nach jedem Umbau der Abschnitte prüfen, ob jede Lücken-Kategorie noch einen Abschnitt hat, der sie druckt.**

Zusätzlich: Lücken-Sätze sind bei 130 Zeichen gedeckelt (Wortgrenze). Eine Repo-Liste mit 20 Namen flutet sonst den Brief, egal wie ehrlich sie ist.

### Die eine Handlung kommt aus dem Audit, nicht aus dem Label (adas-empire#86)

„Wenn du zwei Minuten hast" zog vorher das älteste offene `P1-money`-`blocked-munir`-Ticket. Das ist die falsche Quelle: es gibt rund 90 solcher Tickets, viele sind **Dubletten derselben Handlung**, und die Sortierung nach echter Geldwirkung steckt im Audit `adas-empire#86` („Die ersten sieben"), nicht im Label.

Der Brief liest deshalb **Zeile 1 der Tabelle** aus diesem Issue — ein Satz, den ein Mensch nach Geldwirkung sortiert hat, mitsamt Ticket-Referenz. Quelle umstellbar über `RITUAL_MUNIR_LISTE`.

**Ist die Quelle nicht lesbar, wird das gesagt** („Die Blockerliste … war nicht lesbar — die Empfehlung oben ist nur das älteste Geld-Ticket, nicht das wichtigste") und der Brief fällt sichtbar auf den alten Weg zurück. Rot-Probe mit `RITUAL_MUNIR_LISTE=munirad7s/adas-empire#999999` gefahren — genau dieser Satz erschien, und **genau diese Probe hat Befund 3 oben aufgedeckt**.

**Folge für alle Agenten:** Wer `adas-empire#86` pflegt, schreibt direkt in Munirs Morgenbrief. Zeile 1 der Tabelle ist kein Notizzettel mehr.
89 changes: 84 additions & 5 deletions .empire/tools/ritual.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ gap() { printf '%s\t%s\n' "$1" "$2" >> "$GAPFILE"; }
gaps_for() { awk -F'\t' -v w="$1" '{n=split($1,c," "); for(i=1;i<=n;i++) if(c[i]==w){print $2; break}}' "$GAPFILE"; }
gaps_flat() { cut -f2- "$GAPFILE"; }
# Im Brief steht kein Stichwort-Fragment, sondern ein Satz — auch die Luecke.
gaps_satz() { gaps_for "$1" | sed 's/[^.…!?]$/&./'; }
# Keine Luecke darf den Brief fluten: eine Repo-Liste mit 20 Namen ist als
# Luecken-Satz genauso unlesbar wie als Block. Deckel bei 130 Zeichen, an der
# Wortgrenze — der Vollstand steht im Log.
gaps_satz() {
gaps_for "$1" | sed 's/[^.…!?]$/&./' | awk '{ if (length($0) > 130) { s = substr($0, 1, 130); sub(/ [^ ]*$/, "", s); print s " …" } else print }'
}
has_gap() { [ -n "$(gaps_for "$1")" ]; }
gapcount() { wc -l < "$GAPFILE" | tr -d ' '; }

Expand Down Expand Up @@ -588,6 +593,33 @@ write_snapshot() {
return 0
}

# (g) Munir-Blockerliste — adas-empire#86. Die eine Handlung im Morgenbrief
# wird NICHT neu erhoben: der Backlog kennt 93 blocked-munir-Tickets, aber viele
# sind Dubletten derselben Handlung, und die Sortierung nach Geldwirkung steckt
# im Audit, nicht im Label. Deshalb ist Zeile 1 der Tabelle „Die ersten sieben"
# aus #86 die Quelle — ein Satz, den ein Mensch nach echter Geldwirkung sortiert
# hat. Fällt die Quelle aus, steht das als Lücke da und der Brief fällt sichtbar
# auf das ältestes-P1-money-Ticket zurück. Kein erfundener Satz.
MUNIR_LISTE_ISSUE="${RITUAL_MUNIR_LISTE:-munirad7s/adas-empire#86}"
collect_munir_liste() {
local repo nr body
repo="${MUNIR_LISTE_ISSUE%#*}"; nr="${MUNIR_LISTE_ISSUE##*#}"
if ! body="$(gh issue view "$nr" -R "$repo" --json body -q .body 2>"$TMP/ml.err")"; then
gap "entscheidungen" "Die Blockerliste ($MUNIR_LISTE_ISSUE) war nicht lesbar — die Empfehlung oben ist nur das älteste Geld-Ticket, nicht das wichtigste"
return 1
fi
# Erste Datenzeile der Tabelle: | **1** | **<Satz>** | <Ticket> | <Warum> |
printf '%s' "$body" | tr -d "$CR" \
| awk -F'|' '/^\| *\*\*1\*\* *\|/ { print $3 "\t" $4; exit }' \
| sed -e 's/\*\*//g' -e 's/`//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
> "$TMP/munir1.txt"
if [ ! -s "$TMP/munir1.txt" ]; then
gap "entscheidungen" "Die Blockerliste ($MUNIR_LISTE_ISSUE) hat keine erste Zeile geliefert — die Empfehlung oben ist nur das älteste Geld-Ticket, nicht das wichtigste"
rm -f "$TMP/munir1.txt"; return 1
fi
return 0
}

# (d) Foki — die drei #-Überschriften aus Now.md. Vault ist Kanon.
collect_foki() {
if [ ! -f "$NOW_MD" ]; then
Expand Down Expand Up @@ -761,9 +793,32 @@ befunde() {
# Die Kopfzeile darf nicht "irgendwas ist kaputt" sagen und den Leser nach
# unten schicken — sie muss allein tragen. Also: dieselbe erste Sache, nur
# kurz genug für eine Zeile.
# Und sie muss ein GANZER Satz bleiben. Ein abgeschnittenes „… meldet
# backup-offsite, mollie-recurring als …." ist schlechter als eine kürzere,
# aber vollständige Aussage — gemessen, sobald zwei Monitore rot waren.
KAPUTT_KURZ=""
[ -n "$KAPUTT" ] && KAPUTT_KURZ="$(kurz "$(printf '%s\n' "$KAPUTT" | head -1 \
| sed -e 's/^Auf dem Server stimmt etwas nicht: //' -e 's/\.$//' -e 's/;.*//')" 62)"
if [ -n "$KAPUTT" ]; then
local roh; roh="$(printf '%s\n' "$KAPUTT" | head -1 \
| sed -e 's/^Auf dem Server stimmt etwas nicht: //' -e 's/\.$//' -e 's/;.*//')"
KAPUTT_KURZ="$(kurz "$roh" 62)"
case "$KAPUTT_KURZ" in
*' …')
# Passt nicht in eine Zeile. Statt vage zu werden: EIN Name plus Anzahl.
# Vollständig stehen sie zwei Abschnitte weiter unten.
local kn kd
kn="$(jq -r '(.server.kuma_down // [])|length' "$TMP/lage.json" 2>/dev/null | tr -d "$CR")"
kd="$(jq -r '(.server.kuma_down // [])[0] // ""' "$TMP/lage.json" 2>/dev/null | tr -d "$CR")"
if [ -n "$kd" ] && [ "${kn:-0}" -gt 1 ] 2>/dev/null; then
KAPUTT_KURZ="$kd und $((kn-1)) weitere $([ "$((kn-1))" -eq 1 ] && printf 'Überwachung ist' || printf 'Überwachungen sind') ausgefallen"
elif [ -n "$kd" ]; then
KAPUTT_KURZ="die Überwachung $kd ist ausgefallen"
else
KAPUTT_KURZ="$(printf '%s' "$roh" | sed 's/:.*//')"
[ "${#KAPUTT_KURZ}" -gt 62 ] && KAPUTT_KURZ="auf dem Server stimmt etwas nicht"
fi
;;
esac
fi

GELD_ALARM=""
if [ -f "$TMP/lage.json" ]; then
Expand Down Expand Up @@ -793,6 +848,16 @@ zwei_minuten() {
printf 'Antworte %s. Zwei Sätze reichen — Hauptsache, es kommt heute etwas zurück.\n' "$(kurz "$WARTET_NAME" 40)"
return
fi
# Erste Wahl: der von Hand nach Geldwirkung sortierte Kopf der Blockerliste.
if [ -s "$TMP/munir1.txt" ]; then
local satz ticket
satz="$(cut -f1 "$TMP/munir1.txt")"
ticket="$(cut -f2 "$TMP/munir1.txt" | sed -e 's/[[:space:]]*→.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ "$REDACT" = "1" ]; then satz="‹Handlung geschwärzt›"; ticket=""; fi
printf '%s\n' "$(kurz "$satz" 150)"
[ -n "$ticket" ] && printf '(%s)\n' "$ticket"
return
fi
if [ -f "$TMP/blocked.json" ] && [ "$(jq -r 'length' "$TMP/blocked.json")" -gt 0 ]; then
local e t r n
e="$(jq -r '([.[] | select(.labels|index("P1-money"))] + .) | .[0] | "\(.title)\t\(.repo)\t\(.number)"' \
Expand Down Expand Up @@ -971,9 +1036,16 @@ render_morgenbrief() {
jq -r --arg heute "$(date '+%Y-%m-%d')" --argjson redact "$REDACT" '
def was($s): if $redact == 1 then "‹Termin geschwärzt›" else ($s|.[0:44]) end;
[.events[]|select(.allDay)] as $a |
[.events[]|select(.allDay|not)] as $t |
# Chronologisch, und die Tagesgrenze wird DREIFACH unterschieden.
# Google liefert im Fenster auch Termine, die vor `timeMin` begonnen
# haben und noch laufen; die wurden vorher als „Morgen" ausgegeben —
# gemessen um 00:35, als der gestrige 23:00-Block als „morgen 23:00"
# im Brief stand. Vergangen ist nicht künftig.
[.events[]|select(.allDay|not)|select(.start != null)] | sort_by(.start) as $t |
([ $a[] | " Den ganzen Tag: " + was(.summary) + "." ]
+ [ $t[] | (if (.start|.[0:10]) == $heute then " Um " else " Morgen um " end)
+ [ $t[] | (if (.start|.[0:10]) < $heute then " Läuft noch, seit "
elif (.start|.[0:10]) == $heute then " Um "
else " Morgen um " end)
+ (.start|.[11:16]) + " " + was(.summary)
+ (if .location and $redact == 0 then " (" + (.location|.[0:22]) + ")" else "" end) + "." ])[0:6] | .[]
' "$TMP/cal.json" | tr -d "$CR"
Expand All @@ -996,6 +1068,12 @@ render_morgenbrief() {
# --- Wenn du zwei Minuten hast
printf '\n⏱️ Wenn du zwei Minuten hast\n'
zwei_minuten | sed 's/^/ /'
# Ohne diese Zeile wären die Lücken der Kategorie `entscheidungen` im
# Morgenbrief STUMM: der Brief hat seit #106 keinen Entscheidungs-Block
# mehr, in dem sie sonst aufschlügen. Gemessen in der Rot-Probe — die
# Empfehlung fiel sichtbar zurück, aber niemand erfuhr warum. Eine Lücke,
# die keinen Abschnitt hat, ist wieder eine stille Null.
gaps_satz entscheidungen | sed 's/^/ /'
} | falte > "$out"
printf '%s' "$out"
}
Expand Down Expand Up @@ -1375,6 +1453,7 @@ case "$MODE" in
collect_calendar
collect_people
collect_blocked
collect_munir_liste || true
BRIEF="$(render_morgenbrief)"
CH="${BUZZ_CHANNEL:-$CH_GENERAL}"; LABEL="🌅 Morgenbrief"
;;
Expand Down
Loading