Skip to content

repo_shape.py, validate-pins.py, family.py: a refusal built without a remedy prints its REFUSED line alone, the assembly root's validate-pins.py still adds the lockstep remedy to its own, and add refuses a .gitmodules git cannot rea (#370) - #494

Merged
brettheap merged 10 commits into
mainfrom
fix/refusal-default-remediation
Oct 14, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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

What

On the issue's three decisions: (1) the remedy now comes from the caller, and repo_shape gives none by default. (2) add uses gitmodules-unreadable and the gate's remedy. (3) validate-family.py is NOT changed: its refusals lose the wrong trailer through the holder's repo_shape.py.

Why

The issue reproduces on current main (ec4a7a0). I measured it on three projects scaffolded into local bare remotes and a family holder made with family.py init and add. Everything ran offline, with a gh stub that exits 97, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, an empty global git config and GIT_ALLOW_PROTOCOL=file. There were 36 runs, main ec4a7a0 against head 9d313e7. $R is the scratch root; shas are masked for the comparison.

The issue's three rows. Main:

REFUSED git-failed: `git config -z --file .gitmodules --list` in $R/h/Fam exited 128: fatal: bad config line 4 in file .gitmodules
Remediation: run `git submodule update --init --recursive`, then `python3 scripts/validate-pins.py`. If the PIN itself is stale, advance the gitlink, `contracts/<leg>-pin.yaml` and every workflow `@<sha>` that names the leg in ONE commit (see README, 'The lockstep invariant').
add: exit 2
REFUSED member-pin-commit: Nova: pin.commit is '<12 hex>', which is not 40 hex. An abbreviated oid, a branch or a tag is a moving reference.
Remediation: run `git submodule update --init --recursive`, then `python3 scripts/validate-pins.py`. If the PIN itself is stale, ...
validate-family.py: exit 2
REFUSED family-wrong-kind: $R/h/Fam/family.yaml: kind is 'project-manifest', expected 'family-manifest'
Remediation: run `git submodule update --init --recursive`, then `python3 scripts/validate-pins.py`. If the PIN itself is stale, ...
bump: exit 2

Head:

REFUSED gitmodules-unreadable: .gitmodules could not be read as git reads it: `git config -z --file .gitmodules --list` in $R/h/Fam exited 128: fatal: bad config line 4 in file .gitmodules
Remediation: git cannot read it either, so none of this holder's submodules can be cloned from it as it stands. Mend the file by hand (`git config -f .gitmodules --list` says where it breaks), then run this again.
add: exit 2
REFUSED member-pin-commit: Nova: pin.commit is '<12 hex>', which is not 40 hex. An abbreviated oid, a branch or a tag is a moving reference.
validate-family.py: exit 2
REFUSED family-wrong-kind: $R/h/Fam/family.yaml: kind is 'project-manifest', expected 'family-manifest'
bump: exit 2

All 36 runs follow. "lockstep" is the Remediation: run \git submodule update ...trailer. On all 22 rows markedlockstep → none`, the head output is main's with that one line removed, checked by script. Every exit code is unchanged.

# case exit main → head code main → head remedy main → head head bytes == main
F1 family.py add over an unparsable .gitmodules 2 → 2 git-failed → gitmodules-unreadable lockstep → own: git cannot read it either, so none of this holde… no
F1d family.py add --dry-run over an unparsable .gitmodules 2 → 2 git-failed → gitmodules-unreadable lockstep → own: git cannot read it either, so none of this holde… no
F1v holder validate-family.py over the same .gitmodules 2 → 2 gitmodules-unreadable own: git cannot read it either, so none of this holde… yes
F2 holder validate-family.py, a short member pin.commit 2 → 2 member-pin-commit lockstep → none no
F3 family.py bump, family.yaml of the wrong kind 2 → 2 family-wrong-kind lockstep → none no
F4 family.py bump, family.yaml not a mapping 2 → 2 family-unreadable lockstep → none no
F5 holder validate-family.py, family.yaml not a mapping 2 → 2 family-manifest-unreadable lockstep → none no
F6 holder bootstrap.py, family.yaml not a mapping 2 → 2 family-manifest-unreadable lockstep → none no
F7 holder validate-family.py, family.yaml unparsable 2 → 2 yaml-unparsable lockstep → none no
F8 family.py siblings, family.yaml unparsable 2 → 2 yaml-unparsable lockstep → none no
F9 holder validate-family.py, no contracts/shape-pin.yaml 2 → 2 shape-pin-missing lockstep → none no
F10 update-shape.py check on the holder, shape pin with files: [] 2 → 2 update-pin-no-files lockstep → none no
F11 family.py init, the holder's local remote exists and is not a git repository 2 → 2 remote-unreadable lockstep → none no
F12 shape-doctor on the holder (sanity, nothing broken) 0 → 0 — none yes
F13 family.py add, the member's project.yaml id is not an id 2 → 2 member-bad-id lockstep → none no
F14 family.py add, the member's project.yaml is not a mapping 2 → 2 member-manifest-unreadable lockstep → none no
F15 family.py add, the member's project.yaml is unparsable 2 → 2 yaml-unparsable lockstep → none no
F16 git status of the holder after the refused adds 0 → 0 — none yes
A0 validate-pins.py, nothing broken 0 → 0 — none yes
A1 validate-pins.py, a short spec-pin commit (pin-tag-only) 2 → 2 pin-tag-only lockstep yes
A2 validate-pins.py, spec-pin unparsable (load_yaml in repo_shape) 2 → 2 yaml-unparsable lockstep yes
A3 validate-pins.py, no contracts/shape-pin.yaml 2 → 2 shape-pin-missing lockstep yes
A4 validate-pins.py, spec leg not initialized 2 → 2 pin-submodule-uninitialized lockstep yes
A5 validate-pins.py --root outside any git repository 2 → 2 not-a-git-repo lockstep yes
A6 validate-pins.py, project.yaml not a mapping 2 → 2 manifest-unreadable lockstep yes
A7 validate-manifest.py, project.yaml not a mapping 2 → 2 manifest-unreadable lockstep → none no
A9 assembly bootstrap.py, project.yaml unparsable 2 → 2 yaml-unparsable lockstep → none no
A10 bump-leg.py, project.yaml unparsable 2 → 2 yaml-unparsable lockstep → none no
A11 bump-leg.py, project.yaml not a mapping 2 → 2 bump-leg-manifest-unreadable lockstep → none no
A12 validate-repository-naming.py --project, not a mapping 2 → 2 manifest-unreadable lockstep → none no
A13 update-shape.py check, shape pin with files: {} (the issue comment) 2 → 2 update-pin-no-files lockstep → none no
A14 adopt-project.py check, plan of the wrong kind 2 → 2 plan-wrong-kind own: this is a PLACEMENT plan, written by `shape-doct… yes
A14b adopt-project.py check, plan of another wrong kind 2 → 2 plan-wrong-kind lockstep → none no
A14c adopt-project.py check, plan not a mapping 2 → 2 plan-unreadable lockstep → none no
A15 adopt-project.py check, plan unparsable 2 → 2 yaml-unparsable lockstep → none no
A16 shape-doctor.py, project.yaml unparsable: the manifest row quotes REFUSED yaml-unparsable: … through refusal_line 1 → 1 yaml-unparsable (quoted) not quoted yes

The doctor's catch-all row, measured in process on a scaffolded root with one check that runs a failing git rev-parse through repo_shape.git_out: on main "next" is the lockstep sentence; on head it is null.

family.py bump and remove now put the holder back when they fail after writing (#416), and a refusal raised then ends with the sentence saying what was put back. Injecting a git-failed refusal with no remedy at the commit step, in process, on main and head:

main: REFUSED git-failed: injected before the commit. Nothing was bumped: the bump has been undone — the gitlink at members/IRRS put back in the index as it was, family.yaml restored byte for byte, members/IRRS back on main at <12 hex> — and the holder is exactly as it was before this command.
      Remediation: run `git submodule update --init --recursive`, then `python3 scripts/validate-pins.py`. If the PIN itself is stale, ...
head: REFUSED git-failed: injected before the commit. Nothing was bumped: the bump has been undone — the gitlink at members/IRRS put back in the index as it was, family.yaml restored byte for byte, members/IRRS back on main at <12 hex> — and the holder is exactly as it was before this command.

remove, and both verbs after the commit has landed, read the same: main adds the lockstep line, and head ends at the sentence. Exit 2 in all eight runs.

What the issue said that no longer holds on current main: it was drafted at c7c02fa, where the holder's validator said family ok over an unparsable .gitmodules. Since #317 that validator refuses gitmodules-unreadable (row F1v). After #267, update-shape.py's pin refusals sit in check_pin rather than read_pin, with the same codes. The sites the issue lists are all still there. An AST scan finds 67 repo_shape Refusal(...) built without a remediation, in 14 files. Eleven of them are in repo_shape.py's own helpers, which every tool reaches. validate-pins.py's 17 keep the lockstep remedy wherever that validator prints them, and the other 50 now carry none. Two of the 50 are #416's member-store-gone, which only its rollback sentence reads, through the refusal's detail, so their remedy was never printed.

Scope. scripts/repo_shape.py and templates/assembly-root/scripts/validate-pins.py are pinned copies; repo_shape.py is copied out of scripts/, not templates/. update-shape.py check will therefore report upstream-changed for both in every assembly root, and for repo_shape.py in every family holder. This lands a digest-moving change before the v1.0.0 tag, as ruled. validate-family.py, family.py's other remedies and every refusal code AGENTS.md names are unchanged; tests/test_agents_md_codes.py passes.

Tests

tests/test_refusal_default_remediation.py, new, 20 tests:

  • 14 fail on main ec4a7a0 and pass on head. They cover the Refusal default and __str__, git_out/load_yaml refusals, add/add --dry-run against the holder gate's lines, the index and HEAD copies, the issue's short member pin and wrong-kind bump, update-shape.py check on files: 0/false/~/{}/[], and the doctor row's NEXT.
  • 6 pass on main as well, because they guard what must not change: REMEDIATION's words, validate-pins.py's two lines on pin-tag-only and yaml-unparsable, its single trailer beside an old-default repo_shape.py, refusal_line, and adopt-project.py's late-failure line with no empty remedy line under it. I checked that each one bites by breaking the fix in a scratch copy:
    • without the validator's handler, 2 fail;
    • with the handler made unconditional, the old-default test fails;
    • without refusal_line's guard, 1 fails;
    • with REMEDIATION's words changed, 1 fails;
    • with the blob-case remedy dropped, 2 fail;
    • with adopt-project.py comparing only against REMEDIATION again, 1 fails.

The new file skips nothing on any OS. The family runs are offline with the offline fixture's gh shim, which add --local-remote-dir never calls. The skips below are existing tests' own (git-filter-repo absent, symlinks, file names a platform refuses).

Targeted runs on head 9d313e7, with git-filter-repo 2.47.0 first on PATH:

Composes with

Trial merges (git merge-tree --write-tree --name-only <branch> HEAD) on 9d313e7, against every open branch that touches a file this PR touches:

branch PR result
fix/pin-files-not-a-list #343 CLEAN
fix/update-shape-pinned-copy-symlink #347 CLEAN
fix/adopt-check-hand-edited-plan #356 CLEAN
fix/doctor-manifest-kinds-unreadable-pin #358 CLEAN
fix/ruleset-hint-tracking-branch #373 CLEAN
fix/trailer-non-ascii-old-git #376 CLEAN
fix/adopt-refuse-existing-split-branch #382 CLEAN
fix/adopt-spell-question-and-submodule #384 CLEAN
fix/adopt-check-link-and-manifest #387 CLEAN
fix/adopt-record-submodule-placement #394 CLEAN
fix/template-quoted-placeholders #395 CLEAN
fix/family-init-values #397 CLEAN
fix/doctor-next-is-only-a-command #402 CLEAN
fix/pin-source-env-collisions #409 CLEAN
fix/validate-pins-workflow-not-utf8 #419 CLEAN
fix/adopt-plan-field-types #435 CONFLICT adopt-project.py
fix/link-source-values #453 CLEAN
fix/family-bump-peels-annotated-tag #466 CLEAN
fix/doctor-mid-merge-not-compliant #468 CONFLICT docs/handbook.html
fix/adopt-pin-writes-pin-file no PR CONFLICT adopt-project.py docs/cli.md

Left out

  • Per-site remedies for the refusals that now print their line alone, such as member-pin-commit (family.py bump) and shape-pin-missing (restore from history).
  • Narrowing validate-pins.py's lockstep remedy on its refusals that are not about a leg (shape-pin-missing, manifest-missing, neutral-pin-*), kept byte-identical here on purpose.
  • tests/test_shape_doctor.py::test_the_quoted_refusal_line_is_the_refusals_own_less_its_remediation still says "the loader's default remedy" in its docstring; it passes because it passes REMEDIATION explicitly.

Closes #370

🤖 Generated with Claude Code

Summary by Sourcery

Make refusal remediation caller-owned so each tool reports only guidance appropriate to its repository root.

Bug Fixes:

  • Ensure refusals without a caller-provided remedy print only their refusal line instead of an unrelated lockstep instruction.
  • Report unreadable .gitmodules files from family.py add using the holder validator's error code and actionable remedy.
  • Prevent adoption failure reports and shape-doctor output from emitting stale or empty remediation lines.

Enhancements:

  • Keep the assembly root lockstep remediation in validate-pins.py, preserving its existing output while making remediation ownership explicit.

Tests:

  • Add coverage for remedy ownership, holder .gitmodules handling, validator compatibility, shape-doctor output, update-shape refusals, and adoption failures.

… remedy prints its `REFUSED` line alone, the assembly root's `validate-pins.py` still adds the lockstep remedy to its own, and `add` refuses a `.gitmodules` git cannot read as `gitmodules-unreadable` with the holder gate's remedy, so a family holder, a shape pin or an adoption plan is no longer told to run `validate-pins.py` and move a leg pin (#370)

`repo_shape.Refusal` defaulted its remediation to `REMEDIATION`, the
assembly root's lockstep remedy: run `git submodule update`, then
`validate-pins.py`, and move a leg's gitlink, `contracts/<leg>-pin.yaml`
and its workflow `@<sha>` refs in one commit. Every refusal built without
a remedy of its own printed it, in every tool and every kind of root,
including those `git_out`, `load_yaml` and `find_repo_root` raise.

- `Refusal`'s remediation defaults to none, and `str()` of a refusal with
  none is its `REFUSED` line alone. `repo_shape` cannot know which tool or
  root it refuses for, so it assumes no remedy rather than one tool's.
- `REMEDIATION` keeps its name and its bytes. `validate-pins.py`, the
  assembly root's gate and the one place it is right, prints it under each
  refusal it makes that names no other, its own and those `repo_shape`
  raises for it. It imports only that name, which every `repo_shape.py`
  a root was ever given defines, and beside one that still defaults to it
  it prints it once.
- `family.py add`, and `add --dry-run`, refuse a `.gitmodules` git cannot
  read as `gitmodules-unreadable`, in the words and under the code the
  holder's `validate-family.py` uses for the same file (#300), where it was
  `git-failed`. With no `.gitmodules` in the working tree git reads the
  index's copy or HEAD's, and the remedy then says which and how to put it
  back.
- `shape-doctor.py`'s `refusal_line` quotes a refusal with no remedy whole,
  and a row a remedy-less refusal ends has no NEXT, where it carried the
  lockstep sentence.
- `adopt-project.py`'s report of a failure once a leg repository exists
  left the lockstep remedy off by comparing against it (#445); it leaves an
  absent one off too, so no empty line follows its `REFUSED` line.

Measured on a scaffolded root, a family holder and three members in local
bare remotes, main against this head, 36 runs. On 22 the lockstep trailer
is gone and the `REFUSED` line stands alone: the holder's
`validate-family.py`, `bootstrap.py` and `family.py siblings`, `family.py`
`add`, `bump` and `init`, `update-shape.py check` on a shape pin with no
`files:` rows, `validate-manifest.py`, the root's `bootstrap.py`,
`bump-leg.py`, `validate-repository-naming.py` and `adopt-project.py
check` on a plan. On `add` and `add --dry-run` it is the two lines the
holder's gate prints.

Unchanged: every remedy a caller passes, and `validate-pins.py`'s output,
byte for byte on the six refusals measured (`pin-tag-only`,
`yaml-unparsable`, `shape-pin-missing`, `pin-submodule-uninitialized`,
`not-a-git-repo`, `manifest-unreadable`); the holder gate's
`gitmodules-unreadable`, `adopt-project.py`'s placement-plan refusal and
the doctor's `manifest` row, also measured. No refusal code changes but
`add`'s, from `git-failed` to `gitmodules-unreadable`, which AGENTS.md does
not name.

`scripts/repo_shape.py` and `templates/assembly-root/scripts/validate-pins.py`
are pinned copies, so `update-shape.py check` reports both as
`upstream-changed` in every assembly root, and `repo_shape.py` in every
holder; `validate-family.py` is not changed.

Left out: per-site remedies for the refusals that now print their line
alone, and narrowing `validate-pins.py`'s lockstep remedy on its refusals
that are not about a leg (`shape-pin-missing`, `manifest-missing`,
`neutral-pin-*`).

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 14 hours and 17 minutes 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

This PR removes the incorrect global lockstep remediation from shared Refusal construction, makes callers explicitly supply remedies, preserves validate-pins.py's historical assembly-root output, and gives family add a matching holder-specific .gitmodules diagnostic with comprehensive regression coverage.

Sequence diagram for caller-specific refusal remediation

sequenceDiagram
    participant Tool
    participant RepoShape as repo_shape
    participant Validator as validate-pins.py
    participant Output

    Tool->>RepoShape: Refusal(code, detail)
    RepoShape-->>Tool: refusal with no remediation
    Tool->>Output: print refusal line only

    Validator->>RepoShape: Refusal(code, detail)
    RepoShape-->>Validator: refusal with no remediation
    Validator->>Output: print refusal plus REMEDIATION
Loading

Sequence diagram for unreadable .gitmodules handling in family add

sequenceDiagram
    participant Add as family.py add
    participant Git
    participant Holder

    Add->>Git: git config -z --file .gitmodules --list
    alt .gitmodules is readable
        Git-->>Add: submodule configuration
    else .gitmodules is unreadable
        Git-->>Add: refusal with git error
        Add->>Holder: construct gitmodules-unreadable refusal
        Holder-->>Add: holder-specific remediation
        Add-->>Add: print refusal and remediation
    end
Loading

Flow diagram for refusal rendering without a default remedy

flowchart TD
    A[Refusal created] --> B{remediation supplied?}
    B -->|yes| C[Render REFUSED line and remediation]
    B -->|no| D[Render REFUSED line alone]
    C --> E[Caller prints result]
    D --> E
    F[shape-doctor refusal_line] --> G{remediation supplied?}
    G -->|yes| H[Remove remediation trailer]
    G -->|no| I[Keep complete refusal line]
Loading

File-Level Changes

Change Details Files
Make refusal remediation caller-owned instead of applying a shared default.
  • Change Refusal's default remediation to empty and omit the trailer when absent.
  • Keep the assembly lockstep remedy exported under REMEDIATION for callers that explicitly need it.
  • Update shared-reader and downstream reporting behavior so refusals without remedies remain single-line and doctor NEXT commands are absent.
scripts/repo_shape.py
shape-doctor.py
adopt-project.py
Preserve the assembly validator's lockstep output explicitly.
  • Import REMEDIATION in the templated validate-pins.py.
  • Append it only when the caught refusal has no remedy, preserving existing output and avoiding duplicate trailers with legacy repo_shape copies.
templates/assembly-root/scripts/validate-pins.py
Give family add operations the holder-specific .gitmodules refusal and remediation.
  • Translate git config failures into gitmodules-unreadable.
  • Reuse the holder gate's wording and explain whether the unreadable file came from the index or HEAD when no working-tree copy exists.
  • Apply the behavior to normal and dry-run add paths without changing exit status or rollback behavior.
scripts/family.py
Add regression coverage for default-remediation removal and tool-specific output.
  • Test Refusal formatting, shared reader refusals, family add and gate parity, index/HEAD restoration guidance, pin and manifest failures, validator compatibility, doctor rows, and adoption late failures.
  • Verify lockstep output remains byte-for-byte stable for validate-pins.py and is not duplicated with older pinned repo_shape copies.
tests/test_refusal_default_remediation.py

Assessment against linked issues

Issue Objective Addressed Explanation
#370 Ensure family tools do not give holders assembly-root remediation instructions for refusals that have no appropriate remedy of their own. ✅
#370 Make family.py add report an unreadable .gitmodules with the same refusal code and remedy as the holder's family validator. ✅
#370 Preserve the assembly-root lockstep remediation where it is appropriate, in particular for validate-pins.py refusals. ✅

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-12T22:46:01Z) @codex review

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T22:51:12.428568Z 9d313e7 Manual request
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d313e7ff1

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread scripts/repo_shape.py
"""

def __init__(self, code: str, detail: str, remediation: str = REMEDIATION):
def __init__(self, code: str, detail: str, remediation: str = ""):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore NEXT commands for remedy-free doctor findings

When a doctor check propagates a shared Refusal that does not explicitly supply remediation (for example, a git_out failure), this empty default flows through shape-doctor.py::run_checks as exc.remediation or None; the row remains a FINDING but has no NEXT command, and the new test explicitly locks in that result. Supply a doctor-specific fallback or contextual remediation rather than omitting NEXT, because the doctor contract requires every finding to carry the exact next command.

AGENTS.md reference: AGENTS.md:L226-L228

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(lane openRepoShape-3, 2026-10-12T23:47Z) An independent review checked this and does not take it, for four reasons:

  1. Main's NEXT here was not a command. It was the assembly root's prose (run git submodule update --init --recursive, then validate-pins.py), and the catch-all runs for every kind of root. On a family holder or a non-root that script does not exist, and on an assembly root obeying it detaches the legs for an unrelated fault.
  2. The doctor's own rulings prefer no NEXT to a wrong one (shape-doctor.py: the legs row's NEXT (and the members row's) runs <root>/scripts/bootstrap.py without asking whether the root has it, so a root that lost it pastes python3: can't open file, exit 2, and for an empty leg mount the legs NEXT would not cure the row even with the file there (bootstrap.py only prints Run git submodule update --init spec) #276, shape-doctor.py: validator_row reads a validator's exit 1 as "reports a finding" whatever caused it, so a validator that CRASHED (a : in the standard's path splits the PYTHONPATH the template fallback runs under, and validate-manifest.py dies on import repo_shape) is a FINDING row quoting ModuleNotFoundError, and a root whose manifest is valid goes from COMPLIANT, SHAPE BEHIND to INVALID (manifest) #278), and the open shape-doctor.py: a NEXT is only the command, and the sentence that ended twenty-five of them after # is the row's why, on a WHY line of its own and "why" in --json, so a NEXT pasted into an interactive zsh -- which reads that # as a word -- no longer hands the comment to the command as arguments, runs what follows its ;, or runs the make bootstrap in its backticks, and the template validator's line is env PYTHONPATH=... python3 ... (#277) #402 requires NEXT to be a bare command.
  3. Twelve ordinary breakages of a scaffolded root (leg dir or .git removed, unparsable .gitmodules or pins, broken index, HEAD or objects in the root and the leg) never reach the catch-all, on main or on this head. Every row's NEXT is the same.
  4. The catch-all only catches refusals nobody anticipated, so any generic fallback would be a made-up command.

Keeping exc.remediation or None.

This was referenced Oct 12, 2026
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>
sourcery-ai[bot]
sourcery-ai Bot previously approved these changes Oct 13, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sourcery assessment

Approved.

…n: scripts/family.py keeps both new constants

#507 added HEADS_PREFIX and this branch added GITMODULES_UNREADABLE (#370)
at the same place in scripts/family.py. The resolution keeps both blocks
verbatim, this branch's first; no other line is touched.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit 3121945 into main Oct 14, 2026
5 checks passed
@brettheap
brettheap deleted the fix/refusal-default-remediation branch October 14, 2026 13:21
brettheap pushed a commit that referenced this pull request Oct 14, 2026
Merged by sha, 3121945, fetched at
13:35Z, on top of b602c16. It brings one commit since b0826e0: 3121945
(#494 for #370: a refusal built without a remedy prints its `REFUSED`
line alone, because `Refusal`'s default remediation is now none, and the
assembly root's `validate-pins.py` gives its own refusals the lockstep
remedy).

No conflict. The tree is `git merge-tree --write-tree b602c16 3121945`'s
(4a502cd) on every path, and no top-level unit of adopt-project.py,
scripts/repo_shape.py, scripts/family.py or shape-doctor.py changed on
both sides. #494 changes adopt-project.py in `_failed_loudly` alone.

This branch's refusals each name their own remediation, so none falls
back to the new empty default: `plan`'s `_refuse_unholdable_values`
through `refuse_manifest_values` (`f"{remedy} {tail}"`), `plan`'s
`_refuse_misread_values` (`MANIFEST_MISREAD_REMEDIATION`, then "No plan
was written."), and `execute`'s `_refuse_what_check_finds`, whose list
carries the misread remediation after its own. Every other `Refusal` in
a unit this branch changed passes one too.

No top-level name is bound twice in adopt-project.py, none is read and
never bound, and no imported name goes unread. shape_materialize.py and
family.py import `parse_yaml` and `YamlError` twice, as on main.

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 14, 2026
…ck, a refresh round under ruling 27 for #511 (#471)

The merge is CLEAN: git merged adopt-project.py automatically (#494 touches
only `_failed_loudly` there, #511 the eol-block and ignored-shape-file
code), and scripts/family.py, scripts/repo_shape.py, shape-doctor.py,
templates/assembly-root/scripts/validate-pins.py and
tests/test_refusal_default_remediation.py came in whole from main. No file
conflicted and nothing was resolved by hand.

#494 touches neither README.md nor docs/handbook.html, so the handbook
footer keeps its clause "(#471)" after #502's and the line
`README.md blob f3b378d` is unchanged:
the old and the new blob are the same, equal to `git hash-object
README.md` of the merged README.md. README.md stays at 1422 lines and
AGENTS.md at 490.

The merged tree is d58ea4b, the tree
`git merge-tree --write-tree 9f28d90 3121945` prints, so the merge adds
nothing main did not already hold.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 14, 2026
…epo 2.47.0, so the adoption test files that skip without it run there, and a source that keeps its own root `.gitattributes` gets one marked block of `/<copy> text eol=lf` lines appended to it in the split, so a Windows clone writes the pinned copies at the root with the bytes the pin records; the verification counts that file as the source's bytes followed by exactly that block, and `update-shape.py apply --add` extends it (#471) (#511)

v1.0.0 says that every AGENTS.md procedure, adopt included, works end to
end on Linux, macOS and Windows. `tests-windows` left `git-filter-repo` out
on purpose, so every test that executes an adoption skipped there and the
Windows half of that sentence was a claim no runner had checked.
`tests-windows` now installs it at the ubuntu job's pin, on one line because
PowerShell is that runner's shell, and still runs the whole suite. At #497's
base 23 files skipped without it (the 21 that spell
`shutil.which("git-filter-repo")`, `test_adopt_filter_repo_failure.py` and
`test_adopt_git_reads.py`), 178 of their 1731 items. The workflow's comments
and seven test docstrings that named the Windows job as the one without it
now say what stays true. The tool adds about 8 to 11 minutes to the Windows
job. Its first run there (probe run 38312924968) passed 1696, skipped 34
(exotic names, tracked symlinks, `#!` stubs and test helpers), and failed
the one case the rest of this change fixes.

Adoption's first run on a Windows runner failed one ordinary case: a source
that tracks its own root `.gitattributes`. The shape's `* text=auto eol=lf`
went beside it at `shape/.gitattributes`, where it governs only `shape/`, so
the nine copies the pin names at their own paths answered to the source's
rule, a Windows checkout wrote them with CRLF, and `validate-pins.py`, which
digests the bytes on disk (#51), reported shape-copy-drift on each. Under
Git for Windows' default `core.autocrlf=true` any root `.gitattributes` but
the shape's does it. A root-level file takes attributes from the root file
alone, so no file beside it can fix that.

Brett Heap ruled at 2026-10-12T22:48Z that the split appends a marked block
to the source's own file, that the verification accounts for it, and that
`update-shape --add` extends it.

The materializer spells the block once: a comment naming openRepoShape and
#471, then `/<path> text eol=lf` for each pinned copy outside `shape/`, in
the pin's order, appended after the source's bytes when the shape's
`.gitattributes` collided, and never through a symlink. `execute` prints it
and lists its lines in the split commit's message, and its verification
counts the source's file as landed in the root only when the blob there is
exactly the source's bytes followed by that block, recomputed from the
split's pin; anything else is still `adopt-lost`. `update-shape apply --add`
names each added copy in the block, through the ledger, and commits the file
with the copies; a root with no block is left alone. The follow-up says what
the block protects. A source with no root `.gitattributes` keeps today's
behaviour.

The two `git-filter-repo` failure tests whose stub is a `#!` script now run
on Windows, where git starts the stub with its own `sh`. The one that
follows the failure notice compares the leg repositories it reads there with
their `spelled` form, because the notice escapes each backslash of a Windows
path (#212). The test that a `.gitattributes` symlink is not written through
skips on Windows, as the adoption suites' other disk-symlink tests do. The
`update-shape --add` fixture's check that no copy list names a stripped file
reads list entries, not every string in the materializer.

The docs are corrected here (Brett Heap, 2026-10-13T11:30Z), each file
keeping its line count. README.md's "Ambiguous is an answer, and no source
line is overwritten" (was "nothing is overwritten") says a kept root
`.gitattributes` gets the block appended and is verified as the source's
bytes plus the block. Its "Why `.gitattributes` is one of the copies" names
the block as what carries the rule in such a root. AGENTS.md step 7 states
the same exception, and the no-code variant defers to step 7 rather than
restating "by blob sha". docs/handbook.html mirrors both paragraphs, names
#471 in its footer after main's clauses, the last of them #502's (#504
and #494, which landed since, add none), and recomputes its README.md blob
over the merged README.md.

Review round 1. A `.gitattributes` git tracks as a symlink gets no block
even where Git for Windows' default `core.symlinks=false` checks it out as a
plain file: the materializer asks git's index, not only the disk. Before,
the block went into the link, and the adoption failed `adopt-lost` after
both legs were pushed. The plan reads the link from the source tree. Its
follow-up says nothing was appended and git reads no `.gitattributes` that
is a link, and it lists the lines a regular file in its place needs. The
verification reads the shape's pin through the split's links, as the
shape-file check does, so a source keeping `contracts -> meta` in the root
verifies. `--add` extends the last block. README.md's `--add` sentence
names the block, at an unchanged line count.

Merged with main at d41cc29, at 89c6a6c and at 8beb3d9. Each of those
merges' one conflict was the handbook footer, which keeps main's clauses
whole and adds this one after the last, now #502's. Two of what main brought
meet this change, #513 and #512. #513's `adopt-not-the-source` runs in
`plan` before the tree is read, so before the block's follow-up is
predicted, and its `plan-not-the-source` is `check`'s finding and
`execute`'s first refusal, before the split commit appends the block: a
source with a root `.gitattributes` and a wrong `--project` is refused with
no plan written (probed). #512's `merged-away` takes the `.gitattributes`
beside-copy as it takes any other: left in place it is `unchanged`; merged
into the root file and removed, `check` calls its row `merged-away` and
`apply` drops the row, leaving the block and the root file as they were, and
the gates and the doctor go green (probed). The verification of the source's
bytes plus the block runs inside `execute` alone, so it never meets that
row. A `.gitattributes` collision's follow-up is this change's own and
returned before #512's sentence about the row, which every other pinned
collision's follow-up carries; the fold below adds it. The rest do not meet
it. #402 rewrote the doctor's NEXT lines, in a file this change does not
touch. #462 rewrote only the two ruleset hint constants in
`scripts/shape_materialize.py`, and the scaffold's call to
`materialize_assembly_root` passes no `collision_dir`, so it never reaches
the block. #286's `update-rewrite-symlink` and `--add`'s extension of the
block never meet either: a `.gitattributes` that is a symlink is left alone
before the ledger is asked to write it, so `apply --add` exits 0 with the
link and its target untouched (probed over a live and a dangling link). #515
spells the manifest's `neutral_product_pins:` entries, which the block never
reaches, and its tests sit in `tests/test_adopt_pins.py` beside this
change's fixture fix and merged clean. #507 is `family.py` and the quoting
of the commands it prints, #517 is `scaffold-project.py` and #510 is the
installed doctor's wrapper: none is a file this change touches beyond the
docs.

Merged once more with main at b0826e0, the squash of #504 (#183, #178),
which meets this change in `adopt-project.py` and
`scripts/shape_materialize.py`; the handbook footer did not conflict, because
#504 does not touch it. Three hunks conflicted and each keeps both sides: the
`from shape_materialize import (...)` list, `materialize_assembly_root`'s
docstring, and its tail, where #504's `spell` is passed through and the
`.gitattributes` block is appended after it. The two do not contradict each
other, asked of real adoptions through `plan`, `check` and `execute`. (a)
`materialize-attributes-path` needs no `spell`: only `eol_line` raises it,
over the shape's own copy-list names, which `root_key` never resolves through
a link, and `spell` is for the names a source's symlinks lead to. (b) Where
#504's `check` passes, a root `.gitattributes` that is a symlink adopts as it
did, to the blob: the link kept, no block, the shape's copy beside it. Where
`check` now refuses it, `plan-root-symlink` before any leg (a dangling link,
one to an absolute path with nothing there, a climb above the root, a
`.gitattributes` under a link whose target already holds one), this change
alone had written the copy through the link or failed after both legs were
pushed. (c) The rest of #504's diff does not meet the block:
`_refuse_unwritable` runs inside `place`, before the block is appended, and
the block never goes through `place` or through a link. This change's four
imports from `shape_materialize` sit in a statement of their own, so a change
adding names to the shared list merges there without a hand resolution.

Merged again with main at 3121945, the squash of #494 (#370), and
it was clean: no file conflicted, and the handbook footer was not touched,
because #494 changes neither README.md nor docs/handbook.html. It meets this
change in `adopt-project.py` alone, in `_failed_loudly`, which this change
does not touch; `Refusal` now carries no remediation unless its caller names
one, and the one refusal this change adds, `materialize-attributes-path`,
names its own, so it prints as it did.

- The follow-up for a source's own `.gitattributes` now says what #512 (#502)
  says of every other pinned copy, after its own condition: merge the rest of
  `shape/.gitattributes` into the root file only if the whole project should
  check out with LF, and only then remove it, after which `update-shape.py
  check` reports its pin row `merged-away` and `apply` drops the row. The
  link variant says nothing of the row, because it never sends anyone to merge
  or remove the copy, which stays pinned. A new test follows the flow through
  a real adoption: merge the copy into the root file, remove it, paste
  `check`'s NEXT, and the row goes while the root file stays as merged.

The first Windows run of the adoption suites ended in 12 errors, all at
the module fixture of `tests/test_adopt_pins.py`: it deleted a
`shutil.copytree` copy of a repository with `shutil.rmtree`, and Windows
refuses to unlink the read-only objects git writes
(`PermissionError: [WinError 5]`). It now deletes it with `rmtree` from
`tests/conftest.py`, which clears the read-only bit and retries, as the other
suites do.

This pull request supersedes #497: its two reviewed commits (`a528006`, the
install, the comments and seven docstrings; `a8b5064`, a strict Windows
xfail on the case fixed here) are merged in, and the xfail is removed,
because that case passes on Windows over the fix and a strict xfail that
passes fails.

Closes #471

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…5 cites #504 and #494, section 6 drops #178 and #183, which #504 closed, adds the four limits #504 disclosed for #457 (J1, J2, J3, reftable) now that they describe main, and narrows two lines #504 and #494 made partly untrue, and section 7 lists the 2 open milestone issues (#472)

Refresh round 3a, against main 3121945 (#494, merged
2026-10-14T13:21:57Z). Two pull requests merged after round 2's
8beb3d9, and both merge commits are on main, as are all 225:

- #504 (b0826e0, lane 1, closes #178 and #183) under adopt-project.py,
  as a new bullet, "Symlinks the root keeps": `plan-root-symlink` and
  `adopt-symlinks-off`, both before any leg.
- #494 (3121945, lane 3, closes #370) under the shared helpers, where
  #370's parent #459 puts it: a refusal with no remedy of its own prints
  its `REFUSED` line alone, `validate-pins.py` keeps the lockstep remedy,
  and `family.py add` refuses `gitmodules-unreadable`. Lane 3 asked on
  #473 (13:22Z) that #370 be placed in the notes.

Each line was written from the PR body and its squash message, and its
central claim read in main's code: `_entry_findings` and
`_root_symlink_findings` (adopt-project.py:4701, :5717),
`_refuse_symlinks_checked_out_as_files` (:5824) before
`_create_leg_remotes` (:7542, :7580); `Refusal`'s empty default
(scripts/repo_shape.py:166-174), `validate-pins.py`'s handler
(templates/assembly-root/scripts/validate-pins.py:1181-1184), family.py's
`gitmodules-unreadable` (:1912-1926), and the doctor rows that take
`exc.remediation or None` (shape-doctor.py:4546).

Section 6:
- Dropped #178 and #183, closed by #504.
- Added L1-J1, L1-J2, L1-J3 (#457, 2026-10-12T23:36Z) and L1-K8 (#457,
  2026-10-14T01:17Z). Round 2 left them out because they described
  #504's branch. #504's body lists all four as its known limits. J2's
  `check` half (the refusal and its false sentence), J1's `check` half
  (`plan ok`) and J3's `check` halves were run at 3121945 in an offline
  probe. J1's and J3's `execute` halves rest on lane 1's measurement and
  the code (`_has` and `CLONE_GIT_TREES` at :5427-5438;
  `adopt-symlinks-off` at :5824-5866). K8 is read from the code only
  (`CLONE_GIT`, :5201-5206), and its line says it is untested.
- Narrowed L1-K1's line. A link at `contracts/shape-pin.yaml` that leads
  nowhere is now a `plan-root-symlink` finding, since the walk follows
  `SHAPE_PIN` (shape_materialize.py's `assembly_root_paths`). The probe
  ran it, and a real file there still passes `check`. The line now says
  a file, or a link there to a declared pin's file, as K1 measured.
- Narrowed #408's line. The issue's own repro, a policy file that does
  not parse, carried `repo_shape`'s default lockstep remedy, which #494
  removed, so the `placement` row now has no NEXT. Only a policy file
  that is not UTF-8 still gets prose there (`load_yaml`'s own remedy,
  which holds no backtick). An in-process probe of `PathPolicy.load` with
  main's and 8beb3d9's repo_shape.py shows both.
- Kept L1-K2, K9 and K10, which lane 1 measured as the same on #504's
  branch. The five parents have no new comment and an unchanged body
  since round 2's read (03:11Z to 03:18Z). Their sub-issues still list
  every open, unmilestoned one: 45 (#457 10, #458 6, #459 17, #460 9,
  #461 3).

Section 7: the milestone is 45 closed and 2 open (#471, #472). #370 left
the list. Of the three issues section 7 named as outside the milestone,
only #181 is open.

No claim is made about an unlanded pull request (#511, #518), and
nothing is said about a tag or a date. 7,354 words before, 7,628 after.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…5 cites #511 and drops the Windows qualifier now that the Windows job runs the adoption suites, section 6 cites the v1.1.0 issue of each line the triage scheduled, drops the five lines closed on their parents and #408, and adds #511's line-ending limits, and section 7 lists #472 alone (#472)

Refresh round 3b, against main 5ed3e5d (#511, merged 2026-10-14T16:08:28Z,
closing #471). All 226 merge commits are on main.

Section 5. #511 is cited twice: under adopt-project.py, as a new bullet on
a source's own `.gitattributes`, and under the test suite and CI, in the
"Three operating systems" bullet. "Every source path is verified by blob
sha" gains the exception AGENTS.md step 7 now states. The claims were read
in main's code:
- `.github/workflows/tests.yml`:171-182, where `tests-windows` installs
  `git-filter-repo==2.47.0`; :209, where it runs the whole suite; and
  :118-125, where it runs on pull requests, not on a push to main;
- `scripts/shape_materialize.py`: `EOL_BLOCK_HEAD` (:701), `eol_line`
  (:712-721), `eol_block_paths` (:724-729), `with_eol_block` (:738-744), and
  `_append_eol_block` (:813-831), which writes through no symlink, as called
  from `materialize_assembly_root` (:2256-2257);
- `adopt-project.py`: `_land_the_eol_block` (:8081-8124, called at :8063)
  and the split message's `APPENDED TO` (:7953);
- `update-shape.py`: `_extend_the_eol_block` (:3061-3089).

The qualifier. Main's workflow now does what the unqualified sentences say,
so both `<!-- drop the Windows qualifier once #471 lands as proposed -->`
comments and the two sentences they marked are gone.

Section 6, re-read after lane 3's v1.1.0 triage (#473, 16:03:48Z, on
Brett Heap's approval at 14:31:28Z):
- 74 lines marked `→ #N` or "rides with #N" on their parent now end with
  that number, as in (#524). For each parent, the set of numbers cited
  equals the set its marks name. One sentence in the section's opening
  says what the number is.
- Dropped, each closed on its parent: `--project ATLAS` and a fork's
  `origin` (#457, ruled behaviour); the scaffold's three working trees
  (#459, fixed by #505 and kept documented); and the `shape currency`
  row's uncommitted-copy line (#460, obsolete since #402 and #512). Also
  #408, closed as fixed by #494.
- Added, under #457: the symlinked root `.gitattributes`, the nested one
  (#530), and a root adopted before #511 (lane 4, 2026-10-12T23:25Z);
  then #511's three posted at 16:09:31Z: a copy written through a
  symlinked directory, a CRLF `.gitattributes` under a rule other than
  `text=auto`, and a `filter=` or `working-tree-encoding=` rule. Its
  fourth, a test helper that feeds text on stdin, is left out: no person
  meets it. These rest on lane 4's measurement (V511c at 1ce4f45, whose
  tree is main's); `eol_line` writing only `text eol=lf` and the symlink
  skip were read in code.
- Reworded:
  - AT-scaffold N9 (#458), with lane 4's 16:10:03Z note. `cmd_apply`
    always stages the pin and the manifest (`update-shape.py`:3280-3281),
    and `_land` prints them all (:3224).
  - The annotated-tag line (#459). It is now ruled that `bump --to` peel
    it (#541), so "One rule is still to be chosen" is gone.
  - The Windows symlink line (#457), narrowed to its parent's own words.
    #511's end-to-end test of a source that tracks a symlinked
    `.gitattributes` under `core.symlinks=false` carries only
    `needs_filter_repo` (tests/test_adopt_ignored_shape_files.py:862-863),
    so it now runs on Windows.
- Left out, new: the three CI lines new on #461 (the merge queue and two
  CI costs). They are this repository's CI, not a limit of the shape.
- Lane 1 has posted no #504 batch, so D13 stays out.

Section 7: v1.0.0 is 46 closed and #472 open. #181 is still outside the
milestone.

No claim is made about an unlanded pull request (#518, #551), and nothing
is said about a tag or a date. 7,628 words before, 7,906 after.

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