"""monorepo-strategies-for-coding-agents.py — work out, for a monorepo on disk, (1) which instruction files each of three coding-agent harnesses would load for a given working directory, and (2) which workspace packages a changed file puts in scope. Standard library only. Python 3.13.12 on Windows was used for the output in the article. Subcommands ----------- fixture DIR write a small synthetic monorepo (three packages, layered instruction files, one deliberately oversized root AGENTS.md) so the other two subcommands have something real to run against. context DIR resolve the instruction chain for working directory DIR three ways and print each file, its size, and the running total: claude CLAUDE.md and CLAUDE.local.md "from your current working directory and every directory above it", ordered "from the filesystem root down to your working directory", with CLAUDE.local.md appended after CLAUDE.md at each level. Files over 4 MiB are skipped. Subdirectory files are NOT in this chain: they "load on demand when Claude reads files in those directories". (code.claude.com/docs/en/memory, read 2026-09-17) codex ~/.codex/AGENTS.override.md then ~/.codex/AGENTS.md, "only the first non-empty file at this level", then from the Git root down to the working directory, per directory AGENTS.override.md, then AGENTS.md, then project_doc_fallback_filenames. "At most one file per directory is included", joined root to leaf, and Codex "stops adding files once the combined size reaches the limit defined by project_doc_max_bytes (32 KiB by default)". (learn.chatgpt.com/docs/agent-configuration/agents-md, read 2026-09-17) gemini ~/.gemini/GEMINI.md, then GEMINI.md in the workspace directories "and their parent directories", stopping at a boundary marker (context.memoryBoundaryMarkers, default [".git"]) and searching at most context.discoveryMaxDirs directories (default 200). (gemini-cli docs/cli/gemini-md.md and docs/reference/configuration.md at tag v0.60.0, read 2026-09-17) blast FILE [FILE...] from the root package.json "workspaces" globs, map each changed file to the package that owns it, then add every package that depends on it, transitively, through workspace-internal dependencies. This is the SHAPE the build tools document for a changed-set selection -- Nx: "Affected projects are projects that have been changed and projects that depend on the changed projects" -- computed here from package.json alone. It is a stand-in, not a reimplementation: it reads no project graph, no lockfile, no tsconfig path mapping and no task pipeline, so a real `nx affected`, `turbo --affected` or `pnpm --filter "...[ref]"` run can legitimately select more. Options ------- --home DIR treat DIR as the home directory when looking for the global ~/.codex and ~/.gemini files (default: the real home directory). --max-bytes N Codex project_doc_max_bytes (default 32768). --root DIR repository root for `blast` (default: the current directory). --csv FILE `blast` also writes one row per package. Usage ----- python monorepo-strategies-for-coding-agents.py fixture demo python monorepo-strategies-for-coding-agents.py context demo/packages/api --home demo/home python monorepo-strategies-for-coding-agents.py blast packages/shared/src/types.ts --root demo """ from __future__ import annotations import argparse import csv import fnmatch import json import os import sys CLAUDE_MAX_BYTES = 4 * 1024 * 1024 # "Claude Code skips a file over 4 MiB" CODEX_DEFAULT_MAX = 32 * 1024 # project_doc_max_bytes default, 32 KiB GEMINI_MAX_DIRS = 200 # context.discoveryMaxDirs default GEMINI_BOUNDARY = [".git"] # context.memoryBoundaryMarkers default def size_of(path: str) -> int: try: return os.path.getsize(path) except OSError: return -1 def ancestors(start: str, stop_at_boundary: bool = False, limit: int = 10_000) -> list[str]: """Directories from `start` up to the drive root, nearest first.""" out: list[str] = [] cur = os.path.abspath(start) while len(out) < limit: out.append(cur) if stop_at_boundary and any(os.path.exists(os.path.join(cur, m)) for m in GEMINI_BOUNDARY): break parent = os.path.dirname(cur) if parent == cur: break cur = parent return out def chain_claude(cwd: str) -> list[tuple[str, int, str]]: """Root-down order; CLAUDE.local.md after CLAUDE.md at each level.""" picked: list[tuple[str, int, str]] = [] for d in reversed(ancestors(cwd)): for name in ("CLAUDE.md", "CLAUDE.local.md"): p = os.path.join(d, name) n = size_of(p) if n < 0: continue if n > CLAUDE_MAX_BYTES: picked.append((p, n, "skipped: over 4 MiB")) else: picked.append((p, n, "loaded at launch")) return picked def chain_codex(cwd: str, home: str, max_bytes: int, fallbacks: list[str]) -> list[tuple[str, int, str]]: """Global first, then Git root down to cwd, at most one file per directory, capped.""" order = ["AGENTS.override.md", "AGENTS.md", *fallbacks] candidates: list[str] = [] for name in ("AGENTS.override.md", "AGENTS.md"): p = os.path.join(home, ".codex", name) if size_of(p) > 0: # "only the first non-empty file at this level" candidates.append(p) break dirs = ancestors(cwd) git_root = next((d for d in dirs if os.path.exists(os.path.join(d, ".git"))), dirs[-1]) idx = dirs.index(git_root) for d in reversed(dirs[: idx + 1]): # Git root down to cwd for name in order: p = os.path.join(d, name) if size_of(p) > 0: # "Codex skips empty files" candidates.append(p) break # at most one file per directory picked: list[tuple[str, int, str]] = [] total = 0 stopped = False for p in candidates: n = size_of(p) if stopped or total + n > max_bytes: stopped = True picked.append((p, n, f"dropped: would pass project_doc_max_bytes ({max_bytes} B)")) continue total += n picked.append((p, n, f"included, running total {total} B")) return picked def chain_gemini(cwd: str, home: str) -> list[tuple[str, int, str]]: picked: list[tuple[str, int, str]] = [] g = os.path.join(home, ".gemini", "GEMINI.md") if size_of(g) >= 0: picked.append((g, size_of(g), "global")) for d in reversed(ancestors(cwd, stop_at_boundary=True, limit=GEMINI_MAX_DIRS)): p = os.path.join(d, "GEMINI.md") if size_of(p) >= 0: picked.append((p, size_of(p), "workspace or parent")) return picked def print_chain(label: str, rows: list[tuple[str, int, str]], base: str) -> None: print(f" {label}") if not rows: print(" (no instruction file found)") return for p, n, note in rows: rel = os.path.relpath(p, base) print(f" {n:>7} B {rel:<46} {note}") loaded = sum(n for _, n, note in rows if note.startswith(("loaded", "included", "global", "workspace"))) print(f" {loaded:>7} B TOTAL IN CONTEXT ({len(rows)} file(s) found)") def cmd_context(args) -> int: cwd = os.path.abspath(args.dir) home = os.path.abspath(args.home) if args.home else os.path.expanduser("~") base = os.path.dirname(cwd) print(f"working directory: {cwd}") print(f"home for global files: {home}\n") print_chain("Claude Code (CLAUDE.md chain, launch-time)", chain_claude(cwd), base) print() print_chain( f"Codex (AGENTS.md chain, cap {args.max_bytes} B)", chain_codex(cwd, home, args.max_bytes, args.fallback), base) print() print_chain("Gemini CLI (GEMINI.md chain)", chain_gemini(cwd, home), base) return 0 def load_workspace(root: str) -> dict[str, dict]: """Map package name -> {dir, deps} from the root package.json workspaces globs.""" with open(os.path.join(root, "package.json"), encoding="utf-8") as fh: rootpkg = json.load(fh) globs = rootpkg.get("workspaces") or [] if isinstance(globs, dict): globs = globs.get("packages", []) pkgs: dict[str, dict] = {} for pattern in globs: parent = os.path.join(root, os.path.dirname(pattern.replace("/", os.sep))) leaf = os.path.basename(pattern) if not os.path.isdir(parent): continue for entry in sorted(os.listdir(parent)): if not fnmatch.fnmatch(entry, leaf): continue manifest = os.path.join(parent, entry, "package.json") if not os.path.isfile(manifest): continue with open(manifest, encoding="utf-8") as fh: data = json.load(fh) deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})} pkgs[data["name"]] = {"dir": os.path.join(parent, entry), "deps": sorted(deps)} for meta in pkgs.values(): meta["deps"] = [d for d in meta["deps"] if d in pkgs] return pkgs def owner_of(path: str, pkgs: dict[str, dict]) -> str | None: full = os.path.abspath(path) best, best_len = None, -1 for name, meta in pkgs.items(): d = os.path.abspath(meta["dir"]) if (full == d or full.startswith(d + os.sep)) and len(d) > best_len: best, best_len = name, len(d) return best def cmd_blast(args) -> int: root = os.path.abspath(args.root) pkgs = load_workspace(root) dependents: dict[str, set[str]] = {n: set() for n in pkgs} for name, meta in pkgs.items(): for dep in meta["deps"]: dependents[dep].add(name) changed: set[str] = set() unowned: list[str] = [] for f in args.files: o = owner_of(os.path.join(root, f), pkgs) if o: changed.add(o) else: unowned.append(f) selected = set(changed) queue = list(changed) while queue: cur = queue.pop() for d in dependents[cur]: if d not in selected: selected.add(d) queue.append(d) print(f"workspace: {root}") print(f"packages found: {len(pkgs)} ({', '.join(sorted(pkgs))})") print(f"changed files: {len(args.files)}") if unowned: print(f" outside every package (root-level change): {', '.join(unowned)}") print(" a root-level change is why every tool here has an escape hatch: it can touch all packages") print(f"directly changed packages: {', '.join(sorted(changed)) or '(none)'}") print(f"in scope with dependents: {len(selected)} of {len(pkgs)} -> {', '.join(sorted(selected)) or '(none)'}") if pkgs: print(f"share of the workspace a changed-set run would cover: {100 * len(selected) / len(pkgs):.0f}%") if args.csv: with open(args.csv, "w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow(["package", "directory", "workspace_deps", "directly_changed", "in_scope"]) for name in sorted(pkgs): w.writerow([name, os.path.relpath(pkgs[name]["dir"], root).replace(os.sep, "/"), " ".join(pkgs[name]["deps"]), int(name in changed), int(name in selected)]) print(f"wrote {args.csv}") return 0 FIXTURE_ROOT_RULES = ( "- Run package scripts from the package directory, not the repository root.\n" "- Prefix commit subjects with the package name.\n" "- Never edit generated output; run the codegen script in the package instead.\n" ) def cmd_fixture(args) -> int: root = os.path.abspath(args.dir) pkgs = { "api": {"deps": {"@demo/shared": "workspace:*"}}, "web": {"deps": {"@demo/shared": "workspace:*"}}, "shared": {"deps": {}}, } os.makedirs(os.path.join(root, ".git"), exist_ok=True) # git-root marker for both chains os.makedirs(os.path.join(root, "home", ".codex"), exist_ok=True) os.makedirs(os.path.join(root, "home", ".gemini"), exist_ok=True) with open(os.path.join(root, "package.json"), "w", encoding="utf-8") as fh: json.dump({"name": "demo-monorepo", "private": True, "workspaces": ["packages/*"]}, fh, indent=2) # A root AGENTS.md that grew to 28 KB, the situation the cap is there for. root_agents = "# Repository conventions\n\n" + FIXTURE_ROOT_RULES filler = "- Convention line kept for the fixture, one of many that accumulated over time.\n" root_agents += filler * ((28_000 - len(root_agents)) // len(filler)) write = { "CLAUDE.md": "# Repository conventions\n\n" + FIXTURE_ROOT_RULES, "AGENTS.md": root_agents, "GEMINI.md": "# Repository conventions\n\n" + FIXTURE_ROOT_RULES, } for name, text in write.items(): with open(os.path.join(root, name), "w", encoding="utf-8") as fh: fh.write(text) for name, meta in pkgs.items(): pdir = os.path.join(root, "packages", name) os.makedirs(os.path.join(pdir, "src"), exist_ok=True) with open(os.path.join(pdir, "package.json"), "w", encoding="utf-8") as fh: json.dump({"name": f"@demo/{name}", "version": "1.0.0", "dependencies": meta["deps"]}, fh, indent=2) with open(os.path.join(pdir, "src", "index.ts"), "w", encoding="utf-8") as fh: fh.write(f"export const name = '{name}';\n") local = (f"# {name} package\n\n- Conventions that apply only inside packages/{name}.\n" "- Copy the example environment file before running anything.\n") for fname in ("CLAUDE.md", "AGENTS.md", "GEMINI.md"): with open(os.path.join(pdir, fname), "w", encoding="utf-8") as fh: fh.write(local + "- Padding line to give the leaf file a realistic size.\n" * 100) with open(os.path.join(root, "packages", "shared", "src", "types.ts"), "w", encoding="utf-8") as fh: fh.write("export type Id = string;\n") with open(os.path.join(root, "home", ".codex", "AGENTS.md"), "w", encoding="utf-8") as fh: fh.write("# Personal defaults\n\n- Prefer small commits.\n") with open(os.path.join(root, "home", ".gemini", "GEMINI.md"), "w", encoding="utf-8") as fh: fh.write("# Personal defaults\n\n- Prefer small commits.\n") print(f"fixture written to {root}") return 0 def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description="Instruction-file chains and changed-package scope in a monorepo.") sub = ap.add_subparsers(dest="cmd", required=True) c = sub.add_parser("context", help="resolve the instruction chain for a working directory") c.add_argument("dir") c.add_argument("--home", default=None) c.add_argument("--max-bytes", type=int, default=CODEX_DEFAULT_MAX, dest="max_bytes") c.add_argument("--fallback", action="append", default=[], help="project_doc_fallback_filenames entry") c.set_defaults(func=cmd_context) b = sub.add_parser("blast", help="packages in scope for a set of changed files") b.add_argument("files", nargs="+") b.add_argument("--root", default=".") b.add_argument("--csv", default=None) b.set_defaults(func=cmd_blast) f = sub.add_parser("fixture", help="write a synthetic monorepo to run against") f.add_argument("dir") f.set_defaults(func=cmd_fixture) args = ap.parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))