Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions plugins/multi-repo/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ Core references below were verified against bb at commit `611e34892` (2026-09-24

## 1. Concepts

A **project's source is a `.bb` git repo** — the workspace definition, not the code. It contains:
A **project's source is the primary repo** — one real repo whose default
branch carries `.bb/`, a directory holding the workspace definition, not the
code. `.bb/` contains:

```
repos.json the repo set
Expand Down Expand Up @@ -68,14 +70,39 @@ Keep it strict JSON. Documentation lives in the seeded `AGENTS.md` and the plugi

### Where `.bb` lives

`project_sources.path` is the canonical `.bb` checkout, per `(projectId, hostId)` — one per machine (`packages/db/src/schema.ts:496`).

Two ways in, same code path afterwards:

- **Shared** — the `.bb` repo has a remote. Clone it, create the project pointing at it. A teammate gets the whole workspace definition from one URL.
- **Local, from scratch** — create the project pointing at a fresh directory. The plugin initializes `.bb` on first use. No remote, no setup.

The local variant upgrades to shared with `git remote add` and a push. Nothing else in the design changes and there is no migration.
`project_sources.path` is the canonical project-source checkout, per
`(projectId, hostId)` — one per machine (`packages/db/src/schema.ts:496`).
There are two layouts, detected from the default branch's committed tree:

- **primary** — the checkout is a clone of the primary repo and the definition
sits at `P/.bb/`. `repos.json` lists only the other repos; P is derived from
the checkout's `origin` and materialised into the workspace in addition to
them. `.bb/` is copied from the canonical checkout to `<workspace>/.bb`,
because core reads `<workspacePath>/.bb/AGENTS.md` and `.bb/skills` from the
workspace root only.
- **standalone** — the checkout is a `.bb` git repo with the definition at its
root. This is the original layout, still read and written as before so a
machine that has not adopted keeps working.

Reads (`readProjectFiles`, `repoSet`) resolve the default branch's committed
tip — `origin/<default>`, or the local default branch when the plugin's own
commit is ahead of it — never the working tree or a feature branch.

Ways in:

- **Primary, from an existing repo** — point the project at a clone of P. If it
does not carry `.bb/`, the plugin seeds it on the default branch and publishes
a `guidance-<seed>` branch with a pull request.
- **Shared** — the standalone `.bb` repo has a remote. Clone it, create the
project pointing at it.
- **Local, from scratch** — create the project pointing at a fresh directory;
the plugin initializes a standalone `.bb` there.

`bb repos adopt <primary>` (and the panel) copies a standalone tree into
`.bb/` on a branch of the primary and opens a pull request; once it merges, a
later run re-points `project_sources.path` in place. That is BB-106 Case A: the
SDK's `bb.sdk.projects.sources.update` re-points the source row for the same
project id, so no store re-key and no steward respawn is needed.

## 3. Why a plain-directory workspace root

Expand Down
53 changes: 43 additions & 10 deletions plugins/multi-repo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ containing a checkout of each**.
.bb/ the project definition: repos.json, AGENTS.md, skills/
```

A project's source stops being *code* and becomes the **workspace definition**:
a small `.bb` git repo holding the repo set, cross-repo guidance, and project
skills. Every repo is equal — there is no privileged primary repo.
A project's source is the **primary repo** — one real repo whose default
branch carries `.bb/`, a directory holding the repo set, cross-repo guidance
and project skills. `.bb/repos.json` lists only the *other* repos; the primary
is derived from the project source's own `origin` and is not repeated. The
standalone `.bb`-repo layout is still supported, so a machine that has not
adopted keeps working; `bb repos adopt` moves a standalone definition into the
primary, per machine.

Every repo is equal in the workspace; the primary is one of them.

Standalone: no bb fork, no bb-internal packages, no dependency on any other
plugin.
Expand Down Expand Up @@ -94,7 +100,7 @@ parse fails the launch with a message naming the offending entry.
| **Repos** nav panel | The repo set as a list — add by URL with completions from local checkouts, change a base branch inline, remove — each change one commit in `.bb`. A **New project** form that creates a multi-repo project in one step. `repos.json` as text behind a disclosure, which opens itself when the file does not parse. |
| **Changes** thread panel | Per-repo diff and pull request. Opened from the thread panel's Actions list. |
| Composer banner | One row above the composer naming the repos that changed, their file counts and the `+`/`−` totals. Click it to open the Changes panel. Hidden when nothing has changed. |
| `bb repos` | `list`, `add`, `remove` (each takes `--project`), and `status` for the current thread. |
| `bb repos` | `list`, `add`, `remove` (each takes `--project`), `adopt <primary>`, and `status` for the current thread. |
| Agent tools | `workspace_list_repos`, `workspace_add_repo`, `workspace_remove_repo`, `workspace_publish_guidance`. |
| Agent instructions | A generated table of `repo → branch → path`, plus a `multi-repo` skill. |

Expand Down Expand Up @@ -126,6 +132,26 @@ A second benefit: two threads cannot both check out `main` as worktrees of one
repo, but independent clones can — and `.bb` is on its default branch in every
workspace.

### Where `.bb` lives

`.bb/` is a directory on the primary repo's default branch, next to the code,
reviewed like code. The project source is a clone of that primary repo, and
core reads `<workspace>/.bb/AGENTS.md` and `<workspace>/.bb/skills` from the
workspace root — so each thread materialises `.bb/` there from the primary's
`.bb/`. Reads (`readProjectFiles`, `repoSet`) come from the default branch's
committed tip, never the working tree or a feature branch.

The **standalone** layout — a small `.bb` git repo that is the project source —
is still read and written exactly as before. `bb repos adopt <primary>` copies
a standalone tree into `.bb/` on a branch of the primary and opens a pull
request; once it merges, running `bb repos adopt` again re-points
`project_sources.path` in place. The project id, every stewards binding and
every thread stay as they are (BB-106, Case A).

A workspace built before an adopt keeps publishing guidance to the old
standalone checkout; `bb repos status` and the Changes panel say so, and a
branch it publishes has to be re-applied to the primary by hand.

### The object cache

One bare repo per remote, per machine, at `<plugin host-data>/repos/<key>.git`.
Expand Down Expand Up @@ -174,17 +200,24 @@ the generated block cannot be edited.
### Writing back to `.bb`

The canonical `.bb` checkout is outside the agent's sandbox, so the write is
inverted: the canonical repo *fetches* from the workspace clone, initiated by
the plugin's host entry.
inverted: the plugin's host entry does it.

On the **standalone** layout, the canonical repo *fetches* from the workspace
clone:

```
git -C <canonical-.bb> fetch <workspace>/.bb HEAD:refs/heads/guidance-<threadId>
```

That avoids the sandbox and `receive.denyCurrentBranch` at once, and leaves a
named branch to review and merge. `workspace_publish_guidance` is the agent's
entry point; `repos.json` edits from the tools, the CLI and the panel take the
same host-side route, which is why they work under `accept-edits`.
On the **primary** layout, `.bb/` is a directory in the primary repo and the
default branch is reviewed like code, so `workspace_publish_guidance` builds a
`guidance-<threadId>` branch with the workspace's `.bb/` content and stops
there — it never moves the default branch. `mergeGuidance` reports that a
lesson needs a pull request (BB-108 adds that flow). That avoids the sandbox and
`receive.denyCurrentBranch` at once, and leaves a named branch to review and
merge. `workspace_publish_guidance` is the agent's entry point; `repos.json`
edits from the tools, the CLI and the panel take the same host-side route,
which is why they work under `accept-edits`.

### Reading `.bb` from another plugin

Expand Down
12 changes: 12 additions & 0 deletions plugins/multi-repo/changes-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,18 @@ export function ChangesPanel({ threadId }: { threadId: string }) {
</div>
)}

{workspace.predatesAdopt && (
<div className="rounded-md border px-3 py-2 text-xs">
<div className="font-medium">This workspace predates the adopt</div>
<p className="text-muted-foreground mt-1">
It was built from the standalone <code className="font-mono">.bb</code> at{" "}
<span className="font-mono">{workspace.projectSourcePath}</span>, and the project source is now a
primary repo. Guidance published from this thread goes to the old checkout; re-apply such a branch to
the primary by hand.
</p>
</div>
)}

{workspace.sharedWith.length > 0 && (
<div className="rounded-md border px-3 py-2 text-xs">
<div className="flex items-center gap-1.5 font-medium">
Expand Down
125 changes: 125 additions & 0 deletions plugins/multi-repo/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { PROJECT_FILE_PATH_MAX, projectFilePathProblem } from "./names.js";

const absolutePath = z.string().min(1).max(4096);

/** Which of the two project-source shapes a checkout has. */
export const projectLayoutSchema = z.enum(["standalone", "primary"]);
export type ProjectLayoutValue = z.infer<typeof projectLayoutSchema>;

/**
* The most text one file in `readProjectFiles` may carry.
*
Expand Down Expand Up @@ -238,6 +242,8 @@ export const hostContract = defineRpcContract({
reposJson: z.string().max(200_000).nullable(),
remote: z.string().max(2000).nullable(),
defaultBranch: z.string().max(300).nullable(),
/** `standalone` (definition at the root) or `primary` (`.bb/`). */
layout: projectLayoutSchema,
warnings: z.array(z.string().max(600)).max(20),
})
.strict(),
Expand Down Expand Up @@ -276,6 +282,8 @@ export const hostContract = defineRpcContract({
path: absolutePath,
reposJson: z.string().max(200_000),
message: z.string().min(1).max(300),
/** Which layout's definition path to write; defaults to `standalone`. */
layout: projectLayoutSchema.optional(),
})
.strict(),
output: z
Expand Down Expand Up @@ -332,6 +340,10 @@ export const hostContract = defineRpcContract({
operationId: z.string().min(1).max(200),
pathKey: z.string().min(1).max(200),
projectSourcePath: absolutePath,
/** Which shape the project source has; decides how `.bb/` is materialized. */
layout: projectLayoutSchema.optional(),
/** The primary work repo's directory; `.bb/` is copied out of it. */
primaryDir: z.string().min(1).max(100).nullable().optional(),
repos: z.array(repoRequestSchema).max(64),
branchName: z.string().min(1).max(300),
fetchTtlMs: z.number().int().nonnegative(),
Expand All @@ -343,6 +355,8 @@ export const hostContract = defineRpcContract({
.object({
root: absolutePath,
repos: z.array(workspaceRepoSchema).max(64),
/** The default-branch commit `.bb/` was materialized from, or null. */
definitionBase: z.string().max(64).nullable(),
})
.strict(),
},
Expand Down Expand Up @@ -479,6 +493,8 @@ export const hostContract = defineRpcContract({
projectSourcePath: absolutePath,
workspaceBbPath: absolutePath,
threadId: z.string().min(1).max(100),
/** The default-branch commit the workspace `.bb/` was materialized from. */
definitionBase: z.string().max(64).nullable().optional(),
})
.strict(),
output: z
Expand Down Expand Up @@ -513,6 +529,56 @@ export const hostContract = defineRpcContract({
})
.strict(),
},

/**
* Copy a standalone `.bb` tree into a primary repo as `.bb/` on a branch, or
* report that it already carries one. Idempotent; never touches the default
* branch.
*/
adoptProjectSource: {
input: z
.object({
standalonePath: absolutePath,
primaryUrl: z.string().min(1).max(2000),
primaryPath: absolutePath,
branch: z.string().min(1).max(300),
openPullRequest: z.boolean(),
})
.strict(),
output: z
.object({
status: z.enum(["already", "pull-request", "failed"]),
message: z.string().max(2000),
prUrl: z.string().max(600).nullable(),
warnings: z.array(z.string().max(600)).max(20),
primaryPath: absolutePath,
defaultBranch: z.string().max(300).nullable(),
})
.strict(),
},

/** Clone/ensure a primary repo and seed `.bb/` on it when it does not carry one. */
preparePrimaryProjectSource: {
input: z
.object({
url: z.string().min(1).max(2000),
path: absolutePath,
seed: z.string().max(200_000),
branch: z.string().min(1).max(300),
openPullRequest: z.boolean(),
})
.strict(),
output: z
.object({
ok: z.boolean(),
layout: projectLayoutSchema,
seeded: z.boolean(),
prUrl: z.string().max(600).nullable(),
warnings: z.array(z.string().max(600)).max(20),
message: z.string().max(2000),
})
.strict(),
},
});

export type HostContract = typeof hostContract;
Expand Down Expand Up @@ -545,6 +611,10 @@ export const repoSetViewSchema = z
/** Null when this project has no `.bb` checkout on a reachable machine. */
projectSourcePath: absolutePath.nullable(),
hostId: z.string().max(100).nullable(),
/** `standalone` (definition at the root) or `primary` (`.bb/`). */
layout: projectLayoutSchema,
/** The primary repo derived from the source's `origin`, or null. */
primary: z.object({ dir: z.string().max(100), url: z.string().max(2000) }).strict().nullable(),
/** The file's raw text, for the editor. Null when it does not exist yet. */
reposJson: z.string().max(200_000).nullable(),
/** Parsed entries, empty when `error` is set. */
Expand Down Expand Up @@ -572,6 +642,13 @@ export const workspaceViewSchema = z
branchName: z.string().max(300),
hostId: z.string().max(100),
projectSourcePath: absolutePath,
/**
* True when this workspace was provisioned from a standalone `.bb` and the
* project source has since been adopted into a primary repo. Guidance
* published from it still goes to the old canonical checkout, so it has to
* be re-applied to the primary by hand.
*/
predatesAdopt: z.boolean(),
repos: z.array(workspaceRepoSchema).max(64),
/**
* The other threads attached to this same checkout — forks, which keep
Expand Down Expand Up @@ -765,6 +842,16 @@ export const rpcContract = defineRpcContract({
source: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("fresh"), repos: z.array(repoSeedSchema).max(64) }).strict(),
z.object({ kind: z.literal("clone"), url: z.string().min(1).max(2000) }).strict(),
z
.object({
kind: z.literal("primary"),
url: z.string().min(1).max(2000),
/** The other repos to seed `.bb/repos.json` with. */
repos: z.array(repoSeedSchema).max(64),
/** Publish a seed branch and open a pull request. */
openPullRequest: z.boolean().optional(),
})
.strict(),
]),
})
.strict(),
Expand All @@ -774,6 +861,44 @@ export const rpcContract = defineRpcContract({
projectId: z.string().max(100).nullable(),
error: z.string().max(2000).nullable(),
warnings: z.array(z.string().max(600)).max(20),
/** The layout the source ended up with, when the project was created. */
layout: projectLayoutSchema.nullable(),
/** The seed/adopt pull request, when one was opened. */
prUrl: z.string().max(600).nullable(),
})
.strict(),
},

/**
* Adopt a standalone `.bb` into a primary repo, per machine.
*
* The first run copies the tree to `.bb/` on a branch and opens a pull
* request; a later run, once `.bb/` is on the default branch, re-points the
* project source at the primary checkout. Case A of BB-106: the project id,
* every binding and every thread stay as they are.
*/
adoptProjectSource: {
input: z
.object({
projectId: z.string().min(1).max(100),
/** URL or local path of the primary repo. */
primary: z.string().min(1).max(2000),
/** Where the primary clone lives or should be cloned to. */
path: absolutePath,
/** Publish a branch and open a pull request. */
openPullRequest: z.boolean().optional(),
})
.strict(),
output: z
.object({
status: z.enum(["already", "pull-request", "failed"]),
message: z.string().max(2000),
prUrl: z.string().max(600).nullable(),
warnings: z.array(z.string().max(600)).max(20),
primaryPath: absolutePath,
defaultBranch: z.string().max(300).nullable(),
/** True when the project source now points at the primary checkout. */
repointed: z.boolean(),
})
.strict(),
},
Expand Down
3 changes: 2 additions & 1 deletion plugins/multi-repo/fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,13 @@ function hostResponder(call: HostRpcCall): unknown {
}),
remote: null,
defaultBranch: "main",
layout: "standalone",
warnings: [],
};
case "discoverCheckouts":
return { checkouts: [] };
case "provisionWorkspace":
return { root: ROOT, repos: WORKSPACE_REPOS };
return { root: ROOT, repos: WORKSPACE_REPOS, definitionBase: null };
case "workspaceStatus":
return {
repos: WORKSPACE_REPOS.map((repo) => ({
Expand Down
Loading
Loading