← Back to Blog
For: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

Monorepo vs Polyrepo for AI Agents: The Proof Perimeter

Your agent can already read every repo you own. What a polyrepo cannot give it is every consumer's checks, run against its change before merge.

#coding-agents#monorepo#claude-code#context-engineering#ci-cd#developer-tools

Ahmed El-Refee, the CTO of Fortius Technology, ran Claude Code against an API, an admin frontend and a database project, each in its own repository. His word for the result is "a disaster": "three disconnected Claude sessions, each blind to the others," with himself as "the human integration layer - copying API contracts from the backend session into the frontend session." He merged the three into one repository, and in his account, cycle time on full-stack features fell "from a day to a couple of hours." Edvaldo Gjonikaj at TextYess tells the same story with four repositories: "the agent was working with half a map."

Both teams credit the idea Nx put on the event page for its 2026 AI Monorepos conference: "When it comes to AI we know context is king." I think that credits the part of the win you no longer need a monorepo for. The part you do need is the one that stops a renamed field from shipping as a $0.00 order total, which I show below with runnable code: every consumer's tests running on the change before it merges. If you are weighing a monorepo and a polyrepo for AI coding agents such as Claude Code, Codex or Cursor, ask what the agent can prove before merge.

Monorepo vs polyrepo for AI coding agents: proof beats context

A monorepo helps a coding agent because it lets the agent prove a cross-project change against every consumer in one commit and one continuous integration (CI) run, before merge. Repository topology has little to do with what the agent sees. Reading across repositories is now cheap to buy without moving any code. Proving a change across them is not. That is the asset, and it is also the thing most monorepos turn off when they scope CI to the package that was touched.

I call the boundary of that proof the Proof Perimeter: the set of affected projects whose CI-gated checks run against the changed source of their dependencies before the change merges. "Proof" means checks that actually ran, so a project inside the perimeter is exactly as proven as its tests are good.

Local runs do not count. An agent can run consumer tests on its own machine, say from a stop hook, but the merge does not wait for them. A monorepo makes a wide perimeter possible without any cross-repository machinery. The common path-scoped CI setup switches it off again. A polyrepo needs extra machinery to get one before merge at all: Zuul's cross-repo Depends-On: gating, Gerrit topic submission, or a fan-out of downstream pipelines. Even then, the change lands as several merges. The affected projects left outside the perimeter are its perimeter gap, and its size is the number this article ends up asking you to track on every PR.

None of this is new at Google. Potvin and Levenberg described Google's repository as "a common source of truth for tens of thousands of developers", and Hyrum Wright's chapter on large-scale changes in Software Engineering at Google ties atomic changes to repository topology. What agents change is the price of each part of that argument. Seeing other projects' code is now cheap in any layout, and --add-dir gets most of the way there. What still needs one commit graph is the rest: consumers that build against the changed source, and one run that checks them together.

Google could test every affected target because a central team owned the build system. Most teams that adopt coding agents run path-filtered CI instead, and in that setup what should run and what does run drift apart. An affected command such as nx affected removes the drift by construction. A path-filtered workflow has nothing that compares its triggers with the dependency graph, and the perimeter gap is that comparison.

Reading and proving are bought very differently.

d2
grid-rows: 1
grid-gap: 40
read: "Read across projects" {
  label.near: top-center
  grid-columns: 1
  grid-gap: 14
  style: {fill: "#EAF7EC"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  r1: "--add-dir / meta-repo" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  r2: "MCP code search" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  r3: "Works in any topology" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
}
prove: "Prove across projects" {
  label.near: top-center
  grid-columns: 1
  grid-gap: 14
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  p1: "One commit, all consumers" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  p2: "One CI run before merge" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  p3: "One commit graph, or built gating" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}

Everything in the left column is a tooling purchase: one flag or one server. What sits in the right column is a property of where the code lives. Across separate repositories you can only approximate it with CI orchestration you build and maintain yourself, and the merge is still not atomic.

Conventional advice says to give the agent more of the system to read. Below, the evidence says more reading is a cost the agent pays, often for nothing. What the agent cannot do without the monorepo is run the consumers' tests.

What breaks when coding agents work across multiple repositories

Look closely at what El-Refee was relaying between sessions. It was contracts: an API shape the backend session had just changed, pasted into a frontend session that had no way to know it changed. TextYess describes the same job as manually relaying "what the worker contract looked like."

Each session can read and test only its own repository, so the human is the only part of the system that sees a change cross a boundary.

d2
direction: up
classes: {
  repo: {
    label.near: top-center
    grid-columns: 1
    grid-gap: 16
    style: {fill: "#E8F6F3"; stroke: "#5FA898"; font-color: "#2C2C2A"}
  }
  agent: {
    style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
  }
  ci: {
    style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
  }
}
human: "Engineer\ncopies contracts\nby hand" {
  style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"; bold: true}
}
api: "api repo" {
  class: repo
  s: "Session 1\nchanges OrderDTO" {class: agent}
  t: "CI: api tests only" {class: ci}
}
web: "frontend repo" {
  class: repo
  s: "Session 2\nexpects old OrderDTO" {class: agent}
  t: "CI: frontend tests only" {class: ci}
}
db: "database repo" {
  class: repo
  s: "Session 3\nmigration alone" {class: agent}
  t: "CI: migration checks only" {class: ci}
}
human <-> api: "1. read the new contract"
human -> web: "2. paste it here"
human -> db: "3. and here"

Focus on the red cells. Even if the engineer relays every contract perfectly, no single run ever executes the frontend tests against the backend change.

What both teams report is a read-and-write win. After the merge, one session could edit both sides of the contract in one working tree. El-Refee describes the agent reading "the SQL migration... then the TypeScript interface on the frontend... all in one context." TextYess goes further: "The monorepo migration was the headline. The CLAUDE.md tree was the unlock." I accept both reports. But that read-and-write win is the part you can now buy without merging, with --add-dir and a shared instruction file. What merging gave them that no flag gives, whether or not their CI uses it yet, is the ability to run every consumer's checks on that edit before it merges.

Does a monorepo give Claude Code better context? Mostly no

Start with what the agent vendors say about their own tools. Anthropic's guide to running Claude Code in a monorepo warns that "as the codebase grows, the defaults tuned for smaller projects can fill the context window with instructions and file reads unrelated to the task, costing tokens and degrading Claude's performance." Almost every fix it lists is a way to make the agent load less: claudeMdExcludes, Read deny rules, worktree.sparsePaths, and starting the session in a package directory instead of the root. The guide keeps the root as the starting point only for tasks that "span multiple packages or subsystems".

Independent research points the same way.

Gloaguen et al. at ETH Zurich evaluated repository-level context files and found they "do not generally improve task success rates, while increasing inference cost by over 20% on average." Repository overviews, the thing a monorepo's root AGENTS.md usually is, were "not helpful." Lulla et al. found AGENTS.md cut median runtime by 28.64% and output tokens by 16.58%, with "comparable task completion." That is an efficiency win. Neither study shows context making the agent more correct. Chroma's Context Rot study tested 18 models and found performance "consistently degrades with increasing input length." Text that is related but wrong hurt more than unrelated text. Chroma tested text, not code, so the next step is my inference: a large monorepo is full of related-but-wrong code, such as the v1 and v2 client, the deprecated package that still compiles, and the three copies of the retry helper. Airbnb's test-migration team said "the main success driver we saw was choosing the right related files." Cursor reports that adding semantic search to grep improved code retention by 2.6% on codebases of 1,000 or more files, by its own benchmark. Both results are about how files get selected.

Reading across repositories is also no longer hard, which is the second half of the argument. Claude Code's --add-dir gives a session sibling checkouts. Meta-repos, where one parent folder clones the others, do the same at the filesystem level. Sourcegraph's CodeScaleBench, a vendor benchmark still in progress, ran Claude Code with Haiku 4.5 on cross-repository tasks. Adding Sourcegraph's MCP server raised retrieval precision at 5 from 0.007 to 0.471, and recall at 5 from 0.002 to 0.149, without any repository moving. In that same benchmark, the baseline agent hit its two-hour timeout navigating the Kubernetes monorepo, so size alone was no advantage. I found no study that compares task outcomes for the same agent in a polyrepo with --add-dir against a monorepo. The read-side claim in this article rests on retrieval data and on the vendors' own guidance, not on an outcome benchmark.

Topology matters less than it looks. An agent never sees the repository. It sees what its search returns, and both layouts feed the same small window.

d2
grid-rows: 1
grid-gap: 40
mono: "Monorepo" {
  label.near: top-center
  style: {fill: "#F4F6F7"; stroke: "#95A5A6"; font-color: "#2C2C2A"}
  grid-columns: 1
  grid-gap: 18
  tree: "One tree, 4 projects\n(reachable: everything)" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  search: "grep / index / LSP" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  window: "Context window\n(loaded: what search returns)" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
}
poly: "Polyrepo + --add-dir or MCP" {
  label.near: top-center
  style: {fill: "#F4F6F7"; stroke: "#95A5A6"; font-color: "#2C2C2A"}
  grid-columns: 1
  grid-gap: 18
  tree: "4 checkouts\n(reachable: everything)" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  search: "grep / index / MCP search" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  window: "Context window\n(loaded: what search returns)" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
}

Reachable and loaded are different quantities. A monorepo raises the first, but accuracy depends on the second, and good retrieval can give a polyrepo the same loaded set. Heavier navigation tooling does not shrink the loaded set on its own either: below a certain scale, semantic navigation is a net cost on most repositories, as I argued in Claude Code's code intelligence usually makes it dumber. I covered how to keep what gets loaded small in Claude Code on brownfield codebases and the mechanics of attention budgets in context engineering for production agents, so I will not repeat it here.

One honest caveat applies on the read side: cross-repository tooling is clumsier than a single tree. In Claude Code, a CLAUDE.md in an --add-dir directory loads only when CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 is set, and an AGENTS.md there never loads. That friction is real. It is also a configuration problem, and configuration can be fixed.

What a polyrepo cannot buy: verification in one commit

Verification is the part nobody can configure away. Wright's chapter says it directly: "an LSC is dictated by your repository topology: if your organization uses a collection of distributed or federated repositories, making atomic changes across them might not even be technically possible." An LSC (large-scale change) is the kind of edit agents are increasingly asked to make: rename a field in a shared type, bump a library, migrate an API, and fix every caller.

Even the most ambitious polyrepo tooling concedes this. Nx's Polygraph builds a "synthetic monorepo" across separate repositories for agents. Victor Savkin's post on the Nx blog says "each write must still land independently, so the overall change is not atomic," and calls the result "eventually consistent." Unless you add cross-repository gating yourself, that means each repository's CI checks its own slice, and the combination is first tested after at least one part has already merged.

Merge data shows why this matters. Ehsani et al. studied 33,000 agent-authored pull requests (PRs) from five agents on GitHub, and the PRs that were not merged "touch more files, and often do not pass the project's CI/CD pipeline validation." CI is where agent mistakes get caught, which is the reason to care which checks run. Cross-boundary work is also still hard for agents. BeyondSWE averages four beyond-single-repo settings, including cross-repository issue resolution and dependency-driven migration, and reports a best average score of 56.65 (the Codex harness with GPT-5.4 at xhigh reasoning, under a search-aware prompt).

Take one change under the most common CI setup, where each job watches only its own package. An agent edits contracts, which affects four projects. Only the contracts checks run. api, web and worker all depend on the same file and get nothing.

d2
direction: down
change: "Agent edits\npackages/contracts/src/order.ts" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"; bold: true}
}
perimeter: "Proof Perimeter" {
  label.near: top-center
  style: {fill: "#EAF7EC"; stroke: "#3F9E52"; stroke-width: 3; font-color: "#2C2C2A"}
  contracts: "contracts\ntests run before merge" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
outside: "Outside: affected but unchecked" {
  label.near: top-center
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; stroke-dash: 4; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 24
  api: "api\nfirst checked after merge" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  web: "web\nfirst checked after merge" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  worker: "worker\nfirst checked after merge" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
change -> perimeter
perimeter -> outside: "same change, checks never run"

This change is proven for exactly the green box. For the red ones it is a guess, however much of their code the agent read. Reading web tells the agent what web probably expects. Running web's tests tells it what web actually does. Only the second is proof, and only a single commit graph gives it to you by default.

A contract break that compiles and a consumer test catches

Here is the kind of break the perimeter exists for. The agent changes the wire format in contracts to send integer cents, and renames the field to match. Nothing fails to compile, because web reads the payload as a dictionary and falls back to a default. With a typed DTO the rename would fail web's type check, but under path-scoped CI that check does not run either. The dictionary case is the one where reading also tends to miss it.

python
"""break_demo.py - a contract change that compiles everywhere and still breaks web."""# packages/contracts: the wire format every service shares.def to_wire_v1(order: dict) -> dict:    return {"id": order["id"], "total": order["cents"] / 100}# The agent's change: send integer cents, and rename the field to match.def to_wire_v2(order: dict) -> dict:    return {"id": order["id"], "total_cents": order["cents"]}# apps/web: renders the order summary. It never imports a type, it reads a dict.def order_label(payload: dict) -> str:    return f"Order {payload['id']}: ${payload.get('total', 0):.2f}"# apps/web's existing test, unchanged by the agent.def check_order_label(to_wire) -> bool:    return order_label(to_wire({"id": 7, "cents": 1250})) == "Order 7: $12.50"if __name__ == "__main__":    for name, fn in [("v1 contract", to_wire_v1), ("v2 contract", to_wire_v2)]:        label = order_label(fn({"id": 7, "cents": 1250}))        print(f"{name}: web renders {label!r}, web test passes: {check_order_label(fn)}")

Running python break_demo.py prints:

text
v1 contract: web renders 'Order 7: $12.50', web test passes: Truev2 contract: web renders 'Order 7: $0.00', web test passes: False

An agent reading web might spot the .get('total', 0). Dictionary keys are not symbols, though, so find-references and rename see nothing, and a text search for total returns every unrelated total in the codebase. web's test catches the break because it builds its payload with the real contracts serializer. A consumer test that mocks the payload would pass here, so the perimeter is only as wide as the tests that actually import the dependency. And even the good test catches nothing unless it runs on this change before merge. Under path-scoped CI it does not, and the order page shows $0.00 in production.

What a green run does not prove: deploy skew and stale bases

A green run proves the consumers agree at one revision, and that is all it proves. Services still deploy separately. Old web bundles stay cached in browsers and queued worker messages keep the old schema, so for some hours old and new versions run side by side, and no check at merge time covered that window. A contract change across deployable services still needs an expand-then-contract migration, in a monorepo as much as anywhere else. The perimeter can show that the end state is consistent. It says nothing about the path to it.

A green run on the PR branch also goes stale. If another agent merges a change to contracts first, your PR's checks ran against a base that no longer exists. The perimeter holds at merge time only if CI runs again on the real merge result. A merge queue does that. GitHub's queue groups each PR "with the latest version of the base_branch" and the PRs ahead of it. The checks must then pass on that combination. In GitHub Actions the workflow must also trigger on the merge_group event, or the required check never reports. Merge queues are available for public repositories owned by an organization and for private repositories on GitHub Enterprise Cloud. Without one, the branch protection setting that requires branches to be up to date before merging gives the same property, one PR at a time. With many agents opening PRs against shared packages, I treat one of the two as part of the perimeter.

mermaid
sequenceDiagram
  participant A as Agent A's PR
  participant B as Agent B's PR
  participant M as main
  participant Q as Merge queue
  A->>M: checks pass against main at X
  B->>M: merges a contracts change, main is now Y
  A->>Q: enters the queue
  Q->>Q: builds A on top of Y and re-runs checks
  Q-->>A: web test fails, A does not merge

Without the queue, agent A's PR would merge on a green run that tested a main which no longer exists.

How path-scoped CI shrinks the Proof Perimeter in a monorepo

Monorepos usually lose this advantage in their CI configuration. Many monorepo CI setups start path-scoped, including this blog's: each project's job triggers when files under that project change. Path scoping keeps CI fast and keeps an unrelated team's build out of your PR. It also means a change to a shared package runs only the shared package's tests. Your perimeter shrinks to exactly what a polyrepo would have given you, while the team keeps paying monorepo costs without the monorepo benefit.

A short script measures the shrinkage. Its inputs are a workspace (each package's root and its workspace-linked dependencies), a set of CI jobs (path filters plus the package each job checks), and a list of changed files. From those it computes the affected set by walking reverse dependencies, works out which jobs fire, and prints the Proof Perimeter and the perimeter gap: the affected projects minus the ones whose checks ran. Root files such as the lockfile belong to every package, so the script treats a change to one as affecting all of them. Its glob rules follow dorny/paths-filter, where * stops at / and ** crosses it. It uses only the standard library and runs on Python 3.10 or newer.

python
"""proof_perimeter.py - which consumers of a change will CI actually check before merge?Stdlib only. Python 3.10+.Path filters follow dorny/paths-filter with its default predicate-quantifier 'some':a file fires a filter if it matches ANY entry, and an entry '!x' matches everyfile that is NOT under x. '*' stops at '/', '**' crosses it."""import reimport sys# package -> (source root, packages it depends on via workspace links)WORKSPACE = {    "contracts": ("packages/contracts", []),    "api":       ("services/api",       ["contracts"]),    "worker":    ("services/worker",    ["contracts"]),    "web":       ("apps/web",           ["contracts", "ui"]),    "ui":        ("packages/ui",        []),}# Root files that belong to every package. Yours will differ: add shared config# here only if every consumer's filter also lists it.GLOBAL = ["package-lock.json"]# CI job -> (path filters that trigger it, package whose checks it runs).# Path-scoped: each job watches only its own package.CI_JOBS = {    "contracts-test": (["packages/contracts/**"], "contracts"),    "api-test":       (["services/api/**"],       "api"),    "worker-test":    (["services/worker/**"],    "worker"),    "web-test":       (["apps/web/**"],           "web"),    "ui-test":        (["packages/ui/**"],        "ui"),}def glob(pattern: str, path: str) -> bool:    rx = re.escape(pattern).replace(r"\*\*/", "(?:.*/)?")    rx = rx.replace(r"\*\*", ".*").replace(r"\*", "[^/]*").replace(r"\?", "[^/]")    return re.fullmatch(rx, path) is not Nonedef affected(changed: list[str], ws: dict) -> set[str]:    """Changed packages plus every package that depends on them, transitively."""    if any(glob(g, p) for g in GLOBAL for p in changed):        return set(ws)    hit = {pkg for pkg, (root, _) in ws.items()           for p in changed if p.startswith(root + "/")}    grew = True    while grew:        grew = False        for pkg, (_, deps) in ws.items():            if pkg not in hit and hit.intersection(deps):                hit.add(pkg)                grew = True    return hitdef fires(path: str, filters: list[str]) -> bool:    return any(not glob(f[1:], path) if f.startswith("!") else glob(f, path)               for f in filters)def triggered(changed: list[str], jobs: dict) -> set[str]:    """Packages whose checks run, given which filters the changed files fire."""    return {pkg for filters, pkg in jobs.values()            if any(fires(p, filters) for p in changed)}def report(changed: list[str], jobs: dict, ws: dict = WORKSPACE) -> None:    need, ran = affected(changed, ws), triggered(changed, jobs)    print(f"changed:          {changed}")    print(f"affected:         {sorted(need)}")    print(f"proof perimeter:  {sorted(need & ran)}")    print(f"perimeter gap:    {sorted(need - ran)}")def affected_aware(jobs: dict, ws: dict = WORKSPACE) -> dict:    """Each consumer's job also triggers on its dependencies and on GLOBAL files."""    out = {}    for name, (filters, pkg) in jobs.items():        seen, todo = set(), list(ws[pkg][1])        while todo:            dep = todo.pop()            if dep not in seen:                seen.add(dep)                todo.extend(ws[dep][1])        out[name] = (filters + [ws[d][0] + "/**" for d in sorted(seen)] + GLOBAL, pkg)    return outdef load_filters(path: str) -> dict[str, list[str]]:    """Read a dorny/paths-filter file in its plain form: 'name:' then "  - 'glob'"."""    filters, name = {}, None    for raw in open(path, encoding="utf-8"):        line = raw.split(" #")[0].rstrip()        if not line.strip():            continue        if not line.startswith(" "):            name = line.rstrip(":")            filters[name] = []        else:            filters[name].append(line.strip()[1:].strip().strip("'\""))    return filtersif __name__ == "__main__":    if sys.argv[1:2] == ["--gate"]:  # CI: --gate FILTERS_FILE changed_file ...        filters = load_filters(sys.argv[2])        jobs = {pkg: (filters.get(pkg, []), pkg) for pkg in WORKSPACE}        changed = sys.argv[3:]        gap = affected(changed, WORKSPACE) - triggered(changed, jobs)        print(f"perimeter gap: {sorted(gap)}")        sys.exit(1 if gap else 0)    change = ["packages/contracts/src/order.ts"]    print("-- path-scoped CI --")    report(change, CI_JOBS)    print("-- affected-aware CI --")    report(change, affected_aware(CI_JOBS))    print("-- lockfile bump, path-scoped CI --")    report(["package-lock.json"], CI_JOBS)

Running python proof_perimeter.py prints:

text
-- path-scoped CI --changed:          ['packages/contracts/src/order.ts']affected:         ['api', 'contracts', 'web', 'worker']proof perimeter:  ['contracts']perimeter gap:    ['api', 'web', 'worker']-- affected-aware CI --changed:          ['packages/contracts/src/order.ts']affected:         ['api', 'contracts', 'web', 'worker']proof perimeter:  ['api', 'contracts', 'web', 'worker']perimeter gap:    []-- lockfile bump, path-scoped CI --changed:          ['package-lock.json']affected:         ['api', 'contracts', 'ui', 'web', 'worker']proof perimeter:  []perimeter gap:    ['api', 'contracts', 'ui', 'web', 'worker']

Same repository, same change, same agent. Under path-scoped CI, three of the four affected projects merge unproven, which is the polyrepo outcome inside a monorepo. Under affected-aware CI, where each consumer's job also triggers on its dependencies, the perimeter covers all four. ui is correctly left out of both, because nothing it depends on changed. The third run is the one teams rarely think about: a lockfile bump touches every package and, under path-scoped CI, triggers none of their jobs.

d2
grid-rows: 1
grid-gap: 40
classes: {
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  idle: {style: {fill: "#95A5A6"; stroke: "#6E7E7F"; font-color: "#2C2C2A"}}
}
wrong: "Wrong: path-scoped CI" {
  label.near: top-center
  style: {fill: "#FFFFFF"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  grid-columns: 1
  grid-gap: 12
  c: "contracts: run" {class: ok}
  a: "api: skipped" {class: bad}
  w: "web: skipped" {class: bad}
  k: "worker: skipped" {class: bad}
  u: "ui: not affected" {class: idle}
}
right: "Right: affected-aware CI" {
  label.near: top-center
  style: {fill: "#FFFFFF"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  grid-columns: 1
  grid-gap: 12
  c: "contracts: run" {class: ok}
  a: "api: run" {class: ok}
  w: "web: run" {class: ok}
  k: "worker: run" {class: ok}
  u: "ui: not affected" {class: idle}
}

That perimeter gap line is the number I would put in front of every agent PR. A human reviewer can sometimes remember one missing consumer. A fleet of agents opening PRs all day cannot, and neither can the person reviewing them. Post the gap as a PR comment, and also block merge on a non-zero gap, because a busy reviewer can skip a comment and cannot skip a failed gate. I drew the same line between advice and enforcement for agent hooks.

You do not need my script in production, because build tools already compute the affected set: nx affected -t test, turbo run test --affected, or a Bazel query such as kind(test, rdeps(//..., set(<changed targets>))). Two conditions make a triggered job actually test the change. The dependency has to be workspace-linked (workspace:*, a Bazel target, a Go replace) rather than a pinned published version, and the job has to run the consumer's tests. What the script does is make the gap visible on your own CI configuration, which is where it usually hides. Path filters live in workflow YAML, the dependency graph lives in package manifests, and nobody diffs one against the other.

The gate only means something if it reads the same filters CI uses, not a copy that can drift. dorny/paths-filter accepts a file path for its filters, so the fix is to keep them in one file, list each package's dependencies and the lockfile in its own entry, and point both CI and the script at that file:

yaml
contracts:  - 'packages/contracts/**'  - 'package-lock.json'ui:  - 'packages/ui/**'  - 'package-lock.json'api:  - 'services/api/**'  - 'packages/contracts/**'  - 'package-lock.json'worker:  - 'services/worker/**'  - 'packages/contracts/**'  - 'package-lock.json'web:  - 'apps/web/**'  - 'packages/contracts/**'   # web depends on contracts  - 'packages/ui/**'          # and on ui  - 'package-lock.json'

load_filters() reads this plain list form, and --gate builds one job per package from it. Against a path-scoped version of the same file, where each entry lists only the package's own folder, the gate fails the PR. Against the file above, it passes:

text
$ python proof_perimeter.py --gate .github/scoped.yml packages/contracts/src/order.tsperimeter gap: ['api', 'web', 'worker']$ echo $?1$ python proof_perimeter.py --gate .github/filters.yml packages/contracts/src/order.tsperimeter gap: []$ echo $?0

WORKSPACE is still kept by hand in this script. In a real workspace I would generate it from the package manifests, or from nx graph --file=graph.json, so the dependency side cannot drift either. The workflow below wires it together, runs in the merge queue, and diffs against the PR's or the queue's base commit:

yaml
on:  pull_request:  merge_group:            # also run when the PR is tested in the merge queuepermissions:  contents: read  pull-requests: read     # paths-filter reads the PR's changed filesjobs:  changes:    runs-on: ubuntu-latest    outputs:      packages: ${{ steps.filter.outputs.changes }}   # names of the filters that fired    steps:      - uses: actions/checkout@v4   # merge_group detection uses git      - uses: dorny/paths-filter@v4        id: filter        with:          filters: .github/filters.yml   # the same file the gate reads  test:                   # one matrix entry per package whose filter fired    needs: changes    if: needs.changes.outputs.packages != '[]'    strategy:      matrix:        package: ${{ fromJSON(needs.changes.outputs.packages) }}    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-node@v4        with:          node-version: '20'          cache: npm      # filter keys must match the npm workspace names      - run: npm ci && npm test --workspace ${{ matrix.package }}  perimeter-gate:         # mark as a required check, or it is only a comment    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4        with:          fetch-depth: 0          filter: blob:none      - uses: actions/setup-python@v5        with:          python-version: '3.12'      - env:          PR_BASE: ${{ github.event.pull_request.base.sha }}          QUEUE_BASE: ${{ github.event.merge_group.base_sha }}        run: |          BASE="${PR_BASE:-$QUEUE_BASE}"          git diff --name-only --no-renames -z "$BASE"...HEAD \            | xargs -0 python proof_perimeter.py --gate .github/filters.yml

Two things changed from a path-scoped workflow. The test job is a matrix over the filters that fired, using the changes output of paths-filter, so every entry in filters.yml gets a test job by construction and none can be forgotten. The filter keys must match the npm workspace names for --workspace to find them. And the gate reads the same file, so it fails whenever a package's entry is missing one of its dependencies. That proves trigger coverage. Whether each package's tests really exercise the dependency is the test-quality question from the $0.00 example, and no gate answers it.

paths-filter documents merge_group support: under that event, its base and ref inputs "default to commit hashes from the event". I checked that this YAML parses, that each filter fires as its comment says using the script's glob rules, and that the gate step exits 1 and 0 as shown in a local git repository. I have not run the workflow on GitHub. Mark perimeter-gate as a required check in branch protection, or it is only another comment.

Auditing my own monorepo's CI path filters against its dependency graph

I ran the same check on the repository this blog lives in. It holds four apps: the blog, a Mermaid diagram editor, a learning app and a local agent tool. Each has its own package.json or Python project, and none imports another. Its PR workflow runs the blog build on every PR, and scopes the other three to their own paths with dorny/paths-filter.

I modelled that workflow with the script's functions, then added one hypothetical change: a shared packages/diagram-core that both the blog and the diagram editor import. Both apps render diagrams, so this is the first shared package I would plausibly extract. The model also checks one real filter from the workflow, named code.

python
from proof_perimeter import fires, report# The apps in this repo, and the jobs in its .github/workflows/pr-checks.yml.ws = {    "blog":         ("ranjankumar-blog", []),    "studio":       ("mermaid-studio",   []),    "learning":     ("learning",         []),    "agent-studio": ("agent-studio",     []),}jobs = {    "build":              (["**"],                "blog"),  # no filter: every PR    "studio-build":       (["mermaid-studio/**"], "studio"),    "learning-build":     (["learning/**"],       "learning"),    "agent-studio-tests": (["agent-studio/**"],   "agent-studio"),}print("-- today --")report(["mermaid-studio/src/lib/parse.ts"], jobs, ws)print("-- add packages/diagram-core, imported by blog and studio --")ws["core"] = ("packages/diagram-core", [])ws["blog"] = ("ranjankumar-blog", ["core"])ws["studio"] = ("mermaid-studio", ["core"])jobs["core-test"] = (["packages/diagram-core/**"], "core")report(["packages/diagram-core/src/layout.ts"], jobs, ws)print("-- the workflow's 'code' filter, on a studio-only change --")code = ["ranjankumar-blog/**", "!ranjankumar-blog/content/posts/**"]print("code fires:", fires("mermaid-studio/src/lib/parse.ts", code))
text
-- today --changed:          ['mermaid-studio/src/lib/parse.ts']affected:         ['studio']proof perimeter:  ['studio']perimeter gap:    []-- add packages/diagram-core, imported by blog and studio --changed:          ['packages/diagram-core/src/layout.ts']affected:         ['blog', 'core', 'studio']proof perimeter:  ['blog', 'core']perimeter gap:    ['studio']-- the workflow's 'code' filter, on a studio-only change --code fires: True

Today the path scoping is correct, because there is nothing to share, and the perimeter equals the affected set for every change. Once a shared package appears, the editor's checks stop running on changes that can break it. Meanwhile the blog stays inside the perimeter only by accident, because its build job has no path filter at all.

The last line is the one real finding in my repository today, and it is a filter bug rather than a perimeter gap. The code filter is meant to mean "blog code, but not posts". Under dorny/paths-filter's default predicate-quantifier: 'some', the ! entry matches every file outside the posts folder, so the filter fires on a change that only touches the diagram editor. No job reads code today, so nothing breaks. The first job someone gates on it will run on every PR in the repository. In paths-filter v4, predicate-quantifier: 'some-with-excludes' makes the ! entry exclude the posts folder as intended. My workflow pins v3, which only offers 'some' and 'every'. 'every' gives the same result here, because every other filter in that step has a single pattern, and that is the fix I made.

d2
direction: down
blog: "build (blog)\nno path filter:\ninside by accident" {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
studio: "studio-build\nfilter: mermaid-studio/**\noutside the perimeter" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
core: "packages/diagram-core\n(hypothetical shared code)" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"; bold: true}
}
blog -> core: "imports"
studio -> core: "imports"

Yellow is the dangerous colour here. A perimeter that holds by accident will be "optimised" away by the first person who adds a path filter to speed up the blog build. Fixing it takes one line per consumer, adding packages/diagram-core/** to its filter, and I would add that line in the same PR that creates the package.

Where the monorepo advantage breaks

This argument has limits, and a team weighing a migration should know them before it starts.

Cycle time and CI cost grow with the perimeter. Faros AI's data across 320 teams shows a median PR cycle time of 19 hours in monorepos against 2 hours in polyrepos (at the 90th percentile, 8.6 days against 5.5). That is a correlation, and Faros attributes it to review topology, CI surface area and queueing rather than to monorepos as such. A migration still has to justify that cost. Liz Fong-Jones told LeadDev that monorepos still work, "but you need to be good at detecting what dependencies have changed," and doubted that smaller startups are. If you cannot afford affected-aware CI, you are paying for a perimeter you have turned off.

Cost also limits how far atomic changes can go as the repository grows. Wright's chapter says it plainly: "as a codebase and the number of engineers working in it grows, the largest atomic change possible counterintuitively decreases." Uber's Go monorepo shows the scale. Of 500,000 commits, 1.4% touched more than 100 services and 0.3% touched more than 1,000, and Uber had to add tiered rollouts because one commit could reach that many deployments. At that size, the one-commit reach that makes a wide perimeter possible also lets one commit break hundreds of services.

The more agents write to one tree, the more they collide. Xu et al. found co-active agent-authored PR pairs in 40.2% of 2,807 repositories. Same-agent pairs conflicted 19.8% of the time. Cross-agent pairs conflicted 41.7% of the time, but they were only 0.5% of co-active pairs. Conflicts happen in any shared repository, and a monorepo puts more of the work into one, so the risk grows with the number of writers sharing a tree, down to subagents running in parallel inside a single session.

Reads have the same reach as writes. Claude Code's read-only tools need no approval inside the working directory, so a session started at the root can open every file in it. CLAUDE.md guides behaviour but does not enforce it, as I argued in Skills vs Hooks in Claude Code. Check Point showed in 2026 that committed project settings could run shell commands on collaborators' machines, through hooks (GHSA-ph6w-f82w-28w6) and through MCP auto-approval settings (CVE-2025-59536), both patched before Check Point published. More contributors to one tree means more people who can edit that file.

d2
grid-rows: 2
grid-columns: 2
grid-gap: 24
ci: "Cycle time\n19h vs 2h median PR cycle\n(Faros, 320 teams)" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
atomic: "Atomic change ceiling\nshrinks as the repo grows\n(Google SWE book, ch. 22)" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
agents: "Parallel agents\n19.8-41.7% conflict rate\n(Xu et al. 2026)" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
radius: "Read radius = whole tree\ncommitted settings as RCE\n(CVE-2025-59536)" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}

Across the top row is cost, which you pay to keep the perimeter wide. Along the bottom is risk, and it grows with the same width. Remote code execution (RCE) through committed agent settings is the extreme case, and it is the reason agent configuration in a shared tree needs the same review as CI configuration.

Should you move to a monorepo for AI coding agents? A decision guide

Skip "should we move to a monorepo for our agents?" and ask instead: "what can our agent prove before merge, and is that enough for the changes we ask it to make?" Three answers decide it.

d2
grid-rows: 3
grid-columns: 2
grid-gap: 26
q1: "1. Are 10%+ of changes\ncross-project?" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
a1: "If not: stay polyrepo,\nadd --add-dir or MCP search" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
q2: "2. Can you keep CI triggers in sync\nwith the dependency graph?" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
a2: "If not: a monorepo buys cost\nwith no wider perimeter" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
q3: "3. Consolidate, then measure\nthe perimeter on every PR" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
b3: "" {style: {opacity: 0}}
q1 -> q2
q2 -> q3

Each question has a concrete test:

  1. Are 10% or more of your changes cross-project? If they are not, stay polyrepo and give the agent --add-dir or MCP search for reading.
  2. Can you keep CI triggers in sync with the dependency graph, through an affected command or through filter discipline? If you cannot, consolidating adds cost and leaves the perimeter no wider than before.
  3. With yes to both, consolidate, and measure the perimeter gap on every PR from the first day.

That 10% line is a judgment call anchored on Google's figure that large-scale changes are typically 10% to 20% of a project's changes (Wright, ch. 22). Google's monorepo is part of why that share is so high, so treat it as an upper reference. Nx claims cross-project changes make up "as much as 20% of commits," which is a vendor number. If your agents mostly make changes inside one service, the monorepo's perimeter has little to protect, and better retrieval gives you the read side for free.

If you stay polyrepo, you can still widen the perimeter before merge, just not into a single commit. Consumer-driven contract tests run a consumer's expectations against the provider's build, which proves the interface but not the behaviour behind it. A downstream trigger (repository_dispatch in GitHub Actions) runs consumers' CI against a provider branch before you merge it, if you are willing to wait for every pipeline. Gating systems such as Zuul go further and test a change together with its dependent changes in other repositories. All three need machinery you build and maintain, and the change still lands as several merges.

Checklist: what can your agent prove before merge?

  • For your last ten agent PRs that touched shared code, list the affected consumers and the checks that ran. The difference is your current perimeter gap.
  • Diff your CI path filters against your dependency graph. Every consumer's job should trigger on its dependencies' source roots, or use the build tool's affected command.
  • Find jobs that are inside the perimeter only because they have no filter. Write down why they run on everything, so nobody "optimises" them out.
  • Before adding a shared package, add it to every consumer's trigger in the same PR.
  • Do not count AGENTS.md or CLAUDE.md as a safety boundary. They change cost and speed, and the research shows no reliable gain in correctness.
  • If you stay polyrepo, decide which cross-repo changes need a pre-merge downstream run, and accept that the rest are verified eventually.
  • If you consolidate, budget for affected-aware CI before the migration, not after the first broken consumer.

Conclusion: context is the cost, proof is the asset

Fortius and TextYess were right to merge their repositories, and they were right that the agents got better. The mechanism they credit, one session that can read and edit everything, is the one you can now buy elsewhere. By 2026 any team can get it with a flag or an MCP server. What makes consolidation worth its cost is that the contracts and every consumer of them now sit in one commit graph, where one CI run can check them together before merge.

So treat context as a cost to scope down and the Proof Perimeter as the asset to protect. A monorepo whose CI only tests the touched package has the cost and none of the asset. A polyrepo can widen its perimeter only by building cross-repository gating, and it still cannot merge atomically. Before you move a single repository, measure what your agent can prove before merge today. That number tells you whether a monorepo would change anything.

FAQ: monorepos and AI coding agents

Do monorepos help AI coding agents?

They help when CI runs the checks of every project a change affects before it merges. If CI tests only the package that was touched, the agent gets more files to search through and no extra proof, which leaves it roughly where a polyrepo would.

Can Claude Code work across multiple repositories?

Yes, for reading and editing. --add-dir adds sibling checkouts to a session, and MCP code search reaches repositories you have not cloned. A CLAUDE.md in an added directory loads only with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. What no flag adds is one CI run over the combined change.

What is affected-aware CI in a monorepo?

It is CI that runs a project's checks when that project changes or when anything it depends on changes. You rarely compute the set by hand: nx affected, turbo run --affected and Bazel rdeps queries derive it from the dependency graph.

References


Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Right-Sized AI cover

Right-Sized AI

Harness Engineering for Production AI Systems cover

Harness Engineering

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments