Skip to main content

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):

  1. Slug validation. repo must be an owner/name pair; anything else is a 422 before the bind is even consulted.
  2. 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-owner guidance.
  3. 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 the import verb are optional; separate numbers with spaces, commas, or and);
  • skip - leave the backlog alone (no, none, and leave them count 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

FailureWhenBehavior
Malformed repo slugcreate422 (string_pattern_mismatch), bind never consulted, nothing persisted
App not installed on the repocreate422 with the install narration, nothing persisted
Installation cannot read the repocreate422 with the grant-access narration, nothing persisted
Bind requested but the App/VCS is unconfiguredcreate503, nothing persisted
Access revoked after bindingmilestone/issue syncSync skips with the same narration sent to the chat - never a mid-workflow crash
Snapshot walk failsany planning phaseDegrades to ungrounded planning, logged (planning.codebase_snapshot_failed)
Candidate listing failslist_backlog_candidates (planning)Planning continues without the import prompt, logged (planning.backlog_candidates_failed); nothing is imported
Unknown number in the import replybacklog_import gateNarrated (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 replybacklog_import gateNarrated (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.