I've written before about my feature-workflow plugin — capture a feature, plan it, get it reviewed, ship it, mostly one repo at a time. The repos in a couple of my orgs change together, though, and coordinating a change across them in Claude Code kept hitting the same wall.

Claude Code's permissions are scoped to the directory I launch it in. Everything in that directory's subtree is in scope — files edit without a prompt, the allowlist applies, the CLAUDE.md loads. A repo nested inside it inherits all of that. A repo sitting next to it on disk does not. So with two sibling repos — a producer and the service that consumes it — working in one and reaching into the other made Claude push back on every cross-repo edit.

That got worse when the change had to land in both in a set order. Producer first, then consumer: from two separate sessions that meant finishing one repo, switching over, and doing the other. Subagents and worktrees already keep my parallel work from colliding, so the problem was never the number of terminals — it was that a single session couldn't reach across the repo boundary without friction.

feature-workflow is at 9.13.0 now and gets around this by making the related repos children of one parent. A workspace is a thin git repo with the member repos cloned inside it, so launching Claude at the workspace root puts every member in scope at once. From that one place I can push a workshop and update the platform it depends on without a handoff between sessions.

Nested clones and the allowlist

The workspace is a real, tiny git repo. The members are cloned into it as ordinary, gitignored clones — not submodules, not subtrees.

~/my-workspace/
├── .feature-workspace.yml   ← manifest (topology)
├── .claude/settings.json    ← the allowlist
├── scripts/clone-members.sh ← bootstrap from manifest
├── docs/features/           ← cross-repo epics
├── engine/                  ← member: gitignored clone
└── app/                     ← member

The members are gitignored, and that line is doing more than keeping git status quiet. In git there's no parent that owns the others — the workspace and every member are separate repositories with their own histories, peers as far as git is concerned, since git has no notion of one repo containing another. The hierarchy exists only on the local filesystem: the members nest inside the workspace on disk, and that on-disk tree is what Claude Code scopes permissions to. So the workspace is the structural parent locally and only the logical parent in git, through the manifest. Gitignoring the members is what keeps the two views from colliding — without it the workspace repo would try to track their files or embed one as a gitlink. Each one stays fully independent, with its own remote, branches, and CI.

Nesting them under the launch directory is what puts them in scope. Claude Code can reach outside the working directory through its additionalDirectories setting, but that grants file access only — it doesn't load the directory's CLAUDE.md, skills, or settings, so a member added that way would be editable while losing its own instructions and tooling. Cloning the member into the working tree gets both: edits without a prompt, and the member's CLAUDE.md loaded when I work in it.

The other half is two lines in the workspace .claude/settings.json:

{
  "permissions": {
    "allow": ["Bash(git -C *)", "Bash(gh -R *)"]
  }
}

git -C engine commit ... runs against a member without cd-ing into it, and gh -R myorg/app pr create ... does the same for GitHub. The allowlist is scoped to the directory I launched in, so allowing both here covers every member at once — I commit in one and open a PR in another from the workspace root, no prompt per command.

The manifest

A workspace is identified by a .feature-workspace.yml file, not by its name. It's a graph — nodes are repos, edges are the contracts between them.

org: myorg
members:
  - { dir: engine, repo: myorg/engine }
  - { dir: app,    repo: myorg/app }
contracts:
  - id: engine:engine-api
    owner: engine
    consumers: [app]
    kind: http
deploy:
  - { group: engine-stack, dir: engine }
  - { group: app-stack,    dir: app }

One file drives four things: cloning the members, warning when I edit a producer, ordering the children of a cross-repo epic, and ordering deploys.

Three scopes

Work lands in one of three places depending on how many repos it touches.

Scope Doc lives in Runs as
One member that member's docs/features/ a normal feature
Workspace only the workspace's docs/features/ a normal feature
Two or more members the workspace's docs/features/ an epic, one child per member

A single-repo feature is cd engine && /feature-capture, unchanged from the normal flow. The middle row is the one that fixed where my ideas land: a platform idea that surfaces while I'm working in a consumer repo used to get written down inside that consumer, where it didn't belong. Now it lives in the workspace.

Feature IDs are repo-namespaced only when they cross repos — app:adopt-auth — and bare inside a single repo.

Cross-repo epics

A cross-repo feature is a normal epic whose children use namespaced repo:id references. Prerequisites across repos use the same form in dependsOn:, so producer-first ordering comes out of the topological sort that already orders children inside a single repo.

The dispatcher scans the workspace and every member, keys everything by repo:id, and feeds that into the same process a single repo uses. There's almost no new code in the cross-repo path. Running /feature-autopilot on the epic changes into each child's member directory and runs there, so the child's branch, PR, and worktree all target that member's own remote — and I can let it work several members while I'm doing something else. It's still review-gated, with plan and implementation review on each child before it lands, not a lights-out pipeline.

Coordinating a breaking change

A contract in the manifest makes a breaking change visible while I'm making it. Editing a file in a member that owns a contract surfaces a one-time warning:

⚠️ Editing `engine`, a producer of cross-repo contract(s)
[engine:engine-api → consumers: app]. Coordinate this
as a cross-repo epic and roll out producer-first with an
expand/contract migration.

It's deduped per member, so it fires once rather than on every edit. The rollout is the expand/contract sequence: the producer adds the new shape, the consumers migrate to it, then the producer removes the old shape. The epic's dependency edges encode that order.

/feature-deploy honors the same order at release time. It walks the manifest's deploy: groups top to bottom, which is written producer-first, preflights each member for a clean tree on its release branch, and gates each group before the next. It doesn't own a deploy command, doesn't roll back across groups, and doesn't reorder. Manifest order is the law.

The ordering matters when a consumer reads infrastructure the producer publishes. In one workspace the consumer's deploy preflight greps every parameter-store path (SSM) out of its CDK and aborts with a MISSING: list if a producer hasn't published yet — so a producer that ships new infra has to deploy first or the consumer's deploy refuses to start.

Nine repos, one platform

The first workspace I ran this on has nine members: one platform repo (infrastructure, an API, a content registry) consumed by a marketplace app, an admin service, and six content repos. The platform owns three contracts — an HTTP API, a registry schema every content repo reads, and an entitlement-ledger schema. It's shipped more than 100 features across a dozen epics, with member features promoted through each repo's own dev branch.

The registry-schema change was the one that needed the coordination. Every content repo consumes that schema and there was no async migration path, so all the consumers had to move at once, gated by the platform's contract test before any of them could land.

Four repos, three producers

The second workspace points the other way: one host repo that's the plumbing, plus three domain packages that each publish a versioned wheel the host consumes. The contracts are the wheels — a shared protocol pinned at a version, enforced by a contract test that asserts each package exports the 33 names the host expects. It's a tightly intertwined setup — the host depends on all three packages at once. The packages are built to one template on purpose, so the host can treat them interchangeably, and the contract test enforces it.

The change that exercised it brought two newer packages up to v1 of that protocol so the host could serve all three the same way. It ran as a six-child epic, producer-first: bring the first package's API to the spec, release its wheel at v1.1.0, adopt it in the host, then the same three steps for the second package. All six landed together — the first package as the pilot, the second a close port once the path was proven.

Nine-repo Four-repo
Shape one producer, many consumers many producers, one consumer
Contract kind HTTP + schema versioned wheels
Deploy order platform, then consumers wheels, then host

Same manifest format, same skills, same three-scope rule on both.

Setting one up

Scaffolding a workspace is one command, which writes the manifest, the gitignore, the allowlist, a CLAUDE.md, and the clone script:

/feature-init --workspace --org myorg \
  --member engine=myorg/engine \
  --member app=myorg/app

./scripts/clone-members.sh   # idempotent, reads the manifest

After that I launch Claude at the workspace root. A single-member feature is cd engine && /feature-capture; a cross-repo one is captured at the root as an epic and dispatched with /feature-autopilot.

Source: the workspace support is in the feature-workflow plugin at github.com/schuettc/claude-code-plugins.