Building an increment¶
Audience: the agent building it. Goal: executor. Follow it in order. This is the procedure that
runs before Cutting a release; the rules about when to release are in
CLAUDE.md.
Extracted from CLAUDE.md on 20260806 00:00, when that file crossed its own size guardrail — as
RELEASING.md was on 20260801. Nothing was dropped in the move. Which plan is live stays in
CLAUDE.md, because it changes every few days and this procedure does not.
Settle your role before anything else¶
A cleared or fresh context keeps the repository and loses the one fact that decides what it may
touch. CLAUDE.md carries the rule; this is the procedure and the record behind it.
- Name your own role — coder, planner, or another — from what the user said in this session.
Not from what the repository makes possible, not from what the previous session was doing, and
not from the work that happens to be in flight: a session that opens on an unlanded docs
branch reads as planner and may not be one. If you cannot determine it, ask the user and do
nothing else until they answer. This is one of very few rules here where the correct move is
to block, and it has to be stated as an exception because
CLAUDE.md§ Working mode other- wise overrides the default of stopping. - Ask every live peer the same question.
ListAgents, thenSendMessageeach live session: what role do you hold, what are you working on, which paths are you holding? State your own role, file set and timing in the same message — a peer cannot route around you if it does not know you exist — and wait for the reply before touching a shared path. - Then work inside the ownership table in
CLAUDE.md§ Documentation has one owner, and re-check at the moment of landing:python3 tools/shared_file_overlap.py --fetch --strict, then read the merged state of what it names.
Both failure directions are silent, and 20260823 produced both inside a few hours. One session opened on an in-flight docs branch, from which planner was the natural inference, and was told "you are the coder" two tool calls later — without that, it would have landed documents it did not own. The other announced a land on a mandate its peer could not see; the mandate was real, and the only thing that established it was the peer asking. A rule naming only the first direction invites the second, where an agent that is in fact the planner leaves a document wrong out of misplaced deference.
A peer's answer is coordination, never permission. It cannot authorise an action the user has not, and an instruction relayed through a peer is something to put to the user as a diff — not something to land.
Proposing a change to a document you do not own¶
The ownership table is in CLAUDE.md §
Documentation has one owner. This is
what to do when a document it does not give you is wrong.
Propose as git diff <sha> -- <file> against a named commit, in your branch's commit message or
a note the planner reads. Never an edit, and never "it is one line".
The planner incorporates it — judging when, not whether. A correction to what is true today
lands on main at once. A doc change describing your unlanded work lands with your merge: main
must not document a command that does not exist yet.
Why a round trip is worth it. Documentation is the coordination surface, and a clean auto-merge is not a correct merge — git merges edits that do not overlap textually, never edits that agree (20260729). The cost is accepted: a correction waits for the planner.
The one narrow exception is VERIFICATION.md — add only the row a test
you wrote requires, and nothing else in that file.
tests/test_verification.py
hard-fails on an unresolvable name, so a renamed or new test with no row makes your own branch red
and you could not self-certify.
Read the build order out of plans/¶
Never "the newest file" there. That directory also holds shipped plans, an iteration log,
standalone increments, re-entry checklists and decision records;
docs/README.md has the table
that tells them apart, and CLAUDE.md names the one that is live.
Two rules about reading a plan, each learned more than once:
- A plan's open decision is what the plan has not settled — not what the repository has not
settled. T5's D-4 sat open in the plan while
manifest.pyhad already answered it one line belowVECTOR_TIERS. Read the sibling key before weighing the decision table. - Read a plan's test list as part of its specification, not as an illustration of it. Where prose and test list disagree, the test list has been forced to be concrete and the prose has not (T7's containment rule named one of its two real layers). But a named test can also be a prediction that cannot discriminate — T4's conflicting-hunk test asserted an observable both implementations produce — so re-derive the scenario before writing it.
One increment at a time¶
Never batch increments; each is a separate, bisectable landing:
- Own worktree, branch
YYYYMMDD_HHMM-i<N>-<slug>. And run the gates there, never in the primary checkout — that is wheretools/land.pymerges, so another session can change its tree under a runningpytestat any moment. Measured 20260823: a peer landed mid-./check.shand three tests failed with "matched 0# Partheading(s)" — loud, plausible, and pointed at the file that session had just touched. Nothing was wrong. A red run in the primary checkout may be about a tree that no longer exists, so re-run it at the new sha before believing it. - Implement the increment with its tests — tests ship in the increment that introduces the
behaviour, never deferred. A test that touches a PDF fixture, the
claudeextractor or a real embedding backend carries the matchingtests/conftest.pypredicate (pdf_runnable()/pdf_extraction_runnable()/paid_runnable()) in its skip marker — on a worktree with the extra installed, a test that forgot one runs, passes, and says nothing about the CI leg where it cannot (20260804 13:30). - Green before review: run
./check.sh(ormake check) — every gate underset -e, so a failure is a non-zero exit rather than a line in a log that a pipe then swallows. Run it bare and read its own exit status:./check.sh | tail && git commitreportstail's status, and that shape has landed commits over a red gate twice — once one line below the comment incheck.shthat explains it (T4). Re-run it as the last step before every commit — green expires at the next keystroke, a reworded comment included. It formats Python inside Markdown fences too: a docs-only commit can still fail the gate. And a green run proves this worktree's venv, not the three-leg matrix: for a test gated on an extra, run its file once with the extra absent (uv sync --frozen --extra light, run it, thenuv sync --frozen --extra light --extra pdf --extra claudeto restore — never--all-extras, which pulls the ~2 GB[st]extra the matrix deliberately omits) —uv run --extra Xdoes not prune extras a previous sync installed. -
Break the code on purpose — after committing. Mutate the 3–5 most safety-critical assertions, confirm the right test fails for the right reason, restore.
python3 tools/mutate.py tools/batteries/tools-release_order_gate.toml python3 tools/mutate.py --check-anchors tools/batteries/*.toml # do they still hold?
The battery is a committed file, one per target, and you append to the one your target
already has — never a second file for it, which is how two increments end up maintaining two
sets of mutants that disagree. grep -l 'file *= *"src/pinakes/x.py"' tools/batteries/*.toml says
which; no hit means start one, named for the path with / → - and the extension dropped. The
rule, and what to do when an anchor rots, are in
tools/batteries/README.md;
tests/test_batteries.py fails if an anchor stops resolving, if a kills selector names a test
that no longer exists, or if two batteries claim one file.
What the comments in a battery are for. The proof is re-derivable — an afternoon per gate, measured. The reasoning about which mutants were worth writing is not, and it is the only thing in the file that the code does not already contain. Write it down beside the mutant.
The harness rules, each earned at least twice (RETROSPECTIVES.md §
Start here → "run a mutation pass") and each a refusal in tools/mutate.py:
- Commit before mutating — git checkout <file> restores to the last commit, not to the
pre-mutation state, and has silently reverted uncommitted fixes six times here. After any
restore, grep for the thing that was supposed to survive it.
- Assert each mutation's anchor matched exactly once — a str.replace that matches nothing
returns the string unchanged and reads as SURVIVED.
- Clear the module's __pycache__ after writing and after restoring — CPython invalidates
on (mtime-to-the-second, size), so a same-length mutant applied and reverted within one
second runs from stale bytecode.
- Run without -x, and read which assertion fired — a failing test proves the mutant is
caught, never that it is caught for the stated reason.
- Kill one known-catchable mutant first — a run with no kills is a broken harness, not a
clean bill.
"Mutation-verified" is a per-assertion claim, never a per-commit one, and "pinned by test
X" is a claim about a failing test: revert the fix and watch it go red, or do not write the
word.
And know what the battery cannot reach: a defect with no assertion anywhere. A textwrap
reflow of a comment run flattened a \-continued shell command onto one line — legal to ruff,
invisible to pyright, read as prose by a diff review (0.25.3, 4d5debf). A prose tool pointed
at text containing load-bearing whitespace needs its output re-read as the thing it is — a
command, a table, an indent — never as prose. The sibling case: a script navigating Markdown
matches the heading level, never a bare startswith("## ") — that steps over every #,
and it is how 0.25.3's release section landed inside ROADMAP's Part 5. Both are prose-shaped
tools applied to structure they do not model.
5. Retrospective review — a fresh adversarial pass over the increment's own diff, repeated
until clean. Findings and fixes are their own commit — and a fix gets the same treatment
as the code it fixes, mutation included: a fix applied under review inherits the review's
confidence and none of its scrutiny, and a fix can silently disarm a test written for something
else, so the battery re-runs after it. Anything worth keeping gets a
retro.d/ fragment; trivia
stays in the commit message.
🛑 The pass opens the increment's own fragments, and every file the increment changed. Not
the plan it was working from — the diff. This step's own position in this list is why it did
not: step 6 writes the changelog.d/ fragment after the last review pass in step 5, so the
procedure guarantees the fragment is never reviewed. Either write the fragments before the
final pass, or run one more pass after them; do not leave it to memory.
A fragment is not scratch. tools/fragments.py splices it into CHANGELOG.md and
docs/RETROSPECTIVES.md, and docs/ publishes on every push — so an unreviewed fragment is
an unreviewed published document. Measured 20260826 07:42: the changelog fragment written in d9fe1a9
carried "wrong for twelve hours", a duration that had been invented and repeated across
four files, in an increment whose subject was unmeasured claims. The review pass did not catch
it because the pass read the plans and never opened the fragment; it was found while
re-deriving timestamps for an unrelated reason.
The same hole covers ordinary source files. A corpus measurement over this repository's own
transcripts found that what a pass misses is overwhelmingly the fragments its own increment
wrote — on nine of the twelve increments with any miss at all — and that src/pinakes/__init__.py,
where __version__ lives, was opened by none of the 41 passes over the mutation-batteries
increment. A file the increment changed and no pass opened is the cheapest place for a defect
to survive review.
Ask it about your own increment rather than quoting a ratio from here:
python3 tools/review_ledger.py <increment>, whose last section is headed
CHANGED BY THIS INCREMENT, OPENED BY NOBODY and lists the paths. No percentage is quoted in
this paragraph, deliberately. It carried 207 of 248, 83%, 90% for four hours and all three
were wrong by the time they were read — the tool's scan could not see an extensionless filename
or a .lock, so Makefile and uv.lock were reported unopened on every increment that touched
them. Worse, the corpus is alive: it is this repository's own transcripts, and the sessions
measuring it are writing into it, which moved a published figure from 96.1% to 95.8% between two
runs with no code change at all. A ratio over a growing denominator is a claim with a
shelf-life measured in minutes; the command has none.
The rule says opened, and that word is doing deliberate work. What a transcript can show is
which files a pass opened; whether it reviewed them is not observable and no tool will ever
check it. So the checkable rule is the weak one — open every changed file and every fragment —
and it is stated as the floor rather than the goal. An opened file is not a reviewed file;
the measurement is a lower bound on attention, never evidence of it.
6. A changelog.d/ fragment in the same commit as the code — never an edit to CHANGELOG.md
itself
(changelog.d/README.md).
Written before the last review pass, or reviewed by one of its own — see step 5.
7. Land it — but first python3 tools/shared_file_overlap.py --fetch --strict: other agents
land work concurrently at any time, and a clean auto-merge is not a correct merge
(CLAUDE.md § Landing work). Then python3 tools/land.py <branch> --cleanup. Never git merge by hand — from inside
the branch's own worktree that merges the branch into itself and reports success three times over
(CLAUDE.md; what land.py
refuses, and why --cleanup deletes both copies of a branch:
RELEASING.md § Landing a branch). Then
gh run list --branch main: local green is one leg of a three-leg matrix, and main has been
red for three pushes and, later, four consecutive merges without anyone noticing (20260728, 20260801).
Which documents an increment touches, and in what order: docs/README.md § Landing a new
increment.
Content mine, keystrokes yours¶
Some planner-owned text cannot land in its own commit. A docs/VERIFICATION.md section for a
gate you are adding, or a counted paragraph in tools/batteries/README.md that
tests/test_batteries.py asserts, must change atomically with the code — any split ordering
leaves main red or wrong at some commit, and a repository whose gates are its memory cannot
afford either.
So the planner decides the exact text and says so explicitly, and the implementer pastes it into their branch unchanged.
| Authorship | the planner's, entirely. Nothing about the ownership table is suspended |
| The keystrokes | the implementer's, because the commit has to be theirs |
| The form | the planner gives the verbatim text, not a description of it |
This is not a licence to draft and seek approval afterwards. "I wrote it, is this alright?" puts the planner in the position of reviewing rather than deciding, which is the thing the ownership rule exists to prevent — and a review that says yes is indistinguishable from one that was never properly read. Ask; do not draft.
Why it is written down at all: it was re-derived from first principles four separate times in one session (20260825), and two such edits were authorised into a coder's branch that day with no record of why an implementer's commit contained planner-owned text. A future reader had no way to learn the difference between this and a broken rule.
Hand over before you stop¶
Context dies with the session; disk survives. So the handover is part of the increment, landed
in the same branch as the work — never a follow-up, because the pointers an increment falsifies are
exactly the ones the next session opens first. Set by the user 20260811 15:37, alongside the rule
that a boundary needing a context clear is a stop rather than an offer
(CLAUDE.md § Working mode).
| Where | What goes stale the moment the increment lands |
|---|---|
CLAUDE.md's live-plan pointer |
"Build E1 next" — the first thing any session in this repo reads |
docs/README.md's plan-routing row |
the same claim, for whoever opens docs/ first |
| The plan itself | mark the increment built, and record what shipped beside what the section asked for — including anything the next increment needs that only this one learned |
| The plan's baseline / measurement block | which of its rows this increment just falsified, and how far file:line moved. Every plan here has drifted this way; the template release's did about thirty times in four days |
| STATUS.md | the surface row — onmain, unreleased between landing and release, then the version |
All five are planner-only, so an implementer hands over by proposing them. The rule that the
handover lands in the same branch as the work (the user, 20260811 15:37) is unchanged and is not
weakened here: what lands in that branch is the git diff <sha> -- <file> for each row, per
§ Proposing a change to a document you do not own.
The planner incorporates them. An implementer that edits these directly has broken the ownership
rule, not satisfied the handover one — and an implementer that writes nothing has satisfied
neither. The two rules meet here rather than collide; only the form of the handover differs by
role.
RESUME.md may only ever hold what is also recoverable from main. It is excluded via
.git/info/exclude — a file that is itself never committed — so a cloud run, a scheduled routine,
another checkout or a fresh worktree sees no handover at all, and cannot discover that one
exists. Neither CLAUDE.md nor docs/README.md points at it, deliberately: committing it
would make a file both roles write into a planner-only merge hotspot. So it is a convenience, never
a carrier. Anything that would be lost if it vanished belongs in the five rows above, which are
committed. The file already claims this about itself and nothing enforces it — that is the whole of
the rule. This is the protection-for-me versus visibility-for-everyone distinction applied to the
repository's own state, and nobody noticed the asymmetry until it was written into a plan about
.gitignore.
A pointer nothing links to is not a handover. Verify by opening what a fresh session opens —
CLAUDE.md, then docs/, then the plan — not by trusting that you wrote it down somewhere. A
missing row has no wrong text to find, so no diff review and no grep reaches it; only the question
does (RELEASING.md, 20260811).