diff --git a/AGENTS.md b/AGENTS.md
index 207bdd26..ee82ff3b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -292,8 +292,7 @@ git -C push -u origin shape/update- # then open a pull request
`upstream-removed`, `unmapped` or `copy-missing`; `apply` refuses the last
three. A file with no row is not a shape copy and is none of your business.
2. **Show the human the verdicts and let them say yes.** Re-pinning records
- which openRepoShape this project is a copy of. Do not pass `--yes` on your
- own initiative.
+ which openRepoShape this project is a copy of. Never pass `--yes` yourself.
3. **Never pass `--accept-local` to make a refusal go away.** It re-pins a
locally edited file FROM THE PROJECT'S OWN BYTES, which makes the drift
`validate-pins.py` reports today invisible. Whether the project keeps its
@@ -368,8 +367,7 @@ git -C push -u origin # then open a pull request
A FAMILY is a holder that pins other projects' ASSEMBLY ROOTS as submodules
under `members/`. It is not a project — no legs — and membership confers
-nothing. Use one when the parts DEPLOY SEPARATELY; one project is what parts
-that ship together already are.
+nothing. Use one when the parts DEPLOY SEPARATELY.
```sh
python3 scripts/family.py init --org --family --into .. [--reuse-empty-repo]
@@ -393,13 +391,15 @@ python3 scripts/family.py siblings --family-root
deliberate and reads like a mistake until somebody explains it.
2. **A family pins ASSEMBLY ROOTS, never legs.** `--member /`,
never `-spec`: a leg has no `project.yaml`, and is rightly refused.
+ A holder keeps `members/` in ONE spelling on every disk: `add` refuses
+ `family-members-dir-variant` (`member-path-taken` for a variant of the
+ member's own path) until a human renames what sits under another spelling.
3. **Each of `add`, `bump` and `remove` writes ONE commit** moving the
gitlink and the pin together, on a branch you make first (see above): the
tracking branch is refused (`family-on-tracking-branch`). Push the branch
as its `NEXT` line says and land it as a pull request; never suggest a
push to the default branch. Never hand-edit `family.yaml`'s `members:`
- block — the tool rewrites it wholesale. `bump` takes the same repeatable
- `--trailer ": "` as `update-shape.py apply`.
+ block — the tool rewrites it wholesale.
4. **The member must be scaffolded or adopted FIRST.** A family cannot make a
project out of a repository; it can only pin one that already is.
5. **`make siblings` in the holder — or `family.py siblings --family-root
diff --git a/README.md b/README.md
index f0761220..f87d91a8 100644
--- a/README.md
+++ b/README.md
@@ -883,10 +883,9 @@ is reviewed identically to one in this family.
| what the holder owns | the legs | nothing but pins and utilities |
**Use a family when the parts deploy separately.** Eight services that are
-eight APIs are eight projects: folding them into one assembly root would give
-them one gate, one release and one pin, which is the opposite of what "deploy
-separately" means. **Use one project when the parts ship together** — that is
-what the spec and code legs already are.
+eight APIs are eight projects: one assembly root would give them one gate, one
+release and one pin, the opposite of "deploy separately". **Use one project
+when the parts ship together**: that is what the spec and code legs are.
### The workstation layout: a plain folder, and two copies of every member
@@ -904,13 +903,13 @@ InkRouter/ a PLAIN FOLDER, not a git repository
```
`scripts/family.py init` lands the holder there: `--into ` is the PARENT
-directory (the directory you are standing in, by default), the folder
+directory (the directory you are standing in, by default; refused inside a
+checkout of this standard, `family-landing-in-standard`), the folder
`/` is created in it, and the holder is cloned INSIDE that at
-`//` — the same "lands where you were standing" rule
-projects have. Standing in the family folder already, it does not nest a
-second one. `--work-dir` remains the override that lands `/`
-and makes no folder. The doubled name is kept on purpose: the holder keeps its
-own repository name and `family.yaml` is what tells the two apart.
+`//`. Standing in the family folder already, it does not
+nest a second one. `--work-dir` is the override that lands
+`/` and makes no folder. The doubled name is on purpose: the
+holder keeps its own repository name, and `family.yaml` tells the two apart.
`make siblings` in the holder — or `python3 scripts/family.py siblings
--family-root `, the same file through the other entry point — clones
@@ -923,17 +922,17 @@ inside the holder is pinned and DETACHED, and is what `bootstrap` and
**fetched and nothing else** — no checkout, no reset, no pull — and one that
is a different repository is reported and skipped. It is idempotent.
-**Nothing ever moves a checkout.** When the holder's parent folder is not
-named after the family, `make siblings` WARNS and prints the exact `mv` for a
-human to run. A tool that relocated the directory would do it out from under
-the shell, the editor and every agent lane standing in it, and would break
-linked worktrees, whose `.git` files carry absolute paths — an act by
-surprise, which is the one thing every refusal here exists to avoid.
+**Nothing ever moves a checkout**: a linked worktree's `.git` file carries an
+absolute path. The holder's parent folder must be named EXACTLY after its
+repository, case included, on every disk. When it is not, and no link of that
+name beside it leads to it, `make siblings` WARNS, prints the exact `mv` for a
+human to run and clones nothing until it is run, so the next run clones each
+member once. For a holder straight in a folder (`~/projects/InkRouter`) the
+move makes the family folder `~/InkRouter`, beside `~/projects`, and the holder
+`~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`.
### The InkRouter example
-Eight services, each its own project, one holder:
-
```
InkRouter/ the family FOLDER (see the layout above)
InkRouter/ the family holder — family.yaml, members/, utilities
@@ -957,13 +956,14 @@ branch made first (the tracking branch is refused) and print its push: the
gitlink and `members[].pin` move together, the lockstep rule an assembly root
applies to its legs, which `scripts/validate-family.py` enforces. Each row
records the member's `project.yaml` `id`, so the validator can check
-`members/` holds the project the row claims, not merely a repository
-at the right commit. **A family pins assembly roots, never legs**:
-`-spec` is refused; a leg has no `project.yaml`.
-
-`make bootstrap` in the holder fetches every member and its legs — resolving
-the GitHub App or `SHAPE_LEGS_TOKEN` credential the workflow does, as a member
-is a private assembly root mounting two private legs — then runs each member's
+`members/` holds the project the row claims. **A family pins assembly
+roots, never legs**: `-spec` is refused; a leg has no `project.yaml`.
+A holder keeps `members/` in ONE spelling on every disk: `add` refuses
+`family-members-dir-variant` (`member-path-taken` for a variant of the member's
+own path) until a human renames what sits under another spelling.
+
+`make bootstrap` in the holder fetches every member and its legs, with the
+workflow's GitHub App or `SHAPE_LEGS_TOKEN` credential, then runs each member's
own `make bootstrap`; `make validate` runs the family validator, then each
member's. `make bootstrap` reports and skips one it lacks a credential for.
@@ -972,12 +972,11 @@ The holder carries the same COPY pin an assembly root does, so
instead of `project.yaml` and must leave `validate-family.py` green.
The name is the `family` form in `contracts/repository-naming.yaml`: one
-CamelCase token, exactly an assembly root's rule, with **precedence below**
-it, and DECLARED-ONLY. `InkRouter` is spelled like an assembly root and
+CamelCase token, exactly an assembly root's rule, with **precedence below** it,
+and DECLARED-ONLY. `InkRouter` is spelled like an assembly root and
`family.yaml` is the only thing that tells them apart, so the classifier
reports the holder form only when it is asked for (`--role family`). That is
-what let a fifth family be added to a policy two live projects already carry a
-copy of without changing any existing name's answer.
+what let a fifth family be added without changing any existing name's answer.
## The double pin, and the lockstep invariant
diff --git a/docs/handbook.html b/docs/handbook.html
index fadb789d..76b5ad83 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1325,7 +1325,7 @@ Adopting an existing repository
-
+
Reference
Families: a holder for projects that ship separately
@@ -1362,7 +1362,8 @@ The workstation layout: a plain folder, and two copies of every member
IRRS-worktrees/… whatever the member's own work needs
session-handoff-*.md the estate's own files live in the folder
scripts/family.py init lands the holder there: --into <dir>
- is the PARENT directory (the directory you are standing in, by default), the folder
+ is the PARENT directory (the directory you are standing in, by default; refused inside a
+ checkout of this standard, family-landing-in-standard), the folder
<Family>/ is created in it, and the holder is cloned INSIDE that at
<into>/<Family>/<Family> — the same “lands where you
were standing” rule projects have. Standing in the family folder already, it does not
@@ -1381,12 +1382,18 @@
The workstation layout: a plain folder, and two copies of every member
follows it. An existing clone is fetched and nothing else — no
checkout, no reset, no pull — and one that is a different repository is reported and
skipped. It is idempotent.
- Refuses Nothing ever moves a checkout: when
- the holder’s parent folder is not named after the family, make siblings
- WARNS and prints the exact mv for a human to run. A tool that relocated the
- directory would do it out from under the shell, the editor and every agent lane standing in
- it, and would break linked worktrees, whose .git files carry absolute paths
- — an act by surprise, which is the one thing every refusal here exists to avoid.
+ Refuses Nothing ever moves a checkout: the
+ holder’s parent folder must be named EXACTLY after its repository, case included, on
+ every disk. When it is not, and no link of that name beside it leads to it,
+ make siblings WARNS, prints the exact mv for a human to run and
+ clones nothing until it is run, so the next run clones each member once. For a holder
+ straight in a folder (~/projects/InkRouter) the move makes the family folder
+ ~/InkRouter, beside ~/projects, and the holder
+ ~/InkRouter/InkRouter, not ~/projects/InkRouter/InkRouter. A tool
+ that relocated the directory would do it out from under the shell, the editor and every
+ agent lane standing in it, and would break linked worktrees, whose .git files
+ carry absolute paths — an act by surprise, which is the one thing every refusal here
+ exists to avoid.
Ruled by Brett Heap on 2026-09-09 (issue #76): the plain
folder, and the holder inside it at <Family>/<Family>.
@@ -1414,6 +1421,10 @@ The InkRouter example
Refuses A family pins assembly roots, never legs:
adding <Project>-spec is refused, because a leg has no
project.yaml and belongs to its own root.
+ Refuses A holder keeps members/ in ONE spelling on
+ every disk: add refuses family-members-dir-variant
+ (member-path-taken for a variant of the member’s own path) until a human
+ renames what sits under another spelling.
make bootstrap in the holder fetches every member and its legs —
resolving the same GitHub App or SHAPE_LEGS_TOKEN credential the workflow does
— and then runs each member’s own make bootstrap.
diff --git a/templates/family-root/AGENTS-shape.md b/templates/family-root/AGENTS-shape.md
index a9e740e4..fbc984d9 100644
--- a/templates/family-root/AGENTS-shape.md
+++ b/templates/family-root/AGENTS-shape.md
@@ -40,7 +40,10 @@ commit as a pull request: each refuses this holder's tracking branch
(`family-on-tracking-branch`) and names the `git switch -c` that makes a
branch, as the block's first line does, and ends by printing that branch's
`git push`. The member must already be scaffolded or adopted, because a family
-cannot make a project out of a repository.
+cannot make a project out of a repository. A holder keeps `members/` in ONE
+spelling on every disk: `add` refuses `family-members-dir-variant`
+(`member-path-taken` for a variant of the member's own path) until a human
+renames what sits under another spelling.
`bump` takes a repeatable `--trailer ": "`, appended to the commit
it writes in the order given. It composes that message itself, so whatever
diff --git a/templates/family-root/README.md b/templates/family-root/README.md
index e0150475..9bfa6aa9 100644
--- a/templates/family-root/README.md
+++ b/templates/family-root/README.md
@@ -127,7 +127,10 @@ python3 scripts/family.py remove --family-root --member
Each writes ONE commit, with explicit pathspecs, moving the gitlink and the
pin together, on the branch the holder stands on: it refuses the tracking
branch, so make a branch first, as the block's first line does. Push it as its
-`NEXT` line says and land it as a pull request.
+`NEXT` line says and land it as a pull request. A holder keeps `members/` in
+ONE spelling on every disk: `add` refuses `family-members-dir-variant`
+(`member-path-taken` for a variant of the member's own path) until a human
+renames what sits under another spelling.
## Private members in CI
diff --git a/tests/test_family_docs_one_spelling.py b/tests/test_family_docs_one_spelling.py
new file mode 100644
index 00000000..eced3460
--- /dev/null
+++ b/tests/test_family_docs_one_spelling.py
@@ -0,0 +1,127 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The family docs say what `init`, `make siblings` and `members/` do (#695).
+
+Three sentences had fallen behind the tools, and one rule had no sentence:
+
+- `init` lands where you stand, and refuses that default inside a checkout of
+ this standard (`family-landing-in-standard`, #488). README and the
+ handbook gave the default and not the refusal.
+- `make siblings` stops before it clones anything, and a family folder
+ reached through a link of the family's name passes (#578). README and the
+ handbook still said it WARNS and prints the `mv`. They now also say the
+ folder is named EXACTLY after the repository on every disk, and where the
+ printed move puts the family folder for a holder straight in a folder.
+- A holder keeps `members/` in ONE spelling on every disk, and `add`
+ refuses `family-members-dir-variant` (`member-path-taken` for a variant of
+ the member's own path) until a human renames the entries (#574). No doc
+ said so.
+
+THE SAME WORDS IN FIVE PLACES. The one-spelling sentence is the same text in
+AGENTS.md, README.md, the handbook, the holder's README and the holder's
+pinned `AGENTS-shape.md`, so a reader meets one rule however they came. The
+handbook spells it in HTML, so it is read as the text a browser shows, with
+`` as backticks.
+
+The printed move itself is pinned by `test_family_siblings_placement.py`
+(`~/projects/InkRouter` becomes `~/InkRouter/InkRouter`): this file holds the
+sentences that describe it.
+"""
+
+from __future__ import annotations
+
+import ast
+import html
+import re
+
+import pytest
+
+from conftest import REPO
+
+ONE_SPELLING = (
+ "A holder keeps `members/` in ONE spelling on every disk: `add` refuses "
+ "`family-members-dir-variant` (`member-path-taken` for a variant of the "
+ "member's own path) until a human renames what sits under another "
+ "spelling.")
+
+INTO_REFUSED = (
+ "(the directory you are standing in, by default; refused inside a "
+ "checkout of this standard, `family-landing-in-standard`)")
+
+STOPS_BEFORE_CLONING = (
+ "When it is not, and no link of that name beside it leads to it, "
+ "`make siblings` WARNS, prints the exact `mv` for a human to run and "
+ "clones nothing until it is run, so the next run clones each member "
+ "once.")
+
+NAMED_EXACTLY = (
+ "named EXACTLY after its repository, case included, on every disk")
+
+STRAIGHT_IN_A_FOLDER = (
+ "For a holder straight in a folder (`~/projects/InkRouter`) the move "
+ "makes the family folder `~/InkRouter`, beside `~/projects`, and the "
+ "holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`.")
+
+#: What README and the handbook said before #578, which `make siblings` no
+#: longer does.
+BEFORE_578 = "`make siblings` WARNS and prints the exact `mv`"
+
+HANDBOOK = "docs/handbook.html"
+README = "README.md"
+THE_FIVE = [
+ "AGENTS.md", README, HANDBOOK, "templates/family-root/README.md",
+ "templates/family-root/AGENTS-shape.md"]
+README_AND_HANDBOOK = [README, HANDBOOK]
+
+
+def shown(path: str) -> str:
+ """The text a reader sees in `path`, on one line.
+
+ A page is read as a browser shows it: `` is a backtick, any other
+ tag is nothing, entities are their characters and a curly apostrophe is
+ a straight one. Every other file is its own text. Runs of white space,
+ and so every wrap, are one space."""
+ text = (REPO / path).read_text(encoding="utf-8")
+ if path == HANDBOOK:
+ text = re.sub(r"?code>", "`", text)
+ text = html.unescape(re.sub(r"<[^>]+>", "", text))
+ text = text.replace("’", "'")
+ return " ".join(text.split())
+
+
+@pytest.mark.parametrize("path", THE_FIVE)
+def test_each_doc_names_the_one_spelling_rule_in_the_same_words(path):
+ assert ONE_SPELLING in shown(path)
+
+
+@pytest.mark.parametrize("code", ["family-members-dir-variant",
+ "member-path-taken"])
+def test_the_codes_the_rule_names_are_codes_family_py_prints(code):
+ source = (REPO / "scripts" / "family.py").read_text(encoding="utf-8")
+ held = {node.value for node in ast.walk(ast.parse(source))
+ if isinstance(node, ast.Constant) and isinstance(node.value, str)}
+ assert code in held
+
+
+@pytest.mark.parametrize("path", README_AND_HANDBOOK)
+def test_the_into_sentence_gives_the_refusal_with_the_default(path):
+ assert INTO_REFUSED in shown(path)
+
+
+@pytest.mark.parametrize("path", README_AND_HANDBOOK)
+def test_make_siblings_is_described_as_it_stops_before_cloning(path):
+ assert STOPS_BEFORE_CLONING in shown(path)
+
+
+@pytest.mark.parametrize("path", README_AND_HANDBOOK)
+def test_the_stale_warns_and_prints_sentence_is_gone(path):
+ assert BEFORE_578 not in shown(path)
+
+
+@pytest.mark.parametrize("path", README_AND_HANDBOOK)
+def test_the_folder_is_named_exactly_after_the_repository(path):
+ assert NAMED_EXACTLY in shown(path)
+
+
+@pytest.mark.parametrize("path", README_AND_HANDBOOK)
+def test_the_move_for_a_holder_straight_in_a_folder_is_one_level_up(path):
+ assert STRAIGHT_IN_A_FOLDER in shown(path)