Skip to content

setup-project.py: the clone path <into>/<Project> is refused in step (3), before anything is created, when anything at all stands there, naming the path and telling a rehearsal's leftover clone apart as one to move or delete, and the plan names the clone path before the yes, so rehearsing and then running for real in the same folder - README's own order - no longer creates three repositories and then refuses with a hand clone that fails on the same directory (#498) - #509

Merged
brettheap merged 4 commits into
mainfrom
fix/setup-refuse-taken-clone-path-first
Oct 13, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)

What was wrong (#498, audit finding auS1-02)

README's "Rehearse first, creating nothing" runs the scaffold against --local-remote-dir ./rehearsal and leaves ./Atlas behind, "both of which you can delete". Someone who kept it and then ran the real command in the same folder went through the preflight, the naming check, the plan and the yes. Step (6) then created and pushed all three repositories. Only then did step (7) find ./Atlas and refuse. Its printed git clone failed on the same directory, and a re-run was refused scaffold-remote-exists. resolve_family decides the clone path in step (3), but nothing read that path before step (7).

What changes (setup-project.py; setup.sh hands over to it unchanged)

  • Step (3), right after resolve_family: refuse_a_taken_clone_path refuses, with exit 2, when anything stands at <into>/<Project>.
    • The check is os.path.lexists, so a symbolic link to nothing counts. git clone refuses one too.
    • An empty directory is refused as well. This is the issue's option 1, put to Brett Heap on 2026-10-13; he did not object.
    • The refusal says nothing was created, names the path, says what is there when it can tell, and then gives the way out.
  • Telling what is there:
    • What LOOKS like a rehearsal's clone (an origin on this disk, named <Project>.git, and a project.yaml at the root) is said to look like one. Its way out is "Move it, or delete it if nothing in it is yours, then run this again." A working clone of a local or network repository looks the same, so the tool never asserts it (review round 1).
    • An empty directory gets "Move or delete it, then run this again."
    • Everything else gets "Move it, or choose another --into, then run this again.":
      • a clone of GitHub, named with any user:token@ dropped;
      • a local mirror under another name;
      • a directory that is not a clone;
      • a directory holding a .git whose origin cannot be read;
      • a file;
      • a symbolic link, which is named as one.
    • The origin is read with git config --file <clone>/.git/config, never with git -C and never with git run inside the clone. git -C would walk up from an empty .git and report the origin of the repository that holds <into>. Running git inside the clone would read a config that can name a command.
  • Step (5): the plan prints clone into <path> after the dry run, on every run. That path is the one thing the run writes on this disk, and the plan never named it before the yes.
  • Step (7) stays as the backstop, now also using lexists. Its way out now says to move the thing aside first and to run the hand clone from the clone's parent.

Example

REFUSED: <R>/Atlas already exists, and that is where this project would be cloned, so the run stops here and nothing was created. It looks like the clone a rehearsal left behind: its `origin` is <R>/rehearsal/Atlas.git, a repository on this disk, as `--local-remote-dir` makes one. Move it, or delete it if nothing in it is yours, then run this again.

Tests

New tests. The runs through both entry points are written once, as two helpers in tests/conftest.py. Each test file calls them through a one-call wrapper, so Sonar's duplication gate holds.

  • assert_a_rehearsals_clone_stops_the_real_run follows README's order in one folder: a rehearsal, then a real run into a second --local-remote-dir. The real run must refuse in step (3), say the clone looks like the rehearsal's, offer deleting it only if nothing in it is the person's, and leave the second remote directory absent. The rehearsal's plan must name clone into <path> between the dry run and step (6).
  • assert_a_taken_clone_path_is_refused runs three cases, all with --yes: an empty directory, a file, and a directory that is not a clone. Each must be refused in step (3) with the exact wording, create no remotes, and leave the path untouched.
  • In-process tests in tests/test_setup_project_py.py:
    • eight what_stands_at cases, among them a token dropped from a GitHub origin and an empty .git inside another repository, which must not be reported as that repository's clone;
    • a symbolic link to nothing, with a free path as the control;
    • the step (7) backstop's exact way out, with nothing run.
  • The two existing plan_and_confirm calls pass the new clone argument. The family no-terminal rehearsal also asserts the clone into line.

Targeted run: 386 passed, 0 failed, 0 skipped, in 3 min 47 s. Files: tests/test_setup_project_py.py, test_setup_sh.py, test_openreposhape_command.py, test_windows_paths.py, test_cli_reference.py, test_agents_md_codes.py and test_repo_hygiene.py.

Mutants. Each mutant was run in a scratch worktree with this branch's test files:

  • The fix reverted (setup-project.py at cc8f2da4): all 19 selected tests fail: the 18 new ones and the amended family rehearsal. The base creates all three remotes and refuses in step (7) with the old "Finish by hand" text, prints no clone into line, and lacks the new functions.
  • Only the step (3) call removed: all 8 runs through both entry points fail. The real run reaches the step (7) backstop after creating the repositories.
  • lexists changed to exists in step (3): the link test fails, DID NOT RAISE Refusal.

Review round 1 (independent review at 427237a: NOT MERGEABLE on one MUST)

All folded into this push, with the merge of origin/main and the docs commit. The review fixes are their own commit, 7a97d53b:

  • MUST-1: a real working clone of a local, NAS, UNC or file:// repository named <Project>.git that holds a project.yaml was told "It is the clone a rehearsal left behind … Move or delete it", even with unpushed commits.
    • Now it is "It looks like the clone a rehearsal left behind …", with "Move it, or delete it if nothing in it is yours, then run this again."
    • git config --file is kept, and no git runs inside the clone.
    • Test: a clone of a local bare repository setup never made, with an unpushed commit, gets neither the assertion nor a bare "Move or delete it".
  • SHOULD-1: an origin carrying ESC[2J and a newline before a forged REFUSED: is written into .git/config as bytes. The test asserts that git reads it back as written, and that it is never echoed. This pins CONTROL_RE.
  • SHOULD-2: the step (7) backstop now has a link-to-nothing case, which pins its lexists. It is skipped where os.symlink is refused.
  • NIT-1: without_credentials cuts to the LAST @ before the path, and now applies to the rehearsal sentence too. Tests: https://dana:p@SECRET3@github.com/..., and file://dana:SECRET7@/....
  • NIT-2 (taken):
    • A symbolic link at the clone path is named as one ("It is a symbolic link to .") and is never offered for deletion. Tests cover a dangling link and a link to an empty directory.
    • An unreadable or missing origin reads "It is a directory holding a .git." instead of "It is a git repository."
  • NIT-3: left as it is. The backstop's hand-clone caveats (protocol.file.allow under --local-remote-dir, PowerShell &&, unquoted paths) are on main already.

Mutants for the review, run in a scratch worktree at 121c204b with the fixed tests (log logs/mutants-review-round-1.log). Each one is killed:

Mutant Tests that fail
The old rehearsal wording, stated as fact with "Move or delete it" 4
CONTROL_RE filter dropped (the reviewer's M5) 1
Backstop lexists changed to exists (M6) 1
The first-@ regex 1
The symlink branch dropped 2

Targeted run at 7a97d53b: 392 passed, 0 failed, 0 skipped, in 3 min 22 s (log logs/targeted-2.log). The files are the round-0 set: test_setup_project_py, test_setup_sh, test_openreposhape_command, test_windows_paths, test_cli_reference, test_agents_md_codes and test_repo_hygiene. The docs commit had already passed test_repo_hygiene, test_cli_reference, test_agents_md_codes and the two setup files: 295 passed, in logs/docs-run-1.log.

Docs

The docs are corrected in this PR. Brett Heap ruled at 2026-10-13T11:30Z that each lane-4 fix PR carries its own doc lines. The branch first merges origin/main at 47b1e06c, which brings in #505 (af01068b), with no conflicts. The doc commit then corrects these lines; each file keeps its line count (README.md 1422, its cap; AGENTS.md 490, its cap; docs/handbook.html 1933):

No --help text changed, so docs/cli.md stands.

Shared files

Closes #498

🤖 Generated with Claude Code

…p (3), before anything is created, when anything at all stands there, naming the path and telling a rehearsal's leftover clone apart as one to move or delete, and the plan names the clone path before the yes, so rehearsing and then running for real in the same folder - README's own order - no longer creates three repositories and then refuses with a hand clone that fails on the same directory (#498)

README's "Rehearse first, creating nothing" runs the scaffold against
`--local-remote-dir ./rehearsal` and leaves `./Atlas` behind, "both of
which you can delete". A person who kept it and ran the real command in
the same folder went through the preflight, the naming check, the plan
and the yes; step (6) created and pushed all three repositories; and only
then did step (7) find `./Atlas` and refuse. Its printed `git clone`
failed on the same directory, the `cd Atlas` after it entered the
rehearsal's project, and a re-run was refused `scaffold-remote-exists`.
The path was decided in step (3), by `resolve_family`, and nothing read it
before step (7).

- Step (3), right after `resolve_family`: `refuse_a_taken_clone_path`
  refuses when anything at all stands at the clone path. `os.path.lexists`,
  so a symbolic link to nothing counts: `exists` reads it as absent and
  `git clone` still refuses it. An EMPTY directory is refused too, though
  `git clone` would take one: #498's option 1, put to Brett Heap on
  2026-10-13, who did not object. The refusal, exit 2, says "<path> already
  exists, and that is where this project would be cloned, so the run stops
  here and nothing was created.", then what is there where it can tell
  (`what_stands_at`), then the way out.
- A rehearsal's clone is told apart by all three of: an `origin` on this
  disk, named `<Project>.git`, and a `project.yaml` at its root. "It is the
  clone a rehearsal left behind: its `origin` is <path>, a repository on
  this disk, as `--local-remote-dir` makes one. Move or delete it, then run
  this again." An empty directory is offered for deletion too. Anything
  else is somebody's and is never offered for deletion: a clone of GitHub
  (named, with any `user:token@` dropped), a local mirror under another
  name, a directory that is not a clone, a file. "Move it, or choose
  another --into, then run this again."
- The `origin` is read with `git config --file <clone>/.git/config`, never
  `git -C <clone>`, which walks up from an empty or broken `.git` and
  reports the `origin` of whatever repository holds `<into>`. A value
  carrying a control character is not echoed.
- Step (5): the plan prints `clone into   <path>` after the scaffold's dry
  run, on every run, `--yes` and no terminal included. The plan named the
  three repositories and never the one thing the run writes on this disk;
  it is also what an assistant relays to the person deciding.
- Step (7) keeps its check as the backstop for a path that appears while
  the run is going, now `lexists` too. Its way out moves the thing aside
  first and runs the hand clone from the clone's parent, where it used to
  print a `git clone` that met the same directory.

`setup.sh` hands over to `setup-project.py`, so both entry points refuse
alike. The runs through each are asserted once, in two helpers in
`tests/conftest.py`, with a one-call wrapper per file. README.md,
AGENTS.md and docs/handbook.html are #483's (lane 5) and are not changed
here; no `--help` text changed, so docs/cli.md stands.

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

sourcery-ai Bot commented Oct 13, 2026

Copy link
Copy Markdown

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

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

@sourcery-ai

sourcery-ai Bot commented Oct 13, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR closes the rehearsal-leftover safety gap by determining and refusing an occupied clone path before any remote creation, identifying safe versus unsafe existing content, showing the destination in the confirmation plan, and preserving a clearer late-race backstop; comprehensive tests cover both entry points and the path-classification edge cases.

Sequence diagram for preflight clone-path refusal

sequenceDiagram
    participant User
    participant Main as _main
    participant Resolver as resolve_family
    participant Guard as refuse_a_taken_clone_path
    participant Classifier as what_stands_at
    participant Remotes as Remote creation

    User->>Main: Run setup-project.py
    Main->>Resolver: resolve_family(opts, shape_root)
    Resolver-->>Main: landing
    Main->>Main: landing.clone_path(opts.project)
    Main->>Guard: refuse_a_taken_clone_path(clone, project)
    alt clone path is occupied
        Guard->>Classifier: what_stands_at(clone, project)
        Classifier-->>Guard: description and disposal guidance
        Guard-->>User: Refusal, nothing was created
    else clone path is free
        Guard-->>Main: Continue
        Main->>Remotes: scaffold and create repositories
    end
Loading

Sequence diagram for rehearsal clone detection

sequenceDiagram
    participant Guard as refuse_a_taken_clone_path
    participant Classifier as what_stands_at
    participant Config as origin_recorded_in
    participant Git as git config --file

    Guard->>Classifier: what_stands_at(clone, project)
    Classifier->>Config: origin_recorded_in(clone)
    Config->>Git: git config --file clone/.git/config --get remote.origin.url
    Git-->>Config: origin URL
    Config-->>Classifier: origin URL or empty
    alt local origin, Project.git, and project.yaml
        Classifier-->>Guard: Rehearsal clone, disposable
        Guard-->>Guard: Move or delete it
    else other existing content
        Classifier-->>Guard: Non-disposable description
        Guard-->>Guard: Move it or choose another --into
    end
Loading

Flow diagram for clone destination confirmation

flowchart TD
    A[resolve_family determines landing] --> B[Compute clone path]
    B --> C{Path occupied?}
    C -->|Yes| D[Classify existing content]
    D --> E[Refuse before remote creation]
    C -->|No| F[Run names and scaffold dry run]
    F --> G[Print clone into path]
    G --> H{Confirmation required?}
    H -->|Yes| I[Ask for confirmation]
    H -->|No| J[Create repositories and clone]
Loading

File-Level Changes

Change Details Files
Reject an occupied clone destination during step (3), before remote repositories or scaffold artifacts are created.
  • Resolve the clone path immediately after family resolution and check it with os.path.lexists.
  • Classify existing paths, including rehearsal clones, empty directories, files, symlinks, and unrelated repositories.
  • Use the clone’s own .git/config to inspect origin without repository discovery or credential leakage.
  • Provide distinct remediation guidance for disposable rehearsal/empty paths versus paths that must be moved or bypassed.
setup-project.py
Make the clone destination explicit in the confirmation plan and improve the late-race backstop.
  • Print clone into <path> for every plan before confirmation.
  • Retain the step (7) existence check using lexists.
  • Update the late-failure instructions to move the conflicting path first and run the hand clone from its parent directory.
setup-project.py
Add shared integration coverage for both command entry points and all occupied-path scenarios.
  • Exercise rehearsal-then-real execution and verify refusal before remote creation.
  • Verify exact refusals and path preservation for empty directories, files, and unrelated directories.
  • Reuse helper assertions across setup.sh and direct setup-project.py tests.
tests/conftest.py
tests/test_setup_project_py.py
tests/test_setup_sh.py
Add focused unit coverage for path classification, symlink handling, planning output, and the step (7) backstop.
  • Cover local/file origins, GitHub credentials, renamed mirrors, missing manifests, missing origins, .git files, and nested empty .git directories.
  • Verify dangling symlinks are treated as occupied while free paths remain allowed.
  • Update plan_and_confirm callers for the new clone argument and assert family-level plan output.
tests/test_setup_project_py.py

Assessment against linked issues

Issue Objective Addressed Explanation
#498 Refuse an already-occupied <into>/<Project> clone path during step (3), before confirmation or remote creation, while retaining the later existence check as a backstop for paths that appear during the run. ✅
#498 Explain what is occupying the path when identifiable, distinguish a rehearsal’s leftover clone from other content, and give appropriate recovery guidance without deleting or moving anything automatically. ✅
#498 Show the intended clone destination in the plan before the user confirms, and test the behavior through both setup-project.py and setup.sh. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

Brett Heap and others added 3 commits October 13, 2026 11:31
Brings in af01068 (#505, the v1.0.0 accuracy pass over README.md, AGENTS.md
and docs/handbook.html) and 47b1e06 (#395), so #498's doc corrections are
made on the text main now carries.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ready stands at is refused up front, so the rehearsal's `./Atlas` is no longer optional to delete before a real run in the same folder, AGENTS.md names it as the third refusal an assistant can foresee before anything is created, and "What setup.sh does" no longer says the flow has no behaviour of its own (#498)

The tool half of #498 refuses `<into>/<Project>` in step (3) when
anything stands there. The documents still said otherwise. Brett Heap
ruled at 2026-10-13T11:30Z that each lane-4 fix pull request carries its
own doc lines, so they are corrected here, on the text #505 (af01068)
left. Each file keeps its line count: README.md stays at its cap of 1422,
AGENTS.md at 490, and docs/handbook.html at 1933.

- README.md:306-313. The rehearsal "leaves behind ... all of which you can
  delete" now reads "all deletable; a real run here refuses up front while
  `./Atlas` is there". The rest of that paragraph is rewrapped with its
  words unchanged.
- README.md:524-525. "What setup.sh does" no longer says the flow runs its
  commands "with no behaviour of its own". It says that before the second
  command the flow refuses, creating nothing, if anything stands at
  `<into>/Atlas`.
- AGENTS.md:88-96. "Two you can foresee from your flags" becomes "Three
  you can foresee". The third is anything already at `<into>/<Project>`,
  a rehearsal's clone included. The assistant asks the human to move or
  delete it and never does either itself. Both refusal codes are still
  spelled as before.
- AGENTS.md:110-113. The steps by hand begin with "a free clone path".
- docs/handbook.html:869-872, 956-957 and 1818-1825 are the README's and
  AGENTS.md's twins of the above. The footer at 1872-1875 adds "a taken
  clone path refused up front, added (#498)" and sets the README blob to
  `git hash-object README.md`.

README.md:445 ("clones the root as `./Atlas` where you ran it") is left
as it is. Saying more there fits only by rewrapping all ten lines from 436
to 445, which belong to other findings.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s clone is said to LOOK like one and is offered for deletion only "if nothing in it is yours", a symbolic link there is named as one and never offered for deletion, and a credential is cut at the last `@`, so a real working clone of a local or network repository with unpushed work is no longer told "It is the clone a rehearsal left behind ... Move or delete it" (#498)

The independent review of #509 at 427237a found this (MUST-1). The three
signs `what_stands_at` reads are an `origin` on this disk, named
`<Project>.git`, and a `project.yaml` at the root. A working clone of a
repository on a local path, a NAS mount or a UNC share meets all three,
and so does a rehearsal's clone somebody has since worked in. The refusal
stated a guess as a fact and offered deletion outright, and an assistant
relaying "delete it" could destroy unpushed work.

- The rehearsal sentence now reads "It looks like the clone a rehearsal
  left behind: its `origin` is ..., a repository on this disk, as
  `--local-remote-dir` makes one." The way out is "Move it, or delete it
  if nothing in it is yours, then run this again." Only an empty directory
  still gets a plain "Move or delete it".
- The `origin` is still read with `git config --file`, and no git runs
  inside a directory this tool did not make: its config can name a
  command (`core.fsmonitor`) that `git status` executes. That is why
  unpushed work is not checked for, and why the wording carries the
  condition instead.
- `what_stands_at` returns its way out with its sentence, so the three
  strengths (`DISCARD_IT`, `DISCARD_IT_IF_UNUSED`, `KEEP_IT`) are chosen
  where the thing is identified.
- A symbolic link at the clone path is named first: "It is a symbolic
  link to <target>.", with KEEP_IT. `os.path.isdir` follows a link, so a
  link to an empty directory used to be offered "Move or delete it", and
  `rm -r Sample/` acts on the target. That was NIT-2.
- A directory whose `origin` cannot be read says "It is a directory
  holding a `.git`." rather than "It is a git repository.". An empty
  `.git`, or a `.git` file naming a missing gitdir, is not a repository
  (NIT-2).
- `without_credentials` cuts up to the LAST `@` before the path, so
  `https://dana:p@SECRET3@github.com/...` no longer leaves `SECRET3`
  showing. It now applies to the rehearsal sentence too (NIT-1).

Tests, in tests/test_setup_project_py.py, with conftest's rehearsal
assertion following the new wording:
- a working clone of a local bare repository setup never made, holding
  an unpushed commit, is told only that it looks like a rehearsal's clone,
  and never "Move or delete it" (MUST-1);
- an `origin` carrying an ESC sequence and a newline before a forged
  `REFUSED:` line. It is written into `.git/config` as bytes and read back
  by git first, and is never echoed (SHOULD-1, which pins `CONTROL_RE`);
- the step (7) backstop with a symbolic link to nothing at the path,
  which pins its `lexists` (SHOULD-2);
- a password holding `@`, and a `file://` origin with credentials (NIT-1);
- a link to nothing and a link to an empty directory, each named as a
  link with KEEP_IT (NIT-2).

Each change reverted alone fails these tests. The old rehearsal wording
fails 4. Dropping the control-character filter fails 1. The backstop
asking `exists` fails 1. The first-`@` cut fails 1. Dropping the link
branch fails 2.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit cdcd2f0 into main Oct 13, 2026
5 checks passed
@brettheap
brettheap deleted the fix/setup-refuse-taken-clone-path-first branch October 13, 2026 13:44
brettheap pushed a commit that referenced this pull request Oct 13, 2026
… footer keeps #509's clause "a taken clone path refused up front, added (#498)" and adds this pull request's after it, with `README.md blob` re-hashed from the merged README.md (#500)

PR #509 landed as cdcd2f0 and rewrote the footer of docs/handbook.html: its
"added (#498)" clause and the `README.md blob <hash>` line that
tests/test_repo_hygiene.py checks. This branch rewrites the same lines, so
the merge conflicted there and only there. The resolution keeps main's text,
adds "the installed doctor keeping its clone of the standard, which its NEXT
lines run from, added (#500)" in the same `; ... added (#NNN)` form before
" &mdash; a page cannot name the commit", re-wraps only the lines it
touches, and sets `README.md blob` to `git hash-object README.md` of the
merged file: 43df8f4 on main, 6bf3f89 on this branch's head, 5f1868d once
merged. README.md stays at 1422 lines and AGENTS.md at 490.

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 13, 2026
…471's PR carries main's #509 and #435 and its footer reads right (#471)

One conflict, docs/handbook.html's footer, which every PR in the doc train
rewrites. Resolved by the doc-train rule: main's text is kept whole, including
#509's clause "a taken clone path refused up front, added (#498)", and this
PR's own clause, "the line-ending block an adoption appends to a root
.gitattributes the source keeps, and its verification as the source's bytes
plus that block, added (#471)", follows it in the same "; ... added (#NNN)"
form before " &mdash; a page cannot name the commit". Only the touched lines
were re-wrapped, to the footer's 100-column width. The footer's
`README.md blob` moves from c61ac37 (this
branch before the merge; main's own was 43df8f4)
to e6c41c9, `git hash-object README.md` of the
merged README.md. README.md stays at 1422 lines and AGENTS.md at 490.

Every other file merged without a conflict, including adopt-project.py, which
both sides changed (main's #435 shape checks on the plan, this PR's block).

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 13, 2026
…e: docs/handbook.html's footer keeps main's text and adds this PR's clause after #498's, and names the merged README.md's blob (#499)

Five commits of main since this branch's base 47b1e06 merged, among them
#509 (cdcd2f0, #498), which rewrote the handbook footer, and #435
(f391e58, #386), which gates a plan's `source:`, `legs:`,
`allow_empty_legs:`, `paths:` and `pins:` by type at the top of
`adopt-project.py`'s `check` and `execute`. AGENTS.md, README.md and
adopt-project.py merged without a conflict.

docs/handbook.html conflicted in its footer only. The resolution keeps
main's text, "a taken clone path refused up front, added (#498)" included,
adds this PR's clause after it in the same form, "an adoption&rsquo;s
<code>--project</code> as the source&rsquo;s own name, added (#499)",
and re-wraps only the two lines that clause touches, to the footer's
100 columns, now three. The PR's own body edit at "It KEEPS ITS NAME"
merged as it was.

`README.md blob` moves from main's 43df8f4
and this branch's d19633d to
045250e, `git hash-object README.md` of
the merged README.md, which `tests/test_repo_hygiene.py` checks. README.md
stays at 1422 lines and AGENTS.md at 490.

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 added a commit that referenced this pull request Oct 13, 2026
…ndard it ran from and names it on the report's last line, so a NEXT line pasted after the command has exited runs instead of dying with `can't open file`, and it hands `--json` and `--placement-plan <file>` on to `shape-doctor.py` instead of refusing them (#500) (#510)

Installed, `openRepoShape --doctor` has no checkout beside it, so it clones
the standard into `mktemp -d` and runs `shape-doctor.py` from there. The
doctor builds its NEXT lines from that clone: `update-shape.py check
--upstream <clone>`, `adopt-project.py plan`, `setup-project.py --preflight`,
the template validators. An EXIT trap then removed the clone as the command
returned, so each of those lines, pasted, died with `python3: can't open
file`, exit 2. And `--doctor` refused every argument but the path, so
`--json` (AGENTS.md doctor step 7) and `--placement-plan <file>` (the
`placement` row's own NEXT, step 4) could not be asked for at all.

Brett Heap's ruling, 2026-10-13: "Keep the run's clone".

- The EXIT trap is disarmed once the doctor has RUN, and not before. A
  refusal on the way (the clone failed, the ref did not check out, the ref
  carries no `shape-doctor.py`) printed no NEXT line and still leaves
  nothing behind. So does an interrupted run, because bash runs the EXIT
  trap on INT, TERM and HUP. Beside a checkout nothing is cloned, and
  nothing is kept or named.
- The clone lives in `${TMPDIR:-/tmp}/openRepoShape-doctor.XXXXXXXX`, so a
  person can find every directory they have been left, and it is resolved
  with `pwd -P`, because the doctor prints its standard resolved (macOS's
  `$TMPDIR` sits under `/var` -> `/private/var`). Every step of making it is
  checked: `--doctor` runs under `set +e`, and an unchecked `cd -- ""` after
  a failed `mktemp` would have pointed the trap at the person's own
  directory.
- The report's last line names the directory, says that any NEXT command
  above runs from it, and says it is safe to delete once the person is done
  with them. The doctor can refuse (a missing `<path>`, a `--placement-plan`
  it cannot write: exit 3) with no report and no NEXT line, and the clone is
  kept all the same, so the line never claims there are NEXT commands.
  Under `--json` that line goes to stderr. stdout is a JSON object a caller
  parses whole, one more line makes it a document `json.load` refuses, and
  the object already names the clone as `standard`. Without `--json` it
  stays on stdout, so a report saved to a file still says what it left
  behind. The line hands out no `rm` command: an agent told to "run the
  command the row names" would delete the clone before the commands it holds
  had run.
- `--json` and `--placement-plan <file>` (or `--placement-plan=<file>`)
  are the doctor's when they come AFTER `--doctor`, the way a subcommand's
  flags follow it. Anywhere else they are the lone flags they always were,
  passed on to setup.sh, which refuses them, so a scaffold run reads those
  words as before. The file reaches `shape-doctor.py` as
  `--placement-plan=<file>`, so a name beginning with `-` is still a file. A
  missing file, an empty one, or a flag where the file goes is refused by
  name, exit 2.
- The doctor runs in the person's own directory and is never `cd`'d, so a
  relative `<file>` lands where the command was typed: not in the
  repository, and not in the clone.
- A path may follow the flags (`--doctor --json ../Atlas`). A word before
  `--doctor`, or a second path, is still a `<Project>` and is refused as
  before. The refusal's wording changes: it named "a path and nothing else",
  and now gives the full form.
- `openRepoShape --help`'s `--doctor` paragraph is the text this changes.
  It now names the two flags and the kept clone. Its verdict list gains
  `MISPLACED` and `CANNOT ANSWER`, and `COMPLIANT, SHAPE BEHIND` gets its
  comma back. Its exit codes now say that 3 means the doctor cannot answer
  and that this command's own refusals exit 2. The old text said "3 usage",
  which no refusal here exits. `docs/cli.md` is regenerated.

Tests (tests/test_shape_doctor.py, shim section; offline, local bare
standard, the refusing `gh`/`curl` stubs, `$TMPDIR` the test's own):
- the stdin test now asserts the opposite of what it pinned: the clone
  outlives the run, is the only entry in `$TMPDIR`, is named on the last
  line, and carries the `openRepoShape-doctor.` prefix;
- the installed file, alone in a directory, over a drifted project: every
  NEXT that names the kept clone runs after the command has exited, as
  `pasted()` splits it, and the `shape currency` one reproduces
  `locally-modified scripts/bootstrap.py`;
- `--json` from the installed form parses as one object, the kept line is
  on stderr, and the rows equal the beside-a-checkout `--json` rows with only
  the standard's path differing;
- `--placement-plan` (relative, path-then-flag and `=`-flag-then-path): the
  plan lands in the directory the command ran from and nowhere else, and the
  project's tree is unchanged;
- six refusals (a scaffold flag, a second path, `--json` before `--doctor`,
  a missing, empty or flag-shaped plan file), all exit 2, nothing on stdout,
  nothing cloned, nothing fetched;
- tests/test_openreposhape_command.py: a ref with no `shape-doctor.py` is
  refused and leaves `$TMPDIR` empty.

Mutants: with main's `openRepoShape` put back, 12 of these 15 fail. With
only `trap - EXIT` removed, the stdin, NEXT-lines and `--json` tests fail.

Docs (Brett Heap, 2026-10-13T11:30Z: each lane-4 fix carries its own doc
lines). These are reworded in place, at the line caps:
- AGENTS.md: the compliance section's installed-form sentence (#505 said the
  clone is "deleted on exit" and the form "takes a path and nothing else"),
  and step 7 (`--json` is one object, alone on stdout);
- README.md: the doctor paragraph ("writes nothing into the repository and
  fetches nothing from it"; installed, it KEEPS the clone and its last line
  names it), `--json` "on stdout", and `--placement-plan` as all
  `shape-doctor.py` ever writes;
- docs/handbook.html mirrors all three and re-hashes `README.md blob`.

Review round 1, the independent review at 9808aec (all three OSes and
Sonar were green there), and the merges of main (#509, #506 and #507 each
rewrote the same handbook footer lines):
- SHOULD-1: the kept line said "because NEXT commands name files in it"
  even when the doctor refused and printed none. It now reads "the standard
  this run used is kept at <dir>, and any NEXT command above runs from it;
  it is safe to delete once you are done with them." When the clone is
  kept is unchanged. A test pins the refusal case: a missing `<path>`, exit
  3, one line on stdout.
- NIT-1: the `--help` synopsis line reads `openRepoShape --doctor [<path>]
  [--json] [--placement-plan <file>]`, the full form the paragraph below it
  and the refusal already give. `docs/cli.md` is re-rendered.
- NIT-5: `--help` said "It writes nothing into the repository" and then
  exempted a file that can be in the repository. It now says the doctor
  writes no file but the `--placement-plan <file>` you name, at the path you
  give, and that a relative one lands in the directory you ran this from,
  even inside the repository.
- docs/handbook.html: the footer keeps #509's "added (#498)" clause, #506's
  "added (#462)" clause and #507's "added (#487)" clause and adds this one
  after them, with `README.md blob` re-hashed from the merged README.md.

Closes #500

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
Merged by sha, e6fbd74, fetched at
22:23Z, on top of 180902f. It brings seven commits since 191105a, oldest
first: cdcd2f0 (#509), fc48854 (#496), f391e58 (#435, the plan's field
types), d52b100 (#506), 8cd7dbe (#347), d41cc29 (#402) and e6fbd74
(#515 for #514: `execute` writes each `neutral_product_pins:` entry so
it reads back as the text `plan` recorded).

No conflict: the tree is `git merge-tree`'s (8178281). One function
changed on both sides, `_template_table`: this branch spells three plan
keys by their constants (`ELECTED_ON_KEY`, `REFERENCE_KEY`,
`ELECTED_BY_KEY`), and #514 takes the pins' list from
`_neutral_product_pins(pins)`. Both are kept. No top-level name is bound
twice in adopt-project.py; shape_materialize.py and family.py import
`parse_yaml` and `YamlError` twice on both parents, the same objects.

#435 comes first. `check` returns on `_plan_type_findings` before any of
this branch's questions, and `execute` calls `_refuse_a_mistyped_plan`
before it opens the source. Probed: `source:` or `legs:` set to 7, true
or a list, and `allow_empty_legs:`, `paths:` or `pins:` set to something
that is not a list (16 plans). Each is one finding at `check`, and
`execute` refuses it before the work directory or a remote exists. None
prints `manifest-misread` or #181's remediation, and none ends in a
traceback.

#514 quotes the one line this gate still found a misread on. This gate
asks `_template_table` (`_pin_field`), so it asks the quoted line with no
edit here. At this merge the quoting file, main's
tests/test_adopt_manifest_values.py and tests/test_adopt_pins.py and
tests/test_template_text_placeholders.py give 14 failed and 803 passed.
They are the 14 rows round K4 named, each asking what #514 changes. The
next commit makes them true.

Lane: openreposhape-1 (openRepoShape-1)
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