# Cross-repo plans

> A cross-repo plan is a single Minions plan whose work items ship into **two or more configured projects**. The plan has no owning project; each `missing_features` item declares its own `project`, the materializer fans the items into per-project `work-items.json` files, and verify completion fans out into one verify work item per touched repo.
>
> Shipped by `PL-cross-repo-coordination` (`prd/minions-opg-2026-06-10-3.json`, items `P-7a3f1c08` … `P-8b5d3e07`). Default plan shape is unchanged for single-project plans — everything in this doc only activates when the plan markdown declares more than one project.

## When to use a cross-repo plan

Use a cross-repo plan when **one coherent feature touches code in multiple repos and the work should be tracked together**. Common examples:

- An engine change in `minions` + a matching opg compliance file in `minions-opg`.
- A protocol/contract change in repo A whose downstream consumer in repo B must land in lockstep.
- A docs/runbook sweep that updates the same convention across all configured repos.

Use **separate single-project plans** when the repos are evolving independently and you only happen to be working on both at once — there's no win in coupling unrelated PRs onto a single PRD lifecycle.

A cross-repo plan does NOT mean "shared git history". Repos are still separate Git origins and each item gets its own PR in its own repo. The "cross-repo" part is purely a coordination layer in the engine: one PRD, per-item project routing, one rollup card on the dashboard, fan-out verify at the end.

## Authoring (plan markdown → PRD)

### 1. Plan markdown declares projects

A cross-repo plan is detected by a single signal at parse time: `shared.extractPlanTargetProjects(planContent)` returns ≥ 2 entries AND `shared.extractPlanDeclaredProject(planContent)` returns `''` (no singular `Project:` line). The parser accepts two forms, both anchored in the first 80 lines:

**Primary signal — structured HTML comment** (preferred, written by the engine's plan template):

```markdown
<!-- minions:targetProjects=minions,minions-opg -->
```

**Fallback — human-readable plural `Projects:` line** (works when an operator hand-edits a plan):

```markdown
**Projects:** minions, minions-opg
```

Both forms accept `,`, `|`, or `;` as separators with surrounding whitespace, and strip surrounding quotes. See `engine/shared.js#extractPlanTargetProjects` (line ~3713) for the exact regex.

The dashboard's Create-Plan modal (P-b51c08af) renders a multi-select project picker. Submitting two or more projects normalizes to a deduped array and `POST /api/plans/create` writes the plan with the plural `**Projects:**` header + HTML comment marker, no singular `**Project:**` line. Selecting zero or one project keeps the legacy singular header (no behavioural change for single-project plans).

### 2. Plan-to-prd dispatch

When the central planner picks up a plan-to-prd work item, `engine.js#discoverCentralWorkItems` (~line 7470) parses both project signals from the plan content and sets `vars.target_projects` on the dispatch when the plan is cross-repo. The `plan-to-prd.md` playbook (P-3b8c40d9) injects a `{{target_projects}}` section that:

- Tells the agent to **omit the top-level `"project"` field** on the PRD JSON (or set it to `""`).
- Requires **every `missing_features` item to carry an explicit `project`** chosen from the target list.
- Defaults `branch_strategy` to `"parallel"` (a literal shared git history across separate repos is impossible — `shared-branch` is only correct as a naming convention when the plan author explicitly asked to keep matching branch names across repos for a single launch).
- Reminds the agent that `depends_on` across projects is **advisory** (see below) and cross-repo cycles are still cycles.

The plan-to-prd dispatch itself runs in the first listed project's read-only worktree so `vars.project_path` resolves and the playbook's per-project carve-outs behave; the dispatch never mutates that project.

### Authored PRD shape

```json
{
  "plan": "minions-opg-2026-06-10-3",
  "project": "",
  "branch_strategy": "parallel",
  "missing_features": [
    {
      "id": "P-aaa1",
      "name": "Engine plumbing in minions",
      "project": "minions",
      "depends_on": []
    },
    {
      "id": "P-bbb2",
      "name": "Compliance file in minions-opg",
      "project": "minions-opg",
      "depends_on": ["P-aaa1"]
    }
  ]
}
```

The top-level `project` is empty. Each item names its own repo. `depends_on` may freely cross project boundaries.

## Per-item project routing

`engine.js#materializePlansAsWorkItems` (~line 5151) groups PRD `missing_features` by `item.project` into a per-project map, then writes each group to `shared.projectWorkItemsPath(project)` — i.e. `<MINIONS_DIR>/projects/<name>/work-items.json`. The routing rules:

| `item.project` value | Where it lands |
| --- | --- |
| Names a configured project | That project's `projects/<name>/work-items.json` |
| Names an unknown project | Skipped; PRD item stamped with `_invalidProject: <error>` (see `formatUnknownProjectError`) |
| Unset, and central WIs disabled | Falls back to the plan-level `defaultProject` (or skipped if neither is resolvable) |
| Unset, and central WIs enabled | Lands in central `work-items.json` (legacy single-project plans) |

The materializer enforces a single per-project lock per write (`mutateWorkItems(wiPath, …)`), so per-project writes never contend. Cross-project re-opens (e.g. a `missing` PRD item whose previous WI lives in a sibling project) are queued as `deferredReopens` and executed outside the current project's lock (see `engine.js:5297` for the pattern).

**Reconciliation, PRD-removal sync, and `i.projects[]` rollup** all happen per-project under the same loop. Per-item state is owned by exactly one project's file at a time, so reading `projectWorkItemsPath(project)` always gives a consistent slice for that repo.

## Cycle detection (runs ONCE over the whole plan)

`detectDependencyCycles(plan.missing_features)` (`engine.js:4638`) runs once over the full PRD `missing_features` set **before** the per-project grouping loop, so a cyclic chain that crosses repos (e.g. `P-aaa1` in `minions` → `P-bbb2` in `minions-opg` → `P-aaa1`) is detected and every cyclic item is skipped at materialization time — neither project's `work-items.json` receives the cyclic items.

Caveat: `detectDependencyCycles` is a partial DFS — 2-cycles (`A → B → A`) are fully detected, but N-cycles ≥ 3 with distinct nodes only flag the back-edge target and recursing parent. Both legs of a 2-cycle and the canonical back-edge of a longer cycle reliably get skipped. Fixing the partial detector is tracked separately; cross-repo plans inherit the same behaviour as single-project plans.

## Cross-repo `depends_on` (advisory merge)

`item.depends_on` may freely reference a work-item id that lives in another project. The dispatcher in `engine.js#spawnAgent` (~line 2498) partitions resolved dep branches into two buckets via `dep.isCrossRepo`:

1. **Same-project deps** — fetched into the dispatcher's `rootDir` and merged into the worktree using the historical loop (with ancestor pruning, preflight conflict detection, ADO bearer-token retry, etc.). A merge failure here is a real failure (`FAILURE_CLASS.MERGE_CONFLICT` after preflight, retryable).

2. **Cross-repo deps** — **advisory only**. The engine:
   - Fetches the dep branch into the **dep's** project rootDir (the current project's origin doesn't carry the ref).
   - Transfer-fetches the tip into the current worktree under the namespaced ref `refs/cross-repo-deps/<branch>` via a filesystem-path remote.
   - Computes a best-effort tip SHA + changed-file list (vs the dep project's main).
   - Appends a `## Cross-repo dependencies` section to the prompt (`engine.js#buildCrossRepoDepsSection`, ~line 671) listing the repo, branch, short SHA, ref, and changed files.
   - **Never merges** the dep branch into the worktree (cross-repo merges would commingle unrelated repo content).
   - **Never escalates** on fetch failure — the dispatch proceeds with an advisory entry that records the attempt with `files: []`.
   - Cross-repo deps with no resolvable `projectRoot` (missing `localPath` on the dep project) are dropped with a warn and never reach the agent.

The agent's contract with cross-repo deps is read-only inspection (`git show refs/cross-repo-deps/<branch>`, `git log refs/cross-repo-deps/<branch> -- <files>`) followed by code changes in **its** repo that honor the contract surfaced in the dep branch. This matches the natural semantics of independent repos: there's no single git history to merge into.

## Shared-branch pre-creation (per project, idempotent)

When a cross-repo plan declares `branch_strategy: "shared-branch"` with an explicit `feature_branch`, the materializer's tail block in `engine.js#materializePlansAsWorkItems` (~line 5318) pre-creates that branch in **every project the plan touched** before the first dispatcher pass:

```text
for each project P in itemsByProject (excluding the `_central` bucket):
    root = path.resolve(P.localPath)
    main = shared.resolveMainBranch(root, P.mainBranch)
    if not git rev-parse --verify --quiet refs/heads/<branch>:
        git branch <branch> <main>
    git push -u origin <branch>      # no-op if origin already has the tip
```

The block is fully idempotent — `rev-parse --verify` guards the local `git branch` create, and `git push -u origin <branch>` is a no-op when origin already has the tip. Per-project failure isolation is explicit: a broken `localPath` or git config in one project logs a warn and continues to the next. The `_central` pseudo-project bucket (legacy central work-items.json) is skipped because it has no project root.

This is what makes the per-project dispatcher's later `git worktree add ... origin/<branch>` work — each project's origin already has the branch pointing at its own main, and parallel dispatches across repos can each commit + push to the same branch name in their own repo independently.

`shared-branch` is **only a naming convention** across cross-repo plans, not a literal shared git history. Two PRs ship — one per repo — and each repo's branch evolves independently. The dashboard groups them by `plan.feature_branch` for display.

## Per-project dispatch (read-side)

`engine.js#discoverFromWorkItems(config, project)` (~line 6671) reads exactly one project's `projectWorkItemsPath(project)` per call, routes pending items via `routing.md`, and returns `newWork[]` entries for the dispatcher. Cross-repo plans don't change this loop — the per-project file is the source of truth for what each project's dispatcher sees, and the engine iterates all configured projects per tick.

Module-level `_claimedAgents` in `engine/routing.js` persists across in-process calls but is cleared per tick (`routing.resetClaims()`), so two items in different projects routed to the same agent in the same tick contend correctly for the single agent slot.

## Per-project verify fan-out

`engine/lifecycle.js#checkPlanCompletion` (line 24, fan-out at lines 217–274) gates on two conditions:

1. Every PRD `missing_features` item has been materialized into a WI.
2. Every materialized WI is in a terminal state (`DONE_STATUSES` or `cancelled`).

When both gates pass, the function groups **active PRs** by project — reading each project's `pull-requests.json` via `shared.projectPrPath(p)`, joining to the plan's done items via `engine/pr-links.json` (`getPrLinks()`). The result is a `touchedProjects` array: every project that has at least one active PR linked to one of the plan's done items.

For each touched project the fan-out (lines 245–415):

- Looks up any existing verify WI keyed on `(sourcePlan, itemType:'verify', project)`. Legacy single-WI plans match against the primary project name when `existingVerify.project` is unset.
- Skips if the existing verify is active; re-opens if it's eligible (`isReopenableVerify`).
- Otherwise builds a per-project setup block:
  - **Shared-branch plans** reuse `feature_branch` directly — the verify agent checks out the branch in-place in the project's localPath.
  - **Parallel-branch plans** merge each linked PR branch into a per-project worktree at `<localPath>/../worktrees/verify-<projName>-<planSlug>`.
- Validates every branch ref through `shared.validateGitRef` before splicing into the bash setup commands (P-f3-verify-prompt — defense in depth against shell injection from a crafted `feature_branch` or PR `branch` field).
- Writes a new verify WI into the touched project's `projectWorkItemsPath(p)` with `itemType: 'verify'`, `type: 'verify'`, `priority: 'high'`, `status: 'pending'`, and `project: <projName>`. The title is project-prefixed (`Verify plan (minions-opg): …`) when `touchedProjects.length > 1` to disambiguate the dashboard listing.

**Fallback:** when no PRs were ever linked (e.g. items completed without PR records, or legacy state), the fan-out collapses to a single verify WI on the **primary project** (the project with the most completed items) so the plan still gets verified.

The verify agents then run in parallel — one per touched project, each in its own worktree, each running its own build + test cycle — and each writes a per-project verify guide.

## Dashboard surfaces

Plan card and detail views surface the cross-repo shape:

- **`i.projects[]` per PRD item + plan `_projects` rollup** (P-e8d49105, `engine/queries.js`) — populated when at least one PRD item carries a `project` field. Plan card uses `_projects.length >= 2` as the gate for cross-repo UI variations.
- **Per-project status rollup pills** (P-66b1faec, `dashboard.js#handlePlansList`) — when `Object.keys(_perProjectProgress).length >= 2`, the plan card meta line renders one pill per project with a per-project completion count (e.g. `minions 3/4 · minions-opg 1/2`). Single-project plans render the legacy aggregate progress bar unchanged.
- **Per-project verify badges in plan detail** (P-d7e592b1, `dashboard/js/render-plans.js`) — when `verifyWis.length >= 2`, the detail view renders one labelled verify badge per project; single-project plans render the legacy single badge unchanged.

## Back-compat contract

The single-project path is the unchanged default. Specifically:

| Surface | Single-project plan | Cross-repo plan |
| --- | --- | --- |
| Plan markdown header | `**Project:** <name>` | `**Projects:** a, b` + `<!-- minions:targetProjects=a,b -->` |
| `extractPlanDeclaredProject` | Returns `<name>` | Returns `''` |
| `extractPlanTargetProjects` | Returns `[]` | Returns `[a, b]` (≥ 2 entries) |
| PRD `project` field | `<name>` | `""` (or omitted) |
| PRD `missing_features[i].project` | Usually unset | **Always set**, must be in target list |
| `materializePlansAsWorkItems` grouping | One project (or central) | One project bucket per distinct `item.project` |
| Shared-branch pre-create | Only the plan's project | Every touched project (per-project, idempotent) |
| `checkPlanCompletion` verify | One verify WI on primary project | One verify WI per touched project |
| Dashboard plan card | Aggregate progress bar | Per-project rollup pills (when `_projects.length >= 2`) |
| Dashboard plan detail verify | Single badge | One labelled badge per project (when `verifyWis.length >= 2`) |

In every row, the cross-repo path is gated on the cross-repo signal (`target_projects` parsed from plan markdown, or per-item `project` field populated). If the signal is absent, the surface behaves exactly as it did before `PL-cross-repo-coordination`.

## Failure modes

| Signal | Outcome |
| --- | --- |
| PRD item `project` names an unknown project | Item skipped at materialization; PRD item stamped with `_invalidProject: <error>` via `mutatePrdLocked`. Surfaces in dashboard PRD view; fixable by editing the PRD or registering the project. |
| Cross-repo `depends_on` references unknown project | Dep dropped with a warn (`Skipping cross-repo dep <branch> — dep project "<name>" has no resolvable rootDir`); dispatch proceeds. |
| Cross-repo dep branch fetch fails | Advisory entry emitted with `files: []`; dispatch proceeds, agent sees a prompt section noting the attempt. No retry, no escalation. |
| Cross-repo cycle in `depends_on` | Detected at materialization; every cyclic item skipped, no WIs written. (2-cycles fully detected; N≥3-cycles partially detected — back-edge target + recursing parent.) |
| Shared-branch pre-create fails in one project | Logged at `warn`, per-project loop continues to the next project. The dispatcher will fail later when it can't `git worktree add ... origin/<branch>` in that project; the inbox alert from that failure points back to the missing branch. |
| Verify completion finds no active PRs linked | Falls back to a single verify WI on the primary project (the project with the most completed items). |

## Worked example

Plan markdown `plans/cross-repo-launch.md`:

```markdown
# Cross-repo launch coordination

**Projects:** minions, minions-opg
<!-- minions:targetProjects=minions,minions-opg -->

## Context

Add a new compliance check + matching engine plumbing.
```

Operator approves the plan. Plan-to-prd dispatches against the `minions` project (read-only worktree, first in the target list). The plan-to-prd agent writes PRD `prd/cross-repo-launch.json`:

```json
{
  "plan": "cross-repo-launch",
  "project": "",
  "branch_strategy": "parallel",
  "missing_features": [
    {
      "id": "P-001",
      "name": "Add compliance-check helper in engine",
      "project": "minions",
      "depends_on": []
    },
    {
      "id": "P-002",
      "name": "Wire compliance helper into opg dashboard",
      "project": "minions-opg",
      "depends_on": ["P-001"]
    }
  ]
}
```

Engine ticks once:

1. `materializePlansAsWorkItems` groups by project: `{ minions: [P-001], minions-opg: [P-002] }`. Cycle scan runs once over both items, finds nothing. Writes `projects/minions/work-items.json` with P-001 and `projects/minions-opg/work-items.json` with P-002. Branch-strategy is `parallel`, so no shared-branch pre-create runs.
2. `discoverFromWorkItems(config, minions)` picks up P-001, routes it via `routing.md`, dispatches `implement` agent. Worktree created on `work/P-001` off `minions`' main.
3. `discoverFromWorkItems(config, minions-opg)` picks up P-002. Its `depends_on: ["P-001"]` resolves to a same-project dep? **No** — P-001 lives in the `minions` project, so it's a **cross-repo dep**. The dispatcher fetches P-001's branch into the `minions` rootDir, transfer-fetches it into the `minions-opg` worktree under `refs/cross-repo-deps/work/P-001`, computes a short SHA + changed-file list, and appends a `## Cross-repo dependencies` section to the prompt. The agent inspects via `git show` / `git log` and ships matching code in `minions-opg`.
4. Both items complete with PRs. `engine/pr-links.json` links each PR to its WI.
5. Next tick `lifecycle.checkPlanCompletion` runs. Gates pass (every item materialized + done). Groups active PRs by project: `{ minions: [PR for P-001], minions-opg: [PR for P-002] }`. Creates two verify WIs:
   - `(minions)` verify WI in `projects/minions/work-items.json` with `project: 'minions'`.
   - `(minions-opg)` verify WI in `projects/minions-opg/work-items.json` with `project: 'minions-opg'`.
6. Both verify agents dispatch in parallel. Each builds + tests its own repo. Each writes `prd/guides/verify-cross-repo-launch.md` in its own repo's worktree.
7. Dashboard plan card shows `minions 2/2 · minions-opg 2/2` rollup pills and two labelled verify badges. Operator archives the plan via `POST /api/plans/archive` when both verifies complete.

Two PRs ship — one per repo — coordinated by a single plan that lived in the central plans tree.

## Code-site index

When you need to change the cross-repo path, the load-bearing seams are:

| Concern | File:line | Notes |
| --- | --- | --- |
| Parse plan markdown projects | `engine/shared.js:3690` (`extractPlanDeclaredProject`), `engine/shared.js:3713` (`extractPlanTargetProjects`) | Both scan first 80 lines; HTML comment marker preferred over `**Projects:**` fallback. |
| `POST /api/plans/create` array normalization | `dashboard.js` (P-2e9b54d1) | Dedup + per-name validation via `shared.findProjectByName`; 0–1 → singular header, 2+ → plural header + marker. |
| Plan-to-prd dispatch wiring | `engine.js:7470+` (`discoverCentralWorkItems`) | Sets `vars.target_projects` when plan is cross-repo; picks first listed project for the read-only worktree. |
| Plan-to-prd playbook contract | `playbooks/plan-to-prd.md` (P-3b8c40d9) | Tells the agent to omit top-level `project`, set per-item `project`, default to `parallel`. |
| Per-project work-item grouping | `engine.js:5151–5181` (`materializePlansAsWorkItems`) | `itemsByProject` map; unknown-project items stamped `_invalidProject`. |
| Cross-repo cycle detection | `engine.js:5183–5192` | One global scan over `plan.missing_features` BEFORE per-project loop. |
| Shared-branch pre-create per project | `engine.js:5318–5356` | Idempotent `rev-parse` → `branch` → `push -u`; per-project failure isolation. |
| Cross-repo dep partitioning + advisory fetch | `engine.js:2495–2682` (`spawnAgent` dep loop) | `isCrossRepo` split; transfer-fetch via filesystem-path remote into `refs/cross-repo-deps/<branch>`. |
| Cross-repo dep prompt section | `engine.js:662–710` (`buildCrossRepoDepsSection`) | Renders one section per dep with repo, branch, SHA, files, local ref. |
| Per-project verify fan-out | `engine/lifecycle.js:217–274` (`checkPlanCompletion`) | Group active PRs by project via `engine/pr-links.json`; one verify WI per touched project. |
| Verify WI creation | `engine/lifecycle.js:387–411` | `itemType:'verify'`, `project: projName`, project-prefixed title when `touchedProjects.length > 1`. |
| Per-project rollup pills | `dashboard.js#handlePlansList` (P-66b1faec) | Gate `_perProjectProgress.length >= 2`. |
| Per-project verify badges | `dashboard/js/render-plans.js` (P-d7e592b1) | Gate `verifyWis.length >= 2`. |
| `i.projects[]` + plan `_projects` rollup | `engine/queries.js` (P-e8d49105) | Populated when at least one PRD item has a `project` field. |

## Executable spec

`test/integration/cross-repo-plan-e2e.test.js` (P-8b5d3e07) drives the four load-bearing seams against the real engine code in an isolated `MINIONS_TEST_DIR` (no live state touched, no agents spawned):

1. PRD with per-item `project` fans into per-project `work-items.json` files (alpha vs beta).
2. `discoverFromWorkItems(config, project)` returns only the queried project's items.
3. `checkPlanCompletion` creates one verify WI per touched project when each project has at least one active PR linked to a done WI.
4. A 3-feature cyclic chain that crosses project boundaries (alpha → beta → alpha) is detected and none of the cyclic items are written.

Run via `node test/integration/cross-repo-plan-e2e.test.js` (or as part of `npm run test:integration`).

## Pointers

- Authoring entry points: [`playbooks/plan.md`](../playbooks/plan.md) (P-c1f87a92), [`playbooks/plan-to-prd.md`](../playbooks/plan-to-prd.md) (P-3b8c40d9).
- Single-project plan flow (the default): [`CLAUDE.md` → Plan → PRD → Work Items → Verify](../CLAUDE.md#plan--prd--work-items--verify).
- Worktree behaviour for parallel-branch verify worktrees: [`docs/worktree-lifecycle.md`](worktree-lifecycle.md).
- Live-checkout opt-out (rare; opt-in per project, not per plan): [`docs/live-checkout-mode.md`](live-checkout-mode.md).
