Repository navigation
lane-worktrees: the daily estate report, and nothing new lands beside the code (#162, part 2) - #169
Conversation
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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
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 |
Reviewer's GuideThis 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 reportsequenceDiagram
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
Flow diagram for workspace commit hygieneflowchart 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
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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
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.
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>
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>
There was a problem hiding this comment.
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
Open (7)
Pre-staged prohibited files bypass validation · New Unreadable sidecars are falsely treated as empty · New Generated setup command fails to quote cache paths · New Terminate and wait for pytest's entire process group Virtualenv site-packages rejection lacks a matching ignore rule Pass resolved projects root to delegated status command Renamed origin clones are incorrectly treated as canonical
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>
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>
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>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Report encoding, pathspec failure handling, and terminal process cleanup contain correctness gaps.
Review effort: Balanced
Findings: 5
Open (6)
Preserve non-UTF-8 bytes when reading .gitignore · New Use surrogateescape when writing the temporary report body · New Use surrogateescape for local report output · New Propagate Git inspection failures in pathspec-check · New Retain process-group state for terminal cleanup · New Document per-checkout-parent state locations · New
Resolved since last review (1)
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>
|
…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>
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>
…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…
…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 …
…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_…






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.tests/run.shleaves nothing beside the code.PYTHONPYCACHEPREFIX=${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,-p no:cacheprovider, and every temporary directory of a run (--basetempand the suite'sTMPDIR) 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 beforeTMPDIRmoves, so it is still the workstation's. The suite runs in its own process group, and the whole group is stopped before cleanup.openRepoTools --installnames${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.shruns from it once it has pytest.lane-worktrees sweep --all --dry-run --report [--post <file|owner/repo#n>]: the estate's leftovers as one markdown document.lane-startruns it once a day per workstation, detached, guarded by a stamp file. It changes nothing.wip initseeds the.gitignorelines.lanes-edit.sh's commit path refuses, exit 2 with nothing staged, a pathspec that would stage bytecode, a cache,node_modules, a venv orsite-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.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-statefor 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, soxFactory/xFactories/*are never found as checkouts andxFactory/xFactories/.lane-stateis never read. It also reads the workspace register onorigin/<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.Lane:owner, and LOAD-BEARING with its dependents.lane-worktrees/<lane>/*of a lane whose snapshot isCLOSEDor whose log endsENDED/RETIREDmainahead of origin. Where onlyhandoffs//lanes/paths moved, the Amendment 4 remedy is givenrescue/*branches and dirty inventory trees older thanaging_days(14), with owner and ageignored_report_mb(50)du -skper ignored directoryjunit*.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>/--expire's own table (now shared asexpiry_rows): what would expire, and what is kept because its rescue left origin.gitignorelines the workspace lacks (with the command that adds them), and bytecode already in its history (a person's word)status --allfindingsThe 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 takesreport-<YYYYMMDD>.stampwith an atomicset -Ccreate, 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-runstarts none, andLANE_WORKTREES_REPORT=offturns it off (the suite sets that).Where this differs from the issue's wording, and why
--expireis "run by the nightly report", but the brief requires a report that changes nothing. The report therefore prints--expire's dry run, and expiring stays the actsweep --expire --yes.Tests
tests/test_run_wrapper.py(new)pgrep. Checks bytecode prefix,-p no:cacheprovider,--basetempunder the run root, run root removed after the run, lock still under the caller'sTMPDIR. 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 pytesttests/test_lane_worktrees_report.py(new)main, 30-day rescue branch and dirty inventory tree, cache, killedtmp.*, ignoredbuild/, JUnit file, a reports directory, a would-expire and a kept archive, workspace.gitignore+.pycin history,status --all(pointed at the same estate, with or without--estate). Every section finds its own, and a whole-estate snapshot is unchanged.--postto a file and toowner/repo#n(fakegh).--all/--reportmisuse → 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,offand--dry-runstart nonetests/test_openrepotools_command.py--installnames the venv: "not created" with the two commands, then "present". Never creates one. The printed command survives a cache path with a spacetests/test_wip_init_command.py.gitignore= template + the ten lines, each once.git check-ignoreof an attachment.pyc. Re-run "nothing to do".wip_hygiene_linesrepeats no line the file has, and prints nothing when it has them all (×3)tests/test_lane_helpers.shpathspec-checkrefuses an attachment with__pycache__, avenv/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 untilsite-packages/is added. A clean pathspec passes.commitrefuses 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 bothpathspec-checkand the commit pathMutation-checked: with the nested-parent lookup removed, the report case fails on the nested orphan.
Locally (Eagle,
tests/run.shwas 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.py152/152, the venv case and both install idempotence cases, #168's sweep module 23/23, andbash -non 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..lane-stateon Eagle (B8, #170).xFactory/xFactories/.lane-stateexists, 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 treeopenRepoProject, 1 commit, 103 paths: not handoff-only).lane-state, B8)/tmp/pytest-of-brett/pytest-3507, 342 MB).dart_toolat 4.0 GB)brett-wip/.gitignorelacks 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 measuredstatus --allfindingsReview
Round 1 on #169 (Copilot, 4 threads), all taken:
da7bc59: the suite's whole process group is stopped (set -mfor 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, andstatus --allreads the estate the report resolved;lane-startpasses 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 at38e090ffailed only ontest_repo_hygiene's line caps (AGENTS.md 329/316, README.md 491/486; Linuxtestswas otherwise 963 passed). The full text lives indocs/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):
30cc63a: taken, because an unconditional flock-file assertion would have failed the macOS landing gate;stop_groupreaps the leader before the KILL deadline; ignored evidence FILES are skipped; the report's repair path is unquoted; the daily report's directory inherits the umask.The Opus adversarial review of
30cc63a(standing in for Codex, which is over quota): B7 taken in0cf1051. 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):--yesswitched off until lane-worktrees: review findings deferred from #168/#169 (incl. five --yes data-loss paths found after the two-round cap) #170;B8 (the report reads no
.lane-stateon 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:
lane-start, with optional file or issue posting and no impact on startup success.openRepoTools --install..gitignorefiles with rules covering generated bytecode, caches, dependencies, and virtual environments.Bug Fixes:
Enhancements:
Documentation:
Tests: