Skip to content

family.py: bump --dry-run fetches the member, resolves --to and prints the old -> new row and its trailers without checking the member out or rewriting family.yaml, and remove --dry-run prints the row, gitlink, .gitmodules section and directory it would remove and changes nothing, so the module's EXIT CODES line is true of both (#326) - #345

Merged
brettheap merged 4 commits into
mainfrom
fix/family-dry-run
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #326 (comment)

What

family.py bump and family.py remove take --dry-run. Each dry run makes every refusal its verb makes before its first write, prints what the real run would write or remove, and changes nothing in the holder.

Why

bump, add and remove had no --dry-run. The module docstring said 1 nothing (a dry run prints and exits 0), which was true only of init and siblings, and bump-leg.py already had one. To see what a bump would pin you had to run it: fetch the member, check it out at --to, rewrite family.yaml and stage. Even the nothing to do answer from #210 cost the fetch and the checkout.

The rulings, as implemented

  • bump --dry-run leaves after the fetch and before the checkout, which is the real run's first write. The refusals before that point are the real run's own code, so they come out byte for byte the same, exit 2: the row (member-unknown, member-ambiguous), --to's form (member-to-not-a-commit), family.py: bump and remove refuse member-path when a row's path: is not members/<project> or its project: is not a directory name, and hand git every path literally, so a hand-edited path: members/* or project: . no longer makes remove unmount every member in one commit while dropping one row (#299) #315's member-path, and member-uninitialized.
    • It fetches, as the real run does. The fetch writes into the member's object store, which nothing in the holder records. The help and the docstring both say so. When the fetch fails, the closing line says --to was resolved from what the member's object store already held.

    • It resolves --to the way the checkout accepts it, with rev-parse --verify <to>^{commit}. A commit the member does not have after the fetch exits 2, where the real run's checkout would fail.

    • The digest is real. tree_digest reads objects and never a checkout, so the dry run prints the digest the real run computes. The "digest computed by the real run" fallback was never needed.

    • What the real run asks after its checkout is also asked, with nothing written:

      • the member's project.yaml at --to, read from its objects by the same _read_member_project. member-not-a-project, member-manifest-unreadable and member-bad-id come out word for word. load_yaml's own refusals come out word for word except the file's name: <repository> @ <commit>:project.yaml, not the member's path;
      • family.rewritten (family-members-unrecognised).
    • What it does not ask: what only the write itself can answer. A member whose local changes git checkout would refuse to overwrite gets exit 0 and a row from the dry run, and exit 2 from the real run's checkout. The module docstring says so.

    • Output. It prints:

      • commit <old> -> <new> and tree <digest>, in bump-leg.py --dry-run's layout;
      • the gitlink old -> new;
      • the whole row as family.yaml would carry it.

      The real run builds its rows with the same _bumped_rows, so the row printed is the row written by construction.

    • Exit 1 for nothing to do uses the real run's own family.py bump to the commit already pinned exits 2 as "REFUSED a git or gh command failed" around git's "nothing to commit", where family.py documents exit 1 for nothing and bump-leg.py answers the same re-run with "nothing to do", exit 1 #210 rule, checked without writing anything: HEAD's gitlink is already --to AND the rewritten manifest hashes (git hash-object --path, no -w) to the blob HEAD already has. A row that names --to over a digest that does not recompute, or over a gitlink that moved alone, is still a move under --dry-run, as it is in the real run.

    • It never runs checkout, add, commit or submodule, never runs hash-object -w, and never writes family.yaml.

  • remove --dry-run leaves after member-path and before git submodule deinit.
    • It runs ls-files --error-unmatch over the same literal pathspec, and otherwise lists what git rm -r unstages. Where the index holds nothing at the path, this fails with the same line from git as the real run's deinit (did not match any file(s) known to git). It is printed the same way, the exact command and git's own words, exit 2, with no remediation for a leg.
    • It asks family-members-unrecognised.
    • It prints the row, the gitlink, the .gitmodules section, the holder's .git/config registration and the directory.
      • The section is the one a real git rm drops. Git looks the path up the way it reads the work-tree .gitmodules (overwrite on), and drops every section of the name it finds:
        • a name keeps the last path it is given;
        • a name that moves to another path takes the old path out of git's map;
        • a suspicious name (../ghost) is ignored.
      • Where that lookup finds no name, the dry run says none is removed. It names the other sections that still say the path and stay.
      • The registration is printed only when the holder's config holds one under that same name, the one deinit drops.
    • The index, .gitmodules, family.yaml, the work tree, .git/config and the holder's own object store stay byte-identical. It does not ask what only git rm answers: an unmerged .gitmodules.
  • --trailer with --dry-run prints trailers N to append, in order: and the lines, worded as bump-leg.py words them, after the row. It prints nothing of the kind for nothing to do.
  • The module docstring's EXIT CODES paragraph now says which subcommands have a dry run. A new paragraph says what each dry run asks, that bump's fetches, and what neither asks. bump's and remove's --help gained --dry-run, and docs/cli.md was regenerated with scripts/render-cli-reference.py.

Why add is not in it

This PR is ruling 1. add's dry run comes after the three changes to cmd_add now in flight have landed: this lane's #317 and #318, and lane 3's #330. This PR does not touch cmd_add or any function only it calls. remove's dry run reads .gitmodules and the index with readers of its own, not with add's _submodule_paths/_index_entries, because #317 and #318 change those two. AGENTS.md is not changed here because of its line cap; a line in its family section naming the two dry runs is a follow-up.

Tests

tests/test_family_dry_run.py is new, written in tests/test_family.py's style over the same local-bare-remote family, with a gh shim that exits 97. Every "changes nothing" claim is checked as bytes: ls-files -s -z, status --porcelain=v1 -z, family.yaml, .gitmodules, .git/config, the member's HEAD, the whole work tree and the holder's .git/objects. The comparison runs in an order that names the first fact that moved.

Mutants

Each mutant was run against the test the ruling names, and each was killed by the assertion the ruling names:

mutant test fails on
the dry-run return moved after the checkout test_bump_dry_run_prints_the_move_and_a_real_bump_writes_exactly_that AssertionError: member HEAD changed
write_members before the return same AssertionError: family.yaml changed
remove's return moved after git rm test_remove_dry_run_prints_what_a_real_remove_takes_and_changes_nothing AssertionError: index changed

Fix round (review of 875d802)

One commit on top of 875d802; nothing rebased.

  • S1: the index listing in remove --dry-run now goes through _git_bytes, so a failure is a CommandFailed. It is printed as _bump_dry_run prints its own (REFUSED listing members/<P> in the index, where a real remove unmounts it. THE EXACT COMMAND WAS: ..., with git's own did not match any file(s) known to git), exit 2, and never with repo_shape's leg remediation. Every git call the dry runs make whose failure would reach the human now goes through _git_bytes: the manifest read, the .gitmodules and config listings, and hash-object. New test: a row whose gitlink was git rm --cached and committed. Dry and real both exit 2 on git's same line, nothing moves, and neither prints validate-pins or a leg pin.

  • S2: _gitmodules_names now models git's own lookup over git config -z --file .gitmodules --list:

    • a name keeps its LAST path;
    • a name moving away takes the old path out of git's path map, whoever held it;
    • suspicious names (check_submodule_name) and option-like paths are skipped.

    It returns the name whose sections git rm drops (every section of that name), or none. The other names with a path = <P> line are printed as left in place. New parametrized test: the review's three probes plus the common two-names case. Each runs the dry run, then a real remove, which must take out exactly the sections of the name printed: last-path-wins drops both ghost sections; path-left-behind drops nothing; suspicious-name drops members/IRRS and keeps ../ghost.

  • N1: the module docstring and _bump_dry_run's now say "every refusal made before its first write", and name what is not asked: a member's local changes the checkout would refuse, and an unmerged .gitmodules.

  • N2: after a failed fetch the closing line says --to was resolved from what the object store already held. New test, with the member's origin pointed at a missing path.

  • N5: the state the tests compare now includes the holder's own .git/objects. The in-process test treats hash-object -w and any non-listing config as writes, and gained a nothing to do case, which is the run that asks hash-object.

  • N7: this body: refusals word for word except load_yaml's file name; the trailers header's 5 spaces; S1's and S2's wording; "before its first write".

  • N8: remove --dry-run prints registration [submodule "<name>"] in the holder's .git/config, dropped by \git submodule deinit`. It does so only when the holder's config holds one under the name git rm drops (deinit` looks the name up the same way). The remove and ghost tests check that the real remove drops exactly that registration.

  • N3, N4, N6: follow-ups, listed below.

Fix-round mutants, each against the test that must catch it:

mutant killed by
MJ: remove --dry-run without --error-unmatch test_remove_dry_run_of_a_member_the_index_does_not_hold_fails_as_remove (assert 0 == 2)
MI: hash-object -w test_a_dry_run_runs_no_git_command_that_writes[bump-nothing] (['git', 'hash-object', '-w', ...])
MS2: a name keeps its FIRST path (the old rule) test_remove_dry_run_names_the_sections_a_real_remove_drops[last-path-wins] and [path-left-behind]
MS3: suspicious names read like any other test_remove_dry_run_names_the_sections_a_real_remove_drops[suspicious-name]

Follow-ups (not in this PR)

Part of #326

🤖 Generated with Claude Code

Summary by Sourcery

Add non-mutating previews for family member bumps and removals while preserving the real commands' validation and exit-code semantics.

New Features:

  • Add --dry-run support to family.py bump, including realistic commit resolution, tree digest calculation, manifest validation, row and trailer previewing, and correct exit behavior.
  • Add --dry-run support to family.py remove, previewing the affected family row, gitlink, .gitmodules sections, configuration registration, and directory without modifying the holder.

Bug Fixes:

  • Ensure dry-run refusal handling matches the corresponding real operation for pre-write validation failures and preserves the documented exit-code behavior.

Enhancements:

  • Model Git's .gitmodules section lookup when determining which sections a real removal would delete, including duplicate, moved, and suspicious names.
  • Share row generation and no-op detection between real and dry-run bump operations.

Documentation:

  • Update the CLI reference and module documentation to describe dry-run support, fetch behavior, no-op handling, and refusal boundaries.

Tests:

  • Add comprehensive coverage verifying dry-run output, exit codes, refusal parity, trailer previews, no-change guarantees, Git metadata handling, and equivalence with subsequent real operations.

…rints the `old -> new` row and its trailers without checking the member out or rewriting `family.yaml`, and `remove --dry-run` prints the row, gitlink, `.gitmodules` section and directory it would remove and changes nothing, so the module's `EXIT CODES` line is true of both (#326)

`bump`, `add` and `remove` took no `--dry-run`, though the module's exit-code
line said "1 nothing (a dry run prints and exits 0)", which was true of `init`
and `siblings` only, and `bump-leg.py` has one. Seeing what a bump would pin
meant running it: fetch, `git checkout` in the member, rewrite `family.yaml`,
stage. Even #210's `nothing to do` cost the fetch and the checkout.

The coordinator's rulings, as implemented:

- `bump --dry-run` leaves after the fetch and before the checkout, the real
  run's first write, so every refusal made before that point is the same
  refusal, exit 2: the row, `--to`'s form, #315's `member-path` and
  `member-uninitialized`. It fetches, as the real run does. A fetch writes
  into the member's object store, which nothing the holder records, and the
  help and the docstring say so. `--to` is resolved as `git checkout` takes
  it (`^{commit}`), and a commit the member lacks is exit 2. The questions
  the real run asks after its checkout are also asked, of the member's
  objects: `project.yaml` through the same `_read_member_project`
  (`_member_id_at`, in a temporary directory), and `family.rewritten`. The
  digest is `tree_digest`, which reads objects, so it is the real run's
  digest, never a guess. It prints `commit <old> -> <new>` and `tree`,
  `bump-leg.py`'s layout, then the gitlink `old -> new` and the whole row as
  `family.yaml` would carry it. The row comes from `_bumped_rows`, which the
  real run now calls too, so the printed row is the written row by
  construction. Exit 1 follows the real run's #210 rule, asked without the
  write: HEAD's gitlink is `--to`, and the rewritten manifest hashes
  (`git hash-object --path`, no `-w`) to HEAD's blob. A digest repair or a
  gitlink that moved alone is therefore a move under `--dry-run` too. A
  `project.yaml` that is a symlink is left to the real run, which reads
  through it, and the dry run says so.
- `remove --dry-run` leaves after `member-path` and before `git submodule
  deinit`. `ls-files --error-unmatch` over the same literal pathspec fails
  where `deinit` fails, and otherwise lists what `git rm -r` unstages. It
  asks `family-members-unrecognised`, and prints the row, the gitlink, the
  `.gitmodules` section `git rm` drops (the one it reads last when two give
  the path, with the one it leaves named) and the directory. Its readers
  are its own, not `add`'s `_submodule_paths` and `_index_entries`, which
  #317 and #318 are changing.
- `--trailer` with `--dry-run` prints the trailers after the row, worded as
  `bump-leg.py` words them, and never for `nothing to do`.
- The `EXIT CODES` paragraph says which subcommands have a dry run, and a
  new paragraph says what each asks. `bump`'s and `remove`'s `--help` gained
  `--dry-run`, and `docs/cli.md` is regenerated.

`add` gets none here. Its dry run follows the three changes to `cmd_add` in
flight (this lane's #317 and #318, lane 3's #330), and neither `cmd_add` nor
any function only it calls is touched. AGENTS.md is unchanged; a line in its
family section is a follow-up.

tests/test_family_dry_run.py, over `test_family.py`'s local-bare-remote
family, checks "changes nothing" as bytes: the index (`ls-files -s -z`),
`status --porcelain=v1 -z`, `family.yaml`, `.gitmodules`, `.git/config`, the
member's HEAD and the work tree. Then the real run writes exactly what the
dry run printed. Covered: a bump previewed and then made; `--to` the pin
(exit 1, with and without trailers); both #210 repairs; trailers printed and
then committed; six refusals compared stderr for stderr with the real run; a
commit the member lacks; `member-not-a-project` at `--to`; a linked manifest;
a remove previewed and then made; two sections for one path; the
unrecognised manifest; and, in process, no git command that writes.

Mutants, each run against the test the ruling names: the dry-run return
moved after the checkout fails `member HEAD changed`; `write_members` before
the return fails `family.yaml changed`; `remove`'s return moved after `git rm`
fails `index changed`.

Part of #326

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 13 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds fully validated dry-run support to family.py bump and remove: bump fetches and resolves object data but stops before checkout or holder writes, while remove reports the exact row, gitlink, .gitmodules section, and directory it would delete; extensive tests verify output parity, refusal parity, exit codes, and that no prohibited state changes occur.

Sequence diagram for family bump dry run

sequenceDiagram
    participant CLI
    participant Holder
    participant Member

    CLI->>Holder: cmd_bump
    Holder->>Member: git fetch
    Holder->>Member: rev-parse --verify <to>^{commit}
    Holder->>Member: _member_id_at
    Holder->>Member: tree_digest
    Holder->>Holder: _bumped_rows
    Holder->>Holder: family.rewritten
    alt nothing to do
        Holder-->>CLI: nothing to do, exit 1
    else move to preview
        Holder-->>CLI: print gitlink, row, and trailers
        Holder-->>CLI: exit 0
    end
    Note over Member,Holder: No checkout, family.yaml write, staging, or commit
Loading

Sequence diagram for family remove dry run

sequenceDiagram
    participant CLI
    participant Holder
    participant Member

    CLI->>Holder: cmd_remove
    Holder->>Holder: ls-files --error-unmatch
    Holder->>Holder: family.rewritten
    Holder->>Holder: _gitmodules_names
    Holder->>Member: inspect directory
    Holder-->>CLI: print row, gitlink, .gitmodules section, and directory
    Holder-->>CLI: exit 0
    Note over Member,Holder: No git submodule deinit, git rm, index, manifest, or config writes
Loading

File-Level Changes

Change Details Files
Add a non-mutating preview path for family.py bump, preserving real-run validation while stopping before checkout and holder writes.
  • Fetch the member before previewing, resolve --to with commit verification, and compute the real tree digest from objects.
  • Read project.yaml at the target commit without checkout and reuse row-building logic shared with the real bump.
  • Detect the existing nothing to do condition using HEAD gitlink and an unstored manifest blob hash.
  • Print the commit transition, digest, gitlink, rewritten row, trailers, and explicit fetch/no-write status.
  • Expose --dry-run in the parser and CLI documentation.
scripts/family.py
docs/cli.md
Add a non-mutating preview path for family.py remove that describes the exact removal effects.
  • Validate the literal index path with ls-files --error-unmatch before previewing.
  • Inspect index entries and .gitmodules sections, including duplicate-path behavior matching the section removed by git.
  • Run manifest validation and print the row, gitlink/index entries, removed section, directory, and unchanged-state notice without deinit, rm, or other writes.
  • Expose --dry-run in the parser and CLI documentation.
scripts/family.py
docs/cli.md
Provide comprehensive regression coverage for dry-run semantics, output, refusals, and write isolation.
  • Verify byte-for-byte holder and member state preservation, including index, work tree, configuration, manifests, and member HEAD.
  • Compare preview output with the subsequent real bump/remove results, including digests, rows, trailers, duplicate .gitmodules sections, and repair cases.
  • Cover matching refusal behavior, missing commits, manifest errors, symlink handling, nothing to do, and manifest-recognition validation.
  • Record subprocesses to ensure dry runs execute no writing git commands while confirming bump fetches.
tests/test_family_dry_run.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

…s the real run prints a failed git command, with no leg remediation, and names exactly the `.gitmodules` sections and the registration a real `git rm` and `deinit` drop, by git's own reading of the work-tree file; fix round on the review of 875d802 (#326)

The review of 875d802 (MERGEABLE WITH NITS) found two SHOULDs and eight nits.

S1. Over a row whose gitlink the index no longer holds, the real `remove`
fails at `git submodule deinit` and prints the command and git's line, exit
2. The dry run failed in `git_out`, whose `Refusal` carries `repo_shape`'s
default remediation: run `validate-pins.py`, advance
`contracts/<leg>-pin.yaml`. A holder has neither. The dry runs now ask git
through `_git_bytes`, whose failure is a `CommandFailed`. `remove --dry-run`
prints the failed listing as `_bump_dry_run` prints its `rev-parse`: the exact
command and git's own `did not match any file(s) known to git`, exit 2. The
manifest read, the `.gitmodules` and config listings and `hash-object` go the
same way.

S2. `_gitmodules_names` claimed a name keeps the FIRST `path` it is given.
That is git's rule for a `.gitmodules` read from a tree. The work-tree file,
which `git rm` reads, is read with overwrite on (git 2.43, `gitmodules_cb`),
and three things follow:
- a name keeps its LAST path;
- a name moving away takes the old path out of git's path map, whichever name
  held it;
- a suspicious name (`check_submodule_name`, e.g. `../ghost`) is ignored.
The model now follows all three over `git config --list`'s reading of the
file. It names the one name whose every section `git rm` drops, or none, and
the other names whose sections still say the path. Probed, then tested against
a real remove: ghost given ELSEWHERE and then the member's path drops both
ghost sections; the reverse drops nothing; `../ghost` is ignored and the
member's own section is dropped.

N1: the docstrings say "every refusal made before its first write", and name
what is not asked: a member's local changes the checkout would refuse, and an
unmerged `.gitmodules`. N2: after a failed fetch the closing line says `--to`
was resolved from what the member's object store already held. N5: the tests
compare the holder's own `.git/objects`, and the in-process test treats
`hash-object -w` and any non-listing `config` as writes, with a `nothing to
do` case that asks `hash-object`. N8: `remove --dry-run` prints the
`.git/config` registration `deinit` drops, looked up by the same name, only
where the holder has one. N3, N4 and N6 are follow-ups in the PR body.

New tests: a row the index does not hold (dry and real both exit 2 on git's
line, nothing moves, no leg remediation); four `.gitmodules` hand edits, each
compared with a real remove; a failed fetch; the registration in the remove
preview; a `nothing to do` case in the in-process test. Mutants: MJ (no
`--error-unmatch`) fails the index test; MI (`hash-object -w`) fails the
in-process `nothing to do` case; a name keeping its first path fails two of
the four hand edits; a suspicious name read like any other fails the third.

Part of #326

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap and others added 2 commits October 12, 2026 13:04
…les only and runs every git with automatic maintenance off, so git's background maintenance lock no longer fails five tests on the macOS runner (#326)

PR #345's first macOS run failed five tests in this file, all `objects
changed`: the index-does-not-hold test and the four `.gitmodules` hand
edits. In each, the BEFORE snapshot held one more file,
`.git/objects/maintenance.lock`. The set-up's `git commit` started git's
background auto-maintenance (the runner carries a newer git). The job held
the lock when `state()` was taken and released it before the dry run's
comparison. A race in the test, not the tool: ubuntu passed.

Two changes, either of which closes it:
- Every git this module starts runs with `maintenance.auto=false`,
  `gc.auto=0` and `gc.autoDetach=false`. They are given through git's
  `GIT_CONFIG_COUNT` protocol, never a global file, layered on
  `test_family_member_path.isolated` by a wrapper of the same name, so no
  background job packs or locks objects mid-test.
- `objects()` lists OBJECT FILES only: loose objects (two hex digits, then
  the rest of a sha-1 or sha-256 id) and `pack/pack-*.pack` and `.idx`. A
  `*.lock`, `info/` and git's other transient files can no longer fail it.
  A blob the dry run wrote would still be a new object file, so the
  comparison keeps its meaning. Mutant MI (`hash-object -w`) is killed by
  the in-process recorded-call check, which this does not touch.

Part of #326

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

This was referenced Oct 12, 2026
@brettheap
brettheap merged commit 3ae0109 into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/family-dry-run branch October 12, 2026 15:19
This was referenced Oct 12, 2026
brettheap added a commit that referenced this pull request Oct 12, 2026
…merged main a983e6a

#386's branch is stacked on #356's fix/adopt-check-hand-edited-plan and
was built at 5a5011d. #356 has since merged main a983e6a, which brings
#245 (a relative `.gitmodules` url resolved from the source's remote and
the leg's) and #371 (`execute` verifies before it exits on a refused
`gh pr create`) into adopt-project.py, with #351, #350, #345 and #372 in
other files.

The merge is clean. `cmd_check` and `cmd_execute` still ask
`_plan_type_findings` and `_refuse_a_mistyped_plan` first, right after
`Plan.load` and before `_work_root`, so nothing #245 or #371 added runs on
a mistyped `source:`, `legs:`, `pins:`, `allow_empty_legs:` or `paths:`.
#245's new reader, `_kept_relative_urls`, takes `plan.entries`, which
`_plan_list` already reads only from a list, and every reader of
`plan.legs` asks its type. #245 gives `_refuse_what_check_finds` a
`local_remote_dir` and rewrites its first line; `_refuse_a_mistyped_plan`
shares only its shape and `REFUSED_FINDINGS_SHOWN`, not its text, so
neither needs a change.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…elative-url findings join `_entry_findings` last, after this branch's `plan-root-symlink` and #255's values, and `execute` probes `core.symlinks` after #190's remote-aware refusal

origin/main moved a2cb7d3 -> 92e29d9 (ten commits). The coordinator named
1f90a0f; 92e29d9 came after it and changes adopt-project.py too, so main
is merged at its tip, by sha:
- ab623dd (#296 via #349), tests/conftest.py: a session `gh` guard first
  on PATH;
- 161c6c9 (#190 via #245), adopt-project.py and scripts/repo_shape.py: a
  relative `.gitmodules` url kept in a leg is resolved from the source's
  remote and from the leg's; `_entry_findings` takes `local_remote_dir`
  and returns its NOTEs as a third value; `_submodule_findings` returns
  the submodule findings and the relative-url findings apart, so that the
  relative urls come last;
- d82a83e (#282 via #351), scaffold-project.py;
- 0f67f7b (#331 via #350), the template's scripts/validate-family.py;
- 3ae0109 (#326 via #345), scripts/family.py;
- 28bdc84 (#337 via #372), single-repository.yaml;
- a983e6a (#304 via #371), adopt-project.py and AGENTS.md: `execute`
  verifies before it exits on a refused `gh pr create`, and `_verify` no
  longer prints NEXT: `_next_steps` does, once the split is verified and
  no pull request was refused;
- 8325d05 (#276 via #354), shape-doctor.py;
- 1f90a0f (#323 via #380), scripts/shape_materialize.py (the pin-rewrite
  helpers), scripts/bump-leg.py and update-shape.py;
- 92e29d9 (#319 via #377), adopt-project.py: `plan` asks about every path
  `execute` will not extract as it is named.

scripts/shape_materialize.py, scripts/repo_shape.py and every other file
merge with no conflict. adopt-project.py conflicts in four hunks, each
resolved by keeping both sides:
1. The module docstring's `check` paragraph. Main's clause for the
   submodules gains "each relative url naming the same repository from the
   leg as from the source"; this branch's symlink clause follows it, as it
   followed the submodule clause before.
2. `_entry_findings`' docstring. Main's opening (the findings, what is
   printed once after them, and the NOTEs) and its order, with this
   branch's symlinks after the source's own submodules and before #255's
   values, and #190's relative urls last.
3. `_entry_findings`' return. Main's three values. The findings are, in
   order: each entry's path, coverage and the legs (with what the root keeps
   where the shape writes), the source's own submodules (`split`), this
   branch's `_root_symlink_findings`, #255's values, and #190's relative
   urls (`moved`) last. The remediation and the NOTEs are main's: the
   symlink findings carry their own fix and add none.
4. `cmd_execute`. Main's `_refuse_what_check_finds(plan, source,
   args.local_remote_dir)`, then this branch's
   `_refuse_symlinks_checked_out_as_files`, both before any leg, as before.

Checked on the merged tree:
- AST (fin183g/names.py, base a2cb7d3): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere. Main
  changed the signatures of `_entry_findings`, `_submodule_findings` and
  `_refuse_what_check_finds`, each by a trailing `local_remote_dir=None`;
  every call in the merged file is main's or passes two arguments, and
  this branch's tests call `_refuse_what_check_finds(loaded, source)`.
- Both sides intact (fin183g/sides_intact.py): the only lines that differ
  from either side's own change are those of the four hunks.
- What this branch changed in the materializer (fix B among it) and
  1f90a0f's pin-rewrite helpers share no function: 1f90a0f adds
  `is_nested_key`, `with_scalar`, `pin_values_in` and `rewrite_fault`, and
  imports `YamlError`, `_strip_comment` and `parse_yaml`, none of them a
  name this branch binds. `place`, `copy_in_the_way` and
  `_refuse_unwritable` are as they were.
- a983e6a changes what happens after the split is pushed (the pull
  request, the verification, NEXT). This branch's refusals all come
  earlier: `plan-root-symlink` and `adopt-symlinks-off` before any leg,
  and the materializer's before the split is committed. Its one line in
  `_verify` (`follows too many symlinks`) is kept.
- 92e29d9 changes what `plan` asks, not what `check` or `execute` take.

Left for the two commits after this one:
- tests/test_adopt_root_symlinks.py's `refused_past_check` switches
  `_refuse_what_check_finds` off with a two-argument lambda, and `execute`
  now passes three, so the two tests that use it fail here with a
  TypeError. A rehearsal of this merge with the docstring commit gave
  `2 failed, 481 passed` over this branch's file and
  tests/test_adopt_{relative_submodule_url,github_exits,entry_paths,
  submodules,template_values,execute_refusals}.py, those two the only
  failures. The next commit fixes the fixture.
- `_entry_findings`' docstring, as merged, says the two commands differ
  "in the one place a remote decides it". The commit after names the
  second place, this branch's symlink destinations asked of each
  command's own disk.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…351, #350, #345, #372, #371, #354, #380, #377 and the rest since ab623dd merge with no conflict, beside the pin functions this branch moved out of `scaffold-project.py` and its `plan --pin` in `adopt-project.py`

Twelve commits of main since ab623dd, the base this branch last merged,
reach five files this branch changes: `adopt-project.py` (161c6c9 #245,
a983e6a #371, 92e29d9 #377), `scaffold-project.py` (d82a83e #351),
`scripts/shape_materialize.py` (1f90a0f #380), `scripts/repo_shape.py`
(161c6c9) and `docs/cli.md` (3ae0109 #345). Git merged every one with no
conflict:

- `scaffold-project.py`: #351's `_identity` value checks and its
  `unwritable_reason` import sit beside the imports that replaced the pin
  functions; pyflakes finds no undefined name in either tool (only the
  unused imports main itself has).
- `adopt-project.py`: #245's `_entry_findings` now returns its NOTEs as a
  third value and takes `local_remote_dir`; `_pin_findings` is still read
  through `_leg_findings`, inside it.
- `scripts/repo_shape.py` equals the tree `git merge-tree` gives for
  6494714 with cd1fc79 (blob 091966b), and `validate-pins.py` and
  `tests/test_pin_source_env_collisions.py` are still Q's blobs.
- `render-cli-reference.py --check`: ok.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…mounts, reads the member from throwaway clones outside the holder for the row's id and digest, and prints the row, gitlink, `.gitmodules` section and trailers without mounting, writing or committing anything, and `add` takes `--trailer` as `bump` does, so every subcommand has the dry run the module's `EXIT CODES` line promises (#326) (#400)

The second and last part of #326. Part 1 (#345, 3ae0109) gave `bump` and
`remove` a `--dry-run`, and left `add` out until the changes to `cmd_add`
then in flight had landed. Until now the only way to see what `add` would
pin was to run it: clone the member, mount it, rewrite `family.yaml` and
commit.

`add --dry-run` leaves `cmd_add` after the probe and before
`MountRollback` is taken or `git submodule add` runs. So every refusal
`add` makes before its first write is the real run's own code, in its
order, with the same code, the same words and exit 2. That covers the
holder's refusals and the member's, which are asked of the probe's
throwaway clone. Lane 3's #381 (f0a8bf6) added `member-duplicate-id` right
after the probe, and `cmd_add` asks it before the dry-run return, through the
same function, so `--dry-run` refuses it too: real and dry run give
byte-identical output, exit 2, and a table row pins it. The same holds for
#399's `member-malformed` names beginning with `.`, `_` or `-`.

The dry run reads the member a second time, as the real run does. The
real run's second reading is the mount, whose reading the row records.
The dry run's is a second bare clone, made as the probe makes its own: in
a temporary directory outside the holder, deleted whatever the answer. It
is read at the commit the probe resolved. The `id:` comes from
`_member_id_at`, through the function the checkout is read with. The
digest comes from `tree_digest`, which reads objects and never a
checkout, so it is the digest the real run computes, never a guess. A
`project.yaml` that is a symbolic link is left to the real run's mount,
which reads through it; the dry run says so and guesses no id.

It prints, in `bump --dry-run`'s layout, and exits 0:
- `commit none -> <sha>` and the digest;
- the gitlink;
- the `.gitmodules` section `git submodule add` writes: name, path, url,
  and whether the file is new;
- the registration in `.git/config`, written or with its url rewritten;
- the directory and object store the mount makes;
- the row as `family.yaml` would carry it;
- the trailers.
Without `--at`, a note says the real run pins the tip it finds when it
mounts, and gives the `--at` that pins the commit shown. The dry run never
runs `git submodule add`. It never writes `.gitmodules`, `family.yaml`,
the index, the work tree, `.git/config` or `.git/modules`.

`add` takes `--trailer`, `bump`'s repeatable flag. Ruling 4 asks
`--trailer` with `--dry-run` to print the trailers it would write, and
`add` had none. The real run carries the lines into its one commit
through `_commit_one`. Without the flag, the commit is the one `add` has
always written.

Git's own refusal at the mount is named, not made. With no `.gitmodules`
in the work tree and one in the index or HEAD, `git submodule add` refuses
before it writes anything, and the real run exits 2 there with the holder
unchanged. The dry run says so in a note, with how to put the file back,
and exits 0. That is how part 1 treats the checkout's own refusal.

The module docstring's `EXIT CODES` line now says every subcommand takes
`--dry-run`. `add`'s help names the dry run and says it reads the member.
`docs/cli.md` is regenerated. AGENTS.md is not changed (line cap); its
family section naming the dry runs and `add --trailer` is a follow-up.

The tests are appended to tests/test_family_dry_run.py; part 1's tests
are untouched.
- A real add previewed, for a holder with members and for a family's
  first member (absent `.gitmodules` stays absent). These stay
  byte-identical: HEAD, the index, `git status`, `family.yaml`,
  `.gitmodules`, `.git/config`, the `.git/modules` listing, the work tree
  and the holder's objects. No `gh` call and no temporary clone left. The
  real add then writes exactly the printed row, digest, gitlink, section,
  registration and directory, and the holder validates.
- 19 refusals before the first write, each the same stdout, stderr and
  exit 2 from the real run and the dry run, with the holder unchanged:
  the holder's, the member's, `member-duplicate-id` (a copy of a pinned
  project under a new name) and five `member-malformed` names beginning
  with `.`, `_` or `-`.
- `--trailer` printed, and carried by the real commit.
- `--at` previewed and pinned.
- A registration the mount rewrites.
- The `.gitmodules` note, for an unstaged and a staged deletion.
- A linked manifest (POSIX only).
- In process: both clones are outside the holder, under the run's TEMP.
  The holder's `git status`, asked while the dry run's clone exists, is
  unchanged. No git command in the holder writes, and no `git submodule`
  runs.
- A member whose `project.yaml` records `id: routing-core` under the name
  `IRDI`: the dry run prints that id, read from the member and not made
  from its name, and the real add writes exactly the printed row.
- The dry run's own clone failing (the member's remote moved away between
  the two readings): exit 2, its clone removed, the holder unchanged. And
  a holder that is a linked worktree: the object store printed is the one
  the real add makes.

Mutants, each killed:
- the dry-run return moved after `git submodule add`: `index changed`;
- the temporary clone made inside the holder: the status asked while the
  clone exists;
- the temporary directory not removed: the leftover check;
- an id made from the name (`project.lower()`): the id test;
- the dry run's own clone leaked on its failure path: the failing-clone
  test;
- the store assumed to be `.git/modules/<path>`: the linked-worktree test.
- the dry run skipping `_checked_member_id`: the `duplicate-id` row;
- the dry run skipping `_checked_member`, or `MEMBER_RE` loosened to its
  pre-#399 class: the `malformed*` rows.

Closes #326

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…ut family.yaml before creating anything, `--dry-run` too, so `--id null`, `--tracking-branch true`, a blank `--created-by` and `--created-on 20261011` are refused (#307) (#397)

`family.py init` rendered `templates/family-root/family.yaml` from `--id`, `--org`, `--name`, `--created-by`, `--created-on` and `--tracking-branch` and pushed it. The holder reads that file with its own YAML reader and its own `scripts/validate-family.py`, and `init` asked neither anything. Every row of #307 reproduces on c7c02fa (the issue's own script, with a stub `gh`): `--dry-run` and the real run both exit 0 for all 15 rows, and

- `--created-by ' '`, `--created-on 20261011` / `2026-W41-6` / `tomorrow`, `--id null|true|false`, `--org null`, `--name 'Contoso Group'` leave a holder whose `validate-family.py` exits 1 from its first commit. `init` cannot be run again over it (`family-remote-exists`, no `--force`).
- `--tracking-branch true`, `007`, `null`, `2026` exit 0 with `validate-family.py` exit 0 (it has no rule about `tracking_branch`), the holder pushed on that branch, `family.yaml` reading back as `True`, `7`, `None`, `2026`, and `update-shape.py apply --branch` printing `gh pr create --base True` / `--base 7` / `--base main` (for `null`) / `--base 2026`.

**What changed.** `scripts/family.py` asks two questions about the manifest `init` is about to write, right after the substitution table is built: before the plan is printed, before the `--dry-run` return, before `_create_holder_remote` (which is where `gh repo create` runs against GitHub). Both refuse, name the flag, and say nothing was created.

- `family_manifest_misreads` renders each line of the TEMPLATE that carries one of the values, reads it back with the holder's own reader (`repo_shape.parse_yaml`) and refuses a value that does not come back as the same text: `family-manifest-misread`. This is what catches `--id null`, `--org null`, `--created-on 20261011` and the silent `--tracking-branch true|007|null|2026`. A line the refusal quotes is shown the way Python spells it where it holds a line break or an escape, so a value cannot forge a second line of the refusal.
- `family_manifest_invalid` renders the whole `family.yaml` and runs the validator the template ships over it, loaded from `templates/family-root/scripts/validate-family.py` (`Report` and `_check_envelope`), never re-implemented: `family-manifest-invalid`, in the validator's own words. This is what catches a blank `--created-by`, `--created-on 2026-W41-6` / `tomorrow` and `--name 'Contoso Group'`, and whatever the validator learns next. A misread is reported first and alone, because the validator's finding about it (`id is None`) is its consequence. A validator that is missing, broken, raising or exiting, whose `Report` has no `findings`, or that answers with a finding that is not text, is `family-validator-unusable`, not a traceback and not a zero.
- A value no flag supplied is named by where it came from (`--id (not given, so --family lowercased)`).
- `init --help` and `docs/cli.md` (regenerated, `render-cli-reference.py --check` ok) say what is held to what.

No template is edited. A refused value is refused, not quoted or corrected.

**Fix round 1 (the review of 14e2d68).** The first review found one broken test elsewhere in the suite and four claims no test guarded. This round is one commit on top of 14e2d68 (tests, plus one change to `scripts/family.py` for the validator that cannot be asked); the first commit is not amended.

- **`tests/test_stdout_encoding.py` (the blocker).** The `family-init-dry-run` row ran `init --name <non-ASCII> --dry-run` and asserted the name is printed in the plan. The holder's own `validate-family.py` refuses that name (`naming-unclassified`: a family holder is one CamelCase token), so `init` now refuses it before the plan prints. That is this PR working as intended, so the TEST changed and the refusal did not: the row drops `--name`. The plan still prints the non-ASCII `--created-by` on its `created by` line, so the constrained stdout still has a value to escape (`created by   日本 ...` under `cp1252`, the characters themselves under UTF-8), and the row still fails if `family.py main` stops calling `escape_what_stdout_cannot_encode`. On the first head the old row failed (`1 failed`); the row as it is now passes on this head, and on this head merged with origin/main.
- **A validator that cannot be asked.** The in-process call now sits in one `try` that also catches `SystemExit` and reads `report.findings` there, and a finding that is not text is refused by name. Three cases used to escape the refusal: a `Report` with no `findings` (an `AttributeError` traceback), a finding that is not a string (a `TypeError` from the regular expression) and a `_check_envelope` that calls `sys.exit(0)` (`init` exited 0, having printed and created nothing). Each is now `family-validator-unusable`, exit 2, nothing created; one test per case over a deliberately broken copy of the template, and one run at the command line for the exit.
- **`_line_shown`.** A value with a line break, an ESC and a would-be forged line (`x\nFINDING forged: y\x1b[2J`, and a CR, U+2028 and U+0085 variant) is one escaped sentence: asserted on `family_manifest_misreads` itself, through `main` and what it prints (in process, so no argv crosses to Windows), and at the command line (skipped on Windows before any call, since argv is UTF-16 there; the in-process rows hold it on every OS).
- **Two more guarded hunks.** The `sys.path` restore (also when the validator fails to load) and the comment-line skip each had a surviving mutant; each now has a test that kills it. The bytecode flag's restore is guarded by the same tests.
- **The bytecode test** asked the REAL template's `__pycache__` before and after, so any stale `validate-family*.pyc` there failed it whatever the code did. It runs over a copy of the template made without a `__pycache__`, with the flag cleared first so that it is `family_validator` that must set it, and a control shows that importing the same file that way does write a `.pyc`.

**How this composes with #259 (Brett ruled to quote the placeholders).** The misread check never says which lines are bare: it renders the real template line and reads it back. So once `fix/template-quoted-placeholders` (PR #395, first head 233dc71) quotes `id:`, `org:`, `created_on:`, `visibility:` and `tracking_branch:`, `--id null`, `--org null` and `--tracking-branch true|007|null|2026` read back exactly, the first question refuses nothing, and no edit is needed here. What the validator refuses stays refused: `--created-on 20261011` becomes `family-manifest-invalid` (`family-created-on`) instead of a misread, and a blank `--created-by` and `--name 'Contoso Group'` are unchanged. Proved three ways, all in `tests/test_family_init_values.py`:

- the same values through a copy of the template with the placeholder forced quoted: accepted, and `parse_yaml(render(...))` reads the exact text back;
- an end-to-end run of a forged one-commit checkout of `scripts/`, `templates/` and `contracts/` whose `family.yaml` is forced quoted: `init` exits 0, the holder's own `validate-family.py` exits 0, `family.yaml` reads back as typed, and the branch pushed is the typed one (a second forged checkout forced bare: refused, nothing created);
- the tests that read the real template's own quoting to say what to expect: run here with the real template as it is, and on a tree with #259's branch merged in (below), so the file passes whichever of this and #259 lands first.

**Once #259 is on main, 19 rows skip for good and one sentence goes unreached.** With the template quoted, the 16 end-to-end rows of `test_init_asks_the_holders_own_programs_before_anything_is_created` whose quoted answer is "the run is fine" and the 3 of `test_an_id_the_family_name_would_derive_is_named_by_where_it_came_from` skip with a message that says why (the 8 `test_a_quoted_value_is_held_as_typed` rows un-skip and pass), and nothing fails. `MANIFEST_MISREAD_REMEDIATION`'s "family.yaml writes a value like these unquoted" is then text no real template reaches; a template that writes one bare (the forged bare checkout the tests use) still does. Nothing is changed here for it: whichever of this and #259 lands second can drop the dead rows and reword that sentence in the same PR.

**Same helpers as #314 (#261)?** #314's `manifest_misreads` / `manifest_invalid` are hard-wired to the assembly root (`project.yaml`, its templated list, `validate-manifest.py`'s `_findings`) and were not on main when this PR was written, so this PR writes the smallest version the family needs, in `scripts/family.py` (not `shape_materialize.py`, which #266, #270, #314 and #316 all edit). They differ only in the template, the validator's entry point and the sentence that names the file. #314 landed first (3ef059c), and this PR leaves both sets of helpers as they are: **unifying them is left to a follow-up on #459's checklist (#459 (comment)

**Evidence, refreshed when this PR was opened (head abb27e6).** Main had then moved to 28bdc84: #345 landed as 3ae0109, #350 as 0f67f7b, and lane 3's `single-repository.yaml` as 28bdc84. The review's re-verify of abb27e6 ran on the head merged with 28bdc84: clean, with the six init, encoding, CLI-reference and family files at 305 passed / 9 skipped, and `test_family.py` with #345's `test_family_dry_run.py` at 126 passed. Merge-tree was also clean against #259's 3894aa8 (PR #395) and #314's 4b8da3d. The results below were taken before that; main has moved since.

- Reproduction, before and after, with the issue's script: on c7c02fa every row exits 0; on this branch all 13 failing rows now exit 2 on `--dry-run` and on the real run, with no holder, no remote directory and nothing pushed; the two controls (`--tracking-branch main`, `--name ContosoGroup`) are unchanged. `--tracking-branch 2026` is refused too, as #314 does for the scaffold: it worked only because every reader calls `str()` on the number.
- `tests/test_family_init_values.py`: 125 tests, 103 of them fail on c7c02fa; on this branch 117 pass and 8 skip (the 8 rows that need the real template to be quoted, which they run against the day it is). On 14e2d68, the first head, the 4 tests of this round that guard a code change are the ones that fail (the three validator cases and the exit at the command line); the rest guard what 14e2d68 already did.
- Mutations: fifteen of the change in the first round (drop the call, move it after the `--dry-run` return, move it after the remote is created, empty either question, ignore the template's quoting, ignore the template's validator, drop the not-given naming, drop a finding no flag owns, write bytecode into the template, let a validator's `SystemExit` through at load, ask the validator first, drop the unusable-validator checks), and eight more in this round, each killed by a new test: `_line_shown` always in backticks (9 fail), the `sys.path` restore dropped (2), the bytecode flag's restore dropped (2), the comment skip dropped (1), `findings` read outside the `try` (1), the non-text check dropped (1), `SystemExit` let through the run (2, one a zero at the command line), and `main` without `escape_what_stdout_cannot_encode` (the `family-init-dry-run` row of `test_stdout_encoding.py`). The write-bytecode mutation is now killed by the new bytecode test.
- Targeted runs on the head of this branch, abb27e6 (Linux, Python 3.12.3, git 2.43.0, git-filter-repo 2.47.0): `test_family_init_values.py test_family.py test_stdout_encoding.py test_cli_reference.py`: 276 passed, 9 skipped (the 8 rows that need a quoted template, and one Windows-only row).
- Merged with origin/main at 161c6c9 (which has #316, #317, #318, #245 and #349): `test_family_init_values.py test_family.py test_family_add_refusals.py test_family_add_excludes.py test_family_member_path.py test_stdout_encoding.py test_cli_reference.py` 407 passed, 9 skipped; `test_shape_doctor.py` 255 passed, 3 skipped (the PowerShell row, which needs a Windows host); `test_update_shape_add.py test_update_shape_pr_base.py test_template_values_single_pass.py test_scaffold_ignored_shape_files.py` 223 passed (with the git identity blanked, because the standard's own checkout sets a `user.name` that scaffold and adopt tests read). Main then moved to 0f67f7b (#350 landed, which changes the validator this reads, and a scaffold PR): `test_family_init_values.py test_stdout_encoding.py test_cli_reference.py test_validate_family_duplicates.py` on this branch merged with it: 197 passed, 9 skipped.
- Merge-tree (`git merge-tree --write-tree`), all clean: origin/main at 0f67f7b; #345 (abaaf3c, which landed as 3ae0109); #314 (4b8da3d); and `fix/template-quoted-placeholders` (3894aa8, #259). This branch + origin/main + #345 + #259's branch together are clean too, and `test_family_init_values.py test_family.py test_stdout_encoding.py test_cli_reference.py test_family_dry_run.py test_validate_family_duplicates.py test_template_text_placeholders.py` there: 442 passed, 20 skipped (the 19 rows above, which skip because #259 quotes the template, and one Windows-only row). #345 changes `cmd_bump`, `cmd_remove` and `build_parser`, not `cmd_init`; #350 (landed) added member-name checks to the validator and left `_check_envelope` and `Report`, the two names this change asks, as they are. #314 at 4b8da3d did not merge with origin/main by itself then (`adopt-project.py`, from what landed after its last merge), so it was not composed with the rest, but it was clean against this branch alone. #309, #312, #316, #317, #318 and #350, which the first round merged against or named, have landed.
- `--created-by '{{ORG}}'`: on c7c02fa `init` created the remote and then died with `template-unsubstituted` once the worktree was rendered. Merged with main, #316's one-pass `render` writes the value as typed: `init` exits 0, `family.yaml` records `created_by: "{{ORG}}"` and the holder's own `validate-family.py` is green. This change does not touch it and does not refuse it. (On this branch alone, without #316, the pre-flight render refuses it before anything exists, with the same `template-unsubstituted` text.)

**Left out.**

- `AGENTS.md`, `README.md`, `docs/handbook.html`: no prose change (#309 has landed since; `init --help` carries the sentence).
- A calendar check of `--created-on` (`2026-13-45` passes the validator's `DATE_RE`): the validator's rule, not copied here.
- A branch git itself refuses (`a..b`, `x.lock`): #283.
- `--org test+org` passes `validate-family.py`; not claimed.
- `--created-by` with an ESC or OSC is printed raw by the plan's `created by` line on c7c02fa and here: not part of this change.
- No run against GitHub (the `gh repo create` ordering is proved in-process, with every step that would reach GitHub replaced by one that fails the test), none on macOS or Windows.

Reviewed adversarially at 14e2d68 (CHANGES NEEDED: 1 blocker, 1 should-fix, 6 nits). The blocker was a test elsewhere in the suite that the new refusal turned red, `tests/test_stdout_encoding.py`'s `family-init-dry-run` row; the should-fix was the line-escaping hunk (`_line_shown`), which no test guarded. Fix round 1 (abb27e6) changed that test and not the refusal, guarded `_line_shown` and the two other surviving mutants, closed the three ways a broken validator escaped `family-validator-unusable`, and rewrote the bytecode test over a copy of the template. Its re-verification at abb27e6 found every finding closed: APPROVE WITH NITS (0 blockers, 0 should-fixes, 2 nits, both about this description and the commit trailer and not about the code: the trailer names Opus 5.5 below, and the description's merge-tree and composition paragraphs are refreshed above). CI is green on Linux, macOS and Windows, with SonarCloud passing, at d216ba8 (main 8782333 merged in).

Closes #307

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… writes its pin file as the scaffold does, so an adopted root passes `validate-pins.py` (#273); a `--pin-source` that cannot answer is `pin-source-unreadable` (#339) (#508)

**Brought up to date after #409 squash-landed** by merging origin/main (9736424, #409's squash) as f9ed46f. Its `scaffold-project.py` conflict was resolved to this branch's side once `git diff b70a07d 9736424 -- scaffold-project.py` (#409's final head against its squash) came out empty. So the file is the one tested on this branch plus main's own later changes to it, and holds nothing of #409's; details below.

This branch carries #275's adopt half. It was built on `fix/pin-source-env-collisions`, PR #409 (#275's scaffold half and its fix round), merged at 6494714 as 583f647, over the earlier 5690235 (34dbd86). After 6494714, #409 only merged main, five times, up to its final head b70a07d, and none of those merges has a hand edit (`git show --remerge-diff` is empty for each). Adopt's refusal of two products that share a source variable goes through #409's function, so this branch lands after #409. It was never rebased.

`git diff b70a07d 9736424` is empty for each file #409 touched: `scaffold-project.py`, `scripts/repo_shape.py`, `templates/assembly-root/scripts/validate-pins.py` and `tests/test_pin_source_env_collisions.py`. What f9ed46f resolves, four files with one hunk each:
- **`scaffold-project.py`.**
  - The conflict hunk is main's block from `PIN_COLLISION_REMEDIATION` through `_pin_collision_remediation`, which #409's squash edits: it adds `PIN_VARIABLE_REMEDIATION`, `_colliding_pin_variables` and `_pin_collision_remediation` and changes `_refuse_colliding_pins`. This branch moved all of that to `scripts/shape_materialize.py`. The hunk takes this branch's side. (The merge commit's message says "re-adds"; this is the exact account.)
  - #409's auto-merged import of `PIN_SOURCE_ENV_PREFIX, pin_source_env_collisions` is dropped, because nothing in the file uses either name any more.
  - It is not `git checkout --ours` of the whole file. Main changed the file beyond #409 since this branch last merged it: #355's `--tracking-branch` check (6c2a371) and a73b4e9's `RULESET_HINT`. Those lines stay.
  - The result is the file `git merge-tree` gives for this branch with main before the squash (518010b). Its diff from the file tested here has the same patch-id as main's own 3ef059c..518010b change to it.
- **`templates/assembly-root/scripts/validate-pins.py`.** Main's side, which is #409's final blob, with main's #359 workflow reader in it. This branch had no change of its own to the file beyond #409.
- **`adopt-project.py`, `_commit_the_split`.**
  - Main: #445 wraps the function in its late-failure `try`, and #394 builds an `append` with the plan's submodule record.
  - This branch: its only edits there were a comment and `neutral_pins=_recorded_pins(plan)` on the materializer call.
  - Both now sit on main's call inside the `try`: `append=append, neutral_pins=_recorded_pins(plan)`.
- **`docs/cli.md`.** It is regenerated with `scripts/render-cli-reference.py`, and `--check` is ok. It differs from main's only in the `--pin`, `--pin-owner` and `--pin-source` help of `adopt-project.py plan`.

`scripts/repo_shape.py`, `templates/assembly-root/scripts/validate-pins.py` and `tests/test_pin_source_env_collisions.py` are main's blobs, which are #409's: this branch adds nothing to them. Nothing of #409 is lost. Its scaffold behaviour is `shape_materialize.refuse_colliding_pins` and `pin_variable_collisions`, keyed by #409's `pin_product_key`.

**73d65c6, after the merge.**
- **The change.** `_read_pins` reads `plan.get("pins")` where it read `plan.data.get("pins")`.
- **Why.** #445's `tests/test_adopt_late_failure.py` hands `_commit_the_split` a `SplitPlan` as its plan, a `dict` that adds `entries`. On f9ed46f, three of its tests stopped at "committing the split" with `AttributeError: 'SplitPlan' object has no attribute 'data'` (3 failed, 83 passed in that file).
- **Nothing else changes.** `Plan.get` with no default returns `plan.data.get(key)` exactly, so a loaded plan reads as before.

`adopt-project.py plan --pin` took a NAME only (`--pin openGlass`) and refused a trailing `@<commit>`. `plan`, `check` and `execute` each exited 0, and `execute` printed `adoption verified`. The manifest then listed the name in `neutral_product_pins:` with no `contracts/<product>-pin.yaml` beside it, and on its first run the adopted root's own `validate-pins.py` refused it as `neutral-pin-missing`.

#273's table, reproduced on c7c02fa with the issue's own commands (local bare remotes, and a stub `gh` that exits 97):

| `--pin` | c7c02fa | this branch |
|---|---|---|
| `openGlass` | `execute` exit 0, `- openGlass`; clone's `validate-pins.py` exit 2 `neutral-pin-missing` | `plan` exit 2 `pin-malformed`, with the scaffold's remediation (`--pin openGlass@<40 hex>`); no plan is written |
| `opensoft/openGlass` | exit 0, `- opensoft/openGlass`; exit 2 `neutral-pin-missing` for `contracts/opensoft/openglass-pin.yaml` | `plan` exit 2 `pin-malformed` |
| `spec` | exit 0; `validate-pins.py` exit 0, reading the leg's `contracts/spec-pin.yaml` as the neutral pin | `spec@<sha>` is `adopt-pin-collides` at `plan` |
| `shape` | exit 0; `contracts/shape-pin.yaml` read as the neutral pin | `shape@<sha>` is `adopt-pin-collides` at `plan` |
| `openGlass@<sha>`, `opensoft/openGlass@<sha>` | `plan` exit 2 `adopt-pin-malformed` | `plan`, `check` and `execute` exit 0, and `gh` is called 0 times. `contracts/openglass-pin.yaml` is written and the manifest lists `- openGlass`. In the clone, `validate-manifest.py` exits 0, and `validate-pins.py` exits 0 with `openGlass: pin commit … digest recomputes` |

Brett ruled for decision 1's first option ("go with your recommendation on #273"): adopt takes the scaffold's pin and writes the scaffold's pin file. That reverses the NAME-only `--pin` from #6, and the comment that described it is replaced with the new contract.

- **One implementation.** The scaffold's pin code moved into `scripts/shape_materialize.py`, next to `neutral_pin_path` and the materializer that writes the file. What moved:
  - `PIN_ARG_RE`, `parse_pin`, `pin_digest_from_source` and `pin_digest_from_gh`;
  - `declared_pin_values`, `declared_pin_products` and `PIN_COLLISION_REMEDIATION` (in #309's wording);
  - from #275's branch, `PIN_VARIABLE_REMEDIATION` and the variable check;
  - `_refuse_colliding_pins`, split into the pure `pin_collisions` and `pin_variable_collisions`, and `refuse_colliding_pins(refusal=...)`, which raises one refusal for both.

  The code did not move verbatim. `_refuse_colliding_pins` became the three functions above; `pin_digest_from_source` was rewritten for #339 (below); and fix round 1 changed three of them (below): `pin_variable_collisions` keys each pin by #275's `pin_product_key`, `pin_digest_from_source` spells a path that is not printable, and `pin_digest_from_gh` refuses a missing `gh`. The scaffold's behaviour is otherwise unchanged. `scaffold-project.py` imports what it calls under its old names. Lane 5's review ran 34 scaffold cases (every pin refusal, the `gh` stub's four modes, dry and real successes) on 34dbd86 and on this branch at 7a94b6b. rc, stdout, stderr, the `gh` log and the files made were identical, apart from the shape's own commit and digest and #339's row. Its pin tests and #275's pass as they are. `tests/test_git_output_decoding.py`'s exemption for `pin_digest_from_gh` follows the function to its new file.
- **`plan`.** `--pin`, `--pin-owner` and `--pin-source` mean what they mean for the scaffold. `plan` reads the digest from `--pin-source` or `gh api`, and records product, repository, commit, digest and `digest_source` in the plan, so `check` and `execute` read no network for a pin. `plan` refuses:
  - a name with no `@<commit>`, as `pin-malformed`. That includes an empty `--pin ''`, which adopt used to drop and now refuses, as the scaffold always has;
  - `spec`, `code` and `shape`, in any case, as `adopt-pin-collides` (#273 decision 4), in the scaffold's sentence and remediation (#241, #309);
  - one product given twice, the same way, whether it is a repeat or two names that share one file (decision 5);
  - two products that one `SHAPE_PIN_SOURCE_<PRODUCT>` names, such as `open-glass` and `open_glass`, the same way, through #275's check;
  - a leg path over a pin's file, as `adopt-leg-path-collides`;
  - a `--pin-source` path that `check` would refuse in `digest_source:` (fix round 1), as `adopt-unwritable-value`. It is asked only when a `--pin` records the path;
  - with no `gh` on PATH and no `--pin-source`, `gh-not-found` (fix round 1).
- **`check` and `execute`.** These are new `check` findings, and `execute` refuses each one before any leg exists. They come from `_leg_findings`, the list `check` and `_refuse_what_check_finds` share:
  - `plan-pin-unrecorded`: a name alone (an old plan's `pins: [openGlass]`, or a hand edit), or an entry missing its commit or digest;
  - `plan-pin-malformed`;
  - `plan-pin-collides`, which covers two pins at one file, a pin at a file the shape writes, and two products that share one source variable;
  - `plan-root-holds-pin`: a source path kept in the root at the pin's file. The materializer would write the shape's pin beside it under `shape/`, and `validate-pins.py` would read the source's file instead.

  How each finding ends:
  - `plan-pin-unrecorded` and `plan-pin-malformed` end with the `plan --pin <product>@<40 hex commit>` that records the pin whole.
  - `plan-pin-collides` ends with re-running `plan` with each product once, or, for two products that share a source variable, with one product of each such group.
  - `plan-root-holds-pin` ends with the kept path's own remedy: send that entry to a leg, `drop` it, or rename it in the source and re-run `plan`.

  A leg moved over a pin's file in the plan is `plan-leg-path-collides`.
- **Also part of #386: its `pins:` row.** A hand-edited `pins:` that is not a list (a name such as `pins: openGlass`, a number, a mapping, a quoted string) is now `plan-pin-malformed` in `check` (`` `pins:` is 'openGlass', not a list of pins ``), and `execute` refuses it before any leg exists.
  - Where: `_read_pins` in `adopt-project.py`; the test row is `plan-pin-malformed-not-a-list` in `tests/test_adopt_pins.py`.
  - Before: on main, `check` said `plan ok` for the name, the mapping and the string, and gave a traceback for the number (reproduced by lane 5's review on cb7063f). #386 records that `execute` then wrote the name as eight one-letter pins.
  - Not fixed here: #386's other keys, `allow_empty_legs:`, `paths:`, `legs:` and `source:`, so #386 stays open. Lane 2's #435 takes all of #386, `pins:` included, and its `pins:` rule keeps only text items where this branch writes mappings; whichever lands second reconciles them (#435 (comment), and "Where the meaning meets" below).
- **`execute`.** It writes each pin through `materialize_assembly_root(neutral_pins=...)`. The manifest lists the product with the owner stripped (decision 3), so the entry and the file agree.
- **Docs.** `--help` is updated and `docs/cli.md` regenerated (`render-cli-reference.py --check` ok).

`pin_digest_from_source` caught only `Refusal`. A directory that is no clone is git's own failure, so it was always `pin-source-unreadable`, exit 2. A path `subprocess.run` cannot enter never reaches git: it is a `FileNotFoundError`, `NotADirectoryError` or `PermissionError` out of `cwd=`, and nothing caught it. `scaffold-project.py --pin ... --pin-source <that>` ended `--dry-run` and the real run alike in a traceback, exit 1. Since the move above, `adopt-project.py plan --pin` reads its digest through the same function and ended the same way. Both reach it through `declared_pin_values`, and `check` and `execute` read no source, so that call is the only reader.

The issue's table, run on ab623dd for the scaffold and on d806e8f for `adopt-project.py plan` (adopt has no `--pin-source` on ab623dd), then on this head. `gh` is a stub that logs and exits 97, with `GH_TOKEN` bogus and an empty `GH_CONFIG_DIR`; it was called 0 times in every run:

| `--pin-source` | before | this branch |
|---|---|---|
| a directory that is not a clone | `pin-source-unreadable`, exit 2 | unchanged |
| a path that does not exist | `FileNotFoundError` traceback, exit 1 | `pin-source-unreadable`, exit 2: `<path> could not answer for opensoft/openGlass @ <40 hex>: no such directory` |
| a file | `NotADirectoryError`, exit 1 | exit 2, `... it is a file, not a directory` |
| under a parent this user cannot enter | `PermissionError`, exit 1 | exit 2, `... Permission denied` |
| a symlink loop (not in the issue) | `RuntimeError` out of `Path.resolve()` on 3.12, exit 1 | exit 2, `... Too many levels of symbolic links` |

The real run and `--dry-run` give the same stdout and stderr, and nothing exists afterwards (no remote directory, no work directory; for adopt, no plan file).

What changed, in `scripts/shape_materialize.py` only:
- `pin_digest_from_source` catches `OSError` as well as `Refusal` and raises the existing `pin-source-unreadable` with its remediation unchanged. The message names the path and the reason.
- `stat()` judges the source inside the guard, as #312 does in `validate-pins.py`, and the sentences are that file's: a missing path, or one beneath a file, is "no such directory"; a file is "it is a file, not a directory"; any other `OSError`, from `stat()` or from git's `cwd=`, is the system's own reason (`strerror`). It is not `exists()`/`is_dir()`, which before 3.14 swallow only a missing path. A source `stat()` can read but `cwd=` cannot enter, a directory with no search bit, fails in `tree_digest` and the same guard answers it.
- `declared_pin_values` resolves the path through a guarded `_resolved_pin_source`. `Path.resolve()` raises a `RuntimeError` on a symlink loop before 3.13 (an `OSError` since), before the digest was asked for. As typed, `stat()` meets the loop and says so.
- A source that answers is read exactly as before: a relative path resolves against the working directory, a symlink to a clone is followed, `digest_source` reads `local-clone (<path as typed>)`, and the digest is `tree_digest`'s.

`tests/test_pin_source_unreadable.py` (46 tests in the #339 commit; fix round 1 adds 17 for S1 and moves one, below) runs each case through `scaffold-project.py --dry-run`, its real run and `adopt-project.py plan`: missing, a file, beneath a file, relative and missing, a parent and a source with no search bit (skipped on Windows and as root, by a mark, so before any fixture builds a directory), a 300-character name, a symlink loop (skipped on Windows) and a directory that is no clone. Each asserts exit 2, the refusal naming the path and the reason, its remediation, no traceback and nothing created. It also holds `--dry-run` and the real run to the same stdout and stderr, checks the path is printed escaped under an ASCII stderr and as a surrogate when it is not UTF-8, and checks that a relative source and a symlink to a clone read as before. The session `gh` guard fails any test whose run called `gh`.

origin/main ab623dd reached this branch through implQ's branch: 34dbd86 is a30091f merged with ab623dd. One file conflicted there, `templates/assembly-root/scripts/validate-pins.py`, in seven hunks, because #312 (8cdca5e) replaced `keyed_sources` and `default_source` with `PinSources` and #275 added `env_shared` and `shared`. #312's blob is the same at its PR head, at the squash and at ab623dd, so it landed the file implQ resolved against. The resolution keeps #312's side in all seven and adds #275's on top:

| # | hunk | resolution |
|---|---|---|
| 1 | `resolve_neutral_pin_source` signature | #312's `sources: PinSources` and 3-tuple return, plus #275's `env_shared: bool = False` |
| 2 | its docstring tail | #312's `Returns (path, how, typed)` text, plus #275's "Rule 2 is skipped when `env_shared`" paragraph |
| 3 | `_recompute_neutral_pin_digest` signature | #312's `sources`, plus #275's `shared: frozenset[str]` after `report` |
| 4 | the `resolve_neutral_pin_source(...)` call inside it | #312's 3-tuple unpack, plus `env_shared = product.casefold() in shared` and the extra argument |
| 5 | `_check_one_neutral_product_pin` signature | #312's `sources`, plus #275's `shared` |
| 6 | its call of `_recompute_neutral_pin_digest` | `sources, report, shared` |
| 7 | `_check_neutral_product_pins` docstring and body | #312's `PinSources(...)` and `_note_unused_pin_sources`, plus #275's paragraph, `_report_pin_source_collisions(products, report)` and `shared = frozenset(pin_source_env_shared(products))` |

This branch's own merge of 34dbd86 (5690235) had no conflict, and neither had its merge of #409's fix round, 6494714 (583f647). That fix round made `neutral-pin-source-collides` a note and keyed "one product" by `repo_shape.pin_product_key`. At 421e4e5, `templates/assembly-root/scripts/validate-pins.py`, `scripts/repo_shape.py` and `tests/test_pin_source_env_collisions.py` were byte-identical to the tree `git merge-tree` gives for 6494714 with origin/main 3ef059c (blobs 5604007, 091966b, 5220787), so this branch added nothing to those three files beyond #409. The 34dbd86 merge brought in #335, #312, #317, #270 (f795cf7), #344 and #349 with no conflict. Earlier merges of origin/main (87373a6, for #309's reworded constant, and e43fa95) are unchanged.

Fix round 1 merged origin/main three more times:
- **cd1fc79 (2bcfa69).** It brought #245, #351, #350, #345, #372, #371, #354, #380, #377 and the rest since ab623dd, with no conflict.
- **45e01f4 (4098b61).** It brought #396's `tests/test_agents_md_codes.py`. That test finds `scaffold-pin-collides` as a string constant of `scripts/shape_materialize.py`, which `SHIPPED` lists.
- **3ef059c (421e4e5), #314.** One conflict, one hunk in `adopt-project.py`. This branch's `_refuse_an_unrecordable_pin_source` and #314's section "The project must be able to hold what `execute` writes into it (#261)" were added at the same place; both are kept. Where the two meet, lane 5's earlier recipe applies:
  - `_flag_plan` builds each pin as the `PIN_FIELDS` mapping `plan` writes, not `list(pins)` of products. So the plan `plan` asks the validator about is the plan it writes.
  - #314's `test_plan_asks_what_check_asks_of_the_same_keys` plans `--pin openGlass@<commit> --pin-source <stand-in>` where it planned the name alone, which `plan` now refuses.

Then f9ed46f, after #409 squash-landed, and 73d65c6 after it (top of this description).

On the merged tree exactly one test failed: `tests/test_adopt_template_values.py::test_check_finds_and_execute_refuses_a_hand_edited_value[pins-surrogate]`. It plans, then hand-edits the plan to `pins: ["openX\ud800"]`, a name-only pin with a lone surrogate. Before this branch the refusal's headline was `REFUSED plan-unwritable-value`. A name-only pin in a plan is now `plan-pin-unrecorded` (#273), so `check` has a second, independent finding for that very plan, and the headline is the first finding's:

```
REFUSED plan-pin-unrecorded: 2 finding(s) ...
FINDING plan-pin-unrecorded: `pins:` entry 1, 'openX\ud800', is a name with no commit and no digest ...
FINDING plan-unwritable-value: a `pins:` item is 'openX\ud800', which `execute` writes into the assembly root's `project.yaml`, and it holds a lone surrogate ...
```

Nothing about #270's contract changed. The lone surrogate is still found as `plan-unwritable-value`, listed under the refusal, and `execute` still refuses before any leg exists. `check` builds `_leg_findings`, where `_pin_findings` sits, before it calls `_value_findings`, so the pin finding comes first. d806e8f changes one assertion for that row, `pins` only: the headline is `REFUSED plan-pin-unrecorded: ` where it was `PLAN_REFUSED`. The other 15 rows and the finding assertions, including the `plan-unwritable-value` line, are untouched.

Lane 5's review of 7a94b6b found 3 should-fix and 5 nits. Each is answered here:
- **#409's `pin_product_key` (review N1).** The merge of #409's fix round could not carry it, because this branch had moved the function that used it out of `scaffold-project.py`. `pin_variable_collisions` now keys each pin by that function, where it casefolded. `openß` and `openss` are two pin files and one `SHAPE_PIN_SOURCE_OPENSS`, and a casefold named each of the two pins twice. No `--pin` or recorded `pins:` entry `PIN_ARG_RE` admits can spell either, so the test asks the function directly. `refuse_colliding_pins`' docstring now says, in #409's words, that the root's `validate-pins.py` only notes such a pair.
- **S1, a `--pin-source` path that is not printable.** `pin_digest_from_source` names the path as typed unless it is not printable (`str.isprintable`), and then as Python's `repr` spells it (`shown_source`). That covers the refusal's head and git's own reason, which names the directory it ran in. Printed raw, an ESC reached the terminal, and a line break or a carriage return began a line of the path's choosing, a forged `REFUSED` line among them, from the scaffold and from adopt. The traceback #339 replaced had shown a `repr`. A printable path is named byte for byte as before.
- **S2, no `gh` on PATH.** `pin_digest_from_gh` refuses as `gh-not-found`, exit 2, as `repo_shape.tree_digest_from_gh` names it, and offers `--pin-source`. It was a `FileNotFoundError` traceback, exit 1, from the scaffold and, new with this branch, from `adopt-project.py plan --pin`.
- **S3, a hand-edited `commit:` or `tree_sha256:`.** The lane's decision is to accept it and document it (option (c)); no behaviour changed. A pair that disagrees passes `check` and `execute` (`adoption verified`), because neither reads a network for a pin, by design. The adopted root's own `validate-pins.py` then exits 1 with `neutral-pin-digest-mismatch` wherever it can read the product. `check` now says so once, after the pins it will write: "each pin as `plan` read it, not read again here: the root's own `validate-pins.py` recomputes its digest wherever it can read the product". Options (a), `execute` recomputes before it creates the legs, and (b), `check` recomputes from a clone still on the machine, are filed as #449.
- **N2, a `--pin-source` that `plan` cannot record.** `plan` asks `check`'s question (`one_printable_line`) of the path it records in each pin's `digest_source:`, before any digest is read. It refuses one that fails as `adopt-unwritable-value`, in #270's words (`unwritable_reason`), or as "an invisible formatting character" where those name nothing. Before, it was planned, exit 0, and printed raw in the report. `check` then refused it as `plan-pin-malformed` and said to re-run the same `plan`. The report's `pin` line and `check`'s `write` line now spell the digest's source with `_shown`. The scaffold half is pre-existing (a line break in the path is written into the pin file; a byte that is not UTF-8 dies after all three repositories exist) and is filed as #450.
- **N3, six hunks no test guarded.** Each now has a test that fails when it alone is reverted:
  - the two name guards (a `pins:` name holding an ESC and a forged line);
  - the lower-casing of the commit and the digest (an upper-cased hand edit);
  - the `digest_source` printability check;
  - the not-text check (`commit: 1234567` unquoted);
  - the gitlink filter (a submodule kept where the pin is written is `plan-submodule-root` alone).
- **N4.** This body.
- **N5, #339's third decision.** A `--pin-source` that nothing reads is still silent, now in adopt too. This PR does not decide it. #451, filed, lays out refuse, note and leave, so closing #339 loses nothing.

Lane 5's second review, of 421e4e5, found no blocking item, 2 should-fix items and 3 nits. The two should-fix items are this body: the landing after #409 (above) and #386 (above). The code needed no change for them. f63d1ed adds two tests and no code:
- **n1. `plan` asks nothing of a `--pin-source` no `--pin` records.** With the `args.pin and` guard dropped, such a path holding an ESC or a zero-width joiner was refused in a sentence naming no file ("`execute` writes into , and ..."), and every test still passed. `test_a_pin_source_no_pin_records_is_not_asked_about` holds the plan to exit 0 and `pins: []`, with the path named nowhere in the plan or the report. The ESC row is POSIX-only by the file's `POSIX_NAMES` mark; the zero-width joiner row runs everywhere. It holds #451's answer today, silence, so a decision there changes this test on purpose.
- **n2. A printable `--pin-source` is printed as it is.** With `_spelled` in place of `_shown` in `plan`'s `pin` line, every test still passed. `test_a_printable_pin_source_is_printed_as_it_is` holds `plan`'s `pin` line, and `check`'s `write` line for the plan `plan` wrote, to their exact text, ending `(local-clone (<path>))` with no backtick.
- **n3** needs a code change and is left out (below).

**Fix round 2, on f63d1ed.** Each run had a `gh` stub first on PATH (it logs and exits 97), `GH_TOKEN` bogus, an empty `GH_CONFIG_DIR`, `git-filter-repo` 2.47.0 next on PATH, and the identity blanked with `GIT_CONFIG_COUNT`; the session `gh` guard came first inside each run.
- **The pin set: 812 passed, 2 skipped.** It covers adopt pins, adopt plan, #339's file, #275's tests, the pin and neutral-pin validators, git output decoding, adopt template values, CLI reference, scaffold pin and reuse, scaffold pin collisions, #314's two files and #396's file. The skips are `test_adopt_plan.py:403` (no MedxEHR clone) and `test_adopt_template_values.py:223` (this host reads `git config user.name` from the repository, not from `GIT_CONFIG_GLOBAL`). That is fix round 1's 809 passed, plus the three new rows.
- **Shape doctor and scaffold leg-path collisions: 369 passed, 3 skipped** (the PowerShell line exists only on a Windows host).
- **Mutation probes**, each one line changed in a throwaway worktree, over `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` less the five end-to-end adoptions:
  - the `args.pin and` guard dropped: 2 failed, both n1 rows;
  - `plan`'s `pin` line `_spelled`: 1 failed, the n2 test;
  - `check`'s `write` line `_spelled`: 2 failed, the n2 test and `test_an_upper_cased_hand_edit_is_written_lower_cased`;
  - `plan`'s `pin` line raw: 1 failed, the no-break-space test that already guarded that hunk.

**On the merged tree, 73d65c6** (the merge f9ed46f and the `_read_pins` commit), with the same harness:
- **The pin set** above, with #445's `test_adopt_late_failure.py`, `test_adopt_git_reads.py` and `test_adopt_filter_repo_failure.py` and #387's `test_adopt_empty_symlinks.py` and `test_adopt_root_manifest.py`: **988 passed, 2 skipped**. The skips are the two above.
- **Shape doctor, scaffold leg-path collisions and the adopt suites:** **967 passed, 3 skipped** (the PowerShell line). The adopt suites are e2e, execute refusals, verify, root shape paths (#387's too), leg-path collisions, clone template, ignored shape files and submodules.
- **On the merge alone, f9ed46f** (the same tree as before the `_read_pins` commit): `tests/test_adopt_late_failure.py` gave 3 failed, 83 passed, the three `AttributeError` rows above.
- `render-cli-reference.py --check` is ok on the merged tree. pyflakes reports the same four warnings on `adopt-project.py`, `scaffold-project.py` and `scripts/shape_materialize.py` as before the merge.

**Fix round 1, on 421e4e5.** Each run had `git-filter-repo` 2.47.0 on PATH and the identity blanked with `GIT_CONFIG_COUNT`. A `gh` stub that logs and exits 97 was first on PATH, with `GH_TOKEN` bogus and an empty `GH_CONFIG_DIR`, and the session `gh` guard came first inside each run.
- **The pin set:** 809 passed, 2 skipped (the same files and skips as above).
- **Doctor and scaffold:** 782 passed, 3 skipped (the PowerShell line exists only on a Windows host). It covers shape doctor, scaffold leg-path collisions, scaffold e2e, referent chain links, naming policy, repo hygiene and #351's free-text values.
- **The adopt suites:** 882 passed. It covers e2e, execute refusals, verify, spec-only, leg paths, root shape paths, plan escapes, leg-path collisions, clone template and submodules.
- **Before the #314 merge,** on 74ce4d9's tree: the first two sets gave 577 passed, 2 skipped and 782 passed, 3 skipped. On 2bcfa69's tree (the merges alone, before the fix) they gave 542 passed, 2 skipped and 782 passed, 3 skipped. After the 45e01f4 merge, `tests/test_agents_md_codes.py`, repo hygiene and CLI reference: 110 passed.
- **Lane 5's second review** also ran 421e4e5 merged with main cb7063f and with 53bee5b: the pin set, the manifest-value set, and the doctor set with #357's `tests/test_doctor_validator_rows.py`. All passed, with only the skips above.

Proof that fix round 1's tests test something:
- **On 2bcfa69's tree,** with this head's two test files laid over it, 31 of the new rows fail:
  - S1: 17;
  - N2 and its report spelling: 7;
  - S2: 3;
  - the `pin_product_key` port: 2;
  - `check`'s S3 line: 1;
  - the not-UTF-8 `adopt plan` row: 1.

  The N3 rows pass there, as they must: they guard hunks this branch already had.
- **Mutation probes.** Each hunk was reverted alone, in a throwaway worktree, and both test files were run: 18 of 18 were caught.
  - **The port:** the casefold keying (2 fail); the casefold lookup alone (2).
  - **S1:** the path named raw (13); git's reason named raw (2); every path `repr`'d, printable or not (27).
  - **S2:** no `except` (3).
  - **N2:** no `plan` check (11); only #270's question asked (1); the formatting-character reason said otherwise (1); the report's `pin` line raw (1); `check`'s `write` line raw (1).
  - **S3:** the line dropped (1).
  - **N3:** the `_pin_fix` name guard (1); the `_read_pins` path guard (1); the lower-casing (1); the `digest_source` check (1); the not-text check (1); the gitlink filter (1).
- **The issue rows reproduced.** S1, S2 and N2 were reproduced on 2bcfa69's tree and shown gone on the fix (a probe driving both tools; `gh` was called 0 times).

**Before fix round 1.** Runs were on 7a94b6b, with `git-filter-repo` 2.47.0 first on PATH and the repository-local `user.name` blanked with `GIT_CONFIG_COUNT` (a workstation whose standard checkout sets one fails `test_the_scaffold_refuses_with_no_elector` on main itself; CI sets none). 7a94b6b differs from ffd1297 in `tests/test_pin_source_unreadable.py` only, so the three suites that do not read that file ran on ffd1297:

- the new file: 46 passed;
- adopt pins, adopt plan, git output decoding: 172 passed, 1 skipped (the MedxEHR clone is absent);
- scaffold pin and reuse, pin collisions, e2e, referent chain links, neutral pin and pin validators, #275's tests, naming policy, repo hygiene, CLI reference, scaffold leg-path collisions: 571 passed;
- shape doctor: 254 passed, 3 skipped (the PowerShell line exists only on a Windows host);
- the new file with repo hygiene, git output decoding and CLI reference, once more after the last edit: 176 passed.

Earlier on 5690235 and d806e8f, before the #339 commit: the adopt suites (e2e, execute refusals, verify, spec-only, leg paths, root shape paths, plan escapes, leg-path collisions, clone template) 740 passed, and `tests/test_adopt_template_values.py` with #273's other adopt tests 489 passed, 2 skipped. They were not re-run on 7a94b6b: the #339 diff touches `pin_digest_from_source` and the call in `declared_pin_values`, which those suites reach through `test_adopt_pins.py` only. Nothing was run on macOS or Windows.

Proof the new tests test something:
- **On d806e8f.** 38 of the 46 fail, each on the traceback and its exit 1. The 8 that pass are the unchanged behaviour: the not-a-clone refusal (4 rows) and the valid-source rows (4).
- **Mutation probes.** Ten hunks reverted one at a time, each failing at least one new test: the `OSError` catch (16 fail), the catch narrowed to `OSError` only (21), the missing-path clause (9), the clause narrowed to `FileNotFoundError` (3), the `S_ISDIR` judgement (3), the loop guard (4), no resolve at all (3), the old `.resolve()` call (4), the reason as `str(exc)` (12), and the file sentence as the OS's (3).
- #273's own 52 tests still fail on c7c02fa (51 fail or error; the one that passes checks the test's own table), and its 18 mutation probes were each caught.

**Re-verified after the merge.** An independent re-verification of f9ed46f and 73d65c6 found the code correct:
- #409 is carried whole. A 30,000-case fuzz of `--pin` lists through #409's final refusal and this branch's port differed in 0 cases.
- `scaffold-project.py` equals the tested file plus main's own changes that are not #409's (same patch-id).
- `_commit_the_split` keeps #445's `try` and #394's `append`.
- `_read_pins` reads a loaded plan as before.

Its findings were all in this description (the #506 bullet and three stale facts), now fixed. Runs on the head and on its merge with main 48585e6: 988 passed, 2 skipped; and 967 passed, 3 skipped.

`git merge-tree --write-tree` was run at 01:48Z on 2026-10-13, comparing hunks per file. Each PR head, fetched from `refs/pull/<n>/head` with no `gh`, was merged with 73d65c6 and with origin/main 9736424 alone. Two sets were asked about:
- every PR this description listed before that main does not now contain;
- every PR numbered above #468 whose head main does not contain: #473, #474, #490, #491, #493, #494, #495, #496, #497, #503, #504 and #505.

**Open state, read with `gh` by the coordinator at 02:00Z and refreshed at 02:25Z after a re-verification on main 48585e6.** Every pull request named below is open. #184, #94, #96, #98, #100 and #161 are closed, and #465 landed as 48585e6. `fix/adopt-plan-quote-yaml-keywords` has no pull request. #506 (`fix/scaffold-reuse-seeded-legs`) opened after this list was made, and the re-verification reads it.

- **Landed since the last draft of this list:** #343, #346, #355, #358, #373, #378, #382, #387, #394, #397, #401, #404, #409, #419, #452, #456, #465, #466 and #484. #394's `_commit_the_split` conflict and #401's `docs/cli.md` conflict are both resolved in f9ed46f, above.
- **Clean, 20:** #266, #347, #356, #374, #376, #384, #395, #453, #467, #468, #473, #474, #490, #491, #493, #494, #495, #497, #503 and #505. #453, #468 and #491 were updated onto main since the first read and now merge clean. #507 (lane 5's #486-#488) shares only the generated `docs/cli.md` with this branch and merges clean with it.
- **Conflicting with main alone, and this branch adds no hunk:**
  - #402: `shape-doctor.py` 5;
  - #496: `shape-doctor.py` 10 and `tests/test_shape_doctor.py` 1.

**Conflicts this branch adds:**
- **#435** (3efcc7a, lane 2's `fix/adopt-plan-field-types`, for #386).
  - It adds 2 hunks in `adopt-project.py`: 5 here, against 3 with main alone. They are `render_plan`'s `pins:` line and `Plan.pins`.
  - Keep this branch's `_pin_lines` and `_pin_products`. The rest is under "Where the meaning meets".
- **#504** (37ef29a, `fix/adopt-root-kept-symlinks`).
  - It adds 1 hunk in `adopt-project.py`: 2 here, against 1 with main alone. It is the materializer call in `_commit_the_split`.
  - Keep both arguments and both comments: `append=append, neutral_pins=_recorded_pins(plan), spell=_spelled`.
- **#506** (119d8c4, `fix/scaffold-reuse-seeded-legs`).
  - It adds 1 hunk in `scaffold-project.py`. With main alone, #506 conflicts only in `docs/handbook.html`.
  - #506 inserts `LEG_TEMPLATES` right after `declared_pin_products`, which this branch moved out, and rewrites `_reusable_remotes` further down. Keeping both sides breaks the file with an unterminated string literal.
  - Keep only #506's `LEG_TEMPLATES` block. Drop #506's `declared_pin_values`/`declared_pin_products`, which moved here, and this branch's three-line `_reusable_remotes` opener, which #506 rewrites below. Resolved that way, pyflakes is clean and #506's scaffold tests pass (88).
- **`fix/adopt-plan-quote-yaml-keywords`** (ed9968f; a branch with no pull request).
  - It adds 2 hunks in `adopt-project.py`: 3 here, against 1 with main alone.
  - One is `render_plan`'s `pins:` line. Keep `_pin_lines`, which already sends product, repository and `digest_source` through `y`.
  - The other is `_refuse_unholdable_values`' signature. Keep `pins: dict`, with that branch's `_validator_judged` and `_named_as_read` above it.
- `fix/adopt-late-failure-notice` is no longer on origin. #445, the late-failure notice, is on main and merged here.

**Where the meaning meets:**
- **#435 and #386.** The coordinator has said this on #435: #435 (comment)
  - #435's `_plan_key_findings` (`plan-bad-pins`) keeps only text items in `pins:`, and its `PINS_REMEDIATION` tells a person to write `pins: [openGlass]`.
  - This branch writes each pin as a mapping, and it makes a name alone `plan-pin-unrecorded`.
  - Read from #435's code, not run: on a tree with both, the plan `plan --pin` writes is `plan-bad-pins` in `check`.
  - Whichever lands second reconciles them, with three changes:
    - `_plan_key_findings` takes this branch's mappings, or leaves `pins:` items to `_read_pins`;
    - a `pins:` that is not a list gives one finding, not both `plan-bad-pins` and `plan-pin-malformed`;
    - `PINS_REMEDIATION` says the mapping `plan` writes.
  - Until that is done, `tests/test_adopt_pins.py::test_a_plan_with_a_whole_pin_checks_ok` fails.
- **#314.** It landed, and it is merged here (above).
- **`fix/adopt-plan-quote-yaml-keywords`.** Its pin tests are written against the name-only `--pin`. They need the `name@commit` form, and their expected `pins: [...]` line becomes `_pin_lines`'s mapping.

- **Two unwritable values are refused in two runs (lane 5's second review, n3).** `plan --elected-by 'A<LF>B' --pin openGlass@<sha> --pin-source <a path holding an ESC>` refuses the elector alone, exit 2. Re-run with the elector retyped, it refuses the source; #270's refusal names every unwritable value at once. Reproduced on 421e4e5, with `gh` called 0 times. One refusal would need a code change, not made here:
  - `_refuse_an_unrecordable_pin_source` would split into a function that returns the sentence;
  - `_checked_plan_inputs` would ask it before #270's `unwritable_values` raise, and raise one `adopt-unwritable-value` that names both, with both remediations;
  - a test would add the two-value row.

  N2's question is `one_printable_line`, not `unwritable_reason`, so it cannot simply join #270's list.
- **Docs.** The README, AGENTS.md and `docs/handbook.html` are unchanged, because none of them calls adopt's `--pin` a name. A sentence saying adopt now takes the scaffold's `--pin` is a follow-up. It costs an AGENTS.md line-cap raise, and a README edit forces the handbook's README blob to change.
- **A product spelled `null`, `true` or `false`.** The scaffold already accepts `--pin null@<sha>`: it exits 0 and builds a root whose `validate-pins.py` exits 1 `neutral-pin-entry-malformed`. Adopt's `plan` writes `product: null` through `y`, and `check` then finds `plan-pin-malformed` and refuses it before any leg exists. The one fix for both tools is in `parse_pin`, and it would change the scaffold's behaviour.
- **Wording shared with the scaffold.** `digest_source` is written bare into the pin file, so a `--pin-source` path holding ` #` is cut short there. `templates/assembly-root/contracts/neutral-product-pin.yaml` and `validate-pins.py`'s `neutral-pin-missing` remediation name only the scaffold as the writer. Two comments in `scripts/repo_shape.py` still name the scaffold's `pin_digest_from_gh`, in a digest-pinned shipped copy.
- **Filed as follow-ups:**
  - #449: S3, a hand-edited `commit:` or `tree_sha256:` that disagrees, with options (a) and (b);
  - #450: the scaffold half of N2, a `--pin-source` path no file can hold;
  - #451: #339's third decision (N5), a `--pin-source` that nothing reads.
- **`validate-pins.py` prints a `--pin-source` path raw** in `neutral-pin-source-unreadable`, the twin of S1. It is a shipped template that #409 and other open PRs edit, so it is left for its own change. No issue is filed for it yet.
- **Windows and macOS** were not run here; CI ran both (below). Two assertions that depend on what Windows answers for a path (a name past the filesystem's limit, a path beneath a file) check only the refusal and its remediation there. Fix round 1's rows that need a control character or a byte that is not UTF-8 in a path, or a symlink to `git`, are skipped on Windows by a mark, and so is fix round 2's ESC row. The rows for a no-break space and a zero-width joiner run everywhere, and so does the in-process `gh-not-found` test.

Reviewed independently by lane 5 at 7a94b6b (3 should-fix, 5 nits: fix round 1) and at 421e4e5 (0 blockers, 2 should-fix, 3 nits: fix round 2). After #409's squash, the re-verification of f9ed46f and 73d65c6 found the code correct; its 1 should-fix and 3 nits were in this description and are fixed. GitHub's update-branch then merged main twice (33bae3f, 2170526), with no hand edit. CI's tests, tests-macos and tests-windows and SonarCloud all passed at 2170526 (main cac6bb2 merged in). On that head merged with main 57c85e9 (#490, #474 and #468, none of which touches this branch's files), the pin and adopt set with `tests/test_update_shape_landing.py` gave 1112 passed, 2 skipped, and the doctor, adopt end-to-end, unfinished-merge and bootstrap set gave 1056 passed, 3 skipped (the doctor's PowerShell line, which exists only on a Windows host).

Closes #273
Closes #339

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… text placeholders, so `--id 007`, `--tracking-branch 2026`, `--spec-path true` or `--org null` read back as typed, and `family.py add` quotes a member id alike (#259) (#395)

Brett Heap ruled on 2026-10-12: "quote #259". The templates now write each text field a tool fills from a value it was given between double quotes:

| template | fields |
|---|---|
| `templates/assembly-root/project.yaml` | `id`, `elected_on`, `visibility`, `tracking_branch`, the spec leg's `path`, the code leg's `path` |
| `templates/assembly-root/contracts/spec-pin.yaml`, `code-pin.yaml` | `submodule_path` |
| `templates/family-root/family.yaml` | `id`, `org`, `created_on`, `visibility`, `tracking_branch` |

`render` already spells a value between a YAML template's double quotes with `repo_shape.double_quoted` (#284). The single-pass `render` from #316, on main as 34ecbc2, does the same through `YAML_PLACEHOLDER_RE`. So each value reads back exactly. An ordinary value renders as it did before, plus the two quote characters.

`family.yaml`'s `org:` is quoted along with the four fields #259 names. It is the same defect in the same file: `family.py init --org 2026` and `--org null` pass `checked_value`, and on c7c02fa the holder then fails `family-org` (#307 lists the `--org null` row). Taking `org:` back out is not a one-line revert. It fails 14 of the new tests (the 8 `family.yaml:org` in-memory cases, and all 6 `family.py init` rows, because every row of `FAMILIES` passes a misread `--org`), and it would also take the `ORG` row out of `TEXT_FIELDS` and put plain `--org` values into `FAMILIES`. The lane keeps `org:` quoted, as the same defect in the same file, unless Brett says otherwise.

`scripts/family.py`: `_member_lines` writes each member's own `project.yaml` id into the holder's `members:` block. That id now reads back exactly, so a bare `007` in the row came back as 7, and `validate-family.py` would report `member-id-mismatch` for a member it had just pinned. `_member_id` quotes an id only when the reader would not read the bare text back as the same text. No row whose id reads back as text moves: `irrs` is still `id: irrs`. Two edges:

- A legacy row whose bare id the reader misreads moves once. A holder written before this change, by `family.py add` of a member scaffolded with `--id 2026`, holds `id: 2026`. The next `bump`, `add` or `remove` of that holder rewrites it as `id: "2026"`. That is harmless: `validate-family.py` compares with `str()` and passes before and after.
- Mixed versions. A `family.py` from before this change, run against a holder this branch wrote, turns `id: "007"` back into `id: 007`, and `validate-family.py` then fails `member-id-mismatch` (the member declares `007`, `family.yaml` records `7`). A current `family.py` that rewrites the holder next then writes that row as `id: "7"`. Nothing in this branch can prevent it: run `family.py` from a current checkout on a holder that has such a member.

Left unquoted on purpose: `topic` (`xf-project-<id>`), every `repository` (`<org>/<Name>`; the slash keeps it text), `SHAPE_REPOSITORY` and `DIGEST_DEFINITION` (constants), and the block or list placeholders (`NEUTRAL_PRODUCT_PINS`, `*_NAMING`, `SHAPE_FILES`).

**Why.** Every reader of these files uses `repo_shape`'s YAML subset. It reads a bare `007` as 7, `2026` and `20261011` as numbers, `1.5` as a float, `true` as True and `null` as None. The checks on the flags (`PROJECT_ID_RE`, `checked_value`, the leg-path rule) accept every one of these. Reproduced on c7c02fa, with local bare remotes only:

- `scaffold-project.py --id 2026|007|true|null|20261011` exits 0, and the project's own `validate-manifest.py` then fails `manifest-id`.
- `--spec-path 2026 --code-path 007` fails `manifest-leg-path`, and `validate-pins.py` exits 2.
- `--tracking-branch 007` pushes each leg on `007`, and the root's `bootstrap.py` then puts the legs on a local `7` with `fatal: no upstream configured`. `true` and `null` do the same on `True` and `main`.
- `adopt-project.py execute` with `--tracking-branch 007` does the same; `--id 007|20261011` gives `manifest-id`; leg paths `2026`/`007` give `manifest-leg-path`.
- `family.py init --id 2026|007|true|null|20261011` gives `family-id`; `--org 2026|null` gives `family-org`; `--tracking-branch 007|true|null` reads back as 7/True/None.

On this branch every one of those rows reads back as typed, bootstraps onto the typed branch with `origin/<branch>` as upstream, and passes the root's own validators.

**Every reader checked.** Every reader reads these fields through `load_yaml`/`parse_yaml` into a dict. None uses a line regex, `split(":")`, `startswith("<field>: ")` or a whole-line rewrite of them:

- `templates/assembly-root/scripts/bootstrap.py`: `tracking_branch`, leg `path`.
- `validate-manifest.py`: `id`, `elected_on`, `visibility`, leg `path`.
- `validate-pins.py`: `submodule_path` against the manifest's `path`. The pin side is read as it stands and the manifest side goes through `str()`, so the two differ in type exactly when a bare value is misread (that is the `pin-path-mismatch` row above). With both lines quoted they are the same text.
- `validate-repository-naming.py`: `str(id)` for the topic.
- `templates/family-root/scripts/validate-family.py`: `id`, `org`, `created_on`, `visibility`, `str()` member-id compare.
- `siblings.py`, family `bootstrap.py`.
- `scripts/bump-leg.py`: `tracking`, leg `path`. Its `rewrite_pin` rewrites only `commit:` and `tree_sha256:`.
- `update-shape.py`: `tracking_branch_of`. `rewrite_manifest` touches only the `shape:` block.
- `scripts/family.py`: `tracking()`, the member's id, and the `members:` rewrite. The member-id writer is the one fixed here.
- `shape-doctor.py`: leg `path`, member row `path`, working-clone `str(id)` compare.
- `adopt-project.py`: plan reads and `_template_values`.
- `setup-project.py`: `family.yaml` `name`/`kind` only.
- `scripts/shape_advisory.py`: the single-repository record only.

The `str()` fallbacks (`bootstrap.py`, `adopt-project.py`'s `checked_value`) stay, because projects made before this keep their bare lines.

**Pins.** The four files are TEMPLATED, not COPIED, so no row of any project's `contracts/shape-pin.yaml` names them. `Atlas` was scaffolded and `family.py init Contoso` was run on c7c02fa and on this branch. Each `contracts/shape-pin.yaml` differs only in `commit:` and `digests.tree_sha256`, which identify the standard itself and change with every commit; every per-file `sha256` row is identical. `update-shape.py check` of the c7c02fa project and holder against this branch reports every copied file `unchanged` (11 and 10 rows): "no copied file differs; `apply` would move the pin alone".

**Tests.**

- New: `tests/test_template_text_placeholders.py`, 123 tests:
  - Each of the 13 fields is rendered with each of 8 values the reader misreads (`2026 007 1.5 true null 20261011 ~ False`), and must read back exactly.
  - 6 `scaffold-project.py` runs, then a recursive clone, `bootstrap.py`, the three validators, and `bump-leg.py`, which refuses on the tracking branch and finds the leg at its path on a branch of its own.
  - 6 `adopt-project.py` `plan`/`check`/`execute` runs into local bare remotes, then a clone, `bootstrap.py` and the validators.
  - 6 `family.py init` runs.
  - One `family.py add` of members whose ids are `007` and `true`.
  - **All 123 fail on c7c02fa.**
- `true`/`null` through adopt: `plan` writes them bare into the plan, and the plan's reader then misreads them. That belongs to the plan writer, #181 (lane 1). Those two rows quote the value in the plan by hand, as #181's writer will, and test `check`/`execute`.
- Mutation probes: reverting any one of the 13 quoted lines fails 8 cases of its field. Reverting `_member_id` fails the member test (the rows read 7/True).
- The `try/except YamlError` in `_member_id` is guarded by the follow-up commit. The review found that calling the parse directly left the member test passing. Ids such as `[`, `{`, `&a`, `*a` and `!t` reach the `except` (they raise `YamlError` bare), and `_member_id` returns each one exactly, as it does 34 adversarial inputs in all (the review's in-process probe). `test_a_member_id_the_reader_refuses_bare_is_rewritten_between_quotes` (7 cases: `[`, `{`, `&a`, `*a`, `!t`, an unclosed `'` and an unclosed `"`) opens a hand-edited holder whose row holds the id between double quotes, rewrites the `members:` block with `Family.rewritten`, and asks that the id read back exactly and the text come out byte for byte. With the `try/except` removed all 7 fail with the reader's `YamlError`, and with the `except` returning the bare text all 7 fail too; the old member test still passes under the first of those mutants.
- 14 existing tests that asserted or edited a bare rendered line now spell it quoted: `test_manifest_validator` ×5, `test_pin_validator`, `test_reference_default`, `test_scaffold_e2e` ×2, `test_setup_project_py`, `test_setup_sh`, `test_family`, `test_shape_doctor` ×2.
- Targeted runs on this branch:

  | files | result |
  |---|---|
  | new file + `test_template_quoted_values`, `test_scaffold_e2e`, `test_scaffold_pin_and_reuse`, `test_scaffold_leg_paths`, `test_scaffold_leg_path_collisions`, `test_reuse_visibility`, `test_reference_default` | 405 passed |
  | the six adopt files | 491 passed, 1 skipped (the MedxEHR clone is not set) |
  | `test_family`, `test_family_add_refusals`, `test_family_member_path`, `test_manifest_validator`, `test_pin_validator`, `test_bump_leg`, `test_update_shape`, `test_update_shape_pr_base`, `test_setup_project_py`, `test_park_resume_targets`, `test_repo_hygiene`, `test_cli_reference`, `test_setup_sh` | 603 passed |
  | `test_shape_doctor` | 241 passed, 3 skipped (Windows-only), 1 failed; that one test was fixed, and with its sibling it reran at 2 passed |

**The review.** An independent opus review at 233dc71 (the branch's first commit) found no code defect. Its verdict was CHANGES NEEDED (0 blockers, 2 should-fixes, 6 nits) because it asked for this description and its composition counts to be corrected, and they are. Its evidence:

- **Readers and rewriters.** Every reader in the shipped tree reads the 13 lines through `load_yaml`/`parse_yaml`, and no line regex or whole-line rewrite touches them. It read `bump-leg.py` `rewrite_pin`, `update-shape.py` `rewrite_shape_pin` and `rewrite_manifest`, `family.py` `rewritten`, the three validators, `siblings.py`, both `bootstrap.py`, the doctor, `setup-project.py`, the workflows, the Makefile and `setup.sh`. No test among main's 17 commits since c7c02fa (through #245's 161c6c9) edits one of these lines bare.
- **`_member_id`, the one rewriter changed.** A mutant that writes every id bare fails the member test (the rows read back as `{'IRRS': 7, 'IRSS': True}`). A mutant that quotes every id fails `b"    id: irqs" in lines`. So the minimal-churn choice is tested both ways.
- **Old projects and holders.** Against a control commit (c7c02fa plus an unrelated README edit), a project and a holder made on c7c02fa give identical `update-shape.py check` and `shape-doctor.py` output, up to the shas. `check --at 233dc71` reports all 11 project files and all 10 holder files `unchanged` and says `no copied file differs`. The doctor's verdict is `COMPLIANT, SHAPE BEHIND (... no copied file differs)` for the control and for 233dc71. This branch's `family.py add` and `bump` on a c7c02fa holder leave the old `atlas` row and the header byte for byte, apart from the legacy-id edge described at the top.
- **Tests.** All 123 new tests fail on c7c02fa (`123 failed`) and pass on 233dc71 (`123 passed`). The 14 changed existing tests pass (49 passed).
- **Not covered.** Windows and macOS were reviewed by reading and not run: the plan edit and the member-row check are in bytes, and nothing writes to git's stdin in text mode. Of `test_shape_doctor.py` and `test_family.py` the review ran only the changed tests.

**Follow-up commit.** A second commit, `3894aa8`, answers two nits from the review, and changes no behaviour. The `_member_id` docstring said "no existing holder's `members:` block moves by a byte", which was false: the next `bump`, `add` or `remove` of a holder written before this change rewrites a legacy `id: 2026` row as `id: "2026"`, harmlessly, because `validate-family.py` compares with `str()`. The docstring now says exactly that, and a grep of every comment and docstring the branch added for "by a byte" and "moves" found one more copy, in the member test's docstring, corrected the same way. The `try/except YamlError` in `_member_id` is guarded by a new test (see Tests), so the new file holds 130 tests. Every count above that says 123 (123 new tests, all failing on c7c02fa and passing on 233dc71) is for the first commit; the follow-up commit adds the 7. The `except` also carries a comment saying which ids reach it.

**Composition at the landing head (4984651).** `49846518` is GitHub's update-branch merge of origin/main `cc8f2da4` (#376, a `--trailer` value carried into the commit as typed) into `3220c9ee`. Git reported no conflict, and the merge has no hand edit. #376 changed `scripts/family.py` and `tests/test_family.py`, which this branch also changes. None of the lines it added under `tests/` names a key the templates now quote. The coordinator ran targeted tests on this head, Linux only, before CI:
- main's changed test files (`tests/test_commit_trailers.py`, `tests/test_family.py`, `tests/test_bump_leg.py`, `tests/test_update_shape.py`) with `tests/test_family_add_member_id.py`: 421 passed, 1 skipped (a case-insensitive disk only);
- this branch's template set (`tests/test_template_text_placeholders.py`, `tests/test_template_quoted_values.py`, `tests/test_scaffold_manifest_values.py`, `tests/test_bootstrap_spelling.py`, `tests/test_bootstrap_after_bump.py`) with the hygiene, CLI-reference and AGENTS.md-codes tests: 652 passed.

**Composition at 3220c9e.** `3220c9ee` is GitHub's update-branch merge of origin/main `70d0d1b3` into `e5e768bc`. It brings in three commits: #491 (da19735), in which `setup-project.py` asks at the person's own terminal; #374 (7ab11cf), update-shape's NEXT quoting; and #384 (70d0d1b), adopt's `_spelled` plan values. Git reported no conflict, and the merge has no hand edit. #491 changed `tests/test_setup_project_py.py` and `tests/test_setup_sh.py`, which this branch also changes. A grep of the lines main added under `tests/` for the keys the templates now quote found two hits, and neither reads a rendered template line: `tests/test_update_shape_next_quoting.py` writes its own `project.yaml`, and `tests/test_adopt_question_and_submodule_spelling.py` edits a plan's `- path:`/`question:` entry, which `adopt-project.py` writes bare.

The coordinator ran targeted tests on the same tree (`e5a74d19`), Linux only, before CI:
- six of main's eight changed test files (the setup, update-shape, `openRepoShape` command and doctor-row tests): 365 passed;
- main's other two (adopt's spelling and repo hygiene), `tests/test_adopt_github_exits.py` and nine more files of this branch's sixteen-file set: 943 passed.

**Composition, refreshed again 2026-10-13 (head e5e768b).** `6d108d18` merges origin/main `56713e21` into `6cbbac60`. That brings in two commits: 5e02abe, the doctor's and `validate-pins.py`'s gitlink repairs, and 56713e2 (#479), in which adopt's `plan` takes the tracking branch from the source's default branch. Git reported no conflict and `docs/cli.md` auto-merged; the merge has no hand edit. Two kinds of test failed on it, and two commits fix them:
- **`7968f902`** changes one expected literal, at `tests/test_adopt_github_exits.py:675`, for `test_a_source_on_another_branch_opens_its_pull_request_against_it[master]` and `[trunk]`.
  - The test reads the adopted root's `project.yaml`, which this branch's template renders as `tracking_branch: "master"`, so it now expects the quoted line.
  - The look-alike lines at `:743` and `tests/test_adopt_tracking_branch.py:73-76` read a plan, which `adopt-project.py` writes bare, and they stay as they are.
- **`e5e768bc`** adds `git("branch", branch, cwd=source)` to `tests/test_template_text_placeholders.py::test_an_adoption_reads_back_every_value_it_was_given`.
  - All six rows had failed at `check` with `plan-tracking-branch-missing`: #479 refuses a tracking branch (`2026`, `007`, `1.5`, `20261011`, `true`, `null`) the source does not have, and the test's source had only `main`.
  - The source now carries each row's branch, in the flag rows and the by-hand rows alike. The test's assertions are unchanged.

Targeted runs, Linux only:
- On e5e768b: main's seven changed test files, 211 passed; this branch's sixteen-file set, 1223 passed, 2 skipped.
- On 7968f90: the same sixteen files gave 6 failed, which are the six rows e5e768b fixes. The scaffold, setup, doctor and family set gave 1094 passed, 22 skipped, and twelve further template-grep files gave 1259 passed.
- `render-cli-reference.py --check` is ok.

**Composition, refreshed 2026-10-13 (head 6cbbac6).** `d651b594` merges origin/main `aa4e1f59` (#508) into `d526f768`.

- **The one conflict.** Git conflicted in one file, `tests/test_adopt_manifest_values.py`, in four hunks, all against #356 (965420b, #293/#294). #356 refuses an `elected_on:` that `election_date` does not read, as `election-date-malformed`, before the validator is asked (`_manifest_values` returns None, adopt-project.py:2258).
- **Resolved by what the merged tree prints**, measured with `adopt-project.py plan`/`check`/`execute` into local bare remotes:
  - `id: "007"` checks clean and `execute` writes it as typed, so it stays in `TEXT_THE_TEMPLATE_NOW_HOLDS`.
  - `visibility: "true"` gets the validator's `manifest-visibility: visibility is 'true'`.
  - `elected_on: "20261011"` gets #294's one finding, "the plan's `elected_on:` is the text '20261011'", and the validator is not asked. That row keeps this branch's list, `TEXT_THE_VALIDATOR_REFUSES`, with main's sentence.
  - Two tests git merged cleanly now ask `said_of`, as main's own rows do: `test_check_refuses_a_text_value_the_validator_refuses` and `test_where_a_misread_rule_judges_the_rest_the_validator_still_answers`.
- **Two more failures that no hunk showed**, fixed in the same merge:
  - `[branch-tilde]` in `tests/test_scaffold_manifest_values.py` was this branch's own `xfail(strict=True)` until #355 landed as 6c2a371, so it XPASSed. The mark is off, and the row asserts `REFUSED scaffold-bad-tracking-branch:`.
  - #391's `tests/test_bootstrap_spelling.py` and #490's `tests/test_bootstrap_after_bump.py` matched `    path: spec` and `tracking_branch: main` in a scaffolded `project.yaml`, and nine cases failed on the merge. They now match the quoted lines.
- **The #507 interplay.** `6cbbac60` switches the holder to `family/members` before the real `add`s in `test_a_member_whose_id_yaml_would_misread_is_pinned_under_that_id`, so the test holds once PR #507's `family-on-tracking-branch` refusal lands.
  - On the clean merge of 6cbbac6 with #507's head 708f3bb, that file, all 13 `tests/test_family*.py` and `tests/test_shape_doctor.py::test_a_sibling_at_the_right_origin_with_another_id_is_not_the_member` give 686 passed, 19 skipped.
  - Without the switch line, the member test fails `REFUSED family-on-tracking-branch`.
- **Targeted runs on 6cbbac6, Linux only:**
  - the template, adopt-plan and manifest files (14): 995 passed, 2 skipped;
  - scaffold e2e, setup, the doctor, leg-path collisions and every family file: 1094 passed, 22 skipped;
  - the 15 further files a grep for template lines found: 1503 passed;
  - `render-cli-reference.py --check`: ok.
- **Re-verified independently** at d651b59 and 6cbbac6: APPROVE WITH NITS (0/0/3), all three in this paragraph and fixed here. The re-verification also ran the three rows, five mutations, and 26 more files (834 passed; 291 passed, 1 skipped).
- **Not run locally:** the full suite, macOS and Windows; CI's three jobs are the check.

**Composition, as measured on 2026-10-12.** The merge trees below were computed for 233dc71; the follow-up commit 3894aa8 changes only `scripts/family.py` comments and adds tests. At 15:28Z main was 3ae0109 (#245, #351, #350 and #345 had landed since the merge trees): 3894aa8 merges clean with it, and the review's re-verify ran the family tests on that merge (61 passed). Every sha below is the one it had then; main and the other branches have moved since.

`git merge-tree --write-tree --name-only`, run after a fetch with automatic maintenance off on 2026-10-12:

- **origin/main 161c6c9**, 17 commits past c7c02fa (the base): clean, tree 7ba8246. #316 (34ecbc2), #317, #318, #311 and #245 were on main by then.
- **The 46 other remote branch heads** (the 30 open PR heads and 16 branches with no open PR): clean for 44. Among them #314 (4b8da3d), #266 (3410be7), #355 (a03abcf), lane 1's `fix/adopt-plan-quote-yaml-keywords` (d38e143), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-late-failure-notice` (9f1778d), #380 (73068a3), and `fix/family-init-values` (14e2d68, #307). The two that conflict, each in the same file as it does with c7c02fa and with origin/main on its own: lane 1's `fix/adopt-root-kept-symlinks` (6c44591, `adopt-project.py`) and #358 `fix/doctor-manifest-kinds-unreadable-pin` (ebffb08, `shape-doctor.py`). This branch touches neither file.
- #314 and d38e143 each conflicted with origin/main on their own, in `adopt-project.py`. Merged with this branch, neither gained a conflict.

**Composition with #314, which lands before this branch.** Its misread gate (`manifest_misreads`) reads the template, so once the template quotes a field it refuses nothing there, and some of #314's tests expected that refusal.

Composing with #314 (landed as 3ef059c), whose misread gate asks the template how a value is written. With `project.yaml` quoting `id`, `elected_on`, `visibility` and `tracking_branch`, the gate refuses nothing on the shipped templates, and 45 of #314's tests flipped on the merge. Forty expected a refusal the value no longer needs. Thirty-four of them now assert that the value is accepted, and the six whose premise was a bare placeholder now assert that no shipped template placeholder is misread. For example: a scaffold of `--id null|true|false|007|2026`, or of `--tracking-branch true|True|TRUE|false|False|null|Null|NULL|007|2026|1.5|1.50|0`, exits 0, and the manifest holds the text it was given. A dry run prints the plan. An id derived from `--project True|Null` is held as typed. `--id=null` reaches `gh repo create`. Adopt's `id: "007"` checks clean and `execute` writes it as typed. The rule's own sentence, remediation and line-break wording are still asserted, on a copy of the standard whose `project.yaml` writes the four lines bare. Four stay refused by #314's validator gate, now in its words about the text: `visibility is 'true'` and `elected_on is '20261011'`. One is a real break. `--tracking-branch=~` reads back as `~`, which git refuses as a branch name, and it dies at `git init -b ~`, after `gh repo create`. That is #283, so the row is `xfail(strict=True)` with that reason, and it turns XPASS (and fails) once PR #355's `git check-ref-format --branch` refusal lands. `--help` for `--id` and `--tracking-branch` no longer says such values are refused, and `docs/cli.md` is regenerated.

**Composition with the other branches.** Where another branch's rules meet this one, the tests were run on the merged tree. Quoting changes what some of their tests see, and the changes are not all one kind: some tests expected a refusal the value no longer needs, some are still refused under another rule, and some guard a case quoting does not fix. Whoever lands second must tell them apart, so each branch's flips are classified below.

- **#266** (lane 2, `fix/leg-path-yaml-scalars`). 27 flips, all from one cause: its rule asks a hard-coded bare line, `LEG_PATH_LINE = "path: {}\n"`, so it refuses leg paths the quoted templates now read back. 15 are `test_the_rule_agrees_with_every_template_line_that_holds_a_leg_path` (null, Null, NULL, ~, true, True, TRUE, false, False, FALSE, 2026, 007, 0, 1.5, 1.50) and 12 are this branch's own end-to-end tests (6 scaffold rows fail with `REFUSED scaffold-bad-leg-path`, 6 adopt rows with `FINDING plan-bad-leg-path`). Counted on the merge with b2b42f8. #266's head was then 3410be7, whose rule reads each template line that holds a leg path, renders the value into it as `render` does and asks the reader, so it has no hard-coded line left. The merge with this branch was clean (tree 1e348c6); that head was read and the 27 were not re-run on it. At b2b42f8, whichever of #266 and #259 landed second had to drop or re-point the bare rule; 3410be7 re-points it already.
- **Lane 1's #181** (`fix/adopt-plan-quote-yaml-keywords`, d38e143). Its gate reads the template too, so `plan --tracking-branch 2026|007|true` exits 0 on the composed tree (exit 2 `adopt-manifest-misread` on d38e143). On the merge, `tests/test_adopt_plan_yaml_quoting.py` and this branch's 6 adopt end-to-end tests give 70 failed, 371 passed:
  - 57 refusals that are now unnecessary: `plan_refuses` ×14, `plan_names_an_id` ×2, `check_names_an_id` ×4, `check_reports` ×14 and `execute_refuses` ×14 (every value except `elected_on` and `visibility`), `text_the_loader_reads_as_other_text` ×7, and the two tests that need a bare placeholder (`test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword`, `test_the_template_line_says_whether_a_value_is_quoted`).
  - 11 guards #181 loses. See "Do not flip".
  - 2 that fail only by cascade: `execute_refuses[pins='null']` and `[pins='True']` pass alone (`2 passed`). An earlier case no longer refuses, and its `execute` pushes the split branch.
  - The writer half of #181 (`y` asking the reader, so `true`/`null`/`True` are quoted in the plan) is still needed and composes cleanly. With it, `plan --tracking-branch true` reaches the quoted template intact, and this branch's two hand-quoted adopt rows become flag-driven.
- **Do not flip these 12 to "accepted".** Each still needs a refusal or a finding, and quoting does not supply it:
  - **`~` (1, #314).** `test_a_value_the_reader_misreads_is_refused_before_anything_is_made[branch-tilde]`. `--tracking-branch=~` is not a branch name git accepts, and #314 refused it only as a misread. On the merge it passes the plan and dies at `git init -q --bare -b ~ .../Atlas-spec.git` with `fatal: invalid initial branch name: '~'`, and against GitHub that is after `gh repo create` has made all three repositories. That is lane 2's #283 (PR #355). `~` must stay refused, by #283's ref-name check, which is why #314's row for it is not flipped to "accepted".
  - **A hand-edited plan value that is not text (7, #181).** `test_check_names_a_plan_value_that_is_not_text_by_what_was_read[tracking_branch='007']`, `[tracking_branch='TRUE']`, `[tracking_branch='1.50']`, `[tracking_branch='[a]']`, `[visibility='{a: b}']`, `[id='007']` and `[elected_on='20260902']`. The plan's reader reads each as something other than text, and on the merge `check` passes. Measured on the merge: `tracking_branch: 007` and `id: 007` are adopted silently as `"7"` with every validator passing; `visibility: {a: b}` pushes both legs and then fails `manifest-visibility`; `tracking_branch: [a]` is still refused at `execute` (`unsafe-value`) before any leg exists. On d38e143 alone, `check` exits 1 `plan-manifest-misread` for each of those four. Re-point the gate at "a plan value that is not a string" and these keep their guard.
  - **A quoted value the validator refuses (4, #181).** `test_check_reports_a_plan_carrying_one[elected_on='20260902']` and `[visibility='true']`, and `test_execute_refuses_a_plan_carrying_one_before_any_leg_exists[elected_on='20260902']` and `[visibility='true']`. The value stays invalid for the validator (`DATE_RE`, the visibility choices), and #181's misread gate caught it only incidentally. On the composed tree `check` passes and `execute` pushes the legs, and the gate fails afterwards. That is what main does today, and #314's validator gate (`plan-manifest-invalid`) would catch it.
- **main** (merge with ab623dd, tree cdeec7f, run by the review): this branch's new file, `test_adopt_template_values.py` (#270), `test_template_values_single_pass.py` (#316) and `test_template_quoted_values.py` give 431 passed, 1 skipped. The skip is `test_adopt_template_values.py:223`, which reads `git config user.name` and skips where the shared repository's `.git/config` sets one; it is environmental. The changed tests with `test_family_add_refusals.py` (#317, #318), `test_pin_validator.py` and `test_manifest_validator.py` give 132 passed. #270's `unwritable_reason` is value-based, not template-relative, so it refuses as it did and nothing flips. Main then added #245 (`adopt-project.py` and `repo_shape.remote_key`), which adds no test that edits a bare quoted line; it was not run on that merge.
- **#380** (lane 3, `fix/pin-rewrite-indentation`; it landed since as 1f90a0f). It rewrites `commit:` and `tree_sha256:` only, and its tests render the pins from the templates. On the merge at dc0fa7b, `test_pin_rewrite_layout.py` and the 6 scaffold end-to-end tests gave 121 passed. Its head was then 73068a3; that merge was clean and was not re-run.
- **#397** (`fix/family-init-values`, #307; measured at 14e2d68): its tests skip themselves where the template quotes. 137 passed, 19 skipped, 0 failed. With its fix-round head abb27e6, this branch's 3894aa8, main 0f67f7b and #345 together: 442 passed, 20 skipped (the 19 rows, and one Windows-only row).
- **#345** (run at 875d802; it has since landed as 3ae0109): `test_family_dry_run.py` and the member test, 22 passed. Its `"    id: irrs"` assertion holds, because ordinary ids stay bare.

**Left out.**

- The adoption plan writer `y` still writes `true`/`null` bare into the plan. That is #181 (lane 1).
- `templates/assembly-root/contracts/neutral-product-pin.yaml` still writes `product`, `source_repository` and `digest_source` bare, and `family.py` writes a member row's `project:` bare. Neither is a #259 field.
- A plan value someone edits to a non-text type (`tracking_branch: 007` or `id: 007`, unquoted, in the plan) is read by the plan's reader as the number 7. `str()` makes it `"7"`, and the quoted template now writes it faithfully. For `id` this is a change: on c7c02fa a bare `id: 007` failed loudly (`validate-manifest: manifest-id`, manifest line `id: 7`), and on this branch it is adopted silently as id `"7"` and every validator passes. `tracking_branch: 007` was already adopted silently as `7`. The plan side is not this branch's: the guard against it belongs with #181 (adopt plan writes YAML keywords and non-text values unquoted), whose gate should refuse a plan value that is not a string. The case is narrow, because `plan --id 007` itself writes `id: "007"`; only a hand edit reaches it.
- No docs change: README, AGENTS.md, the handbook and the template comments show none of these lines; `docs/cli.md` is unchanged because no `--help` text changed.

Reviewed adversarially at 233dc71 (CHANGES NEEDED: 0 blockers, 2 should-fixes, 6 nits; the code and the tests were sound, and the should-fixes were the composition counts and the claim about a hand-edited plan `id`). Fix round 3894aa8 changes no behaviour: it corrects the description and the two false "moves by a byte" claims, and guards `_member_id`'s `except YamlError` with a test of 7 cases. Its re-verification at 3894aa8 found every finding handled: APPROVE WITH NITS (0 blockers, 0 should-fixes, 1 nit: shas that had moved since the description was written, refreshed here). What was added after that re-verification, main and the composition with #314, is described under "Composition with #314". An independent re-verification of that composition at 77cd0b2 confirmed it with nits only (0 blockers, 0 should-fixes, 5 nits): the 45 flips reproduce test for test, breaking the real rule or the real template's quoting turns the compose tests red, and the `[branch-tilde]` row XPASSes on a merge with #355.

**Composition with #381.** #395's quoted `id: "{{PROJECT_ID}}"` composes with #381's `member-duplicate-id` test (`tests/test_family_add_member_id.py`), whose id rewrite now matches the id bare or quoted and keeps its quoting, so the full suite, which failed that one test on Linux at 1ef3310, no longer fails `test_a_member_whose_id_changes_between_the_probe_and_the_mount_is_unwound` on Linux.

CI is green on Linux, macOS and Windows, with SonarCloud passing, at 4984651 (main cc8f2da merged in by update-branch). Main af01068 (#505), which landed after, touches none of its files, and the combined tree passed the template, hygiene, CLI-reference, AGENTS.md-codes and setup tests (807 passed).

Closes #259

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant