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
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,7 @@ git -C <path> push -u origin shape/update-<sha> # 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
Expand Down Expand Up @@ -368,8 +367,7 @@ git -C <path> push -u origin <branch> # 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 <org> --family <Name> --into .. [--reuse-empty-repo]
Expand All @@ -393,13 +391,15 @@ python3 scripts/family.py siblings --family-root <path>
deliberate and reads like a mistake until somebody explains it.
2. **A family pins ASSEMBLY ROOTS, never legs.** `--member <org>/<Project>`,
never `<Project>-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 "<Key>: <value>"` 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
Expand Down
57 changes: 28 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -904,13 +903,13 @@ InkRouter/ a PLAIN FOLDER, not a git repository
```

`scripts/family.py init` lands the holder there: `--into <dir>` 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
`<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 nest a
second one. `--work-dir` remains the override that lands `<work-dir>/<Family>`
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.
`<into>/<Family>/<Family>`. Standing in the family folder already, it does not
nest a second one. `--work-dir` is the override that lands
`<work-dir>/<Family>` 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 <path>`, the same file through the other entry point — clones
Expand All @@ -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
Expand All @@ -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/<Project>` holds the project the row claims, not merely a repository
at the right commit. **A family pins assembly roots, never legs**:
`<Project>-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/<Project>` holds the project the row claims. **A family pins assembly
roots, never legs**: `<Project>-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.

Expand All @@ -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

Expand Down
27 changes: 19 additions & 8 deletions docs/handbook.html
Original file line number Diff line number Diff line change
Expand Up @@ -1325,7 +1325,7 @@ <h2>Adopting an existing repository</h2>

<section id="families">

<!-- reads README.md § Families: a holder for projects that ship separately: blob b3d19092bb6ee8f0e5809133efda8c7d1e870dc7 -->
<!-- reads README.md § Families: a holder for projects that ship separately: blob 82d7f8087b9ee4d097d33e15fbfe5ae754a04a79 -->

<p class="eyebrow">Reference</p>
<h2>Families: a holder for projects that ship separately</h2>
Expand Down Expand Up @@ -1362,7 +1362,8 @@ <h3>The workstation layout: a plain folder, and two copies of every member</h3>
IRRS-worktrees/… whatever the member's own work needs
session-handoff-*.md the estate's own files live in the folder</code></pre></div>
<p><code>scripts/family.py init</code> lands the holder there: <code>--into &lt;dir&gt;</code>
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, <code>family-landing-in-standard</code>), the folder
<code>&lt;Family&gt;/</code> is created in it, and the holder is cloned INSIDE that at
<code>&lt;into&gt;/&lt;Family&gt;/&lt;Family&gt;</code> &mdash; the same &ldquo;lands where you
were standing&rdquo; rule projects have. Standing in the family folder already, it does not
Expand All @@ -1381,12 +1382,18 @@ <h3>The workstation layout: a plain folder, and two copies of every member</h3>
follows it. An existing clone is <strong>fetched and nothing else</strong> &mdash; no
checkout, no reset, no pull &mdash; and one that is a different repository is reported and
skipped. It is idempotent.</p>
<p><span class="refuses">Refuses</span> <strong>Nothing ever moves a checkout</strong>: when
the holder&rsquo;s parent folder is not named after the family, <code>make siblings</code>
WARNS and prints the exact <code>mv</code> 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 <code>.git</code> files carry absolute paths
&mdash; an act by surprise, which is the one thing every refusal here exists to avoid.</p>
<p><span class="refuses">Refuses</span> <strong>Nothing ever moves a checkout</strong>: the
holder&rsquo;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,
<code>make siblings</code> WARNS, prints the exact <code>mv</code> 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 (<code>~/projects/InkRouter</code>) the move makes the family folder
<code>~/InkRouter</code>, beside <code>~/projects</code>, and the holder
<code>~/InkRouter/InkRouter</code>, not <code>~/projects/InkRouter/InkRouter</code>. 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 <code>.git</code> files
carry absolute paths &mdash; an act by surprise, which is the one thing every refusal here
exists to avoid.</p>
<p class="aside">Ruled by <strong>Brett Heap on 2026-09-09</strong> (issue #76): the plain
folder, and the holder inside it at <code>&lt;Family&gt;/&lt;Family&gt;</code>.</p>

Expand Down Expand Up @@ -1414,6 +1421,10 @@ <h3>The InkRouter example</h3>
<p><span class="refuses">Refuses</span> <strong>A family pins assembly roots, never legs</strong>:
adding <code>&lt;Project&gt;-spec</code> is refused, because a leg has no
<code>project.yaml</code> and belongs to its own root.</p>
<p><span class="refuses">Refuses</span> A holder keeps <code>members/</code> in ONE spelling on
every disk: <code>add</code> refuses <code>family-members-dir-variant</code>
(<code>member-path-taken</code> for a variant of the member&rsquo;s own path) until a human
renames what sits under another spelling.</p>
<p><code>make bootstrap</code> in the holder fetches every member and its legs &mdash;
resolving the same GitHub App or <code>SHAPE_LEGS_TOKEN</code> credential the workflow does
&mdash; and then runs each member&rsquo;s own <code>make bootstrap</code>.
Expand Down
5 changes: 4 additions & 1 deletion templates/family-root/AGENTS-shape.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<Key>: <value>"`, appended to the commit
it writes in the order given. It composes that message itself, so whatever
Expand Down
5 changes: 4 additions & 1 deletion templates/family-root/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ python3 scripts/family.py remove --family-root <path> --member <Project>
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

Expand Down
127 changes: 127 additions & 0 deletions tests/test_family_docs_one_spelling.py
Original file line number Diff line number Diff line change
@@ -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
`<code>` 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: `<code>` 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)
Loading