799 lines
46 KiB
Markdown
799 lines
46 KiB
Markdown
# 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=<resolved-commit>#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/<slug>.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/<column> 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/<id>/draft-answer answer the Planner's
|
||
latest draft question, queue the next turn
|
||
-> POST /api/board/cards/<id>/draft-retry re-run a failed draft
|
||
turn unchanged
|
||
-> DELETE /api/board/cards/<id> remove a manual backlog card, or an
|
||
abandoned draft that never finished
|
||
-> PATCH /api/board/cards/<id> 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/<slug>.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/<slug>.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/<slug>.md und übergibt den Auftrag
|
||
an agents/researcher.md
|
||
- extrahiert den
|
||
===RESEARCH-REPORT===...===END-RESEARCH-REPORT===-Block und
|
||
schreibt ihn selbst nach docs/research/<slug>.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/<child-slug>.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/<slug>.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-<uuid> 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/<child-slug>.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/<slug>.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-<uuid>`, 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: ... / --- / <intent body>` 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/<slug>.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-<uuid>` 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/<child-id>/parent` with body
|
||
`{"parent_id": "<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/<parent-id>-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/<slug>-baseline-<unix-timestamp>.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" <slug> --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 <repo>/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/<slug>.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 <hash>:<path>` (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 <BOARD_SCREENSHOT_SCRIPT> <commit_hash>`
|
||
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/<slug>.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"
|
||
```
|