From 73fa3ff4ee605a6634d17efb3342a5fa547d3416 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 17:33:37 +0000 Subject: [PATCH 1/2] README.md, the handbook, AGENTS.md and the family-root docs: `init`'s `--into` sentence names the `family-landing-in-standard` refusal, `make siblings` is described as it stops before cloning, and `members/` has its one-spelling rule, so three sentences that fell behind #488, #578 and #574 are true of main (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md and docs/handbook.html § Families: - The `--into` sentence says the default (the directory you are standing in) is refused inside a checkout of this standard, and names the code. - The `make siblings` paragraph says what it does since #578: the holder's parent folder must be named EXACTLY after its repository, case included, on every disk; where it is not, and no link of that name beside it leads to it, it WARNS, prints the exact `mv` and clones nothing until it is run, so the next run clones each member once. Lane 3's two #460 sentences (L20, L21) ride in it: a holder at `~/projects/InkRouter` is moved to `~/InkRouter/InkRouter`, beside that folder, not inside it. The one-spelling rule, one sentence in the same words in AGENTS.md (family item 2), README.md § Families, the handbook, the holder's README and the holder's pinned AGENTS-shape.md: 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 what sits under another spelling. Neither cap is raised. README.md stays at its cap of 1459 lines and AGENTS.md at 492, by cutting wording that is stated elsewhere: README § Families lost the "same rule projects have" aside, the tool's rationale after "Nothing ever moves a checkout" (the handbook, the holder's README and the tool's own output keep it), "not merely a repository at the right commit", the private-legs reason in the `make bootstrap` paragraph and the policy history in the naming paragraph; AGENTS.md lost the converse in the family intro, the `bump --trailer` sentence (stated in "Advancing a leg" and in AGENTS-shape.md) and "on your own initiative" in "Updating" step 2. The handbook marker for README § Families is recomputed. templates/family-root/AGENTS-shape.md is pinned: every holder re-pins it (`upstream-changed`). tests/test_family_docs_one_spelling.py holds the sentences to the five places and the codes to family.py. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 12 +-- README.md | 54 +++++------ docs/handbook.html | 23 +++-- templates/family-root/AGENTS-shape.md | 5 +- templates/family-root/README.md | 5 +- tests/test_family_docs_one_spelling.py | 126 +++++++++++++++++++++++++ 6 files changed, 183 insertions(+), 42 deletions(-) create mode 100644 tests/test_family_docs_one_spelling.py diff --git a/AGENTS.md b/AGENTS.md index 07f60556..48bb220d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -290,8 +290,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 @@ -366,8 +365,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] @@ -391,13 +389,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 2dc61e44..80c14dde 100644 --- a/README.md +++ b/README.md @@ -870,10 +870,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 @@ -891,13 +890,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 @@ -910,12 +909,13 @@ 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 `~/InkRouter/InkRouter`, beside that folder, not inside it. ### The InkRouter example @@ -944,13 +944,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. A member it cannot fetch is reported and skipped, never a failure. @@ -959,12 +960,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 02c5188e..61cbeffa 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1282,7 +1282,7 @@

Adopting an existing repository

- +

Reference

Families: a holder for projects that ship separately

@@ -1319,7 +1319,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 @@ -1338,11 +1339,15 @@

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 +

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 + ~/InkRouter/InkRouter, beside that folder, not inside it. 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>.

@@ -1371,6 +1376,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..cd35e49b --- /dev/null +++ b/tests/test_family_docs_one_spelling.py @@ -0,0 +1,126 @@ +# 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 `~/InkRouter/InkRouter`, beside that folder, not inside it.") + +#: 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"", "`", 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) From c42286de2b602611fa8e514b804ac334bb3b5ff7 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 18:53:32 +0000 Subject: [PATCH 2/2] README.md and the handbook: for a holder straight in a folder the move makes "the family folder `~/InkRouter`, beside `~/projects`, and the holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`", so "beside that folder" no longer reads as the holder itself (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit N1 on 73fa3ff4. What sits beside `~/projects` is the family folder `~/InkRouter`; the holder is one level inside it. The sentence now says so in README § Families and the handbook, and STRAIGHT_IN_A_FOLDER in tests/test_family_docs_one_spelling.py holds the new words. The README stays under its cap (1458 of 1459): the one added line is paid for by cutting the caption "Eight services, each its own project, one holder:" under "The InkRouter example", which repeats the section's intro and its block. The handbook marker for README § Families is recomputed. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- README.md | 5 ++--- docs/handbook.html | 14 ++++++++------ tests/test_family_docs_one_spelling.py | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 80c14dde..1a59c9fb 100644 --- a/README.md +++ b/README.md @@ -915,12 +915,11 @@ 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 `~/InkRouter/InkRouter`, beside that folder, not inside it. +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 diff --git a/docs/handbook.html b/docs/handbook.html index 61cbeffa..cabc7a05 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1282,7 +1282,7 @@

Adopting an existing repository

- +

Reference

Families: a holder for projects that ship separately

@@ -1344,11 +1344,13 @@

The workstation layout: a plain folder, and two copies of every member

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 - ~/InkRouter/InkRouter, beside that folder, not inside it. 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.

+ 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>.

diff --git a/tests/test_family_docs_one_spelling.py b/tests/test_family_docs_one_spelling.py index cd35e49b..eced3460 100644 --- a/tests/test_family_docs_one_spelling.py +++ b/tests/test_family_docs_one_spelling.py @@ -58,7 +58,8 @@ STRAIGHT_IN_A_FOLDER = ( "For a holder straight in a folder (`~/projects/InkRouter`) the move " - "makes `~/InkRouter/InkRouter`, beside that folder, not inside it.") + "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.