Coordination & the engineer swarm
Bloom's Product-Owner role is not just planning - it watches delivery and coordinates the work: delegating tickets, tracking status, handling blockers, and rolling up milestones. This page describes the coordination model and the swarm simulator used to exercise it end-to-end.
The swarm simulator here is a test harness, not the product runtime. It stands in for real engineers so the PO-side coordination can be exercised against a real GitHub repo. The production execution backend now exists - a pluggable engineer engine (M6, default a Claude Code engineer: one issue -> one isolated
feat/issue-<n>branch -> one PR, with the review-driven rework loop and failure classification/escalation). See engineering-engine.md; this page stays focused on the coordination model and label contract that any engine plugs into. Both follow the hybrid identity model: a single App provides access while each commit is authored per-engineer (<name>@<domain>), so attribution shows up in history without a GitHub account per engineer.
The label contract
Bloom coordinates through GitHub-native ownership signals plus Bloom issue labels. Human
engineers and reviewers should use native GitHub assignees, requested reviewers, teams, and
reviews where possible. Agent engineers use Bloom labels, App-authored branches/PRs, comments,
reviews, checks, and statuses. See apps/server/src/coordination/labels.ts.
| Label | Meaning |
|---|---|
status:ready | dependencies met; awaiting delegation |
status:in-progress | delegated to an engineer, being worked |
status:in-review | a PR is open, awaiting review/merge |
status:changes-requested | review requested changes; the PR needs fixes (engineer keeps it) |
status:needs-attention | escalated after repeated failed reviews; paused for a human |
status:blocked | blocked on a dependency, or an engineer reported a blocker |
status:awaiting-client | Bloom-approved; parked for the owner's final sign-off (M11) |
eng:<name> | the collaborator currently assigned |
type:<kind> | the ticket's kind - engineering / design / ux / content / research (M11) |
deployment | deployment work: the engine scaffolds the pipeline, then the approved PR parks awaiting client credentials (M18) |
A ticket carries at most one status:* at a time. Done = the issue is closed (via a PR's
Closes #N). Milestone status is derived: open → in-progress → closed (Bloom closes a milestone
when all its issues are done).
Roles and routing (M11). Collaborators have a role (engineer or designer, via
BLOOM_COLLABORATORS=<name>[:<role>]). A ready ticket is delegated only to a collaborator whose role
is eligible for its type:* kind - design/ux work goes to a designer, everything else to an
engineer - least-loaded within that pool, with capacity enforced per role. A ticket no role on the
roster can take stays status:ready and is surfaced rather than misrouted. eng:<name> names the
assigned collaborator regardless of role (one namespace).
Design deliverables and the client gate (M11). A design ticket is produced by the Bloom
Designer, which posts comparable design studies on the issue and flips it to status:in-review.
Deliverables reviewed this way have no PR: Bloom reviews the issue's studies against the ticket's
acceptance criteria and drives the same request-changes / escalation loop as the PR path. For
client-facing kinds (BLOOM_CLIENT_APPROVAL_KINDS, default design,ux,content), Bloom's approval
does not finalize the work - it moves the ticket to status:awaiting-client and asks the owner to
sign off; the owner approves (finalize) or requests changes (back to the collaborator) in chat.
status:awaiting-client consumes no capacity, is never flagged stalled, and is skipped by the
reconciler - a parked ticket is resolved in chat, never re-planned.
Deployment tickets (M18/M19). A deployment-labeled ticket takes the same parking path
without any kind configuration: the engine's brief switches to scaffold-the-pipeline rules - a
real CI deploy workflow is REQUIRED (a deployment PR without a .github/workflows/ file is
deterministically rejected, M19-7), secrets are never in the repo. Delegating the ticket
provisions deploy-time secret delivery (M19-4): a per-project machine identity in the credential
store (design), OIDC-bound to the repo's default branch, plus
non-secret INFISICAL_* repo variables the workflow reads; the workflow fetches its secrets at
deploy time via the Infisical action with the runner's OIDC token. When Bloom approves the
pipeline PR it parks the ticket at status:awaiting-client and points the owner at the
dashboard Credentials page (M19-2/6); once the scheme's required secret names are all
provided, Bloom nudges exactly once that go-live is one approve away - the owner's approve
then merges the pipeline PR and closes the ticket. On that finalize, Bloom learns the app's
live URL from the README's Live URL: line (a brief rule - the engineer picks the concrete
host) and messages it to the owner; every later merged-PR notification links it too, so each
shipped update is one tap from checkable. (With no credential store configured, parking falls
back to the M18-5 manual repo-secrets wording.)
Human and agent ownership signals are mutually exclusive. A ticket assigned to a human engineer must not be delegated to a Bloom engineer, and a ticket labeled for a Bloom engineer must not be assigned to a human engineer unless an explicit handoff clears or replaces the previous owner signal. The reconciler reads both kinds of signals so humans and agents do not duplicate, steal, or race the same work.
The loop
plan ─▶ Bloom labels tickets ready/blocked (by dependency) and delegates ready ones
to the least-loaded eligible actor under a capacity cap, using native GitHub
assignment for humans and eng:<name> labels for agents
│
▼
engineer picks up its in-progress ticket ─▶ opens a PR (Closes #N) ─▶ status:in-review
(or reports a blocker ─▶ status:blocked, returned to the pool)
│
▼
Bloom reviews the PR ─▶ posts the full review on the PR (a review comment the engineers
act on and discuss) ─▶ merges on approval ─▶ issue closes (done)
(or requests changes ─▶ status:changes-requested, the engineer reworks the same PR;
after BLOOM_REVIEW_MAX_ROUNDS failed rounds ─▶ status:needs-attention, escalated to
a human and out of the automated loop so a stuck ticket can't cycle forever)
│
▼
Bloom reconciles: unblock dependents ─▶ delegate next ─▶ close finished milestones ─▶ notify
Bloom's side is a deterministic, idempotent reconciler
(services/coordinator.ts) run on every coordination
trigger (planning finished, PR opened, issue closed/blocked), so it always converges to the
desired state regardless of event ordering or retries.
Dependency correctness. A ticket is ready only when every dependency resolves to a closed
issue; a dependency that resolves to none (a dangling/typo'd title) keeps the ticket blocked
rather than being silently treated as satisfied (M13-4). Dangling dependencies and cycles are also
caught and repaired at planning time - before tickets ever sync to GitHub - so the graph the
reconciler sees is already sound.
Reviewing the code, not the description. A strict review fetches the PR's actual changed files (the diff, per file with patch hunks) and judges them against the ticket's acceptance criteria, so the verdict is grounded in the code rather than the PR body. The diff is rendered with a new-file line-number gutter, letting the reviewer anchor each finding to a specific line. The review carries code-anchored findings, each tagged with a file, a line, and a severity (blocking / major / minor / nit); Bloom approves only when no blocking or major finding remains.
Where feedback goes. The PO↔engineer channel is the repo itself. A finding that names a diff line is posted as an inline review comment on that line; file-level findings, unmet acceptance criteria and documentation gaps go in the review body. (A comment on a line outside the diff would make GitHub reject the whole review, so Bloom validates each line against the diff first and falls back to a body-only review if the inline comments are rejected - the review always lands.) None of this is dumped into the client's Telegram feed: they get a concise headline instead (approved+merged, or "needs fixes" with a link to the PR).
Configuring Bloom
Set an engineer roster (otherwise Bloom plans but does not delegate):
BLOOM_ENGINEERS=ada,linus,grace
BLOOM_ENGINEER_CAPACITY=1 # max concurrent in-flight tickets per engineer
Running the swarm simulator
Historical (Python-era). The swarm simulator lived in the Python backend (
bloom.swarm) and retired with it (#807/#808); the commands below run at git commitd69f789.
The swarm authenticates as a distinct identity from bloom-po (so engineer PRs come from a
different actor than the reviewing PO - you can't approve your own PRs). Two ways:
Preferred - the "Bloom Engineers" GitHub App (faithful hybrid: one App for access installed on the org, short-lived installation tokens, per-engineer commit authorship):
SWARM_REPO=hidden-claw/<project-repo> \
SWARM_GITHUB_APP_ID=<id> SWARM_GITHUB_INSTALLATION_ID=<inst> \
SWARM_GITHUB_APP_PRIVATE_KEY_PATH=/path/to/bloom-engineers.pem \
python -m bloom.swarm
Fallback - a PAT with write access to the repo (skips the App install):
SWARM_REPO=hidden-claw/<project-repo> SWARM_GITHUB_TOKEN=<pat> python -m bloom.swarm
Each tick, every engineer picks up its eng:<name> + status:in-progress tickets and opens a
real trivial PR (a branch + a stub file under deliverables/ + Closes #N), occasionally
reporting a blocker instead. It also picks up its status:changes-requested tickets - the ones
Bloom sent back after a failed review - and reworks them: a new authored commit on the same
branch plus a reply comment, flipping the ticket back to status:in-review so Bloom re-reviews.
This closes the full review→fix→re-review loop. Set SWARM_ONCE=1 to run a single tick. See
apps/api/src/bloom/swarm/ at that commit.
On the deployed VM it runs as a container alongside Bloom (a swarm compose profile). Put
SWARM_GITHUB_TOKEN / SWARM_REPO (and optionally SWARM_ENGINEERS) in the Actions
secrets/variables so they render into .env, then start it with:
docker compose --profile swarm up -d swarm
Specialist reviewers (M7)
Bloom's specialist reviewers are typed, opt-in review actors - architecture, security, and QA
lenses - defined in domain/models.ts
(SpecialistReviewer, SpecialistRole, ReviewerActorKind, SpecialistReviewMode,
SpecialistOutputCapability) and configured via BLOOM_SPECIALIST_REVIEWERS
(the specialist_reviewer_roster twin, settings.ts).
When a roster is configured, Bloom fans a PR out to enabled specialists after the Product Owner
review has the changed files and project context available. The concrete specialist clients live
under agents/ and are dispatched by role through
services/specialistReview.ts:
- Architecture checks module boundaries, coupling, maintainability, and alignment with the documented architecture.
- Security checks authentication, authorization, secret handling, input validation, dependency risk, and least-privilege concerns.
- QA checks acceptance criteria coverage, test depth, regression risk, and verification evidence.
The result is a deterministic, fail-soft SpecialistReviewRound: output ordering follows the
configured roster, and one failing specialist is recorded as an error without dropping the rest
of the round.
How this differs from Product-Owner review and engineer assignment:
- Product Owner review (FR-8,
agents/reviewer.ts) is the single review that gates a PR: it approves and merges, or requests changes. A specialist reviewer is additive - itsreview_modedefaults toadvisory(findings that inform, but do not by themselves approve, block, or merge anything).blockingmode is reserved as a future value; the shipped M7 flow records specialist findings but never treats them as the merge gate. - Engineer assignment (FR-11,
services/coordinator.ts) is Bloom'seng:<name>label plus native GitHub assignees, driven exclusively by the Coordinator's reconciler.SpecialistReviewerconstruction and thespecialist_reviewer_rosterconfig parser are pure data: they never call the GitHub client, never touch astatus:*oreng:*label, and never set a native assignee. Enabling specialist reviewers cannot reassign, duplicate, or race engineering ownership - the human/agent ownership rule above is unaffected. - Both human and AI specialists are first-class (
reviewer_kind): a human specialist is a named collaborator Bloom expects to review via native GitHub means (comments/reviews), while an AI specialist is Bloom-run analysis published through App-authored GitHub review/comment surfaces. Either kind remains separate from engineer ownership.
Specialist reviewers are opt-in and disabled by default: an unset BLOOM_SPECIALIST_REVIEWERS
yields an empty roster. To configure one, list <role> (AI, default) or <role>:human
comma-separated:
BLOOM_SPECIALIST_REVIEWERS=security,architecture:human
An unsupported role or kind raises at startup rather than silently doing nothing.
Where specialist feedback goes. Specialist findings are published on the pull request as a COMMENT review. Findings that name a valid changed line are posted inline; file-level findings, out-of-diff findings, and any GitHub inline-comment rejection fall back to the review body so the specialist round still lands. The dashboard project detail stores compact per-role status, and proactive digests can include that compact status. Full specialist reasoning stays in GitHub, not in the user's Telegram feed.
Project lifecycle (archive / delete)
The owner manages a project's end-of-life from Telegram, out-of-band (like /status):
new project(or/new,start a new project) → spin up a brand-new project in the same chat, alongside any existing ones. The new project becomes active; your others are kept intact (nothing on GitHub is touched). The next message describes the new idea.projects(or/projects,list projects) → list every project in this chat with its stage, marking the active one.switch to <n>(or/switch <n>,project <n>) → make project #n the active one; all further chat then flows to it.archive this project(or/archive) → afterCONFIRM ARCHIVE, Bloom archives the active project's repo (read-only) and pauses coordination on it. The project is retired from the chat and the chat re-focuses on another (or is empty).delete this project(or/delete) → afterCONFIRM DELETE, Bloom permanently deletes the active project's repo and retires it from the chat.
A single chat can hold multiple projects at once. Each project is its own workflow run with
a chat-scoped run key ("<chat_id>:<seq>"), so inbound GitHub webhooks still route to the exact
project that owns the repo. Inbound Telegram messages route to the chat's active project; use
new project / projects / switch to <n> to manage the set. The destructive ops are two-step
(any non-confirming reply cancels). A project still in discovery/planning (no repo yet) is
dropped immediately on delete - there's nothing on GitHub to destroy. A client-owned repo
can only be archived - deleting it is the client's to do (Bloom offers to archive instead).
Deleting needs the GitHub App to have Administration (delete) permission. Internally each run
carries a lifecycle state (active/archived/deleted) that gates coordination and PR/issue
handling; a per-chat index (persisted under chat:<chat_id>) tracks the project list and which
one is active.
What this exercises
Running Bloom (with a roster) plus the swarm against a project repo tests the PO capabilities end-to-end: ticket/PR status tracking, label usage, milestone status (open/in-progress/done), delegation of unassigned tickets, dependency-aware sequencing, and blocked-work handling.