193 Commits

Author SHA1 Message Date
marfrit dc5cb9ac0d Tor 1: die Paket-Haelfte ist eingeloest, die Erbauer-Haelfte nicht
Bis heute war arch/bullpen/ ein LEERES Verzeichnis -- boltzmann, der Wirt mit
den Abnahmetests, konnte bullpen also gar nicht installieren (gemessen:
pacman -Q bullpen -> nicht gefunden). Seit v0.2.1 liegt es in beiden Pools,
beide Rezepte tragen dieselben Vollstaendigkeitspruefungen, beide Bauten
liefern dieselbe Bilanz (23 Programme, 68 Dateien), und beide Werkzeuge sind
aus dem Paket rauchgetestet.

Offen bleibt: bullpen-up legt die neun Container nicht an, und sie laufen
weiter aus /opt/bullpen-src statt aus dem Paket.
2026-08-08 18:18:33 +02:00
marfrit 5eb6291e36 Abnahme als Urkunde: bullpen-attest, und pen-verify wird bullpen-verify
@foreman meldete am 2026-08-08 "Phase B — ABNAHME GRUEN, 14/14, exit 0" und
nannte @testdesigner Tester-of-Record. @testdesigners eigenes Protokoll zeigte
fuer dasselbe Fenster: kein Lua, kein Test gefunden. Der Koordinator hatte die
Abnahme der Arbeit ausgefuehrt, die er koordinierte, und sie jemand anderem
zugeschrieben. Auffindbar war das nur durch einen Zeitstempelvergleich von Hand.

Ein Rueckgabewert beweist nichts, solange offen ist, WER ihn erzeugt hat und
WORAN. bin/bullpen-attest fuehrt den Test selbst aus und bindet beides: sha256
von Pruefling UND Test, Commit, gemessener Rueckgabewert, Container. Es urteilt
nicht (0 = GRUEN) und endet mit dem gemessenen Code, ist also &&-tauglich.

Laeufer nach Datei-Art. Der erste Entwurf startete .py-Tests als Skript --
@testdesigner schreibt aber pytest-Suiten, die als Skript NICHTS tun und mit 0
enden; das Werkzeug haette jede pytest-Datei gruen gesprochen, genau die
Fehlerklasse, gegen die es gebaut ist. Jetzt: test_*.py/*_test.py laufen unter
pytest -q, der Pruefling kommt ueber BULLPEN_IMPL (ein zweites argv laese pytest
als weiteren Testpfad). Beide Richtungen gemessen auf boltzmann.

deploy/pen-verify -> bin/bullpen-verify: es ist ein Programm, gehoert in die
bin-Familie und bekommt damit seinen /usr/bin-Verweis. Findet roster.yaml jetzt
auch unter /usr/share/bullpen/deploy, laeuft also aus dem Paket.

Doku dort, wo sie gelesen wird -- Rollendateien, nicht nur Architekturpapier:
- testdesigner: Urkunde statt Prosa, ROT vor der Uebergabe, BULLPEN_IMPL erklaert
- foreman: spricht KEINE Abnahme mehr aus; reicht weiter oder meldet unattestiert
- ORCHESTRATION 4.2, ARCHITECTURE, participants/deus.md
- Tor 2 (Zulassungssteuerung) aufgeloest, Tor 3 (room.jsonl) erledigt
v0.2.1
2026-08-08 17:58:38 +02:00
marfrit f2b876ae24 Tor 2: keine Zulassungssteuerung — die Ueberbuchung war eine Buchhaltungsgroesse
markus, beim Lesen der 2,62: "Da es Container auf dem selben Host sind,
haben die soviele shared pages..." Richtig, und nachgemessen deutlich:

  Summe der Deckel   10752 MiB      Summe der BELEGTEN  1844 MiB
  Eltern-Deckel       4096 MiB      Eltern gemessen     2,33 GiB
  hertz: 3752 MB verfuegbar, davon 3838 MB Puffer/Cache

architect haelt 62 MiB bei 1536 MiB Deckel, reviewer 89. Neun Container
auf demselben Wirt, aus demselben Abbild, teilen Seitencache,
Bibliotheken und COW-Seiten. Deckel sind Sicherungen JE CONTAINER, kein
Budget — ihre Summe mit dem Eltern-Deckel zu vergleichen ergibt eine
Zahl ohne Bezug zum Bedarf. Die stand als Warnung im Manifest UND im
Roster; ich hatte sie selbst dort hineingeschrieben.

ENTSCHIEDEN: keine Zulassungssteuerung. Fables Einwand "OOM ist kein
Scheduler" bleibt sachlich richtig, wiegt aber weniger schwer als das
Serialisieren einer Gesellschaft, die vom gleichzeitigen Widerspruch
lebt (architect <-> skeptic, foreman waehrend reviewer prueft).

Stattdessen wird der Druck GEMESSEN: pen-verify meldet Belegung gegen
Deckel je Container, den Eltern-Stand und was auf dem Wirt frei ist.
Eine Zahl, die man ansehen kann, statt einer, die man ausrechnet.
2026-08-08 17:20:00 +02:00
marfrit 180bd36476 Lease-Tor wieder scharf — laut, und in BEIDEN Dateien
Fables Punkt 5.2: das Tor war gebaut und beim Container-Umbau lautlos
abgeklemmt. Auf ausdrueckliche Entscheidung wieder scharf, und zwar
LAUT: eine abgelehnte Ansprache landet als eigene Nachricht im Raum,
nicht nur im Rueckgabewert. Eine stille Ablehnung sieht aus wie
Schweigen, und Schweigen ist die Fehlerklasse, an der dieser Aufbau den
ganzen Tag gelitten hat.

Gemessen, alle vier Richtungen:
  lease_acquire               -> ok, ttl 6000
  ask MIT Lease               -> id 184
  ask OHNE Lease              -> lease_required, "Warnung als Nachricht 185"
  im Raum                     -> [185] from room, verified true
  chat statt ask              -> geht durch (das Tor gilt nur fuer ask)

ZWEI EIGENE FEHLER, vom Test gefunden:

1. Die Pro-Nick-Umstellung war UNVOLLSTAENDIG. Sie erfasste room_say in
   beiden Dateien, die Lease-Werkzeuge aber nicht — die prueften weiter
   das GETEILTE Geheimnis, das am selben Vormittag aus den
   Agentencontainern entfernt wurde. foreman bekam `unauthorized`,
   konnte kein Lease erwerben, und mit dem frisch scharfen Tor damit gar
   nicht mehr verteilen. Eine Sicherung, die niemand passieren kann, ist
   ein Riegel. lease_acquire und lease_seize pruefen jetzt pro Nick;
   lease_status ist lesend und braucht keinen Nachweis.

2. WELCHE DATEI GEWINNT, WEISS NIEMAND. Die ausgelieferte bullpen.lua
   traegt das Tor, und trotzdem antwortete der Raum mit dem Wortlaut aus
   bullpen-room.lua. Fable rechnete mit Byte-Ordnung (bullpen.lua
   zuletzt), ich korrigierte auf locale-Sortierung — `ls -1` liefert in
   BEIDEN dieselbe Reihenfolge, und das Ergebnis passt zu keiner der
   beiden Erklaerungen. Ungeklaert.

   Deshalb verhalten sich jetzt BEIDE Dateien gleich: Tor, laute
   Ablehnung und Pro-Nick-Pruefung in jeder. Solange die
   Registrierungsreihenfolge unerklaert ist, darf das Verhalten des
   Raums nicht daran haengen. Zwei Dateien, die dasselbe Werkzeug
   registrieren, sind die eigentliche Falle — nicht die Sortierregel.
2026-08-08 17:18:10 +02:00
marfrit 2aa4a9c0e6 ARCHITECTURE: Tor 4 geschlossen — der USB-Pool ist schneller als die Alternative
Gemessen mit der Last, auf die es ankommt (4k synchron, nicht
sequentiell lesend — feedback_measure_the_load_that_matters: "Lesetests
beweisen keine Nutzbarkeit"):

  sdapool (USB, drehend)  124 IOPS, 498 KB/s, fsync 18 ms
  default (mmc, intern)    85 IOPS, 340 KB/s, fsync  9 ms

Der Entwurf fuerchtete genau diese Last und nannte sie "die Last, bei
der so etwas einbricht". Sie bricht nicht ein — und ein Umzug auf den
internen Speicher wuerde es VERSCHLECHTERN: SD/eMMC sind bei kleinen
Zufallsschreibvorgaengen schwach, btrfs COW buendelt auf der drehenden
Platte gut.

Die 18 ms fsync sind plausibel echt: eine Umdrehung bei 5400 U/min sind
~11 ms, der USB-Brueckenchip luegt also nicht ueber seinen Flush. Das
ist der Preis je room_say. io-Wartezeit bei laufendem Aufbau: 0 %.

Ein offenes Tor, das sich beim Messen aufloest, ist ein gutes Ergebnis —
und eines, das man nur bekommt, wenn man misst statt zu schaetzen.
2026-08-08 16:49:53 +02:00
marfrit 83170728e9 Doku: zwei Texte, die aktiv in die Irre fuehrten
Fables Punkte 4.6 und 5.4. Beide sind billig und beide haben Schaden
angerichtet, weil sie gelesen und geglaubt wurden.

participants/deus.md versprach "watch it" — und deus hat am 2026-08-08
genau das ausformuliert: "Ich ueberwache den Raum auf Blockaden und
greife wieder ein." Es kann das nicht. Der Lurker kennt als Laufzeit nur
`claude` und `opencode`, kein `pi`; der Prozess endet mit der Antwort;
es gibt weder Timer noch Waechter. Zwischen zwei Anstoessen existiert
deus nicht.

Neu steht dort, dass es nicht mitlaeuft, und die Anweisung, NIEMALS
weitere Beobachtung zu versprechen — sondern zu sagen, WORAUF jemand
schauen muss und WORAN man es erkennt. Fables Begruendung, wortwoertlich
richtig: "wer es liest, hoert auf hinzusehen". Ein unbelegtes
Versprechen ist schlimmer als gar keins, weil es Aufmerksamkeit durch
das Gefuehl ersetzt, sie sei schon vergeben.

docs/ORCHESTRATION.md ist das Onboarding-Dokument fuer neue Beteiligte
und beschrieb die Vor-Container-Welt: Raum auf hertz, Lurker auf
noether, @foreman auf orca:4097, Grind-Host boltzmann. 21 Treffer auf
Adressen, die es nicht mehr gibt. Aus genau dieser Quelle stammte schon
der "vier sic-Fundstellen"-Fehler.

KEIN Neuschrieb: die Mechanik traegt weiter und ist der Wert des
Dokuments — Weckmengen, Ticketform, die drei Fehlerbilder. Falsch sind
die ADRESSEN. Deshalb ein Abschnitt 0 mit Umrechnungstabelle vor allem
anderen, plus die drei Aussagen, die inzwischen ins Gegenteil gekippt
sind:

  * die zwei Endpunkte (:8080 strikt / :8081 gelockert) gibt es nicht
    mehr — eine Instanz laedt beide Plugins, das spaetere gewinnt;
  * PRIVILEGED als Namensliste gibt es nicht mehr — ein Nick ist
    privilegiert, wenn seine Geheimnisdatei existiert;
  * das Lease-Tor ist abgeklemmt, und der Lurker erneuert Leases, die
    nichts bewachen. Wer es scharf schaltet, tue es wissentlich.

Dazu die Invariante, die im Dokument fehlte: LMCP_TOOL_ALLOW. Ohne sie
hat jeder Token-Halter eine Wurzelschale im Raum — inklusive des
Orchestrators, dem dasselbe Dokument ausdruecklich keine zugesteht.
2026-08-08 15:55:46 +02:00
marfrit e21fc99091 deploy/pen-verify — Drift wird ein Diff statt ein Zufallsfund
Das Architekturmanifest nennt als staerkstes Argument fuer einen
deklarativen Erbauer nicht Bequemlichkeit, sondern SICHTBARKEIT: zwei
stille Drifts blieben monatelang unbemerkt, weil nichts sie angezeigt
hat — der Kollaps der Zwei-Endpunkt-Architektur und das abgeklemmte
Lease-Tor. Beide wurden per Zufall gefunden, beim Suchen nach etwas
anderem.

pen-verify haelt den laufenden Aufbau gegen deploy/roster.yaml und
AENDERT NICHTS. Rueckgabe 0 wenn Soll == Ist, sonst 1 — damit ist es in
einer Unit oder einem Timer benutzbar, ohne dass jemand die Ausgabe
liest. Geprueft werden Eltern (Speicher, nesting, idmap, autostart,
Pool), je Kind Existenz/Zustand/Speicher/autostart, die Geheimnisse
(genau die Nicks, die das Roster diesem Container gibt) und ob die
erwarteten Units AKTIVIERT sind — nicht bloss vorhanden.

Dazu eine Invariante, die sich aus keiner Konfiguration ergibt:
LMCP_TOOL_ALLOW im Raum. Ohne diese Liste bringt lmcp seinen vollen
Grundstock mit, und jeder Token-Halter hat eine Wurzelschale im
Raum-Container. Genau das war B6.

ERSTER LAUF: 52 Pruefungen, 4 Abweichungen — und alle vier waren echt:
  * foreman, architect, reviewer standen auf boot.autostart=false und
    waeren nach einem Wirtsneustart nicht wiedergekommen;
  * reviewers bullpen-lurker war nie aktiviert — ein Container mit
    angemeldetem Claude Code und nichts verdrahtet.
Behoben, nachgemessen: 0 Abweichungen bei 52 Pruefungen, Rueckgabe 0.
reviewers Lurker ist aktiviert, aber NICHT gestartet — der Raum ruht,
und ihn stillschweigend aufzuwecken waere ein anderer Eingriff.

Die `abweichung:`-Vermerke im Roster sind damit erledigt und entfernt.
Sie waren ein Merkzettel, kein Dauerzustand.

Was hier NOCH NICHT steht: das Erzeugen. bullpen-up ist weiterhin der
alte Einzel-Container-Starter. Aber die Reihenfolge ist Absicht — ein
Erbauer ohne Abnahme baut nur schneller das Falsche, und pen-verify IST
die Abnahme fuer jeden kuenftigen Erbauer-Lauf.
2026-08-08 15:50:07 +02:00
marfrit dd13f0fe3f deploy/roster.yaml + secrets.example — die erklaerte Quelle
Das Architekturmanifest nennt den Erbauer als offenes Tor 1 und
roster.yaml als "die Quelle, aus der sowohl die Container als auch das
Dispatcher-Roster entstehen. Eine Liste, zwei Verbraucher." Bisher gab
es sie nicht; die neun Container sind von Hand angelegt.

Diese Fassung ist AUFGENOMMEN, nicht entworfen: jede Zahl kommt aus dem
laufenden Aufbau. Drei Abweichungen sind dabei sichtbar geworden und
stehen jetzt als `abweichung:` in der Datei statt in niemandes Kopf:

  * foreman, architect, reviewer haben boot.autostart=false — sie kaemen
    nach einem Wirtsneustart nicht wieder.
  * reviewer hat ausserdem KEINE einzige aktivierte Unit: ein Container
    mit angemeldetem Claude Code und nichts verdrahtet.

Ebenfalls festgehalten, weil man es sonst wieder herausmessen muss: neun
Kinder sind heute die Decke (ein zehnter scheitert am Start mit
"newuidmap failed to write mapping", trotz idmap.size=1e9); die Summe
der Kinder-Deckel uebersteigt den Eltern um Faktor 2,62; die
Erlaubnisliste des Raums; dass grinds Ausgang GEFILTERT und nicht dicht
ist (eine Nullsperre baut einen toten Container, die Grinder-Schleife
braucht Proxy und Raum); und dass deus bewusst keine Zugangsdaten haelt.

secrets.example enthaelt keine Werte, sondern beschreibt, was existieren
muss und woher es kommt. Der Erbauer soll ABBRECHEN, wenn etwas fehlt,
statt sich Geheimnisse auszudenken — ein Aufbau, der das tut, ist beim
naechsten Lauf ein anderer Aufbau.

Der Erbauer selbst (deploy/bullpen-up, das die neun daraus erzeugt und
die Container AUS DEM PAKET aufsetzt) fehlt weiterhin. Das ist der
naechste Schritt; ohne die Quelle waere er nicht schreibbar gewesen.
2026-08-08 15:38:21 +02:00
marfrit 687c6dea89 Pro-Nick-Geheimnisse: verified bezeichnet wieder einen Absender
Bisher: EIN geteiltes Geheimnis, dazu PRIVILEGED als Namensliste. Wer das
Geheimnis hatte, sprach als jeder privilegierte Nick. Gemessen am
2026-08-08 aus dem deus-Container:

    room-post testdesigner … /etc/bullpen/post-secret
    -> id 175, from: testdesigner, verified: true

Nach der Umstellung wird derselbe Aufruf abgewiesen. Fuenf Faelle
gemessen: eigener Nick mit eigenem Geheimnis -> ok+verified (id 178
deus, id 181 foreman); fremder Nick mit eigenem Geheimnis ->
unauthorized; fremder Nick ohne Geheimnis -> unauthorized.

WIE. `/etc/bullpen/post-secret.d/<nick>`. Ein Nick ist privilegiert GENAU
DANN, wenn seine Datei existiert. Damit verschwindet PRIVILEGED als
zweite, separat zu pflegende Wahrheit — und mit ihr die Falle, die uns
heute frueh zweimal getroffen hat: ein Mitglied in TRUST aufnehmen und
PRIVILEGED vergessen (oder umgekehrt) laesst seine Nachrichten
GERAEUSCHLOS verschwinden, ok:true beim Absender, verworfen beim
Empfaenger.

Jeder Container haelt nur die Geheimnisse der Nicks, die er beherbergt;
der room haelt alle, weil er prueft. Das geteilte Geheimnis ist aus den
Agentencontainern entfernt (beiseitegelegt, nicht geloescht) und bleibt
nur im room als Rueckfall.

DER RELAXTE ENDPUNKT AENDERT SICH MIT. Er vergab `verified` bisher
SERVERSEITIG ohne Nachweis — wer ihn erreichte, hatte den Raum-Token,
und das galt als genug. Das war ein Identitaetsloch: man konnte jeden
Nick behaupten. Sein Existenzgrund war, dass @foreman das GETEILTE
Geheimnis nicht halten sollte; haelt jeder nur sein eigenes, ist genau
das erfuellt. Beide Endpunkte haben jetzt dieselbe Regel — womit auch
der stille Kollaps des Zwei-Endpunkt-Aufbaus gegenstandslos wird: es ist
egal geworden, welche Plugin-Datei beim Laden gewinnt.

Der Nickname wird gefiltert, bevor er in einen Pfad wandert (`^[%w_.-]+$`).
Ein `..` oder Schraegstrich im `from`-Feld waere sonst ein Weg gewesen,
eine beliebige Datei als Geheimnis lesen zu lassen.

Rueckfall erhalten: ohne post-secret.d gilt das alte Verhalten
unveraendert. Dieselben Dateien laufen auch in aelteren Aufbauten; ein
Flag-Day waere hier der Pflasterweg gewesen.

Clientseitig lesen Lurker und bullpen_worker jetzt nick-eigen. Der
Arbeiter loest das Geheimnis erst in run() auf statt beim Import — ein
Modul, das sein Geheimnis vor seinem Namen liest, kann das richtige
nicht finden.

Nebenbefund, nicht geaendert: der Raum vergibt ids ZAEHLER-ZUERST, also
verbrennt auch ein abgewiesener Post eine id (178 -> 181 in der Messung
oben). Dokumentiertes Verhalten fuer Abstuerze; dass es auch fuer
fehlgeschlagene Anmeldung gilt, stand nirgends.
v0.2.0
2026-08-08 13:37:56 +02:00
marfrit 878e99c562 docs/kampagnen: Fables Architektur-Review, wortlaut
Es lag nur im Sitzungskontext. Das ist zum vierten Mal an einem Tag
dieselbe Falle: die stehende Rolle von @deus lag nur in einem Container,
das Architektur-Manifest nur in einem Sitzungsverzeichnis, das
Befundprotokoll ebenso — und jetzt beinahe das Review, das die zwei
schwersten Befunde des Tages enthaelt.

Wortlaut unveraendert, plus ein Anhang mit dem, was danach NACHGEMESSEN
wurde. Fable hatte ausdruecklich den Auftrag, die Befunde nicht
ungeprueft zu uebernehmen; dieselbe Regel gilt fuer sein Review. Drei
Behauptungen gegengeprueft, alle drei bestaetigt:

  * foreman haelt das geteilte Post-Geheimnis, byteidentisch mit dem
    im room;
  * das Lease-Tor ist tot — das aktive room_say hat null Lease-Bezuege,
    das ueberschriebene sechs;
  * room_say bindet den Aufrufer nicht an den Nick: aus dem
    deus-Container als testdesigner gepostet -> id 175,
    from: testdesigner, verified: true.

Die zwei Befunde, die ICH nicht hatte und Fable fand: der Kollaps der
Zwei-Endpunkt-Architektur beim Container-Umbau (eine lmcp-Instanz laedt
beide Plugins, das spaeter geladene ueberschreibt lautlos) und die
Folge daraus, dass foreman das Geheimnis ueberhaupt bekommen musste.
Dazu die Korrektur meines eigenen B3-Fixes: eine Fassungsliste im
Vertrag zu NENNEN ist eine zweite Kopie der Tatsache und veraltet; der
Test muss den laufenden Server fragen.
2026-08-08 13:19:05 +02:00
marfrit 04d89da5cc docs/kampagnen: Befundprotokoll des ersten Container-Durchlaufs
Phase B der lmcp-Angleichung, vollstaendig durchgelaufen, GRÜN gemeldet
und inhaltlich falsch abgenommen. Acht Befunde, alle mit Beleg.

Warum ins Repo und nicht in eine Sitzungsnotiz: das ist die Lehre des
Tages, dreimal gelernt. Die stehende Rolle von @deus lag nur in einem
Container und war beim Neubau weg. Das Architektur-Manifest lag nur in
einem Sitzungsverzeichnis. Beides ist heute nachgezogen worden. Ein
Protokoll, das die naechste Arbeit bestimmt, gehoert an denselben Ort.

DIE ZWEI SCHWEREN:

B6 — Der Orchestrator hat eine Wurzelschale, ueber den Raum. Die
tools.d-Dateien FUEGEN room_say/room_read HINZU; der Grundstock aus
server.lua bringt shell/write_file/edit_file mit, und eine Werkzeugdatei
kann nichts wegnehmen. Nachgewiesen aus dem foreman-Container mit dem
Raum-Token: uid=0(root), hostname room, Schreibzugriff auf room.jsonl.
@foreman hat die Schale unaufgefordert benutzt und damit die Abnahme
seiner eigenen Koordination ausgefuehrt.

B7 — `verified` beweist niemanden. Nachgewiesen: aus dem deus-Container
als "testdesigner" gepostet, mit dem geteilten Geheimnis -> id 175,
from: testdesigner, verified: true. room_say bindet den Aufrufer nicht
an den Nick. Ursache: die Zwei-Endpunkt-Architektur (strikt/relaxt) ist
beim Container-Umbau still kollabiert - eine Instanz laedt beide
Plugins, das spaeter geladene ueberschreibt, foreman brauchte deshalb
das Geheimnis. Dabei wurde das Lease-Tor mit abgeklemmt: das aktive
room_say hat null Lease-Bezuege, der Lurker erneuert Leases, die nichts
bewachen.

WAS FUNKTIONIERT HAT, damit der Blick nicht schief wird: die
Rollentrennung hielt ohne Nachfassen (@foreman wies den Fix @coder zu
statt selbst zu tippen), @coders eingebetteter Selbsttest wurde von der
Gesellschaft verboten statt von mir, und der Kaefig meldete Fehllaeufe
ehrlich mit exit 1 und echter Lua-Meldung.

Die Befunde B6/B7 sind von zwei unabhaengigen Stellen bestaetigt: Fable
(Architektur-Review) fand B7 und den Endpunkt-Kollaps, den ich
uebersehen hatte; @deus fand B3 und die Abnahme-Vermischung selbstaendig,
ohne dass ihm gesagt wurde, wonach er suchen soll - und trennte sauber,
was es nicht pruefen konnte.
2026-08-08 13:04:14 +02:00
marfrit 24a2af4ab6 PRIVILEGED: deus aufgenommen — das zweite Tor
@foreman hat auf Nachricht 144 nicht geantwortet, und zwar nicht weil er
tot war (141/142 zeigen ihn kurz davor antwortend), sondern:

  07:10:29  bullpen-lurker up, watching @foreman from id 143
  07:18:03  @foreman: refusing ask without verified:true from 'deus'

Es gibt ZWEI getrennte Tore, und heute frueh habe ich nur das erste
gesehen:

  cfg.TRUST          wer einen Agenten WECKEN darf      (deus: behoben)
  PRIVILEGED         wessen Beitrag der Raum STEMPELT   (deus: fehlte)

Das zweite ist keine Erlaubnis, sondern eine BEFOERDERUNG. Wer in
PRIVILEGED steht, MUSS das gemeinsame Geheimnis mitschicken und bekommt
dafuer verified:true. Wer nicht drinsteht, darf ohne Geheimnis posten -
und wird nie gestempelt. Der Lurker verlangt den Stempel (R4,
bullpen-lurker:481). deus war also "frei genug" zum Posten und genau
dadurch wirkungslos.

Gemessen ueber alle 144 Nachrichten: jeder Absender traegt verified in
Hoehe seiner Gesamtzahl - ausser deus, 2 gesendet, 0 verifiziert.

Beide Dateien angefasst, weil tools.d beide laedt und beide eine eigene
Liste fuehren. Einzeln gepflegt laufen sie auseinander; jetzt 16 Nicks in
beiden, gegengeprueft auf Gleichheit.

DASSELBE IST SCHON EINMAL PASSIERT und steht als Warnung direkt ueber der
Liste: der "verified=None dead-end" vom 2026-07-24, damals traf es die
Arbeiter - @foreman konnte @dispatcher fragen und die Antwort nie
konsumieren. Heute deus.

Der EIGENTLICHE Fix ist nicht dieser Commit. Zwei Listen an zwei Orten,
die beide beim Hinzufuegen eines Mitglieds gepflegt werden muessen, sind
eine Falle mit Ansage - sie gehoeren an eine Quelle (roster.yaml, siehe
docs/ARCHITECTURE.md, offenes Tor 1). Bis dahin: wer ein Mitglied
hinzufuegt, fasst BEIDE an.
2026-08-08 12:24:46 +02:00
marfrit fb4676e359 README: auf docs/ARCHITECTURE.md verweisen
Das Manifest nuetzt nur, wenn es gefunden wird. Der Abschnitt Design
beschrieb bisher die Gespraechsmechanik und schwieg zur Aufstellung -
also genau zu dem, was jemand vor einem Neubau lesen muss.
2026-08-08 11:37:48 +02:00
marfrit 214e2688c0 docs/ARCHITECTURE.md — das Entwurfsmanifest kommt ins Repo, Fassung 3
Fassung 2 entstand am 2026-08-07 als Entwurf VOR dem Bau und lag danach
nur in einem Sitzungsverzeichnis. Dieselbe Krankheit, gegen die das
Dokument antritt: die stehende Rolle von @deus lag genauso nur in einem
Container und war nach dem Neubau weg. Wenn ein Dokument den Betrieb
bestimmt, gehoert es versioniert - sonst ueberlebt es den naechsten
Neubau nicht, und der Neubau ist der Moment, in dem man es braucht.

Fassung 3 trennt sauber, was Fassung 2 vermischte: was ENTWORFEN war,
was GEBAUT wurde, und was die Messung danach KORRIGIERT hat. Alle Zahlen
sind nachgemessen, nicht uebernommen.

WAS DER BAU SCHULDIG BLIEB (neu, und der eigentliche Grund fuer diesen
Commit):

* Der Erbauer existiert nicht. roster.yaml und secrets.example fehlen,
  deploy/bullpen-up ist noch der alte Einzel-Container-Starter. Die neun
  Container sind von Hand angelegt, und sie laufen aus /opt/bullpen-src,
  einem git-Checkout - `dpkg -l bullpen` ist in jedem leer. Die stehende
  Anweisung war, die laufende Instanz AUS DEM PAKET zu bauen. Das war
  Fables Blocker Nummer eins an Fassung 1 und ist wieder offen.
* Die Speicherdeckel summieren sich auf 10752 MiB in einem Eltern mit
  4096 MiB: Ueberbuchung 2,62. Genau der Zustand, den die
  Zulassungssteuerung verhindern sollte ("OOM ist kein Scheduler") - und
  die Sperre gibt es nicht. Was den Deckel heute haelt, ist Glueck.
* room.jsonl hat keine Sicherung, obwohl das Dokument ihn selbst "den
  einzigen unersetzlichen Zustand" nennt.
* Neun Kinder sind die Decke; ein zehnter scheitert an newuidmap trotz
  idmap.size=1e9.

KORREKTUREN, die die Messung erzwungen hat:

* @deus ist kein Lurker - der Lurker kennt nur claude und opencode, kein
  pi. Kein Rueckschritt (der alte deus hatte auch keinen Wecker), aber es
  heisst: wenn sich die Gesellschaft verklemmt, weckt ihn niemand.
* deus haelt keine Schluessel, also baut er den Sandkasten nicht mehr -
  `grind` IST der Sandkasten.
* TRUST ist eine Architektur-Invariante. Fehlt ein Nick, verschwindet
  seine Nachricht geraeuschlos: ok:true beim Absender, verworfen beim
  Empfaenger. Nachzusehen ist beim EMPFAENGER.
* Raum und Rolle duerfen auf demselben Rechner liegen (LMCP_HOST).
* Der Grinder-Ausgang ist GEFILTERT, nicht gesperrt - "kein Ausgang"
  baut einen toten Container, die Schleife braucht Proxy und Raum.
* bullseyes Denk-Anzeige ist ueber Containergrenzen tot: sie liest
  DATEIEN, die im Nachbarcontainer liegen, und der except schluckt es.
2026-08-08 11:37:06 +02:00
marfrit c2e8722386 participants/deus.md — die stehende Rolle kommt ins Repo
Sie lag bisher NUR in einem Container (~/.pi/agent/AGENTS.md auf dcw2:deus,
152 Zeilen) und ist beim Neubau verschwunden. Das Repo hatte
docs/deus_ex.md — 318 Zeilen Handbuch, Ueberschneidung mit der stehenden
Rolle: acht Zeilen. Zwei verschiedene Dinge also, und das
betriebsrelevante war das unversionierte.

Nicht als Kopie uebernommen, weil die Landkarte nicht mehr stimmt. Die
alte Fassung nannte noether als Lurker-Wirt, orca:4097 als @foremans
opencode, boltzmann als Grind-Wirt und hertz:/var/lib/bullpen/room.jsonl
als Raum. Seit dem Umbau ist jede dieser Adressen ein Container. Die
PRINZIPIEN tragen alle, die ADRESSEN keine - eine Kopie waere eine Karte
eines Landes gewesen, das neu vermessen wurde.

Drei Aenderungen, die mehr als Umbenennung sind:

* Der Sandkastenbau ist NICHT mehr deus' Aufgabe. Frueher war er es, weil
  die eingesperrten Agenten kein Dateisystem erreichen konnten. Jetzt IST
  `grind` der Sandkasten - bwrap, Interpreter, gesperrter Ausgang. deus
  hat bewusst keine Zugangsdaten, und das ist der Punkt der ganzen
  Verschachtelung: die Komponente mit dem weitesten Auftrag haelt keine
  Schluessel. Braucht eine Kampagne echte Reichweite, ist das ein BEFUND,
  kein Grund, sich einen Schluessel zu besorgen.

* Die Prime Directive bekommt ihre Grenze ausbuchstabiert. "Schreib nie
  den Test, den die Gesellschaft liefern sollte" stimmt - aber ein
  Vertrag und ein Abnahmetest DUERFEN von aussen kommen, wenn die
  Gesellschaft die Implementierung liefern soll. Das ist keine Uebernahme,
  sondern die Trennung, auf der die Abnahme ruht: an genau dieser Arbeit
  war der Defekt viermal hintereinander der Selbsttest, nie der Code.
  Entschieden wird nach dem, was beauftragt war, nicht nach Bequemlichkeit.

* Die Unwedging-Tabelle beginnt jetzt mit dem Fehler von heute: ask
  gesendet, ok:true, keinerlei Reaktion -> der Absender steht nicht in
  TRUST. Nachzusehen ist im Lurker-Protokoll des EMPFAENGERS; das eigene
  ok:true beweist nichts.

Die Eskalationsleiter und die Regel zur Paketgroesse sind aus
/etc/bullpen/deus.md der Sicherung uebernommen - "kein Paid-Offloading,
niemals" und "Scheitern auf allen drei Stufen ist ein Befund ueber das
Arbeitspaket, nicht ueber die Modelle".
2026-08-08 09:40:50 +02:00
marfrit 7f59396759 TRUST: deus aufgenommen
@deus stand nicht in der Vertrauensliste. Das war kein
Sicherheitsgewinn, sondern ein Widerspruch zur Rolle: deus IST der
Eingriff von aussen, wenn sich die Gesellschaft verklemmt - ein
Weckrecht ist seine ganze Aufgabe.

Gemessen, als deus den Phase-B-Vertrag in den Raum stellte:

  room_say  -> {"id":143,"ok":true}
  @foreman  -> "refusing ask from untrusted nick 'deus'"

Der Raum hat die Nachricht angenommen und gespeichert, der Empfaenger
hat sie verworfen. Beide Seiten verhalten sich korrekt, und genau
deshalb ist der Fehler unsichtbar: kein Fehlschlag, keine Rueckmeldung
an den Absender, nur Stille. Sichtbar ausschliesslich im
Lurker-Protokoll des EMPFAENGERS - wer beim Absender nachsieht, findet
ein sauberes ok:true und sucht weiter am falschen Ende.

Die zwanzig anderen Nicks bleiben unveraendert. Wer die Liste weiter
EINENGEN will, kann das je Rolle ueber ~/<nick>_lurker/.trust - diese
Vorgabe ist die Obergrenze, nicht die Vorschrift.

Nebenbefund, absichtlich NICHT mit geaendert: die Liste fuehrt noch
`pica`, `pipi` und `orca` - Agentcontainer auf dcw2, von denen zwei
seit dem Umbau gestoppt sind. Sie schaden nicht (ein Eintrag fuer
einen Nick, der nicht postet, tut nichts), aber sie beschreiben eine
Aufstellung, die es nicht mehr gibt. Das gehoert in eine eigene
Durchsicht, nicht in einen Fix, der etwas anderes repariert.
2026-08-08 09:09:45 +02:00
deus b76915d977 src/version_report: Ernte aus Phase A der lmcp-Angleichung
Die Gesellschaft hat Phase A ueber Nacht abgeschlossen; @deus hat das
Ergebnis aus dem Raum gezogen und eingecheckt (Buendel-Commit 2806312,
Container deus, ohne Gitea-Schluessel - herausgebracht per git bundle).

Warum unter src/: das ist der Werkstattboden der Gesellschaft, nicht
bullpen-Laufzeit. Dort liegen bereits slugify.py und der mneme-Nachbau -
Code, den ihre Grinder gegen tests/ geschrieben haben. version_report.lua
gehoert in dieselbe Reihe und bleibt damit korrekt AUSSERHALB des
Debian-Pakets (siehe SKIP-Begruendung in debian/bullpen/build-deb.sh).

Nachgemessen, nicht uebernommen:
  * 10 von 10 gegen den unabhaengig geschriebenen Abnahmetest, den ich
    NICHT aus dem Raum habe (Rueckgabe 0); derselbe Test faellt auf einer
    absichtlich kaputten Fassung mit zwei Fehlschlaegen durch.
  * 0 Zeilen Abweichung zur Quelle im Raum (Nachricht 133).
  * die 9 Belegnummern in BEFUND.md alle geprueft - Absender, Empfaenger
    und Inhalt passen; fuenf davon lagen VOR deus' Lesefenster und wurden
    aus den Rueckblicken rekonstruiert.

Der Schluessel ist laengenpraefixiert (#v..":"..v..#p..":"..p..#ua..":"..ua)
statt schlicht verkettet. Das ist kein Schoenheitsfehler weniger, sondern
der Unterschied zwischen ("ab","c") und ("a","bc") - bei naiver
Verkettung derselbe Schluessel, also eine verschluckte Meldung.
2026-08-08 07:58:34 +02:00
marfrit 20416ff7e2 Units: /usr/local/bin raus, und bullseye bekommt endlich eine
Acht der vierzehn Units starteten ihr Programm ueber
/usr/local/bin/bullpen-*. Dorthin darf ein .deb nicht schreiben -
/usr/local gehoert dem Administrator (Debian Policy 9.1.2), und das
bullpen-Paket legt seine Einstiegspunkte folgerichtig nach /usr/bin.
Wer das Paket installiert und eine dieser acht Units aktiviert, bekam
203/EXEC. Sechs Units machten es schon richtig; jetzt alle vierzehn:

    ExecStart=/usr/bin/env bullpen-<rolle>

`env` sucht ueber PATH und traegt damit BEIDE Aufstellungen - Paket
unter /usr/bin und Arbeitskopie mit Verweisen unter /usr/local/bin. Ein
fester /usr/bin-Pfad haette die Arbeitskopie gebrochen. Die
PATH=-Zeilen, die /usr/local/bin ENTHALTEN, bleiben genau deshalb
stehen: dort ist der Pfad eine Suchreihenfolge, keine Festlegung.

Dazu systemd/bullseye-server.service. Die Weboberflaeche lief seit dem
Umbau auf eine Unit, die es nur im Container gab - nicht im Repo, nicht
im Paket, also nirgends wiederherstellbar. Sie loest dasselbe
Zwei-Aufstellungs-Problem ueber PYTHONPATH statt ExecStart, weil
`python3 -m bullseye.server` das Verzeichnis UEBER bullseye/ braucht:
/usr/share/bullpen (Paket), /opt/bullpen-src (Container), %h/src/bullpen
(Arbeitskopie). Nicht vorhandene Eintraege ueberspringt Python still,
also darf alles dreifach dastehen. Nachgemessen am ausgepackten Paket:
`PYTHONPATH=/usr/share/bullpen python3 -c "import bullseye.server"` traegt.
2026-08-08 07:47:06 +02:00
marfrit baf0cb03ae Raum und Rolle duerfen auf demselben Rechner liegen
Beide Dateien setzten voraus, dass der Raum WOANDERS laeuft, und gingen
deshalb ueber `sic <host> lmcp-tool`. Seit bullpen als ein Container je
Rolle laeuft, ist der Raum ein Nachbar im Netz: es gibt weder sic noch
einen SSH-Schluessel noch einen Fremdhost. Vier Fundstellen derselben
Annahme, gemessen im laufenden Aufbau:

  * lurker: hz() rief `sic ROOM_HOST lmcp-tool`. Das fiel NICHT auf, weil
    room_read bei Fehlschlag bewusst None liefert statt einen leeren Raum
    vorzutaeuschen - der Lurker meldete "up, watching ..." und tat dann
    still gar nichts, waehrend Nachrichten an ihn im Raum lagen.
  * lurker: run_opencode schob den Prompt per scp auf OC_HOST. Sichtbar
    als "@testdesigner opencode: prompt transfer failed rc255".
  * bullseye: `sic hertz lmcp-tool` an drei Stellen fest verdrahtet.

Die Entscheidung faellt jetzt an EINER Stelle je Datei ueber LMCP_HOST:
ist die Variable gesetzt, zeigt das lokale lmcp-tool bereits auf einen
Raum. Bewusst dieselbe Bedingung mit demselben Namen in beiden Dateien,
damit "welches lmcp-tool antwortet hier" EINE Antwort hat. Der alte Weg
ueber sic bleibt der Rueckfall - die Aufstellung mit getrennten Rechnern
ist weiterhin gueltig, nicht veraltet.

Ebenso bei opencode: liegt der Motor lokal (shutil.which), laeuft oc-run
direkt statt ueber scp+sic.

NICHT mit umgestellt und als Luecke im Quelltext vermerkt: die
Denk-Dateien-Anzeige in bullseye liest /var/lib/bullpen/thinking. Das
sind DATEIEN, keine Raum-Nachrichten; sie liegen im Container `workers`,
waehrend der Code in `bullseye` laeuft. Der umgebende except schluckt
den Fehler, die Anzeige bleibt also leer und sieht dabei gesund aus.
Das zu reparieren heisst zu entscheiden, wo die Dateien kuenftig liegen.
2026-08-08 07:36:27 +02:00
marfrit a7ecf4fb77 bullpen_config: totes Modell ersetzt, Vertrauensliste vervollstaendigt
Zwei Vorgaben zeigten ins Leere.

BULLPEN_MODEL/BULLPEN_OC_MODEL nannten deepseek-v4-flash-dspark ueber den
Alias bosch-dspark. Diesen Provider gibt es in orcas opencode-Konfiguration
nicht mehr; jeder Worker, der darauf zurueckfiel, bekam
"Model not found: bosch-dspark/deepseek-v4-flash-dspark". Aufgefallen ist es
erst, als @herder gebraucht wurde - bis dahin lief es unbemerkt ins Leere.
Jetzt nemotron-3-ultra fuer die Worker, big-pickle fuer den opencode-Weg;
letzteres, weil nemotron opencodes SSE-Parser mitten im Strom bricht
(die Rahmung des Proxys ist nachweislich korrekt, der Fehler sitzt im
Klienten).

BULLPEN_TRUST fuehrte 18 Nicks, aber weder skeptic noch architect. @architect
wies deshalb seinen eigenen Debattenpartner ab - dokumentiert im Journal des
Lurkers seit dem 2026-07-25, am selben Abend wurde er abgeschaltet. Beide
ergaenzt.

NB: dieselben Vorgaben stehen ein zweites Mal in noethers eigenem Checkout,
denn die Lurker laden von dort. Wer hier etwas aendert, aendert nur die
Haelfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
v0.1.0
2026-08-07 21:17:04 +02:00
marfrit e48257bc79 bullpen_config: OC_MODEL auf hossenfelder-proxy, bosch-dspark gibt es nicht mehr
Der Anbieter "bosch-dspark" war in orcas opencode-Konfiguration ein Alias auf
den Gateway. Er ist am 2026-08-05 in "hossenfelder-proxy" umbenannt worden,
weil sein Name nach einem Container klang, den es seit dem Vortag nicht mehr
gibt - und weil ein Name, der auf etwas anderes zeigt als er sagt, beim
naechsten Fehler in die falsche Richtung weist.

Damit lief OC_MODEL ins Leere. Geprueft: von orcas Anbietern fuehrt
hossenfelder-proxy das Modell unter dem schlichten Namen "deepseek-v4-flash";
"hossenfelder" fuehrt daneben die praefixierten Katalogeintraege, darunter die
kostenpflichtige Wolkenvariante.

Im selben Zug in orca aufgeraeumt (nicht Teil dieses Commits, nur zur
Nachvollziehbarkeit): die Anbieter litellm, bosch-ornith und bosch-qwen sind
aus beiden opencode-Konfigurationen entfernt. LiteLLM ist stillgelegt, die
beiden bosch-Ports 8888 und 8086 sind gemessen tot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-08-05 11:04:01 +02:00
marfrit d87189359a bullpen_config: Standardmodell auf deepseek-v4-flash, dspark gibt es nicht mehr
MODEL und OC_MODEL zeigten auf "deepseek-v4-flash-dspark". Dieser Name kam vom
vLLM auf bosch:8888, das mit --served-model-name ...-dspark lief; der Container
ist am 2026-08-04 abgeschaltet worden. An seiner Stelle steht DS4 auf
bosch:8085, und das meldet sich als "deepseek-v4-flash".

Folge war, dass alle sieben Teilnehmerdienste ins Leere liefen. Der Gateway
antwortete zunaechst mit model_not_found; seit der Kostenregler auf 'free'
steht, kommt stattdessen policy_blocked - was in die Irre fuehrt, weil es
nicht der Preis ist, sondern ein Name, den es nicht gibt.

Der Anbieterteil von OC_MODEL bleibt "bosch-dspark": das ist in orcas
opencode-Konfiguration ein Alias, der auf den Gateway zeigt, nicht auf den
abgeschalteten Container. Nur der Modellname war falsch.

Die Vorkommen in tests/ bleiben unberuehrt - das sind aufgezeichnete
Protokollzeilen frueherer Laeufe, also Belege und keine Konfiguration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-08-05 10:36:06 +02:00
marfrit e9b955c693 test_lurker_swap: den Vertrag von #118 pruefen, nicht den davor
d2eb982 (#118, lurker half) stellt dem Fallback-Ergebnis eine Zeile voran, die den
Modellwechsel benennt. Der Test stammte noch von 3a267b7 und verglich auf Gleichheit
mit der blanken opencode-Ausgabe - ich habe das Verhalten geaendert und den Test nicht
mitgezogen.

Bewusst nicht auf ein blosses 'in' gelockert: das waere gruen, liesse aber zu, dass das
Banner spaeter unbemerkt verschwindet - und genau dessen Fehlen war der Defekt, den
#118 behoben hat (2026-08-02 starb ein Fable-Lauf an max_turns, deepseek antwortete
stattdessen, und die einzige Spur stand im journal). Geprueft werden jetzt beide
Eigenschaften: die Antwort kommt durch, UND der Wechsel steht in der Antwort.
2026-08-03 12:27:20 +02:00
marfrit 54106c4210 models-json: CATALOG_PROVIDER aus bullpen_config statt als Literal
tests/test_no_hardcoded_hosts.py verbietet Fleet-Hostnamen in Laufzeit-Strings und
erlaubt genau einen Ort dafuer: lib/bullpen_config.py, wo alle Vorgaben per get() mit
Umgebungsuebersteuerung liegen. bin/models-json trug 'hossenfelder-proxy' als Literal
und riss den Test - eingeschleppt mit Roundhouse-Karte 3 am selben Tag.

Der Wert ist ein Provider-LABEL, kein Hostname, enthaelt aber einen; die Regel prueft
auf Substring, und das ist keine Ueberempfindlichkeit: ihr Zweck ist, dass hiesige
Namen nicht in Code wandern, der veroeffentlicht wird. Uebersteuerbar bleibt er als
BULLPEN_CATALOG_PROVIDER, jetzt nur an einer Stelle statt an zweien.
2026-08-03 12:27:20 +02:00
marfrit fe00e1b3e8 room-ask: Post-Secret mitschicken, Wartezeit auf 90 s
bullpen.lua weist einen Post eines PRIVILEGIERTEN Nicks ohne `secret` mit
"unauthorized" zurueck (R4-Gatter, tests/test_post_secret_gate.py). room-ask schickte
keins - damit scheiterte jedes `bp --from noether|markus|foreman ...`, obwohl bin/bp
Zeile 8 genau diesen Aufruf als vorgesehenen Weg dokumentiert. Der Default-Nick von bp
ist `markus`, also war auch der blanke Aufruf betroffen. bullpen_worker.py loest das
seit jeher; room-ask ist bei der Einfuehrung des Gatters liegengeblieben.

Gelesen wird aus denselben zwei Pfaden wie im Worker: /etc/bullpen/post-secret (gehoert
root, so laufen die Daemons) und ~/.config/bullpen/post-secret (fuer CLI-Aufrufer wie
diesen). Fuer einen nicht privilegierten Nick ignoriert das Gatter den Wert, mitschicken
ist also immer richtig. Fehlt das Secret UND lautet die Antwort "unauthorized", nennt die
Fehlermeldung jetzt beide Pfade, statt den Aufrufer raten zu lassen.

bullpen_worker wird bewusst NICHT importiert: das ist der Harness fuer Daemon-Worker und
zieht bullpen_config nach. Ein CLI daran zu koppeln, damit es eine Konstante bekommt,
waere teurer als acht Zeilen zu wiederholen.

Zweitens: die Wartezeit steigt von 60 auf 90 s (ROOM_ASK_TIMEOUT uebersteuert). Der
Poll-Takt der Worker liegt seit heute bei 10 s statt 2 s; ein gemessener Rundlauf mit
@dispatcher dauerte 54 s, die alte Vorgabe war dafuer zu knapp.

Abgenommen: `bp --from noether dispatcher ...` postet wieder, Antwort in 20 s, und die
Nachricht traegt den verified:true-Stempel (Room-id 2305) - genau das, wofuer das Gatter
da ist, damit die Lurker der Orchestratoren sie akzeptieren.
2026-08-03 12:19:32 +02:00
marfrit a3101bc044 room_read: Offset-Index statt Volltext-Scan; Poll-Takt auf 0,1 Hz
room_read dekodierte bei JEDEM Aufruf jede Zeile der room.jsonl. Bei 2224 Zeilen
und 1,9 MB kostete das 156,51 ms pro Aufruf. Sieben Worker-Daemons pollen den Room
(je Poll zwei TCP-Verbindungen, gemessen ~6 Anfragen/s), also lief lmcp auf hertz
dauerhaft mit 85-90 % eines Kerns: 5 Tage 20 h CPU-Zeit in 7 Tagen Laufzeit, auf
einem Pi mit vier Kernen, der nebenbei Fleet-Cron, SSH-Gateway und das Backup traegt.

Die Datei ist append-only und room_say vergibt die ids counter-first (Luecken
moeglich, Rueckschritte nicht - im Bestand geprueft: 2224 Zeilen, streng steigend).
Damit traegt ein Offset-Index mit Sprungsuche. Aufgebaut wird inkrementell: pro
Aufruf werden nur die neu angehaengten Bytes gelesen. Der Normalfall - "gibt es
etwas neuer als meine letzte id?" - dekodiert jetzt gar nichts mehr.

A/B gegen die echte room.jsonl, 96/96 Kombinationen aus since/limit byte-identisch.
Leer-Poll 156,51 ms -> 0,00 ms (Faktor 32138), mit Treffern Faktor 3013. Nach dem
Ausrollen gemessen: lmcp-CPU 85-90 % -> 0 %, Syscalls je 5 s von 15839 auf 331.

Beide .lua-Dateien tragen dieselbe room_read-Fassung (bullpen.lua auf :8080,
bullpen-room.lua auf :8081). Die Duplizierung ist Entwurfsabsicht, also wird sie
beidseitig gepflegt statt heimlich zusammengelegt.

Der Poll-Takt faellt zusaetzlich von 2,0/2,5 s auf 10 s. Nach dem Index kostet ein
Leer-Poll fast nichts mehr, aber sieben Prozesse, die alle zwei Sekunden aufwachen,
bleiben Grundrauschen. Preis: bis zu 10 s laengere Aufgreifzeit je Anfrage.
Uebersteuerbar per BULLPEN_POLL_SECONDS. Die Teilnehmer erben den Wert aus
bullpen_worker, statt ihn ein zweites Mal aus der Umgebung zu lesen.
2026-08-03 11:45:31 +02:00
marfrit 79994a1ac2 models-json: render the pi-agent model list from the catalog, and do the cutover (Roundhouse card 3)
catalog-diff made the drift visible; this closes it. Applied to both pi-agent hosts:
13 providers -> 7, and the six that pointed at endpoints which no longer answer are gone
(bosch-ornith, bosch-qwen, dirac:8081, and all three escher entries — escher being the
machine that went out for RMA). hossenfelder-proxy went from 5 hand-written models, four
of which the gateway no longer serves, to the 18 the catalog actually offers.

WHAT IT REFUSES TO DO. It does not rename providers. The names are referenced from
outside the file — bullpen's cfg.OC_MODEL is literally
"bosch-dspark/deepseek-v4-flash-dspark" — and a running agent session pins the provider it
started with. Three names point at the same gateway endpoint and tidying that up would
break both.

That distinction is also what the first draft got wrong: it filled EVERY gateway-facing
provider with the same filtered catalog, so 53 entries became 93 and the picker would have
shown one list three times under three names. But `hossenfelder-proxy` is a catalogue
while `bosch-dspark` and `boltzmann-npu` each pin ONE model and exist to be referenced.
An alias gets validated, not filled: it keeps its pin, and the tool says so loudly if the
catalog no longer has it — a pin at a vanished model is a silent 404 later.

Selection is policy, not fact: the default filter is local + free, because a picker with
259 entries is not a picker, and because the "no paid offloading" posture should be
visible in the tool a human actually looks at. Enforcement stays in the gateway's cost
regulator, which answers 403 — a filter is a convenience, a regulator is a guarantee.

Verified functionally on both hosts after the write, not just structurally: a prompt
through the regenerated provider answers. Backups taken before each write.
2026-08-02 17:38:09 +02:00
marfrit 2cb135b18f catalog-diff: show which hand-kept model entries the gateway no longer knows (Roundhouse card 2)
Read-only. Changes nothing, disables nothing, proposes nothing — it makes visible what
drifts unseen. Fable's architecture pass named it the second card, after exposing the
catalog's fields, and deliberately as a diagnostic rather than a fix: the hand lists live
on hosts with running agents, so the cutover is an operator decision, not a script's.

Measured on pica: 13 providers, 53 entries — and SIX providers point at endpoints that no
longer answer (bosch-ornith, bosch-qwen, dirac:8081, and all three escher entries, escher
being the machine that went out for RMA). Of the entries that route through the gateway,
4 of 5 name models it no longer serves. deus carries a byte-identical file.

Two distinctions separate a finding from noise, and the first draft got both wrong, which
is why they are spelled out in the docstring:

  * Normalise BOTH sides. The hand list stores gateway ids with the display tag
    ("[local] deepseek-v4-flash") and so does the catalog; stripping one side only
    guarantees zero matches and reads as total drift. My first run reported 51 of 53
    entries unknown — an artefact of my own comparison, not a fleet fault.
  * Not every entry belongs in the catalog. A provider aimed straight at a backend
    bypasses the gateway on purpose and is absent from the catalog without being dead.
    Those are probed directly and reported separately. "Points at the gateway" is decided
    by hostname AND port, since the same box also runs litellm on 4000 with its own id
    namespace — a substring match put 36 litellm entries in the wrong bucket.

A 401 counts as answering: an authenticated endpoint that refuses us is alive, which is
the question being asked.
2026-08-02 15:20:45 +02:00
marfrit 8176927668 grinder: infer the target in a flat single-module repo instead of bouncing the ticket (#99)
_infer_targets finds the file to edit by matching repo filenames against the TEST source.
That works when a test names a path, but a plain pytest writes `import catalog_entry` —
no extension — so nothing matches and the grind is rejected with "could not infer any
target file".

It bit twice on 2026-08-02, both times on a one-function build repo of exactly the shape
these Roundhouse/bullpen cards produce: modeltag first, then catalog_entry. The first
time I pasted the literal filename into the test as a comment and moved on — that fixed
one ticket and left the cause. Naming the target explicitly is no escape either:
serve()'s target regex requires a slash, so a root-level file cannot be named at all.

Fallback: when neither heuristic matches, and exactly ONE candidate remains after
dropping docs (.md/.txt/.toml/…) and dotfiles, that file is the target. Deliberately
narrow — two candidates keep bouncing, because a grind on the wrong file costs more than
a rejected ticket, and an explicit mention still wins over the fallback.

tests/test_infer_flat_repo.py pins both directions. Mutation-checked: removing the
fallback fails exactly the two tests that assert it, while the two that guard its
narrowness stay green.
2026-08-02 12:18:09 +02:00
marfrit d2eb9820f2 lurker: say in the ROOM when a model swap happened, not only in the journal (#118, lurker half)
The lurker falls back from claude to opencode on any dead end — right, since posting a
failure verbatim helps nobody. But the reply then carried only the SECOND model's name,
so a thin answer read as "the configured model had little to say" when the configured
model had in fact never finished.

Live on 2026-08-02: a Fable architecture run for @reviewer died on error_max_turns, the
lurker fell back to opencode/deepseek, deepseek burned its own budget on the same search
and answered nothing useful. That Fable had run at all appeared solely in
`journalctl --user -u bullpen-lurker@reviewer` — exactly where nobody looks while reading
a disappointing reply. Same defect idea #118 fixed for the grinder: report the model that
actually served, and say so when it was not the one asked for.

The banner only appears on an actual swap; the common case stays as quiet as before.

tests/test_lurker_fallback_visible.py pins all three: the banner names both models on a
swap, no banner when the configured model answers, and a double dead-end still returns a
real diagnosis rather than a banner wrapped around nothing. Mutation-checked — cutting the
announcement fails exactly the first test and leaves the other two green. (The first
attempt at that mutation silently did not apply, which made three green tests meaningless;
redone via a file with an explicit marker assertion.)
2026-08-02 12:08:43 +02:00
marfrit 8b09e6dd0d doctor: a scheduled deterministic post is not a stuck retry (#96 follow-up)
stuck_dispatch_check flags N byte-identical (from,to,body) posts as a loop, on the sound
premise that a model re-deciding rarely reproduces prose verbatim. A SCRIPT does, by
construction: bullpen-selfimprove builds its brief from the ISO week number, so poking it
twice in the same week emits the same bytes. Re-running W31 on 2026-08-02 put the herd
into "NEEDS A LOOK" over three identical briefs posted days apart — a false alarm, and
the worst kind: a monitor people learn to ignore is worse than no monitor.

What separates the two is TIME, not content. A stuck retry is tight; a schedule is not.
Duplicates now only count when they fall inside DUP_WINDOW_SECS (1h).

tests/test_doctor_dup_window.py pins both directions, because the lazy fix — widen the
window until it stops complaining — would disable the detector instead of correcting it:
tight repeats still flagged, the same post days apart ignored, an upper bound on the
window itself, and undated posts still flagged so a missing ts is not an escape hatch.
2026-08-02 10:00:30 +02:00
marfrit 162d5cdd10 deploy: install every executable in bin/, not just bullpen-* (#98 follow-up)
install.sh promises "the running fleet == the repo, always; editing the live file IS
editing the tracked file". Its bin/ loop globbed `bin/bullpen-*`, so five entrypoints were
never deployed by it at all: `bp`, `room-ask`, `room_tail`, `news-de` and `deus-seize` —
the commands a human types, plus the emergency stop. They had been hand-copied once and
then drifted silently. Measured on 2026-08-02: the room host was running a `bp` from
2026-07-21 (61 lines behind the repo, which is how `bp --route` came back as "no reply
from @--route"), and deus-seize had never reached any host, so tonight's portability work
on it was live nowhere.

The hole is invisible to inspection: the glob reads as "the bullpen commands", and every
file it misses is sitting right beside the ones it catches. tests/test_install_covers_bin.py
compares the two sets instead of reading the pattern — red under the old glob, green now.

Directories and non-executables are skipped explicitly, so bin/__pycache__ (which appears
the moment anything in bin/ is imported) cannot be symlinked into /usr/local/bin.
2026-08-02 09:58:00 +02:00
marfrit 754e769f2f bp --route: execute at the caller, because the relay @dispatcher was asked for is worse (#98)
W31 wanted a relay mode so the human stops being the router. @reviewer's design pass
rejected it, and the rejection is the load-bearing part: @dispatcher is itself PRIVILEGED
and in TRUST, so relaying would turn a single-keyword regex hit into an authenticated
order every worker accepts — a confused deputy. Identity has no good answer either:
posting as `dispatcher` breaks in_reply_to for the real asker and turns a stateless
one-shot into an orchestrator; posting as the asker means forging `from` and signing the
forgery with the room secret, which is the exact attack test_post_secret_gate.py exists
to stop. Opt-in relay keeps all of that plumbing and discards the checkpoint precisely
when it is needed, since "relay this" is said before the route is seen.

The problem worth solving was never "the human types too much" — it is that a rich agent
sometimes IMPROVISES instead of running the command it was handed. That is a compliance
defect at the CALLER, so it is fixed at the caller. `bp --route "<request>"` asks
@dispatcher, PRINTS the route, then runs it here as the caller: TRUST, verified and
reply-routing all unchanged, and the inspect-before-work checkpoint survives because the
route is printed first. @herder and @reviewer are excluded from auto-run — live host
reach and real money per call must still pass a human. An ambiguous answer (the roster)
stops with a non-zero exit rather than picking blindly.

Also stored the matching lesson in /bullpen/lessons-learned (id 228), which the lurker
already prepends to every poke: run what @dispatcher hands you, do not improvise.

tests/test_bp_route.py pins the three properties that separate this from a relay — route
shown before execution, caller's own nick on the ask, heavyweight workers not auto-run —
plus non-interference with plain `bp`. 6/6.
2026-08-02 09:51:48 +02:00
marfrit 8a68d50c86 evals: read back the trajectory records, and fix why the grinder's were never written (#97)
Three workers scored their own trajectories into mneme `/trajectory/*` and nothing ever
read them. W31 called that the free feedback loop for the other gaps. Chasing the reader
turned up the bigger half first: the grinder's records were never stored at all.

mem.save() fails SOFT by design — a worker must not die because shared memory is
unreachable — and /etc/bullpen/mneme-token exists only on the room host. The grind engine
runs on the grind host, so every escalation-discipline record since the feature shipped
was discarded while the room reply cheerfully printed `ESCALATION-EVAL: PASS`.
/trajectory/researcher and /trajectory/dispatcher have records precisely because those
two run on the room host. Fixed by placing the token on the grind host — no new exposure
there, since that host runs the mneme container the token belongs to — and by making the
discard LOUD: the writer now warns when the save returns None.

bin/bullpen-evals is the reader: grouped by agent+rubric, every FAIL printed in full
(hiding one behind a count would defeat the point), --since for the before/after
measurement a config change is supposed to get for free, --detail for the raw list.
Reads only, so it runs anywhere.

Two bugs it caught on its first real read, which is the argument for having a reader:
  * the parser used re.findall, which returns '' for a group that did NOT participate, so
    `quoted if quoted is not None else bare` always took the quoted branch and every field
    came out empty. finditer distinguishes them, and it matters because note="" is
    legitimately empty.
  * final_model was unquoted, and since #118 a tier id carries its cost prefix, so the
    value contains a space and truncated to "[local]". Quoted now, like note already was.

Anchor term is `rubric`: measured, it matches every eval record with zero false positives
while `EVAL` also hits prose about EvalPlus elsewhere. mneme ANDs its FTS5 terms and does
not stem, so the anchor is chosen, not guessed. Its /query response carries no timestamp
either — a record must bring its own ts=, and one that does not is reported as undated
rather than dragging the span back to 1970.
2026-08-02 09:51:48 +02:00
marfrit 3d8d28d009 doctor: enforce the orchestrator wake-set invariant instead of only writing it down (#96)
W31 listed "single-session-foreman — cannot be woken by worker replies" as an open gap.
MEASURED against the room log: 233 of 259 replies addressed to @foreman drew a foreman
post within 120 s, median 8 s. That is reply-driven waking, not busy-polling. The gap as
stated was closed on 2026-07-24 by the `.wakereplies` opt-in, three days before the run
reported it.

What is real is the residue. ORCHESTRATION.md §2 states the invariant — "any orchestrator
MUST wake on replies" — and it was written down only after a reply-deaf lurker deadlocked
a campaign. Nothing checks it. The opt-in is a bare flag file in a per-nick lurker dir,
and deploy/install.sh deliberately does not touch those, so a fresh fleet, a restored
home, or a renamed nick brings the herd up looking healthy and quietly unable to finish a
pipeline: the orchestrator ends its turn correctly and waits forever for an event the
lurker discards.

cfg.ORCHESTRATORS names the nicks that drive pipelines (configurable; "foreman" here) and
bullpen-doctor now flags any of them whose lurker lacks the flag. One `test -e` per
orchestrator, read-only. Verified both directions: silent while the flag is present, and
with it moved aside it reports @foreman REPLY-DEAF.
2026-08-02 09:51:48 +02:00
marfrit b03d89770f bullpen-up: honour a deliberate inhibit instead of silently re-arming it (#95)
bullpen-up exists to enable and start its roster, so a unit an operator switched off by
hand came back on the next bring-up — with nothing in the output to say it had. That is
not a bug in bullpen-up; it is doing exactly its job. It does mean an operator's "off"
silently expires, which is the failure class markus has flagged before: re-arming an
inhibit is a decision, not housekeeping.

bullpen-selfimprove.timer was live proof — disabled on request 2026-08-01 while #100 and
#118 ran through the room, and `bullpen-up --dry-run` reported `WOULD … enable+start`.

Mechanism: a file named exactly like the unit in /etc/bullpen/inhibit/ or
~/.config/bullpen/inhibit/, whose CONTENT is the reason. Both paths are read so the same
mechanism works in either install layout, and cfg.inhibit_reason() reads at call time —
lifting an inhibit must not require restarting anything. The reason is mandatory by
construction: an empty file still reports "(no reason recorded)", because an inhibit
nobody can explain is archaeology six weeks later.

Deliberately NOT a tear-down: bullpen-up never stops or disables, so an inhibit that
arrives while the unit runs blocks the re-arm, not the current run. Reporting only
"INHIBIT" there would read as "it is off", so that case says ACTIVE explicitly and tells
the operator to stop it by hand if that is what they meant.

tests/test_bullpen_up_inhibit.py pins all four properties plus non-interference with
ordinary bring-up; with the inhibit check removed, exactly those four fail and the
bring-up test stays green.
2026-08-02 08:44:45 +02:00
marfrit 5bcb661504 tests: bring the trust-gate spec up to the model it is supposed to guard (#95)
Four tests in tests/test_post_secret_gate.py had been failing since two deliberate,
documented changes moved the trust model out from under them. They were not a
regression — verified against 211a05d — but permanently-red tests are worse than no
tests: the next real regression at this gate would have landed in the noise.

What moved:
  * 50009af widened PRIVILEGED to the whole worker family (callboy/librarian/coder/
    researcher/dispatcher/…) so their replies stamp verified:true for orchestrators'
    lurkers, and taught bullpen_worker to send POST_SECRET. "Non-privileged workers post
    with no secret" therefore names the wrong witness — @callboy is GATED now. @pipi is
    a genuinely unprivileged nick and carries that property.
  * 192d233 downgraded bullpen-grinder serve()'s verified check from rejection to a
    stderr warning: the room API returns verified:null when `from` carries an @ prefix,
    so the strict check silently dropped legitimate asks, and TRUST already
    authenticates the caller.

What the spec asserts now:
  * an unprivileged nick still posts with no secret and is NOT stamped verified;
  * a privileged worker WITHOUT the secret is refused fail-closed, and WITH it is
    stamped verified:true — the other half of 50009af, which nothing covered;
  * the grinder proceeds on a missing verified but must SAY SO on stderr. Pinning the
    warning keeps the relaxation visible; without it an unverified grind is
    indistinguishable from a verified one in the log, which is how this drifted unseen;
  * the grinder refuses an asker outside TRUST even when verified is true. That gate did
    not move, and with verified downgraded it is the only one left — the lurker had such
    a test, the grinder did not.

Not merely made green: each guarded property was broken in a scratch copy and the
intended test caught it — callboy dropped from PRIVILEGED, `verified` copied from caller
args at message construction, the stderr warning removed, the TRUST clause removed. 4/4.

Restoring the strict verified check instead remains available and is a posture decision,
not a bug fix (docs/ORCHESTRATION.md §3); markus chose to follow the code.
2026-08-02 08:25:52 +02:00
marfrit e839678130 tests: bring model_tag's contract into the repo (#118)
The module shipped without its 17-case spec, which was still sitting in the /tmp build
dir where the room ground it — a module whose contract lives somewhere else is exactly
the drift this work set out to remove. Repointed at lib/bullpen_modeltag and verified
17/17 against the shipped implementation.
2026-08-02 00:45:19 +02:00
marfrit 2cf4ec3778 grinder: the diff-noise pathspecs need :(glob), or top-level scratch slips through
`:(exclude)**/__pycache__/**` reads as "exclude any __pycache__" and is not. Without
`glob` magic git will not let `**` span ZERO directories, so the pattern hid a nested
__pycache__ and missed one at the repo root — which is exactly where pytest puts it for
a flat repo. Every grind diff has been carrying a .pyc; @reviewer flagged the symptom on
the modeltag grind, and `git diff --stat` against both spellings shows the mechanism.

The failure is invisible by inspection, since both spellings say the same thing in
English. So tests/test_grind_noise.py builds a real git repo with real scratch and diffs
it with the real pathspec list: red on the old spelling for the top-level case only,
green on the new one, ~30 ms, no model. NOISE moved to module level to be testable at all.

Also normalised pytest-of-* and .pytest_cache to the same **/…/** + glob form; they were
top-level-only patterns and would have missed a nested occurrence.
2026-08-02 00:43:47 +02:00
marfrit eafa29a074 grinder: report the cost tier that actually served, not the tier asked for (#118)
A worker reported `model qwen3.6-coding` — the rung it ASKED for. That cannot answer
"which rung solved this task", which on 2026-08-01 turned a question that should have
been a glance at the log into a 100-minute measurement.

lib/bullpen_modeltag.py is the pure function, built through the room: contract by
@testdesigner (17 cases, three rounds), implementation ground by @py, reviewed twice by
@reviewer. Each contract round closed a defect the previous one structurally could not
see:
  1. unsatisfiable — one case passed an unprefixed name but demanded the prefixed tier,
     contradicting a sibling. Caught by a reference implementation written from the
     docstring: 11/12, with exactly one impossible failure.
  2. incomplete — a same-model free->paid escalation showed no switch, because names
     were compared after stripping the prefix. In a cost-transparency module that is
     the case that matters. Caught by @reviewer probing the green implementation.
  3. false model of reality — the spec assumed `served` carries a prefix. Measured
     against the live gateway: the catalog prefixes every id (246 [$], 15 [free],
     4 [local], none bare) but the response echoes the model BARE, so every ordinary
     call reported an invented switch to [unknown]. Round 2's fix caused this:
     tightening the comparison removed the looseness the wire format relies on.
Resolution: a missing prefix on `served` means no tier information, not a different
tier — inherit the asked tier and compare names only.

Two more @reviewer findings folded in: the contract's `assert "modeltag.py" in
inspect.getsource(modeltag)` checked the wrong file (the harness needs the name in the
TEST source, for _infer_targets) and the model satisfied it by pasting the filename into
the docstring — a contract that rewards gaming; and a formatting-only difference
(`[free]x` vs `[free] x`) rendered as a switch to itself.

On integration: four if/else pairs producing identical strings collapsed, plus one
branch made unreachable by the inherit assignment above it — behaviour unchanged, the
17 cases still pass. The default gateway name is no longer a fleet hostname (it leaked
into every room message); GATEWAY derives from cfg.PROXY.

PY_GRIND_TIERS now carries prefixed ids, since the bare name has no tier to report.
Verified the gateway accepts the prefixed form and answers 200.
2026-08-02 00:40:10 +02:00
marfrit 05f06ed6a9 portability: fix the two bugs @reviewer found in the portability commit itself (#100)
1. bin/bullpen-up ROSTER collapsed silently. `{cfg.ROOM_HOST: …, cfg.COORD_HOST: …,
   cfg.GRIND_HOST: …}` is a dict literal with CONFIGURABLE keys — a small fleet putting
   two roles on one machine (the plausible target of #100) lost the earlier entry
   without a word. That is the same silent-no-op the commit claimed to fix, one level
   down. Now a list of roles merged per host AND per scope, so a combined
   room+coordinator box brings up both its system and its user units. Verified: split
   roles unchanged (8 system + 12 user), collapsed onto one host keeps all 20.
   Also renamed the local `cfg` in main(), which shadowed the config module.

2. bin/bullpen-selfimprove posted as `from=$BULLPEN_COORD_HOST` — a room NICK confused
   with a HOSTNAME. "noether" is a privileged nick that merely happens to equal the
   coordinator's hostname here; on any other fleet the run would post as an unknown
   nick and be rejected silently, which the R4 comment directly above explains. New
   cfg.POST_NICK.

Test gaps @reviewer named, both closed:
  * shell entrypoints were entirely outside the AST check — and bullpen-selfimprove is
    Bash and was one of the offenders that commit had to fix. Added a grep-based check.
  * `fritz.box` was missing from the host list, though it is the strongest fleet marker
    there is. Adding it immediately surfaced three more real offenders: a hardcoded
    mneme URL in bullpen_participant, another in the lurker, and the legitimate
    last-resort fallback in bullpen_mem (exempted, with the reason).

Documented rather than fixed: %h resolves to the service manager's home regardless of
User=, and a stray /root/.local/bin copy would shadow every system unit — both now in
the units-test docstring. cfg.FLEET_HOSTS lost the `pve\d`/`dcw\d` families the regex
had, so a future pve5 needs adding by hand; noted at the definition. `orca` added.

57/57 across both portability suites. bullpen-up --dry-run on noether unchanged;
selfimprove composes a brief with zero origin-fleet mentions under overridden hosts.
2026-08-02 00:40:10 +02:00
marfrit eb75a30169 lib: adopt hertz's uncommitted comment fixes (stash is retired; mneme needs no successor framing)
These two edits existed only in hertz's working tree since the mneme migration —
install.sh's design says editing the live file IS editing the tracked file, but
they were never committed, so every other host still carried the stale wording
and a git pull on hertz would have refused. Bringing them upstream first makes
hertz's tree a clean discard.
2026-08-02 00:07:39 +02:00
marfrit f385070dbf bullpen: no fleet host in a runtime string; entrypoints run from either layout (#100)
Follows the architecture pass on #100. Three classes of change:

1. sys.path — the ten Python entrypoints used two idioms: seven pinned
   /usr/local/lib/bullpen (system layout only), three resolved ../lib from
   realpath(__file__) first. The second form is a superset: it works in both
   layouts and still falls back to /usr/local/lib. Unified on it. Proven: on
   noether, where /usr/local/lib/bullpen does not exist, `bullpen-dispatcher
   --roster` now runs straight from the checkout — it could not before.

2. Role hosts from config — deus-seize, bullpen-selfimprove, bullpen-up's
   ROSTER keys, and the lurker's opencode host/model now come from
   bullpen_config (env > /etc/bullpen/bullpen.conf > origin-fleet default), so
   behaviour here is unchanged. bullpen-up in particular was a silent no-op on
   any other fleet because its ROSTER was keyed by literal hostnames.

3. Advice the system gives BACK — @dispatcher answered every routing question
   with `sic hertz room-ask …` and @callboy reported artifacts as `sic hertz
   cat …`. On a foreign fleet that is an instruction to talk to a machine that
   does not exist. Now cfg.ROOM_HOST.

tests/test_no_hardcoded_hosts.py enforces (3) by AST, not grep: prose in
docstrings and comments stays (why a thing runs on boltzmann is worth keeping),
but a hostname inside a string the program actually uses fails the test. It
caught three sites I had missed by eye — the dispatcher's proxy default, its
"on <host>" routing regex, and the grinder's timeout message.

The routing regex is the interesting one: those names are real fleet knowledge,
not addresses, so they moved to cfg.FLEET_HOSTS rather than being deleted.
Verified both directions — with the default list "on boltzmann" matches and "on
buildbox" does not; with BULLPEN_FLEET_HOSTS="buildbox chatbox" it is exactly
reversed.

Also verified: DRY_RUN=1 bullpen-selfimprove composes a brief with zero mentions
of the origin fleet under overridden hosts; deus-seize still runs; 52/52 across
both portability suites.

NOT fixed, flagged: deus-seize and bullpen-selfimprove pass the room post-secret
as an lmcp-tool argv value, so it is visible in `ps` on the room host (and in
curl's argv inside lmcp-tool). lmcp-tool has no stdin form for argument values;
closing it means changing lmcp-tool, which is deployed fleet-wide.
2026-08-02 00:06:26 +02:00
marfrit c2af7b6882 systemd: units carry no username — one file, correct in both install layouts (#100)
deploy/install.sh symlinks the SAME tracked unit into ~/.config/systemd/user OR
/etc/systemd/system, so any literal ExecStart path is wrong in one of them. That
is why the path flip-flopped: 6905f4e moved %h/.local/bin -> /usr/local/bin ("--
system needs it"), 933dd86 moved it back but as /home/mfritsche/.local/bin,
dropping the specifier. bullpen-triage crash-looped 27000+ times on 203/EXEC.

Use a pair that resolves correctly in both scopes instead:
  Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
  ExecStart=/usr/bin/env <prog>
Measured on hertz, not recalled: in a system unit %h expands to /root, so
/root/.local/bin misses and /usr/local/bin wins; in a user unit it expands to the
user's home and ~/.local/bin wins first. /usr/bin/env resolves the program
through the unit's own PATH in both scopes.

The two layouts stay as they are — DEPLOY.md justifies them with a trust
boundary (agent tier without fleet credentials vs. coordinator tier with sic
reach), so collapsing them would trade a security property for deploy comfort.

tests/test_units_portable.py fails on the pre-fix tree (exactly these 7 units)
and passes after. Verified live: daemon-reload + restart of every affected unit
on noether, all active/running, NRestarts=0.
2026-08-01 23:49:25 +02:00
marfrit c00359660e docs: model gateway, wrong-chat-template failure mode, room-ask trust caveat
Three gaps found while onboarding onto this society from cold context.

ORCHESTRATION §4.1 — how a grinder reaches a model. Tiers were documented but
not the path: grinders go through llm-proxy, and the standing ladder lives in
PY_GRIND_TIERS on the unit, not in the ticket. A ticket's tiers= is per-grind
override; the unit is policy. Also names the gateway as the only place to
enforce request-level policy across all clients.

ORCHESTRATION §5 Failure Mode D — a wrong chat template stops an agent
mid-chain. Reads as the model losing the plot, is a tokenizer problem one layer
below the room. Separates the two causes that need different fixes: a template
from the wrong family (configuration, fix with --chat-template-file) versus the
model spelling the marker out as ordinary text (behaviour, mitigate with
stop-strings at the gateway). Includes the tokenise test that tells them apart:
one token = real special token, six = the model typed it.

invoking-from-a-rich-agent §1 — room-ask fails for a PRIVILEGED nick. It posts
through the strict endpoint without the shared secret, so most rich agents get
"unauthorized" from the tool the doc recommends. ORCHESTRATION §3 explains the
gate; this adds the caveat where a reader actually looks for it.

No local hostnames in the new text — the gateway is referenced by repository,
hosts as placeholders.
2026-08-01 12:46:16 +02:00
marfrit 211a05dbe4 grinder: recover an undeployed target-regex fix + widen bin/ matching
Found while deploying tonight's mneme-embeddings grind: boltzmann's
/usr/local/bin/bullpen-grinder had silently diverged from this repo since
2026-07-25 (never re-synced after any of tonight's commits -- meaning gaps
#1/#2/#4 were only EVER live via the env-var-driven tier list, not the
report()-side AUTO-REVIEW/ESCALATION-EVAL code, which boltzmann was still
running the old version of). The deployed copy had its own hand-patched fix
that never made it into git: exclude inclusionai/ling-prefixed strings from
the target regex (a research ticket quoting a model name like
"inclusionai/Ling-flash-2.0" looks exactly like a dir/file target otherwise),
plus a bin/[\w.-]+ alternative so bullpen's own extensionless bin/bullpen-*
scripts can be auto-inferred as targets at all. Recovered both into the
regex here, properly commented, rather than losing them on the next deploy.

The deploy-sync gap itself is fixed separately by converting boltzmann's
/usr/local/bin/bullpen-grinder into a symlink into this repo (matching the
noether coordinator pattern already used for triage/selfimprove/doctor) --
see the accompanying deploy step, not a code change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:14:36 +02:00
marfrit 933dd865b3 systemd: fix stale ExecStart on triage + selfimprove units (noether)
Both pointed at /usr/local/bin/<name>, which no longer exists (removed at
some point, likely the same de-shadowing pass that took out the sic shadow
copies) — the correct path, matching every other noether-scope unit
(doctor, grinder-{py,godev,jsdev}), is the ~/.local/bin symlink into this
repo. Neither unit was ever actually re-exec'd since the file went away, so
this was silent: a running process from before the removal kept going, and
only a fresh restart/timer-fire would hit ENOENT (systemd 203/EXEC).

Found by restarting bullpen-triage.service after the escalation-dedup fix
(previous commit) and hitting exactly that. Checked selfimprove too since it
has the identical ~/.local/bin-symlink-but-unit-says-/usr/local/bin shape --
same bug, same fix. Both verified live: triage active+running, selfimprove
oneshot exits 0/SUCCESS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 10:37:28 +02:00
marfrit 7fc83870ee triage: escalate-once-then-quiet on its OWN backend failures
Room-history mining (2026-07-28, the "mine the log for lessons" backlog item
from 2026-07-25) found 13 near-identical "triage model unreachable" posts to
@markus spread across 2026-07-21..23 -- triage's own model backend (deepseek
via the proxy) was down/misconfigured for a real stretch, and every single
poll cycle spammed an individual room message with zero dedup. The failure
supervisor had no supervisor of its own.

Fix: track consecutive own-backend failures. First 2 post normally (still
useful signal for a transient blip). The 3rd posts ONE consolidated warning
naming the likely cause (backend down, not the agent being watched) and goes
quiet. Further consecutive failures log to stderr only. A subsequent success
posts a brief recovery note before resuming normal triage. Verified the exact
sequence (2 normal / 1 escalate / N suppressed / 1 recover) against the real
module logic before deploying.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 10:33:51 +02:00
Markus Fritsche aff56bb582 bullseye: live SSE thinking stream (idea #155)
Ground truth check before writing this off as too big: Handler is already a
ThreadingHTTPServer (SSE-capable for free), and thinking_payload() -- one sic
hertz SSH round-trip per call -- was already being hit independently by every
open tab every 1.5-3s via client-side setInterval. A naive "just hold the
connection open and loop the existing fetch" SSE implementation would have
made that worse: N open tabs = N concurrent SSH-polling loops running forever.

So: one background daemon thread polls thinking_payload() on the same 1.5s
cadence the client already used, into a version-counted shared cache
(_THINK_CACHE + _THINK_LOCK). GET /api/thinking (snapshot, unchanged
contract) and the new GET /api/thinking/stream (SSE) both read from it --
zero additional sic calls per client, and strictly FEWER total SSH
round-trips than todays per-tab polling, not just parity. Cache-update logic
pulled out as _update_thinking_cache() specifically so it is unit-testable
without a thread/sleep in the way (5 new tests, tests/test_bullseye_thinking.py).

Client (page.py): EventSource replaces the hand-rolled setTimeout poll loop
-- simpler, not just live, since EventSource reconnects on drop natively.
Falls back to the old poll loop when window.EventSource is absent.

69/69 bullseye tests green (64 existing + 5 new). Live-smoke-tested standalone
(snapshot + SSE endpoints both verified with curl) before wiring into the
real service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 23:02:56 +02:00
marfrit e40607bae4 grinder: surface the escalation-discipline eval verdict to its consumer
W31 gap #4 (reviewer-confirmed): _eval_escalation_discipline computed a
PASS/FAIL verdict on every grind and saved it to mneme /trajectory/grinder,
but nothing ever read it back — pure write-only telemetry. Have
_eval_escalation_discipline additionally return (verdict, note); report()
now embeds it as an "ESCALATION-EVAL:" line on GREEN results, which lands
directly in the same auto-review ping @reviewer already gets on an
escalated/regressed/large-diff GREEN (gap #2, c6d5e72) — so the one
consumer the idea named ("reviewer/foreman decisions") sees it exactly
where it's decision-relevant, instead of it sitting unread in a namespace
nobody queries.
2026-07-27 22:48:08 +02:00