Skip to main content

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.

LabelMeaning
status:readydependencies met; awaiting delegation
status:in-progressdelegated to an engineer, being worked
status:in-reviewa PR is open, awaiting review/merge
status:changes-requestedreview requested changes; the PR needs fixes (engineer keeps it)
status:needs-attentionescalated after repeated failed reviews; paused for a human
status:blockedblocked on a dependency, or an engineer reported a blocker
status:awaiting-clientBloom-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)
deploymentdeployment 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 commit d69f789.

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 - its review_mode defaults to advisory (findings that inform, but do not by themselves approve, block, or merge anything). blocking mode 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's eng:<name> label plus native GitHub assignees, driven exclusively by the Coordinator's reconciler. SpecialistReviewer construction and the specialist_reviewer_roster config parser are pure data: they never call the GitHub client, never touch a status:* or eng:* 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) → after CONFIRM 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) → after CONFIRM 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.