The layout
2026-09-13 Draft
Every repo has a shape. Almost nobody writes it down, and the ones who do write it in a README that is wrong within a month. I made the shape a file that the build checks, and the file turned out to be the map agents had been missing.
The question nobody answers
Where does a new test go? Which files does a package have to carry? What is that directory for, and is it still for that? On a project with one author the answers live in one head, and they drift there, slowly. On a project where agents write most of the code they drift fast. An agent has no memory of last session's decision, so it invents a path that looks reasonable, and the next one invents another, and a month later the tree is a record of every session's guess.
I tried the usual things. A conventions document, which nobody reads. A review step, which is me, reading diffs for paths, which is the worst use of a person I can think of. What I wanted was for the question to have one answer, in one place, and for the answer to be checked the way a test is checked.
A file that reads like a listing
The answer is a file at the root of the repo called repo.layout. It is written the way a directory listing looks, one entry per line, two spaces of indentation per level, and a comment on every line saying why that path exists. This is the whole of a small one:
# Contract for this project. `layout .` checks it.
package.json # Name, scripts, dependencies
README.md # What this is
repo.layout # This file
src/ # Application code
index.ts # Entry point
utils/ # Pure helpers, no framework
{:util}.ts
tests/ # One test per surface
{:name}.test.tsA plain name is a file or a directory that has to be there. A name in braces with a colon is a slot: it matches any one path segment and remembers what it matched. A question mark in front of an entry makes it optional. A pair of empty braces means anything else here is fine, for a directory of images nobody wants to enumerate. A regex in braces is for the places where the casing is the rule. An arrow says a symlink has to point at a particular target, which is how one file of doctrine can serve several harnesses without a copy drifting.
That is the whole syntax, and it is deliberately small. There is no checking inside files, no glob policy, no tool that rewrites the tree to match. The file says what may exist, and where.
And runs like a test
The other half is one command. Run the checker at the root and it walks the tree against the contract and prints the tree back, with a tick on every row that matched and a cross on every row that did not. A row that matched shows the rule it matched, so a test directory prints as the slot it satisfied. A row that failed shows the real path, because no rule claimed it. The exit code is zero when the tree matches and one when it does not, so in the build it is one line that is silent on success and fails loudly on drift.
This is that command against the small project above. The colours are the tool's own, taken from the run: green where a row matched its rule, red where nothing in the contract claimed the path, and the comment column dim behind both.
On a phone it is wider than the screen, so the block scrolls sideways and the comment column sits off to the right.
$ layout .
✓ package.json # Name, scripts, dependencies
✓ README.md # What this is
✓ repo.layout # This file
✓ src/ # Application code
├─ ✓ index.ts # Entry point
└─ ✓ utils/ # Pure helpers, no framework
└─ ✓ {:util}.ts
✓ tests/ # One test per surface
└─ ✓ {:name}.test.ts
$ echo $?
0The first run on a real project always reports something. A lockfile, a build output, a dotfile somebody forgot. That is the moment of the whole exercise: for each one you decide whether it belongs, and if it does you write the line and the reason, and if it does not you delete it. After the first run the file is true, and the check keeps it true.
Here is the same project after somebody adds a directory the contract never claimed and leaves a build output at the root. The rows that still match stay green and keep their rule, the two that do not go red and show the real path, and the exit status at the foot is one:
$ layout .
✗ dist.js # Unexpected by layout
✓ package.json # Name, scripts, dependencies
✓ README.md # What this is
✓ repo.layout # This file
✗ src/ # Application code
├─ ✗ components/ # Unexpected by layout
├─ ✓ index.ts # Entry point
└─ ✓ utils/ # Pure helpers, no framework
└─ ✓ {:util}.ts
✓ tests/ # One test per surface
└─ ✓ {:name}.test.ts
$ echo $?
1The comment column is the point
The shape says what an agent may write. The comment says why the slot is there, and the why is the part that changes behaviour. A slot for a test file with the comment what the suite proves, in prose, is an instruction. An agent that reads it stops emitting an empty stub to satisfy the name. The checker lints for this too: a directory with no comment is a warning, and so is a comment off the alignment column, and a strict flag makes either of them a failure. A formatter puts every comment on the column so nobody counts spaces.
I did not expect the comments to matter as much as the rules. They matter more. The rules stop the wrong path from landing. The comments stop the wrong file from being written in the right path, which is the more expensive mistake.
Rules, not listings
The contract for Chippy opens with a paragraph that took me a while to earn. Every directory is a rule, not a listing. A unit is a name-bound pair, a source file and its test beside it with the same name, and the slot is written twice so the checker holds them to it. A family of things is one slot. The empty braces appear only inside a unit, never at a level where they would let anything in. A directory whose members must be spelled out is a package root, because its toolchain names those files, or it should not exist.
That paragraph is a theory of the repo, and once it was written down the tree started to look like it. A file that did not fit a rule was a file that had no reason to be there, most of the time, and when it did have a reason the reason became a line in the contract with a comment saying so. Findings, incidents, research and session state do not fit any rule, and that is correct: they live on the index, not in the repo, and the contract says so at the top.
Before and after
Before the check ran against the real tree, a merge deleted a directory twice in one day and the directory stayed on disk. Ignored files lived under it, the checker never reads a dotfile no rule asks for, and so the ghost was unseen rather than unexpected. Nothing failed. I found it by hand, the second time.
After, the gate runs two checks. The contract against the actual tree, ignored files included, with nothing excluded. Then every path the repository does not track, whether untracked or ignored, has to be a path the contract claimed, and anything else is not tracked and not claimed, which fails. The clean command deletes exactly the paths the second check names, so the tree the check saw is the tree that gets cleaned. The ghost directory cannot happen now, because the thing that hid it is the thing that is checked.
What it changed
A green check does not promise the files are right. It promises the map is. That turns out to be the promise I needed, because agents read the map before they write, and a map that is always true is the one document in the repo that never has to be re-explained. When a session starts, the shape of the repo is not a thing it has to guess or ask about. It is a file, at the root, with a reason on every line, and the build fails if it lies.