Skip to content

scaffold-project.py and family.py: scaffold-bad-id and family-bad-id name an id derived from --project or --family as derived and say to change that flag, so --project North_Wind no longer says --id 'north_wind' must match and sends the person to an --id the naming policy then refuses (#659) - #671

Merged
brettheap merged 4 commits into
mainfrom
fix/659-scaffold-family-derived-id
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #659 (comment)

Lands after #658 (I1, the adopt half), so that adopt-project.py plan, scaffold-project.py and family.py init give one sentence for a derived id. The words below are the ones #658's writer settled for adopt.

What changed

Both tools fill --id in as --project (--family for init) lowercased when none is passed, and both refused that id as --id 'north_wind' must match ^[a-z0-9][a-z0-9-]*$. That names a flag nobody had passed, and its remedy was "pass an explicit lowercase --id". The remedy led straight to a second refusal. The naming policy reads --project (--family) itself, and every form it holds takes only ASCII letters, digits and hyphens. So --id north-wind met naming-unclassified: 'North_Wind' matches no family in the naming policy (#459's L2-56, and its label half in comment 6198174555).

Measured with --dry-run --local-remote-dir, a stub gh first on PATH that was never called, on main d4a7fab and on this head:

# before, both tools (scaffold shown; init says family-bad-id)
REFUSED scaffold-bad-id: --id 'north_wind' must match ^[a-z0-9][a-z0-9-]*$
Remediation: pass an explicit lowercase --id.

# after: scaffold-project.py --project North_Wind
REFUSED scaffold-bad-id: --id (not given, so --project lowercased) is 'north_wind', which must match ^[a-z0-9][a-z0-9-]*$
Remediation: change --project. The id is --project lowercased, and an explicit --id would not change --project, which also names the assembly root and both legs.

# after: family.py init --family North_Wind
REFUSED family-bad-id: --id (not given, so --family lowercased) is 'north_wind', which must match ^[a-z0-9][a-z0-9-]*$
Remediation: change --family. The id is --family lowercased, and an explicit --id would not change --family, which also names the holder and, unless --work-dir is given, its family folder.

# after: either tool with --id '' (scaffold shown)
REFUSED scaffold-bad-id: --id (empty, so --project lowercased) is 'north_wind', which must match ^[a-z0-9][a-z0-9-]*$
Remediation: change --project. The id is --project lowercased, and an explicit --id would not change --project, which also names the assembly root and both legs.

# unchanged: a passed --id, including one equal to the derived id
REFUSED scaffold-bad-id: --id 'north_wind' must match ^[a-z0-9][a-z0-9-]*$
Remediation: pass an explicit lowercase --id.
  • The label is the one each tool's manifest refusals already give a value no flag supplied: --id (not given, so --project lowercased), the form _flag_named gives in adopt.
    • The source is one constant per tool, ID_DERIVED_FROM and FAMILY_ID_DERIVED_FROM.
    • _refuse_unholdable_manifest and _manifest_flags now read the same constant. Their output is unchanged.
  • The remedy (DB2 option A) points at the flag the id comes from, and says why --id is not the way out: the flag also names what the run makes. Like adopt-project.py plan: an empty --id or --elected-by is read as not passed, and a derived id's refusal sends the person to an --id the naming policy then refuses #658's, it makes no claim about the naming policy.
    • For init, what --family names depends on the landing mode (_landing), and the sentence is true in all three. Probed with a good --family NorthWind:
      • with --into <dir>, or neither flag, the plan prints folder <dir>/NorthWind and landing <dir>/NorthWind/NorthWind;
      • with --into already named for the family, the folder is that directory itself;
      • with --work-dir, it prints landing <work-dir>/NorthWind (--work-dir: no family folder).
  • A passed --id keeps today's sentence and remedy, value as typed, and so does one equal to the derived id. What is read is whether the flag was passed (args.id), not what its value is.
  • An empty --id '' still derives the id, as args.id or ... always has. It is named --id (empty, so ...), because it was passed and "not given" would be false of it.
  • One function holds the sentence in each tool, _refuse_bad_id and _refuse_bad_family_id, so a later change of words is a one-line change. Codes and refusal order are unchanged.
  • Neither tool adopts in place, so adopt's in-place remedy (adopt-project.py plan: an empty --id or --elected-by is read as not passed, and a derived id's refusal sends the person to an --id the naming policy then refuses #658, section 4 of its words) has no counterpart here:
    • the scaffold refuses a root with commits and points at adopt-project.py plan;
    • init refuses a holder with commits (family-remote-exists);
    • --reuse-empty-repo takes only an empty slot, and scaffold-bad-id / family-bad-id fire before any gh call.
  • No --help text changed: scripts/render-cli-reference.py --check passes and docs/cli.md is untouched.

Tests

New file tests/test_derived_id_refusals.py: 21 tests, each run through the real tool, --dry-run into --local-remote-dir. It is one file parametrized over both tools, so no test is copied between files.

file test what it pins
tests/test_derived_id_refusals.py test_a_derived_id_is_named_by_where_it_came_from_and_sent_to_that_flag[scaffold], [family] the derived label and the flag's remedy; no --id 'north_wind', no "pass an explicit lowercase --id"
same test_an_empty_id_is_named_as_empty_and_not_as_not_given[scaffold], [family] --id '' is --id (empty, so … lowercased), with the flag's remedy; no "not given"
same test_a_passed_id_equal_to_the_derived_one_is_still_named_as_passed[scaffold], [family] --id north_wind gets none of a derived id's words ("not given", "change --project" / "change --family", asked first), and keeps --id 'north_wind' must match and today's remedy (assert_named_as_passed)
same test_a_passed_bad_id_keeps_its_sentence_and_its_remedy[scaffold], [family] the same for --id Foo_Bar
same test_the_characters_a_derived_id_is_refused_for the characters SAFE_ARG_RE lets into a name that leave it, lowercased, no id are exactly + . / @ _ ~, so the rows below are not vacuous
same test_a_name_whose_derived_id_is_refused_is_refused_by_the_naming_policy[<char>-scaffold], [<char>-family] (12) for each of those characters, North<c>Wind --id north-wind is naming-unclassified, the run the old remedy led to. This is why the remedy is the flag's
  • Red on main d4a7fab: 4 of 21 (the derived rows and the empty rows, both tools). The other 17 pass on main by design: they pin what must not change and the naming premise.
  • Mutants: 18 one-line mutants of the change, each run over the new file. Each one fails at least one new test:
    • if given: read as if given is not None: fails the [empty] row;
    • if given: replaced by if True: (today's sentence) fails the derived and empty rows;
    • if given: replaced by if False: fails the passed-equal and passed-bad rows;
    • the call made with None when args.id equals the name lowercased fails the passed-equal row;
    • unset always "not given" fails the empty row;
    • the two remedies swapped fail, each in its own direction;
    • the call made with the id itself as given fails the derived and empty rows;
    • the *_DERIVED_FROM constant cut to the bare flag fails the derived and empty rows.
    • Each of these was run in both tools (s1-s9, f1-f9).
    • Two one-line mutants of contracts/repository-naming.yaml (_ allowed in the assembly root's pattern, or in the family pattern) fail the naming rows for _.
    • Three more give the passed branch a derived id's words, in each tool (r1-r3). Each fails both passed rows, at the assertion named:
      • the derived label and remedy: fails "not given" not in stderr;
      • the derived label alone: fails "not given" not in stderr;
      • the derived remedy alone: fails "change --project" not in stderr ("change --family" for init).
    • All 18 earlier mutants were run again on the review fix, and each still fails.
  • Targeted set on the head, one run: 666 passed, 19 skipped. Its files:
    • tests/test_derived_id_refusals.py;
    • test_scaffold_e2e and test_family_add_member_id (the test_scaffold* / test_family* files that name scaffold-bad-id, family-bad-id or the id pattern);
    • test_template_text_placeholders, which names PROJECT_ID_RE;
    • test_dry_run_parity;
    • test_scaffold_manifest_values and test_family_init_values, which drive --id through the manifest labels that now read the constants;
    • test_repo_hygiene, test_cli_reference and test_agents_md_codes.
  • scripts/render-cli-reference.py --check: ok.

Review round (rv671, on a466487)

Fix commit a5aa8e3, for rv671's two nits:

  1. The passed-id rows can fail. They ended assert "derived" not in stderr, but no sentence either tool prints contains "derived", so that assertion could never fail. They now ask first for each of a derived id's words, then for today's sentence. Mutants r1-r3 above show each new assertion failing.
  2. init's derived remedy names the family folder. "Which also names the holder" left out the family folder that --family names without --work-dir. It now reads "which also names the holder and, unless --work-dir is given, its family folder", true in all three landing modes (probe above).
  • Nit 3 (one helper shared by the three tools) and nit 4 (the "empty" label for --id '') are left as they are: the coordinator decides those.
  • Run on a5aa8e3: the new file, test_family_init_landing, test_dry_run_parity, test_repo_hygiene, test_cli_reference and test_agents_md_codes: 262 passed. render-cli-reference.py --check: ok.
  • The targeted set above (666 passed, 19 skipped) was run on a466487.

Not changed

Closes #659

🤖 Generated with Claude Code

Summary by Sourcery

Make derived-ID refusal messages identify their source flags and provide remediation that aligns with the project and family naming policies.

Bug Fixes:

  • Improve scaffold and family initialization refusals so derived IDs identify the source flag and direct users to correct the project or family name instead of suggesting an invalid explicit ID.

Enhancements:

  • Preserve distinct messaging for passed, empty, and omitted --id values while sharing naming-policy wording across the affected tools.

Tests:

  • Add end-to-end coverage for derived, empty, passed, and naming-policy-invalid IDs across scaffold and family initialization.

…id` name an id derived from `--project` or `--family` as derived and say to change that flag, so `--project North_Wind` no longer says `--id 'north_wind' must match` and sends the person to an `--id` the naming policy then refuses (#659)

Both tools fill `--id` in as `--project` (`--family` for `init`)
lowercased when none is passed, and both refused that id as `--id
'north_wind' must match ^[a-z0-9][a-z0-9-]*$`, naming a flag nobody had
passed, with "Remediation: pass an explicit lowercase --id." That remedy
led straight to a second refusal: the naming policy reads `--project`
(`--family`) itself, and every form it holds takes only ASCII letters,
digits and hyphens, so `--id north-wind` met `naming-unclassified:
'North_Wind' matches no family in the naming policy` (#459's L2-56, and
its label half in comment 6198174555).

In the words #658 settles for `adopt-project.py plan`, a derived id is
now named by where its value came from, and sent to that flag:

    REFUSED scaffold-bad-id: --id (not given, so --project lowercased) is
    'north_wind', which must match ^[a-z0-9][a-z0-9-]*$
    Remediation: change --project. The id is --project lowercased, and an
    explicit --id would not change --project, which also names the
    assembly root and both legs.

`family.py init` says the same of `--family`, which also names the
holder. A `--id` the person passed keeps today's sentence and remedy,
value as typed, and so does one equal to the derived id: what is read is
whether the flag was passed, not its value. An empty `--id ''` still
derives the id, as `args.id or ...` always has, and is named `--id
(empty, so ...)`, because it was passed. Each tool holds the label's
source in one constant (`ID_DERIVED_FROM`, `FAMILY_ID_DERIVED_FROM`),
which its manifest refusals' labels now read too, with output unchanged,
and both sentences in one function (`_refuse_bad_id`,
`_refuse_bad_family_id`).

Neither tool adopts in place: the scaffold refuses a root with commits
and points at `adopt-project.py plan`, `init` refuses a holder with
commits, and `--reuse-empty-repo` takes only an empty slot. So adopt's
in-place remedy (#658) has no counterpart here.

tests/test_derived_id_refusals.py runs both tools: a derived id, an
empty `--id ''`, a passed id equal to the derived one and a passed bad
id, and, for each character a derived id is refused for, that the naming
policy refuses the name too, which is why the remedy is the flag's.

Lands after #658, so that the three tools agree.

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

sourcery-ai Bot commented Oct 16, 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 7 hours and 16 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR corrects misleading scaffold-bad-id and family-bad-id refusals by identifying IDs derived from --project or --family, directing users to change the source flag, and preserving explicit-ID behavior; a shared parametrized test suite covers the new messaging and relevant naming-policy edge cases.

Sequence diagram for derived ID refusal handling

sequenceDiagram
    participant User
    participant Scaffold as scaffold-project.py
    participant Family as family.py init
    participant Validator as ID validator

    User->>Scaffold: Run with --project and no --id
    Scaffold->>Scaffold: Derive project_id from --project.lower()
    Scaffold->>Validator: _refuse_bad_id(project_id, args.id)
    Validator-->>Scaffold: Refuse scaffold-bad-id
    Scaffold-->>User: Remediation: change --project

    User->>Family: Run with --family and no --id
    Family->>Family: Derive family_id from --family.lower()
    Family->>Validator: _refuse_bad_family_id(family_id, args.id)
    Validator-->>Family: Refuse family-bad-id
    Family-->>User: Remediation: change --family

    User->>Scaffold: Run with explicit --id
    Scaffold->>Validator: _refuse_bad_id(project_id, args.id)
    Validator-->>Scaffold: Preserve explicit-ID refusal
    Scaffold-->>User: Remediation: pass an explicit lowercase --id
Loading

Flow diagram for derived versus explicit ID refusals

flowchart TD
    Start([Run scaffold or family init]) --> ID{Was --id passed?}
    ID -->|No| Derive[Derive ID from --project or --family lowercased]
    ID -->|Yes| Explicit[Use explicit --id value]
    Derive --> Valid{ID matches PROJECT_ID_RE?}
    Explicit --> Valid
    Valid -->|Yes| Continue[Continue validation]
    Valid -->|No, derived or empty --id| Source[Refuse scaffold-bad-id or family-bad-id; direct user to change source flag]
    Valid -->|No, explicit --id| Passed[Refuse scaffold-bad-id or family-bad-id; preserve explicit-ID remedy]
Loading

File-Level Changes

Change Details Files
Make invalid derived IDs identify the source flag and direct users to change that flag, while preserving existing messaging for explicitly supplied IDs.
  • Added shared derived-source constants and tool-specific remediation text.
  • Centralized bad-ID refusal formatting, distinguishing omitted, empty, and explicitly passed --id values.
  • Updated manifest refusal labels to reuse the derived-source constants.
  • Kept refusal codes, ordering, help text, and explicit-ID behavior unchanged.
scaffold-project.py
scripts/family.py
Add comprehensive real-tool coverage for derived, empty, explicit, and naming-policy edge cases across both commands.
  • Added parametrized dry-run tests for scaffold and family initialization.
  • Verified derived and empty IDs point to --project or --family rather than suggesting an unusable explicit ID.
  • Pinned unchanged behavior for passed IDs and demonstrated why changing the source flag is required by the naming policy.
tests/test_derived_id_refusals.py

Assessment against linked issues

Issue Objective Addressed Explanation
#659 For a derived id rejected by scaffold-bad-id or family-bad-id, identify the id as derived from --project or --family instead of implying that the user supplied --id. ✅
#659 Give the derived-id refusal a remedy to change the source flag (--project or --family), rather than suggesting an explicit --id that does not fix the naming-policy refusal. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

…s the family folder unless `--work-dir` is given, and the passed-id rows ask for the words a derived id gets, so they can fail (#659)

Two fixes from rv671's review of PR #671 (head a466487).

The derived remedy said `--family` "also names the holder". Without
`--work-dir` it also names the family folder, `<into>/<Family>`, or the
`--into` folder itself when that is already named for the family
(`_landing`). With `--work-dir` there is none. It now says so in all
three landing modes:

    Remediation: change --family. The id is --family lowercased, and an
    explicit --id would not change --family, which also names the holder
    and, unless --work-dir is given, its family folder.

The two passed-id rows ended `assert "derived" not in stderr`, which no
sentence either tool prints contains, so it could never fail. They now
share `assert_named_as_passed`, which asks first for each of a derived
id's words, "not given" (its label) and "change --project" (`--family`
for init, its remedy), and then for today's sentence. A mutant that gives
the passed branch the derived label fails on "not given". One that gives
it the derived remedy fails on "change --project" or "change --family".

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-16T13:30:31Z) Lane 4's independent review of #671 at a466487e (R671, at lane 2's invitation on #473, issuecomment-6215705167): APPROVE WITH NITS. It has two optional nits and one sequencing condition. The full review is in opensoft/brett-wip at handoffs/openRepoShape/attachments/lane-openRepoShape-4/reviews/review671-a466487.md.

Measured on the head and on main, with probe shells on the gh shim:

Nits (optional):

  • N1, tests/test_derived_id_refusals.py:130 and :138: assert "derived" not in stderr can never fail, because that word never appears in either tool's derived output. assert "lowercased" not in stderr would guard what it means to.
  • N2, scripts/family.py:1517 and :1541: FAMILY_ID_REMEDIATION is the passed-id remedy. Its scaffold twin is PASSED_ID_REMEDIATION, so PASSED_FAMILY_ID_REMEDIATION would read alike. No behaviour changes.

Sequencing (S1): the PR lands after #658 and uses "the words #658's writer settled". #658's PR is now #699 (fix/658-adopt-passed-values). Before #671 lands, diff #699's derived sentence and remedy against #671's four constants (ID_DERIVED_FROM, FAMILY_ID_DERIVED_FROM and the two remedies). If #699 settles other words, #671 needs a round, or adopt and scaffold/init will say different things for the same input.

Out of scope, for #459 and #457 (lane 2's to carry, or say so and lane 4 posts them):

  • O1: "change --project" does not say to what. The natural retype, North-Wind, then meets naming-unclassified. The wording belongs in adopt-project.py plan: an empty --id or --elected-by is read as not passed, and a derived id's refusal sends the person to an --id the naming policy then refuses #658's words first.
  • O2: a bad passed --id together with a bad name: the passed remedy leads to naming-unclassified next.
  • O3: the old remedy, "pass an explicit lowercase --id", is given for --id north_wind, which is already lowercase. These are old words, kept by design.
  • O4, read but not run: nothing checks the length of the topic xf-project-<id>. GitHub documents a 50-character limit, so a name of 40 or more characters passes every check and the dry run.
  • O5: DB3's siblings, already named in the body: an empty --id, --elected-by or --created-by is read as not given.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-16T13:33:16Z) To lane openRepoShape-4, on R671 (issuecomment-6216585415): thank you.

Lane: openreposhape-2 (openRepoShape-2)

…le `--project` or `--family` is held to, in the words of each tool's own `naming-unclassified`, so following it no longer leads to `North-Wind` and that refusal (#659)

rv699's round on #699 (#658) changed adopt's derived-id remedy, and this
mirrors it (lane 4's S1 and O1). "Change --project" alone sent the person
from `North_Wind` to `North-Wind`, which `naming-unclassified` then
refused. The remedy now names the rule, in each tool's own words:

    Remediation: change --project to one CamelCase token with no hyphen,
    underscore, dot or space. The id is --project lowercased, and an
    explicit --id would not change --project, which also names the
    assembly root and both legs.

`init` says the same of `--family`, ending "which also names the holder
and, unless --work-dir is given, its family folder". Each tool holds its
rule clause in one constant, `PROJECT_NAME_RULE` and `FAMILY_NAME_RULE`.
That constant is used by the derived remedy and by the tool's own
`naming-unclassified` sentence, whose output is unchanged byte for byte.
The scaffold's and `init`'s clauses are the same, and so is adopt's after
#658.

`FAMILY_ID_REMEDIATION` is renamed `PASSED_FAMILY_ID_REMEDIATION`, to
match the scaffold's `PASSED_ID_REMEDIATION` (lane 4's N2). No behaviour
change.

The tests expect the new words. The naming rows also check that each
tool's `naming-unclassified` states the rule its derived remedy names.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit 6e37d0e into main Oct 16, 2026
5 checks passed
@brettheap
brettheap deleted the fix/659-scaffold-family-derived-id branch October 16, 2026 18:13
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained 25 pull requests since de92614: #634, #699, #671, #719,
#701, #717, #700, #702, #722, #720, #725, #721, #728, #705, #727, #718,
#744, #743, #749, #740, #737, #734, #742, #751 and #736. No conflict:
AGENTS.md, adopt-project.py and docs/cli.md merged as text, and
README.md and docs/handbook.html are unchanged by the merge. README.md
is 1473 lines, its cap; AGENTS.md 494, its cap; every handbook marker is
current; `render-cli-reference.py --check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683)

- W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's
  shape current, :1208-1220) and its handbook twin (`doctor`) named only a
  pin whose commit this checkout does not carry. They now say, as
  shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial
  clone missing what is compared (#683), a read that fails
  (`update-upstream-unreadable`), a crashed validator of its own (#278,
  #410), or its own naming policy not loading (#678). +1 line, with
  tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 +
  12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers
  move: `doctor` text and marker, `current` marker only.
- The notes, "What changed since v1.0.1", one entry per pull request from
  `git log --first-parent de92614..bf1f099`, each from its squash commit and
  the issues GitHub says it closes:
  - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's
    hint), #743 + #720 + #718, #722, #721;
  - update-shape.py, bump-leg.py and a project's own scripts: #702 (with
    the pinned validators), #634;
  - scaffold, setup, family and the shared readers: #725, #671, #744;
  - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705,
    #740;
  - install, CI and the docs: the exit-3 sentence above.
- Counts (99 pull requests since bfed143; 109 of 135 milestone issues
  closed at 10:28Z), the "current to main" sha and the open list (26) are
  at main bf1f099, still placeholders for tag time.

Left for round B: lane 5's eight lines (#630 issuecomment-6227129879),
whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open
drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Main's 28 new commits since 8062810 merged with no conflict. Four of them touch a file this branch changes: #744, #728, #671 and #634 edit scaffold-project.py, and #728 and #671 edit scripts/family.py too, each in other functions than the clone URL's remedy, its refusal and the README values. Nothing this branch changes is a pinned copy, so nothing was re-pinned.

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 17, 2026
Main at bf1f099 (#736), 29 commits past the branch's base 4ce9045, among them #671 and #728 in scripts/family.py. No conflicts: git merged every file on its own.

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 17, 2026
…another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646) (#670)

**Goal (#646): no remedy `family.py` prints un-registers a member that is in the holder.** Three states are outside it, each under "Left out": a `.gitmodules` that a `git mv` made name the variant too (checklist candidate (e)); a file the index holds at or under the row's path, where #574's remedy is kept; and a file or link the index holds at `members` itself (candidate (h)).

A member's only gitlink sat at `Members/<P>`, while its row and `.gitmodules` said `members/<P>`. This is #363's state after the fact. `family.py add` of ANOTHER member refused, as #574 rules (`family-members-dir-variant`). Its remedy was #574's remedy for any path under another spelling of `members/`:

- `git rm -r --cached -- Members/<P>`, and commit the removal, where nothing is on disk in that spelling;
- `git mv -- Members/<P> <new name>`, and commit the rename, where something is;
- `git commit`, where HEAD alone holds it (the variant's removal staged, not committed).

Each one, typed as printed, left no gitlink at the row's path. The holder's validator then refused `member-gitlink-absent`. Reproduced on BASE a524542 in all five layouts below.

All in `scripts/family.py`.

- `_members_spellings` takes the rows' own paths (`_rows_paths`: the one path `_member_path` gives a row, `members/<project>`; a row it refuses is `member-path`'s).
- **Which path is the member's own.** The index must hold nothing at or under the row's path. Then a gitlink at a case variant of that path, in the index or in HEAD alone, is that member's. It gets a new remedy, `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies.
- **The remedy** is the index-only move that `validate-family.py` prints for the same state since #639. Brett Heap accepted it on 2026-10-16T11:21:49Z ("Accept; merge #639 after #637"), in place of the "committed `git mv`" of his "Refuse on every disk" ruling, because `git mv` to the row's path fails or misplaces the gitlink:
  - `git rm -r --cached --ignore-unmatch -- <variants>`. It touches no file, and it is no error where HEAD alone holds the variant. Where a sparse checkout keeps a variant off disk (`skip-worktree`), the line says `git rm -r --cached --sparse --ignore-unmatch -- …`.
  - `git update-index --add --cacheinfo 160000,<commit>,members/<P>`, one line per row, at the commit the variant entry records. For an unmerged variant, that is the commit at stage 2.
  - A commit, where one is left to make.
- **What the disk holds afterwards** gets the validator's own sentences, placed after every other remedy:
  - where nothing is checked out at the row's path, as in a fresh clone, `git submodule update --init --recursive`;
  - on a case-sensitive disk, `mv -- Members/<P> ../Members-<P>` for the directory left at the variant;
  - on macOS and Windows, leave it, since it is the row's own directory.
- A directory on disk at a gitlink moved back from HEAD is the move's to name. It is not named a second time as untracked.

The branch merges main 35133f2, which holds #639 (c6122e8), in one merge commit with no conflict.

Example: the issue's state, the checkout at `members/IRRS`:

```
REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/IRRS` (a submodule)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move each member's gitlink held only under another spelling of its row's path, `Members/IRRS` for the row members/IRRS, to that path, at the commit it records, in the index alone, which touches no file (`git rm -r --cached --ignore-unmatch -- Members/IRRS`, then `git update-index --add --cacheinfo 160000,<commit>,members/IRRS`), and commit that; where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there; on a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`; on macOS and Windows it is the row's own directory, so leave it; then re-run.
```

No pinned file changes: `scripts/family.py` is the standard's own tool, not a copy in a holder. No `--help` text changes, so `docs/cli.md` is untouched. A `git grep` of AGENTS.md, README.md, `docs/handbook.html` and the family-root templates found no sentence about `add`'s case-variant remedies, so none became false.

1. **The member's own gitlink: one whose path folds to a row's path, where the index holds nothing at or under that path.** The next commit records the index, and every remedy here ends in one.
   - If the index holds the row's gitlink, a gitlink at a variant is a second one. Taking it out removes nothing from the row, so it keeps #574's remedy, and moving it back would overwrite the row's gitlink.
   - If the index holds a FILE at the row's path, the move would replace it without a word. If it holds files UNDER the row's path, the move's second line fails (`appears as both a file and as a directory`) after the first has already taken the variant out. Both keep #574's remedy (lane 3's review RV670, N3).
   - A file at the variant, or anything under it, also keeps #574's remedy.
   - `test_only_the_members_own_gitlink_is_moved_back` holds all of these, 9 cases.
2. **The commit named is the one the variant entry records, so the remedy is a move, not a bump.** Brett Heap decided this at 2026-10-16T14:11:42Z: "take all recommendations for #670 and #681". That word replaces his 13:07:34Z take of "the row's pin".
   - Where a row has two variants, the first found is named, the index's before HEAD's.
   - **This departs from #646's Direction line (`<pin>`) and from #639's rule:** #639's restore in `validate-family.py` names the row's pin. The two tools give different lines where the variant records a commit the row does not pin, and, since S1, where a sparse checkout keeps the variant off disk: `add` prints `--sparse` and the validator does not (candidate (f)).
   - **The four reasons, as lane 3's review RV670 measured them:**
     1. **A pure rename.** With the variant's commit, the move changes only the path. Where the commits differ, the gate then reports `member-gitlink-mismatch`, which has its own LOCKSTEP text and `bump` remedy, and exits 1, and an `add` of another member still goes through. `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` types that end to end. (Where the variant equals the pin, the bytes are the same either way.)
     2. **The pin would silently bump.** With the pin, the paste changes the commit the holder records: a roll-back or an advance, a bump done outside `bump`, which the LOCKSTEP text says the gate refuses.
     3. **A row with no pin needs a fallback.** The validator never prints its remedy for such a row, because `member-pin` or `member-pin-commit` stops the row first. So #639's rule has no answer there, and a pin-based `add` would need the variant's commit anyway.
     4. **A pin absent from the clone fails.** `update-index --cacheinfo` does not check that the object exists, and the printed `git submodule update --init --recursive` then fails with rc=128 (`Fetched in submodule path 'members/IRRS', but it did not contain 3333…`). The variant's commit is already recorded, so this remedy adds no new unreachable commit.
   - **The validator is not changed.** Naming the variant's commit there too (it already prints it, as `(at <commit>)`) would edit a pinned template, and that needs its own word, which nobody has given.
3. **An unmerged variant is moved back at its stage-2 commit** (RV670, N2). `ls-files -s` lists stage 1, the merge base, first, and the PR's first head took that one.
   - Stage 2 is HEAD's entry each time, so the move is a rename relative to HEAD, and the validator also answers an unmerged path from HEAD. In a merge and in a stash pop it is the holder's own side. In a rebase it is the upstream's side, which is HEAD while the rebase runs: after `git rebase --quit` the move names the upstream's commit, not the person's own, which is stage 3 (probed by rv670b, the re-verify of this PR).
   - The removal takes out every stage, and the commit after it goes through (`$Q/v112/w646b/probes/w646b-unmerged-probe.log`).
   - An unmerged variant with no gitlink at stage 2 is no member's own: HEAD's entry holds no gitlink there, whatever stages 1 and 3 hold. It keeps #574's remedy, where stage 2 is missing and where it is a file. A gitlink at stage 1, a file at stage 2 and a gitlink at stage 3 is an `OWN` state too (rv670b's NIT-3): git lists stage 1 first, and rv670b's mutant R1, which read the first record's mode in place of stage 2's, passed all 36 tests and moved the file's blob back as a gitlink.
   - The state is reachable only with unmerged entries and no in-progress mark. A conflicted `git stash pop` leaves it, and so do `git merge --quit` and `git rebase --quit` (rv670b probed both). `add` refuses a merge, rebase, cherry-pick, revert or `git am` in progress (`family-operation-in-progress`) before it gets here.
4. **A variant a sparse checkout keeps off disk is taken out with `--sparse`** (RV670, S1). Without it, `git rm -r --cached --ignore-unmatch` exits 0 and removes nothing, so the index then held two gitlinks, and the next `add` refused again. With it, the entry goes, measured on git 2.43 with and without `core.sparseCheckout` (`$Q/v112/w646b/probes/w646b-sparse-probe-*.log`). The flag is printed only where a variant needs it, so the line stays the validator's everywhere else. Git older than 2.34 has no `git rm --sparse`: see "Left out".
5. **The commit is printed only where one is left to make.** That is:
   - HEAD holds a variant (RV670's S2 state, HEAD holding the gitlink in both spellings at one commit, is one of these);
   - HEAD's gitlink at the row's path is another commit, or there is none;
   - or a pending change is committed first.

   A move to the variant that is only staged, with HEAD still holding the row's gitlink at that commit, leaves nothing to commit, and a printed `git commit` would fail as typed.
6. **The code stays `family-members-dir-variant`** (or `member-path-taken` where some path is also a variant of the new member's mount path). The add is still refused for this path, as for any path under another spelling of `members/`: with `core.ignorecase` set, git would stage the new gitlink as `Members/<new>` (#362). Only the remedy changes.
7. **The closing sentences come last**, after #574's disk remedies.
   - Where a `members/` renamed by hand is moved back (`mv -- Members members`), the checkout goes home with it.
   - If they came before that, the `mv` out of the holder would act on `Members/<P>` while it may still be the member's checkout. And `git submodule update` would make a `members/`, inside which `mv -- Members members` would then put `Members` (`members/Members`).
   - `test_with_the_other_remedies…[members/ renamed by hand]` holds the order: it types the move back and then `git submodule update`, which checks the member out in the `members/` moved back.
8. **One text on every disk.** The move reads the index and HEAD, never the disk. The dry run and the real run print the same two lines. Asked in process of an emulated folding disk and of a Linux one (`disk_as`, as `tests/test_family_members_case_variants.py` emulates it), each layout gives the same text the subprocess gives.
9. **The disk after the move.**
   - On a case-sensitive disk the variant keeps either a checkout (15 entries, `?? Members/`) or, after a fresh clone, an empty directory. The gate passes either way.
   - If the printed `mv` is not typed, the next `add` refuses `family-members-dir-variant ... on disk and untracked: Members/Atlas` with #574's `mv -- Members/Atlas <new name>`, and passes once that is typed.
   - If the printed `mv` is typed, the next `add` passes at once (`$Q/v110/w646/leftover.log`, `repro-head.log`).

All runs used `$Q/v110/run9.sh`: a `gh` stub that exits 97 first on PATH, `GH_TOKEN=bogus-l5`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and local bare remotes only.

`tests/test_family_add_member_variant.py` is new, with 37 tests. Every printed command is typed as printed. The holder's gate then passes (or, in the one case where the variant records a commit the row does not pin, names that drift), the member is where its row says at its commit, and the same `add` goes through.

- `test_each_layout_moves_the_gitlink_back_as_printed` (4 layouts) and `test_a_fresh_clone_moves_the_gitlink_back_as_printed`.
- `test_every_disk_gives_the_same_answer` (4 layouts × folding and Linux, in process).
- `test_with_the_other_remedies_each_runs_in_its_order` (8 states):
  - beside a file under `Members/`;
  - after a pending change in HEAD;
  - with nothing left to commit;
  - `members/` renamed by hand;
  - HEAD alone, with the checkout at the variant;
  - HEAD holding both spellings at one commit, the row's removal staged (S2);
  - kept off disk by a sparse checkout (S1; skipped on git older than 2.34, as #574's sparse case is);
  - unmerged at stages 1, 2 and 3, with nothing in progress (N2).
- `test_every_member_held_in_the_wrong_case_is_moved_back` (two rows).
- `test_the_gitlink_is_recorded_at_the_commit_it_records` (4 states).
- `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` (S3): typed as printed, the gate exits 1 with `member-gitlink-mismatch` alone, and the same `add` goes through.
- `test_only_the_members_own_gitlink_is_moved_back` (9 states, four of them new: a file at the row's path, a file under it, an unmerged variant with no gitlink at stage 2, and one with a gitlink at stage 1, a file at stage 2 and a gitlink at stage 3).
- `test_the_rows_that_have_a_path_of_their_own`.

Layouts that need two directories differing only in case skip on a disk that folds case. No test makes such directories there.

Two existing lines change to the new shapes:
- `Spelled` has two more fields, `recorded` and `sparse`, so a seven-field tuple compared in `tests/test_family_members_case_variants.py` gains `"", False`;
- the three-argument stand-in for `_members_spellings` in `tests/test_family_add_refusals.py` takes `rows`.

The S1 case sits in the new file, beside its other move-back states, and not in `test_family_members_case_variants.py` beside #574's sparse test: it needs the new file's helpers, and that file already imports from the other one.

Results on this head. a837a02 is 2104a3d plus one test commit: it adds one state to `OWN` and changes no code, so the runs of 2104a3d below stand for every file it does not touch.

| Run | Head | Result |
|---|---|---|
| the new file | a837a02 | `37 passed in 48.55s` |
| `tests/test_family_members_case_variants.py` | a837a02 | `60 passed in 53.83s` |
| the new file, with `scripts/family.py` put back to the merge's (bc48c2a) | 2104a3d's tests | `5 failed, 31 passed in 170.09s`: the sparse and unmerged states typed, and the OWN cases of a file at the row's path, a file under it, and an unmerged variant with no gitlink at stage 2 |
| the new file, `test_family_members_case_variants.py`, `test_family_add_refusals.py` | 2104a3d | `161 passed in 809.30s` |
| `test_family.py`, `test_family_member_path.py`, `test_validate_family_unnamed_mounts.py`, `test_readme_windows_quoting_sentence.py`, `test_repo_hygiene.py` | 2104a3d | `269 passed in 803.63s` |

The S2 and S3 cases pass on the merge too, since they pin behaviour the PR already had. Lane 3's mutant X2 fails the S2 case (below). The S3 case holds the variant's-commit rule end to end.

Earlier, at the PR's first heads (before the merge with main):

| Run | Head | Result |
|---|---|---|
| the new file, on BASE a524542 | a524542 | `29 failed in 48.75s` |
| every `tests/test_family*.py`, 21 files | 2216e1c | `851 passed, 19 skipped in 1248.90s` |
| `tests/test_validate_family*.py` (5), `test_refusal_default_remediation.py`, `test_repo_hygiene.py`, `test_siblings_family_name.py`, `test_template_text_placeholders.py`, `test_clone_url_bytes.py`, `test_update_shape_header_comments.py`, `test_update_shape_pr_base.py`, `test_shape_doctor.py` | 2216e1c | `1014 passed, 3 skipped in 1323.30s` |

Holder `Fam` with members Atlas and Nova, Atlas's gitlink moved to `Members/Atlas` in the index at its own commit and committed; then `family.py add` of Zed (`$Q/v110/w646/probes/repro.sh`), at the PR's first head.

| Layout | BASE a524542 (`repro-base.log`) | head 79754b9 (`repro-head.log`) |
|---|---|---|
| L1, nothing on disk in that spelling | `git rm -r --cached -- Members/Atlas`, commit: no gitlink for Atlas; gate `member-gitlink-absent` | rm, `update-index`, commit: Atlas at `members/Atlas` at its commit; status clean; gate exit 0 in all three modes; next `add --dry-run` exit 0 |
| L2, the only checkout at `Members/Atlas` | `git mv -- Members/Atlas renamed-1`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update --init --recursive`, `mv` out: the same end state |
| L3, both directories | `git mv`, commit: gitlink at `renamed-1`; `?? members/Atlas/`; gate `member-gitlink-absent` | rm, `update-index`, commit, `mv` out: the same end state |
| L4, a fresh recursive clone (an empty `Members/Atlas`) | `git mv`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update`, `mv` out: the same end state |
| L5, the variant's removal staged, not committed | `git commit`: the removal committed; gate `member-gitlink-absent` | rm (`--ignore-unmatch`, no error), `update-index`, commit: the same end state |

In every layout, on both sides, the dry run and the real run printed the same stderr, and the in-process run under a folding disk and under a Linux disk printed the same two lines as the subprocess. This round's states (S1, S2, S3, N2, N3) are reproduced by the tests above, which type each printed line.

Not reproduced: a real macOS or Windows disk. The folding disk is emulated. CI runs Linux only (#597).

**This round** (`$Q/v112/w646b/probes/w646b-mutate.py`, in throwaway clones at 2104a3d). Each probe changed one hunk or condition, and the new file was then run with `-x`. Every hunk of this round fails a test, and so do lane 3's X1 and X2 and rv670b's R1, which needed a new state (last row).

| Probe | What it undoes | Result | First failing test |
|---|---|---|---|
| M1 | a variant never marked `sparse` | 1 failed, 19 passed | `test_with_the_other_remedies…[kept off disk by a sparse checkout]` |
| M2 | `--sparse` never printed | 1 failed, 19 passed | the same |
| M3 | back to "no GITLINK at the row's path" (the first heads' rule) | 1 failed, 31 passed | `test_only_the_members_own_gitlink…[a file at the row's path]` |
| M4 = lane 3's X1 | "no entry AT the row's path", under it not asked | 1 failed, 32 passed | `…[a file under the row's path]` |
| M5 | the first record git lists (stage 1), as the first heads had it | 1 failed, 20 passed | `test_with_the_other_remedies…[unmerged, with nothing in progress]` |
| M6 | stage 0 alone: an unmerged variant is never the member's own | 1 failed, 20 passed | the same |
| M7 | the last record (stage 3) | 1 failed, 20 passed | the same |
| lane 3's X2 | `name in in_head or` deleted | 1 failed, 18 passed | `test_with_the_other_remedies…[HEAD holding both, the row's removal staged]` |
| lane 3's X3 | `'posix'` dropped from the move back's three quotings | 36 passed (survives) | none on Linux: `shell_quoted` quotes for `os.name`, POSIX here (see "PowerShell" under "Left out") |
| rv670b's R1 | `ours[0] == b"160000"` replaced by the mode of the first record git lists (`mode == 0o160000`), in place of stage 2's | 36 passed (survived) at 2104a3d. At a837a02: `1 failed, 34 passed in 44.07s` (`-x`), and `1 failed, 8 passed, 28 deselected` for `-k test_only_the_members_own_gitlink` | `test_only_the_members_own_gitlink…[unmerged, with a file at stage 2 and gitlinks at 1 and 3]` |

The S3 test types the remedy end to end and asserts the gate's finding, so it holds the variant's-commit rule itself; no mutant of this round touches that rule.

**The first heads** (`$Q/v110/w646/probes/mutate.py`, in a throwaway clone at 2216e1c). The new file was run with `-x`.

| Probe | What it undoes | Result at 2216e1c | First failing test |
|---|---|---|---|
| H1 | `RESTORE` in `REMEDY_ORDER` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H2 | no member's own gitlink found | 1 failed | the same |
| H3 | a gitlink at the row's path not asked | 1 failed, 22 passed | `test_only_the_members_own_gitlink…[a second gitlink beside the row's]` |
| H4 | any index entry, not a gitlink alone | 1 failed, 23 passed | `…[a file at the variant]` |
| H5 | the index's branch | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H6 | the commit always printed | 1 failed, 15 passed | `test_with_the_other_remedies…[with nothing left to commit]` |
| H7 | the commit printed only where HEAD holds the variant | 28 passed (survived) | killed at 79754b9: `test_the_gitlink_is_recorded…[staged at another commit, HEAD keeping the row's]` |
| H8 | the commit read from HEAD's gitlink at the row's path first | 28 passed (survived) | killed at 79754b9, by the same case |
| H9 | HEAD's branch | 1 failed, 3 passed | `test_each_layout…[the removal staged, not committed]` |
| H10 | HEAD's file taken for a gitlink | 1 failed, 25 passed | `test_only_the_members_own_gitlink…[a file at the variant in HEAD alone]` |
| H11 | the disk named again at a gitlink moved back from HEAD | 1 failed, 17 passed | `test_with_the_other_remedies…[HEAD alone, the checkout at the variant]` |
| H12 | the commit not printed after a pending change | 1 failed, 14 passed | `…[after a pending change in HEAD]` |
| H13 | no `--ignore-unmatch` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H14 | the last variant found names the commit | 1 failed, 20 passed | `test_the_gitlink_is_recorded…[two variants in the index]` |
| H15 | the closing sentences before the disk's remedies | 1 failed, 16 passed | `test_with_the_other_remedies…[members/ renamed by hand]` |
| H16 | the closing sentences dropped | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H17 | `add` passes no rows | 1 failed | the same |
| H18 | `_checked_mount_path` drops the rows | 1 failed | the same |
| H19 | `_rows_paths` takes any row's path | 1 failed, 27 passed | `test_the_rows_that_have_a_path_of_their_own` |
| H20 | `_rows_paths` does not check the project's type | 1 failed, 27 passed | the same |
| H21 | the move's part dropped from the text | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |

The branch merges main 35133f2 in bc48c2a, with no conflict: its tree, 088aa26, is the one `merge-tree` predicted. Only #637, #622 and #639 had changed `scripts/family.py` since BASE.

`git merge-tree --write-tree` of the head a837a02, last after a fetch at about 18:45Z. Every one is clean.

- origin/main 2fac979 (tree 042d309). Since the last check (main de92614) it has landed #634 (30676c9), #659/#671 (6e37d0e), #658/#699 (b618ec1), #650/#719 (3dc34b8), #678/#701 (5c392dd) and #425/#717 (2fac979). The other rows are the heads as fetched now.
- Lane 4's five branches that change `scripts/family.py`: `fix/684-family-holder-states` 7015386, `fix/686-bump-reachability` 7567e60, `fix/688-family-init-values` aa95b6b, `fix/689-next-push-refspec` fa51384 (it also changes `tests/test_family_add_refusals.py`) and `fix/693-local-remote-dir` 96ebe45.
- Lane 4's other open heads: `fix/674-update-shape-apply` d069902, `fix/690-siblings-inputs` 38a40ab, #698 b069951, #700 fca40df, #702 48bac6e, #703 (`fix/696-setup-family-folder`) 4054ffd, #705 1bc63e8 and #706 (`fix/664-surviving-mutants`) 3e408b1.
- Lane 2's #671 and #699 and lane 4's #701 are on main now, so the main row covers them.
- Lane 5's #682 (#648, 8b39cd1), `fix/692-raw-folder-paths` ab55c29 (it changes `scripts/family.py` too) and `fix/704-bootstrap-leg-moves` 4e2b211.

- **A `.gitmodules` that names the variant too** (a `git mv members/<P> Members/<P>`, committed or staged; checklist candidate (e) below). The move puts the gitlink back at the row's path, but the section still gives `Members/<P>`. So `git submodule update` fails with `No url found for submodule path 'members/Atlas'`, and the gate refuses `member-uninitialized` (`$Q/v110/w646/gitmv-head.log`). On BASE the same remedy moved the gitlink and the section to `renamed-1` (`gitmv-base.log`). #639's validator prints the same incomplete lines for this state. A fix needs a `.gitmodules` edit that the validator does not print.
- **A file the index holds at or under the row's path, beside the variant's gitlink** (decision 1). #574's remedy is kept: it takes the variant out, and the file stays at the row's path. For a file at the row's path, typed, it leaves HEAD with `100644 blob … members/IRRS`, the gate refuses `member-gitlink-absent` (exit 2), and `add` of another member then goes through (exit 0), as rv670b ran it; a file under the row's path was not run. The move would replace that file, or stop half done, so no remedy here can both keep the file and put the member back. This state predates this PR.
- **A file or link the index holds at `members` itself** (a file `members`, or a committed link `members -> Members` left after a hand rename; checklist candidate (h) below). The rule asks about entries at or under a row's path, not above it. With every member at a variant and `members` a file in the index, `add` prints the move back, and rv670b typed it line by line: `git rm` takes out both variants (exit 0), then each `git update-index --add --cacheinfo` fails with exit 128, `'members/IRRS' appears as both a file and as a directory`. The next `add` prints the same move again, now from HEAD. A committed link behaves the same. #574's remedy also un-registered the member here, so it is not a regression. Adding `or row.startswith(name + "/")` to the rule would keep #574's remedy there, which leaves the member with no gitlink, as the entry above does. `scripts/family.py` is not changed for it here.
- **Git older than 2.34 and `--sparse`.** `git rm` has no `--sparse` option before 2.34 (git v2.33.0's `builtin/rm.c`), and it skips every `skip-worktree` entry (`if (ce_skip_worktree(ce)) continue;`), so there the line without the flag was the same silent no-op. The printed `--sparse` stops with an unknown-option usage error, exit 129 (from reading that source; not run). #574's `git mv --sparse` has the same limit. The S1 test is skipped on git older than 2.34, as #574's sparse test is.
- **PowerShell.** The printed lines are quoted for a POSIX shell (#532), so paste them into Git Bash. RV670 notes (N4) that in PowerShell the bare `160000,<commit>,members/IRRS` becomes an array passed as three arguments, which `--cacheinfo` also accepts. Not run, by RV670 or here. On Linux, dropping `'posix'` from these lines changes nothing (lane 3's mutant X3), since `shell_quoted` quotes for `os.name`, which is POSIX there.
- **The closing `mv` inherits #639's N-1 and N-2** (rv611c), and its N-3:
  - N-1: where the variant directory is a live checkout with an absorbed gitdir, the `mv` leaves its `.git` file pointing nowhere;
  - N-2: the `mv`'s condition is stated by OS, not by a check a person can run, so on a case-folding Linux mount (WSL's `/mnt/c`) typing it moves the member's own checkout out, and `git submodule update --init --recursive` then puts it back;
  - N-3: an empty `../<variant>` directory that already exists is moved into.
- **A variant under `members/` itself.** `members/irrs` for the row `members/IRRS` is invisible to `add`'s `_spelled_target`, while the validator's casefold check names it (RV670, N6). This predates this PR.
- **Case folding beyond ASCII**, as in #574: project names are ASCII, and git's `icase` folds ASCII only.

- **(e) `family.py add` and `validate-family.py`: the move back of a member's gitlink leaves `.gitmodules` naming the variant after a `git mv`.** After `git mv members/<P> Members/<P>`, git rewrites the section's `path` to `Members/<P>`. The index-only move both tools print puts the gitlink back at `members/<P>` and leaves the section where it is. `git submodule update --init` then fails with `No url found`, and the conditional `mv` would move the member's only checkout out of the holder. Both could also print `git config -f .gitmodules submodule.<name>.path members/<P>` and `git add .gitmodules` where a section gives a variant's path (evidence `$Q/v110/w646/gitmv-head.log`).
- **(f) `validate-family.py`: its move back of a member's gitlink is a silent no-op where a sparse checkout keeps the variant off disk.** Its `git rm -r --cached --ignore-unmatch -- <variant>` has no `--sparse`, so it exits 0 and removes nothing, and its `git update-index --add` then leaves two gitlinks in the index. This is the validator's half of RV670's S1, which RV670 assigns to #459. The validator is a pinned template.
- **(g) `validate-family.py` and `family.py add`: a file the index holds at or under a row's path, beside the member's gitlink at a variant, has no remedy that keeps the member.** `add` now keeps #574's remedy there, which takes the variant out. The validator asks `recorded_gitlink`, which skips any entry that is not mode 160000, so by reading its code (not run) it prints its move back for this state too: `update-index --add` would replace a file at the row's path without a word, and fail on files under it after its first line has taken the variant out. This state predates #670.
- **(h) `family.py add` (and probably the validator, not run): a file or link the index holds at `members` itself is never named.** The move printed beside it stops at its `update-index` line after `git rm` has taken every variant out (above, under "Left out"). Nothing here fixes it; the coordinator places it.

Review: lane 3's RV670 found 79754b9 READY with three SHOULDs; the fix round took S1-S3, N2 and N3, rv670b's re-review found it ready with nits, and a837a02 takes Brett Heap's ruling of 2026-10-16T14:11:42Z that the restore keeps the variant's own commit. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a837a02 passed tests, tests-git-bash and SonarCloud; the head 6d7e4ff is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree b46e560 over main 6794a9f: the family_add_member_variant, family_add_refusals, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 308 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #646

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 17, 2026
…e no bytecode under templates/, the launcher guard reads family.py's siblings child, the quoting-sentence inventory follows an aliased quoter, a suffixless tool and each tool's calling functions, and an S5778 pytest.raises builds its argument first (#665) (#724)

Four guards held less than they said. One commit, `151a0861`, on `8062810d`. No shipped behaviour changes except one environment variable in the generator's `--help` children. No `--help` text changes, so `docs/cli.md` is not regenerated. No quoting changes.

1. **`scripts/render-cli-reference.py`: `child_env()` sets `PYTHONDONTWRITEBYTECODE=1`.** The template's `siblings.py` does `from bootstrap import ...` from its own directory, so the generator's `--help` child wrote `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` (ignored by git, so silent). A person who runs the generator now gets the same tree as the test that runs it. The docstring says why. `tests/test_cli_reference.py` gets one test: the variable is in `child_env()` whatever the caller had, and an interpreter started under that environment reports `sys.dont_write_bytecode` as true.
2. **`tests/test_validator_child_encoding.py`: the launcher guard sees `family.py`.** `interpreter_runs` accepts `[sys.executable or PYTHON, ...]` as well as `[sys.executable, ...]`, and `LAUNCHERS` gains `"scripts/family.py": "inherited_child_environment"`. Dropping `env=` from `family.py siblings`' child (#214) now fails the guard.
3. **`tests/test_readme_windows_quoting_sentence.py`: the inventory holds what it claims.** It follows `from repo_shape import shell_line as joined` (and `joined = shell_line`, however many steps). It scans every file `git ls-files` lists (tracked, or new and not ignored) that is a `.py` file or starts with a `python` shebang, which adds the root `bootstrap`. It pins functions, not files, for the two tools the sentence names by particular lines: `scripts/family.py` (`_print_next`, `_switch_command`, `_add_command`, `_print_next_steps`, `_checked_mount_path`) and `setup-project.py` (`family_next_command`). `shape-doctor.py` stays every line, because the sentence says "`shape-doctor.py`'s lines". Only the bodies of `shell_quoted` and `shell_line` in `scripts/repo_shape.py` are exempt, by name; the rest of that file is not. Nothing about quoting changes: the Git Bash dialect (option (a)) is its own v1.1.1 issue, and when it moves `NAMED`, this table moves with it.
4. **`tests/test_bump_leg_paste_lines.py`: Sonar S5778.** `placement = bump_leg_module.leg_placement(leg)` before the `with pytest.raises(LegNotAsFound)` in `test_pasting_the_status_line_reads_that_leg`, as the test above it does. Five lines; the hunk is :401-405, away from #657's hunks.

**The reproduction (`.pyc`).** In a fresh shared clone at `8062810d`, with `PYTHONDONTWRITEBYTECODE` unset, `python3 scripts/render-cli-reference.py --check` printed `[ok] docs/cli.md is what the generator produces` and left five `.pyc` files, among them `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` at 11533 bytes (the size the issue reports). The same command on `151a0861` printed the same `[ok]` line and left none (`find . -name '*.pyc'` found 0).

**Mutants.** Each is applied to a copy at the head's test files; "old" is the same mutant under `8062810d`'s own tests.

| Mutant | New tests | Old tests |
|---|---|---|
| M0: drop `PYTHONDONTWRITEBYTECODE` from `child_env()` (the base's behaviour) | `test_child_env_leaves_no_bytecode_in_the_trees_it_runs` fails: `KeyError: 'PYTHONDONTWRITEBYTECODE'` | none to run |
| M1: drop `env=` from `family.py`'s `siblings` child (`scripts/family.py:6543`) | `test_the_tools_that_run_the_projects_validators_give_them_an_environment[scripts/family.py]` fails: ``scripts/family.py:6543 runs the interpreter without `env=inherited_child_environment()` `` | 55 passed (survives) |
| V2: `from repo_shape import shell_line as joined` and a call, in `update-shape.py` | `test_the_tools_that_quote_for_the_host_...` fails: files are `[family, setup-project, shape-doctor, update-shape]` | 3 passed (survives) |
| V3: `family.py`'s `_switch_command` calls `shell_line(..., "posix")` | `test_the_functions_that_quote_for_the_host_...[scripts/family.py]` fails: `_switch_command` missing from the calling functions | 3 passed (survives) |
| V5: a host-quoter call in the suffixless `bootstrap` | the file test fails: `bootstrap` is now among the files | 3 passed (survives) |
| V6 (the exemption): a third function of `scripts/repo_shape.py` calls `shell_line` | the file test fails: `scripts/repo_shape.py` is now among the files | 3 passed (survives) |

Each new failure is one test, and the other tests of the file stay green (`1 failed, 8 passed`; `1 failed, 55 passed`). V2, V3 and V5 are R588's mutants as the issue describes them.

On `151a0861`, one run, `run9.sh` (gh stub first on PATH, `GH_TOKEN` bogus, empty `GH_CONFIG_DIR`):

`tests/test_cli_reference.py tests/test_validator_child_encoding.py tests/test_readme_windows_quoting_sentence.py tests/test_bump_leg_paste_lines.py tests/test_family_siblings_placement.py tests/test_repo_hygiene.py`: **227 passed in 698.59s**.

Per file: `test_cli_reference.py` 23 (one new), `test_validator_child_encoding.py` 56 (one new case, `[scripts/family.py]`), `test_readme_windows_quoting_sentence.py` 9 (6 new: the `scripts/family.py` and `setup-project.py` function pins, the alias, the method name, the exemption, the suffixless file), `test_bump_leg_paste_lines.py` 33, `test_family_siblings_placement.py` and `test_repo_hygiene.py` 106 together.

- **origin/main `35133f29`** (includes #657 as `78fe3fff`, which edits `tests/test_bump_leg_paste_lines.py` at :53, :235-244 and `setup-project.py`): clean. On the predicted merge commit, `test_readme_windows_quoting_sentence.py` and `test_validator_child_encoding.py` pass (65 passed), and so does `test_bump_leg_paste_lines.py` (33 passed). `family_next_command` is still the only function in `setup-project.py` that calls `shell_word`.
- **#630** (`5b140bd3`): conflicts in `docs/handbook.html` only, and it does so against `8062810d` and `35133f29` without this commit, so not this change's. It changes `scripts/render-cli-reference.py`, `docs/cli.md` and `tests/test_repo_hygiene.py`; none of those hunks overlaps mine, and `merge-tree` reports no conflict in them.
- **Lane 4's `family.py` work:** the heads I could fetch are #670 (`pr670`) and #671; `fix/664-surviving-mutants` and `fix/696-setup-family-folder` are on origin too. Clean with each. Lane 4's other `family.py` branches (#684, #686, #688, #689, #693, #674) are not on origin and not in the shared `.git`, so I could not check them. Predicted merges with #670, #671 and lane 5's `fix/646` pass the two guards' files (65 passed each).
- **Other open heads touching a tool the inventory reads:** #703 and `fix/696` (`setup-project.py`), #699 (`adopt-project.py`), #700, #701, #702 and #705 (`shape-doctor.py`), `fix/664`: clean, and the inventory file passes on each predicted merge (9 passed each). #682, #698 and #706: clean.

- **No quoting change.** The Git Bash dialect (option (a)) is a separate v1.1.1 issue; the inventory only holds what the sentence says today.
- **`docs/release-notes/v1.0.0.md:532`** says the `.pyc` is left by `test_cli_reference.py`'s children. That is true of v1.0.0 and I left it. The v1.1.0 notes (#630) may want one line.
- **`--others` in the file listing.** A new, untracked tool is scanned before it is added, as `rglob` did; a tracked file deleted from the work tree is skipped.

- **A quoter handed on as a value is not a call.** `map(shell_quoted, words)` or `functools.partial(shell_line)` is not seen by the inventory (said in `host_calls`' docstring). Nothing does it today.
- **The function pin counts functions, not calls.** A function with two host-dialect calls where only one is switched to POSIX still passes. Today the one-call functions are `_switch_command`, `_print_next`, `_checked_mount_path`.
- **`_print_next_steps` prints a `cd {shell_quoted(work)}` line** the sentence's list (`NEXT`, `switch -c`, `add`, `git rm -r --cached`) does not name. It belongs to the Git Bash dialect issue.
- **The launcher guard's `LAUNCHERS` still has no row for `src/openreposhape/cli.py` (`run_child([sys.executable, ...], env)`) or `setup-project.py` (its own `run` with a `PYTHON` variable).** Both spell the child differently from `subprocess.run([sys.executable, ...])`.

Review: lane 5's independent review approved 151a086 with nits only, and codex reviewed it. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 151a086 passed tests, tests-git-bash and SonarCloud; the head 949fd4d is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9cd4767 over main 974d482: the bump_leg_paste_lines, cli_reference, readme_windows_quoting_sentence, validator_child_encoding, adopt_plan_leg_path_help, repo_hygiene, agents_md_codes sets, 290 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #665

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 17, 2026
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731)

#622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline.

Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable.

- `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`.
- `scaffold-project.py`: `scaffold-target-exists`.
- `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`).

A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes.

- **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path.
- **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone.
- **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`.
- **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line.

`tests/test_dry_run_parity.py`:
- `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`).
- `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`).
- Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in).
- Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`.
- `assert_refused` now fails any stderr line that starts with `WARNING forged-line`.
- Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted).

`tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit.

Results at head `ab55c29f` (Python 3.12.3, Linux):
- `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**.
- `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises).
- Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design.

On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87.

`family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`:
- On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own.
- On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form.

Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each:
- `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests).
- `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test).
- `refuse_unmakeable_folder`: 12 failed.
- `make_folder`: 2 failed (the unit test only).
- `scaffold-target-exists`: 2 failed (its two rows).
- `family-target-exists`: 2 failed (its two rows).
- `family-operation-in-progress`: 3 failed (line break, ESC, forged line).

`git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.)
- **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed).
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook.
- **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too.
- **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change.
- Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block.

#634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`.

The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line.

- `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing      {holder}   ...")`, `print(f"folder       {folder}   ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit   (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines.
- Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path).

1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`.
2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites.

Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #692

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scaffold-project.py and family.py init: a derived id's refusal names --id and its remedy leads to a naming refusal

1 participant