Skip to content

adopt-project.py: plan asks about every source path execute will not extract as it is named, with the reason and root or drop as its only answers, so -notes.md is no longer planned leg: spec for check to refuse, and #166's split question says the same of such an entry (#319) - #377

Merged
brettheap merged 2 commits into
mainfrom
fix/adopt-plan-asks-unsafe-paths
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #319 (comment)

What

Why

Measured on main ab623dd and head c35c896, with a driver that puts each name into a source's index (no checkout), runs plan, check and execute --yes --local-remote-dir, then answers the entry and runs check and execute again, each answer on a fresh source. git-filter-repo 2.47.0, git 2.43.0.

source path main ab623dd: plan writes main: check / execute as planned head: plan writes head: check / execute as planned answered root / drop (main and head)
-notes.md leg: spec, review_required: false, no question exit 1 plan-unsafe-path / exit 2 REFUSED plan-unsafe-path, no remote leg: null, review_required: true, question: "...because it begins with -... So root or drop is the only answer execute takes for it..." exit 1 plan-unsafe-path (leg: null) + plan-unresolved / exit 2 REFUSED plan-unresolved, no remote check 0, execute 0 adoption verified
notes\t.md same as above same same, reason "the control character \t" same same
a\rb.md same same same, reason "the control character \r" same same
a\nb.md same same same, reason "a line feed (\n)..." same same
caf\xe9.md (not UTF-8) same same same, reason "\xe9, which is not UTF-8" same same
a==>b.md same same same, reason "==>..." same same
Icon\r leg: null, the policy's question ("spec, code, or the assembly root?") and nothing more exit 1 plan-unsafe-path + plan-unresolved / exit 2 REFUSED plan-unresolved the policy's question, then "But execute will not extract this path... because it ends in a carriage return (\r)... would name Icon, another path. So root or drop..." unchanged same
src/a\nb.py beside submodule src/vendored (#166 split) src/ asked; the split names `src/a\\nb.py` as an entry to write, with any leg: plan-unresolved the split question ends "Of those entries, execute will not extract src/a\nb.py into a leg as it is named, because it holds a line feed... so root or drop is the only answer for it." unchanged n/a

What the plan FILE shows, measured (the writer's escaping, repo_shape.double_quoted): the question spells a character as _spelled does, a backslash then a letter, and the file doubles that backslash. For Icon\r, path: "Icon\r" holds the carriage return, and the question line holds (`\\r`) and `Icon`. A line feed is `\\n`, and the byte 0xE9 is `\\xe9` in the question and "caf\udce9.md" in path:. Every line of the plan is printable, and each reads back as the question.

A source with no such path plans byte-identically, both the plan file and plan's report, diffed main ab623dd against head c35c896 for the synthetic tree, a source with a registered submodule, and one holding admitted names (My Notes.md, #notes.md, [ID].tsx, lead.md).

Where the issue is out of date: its body measured be2790e, where check exited 0 on -notes.md. On ab623dd, where #196 has landed, check already exits 1 with plan-unsafe-path and execute refuses before any leg. What was left was plan's answer and question, which is what this changes.

Tests

Targeted set on head c35c896, with git-filter-repo 2.47.0 on PATH:

  • all 19 tests/test_adopt_*.py: 399 passed, 1 skipped and 1125 passed, 1 skipped (two runs). Skips: test_adopt_plan.py:393 needs a read-only clone of MedxSoft/MedxEHR (SHAPE_MEDXEHR_CLONE unset); test_adopt_template_values.py:223 skips where git config user.name is not read from GIT_CONFIG_GLOBAL on this host.
  • tests/test_shape_doctor.py, tests/test_scaffold_leg_path_collisions.py and the four guards (test_repo_hygiene.py, test_cli_reference.py, test_git_output_decoding.py, test_stdout_encoding.py): 535 passed, 4 skipped. Skips: three PowerShell-line tests run only on a Windows host, and one about Windows' default stdout encoding.
  • Nothing is skipped on an OS by this change. test_the_answers_it_offers_are_adopted_and_verified[notes\t.md-*] is POSIX only, because NTFS cannot hold a tab and execute checks the source out.

New, in tests/test_adopt_entry_paths.py:

  • test_plan_asks_about_a_path_execute_refuses: eight names, each put into the index with no checkout. Checks the row, the exact question, the question line as the file writes it (pinned per name), the printed question, check's two findings, and execute's refusal before any leg.
  • test_the_answer_the_question_offers_checks_ok: root and drop.
  • test_a_leg_only_a_refused_path_fed_is_recorded_as_seeded.
  • test_the_answers_it_offers_are_adopted_and_verified: -notes.md and notes\t.md, each answered root and drop, run plan → check ok → execute adoption verified, with the path in the root tree or nowhere. Needs git-filter-repo; notes\t.md is POSIX only, since NTFS cannot hold a tab and execute checks the source out.

New, in tests/test_adopt_submodules.py:

  • the split question with a refused file and a refused submodule, and the edit it names checking ok;
  • an orphan -dep told drop, with check unchanged for drop / root / code;
  • a run of spaces kept in the split question;
  • _refused_replacements unit cases, including past the cap.

Changed because plan now asks: three tests in test_adopt_entry_paths.py answer spec (or drop) for a leg: null entry, where plan used to write spec. test_adopt_plan_escapes.py's not-utf8 case now asks. #228's two tests in test_adopt_literal_names.py (see What) assert the question, and ask the literal git grep through cross_leg_references.

Run with head's four test files against main ab623dd's adopt-project.py: 32 failed, 130 passed, and the 32 are exactly the new or changed tests (four of them are _refused_replacements unit cases, which fail on main because it has no such function). All of them pass on head. Each fix was broken in a scratch copy, one at a time, and the tests caught every break: no asking in plan (12 failed), seeding read from ruled (1 failed), no split sentence (2 failed), no drop for a submodule (3 failed), Verdict's space folding restored (1 failed). Without -e in #228's grep, both changed #228 tests fail.

Composes with

git merge-tree --write-tree of each branch against head c35c896, at 14:29Z for lane 3 and 14:46Z for the others. Where a branch also touches adopt-project.py, its tests were run on a scratch merge commit.

PR branch @ head merge tests on the scratch merge
#329 fix/adopt-spelled-unambiguous @ 2da8691 (local, rebased; origin has 7ef0804, also CLEAN) CLEAN test_adopt_entry_paths.py, test_adopt_plan_escapes.py, test_adopt_submodules.py, test_adopt_literal_names.py, test_adopt_spelled_unambiguous.py: 439 passed
#328 fix/adopt-spell-question-and-submodule @ a96297e CLEAN its own test file, test_adopt_entry_paths.py, test_adopt_plan_escapes.py, test_adopt_literal_names.py, and four split-question tests in test_adopt_submodules.py: 248 passed
#320 fix/adopt-refuse-existing-split-branch @ 7e55d00 (local) CLEAN test_adopt_split_branch.py, test_adopt_e2e.py: 43 passed
#373 (#324) fix/ruleset-hint-tracking-branch @ 5ac03de CLEAN test_ruleset_hint_tracking_branch.py: 14 passed
#371 docs/adopt-execute-exits @ fcbf5c6 CLEAN
#356 fix/adopt-check-hand-edited-plan @ 5a5011d CLEAN
#353 fix/adopt-git-reads-not-utf8 @ dcbc37b CLEAN
#314 fix/scaffold-manifest-values @ 4b8da3d CLEAN
#245 fix/adopt-relative-submodule-url @ a0272b2 CLEAN
#184 fix/adopt-report-filter-repo-failure @ f416e13 CLEAN

#329 respells a byte that is not UTF-8 (\udce9 for \xe9). This PR's not-UTF-8 question test derives that spelling from _spelled_character, so it passes on either side of #329.

Left out

Closes #319

🤖 Generated with Claude Code

Summary by Sourcery

Ask users to resolve paths that execute cannot safely extract before producing adoption plans.

New Features:

  • Make plan ask about source paths that execute cannot extract safely under their original names, including the reason and valid root or drop remediation choices.
  • Extend split-entry questions to identify refused replacement paths and provide submodule-specific guidance.
  • Record follow-ups, collision analysis, and seeded legs using the asked/refused path state.

Bug Fixes:

  • Prevent plans from assigning unsafe paths to spec or code legs only for check and execute to reject later.
  • Preserve exact path names, including repeated spaces, when naming split replacements in questions.
  • Avoid generating follow-ups for unsafe spec-directory names that cannot be extracted into a leg.

Enhancements:

  • Retain existing policy and submodule questions while appending path-safety explanations and remediation guidance.
  • Keep classifier rules and reasons on refused entries while marking them unresolved for review.

Tests:

  • Add coverage for unsafe path questions, remediation answers, seeded-leg behavior, split questions, submodule handling, escaping, and literal path names.
  • Update affected adoption tests to validate the new plan questions and unchanged check/execute behavior.

…not extract as it is named, with the reason and `root` or `drop` as its only answers, so `-notes.md` is no longer planned `leg: spec` for `check` to refuse, and #166's split question says the same of such an entry (#319)

Since #196 `check` and `execute` ask one test of an entry's path,
`entry_path_problem`, and `plan` asked it nothing. A source holding
`-notes.md`, `notes\t.md`, `a\rb.md`, `a\nb.md`, `a==>b.md` or a name that
is not UTF-8 was planned `leg: spec`, `review_required: false`, with no
question; `check` then refused that answer as `plan-unsafe-path`, and
`execute` refused it before any leg. `Icon\r` and `   `, which no rule
places, were asked the policy's question, which offers `spec` and `code`.
#166's split question named such a path as an entry to write, with any
`leg:`.

- `ask_about_refused_paths` runs after `ask_about_submodules`. Each entry
  the test refuses that is not answered `root` is written `leg: null`,
  `review_required: true`, confidence `review`, the classifier's rule and
  reason kept. Its `question:` says where the rules put it, or keeps the
  policy's question first; then that `execute` will not extract it into a
  leg as it is named, with `entry_path_problem`'s reason; that `root` or
  `drop` is the only answer `execute` takes; and that renaming it in the
  source and re-running `plan` is the way into a leg.
- An entry that also holds a submodule is told `drop` is its only answer,
  because `root` is `plan-submodule-root`.
- `_asked_about`'s split sentence names each replacement entry the test
  refuses, with its reason, and says `root` or `drop` (`drop` for a
  submodule). Past SPLIT_ENTRIES_NAMED of them it counts the rest.
- What the rules imply (follow-ups, predicted collisions, the `seeding:`
  record) is read with such an entry asked, because no answer puts it in
  a leg. A leg only that path fed is recorded as seeded, with the
  `--allow-empty-leg` warning, which matches what `check` says of the plan
  as written and once it is answered.
- `_asked_about` sets its question as written. `Verdict` collapsed each run
  of spaces, so `src/a  b.md` was named `src/a b.md`, an entry that covers
  nothing.

Unchanged: `check` and `execute` (no line of either is touched);
`shape-doctor.py`'s placement plan (it calls `walk` only); `--help`. The
plan file and `plan`'s report are byte-identical on main and here for the
synthetic tree, a source with a registered submodule, and one holding
admitted names (`My Notes.md`, `#notes.md`, `[ID].tsx`, ` lead.md`),
diffed.

Changed with it: #228's two tests of a spec directory that begins with
`-` (`-notes/`, and `-Otouch pager-ran;/`, named like `git grep`'s pager
option) read `leg: spec` and the follow-up from the plan. `plan` now asks
about such a directory and writes no follow-up for it, because no answer
`execute` takes moves it to the spec leg. Each test now asserts the
plan's question and that the plan names no file for it, and still asks
#228's literal `git grep`, of the rules' own answer `spec` through
`cross_leg_references`: the same follow-up, and nothing run. Every other
assertion is kept, the `leg: spec` one as the rules' answer.

Left out: an entry the rules answer `root` whose path the test refuses
stays `root` with no note, because `check` and `execute` accept it.

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

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

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

You can request another review in 2 days and 3 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

Planning now mirrors execution's path-safety constraints: unsafe-as-named paths are explicitly reviewed instead of being assigned unusable legs, with precise remediation questions and downstream planning derived from the asked state. The PR adds broad regression coverage for path escaping, splits, submodules, seeding, literal names, and successful root/drop adoption while leaving check and execute unchanged.

Sequence diagram for planning an unsafe source path

sequenceDiagram
    participant Plan
    participant Classifier
    participant Safety as entry_path_problem
    participant Planner
    participant File as adoption-plan.yaml
    Plan->>Classifier: walk(policy, paths)
    Classifier-->>Plan: ruled entries
    Plan->>Safety: entry_path_problem(entry.path)
    Safety-->>Plan: reason or no problem
    Plan->>Planner: _refused_as_named(entry, problem, held)
    Planner-->>Plan: leg null, review_required, question
    Plan->>Planner: _predict_collisions(implied)
    Plan->>Planner: follow_ups_for(..., implied, ...)
    Plan->>Planner: seeded_legs(assigned_paths(implied))
    Plan->>File: render_plan(...)
Loading

State diagram for an unsafe-as-named plan entry

stateDiagram-v2
    [*] --> Classified
    Classified --> Asked: entry_path_problem(path)
    Asked --> Root: answer root
    Asked --> Drop: answer drop
    Asked --> Refused: answer spec or code
    Root --> Accepted: check and execute accept
    Drop --> Accepted: check and execute accept
    Refused --> Refused: plan-unsafe-path
    Asked --> Asked: rename source path and re-run plan
    Accepted --> [*]
    Refused --> [*]
Loading

Flow diagram for unsafe path planning and adoption

flowchart TD
    A[walk source paths] --> B[ask_about_refused_paths]
    B --> C{entry_path_problem?}
    C -->|no| D[keep classified entry]
    C -->|yes| E[_refused_as_named]
    E --> F[leg: null and review_required]
    F --> G[question explains reason and root/drop answers]
    G --> H[derive collisions, follow-ups, and seeded legs]
    H --> I[write adoption-plan.yaml]
    I --> J{answer}
    J -->|root| K[keep path in assembly root]
    J -->|drop| L[omit path]
    J -->|spec/code| M[check or execute refuses unsafe path]
Loading

File-Level Changes

Change Details Files
Make planning proactively ask about source paths that execution cannot extract under their original names.
  • Run path-safety classification during planning and preserve classifier rule/reason while writing leg: null, review-required entries.
  • Generate questions explaining the exact refusal reason, valid root/drop remediation, submodule-specific drop behavior, and renaming guidance.
  • Apply the same treatment to split replacement entries, including bounded naming, per-entry reasons, and preservation of repeated spaces.
  • Use the asked entries when calculating collisions, follow-ups, and seeded legs so the plan reflects what execution can actually adopt.
  • Integrate the new refusal pass after submodule handling without changing check or execute.
adopt-project.py
Add comprehensive coverage for refused-path planning, remediation, split questions, escaping, seeding, and execution outcomes.
  • Test refused names including leading dashes, control characters, line feeds, rename arrows, whitespace-only names, and non-UTF-8 bytes.
  • Verify exact in-memory and serialized question text, plan metadata, check findings, and execution refusal behavior.
  • Verify root and drop answers pass checks and complete adoption with the expected root-tree results.
  • Cover submodule-only drop remediation, split-question naming limits, repeated-space preservation, and seeded-leg reporting.
tests/test_adopt_entry_paths.py
tests/test_adopt_submodules.py
tests/test_adopt_plan_escapes.py
Update literal-name follow-up tests to distinguish the rules' classification from paths that plan must ask about.
  • Assert dash-prefixed spec directories are recorded as asked with no planned follow-up.
  • Continue validating literal git grep behavior through cross_leg_references using the rules' underlying spec classification.
  • Retain pager-option and cross-leg reference regression coverage.
tests/test_adopt_literal_names.py

Assessment against linked issues

Issue Objective Addressed Explanation
#319 Have plan identify source-path entries that execute will refuse to extract as named, instead of assigning them a leg that cannot be executed. ✅
#319 For each such entry, write an unresolved, review-required plan entry whose question explains the refusal and offers the actionable root or drop choices. ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-12T14:48:26Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 92e29d9 into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-plan-asks-unsafe-paths branch October 12, 2026 16:31
brettheap added a commit that referenced this pull request Oct 12, 2026
Main moved while this branch merged 1f90a0f: 92e29d9 (#319 via #377)
has `plan` ask about every source path `execute` will not extract as it
is named, with the reason and `root` or `drop` as its only answers. It
touches adopt-project.py and four adopt tests.

The merge is clean. In adopt-project.py the merge's diff against this
branch is main's own diff (154 lines), and its diff against main is this
branch's own (824 lines). Main changes `cmd_plan` and `_asked_about` and
adds `_refused_as_named`, `_refused_replacements` and
`ask_about_refused_paths`; this branch changes none of them. All of it
runs in `plan`, before `execute` exists, so nothing the late failures go
through changes.

Checks: no top-level name is bound twice in adopt-project.py and eleven
other modules, no global is undefined, and `render-cli-reference.py
--check` is ok.

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

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

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

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

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

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…r and validate-manifest.py about the manifest's values before anything exists, so `--id null`, `--tracking-branch true` and a blank `--elected-by` are refused (#258, #261) (#314)

Both tools fill `templates/assembly-root/` from values a person typed or edited into a plan, and the
new project then reads the result with two programs of its own: its YAML reader (`repo_shape`) and
its `scripts/validate-manifest.py`. Neither tool asked either of them. Now each is asked, before a
work directory or a remote exists and under `--dry-run` alike, and a value the project cannot hold
is refused with the value named and the reason given.

- **#258, the scaffold.** `id:` and `tracking_branch:` are written bare. `--id null` and `--id true`
  scaffolded three repositories whose first bootstrap failed `manifest-id`; `--tracking-branch true`,
  `007` or `null` pushed every repository on the branch as typed while bootstrap read the manifest
  back as `True`, `7` or `main` and put each leg on a local branch the remotes do not have, with no
  upstream, exit 0. Now `scaffold-manifest-misread` names the value and what the reader makes of it.
- **#261, both tools.** A blank `--elected-by`, `--reference` or `--name`, `--elected-on 2026-W41-6`
  or `20261011` (read by `date.fromisoformat` from Python 3.11), `--org test+org`, and a plan edited
  to `visibility: secret` or `elected_by: null` were accepted, and `adoption verified` or a scaffold
  exit 0 followed. Now the scaffold says `scaffold-manifest-invalid`, `adopt-project.py plan` says
  `adopt-manifest-invalid`, `check` makes a `plan-manifest-invalid` FINDING of each, and `execute`
  refuses it through `check`'s own list (#168), before a leg repository exists. The sentence quotes
  the validator's own finding, so the words are its words.
- **Names that cannot be directories.** A `--project` of 247 to 250 characters died in `Path.exists`
  with `OSError: File name too long` and a traceback under `--local-remote-dir` (scaffold from 247,
  adopt `execute`); `plan` admitted 251 to 255 that `check` refused as `unsafe-value: legs.spec`.
  `scaffold-name-too-long` and `adopt-name-too-long` refuse the first before anything exists, and
  `plan` now holds the three derived names to `checked_value` as `check` does.
- **A validator that cannot be asked.** A copy of the standard whose
  `templates/assembly-root/scripts/validate-manifest.py` is missing, does not load, lacks what is asked
  of it (`DATE_RE`, `_findings`) or raises, is `manifest-validator-unusable` and not a traceback: it names the
  file, says the defect is the copy's and that nothing was created. A failed load is not cached.

- `scripts/shape_materialize.py` (the one module both tools import, in no copy list): `manifest_misreads`
  renders each template line that carries a value and reads it with `parse_yaml`; `manifest_invalid`
  renders `project.yaml` from the whole substitution table and runs the validator the template ships,
  loaded from that file (no bytecode written into the template tree, `sys.path` put back);
  `refuse_manifest_values` and `manifest_value_problems` compose them; `misread_placeholders` names the
  placeholders `manifest_misreads` would refuse; `election_date` admits only what the validator's own
  `DATE_RE` admits, on every Python; `refuse_unstorable_names` holds a name to 255 bytes, `.git`
  included under `--local-remote-dir`, and shows a name too long at both ends. A placeholder is what
  `PLACEHOLDER_RE` finds in a line, not a second copy of its alphabet.
- `scaffold-project.py`: `_refuse_unholdable_manifest` after the substitution table is built and before
  the plan is printed, the name rule before anything asks `gh api`; `--help` for `--id`, `--elected-by`
  and `--tracking-branch` says the rule.
- `adopt-project.py`: `_flag_plan`, `_manifest_values` (the table `execute` renders from, asked of
  `_template_values` and not copied), `_manifest_findings` in `_entry_findings`, `_refuse_unholdable_values`
  in `cmd_plan`, the name rule in `_repository_urls`.
- `docs/cli.md`: regenerated (four help paragraphs).
- `tests/test_scaffold_manifest_values.py` (106 tests), `tests/test_adopt_manifest_values.py` (79).

The templates write `id:`, `elected_on:`, `visibility:`, `tracking_branch:` and the leg paths bare, and
the project's reader reads `null`, `true` and `007` as None, True and 7. Either the tool refuses what
the project would misread, or the template quotes the placeholder (#259, which Brett has not decided).
This refuses, because:

1. Every neighbouring fix refuses and none quotes: #225 (`--spec-path 2026`: "refused rather than quoted
   in the templates"), #181 (`adopt-manifest-misread`), #266, #251 (a chain link that is not a name). A
   scaffold that quoted while `adopt-project.py plan` refuses `--tracking-branch 007` would disagree with
   it about whether `007` is a branch.
2. Quoting changes the bytes of every new project's `project.yaml` and leg pins for every tool that renders
   them (the scaffold and adopt `execute`), and #259 asks for that to be decided once. (`family.py init`
   renders `templates/family-root/`, not these lines; it has the same defects in its own file and is #307.)
3. The question is asked of the TEMPLATE: each line is rendered and read as the project reads it, so the day
   a placeholder is quoted those values read back and nothing here refuses them, with no edit.
   `test_quoting_the_template_would_make_the_refusal_go_away` holds that.
4. A value that is a legal git branch name and that YAML reads as a boolean or null is not silently retyped:
   it is refused, with the reading named.

**A behaviour change for input that used to work.** `--tracking-branch 2026` and `1.5` worked by accident
(`str(2026)` gives `2026` back, and every reader of `tracking_branch` does `str()`: `bootstrap.py`,
`update-shape.py`, `bump-leg.py`); they are refused as `adopt-project.py plan` refuses them on #181. #259's
quoting would allow them again. The refusal's remediation says so in the true terms: it says only what holds
of every value refused (the project's reader reads back something other than what was typed), names the
failures that do hold (an id that is not text fails `validate-manifest.py`; `007` as a branch reads back as
`7`), says that `2026` or `1.5` as a branch work today only because the tools that read it turn the number
back into text and are still numbers to the project's reader, and gives the way forward, a value the reader
takes for text (`atlas`, `release-2026`, `v2`). It no longer says the first bootstrap would fail, which is
false of exactly those two values.

The rule is the validator's own: `manifest_validator()` loads `templates/assembly-root/scripts/validate-manifest.py`
from the template it ships, and `election_date` asks its `DATE_RE`. The alternative, moving the predicates
into `scripts/repo_shape.py`, is a shape copy: every project in the estate would meet an `upstream-changed`
row on its next `update-shape.py check` for a question no project asks of itself (the argument
`shape_materialize` already makes for the leg-path rule and the commit trailers). Loading the file leaves the
validator the one place the rule is written, and any rule it learns is asked here with no edit
(`test_the_election_date_asks_the_validator_not_a_copy_of_its_rule`).

Reproduction scripts: the issues' own loops (`repro258.sh`, `repro261.sh`), local remotes only.

```
base c3f4987
== --id null: scaffold exit 0 ... bootstrap exit 1  FINDING manifest-id: id is None
== --tracking-branch 007: scaffold exit 0 ... bootstrap exit 0  spec leg on 7, upstream: fatal: no upstream configured for branch '7'
== scaffold --elected-by ' ': exit 0, bootstrap exit 1: FINDING manifest-elected-by: elected_by is empty
== adopt plan --org=test+org: check plan ok, execute exit 0, 1 x 'adoption verified'; bootstrap exit 1: manifest-leg-repository
== adopt --project of 247 characters: execute exit 1 OSError: [Errno 36] File name too long

this branch
== --id null: scaffold exit 2, REFUSED scaffold-manifest-misread: --id is 'null', which is written into project.yaml as `id: null`, where the project's own YAML reader reads it back as no value at all and not as that text.
== --tracking-branch 007: scaffold exit 2, REFUSED scaffold-manifest-misread: --tracking-branch is '007' ... reads it back as the number 7
== scaffold --elected-by ' ': exit 2 REFUSED scaffold-manifest-invalid: --elected-by is ' ', which the project's own validate-manifest.py refuses: manifest-elected-by: elected_by is empty. ...
== adopt plan --org=test+org: plan exit 2 REFUSED adopt-manifest-invalid: --org is 'test+org' ... manifest-leg-repository ...
== adopt --project of 247 characters: execute exit 2 REFUSED adopt-name-too-long
```

Each row is in the logs named in the report; every row of the two issues, and the extra rows lane 2 added
to #258 (`--id 2026` and `007`, `--project Null` and `True`, `--tracking-branch TRUE`, `Null` and `1.50`), is a
test that fails on c3f4987 (130 of the 170 new tests fail there: the rest assert what must
still work, such as `release-1` and `v1.2.3`, or hold the API).

Targeted runs on this head, all with the venv `git-filter-repo` 2.47.0 on PATH:

| run | result |
|---|---|
| test_scaffold_e2e, _pin_and_reuse, test_naming_policy, test_manifest_validator, test_adopt_plan, test_adopt_execute_refusals, test_repo_hygiene, test_cli_reference, test_adopt_entry_paths, test_adopt_finding_spelling, test_adopt_gitmodules_values, test_yaml_flow_quotes, test_load_yaml_refusals, test_adopt_plan_escapes, test_pin_validator, test_neutral_pin_validator, and the two new files (170 of the tests) | 1087 passed, 1 skipped (`SHAPE_MEDXEHR_CLONE` unset) |
| `tests/test_shape_doctor.py`, its own stream | 242 passed, 3 skipped (the PowerShell line, a Windows host only) |

Mutation probes: each hunk reverted in a throwaway worktree, at least one new test fails for each of the
eighteen of the first round and the ten of the second (the date rule, the scaffold gate, the misread scan, the
validator question, the bytecode and `sys.path` guards, both name rules, adopt `plan`, the one list `check` and
`execute` read, the grouping by value, the attribution, the leg-path and non-text filters, the list-`legs` guard,
the no-revision guard, the unclassifiable-names guard; and the remediation wording, `misreads_elsewhere`, the
placeholder pattern, `_line_shown`, the name tails and the three validator guards).

Reach: every tracked YAML file (14) loaded with `repo_shape.load_yaml` on c3f4987 and on this head, parsed
results identical (nothing here touches the reader; 7 are unrendered templates the reader refuses on both).
Writers of the constructs: `scaffold-project.py` and `adopt-project.py execute` (assembly root); the family
holder's writer is #307's.

Nothing ran on Windows or macOS before this PR's CI: the new tests pass bytes and never `text=True` to git, write
plans with `newline="\n"`, and the ones that copy the standard (`a_copy_of_the_standard`) copy a tree the way
`tests/test_family.py` does.

| against | result | the merged tree, run in a throwaway worktree and never committed |
|---|---|---|
| origin/main d82a83e | clean: this head contains it. Main was merged five times (49c21fa b421e6b, 567ee20 c0ed615, f45edf4 34ecbc2, 4b8da3d ab623dd, c578557 d82a83e); each conflict at the tail of `_entry_findings` was resolved by keeping every group, and on this head the tail is `findings + split + unwritable_findings + value_findings + moved`, with the remediations in the same order (see the merges below) | the runs below the merges are this tree |
| origin/fix/render-single-pass 552763d (#260) | clean | 364 passed: the two new files, `test_template_values_single_pass.py`, `test_template_quoted_values.py` |
| origin/fix/adopt-check-shape-paths 8a496d5 (P) | clean | 284 passed: the two new files, `test_adopt_root_shape_paths.py` |
| origin/fix/leg-path-yaml-scalars b2b42f8 (#266) | clean | 722 passed: the two new files, `test_leg_path_yaml_scalars.py`, `test_adopt_leg_paths.py` |
| origin/fix/adopt-plan-quote-yaml-keywords e337f44 (#181) | CONFLICT, two hunks in `adopt-project.py` `_entry_findings`: its docstring and its last statement | keep both and `misreads_elsewhere=True` (below): 1050 passed, 1 skipped: the two new files, `test_adopt_plan_yaml_quoting.py`, `test_adopt_leg_paths.py`, `test_adopt_plan.py`, `test_adopt_execute_refusals.py`, `test_cli_reference.py` |

**With #181, and who does what.** Both branches add a findings group to `_entry_findings`. Keeping both, with
#181's misread group just before this branch's (on this head, `findings + split + unwritable_findings + misread
+ value_findings + moved`), gives two findings for a plan value that is TEXT and that
the template writes bare, which the reader reads back as a number or a boolean (`elected_on: "20261011"`,
`visibility: "true"`, `id: "007"`): #181's `plan-manifest-misread` and this branch's `plan-manifest-invalid`, the
second saying the retyped `id is 7`. `_manifest_findings(plan, misreads_elsewhere=True)` leaves such a value to the
misread rule, by the placeholders `shape_materialize.manifest_misreads` refuses, so it is judged once. It is False
here because with nothing else asking the misread question the validator's finding is the only one: always
leaving such a value out makes this branch alone accept `id: "007"` at `check`, which it refuses, and 13 tests
of this branch fail with it on. **Whoever lands second passes `misreads_elsewhere=True` at the one call in
`_entry_findings`**; nothing is lost in either order, and
`test_a_text_value_the_reader_misreads_is_one_finding_in_the_list_check_prints` fails for each of the three values on
a merge that has not (two findings), and passes on one that has. Run on a throwaway merge with #181's e337f44:
the three pairs are three single findings, and the other 16 rows of the 19-row plan and `check` driver are unchanged. Re-pointing
#181's own gate at `shape_materialize.manifest_misreads` (one definition for both tools, which #258 asks) is
the same person's job and is not done here: lane 1's branch is not this PR's.

`#266` fires first at the scaffold (its leg-path rule, before the manifest gate), at adopt `plan` and at `check`, so
the two messages about a leg path never disagree once it lands. `#260` makes a value holding a `{{KEY}}`
acceptable as typed (see the first left-out item).

- `adopt-project.py`'s misread side is #181's: `--tracking-branch 007` at `plan`, a plan edited to a non-text
  value (`id: 007` read as the number 7), and a value carrying a line break (`elected_on: "2026-09-02\nfoo: bar"`).
  This branch already refuses `--id true` and `--id 007` at `plan`, and `id: "007"`, `visibility: "true"`
  and `elected_on: "20261011"` at `check` and `execute`, through the validator's `manifest-id`,
  `manifest-visibility` and `manifest-elected-on`.
- Leg paths (`--spec-path 2026`): #225's, now #266. `plan` refuses one through the validator's `manifest-leg-path`
  (it passes no `ignore`), and `check` ignores that code because it reports a leg path in the leg-path rule's own words
  (`ignore=LEG_PATH_CODES`), so a hand-edited `spec_path: "2026"` still checks `plan ok` on this branch as it did on
  main. #266 judges it first everywhere.
- `scripts/family.py init`: the same defects in the holder; verified here (`--created-by ' '`, `--created-on 20261011` and
  `--id null` exit 0 and the holder's `validate-family.py` is red; `--tracking-branch true` is silent). **#307.**
- `--pin null@<sha>` and a `--pin` product the reader misreads (the scaffold writes `neutral_product_pins` and a pin
  file product bare): not asked; #181 does adopt's.
- `--org`: the scaffold's `gh repo create` still fails for a name longer than GitHub's 100 characters, at the first
  leg, before anything exists; that is GitHub's limit and not the validator's.
- A refusal that depends on `--pin` without `--pin-source` is reached after `_declaration` has read the forge with
  `gh api` (a read); the leg-path and name rules come before it (the review's NIT-8).

Remaining nits of the adversarial review that are not fixed here, one line each:

- NIT-1: `plan` and `check` disagree about `--spec-path 2026` on this branch alone (see the second item above), and
  the scaffold's sentence names no flag for `--spec-path true`; #266's rule fires first everywhere once it lands.
- NIT-2: `check` exits 2 with one REFUSED and no findings for a bad `org:` (`unsafe-value`) or an unparseable
  `elected_on:` in a plan with no `reference:` (`election-date-malformed`, naming `--elected-on` about a plan key);
  both are better verdicts than main's `plan ok`, but refusals and not FINDINGs.
- NIT-6: before #260 lands, a value that holds a `{{KEY}}` is refused as `template-unsubstituted` (a defect of the tool),
  under `--dry-run` too; main accepted the dry run and wrote the wrong text or died after the push. With #260 both are
  accepted as typed.
- NIT-8: with `--pin` and no `--pin-source`, a misread `--id null` is reached only after the forge read (above).
- Two mutants of the review survive and are judged equivalent: `_reading_of`'s `len(parsed) == 1` and `key in values`
  in `_sentences_of`.

An adversarial review of ca21b57 approved with nits. It had three should-fixes, none a code defect:
- The misread remediation gave a false reason for `--tracking-branch 2026` and `1.5`.
- A conflict with main in `_entry_findings`, and a stale body.
- Double findings once lane 1's #181 lands.

Fix round 1 (7defac7) handles all three and four nits. Its re-verification found no blocker or should-fix. It confirmed that taking the review's #181 suggestion unconditionally would drop this branch's only finding for `id: "007"`, `visibility: "true"` and `elected_on: "20261011"`. The `misreads_elsewhere` flag is therefore the handover: whichever of this PR and #181 lands second passes `True` at the one call in `_entry_findings`, and the guard test fails if it does not. A seeded run of 1324 edited plans on the flagged merge with #181 left no value with no finding and none with two.

Two new nits are left:
- A template validator whose `_findings` returns something other than a list still ends in a traceback.
- The flag's call site in `_entry_findings` has no comment pointing at the handover.

Tests on all three OSes passed at 7defac7, but SonarCloud's quality gate failed on its security rating for new code: `pythonsecurity:S8705` at `shape_materialize.run()`. That is the agentic argument-injection family #119 marked a false positive. The only new call into `run()` was `git rev-parse HEAD` in this tool's own checkout, with constant arguments. Fix round 2 (cf3e8d3) removes it, and a second git call with it. **SonarCloud still flags the same S8705 on cf3e8d3**, so that call was not its cause. The finding sits wholly inside `run()`, and PR #316 carries the same one with no new caller of `run()`. It needed no disposition in the end: main itself carried the identical finding until 13:01:12Z on 2026-10-12, opened by a `bump-leg.py`-only commit and closed by a `validate-pins.py`-only commit, and this PR's one analysis (11:55:50Z) was charged with it as new. SonarCloud passes from 4b8da3d on, with no change to `run()` or its callers.

- `plan` and `check` ask the project's own validator about the manifest with a fixed, well-formed placeholder commit and tree digest. The question starts no process; the commands still read the source with `git`. The validator holds both to their form and compares them with nothing.
- A second call went with it: `_template_values` also ran `tree_digest(SHAPE_ROOT, commit)` (`git ls-tree -r` over the whole standard) on every `plan` and `check`. The table body moved to `_template_table`, which runs nothing. `execute` still builds its table with the real commit and digest, which a new test asserts.
- Both commands now refuse a blank `--elected-by` from a tree with no `.git` exactly as in a checkout, which fixes the review's NIT-9. There are 16 new tests: 14 fail on the previous `adopt-project.py`.
- Main c0ed615 is merged (567ee20), with no conflict.
- A fresh re-verification of the round found the code correct. `execute`'s 29-key table and its 50 pushed files are identical to 7defac7's. An audit hook saw 0 process starts in the manifest question. 14 of the 16 tests fail on 7defac7, and all nine mutants of the round are caught.

Fix round 3 (ab66bc0) changes no behaviour:
- It corrects a stale comment that pointed at `_template_values`.
- It replaces two lane-private review ids that had been committed in a test.
- It splits eight composite assertions SonarCloud flagged (`python:S9073`).
- It defines the validator's path once (`MANIFEST_VALIDATOR`, `python:S1192`).

Targeted runs: 289, 102 and 176 passed, and the suite collects (3499).

**Four later merges of main, and the #351 compose commit.**

- **4b8da3d merges main ab623dd.** It brought #270 (f795cf7), which asks whether a file can hold a value at all: `adopt-unwritable-value` at `plan`, and `plan-unwritable-value` at `check` and `execute`.
  - Some values are judged by both #270 and this branch. Examples: a lone surrogate, a line break or a bidi control in `visibility:` or `elected_on:`, and a control character or a line break in `elected_by` or `reference`, which the validator then reads as blank.
  - For such a value, #270's finding comes first, so `execute` refuses under #270's code. Both findings are listed, and both refuse before any leg exists.
  - #270's own hand-edited-value tests pin that order: three of them fail when it is reversed.
  - Why this order: #270 names the byte, the break or the control the person must retype. The validator describes the manifest that value would be written into, which is a consequence of it.
  - #309's `--spec-path` and `--code-path` help is kept beside this branch's `--tracking-branch` help.
- **c578557 merges main d82a83e.** It brought #245 (161c6c9). #245 splits the submodule findings, puts its relative-url findings last, and returns `check`'s NOTEs as a third value.
  - This branch's group stays after #270's and before #245's, so the tail of `_entry_findings` is `findings + split + unwritable_findings + value_findings + moved`.
  - Two of this branch's tests now unpack that third value.
  - `_repository_urls` keeps `adopt-name-too-long` in #245's shape.
- **The same merge brought #351 (#282).** Its `_identity` refuses an `--elected-by`, `--reference` or `--name` that no file can hold, as `scaffold-unwritable-value`, before this branch asks the validator. So the scaffold and adopt refuse in the same order.
  - a5438a5 applies the compose patch written with #351, unchanged.
  - Five of this branch's scaffold test cases now expect #351's refusal: `--elected-by` TAB in two tests, `\x0b`, `\x1c` and U+2028.
  - Without the patch, the merged tree fails exactly those five.
- **ba58838 and c6f441b merge main 1f90a0f and 92e29d9** through `gh pr update-branch`, with no conflict. They bring #371 (a983e6a), #354 (8325d05), #380 (1f90a0f) and #377 (92e29d9).

**A behaviour change since the head first approved (f45edf4).** `plan --reference $'\t'` and `plan --elected-by ' \n'` were refused as `adopt-manifest-invalid` and are now refused as `adopt-unwritable-value`. `plan --elected-by 'Bob\u202e'`, which f45edf4 planned, is now refused too. The same holds for `check` and `execute` on a value both rules judge: the code is now #270's. No test of this branch pins the old code, and the new order is the one #270's tests require.

Targeted runs on a5438a5, with `git-filter-repo` 2.47.0 on PATH and the checkout's git identity blanked, are below.

| run | files | result |
|---|---|---|
| this branch and #351 | `test_adopt_manifest_values.py`, `test_scaffold_manifest_values.py`, `test_scaffold_free_text_values.py` | 298 passed |
| #270 | `test_adopt_template_values.py` | 77 passed, 1 skipped (`:223`: this checkout's own git config names a user, which outranks the test's `GIT_CONFIG_GLOBAL`; it passes in an export of the tree) |
| #245 | `test_adopt_relative_submodule_url.py`, `test_adopt_gitmodules_values.py`, `test_adopt_submodules.py` | 352 passed |
| the doctor | `test_shape_doctor.py` | 255 passed, 3 skipped (the PowerShell line, a Windows host only) |
| neighbours | `test_scaffold_leg_path_collisions.py`, `test_cli_reference.py`, `test_repo_hygiene.py`, `test_scaffold_pin_collisions.py`, `test_adopt_clone_template.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_literal_names.py`, `test_template_quoted_values.py`, `test_template_values_single_pass.py` | 506 passed |

The same five runs were repeated on c6f441b, the landing head. #377's `test_adopt_entry_paths.py` and `test_adopt_plan_escapes.py` were added to the neighbours. Results: 298 passed; 77 passed and 1 skipped; 361 passed; 255 passed and 3 skipped; 644 passed.

Reviewed adversarially (APPROVE WITH NITS, 0 blockers, 3 should-fixes); fix round 1 handles the three and four nits, and its re-verification found no blocker or should-fix. Fix round 2 validates with a placeholder commit, so the manifest question starts no process; its re-verification found the code correct. Fix round 3 is nits only. CI is green on Linux, macOS and Windows, with SonarCloud passing, at c6f441b (main 92e29d9 merged in). The combined tree with main 45e01f4 passed `test_agents_md_codes.py`, `test_shape_doctor.py` and `test_adopt_manifest_values.py` locally (391 passed, 3 skipped). A re-verification of the merge with ab623dd (4b8da3d) confirmed the merge, the refusal order and the composition. Its one nit, two stale sentences about the order, is fixed here.

Closes #258
Closes #261

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
Merged by sha: origin/main at 16:31Z, one commit past the 1f90a0f merged at
e8e0026. That commit is 92e29d9 (#319 via #377): `plan` asks about every
source path `execute` will not extract as it is named. It touches
adopt-project.py, tests/test_adopt_entry_paths.py,
tests/test_adopt_literal_names.py, tests/test_adopt_plan_escapes.py and
tests/test_adopt_submodules.py.

No conflict: #319 changes the module docstring's `plan`, the entry
questions and `cmd_plan`, none of which this branch changes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…est: `_entry_findings`' docstring keeps both paragraphs, F's empty-symlink one first and #314's value one after it, and nothing else conflicts (#303, #305)

Main has moved since this branch was cut from c7c02fa: #245, #270, #371, #377
and #314 (3ef059c) landed in `adopt-project.py`, among others. Exactly one hunk
conflicted, in `adopt-project.py`: `_entry_findings`' docstring, where this
branch adds "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314 adds "A VALUE NO
FILE CAN HOLD IS SAID BEFORE THE VALIDATOR'S WORD ON IT (#255 before #261)"
after the same paragraph. Both are kept, this branch's first: it describes the
code's order (`_source_path_findings` heads the list) and #314's describes the
tail. The code itself merged without a hand, and `check` lists a source's
paths, then its empty symlinks, then coverage and `_leg_findings` (with
`plan-root-blocks-manifest`), then #166's split, #270's `plan-unwritable-value`,
#314's `plan-manifest-invalid` and #245's relative urls last, so `execute`'s
refusal still wears this branch's finding first.

Rehearsed in a throwaway worktree before #314 landed: the resolved tree is the
same tree object (47dfa8b) as 69c54c9 + origin/main 45e01f4 + #314's head
c6f441b, and no compose fix was needed.

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

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

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

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… exists, a source symlink with an empty target and the source's own `project.yaml` kept in the root, and `plan` no longer offers `root` for that file (#303, #305) (#387)

Two ways `check` said `plan ok` to a source `execute` could not adopt, both reproduced on c7c02fa with the issues' own scripts.

**#303, a symlink whose target is empty.** A `120000` entry whose blob is the empty blob passed `check`, which never reads a link's target.
- With symlinks on (git's default where the disk can hold one), `execute` created both leg repositories, then failed in its first clone of the source: exit 2, nothing on stderr, two empty legs left behind. Linux refuses a symlink to nothing. This happened whatever the plan answered: `docs/empty-link` sent to spec, and a top-level `empty-link` answered `root` or `drop`.
- With `core.symlinks=false`, it said `adoption verified`. The spec leg held the empty link, a clone of that leg exited 128, and the assembly's recursive clone exited 1. The top-level link answered `root` or `code` verified the same way. Only `drop` gave an assembly that clones.

**#305, the source's own `project.yaml` answered `root`.** `plan` asked about it under `ambiguous-top-level-yaml`, whose question offers `root`. Answered `root`, `check` said `plan ok`. `execute` wrote the shape's manifest beside the source's file as `shape/project.yaml`, said "nothing was overwritten" and `adoption verified`. `shape-doctor.py` then said NOT A SHAPE ROOT (exit 2) and pointed at `adopt-project.py plan`. `make bootstrap` failed `naming (exit 2), manifest (exit 1), pins (exit 2)`. Answered `code` or `spec`, the doctor said COMPLIANT and `bootstrap ok`.

**What changed.**

- **`plan-empty-symlink` (#303).** `_empty_symlink_findings` reports each `120000` row of size 0 in `Source.tree()`. The size `ls-tree -l` gives a symlink is the length of its target, so no target is read; a SHA-256 repository reports size 0 too.
  - It is listed right after #196's `plan-unsafe-path`, through `_source_path_findings`, on the line `_entry_findings` already had. So `check` prints it, and `execute` refuses it through `_refuse_what_check_finds` before any leg exists (#168).
  - `EMPTY_SYMLINK_REMEDIATION` is said once after the findings, in `check` and in `execute`'s refusal. The fix is in the source: give the link a target, or remove it, commit, and re-run `plan`.
  - `plan` notes each such link in `check`'s words, and still writes the plan.
- **`ambiguous-project-yaml` (#305).** A new rule in `contracts/path-classification.yaml`, above `ambiguous-top-level-yaml`, for the file `project.yaml`. Its question asks spec, code or drop, and says `root` is not an answer for this file. `release.yaml` keeps the old question.
- **`plan-root-blocks-manifest` (#305).** #313's machinery is extended, not copied: `_shape_blocks`, which `_root_shape_findings` runs over each disk, now refuses the root holding `project.yaml` outright, with no copy question after it. It fires for a kept row at that path (file or symlink), or a kept directory there, on a disk that keeps case or one that ignores it. The finding's remediation is #313's `_fix_the_kept_path`.
  - A submodule there stays `plan-submodule-root`'s, said once.
  - `_kept_there` is the at-or-under lookup the copy branch already did, factored out for both.
  - `execute` refuses it before any leg exists, through the same list.
- **Files.** `adopt-project.py` (+205/-13), `contracts/path-classification.yaml` (+28), the two new test files (399 and 366 lines) and `tests/test_adopt_root_shape_paths.py` (+6/-2).

**Decisions.**

- **#303 is refused whatever the plan answers, and whatever `core.symlinks` says.**
  - Where symlinks are made, no answer adopts. The run fails at the first clone of the source, which checks the whole tree out before anything is taken out. Measured with the link in `docs/` (sent to spec), at the top level answered `root` and `drop`, and with every path rooted or dropped and both legs seeded: there the seeded legs were pushed and the assembly's clone exited 128.
  - `check` cannot know where `execute` will run. A check that passed `drop` with symlinks off would pass a plan the next Linux machine cannot adopt, and the root's own `validate` workflow runs on `ubuntu-latest`.
  - The remediation costs one commit in the source.
- **#305 covers the manifest only.** The issue asked whether other rendered root paths need the same rule. Measured on c7c02fa, with the source's own file kept in the root at each of the 18 `assembly_root_paths()`:
  - `check` passes all 18.
  - These verify over a broken assembly: `contracts/spec-pin.yaml` and `contracts/code-pin.yaml` (DRIFTED, pins fail); `scripts/validate-pins.py`, `scripts/validate-manifest.py`, `scripts/repo_shape.py` and `contracts/repository-naming.yaml` (INVALID); `scripts/validate-repository-naming.py` (COMPLIANT, naming fails); `scripts/bootstrap.py` (COMPLIANT, and `make bootstrap` runs the source's own script).
  - `contracts/shape-pin.yaml` fails verification after both legs exist.
  - README.md, AGENTS.md, CLAUDE.md, .gitignore, .gitattributes, AGENTS-shape.md and the workflow come out COMPLIANT with the copy beside them. `Makefile` is rooted by default, and the follow-up merge is its designed exit.
  - `project.yaml` is the one path `plan`'s own question offered `root` for. The others are reached only by hand-editing a `contracts/` or `scripts/` answer to `root`. They are left for a follow-up issue (under Left out), not folded in here.
- **A symlink at `project.yaml` is refused too.** On c7c02fa:
  - a link to the source's own file verified and was NOT A SHAPE ROOT;
  - a link to nothing had the manifest written through it, and came out COMPLIANT on Linux. A clone that makes no symlinks holds that link as a file naming its target, so it is refused anyway, and #183 refuses a link to nothing at a shape path regardless;
  - a directory `project.yaml/` verified and was NOT A SHAPE ROOT.
- **A case twin `Project.yaml` kept in the root is refused, with the case aside.** It adopts COMPLIANT on Linux. On a disk that ignores case, `place` writes the manifest beside it. This is the trade #172 and #313 already make.

**Evidence.** In this section and in the two below it, "this head" is 69c54c9, the branch's own commit and the one the review approved; main and #314 were merged into the branch afterwards (last paragraphs).

- **Reproductions, on this head** (exits as printed):
  - #303 run 1: `check exit 1`, one `plan-empty-symlink` finding; `execute exit 2`, `REFUSED plan-empty-symlink`, `leg repositories left:` empty.
  - #303 run 2 (`core.symlinks=false`): the same finding and refusal; no leg, no verification.
  - Top-level `empty-link`, symlinks off and answered root, code or drop, and symlinks on and answered root or drop: each `check exit 1`, `execute exit 2`, no leg.
  - #305 root: `check exit 1`, one `plan-root-blocks-manifest` finding; `execute exit 2`, `leg repositories:` empty.
  - #305 code, spec and drop: `plan ok`, `adoption verified`, the root's `project.yaml` begins `schema_version: 1`, doctor COMPLIANT, `bootstrap ok`.
  - The plan's entry for `project.yaml` now reads `rule: ambiguous-project-yaml`, with no `(root)` in its question.
  - `project.yaml` as a link to a file, a link to nothing, a directory, and the twin `Project.yaml`: each `check exit 1` and `REFUSED plan-root-blocks-manifest`.
  - No row needs real GitHub or a cloud tool.
- **New tests.** `tests/test_adopt_empty_symlinks.py` (28) and `tests/test_adopt_root_manifest.py` (27).
  - The symlinks are made with `git update-index --cacheinfo`, so no disk symlink is needed. The one test that checks a link out (a link given a target, end to end) is skipped on Windows by its marker, before it runs.
  - On c7c02fa: 37 failed, 18 passed. The 18 are the controls that must pass on both sides: a symlink with a target, the remediations adopting, `project.yaml` sent to a leg or dropped, a submodule there, other shape paths keeping their copy, and `release.yaml`'s question.
- **One existing test changed.** `test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding` in `tests/test_adopt_root_shape_paths.py` held that a kept file at any shape path is no finding. It now holds that for every path but the manifest, which is `plan-root-blocks-manifest`.
- **Mutation probes:** 14 of 14 hunks reverted one at a time, each killed by at least one new test.
- **Targeted runs.** The adopt list and the doctor ran on a local state of this branch (7a22a21) whose files were this head's but for `tests/test_adopt_root_shape_paths.py`. That file was re-run on this head, so the adopt list is two runs, not one sum.
  - The 15 adopt files (`test_adopt_empty_symlinks`, `_root_manifest`, `_root_shape_paths`, `_leg_path_collisions`, `_execute_refusals`, `_plan`, `_e2e`, `_submodules`, `_verify`, `_mount_ignored`, `_ignored_shape_files`, `_entry_paths`, `_finding_spelling`, `_history_gitlinks`, `_spec_only`) on 7a22a21: 837 passed, 1 failed, 1 skipped (a MedxEHR clone the plan test needs). The one failure is the #313 test this head changes.
  - `tests/test_adopt_root_shape_paths.py` alone, on this head: 120 passed.
  - `tests/test_shape_doctor.py`, as its own stream: 242 passed, 3 skipped (PowerShell lines only a Windows host prints).
  - `tests/test_scaffold_leg_path_collisions.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 167 passed.
  - This head merged with origin/main at 87373a6, which brings `tests/conftest.py`'s new `git()` identity: the two new files, `tests/test_adopt_root_shape_paths.py` and the three above, 342 passed.

**The review.** An independent opus review at 69c54c9 approved with nits: 0 blocker, 0 should, 8 nit. It reproduced every row of both issues on c7c02fa and on this head, and killed 6 of 6 mutants: two of the author's, re-run, and four of its own. Its targeted runs on this head merged with origin/main ab623dd (`git merge-tree` clean, with #349's session `gh` guard in place):
- the new files, `tests/test_adopt_root_shape_paths.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_adopt_execute_refusals.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 379 passed;
- the other adopt files, in two streams: 303 passed, 1 skipped (the MedxEHR clone), and 323 passed;
- the clone-template, literal-names, template-values, plan-escapes, stdout-encoding and Windows-path tests, which cover #268 and #270: 266 passed, 2 skipped (an operator `user.name` set; Windows only);
- `tests/test_shape_doctor.py`: 254 passed, 3 skipped (PowerShell lines only a Windows host prints).

c7c02fa's own `tests/test_adopt_root_shape_paths.py` against this head's code gave 1 failed (the `project.yaml` row, `:191`) and 119 passed, so no other #313 behaviour changed.

- **A cross-lane composition, with lane 1's #183.** On the tree merged with lane 1's `fix/adopt-root-kept-symlinks` (#183, 6c44591), `tests/test_adopt_root_symlinks.py::test_a_link_with_an_empty_target_is_fixed_in_the_source` fails: `AssertionError: assert 'plan-empty-symlink' == 'plan-root-symlink'`. An empty link at or above a shape path is reported by both findings, and `execute` refuses with the first, which is this branch's. The two remediations agree (fix it in the source). Whichever of the two lands second removes the duplicate report. The same file also fails 3 other tests on origin/main with #183 and without this branch (`[a-link-at-the-copy-itself]` and `test_the_materializer_refuses_before_it_writes`, two cases), because #313's `plan-root-blocks-shape-copy` and `materialize-copy-blocked` answer first; those are not this branch's.
- **Two known nits, left for a later round.**
  - An empty link AT `project.yaml` (or at a #313 copy path) gets `plan-root-blocks-manifest`, whose fix offers `drop`, and `plan-empty-symlink`, which says `drop` is no fix.
  - `plan` notes every empty link (25 links print 25 NOTEs), where `check` names 20 and counts the rest.
- The other nits are smaller. A symlink target that begins with a NUL byte passes `check` and fails like #303, and the issue's `_verify` bullet, are follow-up issues; the case twin `Project.yaml` is under Left out. The new tests' `SYMLINKS_OFF` replaces the Windows job's own `GIT_CONFIG_COUNT` settings (`core.autocrlf=false`, `core.longpaths=true`) for those child processes, which is harmless while none of them checks anything out.

**Merge-tree, as of 2026-10-12T15:14Z.** `git merge-tree --write-tree --name-only` of this head (69c54c9), against heads fetched then; every sha below is the one it had then, and main has moved since (see the merge paragraph below). It is a textual merge only; the semantic meeting with #183 was tested by the review (the first item above).
- origin/main: clean at 0f67f7b. That includes #316 (34ecbc2), #268 (e43fa95), #270 (f795cf7) and #245 (161c6c9), which have landed since this branch was cut. It was clean at 161c6c9 and at ab623dd, the review's merge, too.
- Open PRs touching `adopt-project.py`: #184 (f416e13), #353 (dcbc37b), #356 (5a5011d), #371 (34b9500), #373 (5ac03de), #377 (c35c896), #378 (2da8691), #382 (7e55d00) and #384 (a96297e) are all clean. #356 and #382 conflict with origin/main alone (2 and 5 blocks, from #245's landing), not with this head.
- #314 (4b8da3d) was NOT clean: one block, in `_entry_findings`' docstring, where each side adds a paragraph after the same one (this head's "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314's "A VALUE NO FILE CAN HOLD"). Keep both. It had been clean at f45edf4, and it had 3 blocks against origin/main alone since #245 landed, so it needed a merge of main in any case.
- Lane 1's branches without a PR: `fix/adopt-late-failure-notice` (9f1778d), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-plan-quote-yaml-keywords` (d38e143) were clean. `fix/adopt-plan-quote-yaml-keywords` was 3 blocks against this head at e337f44, and conflicts with origin/main alone in 4.
  - `fix/adopt-root-kept-symlinks` (6c44591, #183) conflicts in 3 blocks of `adopt-project.py`: the module docstring's `check` paragraph, `_entry_findings`' docstring and its return line. They are the same 3 that c7c02fa itself has with that branch, and this branch's one added docstring line sits inside the first. It has 5 blocks against origin/main alone.
- Lane 5's `fix/adopt-pin-writes-pin-file` (d806e8f): clean. `docs/adopt-execute-exits` is #371, above.
- When #183 lands, an empty symlink kept at or above a shape path is reported twice, by `plan-empty-symlink` and by #183's `plan-root-symlink` (the first review item above). A dangling link kept at `project.yaml` is reported twice too, by `plan-root-blocks-manifest` and `plan-root-symlink`, and an empty one three times. Whichever lands second should keep one.

**Brought onto main past #314 (bbbb541).** Since this branch was cut from c7c02fa, main took #245 (relative `.gitmodules` urls), #270 (`plan-unwritable-value`), #371 (`execute` verifies before it exits on a refused `gh pr create`), #377 (`plan` asks about every path `execute` will not extract) and #314 (`plan-manifest-invalid`, 3ef059c), all in `adopt-project.py`. The merge of origin/main 3ef059c has ONE conflict, in `_entry_findings`' docstring, where this branch adds "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314 adds "A VALUE NO FILE CAN HOLD IS SAID BEFORE THE VALIDATOR'S WORD ON IT (#255 before #261)" after the same paragraph: both are kept, this branch's first, because it describes the head of the list and #314's its tail. Nothing in code conflicted. `check` and `execute` read one list, and it composes as each feature says: a source's unsafe paths (#196), its empty symlinks (#303), coverage, `_leg_findings` with `plan-root-blocks-manifest` (#305), #166's split, #270's `plan-unwritable-value`, #314's `plan-manifest-invalid`, then #245's relative urls last. So an empty symlink beside an invalid value is still refused wearing `plan-empty-symlink`, with no leg created; probed on the merged tree with an empty link plus `visibility: secret`, plus a line break in `visibility`, and with `project.yaml` answered `root` plus `visibility: secret`, each listing the findings in that order. No test expectation or order changed: the merged tree was rehearsed before #314 landed (69c54c9 + 45e01f4 + c6f441b) and is the same tree object as the pushed merge, and `git diff 3ef059c bbbb541` is this PR's five files and nothing else (+1004/-15). Targeted runs on bbbb541: the new files, `test_adopt_root_shape_paths`, `test_adopt_manifest_values`, `test_scaffold_leg_path_collisions`, `test_agents_md_codes` and `test_cli_reference` 418 passed; `test_shape_doctor` and #377's, #245's and #270's adopt tests 595 passed, 4 skipped (PowerShell lines only a Windows host prints; one operator `user.name`); the other adopt files 234 passed, 1 skipped (the MedxEHR clone) and the guards, literal-names, clone-template, plan-escapes, gitmodules, leg-paths, stdout and Windows-path tests 1101 passed, 1 skipped.

**Left out.**

- `_verify` and the materializer do not themselves refuse a root `project.yaml` that is not the shape's manifest. This is the issue's second bullet. Once `execute` refuses the plan before any leg, through the same list `check` prints, nothing reaches them with one. It is a follow-up issue.
- A top-level file named `project.yaml` only once case is ignored (`Project.yaml`) is still asked `ambiguous-top-level-yaml`'s question, which offers `root`. `check` refuses that answer with the case aside, before any leg.
- #171 (a failed clone exits 2 silently) is lane 1's PR #184. This PR only keeps the empty-symlink run from reaching that clone.
- Follow-up issues proposed by this PR (two since filed):
  - the source's own file kept in the root at one of the other shape paths the root's tools read (the pins, the validators, `scripts/repo_shape.py`, the naming policy, `scripts/bootstrap.py`, `contracts/shape-pin.yaml`), each measured above;
  - a symlink whose target begins with a NUL byte (`\0spec.md`, size 8), which `plan-empty-symlink`'s size test does not see: it fails like #303 (filed as #388);
  - `_verify` asking whether the split's root `project.yaml` is the shape's manifest (filed as #389);
  - an entry git cannot check out where `execute` runs, other than an empty symlink or a NUL-led one (a target longer than the platform allows, say), which is still found only by the first clone, after both legs exist. #303 leaves this as a note.

Reviewed adversarially at 69c54c9 (APPROVE WITH NITS: 0 blockers, 0 should-fixes, 8 nits). The review reproduced every row of both issues on c7c02fa and on that head, and killed 6 of 6 mutants. No fix round followed: the branch's own commit is as reviewed, and what was added after it is the merge described above. CI is green on Linux, macOS and Windows, with SonarCloud passing, at fce86fa (main 7a8b981 merged in). Its merge with main a73b4e9 passed the targeted set (the three adopt test files, `tests/test_shape_doctor.py` and `tests/test_scaffold_leg_path_collisions.py`): 597 passed, 3 skipped.

Closes #303
Closes #305

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #273
Closes #339

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

Labels

None yet

Projects

None yet

1 participant