Skip to content

lane-worktrees: the daily estate report, and nothing new lands beside the code (#162, part 2) - #169

Merged
brettheap merged 36 commits into
mainfrom
feat/lane-worktrees-report
Oct 6, 2026
Merged

brettheap merged 36 commits into
mainfrom
feat/lane-worktrees-report

Conversation

@brettheap

@brettheap brettheap commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What

The second of two stacked PRs for #162 (base: feat/lane-worktrees-sweep, #168). #168 retires what is already there; this keeps more from arriving, and counts what is left every day.

  1. tests/run.sh leaves nothing beside the code. PYTHONPYCACHEPREFIX=${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache, -p no:cacheprovider, and every temporary directory of a run (--basetemp and the suite's TMPDIR) under one run root, ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/, which an EXIT trap removes however the run ends. The lock is computed before TMPDIR moves, so it is still the workstation's. The suite runs in its own process group, and the whole group is stopped before cleanup.
  2. One virtual environment per repository, outside the estate. openRepoTools --install names ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/openRepoTools, says whether it is there, and prints the two commands that make it. It never makes one (pip and the network); tests/run.sh runs from it once it has pytest.
  3. lane-worktrees sweep --all --dry-run --report [--post <file|owner/repo#n>]: the estate's leftovers as one markdown document. lane-start runs it once a day per workstation, detached, guarded by a stamp file. It changes nothing.
  4. The workspace never carries bytecode. wip init seeds the .gitignore lines. lanes-edit.sh's commit path refuses, exit 2 with nothing staged, a pathspec that would stage bytecode, a cache, node_modules, a venv or site-packages, and offers the missing lines (ten of them, site-packages/ included). lanes-edit.sh pathspec-check <path>... asks the same question before a hand commit of attachments.
  5. Tests for each, and the docs (docs/README-lanes.md, README.md, AGENTS.md).

Why

The cleanup analysis's diagnosis is that workspace creation outpaces workspace closeout. #168 is the closeout. This PR covers the rest: things stop landing in worktrees when they are created (causes 3, 4, 19), and the remaining gap becomes a number someone reads every day (causes 7, 9, 10, 17, 20). The report catches what is left after every actor that never runs lane-end.

The report, as implemented

It is meant to read every lane's #97 inventory and snapshot under <parent>/.lane-state for every checkout's parent (#97 keeps a nested lane's control root beside its checkout). On the real estate it reads none (defect B8, deferred to #170): the estate walk never enters a plain directory inside a repository, so xFactory/xFactories/* are never found as checkouts and xFactory/xFactories/.lane-state is never read. It also reads the workspace register on origin/<branch>, and the disk. It never reads the derived index (Amendment 14 clause (b)), and that includes #161's table. It never fetches, never refreshes an index (GIT_OPTIONAL_LOCKS=0) and never expires anything.

section lists
FOREIGN repositories a clone inside a worktree container, and a second clone of an origin the estate already has. Each with path, size, last commit, Lane: owner, and LOAD-BEARING with its dependents
Orphaned worktrees of ENDED lanes inventory trees and .lane-worktrees/<lane>/* of a lane whose snapshot is CLOSED or whose log ends ENDED/RETIRED
Unmerged branches no upstream / gone / diverged / unpushed, with tip, age, owner and "held by a worktree". Never deleted
Root main divergence local main ahead of origin. Where only handoffs//lanes/ paths moved, the Amendment 4 remedy is given
Awaiting disposition rescue/* branches and dirty inventory trees older than aging_days (14), with owner and age
Caches and sandboxes by size
Ignored directories over ignored_report_mb (50) du -sk per ignored directory
Evidence-shaped paths untracked/ignored junit*.xml, MANIFEST*, *-report.md, *REPORT*.md, canary-*, *-evidence, and a directory of reports beside the repositories. Each is a finding to move to the one evidence root, ${XDG_STATE_HOME}/openRepoTools/evidence/<repo>/<UTC>-<slug>/
Sweep archives past retention --expire's own table (now shared as expiry_rows): what would expire, and what is kept because its rescue left origin
Workspace repository hygiene the .gitignore lines the workspace lacks (with the command that adds them), and bytecode already in its history (a person's word)
status --all findings pin drift, ahead/behind, forks, parked records, reported as what they are and never counted as dirt

The head table also carries the rescue-branch count and the sweeps directory's size.

Daily run (lane-start § 5e): runs after the row is written and before the launch. It takes report-<YYYYMMDD>.stamp with an atomic set -C create, so two simultaneous starts run one report. It removes older stamps, then runs the report with stdin, stdout and stderr closed, in a subshell that exits at once, writing ${XDG_STATE_HOME}/openRepoTools/reports/<UTC>.md. Every step either works or is skipped silently. --dry-run starts none, and LANE_WORKTREES_REPORT=off turns it off (the suite sets that).

Where this differs from the issue's wording, and why

Tests

file cases holds
tests/test_run_wrapper.py (new) 6 Stub suite and stub pgrep. Checks bytecode prefix, -p no:cacheprovider, --basetemp under the run root, run root removed after the run, lock still under the caller's TMPDIR. A killed run (TERM→143, INT→130, × flock / mkdir-lock) stops the suite and a child of its own within seconds, removes the run root and releases the mkdir lock. The venv is used when it has pytest
tests/test_lane_worktrees_report.py (new) 9 Builds an estate with every finding: foreign clone + duplicate clone + two clones of one origin with neither named for it, CLOSED-lane tree (top-level and nested parent), no-upstream and diverged branches, handoff-only root main, 30-day rescue branch and dirty inventory tree, cache, killed tmp.*, ignored build/, JUnit file, a reports directory, a would-expire and a kept archive, workspace .gitignore + .pyc in history, status --all (pointed at the same estate, with or without --estate). Every section finds its own, and a whole-estate snapshot is unchanged. --post to a file and to owner/repo#n (fake gh). --all/--report misuse → 64. A record the report cannot read is a finding ("Records the report could not read"), never treated as absent. lane-start: the report runs detached (a 90 s reporter, start returns before it finishes), a stamp from today runs no second report, yesterday's is replaced, a failing reporter never fails a start, off and --dry-run start none
tests/test_openrepotools_command.py +2 --install names the venv: "not created" with the two commands, then "present". Never creates one. The printed command survives a cache path with a space
tests/test_wip_init_command.py +4 Seeded .gitignore = template + the ten lines, each once. git check-ignore of an attachment .pyc. Re-run "nothing to do". wip_hygiene_lines repeats no line the file has, and prints nothing when it has them all (×3)
tests/test_lane_helpers.sh +19 assertions pathspec-check refuses an attachment with __pycache__, a venv/ and a venv under another name (names them but not the notes, and offers the lines, site-packages/ included). The other-named venv is still refused until site-packages/ is added. A clean pathspec passes. commit refuses the same pathspec with 2, commits nothing and stages nothing. With the lines in place nothing of the kind is staged, and bytecode already force-staged is still refused by both pathspec-check and the commit path

Mutation-checked: with the nested-parent lookup removed, the report case fails on the nested orphan.

Locally (Eagle, tests/run.sh was queued behind other estates' pytest runs, so these used a direct harness that calls the test functions): run-wrapper 6/6, report 7/7, wip-init module 63/63, test_repo_hygiene.py 152/152, the venv case and both install idempotence cases, #168's sweep module 23/23, and bash -n on every touched script. CI is the record.

Measurements (Eagle, read-only)

lane-worktrees sweep --all --dry-run --report --estate /workspace/projects: 68.5 s (a second run 79.5 s), exit 0, 524 lines, nothing changed.

finding count
FOREIGN repositories 89
Orphaned worktrees of ENDED lanes 0, but this is not a reading of the estate's snapshots: the report read no .lane-state on Eagle (B8, #170). xFactory/xFactories/.lane-state exists, and by hand it holds five RUNNING lanes with empty inventories; the report never reached it, and the section said "none" where it should have said what it could not read. The one RETIRED lane in the register, browser-ui-repair, has no tree
Unmerged branches (missing / diverged / unpushed upstream) 162
Root main divergence 1 (openRepoProject, 1 commit, 103 paths: not handoff-only)
Awaiting disposition (> 14 d) 23 (rescue branches only: the dirty-inventory-tree half read no .lane-state, B8)
Caches and sandboxes 97, 425.5 MB (largest /tmp/pytest-of-brett/pytest-3507, 342 MB)
Ignored directories over 50 MB 56 (largest a .dart_tool at 4.0 GB)
Evidence-shaped paths 24
Archives past retention 0
Workspace hygiene 2: brett-wip/.gitignore lacks all nine lines, and 703 bytecode paths in its history (e.g. handoffs/codeXfactory/attachments/…/venv/lib/python3.12/site-packages/__pycache__/…pyc), which is cause 19 measured
status --all findings 8 (shape-pin drift)
rescue branches (all ages) 25
the sweeps directory 0 B

Review

Round 1 on #169 (Copilot, 4 threads), all taken:

  • da7bc59: the suite's whole process group is stopped (set -m for the spawn; TERM, reap, ps-checked wait, KILL), so nothing it started outlives the run root or the lock.
  • b0ea54e: site-packages/ is offered and seeded alongside the other lines, so following the offer clears the refusal.
  • b422d8f: duplicate clones are reported even when none is named for the repository, and status --all reads the estate the report resolved; lane-start passes its $PROJECTS_ROOT.

Each new assertion fails against the previous head. All threads were answered and resolved.

#168's round-1 fixes came in by merge (f4f4915), with no rebase and no force-push. The report's archive section now inherits whole-archive and exact-SHA expiry.

Round 2 on #169 (3 threads, at 23d84da), all taken:

  • 65f73ed: bytecode already staged is refused too;
  • f5bccbd: the printed venv command is quoted;
  • dc7971c: the report surfaces records it could not read.

#168's round-2 fixes came in by merge (892b02d).

Docs within their caps: 31db36f. CI at 38e090f failed only on test_repo_hygiene's line caps (AGENTS.md 329/316, README.md 491/486; Linux tests was otherwise 963 passed). The full text lives in docs/README-lanes.md. AGENTS.md is back to 316 lines and README.md to 486, and no cap is raised.

After the cap (Copilot rounds 3-5 on #169):

The Opus adversarial review of 30cc63a (standing in for Codex, which is over quota): B7 taken in 0cf1051. With a terminal on stdin the suite now runs in the foreground, so --pdb/breakpoint() no longer stop it on SIGTTIN while the wrapper holds the lock; a new pty case passes here and hangs against the previous run.sh. #168's fixes came in by merge (9dd63f0):

B8 (the report reads no .lane-state on the real estate), C2, C3, E8, E9, E10 and (d) are in #170's table. The measurement rows above were corrected for B8.

Closes #162

Refs #97 #161 #163 #164 brettheap/new-workstation#48

Lane: openRepoTools-3

🤖 Generated with Claude Code

Summary by Sourcery

Prevent new workspace debris from accumulating and provide a daily, read-only report of the estate's remaining cleanup findings.

New Features:

  • Add a read-only estate report for identifying worktree, repository, branch, cache, evidence, archive, and hygiene findings across all workspaces.
  • Run the estate report automatically once per UTC day from lane-start, with optional file or issue posting and no impact on startup success.
  • Add workspace pathspec checks that prevent commits containing bytecode, caches, dependencies, or virtual environments.
  • Add repository-specific virtual-environment guidance to openRepoTools --install.
  • Seed workspace .gitignore files with rules covering generated bytecode, caches, dependencies, and virtual environments.

Bug Fixes:

  • Prevent test runs from leaving bytecode, pytest caches, temporary directories, or child processes in worktrees after normal or interrupted execution.
  • Ensure interrupted test runs stop their entire process group and release temporary locks.
  • Make report failures and unreadable records visible without treating missing data as clean.
  • Handle filesystems that cannot create non-UTF-8 worktree paths without failing the test suite.

Enhancements:

  • Improve test execution isolation by relocating caches and temporary files to XDG state and cache locations while preserving workstation-wide locking.
  • Document the estate report, workspace hygiene rules, and test-run isolation behavior.

Documentation:

  • Document the estate report, daily reporting behavior, evidence locations, and workspace hygiene protections.

Tests:

  • Add coverage for report contents, immutability, posting, daily detached execution, wrapper cleanup and signal handling, virtual-environment selection, and hygiene enforcement.

brettheap and others added 12 commits October 5, 2026 20:24
A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 5, 2026 21:09
@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@sourcery-ai

sourcery-ai Bot commented Oct 5, 2026

Copy link
Copy Markdown

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

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

@sourcery-ai

sourcery-ai Bot commented Oct 5, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR closes the remaining workspace-estate hygiene gaps by isolating test-run artifacts, adding a read-only daily estate report, enforcing workspace commit hygiene, and documenting a repository-scoped virtual-environment workflow, with extensive shell and Python coverage for cleanup, signal, nested-estate, scheduling, and immutability behavior.

Sequence diagram for the daily estate report

sequenceDiagram
    participant LS as lane-start
    participant Stamp as Daily stamp
    participant Report as lane-worktrees sweep
    participant Estate as Estate state and disk
    participant Output as Report file

    LS->>Stamp: Create report-YYYYMMDD.stamp atomically
    alt Stamp created
        LS->>Report: Start detached sweep --all --dry-run --report
        Report->>Estate: Read inventories, snapshots, registers, and disk
        Report->>Report: Set GIT_OPTIONAL_LOCKS=0
        Report->>Output: Write UTC markdown report
    else Stamp already exists
        LS-->>LS: Skip report
    end
Loading

Flow diagram for workspace commit hygiene

flowchart TD
    Input[Commit pathspec] --> DryRun[git add --dry-run]
    DryRun --> Check{Bytecode, cache, dependency tree, or venv?}
    Check -->|No| Stage[git add and commit]
    Check -->|Yes| Refuse[Exit 2; nothing staged]
    Refuse --> Offer[Print offending paths and missing .gitignore lines]
    Attachment[Hand-committed attachment] --> PathCheck[lanes-edit.sh pathspec-check]
    PathCheck --> DryRun
Loading

File-Level Changes

Change Details Files
Reworks the test wrapper to isolate generated artifacts and temporary files while preserving workstation-wide locking and signal cleanup.
  • Routes Python bytecode and pytest temporary state to XDG cache/state locations.
  • Disables pytest cache and places suite temp directories under a removable per-run root.
  • Runs tests from the repository-scoped cached virtual environment when it contains pytest.
  • Adds TERM/INT handling and coverage for both flock and mkdir locking paths.
tests/run.sh
tests/test_run_wrapper.py
AGENTS.md
docs/README-lanes.md
Adds a read-only estate-wide sweep report and schedules a detached daily report from lane startup.
  • Implements sweep --all --dry-run --report with validation, markdown sections, summary counts, and file or GitHub issue output.
  • Aggregates lane state, repository registers, Git metadata, disk findings, hygiene issues, archives, and status findings without fetching, indexing, or expiring.
  • Adds once-per-UTC-day atomic stamp coordination, detached execution, silent failure behavior, and opt-out/dry-run handling.
  • Adds broad fixture-based tests, including nested lane-state roots, unchanged-estate verification, posting, and lane-start behavior.
lane-worktrees
lane-start
tests/test_lane_worktrees_report.py
tests/conftest.py
tests/test_lane_helpers.sh
docs/README-lanes.md
AGENTS.md
Prevents generated content and dependency environments from entering workspace commits.
  • Makes wip init seed nine hygiene patterns without duplicating template entries.
  • Checks the paths a commit would stage and refuses bytecode, caches, dependency trees, virtual environments, and site-packages with exit 2 before staging.
  • Adds lanes-edit.sh pathspec-check for preflight checks on manually committed attachments.
  • Adds tests for ignore seeding, idempotence, clean and rejected pathspecs, and no-op commit behavior.
lanes-edit.sh
openRepoTools
tests/test_wip_init_command.py
tests/test_lane_helpers.sh
docs/README-lanes.md
README.md
Changes installation guidance to identify, rather than create, the repository-scoped virtual environment.
  • Prints the XDG cache path and whether the environment is present.
  • Prints the venv and pytest installation commands while leaving creation to the user.
  • Tests that installation performs no venv or network-dependent setup.
openRepoTools
README.md
tests/test_openrepotools_command.py

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

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Signal cleanup can leave descendant processes running, and several report and hygiene edge cases produce incomplete or incorrect results.

Review effort: Balanced
Findings: 4 Medium severity

Open (4)
What changed in this PR

Adds read-only estate reporting and prevents generated residue from entering worktrees or workspace commits.

Changes:

  • Adds daily estate-wide cleanup reporting.
  • Isolates test caches, temporary files, and virtual environments.
  • Adds workspace ignore rules and commit hygiene checks.
File Description
lane-worktrees Implements estate reports and posting.
lane-start Starts the daily detached report.
lanes-edit.sh Rejects generated artifacts in commits.
openRepoTools Seeds ignores and reports external venv status.
tests/​run.sh Relocates and cleans test artifacts.
tests/​conftest.py Disables reports during tests.
tests/​test_lane_worktrees_report.py Tests report generation and scheduling.
tests/​test_run_wrapper.py Tests wrapper isolation and signals.
tests/​test_wip_init_command.py Tests workspace ignore rules.
tests/​test_openrepotools_command.py Tests venv installation guidance.
tests/​test_lane_helpers.sh Tests pathspec hygiene checks.
README.md Documents the repository venv.
docs/​README-lanes.md Documents reporting and hygiene behavior.
AGENTS.md Updates test-running guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lane-worktrees Outdated
Comment thread lane-worktrees Outdated
Comment thread lanes-edit.sh
Comment thread tests/run.sh Outdated
brettheap and others added 2 commits October 5, 2026 21:22
Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 5, 2026 21:26
The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Pre-staged artifacts can bypass the commit guard, and several report paths can produce incomplete or mismatched results.

Review effort: Balanced
Findings: 1 High severity · 6 Medium severity

Open (7)

Comment thread lanes-edit.sh
Comment thread lane-worktrees Outdated
Comment thread openRepoTools Outdated
Copilot AI balanced review requested due to automatic review settings October 5, 2026 21:34
brettheap and others added 3 commits October 5, 2026 21:34
Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Log-read failures can produce a falsely clean report, and one generated repair command mishandles workspace paths requiring quoting.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)

Comment thread lane-worktrees
Comment thread lanes-edit.sh
Copilot AI balanced review requested due to automatic review settings October 5, 2026 23:45
brettheap and others added 2 commits October 5, 2026 23:46
5f4bd84 made every unrecorded tree under .lane-worktrees/<lane>/ the
lane's. That included standalone clones, which #162's first protocol
line and the manual keep FOREIGN ("a lane creates worktrees, never
clones"). Copilot flagged it on 296e2f0. Clones are now excluded from
both adoption paths. The ownership case adds a clone under the lane
root and requires it to stay foreign and not retire-counted. On Eagle
none of the lane's seven root trees is a clone, so the dry run is
unchanged there.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Caller-supplied pytest arguments can override the enforced temporary directory and bypass cleanup.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (2)

Comment thread tests/run.sh
brettheap and others added 2 commits October 6, 2026 09:40
Three files conflicted. Each one was resolved by keeping #121's text and
adding this branch's on top:
- lane-start: both sides added a section just before "6. launch". #121's
  5c (the restart intent is written before exec) comes first, then 5e
  (the daily report). The report therefore starts only after the intent
  write, which can refuse the launch.
- lanes-edit.sh: both sides extended the unknown-subcommand list. The
  list keeps #121's five new entries (lane-holders,
  legacy-restart-check, publish-handoff, restart-intent,
  set-restart-intent) and adds pathspec-check.
- tests/test_lane_helpers.sh: both sides added a section before
  "nothing real touched". #121's supervised-restart section comes first,
  then the #162 bytecode section.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 09:42

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Report encoding, pathspec failure handling, and terminal process cleanup contain correctness gaps.

Review effort: Balanced
Findings: 5 Medium severity · 1 Low severity

Open (6)
Resolved since last review (1)

Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lanes-edit.sh
Comment thread tests/run.sh
Comment thread docs/README-lanes.md
brettheap and others added 2 commits October 6, 2026 18:48
Bring #169 current with main after #168 landed as 2080f3d, so the
lander can squash #169 on a green head. Main also carries #171
(f5444c5) and #172 (3660224). The merge base is 00971b9 because the
squash is not an ancestor of this branch, so git saw #168's files as
added on both sides. Four files conflicted:
- lane-worktrees (add/add) and README.md: main's copy is byte-identical
  to #168's old head 87da332, and main changed neither file after it,
  so this branch's copy (#168 plus #169) is the resolution.
- docs/README-lanes.md: the one block is #169's two report subsections
  against nothing on main's side. They stay, and #171's worktrees-table
  subsection, which merged cleanly, stays too.
- lanes-edit.sh: #171 and #169 each added a usage line, a dispatcher arm
  before `*)`, and a refusal-list entry. Main's text comes first, then
  #169's: the `worktrees` line and arm, then `pathspec-check`. The list
  ends `|set-restart-intent|worktrees|pathspec-check`, so it still
  matches the arms the repo-hygiene parser reads (64).
Changed-line comparison: this merge over main equals #169's own diff,
and over 45fa45d equals main's changes since 87da332 (#171 and #172).
The only difference in each is the refusal-list line that carries both
entries.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests-macos on #168's head df0d691 failed one case, 1160 passed:
test_a_path_that_is_not_utf8_is_a_row_not_a_traceback. Its fixture's
`git worktree add` of a Latin-1 path died with "fatal: could not create
directory of '.git/worktrees/caf\xe9': Illegal byte sequence". APFS
refuses a non-UTF-8 name, so no such worktree can exist there, and the
case has nothing to report. It failed with a CalledProcessError before
reaching its assertion.

The fixture now makes the raw-bytes directory first. Where the
filesystem refuses it (OSError), or git refuses the add, the case skips
and says why. Linux accepts the name, so the case runs there as before:
git adds a worktree into an existing empty directory.

Refs #177
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added the ready landing gate: runs tests-macos once before the squash label Oct 6, 2026
@sonarqubecloud

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

@brettheap
brettheap merged commit 500687c into main Oct 6, 2026
14 checks passed
@brettheap
brettheap deleted the feat/lane-worktrees-report branch October 6, 2026 20:43
brettheap added a commit that referenced this pull request Oct 6, 2026
…esidue

Main is 500687c, the squash of #168 and #169, so this branch's history
and main's carry the same content under different commits. The merge is
taken against this branch's old base 45fa45d, so main's text stands
wherever #174 did not itself change it; the result differs from main by
exactly #174's own diff.

Lane: openRepoTools-1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 6, 2026
Main is 500687c, the squash of #168 and #169, so this branch's history
and main's carry the same content under different commits. The merge is
taken against this branch's old base 45fa45d, so main's text stands
wherever #175 did not itself change it; the result differs from main by
exactly #175's own diff.

Lane: openRepoTools-1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 7, 2026
…ck every removal, switch --yes back on (#174)

* Add lane-worktrees: the sweep that retires a lane's abandoned worktrees

A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the sweep against every row of #162's disposition table

tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Place lane-worktrees as the seventeenth installed file

`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the sweep in the lanes manual

"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the estate for --links with scandir

`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a test run's bytecode, cache and temp out of the worktree

A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the repository's virtual environment on --install

One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Seed the workspace .gitignore with bytecode and cache rules

Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a workspace commit whose pathspec carries bytecode

The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the estate report once a day from lane-start

The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report the estate's leftovers with sweep --all --dry-run --report

Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the estate report and prevention at creation

The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 1's defects in the sweep

Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document round 1's rules in the lanes manual

The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what expiry_rows keeps now that #168 checks whole archives

The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the suite's whole process group when a run ends

Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer and seed site-packages/ with the other bytecode rules

Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report duplicate clones with no named checkout, over one estate

Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 2's defects in the sweep

Seven of round 2's eight. Each one let an act proceed on an unknown:

- Another lane's inventory record or directory that cannot be read, or
  that names no path, now refuses the sweep. Who else claims a tree is
  read before the fetch, so the refusal writes nothing.
- A submodule's ignored files (an .env) keep the tree. The tree's
  ignored archive covers the superproject only.
- Ignored files are printed before the archive and listed again at
  removal. A change, an addition or a failed listing leaves the tree.
- A prune candidate whose directory reappeared is left for the next
  sweep to classify.
- A pytest sandbox's .lock is read again at the act, and a live pid
  leaves it.
- `tracked()` returns None when a repository's index cannot be read,
  and such a cache is left.
- A manifest entry whose file is gone makes the archive not whole.

The eighth, unreadable /proc entries of same-account processes, is a
design trade and is filed in #170. On Eagle, 9 such processes exist at
any time (6 `timeout`, 3 `ssh-agent`), so reading them as "unknown"
would keep every tree on every sweep.

Four new or extended cases. Each fails against ae17742 and passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse bytecode that is already staged, not only what add would stage

Copilot round 2 on #169: `git add --dry-run` says nothing about a path
the index already holds at those bytes, yet the path-limited commit
still takes it. bytecode_in_pathspec now also reads `git diff --cached
--name-only --diff-filter=d` for the same pathspec. A staged deletion of
bytecode is a cleanup and is let through. The shell case force-stages a
.pyc after the ignore lines are in place: pathspec-check and the commit
path both refuse it, and nothing is committed.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quote the venv paths in the command --install prints

Copilot round 2 on #169: the command is meant to be pasted into a
shell, and $XDG_CACHE_HOME is the person's to choose, spaces included.
Both paths are now `printf %q`-quoted. The new case installs with a
cache directory containing a space, and shlex reads each path back as
one word.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report what the report could not read

Copilot round 2 on #169: an unreadable lane snapshot or inventory
record became an empty one, so a lane or tree dropped out of every
section and the report could look clean. Reads now go through _ls and
_record. A failure other than "not there", a tree record naming no
path, or an inventory tree whose git status fails is a row in a new
section, "Records the report could not read". The unused _sidecar
reader is gone.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep AGENTS.md and README.md within their line caps

test_repo_hygiene caps AGENTS.md at 316 lines and README.md at 486, and
both files were exactly at their caps on main. The run.sh paragraph had
taken AGENTS.md to 330 and the venv sentence README.md to 491. The full
text already lives in docs/README-lanes.md, so AGENTS.md now says the
same thing in the three lines the old sentence took, and README.md says
it on the line it extends. No cap is raised.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the flock file only where there is flock

Copilot on #169: macOS has no flock, so the wrapper takes the mkdir lock
and never creates openrepotools-pytest.lock. The relocation case checked
for that file unconditionally and would have failed the macOS job, which
is the landing gate. Where there is no flock, the case now checks
instead that the mkdir lock was released.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes off until #170; keep the gate and exit contract honest

The adversarial review of 3c0b188, reproduced with its probes against
the suite's own Estate fixture:

1. --yes and --expire --yes are refused, exit 2, changing nothing,
   unless LANE_WORKTREES_ENABLE_YES=1 is set. #170 lists data-loss paths
   that are still open. The suite's Estate sets the variable; a real
   estate should not until #170 lands. The dry run, --porcelain and
   --report are untouched.
2. B1: a tree that #97's inventory does not name is still the lane's if
   it stands under .lane-worktrees/<lane>/, or if it sits in
   <checkout>/.claude/worktrees on a branch whose own commits carry this
   lane's Lane: trailer. Another lane's claim still wins. Such trees keep
   the quiet-hours liveness rule. A lane whose state is NONE but which
   has trees of its own is refused, exit 2. On Eagle, `sweep
   openRepoTools-3 --dry-run --porcelain` gave exit 0, `summary 0 15 0
   15`. It now gives exit 2, `summary 9 13 4 4`: 9 trees are the lane's,
   and the snapshot is NONE.
3. B2: subprocess output and git metadata files are decoded with
   surrogateescape, and stdout and stderr write with it too, so a
   Latin-1 worktree name is reported rather than raising. An unreadable
   pytest-of-$USER is a kept sandbox row. A catch-all keeps errors that
   no read caught inside the contract: exit 2 on a dry run, and under
   --yes DISPOSITION.md is written first and the exit is 1.
4. B3: every porcelain field escapes backslash, TAB, newline and CR.
   Worktree registrations are read with `git worktree list -z` where git
   has it, so a newline in a path no longer becomes a phantom
   registration.

Five new cases (switch off, ownership plus NONE, escaping, non-UTF-8
path, unreadable sandbox root), each failing against 3c0b188. Two cases
were moved off the lane root to keep testing FOREIGN trees. 40 cases.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the --yes switch, lane-root ownership and porcelain escaping

The manual now leads the sweep section with the --yes switch and #170.
It also says which unrecorded trees are a lane's and that a NONE snapshot
with trees is refused. The exit contract now covers the escaped fields
and the catch-all.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the suite in the foreground when stdin is a terminal

Adversarial review B7: `set -m` puts the suite in a background process
group with the terminal as its stdin. Anything that reads the terminal
(--pdb, breakpoint(), input()) is then stopped by SIGTTIN, and the
wrapper waits on it for ever while holding the workstation flock. With
a terminal on stdin the suite now runs in the foreground, in the
terminal's own process group, and a Ctrl-C reaches all of it from the
terminal. Without a terminal the process-group stop is unchanged.

The new case runs the wrapper on a pseudo-terminal whose stub suite
prints a prompt and reads a line. It passes here. Against the previous
run.sh it hangs and fails, with the transcript showing the prompt and
the typed line that was never read.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never adopt a standalone clone under the lane's root

5f4bd84 made every unrecorded tree under .lane-worktrees/<lane>/ the
lane's. That included standalone clones, which #162's first protocol
line and the manual keep FOREIGN ("a lane creates worktrees, never
clones"). Copilot flagged it on 296e2f0. Clones are now excluded from
both adoption paths. The ownership case adds a clone under the lane
root and requires it to stay foreign and not retire-counted. On Eagle
none of the lane's seven root trees is a clone, so the dry run is
unchanged there.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch the register directly under --yes and refuse when it fails

#170 A1. lanes-edit.sh's log_sync answers 0 on every way it can fail to
fetch, and an inherited LANES_NO_FETCH=1 skips the fetch outright. A lane
rebound on another host after this workstation's last register fetch
therefore read as bound here, and --yes removed its trees (Amendment
18(b): from outside the binding a lane is UNKNOWN, never dead).

Under --yes the sweep now fetches the workspace repository's
origin/<branch> itself, with an explicit refspec, before anything else.
A fetch that fails refuses the run, exit 2, with nothing changed. Every
later helper call reads the ref that fetch left, so an inherited
LANES_NO_FETCH no longer decides. The dry run is unchanged.

The test estate now carries a workspace repository (the fetch's target,
and #170 G6's register) and records its inventory under an earlier
session, as a swept lane's trees are. The new case rebinds the lane on
another host after the last fetch, then runs --yes with origin
unreachable and with LANES_NO_FETCH inherited: both refuse and the tree
stays.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the sweep's fetch refspec so --prune cannot delete local branches

#170 item 6 (Copilot round 3 on #168). With a mirror-style
remote.origin.fetch such as +refs/heads/*:refs/heads/*, the preflight
`git fetch --prune origin` deleted every local branch origin lacks,
before anything was classified or rescued.

The fetch now names its refspec and refmap, as `status --fetch` does:
+refs/heads/*:refs/remotes/origin/*, with --refmap set to the same, so
the configured refspec maps nothing. fetch.pruneTags and the remote's
pruneTags are forced off, so a local tag is never pruned with it.

The case configures the mirror refspec with nothing checked out that
origin has (git refuses to fetch into a checked-out branch, which hides
the prune) and a local-only branch and tag. Both survive --yes.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse when a lane root or a registration list cannot be read

#170 G1 and G5 (Copilot after the cap). Discovery read a lane worktree
root that could not be listed as an absent root, and a failed
`git worktree list` as a repository with no worktrees. With an empty
inventory either one let the dry run exit 0 and clear #163's gate over
trees nobody could see.

Only a root that does not exist is skipped now; any other listing
failure refuses, exit 2. worktree_registrations returns None when both
listings fail. Discovery refuses on it. The prune's after-check, a
branch delete and --branches stop that act. The report records the
checkout as unreadable.

Two cases: a mode-000 lane root with an empty inventory, and a git shim
that fails `worktree list`. Both now exit 2.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk every estate directory for dependents; a partial scan deletes nothing

#170 A11. The estate walk entered a repository only through worktree
containers, so it never saw a plain directory inside a repository (it
found none of xFactory/xFactories/* on Eagle). A clone there that
borrowed a lane clone's objects was unseen, and the clone was deleted
from under it. An empty estate root read no dependents at all, and an
unreadable directory read as one with nothing in it.

walk_estate visits every directory but caches, tool environments and
site-packages. It never follows a symlink or enters a .git, and it
collects .lane-state directories for #170 G6. Whatever it, or the
alternates and config reads, could not read makes the dependents scan
partial. A partial scan, a missing estate root, or a tree outside the
estate root keeps a clone and a scratch directory. Both are removed by
deleting a directory, and a dependent nobody saw would lose its objects.
Worktrees are untouched by this: their objects live in the checkout's
repository, which a worktree removal leaves.

Two cases: a --shared dependent under host/vendor/ makes the clone
load-bearing, and a mode-000 directory in the estate keeps it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read every other lane's control root before granting a tree

#170 G6 (Copilot after the cap). Other lanes' claims were read only
under this lane's control-root parent and the LANES_LANE_STATE_ROOT
override. A lane in a nested estate keeps its .lane-state beside its own
recorded dir, so its claim was missed, the tree was not contested, and
--yes could take it.

The parents read now also include $PROJECTS_ROOT/.lane-state, every
.lane-state the estate walk found, and the parent of every lane's
recorded dir. The recorded dirs come from one `git grep` over the
register's lane logs on origin/<branch>, parsed as lanes-edit.sh's
payload_subfield parses them. A register that cannot be read refuses:
whose claims were missed would be unknown. Lane names compare without
case, as Amendment 15 has them.

The case puts another lane's claim on one of this lane's trees under
<estate>/group/.lane-state, and under a directory outside the estate
that only the register names. Both keep the tree.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep trees whose hidden edits, nested clones, tags or submodule refs would go

Four ways tree_guards let a removal take work no rescue carried:

- #170 A2: a file flagged skip-worktree or assume-unchanged reads clean
  in `git status`, and `git add -A` skips it, so its edit was in no
  rescue. hidden_edits hashes every flagged file present in the tree
  against its index blob, and a difference keeps the tree. A flagged
  file that is absent (a sparse checkout's) is no edit.
- #170 A3: the nested-repository walk passed over venvs and node_modules
  before it looked for a .git, so `pip install -e git+...`'s clone in
  <venv>/src/<pkg> was deleted with the tree. nested_repos skips only
  bytecode caches, and a directory it cannot read keeps the tree.
- #170 item 4: a clone's publication check read branches and the
  stash, not tags. unpublished_ref checks every branch, every tag
  (peeled to its commit) and HEAD against refs/remotes/origin in one
  rev-list. A tag that names no commit cannot be checked, and keeps it.
- #170 item 5: only a submodule's HEAD was checked. A worktree's
  submodules keep their repositories under .git/worktrees/<id>/modules/,
  so their branches, tags and stash went with the tree. Each initialized
  submodule now gets the same check against every remote of it, plus
  its stash. A `git submodule status` that fails keeps the tree; it used
  to skip the submodule checks.

Cases: both flags, a sparse checkout's absent file (still removed), a
clone in .venv/src, a clone's annotated tag on a commit no branch has,
and a submodule's unpublished branch and stash.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Judge an ignored entry by its own name, not an ancestor's

#170 A6. ignored_paths dropped an ignored file when any ancestor was
named like a cache or build output. With --directory an ignored cache or
build directory is listed as itself, so a path beneath a directory named
`build` is an ignored file in a tracked directory. docker/build/prod.env
was deleted unarchived while top.env beside it was archived.

Only the entry's own basename is compared with CACHE_NAMES, VENV_NAMES
and BUILD_NAMES now. An ignored /build/ directory is still build output
and is not archived.

The case puts prod.env in the tracked docker/build/ beside an ignored
/build/ and checks the archive holds prod.env and top.env and nothing
of build/.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave other people's open branches alone: rescue, and keep the remote

Two ways --yes acted on a branch someone else's pull request owns:

- #170 A7: push+remove pushed the lane's unreviewed commit onto a
  teammate's OPEN PR branch, because the decision ignored the merge
  state. Where an open PR names the branch, where origin's tip carries
  another lane's Lane: trailer, or where whether a PR names it could not
  be read (gh unavailable), the commits now go to
  rescue/<lane>/<slice>-<UTC> and origin's branch is left as it is.
- #170 A8: a register LANDED line beat gh's live OPEN for the same
  number, so a LANDED #21 typed for #20 deleted the remote branch of the
  still-open #21 and closed it. The tree and its local branch still go
  (the work is on origin), but the remote branch is never deleted while
  gh answers OPEN for it. The same holds under --branches.

Cases: the open PR, another lane's trailer and LANES_NO_GITHUB each send
the commit to a rescue branch with origin's branch unmoved, and a wrong
LANDED number leaves the open PR's branch on origin.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rescue before a prune, bundle what only a reflog names, self-check each removal

Four #170 items that share one mechanism: before a removal the sweep now
asks git which commits it would take and where each one is kept.

- The loss plan. A worktree's removal takes its git directory: its HEAD
  and that HEAD's reflog, plus the branch and its reflog where the
  branch is deleted after it. A pruned registration takes the same,
  read from its git directory. A clone takes every ref but its
  remote-tracking ones, and every reflog. One rev-list says which of
  those commits no origin ref, surviving branch, tag or stash, push
  seen on origin this run, or bundle head of this run reaches.
- A4: `prune` dropped the only pointer to a detached commit. A
  registration whose git directory names such a commit is now
  rescue+prune: a rescue branch pushed where there is an origin, a
  bundle, then that one registration removed.
- A5: commits only a reflog names (an amend, a reset, checking out away
  from a detached commit) were lost on removal and on delete_local_branch.
  They go to a bundle of their own through temporary
  refs/lane-worktrees/* refs, which are deleted once it is verified.
- Item 8: a bundle that is the only copy of what it holds (bundle+remove
  with no origin, the reflog bundle, the untracked bundle below) gets a
  rescues.tsv row (origin `-`, branch `bundle:<file>`). `--expire` reads
  it as "no other copy" and never expires that archive.
- A9: the WIP rescue pushed every untracked, un-ignored file, a service
  account key included. The pushed WIP commit now carries the tracked
  changes only. A second commit holding the untracked files goes to the
  bundle alone, as the ledger's only copy. --push-untracked pushes them
  as before.

THE SELF-CHECK. Right before each removal (and each prune) the same
question is asked once more. If anything the removal would take is
neither on origin nor in a verified bundle, the removal is refused,
DISPOSITION.md says so, nothing after it is acted on, and the exit is 2.
LANE_WORKTREES_SEAM_NO_LOSS_BUNDLE=1 is the suite's seam: it skips the
reflog bundle so the case can prove the self-check stops the loss.

Cases: a pruned detached commit (rescued, bundled), a reflog-only
commit (bundled, ledger row), the self-check with the seam (exit 2,
both trees left, the refusal in DISPOSITION.md), untracked files with
and without a tracked change (never on origin, in the bundle,
temporary refs gone), --push-untracked, and an origin-less
bundle+remove archive surviving `--expire --yes` at retention 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the push hooks where git-lfs is configured

#170 A10. `git push --no-verify` skips git-lfs's pre-push hook, which is
what uploads LFS objects. A rescued branch therefore reached origin as
pointers only, and the tree, the one copy of the objects, was then
removed.

Where the repository has any filter.lfs.* setting (the global one
included) the push now runs its hooks. A hook that refuses fails the
push and leaves the tree. Without git-lfs, --no-verify stays, so an
unrelated pre-push hook cannot hold a rescue up.

The case installs a pre-push hook that records itself. It runs with
filter.lfs configured and does not run without it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep scratch that holds a tree or a repository

#170 item 7 (Copilot round 3 on #168). A scratch directory was archived
and removed whenever it had no .git of its own, so a worktree or clone
nested beneath it went with the rmtree. A FOREIGN or live tree was not
protected either, and nor was an unpushed commit.

A scratch directory is now kept while any tree of the table lies under
it, or any .git does at any depth (bytecode caches aside). A directory
under it that cannot be read keeps it too. The tree table retires its
own children first, and the scratch goes on a later sweep. The check is
made again at the act and again after the tar.

The case nests a clone with an unpushed commit two levels down in a
scratch directory. --include-scratch --yes keeps the scratch and the
commit.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check --live paths, want the writer count for own trees, stop on NOT WRITTEN

Three gaps in how --yes takes the coordinator's word and the register's:

- #170 B4: a misspelt --live path was accepted in silence, so the
  writer it was meant to protect was not protected. Each --live path
  must now name a tree of the table (it, or a path inside it), and
  `--live none` stands alone. Anything else is usage, exit 64, decided
  before any repository is fetched.
- #170 B5: a tree this session recorded skips the transcript check,
  because the transcript is this one. --live was demanded only when the
  holder read as this session, so with the holder read as none, --yes
  removed trees this session's own writers could be in. Any inventory
  record whose writer is the caller now demands the count too.
- #170 B6: removals went on after a NOTED line failed. The first line
  the register refuses now stops the sweep. Every later tree and item
  is left as the table found it, with the reason, and the exit stays 1.

Cases: a typo'd --live path and `--live none <path>` exit 64 and touch
nothing; a tree recorded by this session refuses without --live and
goes with --live none; with the register refusing, the first tree goes,
the second stays, and exactly one line was attempted.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count --branches by origin's own ref, never by upstream or an open PR alone

Two ways `--branches --dry-run --porcelain` gave #163's gate the wrong
answer (Copilot after the cap on #168):

- G2: every OPEN PR's branch was left out of the retire count, even a
  lane-owned one holding commits origin lacks, so the gate read 0. The
  delete protection stays. Such a branch is now counted unless
  refs/remotes/origin/<branch> holds its tip, and a read that failed
  counts it too.
- G7: a lane-owned branch was judged by its configured upstream alone.
  A branch made with `checkout -b X origin/main` tracks main, so once
  pushed under its own name it still read "ahead" and the gate read 3.
  Publication is now checked in origin's ref for the branch's own name
  first, and a branch origin holds that way is not listed. Unknown
  states are still counted.

Cases: an unpushed lane branch named by an open PR makes the dry run
exit 3 and survives --yes; a branch made from origin/main and pushed
as itself makes it exit 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a tmp.* only on proof, and read hidden processes as unknown

#170 items 1 and 2 (Copilot on #168), with the coordinator's defaults,
which the PR body states for Brett Heap to overrule:

1. --include-sandboxes removed any account-owned tmp.* over an hour old
   with nobody in it, with no proof a suite made it, so a person's
   `mktemp -d` checkout or saved scratch could go. A tmp.* is now
   removed only with a suite's mark whose pid is gone (pytest's .lock,
   tests/run.sh's new .openrepotools-run marker, or run.sh's run-root
   layout of basetemp/ beside tmp/), or when nothing in it has been
   written for aging_days (14). Age is the newest mtime anywhere inside,
   not the top directory's. Any other tmp.* is a `list` row, kept. A
   marker's pid is read again at the act. tests/run.sh writes its pid
   into each run root it makes.
2. The /proc scan read a same-account process whose entries cannot be
   read (not dumpable) as absent, so its tree could be removed under
   it. Such a process is now in ProcScan.unknown and placed by what can
   be read: absolute paths in its command line, and its parent's
   working directory. A tree, scratch directory, cache owner or sandbox
   it is placed in is of unknown liveness and is kept, at the read and
   again at the act. One placed nowhere (ssh-agent re-parented to init:
   three on Eagle today) holds no tree, so a sweep is not frozen for
   ever. One whose status cannot be read at all could be anywhere. A
   process of another account is never a writer here. The human table
   names every unreadable pid in a note. Uid is read from
   /proc/<pid>/status, because a non-dumpable process's /proc entry is
   owned by root.

Cases: four tmp.* directories (unmarked and two hours old: listed and
kept; twenty days old, pytest-marked and run-root-shaped: removed), and
a non-dumpable child standing in a tree, which is kept. The existing
sandbox and report cases now age every file, not only the directory,
and the killed sandbox carries pytest's mark.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #170's fixes change in the sweep

The manual's sweep section and --help now say:

- under --yes the register is fetched first, and a failed fetch refuses;
- each repository's fetch pins its refspec;
- other lanes' claims are read wherever #97 keeps them;
- an unreadable lane root, registration list or register refuses;
- --live must name a tree, and a tree this session recorded wants the
  count;
- the new keep rows (hidden edits, nested clones anywhere, clone tags,
  submodule branches, tags and stash, a partial dependents scan) and
  the rescue+prune row;
- untracked files go to the bundle only, unless --push-untracked;
- rescue rather than push onto another's branch, and no remote delete
  while gh answers OPEN;
- the loss plan, the reflog bundle and the self-check, and the stop on
  NOT WRITTEN;
- the LFS hooks, ignored files judged by their own name, scratch kept
  while trees lie under it;
- tmp.* removed only on proof, the bundle: rows that --expire never
  expires, and the exit contract's new 2 and 64.

The --yes switch and its paragraph go in the next commit, with the
switch itself.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the existing cases to the register fetch, the untracked bundle and B5

Three of #170's fixes change what older cases see. A proof run of the
sweep module on CI (the workstation's pytest lock never came free)
failed these seven at the previous head:

- The table case asserted that the WIP rescue pushed the untracked
  new.txt. It now asserts new.txt is not on origin, and that the
  ledger's bundle: row for the dirty tree holds it (A9).
- The four refusal cases and the unreadable-claim case snapshot the
  estate around a refused --yes. --yes now fetches the register first
  (A1), which writes FETCH_HEAD in the workspace repository and nothing
  else, so the workspace is left out of those snapshots.
- The unreadable-record case wrote its record as this session's
  writer. B5 then refused first, with its own reason, so the record is
  written by the earlier session the fixture uses everywhere else.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the whole estate only where a removal deletes a directory

Measured on Eagle, the whole-estate walk A11 asked for is 683,360
directories: 541,236 of them in xFactory alone, mostly spec trees. It
took 25 s warm and 397 s cold, where the gate's dry run took 15 s at
45fa45d. #163's gate would pay that at every lane-end, for nothing a
worktree's removal can break: a worktree's objects are its checkout's,
and the removal leaves them.

The whole walk now runs only where a removal deletes a directory: a
clone the table could take, or --include-scratch. Otherwise the
estate's shape walk is enough, as before. Other lanes' claims (G6)
follow #97's own rungs for every lane: the override, the parent of
every lane's recorded dir in the register, and $PROJECTS_ROOT. Each is
read beside this lane's own root and beside every checkout the shape
walk finds. So they no longer depend on the whole walk. A lane cannot
claim a tree without a register row.

The G6 case's nested checkout is now a repository, as a lane's is.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse an inventory row that carries no id

#170 G9 (Copilot after the cap, on 87da332). lane_facts skipped a
`lane-trees` row whose id was empty. A tree such a row names outside the
scanned roots was then never discovered, and the dry run could exit 0
and clear #163's gate.

A nonempty row with no id is now an unreadable record. Discovery
refuses it, exit 2, as it refuses one of an unknown schema.

The case gives the inventory one id-less row naming a tree outside both
roots: exit 2, where 45fa45d exits 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail closed when the bytecode check cannot read the pathspec

#170 G13 (Copilot after the cap, on 45fa45d). bytecode_in_pathspec threw
both git reads' stderr away, and its awk exits 0 on no input. A read
that failed therefore printed nothing: a malformed magic pathspec, or an
index git cannot read, makes both `add --dry-run` and
`diff --cached` exit 128. `pathspec-check` then reported the pathspec
clean, and the commit path let it through.

Each git status is kept now, and the function answers 3 when a read
failed. `add --dry-run`'s exit 1 is not a failure: it means a named
path is ignored, which the add would not stage. `pathspec-check`
refuses with exit 2. The commit path refuses with exit 2 before it
stages anything.

The shell suite's #162 block gains four assertions: `pathspec-check
':(bogus)x'` exits 2 and says the read failed, and a commit of that
pathspec exits 2 and commits nothing. At 45fa45d the first exits 0 and
the commit fails later, at the add, with exit 6.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document G9's id-less row and G13's failed read

The manual's inventory paragraph now names a row with no id among the
unreadable records that refuse a sweep. The pathspec-check usage line,
and the comment above the subcommand, now say exit 2 also covers a read
git could not make.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a .git file that is no pointer as no repository, not as unreadable

The read-only dry run over this lane's estate on Eagle kept every
--include-scratch directory as "the dependents scan was partial". The
cause was two empty .git files that uv keeps in its caches to stop git
looking upward. git_common_dir answers '' for those as it does for an
unreadable .git, and dependents_map counted both as unread.

A .git file that can be read and is no gitdir pointer is now skipped
as no repository. Only one that cannot be read leaves the scan partial.

The case puts uv's marker in the estate. A clone taken on a word is
still removed: the scan is whole.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes back on: remove LANE_WORKTREES_ENABLE_YES

5f4bd84 switched --yes and --expire --yes off until #170's data-loss
paths were closed. Every item #170 lists for --yes now has a case that
fails against 45fa45d and passes here: items 1, 2 and 4-8, A1-A11,
B4-B6, G1, G2, G5-G7, G9 and G13. Before every removal the sweep also
asserts, of git itself, that each commit the removal would take is on
origin, in a ref that stays, or in a bundle this run verified. If not,
it refuses with exit 2 and a DISPOSITION.md line.

The switch, its refusal, the --help paragraph and the manual's
blockquote are gone. The suite's estates no longer set the variable.
The switched-off case is replaced by one that runs --yes and
--expire --yes with no variable in the environment.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a clone's removal when a reflog directory under it cannot be listed

#174 Copilot round 1 (lane-worktrees `_loss_plan`): the loss plan of a
clone collected its reflogs with `os.walk`, which passes over a directory
it cannot list in silence. A commit only `logs/refs/heads/<b>` named was
then in no plan: no bundle took it, the self-check asked the same blind
question, and the clone was deleted with it.

`reflog_files` walks `<gitdir>/logs` with an error handler: a `logs`
directory that is not there is an empty answer, anything else it cannot
list makes the loss plan unknown, and the clone is left in place.

The case seals a clone's `logs/refs/heads` (mode 000) with an experiment
only that branch reflog names, and wants the clone and the commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule names an unpublished commit only in its reflog

#174 Copilot round 1 (lane-worktrees `tree_guards`): a submodule's
experiment, made detached and checked out away from, is in no branch, tag
or stash of the submodule - only its reflog names it. Every submodule guard
passed, and the tree's removal took the submodule's repository, which lives
under the tree's own git directory, with the experiment in it.

The submodule's reflogs are read now (`unpublished_reflog`, through
`reflog_files`, so a directory it cannot list is unknown too): any commit
they name that no remote of the submodule holds keeps the tree, and says so.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Archive every ignored entry selected, whatever its ancestors are named

#174 Copilot round 1 (lane-worktrees `_tar`): #170 A6 selects an ignored
entry by its own name, so `docker/node_modules/prod.env` - an ignored file
in a tracked directory - is selected for the archive. The archive's member
filter then dropped any path with a cache- or venv-named part anywhere in
it, `top.env` beside it kept the archive non-empty, it verified, and the
tree was removed without prod.env.

The filter now judges only the parts of a member's path BENEATH the entry
it was added for, and always carries the entry itself; and the archive is
read back for every entry selected, so one missing leaves the tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never remove a cache directory that holds a repository

#174 Copilot round 1 (lane-worktrees `_caches`): a tree kept because a
clone is nested in its venv (`pip install -e git+...` clones into
`<venv>/src/<pkg>`, #170 A3) still gave up its caches, and `.venv` is one.
`--include-caches --yes` removed it, and the clone and its unpushed fix
with it.

A cache candidate that is a repository, holds one anywhere inside, or
holds a directory that cannot be read is now a `keep` row when the table is
built, and asked again right before the removal. The venv case of A3 runs
`--include-caches --yes` too and wants the clone's commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List an unmarked tmp.* that holds a repository, whatever its age

#174 (found auditing that a check guards every removal): `--include-
sandboxes` removed an unmarked `tmp.*` untouched for `aging_days` on age
alone - the second half of #170 item 1's default - so a person's `mktemp -d`
checkout with an unpushed commit in it went. No suite's mark says such a
directory's repositories are fixtures, so one that holds a repository, or
a directory that cannot be read, is listed and never removed; the check is
asked again right before the removal. A marked one is still the suite's,
and its repositories are its fixtures.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bundle a branch's reflog-only commits and self-check before --branches deletes it

#174 Copilot round 1 (lane-worktrees `_act_item`): the loss plan, the
bundle and the self-check guarded a tree's branch delete only. With
`--branches --yes` a merged branch no worktree holds was deleted with
`update-ref -d`, which takes its reflog too - the last pointer to an
experiment committed on it and reset away from.

A branch item now asks what the delete would take (its tip and every
commit its reflog names, against origin, the other branches, tags, the
stash, this run's pushes and bundles), bundles what nothing keeps, with a
`bundle:` ledger row, and asks once more right before the delete. A refusal
there stops the sweep, exit 2, and DISPOSITION.md says so, as it does for a
tree; a refused item is no longer counted in the register's "swept branches"
line. The bundle helpers take a repository and a name instead of a tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold back untracked files by the act's own snapshots, and keep untracked git-lfs content

Two #174 Copilot round 1 findings in the WIP rescue (lane-worktrees
`_wip_rescue`), both about what goes to a bundle only (#170 A9).

Whether untracked files were held back was read from the table's status,
taken minutes earlier. A tree with only a tracked change when it was read,
and an untracked key file by the time of the rescue, had its full
snapshot - key included - pushed to origin. The pushed snapshot is now
always the tracked-only one (unless `--push-untracked`), and what the full
snapshot holds beyond it is what goes to the bundle alone.

An untracked file git-lfs filters is snapshotted as a POINTER: its bytes
go to the local LFS store, which no bundle carries and the removal may
take, so the bundle that was its only copy held no payload. Where git-lfs
is configured, such a tree is now `keep` - in the table, and again at the
act before anything is pushed (a `_Keep` found at the act leaves the tree
as the table would have).

The cases make the file arrive between the table and the act with a `git`
that writes it on the hidden-edit guard's second call.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a clone's annotated tag through its peeled commit, and bundle the tag object only origin lacks

#174 Copilot round 1 (lane-worktrees `lost_tips`, `_loss_plan`): a
clone's loss plan read every ref's raw `%(objectname)`, so an annotated
tag arrived as its tag object. `lost_tips` reported any object that was no
commit as lost whatever kept it - so the tag was bundled, then the
self-check reported it lost again, refused the removal and halted the
sweep, for every clone with an annotated tag.

A tag object is now kept where a SHA in the keep list names it - a
verified bundle's head, or origin's own copy of that very tag object,
read with `ls-remote --tags` (none when origin cannot be asked, so the tag
is bundled) - and its history is its peeled commit's, asked like any other
commit. A SHA in the keep list counts through the commit it peels to.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's first Copilot round changes in the sweep

The manual's table and the command's own help now say that a submodule's
reflog-only commit keeps its tree, that untracked git-lfs content keeps
its tree and untracked files are read from the act's own snapshots, that
a clone's reflog directory that cannot be listed keeps the clone and its
annotated tags are bundled where origin lacks that very tag object, that
the ignored archive carries every entry selected whatever its ancestors
are named, that a `--branches` delete has its own loss plan, bundle and
self-check, and that neither a cache nor an unmarked `tmp.*` sandbox that
holds a repository is ever removed.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose git directory keeps a submodule repository with work no remote of it holds, a deinitialized one's included

#174 Copilot round 2 (lane-worktrees `tree_guards`): the submodule
guards read only what `git submodule status` lists as checked out. A
DEINITIALIZED submodule shows `-`, its working tree is gone, and its
repository - branches, a stash, reflog entries - stays under
`<gitdir>/modules/<name>`, which the tree's removal takes. A branch no
remote held went with the tree.

Every repository kept under the tree's git directory's `modules/`
(nested ones included) is now read: a branch, tag or HEAD no remote of it
holds, a stash (read as its ref, since `git stash list` wants a working
tree) or a reflog entry no remote holds keeps the tree, and so does a
`modules/` directory that cannot be read.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule has a skip-worktree or assume-unchanged edit that git status hides

#174 Copilot round 2 (lane-worktrees `tree_guards`): #170 A2's
hidden-edit guard read the superproject only. A file flagged
skip-worktree or assume-unchanged in a submodule and edited there reads
as clean in that submodule exactly as in the superproject, no rescue
carries it, and it went with the tree. The same `hidden_edits` read now
runs in every checked-out submodule, and flags that cannot be read keep
the tree too.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pass over nothing but .git when proving a removal takes no nested repository

#174 Copilot round 2 (lane-worktrees `nested_repos`): the walk skipped
directories named `__pycache__`, `.pytest_cache`, `.mypy_cache` and
`.ruff_cache`. It is the proof that a removal takes no repository, so a
checkout under a directory with one of those names was missed: a scratch
holding one was archived without it (the archive leaves caches out) and
removed. NESTED_SKIP is gone and every directory but `.git` is entered.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read another lane's claims in every .lane-state the whole-estate walk finds

#174 Copilot round 2 (lane-worktrees `Sweep.gather`): where the whole
estate is walked - a clone the table could take, or `--include-scratch` -
`walk_estate` finds every `.lane-state`, one in a plain directory inside
a repository included (`host/vendor/.lane-state`), but the sweep threw
that list away and read claims only beside the shape walk's checkouts and
the register's recorded directories. With no log naming that place, a
clone another lane claimed from there was removed. The walk's control
roots are now read for claims as well, before liveness and the table.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never push a lane's commits onto a branch gh still answers OPEN, whatever a LANDED line says

#174 Copilot round 2 (lane-worktrees `MergeEvidence.judge`, `_decide`):
a register LANDED line naming a pull request gh still answered OPEN, with
the local tip ahead of the PR's head, made the verdict "moved". That
verdict dropped gh's OPEN answer, so #170 A7's rule never fired and
push+remove put the lane's unreviewed commits onto the open pull
request's branch. The moved and merged-elsewhere verdicts now carry gh's
OPEN numbers, and a branch gh answers OPEN goes to a rescue branch, with
origin's branch left as it is.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never prune a gone worktree's registration whose git directory keeps a submodule repository with work

#174 Copilot round 2 (lane-worktrees `_decide`, `_act_prune`): a
registration whose directory is gone still keeps its submodules'
repositories under `<admin>/modules/`, and the prune - which read only
the superproject's HEAD and reflog - removed them, a branch no remote held
with them. The same submodule-repository read as the tree guard now keeps
such a registration (`keep`), and is asked again right before the prune.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a clone with a local git-lfs store when its commits would go to a bundle only

#174 Copilot round 2 (lane-worktrees `_act_tree`): a clean clone with a
commit only its reflog named had that commit bundled and was removed with
`.git/lfs/objects`. A bundle carries LFS pointers, never their bytes, and
no push uploaded them, so the bytes behind those pointers were lost. A
CLONE whose local LFS store holds anything (or cannot be read) is now kept
wherever a bundle would be the only copy of a commit - the `bundle+remove`
disposition and the reflog-only commits of step 2b alike.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count a suite's .lock or run.sh marker as provenance only when it names a pid

#174 Copilot round 2 (lane-worktrees `sandbox_provenance`): an EMPTY or
malformed `.lock` (or `.openrepotools-run`) counted as a suite's mark,
so a person's two-hour-old `tmp.*` with a checkout in it was removed -
past coordinator default 1's 14-day rule and the repository guard both. A
mark now proves a suite made the directory only when it names a pid; the
run-root layout proof (`basetemp/` beside `tmp/`) is unchanged.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's second Copilot round changes in the sweep

The manual's table and the command's own help now say that every
submodule repository a tree's or a gone registration's git directory keeps
- a deinitialized one's included - keeps it where it holds work no remote
of it holds, that a submodule's hidden edits keep the tree, that a
repository under a cache-named directory is still found, that the
whole-estate walk's `.lane-state` finds are read for claims, that gh's
OPEN wins over a LANDED line, that a clone's local git-lfs store keeps it
where a bundle would be a commit's only copy, and that a suite's mark must
name a pid.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a submodule repository kept under a git directory as that git directory alone, so a gone worktree's is read rather than refused

A submodule repository under `<gitdir>/modules/` names its working tree in
`core.worktree`. Where that directory is gone - every registration whose
worktree directory went, and a submodule removed after its deinit - every
git command run in the repository dies on "cannot chdir", so the guards
of this round's first and sixth commits read every such repository as
unreadable: a gone worktree with any submodule was kept for that reason
alone, never read. The proof run on the round's head showed it (the
prune's reason said the branches "could not be read" where the test
expected the unpublished branch named).

The submodule-repository reads now run each git command there with the
repository's own `objects/` as its working tree (`GIT_WORK_TREE`, through
`git_env(path)`); they are reads that need none - refs, reflogs,
rev-list, cat-file - and a branch no remote holds is named again.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree or registration whose submodule repository has an annotated tag object no remote of it holds

#174 Copilot round 2 (lane-worktrees `module_repos_why`, submodule
guards): `unpublished_ref` asks whether an annotated tag's COMMIT is
published, never its tag object, so a submodule's local annotated tag on
a published commit passed every guard, and its message, which nothing
else held, went with the tree. A clone's own tags have been bundled since
round 1, but a submodule's are in no loss plan.

At the act, before anything is rescued, every submodule repository the
tree's (or a gone registration's) git directory keeps is asked: each
annotated tag object must be answered by some remote of it in
`ls-remote --tags` as that very object, else the tree is kept, and so it
is where no remote could be asked. It asks remotes, so the dry run, which
asks none, still reads `remove`.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read the gone worktree's kept submodule repository in its regression the way the sweep reads it

The proof run on the round's head passed every assertion of
`test_a_gone_worktrees_kept_submodule_repository_is_never_pruned` about
the sweep - the registration kept, the reason naming the unpublished
branch - and then failed its own last c…
brettheap added a commit that referenced this pull request Oct 7, 2026
…s honesty, portability and test items (#170, part 2) (#179)

* Add lane-worktrees: the sweep that retires a lane's abandoned worktrees

A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the sweep against every row of #162's disposition table

tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Place lane-worktrees as the seventeenth installed file

`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the sweep in the lanes manual

"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the estate for --links with scandir

`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a test run's bytecode, cache and temp out of the worktree

A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the repository's virtual environment on --install

One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Seed the workspace .gitignore with bytecode and cache rules

Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a workspace commit whose pathspec carries bytecode

The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the estate report once a day from lane-start

The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report the estate's leftovers with sweep --all --dry-run --report

Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the estate report and prevention at creation

The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 1's defects in the sweep

Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document round 1's rules in the lanes manual

The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what expiry_rows keeps now that #168 checks whole archives

The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the suite's whole process group when a run ends

Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer and seed site-packages/ with the other bytecode rules

Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report duplicate clones with no named checkout, over one estate

Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 2's defects in the sweep

Seven of round 2's eight. Each one let an act proceed on an unknown:

- Another lane's inventory record or directory that cannot be read, or
  that names no path, now refuses the sweep. Who else claims a tree is
  read before the fetch, so the refusal writes nothing.
- A submodule's ignored files (an .env) keep the tree. The tree's
  ignored archive covers the superproject only.
- Ignored files are printed before the archive and listed again at
  removal. A change, an addition or a failed listing leaves the tree.
- A prune candidate whose directory reappeared is left for the next
  sweep to classify.
- A pytest sandbox's .lock is read again at the act, and a live pid
  leaves it.
- `tracked()` returns None when a repository's index cannot be read,
  and such a cache is left.
- A manifest entry whose file is gone makes the archive not whole.

The eighth, unreadable /proc entries of same-account processes, is a
design trade and is filed in #170. On Eagle, 9 such processes exist at
any time (6 `timeout`, 3 `ssh-agent`), so reading them as "unknown"
would keep every tree on every sweep.

Four new or extended cases. Each fails against ae17742 and passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse bytecode that is already staged, not only what add would stage

Copilot round 2 on #169: `git add --dry-run` says nothing about a path
the index already holds at those bytes, yet the path-limited commit
still takes it. bytecode_in_pathspec now also reads `git diff --cached
--name-only --diff-filter=d` for the same pathspec. A staged deletion of
bytecode is a cleanup and is let through. The shell case force-stages a
.pyc after the ignore lines are in place: pathspec-check and the commit
path both refuse it, and nothing is committed.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quote the venv paths in the command --install prints

Copilot round 2 on #169: the command is meant to be pasted into a
shell, and $XDG_CACHE_HOME is the person's to choose, spaces included.
Both paths are now `printf %q`-quoted. The new case installs with a
cache directory containing a space, and shlex reads each path back as
one word.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report what the report could not read

Copilot round 2 on #169: an unreadable lane snapshot or inventory
record became an empty one, so a lane or tree dropped out of every
section and the report could look clean. Reads now go through _ls and
_record. A failure other than "not there", a tree record naming no
path, or an inventory tree whose git status fails is a row in a new
section, "Records the report could not read". The unused _sidecar
reader is gone.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep AGENTS.md and README.md within their line caps

test_repo_hygiene caps AGENTS.md at 316 lines and README.md at 486, and
both files were exactly at their caps on main. The run.sh paragraph had
taken AGENTS.md to 330 and the venv sentence README.md to 491. The full
text already lives in docs/README-lanes.md, so AGENTS.md now says the
same thing in the three lines the old sentence took, and README.md says
it on the line it extends. No cap is raised.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the flock file only where there is flock

Copilot on #169: macOS has no flock, so the wrapper takes the mkdir lock
and never creates openrepotools-pytest.lock. The relocation case checked
for that file unconditionally and would have failed the macOS job, which
is the landing gate. Where there is no flock, the case now checks
instead that the mkdir lock was released.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes off until #170; keep the gate and exit contract honest

The adversarial review of 3c0b188, reproduced with its probes against
the suite's own Estate fixture:

1. --yes and --expire --yes are refused, exit 2, changing nothing,
   unless LANE_WORKTREES_ENABLE_YES=1 is set. #170 lists data-loss paths
   that are still open. The suite's Estate sets the variable; a real
   estate should not until #170 lands. The dry run, --porcelain and
   --report are untouched.
2. B1: a tree that #97's inventory does not name is still the lane's if
   it stands under .lane-worktrees/<lane>/, or if it sits in
   <checkout>/.claude/worktrees on a branch whose own commits carry this
   lane's Lane: trailer. Another lane's claim still wins. Such trees keep
   the quiet-hours liveness rule. A lane whose state is NONE but which
   has trees of its own is refused, exit 2. On Eagle, `sweep
   openRepoTools-3 --dry-run --porcelain` gave exit 0, `summary 0 15 0
   15`. It now gives exit 2, `summary 9 13 4 4`: 9 trees are the lane's,
   and the snapshot is NONE.
3. B2: subprocess output and git metadata files are decoded with
   surrogateescape, and stdout and stderr write with it too, so a
   Latin-1 worktree name is reported rather than raising. An unreadable
   pytest-of-$USER is a kept sandbox row. A catch-all keeps errors that
   no read caught inside the contract: exit 2 on a dry run, and under
   --yes DISPOSITION.md is written first and the exit is 1.
4. B3: every porcelain field escapes backslash, TAB, newline and CR.
   Worktree registrations are read with `git worktree list -z` where git
   has it, so a newline in a path no longer becomes a phantom
   registration.

Five new cases (switch off, ownership plus NONE, escaping, non-UTF-8
path, unreadable sandbox root), each failing against 3c0b188. Two cases
were moved off the lane root to keep testing FOREIGN trees. 40 cases.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the --yes switch, lane-root ownership and porcelain escaping

The manual now leads the sweep section with the --yes switch and #170.
It also says which unrecorded trees are a lane's and that a NONE snapshot
with trees is refused. The exit contract now covers the escaped fields
and the catch-all.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the suite in the foreground when stdin is a terminal

Adversarial review B7: `set -m` puts the suite in a background process
group with the terminal as its stdin. Anything that reads the terminal
(--pdb, breakpoint(), input()) is then stopped by SIGTTIN, and the
wrapper waits on it for ever while holding the workstation flock. With
a terminal on stdin the suite now runs in the foreground, in the
terminal's own process group, and a Ctrl-C reaches all of it from the
terminal. Without a terminal the process-group stop is unchanged.

The new case runs the wrapper on a pseudo-terminal whose stub suite
prints a prompt and reads a line. It passes here. Against the previous
run.sh it hangs and fails, with the transcript showing the prompt and
the typed line that was never read.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never adopt a standalone clone under the lane's root

5f4bd84 made every unrecorded tree under .lane-worktrees/<lane>/ the
lane's. That included standalone clones, which #162's first protocol
line and the manual keep FOREIGN ("a lane creates worktrees, never
clones"). Copilot flagged it on 296e2f0. Clones are now excluded from
both adoption paths. The ownership case adds a clone under the lane
root and requires it to stay foreign and not retire-counted. On Eagle
none of the lane's seven root trees is a clone, so the dry run is
unchanged there.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch the register directly under --yes and refuse when it fails

#170 A1. lanes-edit.sh's log_sync answers 0 on every way it can fail to
fetch, and an inherited LANES_NO_FETCH=1 skips the fetch outright. A lane
rebound on another host after this workstation's last register fetch
therefore read as bound here, and --yes removed its trees (Amendment
18(b): from outside the binding a lane is UNKNOWN, never dead).

Under --yes the sweep now fetches the workspace repository's
origin/<branch> itself, with an explicit refspec, before anything else.
A fetch that fails refuses the run, exit 2, with nothing changed. Every
later helper call reads the ref that fetch left, so an inherited
LANES_NO_FETCH no longer decides. The dry run is unchanged.

The test estate now carries a workspace repository (the fetch's target,
and #170 G6's register) and records its inventory under an earlier
session, as a swept lane's trees are. The new case rebinds the lane on
another host after the last fetch, then runs --yes with origin
unreachable and with LANES_NO_FETCH inherited: both refuse and the tree
stays.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the sweep's fetch refspec so --prune cannot delete local branches

#170 item 6 (Copilot round 3 on #168). With a mirror-style
remote.origin.fetch such as +refs/heads/*:refs/heads/*, the preflight
`git fetch --prune origin` deleted every local branch origin lacks,
before anything was classified or rescued.

The fetch now names its refspec and refmap, as `status --fetch` does:
+refs/heads/*:refs/remotes/origin/*, with --refmap set to the same, so
the configured refspec maps nothing. fetch.pruneTags and the remote's
pruneTags are forced off, so a local tag is never pruned with it.

The case configures the mirror refspec with nothing checked out that
origin has (git refuses to fetch into a checked-out branch, which hides
the prune) and a local-only branch and tag. Both survive --yes.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse when a lane root or a registration list cannot be read

#170 G1 and G5 (Copilot after the cap). Discovery read a lane worktree
root that could not be listed as an absent root, and a failed
`git worktree list` as a repository with no worktrees. With an empty
inventory either one let the dry run exit 0 and clear #163's gate over
trees nobody could see.

Only a root that does not exist is skipped now; any other listing
failure refuses, exit 2. worktree_registrations returns None when both
listings fail. Discovery refuses on it. The prune's after-check, a
branch delete and --branches stop that act. The report records the
checkout as unreadable.

Two cases: a mode-000 lane root with an empty inventory, and a git shim
that fails `worktree list`. Both now exit 2.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk every estate directory for dependents; a partial scan deletes nothing

#170 A11. The estate walk entered a repository only through worktree
containers, so it never saw a plain directory inside a repository (it
found none of xFactory/xFactories/* on Eagle). A clone there that
borrowed a lane clone's objects was unseen, and the clone was deleted
from under it. An empty estate root read no dependents at all, and an
unreadable directory read as one with nothing in it.

walk_estate visits every directory but caches, tool environments and
site-packages. It never follows a symlink or enters a .git, and it
collects .lane-state directories for #170 G6. Whatever it, or the
alternates and config reads, could not read makes the dependents scan
partial. A partial scan, a missing estate root, or a tree outside the
estate root keeps a clone and a scratch directory. Both are removed by
deleting a directory, and a dependent nobody saw would lose its objects.
Worktrees are untouched by this: their objects live in the checkout's
repository, which a worktree removal leaves.

Two cases: a --shared dependent under host/vendor/ makes the clone
load-bearing, and a mode-000 directory in the estate keeps it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read every other lane's control root before granting a tree

#170 G6 (Copilot after the cap). Other lanes' claims were read only
under this lane's control-root parent and the LANES_LANE_STATE_ROOT
override. A lane in a nested estate keeps its .lane-state beside its own
recorded dir, so its claim was missed, the tree was not contested, and
--yes could take it.

The parents read now also include $PROJECTS_ROOT/.lane-state, every
.lane-state the estate walk found, and the parent of every lane's
recorded dir. The recorded dirs come from one `git grep` over the
register's lane logs on origin/<branch>, parsed as lanes-edit.sh's
payload_subfield parses them. A register that cannot be read refuses:
whose claims were missed would be unknown. Lane names compare without
case, as Amendment 15 has them.

The case puts another lane's claim on one of this lane's trees under
<estate>/group/.lane-state, and under a directory outside the estate
that only the register names. Both keep the tree.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep trees whose hidden edits, nested clones, tags or submodule refs would go

Four ways tree_guards let a removal take work no rescue carried:

- #170 A2: a file flagged skip-worktree or assume-unchanged reads clean
  in `git status`, and `git add -A` skips it, so its edit was in no
  rescue. hidden_edits hashes every flagged file present in the tree
  against its index blob, and a difference keeps the tree. A flagged
  file that is absent (a sparse checkout's) is no edit.
- #170 A3: the nested-repository walk passed over venvs and node_modules
  before it looked for a .git, so `pip install -e git+...`'s clone in
  <venv>/src/<pkg> was deleted with the tree. nested_repos skips only
  bytecode caches, and a directory it cannot read keeps the tree.
- #170 item 4: a clone's publication check read branches and the
  stash, not tags. unpublished_ref checks every branch, every tag
  (peeled to its commit) and HEAD against refs/remotes/origin in one
  rev-list. A tag that names no commit cannot be checked, and keeps it.
- #170 item 5: only a submodule's HEAD was checked. A worktree's
  submodules keep their repositories under .git/worktrees/<id>/modules/,
  so their branches, tags and stash went with the tree. Each initialized
  submodule now gets the same check against every remote of it, plus
  its stash. A `git submodule status` that fails keeps the tree; it used
  to skip the submodule checks.

Cases: both flags, a sparse checkout's absent file (still removed), a
clone in .venv/src, a clone's annotated tag on a commit no branch has,
and a submodule's unpublished branch and stash.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Judge an ignored entry by its own name, not an ancestor's

#170 A6. ignored_paths dropped an ignored file when any ancestor was
named like a cache or build output. With --directory an ignored cache or
build directory is listed as itself, so a path beneath a directory named
`build` is an ignored file in a tracked directory. docker/build/prod.env
was deleted unarchived while top.env beside it was archived.

Only the entry's own basename is compared with CACHE_NAMES, VENV_NAMES
and BUILD_NAMES now. An ignored /build/ directory is still build output
and is not archived.

The case puts prod.env in the tracked docker/build/ beside an ignored
/build/ and checks the archive holds prod.env and top.env and nothing
of build/.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave other people's open branches alone: rescue, and keep the remote

Two ways --yes acted on a branch someone else's pull request owns:

- #170 A7: push+remove pushed the lane's unreviewed commit onto a
  teammate's OPEN PR branch, because the decision ignored the merge
  state. Where an open PR names the branch, where origin's tip carries
  another lane's Lane: trailer, or where whether a PR names it could not
  be read (gh unavailable), the commits now go to
  rescue/<lane>/<slice>-<UTC> and origin's branch is left as it is.
- #170 A8: a register LANDED line beat gh's live OPEN for the same
  number, so a LANDED #21 typed for #20 deleted the remote branch of the
  still-open #21 and closed it. The tree and its local branch still go
  (the work is on origin), but the remote branch is never deleted while
  gh answers OPEN for it. The same holds under --branches.

Cases: the open PR, another lane's trailer and LANES_NO_GITHUB each send
the commit to a rescue branch with origin's branch unmoved, and a wrong
LANDED number leaves the open PR's branch on origin.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rescue before a prune, bundle what only a reflog names, self-check each removal

Four #170 items that share one mechanism: before a removal the sweep now
asks git which commits it would take and where each one is kept.

- The loss plan. A worktree's removal takes its git directory: its HEAD
  and that HEAD's reflog, plus the branch and its reflog where the
  branch is deleted after it. A pruned registration takes the same,
  read from its git directory. A clone takes every ref but its
  remote-tracking ones, and every reflog. One rev-list says which of
  those commits no origin ref, surviving branch, tag or stash, push
  seen on origin this run, or bundle head of this run reaches.
- A4: `prune` dropped the only pointer to a detached commit. A
  registration whose git directory names such a commit is now
  rescue+prune: a rescue branch pushed where there is an origin, a
  bundle, then that one registration removed.
- A5: commits only a reflog names (an amend, a reset, checking out away
  from a detached commit) were lost on removal and on delete_local_branch.
  They go to a bundle of their own through temporary
  refs/lane-worktrees/* refs, which are deleted once it is verified.
- Item 8: a bundle that is the only copy of what it holds (bundle+remove
  with no origin, the reflog bundle, the untracked bundle below) gets a
  rescues.tsv row (origin `-`, branch `bundle:<file>`). `--expire` reads
  it as "no other copy" and never expires that archive.
- A9: the WIP rescue pushed every untracked, un-ignored file, a service
  account key included. The pushed WIP commit now carries the tracked
  changes only. A second commit holding the untracked files goes to the
  bundle alone, as the ledger's only copy. --push-untracked pushes them
  as before.

THE SELF-CHECK. Right before each removal (and each prune) the same
question is asked once more. If anything the removal would take is
neither on origin nor in a verified bundle, the removal is refused,
DISPOSITION.md says so, nothing after it is acted on, and the exit is 2.
LANE_WORKTREES_SEAM_NO_LOSS_BUNDLE=1 is the suite's seam: it skips the
reflog bundle so the case can prove the self-check stops the loss.

Cases: a pruned detached commit (rescued, bundled), a reflog-only
commit (bundled, ledger row), the self-check with the seam (exit 2,
both trees left, the refusal in DISPOSITION.md), untracked files with
and without a tracked change (never on origin, in the bundle,
temporary refs gone), --push-untracked, and an origin-less
bundle+remove archive surviving `--expire --yes` at retention 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the push hooks where git-lfs is configured

#170 A10. `git push --no-verify` skips git-lfs's pre-push hook, which is
what uploads LFS objects. A rescued branch therefore reached origin as
pointers only, and the tree, the one copy of the objects, was then
removed.

Where the repository has any filter.lfs.* setting (the global one
included) the push now runs its hooks. A hook that refuses fails the
push and leaves the tree. Without git-lfs, --no-verify stays, so an
unrelated pre-push hook cannot hold a rescue up.

The case installs a pre-push hook that records itself. It runs with
filter.lfs configured and does not run without it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep scratch that holds a tree or a repository

#170 item 7 (Copilot round 3 on #168). A scratch directory was archived
and removed whenever it had no .git of its own, so a worktree or clone
nested beneath it went with the rmtree. A FOREIGN or live tree was not
protected either, and nor was an unpushed commit.

A scratch directory is now kept while any tree of the table lies under
it, or any .git does at any depth (bytecode caches aside). A directory
under it that cannot be read keeps it too. The tree table retires its
own children first, and the scratch goes on a later sweep. The check is
made again at the act and again after the tar.

The case nests a clone with an unpushed commit two levels down in a
scratch directory. --include-scratch --yes keeps the scratch and the
commit.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check --live paths, want the writer count for own trees, stop on NOT WRITTEN

Three gaps in how --yes takes the coordinator's word and the register's:

- #170 B4: a misspelt --live path was accepted in silence, so the
  writer it was meant to protect was not protected. Each --live path
  must now name a tree of the table (it, or a path inside it), and
  `--live none` stands alone. Anything else is usage, exit 64, decided
  before any repository is fetched.
- #170 B5: a tree this session recorded skips the transcript check,
  because the transcript is this one. --live was demanded only when the
  holder read as this session, so with the holder read as none, --yes
  removed trees this session's own writers could be in. Any inventory
  record whose writer is the caller now demands the count too.
- #170 B6: removals went on after a NOTED line failed. The first line
  the register refuses now stops the sweep. Every later tree and item
  is left as the table found it, with the reason, and the exit stays 1.

Cases: a typo'd --live path and `--live none <path>` exit 64 and touch
nothing; a tree recorded by this session refuses without --live and
goes with --live none; with the register refusing, the first tree goes,
the second stays, and exactly one line was attempted.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count --branches by origin's own ref, never by upstream or an open PR alone

Two ways `--branches --dry-run --porcelain` gave #163's gate the wrong
answer (Copilot after the cap on #168):

- G2: every OPEN PR's branch was left out of the retire count, even a
  lane-owned one holding commits origin lacks, so the gate read 0. The
  delete protection stays. Such a branch is now counted unless
  refs/remotes/origin/<branch> holds its tip, and a read that failed
  counts it too.
- G7: a lane-owned branch was judged by its configured upstream alone.
  A branch made with `checkout -b X origin/main` tracks main, so once
  pushed under its own name it still read "ahead" and the gate read 3.
  Publication is now checked in origin's ref for the branch's own name
  first, and a branch origin holds that way is not listed. Unknown
  states are still counted.

Cases: an unpushed lane branch named by an open PR makes the dry run
exit 3 and survives --yes; a branch made from origin/main and pushed
as itself makes it exit 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a tmp.* only on proof, and read hidden processes as unknown

#170 items 1 and 2 (Copilot on #168), with the coordinator's defaults,
which the PR body states for Brett Heap to overrule:

1. --include-sandboxes removed any account-owned tmp.* over an hour old
   with nobody in it, with no proof a suite made it, so a person's
   `mktemp -d` checkout or saved scratch could go. A tmp.* is now
   removed only with a suite's mark whose pid is gone (pytest's .lock,
   tests/run.sh's new .openrepotools-run marker, or run.sh's run-root
   layout of basetemp/ beside tmp/), or when nothing in it has been
   written for aging_days (14). Age is the newest mtime anywhere inside,
   not the top directory's. Any other tmp.* is a `list` row, kept. A
   marker's pid is read again at the act. tests/run.sh writes its pid
   into each run root it makes.
2. The /proc scan read a same-account process whose entries cannot be
   read (not dumpable) as absent, so its tree could be removed under
   it. Such a process is now in ProcScan.unknown and placed by what can
   be read: absolute paths in its command line, and its parent's
   working directory. A tree, scratch directory, cache owner or sandbox
   it is placed in is of unknown liveness and is kept, at the read and
   again at the act. One placed nowhere (ssh-agent re-parented to init:
   three on Eagle today) holds no tree, so a sweep is not frozen for
   ever. One whose status cannot be read at all could be anywhere. A
   process of another account is never a writer here. The human table
   names every unreadable pid in a note. Uid is read from
   /proc/<pid>/status, because a non-dumpable process's /proc entry is
   owned by root.

Cases: four tmp.* directories (unmarked and two hours old: listed and
kept; twenty days old, pytest-marked and run-root-shaped: removed), and
a non-dumpable child standing in a tree, which is kept. The existing
sandbox and report cases now age every file, not only the directory,
and the killed sandbox carries pytest's mark.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #170's fixes change in the sweep

The manual's sweep section and --help now say:

- under --yes the register is fetched first, and a failed fetch refuses;
- each repository's fetch pins its refspec;
- other lanes' claims are read wherever #97 keeps them;
- an unreadable lane root, registration list or register refuses;
- --live must name a tree, and a tree this session recorded wants the
  count;
- the new keep rows (hidden edits, nested clones anywhere, clone tags,
  submodule branches, tags and stash, a partial dependents scan) and
  the rescue+prune row;
- untracked files go to the bundle only, unless --push-untracked;
- rescue rather than push onto another's branch, and no remote delete
  while gh answers OPEN;
- the loss plan, the reflog bundle and the self-check, and the stop on
  NOT WRITTEN;
- the LFS hooks, ignored files judged by their own name, scratch kept
  while trees lie under it;
- tmp.* removed only on proof, the bundle: rows that --expire never
  expires, and the exit contract's new 2 and 64.

The --yes switch and its paragraph go in the next commit, with the
switch itself.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the existing cases to the register fetch, the untracked bundle and B5

Three of #170's fixes change what older cases see. A proof run of the
sweep module on CI (the workstation's pytest lock never came free)
failed these seven at the previous head:

- The table case asserted that the WIP rescue pushed the untracked
  new.txt. It now asserts new.txt is not on origin, and that the
  ledger's bundle: row for the dirty tree holds it (A9).
- The four refusal cases and the unreadable-claim case snapshot the
  estate around a refused --yes. --yes now fetches the register first
  (A1), which writes FETCH_HEAD in the workspace repository and nothing
  else, so the workspace is left out of those snapshots.
- The unreadable-record case wrote its record as this session's
  writer. B5 then refused first, with its own reason, so the record is
  written by the earlier session the fixture uses everywhere else.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the whole estate only where a removal deletes a directory

Measured on Eagle, the whole-estate walk A11 asked for is 683,360
directories: 541,236 of them in xFactory alone, mostly spec trees. It
took 25 s warm and 397 s cold, where the gate's dry run took 15 s at
45fa45d. #163's gate would pay that at every lane-end, for nothing a
worktree's removal can break: a worktree's objects are its checkout's,
and the removal leaves them.

The whole walk now runs only where a removal deletes a directory: a
clone the table could take, or --include-scratch. Otherwise the
estate's shape walk is enough, as before. Other lanes' claims (G6)
follow #97's own rungs for every lane: the override, the parent of
every lane's recorded dir in the register, and $PROJECTS_ROOT. Each is
read beside this lane's own root and beside every checkout the shape
walk finds. So they no longer depend on the whole walk. A lane cannot
claim a tree without a register row.

The G6 case's nested checkout is now a repository, as a lane's is.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse an inventory row that carries no id

#170 G9 (Copilot after the cap, on 87da332). lane_facts skipped a
`lane-trees` row whose id was empty. A tree such a row names outside the
scanned roots was then never discovered, and the dry run could exit 0
and clear #163's gate.

A nonempty row with no id is now an unreadable record. Discovery
refuses it, exit 2, as it refuses one of an unknown schema.

The case gives the inventory one id-less row naming a tree outside both
roots: exit 2, where 45fa45d exits 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail closed when the bytecode check cannot read the pathspec

#170 G13 (Copilot after the cap, on 45fa45d). bytecode_in_pathspec threw
both git reads' stderr away, and its awk exits 0 on no input. A read
that failed therefore printed nothing: a malformed magic pathspec, or an
index git cannot read, makes both `add --dry-run` and
`diff --cached` exit 128. `pathspec-check` then reported the pathspec
clean, and the commit path let it through.

Each git status is kept now, and the function answers 3 when a read
failed. `add --dry-run`'s exit 1 is not a failure: it means a named
path is ignored, which the add would not stage. `pathspec-check`
refuses with exit 2. The commit path refuses with exit 2 before it
stages anything.

The shell suite's #162 block gains four assertions: `pathspec-check
':(bogus)x'` exits 2 and says the read failed, and a commit of that
pathspec exits 2 and commits nothing. At 45fa45d the first exits 0 and
the commit fails later, at the add, with exit 6.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document G9's id-less row and G13's failed read

The manual's inventory paragraph now names a row with no id among the
unreadable records that refuse a sweep. The pathspec-check usage line,
and the comment above the subcommand, now say exit 2 also covers a read
git could not make.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a .git file that is no pointer as no repository, not as unreadable

The read-only dry run over this lane's estate on Eagle kept every
--include-scratch directory as "the dependents scan was partial". The
cause was two empty .git files that uv keeps in its caches to stop git
looking upward. git_common_dir answers '' for those as it does for an
unreadable .git, and dependents_map counted both as unread.

A .git file that can be read and is no gitdir pointer is now skipped
as no repository. Only one that cannot be read leaves the scan partial.

The case puts uv's marker in the estate. A clone taken on a word is
still removed: the scan is whole.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes back on: remove LANE_WORKTREES_ENABLE_YES

5f4bd84 switched --yes and --expire --yes off until #170's data-loss
paths were closed. Every item #170 lists for --yes now has a case that
fails against 45fa45d and passes here: items 1, 2 and 4-8, A1-A11,
B4-B6, G1, G2, G5-G7, G9 and G13. Before every removal the sweep also
asserts, of git itself, that each commit the removal would take is on
origin, in a ref that stays, or in a bundle this run verified. If not,
it refuses with exit 2 and a DISPOSITION.md line.

The switch, its refusal, the --help paragraph and the manual's
blockquote are gone. The suite's estates no longer set the variable.
The switched-off case is replaced by one that runs --yes and
--expire --yes with no variable in the environment.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a clone's removal when a reflog directory under it cannot be listed

#174 Copilot round 1 (lane-worktrees `_loss_plan`): the loss plan of a
clone collected its reflogs with `os.walk`, which passes over a directory
it cannot list in silence. A commit only `logs/refs/heads/<b>` named was
then in no plan: no bundle took it, the self-check asked the same blind
question, and the clone was deleted with it.

`reflog_files` walks `<gitdir>/logs` with an error handler: a `logs`
directory that is not there is an empty answer, anything else it cannot
list makes the loss plan unknown, and the clone is left in place.

The case seals a clone's `logs/refs/heads` (mode 000) with an experiment
only that branch reflog names, and wants the clone and the commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule names an unpublished commit only in its reflog

#174 Copilot round 1 (lane-worktrees `tree_guards`): a submodule's
experiment, made detached and checked out away from, is in no branch, tag
or stash of the submodule - only its reflog names it. Every submodule guard
passed, and the tree's removal took the submodule's repository, which lives
under the tree's own git directory, with the experiment in it.

The submodule's reflogs are read now (`unpublished_reflog`, through
`reflog_files`, so a directory it cannot list is unknown too): any commit
they name that no remote of the submodule holds keeps the tree, and says so.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Archive every ignored entry selected, whatever its ancestors are named

#174 Copilot round 1 (lane-worktrees `_tar`): #170 A6 selects an ignored
entry by its own name, so `docker/node_modules/prod.env` - an ignored file
in a tracked directory - is selected for the archive. The archive's member
filter then dropped any path with a cache- or venv-named part anywhere in
it, `top.env` beside it kept the archive non-empty, it verified, and the
tree was removed without prod.env.

The filter now judges only the parts of a member's path BENEATH the entry
it was added for, and always carries the entry itself; and the archive is
read back for every entry selected, so one missing leaves the tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never remove a cache directory that holds a repository

#174 Copilot round 1 (lane-worktrees `_caches`): a tree kept because a
clone is nested in its venv (`pip install -e git+...` clones into
`<venv>/src/<pkg>`, #170 A3) still gave up its caches, and `.venv` is one.
`--include-caches --yes` removed it, and the clone and its unpushed fix
with it.

A cache candidate that is a repository, holds one anywhere inside, or
holds a directory that cannot be read is now a `keep` row when the table is
built, and asked again right before the removal. The venv case of A3 runs
`--include-caches --yes` too and wants the clone's commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List an unmarked tmp.* that holds a repository, whatever its age

#174 (found auditing that a check guards every removal): `--include-
sandboxes` removed an unmarked `tmp.*` untouched for `aging_days` on age
alone - the second half of #170 item 1's default - so a person's `mktemp -d`
checkout with an unpushed commit in it went. No suite's mark says such a
directory's repositories are fixtures, so one that holds a repository, or
a directory that cannot be read, is listed and never removed; the check is
asked again right before the removal. A marked one is still the suite's,
and its repositories are its fixtures.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bundle a branch's reflog-only commits and self-check before --branches deletes it

#174 Copilot round 1 (lane-worktrees `_act_item`): the loss plan, the
bundle and the self-check guarded a tree's branch delete only. With
`--branches --yes` a merged branch no worktree holds was deleted with
`update-ref -d`, which takes its reflog too - the last pointer to an
experiment committed on it and reset away from.

A branch item now asks what the delete would take (its tip and every
commit its reflog names, against origin, the other branches, tags, the
stash, this run's pushes and bundles), bundles what nothing keeps, with a
`bundle:` ledger row, and asks once more right before the delete. A refusal
there stops the sweep, exit 2, and DISPOSITION.md says so, as it does for a
tree; a refused item is no longer counted in the register's "swept branches"
line. The bundle helpers take a repository and a name instead of a tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold back untracked files by the act's own snapshots, and keep untracked git-lfs content

Two #174 Copilot round 1 findings in the WIP rescue (lane-worktrees
`_wip_rescue`), both about what goes to a bundle only (#170 A9).

Whether untracked files were held back was read from the table's status,
taken minutes earlier. A tree with only a tracked change when it was read,
and an untracked key file by the time of the rescue, had its full
snapshot - key included - pushed to origin. The pushed snapshot is now
always the tracked-only one (unless `--push-untracked`), and what the full
snapshot holds beyond it is what goes to the bundle alone.

An untracked file git-lfs filters is snapshotted as a POINTER: its bytes
go to the local LFS store, which no bundle carries and the removal may
take, so the bundle that was its only copy held no payload. Where git-lfs
is configured, such a tree is now `keep` - in the table, and again at the
act before anything is pushed (a `_Keep` found at the act leaves the tree
as the table would have).

The cases make the file arrive between the table and the act with a `git`
that writes it on the hidden-edit guard's second call.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a clone's annotated tag through its peeled commit, and bundle the tag object only origin lacks

#174 Copilot round 1 (lane-worktrees `lost_tips`, `_loss_plan`): a
clone's loss plan read every ref's raw `%(objectname)`, so an annotated
tag arrived as its tag object. `lost_tips` reported any object that was no
commit as lost whatever kept it - so the tag was bundled, then the
self-check reported it lost again, refused the removal and halted the
sweep, for every clone with an annotated tag.

A tag object is now kept where a SHA in the keep list names it - a
verified bundle's head, or origin's own copy of that very tag object,
read with `ls-remote --tags` (none when origin cannot be asked, so the tag
is bundled) - and its history is its peeled commit's, asked like any other
commit. A SHA in the keep list counts through the commit it peels to.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's first Copilot round changes in the sweep

The manual's table and the command's own help now say that a submodule's
reflog-only commit keeps its tree, that untracked git-lfs content keeps
its tree and untracked files are read from the act's own snapshots, that
a clone's reflog directory that cannot be listed keeps the clone and its
annotated tags are bundled where origin lacks that very tag object, that
the ignored archive carries every entry selected whatever its ancestors
are named, that a `--branches` delete has its own loss plan, bundle and
self-check, and that neither a cache nor an unmarked `tmp.*` sandbox that
holds a repository is ever removed.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose git directory keeps a submodule repository with work no remote of it holds, a deinitialized one's included

#174 Copilot round 2 (lane-worktrees `tree_guards`): the submodule
guards read only what `git submodule status` lists as checked out. A
DEINITIALIZED submodule shows `-`, its working tree is gone, and its
repository - branches, a stash, reflog entries - stays under
`<gitdir>/modules/<name>`, which the tree's removal takes. A branch no
remote held went with the tree.

Every repository kept under the tree's git directory's `modules/`
(nested ones included) is now read: a branch, tag or HEAD no remote of it
holds, a stash (read as its ref, since `git stash list` wants a working
tree) or a reflog entry no remote holds keeps the tree, and so does a
`modules/` directory that cannot be read.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule has a skip-worktree or assume-unchanged edit that git status hides

#174 Copilot round 2 (lane-worktrees `tree_guards`): #170 A2's
hidden-edit guard read the superproject only. A file flagged
skip-worktree or assume-unchanged in a submodule and edited there reads
as clean in that submodule exactly as in the superproject, no rescue
carries it, and it went with the tree. The same `hidden_edits` read now
runs in every checked-out submodule, and flags that cannot be read keep
the tree too.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pass over nothing but .git when proving a removal takes no nested repository

#174 Copilot round 2 (lane-worktrees `nested_repos`): the walk skipped
directories named `__pycache__`, `.pytest_cache`, `.mypy_cache` and
`.ruff_cache`. It is the proof that a removal takes no repository, so a
checkout under a directory with one of those names was missed: a scratch
holding one was archived without it (the archive leaves caches out) and
removed. NESTED_SKIP is gone and every directory but `.git` is entered.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read another lane's claims in every .lane-state the whole-estate walk finds

#174 Copilot round 2 (lane-worktrees `Sweep.gather`): where the whole
estate is walked - a clone the table could take, or `--include-scratch` -
`walk_estate` finds every `.lane-state`, one in a plain directory inside
a repository included (`host/vendor/.lane-state`), but the sweep threw
that list away and read claims only beside the shape walk's checkouts and
the register's recorded directories. With no log naming that place, a
clone another lane claimed from there was removed. The walk's control
roots are now read for claims as well, before liveness and the table.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never push a lane's commits onto a branch gh still answers OPEN, whatever a LANDED line says

#174 Copilot round 2 (lane-worktrees `MergeEvidence.judge`, `_decide`):
a register LANDED line naming a pull request gh still answered OPEN, with
the local tip ahead of the PR's head, made the verdict "moved". That
verdict dropped gh's OPEN answer, so #170 A7's rule never fired and
push+remove put the lane's unreviewed commits onto the open pull
request's branch. The moved and merged-elsewhere verdicts now carry gh's
OPEN numbers, and a branch gh answers OPEN goes to a rescue branch, with
origin's branch left as it is.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never prune a gone worktree's registration whose git directory keeps a submodule repository with work

#174 Copilot round 2 (lane-worktrees `_decide`, `_act_prune`): a
registration whose directory is gone still keeps its submodules'
repositories under `<admin>/modules/`, and the prune - which read only
the superproject's HEAD and reflog - removed them, a branch no remote held
with them. The same submodule-repository read as the tree guard now keeps
such a registration (`keep`), and is asked again right before the prune.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a clone with a local git-lfs store when its commits would go to a bundle only

#174 Copilot round 2 (lane-worktrees `_act_tree`): a clean clone with a
commit only its reflog named had that commit bundled and was removed with
`.git/lfs/objects`. A bundle carries LFS pointers, never their bytes, and
no push uploaded them, so the bytes behind those pointers were lost. A
CLONE whose local LFS store holds anything (or cannot be read) is now kept
wherever a bundle would be the only copy of a commit - the `bundle+remove`
disposition and the reflog-only commits of step 2b alike.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count a suite's .lock or run.sh marker as provenance only when it names a pid

#174 Copilot round 2 (lane-worktrees `sandbox_provenance`): an EMPTY or
malformed `.lock` (or `.openrepotools-run`) counted as a suite's mark,
so a person's two-hour-old `tmp.*` with a checkout in it was removed -
past coordinator default 1's 14-day rule and the repository guard both. A
mark now proves a suite made the directory only when it names a pid; the
run-root layout proof (`basetemp/` beside `tmp/`) is unchanged.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's second Copilot round changes in the sweep

The manual's table and the command's own help now say that every
submodule repository a tree's or a gone registration's git directory keeps
- a deinitialized one's included - keeps it where it holds work no remote
of it holds, that a submodule's hidden edits keep the tree, that a
repository under a cache-named directory is still found, that the
whole-estate walk's `.lane-state` finds are read for claims, that gh's
OPEN wins over a LANDED line, that a clone's local git-lfs store keeps it
where a bundle would be a commit's only copy, and that a suite's mark must
name a pid.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a submodule repository kept under a git directory as that git directory alone, so a gone worktree's is read rather than refused

A submodule repository under `<gitdir>/modules/` names its working tree in
`core.worktree`. Where that directory is gone - every registration whose
worktree directory went, and a submodule removed after its deinit - every
git command run in the repository dies on "cannot chdir", so the guards
of this round's first and sixth commits read every such repository as
unreadable: a gone worktree with any submodule was kept for that reason
alone, never read. The proof run on the round's head showed it (the
prune's reason said the branches "could not be read" where the test
expected the unpublished branch named).

The submodule-repository reads now run each git command there with the
repository's own `objects/` as its working tree (`GIT_WORK_TREE`, through
`git_env(path)`); they are reads that need none - refs, reflogs,
rev-list, cat-file - and a branch no remote holds is named again.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree or registration whose submodule repository has an annotated tag object no remote of it holds

#174 Copilot round 2 (lane-worktrees `module_repos_why`, submodule
guards): `unpublished_ref` asks whether an annotated tag's COMMIT is
published, never its tag object, so a submodule's local annotated tag on
a published commit passed every guard, and its message, which nothing
else held, went with the tree. A clone's own tags have been bundled since
round 1, but a submodule's are in no loss plan.

At the act, before anything is rescued, every submodule repository the
tree's (or a gone registration's) git directory keeps is asked: each
annotated tag object must be answered by some remote of it in
`ls-remote --tags` as that very object, else the tree is kept, and so it
is where no remote could be asked. It asks remotes, so the dry run, which
asks none, still reads `remove`.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read the gone worktree's kept submodule repository in its regression the way the sweep reads it

The proof run on the round's head passed every assertion of
`test_a_gone_worktrees_kept_submodule_repository_is_never_pruned` about
the sweep - the registration kept, the reason naming the unpublished
branch - and then failed …
brettheap added a commit that referenced this pull request Oct 7, 2026
…e's trees, branches, scratch, caches or residue, --inventory-only as the logged door, and lane-worktrees add records every tree it makes (#163) (#175)

* Add lane-worktrees: the sweep that retires a lane's abandoned worktrees

A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the sweep against every row of #162's disposition table

tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Place lane-worktrees as the seventeenth installed file

`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the sweep in the lanes manual

"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the estate for --links with scandir

`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a test run's bytecode, cache and temp out of the worktree

A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the repository's virtual environment on --install

One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Seed the workspace .gitignore with bytecode and cache rules

Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a workspace commit whose pathspec carries bytecode

The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the estate report once a day from lane-start

The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report the estate's leftovers with sweep --all --dry-run --report

Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the estate report and prevention at creation

The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 1's defects in the sweep

Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document round 1's rules in the lanes manual

The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what expiry_rows keeps now that #168 checks whole archives

The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the suite's whole process group when a run ends

Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer and seed site-packages/ with the other bytecode rules

Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report duplicate clones with no named checkout, over one estate

Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 2's defects in the sweep

Seven of round 2's eight. Each one let an act proceed on an unknown:

- Another lane's inventory record or directory that cannot be read, or
  that names no path, now refuses the sweep. Who else claims a tree is
  read before the fetch, so the refusal writes nothing.
- A submodule's ignored files (an .env) keep the tree. The tree's
  ignored archive covers the superproject only.
- Ignored files are printed before the archive and listed again at
  removal. A change, an addition or a failed listing leaves the tree.
- A prune candidate whose directory reappeared is left for the next
  sweep to classify.
- A pytest sandbox's .lock is read again at the act, and a live pid
  leaves it.
- `tracked()` returns None when a repository's index cannot be read,
  and such a cache is left.
- A manifest entry whose file is gone makes the archive not whole.

The eighth, unreadable /proc entries of same-account processes, is a
design trade and is filed in #170. On Eagle, 9 such processes exist at
any time (6 `timeout`, 3 `ssh-agent`), so reading them as "unknown"
would keep every tree on every sweep.

Four new or extended cases. Each fails against ae17742 and passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse bytecode that is already staged, not only what add would stage

Copilot round 2 on #169: `git add --dry-run` says nothing about a path
the index already holds at those bytes, yet the path-limited commit
still takes it. bytecode_in_pathspec now also reads `git diff --cached
--name-only --diff-filter=d` for the same pathspec. A staged deletion of
bytecode is a cleanup and is let through. The shell case force-stages a
.pyc after the ignore lines are in place: pathspec-check and the commit
path both refuse it, and nothing is committed.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quote the venv paths in the command --install prints

Copilot round 2 on #169: the command is meant to be pasted into a
shell, and $XDG_CACHE_HOME is the person's to choose, spaces included.
Both paths are now `printf %q`-quoted. The new case installs with a
cache directory containing a space, and shlex reads each path back as
one word.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report what the report could not read

Copilot round 2 on #169: an unreadable lane snapshot or inventory
record became an empty one, so a lane or tree dropped out of every
section and the report could look clean. Reads now go through _ls and
_record. A failure other than "not there", a tree record naming no
path, or an inventory tree whose git status fails is a row in a new
section, "Records the report could not read". The unused _sidecar
reader is gone.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep AGENTS.md and README.md within their line caps

test_repo_hygiene caps AGENTS.md at 316 lines and README.md at 486, and
both files were exactly at their caps on main. The run.sh paragraph had
taken AGENTS.md to 330 and the venv sentence README.md to 491. The full
text already lives in docs/README-lanes.md, so AGENTS.md now says the
same thing in the three lines the old sentence took, and README.md says
it on the line it extends. No cap is raised.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the flock file only where there is flock

Copilot on #169: macOS has no flock, so the wrapper takes the mkdir lock
and never creates openrepotools-pytest.lock. The relocation case checked
for that file unconditionally and would have failed the macOS job, which
is the landing gate. Where there is no flock, the case now checks
instead that the mkdir lock was released.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes off until #170; keep the gate and exit contract honest

The adversarial review of 3c0b188, reproduced with its probes against
the suite's own Estate fixture:

1. --yes and --expire --yes are refused, exit 2, changing nothing,
   unless LANE_WORKTREES_ENABLE_YES=1 is set. #170 lists data-loss paths
   that are still open. The suite's Estate sets the variable; a real
   estate should not until #170 lands. The dry run, --porcelain and
   --report are untouched.
2. B1: a tree that #97's inventory does not name is still the lane's if
   it stands under .lane-worktrees/<lane>/, or if it sits in
   <checkout>/.claude/worktrees on a branch whose own commits carry this
   lane's Lane: trailer. Another lane's claim still wins. Such trees keep
   the quiet-hours liveness rule. A lane whose state is NONE but which
   has trees of its own is refused, exit 2. On Eagle, `sweep
   openRepoTools-3 --dry-run --porcelain` gave exit 0, `summary 0 15 0
   15`. It now gives exit 2, `summary 9 13 4 4`: 9 trees are the lane's,
   and the snapshot is NONE.
3. B2: subprocess output and git metadata files are decoded with
   surrogateescape, and stdout and stderr write with it too, so a
   Latin-1 worktree name is reported rather than raising. An unreadable
   pytest-of-$USER is a kept sandbox row. A catch-all keeps errors that
   no read caught inside the contract: exit 2 on a dry run, and under
   --yes DISPOSITION.md is written first and the exit is 1.
4. B3: every porcelain field escapes backslash, TAB, newline and CR.
   Worktree registrations are read with `git worktree list -z` where git
   has it, so a newline in a path no longer becomes a phantom
   registration.

Five new cases (switch off, ownership plus NONE, escaping, non-UTF-8
path, unreadable sandbox root), each failing against 3c0b188. Two cases
were moved off the lane root to keep testing FOREIGN trees. 40 cases.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the --yes switch, lane-root ownership and porcelain escaping

The manual now leads the sweep section with the --yes switch and #170.
It also says which unrecorded trees are a lane's and that a NONE snapshot
with trees is refused. The exit contract now covers the escaped fields
and the catch-all.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the suite in the foreground when stdin is a terminal

Adversarial review B7: `set -m` puts the suite in a background process
group with the terminal as its stdin. Anything that reads the terminal
(--pdb, breakpoint(), input()) is then stopped by SIGTTIN, and the
wrapper waits on it for ever while holding the workstation flock. With
a terminal on stdin the suite now runs in the foreground, in the
terminal's own process group, and a Ctrl-C reaches all of it from the
terminal. Without a terminal the process-group stop is unchanged.

The new case runs the wrapper on a pseudo-terminal whose stub suite
prints a prompt and reads a line. It passes here. Against the previous
run.sh it hangs and fails, with the transcript showing the prompt and
the typed line that was never read.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never adopt a standalone clone under the lane's root

5f4bd84 made every unrecorded tree under .lane-worktrees/<lane>/ the
lane's. That included standalone clones, which #162's first protocol
line and the manual keep FOREIGN ("a lane creates worktrees, never
clones"). Copilot flagged it on 296e2f0. Clones are now excluded from
both adoption paths. The ownership case adds a clone under the lane
root and requires it to stay foreign and not retire-counted. On Eagle
none of the lane's seven root trees is a clone, so the dry run is
unchanged there.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch the register directly under --yes and refuse when it fails

#170 A1. lanes-edit.sh's log_sync answers 0 on every way it can fail to
fetch, and an inherited LANES_NO_FETCH=1 skips the fetch outright. A lane
rebound on another host after this workstation's last register fetch
therefore read as bound here, and --yes removed its trees (Amendment
18(b): from outside the binding a lane is UNKNOWN, never dead).

Under --yes the sweep now fetches the workspace repository's
origin/<branch> itself, with an explicit refspec, before anything else.
A fetch that fails refuses the run, exit 2, with nothing changed. Every
later helper call reads the ref that fetch left, so an inherited
LANES_NO_FETCH no longer decides. The dry run is unchanged.

The test estate now carries a workspace repository (the fetch's target,
and #170 G6's register) and records its inventory under an earlier
session, as a swept lane's trees are. The new case rebinds the lane on
another host after the last fetch, then runs --yes with origin
unreachable and with LANES_NO_FETCH inherited: both refuse and the tree
stays.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the sweep's fetch refspec so --prune cannot delete local branches

#170 item 6 (Copilot round 3 on #168). With a mirror-style
remote.origin.fetch such as +refs/heads/*:refs/heads/*, the preflight
`git fetch --prune origin` deleted every local branch origin lacks,
before anything was classified or rescued.

The fetch now names its refspec and refmap, as `status --fetch` does:
+refs/heads/*:refs/remotes/origin/*, with --refmap set to the same, so
the configured refspec maps nothing. fetch.pruneTags and the remote's
pruneTags are forced off, so a local tag is never pruned with it.

The case configures the mirror refspec with nothing checked out that
origin has (git refuses to fetch into a checked-out branch, which hides
the prune) and a local-only branch and tag. Both survive --yes.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse when a lane root or a registration list cannot be read

#170 G1 and G5 (Copilot after the cap). Discovery read a lane worktree
root that could not be listed as an absent root, and a failed
`git worktree list` as a repository with no worktrees. With an empty
inventory either one let the dry run exit 0 and clear #163's gate over
trees nobody could see.

Only a root that does not exist is skipped now; any other listing
failure refuses, exit 2. worktree_registrations returns None when both
listings fail. Discovery refuses on it. The prune's after-check, a
branch delete and --branches stop that act. The report records the
checkout as unreadable.

Two cases: a mode-000 lane root with an empty inventory, and a git shim
that fails `worktree list`. Both now exit 2.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk every estate directory for dependents; a partial scan deletes nothing

#170 A11. The estate walk entered a repository only through worktree
containers, so it never saw a plain directory inside a repository (it
found none of xFactory/xFactories/* on Eagle). A clone there that
borrowed a lane clone's objects was unseen, and the clone was deleted
from under it. An empty estate root read no dependents at all, and an
unreadable directory read as one with nothing in it.

walk_estate visits every directory but caches, tool environments and
site-packages. It never follows a symlink or enters a .git, and it
collects .lane-state directories for #170 G6. Whatever it, or the
alternates and config reads, could not read makes the dependents scan
partial. A partial scan, a missing estate root, or a tree outside the
estate root keeps a clone and a scratch directory. Both are removed by
deleting a directory, and a dependent nobody saw would lose its objects.
Worktrees are untouched by this: their objects live in the checkout's
repository, which a worktree removal leaves.

Two cases: a --shared dependent under host/vendor/ makes the clone
load-bearing, and a mode-000 directory in the estate keeps it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read every other lane's control root before granting a tree

#170 G6 (Copilot after the cap). Other lanes' claims were read only
under this lane's control-root parent and the LANES_LANE_STATE_ROOT
override. A lane in a nested estate keeps its .lane-state beside its own
recorded dir, so its claim was missed, the tree was not contested, and
--yes could take it.

The parents read now also include $PROJECTS_ROOT/.lane-state, every
.lane-state the estate walk found, and the parent of every lane's
recorded dir. The recorded dirs come from one `git grep` over the
register's lane logs on origin/<branch>, parsed as lanes-edit.sh's
payload_subfield parses them. A register that cannot be read refuses:
whose claims were missed would be unknown. Lane names compare without
case, as Amendment 15 has them.

The case puts another lane's claim on one of this lane's trees under
<estate>/group/.lane-state, and under a directory outside the estate
that only the register names. Both keep the tree.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep trees whose hidden edits, nested clones, tags or submodule refs would go

Four ways tree_guards let a removal take work no rescue carried:

- #170 A2: a file flagged skip-worktree or assume-unchanged reads clean
  in `git status`, and `git add -A` skips it, so its edit was in no
  rescue. hidden_edits hashes every flagged file present in the tree
  against its index blob, and a difference keeps the tree. A flagged
  file that is absent (a sparse checkout's) is no edit.
- #170 A3: the nested-repository walk passed over venvs and node_modules
  before it looked for a .git, so `pip install -e git+...`'s clone in
  <venv>/src/<pkg> was deleted with the tree. nested_repos skips only
  bytecode caches, and a directory it cannot read keeps the tree.
- #170 item 4: a clone's publication check read branches and the
  stash, not tags. unpublished_ref checks every branch, every tag
  (peeled to its commit) and HEAD against refs/remotes/origin in one
  rev-list. A tag that names no commit cannot be checked, and keeps it.
- #170 item 5: only a submodule's HEAD was checked. A worktree's
  submodules keep their repositories under .git/worktrees/<id>/modules/,
  so their branches, tags and stash went with the tree. Each initialized
  submodule now gets the same check against every remote of it, plus
  its stash. A `git submodule status` that fails keeps the tree; it used
  to skip the submodule checks.

Cases: both flags, a sparse checkout's absent file (still removed), a
clone in .venv/src, a clone's annotated tag on a commit no branch has,
and a submodule's unpublished branch and stash.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Judge an ignored entry by its own name, not an ancestor's

#170 A6. ignored_paths dropped an ignored file when any ancestor was
named like a cache or build output. With --directory an ignored cache or
build directory is listed as itself, so a path beneath a directory named
`build` is an ignored file in a tracked directory. docker/build/prod.env
was deleted unarchived while top.env beside it was archived.

Only the entry's own basename is compared with CACHE_NAMES, VENV_NAMES
and BUILD_NAMES now. An ignored /build/ directory is still build output
and is not archived.

The case puts prod.env in the tracked docker/build/ beside an ignored
/build/ and checks the archive holds prod.env and top.env and nothing
of build/.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave other people's open branches alone: rescue, and keep the remote

Two ways --yes acted on a branch someone else's pull request owns:

- #170 A7: push+remove pushed the lane's unreviewed commit onto a
  teammate's OPEN PR branch, because the decision ignored the merge
  state. Where an open PR names the branch, where origin's tip carries
  another lane's Lane: trailer, or where whether a PR names it could not
  be read (gh unavailable), the commits now go to
  rescue/<lane>/<slice>-<UTC> and origin's branch is left as it is.
- #170 A8: a register LANDED line beat gh's live OPEN for the same
  number, so a LANDED #21 typed for #20 deleted the remote branch of the
  still-open #21 and closed it. The tree and its local branch still go
  (the work is on origin), but the remote branch is never deleted while
  gh answers OPEN for it. The same holds under --branches.

Cases: the open PR, another lane's trailer and LANES_NO_GITHUB each send
the commit to a rescue branch with origin's branch unmoved, and a wrong
LANDED number leaves the open PR's branch on origin.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rescue before a prune, bundle what only a reflog names, self-check each removal

Four #170 items that share one mechanism: before a removal the sweep now
asks git which commits it would take and where each one is kept.

- The loss plan. A worktree's removal takes its git directory: its HEAD
  and that HEAD's reflog, plus the branch and its reflog where the
  branch is deleted after it. A pruned registration takes the same,
  read from its git directory. A clone takes every ref but its
  remote-tracking ones, and every reflog. One rev-list says which of
  those commits no origin ref, surviving branch, tag or stash, push
  seen on origin this run, or bundle head of this run reaches.
- A4: `prune` dropped the only pointer to a detached commit. A
  registration whose git directory names such a commit is now
  rescue+prune: a rescue branch pushed where there is an origin, a
  bundle, then that one registration removed.
- A5: commits only a reflog names (an amend, a reset, checking out away
  from a detached commit) were lost on removal and on delete_local_branch.
  They go to a bundle of their own through temporary
  refs/lane-worktrees/* refs, which are deleted once it is verified.
- Item 8: a bundle that is the only copy of what it holds (bundle+remove
  with no origin, the reflog bundle, the untracked bundle below) gets a
  rescues.tsv row (origin `-`, branch `bundle:<file>`). `--expire` reads
  it as "no other copy" and never expires that archive.
- A9: the WIP rescue pushed every untracked, un-ignored file, a service
  account key included. The pushed WIP commit now carries the tracked
  changes only. A second commit holding the untracked files goes to the
  bundle alone, as the ledger's only copy. --push-untracked pushes them
  as before.

THE SELF-CHECK. Right before each removal (and each prune) the same
question is asked once more. If anything the removal would take is
neither on origin nor in a verified bundle, the removal is refused,
DISPOSITION.md says so, nothing after it is acted on, and the exit is 2.
LANE_WORKTREES_SEAM_NO_LOSS_BUNDLE=1 is the suite's seam: it skips the
reflog bundle so the case can prove the self-check stops the loss.

Cases: a pruned detached commit (rescued, bundled), a reflog-only
commit (bundled, ledger row), the self-check with the seam (exit 2,
both trees left, the refusal in DISPOSITION.md), untracked files with
and without a tracked change (never on origin, in the bundle,
temporary refs gone), --push-untracked, and an origin-less
bundle+remove archive surviving `--expire --yes` at retention 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the push hooks where git-lfs is configured

#170 A10. `git push --no-verify` skips git-lfs's pre-push hook, which is
what uploads LFS objects. A rescued branch therefore reached origin as
pointers only, and the tree, the one copy of the objects, was then
removed.

Where the repository has any filter.lfs.* setting (the global one
included) the push now runs its hooks. A hook that refuses fails the
push and leaves the tree. Without git-lfs, --no-verify stays, so an
unrelated pre-push hook cannot hold a rescue up.

The case installs a pre-push hook that records itself. It runs with
filter.lfs configured and does not run without it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep scratch that holds a tree or a repository

#170 item 7 (Copilot round 3 on #168). A scratch directory was archived
and removed whenever it had no .git of its own, so a worktree or clone
nested beneath it went with the rmtree. A FOREIGN or live tree was not
protected either, and nor was an unpushed commit.

A scratch directory is now kept while any tree of the table lies under
it, or any .git does at any depth (bytecode caches aside). A directory
under it that cannot be read keeps it too. The tree table retires its
own children first, and the scratch goes on a later sweep. The check is
made again at the act and again after the tar.

The case nests a clone with an unpushed commit two levels down in a
scratch directory. --include-scratch --yes keeps the scratch and the
commit.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check --live paths, want the writer count for own trees, stop on NOT WRITTEN

Three gaps in how --yes takes the coordinator's word and the register's:

- #170 B4: a misspelt --live path was accepted in silence, so the
  writer it was meant to protect was not protected. Each --live path
  must now name a tree of the table (it, or a path inside it), and
  `--live none` stands alone. Anything else is usage, exit 64, decided
  before any repository is fetched.
- #170 B5: a tree this session recorded skips the transcript check,
  because the transcript is this one. --live was demanded only when the
  holder read as this session, so with the holder read as none, --yes
  removed trees this session's own writers could be in. Any inventory
  record whose writer is the caller now demands the count too.
- #170 B6: removals went on after a NOTED line failed. The first line
  the register refuses now stops the sweep. Every later tree and item
  is left as the table found it, with the reason, and the exit stays 1.

Cases: a typo'd --live path and `--live none <path>` exit 64 and touch
nothing; a tree recorded by this session refuses without --live and
goes with --live none; with the register refusing, the first tree goes,
the second stays, and exactly one line was attempted.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count --branches by origin's own ref, never by upstream or an open PR alone

Two ways `--branches --dry-run --porcelain` gave #163's gate the wrong
answer (Copilot after the cap on #168):

- G2: every OPEN PR's branch was left out of the retire count, even a
  lane-owned one holding commits origin lacks, so the gate read 0. The
  delete protection stays. Such a branch is now counted unless
  refs/remotes/origin/<branch> holds its tip, and a read that failed
  counts it too.
- G7: a lane-owned branch was judged by its configured upstream alone.
  A branch made with `checkout -b X origin/main` tracks main, so once
  pushed under its own name it still read "ahead" and the gate read 3.
  Publication is now checked in origin's ref for the branch's own name
  first, and a branch origin holds that way is not listed. Unknown
  states are still counted.

Cases: an unpushed lane branch named by an open PR makes the dry run
exit 3 and survives --yes; a branch made from origin/main and pushed
as itself makes it exit 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a tmp.* only on proof, and read hidden processes as unknown

#170 items 1 and 2 (Copilot on #168), with the coordinator's defaults,
which the PR body states for Brett Heap to overrule:

1. --include-sandboxes removed any account-owned tmp.* over an hour old
   with nobody in it, with no proof a suite made it, so a person's
   `mktemp -d` checkout or saved scratch could go. A tmp.* is now
   removed only with a suite's mark whose pid is gone (pytest's .lock,
   tests/run.sh's new .openrepotools-run marker, or run.sh's run-root
   layout of basetemp/ beside tmp/), or when nothing in it has been
   written for aging_days (14). Age is the newest mtime anywhere inside,
   not the top directory's. Any other tmp.* is a `list` row, kept. A
   marker's pid is read again at the act. tests/run.sh writes its pid
   into each run root it makes.
2. The /proc scan read a same-account process whose entries cannot be
   read (not dumpable) as absent, so its tree could be removed under
   it. Such a process is now in ProcScan.unknown and placed by what can
   be read: absolute paths in its command line, and its parent's
   working directory. A tree, scratch directory, cache owner or sandbox
   it is placed in is of unknown liveness and is kept, at the read and
   again at the act. One placed nowhere (ssh-agent re-parented to init:
   three on Eagle today) holds no tree, so a sweep is not frozen for
   ever. One whose status cannot be read at all could be anywhere. A
   process of another account is never a writer here. The human table
   names every unreadable pid in a note. Uid is read from
   /proc/<pid>/status, because a non-dumpable process's /proc entry is
   owned by root.

Cases: four tmp.* directories (unmarked and two hours old: listed and
kept; twenty days old, pytest-marked and run-root-shaped: removed), and
a non-dumpable child standing in a tree, which is kept. The existing
sandbox and report cases now age every file, not only the directory,
and the killed sandbox carries pytest's mark.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #170's fixes change in the sweep

The manual's sweep section and --help now say:

- under --yes the register is fetched first, and a failed fetch refuses;
- each repository's fetch pins its refspec;
- other lanes' claims are read wherever #97 keeps them;
- an unreadable lane root, registration list or register refuses;
- --live must name a tree, and a tree this session recorded wants the
  count;
- the new keep rows (hidden edits, nested clones anywhere, clone tags,
  submodule branches, tags and stash, a partial dependents scan) and
  the rescue+prune row;
- untracked files go to the bundle only, unless --push-untracked;
- rescue rather than push onto another's branch, and no remote delete
  while gh answers OPEN;
- the loss plan, the reflog bundle and the self-check, and the stop on
  NOT WRITTEN;
- the LFS hooks, ignored files judged by their own name, scratch kept
  while trees lie under it;
- tmp.* removed only on proof, the bundle: rows that --expire never
  expires, and the exit contract's new 2 and 64.

The --yes switch and its paragraph go in the next commit, with the
switch itself.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the existing cases to the register fetch, the untracked bundle and B5

Three of #170's fixes change what older cases see. A proof run of the
sweep module on CI (the workstation's pytest lock never came free)
failed these seven at the previous head:

- The table case asserted that the WIP rescue pushed the untracked
  new.txt. It now asserts new.txt is not on origin, and that the
  ledger's bundle: row for the dirty tree holds it (A9).
- The four refusal cases and the unreadable-claim case snapshot the
  estate around a refused --yes. --yes now fetches the register first
  (A1), which writes FETCH_HEAD in the workspace repository and nothing
  else, so the workspace is left out of those snapshots.
- The unreadable-record case wrote its record as this session's
  writer. B5 then refused first, with its own reason, so the record is
  written by the earlier session the fixture uses everywhere else.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the whole estate only where a removal deletes a directory

Measured on Eagle, the whole-estate walk A11 asked for is 683,360
directories: 541,236 of them in xFactory alone, mostly spec trees. It
took 25 s warm and 397 s cold, where the gate's dry run took 15 s at
45fa45d. #163's gate would pay that at every lane-end, for nothing a
worktree's removal can break: a worktree's objects are its checkout's,
and the removal leaves them.

The whole walk now runs only where a removal deletes a directory: a
clone the table could take, or --include-scratch. Otherwise the
estate's shape walk is enough, as before. Other lanes' claims (G6)
follow #97's own rungs for every lane: the override, the parent of
every lane's recorded dir in the register, and $PROJECTS_ROOT. Each is
read beside this lane's own root and beside every checkout the shape
walk finds. So they no longer depend on the whole walk. A lane cannot
claim a tree without a register row.

The G6 case's nested checkout is now a repository, as a lane's is.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse an inventory row that carries no id

#170 G9 (Copilot after the cap, on 87da332). lane_facts skipped a
`lane-trees` row whose id was empty. A tree such a row names outside the
scanned roots was then never discovered, and the dry run could exit 0
and clear #163's gate.

A nonempty row with no id is now an unreadable record. Discovery
refuses it, exit 2, as it refuses one of an unknown schema.

The case gives the inventory one id-less row naming a tree outside both
roots: exit 2, where 45fa45d exits 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail closed when the bytecode check cannot read the pathspec

#170 G13 (Copilot after the cap, on 45fa45d). bytecode_in_pathspec threw
both git reads' stderr away, and its awk exits 0 on no input. A read
that failed therefore printed nothing: a malformed magic pathspec, or an
index git cannot read, makes both `add --dry-run` and
`diff --cached` exit 128. `pathspec-check` then reported the pathspec
clean, and the commit path let it through.

Each git status is kept now, and the function answers 3 when a read
failed. `add --dry-run`'s exit 1 is not a failure: it means a named
path is ignored, which the add would not stage. `pathspec-check`
refuses with exit 2. The commit path refuses with exit 2 before it
stages anything.

The shell suite's #162 block gains four assertions: `pathspec-check
':(bogus)x'` exits 2 and says the read failed, and a commit of that
pathspec exits 2 and commits nothing. At 45fa45d the first exits 0 and
the commit fails later, at the add, with exit 6.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document G9's id-less row and G13's failed read

The manual's inventory paragraph now names a row with no id among the
unreadable records that refuse a sweep. The pathspec-check usage line,
and the comment above the subcommand, now say exit 2 also covers a read
git could not make.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a .git file that is no pointer as no repository, not as unreadable

The read-only dry run over this lane's estate on Eagle kept every
--include-scratch directory as "the dependents scan was partial". The
cause was two empty .git files that uv keeps in its caches to stop git
looking upward. git_common_dir answers '' for those as it does for an
unreadable .git, and dependents_map counted both as unread.

A .git file that can be read and is no gitdir pointer is now skipped
as no repository. Only one that cannot be read leaves the scan partial.

The case puts uv's marker in the estate. A clone taken on a word is
still removed: the scan is whole.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes back on: remove LANE_WORKTREES_ENABLE_YES

5f4bd84 switched --yes and --expire --yes off until #170's data-loss
paths were closed. Every item #170 lists for --yes now has a case that
fails against 45fa45d and passes here: items 1, 2 and 4-8, A1-A11,
B4-B6, G1, G2, G5-G7, G9 and G13. Before every removal the sweep also
asserts, of git itself, that each commit the removal would take is on
origin, in a ref that stays, or in a bundle this run verified. If not,
it refuses with exit 2 and a DISPOSITION.md line.

The switch, its refusal, the --help paragraph and the manual's
blockquote are gone. The suite's estates no longer set the variable.
The switched-off case is replaced by one that runs --yes and
--expire --yes with no variable in the environment.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a clone's removal when a reflog directory under it cannot be listed

#174 Copilot round 1 (lane-worktrees `_loss_plan`): the loss plan of a
clone collected its reflogs with `os.walk`, which passes over a directory
it cannot list in silence. A commit only `logs/refs/heads/<b>` named was
then in no plan: no bundle took it, the self-check asked the same blind
question, and the clone was deleted with it.

`reflog_files` walks `<gitdir>/logs` with an error handler: a `logs`
directory that is not there is an empty answer, anything else it cannot
list makes the loss plan unknown, and the clone is left in place.

The case seals a clone's `logs/refs/heads` (mode 000) with an experiment
only that branch reflog names, and wants the clone and the commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule names an unpublished commit only in its reflog

#174 Copilot round 1 (lane-worktrees `tree_guards`): a submodule's
experiment, made detached and checked out away from, is in no branch, tag
or stash of the submodule - only its reflog names it. Every submodule guard
passed, and the tree's removal took the submodule's repository, which lives
under the tree's own git directory, with the experiment in it.

The submodule's reflogs are read now (`unpublished_reflog`, through
`reflog_files`, so a directory it cannot list is unknown too): any commit
they name that no remote of the submodule holds keeps the tree, and says so.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Archive every ignored entry selected, whatever its ancestors are named

#174 Copilot round 1 (lane-worktrees `_tar`): #170 A6 selects an ignored
entry by its own name, so `docker/node_modules/prod.env` - an ignored file
in a tracked directory - is selected for the archive. The archive's member
filter then dropped any path with a cache- or venv-named part anywhere in
it, `top.env` beside it kept the archive non-empty, it verified, and the
tree was removed without prod.env.

The filter now judges only the parts of a member's path BENEATH the entry
it was added for, and always carries the entry itself; and the archive is
read back for every entry selected, so one missing leaves the tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never remove a cache directory that holds a repository

#174 Copilot round 1 (lane-worktrees `_caches`): a tree kept because a
clone is nested in its venv (`pip install -e git+...` clones into
`<venv>/src/<pkg>`, #170 A3) still gave up its caches, and `.venv` is one.
`--include-caches --yes` removed it, and the clone and its unpushed fix
with it.

A cache candidate that is a repository, holds one anywhere inside, or
holds a directory that cannot be read is now a `keep` row when the table is
built, and asked again right before the removal. The venv case of A3 runs
`--include-caches --yes` too and wants the clone's commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List an unmarked tmp.* that holds a repository, whatever its age

#174 (found auditing that a check guards every removal): `--include-
sandboxes` removed an unmarked `tmp.*` untouched for `aging_days` on age
alone - the second half of #170 item 1's default - so a person's `mktemp -d`
checkout with an unpushed commit in it went. No suite's mark says such a
directory's repositories are fixtures, so one that holds a repository, or
a directory that cannot be read, is listed and never removed; the check is
asked again right before the removal. A marked one is still the suite's,
and its repositories are its fixtures.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bundle a branch's reflog-only commits and self-check before --branches deletes it

#174 Copilot round 1 (lane-worktrees `_act_item`): the loss plan, the
bundle and the self-check guarded a tree's branch delete only. With
`--branches --yes` a merged branch no worktree holds was deleted with
`update-ref -d`, which takes its reflog too - the last pointer to an
experiment committed on it and reset away from.

A branch item now asks what the delete would take (its tip and every
commit its reflog names, against origin, the other branches, tags, the
stash, this run's pushes and bundles), bundles what nothing keeps, with a
`bundle:` ledger row, and asks once more right before the delete. A refusal
there stops the sweep, exit 2, and DISPOSITION.md says so, as it does for a
tree; a refused item is no longer counted in the register's "swept branches"
line. The bundle helpers take a repository and a name instead of a tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold back untracked files by the act's own snapshots, and keep untracked git-lfs content

Two #174 Copilot round 1 findings in the WIP rescue (lane-worktrees
`_wip_rescue`), both about what goes to a bundle only (#170 A9).

Whether untracked files were held back was read from the table's status,
taken minutes earlier. A tree with only a tracked change when it was read,
and an untracked key file by the time of the rescue, had its full
snapshot - key included - pushed to origin. The pushed snapshot is now
always the tracked-only one (unless `--push-untracked`), and what the full
snapshot holds beyond it is what goes to the bundle alone.

An untracked file git-lfs filters is snapshotted as a POINTER: its bytes
go to the local LFS store, which no bundle carries and the removal may
take, so the bundle that was its only copy held no payload. Where git-lfs
is configured, such a tree is now `keep` - in the table, and again at the
act before anything is pushed (a `_Keep` found at the act leaves the tree
as the table would have).

The cases make the file arrive between the table and the act with a `git`
that writes it on the hidden-edit guard's second call.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a clone's annotated tag through its peeled commit, and bundle the tag object only origin lacks

#174 Copilot round 1 (lane-worktrees `lost_tips`, `_loss_plan`): a
clone's loss plan read every ref's raw `%(objectname)`, so an annotated
tag arrived as its tag object. `lost_tips` reported any object that was no
commit as lost whatever kept it - so the tag was bundled, then the
self-check reported it lost again, refused the removal and halted the
sweep, for every clone with an annotated tag.

A tag object is now kept where a SHA in the keep list names it - a
verified bundle's head, or origin's own copy of that very tag object,
read with `ls-remote --tags` (none when origin cannot be asked, so the tag
is bundled) - and its history is its peeled commit's, asked like any other
commit. A SHA in the keep list counts through the commit it peels to.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's first Copilot round changes in the sweep

The manual's table and the command's own help now say that a submodule's
reflog-only commit keeps its tree, that untracked git-lfs content keeps
its tree and untracked files are read from the act's own snapshots, that
a clone's reflog directory that cannot be listed keeps the clone and its
annotated tags are bundled where origin lacks that very tag object, that
the ignored archive carries every entry selected whatever its ancestors
are named, that a `--branches` delete has its own loss plan, bundle and
self-check, and that neither a cache nor an unmarked `tmp.*` sandbox that
holds a repository is ever removed.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lane-end now refuses to end a lane while #162's sweep still finds its worktrees, branches, scratch, caches or residue on disk (exit 2, naming each and the exact sweep command; a gate that cannot be read is exit 1), with --inventory-only as the one door past it that writes its reason into the row and the ENDED line, and lane-worktrees add makes a lane's worktree and records it in the lane's #97 inventory in the same act, because a lane's inventory was written only at a handoff and lane openRepoTools-3's was empty while nine trees under its root were its own.

The gate reads `lane-worktrees sweep <lane> --branches --include-scratch
--include-caches --dry-run --porcelain` and refuses on 3 and 2, and beside
it reads every tree of the lane's with `git status --porcelain --ignored`
and every entry directly under `.lane-worktrees/<lane>/` that no row names,
so ignored residue counts. `--sweep` is refused by name until #170 switches
`--yes` back on. `--branches` no longer reads a lane directory that is no
repository as a branch read that failed, which held the gate shut for every
lane started outside a checkout.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lane-worktrees keeps its add verb under its own header ahead of the main section, so the file still reads as utilities, sweep, report, add, then main.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose git directory keeps a submodule repository with work no remote of it holds, a deinitialized one's included

#174 Copilot round 2 (lane-worktrees `tree_guards`): the submodule
guards read only what `git submodule status` lists as checked out. A
DEINITIALIZED submodule shows `-`, its working tree is gone, and its
repository - branches, a stash, reflog entries - stays under
`<gitdir>/modules/<name>`, which the tree's removal takes. A branch no
remote held went with the tree.

Every repository kept under the tree's git directory's `modules/`
(nested ones included) is now read: a branch, tag or HEAD no remote of it
holds, a stash (read as its ref, since `git stash list` wants a working
tree) or a reflog entry no remote holds keeps the tree, and so does a
`modules/` directory that cannot be read.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule has a skip-worktree or assume-unchanged edit that git status hides

#174 Copilot round 2 (lane-worktrees `tree_guards`): #170 A2's
hidden-edit guard read the superproject only. A file flagged
skip-worktree or assume-unchanged in a submodule and edited there reads
as clean in that submodule exactly as in the superproject, no rescue
carries it, and it went with the tree. The same `hidden_edits` read now
runs in every checked-out submodule, and flags that cannot be read keep
the tree too.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pass over nothing but .git when proving a removal takes no nested repository

#174 Copilot round 2 (lane-worktrees `nested_repos`): the walk skipped
directories named `__pycache__`, `.pytest_cache`, `.mypy_cache` and
`.ruff_cache`. It is the proof that a removal takes no repository, so a
checkout under a directory with one of those names was missed: a scratch
holding one was archived without it (the archive leaves caches out) and
removed. NESTED_SKIP is gone and every directory but `.git` is entered.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read another lane's claims in every .lane-state the whole-estate walk finds

#174 Copilot round 2 (lane-worktrees `Sweep.gather`): where the whole
estate is walked - a clone the table could take, or `--include-scratch` -
`walk_estate` finds every `.lane-state`, one in a plain directory inside
a repository included (`host/vendor/.lane-state`), but the sweep threw
that list away and read claims only beside the shape walk's checkouts and
the register's recorded directories. With no log naming that place, a
clone another lane claimed from there was removed. The walk's control
roots are now read for claims as well, before liveness and the table.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never push a lane's commits onto a branch gh still answers OPEN, whatever a LANDED line says

#174 Copilot round 2 (lane-worktrees `MergeEvidence.judge`, `_decide`):
a register LANDED line naming a pull request gh still answered OPEN, with
the local tip ahead of the PR's head, made the verdict "moved". That
verdict dropped gh's OPEN answer, so #170 A7's rule never fired and
push+remove put the lane's unreviewed commits onto the open pull
request's branch. The moved and merged-elsewhere verdicts now carry gh's
OPEN numbers, and a branch gh answers OPEN goes to a rescue branch, with
origin's branch left as it is.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never prune a gone worktree's registration whose git directory keeps a submodule repository with work

#174 Copilot round 2 (lane-worktrees `_decide`, `_act_prune`): a
registration whose directory is gone still keeps its submodules'
repositories under `<admin>/modules/`, and the prune - which read only
the superproject's HEAD and reflog - removed them, a branch no remote held
with them. The same submodule-repository read as the tree guard now keeps
such a registration (`keep`), and is asked again right before the prune.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a clone with a local git-lfs store when its commits would go to a bundle only

#174 Copilot round 2 (lane-worktrees `_act_tree`): a clean clone with a
commit only its reflog named had that commit bundled and was removed with
`.git/lfs/objects`. A bundle carries LFS pointers, never their bytes, and
no push uploaded them, so the bytes behind those pointers were lost. A
CLONE whose local LFS store holds anything (or cannot be read) is now kept
wherever a bundle would be the only copy of a commit - the `bundle+remove`
disposition and the reflog-only commits of step 2b alike.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count a suite's .lock or run.sh marker as provenance only when it names a pid

#174 Copilot round 2 (lane-worktrees `sandbox_provenance`): an EMPTY or
malformed `.lock` (or `.openrepotools-run`) counted as a suite's mark,
so a person's two-hour-old `tmp.*` with a checkout in it was removed -
past coordinator default 1's 14-day rule and the repository guard both. A
mark now proves a suite made the directory only when it names a pid; the
run-root layout proof (`basetemp/` beside `tmp/`) is unchanged.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's second Copilot round changes in the sweep

The manual's table and the command's own help now say that every
submodule repository a tree's or a gone registration's git directory keeps
- a deinitialized one's included - keeps it where it holds work no remote
of it holds, that a submodule's hidden edits keep the tree, that a
repository under a cache-named directory is still found, that the
whole-estate walk's `.lane-state` finds are read for claims, that gh's
OPEN wins over a LANDED line, that a clone's local git-lfs store keeps it
where a bundle would be a commit's only copy, and that a suite's mark must
name a pid.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a submodule repository kept under a git directory as that git directory alone, so a gone worktree's is read rather than refused

A submodule repository under `<gitdir>/modules/` names its working tree in
`core.worktree`. Where that directory is gone - every registration whose
worktree directory went, and a submodule removed after its deinit - every
git command run in the repository dies on "cannot chdir", so the guards
of this round's first and sixth commits read every such repository as
unreadable: a gone worktree with any submodule was kept for that reason
alone, never read. The proof run on the round's head showed it (the
prune's reason said the branches "could not be read" where the test
expected the unpublished branch named).

The submodule-repository reads now run each git command there with the
repository's own `objects/` as its working tree (`GIT_…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready landing gate: runs tests-macos once before the squash

Projects

None yet

2 participants