Skip to content

adopt-project.py: the tracking branch defaults to the source's default branch, topics are set before the pull request, and NEXT names scripts/bootstrap.py when the shape's Makefile went aside (#479, #480, #481) - #493

Merged
brettheap merged 12 commits into
mainfrom
fix/adopt-default-branch-and-pr-tail
Oct 13, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #479 (comment), #480 (comment), #481 (comment)

One PR for three issues, all in adopt-project.py execute's tail, so that they cost one 3-OS CI cycle. A fix round merged main (7a8b981, #394) and closed review RV493's findings F1-F4 and codex's two threads. The rules it added are marked (fix round). A second round merged main twice more (93e3bf0, with #373 and #387, then 5a25387) and gave #373's test source the tracking branch (RV493's verification, N1).

What

#479: the tracking branch follows the source, and its remote is asked.

  • plan --tracking-branch now defaults to the branch plan reads the source at, which the plan also records as source.default_branch:. An explicit flag still wins. If checked_value refuses a defaulted branch (for example feat#1), the refusal names it as --tracking-branch (not given, so the source's default branch), not as a flag nobody passed.
  • New finding plan-tracking-branch-missing: check finds it, and execute refuses it before any leg exists, through the one list _entry_findings builds. It fires when the tracking branch is not a branch of the source. Which branches count:
    • the branches of the source's remote origin, once the source has fetched any, because that is the repository the pull request goes to;
    • otherwise the source's own branches.
      plan and check ask nothing of the network. plan prints the same sentence as a NOTE for the value it records, and refuses nothing.
  • (fix round) execute also asks the remote the split goes to, before any leg exists: git ls-remote <assembly url> refs/heads/<tracking>, after _refuse_an_occupied_work_dir and before _refuse_a_split_branch_left_behind and the confirmation. This matters because a clone remembers what its remote has lost. An ordinary git fetch does not prune, so a branch deleted or renamed on GitHub stays as origin/<branch>. A clone made before GitHub renamed master to main also keeps origin/HEAD -> origin/master, so check passed master.
    • Branch absent: REFUSED plan-tracking-branch-missing, naming the url. The remediation is git fetch --prune origin and git remote set-head origin --auto, then renaming the clone's own branch to match where the default was renamed, then plan, check and execute again.
    • Remote unreadable: REFUSED adopt-remote-unreadable, with git's exit and output. It fails closed, at the same point.
    • Under --local-remote-dir the url is the source itself, which the split is pushed back into, so its own branches are the ones asked, and the remediation says so.
  • (fix round) The plan's last follow-up names the base as "the plan's tracking_branch" and no longer names it by value. A hand edit of tracking_branch: therefore cannot leave check and the pull request's body naming the old branch. Before this PR the follow-up said "the source's default branch" while execute used --base main.
  • plan --help now says what --tracking-branch is and what it defaults to. docs/cli.md is regenerated.
  • (fix round 2) shape_materialize.py: RULESET_HINT names the run's tracking branch where it named main, as the pull request's base and the ref the scaffold and the family holder pushed (#324) #373's test tests/test_ruleset_hint_tracking_branch.py gives its adoption source the tracking branch beside master (git branch <tracking>), since execute now refuses a tracking branch the source lacks. The source stays on master, so the test's "master" not in printed assertion still means what it says.

#481: the topic comes before the pull request.

  • execute sets xf-project-<id> on each of the three repositories before it opens the pull request. It tries each repository whatever the previous one answered. It prints set on all three, or set on <those it set> only.
  • What a refused pull request and an unset topic leave to do is said in ONE notice, after the table, and only when the split verified:
    • pull request refused: The branch IS pushed., then Set the topic by hand: with a gh repo edit <repo> --add-topic <topic> for each repository left without the topic, then Open the pull request by hand: and its command, which is still the last thing the run says;
    • pull request open: The split IS pushed and the pull request IS open. Set the topic by hand:, listing only the repositories left without it.
  • A split that fails verification gets no topic command, just as it gets no pull-request command. The re-run its footer names sets the topic.

#480: NEXT matches where the shape's Makefile and bootstrap script went.

  • _commit_the_split now returns the materializer's collisions, and _next_steps reads the whole list:
    • The shape's Makefile went to shape/Makefile and its scripts/bootstrap.py took its own name: NEXT names python3 scripts/bootstrap.py, followed by a NOTE that says why.
    • (fix round) The source's own scripts/bootstrap.py stayed in the root, with or without its own Makefile. Both make bootstrap and python3 scripts/bootstrap.py then run the SOURCE's script, which can exit 0 having placed no leg. So NEXT names no bootstrap command, and its NOTE says which merges come first: merge shape/Makefile into Makefile and shape/scripts/bootstrap.py into scripts/bootstrap.py first, and then run make bootstrap.
    • Otherwise NEXT is byte for byte what it was, and a test checks that.
  • The Makefile collision follow-up (collision_follow_up, scripts/shape_materialize.py) now says the shape's make targets (bootstrap, validate, pins, park, resume) arrive only with that merge, and what runs meanwhile. A test checks that this list matches the template's own targets.
  • (fix round) collision_follow_up is told the whole collision list (_predict_collisions passes it). Where scripts/bootstrap.py is predicted aside too, the Makefile's follow-up names no meanwhile command and says both merges come first. It uses the materializer's existing BOOTSTRAP name for the path.

Unchanged:

  • execute's exit codes.
  • _verify.
  • The leg pushes and ADOPT_RULESET_HINT; both already took the tracking branch.
  • Every other message. (F4: this line said "every message when nothing is refused", which was not true. The plan's last follow-up, the Makefile follow-up and the set on all three line all change on runs where nothing is refused, as the bullets above say.)
  • AGENTS.md, which is at 490 of its 490-line cap on main. Step 7's anchors (IS pushed, gh pr create, the topic, "finish with the command it prints") are still true, and the tool's own output carries the rest. AGENTS.md names no plan-* or adopt-* code, so neither new code is added there.

Why

Measured with the acceptance harness: a stub gh and local bare repositories behind url.insteadOf. Main is 8782333, and head is this PR's first commit's code.

run main 8782333 head
trunk source (develop beside it), plan default_branch: trunk, tracking_branch: main tracking_branch: trunk
same, check / execute --yes plan ok / exit 2 after both legs and the split are pushed: pull request create failed: GraphQL: Base ref must be a branch; the printed way out repeats --base main plan ok / exit 0, pull request trunk <- adopt/three-repo-shape; the split's project.yaml has tracking_branch: trunk
trunk source, plan --tracking-branch main (main's default run above) plan exit 0 with NOTE the tracking branch is main, which is no branch of the source's remote origin(its branches:develop, trunk; ...); check exit 1 FINDING plan-tracking-branch-missing; execute exit 2 REFUSED plan-tracking-branch-missing, and the fake GitHub holds no leg repository
gh pr create refused: the gh calls 2× repo create, pr create; no repo edit 2× repo create, 3× repo edit, then pr create; topic xf-project-delta set on all three
source with its own Makefile: NEXT make bootstrap, which exits 2 in a recursive clone (No rule to make target 'bootstrap') python3 scripts/bootstrap.py plus a NOTE; exits 0 in that clone

(fix round) These rows use the review's probes, with the stub gh first on PATH, a bogus token and an empty config directory. The columns are main 03dc729, this PR's first head ac91a0b, and this head.

run main 03dc729 ac91a0b this head
a clone made while the default was master; GitHub renames it main; an ordinary git fetch: plan / check / execute tracking_branch: main / plan ok / exit 0, pull request against main tracking_branch: master / plan ok / exit 2 after both legs and the split are pushed (2× repo create, 3× repo edit, pr create): Base ref must be a branch, and the by-hand command repeats --base master tracking_branch: master / plan ok / exit 2 REFUSED plan-tracking-branch-missing: tracking_branchismaster, which https://github.com/testorg/Northwind.git`, the repository the split is pushed to, does not have as git ls-remote reads it now; **no gh` call**, no leg repository
plan --tracking-branch release, then release deleted on GitHub after the clone's fetch plan ok / exit 2 after both legs are pushed: Base ref must be a branch the same, after 3× repo edit plan ok / exit 2 REFUSED plan-tracking-branch-missing, no gh call, no leg repository
source keeps its own Makefile and scripts/bootstrap.py (scripts/ answered root): NEXT make bootstrap: exit 2 in a recursive clone python3 scripts/bootstrap.py, which in that clone runs the SOURCE's script and exits 0 no bootstrap command; NOTE ... merge shape/Makefile into Makefile and shape/scripts/bootstrap.py into scripts/bootstrap.py first, and then run make bootstrap.
source keeps its own scripts/bootstrap.py and no Makefile: NEXT make bootstrap, which runs the SOURCE's script: exit 0 the same no bootstrap command; NOTE ... merge shape/scripts/bootstrap.py into scripts/bootstrap.py first, ...
plan made with --tracking-branch develop, then tracking_branch: edited to master: check's last follow-up ... against the source's default branch; ... (main's text) ... against develop, the branch the assembly root and both legs track; ... ... against the plan's tracking_branch, the branch the assembly root and both legs track; ...

Each issue's description held when re-measured on 8782333; the line numbers have moved since 36538fd. #480's mention of the copied AGENTS-shape.md is left out, as described below.

Tests

On 56bb04d, with git-filter-repo 2.47.0 first on PATH: tests/test_adopt_*.py (30 files), every other test file that drives adopt-project.py (among them test_ruleset_hint_tracking_branch.py, test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_template_values_single_pass.py), and the guards test_repo_hygiene.py, test_cli_reference.py, test_agents_md_codes.py, test_git_output_decoding.py and test_stdout_encoding.py. Result: 3730 passed, 5 skipped in 17m23s. The first fix round's head, fc1a35b, gave 2652 passed and 5 skipped on its narrower set. N1 bites: with the merge commit's #373 test (no git branch <tracking>), its four adopt-* cases fail; with the line, the file gives 16 passed. Then main 5a25387 (bump-leg.py, shape-doctor.py; none of this PR's files) was merged clean. On that final head, 9cb9d7b, test_ruleset_hint_tracking_branch.py, test_adopt_tracking_branch.py, test_adopt_e2e.py, test_adopt_github_exits.py, test_adopt_late_failure.py, test_shape_doctor.py, test_scaffold_leg_path_collisions.py and the five guards gave 837 passed, 4 skipped in 6m43s.

Fix-round tests:

  • tests/test_adopt_github_exits.py:
    • github_run takes a before_plan hook, and the refusals share assert_refused_before_any_repository.
    • New: the rename scenario is refused before any gh call, and the way out it names leads to a plan that records main and checks ok.
    • New: an unreadable remote is refused as adopt-remote-unreadable before any gh call.
  • tests/test_adopt_tracking_branch.py: the follow-up names the plan's key. New: a hand edit leaves no branch named in check's follow-up.
  • tests/test_adopt_e2e.py:
    • New: an execute run whose source keeps its own Makefile and scripts/bootstrap.py; its NEXT names no bootstrap command.
    • New: _next_steps with only the script aside.
    • New: _predict_collisions with Makefile and scripts/ kept.
    • The byte-for-byte control now passes an empty collision list.

Do the fix-round tests bite?

  • On ac91a0b's code, the 9 fix-round test nodes give 7 failed and 2 passed. The 2 that pass are the controls: the refused base the source lacks, and NEXT where nothing went aside.
  • Five mutations of this head each fail the test aimed at them, and that test's control still passes:
    • the remote's answer ignored;
    • an unreadable remote not caught;
    • NEXT ignoring the script;
    • the follow-up ignoring the script;
    • the follow-up naming a branch.

New and rewritten tests of the first commit:

  • tests/test_adopt_tracking_branch.py (new):
    • plan records the source's branch, and an explicit flag still wins;
    • check finds a hand-edited branch;
    • execute refuses it before any leg;
    • origin's branches are the ones asked once fetched;
    • a defaulted unsafe branch is named as the default.
  • tests/test_adopt_github_exits.py:
    • the shim now emulates GitHub's base check, takes several refusals and per-repository refusals, and logs the order of its calls;
    • new: the topic is set before a refused pull request; one notice when both are refused (pull-request command last); only the refused repository is named; master and trunk sources open against their own branch; a base the source lacks is refused before any gh call;
    • the assertion at the old 369-372, which held that a refused pull request leaves the topic unset, now holds that it is set and that the notice names only what is left;
    • the failed-verification test also refuses the topic, and asserts that no topic command is offered.
  • tests/test_adopt_e2e.py: NEXT names the command that works, and it exits 0 in a recursive clone (and make bootstrap fails there, where make exists); the follow-up names the template's targets; the control, where the Makefile was not set aside, keeps NEXT byte for byte.
  • tests/test_adopt_late_failure.py: its two by-hand helpers call _finish_by_hand.

On main's code, every new test and every rewritten assertion of the first commit FAILED: 19 failed, 111 passed in those four files. The extended failed-verification test passes there, as it should. Breaking each rule in a scratch copy (9 breaks) failed the tests aimed at it.

Skips:

  • The GitHub-mode runs are skipped on Windows, because the gh shim is a #! script, as the rest of test_adopt_github_exits.py is.
  • Every execute test needs git filter-repo.
  • The plan and check tests in test_adopt_tracking_branch.py run on all three OSes.

Composes with

git merge-tree --write-tree of each open sibling, and lane 3's PR-less adopt branches, against this head, which contains main 5a25387. #373 and #387 have landed and are merged in. #387's note loop and this PR's are both kept in _print_plan_report, in _entry_findings' order. #373's test is the one changed above.

branch result
#356 fix/adopt-check-hand-edited-plan CONFLICT adopt-project.py: _entry_findings' docstring list and return tuple. Each side adds one entry; keep both
#382 fix/adopt-refuse-existing-split-branch CONFLICT adopt-project.py: the module docstring's check paragraph; keep both clauses
#435 fix/adopt-plan-field-types CONFLICT adopt-project.py: _entry_findings' docstring and return (keep both) and _commit_the_split: the same three hunks it already has with main
#384 fix/adopt-spell-question-and-submodule CLEAN
#494 fix/refusal-default-remediation CLEAN
#266, #374, #376 (scripts/shape_materialize.py); #395, #453 (docs/cli.md) CLEAN
#465 fix/docs-validate-pins-gh-fallback CONFLICT docs/handbook.html only, the conflict it has with main 5a25387 itself; none of this PR's files conflict
fix/adopt-root-kept-symlinks (lane 1, f8e7176) CONFLICT adopt-project.py: the module docstring's check paragraph and _entry_findings' docstring and return; keep both
fix/adopt-pin-writes-pin-file (lane 5) CONFLICT adopt-project.py and docs/cli.md: _commit_the_split, where it already conflicts with main
fix/adopt-plan-quote-yaml-keywords CONFLICT adopt-project.py: both add a section before _checked_plan_inputs, and _entry_findings' docstring and return; keep both. Its FLAG_DEFAULTS will want this PR's label for a defaulted tracking branch. Its tests that plan --tracking-branch true or null and run execute will need the same git branch <tracking> in their source as #373's did

The first fix round added no conflict hunk with any of these. For each conflicting sibling, the hunks against this head are the same as against the merge commit before the fix round. The fix round's import of BOOTSTRAP is its own statement for that reason: fix/adopt-root-kept-symlinks and fix/adopt-plan-quote-yaml-keywords both rewrite the shape_materialize import list.

Left out

  • A source that keeps a lowercase makefile or a GNUmakefile: GNU make reads it before the shape's Makefile on a case-sensitive disk, so NEXT's make bootstrap still fails there. It goes on the Tracking: adopt-project.py, converting an existing repository in place #457 checklist.
  • A --tracking-branch that names a branch other than the one the split is made on: the split's pull request then also carries the difference between the two branches. It is not checked.
  • The copied AGENTS-shape.md and adopt-shape-file-missing's wording ("the assembly's first make bootstrap") still say make bootstrap. Those are the shape's own words.
  • check's WHAT WILL HAPPEN does not name the tracking branch, which is the pull request's base.
  • (fix round) check still asks nothing of the network. A branch the clone remembers and GitHub lost passes check, and execute refuses it before any leg.
  • (fix round) The plan's follow-ups are written once, by plan, from its own answers. A leg: edited afterwards is not re-derived into them. In codex's scenario (scripts/ answered root by hand), the split's Makefile follow-up still names python3 scripts/bootstrap.py as what runs meanwhile, while NEXT and the split's WRITTEN BESIDE, NOT OVER list are right. It is a candidate for Tracking: adopt-project.py, converting an existing repository in place #457's checklist.
  • (fix round) plan reads the source at its local default branch. A clone with no local branch of the name origin/HEAD names is refused by plan as git-failed (git rev-parse <branch>). That is why the remediation says to rename the clone's own branch after a rename.

Closes #479
Closes #480
Closes #481

🤖 Generated with Claude Code

Summary by Sourcery

Make project adoption follow the source’s real default branch, complete topic setup before pull requests, and provide collision-aware bootstrap guidance.

New Features:

  • Default the adoption tracking branch to the source repository’s default branch and validate that it exists locally and on the destination remote.
  • Set project topics on all repositories before opening the pull request, while reporting only the remaining manual actions.
  • Choose verified NEXT bootstrap instructions based on where the shape’s Makefile and bootstrap script are materialized.

Bug Fixes:

  • Prevent adoption from creating legs or pushing changes when the tracking branch is missing or the destination remote is unreadable.
  • Keep pull-request instructions and bootstrap guidance accurate for non-main source branches and root file collisions.

Enhancements:

  • Improve collision follow-ups to describe the shape’s Make targets and required bootstrap merges.
  • Consolidate post-verification manual recovery instructions for topic and pull-request failures.

Documentation:

  • Regenerate the CLI documentation with the updated tracking-branch option behavior.

Tests:

  • Add coverage for source-derived tracking branches, missing and stale remote branches, unreadable remotes, topic ordering and partial failures, and collision-aware bootstrap guidance.

…default branch, `execute` sets the topic before the pull request and says what is left in one notice, and NEXT names `python3 scripts/bootstrap.py` where the shape's Makefile went aside, so a `master` source's pull request is no longer refused after both legs are pushed, a refused one no longer leaves the topic unset, and NEXT no longer names a make target the root lacks (#479, #480, #481)

`tracking_branch` is the branch both legs are pushed to, the branch the
root's `project.yaml` tracks, and the base `execute` opens the split's pull
request against. `plan` defaulted it to `main` whatever the source used, so
a `master` or `trunk` source was adopted all the way and then `gh pr create
--base main` was refused (`Base ref must be a branch`), with the same command
printed as the way out (#479). A refused pull request also ended the run
before the `xf-project-<id>` topic was tried, and the way out never said so
(#481). And a source with its own Makefile keeps it, the shape's going to
`shape/Makefile`, while NEXT said `make bootstrap`, which exits 2 there
(#480).

- `plan --tracking-branch` defaults to the branch `plan` reads the source
  at (`source.default_branch:`); an explicit flag still wins. A defaulted
  branch `checked_value` refuses is named as the default, not as a flag.
- `check` finds, and `execute` refuses before any leg exists, a tracking
  branch that is no branch of the source: `plan-tracking-branch-missing`,
  in the one list `_entry_findings` composes. The branches are the source's
  remote `origin`'s once it has fetched any (the repository the pull
  request goes to), and its own otherwise; nothing is asked of the network.
  `plan` prints the same sentence as a NOTE for the value it records.
- The plan's last follow-up names the tracking branch it opens the pull
  request against, where it said "the source's default branch".
- `execute` sets the topic on each of the three repositories before the
  pull request, asking each whatever the one before it answered, and prints
  `set on all three` or the ones it set.
- What a refused pull request and an unset topic leave is ONE notice after
  the table, only once the split verified: `The branch IS pushed.`, a `gh
  repo edit ... --add-topic ...` for each repository left without the
  topic, then the pull request's command, still last; with the pull request
  open, `The split IS pushed and the pull request IS open. Set the topic by
  hand:` and only the repositories left. A failed verification offers no
  topic command either.
- `_commit_the_split` returns the materializer's collisions, and where the
  shape's Makefile went to `shape/Makefile`, NEXT names `python3
  scripts/bootstrap.py` with a NOTE saying why. Elsewhere NEXT is byte for
  byte what it was.
- `collision_follow_up` (scripts/shape_materialize.py) says of the Makefile
  that the shape's make targets (`bootstrap`, `validate`, `pins`, `park`,
  `resume`) arrive only with that merge, and what runs meanwhile.
- `plan --help` says what `--tracking-branch` is and its default;
  `docs/cli.md` is regenerated.

Unchanged: `execute`'s exit codes, `_verify`, the leg pushes and
`ADOPT_RULESET_HINT` (both already took the tracking branch), the
pull-request-only posture, every message when nothing is refused (NEXT
compared byte for byte by a test), and AGENTS.md (at its cap; the tool's
own output carries the change).

Left out: a source that keeps a lowercase `makefile` or a `GNUmakefile`,
which GNU make reads before the shape's `Makefile` on a case-sensitive disk;
and a `--tracking-branch` that names a branch other than the one the split
is made on, whose pull request then carries that branch's difference too.

Lane: openreposhape-3 (openRepoShape-3)
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 3 days and 22 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

The PR updates adoption execution to track the source’s actual default branch with early validation, set repository topics before pull-request creation with consolidated recovery guidance, and choose a working bootstrap command when the shape Makefile is displaced by a source Makefile; CLI documentation and comprehensive tests are updated accordingly.

Sequence diagram for adoption execution ordering

sequenceDiagram
    participant Execute
    participant Repositories
    participant GitHub
    participant Verify
    Execute->>Repositories: _set_the_topic(repositories, topic)
    loop assembly, spec, code
        Repositories->>GitHub: gh repo edit --add-topic
    end
    Execute->>GitHub: _open_the_pull_request(..., tracking, ...)
    GitHub-->>Execute: pull request result
    Execute->>Verify: _verify(...)
    Verify-->>Execute: verification result
    Execute->>Execute: _finish_by_hand(passed, refused, topic, unset)
Loading

Flow diagram for tracking branch validation

flowchart TD
    A[plan reads source default branch] --> B[tracking_branch defaults to source.branch]
    C[explicit --tracking-branch] --> D[tracking branch value]
    B --> D
    D --> E[_tracking_branch_problem]
    E --> F{Branch exists in origin refs?}
    F -->|yes| G[check succeeds]
    F -->|no| H[plan prints NOTE]
    H --> I[check emits plan-tracking-branch-missing]
    I --> J[execute refuses before creating legs]
Loading

Flow diagram for Makefile-aware next steps

flowchart TD
    A[_commit_the_split returns materializer collisions] --> B{Shape Makefile collided?}
    B -->|no| C[_next_steps prints make bootstrap]
    B -->|yes| D[_next_steps prints python3 scripts/bootstrap.py]
    D --> E[NOTE explains shape/Makefile and pending merge]
    F[collision_follow_up] --> G[Lists bootstrap, validate, pins, park, resume]
    G --> H[Targets arrive when Makefile collision is merged]
Loading

File-Level Changes

Change Details Files
Align the tracking branch with the source repository and validate it before adoption work begins.
  • Default plan --tracking-branch to the source branch and preserve explicit overrides.
  • Validate against fetched origin branches, or local branches when no remote branches are available.
  • Add a shared finding used by check and early execute refusal, with matching plan notes and remediation.
  • Use the resolved branch in follow-ups, project metadata, leg pushes, and pull-request base commands.
adopt-project.py
docs/cli.md
tests/test_adopt_tracking_branch.py
tests/test_adopt_github_exits.py
Make GitHub topic setup independent of pull-request success and consolidate manual recovery instructions.
  • Set the project topic on assembly, spec, and code repositories before opening the pull request, continuing after individual failures.
  • Report which repositories received the topic and list only missing-topic commands for manual recovery.
  • Combine topic and pull-request recovery into one post-verification notice while preserving verification precedence and exit behavior.
  • Update late-failure and GitHub-mode tests for call ordering, partial failures, and failed verification.
adopt-project.py
tests/test_adopt_github_exits.py
tests/test_adopt_late_failure.py
Make post-adoption bootstrap guidance account for a source Makefile collision.
  • Return materializer collisions from split creation and use the Makefile collision to select python3 scripts/bootstrap.py in NEXT.
  • Explain that the shape's Make targets become available only after merging shape/Makefile into the root Makefile.
  • Keep the existing make bootstrap output unchanged when no collision occurs.
  • Verify the target list against the template and test recursive-clone bootstrap behavior.
adopt-project.py
scripts/shape_materialize.py
tests/test_adopt_e2e.py

Assessment against linked issues

Issue Objective Addressed Explanation
#479 Default the tracking branch to the source repository’s default branch, while allowing an explicit --tracking-branch value to take precedence, so adopted legs and the pull request use the intended base branch. ✅
#479 Have check detect and report a tracking branch that is not a branch of the repository the pull request targets, so an invalid plan is caught before execute creates repositories or pushes the split. ✅
#480 When the shape's Makefile is placed at shape/Makefile because the source already has a root Makefile, make execute's NEXT command work in the adopted root before the Makefiles are merged. ✅
#480 Explain that the shape's Makefile targets are unavailable until the collision follow-up is merged, and identify the bootstrap command that works meanwhile. ✅
#480 Ensure the adopted root's instructions do not continue directing users to run the unavailable make bootstrap before the Makefiles are merged. ❌ The PR updates NEXT and the Makefile collision follow-up, but explicitly leaves the copied AGENTS-shape.md unchanged; it still tells users to run make bootstrap in the adopted root.
#481 Set the project topic on all three repositories before attempting to open the pull request, so a refused pull request does not leave the topics unset. ✅
#481 When setting a topic fails, tell the person which repository still needs it and provide the corresponding gh repo edit … --add-topic … command in the post-verification notice, including when the pull request is also refused. ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-12T22:45:07Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T22:49:41.818781Z ac91a0b Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac91a0b410

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread adopt-project.py
Comment on lines +1810 to +1814
fetched = sorted(ref[len(ORIGIN_BRANCHES):] for ref in refs
if ref.startswith(ORIGIN_BRANCHES)
and ref != f"{ORIGIN_BRANCHES}HEAD")
if fetched:
return "the source's remote `origin`", fetched

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify tracking branches against the actual remote

When a clone still has refs/remotes/origin/release after that branch was deleted or renamed on GitHub, this cached ref remains in fetched because an ordinary fetch does not prune it, so _tracking_branch_problem accepts a base that no longer exists. execute then creates and pushes both legs and the split before gh pr create rejects the missing base, leaving the user in the documented state where fresh legs must not be created. Verify the advertised refs of the assembly remote, or fail closed until its tracking refs have been fetched with pruning, rather than treating cached remote-tracking refs as proof of existence.

AGENTS.md reference: AGENTS.md:L144-L153

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(lane openRepoShape-3, 2026-10-13T00:25Z) Fixed in fc1a35b: execute now asks the assembly's remote itself (git ls-remote <url> refs/heads/<tracking>) before any leg exists, and refuses plan-tracking-branch-missing when the branch is absent and adopt-remote-unreadable when the remote cannot be read. The stale origin/master after a master-to-main rename is a new test in tests/test_adopt_github_exits.py.

Comment thread adopt-project.py Outdated
Comment on lines +5675 to +5676
bootstrap = ("python3 scripts/bootstrap.py" if makefile_aside
else "make bootstrap")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for a colliding bootstrap script

When the source keeps both its own Makefile and scripts/bootstrap.py in the root, the materializer puts the shape's script at shape/scripts/bootstrap.py, but this command runs the source's pre-existing script instead. Because execute deliberately does not perform the collision follow-ups, the advertised next step can run unrelated code without placing the legs on their tracking branches or validating the adopted project; pass the full collision set into _next_steps and defer bootstrap until the shape script has also been merged.

AGENTS.md reference: AGENTS.md:L139-L143

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(lane openRepoShape-3, 2026-10-13T00:25Z) Fixed in fc1a35b: _next_steps and the plan-time Makefile follow-up now read the whole collision list, and where scripts/bootstrap.py went aside too, NEXT names no bootstrap command and says to merge shape/Makefile and shape/scripts/bootstrap.py first. An execute run of that source is a new test in tests/test_adopt_e2e.py.

…r-tail

One conflict, adopt-project.py's module docstring `execute` paragraph:
this branch's sentence (the topic on all three, then the split's pull
request against the tracking branch, both skipped for local remotes),
then main's sentence on the `submodules:` record in the split's
`project.yaml`. Everything else merged without conflict; docs/cli.md
re-rendered byte-identical.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng branch before any leg exists, NEXT names no bootstrap command where the source's own `scripts/bootstrap.py` stayed in the root, and the pull-request follow-up names the plan's `tracking_branch`, so a clone whose `origin/master` GitHub has renamed is refused before anything is pushed (#479, #480, #481)

Fix round on #493, closing RV493's F1-F3 and codex's two threads.

- F1. `check`'s `plan-tracking-branch-missing` reads the branches the
  source's clone remembers. A clone made before GitHub renamed `master`
  to `main` keeps `origin/master` and `origin/HEAD -> origin/master`
  after an ordinary fetch, so `plan` recorded `master`, `check` said
  ok, and `execute` pushed both legs and the split before `gh pr create`
  refused the base. `execute` now runs `git ls-remote <assembly url>
  refs/heads/<tracking>` after `_refuse_an_occupied_work_dir` and
  before `_refuse_a_split_branch_left_behind`, so before `_confirm` and
  before any leg:
  - branch absent: REFUSED `plan-tracking-branch-missing`, naming the
    url; the remediation is `git fetch --prune origin`, `git remote
    set-head origin --auto`, the clone's own branch renamed where the
    default was renamed, then `plan`, `check` and `execute` again;
  - remote unreadable: REFUSED `adopt-remote-unreadable`, with git's
    exit and output (fail closed);
  - under `--local-remote-dir` the url is the source itself, the split's
    destination there, and the remediation adds that the branch must be
    one of the source's own.
- F2. `_next_steps` takes the whole collision list. Where
  `scripts/bootstrap.py` went aside, with or without the Makefile, NEXT
  names no bootstrap command and says which merges come first, then
  `make bootstrap`. `collision_follow_up` takes the list too
  (`beside`), and the Makefile's follow-up names no meanwhile command
  when the script went aside; `_predict_collisions` passes it.
- F3. The last follow-up says "against the plan's `tracking_branch`"
  and no longer interpolates the branch, so a hand edit of
  `tracking_branch:` cannot leave `check` and the pull-request body
  naming the old one. `follow_ups_for` is back to main's signature.

Tests: the rename scenario and an unreadable remote in
test_adopt_github_exits.py (`github_run` takes a `before_plan` hook);
the hand-edit follow-up in test_adopt_tracking_branch.py; NEXT with both
files aside (an execute run), NEXT with the script alone and the
plan-time Makefile follow-up in test_adopt_e2e.py.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-13T00:25Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Brett Heap and others added 3 commits October 13, 2026 00:39
…-and-pr-tail

One conflict, adopt-project.py's `_print_plan_report`: main's
`_empty_symlink_notes` loop (#387) and this branch's
`_tracking_branch_notes` loop, kept both in `_entry_findings`' order,
the empty symlinks first. Everything else merged without conflict;
docs/cli.md re-rendered byte-identical.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the tracking branch beside `master`, so `execute` reaches the leg push whose ruleset hint the test reads instead of refusing `plan-tracking-branch-missing` (#479, #480, #481)

#373's `refused_run` built the adoption source on `master` alone and
planned `--tracking-branch trunk` or `main`. With #479's
`plan-tracking-branch-missing`, `execute` refuses that plan before any
leg, so the four `adopt-*` cases of
`test_an_adoption_hint_names_the_tracking_branch_the_run_was_made_with`
found no hint (RV493's verification, N1). The source now gets
`git branch <tracking>` beside `master`; it stays on `master`, so the
test's `"master" not in printed` assertion still means what it says.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opt-default-branch-and-pr-tail

No conflict: main's two commits touch none of this branch's files.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-13T01:05Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…409) into fix/adopt-default-branch-and-pr-tail

Conflict: adopt-project.py module docstring, the check paragraph; both clauses kept (the tracking branch, then the split branch).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap and others added 2 commits October 12, 2026 21:57
…the assembly's remote fails a repository git cannot list as a `CommandFailed`, as #382's `_refuse_a_split_branch_left_behind` does, and says itself as `REFUSED adopt-remote-unreadable`, so the two refusals compose and `test_execute_refuses_a_repository_it_cannot_list` and `test_a_remote_that_cannot_be_asked_is_refused_before_any_repository` both hold (#479, #320)

`RemoteUnreadable` is a `CommandFailed` whose `loudly`, the one place `main`
asks how a failed command is said, returns the `adopt-remote-unreadable`
refusal. The `OSError` case keeps the plain `Refusal`: it has no command.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 13, 2026 02:26
…it_cannot_list` expects #493's `adopt-remote-unreadable` for a remote git cannot list, with the failed `git ls-remote` as its cause, and adopt-project.py drops `RemoteUnreadable`, so #382's test and #493's lookup no longer need a refusal that is also a `CommandFailed` (#479, #320)

The first lookup to list the assembly's remote is now
`_refuse_a_tracking_branch_the_remote_lacks`, not
`_refuse_a_split_branch_left_behind`. The test asserts a `Refusal` with code
`adopt-remote-unreadable`, a detail that names the url, the "no leg
repository was created" remediation, and a `CommandFailed` cause whose
`args_list` starts `git ls-remote` and holds the url. adopt-project.py is
back to its 433a1b4 text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…dopt-default-branch-and-pr-tail

Conflict: adopt-project.py `_entry_findings`, two hunks. The docstring list of findings, and the return, keep both sides: #493's tracking-branch findings stay where they were (after the manifest values, before the relative urls of #190), and main's `_plan_field_findings` go last.

Co-Authored-By: Claude Sonnet 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>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 56713e2 into main Oct 13, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-default-branch-and-pr-tail branch October 13, 2026 06:22
brettheap pushed a commit that referenced this pull request Oct 13, 2026
Conflict: tests/test_ruleset_hint_tracking_branch.py, two hunks in run_the_tool, resolved by keeping this branch's run_the_tool (its signature, the per-case env and work directory) and adding #493's setup to it: the adoption source's tracking branch is made beside `master` (`git("branch", tracking, cwd=source)`) and the docstring says why (#479). docs/cli.md, scripts/shape_materialize.py and the rest merged without conflict; docs/cli.md was regenerated with scripts/render-cli-reference.py and is unchanged by it.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…the source the branch each row tracks, which adopt now requires (#479, 56713e2)

test_an_adoption_reads_back_every_value_it_was_given plans, checks and
executes with a tracking branch of 2026, 007, 1.5, 20261011, true or null,
from `--tracking-branch` or by hand in the plan, against a source that had
only `main`. Main's 56713e2 (#479, in #493) makes `check` refuse a tracking
branch the source lacks, as plan-tracking-branch-missing, and `execute` ask
the remote for it (under --local-remote-dir that remote is the source), so
all six rows died at `check`. The source now gets a branch of that name
before `plan`. Nothing the test asserts changed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
… list of mappings, so #386's gate asks it only whether it is a list, in #273's own finding (#386)

Main since #356's squash 965420b changed `adopt-project.py` three times:
70d0d1b (#384, `_spelled` in three findings), 56713e2 (#493, the
tracking branch from the source's default branch) and aa4e1f5 (#508,
lane 5's #273 and #339). aa4e1f5 makes `plan --pin` take
`product@<40 hex>` and write each pin as a MAPPING of `product:`,
`repository:`, `commit:`, `tree_sha256:` and `digest_source:`, and its
`_read_pins` judges each `pins:` entry beside the entries:
`plan-pin-unrecorded` for a name alone, `plan-pin-malformed` for anything
else `plan` never writes there, and for a `pins:` that is no list.

Two hunks conflicted, both in `adopt-project.py`, both the ones
aa4e1f5's message names: `render_plan`'s `pins:` line takes main's
`_pin_lines`, which writes the product through `y` and the commit and
digest quoted, so this branch's `y(pin)` is superseded; and `Plan.pins`
takes main's `_pin_products`, which reads a list only.

The rest is the reconciliation aa4e1f5 and lane 5's comment on #435
asked of whichever landed second. As this branch stood, its item check
took only text, so every plan `plan --pin` now writes was `plan-bad-pins`
in its own `check`:
- `_plan_key_findings` no longer judges `pins:` entries: they are
  `_read_pins`' alone, in its words and codes.
- A `pins:` that is no list is still asked first, beside the other four
  keys and before the work directory, because the naming policy reads the
  root's products from it. Its finding is `_read_pins`' own
  `plan-pin-malformed`, so it is one finding under one code, and
  `plan-bad-pins` is gone.
- `PINS_REMEDIATION` names the mapping `plan` writes.
- `PLAN_KEY_REFUSAL` begins "no leg repository was created and nothing was
  pushed", the words every refusal before a leg says (#445; conftest's
  `refused_before_any_leg` asserts them, and #273's not-a-list row now
  meets this refusal).

`tests/test_adopt_execute_refusals.py` says the same: the `pins:` key rows
expect `plan-pin-malformed` and gain a single mapping with no list around
it; the entry rows move to a test that the gate is silent and
`_read_pins` names them; the controls and the corrected end-to-end plan
are whole pins; and the round trip runs `plan --pin <name>@<commit>
--pin-source` for 2026, 007, 1.5 and openGlass, and holds `check` to
`plan ok`.

`git diff --stat cc8f2da` of the result lists only `adopt-project.py`
and `tests/test_adopt_execute_refusals.py`. Before this commit:
`tests/test_adopt_execute_refusals.py` 150 passed and
`tests/test_adopt_pins.py` 70 passed.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…5 cites the 34 pull requests merged since #343, section 6 drops the 13 known-limit issues that closed and adds the 63 checklist lines the five parents gained, section 7 lists the 12 open milestone issues, and the preflight sentence offers only what the preflight offers (#472)

Refresh round 1 of the draft, against main d41cc29 (#402, merged
2026-10-13T21:17:39Z). GitHub was read between 21:23 and 21:43 UTC: the
merged pull requests, the v1.0.0 milestone open and closed, every open
issue, and the five tracking parents with their sub-issues, bodies and
comments. Every merge commit of the 217 merged pull requests is on main.

Section 5 places each of the 34 pull requests merged after #343
(2026-10-12T22:31:20Z) in its area, from its own body, one clause each;
all 217 merged pull requests are now cited, with #16, #53 and #57 still in
two areas. Three earlier sentences are made true of main: `check` has
more verdicts than the four it named, and #465's docs and #505's accuracy
pass show that `validate-pins.py` may ask `gh api` and that
`openRepoShape --preflight` offers only `gh`, its login and its credential
helper (`setup-project.py --preflight` also offers `git`).

Section 6 lists the 47 open, unmilestoned sub-issues (it listed 60). It
drops #189, #293, #303, #328, #386, #286, #259, #275, #322, #324, #338,
#339 and #359, which closed. #389 is reworded now that #305's refusal has
landed. #420 and the `a=b` line are narrowed to a hand-edited
`adopt_branch:`, because an offline probe on d41cc29 shows #493's
`plan-tracking-branch-missing` now stops a bad `tracking_branch:` at
`check`, with no leg made, while `adopt_branch: "a..b"` still passes
`check` and fails after both legs exist. The family annotated-tag line
says `bump --to` now refuses one (#466). The 63 new lines are the
parents' checklist lines posted or added since the first build, each
checked against the code at d41cc29. Left out, and said so in the
refresh record: docstring, comment and wording nits (as L2-7 was), lines
that describe a branch not on main (#504's, #507's, #511's, #515's and
#517's), lines filed into the milestone (#485, #514, #516), and three with
no effect a person meets.

Section 7 lists the 12 open milestone issues; 24 of the 30 it listed have
closed. Lane 4's #510, #511, #512 and #513 have not landed, so #471,
#499, #500, #501 and #502 stay open here and the Windows qualifier stays.

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 13, 2026
…s a runnable `scripts/family.py`, printed commands quote paths, and `add`, `bump` and `remove` refuse the tracking branch and print their branch's push (#488, #486, #487) (#507)

Three defects from lane 3's v1.0.0 acceptance pass of AGENTS.md's "Creating a family, and adding a member", one commit each, in this order, then one review commit for #486 and one for #487. All three change `scripts/family.py` and `add`'s NEXT line, so they are one pull request.

**What was wrong.**
- AGENTS.md spelled the command `python3 scripts/family.py init --org <org> --family <Name>`, a path that runs from the standard's root. `--into` defaults to where the person stands.
- So the command, followed exactly, printed `landing <standard>/Orbit/Orbit` with no word, and `make siblings` would then clone every member into the standard's checkout too.
- `init`'s NEXT STEPS printed `python3 family.py add …`, built from `Path(__file__).name`. From the root that fails `can't open file`, exit 2.

**What changed** (commit 1).
- With neither `--into` nor `--work-dir`, `init` refuses a landing inside a checkout of openRepoShape: `family-landing-in-standard`. A checkout is a directory holding `scripts/family.py` beside `templates/family-root/`, found in the landing's ancestry, so another clone or an install counts too.
  - The refusal comes before anything is created, under `--dry-run` alike.
  - The remedy names `--into ..` from the root. From deeper inside the checkout it names the relative path that reaches the checkout's parent.
  - An explicit `--into` or `--work-dir` is not second-guessed. `family-holder-already-here` is still asked first.
- NEXT STEPS, and the `family-holder-already-here` remedy, name this file relative to the directory `init` ran in. From the root that is `scripts/family.py`, as setup-project.py, validate-family.py, AGENTS.md and README spell it. It is absolute where no relative path exists.
- AGENTS.md's init command passes `--into ..`, as README's already does, and step 1 says why. The edit is line-neutral: step 1 is one line shorter.
- `init --into --help` says it too, and docs/cli.md is regenerated.

**How tested.** `tests/test_family_init_landing.py`, 9 tests:
- the old command from the root is refused, as a dry run, so nothing can land in the checkout;
- a real run inside a fake checkout creates nothing;
- the remedy from a subdirectory names the right parent;
- an explicit `--into .` or `--work-dir .` passes;
- outside a checkout the default is unchanged;
- AGENTS.md's line, as written, lands beside the checkout;
- the printed NEXT runs, both from the root and from elsewhere.

On origin/main code 6 of them fail. Removing the refusal turns 3 red, and putting `Path(__file__).name` back turns 2 red.

**What was wrong.** `family.py` and the holder's `siblings.py` printed every path in their pasteable commands bare. Under a holder at `…/my work/Sonata/Sonata`:
- the `member-git-dir-cached` remedy `rm -rf <store>` split into `rm -rf …/my`, an absolute directory that is NOT the store, and a relative `work/…`;
- `init`'s NEXT STEPS and `add`'s NEXT split `--family-root` and `git -C` (`fatal: cannot change to '…/my'`, exit 128);
- the `mv` lines for a misnamed family folder pasted as four arguments.

**What changed** (commit 2, and the review commit after #487's).
- `scripts/repo_shape.py` gains `shell_quoted(value, platform=None)` and `shell_line(words, platform=None)`, in a new section at its end, the library `family.py` already imports.
  - It is the doctor's `quote_arg` rule (#101/#102): the same two alphabets, and the same two escapes, `sh`'s `'\''` and PowerShell's doubled apostrophe. The dialect comes from `os.name` or a `platform` argument. An ordinary word is left bare.
  - It does not flatten to ASCII, as the doctor's report does by contract. A command that deletes or moves a directory has to name that directory. So PowerShell's typographic single quotes are doubled too.
  - A word that begins with `=` is quoted, in both dialects. zsh, macOS's login shell, expands a bare `=word` to a command's path (`=ls` reaches the command as `/bin/ls`). This is the one place it differs from the doctor's rule on ASCII.
- `family.py`:
  - `init`'s NEXT STEPS print the `add` on ONE line. It used to paste only into a POSIX shell, because of its `\` continuation. `cd <holder>` and `make siblings` are two lines, with no `&&`, which Windows PowerShell 5.1 rejects between two statements.
  - Those lines and the `family-holder-already-here` remedy quote every path.
  - `add`'s NEXT quotes `git -C <holder>`, and `member-path-taken` quotes each spelling it names.
  - `member-git-dir-cached`, and `remove`'s note about the same store, print the directory alone on its own line. Then comes `rm -rf` with its one argument quoted for the POSIX shell `rm -rf` belongs to, and the sentence names that shell: Git Bash on Windows. PowerShell refuses `-rf` and deletes nothing.
- `siblings.py`: the parent-folder warning's `mv` and `mkdir -p` lines are quoted for a POSIX shell with the standard library's `shlex.join`, and the warning says which shell. It does NOT import the new quoter: a holder that kept its own `repo_shape.py` through `update-shape.py apply --accept-local` takes the new `siblings.py` without it, and an import of a name its copy lacks stopped `make siblings`, `park` and `resume` with an ImportError that `check` and the validator never reported.
- `setup-project.py`'s family hand-over quotes the holder in its `add` line (and names the branch first, see #487). It runs before any checkout of the standard exists, so it carries its own copy of the rule, and a test holds that copy to `shell_quoted`.

**How tested.** `tests/test_family_printed_quoting.py`, 69 tests:
- The quoter round-trips through `shlex.split`, and through a PowerShell reader, for a space and an apostrophe, `é` and a typographic apostrophe, shell syntax, the empty string, and every printable ASCII character alone and before, inside and after a word.
- The host's own `sh`, `bash` and `zsh` hand each quoted word back byte for byte through `printf`, a leading `=` among them (skipped where a shell is absent, and on Windows).
- It agrees with the doctor's `quote_arg` on every one of those ASCII values but a leading `=`, and its two alphabets are the doctor's two patterns.
- Under a holder in `my work` and in `Dana's work`, each of these splits into the argv it means: `init`'s NEXT (with `cd` and `make siblings` on their own lines), `add`'s NEXT, the cached-store remedy and note, the `mv` lines, and setup-project's `switch` and `add`.
  - The `rm -rf`, run as split, deletes the store, and the decoy `<tmp>/my` survives.
  - The `mv` lines, run as split, move the holder.
- The holder's own `siblings.py` runs `--help`, the misnamed-folder warning and `--make park` beside a `repo_shape.py` that lacks every name this change added.
- Three existing tests' expected lines follow the new wording.

Mutations, each turning tests red:
- a bare quoter: 35 red;
- bare `mv` lines in `siblings.py`: 3 red;
- a bare `rm -rf`, a bare `add` NEXT, a bare `init` `add` (round 1, on the 85-test file): 2 red each;
- `siblings.py` importing `shell_line` again: 1 red (the older-`repo_shape.py` test);
- `%` dropped from the POSIX alphabet: 2 red (the every-ASCII agreement and the alphabet test). The round-1 sample test passed it, 36 of 36;
- a leading `=` left bare: 10 red, among them the real-`zsh` test;
- `cd … && make siblings` back on one line: 2 red;
- setup-project's holder unquoted: 2 red.

**What was wrong.**
- AGENTS.md says each act's ONE commit lands as a pull request, and no step made the branch that pull request needs.
- After `init` the holder stands on `main`, and all three acts committed straight onto it.
- `add` printed `NEXT  git -C <holder> push -u origin <branch> && open a pull request`. No branch is called `<branch>`, so the push failed: `src refspec … does not match any`.
- `bump` and `remove` printed no NEXT at all.
- `bump-leg.py` refuses exactly this for a leg (`bump-leg-on-tracking-branch`).

**What changed** (commit 3, and the review commit after it).
- The real run of each act refuses the holder's tracking branch, the one `family.yaml` declares (`family-on-tracking-branch`), and a HEAD on no branch (`family-holder-detached`, which is what `bump-leg.py`'s `bump-leg-root-detached` refuses too).
  - The check comes after the refusals about the arguments and the holder, and before the member is read, fetched or cloned, and before anything is written.
  - The remedy is a quoted `git -C <holder> switch -c <branch>`. The branch is `family/add-<project>`, `family/bump-<id>-<sha12>` or `family/remove-<id>`, and a name that is not a plain branch word becomes `member`.
- `--dry-run` runs on any branch. On a work branch it prints where the commit would land. On the tracking branch it prints that the real run refuses there, with the refusal's code, and the `switch` command on a line of its own. The module's EXIT CODES paragraph names this as the one refusal a dry run does not make.
- Each act that commits ends with ONE pasteable line: `NEXT  git -C <holder> push -u origin <the branch it committed on>`, quoted, then `then open a pull request against <tracking>; never push to it directly`. `bump` and `remove` gain that NEXT.
- `init`'s NEXT STEPS open with the `switch` before the `add`.
- `add`, `bump` and `remove` `--help` say the branch rule, and that each ends by PRINTING the push; docs/cli.md is regenerated.
- AGENTS.md's family block gains the `switch` step before the `add`, and step 3 says the tracking branch is refused, as `family-on-tracking-branch`, and that the branch is pushed as its NEXT line says. README's InkRouter block gains the same step, and its paragraph says it. Both edits are line-neutral: AGENTS.md tightens steps 2 and 5, and README tightens the `make bootstrap` paragraph. `docs/handbook.html` mirrors the block and the sentence, and its footer names #487 and the new README blob.
- The holder's own docs, `templates/family-root/AGENTS-shape.md` and `templates/family-root/README.md`, open their add/bump/remove block with the `switch` and say the act refuses the tracking branch. `setup-project.py`'s family hand-over prints the `switch` before its `add`.
- Test fixtures that committed on `main` now commit on a branch:
  - `tests/test_family.py`'s module fixture grows the family on `family/members` and pushes that branch to the remote's `main`, as the merged pull request would leave it;
  - `test_shape_doctor.py`'s holder fixture and `test_family_add_excludes.py`'s `empty_holder` switch to a branch;
  - `test_family_add_refusals.py` changes in four places: the `NEXT` expectation, `unborn_holder`, the empty-family test, and the corrupt-HEAD test. In that last test, the tag state's branch check is answered in-process, because the test is about the rollback's reading of HEAD.

**How tested.** `tests/test_family_tracking_branch.py`, 17 tests:
- For each act, on `main`: refused, with the holder byte for byte as it was, and for `add` nothing cloned. Then the printed `switch`, run as split, makes the branch; the act goes through on it; and its printed NEXT, run as split, pushes the branch, which the remote then holds at the act's commit.
- A detached HEAD is refused.
- Dry runs on `main` and on a branch say what they should.
- A family made with `--tracking-branch trunk` refuses `add` on `trunk`; on a branch named `main` the dry run says the commit lands there for a pull request against `trunk`, the real run commits, and its NEXT pushes `main`.
- The suggested names pass `git check-ref-format --branch`.
- `init`'s NEXT STEPS open with the `switch`.
- AGENTS.md, README.md and the holder's two docs show the `switch` in the same block as, and before, the first `add`.

Mutations, each turning tests red: the branch check letting `main` through, 4 red; the NEXT line naming `<branch>` again, 3 red; `init`'s NEXT STEPS without the `switch`, 1 red; the tracking branch hard-coded to `"main"`, 1 red (the `trunk` test; every family test passed it before); the holder docs without the `switch`, 2 red; setup-project without the `switch`, 2 red.

An independent review at 10c93de gave CHANGES NEEDED (0 blockers, 1 should-fix, 6 nits). The should-fix was a holder that kept an older `scripts/repo_shape.py` through `apply --accept-local`: its new `siblings.py` could not import the shared quoter, so `make siblings`, `park` and `resume` died. The round at bb11b63 fixes it and all six nits, as described above. Re-verified at bb11b63: APPROVE WITH NITS (0 blockers, 0 should-fixes, 3 nits), with SonarCloud's quality gate OK and 0.0% new duplication. The nits were the holder README's pointer to the push (now "as its `NEXT` line says"), two Sonar smells (S1192: `refs/heads/` is now `HEADS_PREFIX`; S3415: the two expected-first assertions now put the actual value first, as the file's other 32 do), and this description's list of other pull requests. All three are fixed at 3451a56: commit 11ae0a8, then a clean merge of main 994be39. The family set gave 566 passed, 8 skipped there. GitHub's update-branch then merged main cac6bb2 as 8e1471c, and main aa4e1f5 (#508, after #490, #474 and #468) was merged as 708f3bb. There only the `docs/handbook.html` footer conflicted: it takes main's clauses, #468's (#424) included, with the README blob recomputed, and `docs/cli.md` auto-merged and passes `--check`. Main 47b1e06 (#505 and #395, after #376, #374, #384, #495, #493 and #491) was then merged as fcbc7a2, again with only the footer in conflict: main's clauses stay in main's order, this branch's (#487) moves to the end, after (#483), and the README blob is recomputed.

Every run used `/usr/bin/python3 -m pytest -q -p no:cacheprovider`, with a stub `gh` first on PATH, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR` and git's identity blanked. Run on Linux. At e42ef74 (main d41cc29, #402, merged in by GitHub's update-branch), on its tree 655c927: the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1242 passed, 22 skipped**; the second set, plus `tests/test_leg_path_yaml_scalars.py`, `tests/test_ruleset_hint_tracking_branch.py`, `tests/test_scaffold_reuse_seeded_legs.py`, `tests/test_scaffold_pin_and_reuse.py` and `tests/test_doctor_validator_rows.py`, gave **1260 passed, 12 skipped**. The skips are the same as at 5a2776d, below.

At 654df49 (main 8cd7dbe merged in, with #506 and #347): the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1242 passed, 22 skipped**; the second set, plus `tests/test_leg_path_yaml_scalars.py`, `tests/test_ruleset_hint_tracking_branch.py`, `tests/test_scaffold_reuse_seeded_legs.py` and `tests/test_scaffold_pin_and_reuse.py`, gave **1231 passed, 12 skipped**. The skips are the same as at 5a2776d, below.

At 5a2776d (main f391e58 merged in, with #266, #509, #496 and #435): the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1213 passed, 22 skipped** (the template-quoting skips below, and three NTFS-junction cases, Windows only); the second set, plus `tests/test_leg_path_yaml_scalars.py`, gave **1171 passed, 12 skipped** (the four Windows-only skips below, and eight in that file, main's new one, whose cases the quoted templates (#259) leave with nothing to refuse).

At 22733ce (main 631f087, #503, merged in by GitHub's update-branch): the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1213 passed, 22 skipped** (the template-quoting skips below, and three NTFS-junction cases, Windows only); the second set gave **944 passed, 4 skipped**.

At fcbc7a2 (origin/main 47b1e06 merged in, with #505 and #395):

- every `tests/test_family*.py`, `tests/test_validate_family*.py`, `tests/test_park_resume_targets.py`, `tests/test_free_plan_hint.py` and `tests/test_template_text_placeholders.py`: **844 passed, 19 skipped** (`tests/test_family_init_values.py:219` and `:291`, which skip because the template now quotes the placeholder and `id:`).
- `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_shape_doctor.py tests/test_setup_project_py.py tests/test_setup_sh.py tests/test_template_quoted_values.py tests/test_stdout_encoding.py tests/test_windows_paths.py tests/test_commit_trailers.py`: **944 passed, 4 skipped** (the doctor's PowerShell line and `tests/test_stdout_encoding.py:286`, Windows only).

At 708f3bb (origin/main aa4e1f5 merged in):

- every `tests/test_family*.py`, `tests/test_validate_family*.py`, `tests/test_park_resume_targets.py` and `tests/test_free_plan_hint.py`: **718 passed, 8 skipped** (`tests/test_family_init_values.py:242`).
- `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_shape_doctor.py tests/test_update_shape_landing.py tests/test_doctor_unfinished_merge.py tests/test_template_values_single_pass.py tests/test_stdout_encoding.py tests/test_windows_paths.py`: **813 passed, 4 skipped** (the doctor's PowerShell line and `tests/test_stdout_encoding.py:286`, Windows only).

Earlier, with origin/main 48585e6 merged in:

- `tests/test_family*.py tests/test_validate_family*.py tests/test_stdout_encoding.py tests/test_windows_paths.py tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_park_resume_targets.py tests/test_bootstrap_spelling.py tests/test_free_plan_hint.py tests/test_template_values_single_pass.py`: **1303 passed, 9 skipped**.
- `tests/test_shape_doctor.py tests/test_update_shape*.py tests/test_commit_trailers.py tests/test_tracking_branch_ref.py tests/test_scaffold_ignored_shape_files.py tests/test_ruleset_hint_tracking_branch.py tests/test_bump_leg_dry_run_tracking.py tests/test_setup_project_py.py`: **999 passed, 6 skipped**.
- The new files alone: `test_family_init_landing.py` 9, `test_family_printed_quoting.py` 69, `test_family_tracking_branch.py` 17, all passed.

The full suite was not run locally. CI's three jobs are the check.

#505 and #395 have since landed, and both are merged here at fcbc7a2. Each of #505's family sentences was read against this branch's code, and none is false: AGENTS.md 3 and 233; README 569, 822, 1152, 1269 and 1348; the handbook 1126 and 1506. #395 landed with its test switching to `family/members` before its real `add`, as the last bullet below says, and the first run above includes it. #503 then landed as 631f087, and GitHub's update-branch merged it here as 22733ce with no conflict. Main f391e58 (#266, #509, #496 and #435) was then merged by hand as 5a2776d, again with only the footer in conflict: main's clauses stay in main's order, this branch's (#487) moves after #509's (#498), and the README blob is recomputed; `git show --remerge-diff` shows nothing else. #509's reworded sentences in AGENTS.md, README.md and the handbook were read beside this branch's, and none contradicts or repeats another. Its `setup-project.py` changes (the clone-path check, the plan line, the step-(7) backstop and `_main`) and this branch's `shell_word` lines in the hand-over do not meet, and `docs/cli.md` passes `--check`. Main 8cd7dbe (#506 and #347) was then merged by hand as 654df49, again with only the footer in conflict: this branch's (#487) moves after #506's (#462), the README blob is recomputed, and `docs/cli.md`, which both sides changed, still passes `--check`. #506's two `scripts/family.py` hunks (the `FAMILY_RULESET_HINT` import, and its print when the holder's push is refused) sit unchanged inside this branch's code, and that hint prints no path. Under `--reuse-empty-repo`, `init` still refuses its default `--into` inside a checkout of this standard (`family-landing-in-standard`) before it reads the remote, as a dry run with a stub `gh` showed. #506's sentences in AGENTS.md, README.md and the handbook are still true beside this branch's. Main d41cc29 (#402, for #277), which rewrote the doctor's NEXT lines and `tests/test_shape_doctor.py`, was then merged by GitHub's update-branch as e42ef74, with no conflict; the `holder` fixture still switches to `family/members` before its `add`. AGENTS.md stays at 490 lines and README.md at 1422.

`git merge-tree --write-tree` of 708f3bb, at 04:47Z on 2026-10-13:

- **Clean:** #503 (`fix/family-makefile-without-siblings`) and #395. Each merged tree keeps AGENTS.md at 490 lines and README.md at 1422.
- **`docs/handbook.html` footer only:** #505 (`docs/v1-accuracy`) and #491. #468 has landed and is merged here. #506 conflicts there too, and in `scaffold-project.py`, as it does with main. README.md and AGENTS.md merge clean with #505, and `setup-project.py` merges clean with #491. Whichever lands second keeps every footer clause and recomputes the README blob.
- **#395 merges clean but one of its tests fails with this branch.** `tests/test_template_text_placeholders.py::test_a_member_whose_id_yaml_would_misread_is_pinned_under_that_id` inits its own holder and runs a real `add` on `main`, which #487 refuses with `family-on-tracking-branch`. Whichever of the two lands second adds `git("switch", "-q", "-c", "family/members", cwd=root)` after that test's `init`; lane 5 is adding it on #395's own branch now, so this branch needs no change for it. (#395's head d526f76 also conflicts with this head in `tests/test_adopt_manifest_values.py`, a file this branch does not touch: that is #395 against main, which the same round resolves.)

- On Windows `shell_quoted` speaks PowerShell, the native shell this standard documents (`setup-project.py`), as the doctor's NEXT lines do. `git -C …`, `python scripts\family.py …` and `cd …` are shell-agnostic, so they get the platform's quoting, and no printed line joins two commands with `&&`, which Windows PowerShell 5.1 rejects.
- `rm -rf`, `mv` and `mkdir -p` are POSIX commands. They are quoted for a POSIX shell on every host, and the text says "in a POSIX shell (Git Bash on Windows)". PowerShell refuses `rm -rf`'s `-rf` and deletes nothing.
- The tests split a host-quoted line with a PowerShell reader on Windows and with `shlex` elsewhere, and split a POSIX-only line with `shlex` everywhere. The real-shell test is skipped on Windows.
- On Windows the tests do not execute `rm` or the `mv` lines. They delete the split path with `shutil.rmtree`, and stop after checking the `mv` argv.
- Paths: the cached store keeps git's POSIX spelling (`as_posix`), as before. Test paths are `resolve()`d, which expands an 8.3 short name such as `RUNNER~1`.
- Nothing here was run on Windows or macOS locally. The `tests-windows` and `tests-macos` CI jobs are the check; on macOS the real-shell test runs the shipped `zsh`.

- **A shipped template that imports a newer shared helper can break a holder that kept an older copy.** This change no longer does (`siblings.py` uses `shlex.join`), but nothing stops the next one: `update-shape.py apply --accept-local scripts/repo_shape.py` keeps a `repo_shape.py` that lacks a name a newly copied file imports, and `check` and the validator then read the holder as fine. The general guard is for `apply` to refuse that, as rv456 found for `bootstrap.py` and `spelled` (#456).
- **The doctor's `quote_arg` leaves a word that begins with `=` bare**, which zsh expands. `shell_quoted` quotes it; the doctor is left as it is here, and the agreement test excludes that one case.
- **The doctor and `update-shape.py` keep their own quoters.** The doctor's `quote_arg` flattens to ASCII by its report's contract, so moving it onto `repo_shape.shell_quoted` is more than an import change. `update-shape.py`'s POSIX `shlex` lines are lane 3's open #374. A test holds `shell_quoted` and `quote_arg` to one answer on every printable ASCII value but a leading `=`.
- **`family.py`'s by-hand rollback commands (#365) and its `git config -f .gitmodules` removals (#317, #360) keep `shlex.quote`.** They were already quoted, they are POSIX-only (`printf … | git update-index`), and they are worded for a POSIX shell.
- **The holder's `validate-family.py` remedies that name `family.py add`** (`member-gitlink-absent`, `member-gitlink-unnamed`) print no branch step: they are a holder copy, and the `add` they name refuses the tracking branch with its own `switch` remedy.
- **`remove`'s store note still computes `.git/modules/<path>`** rather than asking `git rev-parse --git-path`, as `add` does. In a linked worktree it therefore prints no note. That predates this change.
- **PowerShell verbs** (`Remove-Item`, `Move-Item`) are not printed for the POSIX-only commands.
- **README's `--into` sentence gives the default but not the refusal.** README.md (861) and the handbook (1246) say `--into <dir>` is the PARENT directory, "the directory you are standing in, by default". They do not say that `init` refuses that default inside a checkout of this standard (`family-landing-in-standard`). AGENTS.md item 1 says so, and the README's own example passes `--into ..`. README.md is at its 1422-line cap, so the sentence is on #459's checklist.
- **`setup-project.py`'s own `cd <clone>` line and its step-(7) backstop lines print their paths unquoted.** This change quotes only the family lines it prints. Those two predate it and #509, and they are on #459's checklist.

Reviewed independently at 10c93de: CHANGES NEEDED (0 blockers, 1 should-fix, 6 nits). The round at bb11b63 fixed the should-fix and all six nits, and its re-verification gave APPROVE WITH NITS (0/0/3), with SonarCloud's quality gate OK; those three are fixed at 3451a56. Main was then merged by GitHub's update-branch (8e1471c), by hand at 708f3bb and at fcbc7a2, where each time only the handbook footer conflicted, and by update-branch again at 22733ce (main 631f087, #503), with no conflict. CI was green there on Linux, macOS and Windows, with SonarCloud passing, but main had moved on: main f391e58 (#266, #509, #496 and #435) was merged by hand at 5a2776d, where again only the footer conflicted. CI was green there too, but main moved on again: main 8cd7dbe (#506 and #347) was merged by hand at 654df49, where once more only the footer conflicted. CI was green there as well, and main d41cc29 (#402) then came in by update-branch at e42ef74, with no conflict. CI is green on Linux and macOS, with SonarCloud passing, at e42ef74, a clean merge of main. All three OSes and SonarCloud passed at 654df49, and under Brett Heap's landing rule of 22:08:06Z (ruling 27) macOS and Windows carry over from that head. Main e6fbd74 (#515, for #514), which landed after it, touches none of its files, and the combined tree passed `tests/test_adopt_pins.py`, `tests/test_shape_doctor.py`, `tests/test_scaffold_leg_path_collisions.py`, the hygiene, AGENTS-code and CLI-reference tests, `tests/test_family_printed_quoting.py` and `tests/test_family_tracking_branch.py` (777 passed, 3 skipped).

Closes #488
Closes #486
Closes #487

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…ision_follow_up`'s docstring moves up beside the docstring's first paragraph, so the docstring's end, which #512 (#502) also extends, merges without a hand resolution (#471)

This PR and #512 each appended a paragraph at the END of
`collision_follow_up`'s docstring, so a merge of one into the other, once the
first lands, conflicts in a .py file and needs a hand resolution. The
paragraph about a `.gitattributes` that is a symlink now sits straight after
the docstring's first paragraph, as its own paragraph with one blank line on
each side, and the docstring ends, as it does on main, with "and says both
merges come first (#493's review, F2)." Not one word changes and nothing else
in any file: the sorted lines of the file are equal before and after.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…ey and no path, so a hand edit of the spec leg's mount no longer leaves the split commit's message saying `$(CURDIR)/spec/contracts` over a Makefile that says `$(CURDIR)/legs/spec/contracts` (#522)

`plan` writes the follow-ups once. `execute` renders the Makefile's
`CONTRACTS_DIR` block from the plan's CURRENT `legs.spec_path`, and the
tool's own remediations ask for a hand edit of it (`MOUNT_REMEDIATION`,
`plan-leg-path-collides`). The follow-up named the plan-time path, so after
that edit the split commit's message, which is the pull request's body, and
the Makefile in the same commit disagreed. It now says the Makefile wires
`CONTRACTS_DIR` to the `contracts/` of the spec leg, where the plan's
`legs.spec_path` mounts it, the way #493-F3 names `tracking_branch`.

tests/test_adopt_contracts_dir.py holds the plan test to that one line for
every mount and adds an end-to-end test: a default plan, edited to
`legs/spec` and `impl/code`, then `check` and `execute`; the commit message
carries no `$(CURDIR)` path and the Makefile names the edited one. The
end-to-end fixture is now one helper for the flag and the hand-edit
adoption. Review of PR #572 at 9ddb7ef, F1.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
#582 rewrote what `adopt-project.py`'s `plan` and `execute` say around a
run, so this branch conflicted with main in `adopt-project.py` and
`gh pr update-branch` refused. The merge also takes main's bcfa364 (#538),
709eec1 (#532) and 40b476d (#407), which touch no file this branch
changes.

One conflicted hunk, in the docstring of `follow_ups_for`. Both sides
appended a paragraph after the #493 F3 paragraph that ends "(#493's review,
F3)": #582's about the last follow-up being a review, and this branch's
about the Makefile follow-up naming `legs.spec_path` and no path (#522, F1).
Both are kept. #582's comes first because "It is a review" continues the
paragraph above it, and this branch's follows, reworded from "does the
same" to "names the key too", which would otherwise point at #582's
paragraph. No code was in conflict.

The code from the two sides merged by itself and does not overlap: #582
changed the last follow-up's text, `_finish_by_hand`, `_next_steps`,
`cmd_execute` and the ruleset hint; this branch changed
`cross_leg_references` (via `spec_leg_from_code_leg`), the Makefile
follow-up and `_commit_the_split`'s Makefile block. pyflakes on the merged
`adopt-project.py` prints nothing, as on both parents.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…leg follow-up name each leg where the plan mounts it, so `--spec-path legs/spec` exports a `CONTRACTS_DIR` that exists and a nested `--code-path` gets the right `../` path (#522) (#572)

Both rendered a leg's mount path as if it were the default (#522, from the
v1.1.0 triage of #457's known limits):

- The block `execute` appends to the assembly root's Makefile was one
  constant exporting `CONTRACTS_DIR ?= $(CURDIR)/spec/contracts`, so an
  adoption with `--spec-path legs/spec` pointed the code leg's tooling at a
  directory that does not exist, while `plan`'s own follow-up said the
  Makefile wires `$(CURDIR)/legs/spec/contracts`. The block is now
  `shape_materialize.adopt_makefile_block(spec_path, code_path)`, rendered
  with `execute`'s checked mount paths. It is still the adoption's own
  append, digested with the copy by `_materialize`, so no pinned copy is
  edited by hand; a default adoption gets byte-identical bytes, and
  `ADOPT_MAKEFILE_BLOCK` stays as that default rendering.
- auA-13: the follow-up for a code file that names a spec directory built
  `../<spec_path>/<dir>` "relative to `<code_path>/`", one `..` at any
  depth, so `--spec-path legs/spec --code-path legs/code` printed
  `../legs/spec/contracts/`. `spec_leg_from_code_leg` climbs once per
  segment of the code leg's path below what the two paths share, and
  prints `../spec/contracts/` there.
- `plan` writes its follow-ups once, and `execute` renders the Makefile block
  from the plan's CURRENT `legs.spec_path`, which the tool's own remediations
  (`MOUNT_REMEDIATION`, `plan-leg-path-collides`) ask a person to edit. The
  Makefile follow-up named the plan-time path, so after that edit the split
  commit's message said `$(CURDIR)/spec/contracts` over a Makefile in the
  same commit that said `$(CURDIR)/legs/spec/contracts`. It now says the
  Makefile wires `CONTRACTS_DIR` to the `contracts/` of the spec leg, where
  the plan's `legs.spec_path` mounts it: the key, as #493-F3 does for
  `tracking_branch`, and no path.

tests/test_adopt_contracts_dir.py checks each path by where it leads: in a
recursive clone of a real split with the legs at `legs/spec` and
`impl/code`, the exported directory and the follow-up's read-across both
hold the source's contract, `make contracts-dir` prints that directory, the
shape pin digests the rendered Makefile, and a plan whose mount paths were
edited by hand after `plan` gives a split commit whose message names no
`$(CURDIR)` path over a Makefile that names the edited one.

Closes #522

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…d `legs.spec_path` and no path, and the `contracts/` rule's reason names none, so the hand edit of a leg's mount the tool asks for leaves no plan-time path in `check`, the split commit or the pull request (#600) (#628)

`plan` writes its follow-ups once, and `MOUNT_REMEDIATION` and
`plan-leg-path-collides` both tell a person to change `legs.spec_path` or
`legs.code_path` in the plan afterwards; `execute` then mounts the legs at the
edited paths. The read-across follow-up that `cross_leg_references` writes
named the plan-time values, `code/src/app/main.py` and `../spec/contracts/`
"relative to `code/`", so after that edit `check`'s output, the split
commit's message and the pull request's body, which is the same text,
described a layout that no longer had them (#522's review, U2; #493's review,
F3 did it for `tracking_branch`, #522's F1 for the Makefile line).

- L2-18: the line says where the file is, in the code leg, and that it must
  read across the assembly root from the code leg, where the plan's
  `legs.code_path` mounts it, to the spec directory in the spec leg, where
  `legs.spec_path` mounts it. It is true whatever the two keys hold, and
  `cross_leg_references` and `follow_ups_for` no longer take a mount path, so
  no value of either key can reach it. Rendering it again at `check` and
  `execute` would have needed the source's `git grep` at both, left the plan
  file stale between the edit and the next `check`, and had to leave alone the
  items a person edits into `follow_ups:`.
- The path from the code leg to the spec leg's `contracts/`, which a person
  derives wrongly by hand for nested legs (#522, auA-13), is printed where the
  CURRENT mounts are: `check`'s "mount the legs at X/ and Y/" and the split
  message's "mounted back here at X/ and Y/", which is also the pull request's
  body, end with ", so from the code leg the spec leg's `contracts/` is
  `../spec/contracts/`" (`../../legs/spec/contracts/` for `legs/spec` beside
  `impl/code`). It is computed from the mounts each surface prints, so it
  follows a hand edit of either key, and it is never stored in the plan. It
  says nothing where the spec leg holds no `contracts/`, or where the two
  mounts are not two mount paths. `spec_leg_from_code_leg` (#522) computes it.
- L2-19: the `contracts/` rule's `reason:` in
  `contracts/path-classification.yaml` said `../spec/contracts` from `code/`,
  which is false for a nested plan, and every plan entry the rule decides
  copies it. It says the code reads across the assembly-root mount, which is
  what the legs are mounted for. A test holds every rule's `reason:` and
  `question:`, and the `default:` block's, free of a mount path. The file is
  not a shape copy: `plan` and the doctor read it from the shape's own tree,
  and no root holds it or a `contracts/shape-pin.yaml` row for it, so no
  root's `update-shape.py check` changes.

tests/test_adopt_leg_keys.py plans with the defaults and with `--spec-path
legs/spec --code-path impl/code`, edits both keys by hand to other mounts, and
runs `check` and `execute` once with `--local-remote-dir` and once against a
stub `gh` that logs the arguments of `gh pr create`: `check`'s follow-ups, the
split commit's message and the pull request's body each carry the one
key-form line and none of the plan-time spellings, and `check` and the split
message print the path of the edited mounts. It also holds the `contracts/`
rule's reason, and every plan entry that copies it, to a text with no mount
path in it.

Closes #600

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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