# board-agent Tailnet-only kanban board for the intent lifecycle (`docs/intent/*.md`) and independent research lifecycle (`docs/research-intent/*.md`). Research cards use `recherche_angefordert`, `recherche_laeuft`, `recherche_fertig`, `recherche_fehlgeschlagen` and `archiviert`; they never trigger Builder jobs. The browser projects the unchanged backend phases into the lifecycle groups **Backlog**, **Ready**, **Running**, **Needs attention**, **Deploying**, **Done**, and **Archived**. Cards remain stable intent objects; agent work is started through context-sensitive card buttons, not by dragging a card into an agent-named column. The API and persisted phase names remain compatible. Started as a single-transition pilot (`docs/intent/kanban-board-admin-trigger.md`, superseded) that only triggered `agents/admin.md`. Phase 1 of `docs/intent/git-intent-board-feedback.md` (2026-09-06) replaced that with two automatic transitions: - **Geplant → In Umsetzung** (the `Start Builder` card action): hands the job off to the existing Builder role instead of running the Administrator directly — see "Builder hand-off" below. - **Erfolgreicher Builder-Commit → Review** (automatic): a completion watcher notices the Builder finished with a commit and queues a headless Reviewer run against that exact commit. Ordinary Board Builder jobs always set `defer_deploy_until_review`; only the positive verdict for that exact commit releases the persistent deployment request. - **In Umsetzung → Aufteilen angefordert** (the `Split` card action, only once a card is stuck at `last_run_status == "limit_reached"` with its one automatic retry already spent): queues a headless, read-only Planner run that splits the card's intent into up to 10 independent child intents — see "Split escalation" below (`docs/intent/board-builder-limit-reached-auto-split.md`). Verified result states cannot be assigned by manual PATCH requests. Starting Review queues real work; positive review and successful research require worker results. Archiving and reactivation remain explicit user actions. ## Workflow and recovery contract (September 2026) - A Builder commit is not a successful deployment. Exactly one structured `REVIEW-RESULT` object must name that commit and say `approve` or `reject`. Prose such as “not ready for deployment” cannot release deployment. Invalid output leaves the previous review report intact. - An approval queues activation automatically. The runner uses an immutable `git+file://...?rev=#blunix` source, never a mutable checkout or whatever later reached `origin/dev`. Its receipt records the full revision and `/run/current-system` closure after switch and service-health checks. Automatic rollback/divergence from its last verified revision is refused. - Only a matching approved revision plus activation receipt moves a card to **Deployed**. Existing cards without receipts show **Deployment unverified**; this change does not retroactively activate or certify old cards. - Failed activation offers **Retry deployment**, reusing the approved commit without a model or another Builder run. Failed publication retains the Git worktree for recovery instead of deleting the unpublished change. An empty Builder result is not counted as execution success or proof of goal completion. - State writers share a cross-process transaction lock. Queue publication uses a durable outbox and queue-local claim markers, so recovery does not recreate a job already claimed between publication and receipt. Completion records are applied transactionally before moving to `consumed`, with content deduplication and pipeline/attempt checks. Malformed records go to `results-rejected`. - Archived Operator families no longer restart children in the scheduler. Downstream Builder and deployment queues count as active work for card guards. The board defaults to active goals, with search, an attention-only view, an archive view, collapsible child cards and a separate settings dialog. Cards separate goal state, job outcome and next responsibility. **Details & evidence** contains Overview, Attempts, Evidence, Conversation and Changes. Missing history is displayed as missing, not reconstructed. Cost totals show measurement coverage. An API failure keeps the last visible data and explicitly marks it stale. ### Recovery boundaries Restic includes the atomic board state and persistent deployment ledger. These are metadata backups, not a coordinated snapshot of every job queue or the live SQLite telemetry database. Stop the board workers, both Builders and deployment runner before restoring metadata; reconcile pending jobs and current activation before re-enabling automation. Never restore a pending outbox into running queues or delete claim markers while requests can still be replayed. Repository state, not the card label, remains authoritative for code; activation receipts prove a past switch, not ongoing health or fulfillment of every acceptance criterion. A model review is not an independent exhaustive integration test. ### Regression checks Run `nix develop --command python -m pytest -q` with this directory and `../agent-codex/test_agent_codex.py` as the test targets. The standalone `test_board_browser.cjs` uses Python for fixture presentation and Playwright for browser assertions. Set `PLAYWRIGHT_MODULE` to the installed module path, `CHROMIUM_BIN` to Chromium and optionally `PYTHON_BIN` to Python, then run it with Node. Every HTTP response is mocked; any non-GET request fails the test. Screenshots are written under `/tmp/intent-board-candidate-*.png`. ## Klärung & Reparatur Die kontextsensitiven Aktionen speichern beim Aufruf von `klaerung_reparatur` einen unveränderlichen Auftragstyp: `implementation_status`, `route_next` oder `repair`, zusammen mit Runtime, Modell und Reasoning. Es gibt weder ein globales Mode-Feld noch einen Prompt-Dialog. `Diagnose` wird vor `Repair` angeboten. Die beiden ersten Typen starten eine frische, feste Inspector-Rolle mit read-only Sandbox. Sie liefern getrennte, validierte `INSPECTION_RESULT`- bzw. `ROUTING_RESULT`-Blöcke. Deployment-Evidenz stammt ausschließlich aus dem vertrauenswürdigen Wrapper-State; das Modell kann weder deployen noch die Karte bewegen. Eine Routing-Empfehlung bleibt bis zum expliziten UI-Button als `routing_pending` gespeichert. `repair` ist nur bei echten Fehlern oder negativem Review zulässig und nutzt den isolierten Builder-Worktree als `pipeline_role="fixer"`. Sein eigener `FIX_RESULT` kennt ausschließlich `repaired`, `needs_input`, `not_applicable` und `failed`; eine universelle `TARGET_PHASE` existiert nicht. `repaired` geht zwingend in einen separaten Review; `not_applicable` bleibt ohne erfundene Zielphase sichtbar in der gemeinsamen Spalte. Rückfragen und Antworten werden persistiert, jede Antwort startet eine frische Session. Maximal zwei Repair-Versuche sind erlaubt; der einmalige reine Formatkorrekturlauf im Builder-Harness zählt nicht als Kartenversuch und ein erneut ungültiges Format fällt deterministisch in eine Rückfrage zurück. Ein `limit_reached` ist dabei erst nach Verbrauch der vollständigen Eskalationsleiter ein Repair-Fall. Ein laufender oder später erfolgreicher Eskalationsversuch zeigt keinen Fixer-Button. Normale Board-Builder- und Fixer-Commits setzen `defer_deploy_until_review`: der Builder-Harness pusht den isolierten Commit, erzeugt aber keinen Deployment-Auftrag. Erst ein positiver Review schreibt einen auf den geprüften Commit gepinnten Auftrag in die bestehende persistente `agent-codex`-Deploy-Queue. Deren Runner validiert weiterhin den festen Host `blunix` und das feste Repository; das Modell besitzt weder Zugriff auf diese Queue noch auf `sudo`/`nixos-rebuild`. `tnix` und `pinix` sind kein mögliches Fixer-Deploymentziel. ## Runtime- und Modellwahl Die drei job-auslösenden Spalten (`in_umsetzung`, `review` und `recherche_laeuft`) bieten eigenständige Runtimes für Codex, Claude und OpenRouter. OpenRouter ist keine Codex-Modelloption: Der Worker setzt den Codex-Exec-/JSONL-Harness mit `model_provider="openrouter"` auf den OpenAI-kompatiblen Responses-Endpunkt `https://openrouter.ai/api/v1` um. Für OpenRouter stehen ausschließlich diese Modelle im spaltenabhängigen Dropdown: - `moonshotai/kimi-k2-thinking` - `deepseek/deepseek-v3.2` - `cohere/north-mini-code:free` - `nvidia/nemotron-3-super-120b-a12b:free` Eine leere Modellwahl verwendet die rollenbezogene `*_OPENROUTER_MODEL`-Umgebungsvariable (Builder, Reviewer, Researcher oder Planner). Der über agenix bereitgestellte Schlüssel bleibt als Dateipfad in `*_OPENROUTER_API_KEY_FILE`; erst unmittelbar vor einem OpenRouter-Aufruf liest der jeweilige Wrapper die Datei und setzt `OPENROUTER_API_KEY` nur im Environment des Kindprozesses. Kommandozeile, Queue-Payload und Board-State enthalten den Schlüssel nie. Reviewer, Researcher und Planner/Split behalten auch mit OpenRouter ihre read-only Codex-Sandbox; Builder behält `workspace-write`, Streaming-Auswertung, Antwortkürzung und Iterationslimit des vorhandenen Codex-Harnesses. The final `Archiviert` column is a permanent bookkeeping state for inactive cards. Moving any card to it never queues a Builder, Reviewer, or deployment job. Archived cards remain readable, including their Intent and review report. Moving an archived card back to `Geplant` is also bookkeeping; `Archiviert → In Umsetzung` is rejected so reactivation must use the deliberate `Archiviert → Geplant → In Umsetzung` sequence. **Not part of this phase** (see the intent document's own scope note and `docs/intent/builder-iterationslimit.md`): board state staying in `BOARD_STATE_DIR/state.json` rather than git-versioned `docs/board/.yaml` files with automatic local commits, and real OIDC/Dex self-check auth for the Web-UI (still Traefik BasicAuth, see "Auth" below) — both explicitly deferred, not oversights. ## Architecture `docs/intent/` und `docs/research-intent/` sind Corpus-Verzeichnisse und damit getrackte Eingaben des Repositorys. Das Repository enthält daher auch bei leerem Research-Corpus den Platzhalter `docs/research-intent/.gitkeep`. `ExecStartPre` erzeugt ausschließlich privaten Runtime-State unter `/var/lib/board-agent/` und schreibt niemals in den schreibgeschützten Checkout. ``` Browser (board.html, served by trigger.py) -> GET /api/board merged view: docs/intent/*.md (default phase "geplant") + manual "idea" cards, both overlaid with BOARD_STATE_FILE -> GET /api/board/column-config return the default-filled configuration for the job-triggering columns -> POST /api/board/column-config/ update a validated partial configuration for `in_umsetzung`, `review`, or `recherche_laeuft` -> POST /api/board/ideas create a manual backlog card -> POST /api/board/drafts start an interactive Planner intake dialogue ("draft" card, no backing file yet) -- see "Interactive Planner intake" below -> POST /api/board/cards//draft-answer answer the Planner's latest draft question, queue the next turn -> POST /api/board/cards//draft-retry re-run a failed draft turn unchanged -> DELETE /api/board/cards/ remove a manual backlog card, or an abandoned draft that never finished -> PATCH /api/board/cards/ move a card to a new phase -> for an intent card moving geplant -> in_umsetzung: - reject (409) unless the card's current phase is exactly "geplant" -- the double-trigger guard - write a job_type="builder" job into board-agent-worker's inbox/, including an immutable runtime snapshot - record phase=in_umsetzung + last_run_status=queued -> every other move: just persists the new phase, no side effect systemd.paths.board-agent-worker (watches inbox/*.json) -> board-agent-worker.service (oneshot, worker.py), dispatches by job_type: "builder" (default, for legacy/missing job_type too): - reads the *current* docs/intent/.md content itself (not whatever the job file says) - translates it into ../agent-codex's own builder-inbox/ schema (sender=requested_by=Builder owner JID, so it runs immediately with no /annehmen gate) and writes it there -- no `codex exec` happens in this process for this job type, the already-built ../agent-codex/builder.py pipeline (atomic session, iteration limit, stash+commit) does the actual work - job carries on_complete_dir pointing at BOARD_BUILDER_RESULTS_DIR "review": - uses the originating Builder job's runtime snapshot: Codex runs read-only with gpt-5.6-luna/none; Claude uses its existing settings and disabled write tools; OpenRouter uses the same read-only Codex harness with its own provider and role default - reviewer returns its report as a ===REVIEW-REPORT===...===END-REVIEW-REPORT=== marker block; this script extracts it and writes docs/review/.md itself, outside the model process (see "Why read-only, not a scoped writable dir" below) - sets the card's phase from the report's decision line ("bereit für Deployment" -> bereit_fuer_deployment, "zurück zur Planung" -> planung_mit_feedback) - keeps the decision, report path, and reviewed commit on the card, then checks whether this terminal result completes a fan-out and can generate its synthesis "research": - verwendet den unveränderten Runtime-Snapshot des Board-Jobs: Codex und OpenRouter laufen read-only über den Codex-Harness, Claude mit den shell-fähigen Researcher-Einstellungen - liest docs/research-intent/.md und übergibt den Auftrag an agents/researcher.md - extrahiert den ===RESEARCH-REPORT===...===END-RESEARCH-REPORT===-Block und schreibt ihn selbst nach docs/research/.md - setzt die Karte auf recherche_fertig bzw. bei Fehlern auf recherche_fehlgeschlagen "split" (see "Split escalation" below): - only reachable via the "aufteilen_angefordert" column, itself only reachable once a card's automatic limit_reached retry is already spent -- see trigger.py's update_phase() - run_split() invokes the Planner role read-only, same pattern as review/research: Codex `--sandbox read-only`, Claude with all write tools disabled - context is the unchanged original intent plus the stored builder_report from the failed attempt(s), no git-stash/diff access - parses a ===SPLIT-REPORT===...===END-SPLIT-REPORT===-Block containing up to 10 ===CHILD===...===END-CHILD=== sub-blocks and writes each as its own docs/intent/.md itself, outside the model process -- all-or-nothing: any malformed sub-block, a slug collision, or more than 10 children discards the whole proposal, writing nothing - each new child gets an initial state entry (phase "geplant", parent_id set) -- no auto-trigger, same manual "Geplant -> In Umsetzung" path as any other card - the parent card moves to the terminal phase "aufgeteilt" with its child_slugs recorded "draft" (see "Interactive Planner intake" below): - only reachable via POST /api/board/drafts (opening turn), /draft-answer (a follow-up) or /draft-retry (a failed turn, unchanged) -- never a PATCH phase move - run_draft() invokes the Planner role read-only with **no** repository access at all, unlike every other role above -- a draft has no existing files to read yet - each turn resends the full conversation so far (state.json's draft_conversation, not a persistent model session) and gets back exactly one of a ===DRAFT-QUESTION===... ===END-DRAFT-QUESTION=== follow-up (up to four quick-reply options) or a ===DRAFT-INTENT===...===END-DRAFT-INTENT=== block once the Planner has enough context - a question just updates draft_conversation and the card's phase (entwurf_frage); a finished intent is written to docs/intent/.md by this process (never the model, same marker pattern as split above), under the same repo lock run_split() uses -- but only for this one write, not for an ordinary question turn - record_draft_intent() then replaces the draft- state entry with a normal "geplant" intent card (draft_origin set for audit) -- no auto-trigger, same manual path as any split child Alle Pfade: job file -> done/ (success) oder failed/ (error attached); full report goes to stdout, i.e. `journalctl -u board-agent-worker`. If systemd fails before `worker.py` can process the queue (for example in `ExecStartPre` or while setting up the sandbox), `ExecStopPost` invokes the fixed `failure_handler.py`. It accepts exactly one queue entry from the configured inbox/processing directories, revalidates job ID, slug, source and fixed intent path, atomically moves that entry to failed/, and records `last_run_status="failed"`, `failure_stage` (`pre_start`, `sandbox_setup` or `worker`) and a bounded, cleaned `failure_detail`. Zero or multiple candidates are left untouched and only produce a journal hint. A hook for an old job cannot overwrite a later successful run because the state job ID is checked before updating. systemd.paths.board-agent-completion-watcher (watches builder-results/*.json) -> board-agent-completion-watcher.service (oneshot, completion_watcher.py) - ../agent-codex/builder.py writes a completion record here for any job whose payload carried on_complete_dir -- the builder hand-off above is the only thing that ever sets it - status "committed": queues a job_type="review" job (commit hash and runtime snapshot from the record) in this board's own inbox/, card -> "review" - a deploy-runner record contains both status "committed" and a deployment_status; it is accepted as deployment evidence only when the card already carries Reviewer approval for that exact commit. Without that durable release, committed -> review wins, the reported deployment remains visible as premature evidence, and synthesis is not triggered - anything else (no_changes/check_failed/limit_reached/failed): card phase is left alone (no automatic fallback for a Builder failure, only for a negative Review) -- last_run_status plus the Builder's own final response text (builder_report) are recorded, so the card shows a "Builder-Bericht" element instead of the reasoning only existing in agent.db - claimed records move into builder-results-consumed/, not deleted Fehlerklassen sind absichtlich getrennt: Modellfehler und Fehler innerhalb von `process_job()` werden als `worker` gespeichert; Wrapper-/Vorstartfehler als `pre_start`; ein Fehler beim Aufbau der systemd-Sandbox kann als `sandbox_setup` klassifiziert werden. Der Browser erhält nur die bereinigte Kurzbeschreibung, niemals Journal-, Prompt-, Environment- oder Secret-Inhalte. ``` ## Builder hand-off (why no admin.md call happens anymore) The original pilot ran `agents/admin.md` directly in this process. `docs/intent/git-intent-board-feedback.md` explicitly wants the Builder role instead for this transition — and reusing the Builder means reusing its already-built, tested, deployed execution path (`../agent-codex/builder.py`) rather than duplicating atomic sessions/iteration-limit/stash-commit logic a second time here. So `enqueue_builder_job()` in `worker.py` is a thin translator: it writes a job into `../agent-codex`'s `builder-inbox/` queue and gets out of the way. Everything from there on — sandboxing, `nix flake check`/`nix build` before commit, push to `dev` — is exactly what `../agent-codex/builder.py` already does for XMPP-triggered jobs; see `../agent-codex/README.md`. **Isolated worktrees, still not implemented (deliberate).** The intent document `docs/intent/board-builder-worktree-parallelism.md` calls for board-triggered Builder jobs to run in an isolated git worktree/job-branch so multiple independent cards can build in true parallel. That worktree infrastructure — plus the concurrent-dispatch rearchitecture of `agent-codex/worker.py`'s currently sequential job loop, worktree cleanup on crash/SIGTERM, and the ordering question for merging several finished job branches into `dev` — is deliberately **not** built yet: it touches the production commit/push/auto-deploy path with no way to run its test suite or a real build/flake-check from this environment, and the VPS overload risk the intent itself calls out has already happened live once. `trigger.py`'s `builder_trigger_blocked()` now allows multiple *different* cards to be queued as builder jobs at once (capped by `BOARD_MAX_CONCURRENT_BUILDER_JOBS`, default 1) instead of rejecting a second card outright, but `agent-codex/builder.py` still runs every job against the one shared checkout, serialized by its existing flock — so queued jobs execute one at a time regardless of the cap. Research/review jobs are unaffected: `repo_job_active()` still blocks a new builder job if any research/review job is queued or running, since those still touch the same shared checkout with no lock of their own on the trigger side. Revisit the worktree/parallel-dispatch piece once real concurrent board-triggered load actually shows up. ## Split escalation (docs/intent/board-builder-limit-reached-auto-split.md) A card that hits the Builder iteration limit gets its automatic retries per `docs/intent/board-operator-escalation-ladder.md` below (one flat retry by default, or an Operator-authored ladder of up to two escalation steps for a card that went through the Operator). Once that ladder is fully exhausted and the last attempt still ends in `limit_reached`, the card is simply too big for one atomic Builder session — the `Split` card action is the deliberate, manual escalation: `trigger.py`'s `update_phase()` only accepts that move when `last_run_status == "limit_reached"` **and** `auto_retry_attempts >= len(escalation_ladder_for(card))` (`orchestration.escalation_ladder_for()`, see below), and (like a builder trigger) only when no other board-agent job is queued or running against the shared checkout. The queued `job_type="split"` job runs entirely inside `board-agent-worker.service` (no hand-off to `agent-codex`, unlike "builder"): `run_split()` in `worker.py` invokes the Planner role read-only with the original intent plus the stored `builder_report` as context, asks for up to 10 independent child intents in a `===SPLIT-REPORT===` marker block, and writes each `===CHILD===` sub-block as its own `docs/intent/.md` — the wrapper applies, the model never writes. Any malformed sub-block, a slug collision (with the parent or another child), or more than 10 children discards the *entire* proposal; zero files are written, matching `run_review()`'s existing "reject rather than partially apply" precedent for a missing decision line. Each new child card starts in "Geplant" with `parent_id` set — no auto-trigger, the same manual "Geplant → In Umsetzung" path as any other card. The parent card itself moves to the terminal phase "Aufgeteilt" with `child_slugs` recorded; like "Archiviert", it is reachable only this one way (never a direct PATCH target) and any move away from it again is plain bookkeeping. A failed split attempt (bad proposal, Planner error, timeout) leaves the parent card exactly where it was, in "Aufteilen angefordert", for a manual retry — no forced fallback phase. Deliberately **not** built here (left to `docs/intent/board-subintent-fanout-synthesis.md`, a separate, not-yet-built intent): automatic synthesis once every child reaches an end state, and any Board-UI grouping/tree display beyond the plain `parent_id`/`child_slugs` text already on each card. ## Interactive Planner intake (docs/intent/board-interactive-planner-intake.md) A third way to put a card on the board, next to hand-writing a `docs/intent/.md` file and the plain "+ Karte" backlog prompt: the "+ Karte (interaktiv)" backlog button starts a multi-turn question-and-answer dialogue with the Planner role instead. `create_draft(topic)` writes a `"draft"` card (`draft-`, no backing file) and immediately queues its opening turn. Each `job_type="draft"` turn (`run_draft()` in `worker.py`) invokes the Planner **read-only and with no repository access at all** — no `-C` into the checkout, no `--add-dir` — since a still-forming draft has no existing files to read and giving it repo access would have no upside. The full conversation so far (`draft_conversation` on the card, not a persistent model session) is resent every turn. The reply is exactly one of two marker blocks: `===DRAFT-QUESTION===...===END-DRAFT-QUESTION===` (a follow-up question plus up to four `optionen: a | b | c | d`-style quick-reply suggestions the frontend renders as buttons, alongside a free-text answer field) or `===DRAFT-INTENT===...===END-DRAFT-INTENT===` (a `slug: ... / --- / ` block, same header shape as `run_split()`'s `===CHILD===` sub-blocks) once the Planner has enough context. Both present, or neither, is rejected the same way `extract_split_children()` already rejects a malformed split proposal. A question just updates `draft_conversation` and the card's phase (`entwurf_frage`) — no repo lock, runs alongside Builder/Review/Research/ Split without contending for anything. Finishing the dialogue is the one moment this job type touches the shared checkout: the wrapper (never the model) writes `docs/intent/.md` under the same `acquire_repo_lock()` `run_split()` uses, held only for that narrow write. A slug collision discards the proposal entirely and leaves the card in `entwurf_fehlgeschlagen` for a manual `POST .../draft-retry` (resends the last turn unchanged — no new operator input needed). On success, `record_draft_intent()` replaces the `draft-` state entry with a normal `"geplant"` intent card (`draft_origin` kept for audit) — no auto-trigger, the same manual "Geplant → In Umsetzung" path as any other card, including a split child. `answer_draft(card_id, answer)` only accepts an answer while the card is at `entwurf_frage` (max 4000 characters, max 40 conversation turns total — a notional ceiling, not an expected normal length). `delete_manual_card()` (renamed from `delete_idea()`) now accepts both `"idea"` and `"draft"` sources, so an abandoned dialogue disappears through the same "×" button as a backlog idea. Deliberately **not** built here, per the intent document's own scope note: file attachments (image/audio — no runtime takes non-text input through this board's CLI wrappers today), a structured fixed-field form as a model-free alternative, a voice/phone-call interface, and any per-column runtime/model override UI for drafts (fixed `BOARD_PLANNER_*` defaults, same simplification `run_split()` already makes). ## Escalation ladder (docs/intent/board-operator-escalation-ladder.md) Added 10.09.2026, generalizing the single flat `auto_retry_attempts` retry above into a short, Operator-authored ladder. The Operator's `===OPERATOR-DECISION===` schema gained an optional `escalation` field (at most two entries, each `{model, reasoning_effort, iteration_limit}`): on the parent card for `direct_builder`, per child for `split` (the top-level `escalation` must then be empty — it lives on each child instead), always empty for `needs_human`. A response that omits the field entirely still validates — `extract_operator_decision()` defaults it to `[]` before the exact-keys check, so older Operator runs and any hand-built response without this field keep working unchanged. Each step's `model` is validated against `orchestration.MODEL_POOL`, a new per-runtime allowlist kept in sync by hand with `board.html`'s existing `MODEL_OPTIONS` config dropdown (same pragmatic duplication already used elsewhere in this repo, e.g. the `BOARD_*_MODEL` defaults in `board-agent.nix`). Runtime and lane never change mid-ladder — only model, reasoning_effort and iteration_limit (5–40) may vary per step, since the two Builder lanes already share identical model capabilities and only differ by queue/instance (`board-builder-weighted-lanes.md`). `orchestration.apply_direct()`/`_apply_split_unlocked()` persist the validated ladder onto the card as `escalation_attempts`. `fanout.escalation_ladder_for(card)` is the single shared accessor (lives in `fanout.py`, which `orchestration.py` already imports, to avoid a circular import; `orchestration.escalation_ladder_for` is a re-export of the same function): it returns `escalation_attempts` if the card has one, otherwise a synthetic one-step ladder that repeats the card's own current model/reasoning_effort/iteration_limit — exactly the original flat retry, unchanged, for any card that never went through the Operator (e.g. one dragged straight from "Geplant" to "In Umsetzung"). `completion_watcher.py`'s `process_record()` now checks `auto_retry_attempts` against this ladder's length instead of a hardcoded `< 1`: on `limit_reached` with a step still open, it queues the next Builder attempt using *that step's* model/reasoning_effort/iteration_limit (not the just-failed attempt's identical parameters) via `enqueue_builder_retry_job()`'s new optional overrides. `orchestration. child_failed()` and `fanout.child_terminal()` — the Operator-family scheduler's and the older manual fan-out's respective "has this child given up" checks — both use the same dynamic ladder length rather than the old fixed `>= 1`, so an Operator child only counts as failed (blocking its parent, or `Aufteilen angefordert`'s own eligibility check above) once its own full ladder is spent, not after the first `limit_reached`. `board.html` shows the attempt number and escalation plan on any card with `auto_retry_attempts > 0`, explicitly distinguishing an in-flight escalation, a recovered escalation, and terminal exhaustion. ## Fan-out / synthesis (docs/intent/board-subintent-fanout-synthesis.md) Any two known cards (intent, research, or manual idea) can be linked as parent/child via `PATCH /api/board/cards//parent` with body `{"parent_id": ""}` — a small, board-UI-free API endpoint, exactly the "lightweight mechanism... ohne dass dafür Board-UI-Arbeit nötig ist" the intent calls for. `trigger.py`'s `link_parent()` validates both slugs are real cards, then delegates the actual bookkeeping write to `fanout.link_child()`: `parent_id` on the child, the child's slug appended to `depends_on` on the parent (both `state.json` overlay fields, nothing in the intent documents themselves). Deliberately a *separate* mechanism from the split feature's `child_slugs`/`parent_id` above — same `parent_id` field on the child (reused as-is), but `depends_on` on the parent instead of `child_slugs`, since a fan-out parent's children are manually linked one at a time rather than written atomically by one split run; a card can in principle carry both if it is simultaneously an auto-split parent and a fan-out parent. `fanout.py` (shared by `trigger.py`, `worker.py` and `completion_watcher.py`, same same-directory `import` pattern as `completion_watcher.py`'s existing `import worker`) defines `child_terminal(card_id, stored)`: a Research child is terminal at `recherche_fertig`/`recherche_fehlgeschlagen`/`archiviert`; a Builder (intent) child is terminal at `bereit_fuer_deployment`/`planung_mit_feedback`/`deployed`/`archiviert`, **or** while still sitting in `in_umsetzung` with a stalled `last_run_status` (`no_changes`/`failed`/`check_failed`, or `limit_reached` with its one automatic retry already spent) — a run that never produced a commit still counts as "reached an end state", per the intent's "auch über Teilfehlschläge berichten, statt auf eine manuelle Wiederholung zu warten". `review` phase itself is **not** terminal. It represents the interval between a committed Builder result and completion of its queued Review job. A successful Review routes the card directly to its validated terminal outcome, `bereit_fuer_deployment` or `planung_mit_feedback`; no human drag is required. `maybe_synthesize(slug)` is called after every state change that could plausibly make `slug` newly terminal: in `worker.py` after a successful Review has saved its decision phase and after a Research job's success or failure `update_state()` call, in `completion_watcher.py` after its fallback branch for a Builder run that stalled without a commit, and in `trigger.py`'s `update_phase()` after essentially every phase-saving branch (including archiving and manual terminal moves). The automatic Review call is the common case for a reviewed Builder child and runs only after the decision and review metadata are durable in state. `maybe_synthesize()` looks up `slug`'s `parent_id`, no-ops if absent, and otherwise checks every sibling in the parent's `depends_on` list via `child_terminal()`; once **all** are terminal (success or failure — see the intent's "Annahmen"), it writes a new `docs/intent/-synthese.md` summarizing each child's phase/status/result-link (`commit_hash` for a Builder child, `research_report` for a Research child — reusing the existing per-card fields directly rather than introducing a separate `result_ref`), marks the parent `synthesis_generated: true` so this only ever fires once, and stops there: the synthesis card starts in `geplant` like any other newly-discovered `docs/intent/*.md` file, no automatic trigger, no automatic quality judgment — a person reviews and edits the draft before its own manual "Geplant → In Umsetzung". Deliberately **not** built here: an Evaluator role or automated judgment of whether the parent's goal was actually met; conflict handling between overlapping children (fan-out is for independent children only, same assumption as the worktree-parallelism intent); and true parallel Builder children — `agent-codex/builder.py` still serializes every Builder run against the one shared checkout regardless of how many cards are queued (see "Builder hand-off" above), so multiple Builder children of one parent simply queue up rather than actually run in parallel today. **Board-UI nesting** (`docs/intent/board-nested-child-intents.md`, added 2026-09-09): a card whose `parent_id` still resolves to an existing card no longer appears as its own column card at all — `board.html`'s `render()` skips it via `isNestedChild()` — and is instead rendered embedded inside that parent's card by `renderCard()`'s own recursion, indented under a `.children` container. This applies uniformly to both a manually linked fan-out child and an Operator split child, since both use the same `parent_id` field. Each embedded child is still the exact same, fully interactive card element (badges, dialogs, and contextual actions). A blocked parent identifies its blocking child and offers a `Zum Kind` navigation action; Diagnose/Repair target that child rather than the whole family. If the parent card is gone (deleted state, or never existed), the child falls back to its own column instead of disappearing from the board. `renderCard()` tracks an `ancestry` set through the recursion so a manually mislinked `parent_id` cycle (only `parent_id === child_id` is rejected server-side) can't turn this into an infinite loop client-side. ## Operator outcome visibility (found live 2026-09-10) `orchestration.apply_needs_human()` and the exception branch in `worker.py`'s `job_type == "operator"` handling both used to set the card's phase to `durch_kinder_blockiert` ("blocked by children") even though neither case ever produces a single child — a `needs_human` decision and an Operator job crash both happen *before* any split, so there is nothing to be blocked by. They now use a separate phase, `operator_klaerung_noetig` ("Operator: Klärung nötig"), leaving `durch_kinder_blockiert` exclusively for `orchestration.py`'s three genuine failed-/blocked-child cases (`progress_all()`), where `blocking_children` names real cards. Separately, `appendOrchestrationBadges()` in `board.html` returns immediately for a card with no `parent_id`/`depends_on`/`child_slugs` — the majority of the badge row (status, blocking children, recommended profile, ...) never rendered for a `needs_human` card, since it never gets a family at all. `operator_reason` and `human_action` were stored on the card but never read by `board.html` anywhere. `appendOperatorReason()` is a small, separate function called unconditionally from `renderCard()` (not nested inside the family-only badge function) that renders the Operator's decision, its reason, and — for `needs_human` — the recorded `human_action` as the concrete next step, regardless of whether the card has any family at all. It renders for every Operator decision (`direct_builder`/`split` included), not only `needs_human`, since the reasoning is useful context in all three cases and this is meant as a general "why did this land here" mechanism rather than a one-off fix for this single phase. ## Run-metrics pilot (docs/intent/board-intent-run-metrics-baseline.md) Opt-in, per-slug run history — never automatic, never board-wide. A run is only ever recorded for a slug listed in the `BOARD_METRICS_SLUGS` environment variable (comma-separated, empty by default in `board-agent.nix`; edited directly there to start or end a pilot, same convention as the model/reasoning-effort env tunables above it). For every other slug, `metrics.record_run()` is a complete no-op — no new field is ever written to that card's `state.json` entry. `metrics.py` (imported by `trigger.py`, `worker.py` and `completion_watcher.py`, same sibling-module pattern as `fanout.py`) appends one entry to `card["run_history"]` per finished job — one entry per Builder completion record, one per Review job, one per Research job — each carrying `run_kind` (`"board"` or `"baseline_single_prompt"`), `status`, `job_type`, whatever of `runtime`/`model`/`reasoning_effort` is known, `duration_seconds` (best-effort: derived from the originating job's own `requested_at` for Review/Research, from the Builder job ID's embedded queue timestamp for Builder), and an outcome-specific field (`commit_hash`, `review_decision`, or `research_report`). History is capped at `metrics.MAX_RUN_HISTORY` (200) entries per card. `merged_board()` exposes a compact `metrics_summary` (`total`/`board`/`baseline`/ `successful`) per card, rendered on the board as a small badge by `appendMetricsBadge()` — `null` (nothing rendered) for the vast majority of cards that were never enrolled. `metrics_baseline.py` is the other half: a manually run CLI, deliberately **not** a systemd service, that sends an existing card's intent markdown straight to a model via `worker.run_baseline()` — no `-C` into `agents/` (no role prompt), no `--add-dir`/repo access, and (for Claude) the same `--disallowed-tools Edit,Write,MultiEdit,NotebookEdit` the Reviewer uses — the null-hypothesis comparison point the intent asks for: what a single direct prompt to a strong model produces without the Board's Builder/Reviewer pipeline at all. Only `codex` and `claude` runtimes are supported (OpenRouter's role-scoped config helper doesn't have a "baseline" role and duplicating it wasn't worth the risk for a pilot tool). Both `--model` and `--runtime` are required arguments — deliberately no silent default, since the whole point is comparing against a model the operator explicitly chose. The raw response is written to `docs/metrics/-baseline-.md` for manual grading (no automatic quality judgment, same as every other role in this file); run to run: ``` sudo systemctl show board-agent-worker.service --property=Environment # then, as the drone user, with that same environment: python3 "$BOARD_METRICS_BASELINE_SCRIPT" --runtime claude --model opus ``` Deliberately **not** built here: automatic telemetry for every board run (the allowlist gate is the point, not an oversight); any LLM-as-judge or other automated quality scoring of a baseline response (grading stays manual); a dashboard beyond the one compact per-card badge; and any decision about long-term retention or privacy for a wider rollout beyond the pilot's small, deliberately chosen slug set. ## Why read-only, not a scoped writable dir (Reviewer sandbox) The intent document assumed Codex's `--sandbox workspace-write --add-dir docs/review` could scope the Reviewer to writing only there. **Verified false against this environment's actual `codex-cli` build (2026-09-06):** a real test run with `--add-dir /docs/review` still succeeded at writing a file *outside* that directory — `--add-dir` does not scope writes path-precisely here, it's closer to "writable in general" once `workspace-write` is on. Reads, by contrast, work everywhere regardless of sandbox mode or `--add-dir` (also verified live). So `run_review()` runs fully `--sandbox read-only` (a real, verified restriction — a write attempt under it fails at the shell level, "read-only file system") and gets its report back as a marker block in the text response instead, which this script writes to `docs/review/.md` itself, outside Codex's sandbox entirely. Same principle as `../agent-codex/builder.py`'s `===COMMIT-MESSAGE===` marker: Codex proposes, the wrapper applies. ## Reviewer visual verification (docs/intent/board-reviewer-visual-verification.md) Added 10.09.2026, prompted directly by a live review report (`web-ui-system-darkmode`, commit `4b2047e`) that flagged "no actual rendering checked, only the CSS variable structure" and "contrast only visually plausibilized from the chosen hex values" as unresolved uncertainties — a pure text diff review genuinely cannot close either gap. The Claude branch of `run_review()` now gets an extra, narrowly scoped tool for exactly that: `board-agent/screenshot.py` takes only a commit hash and a repo-relative `.html` path, extracts that file's exact content at that commit via `git show :` (never the live working tree, which may have moved on by the time Review actually runs), and renders it headless in both light and dark `prefers-color-scheme`, printing both PNG paths for the Reviewer to inspect with its own Read tool. `--allowedTools` gets a second, dynamically-built entry pinning the exact resolved `sys.executable ` invocation — same fixed-command precedent as the existing `Bash(git *)` grant, just for one script instead of a whole command family; the script re-validates both arguments itself regardless (commit-hash shape, `.html` suffix, no absolute path, no `..`). `REVIEW_PROMPT_TEMPLATE` gets a `{visual_verification}` section filled in only for Claude, with the commit hash already baked into the example command — Codex/OpenRouter get an empty string here, since `--sandbox read-only` blocks every write (see "Why read-only, not a scoped writable dir" above), leaving Chromium nowhere to put a screenshot. **Chromium flag gotcha, verified live 2026-09-10, not assumed:** `--force-prefers-color-scheme` has no effect at all on a `file://` page in this headless Chromium build (byte-identical screenshots for both values); `--blink-settings=preferredColorScheme=<0|1>` is what actually works (0 renders dark, 1 renders light — confirmed by the rendered pixel color, not the flag's nominal enum ordering). `--no-sandbox` in the script's own Chromium invocation is required, not optional: Chromium's internal setuid sandbox needs privilege escalation `board-agent-worker.service`'s existing `NoNewPrivileges = true` already forbids, so it fails to start without it. Containment for whatever this renders (in practice: this repo's own already-committed, already-reviewed HTML, never third-party content) comes entirely from the outer systemd sandbox instead of Chromium's own process isolation — a deliberate trade-off, not an oversight. A failed screenshot call (e.g. an unanticipated sandbox restriction blocking Chromium) is explicitly framed in the prompt as a review uncertainty to note, not a review failure — the Reviewer keeps evaluating the rest of the diff normally either way. Deliberately **not** built here: a computed WCAG contrast check (the same report's other uncertainty) — left for a separate follow-up if wanted, since it's a different kind of tool (deterministic math, no new dependency) than this one; and any visual verification for Codex/OpenRouter reviews. ## Auth The Traefik route for this service (`intent-board` in `../traefik/routes-internal.nix`) already applies the `auth` BasicAuth middleware — it was the first internal route to opt into it, per an earlier review finding that a write-capable trigger endpoint needs more than the bare Tailnet boundary the read-only internal tools (`tasks`, `schema`) rely on. (An earlier version of this README claimed "no auth beyond the Tailnet boundary" — that was stale; the BasicAuth middleware predates this note.) Real OIDC/Dex self-check auth (the pattern Forgejo/GoToSocial/Headscale already use) is deliberately deferred to a follow-up intent: this repo has no reusable `oauth2-proxy` forwardAuth mechanism today — verified 2026-09-06 that `oauth2-proxy` here is a singleton in front of exactly one other, currently-disabled backend (`launcher`), not a generic middleware. Wiring real self-check auth for this Web-UI means adding a new Dex client, a new LLDAP group scoped to the owner, and the check itself in `trigger.py` — not "point it at the existing oauth2-proxy." ## Deliberately deferred / out of scope - **No XMPP status post to `update@muc.0ff.space`.** `journalctl -u board-agent-worker`/`-u board-agent-completion-watcher` remain the mandatory verification channels. - **Board state stays in `BOARD_STATE_DIR/state.json`**, not git-versioned `docs/board/.yaml` files — see the intent document's Phase 2 note. Keeps the trigger/worker/watcher services from ever writing into the same tracked files a human or another agent role might be editing in the same checkout at the same time. - **Planner-driven model-profile escalation** (a stronger model per job, with justification) from the same intent document — no structured job-metadata mechanism exists for board cards to carry that yet. ## Tests `test_board_agent.py` — same `unittest`/`load_module` pattern as `../agent-codex/test_agent_codex.py`. Local: ```bash nix-shell -p python3 --run \ "cd hosts/blunix/localServices/board-agent && python3 -m unittest test_board_agent.py -v" ```