A monorepo run by one person and several agents has a dependency problem nobody else can see: twenty workspaces can quietly carry five versions of the same package, and the person only finds out when a build breaks in a workspace they forgot existed. The fix that holds up is boring on purpose: one package manager, one lockfile, one catalog that names every shared version, and a check that refuses to let the structure drift.
The monorepo behind this site uses Bun workspaces as the package graph and Turborepo strictly as the dependency-aware task scheduler. Bun owns installs, the single bun.lock, scripts, and tests. Turbo never touches a dependency.
one lockfile, one runtime
The root package.json pins the toolchain in prose-enforceable fields: "packageManager": "bun@1.3.14" and "engines": { "node": "24.x" }. There is one bun.lock at the root and no other lockfile anywhere in the tree. That single sentence removes an entire class of agent error: no workspace can resolve a different dependency set than the one that was checked.
The lockfile is not only a local artifact. Each registered Vercel project pins the same root Bun version in its own vercel.json and installs only its dependency closure with bunx bun@1.3.14 install --filter <workspace> --frozen-lockfile, and scripts/check-vercel-projects.ts enforces that exact command shape across every registered project. The deploy path resolves the same locked graph the laptop resolved, filtered to one product’s needs. A filtered install also cannot see root Turbo, which is why each project declares its own buildCommand.
The workspace globs are themselves the architecture statement:
"workspaces": {
"packages": [
"projects/*",
"projects/*/apps/*",
"projects/*/packages/*",
"packages/*"
]
}
Four levels, no deeper. projects/<name> is a deployable product or a product family, projects/<name>/apps/* holds its runnable surfaces, projects/<name>/packages/* is code shared only inside that product, and root packages/* is code intended for multiple products.
the catalog
Bun’s catalog feature is the dependency registry. External versions used across workspaces live once in workspaces.catalog, and consumers reference them with the catalog: protocol:
"catalog": {
"next": "16.2.12",
"react": "19.2.3",
"typescript": "^6.0.3",
"zod": "^4.4.3",
"effect": "3.22.1",
"@hraness/direct": "github:hraness/direct#v0.7.18"
}
A workspace that needs React declares "react": "catalog:" and nothing else. When a version moves, it moves in one file, and every consumer moves together. Aliased packages use the same registry ("ai-v7": "npm:ai@^7.0.36"), so a compatibility alias is a catalog entry, not a scattered convention.
Internal edges use workspace:*, so Bun, Turbo, and the deployment layer all see the same graph. Direct imports require direct dependencies, and Bun’s isolated installs make phantom imports fail at install time instead of at some later bundler.
workspace boundaries
The rules that keep the graph honest are few and checkable:
- A shared package must not import from a project. The dependency arrow points inward only.
- A project package is promoted to root
packages/*only with a second concrete consumer. Speculative sharing is refused, not deferred. - A grouped family (
apps/*pluspackages/*) exists only when the pieces are genuinely one product.
bun run check:workspaces is the enforcement: it rejects duplicate workspace names, missing catalog entries, incorrect dependency protocols, and non-Bun lockfiles. The check exists so an agent adding a dependency cannot accidentally invent a second version of something the catalog already owns.
It is also one of the lightweight repository-wide policies check:affected keeps on every nonempty plan, because the workspace graph is a global input. A one-line content edit still runs it, and a catalog change is one of the named cases that escalates to the complete gate.
turbo stays a scheduler
Turborepo’s role is deliberately narrow: it is the dependency-aware task scheduler and nothing else. Root scripts like build, lint, typecheck, and test route through turbo run, which walks the workspace:* graph to order tasks, while Bun still performs every install and executes every script. Keeping the scheduler away from dependency resolution means the package graph has exactly one source of truth, and Turbo’s cacheable outputs stay a performance detail rather than a correctness input.
Two monorepo-specific wrinkles show up in how the graphs are admitted locally. Every locally admitted Turbo graph runs one workspace at a time, because a future ^build edge can reach a Bun bundler with no worker limit and two parallel bundlers can oversubscribe the host. And cached successes suppress log replay, so a re-run reports the fresh work instead of re-printing cached output as if it were new evidence.
pinned releases over local copies
Cross-repository dependencies are consumed as immutable releases, never as sibling checkouts or mirrors. The catalog pins Hraness packages two ways: github:hraness/<repo>#vX.Y.Z tags for most, and an exact release-asset tarball URL where the package ships one (@hraness/kb resolves from a registry.npmjs.org tarball URL). The dependency checker recognizes exactly those forms plus full 40-hex commit pins.
This convention does two jobs. It makes every external edge reproducible from source control alone, and it keeps ownership clear: standalone repositories own their source and releases, and this monorepo consumes reviewed artifacts rather than recreating or mirroring them. The exceptions are equally explicit: overrides for transitive pins like postcss, and patchedDependencies for the two carried patches (next@16.2.12, react-aria@3.50.0), which live as checked patch files rather than forks.
Upgrades have their own lane. A checked script (scripts/propose-hraness-dependency-update.ts) discovers newer immutable Hraness releases one tagged repository per run and prepares the proposal without moving tags, publishing, or merging. Syntax recognition of a pin is only the start; the docs note that recognizing a github: tag or release-asset URL does not itself prove release immutability or archive integrity, so artifact admission stays a separate consumer-side step.
policy as data
Structure enforcement extends past dependencies. konsistent.json at the root is a machine-checked policy file, and bun run check:structure (which runs konsistent check) holds it. Two conventions carry most of the weight:
owned-boundaries-have-agent-guides: a path-glob list of every owned directory boundary (each project, app, package,scripts,skills,docs,kb, and their source subtrees) must contain anAGENTS.md. Negation globs like!projects/*/AGENTS.mdkeep the requirement aimed at real boundaries rather than the files that document them.readmes-are-boundary-front-doors:README.mdfiles may exist only at the monorepo root and at deployable project and package roots. Everywhere else, scoped guidance belongs inAGENTS.md, and a stray README anywhere else in the tree fails the check.
This is what “the repository explains itself to agents” looks like mechanically: the map of where guidance must exist is data, not a wiki page, and it fails closed.
The registry habit extends to things that are not dependencies at all. costs.json at the root registers every product data surface (store, kind, retention class, owner module, budget), and check:cost-surfaces fails the tree when a new table, bucket, stream, dynamic route, blob, or provider meter appears unregistered. The pattern is the same as the catalog: an expensive decision gets exactly one typed place to be made, and a checked file is the difference between a convention and a policy.
what this buys an agent
The payoff is operational. An agent asked to add a dependency has one legal place to put a new version and one protocol to reference it by. An agent asked to touch a package can prove which products consume it through the workspace:* graph rather than by grep. A version bump is one catalog edit plus one lockfile update, and check:workspaces turns every tempting shortcut into a check failure instead of a quiet inconsistency. Even the failure modes are legible: a missing catalog entry, a file: protocol, or a second lockfile are all the same class of error, reported by the same check, fixed the same way.
The honest limit is that none of this decides whether a dependency should exist. The catalog deduplicates versions; it does not judge libraries. That judgment is still a human or plan-level call, and the structure’s job is to make sure the call is made once, in the open, in package.json.
sources
- personal-monorepo-template: the public starting shape for this kind of single-maintainer monorepo.
- Bun workspaces and catalogs: the
catalog:protocol this relies on. - The monorepo behind this site (projects): the catalog, lockfile, and
konsistent.jsonconventions described here.