Skip to content

17 · Components — monorepo and multi-repo

← 16 Releasing · Contents · Next: 18 Personas in practice — the project-fit loop →


Most of this book treats a project as one codebase with one green bar. Real systems are rarely that tidy: a backend and a frontend, a shared library and two apps, or three services across three repos. ADD models all of these the same way — through the task graph already in the .add/ bundle. A task owns a scope: (the source subtree it may touch), its own checks, and the frozen contracts it produces (gives:) or consumes (needs:). With that, one milestone can ship a vertical slice across parts — a backend endpoint and the frontend that calls it — instead of splitting the slice across milestones.

This is opt-in and additive: a project whose tasks all share one scope behaves exactly as the rest of the book describes. You reach for the multi-part machinery only when a milestone genuinely spans more than one green bar.

Scope is declared, never inferred

A task's parts of the tree are declared on the node, not guessed from the directory layout. You name them in the task's scope: frontmatter (also the freshness set the gate hashes a receipt against):

---
type: Task
title: Reject overlapping bookings
scope:
  - apps/gateway/**
  - src/bookings/**
---

add new Task <slug> --scope apps/gateway/** writes that line; add locate apps/gateway/service.py does the reverse lookup — which node's scope owns this path. A task that names no cross-part scope is byte-identical to a single-part project. There is no registry to keep in sync and nothing scans apps/* to guess ownership: the scope is on the node that governs it.

Verify each task against its own green bar

In a mixed milestone, a backend task and a frontend task pass on different toolchains. The verify gate enforces this per task, through the bound receipt: add run <slug> --junitxml r.xml -- <the suite for this scope> records the checks that actually ran, and add gate <slug> PASS refuses unless every listed check appears in that receipt with outcome: pass. The engine never runs the suite — that invariant holds here too (NO-EXEC). The AI runs the right suite for the task's scope; the gate checks that the right checks were observed in a fresh, covers-bound receipt. Two tasks, one milestone, two green bars — each held to its own.

Freeze a contract between parts

When one part produces an interface another consumes, that boundary needs a frozen, machine-checkable contract. It is not a separate file type — it is the producer task's gives:, frozen at the freeze stamp, and the consumer task's needs: citing it:

# producer task
gives:
  - "POST /bookings -> 409 OVERLAP on user-overlap"

# consumer task
depends_on:
  - /tasks/add-booking-endpoint.md
needs:
  - /tasks/add-booking-endpoint.md#gives     # a frozen fragment of the producer

When the producer task freezes, its gives: becomes an immutable interface (the frozen-interface rule of the bundle format, FORMAT.md §3.5). The consumer task's needs: cites that frozen fragment by reference — resolved from graph.json at brief time, so a spec edit re-scopes the consumer with no edit here. If the producer later refreezes a changed shape, every node whose needs: cite the old fragment is flagged stale and must re-verify before its next gate — ATG's minimal repair made mechanical: internals may change freely; an interface change propagates as explicit, bounded re-verification of the dependents. A needs: pointing at a gives: that was never frozen is an edge_unresolved finding the consumer can see before it builds against a shape that does not exist.

One milestone, a full-stack slice

The reason to put a producer and a consumer in the same milestone is to ship a vertical slice — but the frontend must not commit to an endpoint the backend has not frozen yet. The depends_on edge and the frozen gives: enforce that ordering: the consumer's needs: cannot resolve until the producer's gives: is frozen, so the slice is ordered by the frozen contract, all inside one milestone. The frontend stays downstream of the backend endpoint, not split into a later milestone. add wave <milestone> reads exactly this DAG: it plans the parallel wave by levels, so producers land before the consumers that depend on them.

Parallel across parts: waves and worktrees

When a milestone's parts are independent, they run in parallel. add wave <milestone> plans the wave from the task DAG and records the streams; each stream runs behind its own frozen contract in its own git worktree, under its own persona lens. add join <bundles…> folds the finished stream bundles back — PASS-only, unioning their deltas and regenerating the graph.

What makes this safe is that graph.json is a compiled cache, gitignored and rebuildable from the node frontmatter at any time. Because every derivable fact is rendered rather than hand-maintained, there is no shared mutable file for N agents in N worktrees to conflict on — the compiled cache cannot go stale and has no concurrent writers. That property, not a coordinator, is what lets a wave fan out.

Across repositories: one bundle each

Parts in separate repositories work the same way, with one honest difference: an edge may not escape its bundle (edge_out_of_bundle is the one fatal finding), so a consumer in repo B cannot needs: a node in repo A directly. Each repo carries its own .add/ bundle — its own five specs, its own tasks, its own vendored engine under .add/tooling/ (add init vendors the flat engine there; add doctor --sync re-vendors a stale copy).

The hand-off between repos is the frozen interface itself. The producing repo freezes its gives: and commits it; the consuming repo carries a copy of that frozen shape as its own contract of record and holds its consumer task against it. The frozen shape is content-addressed, so a copy that drifts from the source is detectable rather than silent. This is deliberately not an automatic transport: the engine ships no cross-repo fetch verb, because a boundary between two teams' repos is exactly where a human-carried, committed contract beats a background pull. "Publishing" is committing the frozen shape in the producer repo; adopting it is committing the copy in the consumer repo.

What this pillar is not

  • Not auto-discovery. Scope is declared on each task's scope:, not inferred from the directory tree.
  • Not a central server. Each repo keeps its own .add/ bundle; graph.json is a local, rebuildable cache, and cross-repo sharing is a committed frozen shape, not shared mutable state.
  • Not a new approval. The cross-part machinery rides the existing three-beat flow and its single contract-freeze — it adds edges and stale-flags the engine tracks, not human checkpoints.

The whole pillar is structure, not policy: who owns a part and how strict its floor is remains the governance story (chapters 09–10) — the sensitivity floor and the personas — layered on top of this graph.


← 16 Releasing · Contents · Next: 18 Personas in practice →