From 5199b141a24b4d79597974e03103f6fa0e4ce5c2 Mon Sep 17 00:00:00 2001 From: Munir Adas Date: Sat, 1 Aug 2026 23:17:08 +0200 Subject: [PATCH] Briefe lesbar machen: Morgenbrief und Gate-Batch als Brief statt Systemreport (#106) --- .empire/AGENTS.md | 232 ++++++++++- .empire/PROGRESS.md | 5 + .empire/tools/lagebild.sh | 19 +- .empire/tools/ritual.sh | 830 +++++++++++++++++++++++++++++--------- 4 files changed, 896 insertions(+), 190 deletions(-) diff --git a/.empire/AGENTS.md b/.empire/AGENTS.md index 13352fe1d3..60e5602698 100644 --- a/.empire/AGENTS.md +++ b/.empire/AGENTS.md @@ -187,6 +187,38 @@ Die Bedarfsanalyse war die Arbeit, nicht das Klicken der Rolle. Ergebnis über * | `[E2E] funnel-probe` (erzwungene Execution `141536`) | success — Lead über den Live-Funnel angelegt, `CL Delete Lead` ok, `CL Delete TP` ok, Kuma-Up gepingt | | ACL-Wächter (#28) gegen den neuen Snapshot | grün, 3/3 User | +### CRM-Löschungen im Gate-Batch (buzz#79 — „gehärtet" ist erst dann „bewiesen sauber") + +Nach #29/#52/#53 kann kein API-User mehr fremde Datensätze löschen. „Kann nicht" ist aber eine Annahme, solange niemand hinsieht — und Espo löscht **soft**: ein gelöschter Lead ist für jeden Lesepfad 404, der Grabstein bleibt liegen. Eine stille Löschwelle senkt KPI-Zahlen und Brief-Vorrat, ohne dass irgendwo etwas rot wird. Seit #79 steht **jeden Abend eine Zeile im Gate-Batch**. + +**Quellenwahl (drei Kandidaten gemessen, nicht geraten):** + +| Quelle | Kann | Kann nicht | Urteil | +|---|---|---|---| +| Access-Log des Containers | vollständig, jede DELETE-Zeile | keine Entity-Identität, kein User | verworfen | +| Grabstein `deleted: true` | identitätsgenau | kein Urheber, kein verlässlicher Löschzeitpunkt (Espo fasst `modified_at` beim Remove nicht an) | verworfen | +| `action_history_record` | `user_id` + `action` + `target_type` + `target_id` + `created_at` | über die **Espo-API** nur `read: own` → als Wächter wertlos | **gewählt — aber über die DB gelesen** | + +Entscheidend: `action_history_record` **read-only über den bestehenden ssh-Pfad** (`docker exec agency-crm-mariadb`) braucht **keinen neuen Espo-User und keine Rechteerweiterung** — genau die Bedingung, die #52/#53 gesetzt haben. Ein Admin-API-Key hätte alles kaputt gemacht, was diese Ticket-Reihe abgebaut hat. + +**Schwelle aus 8 Tagen Historie gemessen, nicht gesetzt:** 26.07.–31.07. löschte täglich genau `n8n-agent` **1× Lead + 1× CTouchpoint** (der Funnel-Probe aus #53) — sonst nichts. Alles darüber und jeder andere User ist erklärungsbedürftig und wird namentlich mit Entity und Anzahl aufgeführt. + +**0 ist kein Ruhezustand.** Bei 0 Löschungen meldet die Zeile ausdrücklich, dass auch der Funnel-Probe nichts gelöscht hat, und nennt den Zeitpunkt der letzten Löschung überhaupt — sonst sähe ein toter Probe-Workflow wie ein sauberer Tag aus. + +**Zeitrahmen:** Espo schreibt `created_at` in UTC, der MariaDB-Container läuft in UTC (gemessen: `NOW() == UTC_TIMESTAMP()`). `NOW() - INTERVAL n HOUR` ist damit derselbe Rahmen wie die Daten. Über `RITUAL_CRM_OFFSET_H` lässt sich jedes vergangene Fenster nachschlagen („was wurde vorgestern gelöscht"), über `RITUAL_CRM_WINDOW_H` seine Länge, über `RITUAL_CRM_CONTAINER` der Ausfall proben. + +**Gemessene Falle:** Die erste Fassung schrieb im Gutfall die feste Formel „(n8n-agent, 1× Lead + 1× CTouchpoint)". Bei genau einem gelöschten Lead behauptete der Führungsbrief damit einen CTouchpoint, den es nicht gab — eine erfundene Zahl, gemessen am 01.08. gefangen. Die Zusammensetzung wird jetzt aus den Zeilen gerechnet. **Regel: auch der Gutfall wird gemessen, nicht formuliert.** + +**Beweisstand (2026-08-01, alle am laufenden System):** + +| # | Probe | Ergebnis | +|---|---|---| +| 1 | Ruhetag (24 h endend vor 48 h = 31.07.) | `✅ 2 Löschungen — ausschließlich der Funnel-Probe (n8n-agent): 1× Lead, 1× CTouchpoint. Erwartet.` | +| 2 | Ausschlag (letzte 24 h, Ticket-Tag) | `⚠️ 61 Löschungen, davon 59 außerhalb des Funnel-Probes` + Aufschlüsselung je User | +| 3 | Detektor rot→grün | 3-h-Fenster meldete `0 Löschungen`; danach Wegwerf-Lead über `n8n-agent` (`delete: own`) angelegt **und gelöscht** (HTTP 200 / GET danach 404); **dasselbe** 3-h-Fenster meldete `1 Löschung … 1× Lead` | +| 4 | Quelle tot | `RITUAL_CRM_CONTAINER=gibtsnicht` → `⚠️ LÜCKE — die Löschspur wurde NICHT erhoben. Das ist kein "0 Löschungen"`, Grund benannt, Exit 1 | +| 5 | Kein neuer Zugang | ACL-Wächter (#28) nach der Arbeit: `OK buzz-agent / OK claude-mcp-admin / OK n8n-agent`, Exit 0 | + ## Approval-Gate (buzz#9 — kein Outbound ohne Freigabe) Doktrin: `.empire/POLICY.md` (drei Klassen FREI · GATED · VERBOTEN). Werkzeug: `.empire/gate.sh`. Agenten-Kurzfassung liegt in `~/.buzz/AGENTS.md` und erreicht damit alle fünf Nest-Agenten (Bumble, claude, codex, Fizz, Honey). @@ -1260,15 +1292,44 @@ Die Gmail-Triage des Führungs-Postfachs (buzz#4/#32) hängt an genau einem Refr 2. **`users.getProfile` + Abgleich gegen das erwartete Postfach.** Ein Token für ein anderes Konto ist kein Fehler, den man sieht — die Triage liest dann still das falsche Postfach. 3. **Die tatsächlich gewährten Scopes** aus der Token-Antwort gegen die, die die Triage braucht (`gmail.readonly`, `.modify`, `.compose`). Ein still geschrumpfter Scope legt sie genauso lahm wie ein toter Token, nur leiser. Direkte Anwendung der buzz#32-Lektion: **ein Scope ist keine Zusicherung, solange man ihn nicht misst.** -### Warum kein `status=down`-Push +### Zwei Alarmwege statt einem (buzz#86 — Fehlalarm von Token-Tod getrennt) + +Die ursprüngliche Regel „ein Fehlschlag pusht NICHTS" war richtig, aber unvollständig. Sie machte zwei sehr verschiedene Lagen ununterscheidbar: **„der Token ist tot"** und **„der Rechner war aus"** sahen beide gleich aus — kein Heartbeat — und hätten dieselbe Meldung ausgelöst. Wer die zweite Lage ein paarmal grundlos gemeldet bekommt, schaltet den Wächter stumm; dann schweigt er auch bei der ersten. + +Seit buzz#86 gibt es **zwei** Wege, und der alte bleibt unangetastet: + +| Lage | Weg | Wie schnell | Was Munir sieht | +|---|---|---|---| +| Probe lief und hat den Token als **tot gemessen** | `status=down`-Push **mit Ursache** | Minuten | `Gmail-Token TOT (invalid_grant) — cd …\google-mcp && npm run auth` | +| Probe lief **gar nicht** (Rechner aus, Node weg, Script kaputt) | kein Push, Kuma alarmiert nach Toleranz | 26 h | Kuma-Standardmeldung **ohne** Ursachentext | + +**Die Unterscheidungsregel ist damit ablesbar, ohne irgendetwas zu prüfen:** Alarm **mit** Ursachentext = handeln (`npm run auth`). Alarm **ohne** Ursachentext = der Rechner war zu lange aus; er **erledigt sich selbst**, sobald der Rechner wieder da ist — dann kommt der Heartbeat und Kuma schickt die Recovery-Mail hinterher. + +Damit das wirklich so ist, trägt die Aufgabe seit buzz#86 zwei zusätzliche Eigenschaften (`scripts/token-probe-task-triggers.ps1`, idempotent): + +- **`StartWhenAvailable`** — holt den verpassten 07:10-Lauf nach, sobald der Rechner wieder verfügbar ist. Vorher fiel er **ersatzlos** aus (gemessen: die Aufgabe trug nur einen `CalendarTrigger`). +- **`LogonTrigger` mit 2 Minuten Verzögerung** — deckt lange Aus-Phasen sofort bei der Anmeldung ab. -Ein down-Push setzt voraus, dass das Script noch läuft. Genau die schlimmsten Fälle — Script kaputt, Node weg, Rechner aus — würden dann still durchgehen. **Das Ausbleiben des Heartbeats ist der Alarm** (Toleranz 26 h, Muster `backup-sh`/`funnel-e2e`; Benachrichtigungen 2+3 = Telegram + Mail, wie bei allen neun bestehenden Push-Monitoren). +Der down-Push **ersetzt** die Fail-loud-Eigenschaft also nicht, er ergänzt sie: er beschleunigt genau den Fall, der Geld kostet (Triage des Führungs-Postfachs blind), und lässt den Rest unverändert scharf. Fälle, in denen der Push selbst unmöglich ist (`kuma-token-missing`, `kuma-unreachable`, `kuma-push-failed`), fallen bewusst auf den alten Weg zurück. -Konsequenz, die nicht wegdiskutiert wird: die Aufgabe läuft „Nur interaktiv". **Ein ausgeschalteter oder abgemeldeter Rechner erzeugt nach 26 h denselben Alarm wie ein toter Token.** Das ist bewusst die sichere Richtung (lieber ein Fehlalarm als ein stilles Loch), aber es ist ein Fehlalarm-Risiko — und ein Wächter, dem niemand mehr glaubt, ist schlechter als keiner. Folge-Ticket buzz#86. +### Der Wächter war weicher als dokumentiert (gemessen 2026-08-01) + +Monitor 54 stand auf `maxretries=1` bei `retryInterval == interval`. Gemessen (adas-empire#79): ein `status=down` erzeugt bei dieser Einstellung einen Heartbeat mit `status=2` (PENDING) und `important=0` — **es geht keine Benachrichtigung raus**. Erst der nächste fällige Beat nach `retryInterval` macht daraus DOWN. Die dokumentierte 26-h-Toleranz war real **~52 h**, und der neue sofortige down-Push wäre komplett verschluckt worden. Seit buzz#86: `maxretries=0`, und `kuma-add-gmail-token.mjs` zieht Konfigurationsdrift idempotent nach statt nur den Push-Token. Die übrigen sieben Altmonitore tragen denselben Fehler → **agency-infra#135**. + +**Die Toleranz gehört ins `interval`, nicht in einen unsichtbaren Retry.** ### Gemessene Falle: `process.exit()` im offenen fetch-Kontext liefert 127 statt 1 -Auf Windows zerreisst ein `process.exit()` aus dem Inneren eines noch offenen `fetch`-Kontexts libuv: `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`, Exit **127**. Beim `invalid_grant`-Pfad ist das zugeschnappt — die Meldung war korrekt, der Exit-Code falsch, und bei einem Wächter **ist der Exit-Code der Vertrag**. Behoben: Fehlschläge werden geworfen, der Prozess endet einmal am Ende nach dem Abklingen der Sockets. Regel für jedes Script hier: nie `process.exit()` mitten in einem laufenden HTTP-Aufruf. +Auf Windows zerreisst ein `process.exit()` aus dem Inneren eines noch offenen `fetch`-Kontexts libuv: `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`, Exit **127**. Beim `invalid_grant`-Pfad ist das zugeschnappt — die Meldung war korrekt, der Exit-Code falsch, und bei einem Wächter **ist der Exit-Code der Vertrag**. Regel für jedes Script hier: nie `process.exit()` mitten in einem laufenden HTTP-Aufruf. + +**Die erste Reparatur war eine geratene Frist — und die trug nicht.** 60 ms Warten reichten nur, solange der letzte `fetch` der Heartbeat im Erfolgsfall war. Mit dem down-Push aus buzz#86 endet auch der **Fehlerpfad** auf einem frischen `fetch`, und der Fehlschlag lieferte prompt wieder 127 (gemessen; ein zweiter Versuch mit größerer Frist war 1 von 3 Läufen weiterhin rot — eine Frist zu raten ist keine Lösung, sie verschiebt nur die Wahrscheinlichkeit). Tragfähig ist, `process.exit()` im Normalfall **gar nicht** zu rufen: + +```js +process.exitCode = code; +setTimeout(() => process.exit(code), 2000).unref(); +``` + +Node endet von selbst, sobald keine Handles mehr offen sind — dann kann die Assertion nicht auftreten. Der `unref()`-Timer hält die Event-Loop nicht am Leben und ist nur die Reissleine, falls Keep-alive-Sockets den Prozess länger offen halten. Gemessen: 9/9 Läufe (6 Fehlerpfad, 3 Erfolgspfad) mit dem erwarteten Code. Zweite, harmlosere Falle: die `.cmd`-Startrampe schreibt echtes UTF-8 ins Log; `Get-Content` dekodiert per Default ANSI und zeigt `gewährt`. Die Datei ist in Ordnung — der Leser braucht `-Encoding utf8` (oder Git Bash). @@ -1545,3 +1606,166 @@ FROM monitor m WHERE m.active=1 ORDER BY 3; - **CRLF + Locale:** `gh`-Ausgaben tragen `\r`, und `comm` braucht `LC_ALL=C`. Ohne `tr -d '\r'` und `LC_ALL=C` meldete der Repo-Abgleich 22 statt 2 fehlende Repos — ein frei erfundener Fund. - **Der lokale Arbeitsbaum lügt:** `priorities.json` nannte lokal noch die Bounce-Adresse `munirdue@gmail.com`. Auf `origin/main` war sie längst korrigiert — der lokale Baum hing hinterher und trug zusätzlich fremde uncommittete Arbeit. **Vor jedem „das ist noch kaputt" gegen `origin/main` prüfen, nicht gegen den Baum.** - **Der Nenner darf nicht fehlen:** `lagebild.sh` bildet inzwischen die Vereinigung aus `priorities.json` und einer owner-weiten Label-Suche und meldet nicht gelistete Repos namentlich (buzz#61). Gegengeprüft: die Abdeckung stimmt. Ebenso vollständig ist die Kuma-Routenabdeckung — jeder Traefik-Host hat einen Monitor. + +--- + +## Empire-Cockpit im Desktop (buzz#15 — eigener `/empire`-Tab) + +**Entschieden: eigener Tab statt Pulse-Erweiterung.** Kriterium aus dem Ticket war die Größe des Upstream-Diffs. Pulse zu erweitern hätte `PulseScreen`/`PulseView`/`PulseTabBar` angefasst — alles Upstream-Dateien, die sich schnell bewegen (im selben Sync-Fenster kamen 7 Upstream-Commits, davon 5 in `desktop/src/features/messages`). Der eigene Tab ist additiv: **ein** neuer Feature-Ordner, **eine** neue Route, und an Upstream-Dateien nur vier Einzeiler (`routes.ts`, `preview-features.json`, Sidebar-Eintrag, zwei Handler-Zeilen in `lib.rs`). Pulse bleibt unberührt und funktionsfähig. + +### Die Kacheln und ihre echten Quellen + +| Kachel | Quelle | Weg | +|---|---|---| +| Gates | offene `blocked-munir`-Issues über alle Repos | `lagebild.sh` (#7) → Snapshot → Tauri-Command | +| Backlog | `ready` je Priorität + `in-progress` + Repo-Abdeckung | dito | +| Agenten | `list_managed_agent_runtimes` | direkt aus der Desktop-Laufzeit, kein Snapshot | +| Rituale | Lauf-Quittungen von `ritual.sh` | `~/.buzz/ritual-runs.jsonl` → Snapshot | + +**Kein Doppelbau:** Der Sammler `.empire/tools/cockpit-snapshot.sh` erhebt nichts selbst — er ruft `lagebild.sh --format json --blocks backlog` und liest die Quittungsdatei. Die Gate-Liste (`top_blocked`, Top-3 nach Prio dann Alter) kommt aus den Issues, die der Backlog-Block **ohnehin schon geholt hat**: additive jq-Zeilen in `lagebild.sh`, null zusätzliche API-Aufrufe, dieselbe Sortierung wie `ritual.sh gate-batch`. + +**Bewusst nur der backlog-Block.** n8n, Server und Mollie bleiben draußen: ein geöffneter Tab darf kein geteiltes Live-System anfassen. Der Sammler spricht ausschließlich mit GitHub. + +### Warum ein Snapshot und nicht Live-Abfragen + +Die Webview kann kein `gh`, kein `ssh`, kein `jq`. Ein Live-Cockpit hieße: jede Tab-Öffnung feuert ~28 GitHub-Abfragen. Deshalb: Sammler schreibt **eine** Datei in den Nest, der Tauri-Command `read_empire_snapshot` liest nur diese Datei (Öffnen kostet nichts), `refresh_empire_snapshot` startet den Sammler auf ausdrücklichen Klick. Der Script-Pfad ist fest verdrahtet (`/cockpit-snapshot.sh`, gespiegelt wie `vault-log.sh`) und nimmt **kein** Argument aus der UI entgegen — der Knopf kann keine Shell werden. + +### Keine stillen Nullen — die Regel ist hier Code, nicht Vorsatz + +Drei Schichten, jede einzeln testbar: + +1. **Sammler:** jeder Block trägt `state` + `reason`; fehlt die Quittungsdatei, steht dort `state:"error"` mit „es ist UNBEKANNT, ob Rituale liefen" — nicht „0 Rituale". Geschrieben wird atomar (`tmp` + `mv`), ein Abbruch hinterlässt nie eine halbe Datei. +2. **Tauri-Command:** fehlende Datei, leere Datei, kaputtes JSON, JSON-Array, zu große Datei → `snapshot: null` + `readError`. Es gibt keinen Pfad, der ein Default-Objekt zurückgibt. +3. **Kachel-Modell (`cockpitModel.ts`):** `headline: string | null`. `null` heißt „nicht erhoben" und wird rot gerendert. Ein fehlendes Feld (`blocked`, `ready_total`, eine einzelne Priorität) wird zur Lücke, **nie** zu 0. Eine echte gemessene 0 darf 0 sagen — und sagt dazu „gemessen, nicht angenommen". + +Dazu die Alters-Regel: ein Snapshot älter als 12 h behält seine Zahl, wird aber als überholt markiert; **ein Snapshot ohne verwertbaren Zeitstempel gilt als alt**, nicht als frisch (Fehlrichtung immer Richtung Misstrauen). + +### Gemessene Fallen + +- **`lib.rs` stand exakt auf der 1000-Zeilen-Ratchet** (`desktop/scripts/check-file-sizes.mjs`: Dateien am Limit dürfen nicht wachsen). Zwei Handler-Zeilen kippen `pnpm check` — und zwar **jede** künftige Command-Registrierung, auch upstream. Hier gelöst durch das Zusammenziehen dreier zusammengehöriger Shutdown-Statements (2 Leerzeilen); die eigentliche Lösung ist ein Split von `lib.rs` → Gardener-Ticket. +- **`routeTree.gen.ts` wird vom Vite-Plugin erzeugt, nicht von `tsc`.** `pnpm build` (= `tsc && vite build`) scheitert deshalb beim ersten Lauf mit einer neuen Route („'/empire' is not assignable to keyof FileRoutesByPath"): der Typcheck läuft, bevor der Generator lief. Reihenfolge: erst `pnpm exec vite build`, dann `pnpm typecheck`. +- **Ein frischer Worktree hat keine Sidecars.** `cargo check` auf `desktop/src-tauri` stirbt in `build.rs` mit „resource path `binaries\buzz-acp-…exe` doesn't exist". Die `.exe`-Sidecars aus dem Haupt-Checkout **kopieren** (nicht junctionen — siehe die Junction-Lektion oben; und nicht `CARGO_TARGET_DIR` teilen — siehe buzz#83). +- Der Sidebar-Eintrag navigiert selbst (`useNavigate`) statt über `onSelect…`-Props: der Prop-Weg hätte AppShell, AppSidebar, `useAppNavigation` **und** die geteilte `SidebarSelectedView`-Union angefasst — vier Upstream-Dateien für einen fork-eigenen Tab. + +### Beweisstand (2026-08-01) + +| Prüfung | Ergebnis | +|---|---| +| Stichprobe Backlog | Script `ready`=44 / `blocked`=8 für `munirad7s/spontan` == unabhängige `gh issue list`-Abfrage, exakt | +| Stichprobe Gates | `top_blocked[0]` = `agency-infra#7`, Labels `blocked-munir,P1-money`, `createdAt` identisch mit `gh issue view` | +| Rot-Probe Sammler | ohne Quittungsdatei meldet der Ritual-Block „UNBEKANNT, ob Rituale liefen", Exit 1 — nicht „0" | +| Rust-Unit | 8/8 (`empire_cockpit`) — fehlende/leere/kaputte/Array-JSON-Datei je eigener Test | +| TS-Unit | 19/19 (`cockpitModel`) — inkl. „fehlendes Feld ist keine 0", „echte 0 darf 0 sagen", Schema-Mismatch, Staleness-Grenze | +| Gates | `pnpm check` (desktop + web) ✅ · `pnpm typecheck` ✅ · `vite build` ✅ · `cargo fmt --all --check` ✅ · `cargo clippy` desktop-tauri ohne neue Warnung | + +**Offene Lücke, ehrlich benannt:** Der gerenderte Screenshot aus der laufenden App fehlt. Die isolierte Dev-Instanz baut und startet von diesem Branch (Onboarding-Screen belegt), aber eine **frische** Dev-Instanz landet im 7-Schritt-Onboarding — der Weg bis zum Tab war im Zeitbudget nicht zu Ende zu gehen. Wer ihn geht: `~/.buzz-dev` ist der Nest der Dev-Instanz, dort müssen `cockpit.json` und `cockpit-snapshot.sh` liegen; ohne `cockpit.json` ist die Rot-Probe geschenkt. + +## Stille Fehler, Runde 2: Wo „grün" nichts beweist (2026-08-01) + +Die zweite Jagd durchsuchte die Bereiche, die Runde 1 nicht angefasst hatte: Backups, Zahlungs-Webhooks, Cloudflare-Pages-Deploys, GitHub-Actions, den EspoCRM-Schreibpfad und die Server-Crons. Vier Muster sind allgemein genug, um für jedes künftige System zu gelten. + +### Ein Abo erbt den Webhook der Erstzahlung nicht + +Mollie schickt den Webhook einer `first`-Zahlung **nicht** an das daraus entstandene Abo weiter — `webhookUrl` ist am Subscription-Objekt eigens zu setzen. Gemessen: das einzige laufende Abo des Hauses (49,99 EUR/Monat, nächste Abbuchung 2026-09-01) hatte `webhookUrl: null`, und zwei von drei Abo-Erzeugungspfaden in n8n bauen den POST-Body ohne dieses Feld. Ein gescheiterter SEPA-Einzug hätte niemanden erreicht. + +Der Gegenbeweis, dass es eine Auslassung und kein Design war, kam aus dem eigenen Haus: `[social-poster] Onboarding API` setzt das Feld korrekt, die Agentur- und FOERDERWERK-Pfade nicht. **Regel: Bei jedem wiederkehrenden Zahlungsweg das erzeugte Objekt zurücklesen und prüfen, ob es einen Rückkanal trägt — nicht den Code lesen, der es erzeugt hat.** Ticket agency-infra#142. + +### Ein `errorTrigger` ist kein globaler Abfang + +n8n-Fehlerworkflows feuern **ausschließlich** für Workflows, die sie in `settings.errorWorkflow` eintragen. Gemessen: 21 von 83 aktiven Workflows hatten den Eintrag nicht — darunter der Bezahl-Checkout, der Lead-Eingang und der ACL-Drift-Wächter aus buzz#28. Einer zeigte auf einen Error-Workflow, der **inaktiv** ist: in der UI konfiguriert, in der Wirkung nichts. + +Der Beweis lief in beide Richtungen an echten Executions, und genau so gehört er geführt: + +``` +141248|vK7GzYSEJpw0Oiu0|error |2026-08-01 10:58:47.711+00 <- MIT errorWorkflow +141249|HKTf8UJnjSkWiRQg|success|2026-08-01 10:58:49.501+00 <- Sentinel 1,8 s später +``` +gegen: `ci7Z4igomhDJCNFY` scheitert 06:00:00 — im Fenster 05:55–06:10 **null** Sentinel-Executions. + +**Regel: Ein Alarm-Kanal ist erst bewiesen, wenn ein Fehler-Zeitstempel und ein Alarm-Zeitstempel nebeneinanderliegen.** Ticket agency-infra#143. + +### Ein Backup ohne Empfänger und ohne Rückspielung ist eine Hoffnung mit Zeitstempel + +Das verschlüsselte Google-Drive-Vollbackup (systemd-Timer, täglich) hat: kein `OnFailure=`, keinen Kuma-Monitor, keine Telegram-/Mail-Meldung. Sein einziges Ergebnis-Artefakt ist `/var/lib/full-server-backup/last-status` — und `grep -rl` über `/opt`, `/usr/local/sbin`, `/etc/cron.d`, `/etc/systemd/system` zeigt: außer dem Skript selbst liest das niemand. + +Die Rot-Probe musste nicht simuliert werden, sie lag im Journal: am 2026-07-26 19:10:09 scheiterte der Dienst mit `result 'signal'` — folgenlos. **Ein Detektor, der seine Gelegenheit hatte und sie nicht genutzt hat, ist widerlegt, nicht verdächtig.** + +Dazu die Abgrenzung, die man beim Zählen von „Backup-Monitoren" leicht übersieht: `verify-backup.sh` (agency-infra#32) ist gute Arbeit, prüft aber `RESTIC_REPOSITORY` aus `backup.env` — das **lokale** Repo auf derselben Platte. Für das Offsite-Repo existiert kein einziger Restore. `restic check --read-data-subset` beweist Blob-Integrität, nicht Wiederherstellbarkeit: er sagt nichts über den `rclone`-Zugang, über die Verfügbarkeit des Repo-Passworts außerhalb des Hosts und über einen durchlaufenden `pg_restore`. Ticket agency-infra#145. + +### Die Frage „wer schreibt das eigentlich" beantwortet der Zeitstempel, nicht das Credential-Inventar + +buzz#29 hatte notiert, `claude-mcp-admin` schreibe aktiv ins CRM, „der Consumer sitzt außerhalb von n8n und ist unbekannt". Gefunden wurde er in zwei Schritten: die neuesten Leads nach `createdById` und `createdAt` sortieren (04:15:36), dann in `/etc/cron.d` nachsehen, was um 04:15 läuft (`agency-crm-sync` → `batch-ingest.sh` mit eigener Zugangsdatei). Bestätigt durch `GET /App/user` mit genau diesem Key → `userName=claude-mcp-admin`. + +**Regel: Ein unbekannter Schreiber ist über den Zeitstempel seiner Schreibungen fast immer identifizierbar. Die Zugangs-Inventare durchsucht man erst, wenn das nicht greift.** + +### Widerlegt statt gemeldet (vier Verdachtsfälle) + +Das gehört genauso ins Protokoll wie die Funde — jeder davon hätte ein plausibles, falsches Ticket ergeben: + +| Verdacht | Warum er fiel | +|---|---| +| Cloudflare-Pages: `adas.casa` steht im Pages-Projekt auf `deactivated` | `curl https://adas.casa` → **301 auf `www.adas.casa`**, dahinter HTTP 200. Der Apex läuft absichtlich über den `agency-apex-redirect`-Container, nicht über Pages. | +| Deployte Commit-SHAs weichen von `main` ab | Die Abweichungen waren 5–7 Minuten alt, an einem Tag, an dem mehrere Agenten pushten. Ein bewegtes Ziel ist kein Befund — hier gilt derselbe Vorbehalt wie bei „der lokale Arbeitsbaum lügt". | +| Stripe-Webhooks laufen ins Leere | Endpunkt `we_…` ist `enabled`, zeigt auf `n8n.adas.jetzt/webhook/mondsamt-paid`, und der empfangende Workflow `[MONDSAMT] paid` hat 14 `production_success`. Stripe hat **null** Subscriptions — dort gibt es keine wiederkehrende Strecke, die blind sein könnte. | +| Zwei aktive n8n-Workflows haben **nie** einen `production_success` | Nur einer ist ein Befund. `[social-poster] subscription-janitor` wurde am selben Tag um 09:45 angelegt, sein erster Cron-Lauf stand noch aus. **Vor „läuft nie" immer `createdAt` gegen die Schedule-Periode halten.** | + +### Handwerk (Runde 2) + +- **`workflow_statistics` ist die prune-feste Quelle** — die Execution-Historie reicht nur ~3,4 Tage (adas-empire#85). Für „lief das je?" gehört die Tabelle abgefragt, nicht `/api/v1/executions`. Zugang: `ssh hetzner "docker exec -i postgresql-m12c6fi640vm8lgeuxrl4evo psql -U infzUTjDXmlo65b3 -d n8n ..."` — der n8n-Container heißt **nicht** `agency-n8n`, und die DB liegt **nicht** in `agency-postgres`. +- **`gh run list` ist blind für Repos ohne Workflows.** Ein leeres Ergebnis heißt „kein CI", nicht „CI kaputt". Die Gegenprobe ist `GET /repos/{o}/{r}/actions/workflows` plus `total_count` der Runs. +- **Ein in der Workflow-YAML referenzierter Name beweist nicht, dass der Wert gesetzt ist** — `GET /repos/{o}/{r}/actions/secrets` beweist es. Skripte, die ohne den Wert still zum No-op werden (`[ -n "${TOKEN:-}" ] || return 0`), sind sonst grün und wirkungslos. +- **Der Berichts-Vorbehalt:** Dass eine Zahl irgendwo im Morgenbrief auftaucht, macht sie nicht zu einem Wächter. `lagebild.sh` hätte einen fehlgeschlagenen Einzug als Zähler gezeigt — ohne Kunde, ohne Aktion, ohne Alarm. Beim Bewerten eines Fundes gehört diese Teil-Mitigation benannt, aber sie entkräftet ihn nicht. + +## Die Briefe lesen sich wie Briefe (buzz#106 — Morgenbrief + Gate-Batch) + +Munirs Befund, wörtlich: „der Struktur von Morgenbrief und so weiter ist nicht so schön, muss für mich übersichtlicher, mit schön menschlich beschrieben, gute Struktur." Der Leser ist einer, er liest um 08:45 auf dem Telefon, mit Neugeborenem und Drittversuch-Klausuren, und er hat zwei Minuten. Ein Brief, der wie ein Systemreport aussieht, wird nicht gelesen — und ein nicht gelesener Brief macht die gesamte Erhebungsmaschinerie (#7, #10, #59, #62, #63) wertlos. + +**Morgenbrief — feste Reihenfolge, keine nummerierten Blöcke mehr:** Kopfzeile in ganzen Worten (beurteilt den Tag, ohne Zahl und ohne Status-Code) · 💶 Geld · ✉️ Menschen · 📅 Dein Tag · ⚙️ Läuft · ⏱️ Wenn du zwei Minuten hast. + +**Gate-Batch:** Kopfzeile mit Anzahl UND Zeitbedarf („Sechs Entscheidungen, etwa drei Minuten.") · nummerierte Entscheidungen, je drei Zeilen (worum es geht · was ein Ja bewirkt · was ein Nein oder Schweigen bewirkt) · dann höchstens fünf Zeilen „was heute gelaufen ist" · Schluss. + +### Was daran mehr ist als Kosmetik + +- **Reiner Text, selbst umgebrochen bei 64 Zeichen** (`falte`, `RITUAL_WIDTH`). Kein Markdown, keine Tabellen, keine Codeblöcke: Was auf dem Telefon zerfallen kann, kommt nicht vor. Die Telegram-Markup-Strippe in `post_telegram` ist damit ein No-Op statt einer Rettung. +- **Die Folgen im Gate-Batch werden dem Ticket ENTNOMMEN, nie erfunden.** „Sagst du ja" ist der erste Satz aus `## Mission`, „Sagst du nein oder gar nichts" der Satz aus `## Money-Link`, der mit „Ohne"/„Solange"/„Bis dahin" beginnt. Fehlt er, steht dort ein ehrlicher Platzhalter („es bleibt genau so liegen wie jetzt") — kein erfundener Schaden. Gemessen am 01.08.: 4 von 6 Entscheidungen trugen ihren echten Preis, 2 fielen sichtbar auf den Platzhalter zurück. **Das ist der Grund, warum `## Money-Link` einen „Ohne …"-Satz enthalten sollte: er landet wörtlich in Munirs Abendbatch.** +- **Lücken stehen im betroffenen Abschnitt, nicht in einem Sammelblock am Ende.** `gap()` trägt jetzt Zielabschnitte (`geld menschen tag laeuft entscheidungen sonst`). Eine Lücke im Sammelblock liest niemand; eine Lücke unter „Geld" ersetzt die Zahl, die dort sonst stünde. +- **Der Gate-Batch zeigt sechs statt zwölf Entscheidungen** (`RITUAL_GATE_LINES`). Zwölf sind auf dem Telefon eine Wand; die Sortierung P1-money-zuerst sorgt dafür, dass die sechs teuersten oben stehen, der Rest wird gezählt statt verschwiegen. + +### Der Fund, der den Inbox-Block ersetzt hat (gemessen 01.08.) + +Die alte Zeile lautete „≥50 neue Nachrichten, davon ≥43 ungelesen". Eine Stichprobe über die **50 neuesten Nachrichten der letzten fünf Tage** ergab: **ausnahmslos Maschinen** — GitHub-CI, das eigene Monitoring, Revolut, Werbung. Kein einziger Mensch. Zwei Konsequenzen: + +1. Die Zahl maß reines CI-Rauschen und sah dabei aus wie eine Aussage über Kunden. +2. `gmail_search` deckelt hart bei 50 (zod-Schema). Eine echte Kundenmail wäre **hinter dem Deckel unsichtbar** geblieben — der gefährlichere Teil. + +Deshalb: **Der Ausschluss gehört in die Abfrage, nicht hinter sie.** Der Menschen-Block fragt `in:inbox newer_than:7d` mit serverseitigem `-from:`/`-category:`-Ausschluss und filtert danach nochmals lokal (`MACHINE_RE`, `OWN_DOMAINS`). Zwei Netze, weil Gmails `from:`-Matching unscharf ist. Wirkung im selben Postfach: aus 50 Maschinen wurden 3 namentlich genannte Menschen + 5 weitere Absender + 29 gezählte Benachrichtigungen. + +Der größte Rauschposten war nicht GitHub, sondern **Munirs eigene Systeme**, die ihm in sein eigenes Postfach schreiben (`mondsamt@`, `autopilot@`, `foerderwerk@`, `kontakt@` auf `adas.team`/`adasgroup.de`). Ein Kunde schreibt nicht von adas.team. + +**Bewusst in Kauf genommen:** schriebe ein echter Kunde von `support@seinefirma.de`, landete er in der Benachrichtigungszählung. Deshalb steht die Zahl der Aussortierten IM Brief — sichtbar falsch ist besser als unsichtbar weg. + +**Einordnung zahlend/interessiert:** Vault-Kundenordner (`04 Areas/clients//`, Zusagen-Kanon) schlägt CRM (Espo `espo_search` auf Contact/Lead, Pipeline-Wahrheit). Antwortet das CRM nicht, ist das eine benannte Lücke — nie „kein Kunde". + +### Fallen, die beim Bau zugeschnappt sind (alle gemessen, alle abgeräumt) + +- **`read` mit `IFS=$'\t'` schluckt führende Leerfelder.** Der Tabulator ist ein IFS-*Whitespace*-Zeichen; ein leeres erstes Feld verschwindet und die ganze Zeile rutscht eine Spalte. Im Brief stand daraufhin der Betreff als Absendername. Regel: bei `@tsv` + `read` darf **kein** Feld leer sein — jq setzt Platzhalter. +- **`jq -n` schreibt mehrzeiliges JSON, und mehrzeiliges JSON überlebt die MSYS-argv-Konvertierung nicht.** `mcp-call.mjs` bekam `--args` und meldete „ist kein JSON". Regel: JSON-Argumente für `mcp-call.mjs` immer `jq -cn`. +- **`state: "error"` ist NICHT `state: "fehlt"`.** Der Zahlungs-Block eines toten Lagebilds enthält trotzdem ein `.pay`-Objekt; `// 0` machte daraus „Kein Zahlungseingang" und „kein Abo" — zwei zuversichtliche Falschaussagen aus einer toten Quelle, direkt in der Rot-Probe sichtbar. Nur `ok`/`warn` dürfen Zahlen tragen. +- **Eine Kopfzeile ohne Inhalt ist die stillste stille Null.** Ein jq-Compilefehler in `render_gate_lines` lieferte „Sechs Entscheidungen, etwa drei Minuten." — und danach nichts. Der Abend sah leer aus. Jetzt wird das Renderergebnis geprüft, bevor die Kopfzeile etwas verspricht. +- **jq-Regex im Shell-Script braucht doppelte Backslashes** (`"\\s"`, `"\\["`). Einfache verschluckt jq mit „Invalid escape" und liefert *nichts* — wieder eine stille Null. +- **Ein laufendes Bash-Script darf nicht bearbeitet werden.** Bash liest die Datei inkrementell nach; ein Edit während eines Hintergrundlaufs erzeugte „erselben: command not found" und einen Syntaxfehler in einer Zeile, die syntaktisch einwandfrei war. Bei parallelen Läufen: Kopie laufen lassen — dann aber `$HERE` beachten, sonst findet die Kopie `lagebild.sh` nicht. +- **`date '+%a'` liefert unter MSYS „Sat".** Ein englischer Wochentag in einem deutschen Brief ist genau der Systemgeruch, der hier weg soll — Wochentage werden aus `date '+%w'` selbst gesetzt. + +### Rot-Proben (der Brief MUSS falsch aussehen können) + +| Probe | Ergebnis | +|---|---| +| `RITUAL_MCP_CONFIG=/gibt/es/nicht.json` | „Ich komme heute nicht ans Postfach: …" und „Deinen Kalender habe ich heute nicht erreicht: …" — **nicht** „Niemand hat geschrieben" / „Keine Termine" | +| `MOLLIE_LIVE_API_KEY=live_kaputt` | „Ich konnte die Zahlungen nicht messen (error): Mollie /subscriptions HTTP 400" — **nicht** „Kein Zahlungseingang" (erst nach dem `state`-Fix, siehe oben) | +| `GH_TOKEN=gho_kaputt` (Gate-Batch) | „Ich konnte heute nicht nachsehen, was auf dich wartet. Das heißt ausdrücklich nicht, dass nichts wartet." — **nicht** „Heute nichts zu entscheiden" | +| Breiten-Probe | längste Zeile 64 Zeichen in beiden Briefen | +| Wochen-Review (#63) unangetastet | vollständiger Lauf nach dem Umbau: 127 geschlossene Issues, 89 offene Entscheidungen, Vorschlagsblock gefüllt | + +### Kleine additive Erweiterung an `lagebild.sh` + +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. diff --git a/.empire/PROGRESS.md b/.empire/PROGRESS.md index a90776de2c..89d14dae60 100644 --- a/.empire/PROGRESS.md +++ b/.empire/PROGRESS.md @@ -29,3 +29,8 @@ 2026-08-01 18:45 | 1 Ticket | #63 Wochen-Review als drittes Ritual (ritual.sh wochen-review, 5 Bloecke, Snapshot-Gedaechtnis ~/.buzz/ritual-snapshots, Windows-Aufgabe So 18:00 + Workflow Weekly Review; Delta gegen 2 Fixtures exakt vorhergesagt, echter Aufgabenlauf Kanal+TG+Vault) | Befund: Relay parst Quartz-Cron -> Wochentag als Name (SUN), '0' wird hart abgewiesen; owner-weit nur 1 offenes ready-P1-money | PR #94 | Blocker: keine 2026-08-01 19:35 | 1 Ticket | #83 buzz-acp-Suite auf Windows gruen (697 passed statt 9 failed): blankes "bash" = WSL-Starter (Pipe kommt nie an -> AgentExited), Path::display() unquoted im Script (die C:Users...json-Muelldateien), Idle-Fenster kleiner als der Prozessstart (gemessen 107ms Mittel/194ms max) | Sackgassen dokumentiert: Fenster aufziehen + threads-required (18s->110s, schlechter) | NEUE FALLE: geteiltes CARGO_TARGET_DIR zwischen Worktrees serviert alte Binaries (676 statt 697 Tests) | Gardener: #96 | PR #95 | Blocker: keine 2026-08-01 20:20 | Jagd auf stille Fehler | FIX #98 ritual-task.cmd gab IMMER 0 an die Aufgabenplanung (echo "exit=$?" in der bash -lc-Kette wurde zum Exit-Status; gemessen ritual.sh=64 -> .cmd=0, nach Fix 64, live im Haupt-Tree gegengeprueft) | 3 Tickets: agency-infra#134 (Kuma gobd-export 0 Benachrichtigungen + 4 Provisionierungs-Skripte mit notificationIDList:{}), buzz#101 (Rituale ohne Gesundheits-Monitor), adas-empire#85 (n8n-Historie nur 3,4 Tage -> Wochen-Workflows unpruefbar) | Falsifiziert statt gemeldet: n8n-"nie gelaufen" war Prune-Artefakt (workflow_statistics + Listmonk-Kampagne 9 beweisen die Laeufe); demo_views_7d-Fenster echt (1h-Probe = 0); Kuma-Routenabdeckung vollstaendig; Kuma-Mailkette zugestellt | Blocker: keine +2026-08-01 20:30 | 2 Tickets | #33 geschlossen OHNE Bau (WF-08 liegt upstream schon in block/buzz#2377 + #3327; der native Pfad haette das Gate GEOEFFNET: check_approver_spec kennt nur "any"=jeder authentifizierte User oder exakten 64-Hex-Key, waehrend das Schema @mention dokumentiert -> naiv verdrahtet approved jeder Agent sich selbst) + #82 blocked-munir (error.data-Fix liegt upstream DREIMAL offen: #4084/#2436/#4121 -> vierter PR waere Duplikat; einziger Mehrwert unserer Version: #4084 loggt data UNREDIGIERT aus einem Adapter-Subprozess) | PR #99 (.empire/AGENTS.md: Befund + vierteilige gate.sh-Schaltbedingung inkl. Rot-Probe "zweiter Key granted -> abgelehnt") | Gardener: #100 | Gate G-1258C4 TIMEOUT -> nichts an block/buzz raus, Audit-Kette (12 Eintraege) belegt requested->timeout, 0 Kommentare/PRs dort | Befunde: Duplikat-Suche im Upstream-Tracker gehoert in JEDEN Upstream-Vorflug (2/2 Treffer heute); die 10 roten Windows-Tests aus #83 fallen auch auf reinem upstream/main+1 Datei -> Upstream-Bug, kein Fork-Schaden | Praezisierung zur CARGO_TARGET_DIR-Falle aus 19:35: nicht "alte Binaries", sondern ZWEI Worktrees mit verschiedenen Quellen auf EINEM target/ ueberschreiben einander die Test-Binary am selben Pfad (676 = upstream-Stand, 697 = Fork-Stand nach #83) -> je Worktree eigenes target/, wenn parallel gearbeitet wird | Blocker: #82 wartet auf Munirs Freigabe (Option A: Review-Kommentar an #4084 mit der Redaction-Luecke) +2026-08-01 21:30 | 1 Ticket | #15 Empire-Cockpit-Tab (eigener /empire-Tab statt Pulse-Erweiterung; 4 Kacheln aus lagebild.sh + managed_agents-Laufzeit + neuen ritual.sh-Lauf-Quittungen; keine stillen Nullen in 3 Schichten, Rust 8/8 + TS 19/19, Stichproben gegen gh exakt) | PR #105 | Befunde: lib.rs stand exakt auf der 1000-Zeilen-Ratchet -> jede Command-Registrierung kippt pnpm check; routeTree.gen.ts erzeugt Vite, nicht tsc; frischer Worktree hat keine Sidecars | Gardener: #108, #109 | Offen: gerenderter Screenshot-Beweis (#109) +2026-08-01 22:00 | 2 Tickets | #48 Windows-Canary als CI-Gate (Root-Cause: der Upstream-Canary traegt `if: github.repository == 'block/buzz'` -> auf JEDEM Fork wird der Job still uebersprungen, ein Dispatch dort meldet success ohne eine Zeile zu bauen; Fork-Pendant empire-windows-canary.yml mit push-auf-main + paths-ignore, dispatch auf jedem Branch, concurrency, timeout 120; Auto-Trigger beim Merge von PR #102 bewiesen: Run 30712467814 success, 18/18 Steps, 32m32s, Artefakt 52.342.569 Bytes; Rot-Probe Typfehler in buzz-cli -> failure in "Build sidecars" mit E0308/exit 101; Doku-PR #107 loeste dank paths-ignore KEINEN Lauf aus) + #79 CRM-Loeschzeile im Gate-Batch (Quelle action_history_record read-only ueber den bestehenden ssh-Pfad -> kein neuer Espo-User; Access-Log und `deleted:true`-Grabstein begruendet verworfen; Schwelle aus 8 Tagen gemessen: taeglich exakt n8n-agent 1x Lead + 1x CTouchpoint; 5 Proben: Ruhetag gruen, Ausschlag 61/59 aufgeschluesselt, Detektor rot->gruen per echter Testloeschung ueber delete:own, tote Quelle = benannte LUECKE statt "0", ACL-Waechter danach 3/3; Kanal-Beweis Buzz-Event 10f831820480c508) | PRs #102 #107 #111 | Gardener: #113 (Fork-Actions entruempeln: ein main-Push zuendet 6 Upstream-Workflows, Sprig scheitert reproduzierbar an "release not found" -> ein dauerhaft roter Actions-Tab entwertet das neue Gate); #114 als Duplikat zu #69 geschlossen | Befunde: der Gutfall darf nicht formuliert werden — die erste Fassung schrieb die feste Formel "1x Lead + 1x CTouchpoint" und behauptete bei EINER Loeschung einen CTouchpoint, den es nicht gab (erfundene Zahl im Fuehrungsbrief, gefixt); einer der drei Espo-Zugangsnamen im zentralen Secrets-File antwortet 401 (gehoert zu #69); `UID` ist in bash readonly (MSYS) -> die Zuweisung scheitert still und man arbeitet mit der Shell-UID weiter | Blocker: Approval-Gate weiterhin OHNE echte Freigabe (gate-audit nur requested/timeout, auch ein Fremd-Request um 19:57 lief in Timeout) -> #32/#42 bleiben zu; claude-Kontingent (#3), Codex bis 08.08. (#13/#18) +2026-08-01 22:45 | 3 Tickets Betrieb gehaertet | adas-empire#79 Eskalationsweg beweisbar (Kanarie durch denselben empire_notify-Code, Ankunft in der ECHTEN INBOX belegt; 3 Rot-Proben; tote Adresse munirdue@ raus) + buzz#77 Sentry-Liveness (Mention+Token, Kuma sentry-liveness, echter Reboot: Agent ohne Zutun in 29 s zurueck, 87/87 Container) + buzz#86 Token-Waechter mit zwei Alarmwegen (Token-Tod pusht status=down MIT Fix-Befehl, Rechner-aus heilt sich per StartWhenAvailable+Logon-Trigger) | PRs adas-empire#83/#84, buzz#112/#116, google-mcp#5 | BEFUND (systemisch): 9/11 Kuma-Push-Monitore mit maxretries=1 bei retryInterval==interval alarmieren erst nach der DOPPELTEN dokumentierten Zeit, ein status=down erzeugt dort nur PENDING/important=0 -> agency-infra#135 | Widerlegte Praemissen: kein Bounce-Regen im Postfach, Eskalationsmails KAMEN an (nur unbewiesen), buzz#77-Logrotation existierte laengst, Monitor 54 hatte nie Alarm geschlagen | Blocker: keine +2026-08-01 23:10 | 1 Ticket | #106 Morgenbrief + Gate-Batch lesbar (Brief statt Systemreport: Kopfzeile in ganzen Worten, Geld/Menschen/Tag/Laeuft/Zwei-Minuten; Gate-Batch nummeriert mit Ja-/Nein-Folgen AUS dem Ticket-Text; reiner Text, Umbruch bei 64) | Fund: 50 von 50 Inbox-Nachrichten der letzten 5 Tage waren Maschinen -> Ausschluss gehoert IN die Gmail-Abfrage, sonst bleibt eine Kundenmail hinter dem 50er-Deckel unsichtbar | Rot-Proben 3/3 (MCP tot, Mollie tot, gh tot) + Breiten-Probe 64 | Fallen: read/IFS-TAB schluckt Leerfelder; jq -n mehrzeilig stirbt in MSYS-argv; state=error != fehlt (stille 0); laufendes Bash-Script nicht editieren | Blocker: keine diff --git a/.empire/tools/lagebild.sh b/.empire/tools/lagebild.sh index 078f88b376..dea02ec070 100644 --- a/.empire/tools/lagebild.sh +++ b/.empire/tools/lagebild.sh @@ -413,12 +413,17 @@ block_pay() { block_error pay "Mollie /payments HTTP $code ohne verwertbare Liste"; return fi - local since30 + local since30 since24 since30="$(date -u -d '-30 days' '+%Y-%m-%d' 2>/dev/null || date -u -v-30d '+%Y-%m-%d')" + # Der Morgenbrief fragt „was hat sich seit gestern bewegt", nicht „wie war der + # Monat". Ohne eigenes 24-h-Fenster müsste er das aus `last_payments` raten — + # und das sind nur die letzten DREI, also eine stille Untergrenze. + since24="$(date -u -d '-24 hours' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-24H '+%Y-%m-%dT%H:%M:%SZ')" jq -n \ --argjson amounts "$SHOW_AMOUNTS" \ --arg since30 "$since30" \ + --arg since24 "$since24" \ --slurpfile subs "$TMP/subs.json" \ --slurpfile pays "$TMP/pays.json" ' ($subs[0]._embedded.subscriptions) as $s @@ -431,6 +436,8 @@ block_pay() { # Prüfung ausnahmslos nie begonnene Checkouts waren. | ($p30 | map(select((.status=="failed" or .status=="expired") and (.method != null)))) as $real_fail | ($p30 | map(select((.status=="failed" or .status=="expired") and (.method == null)))) as $never_started + | ($p | map(select(.createdAt >= $since24))) as $p24 + | ($p24 | map(select(.status=="paid"))) as $paid24 | { state: (if ($act|length) == 0 then "warn" elif ($real_fail|length) > 0 then "warn" @@ -444,6 +451,16 @@ block_pay() { last30_total: ($p30|length), last30_failed_after_method: ($real_fail|length), last30_never_started: ($never_started|length), + # 24-h-Fenster für den Morgenbrief. `payments_window_complete` sagt, ob + # die 50 abgeholten Zahlungen überhaupt 24 h abdecken — sonst wäre + # "1 Zahlung" eine Untergrenze, die wie eine gemessene Zahl aussieht. + last24_total: ($p24|length), + last24_paid: ($paid24|length), + last24_failed_after_method: + ($p24 | map(select((.status=="failed" or .status=="expired") and (.method != null))) | length), + last24_paid_eur: (if $amounts == 1 then ($paid24|map(.amount.value|tonumber)|add // 0) else null end), + last24_paid_methods: ($paid24 | map(.method // "unbekannt") | unique), + payments_window_complete: (($p|length) < 50 or (($p|last|.createdAt) < $since24)), mrr_eur: (if $amounts == 1 then ($act|map(.amount.value|tonumber)|add // 0) else null end), reason: (if ($act|length) == 0 then "keine aktive Subscription" elif ($real_fail|length) > 0 diff --git a/.empire/tools/ritual.sh b/.empire/tools/ritual.sh index 1e9bbf6d07..f306ab7a89 100644 --- a/.empire/tools/ritual.sh +++ b/.empire/tools/ritual.sh @@ -40,7 +40,11 @@ MODE="${1:-}"; shift || true POST=0; TG=0; VAULT=0; REDACT=0; DRY=0 BUZZ_CHANNEL="" -BRIEF_LIMIT="${RITUAL_GATE_LINES:-12}" +# Sechs statt zwölf: der Batch ist nur etwas wert, wenn Munir ihn abends +# wirklich durchgeht. Zwölf Entscheidungen sind auf dem Telefon eine Wand und +# werden weggewischt; die Sortierung (P1-money zuerst) sorgt dafür, dass die +# sechs teuersten oben stehen, und der Rest wird gezählt statt verschwiegen. +BRIEF_LIMIT="${RITUAL_GATE_LINES:-6}" SECRETS_FILE="${SECRETS_FILE:-$HOME/.secrets/master.env}" PRIORITIES="${LAGEBILD_PRIORITIES:-C:/Users/rescue/projects/adas-empire/priorities.json}" EXTRA_REPOS="${RITUAL_EXTRA_REPOS:-munirad7s/buzz}" @@ -81,10 +85,23 @@ for t in jq curl gh; do command -v "$t" >/dev/null || { echo "$t fehlt" >&2; exi CR=$'\r' TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT -DATE_DE="$(date '+%a %d.%m.%Y')" +# Der Brief spricht Deutsch — auch im Datum. `date '+%a'` liefert unter MSYS +# "Sat", und ein englischer Wochentag in einem deutschen Brief ist genau die +# Sorte Systemgeruch, die hier weg soll. +DATE_DE="$(printf '%s, %s' \ + "$(printf 'Sonntag Montag Dienstag Mittwoch Donnerstag Freitag Samstag' | cut -d' ' -f$(( $(date '+%w') + 1 )))" \ + "$(date '+%d.%m.%Y')")" CLOCK="$(date '+%H:%M')" +# Eine Lücke gehört dorthin, wo der Leser sie sonst als 0 lesen würde — nicht in +# einen Sammelblock am Ende. Deshalb trägt jede Lücke ihre Zielabschnitte mit +# (Leerzeichen-getrennt): geld · menschen · tag · laeuft · entscheidungen · sonst. GAPFILE="$TMP/gaps"; : > "$GAPFILE" -gap() { printf '%s\n' "$1" >> "$GAPFILE"; } +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/[^.…!?]$/&./'; } +has_gap() { [ -n "$(gaps_for "$1")" ]; } gapcount() { wc -l < "$GAPFILE" | tr -d ' '; } # Woche = ISO-Woche (Mo–So). Der Sonntag 18:00-Lauf schaut auf die Woche, in der @@ -98,11 +115,12 @@ WEEK_LABEL="KW $(date '+%V') ($(date -d "$WEEK_START" '+%d.%m.') – $(date '+%d # (a) Lagebild — buzz#7. Nicht nachbauen, benutzen. collect_lagebild() { - local blocks="$1" - bash "$HERE/lagebild.sh" --format json --blocks "$blocks" > "$TMP/lage.json" 2>"$TMP/lage.err" + local blocks="$1" extra="${2:-}" + # $extra ist bewusst unquoted: es traegt hoechstens das Wort --amounts. + bash "$HERE/lagebild.sh" --format json --blocks "$blocks" $extra > "$TMP/lage.json" 2>"$TMP/lage.err" local rc=$? if [ ! -s "$TMP/lage.json" ] || ! jq -e . "$TMP/lage.json" >/dev/null 2>&1; then - gap "Lagebild (#7) lieferte kein verwertbares JSON (exit $rc): $(head -c 120 "$TMP/lage.err" | tr -d '\n')" + gap "geld laeuft entscheidungen" "Das Lagebild hat nichts geliefert (exit $rc): $(head -c 120 "$TMP/lage.err" | tr -d '\n')" rm -f "$TMP/lage.json"; return 1 fi # Ein Block, der sich selbst als tot meldet, ist eine Lücke — keine 0. @@ -115,13 +133,18 @@ collect_lagebild() { local b for b in backlog n8n server pay; do case ",$blocks," in *",$b,"*) ;; *) continue ;; esac - local st reason + local st reason cat name st="$(jq -r --arg b "$b" '.[$b].state // "fehlt"' "$TMP/lage.json" | tr -d "$CR")" reason="$(jq -r --arg b "$b" '.[$b].reason // "ohne Begründung"' "$TMP/lage.json" | tr -d "$CR")" + case "$b" in + backlog) cat="entscheidungen"; name="der Backlog" ;; + pay) cat="geld"; name="die Zahlungen" ;; + *) cat="laeuft"; name="$b" ;; + esac case "$st" in ok) ;; - warn) [ "$b" = "backlog" ] && gap "Lagebild/backlog unvollständig: $reason" ;; - *) gap "Lagebild/$b NICHT erhoben ($st): $reason" ;; + warn) [ "$b" = "backlog" ] && gap "$cat" "Der Backlog wurde nur unvollständig gelesen: $reason" ;; + *) gap "$cat" "Ich konnte $name nicht messen ($st): $reason" ;; esac done return 0 @@ -138,9 +161,9 @@ repo_list() { --json repository -L "$lim" 2>/dev/null \ | jq -r '.[].repository.nameWithOwner' 2>/dev/null | tr -d "$CR")" if [ -z "$found" ]; then - gap "Repo-Suche (gh search) lieferte nichts — Repo-Menge nur aus priorities.json, evtl. unvollständig" + gap "entscheidungen" "Die Projektsuche hat nichts zurückgegeben — ich kenne heute nur die gepflegte Projektliste, es können Projekte fehlen" elif [ "$(printf '%s\n' "$found" | wc -l | tr -d ' ')" -ge "$lim" ]; then - gap "Repo-Suche am Limit ($lim) — es können weitere Repos mit blocked-munir existieren" + gap "entscheidungen" "Die Projektsuche lief ins Limit ($lim) — es können weitere Projekte mit offenen Entscheidungen existieren" fi { jq -r '.repo_order[].repo' "$PRIORITIES" 2>/dev/null; printf '%s\n' $EXTRA_REPOS; printf '%s\n' "$found"; } \ | tr -d "$CR" | awk 'NF' | sort -u > "$TMP/repolist.txt" @@ -156,8 +179,11 @@ collect_blocked() { [ -z "$repo" ] && continue ( slug="$(printf '%s' "$repo" | tr '/' '~')" + # `body` kommt mit: der Gate-Batch schreibt die Folgen eines Ja und eines + # Nein aus, und die stehen im Ticket selbst (## Mission / ## Money-Link). + # Ohne den Text müsste der Brief sie erfinden — das ist verboten. if out="$(gh issue list -R "$repo" --state open --label blocked-munir -L "$limit" \ - --json number,title,labels,createdAt,url 2>/dev/null /dev/null "$TMP/bl/$slug.json" 2>/dev/null fi ) & @@ -169,46 +195,166 @@ collect_blocked() { [ -z "$repo" ] && continue [ -f "$TMP/bl/$(printf '%s' "$repo" | tr '/' '~').json" ] || unreadable="$unreadable $repo" done < <(repo_list) - [ -n "$unreadable" ] && gap "blocked-munir nicht lesbar für:$unreadable (fehlen in der Summe, zählen NICHT als 0)" + # Eine Liste mit 25 Repo-Namen ist im Brief eine Wand. Zahl + Beispiele + # sagen dasselbe und bleiben lesbar; die Vollstaendigkeit steht im Log. + [ -n "$unreadable" ] && gap "entscheidungen" "$(kuerzeliste "$unreadable" "Projekte konnte ich nicht lesen") — was dort auf dich wartet, fehlt hier (das ist keine Null)" if ! ls "$TMP/bl"/*.json >/dev/null 2>&1; then - gap "blocked-munir: kein einziges Repo lesbar — Gate-Batch hat keine Datenbasis" + gap "entscheidungen" "Kein einziges Projekt war lesbar — ich weiß heute nicht, was auf dich wartet" # KEINE leere Liste erzeugen. Eine leere Liste wäre eine stille Null und # der Batch würde "Heute kein Munir-Gate" melden — die gefährlichste # denkbare Falschaussage dieses Rituals (gemessen als Bug, dann behoben). return 1 fi - jq -s 'add | map({repo, number, url, title, + jq -s 'add | map({repo, number, url, title, body, labels: (.labels|map(.name)), createdAt}) | sort_by( (if (.labels|index("P1-money")) then 0 elif (.labels|index("P1")) then 1 elif (.labels|index("P2")) then 2 else 3 end), .createdAt )' \ "$TMP/bl"/*.json > "$TMP/blocked.json" 2>/dev/null \ - || { gap "blocked-munir: Aggregation fehlgeschlagen"; rm -f "$TMP/blocked.json"; return 1; } + || { gap "entscheidungen" "Die offenen Entscheidungen ließen sich nicht zusammenführen"; rm -f "$TMP/blocked.json"; return 1; } jq -e 'type == "array"' "$TMP/blocked.json" >/dev/null 2>&1 \ - || { gap "blocked-munir: Aggregat ist kein Array"; rm -f "$TMP/blocked.json"; return 1; } + || { gap "entscheidungen" "Die Liste der offenen Entscheidungen kam beschädigt zurück"; rm -f "$TMP/blocked.json"; return 1; } return 0 } -# (c) Inbox — google-mcp über den MCP-Adapter. Kein Send-Pfad, nur lesen. -collect_inbox() { - local out +# (c) Menschen — wer geschrieben hat und wer wartet. +# +# Der Brief zählt keine Nachrichten mehr, er nennt Menschen. Grund, gemessen am +# 01.08.: die 50 neuesten Posteingangs-Nachrichten der letzten FÜNF Tage waren +# ausnahmslos Maschinen — GitHub-CI, das eigene Monitoring, Revolut, Werbung. +# Die alte Zeile "≥50 neue, davon ≥43 ungelesen" maß also reines CI-Rauschen, +# und weil gmail_search bei 50 deckelt, wäre eine echte Kundenmail dahinter +# UNSICHTBAR geblieben. Deshalb wird das Rauschen serverseitig ausgeschlossen, +# bevor der Deckel greift — und das, was danach übrig bleibt, wird namentlich +# genannt statt gezählt. +# Was hier hängen bleibt, ist gemessen und nicht geraten (Absenderliste vom +# 01.08.): Absender, die nie antworten (no-reply, mailer-daemon, Versand- +# Subdomains), Rollen-Postfächer von Dienstleistern (support@, service@, +# invoice…, billing@) — und, der größte Posten, Munirs EIGENE Systeme, die ihm +# in sein eigenes Postfach schreiben (mondsamt@, autopilot@, foerderwerk@, +# kontakt@ auf den eigenen Domains). Ein Kunde schreibt nicht von adas.team. +# Bewusster Preis: schriebe ein echter Kunde von support@seinefirma.de, landete +# er in dieser Zählung. Deshalb steht die Zahl der Aussortierten IM Brief — +# sichtbar falsch ist besser als unsichtbar weg. +MACHINE_RE='noreply|no-reply|no_reply|donotreply|do-not-reply|mailer-daemon|postmaster|bounce|notifications@|notification@|newsletter|@notify\.|@mail\.|@email\.|@e\.|automated|@bot\.|^support@|^service@|^alerts?@|^invoice|^billing@|^team@|^accounts?@|^security@' +OWN_DOMAINS="${RITUAL_OWN_DOMAINS:-adas\.team|adasgroup\.de|adas\.jetzt|adas\.casa}" +PEOPLE_CAP=8 +REST_CAP=3 +collect_people() { + local out q + # Serverseitig weg: eigene Post, Chats, Entwürfe, Werbung, Soziales und die + # beiden gemessen lautesten Absender. Fenster 7 Tage, damit "wartet seit + # vorgestern" überhaupt sichtbar werden kann. + # Der Ausschluss gehoert in die ABFRAGE, nicht hinter sie: gmail_search + # deckelt bei 50, und wenn 48 dieser 50 Plaetze von Maschinen belegt sind, + # bleibt eine echte Kundenmail unsichtbar hinter dem Deckel. Gemessen genau + # so am 01.08. Was hier serverseitig wegfaellt, faellt drinnen nochmal durch + # MACHINE_RE — zwei Netze, weil das Gmail-Matching unscharf ist. + q='in:inbox newer_than:7d -in:sent -in:chats -in:drafts' + q="$q -category:promotions -category:social" + q="$q -from:notifications@github.com -from:adas.team -from:adasgroup.de" + q="$q -from:noreply -from:no-reply -from:mailer-daemon -from:notification" if ! out="$(node "$HERE/mcp-call.mjs" --server google-mcp --tool gmail_search \ --config "$MCP_CONFIG" --timeout 90000 \ - --args '{"query":"newer_than:1d -in:sent -in:chats -in:drafts","maxResults":50}' 2>"$TMP/inbox.err")"; then - gap "Inbox (Gmail via google-mcp) nicht erhoben: $(head -c 140 "$TMP/inbox.err" | tr -d '\n')" + --args "$(jq -cn --arg q "$q" '{query:$q, maxResults:50}')" 2>"$TMP/people.err")"; then + gap "menschen" "Ich komme heute nicht ans Postfach: $(head -c 120 "$TMP/people.err" | tr -d '\n'). Ob jemand geschrieben hat, weiß ich nicht." return 1 fi printf '%s' "$out" | jq -e '.messages' >/dev/null 2>&1 || { - gap "Inbox lieferte kein verwertbares Ergebnis (Antwort ohne .messages)"; return 1; } - printf '%s' "$out" > "$TMP/inbox.json" - # Gmail deckelt bei maxResults. "50 neue" wäre sonst eine stille Untergrenze, - # die wie eine gemessene Zahl aussieht. - if [ "$(jq -r '.messages|length' "$TMP/inbox.json")" -ge 50 ]; then - gap "Inbox-Abfrage am Limit (50) — die Zahl ist eine Untergrenze, kein Gesamtstand" - echo 1 > "$TMP/inbox.capped" + gap "menschen" "Das Postfach hat geantwortet, aber ohne Nachrichtenliste — ob jemand geschrieben hat, weiß ich nicht."; return 1; } + printf '%s' "$out" > "$TMP/mail.json" + [ "$(jq -r '.messages|length' "$TMP/mail.json")" -ge 50 ] && \ + gap "menschen" "Das Postfach lief ins Abfrage-Limit (50) — es können ältere Nachrichten fehlen" + + # Maschinen aussortieren. Die Zahl der Aussortierten wird genannt, nicht + # verschwiegen: sonst sähe ein leerer Menschen-Block wie ein Messfehler aus. + jq --arg re "$MACHINE_RE" --arg own "$OWN_DOMAINS" ' + def addr: (.from | capture("<(?[^>]+)>").a) // .from | ascii_downcase; + def person: (.from | sub(" *<.*>$";"") | sub("^\"";"") | sub("\"$";"") | gsub("^ +| +$";"")); + [ .messages[] | . + {email: addr, person: (if (person|length) > 0 then person else addr end)} ] + | map(. + {machine: ((.email | test($re)) or (.email | test("@(" + $own + ")$")))}) + | {humans: [.[] | select(.machine|not)], machines: ([.[] | select(.machine)] | length)} + ' "$TMP/mail.json" > "$TMP/people-raw.json" 2>/dev/null \ + || { gap "menschen" "Die Post ließ sich nicht auswerten — ob jemand geschrieben hat, weiß ich nicht."; return 1; } + + # Einordnung: zahlender Kunde vor Interessent vor Rest. Zwei Quellen, beide + # nur lesend — Vault-Kundenordner (Zusagen-Kanon) und CRM (Pipeline-Wahrheit). + local vaultdir="${RITUAL_CLIENT_DIR:-$HOME/Documents/Ai_Brain/04 Areas/clients}" + if [ -d "$vaultdir" ]; then + ls -1 "$vaultdir" 2>/dev/null | sed 's#/$##' | grep -v '^_' | grep -v '\.md$' > "$TMP/clients.txt" + else + : > "$TMP/clients.txt" + gap "menschen" "Die Kundenliste im Vault ist nicht lesbar ($vaultdir) — ich kann heute nicht sagen, wer davon zahlender Kunde ist" + fi + + local n; n="$(jq -r '.humans|length' "$TMP/people-raw.json")" + if [ "$n" -gt "$PEOPLE_CAP" ]; then + gap "menschen" "Mehr als $PEOPLE_CAP Menschen im Fenster — ich habe nur die $PEOPLE_CAP jüngsten eingeordnet" + fi + + mkdir -p "$TMP/crm" + local i=0 mail + while IFS= read -r mail; do + [ -z "$mail" ] && continue + i=$((i+1)); [ "$i" -gt "$PEOPLE_CAP" ] && break + ( + for ent in Contact Lead; do + node "$HERE/mcp-call.mjs" --server espo-mcp --tool espo_search \ + --config "$MCP_CONFIG" --timeout 60000 \ + --args "$(jq -cn --arg q "$mail" --arg e "$ent" '{entityType:$e, query:$q, maxSize:3}')" \ + > "$TMP/crm/$i.$ent.json" 2>/dev/null || rm -f "$TMP/crm/$i.$ent.json" + done + ) & + done < <(jq -r '.humans[].email' "$TMP/people-raw.json" | awk '!seen[$0]++') + wait + + # Ein CRM, das nicht antwortet, darf nicht wie "kein Kunde" aussehen. + local crm_ok=1 + if [ "$n" -gt 0 ] && ! ls "$TMP/crm"/*.json >/dev/null 2>&1; then + crm_ok=0 + gap "menschen" "Das CRM hat nicht geantwortet — ich konnte nicht prüfen, wer davon Kunde und wer Interessent ist" fi + + # Die Einordnung bleibt in der Shell: jq soll nicht raten, welche CRM-Antwort + # zu welchem Menschen gehört — das weiß nur die Schleife, die sie geholt hat. + : > "$TMP/people.jsonl" + i=0 + while IFS= read -r mail; do + [ -z "$mail" ] && continue + i=$((i+1)); [ "$i" -gt "$PEOPLE_CAP" ] && break + local klasse="" quelle="" + if [ -s "$TMP/crm/$i.Contact.json" ] && [ "$(jq -r '.total // 0' "$TMP/crm/$i.Contact.json" 2>/dev/null || echo 0)" -gt 0 ]; then + klasse="kunde"; quelle="CRM-Kontakt" + elif [ -s "$TMP/crm/$i.Lead.json" ] && [ "$(jq -r '.total // 0' "$TMP/crm/$i.Lead.json" 2>/dev/null || echo 0)" -gt 0 ]; then + if jq -e '[.list[].status] | index("Converted")' "$TMP/crm/$i.Lead.json" >/dev/null 2>&1; then + klasse="kunde"; quelle="CRM (gewonnen)" + else + klasse="interessent"; quelle="CRM-Lead" + fi + fi + # Vault schlägt CRM: dort stehen die Zusagen, und ein Ordner unter + # 04 Areas/clients existiert nur für jemanden, mit dem es echt läuft. + local dom slug + dom="$(printf '%s' "$mail" | sed 's/.*@//; s/\.[a-z]*$//' | tr 'A-Z' 'a-z')" + if [ -n "$dom" ] && [ -s "$TMP/clients.txt" ]; then + slug="$(grep -i -m1 -- "$dom" "$TMP/clients.txt" 2>/dev/null || true)" + [ -n "$slug" ] && { klasse="kunde"; quelle="Kundenordner $slug"; } + fi + jq -c --arg mail "$mail" --arg k "$klasse" --arg q "$quelle" ' + [.humans[] | select(.email == $mail)] | sort_by(.date) | last + | {person, email, subject, date, unread: ((.labelIds//[]) | index("UNREAD") != null), + klasse: $k, quelle: $q, anzahl: 1}' "$TMP/people-raw.json" >> "$TMP/people.jsonl" 2>/dev/null + done < <(jq -r '.humans[].email' "$TMP/people-raw.json" | awk '!seen[$0]++') + + jq -s --argjson machines "$(jq -r '.machines' "$TMP/people-raw.json")" \ + --argjson crm_ok "$crm_ok" \ + --argjson total "$n" ' + { machines: $machines, crm_ok: $crm_ok, total_humans: $total, + people: (. | sort_by(if .klasse=="kunde" then 0 elif .klasse=="interessent" then 1 else 2 end, + .date) ) }' "$TMP/people.jsonl" > "$TMP/people.json" 2>/dev/null \ + || { gap "menschen" "Die Menschen-Liste ließ sich nicht zusammenstellen"; rm -f "$TMP/people.json"; return 1; } return 0 } @@ -226,15 +372,15 @@ collect_calendar() { --config "$MCP_CONFIG" --timeout 90000 \ --args "{\"calendarId\":\"ALL\",\"timeMin\":\"$from\",\"timeMax\":\"$to\",\"maxResults\":40}" \ 2>"$TMP/cal.err")"; then - gap "Termine (Kalender via google-mcp) nicht erhoben: $(head -c 140 "$TMP/cal.err" | tr -d '\n')" + gap "tag" "Deinen Kalender habe ich heute nicht erreicht: $(head -c 120 "$TMP/cal.err" | tr -d '\n'). Verlass dich nicht darauf, dass der Tag frei ist." return 1 fi printf '%s' "$out" | jq -e '.events' >/dev/null 2>&1 || { - gap "Kalender lieferte kein verwertbares Ergebnis (Antwort ohne .events)"; return 1; } + gap "tag" "Der Kalender hat geantwortet, aber ohne Termine — verlass dich nicht darauf, dass der Tag frei ist."; return 1; } printf '%s' "$out" > "$TMP/cal.json" # Ein einzelner unlesbarer Kalender darf nicht wie ein leerer Tag aussehen. local bad; bad="$(jq -r '.unreadable|length' "$TMP/cal.json")" - [ "$bad" -gt 0 ] && gap "Kalender unvollständig — $bad Kalender nicht lesbar (die Terminliste ist eine Untermenge)" + [ "$bad" -gt 0 ] && gap "tag" "$bad Kalender waren nicht lesbar — die Terminliste ist unvollständig" return 0 } @@ -259,7 +405,7 @@ collect_nest() { local why; why="$(head -c 140 "$TMP/nest.err" | tr -d '\n')" [ -n "$why" ] || why="$(head -c 140 "$TMP/nest.json" | tr -d '\n')" [ -n "$why" ] || why="keine Ausgabe" - gap "Nest-Doctor (#59) lieferte kein verwertbares JSON (exit $rc): $why" + gap "laeuft" "Den Werkzeug-Check konnte ich nicht ausführen (exit $rc): $why" rm -f "$TMP/nest.json"; return 1 fi return 0 @@ -271,13 +417,16 @@ collect_nest() { # Limit sichtbar (Repo am Limit → Lücke, nicht "genau 100"). CLOSED_LIMIT=200 collect_closed() { + # Das Fenster ist ein Parameter: das Wochen-Review fragt nach der Woche, der + # Gate-Batch am Abend nach HEUTE. Zwei Leser, zwei Zeitraeume, ein Sammler. + local seit="${1:-$WEEK_START}" local repo capped="" unreadable="" mkdir -p "$TMP/cl" while IFS= read -r repo; do [ -z "$repo" ] && continue ( slug="$(printf '%s' "$repo" | tr '/' '~')" - if out="$(gh issue list -R "$repo" --state closed --search "closed:>=$WEEK_START" \ + if out="$(gh issue list -R "$repo" --state closed --search "closed:>=$seit" \ -L "$CLOSED_LIMIT" --json number,title,labels,closedAt,url 2>/dev/null /dev/null || echo 0)" -ge "$CLOSED_LIMIT" ] && capped="$capped $repo" done < <(repo_list) - [ -n "$unreadable" ] && gap "geschlossene Issues nicht lesbar für:$unreadable (fehlen in der Summe, zählen NICHT als 0)" - [ -n "$capped" ] && gap "geschlossene Issues am Limit ($CLOSED_LIMIT) in:$capped — die Summe ist eine Untergrenze" + [ -n "$unreadable" ] && gap "sonst" "$(kuerzeliste "$unreadable" "Projekte konnte ich nicht auf fertige Aufgaben prüfen") — sie fehlen in der Summe und zählen NICHT als 0" + [ -n "$capped" ] && gap "sonst" "geschlossene Aufgaben am Limit ($CLOSED_LIMIT) in:$capped — die Summe ist eine Untergrenze" if ! ls "$TMP/cl"/*.json >/dev/null 2>&1; then - gap "geschlossene Issues: kein einziges Repo lesbar — Bewegungs-Block hat keine Datenbasis" + gap "sonst" "Kein einziges Projekt war lesbar — was heute fertig wurde, weiß ich nicht" return 1 fi jq -s 'add | sort_by(.closedAt) | reverse' "$TMP/cl"/*.json > "$TMP/closed.json" 2>/dev/null \ - || { gap "geschlossene Issues: Aggregation fehlgeschlagen"; rm -f "$TMP/closed.json"; return 1; } + || { gap "sonst" "Die geschlossenen Aufgaben ließen sich nicht zusammenführen"; rm -f "$TMP/closed.json"; return 1; } return 0 } @@ -315,11 +464,11 @@ collect_fillers() { local out if ! out="$(gh search issues --owner munirad7s --state open --label ready --label P1 \ --sort created --order asc --json repository,number,title,labels -L 30 2>/dev/null)"; then - gap "Vorschlag: P1-Auffüller nicht erhoben (gh search fehlgeschlagen) — Block 4 kann kürzer als 3 sein" + gap "sonst" "Vorschlag: P1-Auffüller nicht erhoben (gh search fehlgeschlagen) — Block 4 kann kürzer als 3 sein" return 1 fi printf '%s' "$out" | jq -e 'type == "array"' >/dev/null 2>&1 || { - gap "Vorschlag: P1-Auffüller lieferte kein Array — Block 4 kann kürzer als 3 sein"; return 1; } + gap "sonst" "Vorschlag: P1-Auffüller lieferte kein Array — Block 4 kann kürzer als 3 sein"; return 1; } printf '%s' "$out" | jq '[.[] | {repo: .repository.nameWithOwner, number, title, labels: (.labels|map(.name))} | select((.labels|index("blocked-munir"))|not) @@ -332,27 +481,27 @@ collect_fillers() { # sie, ist das eine LÜCKE und keine "0 Bewegung". Genau diese Verwechslung wäre # im ersten Lauf die gefährlichste Falschaussage des Rituals. load_prev_snapshot() { - [ -d "$SNAPDIR" ] || { gap "Vergleichsbasis fehlt — noch kein Snapshot-Verzeichnis ($SNAPDIR). Die Bewegungszahlen unter 3) sind NICHT 0, sondern unbekannt."; return 1; } + [ -d "$SNAPDIR" ] || { gap "sonst" "Vergleichsbasis fehlt — noch kein Snapshot-Verzeichnis ($SNAPDIR). Die Bewegungszahlen unter 3) sind NICHT 0, sondern unbekannt."; return 1; } local prev prev="$(ls -1 "$SNAPDIR"/*.json 2>/dev/null | sort | awk -v cur="$SNAPDIR/$WEEK_ID.json" '$0 < cur' | tail -1)" if [ -z "$prev" ] || [ ! -s "$prev" ] || ! jq -e '.blocked' "$prev" >/dev/null 2>&1; then - gap "Vergleichsbasis fehlt — kein verwertbarer Snapshot vor $WEEK_ID. Die Bewegungszahlen unter 3) sind NICHT 0, sondern unbekannt." + gap "sonst" "Vergleichsbasis fehlt — kein verwertbarer Snapshot vor $WEEK_ID. Die Bewegungszahlen unter 3) sind NICHT 0, sondern unbekannt." return 1 fi cp "$prev" "$TMP/prev.json" PREV_WEEK="$(jq -r '.week // "unbekannt"' "$TMP/prev.json")" # Ein Snapshot, dem Repos fehlten, erzeugt Phantom-"neu" und Phantom-"gelöst". local pu; pu="$(jq -r '.repos_unreadable // [] | join(" ")' "$TMP/prev.json")" - [ -n "$pu" ] && gap "Vergleichs-Snapshot $PREV_WEEK war unvollständig (nicht gelesen: $pu) — neu/gelöst können Messartefakte enthalten" + [ -n "$pu" ] && gap "sonst" "Vergleichs-Snapshot $PREV_WEEK war unvollständig (nicht gelesen: $pu) — neu/gelöst können Messartefakte enthalten" return 0 } write_snapshot() { [ "$SNAPSHOT" = "1" ] || { echo "snapshot: --no-snapshot gesetzt, nichts geschrieben" >&2; return 0; } - mkdir -p "$SNAPDIR" || { gap "Snapshot-Verzeichnis $SNAPDIR nicht anlegbar — nächste Woche fehlt die Vergleichsbasis"; return 1; } + mkdir -p "$SNAPDIR" || { gap "sonst" "Snapshot-Verzeichnis $SNAPDIR nicht anlegbar — nächste Woche fehlt die Vergleichsbasis"; return 1; } local unread="[]" - grep -q 'blocked-munir nicht lesbar' "$GAPFILE" 2>/dev/null && \ - unread="$(grep -m1 'blocked-munir nicht lesbar' "$GAPFILE" | sed 's/.*für://; s/ (fehlen.*//' | tr ' ' '\n' | awk 'NF' | jq -R . | jq -s .)" + grep -q 'Diese Projekte konnte ich nicht lesen' "$GAPFILE" 2>/dev/null && \ + unread="$(grep -m1 'Diese Projekte konnte ich nicht lesen' "$GAPFILE" | sed 's/.*lesen://; s/ — was dort.*//' | tr ' ' '\n' | awk 'NF' | jq -R . | jq -s .)" jq -n \ --arg week "$WEEK_ID" --arg start "$WEEK_START" --arg at "$(date -Is)" \ --argjson blocked "$( [ -f "$TMP/blocked.json" ] && jq '[.[] | "\(.repo)#\(.number)"]' "$TMP/blocked.json" || echo null )" \ @@ -363,7 +512,7 @@ write_snapshot() { blocked:$blocked, blocked_count:($blocked|if .==null then null else length end), closed_count:$closed, pay:$pay, repos_unreadable:$unread}' \ > "$SNAPDIR/$WEEK_ID.json" 2>/dev/null \ - || { gap "Snapshot $WEEK_ID.json konnte nicht geschrieben werden"; return 1; } + || { gap "sonst" "Snapshot $WEEK_ID.json konnte nicht geschrieben werden"; return 1; } echo "snapshot: $SNAPDIR/$WEEK_ID.json geschrieben" >&2 return 0 } @@ -371,10 +520,10 @@ write_snapshot() { # (d) Foki — die drei #-Überschriften aus Now.md. Vault ist Kanon. collect_foki() { if [ ! -f "$NOW_MD" ]; then - gap "Now.md nicht lesbar ($NOW_MD) — Top-3 fehlen"; return 1 + gap "sonst" "Now.md nicht lesbar ($NOW_MD) — Top-3 fehlen"; return 1 fi grep -E '^## #[123] ' "$NOW_MD" | sed -E 's/^## #[123] //' | head -3 > "$TMP/foki.txt" - [ -s "$TMP/foki.txt" ] || { gap "Now.md enthält keine '## #1..#3'-Foki — Top-3 fehlen"; return 1; } + [ -s "$TMP/foki.txt" ] || { gap "sonst" "Now.md enthält keine '## #1..#3'-Foki — Top-3 fehlen"; return 1; } return 0 } @@ -389,179 +538,486 @@ prio_of() { # erstes echtes Prio-Label elif (.labels|index("P3")) then "P3" else "-" end' } -render_gate_lines() { # $1 = Anzahl +# ---------------------------------------------------------------- Sprache +# +# Munir liest den Brief um 08:45 auf dem Telefon, mit einem drei Wochen alten +# Baby und zwei Drittversuch-Klausuren in der Woche. Er hat zwei Minuten. Was +# hier steht, muss in ganzen Sätzen stehen, kurz sein und ohne Fachjargon +# auskommen — Ticket-Nummern gehören in Klammern ans Zeilenende, nie in den +# Satz. Deshalb rendern die Briefe REINEN TEXT: kein Markdown, keine Tabellen, +# keine Codeblöcke. Was auf dem Telefon zerfallen kann, kommt hier nicht vor. + +WOCHENTAG_DE="Sonntag Montag Dienstag Mittwoch Donnerstag Freitag Samstag" +ZAHL_DE="null eine zwei drei vier fünf sechs sieben acht neun zehn elf zwölf" + +zahlwort() { # 3 -> "drei"; ab 13 die Ziffer (Zahlwörter werden dann unlesbar) local n="$1" - jq -r --argjson n "$n" --argjson redact "$REDACT" ' - def prio: if (.labels|index("P1-money")) then "💶P1-money" - elif (.labels|index("P1")) then "P1" - elif (.labels|index("P2")) then "P2" - elif (.labels|index("P3")) then "P3" else "—" end; - .[0:$n] | to_entries[] | - "\(.key+1). \(.value.repo|sub("^munirad7s/";""))#\(.value.number) [\(.value|prio)] — " + - (if $redact == 1 then "‹Titel geschwärzt›" else (.value.title|.[0:95]) end) - ' "$TMP/blocked.json" + [ "$n" -ge 0 ] 2>/dev/null || { printf '%s' "$n"; return; } + [ "$n" -gt 12 ] && { printf '%s' "$n"; return; } + printf '%s' "$(printf '%s' "$ZAHL_DE" | cut -d' ' -f$((n+1)))" +} + +wann() { # Zeitstempel -> "heute um 09:12" / "gestern um 14:40" / "am Mittwoch" + local raw="$1" ts now hm dmsg dnow dyest tage wd + ts="$(date -d "$raw" '+%s' 2>/dev/null)" + [ -z "$ts" ] && { printf 'zu unbekannter Zeit'; return; } + now="$(date '+%s')" + hm="$(date -d "@$ts" '+%H:%M')" + dmsg="$(date -d "@$ts" '+%Y-%m-%d')" + dnow="$(date '+%Y-%m-%d')" + dyest="$(date -d 'yesterday' '+%Y-%m-%d' 2>/dev/null)" + if [ "$dmsg" = "$dnow" ]; then printf 'heute um %s' "$hm" + elif [ "$dmsg" = "$dyest" ]; then printf 'gestern um %s' "$hm" + else + tage=$(( (now - ts) / 86400 )) + wd="$(printf '%s' "$WOCHENTAG_DE" | cut -d' ' -f$(( $(date -d "@$ts" '+%w') + 1 )))" + if [ "$tage" -le 7 ]; then printf 'am %s um %s' "$wd" "$hm" + else printf 'vor %s Tagen' "$tage"; fi + fi +} + +# Markdown und Ticket-Nummern aus fremdem Text (Issue-Bodies) entfernen: der +# Brief zitiert die Tickets, aber er soll nicht nach Ticket klingen. +entmarkdown() { + sed -e 's/\*\*//g' -e 's/`//g' -e 's/\[\([^]]*\)\](\([^)]*\))/\1/g' \ + -e 's/[[:space:]]\+/ /g' -e 's/^ //' -e 's/ $//' +} + +# Telefon-Breite. Ein Satz, der als eine 190 Zeichen lange Zeile ankommt, wird +# von Telegram und vom Buzz-Client irgendwo umgebrochen — meist mitten im Wort +# und ohne Einrückung. Deshalb bricht der Brief selbst um, am Wortende, und +# rückt Folgezeilen ein, damit die Struktur auf dem Telefon stehen bleibt. +BREITE="${RITUAL_WIDTH:-64}" +falte() { + awk -v w="$BREITE" '{ + match($0, /^ */); ind = substr($0, 1, RLENGTH); rest = substr($0, RLENGTH + 1) + if (length($0) <= w || rest == "") { print; next } + cont = ind " " + n = split(rest, word, " ") + line = ind word[1] + for (i = 2; i <= n; i++) { + if (length(line) + 1 + length(word[i]) > w) { print line; line = cont word[i] } + else { line = line " " word[i] } + } + print line + }' +} + +kuerzeliste() { # " a b c d" + "Text" -> "4 Projekte Text (u. a. a, b, c)" + local items="$1" text="$2" n joined + # shellcheck disable=SC2086 — die Wortaufteilung ist hier gewollt + set -- $(printf '%s' "$items" | tr ' ' '\n' | sed 's#^munirad7s/##' | awk 'NF') + n=$# + if [ "$n" -gt 3 ]; then + printf '%s %s (u. a. %s, %s, %s)' "$n" "$text" "$1" "$2" "$3" + else + joined="$(printf '%s\n' "$@" | tr '\n' ',' | sed 's/,$//; s/,/, /g')" + printf '%s %s (%s)' "$n" "$text" "$joined" + fi +} + +kurz() { # $1 Text, $2 Maximallänge — schneidet an der letzten Wortgrenze + local s="$1" n="$2" + s="$(printf '%s' "$s" | entmarkdown)" + if [ "${#s}" -gt "$n" ]; then + s="${s:0:$n}"; s="${s% *}"; printf '%s …' "$s" + else printf '%s' "$s"; fi +} + +titel_klar() { # "[GATE] Foo anlegen — Bar (5-Min-Handgriff Munir)" -> "Foo anlegen — Bar" + printf '%s' "$1" | sed -e 's/^\[[A-Za-z-]*\] *//' -e 's/ *([^()]*)$//' -e 's/^ *//' +} + +ref_of() { # munirad7s/agency-infra + 138 -> "agency-infra 138" + printf '%s %s' "$(printf '%s' "$1" | sed 's#^munirad7s/##')" "$2" +} + +# ------------------------------------------------------- Befunde aus den Daten +# +# Kopfzeile, Läuft-Block und die Zwei-Minuten-Empfehlung müssen dieselbe Lage +# beurteilen. Deshalb wird sie EINMAL ermittelt und dann nur noch formuliert. + +KAPUTT="" # eine Zeile je kaputter Sache +KAPUTT_KURZ="" # dieselbe Sache in Kopfzeilen-Länge +WARTET_NAME=""; WARTET_SEIT=""; WARTET_KLASSE="" +GELD_ALARM="" + +# Euro deutsch: 49.99 ist eine Maschinenzahl, 49,99 ist ein Betrag. +euro() { printf '%s' "$1" | sed 's/\./,/'; } + +befunde() { + KAPUTT="" + if [ -f "$TMP/lage.json" ]; then + local z + z="$(jq -r ' + [ (if (.server.state? // "ok") != "ok" then + "Auf dem Server stimmt etwas nicht: " + + ([ (if ((.server.kuma_down // [])|length) > 0 + then "die Überwachung meldet " + ((.server.kuma_down)|join(", ")) + " als ausgefallen" else empty end), + (if ((.server.containers_unhealthy // 0)|tonumber? // 0) > 0 + then ((.server.containers_unhealthy|tostring)) + " Dienste sind ungesund" else empty end), + (if ((.server.disk_used_pct // 0)|tonumber? // 0) >= 90 + then "die Festplatte ist zu " + (.server.disk_used_pct|tostring) + " Prozent voll" else empty end) + ] | if length == 0 then ["Genaueres steht im Lagebild"] else . end | join("; ")) + "." + else empty end), + (if (.n8n.state? // "ok") != "ok" and ((.n8n.errors_total // 0)|tonumber? // 0) > 0 then + "In der Automatisierung sind " + (.n8n.errors_total|tostring) + + " Läufe schiefgegangen" + + (if ((.n8n.by_workflow // [])|length) > 0 + then ", am häufigsten " + (.n8n.by_workflow[0].workflow) else "" end) + "." + else empty end) + ] | .[]' "$TMP/lage.json" 2>/dev/null | tr -d "$CR")" + [ -n "$z" ] && KAPUTT="$z" + fi + if [ -f "$TMP/nest.json" ]; then + local nz + nz="$(jq -r ' + ([.servers[]|select(.status!="ok")|.name]) as $bad | + [ (if ($bad|length) > 0 then "Werkzeuge fehlen den Agenten: " + ($bad|join(", ")) + "." else empty end), + (if .trust_accepted != "true" then "Die Agenten vertrauen ihrer eigenen Werkzeugliste nicht — sie arbeiten ohne." else empty end), + (if .process_drift == "ja" then "Die laufenden Agenten haben noch den alten Werkzeugkasten; ein Neustart im Desktop holt sie ab." else empty end) + ] | .[]' "$TMP/nest.json" 2>/dev/null | tr -d "$CR")" + [ -n "$nz" ] && KAPUTT="${KAPUTT:+$KAPUTT +}$nz" + fi + + # 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. + 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)" + + GELD_ALARM="" + if [ -f "$TMP/lage.json" ]; then + local f; f="$(jq -r '.pay.last24_failed_after_method // 0' "$TMP/lage.json" | tr -d "$CR")" + [ "${f:-0}" -gt 0 ] 2>/dev/null && GELD_ALARM="$f" + fi + + WARTET_NAME=""; WARTET_SEIT=""; WARTET_KLASSE="" + if [ -f "$TMP/people.json" ]; then + local w + w="$(jq -r '[.people[] | select(.unread) | select(.klasse=="kunde" or .klasse=="interessent")] + | sort_by(if .klasse=="kunde" then 0 else 1 end, .date) | .[0] + | if . == null then "" else "\(.klasse)\t\(.person)\t\(.date)" end' \ + "$TMP/people.json" 2>/dev/null | tr -d "$CR")" + if [ -n "$w" ]; then + WARTET_KLASSE="$(printf '%s' "$w" | cut -f1)" + WARTET_NAME="$(printf '%s' "$w" | cut -f2)" + WARTET_SEIT="$(wann "$(printf '%s' "$w" | cut -f3)")" + fi + fi +} + +# Genau EINE Handlung. Die Reihenfolge ist die Rangfolge: ein wartender Kunde +# schlägt jede Entscheidung, eine Geld-Entscheidung schlägt jede andere. +zwei_minuten() { + if [ -n "$WARTET_NAME" ] && [ "$WARTET_KLASSE" = "kunde" ]; then + printf 'Antworte %s. Zwei Sätze reichen — Hauptsache, es kommt heute etwas zurück.\n' "$(kurz "$WARTET_NAME" 40)" + 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)"' \ + "$TMP/blocked.json" | tr -d "$CR")" + t="$(titel_klar "$(printf '%s' "$e" | cut -f1)")" + r="$(printf '%s' "$e" | cut -f2)"; n="$(printf '%s' "$e" | cut -f3)" + [ "$REDACT" = "1" ] && t="‹Titel geschwärzt›" + printf 'Entscheide das hier: %s (%s)\n' "$(kurz "$t" 60)" "$(ref_of "$r" "$n")" + printf 'Antwort genügt, den Rest übernehmen wir.\n' + return + fi + if [ -n "$WARTET_NAME" ]; then + printf 'Schreib %s kurz zurück — dort wartet jemand.\n' \ + "$([ "$REDACT" = "1" ] && printf '‹Name geschwärzt›' || kurz "$WARTET_NAME" 40)" + return + fi + if [ -s "$TMP/foki.txt" ] && [ "$REDACT" != "1" ]; then + printf 'Nichts Dringendes von außen. Nimm dir: %s\n' "$(kurz "$(head -1 "$TMP/foki.txt")" 62)" + return + fi + printf 'Nichts. Der Tag gehört dir.\n' } +# --------------------------------------------------------------- Morgenbrief + render_morgenbrief() { local out="$TMP/brief.md" + befunde { - printf '🌅 **MORGENBRIEF** — %s, %s (Europe/Berlin)\n' "$DATE_DE" "$CLOCK" - printf '\n**1) Top-3 heute** (Quelle: Vault `99 System/Now.md`)\n' - if [ -s "$TMP/foki.txt" ]; then - local i=1 - while IFS= read -r l; do printf ' #%d %s\n' "$i" "$(trunc "$l" 110)"; i=$((i+1)); done < "$TMP/foki.txt" + # --- Kopfzeile: ganze Worte, keine Zahl, kein Status-Code. Wer nur diese + # Zeile liest, muss trotzdem wissen, woran er ist. + printf 'Guten Morgen. %s\n' "$DATE_DE" + printf '\n' + if [ -n "$WARTET_NAME" ] && [ "$WARTET_KLASSE" = "kunde" ]; then + printf 'Ein Kunde wartet auf dich, seit %s.\n' "$WARTET_SEIT" + elif [ -n "$KAPUTT" ]; then + printf 'Etwas läuft nicht: %s.\n' "$KAPUTT_KURZ" + elif [ -n "$GELD_ALARM" ]; then + printf 'Jemand wollte zahlen und kam nicht durch.\n' + elif [ -n "$WARTET_NAME" ]; then + printf 'Jemand wartet auf eine Antwort, seit %s.\n' "$WARTET_SEIT" + elif [ -n "$(gaps_for menschen)" ] || [ -n "$(gaps_for laeuft)" ] || [ -n "$(gaps_for geld)" ]; then + printf 'Ruhig, soweit ich sehen konnte — ich kam heute nicht an alles heran.\n' + elif [ -f "$TMP/blocked.json" ] && [ "$(jq -r 'length' "$TMP/blocked.json")" -gt 0 ]; then + printf 'Ruhig. Nichts brennt — heute Abend warten Entscheidungen auf dich.\n' else - printf ' ⚠️ LÜCKE — keine Foki gelesen\n' + printf 'Ruhig. Nichts wartet auf dich.\n' fi - if [ -f "$TMP/lage.json" ]; then - local tm - tm="$(jq -r '.backlog.top_money // [] | .[] | " 💶 " + (.repo|sub("^munirad7s/";"")) + "#" + (.number|tostring) + " — " + (.title|.[0:80])' "$TMP/lage.json" | tr -d "$CR")" - [ -n "$tm" ] && { printf ' ältestes offenes P1-money (Backlog):\n'; printf '%s\n' "$tm"; } + + # --- Geld + printf '\n💶 Geld\n' + # NUR ok/warn tragen Zahlen. Ein Block mit state "error" enthält trotzdem + # ein .pay-Objekt, und `// 0` würde daraus "Kein Zahlungseingang" und + # "kein Abo" machen — zwei zuversichtliche Falschaussagen aus einer toten + # Quelle. Genau das trat in der Rot-Probe auf und ist der Grund für diese + # Bedingung; "fehlt" allein zu prüfen reichte nicht. + local pst="fehlt" + [ -f "$TMP/lage.json" ] && pst="$(jq -r '.pay.state // "fehlt"' "$TMP/lage.json" | tr -d "$CR")" + if [ "$pst" != "ok" ] && [ "$pst" != "warn" ]; then + if [ -n "$(gaps_for geld)" ]; then gaps_satz geld | sed 's/^/ /' + else printf ' Ich konnte die Zahlungen heute nicht abfragen. Das heißt nicht, dass nichts kam.\n'; fi + else + jq -r --argjson redact "$REDACT" ' + .pay as $p | + ( if ($p.last24_paid // 0) > 0 then + " " + (if ($p.last24_paid) == 1 then "Eine Zahlung ist eingegangen" + else ($p.last24_paid|tostring) + " Zahlungen sind eingegangen" end) + + (if $redact == 0 and $p.last24_paid_eur != null + then ", zusammen " + (($p.last24_paid_eur|tostring)|sub("[.]";",")) + " Euro" else "" end) + + (if (($p.last24_paid_methods // [])|length) > 0 + then " (" + (($p.last24_paid_methods)|join(", ")) + ")" else "" end) + "." + else " Kein Zahlungseingang in den letzten 24 Stunden." end ), + ( if ($p.last24_failed_after_method // 0) > 0 then + " Achtung: " + (($p.last24_failed_after_method)|tostring) + + " Mal hat jemand die Zahlungsart schon gewählt und ist dann hängengeblieben." + else empty end ), + ( if ($p.subscriptions_active // 0) > 0 then + " " + (if ($p.subscriptions_active) == 1 then "Ein Abo läuft weiter" + else ($p.subscriptions_active|tostring) + " Abos laufen weiter" end) + + (if $redact == 0 and $p.mrr_eur != null + then " und bringt " + (($p.mrr_eur|tostring)|sub("[.]";",")) + " Euro im Monat" else "" end) + "." + else " Es läuft aktuell kein Abo." end ), + ( if ($p.payments_window_complete // true) then empty + else " Ich sehe nur die letzten 50 Zahlungen — ältere von gestern können fehlen." end ) + ' "$TMP/lage.json" | tr -d "$CR" + gaps_satz geld | sed 's/^/ /' fi - printf '\n**2) Termine heute** (alle lesbaren Kalender, bis morgen 06:00)\n' - if [ -f "$TMP/cal.json" ]; then + # --- Menschen + printf '\n✉️ Menschen\n' + if [ ! -f "$TMP/people.json" ]; then + if [ -n "$(gaps_for menschen)" ]; then gaps_satz menschen | sed 's/^/ /' + else printf ' Ich komme heute nicht ans Postfach — ob jemand geschrieben hat, weiß ich nicht.\n'; fi + else + local np; np="$(jq -r '.people|length' "$TMP/people.json")" + if [ "$np" -eq 0 ]; then + printf ' Niemand hat geschrieben.\n' + else + while IFS=$'\t' read -r klasse person subject date unread; do + [ -z "$person" ] && continue + local rolle="" + case "$klasse" in + kunde) rolle=" (Kunde)" ;; + interessent) rolle=" (Interessent)" ;; + esac + if [ "$REDACT" = "1" ]; then person="‹Name geschwärzt›"; subject="‹Betreff geschwärzt›"; fi + printf ' %s%s hat %s geschrieben.\n' "$(kurz "$person" 34)" "$rolle" "$(wann "$date")" + printf ' Betreff: %s%s\n' "$(kurz "$subject" 46)" \ + "$([ "$unread" = "true" ] && printf ' — wartet noch auf Antwort')" + # Kein Feld darf leer sein: `read` mit IFS=TAB behandelt den Tabulator + # als Whitespace und schluckt führende Leerfelder — dann rutscht die + # ganze Zeile um eine Spalte und der Betreff steht als Absender im + # Brief. Genau das ist beim Bau passiert. Also überall ein Platzhalter. + # + # Kunden und Interessenten stehen VOLLSTÄNDIG da; vom Rest nur die + # ältesten drei, damit ein Dienstleister-Postfach keinen wartenden + # Kunden aus dem Blick schiebt. Was übrig bleibt, wird gezählt. + done < <(jq -r --argjson restcap "$REST_CAP" ' + ([.people[] | select(.klasse=="kunde" or .klasse=="interessent")] + + ([.people[] | select(.klasse!="kunde" and .klasse!="interessent")][0:$restcap]))[] | + [ (if (.klasse // "") == "" then "rest" else .klasse end), + (if (.person // "") == "" then "Unbekannt" else .person end), + (if (.subject // "") == "" then "(ohne Betreff)" else .subject end), + (if (.date // "") == "" then "-" else .date end), + (.unread|tostring) ] | @tsv' \ + "$TMP/people.json" | tr -d "$CR") + fi + local nrest nm + nrest="$(jq -r --argjson c "$REST_CAP" \ + '([.people[] | select(.klasse!="kunde" and .klasse!="interessent")]|length) - $c + | if . < 0 then 0 else . end' "$TMP/people.json")" + [ "${nrest:-0}" -gt 0 ] 2>/dev/null && \ + printf ' Dazu %s weitere Absender, keiner davon Kunde oder Interessent.\n' "$nrest" + nm="$(jq -r '.machines' "$TMP/people.json")" + [ "${nm:-0}" -gt 0 ] 2>/dev/null && \ + printf ' Und %s automatische Benachrichtigungen — die habe ich weggelassen.\n' "$nm" + gaps_satz menschen | sed 's/^/ /' + fi + + # --- Dein Tag + printf '\n📅 Dein Tag\n' + if [ ! -f "$TMP/cal.json" ]; then + if [ -n "$(gaps_for tag)" ]; then gaps_satz tag | sed 's/^/ /' + else printf ' Deinen Kalender habe ich nicht erreicht — plane nicht mit einem freien Tag.\n'; fi + else local nev; nev="$(jq -r '.events|length' "$TMP/cal.json")" if [ "$nev" -eq 0 ]; then if [ "$(jq -r '.unreadable|length' "$TMP/cal.json")" -gt 0 ]; then - printf ' ⚠️ 0 in den LESBAREN Kalendern — nicht alle waren lesbar (siehe Block 6)\n' + printf ' In den Kalendern, die ich lesen konnte, steht nichts — aber nicht alle waren lesbar.\n' else - printf ' keine Termine im Fenster\n' + printf ' Keine Termine. Der Tag gehört dir.\n' fi else - printf ' %s Termine:\n' "$nev" - # Ganztägige zuerst: Klausuren, Fristen und Urlaube stehen dort, und ein - # "ganztägig" ohne Uhrzeit sähe sonst wie 00:00 aus. - jq -r ' + # Termine sind Privatsache: unter --redact (öffentlicher Beleg) bleibt + # die Uhrzeit stehen, der Inhalt nicht. + 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 | - ([$a[]| " ▪ ganztägig — " + (.summary|.[0:70]) + " (" + (.calendar|.[0:22]) + ")"] - + [$t[]| " ▪ " + (.start|.[11:16]) + " — " + (.summary|.[0:70]) - + (if .location then " @" + (.location|.[0:28]) else "" end)])[0:12] | .[] + ([ $a[] | " Den ganzen Tag: " + was(.summary) + "." ] + + [ $t[] | (if (.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" - [ "$nev" -gt 12 ] && printf ' … und %s weitere\n' "$((nev-12))" + [ "$nev" -gt 6 ] && printf ' Danach stehen noch %s weitere Einträge im Kalender.\n' "$((nev-6))" fi - else - printf ' ⚠️ LÜCKE — Kalender nicht erhoben (siehe Block 6)\n' + gaps_satz tag | sed 's/^/ /' fi - printf '\n**3) Inbox** (m.muniradas@gmail.com, letzte 24 h)\n' - if [ -f "$TMP/inbox.json" ]; then - local total unread mark="" - total="$(jq -r '.messages|length' "$TMP/inbox.json")" - unread="$(jq -r '[.messages[]|select(.labelIds|index("UNREAD"))]|length' "$TMP/inbox.json")" - [ -f "$TMP/inbox.capped" ] && mark="≥" - printf ' %s%s neue Nachrichten, davon %s%s ungelesen%s\n' "$mark" "$total" "$mark" "$unread" \ - "$([ -n "$mark" ] && printf ' (Abfrage-Limit erreicht — Untergrenze)')" - if [ "$REDACT" = "1" ]; then - printf ' (Absender/Betreffe geschwärzt — öffentlicher Kontext)\n' - else - jq -r '[.messages[]|select(.labelIds|index("UNREAD"))] | .[0:6][] | - " • " + ((.from|sub(" *<.*>$";""))|.[0:32]) + " — " + (.subject//"(ohne Betreff)"|.[0:70])' \ - "$TMP/inbox.json" | tr -d "$CR" - [ "$unread" -gt 6 ] && printf ' …und %s weitere ungelesene\n' "$((unread-6))" - fi + # --- Läuft (nur wenn etwas kaputt ist) + printf '\n⚙️ Läuft\n' + if [ -n "$KAPUTT" ]; then + printf '%s\n' "$KAPUTT" | sed 's/^/ /' + elif [ -n "$(gaps_for laeuft)" ]; then + printf ' Ich konnte die Technik nicht vollständig prüfen:\n' else - printf ' ⚠️ LÜCKE — Inbox nicht erhoben (siehe Block 6)\n' + printf ' Alles läuft. Nichts kaputt.\n' fi + gaps_satz laeuft | sed 's/^/ /' - printf '\n**4) Lage** (Quelle: `.empire/tools/lagebild.sh`)\n' - if [ -f "$TMP/lage.json" ]; then - jq -r ' - def st(x): if x == "ok" then "" elif x == "warn" then " ⚠️" else " ❌" end; - [ (if .backlog then " Backlog:" + st(.backlog.state) + " " + (.backlog.ready_total|tostring) + " ready (" - + ((.backlog.by_prio."P1-money")|tostring) + "× P1-money, ohne blockierte) · " - + (.backlog.in_progress|tostring) + " in-progress" - else " Backlog: ❌ nicht erhoben" end), - (if (.n8n.state? // "") != "" then " n8n:" + st(.n8n.state) + " " - + ((.n8n.errors_total // "?")|tostring) + " Fehler / " - + ((.n8n.executions_in_window // "?")|tostring) + " Executions (24 h)" - + (if (.n8n.by_workflow // []) | length > 0 - then " — häufigster: " + (.n8n.by_workflow[0].workflow) else "" end) else empty end), - (if (.server.state? // "") != "" then " Server:" + st(.server.state) + " " - + ((.server.disk_used_pct // "?")|tostring) + "% Disk · " - + ((.server.containers_running // "?")|tostring) + " Container up · " - + ((.server.containers_unhealthy // "?")|tostring) + " unhealthy" - + (if ((.server.kuma_down // []) | length) > 0 - then " · Kuma rot: " + ((.server.kuma_down)|join(", ")) else "" end) else empty end), - (if (.pay.state? // "") != "" then " Zahlungen:" + st(.pay.state) + " " - + ((.pay.subscriptions_active // "?")|tostring) + " aktive Abos · 30 T: " - + ((.pay.last30_by_status.paid // 0)|tostring) + " bezahlt / " - + ((.pay.last30_total // "?")|tostring) + " Versuche (" - + ((.pay.last30_failed_after_method // 0)|tostring) + " nach Methodenwahl gescheitert)" else empty end) - ] | .[]' "$TMP/lage.json" | tr -d "$CR" - else - printf ' ⚠️ LÜCKE — Lagebild nicht erhoben (siehe Block 6)\n' - fi - if [ -f "$TMP/nest.json" ]; then - jq -r ' - (.servers|length) as $n | - ([.servers[]|select(.status=="ok")]|length) as $ok | - ([.servers[]|select(.status!="ok")|.name + " (" + .status + ")"]) as $bad | - " Werkzeuge:" + (if .exit == 0 then "" else " ❌" end) - + " " + ($ok|tostring) + "/" + ($n|tostring) + " MCP-Server einsatzbereit" - + (if ($bad|length) > 0 then " — rot: " + ($bad|join(", ")) else "" end) - + (if .trust_accepted != "true" then " · Nest NICHT getraut — .mcp.json wird ignoriert" else "" end) - + (if .process_drift == "ja" then " · Agenten laufen mit ALTEM Werkzeugkasten (Neustart im Desktop nötig)" else "" end) - ' "$TMP/nest.json" | tr -d "$CR" - else - printf ' Werkzeuge: ⚠️ LÜCKE — Nest-Doctor nicht erhoben (siehe Block 6)\n' - fi + # --- Wenn du zwei Minuten hast + printf '\n⏱️ Wenn du zwei Minuten hast\n' + zwei_minuten | sed 's/^/ /' + } | falte > "$out" + printf '%s' "$out" +} - printf '\n**5) Deine Entscheidungen** (offene `blocked-munir`)\n' - if [ -f "$TMP/blocked.json" ]; then - local nb; nb="$(jq -r 'length' "$TMP/blocked.json")" - if [ "$nb" -eq 0 ]; then - if grep -q 'nicht lesbar\|am Limit\|lieferte nichts' "$GAPFILE" 2>/dev/null; then - printf ' ⚠️ 0 in den lesbaren Repos — nicht alle waren lesbar (siehe Block 6)\n' - else - printf ' keine offenen Blocker\n' - fi - else - printf ' %s offen — Top 3:\n' "$nb" - render_gate_lines 3 | sed 's/^/ /' - printf ' voller Batch heute 20:45\n' - fi - else - printf ' ⚠️ LÜCKE — blocked-munir nicht erhoben (siehe Block 6)\n' - fi +# ---------------------------------------------------------------- Gate-Batch - printf '\n**6) Lücken** (bewusst benannt, nie als 0 verbucht)\n' - if [ -s "$GAPFILE" ]; then - sed 's/^/ • /' "$GAPFILE" - else - printf ' • sonst keine — alle Quellen haben geliefert\n' - fi - } > "$out" - printf '%s' "$out" +# Die Folgen eines Ja und eines Nein werden dem Ticket ENTNOMMEN, nie erfunden: +# "Ja" ist die Mission des Tickets (der Zustand, den es herstellt), "Nein" ist +# der Satz aus dem Money-Link, der mit "Ohne" beginnt (der Preis des Nichtstuns). +# Fehlt einer der beiden, steht dort ein ehrlicher Platzhalter — kein erfundener +# Nutzen und kein erfundener Schaden. +render_gate_lines() { # $1 = Anzahl — nummeriert, damit "1 ja, 2 nein" funktioniert + local n="$1" + jq -r --argjson n "$n" --argjson redact "$REDACT" ' + def clean: gsub("\\*\\*";"") | gsub("`";"") | gsub("\\[(?[^]]*)\\]\\([^)]*\\)";"\(.t)") + | gsub("\\s+";" ") | gsub("^ +| +$";""); + def section($h): + ("\n" + ((.body // "") | gsub("\r";""))) + | split("\n## ") | map(select(startswith($h))) | (.[0] // "") + | split("\n") | .[1:] | map(select(test("\\S"))) | (.[0] // "") | clean; + def satz1: . as $t + | ([ $t | match("[.!?](\\s|$)"; "g").offset ] | map(select(. >= 25)) | .[0]) as $i + | (if $i == null then $t else $t[0:$i+1] end); + def schnitt($n): if (.|length) > $n then (.[0:$n] | sub(" [^ ]*$";"")) + " …" else . end; + # Klammer-Einschuebe raus, BEVOR gekuerzt wird: sie kosten die Haelfte der + # Zeile und tragen fuer die Entscheidung nichts bei. Erst danach schneiden — + # sonst endet jeder zweite Satz mitten in einer technischen Aufzaehlung. + def entklammert: gsub("\\s*\\([^()]*\\)";"") | gsub("\\s+";" ") | gsub("^ +| +$";""); + def titel: (.title | sub("^\\[[A-Za-z-]*\\] *";"") | sub(" *\\([^()]*\\)$";"")) | clean; + .[0:$n] | to_entries[] | + (.value.repo | sub("^munirad7s/";"")) as $repo | + (.value | section("Mission") | satz1 | entklammert | schnitt(150)) as $ja | + (.value | section("Money-Link") | split(". ") + | map(select(test("^([Oo]hne |Solange |Bis dahin )"))) | (.[0] // "") | clean | entklammert | schnitt(150)) as $nein | + "\(.key+1)) " + (if $redact == 1 then "‹Titel geschwärzt›" else (.value | titel | schnitt(58)) end), + " (\($repo) \(.value.number))", + " Sagst du ja: " + (if $redact == 1 then "‹geschwärzt›" + elif ($ja|length) > 0 then $ja + else "das Vorhaben läuft weiter — was genau dabei herauskommt, steht im Ticket." end), + " Sagst du nein oder gar nichts: " + (if $redact == 1 then "‹geschwärzt›" + elif ($nein|length) > 0 then ($nein | if test("[.!?…]$") then . else . + "." end) + else "es bleibt genau so liegen wie jetzt, und die Frage kommt morgen wieder." end), + "" + ' "$TMP/blocked.json" } render_gate_batch() { local out="$TMP/brief.md" local nb=0; [ -f "$TMP/blocked.json" ] && nb="$(jq -r 'length' "$TMP/blocked.json")" + local zeige="$nb"; [ "$zeige" -gt "$BRIEF_LIMIT" ] && zeige="$BRIEF_LIMIT" { - printf '🔐 **GATE-BATCH** — %s, %s (Europe/Berlin)\n' "$DATE_DE" "$CLOCK" + printf 'Guten Abend. %s\n\n' "$DATE_DE" if [ ! -f "$TMP/blocked.json" ]; then - printf '\n❌ KEINE DATENBASIS — die blocked-munir-Liste konnte nicht erhoben werden.\n' - printf 'Das ist KEIN "heute nichts zu tun". Siehe Lücken.\n' + printf 'Ich konnte heute nicht nachsehen, was auf dich wartet.\n' + printf 'Das heißt ausdrücklich nicht, dass nichts wartet.\n' + gaps_satz entscheidungen | sed 's/^/ /' elif [ "$nb" -eq 0 ]; then - if grep -q 'nicht lesbar\|am Limit\|lieferte nichts' "$GAPFILE" 2>/dev/null; then - printf '\n⚠️ 0 Blocker in den LESBAREN Repos — aber nicht alle Repos waren lesbar.\n' - printf 'Das ist KEIN "heute kein Munir-Gate". Siehe Lücken.\n' + if [ -n "$(gaps_for entscheidungen)" ]; then + printf 'In dem, was ich lesen konnte, wartet nichts — aber ich konnte nicht alles lesen.\n' + gaps_satz entscheidungen | sed 's/^/ /' else - printf '\nHeute kein Munir-Gate — 0 offene `blocked-munir` über %s Repos.\n' "$(repo_list | wc -l | tr -d ' ')" + printf 'Heute nichts zu entscheiden. Nichts wartet auf dich.\n' fi else - printf '\n**%s Entscheidungen offen.** Reihenfolge: P1-money → P1 → P2 → P3, je Gruppe ältestes zuerst.\n\n' "$nb" - render_gate_lines "$BRIEF_LIMIT" - if [ "$nb" -gt "$BRIEF_LIMIT" ]; then - printf '\n…und %s weitere: https://github.com/issues?q=is%%3Aopen+label%%3Ablocked-munir+user%%3Amunirad7s\n' "$((nb-BRIEF_LIMIT))" + # Kopfzeile: wie viele Entscheidungen und wie lange das dauert. + local minuten=$(( (zeige + 1) / 2 )); [ "$minuten" -lt 1 ] && minuten=1 + printf '%s %s, etwa %s %s.\n' \ + "$(zahlwort "$zeige" | sed 's/^./\U&/')" \ + "$([ "$zeige" -eq 1 ] && printf 'Entscheidung' || printf 'Entscheidungen')" \ + "$(zahlwort "$minuten")" \ + "$([ "$minuten" -eq 1 ] && printf 'Minute' || printf 'Minuten')" + printf 'Antworte einfach mit den Nummern, zum Beispiel "1 ja, 2 nein".\n\n' + # Eine Kopfzeile, die sechs Entscheidungen ankündigt, gefolgt von nichts, + # ist die stillste aller stillen Nullen — sie sieht aus wie ein leerer + # Abend. Gemessen beim Bau: ein jq-Compilefehler tat genau das. Deshalb + # wird das Ergebnis erst geprüft und dann gedruckt. + local zeilen; zeilen="$(render_gate_lines "$zeige" | tr -d "$CR")" + if [ -z "$zeilen" ]; then + printf 'Ich konnte die Entscheidungen nicht aufbereiten — sie sind da,\n' + printf 'aber ich kann sie dir heute nicht vorlegen. Bitte im Backlog\n' + printf 'nach der Markierung blocked-munir schauen.\n\n' + gap "entscheidungen" "Die Entscheidungen ließen sich nicht in Textform bringen (Render-Fehler)" + else + printf '%s\n\n' "$zeilen" + fi + if [ "$nb" -gt "$zeige" ]; then + printf 'Es warten noch %s weitere, die heute nicht dringend sind.\n\n' "$((nb-zeige))" fi - printf '\nJede Zeile ist eine Entscheidung, die nur du treffen kannst. Erledigt = Label `blocked-munir` entfernen.\n' + gaps_satz entscheidungen | sed 's/^/ /' fi - printf '\n**Lücken**\n' - if [ -s "$GAPFILE" ]; then sed 's/^/ • /' "$GAPFILE"; else printf ' • keine — alle Repos gelesen\n'; fi - } > "$out" + + # Was heute gelaufen ist — höchstens fünf Zeilen, dann Schluss. + printf '\nWas heute gelaufen ist\n' + if [ ! -f "$TMP/closed.json" ]; then + if [ -n "$(gaps_for sonst)" ]; then gaps_satz sonst | head -2 | sed 's/^/ /' + else printf ' Konnte ich heute nicht zusammenzählen.\n'; fi + else + local ct; ct="$(jq -r 'length' "$TMP/closed.json")" + if [ "$ct" -eq 0 ]; then + printf ' Heute wurde nichts fertig.\n' + else + local top + top="$(jq -r --argjson redact "$REDACT" ' + group_by(.repo) | sort_by(-length) | .[0:3][] | + " " + (.[0].repo|sub("^munirad7s/";"")) + ": " + + (if $redact == 1 then "‹geschwärzt›" + else (.[0].title | sub("^\\[[^]]*\\] *";"") | sub("^P[0-9](-money)?: *";"") + | if length > 40 then (.[0:40] | sub(" [^ ]*$";"")) else . end) end) + + (if length > 1 then " und " + ((length-1)|tostring) + " weitere" else "" end)' \ + "$TMP/closed.json" | tr -d "$CR")" + printf ' %s %s fertig geworden, in %s Projekten:\n' \ + "$(zahlwort "$ct" | sed 's/^./\U&/')" \ + "$([ "$ct" -eq 1 ] && printf 'Aufgabe ist' || printf 'Aufgaben sind')" \ + "$(jq -r '[.[].repo]|unique|length' "$TMP/closed.json")" + if [ -n "$top" ]; then printf '%s\n' "$top" + else printf ' Welche genau, konnte ich nicht auflisten.\n'; fi + fi + fi + } | falte > "$out" printf '%s' "$out" } @@ -668,7 +1124,7 @@ render_wochen_review() { fi printf '\n**5) Lücken**\n' - if [ -s "$GAPFILE" ]; then sed 's/^/ • /' "$GAPFILE"; else printf ' • keine — alle Quellen haben geliefert\n'; fi + if [ -s "$GAPFILE" ]; then gaps_flat | sed 's/^/ • /'; else printf ' • keine — alle Quellen haben geliefert\n'; fi } > "$out" printf '%s' "$out" } @@ -765,16 +1221,20 @@ append_vault() { case "$MODE" in morgenbrief) collect_foki - collect_lagebild "backlog,n8n,server,pay" + # --amounts: der Geld-Block soll sagen, WIE VIEL sich bewegt hat. Der Brief + # geht in Munirs privaten Kanal; fuer oeffentliche Belege schwaerzt --redact. + collect_lagebild "backlog,n8n,server,pay" "$([ "$REDACT" = "1" ] || printf -- '--amounts')" collect_nest collect_calendar - collect_inbox + collect_people collect_blocked BRIEF="$(render_morgenbrief)" CH="${BUZZ_CHANNEL:-$CH_GENERAL}"; LABEL="🌅 Morgenbrief" ;; gate-batch) collect_blocked + # „Was heute gelaufen ist" ist der Abschluss des Tages, nicht der Woche. + collect_closed "$(date '+%Y-%m-%d')" BRIEF="$(render_gate_batch)" CH="${BUZZ_CHANNEL:-$CH_GATES}"; LABEL="🔐 Gate-Batch" ;;