Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21,935 changes: 921 additions & 21,014 deletions CHANGELOG.md

Large diffs are not rendered by default.

505 changes: 79 additions & 426 deletions CLAUDE.md

Large diffs are not rendered by default.

3,141 changes: 44 additions & 3,097 deletions PERF.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/changes/unreleased/1922-contributor-guide-tidy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
kind: changed
title: Tidy contributor guides and examples
pr: 1922
surface: [docs, build]
invalidates: []
---
Contributor guides, release notes and examples are more concise.
38 changes: 12 additions & 26 deletions docs/rules/README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
# The rules
# Contributor rules

How work moves from somebody's branch to somebody else's machine. Five short
pages, each answering one question, because the question you have is rarely the
whole story and reading the whole story to find one command is how a rule stops
being followed.

| Page | Read it when |
| Page | Purpose |
| --- | --- |
| [branching.md](branching.md) | you are starting work, or wondering which branch anything belongs on |
| [ci.md](ci.md) | a check is red, or you want to know what will run before you push |
| [changelog.md](changelog.md) | you are writing the entry your pull request owes |
| [promotion.md](promotion.md) | you are moving `dev` to `staging`, or cutting a release |
| [../../.github/rulesets/README.md](../../.github/rulesets/README.md) | you are changing what the server enforces |

The short version, which is also in `CLAUDE.md` because it is the part that must
never be looked up:

- **Branch off `dev`. Open the pull request against `dev`.**
- **Never push to `dev`, `staging` or `main` directly, and never force-push any
of the three.**
- **`staging` and `main` only ever fast-forward to a commit that is already on
`dev`.** They are pointers at tested history, not places work is done.
- **Pushes to `dev` and `staging` publish channel builds; a push to `main`
publishes an rc.** A person cuts stable by dispatching `Release` on `main`.
- **Every pull request carries a change entry** in `docs/changes/unreleased/`,
and what it carries is not what shipped but **what somebody now believes
wrongly**.
| [branching.md](branching.md) | Contribution branches and promotion pointers |
| [ci.md](ci.md) | Local checks, CI gates and failure diagnosis |
| [changelog.md](changelog.md) | Change fragments and release notes |
| [promotion.md](promotion.md) | Staging validation and releases |
| [Branch rules](../../.github/rulesets/README.md) | Intended server enforcement |

Branch from `dev` and target pull requests there. Stage only your changes.
Promotion pointers advance through tested history by fast-forward; never
force-push `dev`, `staging` or `main`. Include a change fragment in each pull
request and update the shipped manual when behavior changes.
104 changes: 20 additions & 84 deletions docs/rules/branching.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,22 @@
# Branches

## The map

```
feature/* ──pull request──▶ dev ══fast-forward══▶ staging ══fast-forward══▶ main
(trunk) (soak) (release)
│ │ │
dev build staging build rc / stable
```

`dev` is the trunk and the default branch. Every branch starts there and every
pull request goes back there. `staging` is a pointer at a commit of `dev` that
has been through the full check and is being used by people. `main` is the
release pointer at a commit already on `staging`. Pushes to the three pointers
publish dev, staging and rc builds respectively; a stable release is dispatched
on `main` by a person.

## Why promotion is a fast-forward and not a merge

**`staging` and `main` are never merged into. They are moved.**

```sh
git push origin <a-sha-already-on-dev>:staging
```

Three things follow from that, and all three are the reason:

- **There is no divergence to reason about, ever.** `main` is an ancestor of
`staging` is an ancestor of `dev`. Nobody has to ask which branch is the truth,
nobody cherry-picks a fix "back", and there is no back-merge to forget.
- **A promotion is a decision about one commit, not about a diff.** You use build
`abc123` for two days and then promote `abc123` — the exact bytes that were
tested. Merge-based promotion creates a merge commit that nobody has ever run.
- **The server can enforce it.** A fast-forward-only rule
(`.github/rulesets/promotion-pointers.json`) rejects any push that is not a
descendant. Unreviewed code cannot reach `staging` by accident, only by
somebody switching the rule off in front of witnesses.

The cost is that promoting is a deliberate act rather than a merge button, which
for a soak branch is the point. [promotion.md](promotion.md) is the runbook.

## `main` is the release branch

Since 2026-09-10, `main` is the last fast-forward pointer in the pipeline. Moving
it publishes a release candidate from exactly the commit that soaked on
`staging`; dispatching the `Release` workflow there cuts the stable version.

The old reason for parking it was sound: moving `main` would hand v2 to every v1
user. That is now an explicit release decision rather than an indefinite hold.
The stable dispatch makes that decision on purpose, while an rc on every `main`
push gives the same bytes a public trial first.

## Naming, and how long a branch lives

Short-lived, off `dev`, deleted when merged (GitHub does the deleting):

- `feat/<thing>` — new capability
- `fix/<thing>` — a repair
- `docs/<thing>` — prose only
- `chore/<thing>` — build, CI, tooling

The wave lanes this repository builds in — `git worktree add ~/af-<name> -b
<branch>` — follow the same rule: they are short, they merge, they go. **A branch
that has been open long enough to need rebasing twice is a branch that should
have been split.**

## What is forbidden

- **No work is pushed directly to `dev`, `staging` or `main`.** Everything into
`dev` arrives through a pull request; the only direct updates to `staging` and
`main` are the deliberate fast-forwards in the promotion runbook.
- **No force-push to any of the three.** Rolling back is marking an earlier
stable Latest or promoting a revert forward — never rewriting a branch other
people have pulled.
- **No long-lived integration branch besides `dev`.** Two trunks is two truths.
- **No `git add -A` and no `git add .`** — several sessions work this tree at
once and a wildcard sweeps another lane's in-flight files into your commit.
Stage your own explicit paths. This one predates the branch rules and is in
`CLAUDE.md` for the same reason.

## Merges are squashes

Squash is the only merge method the repository allows. A pull request becomes one
commit on `dev`, which is what makes a promotion SHA a thing a person can reason
about and a revert a single `git revert`.
## Contribution branches

`dev` is the default branch and integration trunk. Start short-lived `feat/`,
`fix/`, `docs/` or `chore/` branches from it and target pull requests at `dev`.
Pull requests merge by squash; delete branches after merging. Stage explicit
paths so unrelated edits do not enter a commit.

## Promotion branches

`staging` points to tested `dev` history. `main` points to a release commit
already on `staging`. Both move by fast-forward, never by merge commits.
Feature work reaches `dev` through pull requests; direct pushes and force-pushes
to these three branches are prohibited except for the deliberate promotion
fast-forwards described in [promotion.md](promotion.md).

Pushes to `dev` and `staging` publish their channel builds. A push to `main`
publishes a release candidate; dispatching `Release` on `main` cuts stable.
Release guards require staging history to be on dev and release history to be
on staging. Inspect `.github/rulesets/` and live branch protection when changing
these rules.
181 changes: 34 additions & 147 deletions docs/rules/changelog.md
Original file line number Diff line number Diff line change
@@ -1,165 +1,52 @@
# The changelog
# Change entries

## What it is for, which is not what a changelog is usually for
## One entry per pull request

**Every pull request into `dev` carries one file describing what it changed.**
Every pull request into `dev` carries a file in `docs/changes/unreleased/`.
Name it `<pr>-<slug>.md`, using the pull request number. Create and validate it:

The reader this is written for is a session opening this repository with a
fortnight-old memory of it — a memory formed from `CLAUDE.md`, a design doc, a
manual page, and whatever it saw the last time it was here. Most of that memory
is still right. The part that is not is expensive out of all proportion: it does
not read as a gap, it reads as knowledge, so it is acted on with confidence and
nobody notices until the push fails or the wrong page gets edited.

That has already happened here more than once. `CLAUDE.md` ordered finished work
pushed to `origin chat-v3-task` for days after that branch stopped existing.
`generate_image` was documented as impossible right up until the wave that
shipped it. In both cases the code was correct, the tests were green, and the
thing that was wrong was what somebody remembered.

**So the changelog's job is to carry the invalidations.** Not what shipped —
`git log` says that and says it better, and an entry that only restates the
commit subject is an entry that cost somebody a minute and earns nobody anything.
What shipped is the headline. What is no longer true is the payload.

## The shape
```sh
make changelog-new PR=82 KIND=changed SLUG=example-change
make changelog-check
```

```markdown
```yaml
---
kind: changed # added changed renamed fixed removed internal
title: dev is the trunk and staging is a tested promotion pointer
kind: changed
title: Describe the resulting behavior
pr: 82
surface: [build, docs] # optional: chat engine resident remote build docs
invalidates: # optional, and the reason the file exists
- "The trunk was `chat-v3-task`. It no longer exists on origin; branch off `dev`."
surface: [chat, docs]
invalidates: []
---

Optional paragraph on why, if the title does not already carry it.
```

Five fields, usually three. **A form long enough to resent is a form that gets a
placeholder typed into it**, and a changelog of placeholders is worse than none,
because it reads as though somebody checked.

`renamed` is its own kind rather than a flavour of `changed` because a name that
moved — a branch, a tool, a command, a flag, a file — is the single most common
way a stale memory turns into a wasted hour.

## Writing `invalidates`

This is the only field with a quality gate, and the gate is that **each line has
to be a claim somebody can check their own memory against.** Two sentences, or
one with a semicolon: what it was, and what it is now.

| No | Yes |
| --- | --- |
| `branch names` | `The trunk was `chat-v3-task`. It no longer exists on origin; work goes to `dev`.` |
| `changed the step default` | ``propose_task``'s step default was 40 in the schema and 200 in the executor. Both are 200.` |
| `image generation` | `The manual said codeaf cannot generate images. It can: `generate_image` is on the belt.` |

The tool refuses a line that does not end in a full stop or is under 25
characters, which catches labels. It cannot catch a full sentence that says
nothing, and neither can review — that one is on you.

**Leave the list empty when nothing anybody believed changed.** Most fixes are
like that, and an empty `invalidates` is an honest answer, not a skipped field.
Inventing one to look thorough is the failure mode that kills the field, because
a reader who opens two folds of noise stops opening them.

## The altitude of an entry, and who set it

**An entry is owed where work meets `dev`, and that is also what sets its
altitude — on purpose.** A wave built as five lanes into a `feat/*` branch lands
on `dev` as one pull request, so it owes one entry, written at the height of the
feature: "tasks are now cut from the repository the brief names", not five
retellings of the lanes. A standalone fix lands directly and its entry is
naturally technical. Nobody chooses an altitude; the shape of the landing chooses
it.

When the finer grain genuinely matters, lanes may each add an entry inside the
feature branch — the gate does not run there — and the pull request into `dev`
carries them all. The test is always the same question at any height: **whose
beliefs changed, and at what height were they held?** A reader believes "codeaf
cannot ground a task in another repository" at feature height; almost nobody
holds a belief at the height of one lane's diff.

## Who wrote the change is not a field

Whether a person or a model authored the work is already recorded, better, by the
commit trailers (`Co-Authored-By: Claude …`) — a second copy in frontmatter would
be the drift the one-source-of-truth law forbids, filled by hand beside a record
made by tooling. And it changes nothing for the reader: an entry's claim is true
or false regardless of who typed it. Count authorship when you want it:

```sh
git log --format='%(trailers:key=Co-Authored-By,valueonly)' | grep -c Claude
```

## When you owe one, and when you do not
Kinds are `added`, `changed`, `renamed`, `fixed`, `removed` and `internal`.
Optional surfaces are `chat`, `engine`, `resident`, `remote`, `build` and `docs`.
Keep the title under 100 characters without a trailing full stop. Explain any
necessary limits in a short body.

Every pull request into `dev`, enforced by the `check` job. The escape hatches,
in order of preference:
## Changed assumptions

1. **`kind: internal` with just a title.** Ten seconds, and it keeps the log
complete — a model *does* want to know that the task executor was refactored
under it, even with nothing to invalidate.
2. **The `no-changelog` label** on the pull request. For a typo in a comment.
Reach for it rarely; a repository where half the changes are unlogged has an
unreliable log, which is worse than an absent one.
Use `invalidates` for specific claims about behavior that changed. State what
used to be true and what is true now, in complete sentences ending with a full
stop and at least 25 characters long. Leave the list empty when no prior claim
needs correcting. Read recent entries before relying on earlier documentation.

There is no exemption for "it is only docs". A docs change is frequently the
*most* invalidating thing in a wave, because it moves what everybody reads.
For housekeeping with no user-facing change, a `kind: internal` entry with only
a title is sufficient. The `no-changelog` pull request label is available for
changes such as comment typos. Documentation changes normally still need entries.

## Why it is not generated
## Release notes

Three ways this could have been automated, and why none of them survives:

- **From `git log`.** It would produce `git log`. The one field worth having is
the one a diff cannot contain.
- **From the pull-request body at merge time, by a bot.** It would need to push
to `dev`, which `branching.md` forbids, and it would still be guessing at the
invalidations.
- **By a model reading the diff after the fact.** This is the tempting one, and
it is wrong in a specific way: the model reading the diff can see what changed
but not what anybody *previously believed*, which is the whole content. The
author knows they are contradicting a page, because they had to go and edit it.

What is automated is everything that does not need judgment: validation,
grouping, the two-layer rendering, the roll-up, and the release notes.

## One file per pull request, not one appended file

Several sessions work this tree at once. A shared `CHANGELOG.md` that every
branch appends to conflicts on every merge, and a step that reliably produces a
conflict is a step people reliably route around. Loose files never collide — the
name carries the pull-request number — and they are easier for a model to filter
than a parsed section of one long document.

## The commands

The entry is named for its pull request, and the number exists only once the
pull request does. **So the order is: push the branch, open the pull request, let
`check` go red once** — its failure message carries the exact command with the
number in it — **then add the entry and push again.** That first red is the
workflow, not a mistake; guessing the next number is how two sessions opening
pull requests in the same hour both guess wrong.
Roll up entries on a contribution branch and merge through a pull request to
`dev` before promotion:

```sh
make changelog-new PR=82 KIND=changed SLUG=branch-rules # scaffold
make changelog-check # validate; CI runs this
make changelog-preview VERSION=v0.2.0 # see the rendered section
make changelog VERSION=v0.2.0 # roll up, at release time
make changelog-preview VERSION=v0.2.0
make changelog VERSION=v0.2.0
```

`make changelog` writes the section into `CHANGELOG.md` and deletes the loose
entries it consumed. **It is run on a branch and lands through a pull request
into `dev` before the promotion** — never as a commit on `staging`, which would
break the fast-forward that `branching.md` depends on.
[`promotion.md`](promotion.md) has it in order.

The release workflow reads the section back out of `CHANGELOG.md` for the release
notes, so the roll-up is not paperwork after the fact: it is the release notes.
It reads them through `codeaf-changes notes <tag>`, because a GitHub release body
has a ceiling and a rolled-up section does not: when the whole section fits it is
the notes, and when it does not the page carries every headline line with the
folds left out and one line saying the record is in `CHANGELOG.md`.
The roll-up writes `CHANGELOG.md` and consumes the included fragments. Keep
release section headings intact: `codeaf-changes notes <tag>` reads them for
release notes. Oversized notes retain every headline and link to the changelog
for the full record. See [promotion.md](promotion.md).
Loading
Loading