Adopting an existing repository (brownfield)
By default Bloom starts a project from nothing: on plan approval it creates a fresh GitHub repository and the first ticket scaffolds the stack. Adoption (M44) is the other entry point: bind a repository that already exists - code, tooling, history and all - as the project's target, and Bloom plans and coordinates changes to that application instead of a from-scratch build. The path is: bind -> understand -> plan -> PR, with an owner-confirmed import step between plan and PR for the repository's existing backlog (M47, below).
Binding an existing repository
Adoption is chosen at project creation. The create body's optional repo field names the
repository to adopt:
POST /api/projects
{"message": "Add PayPal support to my shop", "repo": "acme/legacy-shop"}
Prerequisite: Bloom's GitHub App must be installed on the account that owns the repository - an organization or a personal account - with access granted to that repository. The organizations-only rule applies to repository creation; Bloom can adopt what already exists on a personal account.
The bind is validated before anything persists, so a rejected bind leaves zero server state (no project row, no queued work):
- Slug validation.
repomust be anowner/namepair; anything else is a 422 before the bind is even consulted. - Installation resolution. GitHub is asked which App installation covers the repo. If
the App is not installed there, the create fails with a 422 carrying the
install-the-App-on-
ownerguidance. - Read preflight. The resolved installation must actually be able to read the repo (the shared access preflight, also run before every later sync). A repo outside the installation's repository grant fails with a 422 carrying the grant-access guidance.
On success the project's repo_target is stored with source: "adopted" and
created: true - the repository exists by definition, so the dashboard links it immediately
and webhook routing works from the first event. Everything else about the project (chat,
discovery, PRD approval, planning, review) runs the same lifecycle as a greenfield project.
Code: repoBind.ts (the bind
service), repoPreflight.ts
(the shared access preflight), and the repo field on
crudRoute.ts.
How brownfield planning works
An adopted target flips a set of behaviors that are all guarded by
repo_target.source == "adopted"; a provisioned (greenfield) project is untouched by every
one of them.
Bloom never creates or re-seeds. Repo resolution takes the adopt branch: no
createRepo, no README seed. The repo's own README.md, root PRD.md, and configuration
files belong to its engineers; Bloom records the approved PRD at docs/PRD.md in the
project repo, and only when that file is absent
(workflowContext.ts,
ensureRepo/adoptRepo). An approved plan revision updates Bloom's docs/PRD.md copy in
place - never the repo's root PRD.md
(planEvolution.ts,
applyPlanChange).
Bloom reads the codebase before planning. The codebase snapshot
(codebaseSnapshot.ts) is a
bounded, read-only walk of the repo's default branch through the VCS API: the directory
layout, detected stacks (language, frameworks, package manager, and the manifests that
evidence them), tooling already wired in (lint/format/test/CI markers), CI systems, a README
summary, and documentation files. It is TTL-cached so one walk serves a whole planning pass,
and it never writes anything.
Planning is grounded in the snapshot. Discovery, PRD authoring, milestone planning, and ticket generation each receive brownfield instructions plus the rendered snapshot: discovery treats what the snapshot answers (stack, tooling, architecture) as established facts and asks only about what should change; the PRD frames changes to the existing application; milestones are increments on the current codebase; tickets are diffs against current state, anchored in the directories and modules the snapshot shows. A failed walk degrades to ungrounded (greenfield-style) planning with a logged warning - never a stuck planning phase.
No scaffold ticket - guaranteed. The prompts ask the model not to write scaffold
tickets, and code enforces it: when the snapshot detects an existing stack, scaffold-titled
engineering tickets are deterministically dropped from the generated plan and surviving
dependencies on them are rewired
(scaffoldSuppression.ts).
The suppression is surfaced on the live-updates bus (scaffold_tickets_suppressed), never
silent. An adopted repo that is effectively empty (no detected stack) keeps its scaffold
ticket - it genuinely needs one.
Engineers work within the existing stack. A delegated ticket's engine request carries
repo_source: "adopted", which appends the brownfield rules to the implementation brief
(brief.ts): work within the established stack and
conventions, never re-scaffold or swap the stack, do not overwrite the README or existing
configs, use the incumbent test tooling, and base the work on the repository's existing
default branch - whatever its name. The PR publisher resolves the base branch from the
repository itself, so pull requests open against the repo's own default branch, never an
assumed main.
Importing the existing backlog
An adopted repository usually arrives with open issues of its own. Bloom does not take them on silently: once the plan's tickets are generated - and before anything is synced to the repository - it lists the issues it could take on and asks the owner which ones to import (M47). With a backlog the path becomes bind -> understand -> plan -> import -> delegate -> PR.
What qualifies as a candidate. An open issue of the adopted repository that is not a pull
request, has no assignee, carries none of Bloom's coordination labels (status:*, eng:*,
type:*), and is not already one of Bloom's - neither recorded in the plan's created_issues
nor titled like a planned ticket. Everything else is left alone: an assigned issue belongs to
whoever holds it, and a Bloom-labelled one is already in some Bloom flow (or was deliberately
staged to look like it). Candidates are offered in ascending issue-number order. A provisioned
(greenfield) project has no backlog, and an adopted repository with nothing importable gets no
prompt: the gate is routed around, never shown.
The chat prompt and the reply grammar. The gate posts one #N title line per candidate
and the three ways to answer:
import all- take every listed issue;import #12 #15- pick some by number (the#and theimportverb are optional; separate numbers with spaces, commas, orand);skip- leave the backlog alone (no,none, andleave themcount too).
A reply the grammar rejects - a number that was not offered, or no recognizable form - is narrated back together with the grammar and the gate stays open; nothing is imported on a partial match. Telegram and the web chat both answer the gate through the same workflow turn.
What adoption in place does to the issue. A chosen issue keeps its number - Bloom never
opens a duplicate. Its plan ticket is derived from the issue itself: the title verbatim (Bloom
recognizes its issues by title), the body plus a trailing Imported from #N line as the
description, the body's open task-list items (- [ ] ...) as acceptance criteria (or one
criterion restating the title when it has none), and Depends on: #N / Requires #N /
Blocked by #N lines resolved to the other chosen issues - a reference to a declined or
foreign issue is dropped rather than left to block the ticket forever. A documentation label
routes the ticket to the content role; everything else is engineering. When the plan is synced,
the issue gains Bloom's type:* label (its own labels are kept - labels are add-only) and is
filed under the Imported backlog milestone, created on the repository or reused when it
already exists. The body is not rewritten and no status label is set until the coordinator
delegates it; from then on it is delegated, briefed, implemented, reviewed, and merged exactly
like a generated ticket, with its pull request against the repository's existing default
branch. The import is surfaced on the live-updates bus (backlog_imported with the numbers,
backlog_import_skipped with the offered ones) and in the planning-ready message, which
counts adopted issues separately from opened ones.
Imported tickets survive plan revisions. Every ticket carries its origin (generated or
imported). A plan revision regenerates only the generated tickets, so it never retires an
imported one: imported tickets the revision omits are carried through (with the Imported backlog milestone), and a restated one keeps its origin.
The boundary. Import happens at plan time only: the gate runs once, right after ticket generation. Issues opened later, or offered and declined, are not picked up afterwards - the reconciler and the issue webhook keep skipping non-Bloom issues, and importing after planning has completed is out of scope. Bloom never claims an issue that already has a human assignee.
Code: backlogImport.ts (the
candidate rules, the ticket derivation, and the import writes); the gate - the
list_backlog_candidates and backlog_import nodes in
bloom-sdlc.workflow.json, whose bodies
live in workflowContext.ts
(listBacklog, promptBacklogImport, resumeBacklogImport); the reply grammar in
decisions.ts
(resolveBacklogImportReply); the in-place adoption in
planEvolution.ts
(adoptIssueInPlace); and the revision carry-forward in
planDelta.ts (carryImportedPlan).
Failure modes
| Failure | When | Behavior |
|---|---|---|
Malformed repo slug | create | 422 (string_pattern_mismatch), bind never consulted, nothing persisted |
| App not installed on the repo | create | 422 with the install narration, nothing persisted |
| Installation cannot read the repo | create | 422 with the grant-access narration, nothing persisted |
| Bind requested but the App/VCS is unconfigured | create | 503, nothing persisted |
| Access revoked after binding | milestone/issue sync | Sync skips with the same narration sent to the chat - never a mid-workflow crash |
| Snapshot walk fails | any planning phase | Degrades to ungrounded planning, logged (planning.codebase_snapshot_failed) |
| Candidate listing fails | list_backlog_candidates (planning) | Planning continues without the import prompt, logged (planning.backlog_candidates_failed); nothing is imported |
| Unknown number in the import reply | backlog_import gate | Narrated (I don't see #N among the issues I can import.) with the grammar; the gate stays open, nothing imported on a partial match |
| Unrecognized import reply | backlog_import gate | Narrated (I didn't catch which issues to import.) with the grammar; the gate stays open |
Regression coverage
The offline end-to-end test
repoAdoption.e2e.test.ts drives the whole
path over the production composition with the fake VCS/LLM providers: it binds a seeded
existing repository (whose default branch is deliberately not main), proves discovery's
prompt carries the rendered snapshot of the seeded tree, approves the PRD, asserts the plan
keeps only change-tickets (the model's scaffold ticket is suppressed) synced onto the
existing repo with the README/manifest untouched, then runs the delegated ticket's engine
leg and asserts the PR opens - and merges - against the existing default branch. Two further
cases (M47) seed the repository with a pre-existing backlog - an assigned issue, one already
carrying a Bloom status:* label, and two plain candidates, one declaring the other as a
dependency - and assert that planning parks on the import prompt listing exactly the two
candidates. The first replies import #N for one and asserts it becomes an imported ticket
recorded under its original number, carries Bloom's type:* label and the Imported backlog
milestone on the repository while the declined and excluded issues are untouched, and that its
delegated engine leg opens - and merges - a PR against the existing default branch like a
generated ticket's. The second replies skip and asserts no ticket, label, or milestone
changed and planning completed normally. Run it with pnpm run test from apps/server.