diff --git a/README.md b/README.md
index f3b378dc..f6171a4c 100644
--- a/README.md
+++ b/README.md
@@ -527,7 +527,8 @@ the second it refuses, creating nothing, if anything stands at ` SetupThe one thing to read before electing it
Start here
@@ -972,7 +972,8 @@ What
it refuses, creating nothing, if anything stands at setup.sh actually runs<into>/Atlas:
git clone --depth 1 https://github.com/opensoft/openRepoShape.git <tmp-dir>
python3 scripts/validate-repository-naming.py --explain Atlas Atlas-spec Atlas-code
-python3 scaffold-project.py --org <your-org> --project Atlas --dry-run
+python3 scaffold-project.py --org <your-org> --project Atlas \
+ --visibility private --elected-by 'Your Name' --dry-run
python3 scaffold-project.py --org <your-org> --project Atlas \
--visibility private --elected-by 'Your Name' # or public / internal
mkdir -p <into>/<Family> # --family only, and skipped when <into> IS <Family>
@@ -1021,7 +1022,7 @@ What you have afterwards — the same on every platform
-
+
Setup
If it fails part-way
@@ -1056,7 +1057,7 @@ If it fails part-way
-
+
@@ -1114,7 +1115,7 @@ Reading private legs in CI
-
+
@@ -1350,7 +1351,7 @@ The InkRouter example
-
+
@@ -1450,7 +1451,7 @@ Bootstrap is COPIED into the project, not fetched
-
+
Reference
Keeping a project’s shape current
@@ -1493,8 +1494,12 @@ Keeping a project’s shape current
something to do. apply writes NONE of them unless --add <path>
names one, per file, and names each file it adds in the line-ending block of an adopted
root’s own .gitattributes when that file carries #471’s block;
- --add refuses a path that is already pinned, one the target’s lists do
- not name, and one the root ALREADY has a file at.
+ --add refuses a path that is already pinned
+ (update-add-already-pinned), one the target’s lists do not name
+ (update-add-not-upstream), one with a symlink at or above it, which is never
+ written through (update-add-symlink), one with a file where a directory above
+ it goes (update-add-not-a-directory), and one the root ALREADY has a file at
+ (update-add-would-overwrite).
--upstream takes a path to a clone (offline) or an owner/repo it
bare-clones itself; it defaults to the repository the pin names. make bootstrap
@@ -1508,7 +1513,7 @@
Keeping a project’s shape current
-
+
Reference
Is this repository compliant? openRepoShape --doctor
@@ -1642,7 +1647,7 @@ Is this repository compliant? openRepoShape --doctor
-
+
diff --git a/tests/readme-cap.d/550.txt b/tests/readme-cap.d/550.txt
new file mode 100644
index 00000000..57cebe13
--- /dev/null
+++ b/tests/readme-cap.d/550.txt
@@ -0,0 +1,15 @@
++5
+2026-10-14
+Five lines for two statements the README had wrong (#550, #429).
+One: the by-hand setup commands under "What setup.sh does" dry-ran the scaffold
+without the --visibility and --elected-by that setup-project.py's scaffold_args
+always passes, so the plan a reader ran could name a different elector than the
+real run wrote. The dry run now carries both, which takes a second line, as the
+real run's does (+1).
+Two: the refusals `update-shape.py apply --add` gives were listed as three, in
+prose, and the tool gives five, each with a code of its own. A reader who met
+`update-add-symlink` or `update-add-not-a-directory` found no sentence for it,
+and the list read as complete. The sentence now names all five with what each
+refuses and the code to search for, and it cannot be shorter than that: the five
+codes, in backticks and parentheses, are 138 characters on their own, and the
+two refusals it lacked add about as many again (+4).
diff --git a/tests/test_setup_by_hand_commands.py b/tests/test_setup_by_hand_commands.py
new file mode 100644
index 00000000..ec5e42fb
--- /dev/null
+++ b/tests/test_setup_by_hand_commands.py
@@ -0,0 +1,159 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The by-hand scaffold commands the documents print are the ones setup runs (#550).
+
+README.md's "What setup.sh does" and the handbook's "What `setup.sh` actually
+runs" list "the commands that flow runs", and a reader who runs them by hand
+trusts that they are. Two of them are `scaffold-project.py`: the plan, which
+`setup-project.py` runs with `--dry-run`, and the real run. Both get the same
+arguments from `scaffold_args`, which ALWAYS passes `--visibility` and
+`--elected-by`; the documents showed the plan without them, so the plan a
+reader ran could name a different elector than the real run wrote (#550).
+Nothing tied the printed commands to the flow.
+
+This is the guard. It does not read the source for the flags: it RUNS
+`plan_and_confirm` and `scaffold` in process with `run` replaced by a recorder,
+so what it compares is the argument list those two functions hand to the
+scaffold, in the order they build it, `--dry-run` last on the plan. The
+documents are held to that, after the two things a printed command does that a
+list does not: a backslash continues a line, and `# ...` is a comment.
+
+The values are the documents' own: the project `Atlas`, the visibility
+`private`, the elector `Your Name`, and the organisation the documents write as
+the placeholder ``, which is stood in for by `ORG` on both sides. A
+flag `setup-project.py` passes only when it is given (`--id`, `--name`,
+`--local-remote-dir`) is in neither: the printed lines show the default run, and
+so do the recorded ones.
+
+Nothing here starts a process, touches git or GitHub, or reads a terminal, so
+it holds on every platform; the printed commands are POSIX and are rendered
+with `shlex.quote`, which is what they are written in.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+import shlex
+import subprocess
+from pathlib import Path
+
+from conftest import REPO
+
+from test_setup_project_py import entry_point_module
+
+#: The organisation the documents write as a placeholder, and what stands in
+#: for it on both sides of the comparison.
+PLACEHOLDER = ""
+ORG = "ORG"
+
+#: Where each document's list of commands starts. The README's is the first
+#: fenced block under its heading; the handbook's is the first `` after
+#: its subheading.
+README_BLOCK = re.compile(
+ r"^### What setup\.sh does$.*?^```sh\n(.*?)^```$", re.S | re.M)
+HANDBOOK_BLOCK = re.compile(
+ r"What setup\.sh actually runs.*?"
+ r"(.*?)
", re.S)
+
+SCAFFOLD_COMMAND = "python3 scaffold-project.py"
+
+
+def scaffold_lines(block: str) -> list[str]:
+ """The `scaffold-project.py` commands in a printed block, each as one line
+ with its continuations joined, its comment dropped and its whitespace
+ collapsed, and the organisation placeholder replaced by `ORG`."""
+ lines = []
+ for line in block.replace("\\\n", " ").splitlines():
+ line = " ".join(re.sub(r"\s+#.*$", "", line).split())
+ if line.startswith(SCAFFOLD_COMMAND):
+ lines.append(line.replace(PLACEHOLDER, ORG))
+ return lines
+
+
+def what_setup_runs(monkeypatch, tmp_path: Path) -> list[str]:
+ """The two `scaffold-project.py` commands `setup-project.py` runs for the
+ documents' own values, as `python3 scaffold-project.py ` lines:
+ the plan first, then the real run."""
+ module = entry_point_module()
+ ran: list[list[str]] = []
+
+ def record(argv, *args, **kwargs):
+ ran.append([str(part) for part in argv])
+ return subprocess.CompletedProcess(args=argv, returncode=0,
+ stdout="", stderr="")
+
+ monkeypatch.setattr(module, "run", record)
+ opts = module.Options()
+ opts.project = "Atlas"
+ opts.visibility = "private"
+ opts.elected_by = "Your Name"
+ opts.assume_yes = True
+ args = module.scaffold_args(opts, ORG)
+ module.plan_and_confirm(opts, REPO, ORG, args, str(tmp_path / "Atlas"))
+ module.scaffold(REPO, args)
+ assert len(ran) == 2, ran
+ lines = []
+ for argv in ran:
+ assert Path(argv[1]).name == "scaffold-project.py", argv
+ lines.append(" ".join([SCAFFOLD_COMMAND,
+ *(shlex.quote(part) for part in argv[2:])]))
+ return lines
+
+
+def assert_the_same(name: str, printed: list[str], ran: list[str]) -> None:
+ """The document's `scaffold-project.py` lines are the ones setup runs, in
+ order, and the failure shows both side by side."""
+ assert printed == ran, (
+ f"{name} prints these `scaffold-project.py` commands as the ones "
+ "setup runs (the plan, then the real run):\n "
+ + "\n ".join(printed) + "\nsetup-project.py runs these:\n "
+ + "\n ".join(ran)
+ + "\nThe plan and the real run take the same arguments from "
+ "`scaffold_args`, `--visibility` and `--elected-by` included (#550).")
+
+
+def test_the_readme_prints_the_commands_setup_runs(monkeypatch, tmp_path):
+ text = (REPO / "README.md").read_bytes().decode("utf-8")
+ found = README_BLOCK.search(text)
+ assert found, "README.md has no fenced block under `### What setup.sh does`"
+ assert_the_same(
+ "README.md", scaffold_lines(found.group(1)),
+ what_setup_runs(monkeypatch, tmp_path))
+
+
+def test_the_handbook_prints_the_commands_setup_runs(monkeypatch, tmp_path):
+ text = (REPO / "docs" / "handbook.html").read_bytes().decode("utf-8")
+ found = HANDBOOK_BLOCK.search(text)
+ assert found, ("docs/handbook.html has no code block under `What "
+ "setup.sh actually runs`")
+ assert_the_same(
+ "docs/handbook.html", scaffold_lines(html.unescape(found.group(1))),
+ what_setup_runs(monkeypatch, tmp_path))
+
+
+def test_setup_runs_the_plan_and_the_real_run_with_the_same_arguments(
+ monkeypatch, tmp_path):
+ plan, real = what_setup_runs(monkeypatch, tmp_path)
+ assert plan == real + " --dry-run"
+ assert "--visibility private" in real and "--elected-by 'Your Name'" in real
+
+
+def test_a_printed_command_is_read_as_one_line_without_its_comment():
+ block = ("git clone x\n"
+ "python3 scaffold-project.py --org --project Atlas \\\n"
+ " --visibility private --elected-by 'Your Name' # or public\n"
+ "mkdir -p y # python3 scaffold-project.py is not here\n")
+ assert scaffold_lines(block) == [
+ "python3 scaffold-project.py --org ORG --project Atlas "
+ "--visibility private --elected-by 'Your Name'"]
+
+
+def test_a_plan_printed_without_the_flags_the_real_run_has_is_a_different_line():
+ printed = scaffold_lines(
+ "python3 scaffold-project.py --org --project Atlas "
+ "--dry-run\n")
+ assert printed == ["python3 scaffold-project.py --org ORG --project Atlas "
+ "--dry-run"]
+ assert printed[0] != ("python3 scaffold-project.py --org ORG --project "
+ "Atlas --visibility private --elected-by 'Your Name'"
+ " --dry-run")
diff --git a/tests/test_update_add_codes_documented.py b/tests/test_update_add_codes_documented.py
new file mode 100644
index 00000000..bef8fd43
--- /dev/null
+++ b/tests/test_update_add_codes_documented.py
@@ -0,0 +1,149 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The `update-add-*` codes the documents name are the ones `apply --add` gives (#429).
+
+`update-shape.py apply --add ` refuses in five ways, each with a code of
+its own that a person meets on stderr as `REFUSED update-add-...`:
+`update-add-already-pinned`, `update-add-not-upstream`, `update-add-symlink`
+(#239), `update-add-not-a-directory` (#285) and `update-add-would-overwrite`.
+README.md and `docs/handbook.html` list them in one sentence each, and that
+sentence named three: #269 and #346 (both landed 2026-10-12) each added a
+refusal and left both documents alone, so a reader who met `update-add-symlink`
+or `update-add-not-a-directory` found no sentence for it, and the list read as
+complete (#429). Nothing tied the sentence to the tool.
+
+This is the guard, in both directions, and it is the kind this repository
+already keeps for AGENTS.md's codes (`tests/test_agents_md_codes.py`):
+
+- every `update-add-*` code the tool raises is named in README.md, in
+ backticks, and in `docs/handbook.html`, in ``: a refusal added
+ tomorrow fails here until both documents say so;
+- every `update-add-*` token either document spells is a code the tool raises:
+ a misspelt or renamed one fails here, naming the token and the document.
+
+WHICH CODES THE TOOL RAISES. A string `update-shape.py` could print whole,
+which is `tests/test_agents_md_codes.py::printed_strings` (found with `ast`,
+never a grep, so a comment cannot satisfy it; a docstring and the pieces of an
+f-string are left out), kept when its whole text is `update-add-` followed by
+lowercase words. Nothing runs: no process, no git, no network, the same on
+every platform. `FLOOR` is the five the tool raises today, so a scan that
+stopped finding them (a rename, a code built from pieces) fails by name here
+and does not turn into a test that checks nothing.
+
+WHICH TOKENS A DOCUMENT SPELLS. Any `update-add-` followed by lowercase words
+joined by hyphens, anywhere in its text. A new document naming a code is held
+to the tool without anybody adding it to a list here.
+"""
+
+from __future__ import annotations
+
+import re
+
+from conftest import REPO
+
+from test_agents_md_codes import printed_strings
+
+#: What an `update-add-*` code looks like: the prefix, then lowercase words
+#: joined by hyphens. `fullmatch`ed against a constant, `findall`ed in a
+#: document.
+CODE = r"update-add-[a-z]+(?:-[a-z]+)*"
+
+#: The codes the tool raises today. The scan must find at least these.
+FLOOR = frozenset({
+ "update-add-already-pinned",
+ "update-add-not-upstream",
+ "update-add-symlink",
+ "update-add-not-a-directory",
+ "update-add-would-overwrite",
+})
+
+#: The documents that list the refusals, each with how it spells a code: a
+#: backticked span in the README, a `` element in the handbook.
+DOCUMENTS = {
+ "README.md": "`{}`",
+ "docs/handbook.html": "{}",
+}
+
+
+def raised_codes() -> frozenset[str]:
+ """The `update-add-*` codes `update-shape.py` holds as whole constants."""
+ return frozenset(
+ text for text in printed_strings(
+ (REPO / "update-shape.py").read_bytes())
+ if re.fullmatch(CODE, text))
+
+
+def document_text(name: str) -> str:
+ """A document's text, read as bytes and decoded as UTF-8, so a Windows
+ checkout's locale cannot change what is read."""
+ return (REPO / name).read_bytes().decode("utf-8")
+
+
+def unnamed(raised: frozenset[str], documents: dict[str, str]) -> list[str]:
+ """One line per code in `raised` that a document does not spell its way."""
+ return [f"{name} does not name {code} (as {DOCUMENTS[name].format(code)})"
+ for name, text in sorted(documents.items())
+ for code in sorted(raised)
+ if DOCUMENTS[name].format(code) not in text]
+
+
+def not_raised(raised: frozenset[str], documents: dict[str, str]) -> list[str]:
+ """One line per `update-add-*` token a document spells that is no code in
+ `raised`."""
+ return [f"{name} spells {token}, which update-shape.py does not raise"
+ for name, text in sorted(documents.items())
+ for token in sorted(set(re.findall(CODE, text)) - raised)]
+
+
+def test_the_scan_finds_the_codes_the_tool_raises():
+ missing = sorted(FLOOR - raised_codes())
+ assert not missing, (
+ f"update-shape.py no longer holds {missing} as a whole string "
+ "constant. If a refusal was renamed, rename it in README.md, in "
+ "docs/handbook.html and in FLOOR here; a code built from pieces "
+ "cannot be found, and a refusal nobody can search for is the gap "
+ "this test exists to close (#429)")
+
+
+def test_every_add_refusal_code_the_tool_raises_is_named_in_both_documents():
+ documents = {name: document_text(name) for name in DOCUMENTS}
+ problems = unnamed(raised_codes(), documents)
+ assert not problems, (
+ "\n".join(problems) + "\n`apply --add` gives each of these refusals; "
+ "README.md's and the handbook's sentence about them must name every "
+ "one with what it refuses, so a reader who meets one finds it (#429)")
+
+
+def test_every_add_refusal_code_a_document_spells_is_one_the_tool_raises():
+ documents = {name: document_text(name) for name in DOCUMENTS}
+ problems = not_raised(raised_codes(), documents)
+ assert not problems, "\n".join(problems)
+
+
+def test_a_code_the_tool_raises_and_a_document_omits_is_named_with_its_document():
+ raised = frozenset({"update-add-symlink", "update-add-brand-new"})
+ documents = {
+ "README.md": "`update-add-symlink` and `update-add-brand-new`",
+ "docs/handbook.html": "update-add-symlink only",
+ }
+ assert unnamed(raised, documents) == [
+ "docs/handbook.html does not name update-add-brand-new "
+ "(as update-add-brand-new)"]
+
+
+def test_a_misspelt_code_fails_naming_the_token_and_the_document():
+ raised = frozenset({"update-add-symlink"})
+ documents = {
+ "README.md": "`update-add-symlink` and `update-add-symlnk`",
+ "docs/handbook.html": "update-add-symlink",
+ }
+ assert not_raised(raised, documents) == [
+ "README.md spells update-add-symlnk, which update-shape.py does not "
+ "raise"]
+
+
+def test_a_code_that_is_only_a_prefix_of_a_longer_one_does_not_satisfy_it():
+ raised = frozenset({"update-add-symlink"})
+ documents = {"README.md": "`update-add-symlink-loop`",
+ "docs/handbook.html": "update-add-symlink-loop"}
+ assert unnamed(raised, documents) != []
+ assert not_raised(raised, documents) != []