From c45a452a14a1c2e46d2008652547e9529e6cc154 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:28:26 +0000 Subject: [PATCH 01/23] docs: the triad split's lane-tooling side, surface by surface (T006, T007, T009, T010, T018) docs/triad-lane-tooling-analysis.md: for every lane tool, the installer and its receipt, the hooks, the manuals, the tests, CI and the brett-wip register/inventory/handoff data, what the assembly/spec/code split of opensoft/openRepoTools changes, at file:line on c4864ac, with the plan task each change belongs to and whether it is a patch or a proposal. No shipped command changes: no lane tool reads docs/, openspec/ or specs/ at run time (the adoption plan's cross-leg follow-ups for them are comments, usage text, a URL or the object-key grammar), and every fix the triad does need changes today's behaviour, so each is a proposal with a sketch. The suite's test roots (T009/T010) are lane 1's PR #190 (c139e88); this branch's earlier draft of them was withdrawn before commit, and the document records the overlap, one gap and one measured defect in #190 for lane 1. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/triad-lane-tooling-analysis.md | 541 ++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 docs/triad-lane-tooling-analysis.md diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md new file mode 100644 index 00000000..eec4e6fd --- /dev/null +++ b/docs/triad-lane-tooling-analysis.md @@ -0,0 +1,541 @@ +# The triad split, lane-tooling side: what moves, what breaks, what to change + +**Date:** 2026-10-07 (finished 2026-10-08 on base `c4864ac`) · **Lane:** openRepoTools-3 · **Branch:** `feat/triad-lane-tooling`, +made from `origin/main` at `c4864ac` · **Slice:** (d), the lane-tooling side of the +assembly/spec/code split of `opensoft/openRepoTools`. + +**Plan read:** branch `004-migrate-to-triad` at `ff6f6a7` (the head of PR #187), read with +`git show` only: `specs/004-migrate-to-triad/{plan,tasks,spec,installer-design,research,data-model,quickstart,verification,adopter-rehearsal}.md` +and `openspec/changes/migrate-to-triad/{adoption-plan.yaml,design.md}`. Brett Heap confirmed +the leg names and their public visibility on 2026-10-07: `openRepoTools-spec` mounted at +`spec/` and `openRepoTools-code` mounted at `code/`. + +Every `file:line` below is on main `c4864ac` unless it names another revision. Main moved +to `837928a` while this was finished (#187 landed the plan, its `specs/004-migrate-to-triad/` and +`openspec/changes/migrate-to-triad/` byte-identical to `ff6f6a7`; #188 the openRepoShape repin); +that changed only `specs/`, `openspec/`, `contracts/openreposhape-pin.yaml` and the +`upstream/openRepoShape` gitlink, so no line cited here from a tool or a test moved, and the +branch stays on `c4864ac`. A claim +marked **measured** was run on a throwaway fixture in this lane's scratch directory; one +marked **derived** follows from the code or from git's documented behaviour and was not run. +This document decides nothing. Plan tasks are T006 (root collisions and leg guidance), T007 +(installer entry point), T009 (separate roots in fixtures and hygiene), T010 (CI and pin +checks) and T018 (feature selection, park/resume and bindings). + +--- + +## 0. Summary + +1. **None of the shipped lane tools reads `docs/`, `openspec/` or `specs/` at run time.** The + adoption plan's cross-leg follow-ups for `lane-start`, `lanes-edit.sh`, `openRepoTools` and + `claude-current` (adoption-plan.yaml:325-328 and :334) are comments, usage text, a URL, or + the object-key grammar. Each tool finds its helpers **beside itself**: `$SCRIPT_DIR`, + `$selfdir` or `__file__`. All of those helpers move to `code/` together, so that lookup + works unchanged. The only code that really reads across legs is in the **tests**. Lane 1's + PR #190 (`feat/test-roots-for-the-triad` at `c139e88`) gives the suite those roots, so this + branch carries no test change of its own (§6). +2. **The installer is the most fragile surface.** The README one-liners and every + already-installed copy's own `openRepoTools --install` fetch files by **repository-root + path** from `opensoft/openRepoTools@main` (openRepoTools:70-71, :258-275, :1370-1394). Once + the root split lands, those paths sit under the `code` gitlink. The result: the + one-liners stop working, every installed copy's re-install **refuses** with nothing + placed, and `wip init` **silently** seeds from openRepoShape `main` instead of the pinned + commit (openRepoTools:2516). No patch made before cutover can avoid this without changing + today's behaviour. The T007 entry point has to land **in the root-split PR itself** (§4.10). +3. **Lanes keep working only if their `dir` stays at the assembly root**, and three things + change around them. First, `#n` expands against the assembly. Second, an OpenSpec claim + keyed on `opensoft/openRepoTools` searches the wrong repository's pull requests. Third, a + code-leg worktree can't be seen by the lane's sweep, the `lane-end` gate or the + `lane-handoff` poll unless it was made with `lane-worktrees add --checkout /code`. + The daily report never counts a submodule leg as a canonical checkout. And one live lane + outside this repository runs the **checkout copy** `~/projects/openRepoTools/lanes-edit.sh`, + which disappears from that path at the split (§4.4, §4.5, §4.15). +4. **The code leg needs its own `.gitattributes` and `.gitignore`.** Git applies no + superproject attribute to a path inside a submodule (**measured**, §4.13). The plan puts both + files at the root only. Two hygiene caps are also full: `AGENTS.md` is at its cap + (316/316) and `README.md` is at its cap (486/486). So T006's "add the AGENTS-shape line" + and "merge shape/README.md" fail the hygiene suite unless those caps are raised with dated + entries. + +--- + +## 1. Scope and method + +**In scope:** the lane tools (`lane-start`, `lane-end`, `lane-worktrees`, `lanes-edit.sh`, +`lanes-index`, `lane-handoff`, `lane-rename`, `lane`, `lanes`, `link-estates`, `repos.tsv`), +`openRepoTools --install` and `wip init`, the hooks it merges, the documents that name +checkout paths, the tests and CI that assume one root, and the register/handoff data in +`opensoft/brett-wip` that names `/workspace/projects/openRepoTools` (read, never written). + +**Out of scope:** the estate verbs `park`, `resume` and `status`. They find estates by +`$PROJECTS_DIR` and run `make park`/`make resume`, and they already know three-leg roots +(AGENTS.md rule 4). `claude-current` and `claude-restart-check` appear only where the +installer places them. + +**How:** I read every named file (the installer and `wip init` parts of `openRepoTools`, +`lane-start`, `link-estates`, `tests/conftest.py`, `tests/run.sh`, `.github/workflows/tests.yml` +and AGENTS.md in full; the large tools by their path-bearing sections). I grepped every shipped +file for self-location (`BASH_SOURCE`, `SCRIPT_DIR`, `selfdir`, `__file__`), cross-leg directory +names, hook and settings writes, and `project.yaml` readers. I measured git's behaviour around +submodules and worktrees on a fixture of three local bare repositories. I also read lane 1's +Gate B receipt in `brett-wip/migration/openRepoTools/adopter-fix-20261007-iM20tv/`, whose +standard table is 45 spec / 49 code / 6 root of 100 tracked paths at `c4864ac`. That matches +`git ls-files | wc -l` = 100 here. + +--- + +## 2. What the plan documents say, checked + +| Claim (where) | Checked on `c4864ac` | +| --- | --- | +| No change to lane-start, lane-worktrees, lane-end, link-estates or repos.tsv beyond placing them in code (adoption-plan.yaml:163-230, 259-268) | True of the placement. Not enough for the bindings: see §4.4, §4.5, §4.15. `lane-worktrees` and `lanes-index` are absent from the committed plan (it predates them) and present in lane 1's regenerated rehearsal plan as code. | +| lane-start and lanes-edit.sh name `docs/` (adoption-plan.yaml:326-327); lanes-edit.sh names `openspec/` (:334) | They are named, but never read: §3. | +| lane-handoff names `openspec/` and the follow-ups omit it | lane-handoff:2439 is a comment citing `openspec/changes/add-supervised-context-restart/design.md`. It is not a read. | +| lane-start reads estate `project.yaml` legs (lane-start:1271-1293); lanes-edit.sh too (lanes-edit.sh:5039-5121) | True. Both readers already handle an assembly like openRepoTools's: §4.2, §4.5. | +| repos.tsv:52 has only `openRepoTools` | True. There are no `-spec`/`-code` rows. | +| Paired layout `worktrees//{spec,code}` (plan.md:207-208) has no code on main | True. No lane tool makes, polls or claims a tree there: §4.4. | +| installer-design.md: resolve, select, delegate; one immutable code identity checked against `contracts/code-pin.yaml`; `fetch_from_repo` :258, `collect_commands` :962, `collect_skills` :1370, `wip_shape_ref` :2502, `wip_fetch_template_file` :2519; REPO/REF :70-71 | Every line number holds. `code-pin` has 0 hits in tracked files. | +| installer-design.md:61-64 counts 15 names / 27 files / 29 artifacts | **Stale.** `INSTALLABLES` (openRepoTools:338) holds **17** names. With 3 skills and 3 command files at 2 paths each, that is **29 placed files + 2 hook entries = 31 artifacts**, which is the installer's own statement (openRepoTools:389-396). Placing by rename (#172, `3660224`) landed after the design. It changes the placement mechanics, not the sources. | + +--- + +## 3. The adoption plan's cross-leg follow-ups, line by line + +Lane 1's Gate B receipt (`logs/runB-cross-leg-grep.log`) lists file names only. Each hit +falls into one of these kinds: **comment**, **usage** (text a command prints), **URL**, +**key** (an object key, not a path), **fixture** (data a test builds), or **READ** (a file +opened at run time). + +| Follow-up | Hit on `c4864ac` | Kind | Change needed | +| --- | --- | --- | --- | +| claude-current names `docs/` (:325) | claude-current:89 | comment | none | +| | claude-current:123 | usage (`--help` prints `docs/README-claude-current.md`) | Text only: the path is repository-relative, so after the split it means `spec/docs/…` from the root or `../spec/docs/…` from `code/`. Changing it changes `--help`'s bytes, so this is a **proposal** for the T014 docs batch, not a patch. | +| lane-start names `docs/` (:326) | lane-start:294, :617 | comment | none | +| lanes-edit.sh names `docs/` (:327) | lanes-edit.sh:10387 | comment | none | +| openRepoTools names `docs/` (:328) | openRepoTools:1218 (comment); :1342 (URL `code.claude.com/docs/en/hooks`) | comment, URL | none | +| tests/run.sh names `docs/` (:329) | tests/run.sh:36 | comment | none | +| test_install_skill_and_hook.py (:330) | :73, :1339 | URL | none | +| test_lane_helpers.sh names `docs/` (:331) | :3738, :6228 `cat "$SRC_DIR/docs/README-lanes.md"` | **READ** | #190: `LANE_MANUAL`, skipped when the run has no spec root | +| test_lane_start_claude_current.py (:332) | :15 | docstring | none | +| "… and 1 more" (:333) = test_repo_hygiene.py | :1183, :1214-1217, :1233, :2390, :2624 | **READ** | #190: `doc()` → `ROOTS.path_for` | +| lanes-edit.sh names `openspec/` (:334) | :280, :2770 (comments); :2814-2818, :2895-2897 (grammar `owner/repo:openspec/changes/`); :12110 (comment) | key | No path change. The **repository half** of the key changes meaning: §4.5. | +| test_lane_helpers.sh names `openspec/` (:335) | :1280, :2086, :3781 (object keys of a fake `opensoft/repoE`); :13343 (comment) | fixture, comment | none | +| test_park_resume_commands.py names `specs/` (:336) | :350, a `feature_directory: worktrees/…/spec/specs/…` string in a three-leg record the test writes | fixture | none | +| (not listed) lane-handoff names `openspec/` | lane-handoff:2439 | comment | none | + +--- + +## 4. Surface by surface + +### 4.1 How every tool finds its helpers (all tools) + +| Tool | Self-location (`c4864ac`) | Helpers found beside itself | +| --- | --- | --- | +| lane-start | :339-341 `SCRIPT_DIR` from `readlink -f` | lanes-edit.sh :600-604, claude-current :650, lanes :868, lane :917, lane-worktrees :4307 | +| lane-end | :259-261 | lanes-edit.sh :520, lane-worktrees :532 | +| lanes-edit.sh | :634-639 | lanes-index :2256, repos.tsv :2453, lane-start :9189 | +| lane-handoff | :119-120 | lanes-edit.sh :399, lane-start :685 | +| lane / lanes / lane-rename | lane:114-115 / lanes:102-103 / lane-rename:73-74 | lanes-edit.sh (lane:188, lanes:191, lane-rename:155), lane-start (lane:1166) | +| lanes-index | :325 `dirname(realpath(__file__))` | lanes-edit.sh | +| lane-worktrees | :441, :5278 | lanes-edit.sh, status | +| openRepoTools | :72 `SELF`; :971, :1372, :2504, :2521 `selfdir` | the 17 installables, `skills/`, `commands/`, `contracts/openreposhape-pin.yaml`, `upstream/openRepoShape/templates/…` | + +**Under the triad:** every one of these helpers is code-leg content (adoption-plan.yaml:107-323), +so `code/` finds `code/` exactly as `` finds `` today. Installed +copies in `~/.local/bin` are unaffected. **No change; T006 confirms it.** + +### 4.2 `lane-start` + +| Line | What it does | Under the triad | +| --- | --- | --- | +| :608 | `PROJECTS_ROOT=${PROJECTS_ROOT:-$HOME/projects}` | unchanged | +| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir /workspace/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | +| :1118-1128 | "the harness keys a session to the directory it was started in" (Evidence 3) | **This is why a lane's `dir` should stay at the assembly root.** Re-homing a lane into `code/` changes the Claude project folder (`-workspace-projects-openRepoTools` → `-workspace-projects-openRepoTools-code`). That strands the lane's memory, and `--resume ` stops finding the transcript. **T018 proposal:** bindings keep `dir` = assembly root, and nothing is rebound. | +| :1180-1199 | The home is written from the directory's `origin` | At the assembly root the origin is still `opensoft/openRepoTools`, so the home is unchanged. | +| :4294-4340 | Starts the daily report (`lane-worktrees sweep --all --dry-run --report`) for `$PROJECTS_ROOT` | Inherits the report's blind spot: §4.4. | +| hooks | lane-start writes **no** hook and no `settings.json` (`grep` finds only comments at :142, :1783) | none | + +**Patch:** none needed. **Proposal (T018):** the bindings decision above, plus a fixture test +that runs rung 5 against an openRepoTools-shaped manifest (§7). + +### 4.3 `lane-end` + +The close-out gate (:195-211, :1908-2045) runs `lane-worktrees sweep --branches +--include-scratch --include-caches --dry-run --porcelain`. It then reads every tree with +`git status --ignored` (:1927-1945) and every entry under `${dir%/*}/.lane-worktrees/` +(:2024-2045). Its roots and its trees are lane-worktrees'. **Under the triad it is as blind as +the sweep (§4.4).** A lane with unpushed work in a paired `worktrees//code` tree, or +in any code-leg tree it never recorded, passes the gate. **Patch:** none. **Proposal:** the +lane-worktrees roots change in §4.4 closes this too. + +### 4.4 `lane-worktrees`: sweep inventory, `add`, and the daily report + +**Measured** on a fixture (assembly `A` with `code` and `spec` submodules, and a worktree +of the code leg at `A/worktrees/001-x/code`): + +- `git -C A/code rev-parse --show-toplevel` → `A/code`, and `--show-superproject-working-tree` → `A`; +- `A/code/.git` is a **file**, `gitdir: ../.git/modules/code`; +- `git worktree add` inside the submodule succeeds (git 2.43); +- `git -C A worktree list` lists **only `A`**. The code leg's tree appears only under + `git -C A/code worktree list`, whose main entry is spelled `A/.git/modules/code`; +- `git -C A status` shows `?? worktrees/`. The shape's assembly `.gitignore` template + (`templates/assembly-root/.gitignore` at the pinned openRepoShape) does not ignore it. + +| Line | What it reads | Under the triad | +| --- | --- | --- | +| :2165-2173 `_roots` | `/.claude/worktrees` and `${dir%/*}/.lane-worktrees/` | Unchanged for `dir` = assembly. The paired `A/worktrees//{spec,code}` is **in neither root**. | +| :2175-2248 `_discover` | inventory sidecars + `git worktree list` of the lane's checkout and of every inventoried `checkout` | A code-leg tree is seen **only if** an inventory row names `checkout: /code`. A tree made by `make resume`/Speckit in the paired layout is never seen. | +| :2122-2163 `_lane_claims` | trailer claims under `.claude/worktrees` only | Paired trees are never claimed. | +| :2709-2713, :2759-2763 | scratch and caches under `.lane-worktrees/` | unchanged | +| :5381-5557 `add` | `git worktree add` of `--checkout` or the lane's `dir`, at `${dir%/*}/.lane-worktrees//`, then `set-lane-tree … --checkout ` | Without `--checkout`, it makes a worktree of the **assembly**, with empty, uninitialised `code/` and `spec/` (a worktree does not initialise submodules). **With `--checkout /code` it works today:** `:5448-5451` accepts it because `--show-toplevel` is the submodule root (measured above). | +| :1091-1125 `estate_repos` | walks into a repository only through `worktrees`, `.worktrees`, `.claude`, or a child with `.git` | `A/worktrees/` has no `.git`, so `A/worktrees//code` is never reached (derived). | +| :4884-4907, :4887 `Report.build` | `clones = [r … if os.path.isdir(r/.git)]` → canonical checkouts → branches, root-main, aging | A leg's `.git` is a file, so **a submodule leg is never canonical**. The code leg's branches, which are where all lane-tooling work happens after the split, never reach the daily report. This is already true today for every leg of IRRS, IRSS, MedxEHR and openDox on this workstation. | +| :4760-4770 `_under_container` | `worktrees`, `.worktrees`, `*-worktrees`, `.lane-worktrees/` | Recognises `A/worktrees`. Unaffected. | + +**Patch:** none. Every fix changes what the sweep or the report says **today** about the +estate's existing triads, and the brief rules out behaviour changes. **Proposals (T018):** + +1. **Guidance, no code:** under the triad a lane makes a code tree with + `lane-worktrees add --checkout /code`. That records the tree + with the code leg as its checkout, so every reader above sees it. +2. `_roots` adds `/worktrees` when `/project.yaml` names legs. Trees there are + claimed only by the HEAD `Lane:` trailer rule `_lane_claims` already applies to + `.claude/worktrees`, because that directory is per-feature, not per-lane. The same root + goes into `lanes-edit.sh:12699-12705` (`lane_worktree_roots`) and + `lane-handoff:1994-1996`, so the three readers cannot disagree. +3. `Report.build` treats a checkout whose `.git` is a file pointing into + `/.git/modules/` as canonical when the superproject is canonical. + This changes today's report for IRRS and the other existing triads, so it needs a ruling. + +### 4.5 `lanes-edit.sh` + +| Line | What it does | Under the triad | +| --- | --- | --- | +| :2754-2765 `alias_lookup`; :2776-2847 `canon_object` | resolves a key's repository through repos.tsv. A spelled `owner/repo` is **always accepted** (:2834) | `opensoft/openRepoTools-code#n` works with no repos.tsv row. The bare `openRepoTools-code#n` is refused until the row exists (§4.9). | +| :2791-2813 | `#n` expands against the lane's **home** | Home stays `opensoft/openRepoTools`, so `#n` always means the **assembly's** issue or PR. The code leg numbers its PRs from #1, so `#3` from an openRepoTools lane is the assembly's #3, never the code leg's. **T018 proposal:** code-leg and spec-leg objects are spelled in full. The lane-collision protocol text should say so. | +| :2814-2818, :2895-2897 | `owner/repo:openspec/changes/` keys | Rule 1's sibling reads run `gh pr list --repo --search ` (:10249-10272). A change keyed on `opensoft/openRepoTools` therefore searches the **assembly's** PRs, while spec PRs land in `openRepoTools-spec`. **T018 proposal:** at cutover, each open claim on `opensoft/openRepoTools:openspec/changes/` is released and re-claimed as `opensoft/openRepoTools-spec:openspec/changes/`, through `lanes-edit.sh release`/`claim`. The log is append-only and is never rewritten. | +| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `/workspace/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | +| :12555-12595 `tree_id_for`, `lane_tree_put` | sidecar per tree: absolute `path`, `checkout`, branch, head … | Formats are unchanged. Rows written before cutover stay **true**: they describe worktrees of the monorepo history, which is the assembly's own history (FR-001). | +| :12699-12705 `lane_worktree_roots` | `/.claude/worktrees`, `${dir%/*}/.lane-worktrees/` | Same gap as §4.4, proposal 2. | +| :16791-16829 `set-lane-tree` | takes an absolute path and an **unvalidated** `--checkout` | Records a code-leg tree today: `--checkout /code`. | +| :14973-15030 `session-start`, `guard` | the two hook verbs; the guard compares window, session name and register row, never the cwd | unchanged (§4.11) | +| :594 `LANES_LANE_DIR` | leg detection from the lane's checkout | unchanged | + +**Patch:** none. A behaviour change in object keys or roots needs a ruling. **Proposals:** as +in the table. + +### 4.6 `lanes-index` + +It reads the register through `lanes-edit.sh` beside itself (:325) and keeps its store and +config under XDG (`:160-166`). It has no repository-relative path. **No change.** + +### 4.7 `lane-handoff` + +Helpers beside itself (:119-120, :399, :685). The writers poll covers +`$dir/.claude/worktrees/*` and `$parent/.lane-worktrees/$lane/*` (:1892-1996), and has the +same gap as §4.4. The only `openspec/` reference is a comment (:2439). **No change; one +proposal (§4.4, item 2).** + +### 4.8 `lane`, `lanes`, `lane-rename` + +`lanes` with no arguments narrows to "the checkout around the cwd" by its +`git rev-parse --show-toplevel` and that checkout's `origin` (lanes:394-420, lane:440-460). +**Under the triad** a person standing in `code/` gets `--dir /code --repo +opensoft/openRepoTools-code --prefix openRepoTools-code`. No lane matches, and it offers +`lane-start openRepoTools-code 1` (derived). From the assembly root it is unchanged. +`lane-rename` has no path of its own. **Proposal (T018):** when `--show-superproject-working-tree` +is non-empty and that superproject's `project.yaml` names the toplevel as a leg, narrow to the +superproject's directory and origin. This changes today's output inside IRRS/IRSS legs, so it +needs a ruling. Patch sketch: + +```bash +here_root="$(git rev-parse --show-toplevel 2>/dev/null || :)" +super="$(git rev-parse --show-superproject-working-tree 2>/dev/null || :)" +if [ -n "$super" ] && [ -f "$super/project.yaml" ]; then here_root="$super"; fi +``` + +### 4.9 `link-estates` and `repos.tsv` + +`link-estates` finds the workspace through `workspace.yaml` and never from its own location +(:89-295). It links `~/projects//handoffs` → `/handoffs/` for each +estate directory that exists (:399-409), and `~/projects/xFactory/lanes-edit.sh` → +`$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh` (:421). **Under the triad:** +`/workspace/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, +and it stays one at the assembly root. The hook link points at the installed copy. **No +change.** + +`repos.tsv`:52 maps `openRepoTools` only. Its own rule (repos.tsv:16-23) is that a row is +added "when a new repo first appears in a LANDING line", resolved with `gh repo view`. Rows +for repositories that do not exist yet would break that rule. **Proposal (cutover, after Gate +C creates the legs):** two rows, `openRepoTools-code opensoft/openRepoTools-code` and +`openRepoTools-spec opensoft/openRepoTools-spec`, in the code leg's `repos.tsv`. + +### 4.10 `openRepoTools --install`, `wip init` and the `installed.tsv` receipt + +**Where each artifact comes from today.** `SELF` is this file (openRepoTools:72). When it is a +file, `selfdir` is its directory. + +| Artifact | Local source (when `SELF` is a file) | Remote source | Code | +| --- | --- | --- | --- | +| The 17 `INSTALLABLES` (openRepoTools, park, resume, status, lane, lanes, lane-handoff, lane-rename, lanes-edit.sh, lane-start, lane-end, link-estates, repos.tsv, claude-current, claude-restart-check, lanes-index, lane-worktrees) | `$selfdir/` | `fetch_from_repo `: `gh api repos/$REPO/contents/?ref=$REF`, else `raw.githubusercontent.com/$REPO/$REF/` | :338, :962-990, :258-275 | +| 3 skills × 2 destinations | `$selfdir/skills//SKILL.md` | `fetch_from_repo skills//SKILL.md` | :1289-1290, :1370-1381 | +| 3 command files × 2 destinations | `$selfdir/commands/.md` | `fetch_from_repo commands/.md` | :1302-1303, :1387-1394 | +| `SessionStart` entry | constant `~/projects/xFactory/lanes-edit.sh session-start \|\| true` | none | :1320, :1324-1333 | +| `UserPromptSubmit` guard | constant `~/projects/xFactory/lanes-edit.sh guard` | none | :1352, :1355-1363 | +| `wip init` pin | `$selfdir/contracts/openreposhape-pin.yaml` | `fetch_from_repo contracts/openreposhape-pin.yaml`, **else the literal `main`** | :2502-2517 | +| `wip init` templates | `$selfdir/upstream/openRepoShape/templates/workspace-root/…` | `gh api`/raw from `opensoft/openRepoShape` at the pin | :2519-2544 | + +**The receipt** (`installed.tsv`, rows written at openRepoTools:561-598) is +` `. It records **no source**, so a code-leg source changes +none of its rows. Readers accept four **or more** fields (`receipt_rows_except` :641-645, +`receipt_verdict` :808-819). A fifth column for the code identity, which T008 asks to be +recorded ("record its actual code identity"), can be appended without breaking an older +reader. + +**What happens in each context once the root split has landed** (derived; the GitHub +behaviour for paths under a gitlink is to be measured in T008): + +| Context | Today | After the split, with no T007 | +| --- | --- | --- | +| a. `code/openRepoTools --install` in an assembly checkout | — | `selfdir` = `code/`, all 23 files local. **Works unchanged.** | +| b. `./openRepoTools --install` at the assembly root | the monorepo file | No file of that name until T007. | +| c. README one-liners (README.md:228, :236) | fetch root `openRepoTools`, which then fetches the rest by root path | The root path `openRepoTools` 404s until T007 exists. With a T007 entry point that only `exec`s the code installer, the code installer still fetches `park`, `skills/…` and the rest from `$REPO@$REF` = the **assembly**, where those paths are under `code` and 404. **The entry point must also hand the delegate one immutable code identity.** | +| d. Installed copy `~/.local/bin/openRepoTools --install` | `selfdir` = `~/.local/bin`, so it copies the **17 already-installed files over themselves** (:976-980; an installed copy never updated its commands) and fetches skills and commands from `opensoft/openRepoTools@main` | `collect_skills` gets a 404 and returns 1. `install_commands` dies at :1125 with "could not fetch one of the 3 skills…", **exit 2, nothing placed**. A safe refusal, but on every workstation. | +| e. Installed copy `openRepoTools wip init` | pin fetched from the monorepo | `contracts/openreposhape-pin.yaml` is not at the assembly root (the assembly has `contracts/{shape,spec,code}-pin.yaml`), so `wip_shape_ref` prints **`main`** (:2516). **Silent drift**: templates come from openRepoShape `main`, not from the pin. | +| f. `--version` | `openRepoTools (opensoft/openRepoTools @ main)` | unchanged text, now naming the assembly | + +**Why no patch made now can save (d) and (e).** The population that would need the fix is +every copy already installed. Those copies get new code only from an install that succeeds, +and the only one that can succeed after cutover is (a) or the T007 one-liner. A +pre-cutover change that probes `contracts/code-pin.yaml` first would add a network request to +every remote install today. It would also have to tell a 404 from a transport failure +(installer-design.md:31-33), which `fetch_from_repo` cannot do: it returns a bare status +(:258-275). That is T007 proper. + +**T007 proposal, as a sketch** (bash 3.2; root file `openRepoTools`; it delegates and adds no +install mechanics): + +```bash +#!/usr/bin/env bash +# openRepoTools (assembly root): resolve ONE code identity, then run the code leg's installer. +set -euo pipefail +REPO="${OPENREPOTOOLS_REPO:-opensoft/openRepoTools}"; REF="${OPENREPOTOOLS_REF:-main}" +SELF="${BASH_SOURCE[0]:-$0}" +die() { printf '\nREFUSED: %s\n' "$1" >&2; exit 2; } +# 1. A local adopted checkout: the gitlink and contracts/code-pin.yaml must agree. +if [ -f "$SELF" ]; then + here="$(cd -- "$(dirname -- "$SELF")" && pwd)" + if [ -f "$here/project.yaml" ]; then + link="$(git -C "$here" ls-tree HEAD code | awk '$2 == "commit" { print $3 }')" + pin="$(sed -n 's/^commit:[[:space:]]*"\{0,1\}\([0-9a-f]\{40\}\)"\{0,1\}[[:space:]]*$/\1/p' "$here/contracts/code-pin.yaml")" + [ -n "$link" ] && [ "$link" = "$pin" ] || die "code gitlink ($link) and contracts/code-pin.yaml ($pin) disagree" + [ "$(git -C "$here/code" rev-parse HEAD 2>/dev/null)" = "$pin" ] || die "code/ is not checked out at the pin $pin: git submodule update --init code" + exec "$here/code/openRepoTools" "$@" + fi +fi +# 2. Remote: REF resolved ONCE; the pin read at that commit; the gitlink checked; the code +# installer run with the public variables naming the code identity, so every payload it +# fetches comes from that one commit (gh first, curl second, as fetch_from_repo does). +commit="$(gh api "repos/$REPO/commits/$REF" --jq .sha)" || die "…" +# … read contracts/code-pin.yaml at $commit (source_repository, commit), check the gitlink +# `gh api repos/$REPO/contents/code?ref=$commit --jq .sha` equals it, fetch +# openRepoTools from $code_repo at $code_commit into a temp file, then: +OPENREPOTOOLS_REPO="$code_repo" OPENREPOTOOLS_REF="$code_commit" exec bash "$tmp" "$@" +``` + +The **code installer** then needs installer-design.md's code-side resolution for context (d), +because an installed copy defaults `REPO` to the assembly again: before `collect_skills`, if +`$REPO@$REF` carries `contracts/code-pin.yaml`, payloads come from the pinned code repository +and commit. A pin that is present but malformed refuses. Only an authoritative 404 means a +legacy monorepo. **Landing order:** the entry point has to be in the root-split PR's tree. If +it lands later, the README one-liners are broken for everyone in between. + +### 4.11 Hooks + +Only `openRepoTools --install` writes hooks: two entries merged into +`$CLAUDE_USER_DIR/settings.json` (`plan_hook_merge` :1760-1880, `place_skill_and_hook` :1976). +Both commands name `~/projects/xFactory/lanes-edit.sh`, which `link-estates` points at the +**installed** `lanes-edit.sh` (link-estates:421). `lane-start` and every other tool write no +hook (grep: comments only). `lanes-edit.sh guard` (:15000-15030) compares window, session +name and register row, never the cwd or the repository layout. **No change.** + +### 4.12 Documents that name checkout paths + +| Where | What | Under the triad | Task | +| --- | --- | --- | --- | +| README.md:157 | `docs/README-lanes.md`, `docs/README-claude-current.md` | From the root README these are `spec/docs/…` | T006 / T014 docs batch | +| README.md:228, :236 | the one-liners for the root `openRepoTools` | stay byte-identical (`RAW_INSTALL_LINE`, test_repo_hygiene.py:105-107, is printed by openRepoShape's `--install`); they need T007 | T007 | +| README.md:417-449, :467-468 | pin-bump procedure with `upstream/openRepoShape`, `contracts/openreposhape-pin.yaml` | From the root these are `code/upstream/…`, `code/contracts/…`, or the procedure moves to the code leg's guidance | T006 | +| AGENTS.md:22, :44 | `docs/README-lanes.md`, `openspec/changes/skip-no-lane-guard/` | `spec/…` from the root | T006 | +| AGENTS.md:200-224, :233, :247-250, :285, :291-292 | `upstream/openRepoShape`, `contracts/openreposhape-pin.yaml`, `git submodule update --init upstream/openRepoShape`, `tests/run.sh`, `.github/workflows/tests.yml`, `tests/test_lane_helpers*.{sh,py}` | All are code-leg paths: from the root, `cd code` first | T006 | +| docs/README-lanes.md:573-594, :3979-3997 | `tests/run.sh` | code-leg path | T006 | +| docs/README-lanes.md:1793 | example `STARTED` line, `dir ~/projects…` | an example; true as long as the assembly stays at its path | none | +| docs/README-claude-current.md:172 | "looks for `claude-current` beside itself" | true in `code/` | none | +| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: /workspace/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | + +**Hygiene caps that T006 will trip:** test_repo_hygiene.py:1735-1736 caps AGENTS.md at 316 +lines, and it **is** 316. test_repo_hygiene.py:2232-2233 caps README.md at 486, and it **is** +486. The adoption follow-ups "add the line `Read AGENTS-shape.md first…` to the existing +AGENTS.md" (adoption-plan.yaml:339) and "merge shape/README.md into README.md" (:341) each +add lines. Each cap needs a dated entry in the same PR, following the convention the test's +docstring keeps. + +### 4.13 Tests + +Lane 1's **PR #190** (`feat/test-roots-for-the-triad` at `c139e88`, based on `c4864ac`, +T009/T010) is the suite's roots. It was read with `git diff c4864ac..c139e88 -- tests/` and +was not run here. This table says, for each assumption, what #190 does about it. + +| Test | Assumption (`c4864ac`) | Under the triad | Change | +| --- | --- | --- | --- | +| tests/conftest.py:29 | `REPO = parents[1]`, one root | `REPO` is the code root, and stays so | #190: `ROOTS = resolve_roots(os.environ, SUITE_ROOT)`, `REPO = CODE_ROOT`, `ROOTS.path_for(rel)`. The roots come from `OPENREPOTOOLS_{CODE,ASSEMBLY,SPEC}_ROOT`, or are found through `discover_assembly` (`contracts/code-pin.yaml` + `project.yaml` + the gitlink). `OPENREPOTOOLS_COMPOSED=1` is the composed mode. | +| tests/conftest.py:48-58 | `UPSTREAM = REPO/upstream/openRepoShape` | code leg, unchanged (FR-004) | #190: `UPSTREAM = DEPENDENCY_ROOT`. Standalone it skips as today; composed it fails. | +| test_repo_hygiene.py: 23 reads of README.md, AGENTS.md, CLAUDE.md, docs/README-lanes.md (:849, :860, :887, :911, :918, :942, :954, :957, :988, :1010, :1012, :1038, :1048, :1183, :1217, :1221, :1233, :1246, :1735, :2232, :2390, :2624, :3074) | `REPO / …` | README, AGENTS and CLAUDE are assembly files; README-lanes is a spec file | #190: `doc()` and `code_first()` | +| test_repo_hygiene.py:234, :2453, :2650, :2691 | `git ls-files` at REPO | The code leg's own index only | #190: `tracked_files()` over `ROOTS.tracked_roots()`. The composed run also scans the assembly and the spec leg. | +| test_repo_hygiene.py:2681 | `REPO/.gitattributes` carries `* text=auto eol=lf` | **Fails in the code leg**, because the plan puts `.gitattributes` at the root only (adoption-plan.yaml:41-48). Git applies **no** superproject attribute inside a submodule: **measured**, `git -C A/code check-attr -a lane-start` prints nothing while `git -C A check-attr -a project.yaml` prints `text: auto`, `eol: lf`. The installer's `cmp -s` byte checks and test_openrepotools_command.py's byte comparisons depend on that rule. | #190 reads it from the code root **deliberately**, for the same reason. That makes the **T006** proposal mandatory: the code leg carries its own `.gitattributes` (`* text=auto eol=lf`) and `.gitignore` (`__pycache__/`, `*.py[cod]`, `.pytest_cache/`, as at the root today). Without the ignore file, a bare `python3 -m pytest` in `code/` leaves the submodule dirty in the assembly's status. | +| test_openrepotools_command.py:1398 | README.md | assembly | #190: `ROOTS.path_for("README.md")` | +| test_install_*.py, test_wip_init_command.py:42-43, test_upstream_pin.py:28, :102 | `openRepoTools`, `skills/`, `commands/`, `upstream/…`, `.gitmodules`, `contracts/…` at REPO | all code-leg paths | none needed; #190 also edits test_upstream_pin.py | +| tests/test_lane_helpers.sh:50, :3738, :6228 | `SRC_DIR` = repo root; manual read from `$SRC_DIR/docs/README-lanes.md` | The read comes back **empty**. 13 `has` assertions then fail (:3739-3744, :6229-6239, :6580), and 3 `hasnt` assertions pass with nothing to check (:3745, :6241, :6579) | #190: `LANE_MANUAL="${OPENREPOTOOLS_LANE_MANUAL-$SRC_DIR/docs/README-lanes.md}"`. The two quoting blocks are skipped, by name, when it is set and empty. **Not covered:** the third read, inside the `--retire` block (:6579-6580 on `c4864ac`, :6595-6596 in #190), still reads `$ln_doc` after #190's `if`/`else`. With no spec root `ln_doc` is never assigned, and the suite runs under `set -u` (:42), so the whole shell suite **aborts** there with `ln_doc: unbound variable`. **Measured** on the same construct: `bash -c 'set -uo pipefail; has() { :; }; has d "$ln_doc" x; echo after'` prints that error, exits 127 and never reaches `after`. This happens only in a standalone code leg; today's layout is unaffected. | +| tests/test_lane_helpers_suite.py:157-191 | runs the shell suite at REPO | needs the spec root handed to it | #190: `OPENREPOTOOLS_LANE_MANUAL` from `ROOTS.root_of("spec")` | +| tests/run.sh:75-80, :235-236 | cds to the repo root; venv named `openRepoTools` | code root; the venv name is a constant | #190: makes relative `OPENREPOTOOLS_*_ROOT` absolute before the `cd` | + +**One gap in #190 that the lane tooling's layout raises** (read, not run): +`discover_assembly` stops at the first directory above the code checkout that carries the +assembly markers, and requires that directory to mount this checkout. A code worktree in +plan.md's paired layout, `/worktrees//code`, is therefore a leg with no +assembly. Its manual and front-door reads **skip** unless the roots are named, although the +paired spec worktree sits beside it at `../spec`. + +### 4.14 CI (`.github/workflows/tests.yml`) + +Every job checks out **this repository at its root** (`actions/checkout`, :55, :83, :129, +:184, :235, :291), with `submodules: true` (:57, :85, :186, :293) or `false` (:131, :237). +In the code leg the same file runs the code leg's own tests, with the nested +`upstream/openRepoShape` intact. With no spec or assembly context, #190's document reads +skip in a standalone run. Composed (`OPENREPOTOOLS_COMPOSED=1`), the run is refused before its +first test. #190 changes no workflow. The bash-3.2 list (:254-272) and `test_the_macos_job_parses_every_bash_file…` +(test_repo_hygiene.py:166-194) are code-relative and unaffected. + +**T010 proposal, as a sketch:** each job that runs the full suite adds two read-only +checkouts and names them: + +```yaml + - uses: actions/checkout@ + with: { path: code, submodules: true } + - uses: actions/checkout@ + with: { repository: opensoft/openRepoTools-spec, ref: , path: spec } + - uses: actions/checkout@ + with: { repository: opensoft/openRepoTools, ref: main, path: assembly, submodules: false } + - run: bash tests/run.sh -k 'guard_launch_mode or repo_hygiene' + working-directory: code + env: + OPENREPOTOOLS_SPEC_ROOT: ${{ github.workspace }}/spec # #190's names + OPENREPOTOOLS_ASSEMBLY_ROOT: ${{ github.workspace }}/assembly + OPENREPOTOOLS_COMPOSED: "1" +``` + +"Matching" is lane 1's decision: the spec commit the assembly's `contracts/spec-pin.yaml` +names at assembly `main`, or the paired feature branch of the same name. `tests-no-submodule` +(:127-149) keeps proving that a fork's first run skips rather than fails. The assembly's own +`validate.yml` from the shape checks manifest and pins. + +### 4.15 The register, logs, inventory and handoffs in `brett-wip` (read only) + +`grep -rcE '/workspace/projects/openRepoTools|~/projects/openRepoTools|\$HOME/projects/openRepoTools'` +at brett-wip `c311a78`: + +| File | Hits | What they are | Carry-over proposal | +| --- | ---: | --- | --- | +| lanes/LANES.md | 0 | Rows carry home `opensoft/openRepoTools` | none: the assembly keeps the name | +| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir /workspace/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | +| `/workspace/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: /workspace/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | +| handoffs/openRepoTools/session-handoff-2026-09-13-lane-openRepoTools-3.md, migration-handoff-2026-10-06.md, …-2026-10-02-…-2.md, …-2026-09-16-…-1.md | 56, 42, 26, 24 | historical reads and paths | none: each live lane's next Rule 3 top block restates the current paths | +| handoffs/xFactory/session-handoff-2026-09-05-lane-opsXfactory-1.md | 11 | **"register writes via … `~/projects/openRepoTools/lanes-edit.sh`"**, the **checkout copy** | **Act before cutover:** this lane is live (PAUSED 2026-10-07T11:12:58Z, swap). That path disappears when the live checkout pulls the assembly main. Point its next handoff at the installed copy (`~/.local/bin/lanes-edit.sh` or `~/projects/xFactory/lanes-edit.sh`), and tell its owner. | +| handoffs/xFactory/attachments/opsXfactory-1/land-444.sh | 2 | `LANES_EDIT="${LANES_EDIT:-$HOME/projects/openRepoTools/lanes-edit.sh}"` (:329) | Same. A script that defaults to the checkout copy breaks at the split. | +| handoffs/xFactory/attachments/opsXfactory-1/briefs/DELTA-land-{444-r2,444-r4,444-r5-MERGE-WORKER,462}.md | 1 each | the same checkout-copy path in briefs | same | +| handoffs/xFactory/session-handoff-2026-09-0{2,5}…, -09-10b…, -09-12… (5 files); lanes/log/openxfactory-1.md (4), openXfactory-5.md (1), openXfactory-3.md (1) | 1-4 | mentions of the checkout and of its `lanes-edit.sh` | historical. Check whether any live lane still runs the checkout copy. | +| migration/openRepoTools/prep-20261007T100559Z/{worktrees.tsv, inventory-raw.json, status-openRepoTools.txt}; adopter-fix-20261007-iM20tv/{README.md, logs/lane-worktrees-add.log} | 9, 9, 1; 1, 1 | migration evidence | none: evidence of its time | + +Outside brett-wip, nothing calls a tool by its checkout path. `~/.claude/settings.json` and +the shell rc files have no `projects/openRepoTools` (`grep` exited 1), and neither does +`~/.agents`. In `workBenches` there is one hit, a fixture string at +`devcontainer.test/test-claude-profile-lane-default.sh:282` and `:293`: text a fake prints, +naming a cwd. That is not a tool call. + +--- + +## 5. What does not need to change, and why + +- **How the tools find their helpers** (§4.1): every helper moves to `code/` with the tool. +- **`lane-start`'s directory rungs and `lanes-edit.sh`'s crossing test** (§4.2, §4.5): both + already read `legs:` the way openRepoShape writes them, and the shell suite covers both + (tests/test_lane_helpers.sh:1458-1469 for crossings, :5691-5704 for rung 5). +- **The workspace resolver, the register, logs and handoffs** (`link-estates:89-295`, + byte-identical in four helpers): they are found through `workspace.yaml`, never from a + checkout path. +- **`installed.tsv`**: it records destinations, not sources (§4.10). +- **Both hook entries**: they name the `~/projects/xFactory/lanes-edit.sh` link to the + installed copy (§4.11). +- **`lanes-index`, `lane-rename`, `link-estates`**: no repository-relative path. +- **`set-lane-tree` and the inventory format**: absolute paths. An unvalidated `--checkout` + already admits a code-leg tree. +- **`tests/run.sh`**: the lock, the run root and the venv name are not layout facts. +- **The dependency unit**: `upstream/openRepoShape`, `.gitmodules` and + `contracts/openreposhape-pin.yaml` move together to code, so `test_upstream_pin.py`, + `NEEDS_UPSTREAM` and `wip init`'s local template path stay true there. + +--- + +## 6. What this branch carries + +**One commit: this document** (T006 / T007 / T009 / T010 / T018). No shipped command changed. +Every behaviour change proposed in §4 changes what a command prints or does **today**, for +this repository or for the estate's existing triads. Each is written above as a proposal with +a sketch, for a ruling. + +**Overlap with #190, and why this branch carries no test change.** An earlier draft of this +branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more +completely, under other names. The draft was dropped before it was committed; its diff is +kept only in this lane's scratch directory. + +| File | This branch's draft | #190 | Disposition | +| --- | --- | --- | --- | +| tests/conftest.py | `manifest_legs`, `checkout_roots`, `ASSEMBLY`, `SPEC`, `source()`; `$OPENREPOTOOLS_TEST_{ASSEMBLY,SPEC}_ROOT`; a missing root **fails** | `ROOTS`, `resolve_roots`, `ROOTS.path_for`; `$OPENREPOTOOLS_{CODE,ASSEMBLY,SPEC}_ROOT` and `_COMPOSED`; standalone **skips**, composed **refuses** | dropped; #190's definitions stand | +| tests/test_checkout_roots.py (new) | 9 cases on throwaway layouts, the paired layout among them | tests/test_test_roots.py (new, 573 lines) | dropped; the paired layout is a gap in #190 (§4.13) | +| tests/test_repo_hygiene.py | 23 reads through `source()` | `doc()`, `code_first()`, `tracked_files()` | dropped | +| tests/test_openrepotools_command.py | 1 read through `source()` | `ROOTS.path_for("README.md")` | dropped | +| tests/test_lane_helpers.sh, test_lane_helpers_suite.py | `SPEC_DIR` from `$OPENREPOTOOLS_TEST_SPEC_ROOT` (written, never committed) | `LANE_MANUAL` from `$OPENREPOTOOLS_LANE_MANUAL`, with skips | dropped; one read #190 leaves open (§4.13) | + +--- + +## 7. Decisions for lane 1 (the migration owner) + +1. **Bindings (T018):** keep every lane's `dir` at the assembly root and its home at + `opensoft/openRepoTools`, and spell leg objects in full. The alternative is lanes homed on a + leg, which changes the Claude project folder, and with it memory and `--resume`. +2. **Open OpenSpec claims (T018):** re-key `opensoft/openRepoTools:openspec/changes/` to + `opensoft/openRepoTools-spec:…` at cutover, by release and claim. +3. **Worktree visibility (T018):** adopt §4.4 proposal 1 (guidance) now. Rule on proposals 2 + and 3, which change the sweep and the daily report for existing triads too. +4. **Checkout-copy callers:** opsXfactory-1's handoff and its `land-444.sh` call + `~/projects/openRepoTools/lanes-edit.sh`. Re-point them before the live checkout pulls the + split. +5. **T007 landing order:** the root entry point goes in the root-split PR, and the code + installer's pin resolution covers already-installed re-installs (context d) and + `wip_shape_ref`'s silent `main` (context e). +6. **T006 contents:** the code leg's own `.gitattributes` and `.gitignore`; dated cap entries + for AGENTS.md (316) and README.md (486); the root guidance's `code/` prefixes (§4.12). +7. **T010 context:** which spec and assembly commits a code CI run pairs with, and whether the + code leg's CI carries the two extra checkouts or the assembly's CI runs the composed suite. +8. **Measure in T008:** what `gh api …/contents/` and `raw.githubusercontent.com` answer + for a path under a gitlink. §4.10 derives a 404 and does not claim it. +9. **Standalone skip, or standalone fail (#190):** there are two readings of FR-008. #190 + skips a document read in a standalone code leg and refuses only in composed mode. That + is sound only if the composed job is **required** on every code PR (T010); otherwise a + code-only CI is green with the manual never read. This branch's dropped draft failed in + both modes, which keeps a code-only CI red until T010 gives it context. Choose one, and + write it into T010. +10. **Paired worktrees in #190:** decide whether `discover_assembly` should also accept + `/worktrees//code`, with the spec root at `../spec` (§4.13), and + move the third manual read (#190's test_lane_helpers.sh:6595-6596) inside its `if`, or + a standalone code-leg run aborts on `ln_doc: unbound variable` (§4.13, measured). From 2aa8a59f16891e329b0696f4f6e31e17374348dd Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:19:46 +0000 Subject: [PATCH 02/23] =?UTF-8?q?WIP=20(untested):=20T018=20worktree=20vis?= =?UTF-8?q?ibility=20for=20triad=20legs=20=E2=80=94=20the=20legs=20reader,?= =?UTF-8?q?=20the=20sweep's=20leg=20and=20paired-tree=20discovery,=20lane-?= =?UTF-8?q?end's=20leg=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint for an account swap, on the coordinator's word. NOTHING HERE HAS BEEN RUN UNDER tests/run.sh: `bash -n` passes for lanes-edit.sh and lane-end, and lane-worktrees parses as Python; one manual run of `lanes-edit.sh project-legs` against openRepoShape's assembly-root project.yaml template (pinned submodule) printed the spec and code legs, exit 0, with no workspace configured; 8 for a directory with no manifest; 64 for a relative path. DONE (T018, plan proposal 2 of docs/triad-lane-tooling-analysis.md §4.4): - lanes-edit.sh: `project_leg_paths_file` (lane-start rung 5's awk, the seven deciding lines verbatim, printing every leg) and `assembly_legs`; the read-only subcommand `project-legs ` (0 rows, 8 none, 1 manifest unreadable, 64 usage), exempt from the workspace guard; listed in the unknown-subcommand refusal and the usage header. - lane-worktrees sweep: where the lane's dir has a project.yaml naming legs, `Leg` rows for each leg and each initialized nested submodule (porcelain kind `leg`, a LEGS section, a summary suffix), counted only where the leg's HEAD's own Lane: trailer is this lane's and it holds work, or unreadable; every leg's worktree registrations enumerated; `/worktrees` scanned as the paired root and claimed by the same HEAD-trailer rule as .claude/worktrees; legs' branches under --branches and probed under --fetch. No project.yaml: no helper call and no new code path's output. - lane-end: `leg` porcelain rows counted (retire) or listed (not counted), "leg checkout(s)" in the gate's tally, no `--yes` offered when legs are all that is counted. REMAINS, in order: (1) tests/test_triad_lane_tooling.py — assembly fixture from the pinned template bytes with legs as real repositories and bare local remotes, fake gh exiting 1; single-repository golden comparisons against c45a452's tools (CI checks out depth 1, so the old bytes need a decision: skip where c45a452 is absent, or captured goldens); the shared-awk identity check against lane-start; (2) README-lanes.md and usage text for (a); (3) commit (b) lane-handoff paired-root poll + recording the leg checkout, lane-reconcile's leg registrations; (4) (c) the daily report's legs; (5) (d) add recording the leg; (6) the shell suite once at the end. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-end | 35 +++++ lane-worktrees | 361 ++++++++++++++++++++++++++++++++++++++++++++++++- lanes-edit.sh | 96 ++++++++++++- 3 files changed, 487 insertions(+), 5 deletions(-) diff --git a/lane-end b/lane-end index cde8c280..91a9cb33 100755 --- a/lane-end +++ b/lane-end @@ -1971,6 +1971,7 @@ fi gate_holder=""; gate_refused=""; gate_lines=""; gate_named="" gate_trees=0; gate_branches=0; gate_scratch=0; gate_caches=0; gate_residue=0; gate_foreign=0 +gate_legs=0; gate_leg_lines="" # T018: an assembly's leg checkouts, counted and listed gate_person=0 # counted rows `--yes` leaves too: a person's to settle gate_lf=' ' @@ -2012,6 +2013,25 @@ while IFS="$TAB" read -r g_kind g_a g_b g_c g_d g_e g_f; do gate_named="${gate_named}$(gate_real "$g_path")${gate_lf}" gate_caches=$((gate_caches + 1)) gate_lines="${gate_lines} cache ${g_a} ${g_path} (${g_e:-0} bytes)${gate_lf}" ;; + # T018 (opensoft/openRepoTools#186): A LEG CHECKOUT OF AN ASSEMBLY — only + # where the lane's checkout is one; a single repository prints no such row. + # The sweep counts it (`retire`) where its HEAD's own Lane: trailer is this + # lane's and it holds work origin lacks or edits no commit carries, or + # where it could not be read; one that holds work and is not this lane's + # is listed below the table and counted for nobody, as the assembly's own + # checkout is. No sweep acts on one, so its remedy is a person's. + leg) + g_path="$(printf '%b' "${g_c:-}")" + g_head="${g_e:-?}" + if [ "${g_b:-}" = retire ]; then + gate_legs=$((gate_legs + 1)) + gate_lines="${gate_lines} leg ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} ${g_f:-}${gate_lf}" + else + case "${g_a:-}" in + clean | absent) : ;; + *) gate_leg_lines="${gate_leg_lines} leg ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} ${g_f:-}${gate_lf}" ;; + esac + fi ;; esac done <&2 + fi if [ "$inventory_only" = 1 ]; then note "--inventory-only changed nothing: the close-out gate passed, so the lane ends as any other does" fi @@ -2114,6 +2139,10 @@ else *) note "lane $LANE is NOT ended: the close-out gate could not be read — \`$gate_shown --dry-run --porcelain\` exited $gate_rc, and a read that failed is never an answer (Amendment 7(d))." ;; esac if [ -n "$gate_lines" ]; then printf '%s' "$gate_lines" >&2; fi + if [ -n "$gate_leg_lines" ]; then + note "and the assembly's leg checkout(s) below hold work that is not this lane's by its HEAD's own Lane: trailer; they are listed and NOT counted:" + printf '%s' "$gate_leg_lines" >&2 + fi if [ "$gate_foreign" -gt 0 ]; then note "($gate_foreign FOREIGN tree(s) beside these are not this lane's, and were not counted.)" fi @@ -2129,6 +2158,9 @@ else gate_act=0 if [ "$gate_rc" = 3 ]; then gate_act=1; fi if [ "$gate_rc" = 0 ] && [ "$gate_caches" -gt 0 ]; then gate_act=1; fi + # T018: A LEG IS NO SWEEP'S TO RETIRE, so where legs are all that is + # counted the act offered is the person's, not `--yes`. + if [ "$gate_legs" -gt 0 ] && [ $((gate_trees + gate_branches + gate_scratch + gate_caches)) = 0 ]; then gate_act=0; fi if [ "$gate_act" = 1 ]; then note "the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree):" if [ "$gate_holder" = live ]; then @@ -2143,6 +2175,9 @@ else if [ "$gate_residue" -gt 0 ]; then note "residue is no sweep's to retire: move it to \${XDG_STATE_HOME:-~/.local/state}/openRepoTools/lanes/$LANE/scratch/ or remove it by hand" fi + if [ "$gate_legs" -gt 0 ]; then + note "a leg checkout is the assembly's and no sweep acts on one: commit and push its work from inside it (git -C status, git -C push), then end the lane again" + fi note "or end the lane and leave them where they are, in its inventory, with the reason written down:" note " $prog $LANE$rflag$fflag --inventory-only" exit "$gate_exit" diff --git a/lane-worktrees b/lane-worktrees index c2da8d88..9e836565 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -1352,6 +1352,7 @@ class Tree: self.reg: dict = {} # its registration, where git has one self.held_back = "" # the bundle-only commit of its untracked files self.lost_bundle = "" # the bundle of commits only its reflog named + self.leg = None # T018: the assembly's leg that registers it @property def slice(self) -> str: @@ -1924,6 +1925,230 @@ def ignored_paths(path: str): return out +# ================================================== an assembly's legs (T018) +# +# opensoft/openRepoTools#186, plan task T018 - A TRIAD'S CHECKOUTS. A checkout +# is an ASSEMBLY when its own `project.yaml` declares a leg other than itself: +# the standard's own rule, read by `lanes-edit.sh project-legs` - the one +# reader every lane tool asks, whose grammar is `lane-start`'s rung 5 line for +# line - and never here a second time. Its legs (`spec/`, `code/`) are +# submodules: their `.git` is a FILE, `git -C worktree list` names +# none of their worktrees, and a feature's paired trees under +# `/worktrees//{spec,code}` are worktrees of the LEGS' +# repositories, so before this every reader below was blind to them. +# +# A CHECKOUT WITH NO `project.yaml` IS ASKED NOTHING: no helper call, no read, +# no row - which is every single-repository checkout, whose sweep, report and +# `add` are therefore byte for byte what they were. + + +class Leg: + """One checkout an assembly mounts: a leg its `project.yaml` declares, or + a dependency NESTED in one - an initialized submodule of the leg's own + (`code/upstream/openRepoShape`). It is the ASSEMBLY'S, shared by every + lane the assembly homes, as the assembly's own checkout is, so a sweep + NEVER acts on one: it is read - for the worktrees git registers in it, for + its branches, and for its own dirty and unpushed state - and counted for + this lane only where its HEAD's own `Lane:` trailer is this lane's.""" + + def __init__(self, path: str, decl: str, repository: str, assembly: str, + nested_in: str = ""): + self.path = path + self.real = real(path) + self.decl = decl # its path as declared, under the assembly + self.repository = repository # as the manifest names it ('' when nested) + self.assembly = assembly + self.nested_in = nested_in # the leg it is a dependency of + self.exists = os.path.isdir(path) + self.checkout = False # a checkout of its own is there + self.read_error = "" + self.branch = "" + self.detached = False + self.head = "" + self.dirty = 0 + self.upstream = "" + self.ahead = None + self.on_origin = None + self.origin_url = "" + self.claimed = False # its HEAD's own Lane: trailer is this lane's + self.disposition = "" + self.reason = "" + self.retire = False + + def what(self) -> str: + if self.nested_in: + return f"a dependency nested in the {self.nested_in} leg ({self.decl})" + return (f"the {self.decl} leg of {os.path.join(self.assembly, 'project.yaml')}" + + (f" ({self.repository})" if self.repository else "")) + + def label(self) -> str: + if not self.exists or not self.checkout: + return "(not checked out)" if not self.read_error else "(unreadable)" + if self.detached: + return f"(detached) {short(self.head)}" + if not self.head: + return f"{self.branch} (unborn)" + return f"{self.branch} {short(self.head)}" + + +def project_legs(helper, checkout: str) -> tuple: + """([(repository, path as declared, where it is checked out)], why-not): + the legs `/project.yaml` declares, through `lanes-edit.sh + project-legs`. A checkout with no `project.yaml` is asked nothing; a + manifest that could not be read is never "no legs" (Amendment 7(d)).""" + if not checkout or not os.path.lexists(os.path.join(checkout, "project.yaml")): + return [], "" + proc = helper.call("project-legs", checkout) + if proc.returncode == 8: + return [], "" + if proc.returncode != 0: + return [], (f"`lanes-edit.sh project-legs {checkout}` exited {proc.returncode}: " + + _first_line(proc.stderr)) + rows = [] + for line in proc.stdout.splitlines(): + cols = line.split("\t") + if len(cols) >= 3 and cols[1] and cols[2]: + rows.append((cols[0], cols[1], cols[2])) + return rows, "" + + +def probe_leg(leg: Leg) -> None: + """Is a checkout of its own there? An uninitialized leg is an empty + directory of the assembly's, and git answers the ASSEMBLY there - which is + no checkout of the leg's. A `.git` of its own that git cannot read is a + read that failed, never an absent leg.""" + if not leg.exists: + return + proc = git(leg.path, "rev-parse", "--show-toplevel") + if proc.returncode == 0 and real(proc.stdout.strip()) == leg.real: + leg.checkout = True + elif os.path.lexists(os.path.join(leg.path, ".git")): + leg.read_error = ("it has a .git of its own and git could not read it: " + + (_first_line(proc.stderr) if proc.returncode != 0 else + f"git answers {proc.stdout.strip()} there")) + + +def assembly_checkouts(helper, assembly: str) -> tuple: + """([Leg], declared, why-not) - every leg `/project.yaml` + declares, and every dependency nested in one (the initialized submodules + `git submodule status --recursive` names in each leg), in that order; + `declared` is whether the manifest declares any leg at all, which is what + makes `/worktrees` the paired root.""" + rows, why = project_legs(helper, assembly) + if why: + return [], False, why + out = [] + for repository, decl, path in rows: + leg = Leg(path, decl, repository, assembly) + probe_leg(leg) + out.append(leg) + if not leg.checkout: + continue + sub = git(path, "submodule", "status", "--recursive") + if sub.returncode != 0: + return out, True, (f"the dependencies nested in the {decl} leg ({path}) could not be " + "listed (git submodule status failed): " + _first_line(sub.stderr)) + for line in sub.stdout.splitlines(): + if not line.strip() or line.startswith("-"): + continue # declared and not initialized: nothing to read + parts = line[1:].split(" ", 1) + if len(parts) < 2: + continue + rel = parts[1] + if rel.endswith(")") and " (" in rel: + rel = rel[:rel.rindex(" (")] + nested = Leg(os.path.normpath(os.path.join(path, rel)), f"{decl}/{rel}", "", + assembly, nested_in=decl) + probe_leg(nested) + out.append(nested) + return out, bool(rows), "" + + +def read_leg(leg: Leg, lane: str) -> None: + """What git says about one leg checkout NOW. A read that failed is said, + never converted into a clean-looking value (R22, Amendment 7(d)).""" + if not leg.checkout or leg.read_error: + return + branch = git(leg.path, "symbolic-ref", "-q", "--short", "HEAD") + if branch.returncode == 0: + leg.branch = branch.stdout.strip() + elif branch.returncode == 1: + leg.detached = True + else: + leg.read_error = "HEAD could not be read: " + _first_line(branch.stderr) + return + head = git(leg.path, "rev-parse", "--verify", "-q", "HEAD") + if head.returncode == 0: + leg.head = head.stdout.strip() + elif not leg.branch: + leg.read_error = "HEAD names no commit" + return + status = git(leg.path, "status", "--porcelain=v1", "--ignore-submodules=none", + "--untracked-files=normal") + if status.returncode != 0: + leg.read_error = "git status failed: " + _first_line(status.stderr) + return + leg.dirty = sum(1 for ln in status.stdout.splitlines() if ln.strip()) + leg.origin_url = git_out(leg.path, "config", "--get", "remote.origin.url") or "" + if leg.head: + contains = git(leg.path, "for-each-ref", "--count=1", "--contains", leg.head, + "--format=%(refname)", "refs/remotes/origin/") + if contains.returncode != 0: + leg.read_error = ("whether origin holds its HEAD could not be read: " + + _first_line(contains.stderr)) + return + leg.on_origin = bool(contains.stdout.strip()) + if leg.branch: + up = git(leg.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + leg.upstream = up.stdout.strip() if up.returncode == 0 else "" + if leg.upstream and leg.head: + n = git_out(leg.path, "rev-list", "--count", "@{u}..HEAD") + leg.ahead = int(n) if n and n.isdigit() else None + leg.claimed = bool(leg.head) and lane.lower() in own_trailers(leg.path) \ + and head_lane_trailer(leg.path).lower() == lane.lower() + + +def decide_leg(leg: Leg) -> None: + """A leg's row. It is COUNTED - the sweep's exit 3, `lane-end`'s gate - + where its HEAD's own `Lane:` trailer is this lane's and it holds work + origin lacks or edits no commit carries, or where it could not be read; + otherwise it is listed and counted for nobody, as the assembly's own + checkout is. No `--yes` acts on one.""" + what = leg.what() + if leg.read_error: + leg.disposition, leg.retire = "unreadable", True + leg.reason = (f"{what}: git could not be read through it ({leg.read_error}); a read that " + "failed is never a clean leg (Amendment 7(d)) - read it by hand: " + f"git -C {leg.path} status") + return + if not leg.exists or not leg.checkout: + leg.disposition, leg.retire = "absent", False + leg.reason = f"{what}: not checked out here, so it holds nothing to read" + return + parts = [] + if leg.dirty: + parts.append(f"{leg.dirty} dirty or untracked path(s)") + if leg.head and leg.on_origin is False: + parts.append("its HEAD carries commit(s) no origin ref holds" + + (f" ({leg.ahead} ahead of {leg.upstream})" if leg.upstream and leg.ahead + else "") + + ("" if leg.origin_url else "; it has no origin remote")) + if not parts: + leg.disposition, leg.retire = "clean", False + leg.reason = (f"{what}: clean, and its HEAD is on origin" if leg.head else + f"{what}: clean, on an unborn branch with nothing committed") + return + leg.disposition = ("dirty+unpushed" if len(parts) == 2 else + "dirty" if leg.dirty else "unpushed") + leg.retire = leg.claimed + leg.reason = what + ": " + "; ".join(parts) + ( + "; its HEAD's own Lane: trailer is this lane's, so it is COUNTED - commit and push from " + "inside it: a sweep never acts on a leg checkout, which is the assembly's" + if leg.claimed else + "; listed and NOT counted - a leg checkout is the assembly's, shared by every lane it " + "homes, and its HEAD's own Lane: trailer is not this lane's") + + # ============================================================ the sweep class Options: @@ -1993,6 +2218,8 @@ class Sweep: self.halt = "" # why no further act is taken self.refused_check = "" # the self-check's refusal: exit 2 self.usage = "" # a usage error found once the trees are known: 64 + self.legs: list = [] # T018: the assembly's leg checkouts, where it is one + self.paired_root = "" # T018: /worktrees, where it is one # ------------------------------------------------------------- reading def gather(self) -> None: @@ -2051,6 +2278,7 @@ class Sweep: self.procs.unknown[:6]) + (" ..." if len(self.procs.unknown) > 6 else "") + "): a tree their command " "line or parent's directory places them in is of unknown liveness and kept") + self._assembly() self._discover() self.usage = self._live_usage() if self.usage: @@ -2066,6 +2294,8 @@ class Sweep: self._fetch_all() for tree in self.trees: read_tree(tree) + for leg in self.legs: + read_leg(leg, self.lane) self._dependents() # EVERY CONTROL ROOT THE WHOLE-ESTATE WALK FOUND IS A PLACE ANOTHER LANE'S # CLAIM MAY BE (#174, Copilot round 2): `host/vendor/.lane-state`, with @@ -2082,6 +2312,26 @@ class Sweep: tree.merge = self.evidence.judge(tree.path, slug_of(tree.origin_url), tree.branch, tree.head) + def _assembly(self) -> None: + """T018: WHERE THE LANE'S CHECKOUT IS AN ASSEMBLY, its legs and the + dependencies nested in them are read, and `/worktrees` - the + paired `worktrees//{spec,code}` the standard's three-leg root + keeps its feature trees in - is a root of the table. A checkout with no + `project.yaml` is asked nothing. A manifest, or a leg's own submodule + list, that could not be read refuses: which checkouts the assembly + holds would be unknown, never none (Amendment 7(d)).""" + d = self.facts.dir + if not d or not os.path.isdir(d): + return + legs, declared, why = assembly_checkouts(self.helper, d) + self.legs = legs + if declared: + self.paired_root = os.path.join(d, "worktrees") + if why: + self.refusal = self.refusal or ( + f"the legs of the assembly {d} could not be read, so which checkouts and " + f"worktrees it holds is unknown (Amendment 7(d)): {why}") + def _dependents(self) -> None: """What leans on the trees. WHERE A REMOVAL WOULD DELETE A DIRECTORY - a clone the table could take, or `--include-scratch` - the whole @@ -2146,7 +2396,7 @@ class Sweep: # A CLONE IS NEVER ADOPTED: a lane creates worktrees, never clones, # so a standalone clone under its root stays FOREIGN - a finding for # a person's word, as #162's first protocol line has it. - if t.owner != "foreign" or t.foreign_kind in ("other-lane", "clone"): + if t.owner != "foreign" or t.foreign_kind in ("other-lane", "clone", "paired-clone"): continue if lane_root and under(t.path, lane_root) and t.real != real(lane_root): t.owner = "lane-root" @@ -2154,6 +2404,14 @@ class Sweep: and self.lane.lower() in own_trailers(t.path) \ and head_lane_trailer(t.path).lower() == self.lane.lower(): t.owner = "trailer" + # T018: THE PAIRED ROOT IS PER FEATURE, NOT PER LANE, exactly as + # `.claude/worktrees` is per checkout - so a tree there is this + # lane's by its HEAD's own trailer, the same rule, and by nothing else. + elif self.paired_root and t.exists and under(t.path, self.paired_root) \ + and t.real != real(self.paired_root) \ + and self.lane.lower() in own_trailers(t.path) \ + and head_lane_trailer(t.path).lower() == self.lane.lower(): + t.owner = "trailer" claimed = [t for t in self.trees if t.owner in ("lane-root", "trailer")] if self.facts.state == "NONE" and claimed: self.refusal = self.refusal or ( @@ -2175,6 +2433,10 @@ class Sweep: def _discover(self) -> None: seen: dict = {} lane_dir = real(self.facts.dir) if self.facts.dir else "" + # T018: A LEG CHECKOUT IS NO TREE OF THE TABLE, as the lane's own + # checkout is none: it is the assembly's, and its own row says what it + # holds. A record naming one is that row's, never a tree to remove. + leg_by_real = {g.real: g for g in self.legs if g.checkout} for rec in self.facts.inventory: path = rec.get("path") or "" if not path or rec.get("schema") not in ("1",): @@ -2188,6 +2450,8 @@ class Sweep: continue if lane_dir and real(path) == lane_dir: continue + if leg_by_real and real(path) in leg_by_real: + continue t = Tree(path, "inventory") t.sidecar = rec t.checkout = rec.get("checkout") if rec.get("checkout") not in ("", "unknown") else "" @@ -2201,6 +2465,13 @@ class Sweep: for t in list(seen.values()): if t.checkout and os.path.isdir(t.checkout) and t.checkout not in checkouts: checkouts.append(t.checkout) + # T018: EVERY LEG'S REGISTRATIONS TOO, LAST, so a tree registered in a + # leg's repository is the leg's: `git -C worktree list` + # names none of them, and a code tree recorded under any other checkout + # was the only one a sweep ever saw. + for g in self.legs: + if g.checkout and g.real not in [real(c) for c in checkouts]: + checkouts.append(g.path) regs_by_real: dict = {} for co in checkouts: regs = worktree_registrations(co) @@ -2216,8 +2487,10 @@ class Sweep: for rp, (co, reg) in regs_by_real.items(): t = seen.get(rp) if t is None: - t = Tree(reg["path"], "foreign", "registered") + t = Tree(reg["path"], "foreign", + "leg" if real(co) in leg_by_real else "registered") seen[t.real] = t + t.leg = leg_by_real.get(real(co)) t.registered = True t.checkout = t.checkout or co t.locked = bool(reg.get("locked")) @@ -2245,8 +2518,47 @@ class Sweep: continue kind = "clone" if os.path.isdir(os.path.join(full, ".git")) else "lane-root" seen[real(full)] = Tree(full, "foreign", kind) + if self.paired_root: + self._paired(seen) self.trees = sorted(seen.values(), key=lambda t: (t.owner != "inventory", t.path)) + def _paired(self, seen: dict) -> None: + """T018: THE ASSEMBLY'S PAIRED FEATURE TREES, `/worktrees/ + /` (and `` itself), that no + registration above named - a registration lost, or a clone where a + worktree belongs. A directory that cannot be listed refuses, as a lane + root does (#170 G1): an unread root is never an empty one.""" + root = self.paired_root + try: + names = sorted(os.listdir(root)) + except (FileNotFoundError, NotADirectoryError): + return + except OSError as exc: + self.refusal = self.refusal or ( + f"the assembly's worktree root {root} could not be listed " + f"({exc.strerror or exc}), so which feature trees stand in it is unknown") + return + decls = [g.decl for g in self.legs if not g.nested_in] + for name in names: + fdir = os.path.join(root, name) + if not os.path.isdir(fdir) or os.path.islink(fdir): + continue + try: + os.listdir(fdir) + except OSError as exc: + self.refusal = self.refusal or ( + f"the assembly's feature directory {fdir} could not be listed " + f"({exc.strerror or exc}), so which trees stand in it is unknown") + continue + for full in [fdir] + [os.path.join(fdir, d) for d in decls]: + if not os.path.isdir(full) or os.path.islink(full) or real(full) in seen: + continue + if not os.path.lexists(os.path.join(full, ".git")): + continue + kind = ("paired-clone" if os.path.isdir(os.path.join(full, ".git")) + else "paired") + seen[real(full)] = Tree(full, "foreign", kind) + def _other_lanes(self, extra: list = ()) -> None: """A FOREIGN tree another lane's inventory names is THAT lane's. @@ -2365,6 +2677,11 @@ class Sweep: if t.owner == "inventory" and t.checkout and os.path.isdir(t.checkout) \ and real(t.checkout) not in [real(c) for c in checkouts]: checkouts.append(t.checkout) + # T018: AND EVERY LEG'S, where the lane's checkout is an assembly: after + # the split a lane's branches are made in its code leg, not in the root. + for g in self.legs: + if g.checkout and g.real not in [real(c) for c in checkouts]: + checkouts.append(g.path) return checkouts def _fetch_all(self) -> None: @@ -2372,6 +2689,7 @@ class Sweep: probes = [t.path if t.exists else t.checkout for t in self.trees] if self.opts.branches: probes += self._branch_checkouts() + probes += [g.path for g in self.legs if g.checkout] # T018 for probe in probes: if not probe or not os.path.isdir(probe): continue @@ -2496,6 +2814,8 @@ class Sweep: # ---------------------------------------------------------- deciding def decide(self) -> None: + for leg in self.legs: + decide_leg(leg) for tree in self.trees: self._decide(tree) if tree.owner in ("lane-root", "trailer") and tree.disposition != "foreign": @@ -2525,6 +2845,8 @@ class Sweep: "clone": "a standalone CLONE under this lane's root (a lane creates " "worktrees, never clones)", "other-lane": f"lane {t.other_lane}'s inventory names it"}.get(t.foreign_kind, "") + if t.foreign_kind in ("leg", "paired", "paired-clone"): + where = self._leg_where(t) put("foreign", f"not in lane {self.lane}'s inventory (#97); {where}", False) if t.lean: t.reason += "; LOAD-BEARING: " + _lean_text(t.lean) @@ -2688,9 +3010,28 @@ class Sweep: if t.remote_tip else f"origin has no {t.remote_branch}") + ": pushed as is, then removed") + def _leg_where(self, t: Tree) -> str: + """T018: where a tree of an assembly's legs stands, for its FOREIGN row.""" + paired = bool(self.paired_root) and under(t.path, self.paired_root) + if t.foreign_kind == "paired-clone": + return ("a standalone CLONE in the assembly's paired feature worktrees " + f"({self.paired_root}); a feature's trees are worktrees of its legs") + if paired: + return (f"in the assembly's paired feature worktrees ({self.paired_root})" + + (f", registered in {t.leg.what()}" if t.leg else "") + + "; its HEAD carries no Lane: trailer of this lane's as its own") + return (f"registered in {t.leg.what() if t.leg else 'a leg of the assembly'}, outside " + "every root a lane's trees are claimed in") + def _branches(self) -> None: + leg_by_real = {g.real: g for g in self.legs if g.checkout} for co in self._branch_checkouts(): items = branch_items(co, self.lane, self.evidence) + leg = leg_by_real.get(real(co)) + if leg is not None: + # T018: WHICH CHECKOUT, since a branch name says nothing of it. + for it in items: + it.detail = f"in {leg.what()}" + (f" · {it.detail}" if it.detail else "") if self.opts.fetch: common = git_out(co, "rev-parse", "--git-common-dir") or "" if common and not os.path.isabs(common): @@ -2851,7 +3192,8 @@ class Sweep: # ------------------------------------------------------------ output def retire_count(self) -> int: return (sum(1 for t in self.trees if t.retire) - + sum(1 for i in self.items if i.retire)) + + sum(1 for i in self.items if i.retire) + + sum(1 for g in self.legs if g.retire)) def render(self) -> None: f = self.facts @@ -2883,6 +3225,14 @@ class Sweep: res = f" -> {t.result}" if t.result else "" say(f" {t.disposition:<22} {t.path}") say(f" {'':<22} {t.label()} · {t.reason}{res}") + if self.legs: + # T018: THE ASSEMBLY'S OWN CHECKOUTS, read and never acted on. + say("") + say(f"LEGS (the assembly's own checkouts, from {os.path.join(f.dir, 'project.yaml')}; " + "read, and never acted on)") + for g in self.legs: + say(f" {g.disposition:<22} {g.path}") + say(f" {'':<22} {g.label()} · {g.reason}") kinds = (("branch", "BRANCHES (--branches)"), ("scratch", "SCRATCH (--include-scratch)"), ("cache", "CACHES (--include-caches)"), ("sandbox", "SANDBOXES (--include-sandboxes)"), ("link", "BROKEN LINKS (--links)")) @@ -2902,6 +3252,8 @@ class Sweep: live = sum(1 for t in self.trees if t.disposition == "live") say(f"SUMMARY: {len(self.trees)} tree(s): {self.retire_count()} to retire · " f"{live} live · {foreign} foreign" + + (f" · {len(self.legs)} leg checkout(s), " + f"{sum(1 for g in self.legs if g.retire)} counted" if self.legs else "") + (f" · archive {tilde(self.archive)}" if self.archive else "")) def porcelain(self) -> None: @@ -2914,6 +3266,9 @@ class Sweep: prow("tree", t.disposition, "retire" if t.retire else "-", t.path, t.branch or ("(detached)" if t.detached else "-"), t.head or "-", t.reason + (f" -> {t.result}" if t.result else "")) + for g in self.legs: # T018 + prow("leg", g.disposition, "retire" if g.retire else "-", g.path, + g.branch or ("(detached)" if g.detached else "-"), g.head or "-", g.reason) for i in self.items: prow(i.kind, i.disposition, "retire" if i.retire else "-", i.path, i.detail or "-", str(i.size), i.reason + (f" -> {i.result}" if i.result else "")) diff --git a/lanes-edit.sh b/lanes-edit.sh index aadd2db1..b7b34305 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -360,6 +360,7 @@ # [--dirty ] [--unpushed ] [--writer ] \ # [--generation ] [--operation ] # lanes-edit.sh lane-tree-now +# lanes-edit.sh project-legs # an assembly's legs, by path (T018) # lanes-edit.sh lane-reconcile # lanes-edit.sh worktrees [ | --all] [--index] [--fetch] # lanes-edit.sh pathspec-check ... # opensoft/openRepoTools#162 @@ -5066,6 +5067,70 @@ project_legs_file() { }' "$1" } +# ------------------------------------------- an assembly's legs, by path +# +# opensoft/openRepoTools#186, plan task T018 — WHERE A TRIAD'S LEGS ARE CHECKED +# OUT, for the lane tooling that has to look inside one: `lane-worktrees`' sweep, +# its daily report and its `add`, `lane-handoff`'s writer poll, and +# `lane-reconcile` below. A checkout IS AN ASSEMBLY when its own `project.yaml` +# declares a leg other than itself — the standard's own rule (openRepoShape's +# `templates/assembly-root/project.yaml`: a top-level `legs:` list of `- role:` / +# `repository:` / `path:`, the assembly's own leg at `path: "."`). A checkout +# with no such file, or one naming no leg but itself, is every single +# repository, and every reader asks it nothing more than it did before. +# +# THE READING IS `lane-start`'s RUNG 5, LINE FOR LINE, and never a second +# grammar: rung 5 resolves ONE leg's path for the home it is given, and this +# prints every leg the block declares, as ``. The seven +# lines that decide what a leg is are byte-identical to that reader's, and +# `tests/test_triad_lane_tooling.py` holds them so. As there, ONLY `repository:` +# and `path:` are read, and only as navigation: the manifest CONFERS NOTHING, +# and `role:` is never read. +project_leg_paths_file() { # + [ -f "$1" ] || return 1 + awk ' + /^[A-Za-z_][A-Za-z0-9_]*:/ { inlegs = ($0 ~ /^legs:/); repo = ""; path = ""; next } + !inlegs { next } + /^[ \t]*-[ \t]/ { repo = ""; path = "" } + /^[ \t]+repository:[ \t]*/ { v = $0; sub(/^[ \t]+repository:[ \t]*/, "", v) + sub(/[ \t]+$/, "", v); gsub(/^"|"$/, "", v); gsub(/^'"'"'|'"'"'$/, "", v); repo = v } + /^[ \t]+path:[ \t]*/ { v = $0; sub(/^[ \t]+path:[ \t]*/, "", v) + sub(/[ \t]+$/, "", v); gsub(/^"|"$/, "", v); gsub(/^'"'"'|'"'"'$/, "", v); path = v } + repo != "" && path != "" { print repo "\t" path; repo = ""; path = "" }' "$1" +} + +# THE LEGS OF ONE CHECKOUT, one a line: ``, the last being the declared path +# resolved against the checkout as rung 5 resolves it. The assembly's own `.` +# is left out: it IS the checkout. 0 with legs; 8 with none — no +# `project.yaml`, or one naming no leg but the checkout itself; 1 where the +# manifest is there and could not be read, which is never "no legs" (R22, +# Amendment 7(d)). +assembly_legs() { # + al_dir="${1-}"; al_dir="${al_dir%/}"; al_rows=""; al_out=""; al_tab="$(printf '\t')" + [ -n "$al_dir" ] || return 8 + al_m="$al_dir/project.yaml" + [ -e "$al_m" ] || [ -L "$al_m" ] || return 8 + { [ -f "$al_m" ] && [ -r "$al_m" ]; } || return 1 + al_rows="$(project_leg_paths_file "$al_m")" || return 1 + while IFS="$al_tab" read -r al_repo al_path; do + al_path="${al_path%/}" + case "$al_path" in + ''|.|./.) continue ;; + /*) al_abs="$al_path" ;; + ./*) al_abs="$al_dir/${al_path#./}" ;; + *) al_abs="$al_dir/$al_path" ;; + esac + al_out="$al_out$al_repo$al_tab$al_path$al_tab$al_abs +" + done <, a leg a line + # 8 no legs: no project.yaml there, or one naming no leg but the checkout itself + # 1 a project.yaml is there and could not be read — which is never "no legs" + # 64 a usage error of this subcommand's own + project-legs) + pla="${1-}"; [ -n "$pla" ] || die "usage: project-legs " 64 + [ "$#" -le 1 ] || die "project-legs takes one directory: project-legs " 64 + case "$pla" in /*) : ;; *) die "project-legs takes an ABSOLUTE path: a relative one means whatever the calling process's directory happens to be, and that is never the checkout being asked about" 64 ;; esac + pla_out=""; pla_rc=0 + pla_out="$(assembly_legs "$pla")" || pla_rc=$? + case "$pla_rc" in + 0) printf '%s\n' "$pla_out" ;; + 8) exit 8 ;; + *) die "${pla%/}/project.yaml is there and could not be read, so which legs that checkout declares is NOT known — which is not the same as none (R22, Amendment 7(d))" 1 ;; + esac + ;; + lane-reconcile) lane="${1-}"; [ -n "$lane" ] || die "usage: lane-reconcile " 64 [ "$#" -le 1 ] || die "lane-reconcile takes one lane: lane-reconcile " 64 @@ -17041,6 +17133,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|project-legs|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 ;; esac From 5fcdf6a17961af1d1382d27e39dbf0aef5368fe5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:51:44 +0000 Subject: [PATCH 03/23] =?UTF-8?q?T018=20(a):=20the=20triad=20guidance=20fo?= =?UTF-8?q?r=20the=20sweep=20and=20lane-end's=20gate=20=E2=80=94=20README-?= =?UTF-8?q?lanes,=20usage=20text,=20the=20dated=20=C2=A74.4=20note;=202aa8?= =?UTF-8?q?a59's=20code=20now=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), slice (d2), part (a). This commit carries the guidance for the code 2aa8a59 added: a "A triad: its legs, their worktrees and the paired trees (T018)" section and a `leg` row in the porcelain block of docs/README-lanes.md; a "A TRIAD" paragraph in `lane-worktrees --help`; four lines in lane-end's CLOSE-OUT GATE header (`lane-end --help`); and a dated subsection at the end of §4.4 of docs/triad-lane-tooling-analysis.md, including the note "outputs that change regardless of shape" the coordinator asked for (the unknown-subcommand list and usage header of lanes-edit.sh, and the --help texts), for lane 1's ruling. THE INVARIANT: a checkout with no project.yaml declaring a leg other than itself is asked nothing, so a single repository's sweep rows, gate verdict, exit codes, register lines and inventory rows are unchanged. Only usage text moves for every shape, as listed in the §4.4 note. THE PROOF, run under tests/run.sh with this tree (tools as committed here and in 2aa8a59): tests/run.sh -k "t018 or repo_hygiene or test_lane_worktrees" -> 1 failed, 299 passed, 1006 deselected in 1002.77s The 299 include every test of tests/test_lane_worktrees.py and tests/test_lane_worktrees_report.py, the hygiene module, and the T018 tests: the single-repository golden comparison against c45a452's stored outputs and the assembly cases (template bytes, legs as real repositories behind bare local origins, fake gh exiting 1). The one failure was the hygiene check on host-absolute paths, tripped by the golden fixture's `/home/projects/`; that fixture and its test module are the next commit, WIP, with the fix. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 59 +++++++++++++++++++++++++++++ docs/triad-lane-tooling-analysis.md | 33 ++++++++++++++++ lane-end | 4 ++ lane-worktrees | 10 +++++ 4 files changed, 106 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7c0d16ca..34339f40 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3834,6 +3834,7 @@ lane refused (only when refused) tree (branch, scratch, cache, sandbox, link) +leg (an assembly's checkouts only, T018) summary ``` @@ -3863,6 +3864,64 @@ the table and in `DISPOSITION.md`, which is written even when an error no read caught stops the run), and **2** when the self-check refused a removal; usage is 64, a `--live` path that names no tree included. +### A triad: its legs, their worktrees and the paired trees (T018) + +**Plan task T018 of the triad migration (opensoft/openRepoTools#186).** A lane's +checkout is an **assembly** when its own `project.yaml` declares a leg other than +itself — the standard's rule, as openRepoShape's `templates/assembly-root/project.yaml` +writes it: a top-level `legs:` list of `- role:` / `repository:` / `path:`, the +assembly's own leg at `path: "."`. One reader answers it for every lane tool: + +```sh +lanes-edit.sh project-legs # TAB TAB +``` + +It is `lane-start`'s rung-5 reading of `legs:`, line for line (a test holds the two +equal), and reads `repository:` and `path:` only — the manifest confers nothing, +and `role:` is never read. 0 with legs; 8 with none (no `project.yaml`, or one naming +no leg but the checkout itself); 1 where the manifest is there and cannot be read, +which is never "no legs"; 64 usage. It touches no workspace. + +**Why the sweep needed it.** A leg is a submodule: its `.git` is a file, +`git -C worktree list` names none of its worktrees, and a feature's +trees in the standard's three-leg layout, `/worktrees//{spec,code}`, +are worktrees of the LEGS' repositories. So a code tree was invisible to the sweep, +to `lane-end`'s gate, to the handoff's poll and to the daily report unless it had +been recorded with the code leg as its checkout — and a lane with unpushed work in +one passed `lane-end`. + +**Where the lane's checkout is an assembly**, `lane-worktrees sweep` also: + +* reads every leg, and every dependency nested in one (an initialized submodule of + the leg's own, as `code/upstream/openRepoShape` will be), as a **`leg` row** — + `clean`, `dirty`, `unpushed`, `dirty+unpushed`, `absent` (not checked out here) + or `unreadable`. A leg is the assembly's, shared by every lane the assembly + homes, as the assembly's own checkout is: **no `--yes` acts on one**. It is + COUNTED (exit 3, and `lane-end`'s gate) only where its HEAD's own `Lane:` trailer + is this lane's and it holds edits or commits origin lacks — the rule the sweep + already applies to `.claude/worktrees` — or where it could not be read; one + that holds another lane's work, or nobody's, is listed and counted for nobody; +* lists the worktrees git registers in **every leg** — a FOREIGN row names the leg + it is registered in — so a tree recorded with the leg as its checkout is the + lane's as any recorded tree is; +* takes **`/worktrees`** as a root: a tree there is the lane's by its + HEAD's own `Lane:` trailer and by nothing else, because the paired root, like + `.claude/worktrees`, is per feature and not per lane; a standalone clone there is + FOREIGN; a directory there that cannot be listed refuses, exit **2**; +* with `--branches`, reads every leg's branches too, each row naming its leg; with + `--fetch` (and under `--yes`), asks each leg's origin. + +A manifest, or a leg's own submodule list, that cannot be read refuses, exit +**2**. **A checkout with no `project.yaml` is asked nothing** — no helper call, no +read, no row — so a single repository's sweep, gate, handoff, report and `add` are +byte for byte what they were; `tests/test_triad_lane_tooling.py` holds every one of +them to the output `c45a452`'s tools printed. + +**`lane-end`'s gate** counts a `leg` row marked `retire` as a "leg checkout" it +names, with the remedy a person's (commit and push from inside it; no `--yes` is +offered when legs are all it counted), and lists a leg that holds work and is not +this lane's below the table as NOT counted — whether the gate passes or not. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index eec4e6fd..a39e0a41 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -207,6 +207,39 @@ estate's existing triads, and the brief rules out behaviour changes. **Proposals `/.git/modules/` as canonical when the superproject is canonical. This changes today's report for IRRS and the other existing triads, so it needs a ruling. +#### 2026-10-09: what this branch now implements of §4.4 (T018), and what moves for every shape + +**On the coordinator's brief for slice (d2)** (worktree visibility for triad legs, +plan task T018), proposals 1–3 above are implemented as **shape-gated** changes, +one commit each with its tests (`tests/test_triad_lane_tooling.py`). The gate is +the standard's own rule: the lane's checkout (or the checkout a tool polls) is an +**assembly** when its own `project.yaml` declares a leg other than itself. One +reader answers it for every tool, `lanes-edit.sh project-legs `, whose +seven deciding awk lines are `lane-start`'s rung-5 lines verbatim (a test holds +them equal). A checkout with no `project.yaml` is asked nothing, so on a single +repository every output, exit code, register line and inventory row is the one +`c45a452`'s tools produce: the test stores that "before" as a golden file +generated once from `c45a452`'s own tools (`tests/fixtures/t018/`), because CI +checks out at depth 1, and compares every touched command's output on a single +repository — one with a submodule that is no leg, and one with a `project.yaml` +naming no leg but itself — against it. + +**Outputs that change regardless of shape (for lane 1's ruling).** Two kinds of +text move on every checkout, single repository included, because they are usage +text and not behaviour; the coordinator directed both kept and listed here: + +1. `lanes-edit.sh`'s refusal of an unknown subcommand (exit 2) now lists + `project-legs` among the subcommands it answers to, and its usage header (what + `lanes-edit.sh` with no argument prints) gains the `project-legs` line. + `tests/test_repo_hygiene.py` requires the refusal to list every arm. +2. The `--help` text of the touched commands gains the triad guidance: + `lane-worktrees --help` (a "A TRIAD" paragraph), `lane-end --help` (four lines + in THE CLOSE-OUT GATE), and the commands later commits touch, each named in + that commit's message. + +No exit code, register line, inventory row, sweep row, gate verdict or report +line moves for a checkout with no `project.yaml`. + ### 4.5 `lanes-edit.sh` | Line | What it does | Under the triad | diff --git a/lane-end b/lane-end index 91a9cb33..05ec8b84 100755 --- a/lane-end +++ b/lane-end @@ -208,6 +208,10 @@ # /`, that the table does not name as a tree, scratch or a cache is # RESIDUE - ignored leftovers the sweep's rows never reach - and refuses # even where the sweep answered 0, as a cache row does. +# Where the lane's checkout is an ASSEMBLY (T018, opensoft/openRepoTools#186) +# the sweep also prints a `leg` row per leg checkout: one it counts is named +# as a leg checkout whose remedy is a person's — no sweep acts on a leg — and +# one that holds work and is not this lane's is listed and NOT counted. # `--inventory-only` is the one way past it, and `--sweep` is not built. # # THE HANDOFF FRAGMENT diff --git a/lane-worktrees b/lane-worktrees index 9e836565..509db03c 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -107,6 +107,16 @@ stdout; `--dry-run` makes nothing. A tree made any other way is FOREIGN until recorded, 2 refused (nothing made), 1 made and neither recorded nor taken back out (the commands are printed), 64 usage. +A TRIAD (opensoft/openRepoTools#186, T018). Where the lane's checkout is an +ASSEMBLY - its `project.yaml` declares a leg other than itself, read by +`lanes-edit.sh project-legs` - the sweep also reads each leg and each +dependency nested in one as a `leg` row (never acted on; counted only where +its HEAD's own `Lane:` trailer is this lane's and it holds work, or where it +could not be read), lists the worktrees git registers in every leg, and takes +`/worktrees` - the paired `worktrees//{spec,code}` - as a +root whose trees are the lane's by that same trailer rule. A checkout with no +`project.yaml` is asked nothing, and its table is what it always was. + Exit codes: 0 dry run: nothing to retire · --yes: every act completed 3 dry run: something to retire (the `lane-end` gate's answer, #163) From 8e2227eaa4fdc0afc3a6404927e381821103264a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:52:00 +0000 Subject: [PATCH 04/23] =?UTF-8?q?WIP=20(untested):=20T018=20(a)=20tests=20?= =?UTF-8?q?=E2=80=94=20tests/test=5Ftriad=5Flane=5Ftooling.py=20and=20the?= =?UTF-8?q?=20single-repository=20golden=20captured=20from=20c45a452's=20t?= =?UTF-8?q?ools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint for an account swap, on the coordinator's word. The module and its stored fixture, after the fix of the one failure the previous run found; the fixed form has NOT been run under tests/run.sh (the re-run, `tests/run.sh -k "t018 or repo_hygiene"`, was stopped while still waiting before the lock, exit 143, so it never held it). DONE: - The stored-fixture decision: the "before" of the single-repository invariant is tests/fixtures/t018/single-repository.golden, generated ONCE from c45a452's own tools (`git archive c45a452 | tar -x -C `, then `python3 tests/test_triad_lane_tooling.py --capture `), because CI checks out at depth 1 where c45a452 is not there to run. It covers add (dry run and act), the sweep's porcelain and table with --branches --include-scratch --include-caches, lane-reconcile, lane-end refusing and passing with the row and last log line, lane-handoff's output, top block and inventory, and the estate report, on a repository with a submodule that is no leg and on one whose project.yaml names no leg but itself. - Normalised for what no two runs share and nothing else: the bench's root and $HOME (both spellings), tree ids, operation ids, stamps, hashes, ages, byte sizes, the order of the inventory's sidecars (their file names are a checksum of the bench's path), and the sweep table's note about this account's unreadable /proc processes (a host fact). The golden test runs on Linux only, where it was captured. - The fix since the run: `$HOME` is normalised to `` before the root, so the fixture names no `/home//` path (the hygiene failure). Captured twice from c45a452 afterwards: byte-identical. The current tools' outputs on the same scenario equal it (compared by the module's own functions, not by pytest). REMAINS: run `tests/run.sh -k "t018 or repo_hygiene"` and record the counts; then (b) lane-handoff's paired-root poll and leg-checkout record with lane-reconcile's leg registrations, (c) the report's legs, (d) add's leg record (all three drafted in the session scratchpad, not applied), and the shell suite once at the end. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/t018/single-repository.golden | 297 +++++++++ tests/test_triad_lane_tooling.py | 635 +++++++++++++++++++ 2 files changed, 932 insertions(+) create mode 100644 tests/fixtures/t018/single-repository.golden create mode 100644 tests/test_triad_lane_tooling.py diff --git a/tests/fixtures/t018/single-repository.golden b/tests/fixtures/t018/single-repository.golden new file mode 100644 index 00000000..1d02e805 --- /dev/null +++ b/tests/fixtures/t018/single-repository.golden @@ -0,0 +1,297 @@ +=== add --dry-run === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/inv +--- stderr +lane-worktrees: DRY RUN, nothing was made: git -C /projects/repoS worktree add --no-track -b inv /projects/.lane-worktrees/repoS-1/inv ; then lanes-edit.sh set-lane-tree repoS-1 /projects/.lane-worktrees/repoS-1/inv --checkout /projects/repoS + +=== add === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/inv +--- stderr +lane-worktrees: made /projects/.lane-worktrees/repoS-1/inv on branch inv at in /projects/repoS; recorded in lane repoS-1's inventory as + +=== sweep porcelain === +exit 3 +--- stdout +lane repoS-1 RUNNING none - here ungraceful-stop +tree wip-rescue+remove retire /projects/.lane-worktrees/repoS-1/inv inv 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed +tree foreign - /projects/elsewhere elsewhere not in lane repoS-1's inventory (#97); registered in the lane's checkout +tree live retire /projects/repoS/.claude/worktrees/tr tr a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer +branch list retire old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 0 unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted +scratch archive+remove retire /projects/.lane-worktrees/repoS-1/notes-scratch - lane scratch: tar and sha256 under the sweeps directory, then removed +cache remove - /projects/.lane-worktrees/repoS-1/inv/__pycache__ - a dependency cache or bytecode: it holds no work and is rebuilt +summary 4 3 1 1 +--- stderr + +=== sweep table === +exit 3 +--- stdout +lane-worktrees sweep repoS-1 - DRY RUN - nothing is changed; --yes performs it + lane: state RUNNING · holder none · binding here (eagle/none) · verdict ungraceful-stop + checkout: /projects/repoS · inventory 1 tree(s) · origin as of the last fetch + merged means: the register's LANDED line for the branch's PR, or gh's MERGED (gh: LANES_NO_GITHUB=1) + +TREES + wip-rescue+remove /projects/.lane-worktrees/repoS-1/inv + inv · 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed + foreign /projects/elsewhere + elsewhere · not in lane repoS-1's inventory (#97); registered in the lane's checkout + live /projects/repoS/.claude/worktrees/tr + tr · a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer + +BRANCHES (--branches) + list old-work + · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 · unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted + +SCRATCH (--include-scratch) + archive+remove /projects/.lane-worktrees/repoS-1/notes-scratch · + lane scratch: tar and sha256 under the sweeps directory, then removed + +CACHES (--include-caches) + remove /projects/.lane-worktrees/repoS-1/inv/__pycache__ · + a dependency cache or bytecode: it holds no work and is rebuilt + +SUMMARY: 3 tree(s): 4 to retire · 1 live · 1 foreign +--- stderr + +=== lane-reconcile === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoS-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEdirty/projects/.lane-worktrees/repoS-1/invbranch inv head upstream none 1 dirty 0 unpushed; observed 0/0 at ; head was and is +TREEunmanaged/projects/elsewheregit registers it in /projects/repoS and no sidecar of this lane names it; it is left exactly as it is +TREEunmanaged/projects/repoS/.claude/worktrees/trgit registers it in /projects/repoS and no sidecar of this lane names it; it is left exactly as it is +TREES1 inventoried1 dirty or unpushed0 require recovery2 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== lane-end refused === +exit 2 +--- stdout +--- stderr +lane-end: in-flight read from lanes/log/repoS-1.md (exact), not from the state cell +lane-end: lane repoS-1 is NOT ended: the close-out gate (opensoft/openRepoTools#163) found what is still the lane's: 2 tree(s) and 1 branch(es) and 1 scratch dir(s) and 1 cache(s) + tree wip-rescue+remove /projects/.lane-worktrees/repoS-1/inv [inv ] + git status --ignored: 1 changed; 0 untracked; 2 ignored (.env __pycache__/) + 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed + tree live /projects/repoS/.claude/worktrees/tr [tr ] + git status --ignored: clean, nothing ignored in it either + a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer + branch list old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 + unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted + scratch archive+remove /projects/.lane-worktrees/repoS-1/notes-scratch ( bytes) + lane scratch: tar and sha256 under the sweeps directory, then removed + cache remove /projects/.lane-worktrees/repoS-1/inv/__pycache__ ( bytes) +lane-end: (1 FOREIGN tree(s) beside these are not this lane's, and were not counted.) +lane-end: the table, which changes nothing: +lane-end: lane-worktrees sweep repoS-1 --branches --include-scratch --include-caches +lane-end: the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree): +lane-end: lane-worktrees sweep repoS-1 --branches --include-scratch --include-caches --yes +lane-end: (2 row(s) above marked keep, live, load-bearing or list are left by --yes as well: each says why, and settling it is a person's) +lane-end: or end the lane and leave them where they are, in its inventory, with the reason written down: +lane-end: lane-end repoS-1 --inventory-only + +=== lane-end refused: row and log === +| `repoS-1` | harness `-1111-4180-8180-` | Eagle / test / brett | | none | handoffs/repoS-1.md | LIVE · · working | +STARTED — lane repoS-1, session -1111-4180-8180-@Eagle, , lane:repoS-1 → home opensoft/repoS; dir /projects/repoS; host eagle; container none; os linux +=== add (manifest, no leg) === +exit 0 +--- stdout +/projects/.lane-worktrees/repoP-1/w +--- stderr +lane-worktrees: made /projects/.lane-worktrees/repoP-1/w on branch w at in /projects/repoP; recorded in lane repoP-1's inventory as + +=== sweep porcelain (manifest, no leg) === +exit 3 +--- stdout +lane repoP-1 RUNNING none - here ungraceful-stop +tree push+remove retire /projects/.lane-worktrees/repoP-1/w w origin has no w: pushed as is, then removed +summary 1 1 0 0 +--- stderr + +=== lane-handoff === +exit 0 +--- stdout + +WRITER /projects/repoS/.claude/worktrees/tr — branch tr, last commit trailer work + git status --short: 0 dirty + git log @{u}..: 1 unpushed + trailer work + +WRITER /projects/.lane-worktrees/repoS-1/inv — branch inv, last commit inv work + git status --short: 1 dirty + M i.txt + git log @{u}..: 0 unpushed + +READY — restart with: pclaude --lane repoS-1 + record: PAUSED lane:repoS-1 → swap; dir /projects/repoS; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoS-1 + inventory: 2 worktree(s) recorded — lanes-edit.sh lane-trees repoS-1 +--- stderr +lane-handoff: lane=repoS-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoS-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 2 under /projects/repoS +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoS-1.md (+15 lines at the head) +lane-handoff: handoff commit: handoff(repoS-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoS-1.md +lanes-edit: committed: LOG(repoS-1@Eagle): PAUSED lane:repoS-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoS-1 → swap; dir /projects/repoS; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== lane-handoff: top block === +Lane: repoS-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoS-1`, checkout `/projects/repoS`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (2 found under `/projects/repoS`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/repoS/.claude/worktrees/tr` — branch `tr`, last commit ` trailer work`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) +- `/projects/.lane-worktrees/repoS-1/inv` — branch `inv`, last commit ` inv work`, 1 dirty, 0 unpushed; brief: (fill in: the brief this writer was given) + +=== lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/invinvnone10-1111-4180-8180-/projects/repoS21 +/projects/repoS/.claude/worktrees/trtrorigin/main01-1111-4180-8180-/projects/repoS21 +--- stderr + +=== sweep porcelain after the handoff === +exit 3 +--- stdout +lane repoS-1 SWAPPED none - free resumable +tree wip-rescue+remove retire /projects/.lane-worktrees/repoS-1/inv inv 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed +tree push+remove retire /projects/repoS/.claude/worktrees/tr tr origin has no tr: pushed as is, then removed +tree foreign - /projects/elsewhere elsewhere not in lane repoS-1's inventory (#97); registered in the lane's checkout +branch list retire old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 0 unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted +scratch archive+remove retire /projects/.lane-worktrees/repoS-1/notes-scratch - lane scratch: tar and sha256 under the sweeps directory, then removed +cache remove - /projects/.lane-worktrees/repoS-1/inv/__pycache__ - a dependency cache or bytecode: it holds no work and is rebuilt +summary 4 3 0 1 +--- stderr + +=== lane-end passes === +exit 0 +--- stdout +## Done (RELEASED, CLAIM-LOST, CLOSED, LANDED) + +- nothing recorded as done in this lane's log + +## Open (CLAIMED, TAKEOVER, OPENED, LANDING, WITHDRAWN) + +- nothing open +--- stderr +lane-end: in-flight read from lanes/log/repoS-2.md (exact), not from the state cell +lane-end: close-out gate: nothing of lane repoS-2's is left on disk (lane-worktrees sweep repoS-2 --branches --include-scratch --include-caches --dry-run answered 0) +lanes-edit: line 6 rewritten in /projects/wip/lanes/LANES.md +lanes-edit: state cell REPLACED: 39 chars → 85 chars +lanes-edit: committed: LANES(repoS-2@Eagle): state · ENDED · lane-end on Eagle: window closing; NOTHING IN FLIGHT +lanes-edit: pushed origin/main (attempt 1) +lane-end: row closed: ENDED +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoS-2.md +lanes-edit: committed: LOG(repoS-2@Eagle): ENDED lane:repoS-2 +lanes-edit: pushed origin/main (attempt 1) +lane-end: object log: ENDED + +=== lane-end passes: row and log === +| `repoS-2` | harness `-1111-4180-8180-` | Eagle / test / brett | | none | handoffs/repoS-2.md | ENDED · · lane-end on Eagle: window closing; NOTHING IN FLIGHT | +ENDED — lane repoS-2, session -1111-4180-8180-@Eagle, , lane:repoS-2 — window closing; NOTHING IN FLIGHT +=== report === +exit 0 +--- stdout +# Estate report - Eagle - + +`lane-worktrees sweep --all --dry-run --report` over `/projects`. It changed nothing. + +| finding | count | +|---|---| +| FOREIGN repositories | 0 | +| Orphaned worktrees of ENDED lanes | 0 | +| Unmerged branches with a missing, diverged or unpushed upstream | 5 | +| Root main divergence | 0 | +| Awaiting disposition (older than 14 days) | 0 | +| Caches and sandboxes ( in all) | 1 | +| Ignored directories over | 0 | +| Evidence-shaped paths inside the estate | 0 | +| Sweep archives past retention (90 days) | 0 | +| Workspace repository hygiene | 1 | +| `status --all` findings (pin drift and tracking are reported as what they are, never counted as dirt) | 1 | +| Records the report could not read | 0 | +| rescue branches (all ages) | 0 | +| the sweeps directory | | + +## FOREIGN repositories + +A lane creates worktrees, never clones. Each is a person's to dispose of. + +_none: every clone is the estate's own checkout_ + +## Orphaned worktrees of ENDED lanes + +_none_ + +## Unmerged branches with a missing, diverged or unpushed upstream + +Listed, never deleted: a person pushes each to rescue//... or gives a word. + +- /projects/repoP w · · no upstream · d · repoP-1 · held by a worktree +- /projects/repoS elsewhere · · unpushed [ahead 1] · d · repoS-9 · held by a worktree +- /projects/repoS inv · · no upstream · d · repoS-1 · held by a worktree +- /projects/repoS old-work · · unpushed [ahead 1] · d · repoS-1 +- /projects/repoS tr · · unpushed [ahead 1] · d · repoS-1 · held by a worktree + +## Root main divergence + +_none_ + +## Awaiting disposition (older than 14 days) + +_none_ + +## Caches and sandboxes ( in all) + +Removed without archiving by `--include-caches` / `--include-sandboxes`. + +- /projects/.lane-worktrees/repoS-1/inv/__pycache__ · cache · + +## Ignored directories over + +_none_ + +## Evidence-shaped paths inside the estate + +One evidence root per repository: ${XDG_STATE_HOME:-$HOME/.local/state}/openRepoTools/evidence//-/. Each is a finding to move. + +_none_ + +## Sweep archives past retention (90 days) + +_none_ + +## Workspace repository hygiene + +- /projects/wip/.gitignore · lacks __pycache__/ *.pyc *.pyo .pytest_cache/ .mypy_cache/ .ruff_cache/ node_modules/ .venv/ venv/ site-packages/ · printf '%s\n' '__pycache__/' '*.pyc' '*.pyo' '.pytest_cache/' '.mypy_cache/' '.ruff_cache/' 'node_modules/' '.venv/' 'venv/' 'site-packages/' >> /projects/wip/.gitignore + +## `status --all` findings (pin drift and tracking are reported as what they are, never counted as dirt) + +- repoS · a fixed finding + +## Records the report could not read + +UNKNOWN IS NEVER ABSENT: a lane snapshot, inventory or tree the report could not read is one whose findings above may be missing. + +_none_ +--- stderr + diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py new file mode 100644 index 00000000..4e6e418b --- /dev/null +++ b/tests/test_triad_lane_tooling.py @@ -0,0 +1,635 @@ +# SPDX-License-Identifier: Apache-2.0 +"""T018 (opensoft/openRepoTools#186): the lane tooling sees a TRIAD's trees. + +A lane's checkout is an ASSEMBLY when its own `project.yaml` declares a leg +other than itself - the standard's rule, read by `lanes-edit.sh project-legs` +with `lane-start`'s rung-5 grammar. Its legs (`spec/`, `code/`) are submodules +and its feature trees are worktrees of the LEGS' repositories, under +`/worktrees//{spec,code}` - so before this every lane tool +was blind to them (docs/triad-lane-tooling-analysis.md §4.4). + +TWO THINGS ARE HELD HERE, and the second is the condition of the first: + + * on an assembly built from openRepoShape's OWN template bytes at the + pinned commit (`upstream/openRepoShape/templates/assembly-root/`), with + every leg a real git repository behind a bare local origin and a fake `gh` + that exits 1, the sweep, `lane-end`'s gate (and, in later commits, the + handoff poll, the daily report and `add`) see the legs, their worktrees and + the paired feature trees; + * on a SINGLE REPOSITORY - no `project.yaml`, a submodule that is no leg, and + a `project.yaml` naming no leg but itself - every output of every touched + command is byte for byte what the tools at `c45a452` printed. The "before" + is STORED (`tests/fixtures/t018/single-repository.golden`), generated ONCE + from `c45a452`'s own tools by this module's `--capture` and committed: CI + checks out at depth 1, where `c45a452` is not there to run. It is + normalised for what no two runs share - the bench's path, UTC stamps, + hashes, ages and byte sizes - and for nothing else. + +NOTHING REAL IS TOUCHED: every repository, origin, register, `$HOME` and +state directory is under `tmp_path`, and the network is never reached. +""" + +from __future__ import annotations + +import difflib +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from conftest import REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 + +pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="the lane tools are bash and Python over git")] + +GOLDEN = Path(__file__).resolve().parent / "fixtures" / "t018" / "single-repository.golden" +TEMPLATE = UPSTREAM / "templates" / "assembly-root" +NEEDS_TEMPLATE = pytest.mark.skipif( + not (TEMPLATE / "project.yaml").is_file(), + reason="the pinned openRepoShape is not checked out; " + "run `git submodule update --init upstream/openRepoShape`") + +UUID = "18018018-1111-4180-8180-180180180001" +#: Every command the bench installs beside each other, as `--install` does. +TOOLS = ("lanes-edit.sh", "lane-worktrees", "lane-end", "lane-handoff", "lane-start", + "repos.tsv") +GATE = ("--branches", "--include-scratch", "--include-caches") +US = "\x1f" + +FAKE_GH = "#!/bin/sh\nprintf 'fake gh: no GitHub here (%s)\\n' \"$*\" >&2\nexit 1\n" +FAKE_TMUX = "#!/bin/sh\nexit 1\n" +FAKE_STATUS = ("#!/bin/sh\nprintf 'status --all: reading every estate:\\n\\n" + "=== status repoS ===\\n - a fixed finding\\n'\nexit 1\n") + + +class Bench: + """One workstation under `root`: a workspace repository with a register, + `$HOME`, the projects directory, bare origins, fakes, and the tools under + test copied side by side into `root/bin`.""" + + def __init__(self, root: Path, tools: Path): + self.root = root + self.home = root / "home" + self.projects = self.home / "projects" + self.remotes = root / "remotes" + self.fake = root / "fakebin" + self.bin = root / "bin" + self.wip = self.projects / "wip" + for d in (self.home, self.projects, self.remotes, self.fake, self.bin, root / "tmp", + root / "sandboxes", self.home / ".agents", self.home / ".claude" / "sessions"): + d.mkdir(parents=True, exist_ok=True) + for name in TOOLS: + shutil.copy2(tools / name, self.bin / name) + (self.bin / name).chmod(0o755) + for name, text in (("gh", FAKE_GH), ("tmux", FAKE_TMUX), ("status", FAKE_STATUS)): + (self.fake / name).write_text(text) + (self.fake / name).chmod(0o755) + (self.home / ".gitconfig").write_text( + "[user]\n\tname = t018\n\temail = t018@example.invalid\n" + "[init]\n\tdefaultBranch = main\n[protocol \"file\"]\n\tallow = always\n" + "[advice]\n\tdetachedHead = false\n") + env = {k: v for k, v in os.environ.items() + if not k.startswith(("LANES_", "LANE_", "CLAUDE_", "WORKBENCHES_", "TMUX", "XDG_", + "PROJECTS_", "GH_", "GIT_", "FAKE_", "OPENREPOTOOLS_", + "PYTEST_")) + and k not in ("AGENT_PROTOCOL_ROOT",)} + system_path = os.pathsep.join(p for p in env.get("PATH", "/usr/bin:/bin").split(os.pathsep) + if p and not any(os.path.exists(os.path.join(p, n)) + for n in TOOLS + ("lanes-index",))) + env.update( + HOME=str(self.home), TMPDIR=str(root / "tmp"), + PATH=os.pathsep.join([str(self.fake), str(self.bin), system_path]), + AGENT_PROTOCOL_ROOT=str(self.home / ".agents"), + XDG_STATE_HOME=str(self.home / ".local" / "state"), + XDG_CONFIG_HOME=str(self.home / ".config"), XDG_CACHE_HOME=str(self.home / ".cache"), + PROJECTS_ROOT=str(self.projects), LANES_WORKSTATION="Eagle", LANES_HOST="eagle", + LANES_OS="linux", LANES_CONTAINER="none", LANES_IN_CONTAINER="0", + LANES_NO_GITHUB="1", LANES_INDEX="off", LANE_WORKTREES_REPORT="off", + LANE_WORKTREES_SANDBOX_ROOTS=str(root / "sandboxes"), + LANE_WORKTREES_STATUS=str(self.fake / "status"), + CLAUDE_CONFIG_DIR=str(self.home / ".claude"), LANES_SESSION=UUID, + GIT_CONFIG_NOSYSTEM="1", OPENREPOTOOLS_BIN_DIR=str(self.bin), LC_ALL="C") + self.env = env + self._seed_workspace() + + # ------------------------------------------------------------ plumbing + def run(self, *argv, cwd=None, env=None, timeout=300) -> subprocess.CompletedProcess: + child = dict(self.env) + child.update(env or {}) + return subprocess.run([str(a) for a in argv], cwd=str(cwd or self.root), env=child, + capture_output=True, text=True, timeout=timeout, + stdin=subprocess.DEVNULL) + + def tool(self, name, *args, cwd=None, env=None) -> subprocess.CompletedProcess: + return self.run(self.bin / name, *args, cwd=cwd, env=env) + + def git(self, *args, cwd=None) -> str: + proc = self.run("git", *args, cwd=cwd) + assert proc.returncode == 0, f"git {' '.join(map(str, args))}: {proc.stderr}" + return proc.stdout.strip() + + def commit(self, cwd: Path, message: str, files: dict | None = None, + lane: str | None = None) -> str: + for rel, text in (files or {}).items(): + path = Path(cwd) / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + self.git("add", "-A", cwd=cwd) + self.git("commit", "-q", "--allow-empty", "-m", + f"{message}\n\nLane: {lane}\n" if lane else message, cwd=cwd) + return self.git("rev-parse", "HEAD", cwd=cwd) + + def origin(self, name: str) -> Path: + bare = self.remotes / f"{name}.git" + self.git("init", "-q", "--bare", "-b", "main", bare) + return bare + + def seeded(self, name: str, files: dict) -> Path: + """A bare origin holding one commit of `files`.""" + bare = self.origin(name) + work = self.root / "seed" / name + self.git("clone", "-q", bare, work) + self.commit(work, f"{name}: seed", files) + self.git("push", "-q", "origin", "main", cwd=work) + return bare + + # ------------------------------------------------------------ register + def _seed_workspace(self) -> None: + bare = self.origin("wip") + self.git("clone", "-q", bare, self.wip) + (self.wip / "lanes" / "log").mkdir(parents=True) + (self.wip / "handoffs").mkdir() + (self.wip / "lanes" / "LANES.md").write_text( + "# LANES.md\n\n| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n|---|---|---|---|---|---|---|\n") + self.git("add", "-A", cwd=self.wip) + self.git("commit", "-q", "-m", "seed", cwd=self.wip) + self.git("push", "-q", "origin", "main", cwd=self.wip) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {bare}\npath: {self.wip}\n") + + def lanes(self, home: str, checkout: Path, *names: str) -> None: + """A register row, a handoff and a STARTED line naming `checkout` for + each lane, pushed in one commit; and a RUNNING #97 snapshot.""" + reg = self.wip / "lanes" / "LANES.md" + text = reg.read_text() + for lane in names: + text += (f"| `{lane}` | harness `{UUID}` | Eagle / test / brett | 2026-10-08T00:00Z " + f"| none | handoffs/{lane}.md | LIVE · 2026-10-08T00:00:00Z · working |\n") + (self.wip / "handoffs" / f"{lane}.md").write_text(f"# handoff {lane}\n\nwork.\n") + (self.wip / "lanes" / "log" / f"{lane}.md").write_text( + f"STARTED — lane {lane}, session {UUID}@Eagle, 2026-10-08T00:00:00Z, lane:{lane} " + f"→ home {home}; dir {checkout}; host eagle; container none; os linux\n") + reg.write_text(text) + self.git("add", "-A", cwd=self.wip) + self.git("commit", "-q", "-m", "lanes", cwd=self.wip) + self.git("push", "-q", "origin", "main", cwd=self.wip) + for lane in names: + proc = self.tool("lanes-edit.sh", "set-lane-state", lane, "RUNNING", "--expect", "none", + "--operation", "op-seed", "--owner", UUID) + assert proc.returncode == 0, proc.stderr + + def row(self, lane: str) -> str: + return next(line for line in (self.wip / "lanes" / "LANES.md").read_text().splitlines() + if line.startswith(f"| `{lane}`")) + + def last_log(self, lane: str) -> str: + return (self.wip / "lanes" / "log" / f"{lane}.md").read_text().splitlines()[-1] + + def sweep(self, lane: str, *args) -> subprocess.CompletedProcess: + return self.tool("lane-worktrees", "sweep", lane, *args) + + +# ================================================================ normalising + +_RULES = ( + (re.compile(r"\bc\d{3,}-[A-Za-z0-9._-]*"), ""), + (re.compile(r"op-\d{8}T\d{6}Z-\d+-\d+"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z"), ""), + (re.compile(r"\d{8}T\d{6}Z"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}"), ""), + (re.compile(r"\b[0-9a-f]{7,40}\b"), ""), + (re.compile(r"\b\d+ d\b"), " d"), + (re.compile(r"active \d+ min ago"), "active min ago"), + (re.compile(r"\b\d+(\.\d+)? (B|KB|MB|GB)\b"), ""), + (re.compile(r"\(\d+ bytes\)"), "( bytes)"), +) + + +_PROC_NOTE = re.compile(r"^ note: \d+ process\(es\) of this account could not be read in /proc ") + + +def normalise(text: str, bench: Bench) -> str: + """What no two runs share, and nothing else: the bench's own path and its + `$HOME` (both spellings), tree ids (a checksum of that path), operation ids, stamps, + hashes, ages and byte sizes - and a porcelain row's byte count.""" + # `$HOME` FIRST, and as a word of its own: `tests/test_repo_hygiene.py` + # rightly refuses a committed `/home//` path, and the bench's home + # sits under its root. + for place, word in ((bench.home, ""), (bench.root, "")): + for spelling in sorted({str(place), os.path.realpath(place)}, key=len, reverse=True): + text = text.replace(spelling, word) + for rx, sub in _RULES: + text = rx.sub(sub, text) + out = [] + for line in text.split("\n"): + # A HOST FACT, NOT THE TOOL'S: how many of this account's processes + # `/proc` would not show the sweep is whatever else runs beside it. + if _PROC_NOTE.match(line): + continue + cols = line.split("\t") + if cols[0] in ("scratch", "cache", "sandbox") and len(cols) > 5: + cols[5] = "" + out.append("\t".join(cols)) + return "\n".join(out) + + +def shown(proc: subprocess.CompletedProcess, bench: Bench) -> str: + return normalise(f"exit {proc.returncode}\n--- stdout\n{proc.stdout}--- stderr\n{proc.stderr}", + bench) + + +# =================================================== the single repository + +def single_repository(bench: Bench) -> list: + """THE SCENARIO WHOSE OUTPUTS MAY NOT MOVE: one repository with a + submodule (as openRepoTools has `upstream/openRepoShape`) and a second with + a `project.yaml` naming no leg but itself, every kind of row the touched + commands print, and each command's output in the order it ran.""" + b = bench + out = [] + dep = b.seeded("dep", {"d.txt": "a dependency\n"}) + b.origin("repoS") + s = b.projects / "repoS" + b.git("clone", "-q", b.remotes / "repoS.git", s) + b.commit(s, "seed", {"README.md": "S\n", ".gitignore": ".env\n__pycache__/\n"}) + b.git("submodule", "add", "-q", dep, "vendor/dep", cwd=s) + b.commit(s, "a submodule that is no leg") + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes("opensoft/repoS", s, "repoS-1", "repoS-2") + + # add: the dry run, then the act; work in the tree it made. + out.append(("add --dry-run", shown(b.tool("lane-worktrees", "add", "repoS-1", "inv", + "--dry-run"), b))) + out.append(("add", shown(b.tool("lane-worktrees", "add", "repoS-1", "inv"), b))) + inv = b.projects / ".lane-worktrees" / "repoS-1" / "inv" + b.commit(inv, "inv work", {"i.txt": "i\n"}, lane="repoS-1") + (inv / "i.txt").write_text("changed\n") + (inv / ".env").write_text("SECRET=1\n") + (inv / "__pycache__").mkdir() + (inv / "__pycache__" / "x.pyc").write_bytes(b"\0" * 64) + # a tree under .claude/worktrees carrying the lane's trailer, unrecorded + tr = s / ".claude" / "worktrees" / "tr" + b.git("worktree", "add", "-q", "-b", "tr", tr, "origin/main", cwd=s) + b.commit(tr, "trailer work", {"t.txt": "t\n"}, lane="repoS-1") + # a FOREIGN registration elsewhere, another lane's + b.git("worktree", "add", "-q", "-b", "elsewhere", b.projects / "elsewhere", "origin/main", cwd=s) + b.commit(b.projects / "elsewhere", "theirs", {"e.txt": "e\n"}, lane="repoS-9") + # scratch, and a branch of the lane's own with no tree + (b.projects / ".lane-worktrees" / "repoS-1" / "notes-scratch").mkdir() + (b.projects / ".lane-worktrees" / "repoS-1" / "notes-scratch" / "n.md").write_text("n\n") + tmp = b.root / "tmp-branch" + b.git("worktree", "add", "-q", "-b", "old-work", tmp, "origin/main", cwd=s) + b.commit(tmp, "old work", {"o.txt": "o\n"}, lane="repoS-1") + b.git("worktree", "remove", tmp, cwd=s) + + out.append(("sweep porcelain", shown(b.sweep("repoS-1", *GATE, "--dry-run", "--porcelain"), b))) + out.append(("sweep table", shown(b.sweep("repoS-1", *GATE, "--dry-run"), b))) + out.append(("lane-reconcile", shown(b.tool("lanes-edit.sh", "lane-reconcile", "repoS-1"), b))) + out.append(("lane-end refused", shown(b.tool("lane-end", "repoS-1"), b))) + out.append(("lane-end refused: row and log", normalise( + b.row("repoS-1") + "\n" + b.last_log("repoS-1"), b))) + + # a repository whose project.yaml names no leg but itself + p = b.projects / "repoP" + b.origin("repoP") + b.git("clone", "-q", b.remotes / "repoP.git", p) + b.commit(p, "seed", {"project.yaml": ( + "schema_version: 1\nkind: project-manifest\nid: repoP\nlegs:\n" + " - role: assembly\n repository: opensoft/repoP\n path: \".\"\n")}) + b.git("push", "-q", "origin", "main", cwd=p) + b.lanes("opensoft/repoP", p, "repoP-1") + pt = b.projects / ".lane-worktrees" / "repoP-1" / "w" + out.append(("add (manifest, no leg)", shown(b.tool("lane-worktrees", "add", "repoP-1", "w"), b))) + b.commit(pt, "p work", {"p.txt": "p\n"}, lane="repoP-1") + out.append(("sweep porcelain (manifest, no leg)", + shown(b.sweep("repoP-1", *GATE, "--dry-run", "--porcelain"), b))) + + out.append(("lane-handoff", shown(b.tool("lane-handoff", "--lane", "repoS-1", "--transcript", + "none", "--agent", "claude", "t018 handoff", + cwd=s), b))) + handoff = (b.wip / "handoffs" / "repoS-1.md").read_text() + out.append(("lane-handoff: top block", normalise(handoff.split("\n---\n", 1)[0], b))) + # ONE SIDECAR PER TREE, LISTED IN THE ORDER OF THEIR FILE NAMES - a + # checksum of each tree's path, so of the bench's own path: sorted here. + trees = b.tool("lanes-edit.sh", "lane-trees", "repoS-1") + trees.stdout = "".join(sorted(trees.stdout.splitlines(keepends=True), + key=lambda line: normalise(line, b))) + out.append(("lane-handoff: inventory", shown(trees, b))) + out.append(("sweep porcelain after the handoff", + shown(b.sweep("repoS-1", *GATE, "--dry-run", "--porcelain"), b))) + out.append(("lane-end passes", shown(b.tool("lane-end", "repoS-2"), b))) + out.append(("lane-end passes: row and log", normalise( + b.row("repoS-2") + "\n" + b.last_log("repoS-2"), b))) + out.append(("report", shown(b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", + "--estate", b.projects), b))) + return out + + +def render_golden(sections: list) -> str: + return "".join(f"=== {key} ===\n{text}\n" for key, text in sections) + + +def parse_golden(text: str) -> list: + out = [] + for chunk in re.split(r"(?m)^=== (.+) ===\n", text)[1:]: + out.append(chunk) + return [(out[i], out[i + 1][:-1] if out[i + 1].endswith("\n") else out[i + 1]) + for i in range(0, len(out), 2)] + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="the golden was captured on Linux, where liveness is read in /proc; " + "macOS reads it with lsof, a host fact this proof is not about") +def test_t018_single_repository_outputs_are_c45a452s_byte_for_byte(tmp_path): + """THE INVARIANT. Every output of every touched command on a single + repository - a submodule that is no leg, and a `project.yaml` naming no leg + but itself, included - equals what `c45a452`'s tools printed, stored in the + golden file this module's `--capture` wrote from them.""" + assert GOLDEN.is_file(), f"{GOLDEN} is missing; it is generated from c45a452's tools" + want = parse_golden(GOLDEN.read_text(encoding="utf-8")) + got = single_repository(Bench(tmp_path / "bench", REPO)) + assert [k for k, _ in got] == [k for k, _ in want] + for (key, have), (_k, gold) in zip(got, want): + if have != gold: + diff = "\n".join(difflib.unified_diff(gold.splitlines(), have.splitlines(), + f"c45a452: {key}", f"now: {key}", lineterm="")) + raise AssertionError(f"the single-repository output of '{key}' moved:\n{diff}") + + +# ================================================================ the assembly + +def build_assembly(b: Bench, name: str = "triad") -> dict: + """An assembly made of openRepoShape's own template bytes at the pinned + commit: every file of `templates/assembly-root/` copied, `project.yaml`'s + placeholders filled, the legs mounted as submodules from bare local + origins, and a dependency nested in the code leg (`upstream/dep`), as + openRepoTools' code leg will carry `upstream/openRepoShape`.""" + dep = b.seeded(f"{name}-dep", {"dep.txt": "a nested dependency\n"}) + spec = b.seeded(f"{name}-spec", {"spec.md": "the spec leg\n"}) + code_bare = b.origin(f"{name}-code") + seed = b.root / "seed" / f"{name}-code" + b.git("clone", "-q", code_bare, seed) + b.commit(seed, "code: seed", {"tool.sh": "#!/bin/sh\n"}) + b.git("submodule", "add", "-q", dep, "upstream/dep", cwd=seed) + b.commit(seed, "code: the nested dependency") + b.git("push", "-q", "origin", "main", cwd=seed) + b.origin(name) + a = b.projects / name + b.git("clone", "-q", b.remotes / f"{name}.git", a) + for src in TEMPLATE.rglob("*"): + rel = src.relative_to(TEMPLATE) + if src.is_dir(): + (a / rel).mkdir(parents=True, exist_ok=True) + else: + (a / rel).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, a / rel) + manifest = (TEMPLATE / "project.yaml").read_text(encoding="utf-8") + fill = {"ASSEMBLY_REPOSITORY": f"opensoft/{name}", "SPEC_REPOSITORY": f"opensoft/{name}-spec", + "CODE_REPOSITORY": f"opensoft/{name}-code", "SPEC_PATH": "spec", "CODE_PATH": "code"} + lines = [] + for line in manifest.splitlines(): + if re.fullmatch(r"\{\{[A-Z_]+_NAMING\}\}", line.strip()): + continue + lines.append(re.sub(r"\{\{([A-Z_]+)\}\}", lambda m: fill.get(m.group(1), m.group(0)), line)) + (a / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + b.commit(a, "the assembly, from the template") + b.git("submodule", "add", "-q", spec, "spec", cwd=a) + b.git("submodule", "add", "-q", code_bare, "code", cwd=a) + b.commit(a, "the legs") + b.git("push", "-q", "origin", "main", cwd=a) + b.git("submodule", "update", "-q", "--init", "--recursive", cwd=a) + return {"assembly": a, "code": a / "code", "spec": a / "spec", + "dep": a / "code" / "upstream" / "dep"} + + +def porcelain_rows(out: str, kind: str) -> dict: + rows = {} + for line in out.splitlines(): + cols = line.split("\t") + if cols[0] == kind and len(cols) > 6: + rows[cols[3]] = {"disposition": cols[1], "retire": cols[2], "branch": cols[4], + "head": cols[5], "why": cols[6]} + return rows + + +@pytest.fixture +def triad(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + b.lanes("opensoft/triad", t["assembly"], "triad-1", "triad-2") + return b, t + + +def _lane_start_reader() -> list: + text = (REPO / "lane-start").read_text(encoding="utf-8") + start = text.index(" ls_cand=\"$(awk -v want=\"$ls_home\" '\n") + lines = text[start:].splitlines()[1:8] + return [ln.strip() for ln in lines] + + +def _shared_reader() -> list: + text = (REPO / "lanes-edit.sh").read_text(encoding="utf-8") + start = text.index("project_leg_paths_file() {") + lines = text[start:].splitlines()[3:10] + return [ln.strip() for ln in lines] + + +def test_t018_the_legs_reader_is_lane_starts_rung_5_line_for_line(): + """NEVER A SECOND GRAMMAR: the seven lines that decide what a leg is are + the ones `lane-start` resolves a lane's directory with.""" + theirs, ours = _lane_start_reader(), _shared_reader() + assert len(theirs) == 7 and theirs[0].startswith("/^[A-Za-z_]"), theirs + assert theirs == ours + + +@NEEDS_TEMPLATE +def test_t018_project_legs_reads_the_templates_manifest(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + a = t["assembly"] + proc = b.tool("lanes-edit.sh", "project-legs", a, env={"AGENT_PROTOCOL_ROOT": str(tmp_path / "none")}) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == [f"opensoft/triad-spec\tspec\t{a}/spec", + f"opensoft/triad-code\tcode\t{a}/code"] + for where, rc in ((b.projects, 8), (b.projects / "wip", 8)): + proc = b.tool("lanes-edit.sh", "project-legs", where) + assert proc.returncode == rc, proc.stdout + proc.stderr + assert b.tool("lanes-edit.sh", "project-legs", "relative").returncode == 64 + os.chmod(a / "project.yaml", 0) + try: + if os.geteuid() != 0: + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 1 and "could not be read" in proc.stderr + finally: + os.chmod(a / "project.yaml", 0o644) + + +@NEEDS_TEMPLATE +def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): + b, t = triad + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 0, proc.stdout + proc.stderr + legs = porcelain_rows(proc.stdout, "leg") + assert list(legs) == [str(t["spec"]), str(t["code"]), str(t["dep"])] + assert all(r["disposition"] == "clean" and r["retire"] == "-" for r in legs.values()), legs + assert "the code leg of" in legs[str(t["code"])]["why"] + assert "nested in the code leg (code/upstream/dep)" in legs[str(t["dep"])]["why"] + table = b.sweep("triad-1", "--dry-run") + assert "LEGS (the assembly's own checkouts" in table.stdout + assert "3 leg checkout(s), 0 counted" in table.stdout + + +@NEEDS_TEMPLATE +def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): + """THE GAP §4.4 MEASURED: a paired `worktrees//code` tree, made + as Speckit makes one (a worktree of the code leg's repository), holding the + lane's unpushed commit, passed `lane-end`. Now the sweep claims it by its + HEAD's own `Lane:` trailer and the gate refuses, naming it.""" + b, t = triad + tree = t["assembly"] / "worktrees" / "005-x" / "code" + b.git("worktree", "add", "-q", "-b", "005-x", tree, "origin/main", cwd=t["code"]) + b.commit(tree, "the feature's code", {"f.txt": "f\n"}, lane="triad-1") + # the paired spec tree carries another lane's work: never this lane's + spec_tree = t["assembly"] / "worktrees" / "005-x" / "spec" + b.git("worktree", "add", "-q", "-b", "005-x", spec_tree, "origin/main", cwd=t["spec"]) + b.commit(spec_tree, "the feature's spec", {"s.md": "s\n"}, lane="triad-2") + proc = b.sweep("triad-1", *GATE, "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + trees = porcelain_rows(proc.stdout, "tree") + assert trees[str(tree)]["retire"] == "retire", trees + assert "Lane: trailer" in trees[str(tree)]["why"] + assert trees[str(spec_tree)]["disposition"] == "foreign", trees + assert "paired feature worktrees" in trees[str(spec_tree)]["why"] + assert "the spec leg of" in trees[str(spec_tree)]["why"] + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2, end.stderr + assert "lane triad-1 is NOT ended" in end.stderr and str(tree) in end.stderr + assert "1 tree(s)" in end.stderr + # and the other lane's own sweep claims the spec tree, not the code one + other = porcelain_rows(b.sweep("triad-2", "--dry-run", "--porcelain").stdout, "tree") + assert other[str(spec_tree)]["retire"] == "retire" + assert other[str(tree)]["disposition"] == "foreign" + + +@NEEDS_TEMPLATE +def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): + b, t = triad + away = b.projects / "away-code" + b.git("worktree", "add", "-q", "-b", "away", away, "origin/main", cwd=t["code"]) + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + row = porcelain_rows(proc.stdout, "tree")[str(away)] + assert row["disposition"] == "foreign" and row["retire"] == "-", row + assert "registered in the code leg of" in row["why"] + # and a tree recorded with the leg as its checkout is the lane's + rec = b.tool("lanes-edit.sh", "set-lane-tree", "triad-1", away, "--checkout", t["code"]) + assert rec.returncode == 0, rec.stderr + row = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, "tree")[str(away)] + assert row["retire"] == "retire" and row["disposition"] == "remove", row + + +@NEEDS_TEMPLATE +def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): + """A lane working in the code leg itself: its HEAD's own trailer is this + lane's and origin lacks it, so the leg is COUNTED - the gate refuses, and + offers no `--yes`, which never acts on a leg.""" + b, t = triad + b.git("checkout", "-q", "-b", "in-place", cwd=t["code"]) + b.commit(t["code"], "worked in the leg", {"w.txt": "w\n"}, lane="triad-1") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + leg = porcelain_rows(proc.stdout, "leg")[str(t["code"])] + assert leg["disposition"] == "unpushed" and leg["retire"] == "retire", leg + assert leg["branch"] == "in-place" + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2 + assert "1 leg checkout(s)" in end.stderr and str(t["code"]) in end.stderr + assert "no sweep acts on one" in end.stderr + assert "--yes" not in end.stderr + # another lane's sweep lists that leg and does not count it + other = b.sweep("triad-2", "--dry-run", "--porcelain") + assert other.returncode == 0, other.stdout + assert porcelain_rows(other.stdout, "leg")[str(t["code"])]["retire"] == "-" + assert b.tool("lane-end", "triad-2").returncode == 0 + + +@NEEDS_TEMPLATE +def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): + b, t = triad + (t["code"] / "scratch.txt").write_text("someone's edit\n") + (t["dep"] / "d2.txt").write_text("an edit in the dependency\n") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 0, proc.stdout + proc.stderr + legs = porcelain_rows(proc.stdout, "leg") + assert legs[str(t["code"])]["disposition"] == "dirty" + assert legs[str(t["dep"])]["disposition"] == "dirty" + assert all(r["retire"] == "-" for r in legs.values()) + end = b.tool("lane-end", "triad-1") + assert end.returncode == 0, end.stderr + assert "close-out gate: nothing of lane triad-1's is left on disk" in end.stderr + assert "listed and NOT counted" in end.stderr and str(t["dep"]) in end.stderr + + +@NEEDS_TEMPLATE +def test_t018_the_legs_branches_are_read_under_branches(triad): + b, t = triad + tmp = b.root / "leg-branch" + b.git("worktree", "add", "-q", "-b", "leg-old", tmp, "origin/main", cwd=t["code"]) + b.commit(tmp, "leg work", {"l.txt": "l\n"}, lane="triad-1") + b.git("worktree", "remove", tmp, cwd=t["code"]) + proc = b.sweep("triad-1", "--branches", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + rows = [ln.split("\t") for ln in proc.stdout.splitlines() if ln.startswith("branch\t")] + assert [r[3] for r in rows] == ["leg-old"], rows + assert rows[0][2] == "retire" and rows[0][4].startswith("in the code leg of"), rows + + +@NEEDS_TEMPLATE +def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + os.chmod(t["assembly"] / "project.yaml", 0) + try: + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + finally: + os.chmod(t["assembly"] / "project.yaml", 0o644) + assert proc.returncode == 2, proc.stdout + assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout + + +# ===================================================================== capture + +def capture(tools: Path, golden: Path) -> None: + """Write the golden file from the tools in `tools` - run ONCE, against + `c45a452`'s (`git archive c45a452 | tar -x -C `), and committed.""" + with tempfile.TemporaryDirectory(prefix="t018-capture.") as tmp: + text = render_golden(single_repository(Bench(Path(tmp) / "bench", tools))) + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) in (3, 4) and sys.argv[1] == "--capture": + capture(Path(sys.argv[2]).resolve(), Path(sys.argv[3]) if len(sys.argv) == 4 else GOLDEN) + sys.exit(0) + sys.stderr.write("usage: test_triad_lane_tooling.py --capture []\n") + sys.exit(64) From 96da9afc7a22dece1b2a121850955e09a17cd445 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:52:00 +0000 Subject: [PATCH 05/23] =?UTF-8?q?WIP=20(untested):=20T010=20docs=20?= =?UTF-8?q?=E2=80=94=20the=20=C2=A74.14=20CI=20sketch=20exits=204=20under?= =?UTF-8?q?=20#190's=20composed=20mode;=20three=20ways=20a=20composed=20jo?= =?UTF-8?q?b=20can=20pin=20the=20PR's=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T010 (opensoft/openRepoTools#186). Queued by the coordinator from the review of #190: the §4.14 sketch (code-PR CI with OPENREPOTOOLS_COMPOSED=1 against an assembly checked out at main) would exit 4, because #190 at dbc6837 refuses any code root the assembly does not pin — Roots.absent() (tests/conftest.py:283) runs _leg_at_its_pin (:259), and pytest_configure (:404-412) raises it as pytest.UsageError — while discover_assembly (:159-172) finds only an assembly that mounts the code root. Dated notes in §4.14 and §7 item 7: the composed job must check out an assembly whose code gitlink points at the PR's own commit, or a throwaway assembly built for the run that pins it, or use #190's standalone mode. Documentation only; no tool or test changes. Marked WIP (untested) because the hygiene suite has not been run over this text: the checkpoint came first. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/triad-lane-tooling-analysis.md | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index a39e0a41..828a0482 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -472,6 +472,31 @@ names at assembly `main`, or the paired feature branch of the same name. `tests- (:127-149) keeps proving that a fork's first run skips rather than fails. The assembly's own `validate.yml` from the shape checks manifest and pins. +**2026-10-09: the sketch above, as written, cannot pass #190's composed mode** (found by the +review of #190, read here at `dbc6837`). It checks out the PR's code at `code/` and the +assembly at its `main`, and names that assembly in `OPENREPOTOOLS_ASSEMBLY_ROOT` with +`OPENREPOTOOLS_COMPOSED=1`. #190's composed contract refuses any code root the assembly does +not pin: `Roots.absent()` (tests/conftest.py:283) runs `_leg_at_its_pin("code", …)` (:259), +which compares the code root's HEAD with the gitlink the assembly records at its code mount +and refuses a mismatch ("composed acceptance is for the pinned code leg and no other"), and +`pytest_configure` (:404-412) raises that refusal as a `pytest.UsageError`, **exit 4**, before +the first test. On a code PR the PR's commit is never the commit assembly `main` pins, so the +job would exit 4 on every code PR. Without `OPENREPOTOOLS_ASSEMBLY_ROOT`, `discover_assembly` +(tests/conftest.py:159-172) finds an assembly only above the code root that MOUNTS it at its +gitlink, which a sibling `code/` checkout is not. A composed job therefore has to do one of +three things: + +1. check out an assembly whose code gitlink already points at the PR's own commit (a paired + assembly branch that pins it); +2. build a throwaway assembly for the run that pins the PR's commit (the assembly at `main`, + its `code` gitlink and `contracts/code-pin.yaml` moved to the PR's commit, the code leg + checked out as its submodule), and run composed from that; or +3. run #190's **standalone** mode on code PRs (no `OPENREPOTOOLS_COMPOSED`), leaving composed + acceptance to the assembly's own pin-bump PRs. + +Which one is lane 1's decision under §7 item 7; the sketch above is item 2's starting point, +not a job that can run as it stands. + ### 4.15 The register, logs, inventory and handoffs in `brett-wip` (read only) `grep -rcE '/workspace/projects/openRepoTools|~/projects/openRepoTools|\$HOME/projects/openRepoTools'` @@ -560,6 +585,12 @@ kept only in this lane's scratch directory. for AGENTS.md (316) and README.md (486); the root guidance's `code/` prefixes (§4.12). 7. **T010 context:** which spec and assembly commits a code CI run pairs with, and whether the code leg's CI carries the two extra checkouts or the assembly's CI runs the composed suite. + **2026-10-09:** the §4.14 sketch exits 4 under #190's composed mode as it stands (#190 at + `dbc6837`: `_leg_at_its_pin`, tests/conftest.py:259, refuses a code root the assembly does + not pin, raised by `pytest_configure`, :404-412; `discover_assembly`, :159-172, finds only + an assembly that mounts the code root). The composed job must check out an assembly whose + code gitlink points at the PR's own commit, or a throwaway assembly built for the run that + pins it, or use #190's standalone mode (§4.14, 2026-10-09 note). 8. **Measure in T008:** what `gh api …/contents/` and `raw.githubusercontent.com` answer for a path under a gitlink. §4.10 derives a 404 and does not claim it. 9. **Standalone skip, or standalone fail (#190):** there are two readings of FR-008. #190 From 30f9dc089cd9706c6b6507cfb4058aa66e895fbe Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:18:01 +0000 Subject: [PATCH 06/23] T018 (c): the daily report counts each leg of an assembly as a checkout of its own, with a legs section only where an assembly's leg is checked out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), proposal 3 of docs/triad-lane-tooling-analysis.md §4.4. A leg is a submodule, its `.git` a FILE, so `Report.build` never counted one as a canonical checkout and the branches all lane work makes after the split never reached the report. Now, for every canonical checkout whose own project.yaml declares legs (read by `lanes-edit.sh project-legs`), each checked-out leg is read as a checkout of its own: its branches ("Unmerged branches…"), its root `main` ("Root main divergence") and its rescue branches ("Awaiting disposition"), each leg row ending with `the leg of `; and a section "Assembly legs with uncommitted or unpublished work" lists the legs holding dirty or untracked paths or a HEAD no origin ref holds, or that could not be read. A manifest that cannot be read is a row of "Records the report could not read". Guidance in docs/README-lanes.md's report table and `lane-worktrees --help`. THE INVARIANT: a canonical checkout with no project.yaml is asked nothing; the legs section is added only where a leg was found, so a report of single repositories is byte for byte what it was. This changes the daily report for the estate's existing triads (IRRS, IRSS, MedxEHR, openDox), which is what proposal 3 is for. THE PROOF: * For 8e2227e and 96da9af, which were WIP: `tests/run.sh -k "t018 or repo_hygiene"` -> 162 passed, 1144 deselected in 134.24s (2026-10-09T13:25Z). * For this commit: run.sh's own in-lock command under the same lock, because the wrapper's workstation-wide census had waited 30 minutes on other projects' pytest runs (#184) - on the coordinator's word, the form lane 1 approved on 2026-10-07: `flock -w 7200 /tmp/openrepotools-pytest.lock env TMPDIR=/tmp python3 -m pytest tests -q -p no:cacheprovider --basetemp=/basetemp -k "t018 or repo_hygiene or test_lane_worktrees_report"` -> 184 passed, 1124 deselected in 457.65s. That includes the single-repository golden (the report section of c45a452's stored outputs, unchanged), every test of tests/test_lane_worktrees_report.py, the hygiene module, and two new cases: an assembly whose code leg holds an unpushed branch and whose spec leg is dirty, and an estate with no assembly, which has no legs section. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 3 +- lane-worktrees | 80 ++++++++++++++++++++++++++++---- tests/test_triad_lane_tooling.py | 41 ++++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 34339f40..befcdc2e 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3998,8 +3998,9 @@ lane-worktrees sweep --all --dry-run --report [--estate ] [--post | |---|---| | FOREIGN repositories | a CLONE inside a worktree container (`.lane-worktrees/`, `.claude/worktrees`, `worktrees`, `-worktrees`), and a second clone of an origin the estate already has a checkout of: path, size, last commit, owner (the last `Lane:` trailer), and LOAD-BEARING with its dependents where something leans on it | | Orphaned worktrees of ENDED lanes | trees an inventory records, and directories under `.lane-worktrees/`, of a lane whose snapshot is `CLOSED` or whose log's last lane line is `ENDED` or `RETIRED` — with the sweep that retires them. Empty beside a record it could not read — a lane snapshot, an inventory, the register's lines, a directory the walk could not list — it says "none found in what could be read", never "none" (#170 B8); a failure that is no lane record (an ignored-file listing, say) is still a row of the last section but leaves "none" as it is (#179); so does Awaiting disposition | -| Unmerged branches | local branches of every checkout with no upstream, a gone upstream, a diverged one, or unpushed commits: tip, state, age, owner, and whether a worktree holds it. Never deleted; `main`, `master` and `rescue/*` are not listed | +| Unmerged branches | local branches of every checkout — and of every leg of an assembly (T018) — with no upstream, a gone upstream, a diverged one, or unpushed commits: tip, state, age, owner, and whether a worktree holds it. Never deleted; `main`, `master` and `rescue/*` are not listed | | Root main divergence | a checkout whose local `main` is ahead of `origin/main`; where every changed path is under `handoffs/` or `lanes/`, the remedy is Amendment 4's — those live in the workspace repository and are pushed per write | +| Assembly legs with uncommitted or unpublished work | **only where the estate holds an assembly with a leg checked out** (T018): every leg of a canonical checkout whose `project.yaml` declares legs, holding dirty or untracked paths or a HEAD no origin ref holds, or that could not be read — each named with its assembly. A leg is a submodule (its `.git` a file), so it was never a canonical checkout; it is now read as a checkout of its own here, and in the three branch sections, whose leg rows end with `the leg of ` | | Awaiting disposition | a `rescue/*` branch (local or on origin) and a DIRTY inventory tree whose git directory has been still for `aging_days` (14), with owner and age | | Caches and sandboxes | `--include-caches`' and `--include-sandboxes`' rows across every lane, by size | | Ignored directories over `ignored_report_mb` | every ignored directory of every repository at or over 50 MB (`du -sk`) | diff --git a/lane-worktrees b/lane-worktrees index 509db03c..596452d3 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -114,8 +114,12 @@ dependency nested in one as a `leg` row (never acted on; counted only where its HEAD's own `Lane:` trailer is this lane's and it holds work, or where it could not be read), lists the worktrees git registers in every leg, and takes `/worktrees` - the paired `worktrees//{spec,code}` - as a -root whose trees are the lane's by that same trailer rule. A checkout with no -`project.yaml` is asked nothing, and its table is what it always was. +root whose trees are the lane's by that same trailer rule. The estate report +counts each leg of every canonical assembly as a checkout of its own - its +branches, root `main` and rescue branches, each row naming its assembly - and +adds a section of the legs holding uncommitted or unpublished work, only where +an assembly's leg is checked out. A checkout with no `project.yaml` is asked nothing, and its +table and its report are what they always were. Exit codes: 0 dry run: nothing to retire · --yes: every act completed @@ -2114,7 +2118,7 @@ def read_leg(leg: Leg, lane: str) -> None: if leg.upstream and leg.head: n = git_out(leg.path, "rev-list", "--count", "@{u}..HEAD") leg.ahead = int(n) if n and n.isdigit() else None - leg.claimed = bool(leg.head) and lane.lower() in own_trailers(leg.path) \ + leg.claimed = bool(lane) and bool(leg.head) and lane.lower() in own_trailers(leg.path) \ and head_lane_trailer(leg.path).lower() == lane.lower() @@ -5252,6 +5256,8 @@ class Report: clones = [r for r in repos if os.path.isdir(os.path.join(r, ".git"))] canonical = self._canonical(clones) self.canonical = canonical + # T018: EVERY CANONICAL ASSEMBLY'S LEGS, each a checkout of its own. + self.legs = self._assembly_legs(canonical) parents = _lane_parents(self.root, canonical) + self._recorded_parents() self.state_dirs = _lane_state_dirs(parents) self.tree_roots = _lane_tree_roots(parents) @@ -5260,6 +5266,8 @@ class Report: self._orphans(closed) self._branches(canonical) self._root_main(canonical) + if self.legs: + self._legs_section() self._aging(canonical) self._caches_and_sandboxes() self._ignored_and_evidence(repos, canonical) @@ -5336,6 +5344,57 @@ class Report: self.duplicates[g] = (keep, len(named) != 1) return sorted(out) + def _assembly_legs(self, canonical: list) -> list: + """T018 (opensoft/openRepoTools#186): THE LEGS OF EVERY CANONICAL + ASSEMBLY - a checkout whose own `project.yaml` declares a leg other than + itself, read by `lanes-edit.sh project-legs`. A leg is a submodule, its + `.git` a FILE, so no leg was ever canonical and the branches all lane + work makes after a split never reached this report. Each is read as a + checkout of its own, its rows naming its assembly. A checkout with no + `project.yaml` is asked nothing, so a report of single repositories is + what it always was; a manifest that could not be read is a row of the + last section.""" + legs = [] + for c in canonical: + rows, why = project_legs(self.helper, c) + if why: + self._unread(c, f"its legs could not be read: {why}", record=False) + continue + for repository, decl, path in rows: + leg = Leg(path, decl, repository, c) + probe_leg(leg) + if leg.checkout or leg.read_error: + legs.append(leg) + return legs + + def _leg_cell(self, path: str) -> str: + """'' for a canonical checkout; for a leg, which assembly it is under.""" + for g in getattr(self, "legs", []): + if g.path == path: + return f"the {g.decl} leg of {g.assembly}" + return "" + + def _legs_section(self) -> None: + """T018: every leg checkout holding edits no commit carries or commits + no origin ref holds - or that could not be read - under its assembly.""" + rows = [] + for g in self.legs: + read_leg(g, "") + decide_leg(g) + if g.disposition in ("clean", "absent"): + continue + state = (g.read_error if g.disposition == "unreadable" else + "; ".join(p for p in ( + f"{g.dirty} dirty or untracked path(s)" if g.dirty else "", + ("HEAD carries commit(s) no origin ref holds" + + (f" ({g.ahead} ahead of {g.upstream})" if g.upstream and g.ahead + else "")) if g.head and g.on_origin is False else "") if p)) + rows.append([g.path, f"the {g.decl} leg of {g.assembly}", g.label(), state]) + self.add("Assembly legs with uncommitted or unpublished work", rows, + "none: every leg checkout is clean and its HEAD is on origin", + "A leg is a checkout of its own under its assembly (T018): it is the " + "assembly's, shared by every lane it homes, and a person's to commit and push.") + def _foreign(self, clones: list, canonical: list) -> None: rows = [] for c in clones: @@ -5415,7 +5474,7 @@ class Report: rows = [] fmt = ("%(refname:short)%1f%(objectname:short=12)%1f%(upstream:short)%1f" "%(upstream:track)%1f%(committerdate:iso-strict)%1f%(contents:trailers)%00") - for c in canonical: + for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: proc = git(c, "for-each-ref", f"--format={fmt}", "refs/heads/") if proc.returncode != 0: continue @@ -5443,7 +5502,8 @@ class Report: continue owner = (re.findall(r"^Lane:\s*(\S+)", trailers, re.MULTILINE) or ["-"])[-1] rows.append([f"{c} {name}", tip, state, f"{_age_days(date)} d", - owner, "held by a worktree" if name in held else ""]) + owner, "held by a worktree" if name in held else ""] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) rows.sort(key=lambda r: -int(r[3].split()[0])) self.add("Unmerged branches with a missing, diverged or unpushed upstream", rows, "none", "Listed, never deleted: a person pushes each to rescue//... " @@ -5451,7 +5511,7 @@ class Report: def _root_main(self, canonical: list) -> None: rows = [] - for c in canonical: + for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: if git(c, "rev-parse", "-q", "--verify", "refs/heads/main").returncode != 0 or \ git(c, "rev-parse", "-q", "--verify", "refs/remotes/origin/main").returncode != 0: continue @@ -5465,7 +5525,8 @@ class Report: "only handoff/register paths" if only else f"{len(paths)} path(s)", "Amendment 4: handoffs and the register live in the workspace " "repository and are pushed per write" if only else - "push it through a pull request, or a person's word"]) + "push it through a pull request, or a person's word"] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) self.add("Root main divergence", rows, "none") def _aging(self, canonical: list) -> None: @@ -5473,7 +5534,7 @@ class Report: rows = [] rescue_count = 0 seen = set() - for c in canonical: + for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: proc = git(c, "for-each-ref", "--format=%(refname)\t%(committerdate:iso-strict)", "refs/heads/rescue/", "refs/remotes/origin/rescue/") for line in proc.stdout.splitlines(): @@ -5488,7 +5549,8 @@ class Report: first = name.split("/", 1)[0] if "/" in name else "" owner = first if LANE_SHAPED_RE.match(first) else "-" rows.append([f"{c} rescue/{name}", f"rescue branch, {age} d", - owner, "awaiting disposition"]) + owner, "awaiting disposition"] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) for d in self.state_dirs: for lane in self._ls(d): for rec in self._tree_records(os.path.join(d, lane, "trees")): diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 4e6e418b..3c0e78d2 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -616,6 +616,47 @@ def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout +# ========================================================= (c) the daily report + +def _section(text: str, title: str) -> str: + start = text.index(f"## {title}") + end = text.find("\n## ", start + 3) + return text[start:end if end != -1 else len(text)] + + +@NEEDS_TEMPLATE +def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): + b, t = triad + a = t["assembly"] + tmp = b.root / "leg-branch" + b.git("worktree", "add", "-q", "-b", "leg-unpushed", tmp, "origin/main", cwd=t["code"]) + b.commit(tmp, "leg work", {"l.txt": "l\n"}, lane="triad-1") + b.git("worktree", "remove", tmp, cwd=t["code"]) + (t["spec"] / "edit.md").write_text("an edit in the spec leg\n") + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + branches = _section(proc.stdout, "Unmerged branches with a missing, diverged or unpushed upstream") + assert f"- {t['code']} leg-unpushed ·" in branches, branches + assert f"the code leg of {a}" in branches + legs = _section(proc.stdout, "Assembly legs with uncommitted or unpublished work") + assert f"- {t['spec']} · the spec leg of {a} ·" in legs, legs + assert "1 dirty or untracked path(s)" in legs + assert str(t["code"]) + " ·" not in legs + assert "| Assembly legs with uncommitted or unpublished work | 1 |" in proc.stdout + + +@NEEDS_TEMPLATE +def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): + b = Bench(tmp_path / "bench", REPO) + b.seeded("plain", {"p.txt": "p\n"}) + b.git("clone", "-q", b.remotes / "plain.git", b.projects / "plain") + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + assert "Assembly legs" not in proc.stdout + + # ===================================================================== capture def capture(tools: Path, golden: Path) -> None: From 13cb52d057a64690296172a7ec5e92a099914da3 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:46:10 +0000 Subject: [PATCH 07/23] T018 (d): lane-worktrees add records the leg it made a tree from, and says of a tree made from an assembly itself that its legs are empty there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 of opensoft/openRepoTools#186, slice (d2), proposal 1 of docs/triad-lane-tooling-analysis.md §4.4. Where the lane's checkout is an ASSEMBLY - its project.yaml declares a leg other than itself, read only through `lanes-edit.sh project-legs`, lane-start's rung-5 reading - `lane-worktrees add` matches the checkout it makes the tree from against each leg by their common git directory (the leg's own toplevel must be the leg's path, so an uninitialized leg, which git answers as the assembly, never matches). A tree made from the leg, or from any worktree of that leg's repository (a paired /worktrees//code included), is recorded in the #97 inventory with THE LEG as its checkout, which every inventory reader enumerates; the dry-run and success notes name the leg, and the success note says "with as its checkout" where the checkout given was a worktree of it. A tree made from the assembly itself is made as before, and a note says a worktree does not check the legs out and names the `--checkout /` that makes a leg's tree. A manifest that cannot be read refuses, exit 2, nothing made (Amendment 7(d)). THE INVARIANT: a lane whose checkout has no project.yaml is asked nothing (no helper call), and a manifest that declares no leg other than `.` answers 8 - so on a single repository `add`'s output, exit code, inventory row and take-back text are byte-for-byte what they were. PROOF: test_t018_single_repository_outputs_are_c45a452s_byte_for_byte compares the current tools against tests/fixtures/t018/single-repository.golden, stored from c45a452's tools, whose "add --dry-run", "add" and "add (manifest, no leg)" sections are add's; it passes unchanged. The shell suite's #163 add block (tests/test_lane_helpers.sh) runs once on the merged tree at the end of this slice. Help text that changes regardless of shape (listed in the §4.4 note for lane 1's ruling, item 2): `lane-worktrees --help`'s "A TRIAD" paragraph gains two sentences on add. Guidance: docs/README-lanes.md "In a triad (T018), a tree of a leg is made from the leg". The §4.4 note's first paragraph now names (a) through (d), with (b) a second writer's branch this one merges. Tests (tests/test_triad_lane_tooling.py, fixtures from openRepoShape's assembly-root template bytes, legs behind bare local remotes, gh exits 1): test_t018_add_records_the_leg_it_made_a_tree_from (dry run, an add from /code, an add from a paired worktree recorded under the leg, the sweep then reading that tree through its leg) and test_t018_add_from_the_assembly_itself_says_its_legs_are_empty. COUNTS: `tests/run.sh -k "t018 or repo_hygiene or test_lane_worktrees_report"` on this tree: 186 passed, 1124 deselected in 212.76s (0:03:32), exit 0 (30f9dc0's same selection: 184 passed; the two new (d) tests make 186). run.sh's own census and lock (it waited ~48 minutes behind other runs; no flock fallback was used). The selection holds no supervised-restart test. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 19 +++++++++ docs/triad-lane-tooling-analysis.md | 8 +++- lane-worktrees | 60 +++++++++++++++++++++++++---- tests/test_triad_lane_tooling.py | 51 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index befcdc2e..741f7156 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3974,6 +3974,25 @@ A tree made any other way — a brief's own `git worktree add`, the harness's `isolation: worktree` — is still legal git and is FOREIGN to every sweep until `lanes-edit.sh set-lane-tree ` records it. +**In a triad (T018), a tree of a leg is made from the leg** — the guidance of +docs/triad-lane-tooling-analysis.md §4.4, proposal 1: + +```sh +lane-worktrees add --checkout /code +``` + +Where the lane's checkout is an assembly (its `project.yaml` declares a leg other +than itself, read by `lanes-edit.sh project-legs`), a tree made from any checkout of +a leg's repository — the leg itself, or a worktree of it, a paired +`/worktrees//code` included — is recorded with **the leg** as its +checkout (`/`), which every reader of the inventory enumerates; the +note names the leg, and says so where the checkout given was a worktree of it. Made +from the assembly itself (no `--checkout`), the tree is a worktree of the assembly, +whose legs a worktree does not check out: `add` makes it as before and says that its +legs are empty there, naming the `--checkout /` that makes a leg's +tree. A lane whose checkout has no `project.yaml` is asked nothing, and its `add` is +what it always was. A manifest that cannot be read refuses, exit **2**, nothing made. + ### The estate report — `sweep --all --dry-run --report` The net under every actor that never runs `lane-end`: **creation outpacing diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 828a0482..46a815c5 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -211,7 +211,13 @@ estate's existing triads, and the brief rules out behaviour changes. **Proposals **On the coordinator's brief for slice (d2)** (worktree visibility for triad legs, plan task T018), proposals 1–3 above are implemented as **shape-gated** changes, -one commit each with its tests (`tests/test_triad_lane_tooling.py`). The gate is +one commit each with its tests (`tests/test_triad_lane_tooling.py`): (a) the sweep and +`lane-end`'s gate (proposal 2); (b) `lane-handoff`'s poll of the paired root, each +tree recorded under its leg, and `lane-reconcile`'s leg registrations (proposal 2's +other two readers — a second writer's, on `feat/triad-lane-tooling-handoff`, which +this branch merges with its own tests); (c) the daily report (proposal 3); and (d) +`lane-worktrees add` recording the leg it made a tree from, with proposal 1's +guidance in `docs/README-lanes.md` and the usage text. The gate is the standard's own rule: the lane's checkout (or the checkout a tool polls) is an **assembly** when its own `project.yaml` declares a leg other than itself. One reader answers it for every tool, `lanes-edit.sh project-legs `, whose diff --git a/lane-worktrees b/lane-worktrees index 596452d3..53a7c42b 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -118,8 +118,11 @@ root whose trees are the lane's by that same trailer rule. The estate report counts each leg of every canonical assembly as a checkout of its own - its branches, root `main` and rescue branches, each row naming its assembly - and adds a section of the legs holding uncommitted or unpublished work, only where -an assembly's leg is checked out. A checkout with no `project.yaml` is asked nothing, and its -table and its report are what they always were. +an assembly's leg is checked out. `add` records a tree made from any checkout +of a leg's repository - the leg itself or a worktree of it - with the LEG as +its checkout, and says, of a tree made from the assembly itself, that its legs +are empty there. A checkout with no `project.yaml` is asked nothing, and its +table, its report and its `add` are what they always were. Exit codes: 0 dry run: nothing to retire · --yes: every act completed @@ -5805,6 +5808,18 @@ def report_all(opts) -> int: SLICE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +def git_common_dir_of(path: str) -> str: + """The physical path of `path`'s repository's common git directory, or ''. + A leg and every worktree of its repository share one, under the + assembly's `.git/modules/`.""" + common = git_out(path, "rev-parse", "--git-common-dir") + if not common: + return "" + if not os.path.isabs(common): + common = os.path.join(path, common) + return real(common) + + def add_tree(ns) -> int: """`add ` - the one act that MAKES a lane's worktree, and it records the tree in the lane's #97 inventory in the same act @@ -5876,6 +5891,32 @@ def add_tree(ns) -> int: if top is None or real(top) != real(checkout): return refuse(f"{checkout} is not the root of a git checkout" + (f" ({top} is)" if top else "")) + # T018 (opensoft/openRepoTools#186): THE LEG A TREE IS MADE FROM IS WHAT IS + # RECORDED. Where the lane's checkout is an ASSEMBLY, a tree made from one + # of its legs - the leg itself, or any worktree of that leg's repository - + # is recorded with the LEG as its checkout, which every reader of the + # inventory enumerates. A checkout with no `project.yaml` is asked nothing, + # and the record is the checkout given, as always. + record_co, leg_line, assembly_hint = checkout, "", "" + legs, why = project_legs(helper, lane_dir) + if why: + return refuse(f"the legs of the lane's assembly {lane_dir} could not be read, so which " + f"checkout a tree of it belongs to is unknown (Amendment 7(d)): {why}") + if legs: + made_from = git_common_dir_of(checkout) + for repository, decl, lpath in legs: + if made_from and os.path.isdir(lpath) and git_common_dir_of(lpath) == made_from \ + and real(git_out(lpath, "rev-parse", "--show-toplevel") or "") == real(lpath): + record_co = lpath + leg_line = (f" - the {decl} leg of {os.path.join(lane_dir, 'project.yaml')}" + + (f" ({repository})" if repository else "")) + break + if not leg_line and real(checkout) == real(lane_dir): + assembly_hint = (f"lane {lane}'s checkout {lane_dir} is an ASSEMBLY, and this tree is " + "of the assembly itself: a worktree does not check its legs out (" + + ", ".join(f"{d}/" for _r, d, _p in legs) + " are empty in it). A " + "tree of a leg is made from the leg: --checkout " + + os.path.join(lane_dir, legs[-1][1])) # THE RECORD MUST HAVE SOMEWHERE TO GO BEFORE THE TREE IS MADE: a tree # this act could not record is the very thing it exists to stop. tproc = helper.call("lane-trees", lane) @@ -5909,7 +5950,9 @@ def add_tree(ns) -> int: ["worktree", "add", "--no-track", "-b", branch, path, start_oid]) if ns.dry_run: note(f"DRY RUN, nothing was made: git -C {checkout} {' '.join(argv)}; then " - f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {checkout}") + f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {record_co}{leg_line}") + if assembly_hint: + note(assembly_hint) say(path) return 0 try: @@ -5954,7 +5997,7 @@ def add_tree(ns) -> int: return 2 note(f"{what}, and this is left of what this act made: {'; '.join(left)}. Record a " f"tree that is wanted with: lanes-edit.sh set-lane-tree {lane} {path} " - f"--checkout {checkout}") + f"--checkout {record_co}") return 1 made = git(checkout, *argv) @@ -5965,13 +6008,16 @@ def add_tree(ns) -> int: return take_back(f"git worktree add failed: {_first_line(made.stderr)}") head = git_out(path, "rev-parse", "HEAD") or "" caller = os.environ.get("LANES_SESSION") or os.environ.get("CLAUDE_CODE_SESSION_ID") or "" - record = ["set-lane-tree", lane, path, "--checkout", checkout] + record = ["set-lane-tree", lane, path, "--checkout", record_co] if UUID_RE.match(caller): record += ["--writer", caller] rproc = helper.call(*record) if rproc.returncode == 0: - note(f"made {path} on branch {branch} at {short(head)} in {checkout}; recorded in lane " - f"{lane}'s inventory as {rproc.stdout.strip() or '?'}") + note(f"made {path} on branch {branch} at {short(head)} in {checkout}{leg_line}; recorded " + f"in lane {lane}'s inventory as {rproc.stdout.strip() or '?'}" + + (f" with {record_co} as its checkout" if record_co != checkout else "")) + if assembly_hint: + note(assembly_hint) _ACTIVE.pop("add", None) say(path) return 0 diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 3c0e78d2..b19e15d9 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -657,6 +657,57 @@ def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): assert "Assembly legs" not in proc.stdout +# ================================================================ (d) add + +def _inventory(b: Bench, lane: str) -> dict: + """{path: checkout} of the lane's #97 inventory.""" + proc = b.tool("lanes-edit.sh", "lane-trees", lane) + assert proc.returncode in (0, 8), proc.stderr + rows = {} + for line in proc.stdout.splitlines(): + cols = line.split(US) + if len(cols) > 9: + rows[cols[1]] = cols[9] + return rows + + +@NEEDS_TEMPLATE +def test_t018_add_records_the_leg_it_made_a_tree_from(triad): + b, t = triad + a = t["assembly"] + dry = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", t["code"], "--dry-run") + assert dry.returncode == 0, dry.stderr + assert f"--checkout {t['code']} - the code leg of {a}/project.yaml (opensoft/triad-code)" \ + in dry.stderr + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", t["code"]) + assert made.returncode == 0, made.stderr + tree = Path(made.stdout.strip()) + assert f"in {t['code']} - the code leg of {a}/project.yaml" in made.stderr + assert _inventory(b, "triad-1")[str(tree)] == str(t["code"]) + # FROM A WORKTREE OF THE LEG'S REPOSITORY, the leg is what is recorded + paired = a / "worktrees" / "010-v" / "code" + b.git("worktree", "add", "-q", "-b", "010-v", paired, "origin/main", cwd=t["code"]) + made = b.tool("lane-worktrees", "add", "triad-1", "c2", "--checkout", paired) + assert made.returncode == 0, made.stderr + assert f"with {t['code']} as its checkout" in made.stderr + assert _inventory(b, "triad-1")[made.stdout.strip()] == str(t["code"]) + # the sweep then reads it through its leg + row = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, + "tree")[made.stdout.strip()] + assert row["retire"] == "retire", row + + +@NEEDS_TEMPLATE +def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): + b, t = triad + a = t["assembly"] + made = b.tool("lane-worktrees", "add", "triad-1", "root-work") + assert made.returncode == 0, made.stderr + assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr + assert f"--checkout {a}/code" in made.stderr + assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) + + # ===================================================================== capture def capture(tools: Path, golden: Path) -> None: From 4e39854b8e7f3570ab07931e6b8fa911b8b033c1 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:52:56 +0000 Subject: [PATCH 08/23] =?UTF-8?q?T018=20(b):=20lane-handoff=20sees=20a=20t?= =?UTF-8?q?riad's=20legs=20=E2=80=94=20the=20paired=20trees=20polled,=20ea?= =?UTF-8?q?ch=20tree=20recorded=20under=20its=20leg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), slice (d2), part (b), first half. Two gaps docs/triad-lane-tooling-analysis.md §4.4 and §4.7 measured in the handoff's writer poll, closed SHAPE-GATED through 2aa8a59's shared reader (`lanes-edit.sh project-legs`, called as it is): (1) THE PAIRED ROOT IS POLLED. Where the lane's checkout is an assembly (its own project.yaml declares a leg other than itself), the handoff polls `/worktrees/` and `/` as it polls `.claude/worktrees`: every directory there with a `.git` of its own (anything else under it is the assembly's working tree, where git answers too). A lane with unpushed work in a paired tree used to be absent from its WRITERS block. (2) EACH TREE IS RECORDED UNDER ITS LEG. A polled tree whose repository's common git directory is a checked-out leg's is filed with that leg (project-legs' third column, the path `add --checkout` records) as its checkout. Anything else keeps the lane's dir, as before. The first handoff used to rewrite the leg that `lane-worktrees add --checkout /code` recorded to the assembly. A leg that is NOT checked out is passed over: git answers the assembly there, and the drafts' leg map, which lacked that guard, would have filed every tree of the assembly under a deinitialized leg (measured: with the guard deleted, the deinit case fails). AN UNREADABLE project.yaml is never "no legs" (Amendment 7(d)), and the swap still completes (R-A11-11). The paired root is not polled, the top block's WRITERS section carries a `- NOT POLLED:` line, no sidecar is filed for a tree that is not of the lane's own repository (the checkout `add` recorded is kept), and the lane stays SWAPPING. THE GATE: a checkout with no project.yaml is asked nothing. There is no helper call and no git read, and set-lane-tree gets the same argv. One whose project.yaml names no leg but itself costs one `project-legs` call, which answers 8, and nothing else. `--supervise` and `--restart-status` exit before any changed line. No usage or --help text moves. THE PROOF, under tests/run.sh, lanes-edit.sh at base (lane-reconcile is the next commit): tests/run.sh -k "t018 or supervised_restart or repo_hygiene or openrepotools_command" -rs -> 7 failed, 429 passed, 874 deselected in 3149.31s (load 60-70 on 32 cores) The 429 include tests/test_triad_lane_tooling.py, whose single-repository golden from c45a452 passes UNREGENERATED (tests/fixtures/t018/ untouched), and the new tests/test_triad_lane_tooling_handoff.py: three assembly cases built from openRepoShape's template bytes at the pin (legs as real repositories behind bare local origins, fake gh exiting 1), and a second stored golden, tests/fixtures/t018-handoff/single-repository-decoys.golden. That golden was captured twice from c45a452's own tools, byte-identical, on a single repository laid out to look like a triad: a submodule's worktree under worktrees//code, and a tree recorded with the submodule as its checkout. Its scenario has no project.yaml, and then one naming no leg but itself. The handoff output, top block, inventory and lane-reconcile before and after all match it. The 7 failures are --restart/--supervise timing cases of tests/test_supervised_restart_regressions.py (TimeoutExpired, "observer never confirmed readiness"). A/B at load ~40, one run.sh run each: this tree: -k "" -> 7 passed, 1303 deselected in 143.69s 96da9af (archive copy, no change): -> 7 passed, 1299 deselected in 150.59s so they are load, not this change. Against 96da9af's lane-handoff the three new assembly cases fail (measured); the base handoff turns the `add`-recorded `/code` into ``. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-handoff | 140 +++++++- .../single-repository-decoys.golden | 148 ++++++++ tests/test_triad_lane_tooling_handoff.py | 339 ++++++++++++++++++ 3 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/t018-handoff/single-repository-decoys.golden create mode 100644 tests/test_triad_lane_tooling_handoff.py diff --git a/lane-handoff b/lane-handoff index e854cb7a..cbb62892 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1892,6 +1892,8 @@ lifecycle_begin # TWO PLACES, because the estate uses both: `/.claude/worktrees/` # for a writer working inside the lane's own repository, and # `/.lane-worktrees//` for the ones a lane keeps beside it. +# AND A THIRD WHERE THE CHECKOUT IS AN ASSEMBLY (T018, below): the paired +# feature trees `/worktrees//{spec,code}`. # # THE SNAPSHOT FILE IS A PRECONDITION, NOT A CONVENIENCE (Copilot round 1 on # openRepoTools#47). With no temporary file the count below still rises and @@ -1972,9 +1974,25 @@ poll_writer() { # # handoff's generation and operation, so a tree recorded by a superseded # operation is visible as one. It never fails the poll and never fails the # swap: a lane with no control root simply keeps no inventory yet. + # + # THE CHECKOUT IT IS RECORDED UNDER IS THE LANE'S `dir`, EXCEPT IN AN + # ASSEMBLY (T018): there a tree of a leg's repository is recorded under its + # LEG, the checkout `lane-worktrees add --checkout /code` recorded + # and every reader of the inventory enumerates (`writer_checkout`, below). + # Where no checkout can be vouched for — the assembly's legs could not be + # read and the tree is not of the lane's own repository — NOTHING is filed: + # a guessed checkout would replace the one `add` recorded, so the tree is + # named and the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + pw_co="${dir:-unknown}" + [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" + if [ -z "$pw_co" ]; then + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" + note "the worktree inventory entry for $pw_d was NOT written: $dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + return 0 + fi LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-tree "$lane" "$pw_d" \ - --checkout "${dir:-unknown}" --branch "$pw_branch" --head "$pw_head" \ + --checkout "$pw_co" --branch "$pw_branch" --head "$pw_head" \ --upstream "$pw_up" --dirty "$pw_dirty" --unpushed "$pw_ahead" \ --writer "${uuid:-none}" --generation "$lc_gen" --operation "$lc_op" \ >/dev/null 2>&1 || { @@ -1989,11 +2007,123 @@ poll_writer() { # return 0 } +# ------------------------------------------------ 3a. A TRIAD'S LEGS (T018) +# +# opensoft/openRepoTools#186, plan task T018. THE LANE'S CHECKOUT IS AN +# ASSEMBLY when its own `project.yaml` declares a leg other than itself — the +# standard's rule, read by `lanes-edit.sh project-legs`, the one reader every +# lane tool asks, never a second grammar here. Its legs (`spec/`, `code/`) are +# submodules, and a feature's trees are worktrees of the LEGS' repositories, so +# two things follow, and neither touches a checkout with no `project.yaml`, +# which is asked nothing and polled and recorded exactly as before: +# +# * THE PAIRED ROOT IS POLLED — `/worktrees/` and +# `/`, the standard's three-leg feature +# trees — exactly as `.claude/worktrees` is: every checkout there, because +# the paired root, like that one, is per feature and not per lane, and a +# writer with nothing committed yet has no trailer to be found by. Only a +# directory with a `.git` of its own is a checkout there: anything else +# under it is the assembly's own working tree, where git answers too. +# * A TREE IS RECORDED UNDER THE CHECKOUT IT BELONGS TO. A tree of a leg's +# repository — `lane-worktrees add --checkout /code` makes one +# under the lane's own root, and every paired tree is one — was re-recorded +# here with the ASSEMBLY as its checkout, and the leg `add` had recorded +# was lost at the first handoff. Its repository's common git directory, +# which a leg shares with every worktree of it, names its leg now. +# +# A MANIFEST THAT COULD NOT BE READ IS SAID, AND THE SWAP GOES ON +# (`R-A11-11`): the paired root is then NOT polled, the top block's WRITERS +# section says so, no tree is recorded under a checkout nobody could establish, +# and the lane stays SWAPPING — a poll that could not look everywhere is not +# the record a recovery reads as a finished swap. +legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common="" +tab="$(printf '\t')" +# THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg +# and every worktree of its repository share one (`/.git/modules/ +# `), and git spells it relative where it is the checkout's own `.git`. +common_dir_of() { # + cdo_c="$(git -C "$1" rev-parse --git-common-dir 2>/dev/null)" || return 0 + [ -n "$cdo_c" ] || return 0 + case "$cdo_c" in /*) : ;; *) cdo_c="$1/$cdo_c" ;; esac + ( CDPATH=''; cd -P -- "$cdo_c" 2>/dev/null && pwd -P ) || : + return 0 +} +physical_of() { # + ( CDPATH=''; cd -P -- "$1" 2>/dev/null && pwd -P ) || : + return 0 +} +if [ -n "$dir" ] && { [ -e "$dir/project.yaml" ] || [ -L "$dir/project.yaml" ]; }; then + legs_rc=0 + leg_rows="$(LANES_NO_FETCH=1 "$LANES_EDIT" project-legs "$dir" 2>/dev/null)" || legs_rc=$? + case "$legs_rc" in + 0) [ -z "$leg_rows" ] || legs_state=known ;; + 8) leg_rows="" ;; + *) + leg_rows=""; legs_state=unread + legs_unread="$dir/project.yaml is there and its legs could not be read (\`$LANES_EDIT project-legs $dir\` exited $legs_rc)" + dir_common="$(common_dir_of "$dir")" + note "$legs_unread — which is never 'no legs' (Amendment 7(d)). So the assembly's paired feature trees under $dir/worktrees were NOT polled, and a tree that is not of $dir's own repository is NOT recorded: read them by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." ;; + esac +fi +# EACH LEG THAT IS CHECKED OUT HERE, BY ITS COMMON GIT DIRECTORY. A leg that is +# NOT — a submodule never initialized, or deinitialized — is an empty +# directory of the assembly's, where git answers the ASSEMBLY: its common +# directory would then be the assembly's own, and every tree of the assembly +# would be recorded under that leg. So a leg counts only where git's own +# toplevel there is the leg itself. +if [ "$legs_state" = known ]; then + while IFS="$tab" read -r lg_repo lg_decl lg_abs; do + [ -n "$lg_abs" ] && [ -d "$lg_abs" ] || continue + lg_top="$(git -C "$lg_abs" rev-parse --show-toplevel 2>/dev/null + wc_c="$(common_dir_of "$1")" + if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then + while IFS="$tab" read -r wc_l wc_p; do + if [ -n "$wc_l" ] && [ "$wc_l" = "$wc_c" ]; then printf '%s' "$wc_p"; return 0; fi + done < "$writers_tmp" if [ -n "$dir" ]; then for w in "$dir"/.claude/worktrees/*; do poll_writer "$w"; done parent="${dir%/*}" for w in "$parent"/.lane-worktrees/"$lane"/*; do poll_writer "$w"; done + # THE PAIRED ROOT, where the checkout is an assembly whose legs were read. + # `}" @@ -2055,6 +2185,12 @@ top_block() { else cat -- "$writers_tmp" fi + # T018: A POLL THAT COULD NOT LOOK EVERYWHERE SAYS SO HERE, where the next + # session reads it — never only on a stderr nobody kept. + if [ -n "$legs_unread" ]; then + printf -- '- NOT POLLED: %s, so the assembly'"'"'s paired feature trees under `%s/worktrees` are not in this list. Read them by hand before relaunching a writer there.\n' \ + "$legs_unread" "$dir" + fi printf '\n' } @@ -2215,6 +2351,7 @@ if [ "$do_late" = 1 ]; then lc_unfinished="" [ "$handoff_written" = 1 ] || lc_unfinished="the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" + [ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" lifecycle_finish "$lc_unfinished" else lifecycle_finish "the late PAUSED record was refused by the helper" @@ -2337,6 +2474,7 @@ lc_unfinished="" [ -z "$row_write_refused" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the row's state cell was not flipped" [ "$handoff_written" = 1 ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" +[ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" lifecycle_finish "$lc_unfinished" # ------------------------------------------- 5. the restart line — one command diff --git a/tests/fixtures/t018-handoff/single-repository-decoys.golden b/tests/fixtures/t018-handoff/single-repository-decoys.golden new file mode 100644 index 00000000..e547835c --- /dev/null +++ b/tests/fixtures/t018-handoff/single-repository-decoys.golden @@ -0,0 +1,148 @@ +=== repoD: lane-reconcile before === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoD-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEunmanaged/projects/repoD/worktrees/001-d/specgit registers it in /projects/repoD and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoD-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== repoD: lane-handoff === +exit 0 +--- stdout + +WRITER /projects/.lane-worktrees/repoD-1/dep — branch dep, last commit lane work in the dependency + git status --short: 0 dirty + git log @{u}..: 1 unpushed + lane work in the dependency + +READY — restart with: pclaude --lane repoD-1 + record: PAUSED lane:repoD-1 → swap; dir /projects/repoD; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoD-1 + inventory: 1 worktree(s) recorded — lanes-edit.sh lane-trees repoD-1 +--- stderr +lane-handoff: lane=repoD-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoD-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 1 under /projects/repoD +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoD-1.md (+14 lines at the head) +lane-handoff: handoff commit: handoff(repoD-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoD-1.md +lanes-edit: committed: LOG(repoD-1@Eagle): PAUSED lane:repoD-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoD-1 → swap; dir /projects/repoD; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== repoD: lane-handoff: top block === +Lane: repoD-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoD-1`, checkout `/projects/repoD`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (1 found under `/projects/repoD`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/.lane-worktrees/repoD-1/dep` — branch `dep`, last commit ` lane work in the dependency`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) + +=== repoD: lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoD-1/depdeporigin/main01-1111-4180-8180-/projects/repoD21 +--- stderr + +=== repoD: lane-reconcile after === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoD-1 +STATESWAPPEDgeneration 2operation owner -1111-4180-8180-updated +HOLDERnone +BINDINGfreenone +TREEunmanaged/projects/repoD/worktrees/001-d/specgit registers it in /projects/repoD and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoD-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTresumablethe swap completed and no holder is live: the lane may be resumed once the trees below are read +--- stderr + +=== repoQ: lane-reconcile before === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoQ-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEunmanaged/projects/repoQ/worktrees/001-d/specgit registers it in /projects/repoQ and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoQ-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== repoQ: lane-handoff === +exit 0 +--- stdout + +WRITER /projects/.lane-worktrees/repoQ-1/dep — branch dep, last commit lane work in the dependency + git status --short: 0 dirty + git log @{u}..: 1 unpushed + lane work in the dependency + +READY — restart with: pclaude --lane repoQ-1 + record: PAUSED lane:repoQ-1 → swap; dir /projects/repoQ; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoQ-1 + inventory: 1 worktree(s) recorded — lanes-edit.sh lane-trees repoQ-1 +--- stderr +lane-handoff: lane=repoQ-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoQ-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 1 under /projects/repoQ +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoQ-1.md (+14 lines at the head) +lane-handoff: handoff commit: handoff(repoQ-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoQ-1.md +lanes-edit: committed: LOG(repoQ-1@Eagle): PAUSED lane:repoQ-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoQ-1 → swap; dir /projects/repoQ; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== repoQ: lane-handoff: top block === +Lane: repoQ-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoQ-1`, checkout `/projects/repoQ`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (1 found under `/projects/repoQ`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/.lane-worktrees/repoQ-1/dep` — branch `dep`, last commit ` lane work in the dependency`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) + +=== repoQ: lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoQ-1/depdeporigin/main01-1111-4180-8180-/projects/repoQ21 +--- stderr + +=== repoQ: lane-reconcile after === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoQ-1 +STATESWAPPEDgeneration 2operation owner -1111-4180-8180-updated +HOLDERnone +BINDINGfreenone +TREEunmanaged/projects/repoQ/worktrees/001-d/specgit registers it in /projects/repoQ and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoQ-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTresumablethe swap completed and no holder is live: the lane may be resumed once the trees below are read +--- stderr + diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py new file mode 100644 index 00000000..2eeefb86 --- /dev/null +++ b/tests/test_triad_lane_tooling_handoff.py @@ -0,0 +1,339 @@ +# SPDX-License-Identifier: Apache-2.0 +"""T018 (b) (opensoft/openRepoTools#186): `lane-handoff` and `lane-reconcile` +see a TRIAD's trees. + +A lane's checkout is an ASSEMBLY when its own `project.yaml` declares a leg +other than itself - read by `lanes-edit.sh project-legs`, the one reader every +lane tool asks. Its legs are submodules, and a feature's trees are worktrees of +the LEGS' repositories, so before this (docs/triad-lane-tooling-analysis.md +§4.4 and §4.7): + + 1. `lane-handoff`'s writer poll never looked in the paired feature trees + `/worktrees//{spec,code}`, so a lane with unpushed work + there was absent from its WRITERS block; + 2. a handoff re-recorded a tree made by `lane-worktrees add --checkout + /code` with the ASSEMBLY as its checkout, so the leg `add` had + recorded was lost from the inventory at the first handoff; + 3. `lane-reconcile` recomputed from the checkout's own worktree registrations + and never read the legs', nor the paired root. + +Each is held here on an assembly built from openRepoShape's own template bytes +at the pinned commit, with every leg a real git repository behind a bare local +origin and a fake `gh` that exits 1 (the bench of `tests/test_triad_lane_tooling.py`, +imported, so the two modules build one assembly one way). AND THE CONDITION OF +ALL THREE: on a single repository - no `project.yaml`, or one naming no leg but +itself - the handoff's output, its top block, the inventory it writes and the +reconciliation are byte for byte what `c45a452`'s tools printed, even where the +repository is laid out to look like a triad (a submodule with a worktree under +`worktrees//code`, and a tree recorded with the submodule as its +checkout). That "before" is STORED (`tests/fixtures/t018-handoff/`), generated +ONCE from `c45a452`'s own tools by this module's `--capture`, because CI checks +out at depth 1; `tests/test_triad_lane_tooling.py`'s own golden covers the +ordinary single repository and keeps passing unregenerated. + +NOTHING REAL IS TOUCHED: every repository, origin, register, `$HOME` and state +directory is under `tmp_path`, and the network is never reached. +""" + +from __future__ import annotations + +import difflib +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from conftest import REPO, WINDOWS_SKIP # noqa: E402 +from test_triad_lane_tooling import ( # noqa: E402 + NEEDS_TEMPLATE, US, Bench, build_assembly, normalise, parse_golden, render_golden, shown) + +pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="the lane tools are bash and Python over git")] + +GOLDEN = (Path(__file__).resolve().parent / "fixtures" / "t018-handoff" + / "single-repository-decoys.golden") + + +# ================================================================== helpers + +def inventory(b: Bench, lane: str) -> dict: + """{path: checkout} of the lane's #97 inventory, `lane-trees`' tenth field.""" + proc = b.tool("lanes-edit.sh", "lane-trees", lane) + assert proc.returncode in (0, 8), proc.stderr + rows = {} + for line in proc.stdout.splitlines(): + cols = line.split(US) + if len(cols) > 9: + rows[cols[1]] = cols[9] + return rows + + +def reconcile_rows(out: str) -> dict: + """{physical path: (class, why)} of `lane-reconcile`'s TREE rows. Keyed by + the PHYSICAL path, because git's registrations are spelled physically and + a recorded tree as it was polled (macOS keeps `$TMPDIR` under `/var`, a + symlink); look a path up with `real()`.""" + rows = {} + for line in out.splitlines(): + cols = line.split(US) + if cols[0] == "TREE" and len(cols) > 4: + rows[real(cols[3])] = (cols[2], cols[4]) + return rows + + +def real(path) -> str: + return os.path.realpath(str(path)) + + +def field(out: str, key: str) -> list: + return next((ln.split(US)[1:] for ln in out.splitlines() if ln.split(US)[0] == key), []) + + +def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff") -> subprocess.CompletedProcess: + return b.tool("lane-handoff", "--lane", lane, "--transcript", "none", "--agent", "claude", + why, cwd=cwd) + + +def top_block(b: Bench, lane: str) -> str: + return (b.wip / "handoffs" / f"{lane}.md").read_text().split("\n---\n", 1)[0] + + +def leg_tree(b: Bench, leg: Path, path: Path, branch: str) -> Path: + """A worktree of `leg`'s repository at `path`, on a new branch from origin/main.""" + b.git("worktree", "add", "-q", "-b", branch, path, "origin/main", cwd=leg) + return path + + +@pytest.fixture +def triad(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + b.lanes("opensoft/triad", t["assembly"], "triad-1", "triad-2") + return b, t + + +# ============================================================= (1) and (2) + +@NEEDS_TEMPLATE +def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its_leg(triad): + """(1) THE PAIRED TREES ARE POLLED: a paired code tree holding the lane's + unpushed commit and a paired spec tree holding a writer's first edit are in + the WRITERS block, and what under the paired root is not a checkout of its + own is not. (2) EACH TREE IS RECORDED UNDER ITS LEG: the code tree `add` + made with `--checkout /code` keeps the leg as its checkout through + the handoff, the paired trees are recorded under theirs, and a tree of the + assembly itself is still recorded under the assembly. The reconciliation + then reads every one of them as inventoried, never as unmanaged.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + lane_code = Path(made.stdout.strip()) + assert inventory(b, "triad-1")[str(lane_code)] == str(code) + b.commit(lane_code, "the slice's code", {"c1.txt": "c1\n"}, lane="triad-1") + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "007-y" / "code", "007-y") + b.commit(paired_code, "the feature's code", {"f.txt": "f\n"}, lane="triad-1") + paired_spec = leg_tree(b, spec, a / "worktrees" / "007-y" / "spec", "007-y") + (paired_spec / "draft.md").write_text("a writer's first edit\n") + # under the paired root and NOT a checkout of its own: the assembly's tree + (a / "worktrees" / "007-y" / "notes").mkdir() + (a / "worktrees" / "007-y" / "notes" / "n.md").write_text("n\n") + + proc = handoff(b, "triad-1", a) + assert proc.returncode == 0, proc.stdout + proc.stderr + for tree, branch in ((lane_code, "c1"), (lane_root, "root"), (paired_code, "007-y"), + (paired_spec, "007-y")): + assert f"WRITER {tree} — branch {branch}" in proc.stdout, proc.stdout + for not_one in (a / "worktrees" / "007-y" / "notes", a / "worktrees" / "007-y"): + assert f"WRITER {not_one} —" not in proc.stdout, proc.stdout + assert f"writers polled: 4 under {a}" in proc.stderr, proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + + block = top_block(b, "triad-1") + assert f"(4 found under `{a}`)" in block, block + assert (f"- `{paired_code}` — branch `007-y`, last commit" in block + and "0 dirty, 1 unpushed" in block.split(f"`{paired_code}`", 1)[1].split("\n", 1)[0]), block + assert "1 dirty, 0 unpushed" in block.split(f"`{paired_spec}`", 1)[1].split("\n", 1)[0], block + + inv = inventory(b, "triad-1") + assert inv == {str(lane_code): str(code), str(lane_root): str(a), + str(paired_code): str(code), str(paired_spec): str(spec)}, inv + + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert rec.returncode == 0, rec.stderr + rows = reconcile_rows(rec.stdout) + assert rows[real(paired_code)][0] == "unpushed", rows + assert rows[real(paired_spec)][0] == "dirty", rows + assert not [p for p, (cls, _w) in rows.items() if cls in ("unmanaged", "stale-registration")], rows + assert field(rec.stdout, "TREES")[0] == "4 inventoried", rec.stdout + + +@NEEDS_TEMPLATE +def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad): + """A LEG THAT IS NOT CHECKED OUT is an empty directory of the assembly's, + where git answers the ASSEMBLY - so read naively, its repository would be + the assembly's and every tree of the assembly would be recorded under it. + Deinitialized, the spec leg names no tree; the code leg still names its own.""" + b, t = triad + a, code = t["assembly"], t["code"] + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any((a / "spec").iterdir()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "008-z" / "code", "008-z") + proc = handoff(b, "triad-1", a) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} + + +@NEEDS_TEMPLATE +def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on(triad): + """A `project.yaml` that is there and cannot be read is never "no legs" + (Amendment 7(d)), and a swap is never left unwritten (`R-A11-11`): the + handoff exits 0, the paired root is NOT polled and the top block says so, + a tree of the assembly's own repository is recorded as always, a tree of a + leg keeps the checkout `add` recorded rather than one nobody could + establish, and the lane stays SWAPPING.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + a, code = t["assembly"], t["code"] + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + lane_code = Path(made.stdout.strip()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "009-w" / "code", "009-w") + b.commit(paired_code, "the feature's code", {"w.txt": "w\n"}, lane="triad-1") + os.chmod(a / "project.yaml", 0) + try: + proc = handoff(b, "triad-1", a, "unreadable manifest") + finally: + os.chmod(a / "project.yaml", 0o644) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"{a}/project.yaml is there and its legs could not be read" in proc.stderr, proc.stderr + assert "paired feature trees" in proc.stderr and "were NOT polled" in proc.stderr + assert f"WRITER {paired_code} —" not in proc.stdout, proc.stdout + assert f"WRITER {lane_code} —" in proc.stdout and f"WRITER {lane_root} —" in proc.stdout + assert f"the worktree inventory entry for {lane_code} was NOT written" in proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + assert "the assembly's legs could not be read" in proc.stderr + block = top_block(b, "triad-1") + assert "- NOT POLLED: " in block and f"`{a}/worktrees`" in block, block + assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} + + +# ===================================================== the single repository + +def single_repository_decoys(bench: Bench) -> list: + """THE SCENARIO WHOSE OUTPUTS MAY NOT MOVE, laid out to look like a triad: + a repository with a submodule that is NO leg, a worktree of that submodule + under `worktrees//code` and one of the repository itself under + `worktrees//spec`, and a tree under the lane's own root recorded + with the SUBMODULE as its checkout - first with no `project.yaml`, then with + one naming no leg but the checkout itself. The handoff's output, its top + block and inventory, and the reconciliation before and after it.""" + b = bench + out = [] + dep = b.seeded("dep", {"d.txt": "a dependency\n"}) + + def scenario(name: str, manifest: str | None) -> None: + lane = f"{name}-1" + b.origin(name) + s = b.projects / name + b.git("clone", "-q", b.remotes / f"{name}.git", s) + files = {"README.md": f"{name}\n", ".gitignore": "worktrees/\n"} + if manifest is not None: + files["project.yaml"] = manifest + b.commit(s, "seed", files) + b.git("submodule", "add", "-q", dep, "vendor/dep", cwd=s) + b.commit(s, "a submodule that is no leg") + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes(f"opensoft/{name}", s, lane) + vendor = s / "vendor" / "dep" + decoy_code = leg_tree(b, vendor, s / "worktrees" / "001-d" / "code", "001-d") + b.commit(decoy_code, "decoy code", {"x.txt": "x\n"}, lane=lane) + decoy_spec = leg_tree(b, s, s / "worktrees" / "001-d" / "spec", "001-d") + (decoy_spec / "y.md").write_text("y\n") + mine = leg_tree(b, vendor, b.projects / ".lane-worktrees" / lane / "dep", "dep") + b.commit(mine, "lane work in the dependency", {"m.txt": "m\n"}, lane=lane) + rec = b.tool("lanes-edit.sh", "set-lane-tree", lane, mine, "--checkout", vendor) + assert rec.returncode == 0, rec.stderr + out.append((f"{name}: lane-reconcile before", reconciled(b, lane))) + out.append((f"{name}: lane-handoff", shown(handoff(b, lane, s), b))) + out.append((f"{name}: lane-handoff: top block", normalise(top_block(b, lane), b))) + trees = b.tool("lanes-edit.sh", "lane-trees", lane) + trees.stdout = "".join(sorted(trees.stdout.splitlines(keepends=True), + key=lambda line: normalise(line, b))) + out.append((f"{name}: lane-handoff: inventory", shown(trees, b))) + out.append((f"{name}: lane-reconcile after", reconciled(b, lane))) + + scenario("repoD", None) + scenario("repoQ", ("schema_version: 1\nkind: project-manifest\nid: repoQ\nlegs:\n" + " - role: assembly\n repository: opensoft/repoQ\n path: \".\"\n")) + return out + + +def reconciled(b: Bench, lane: str) -> str: + """`lane-reconcile`, its TREE rows in a stable order: the inventoried ones + come in the order of their sidecars' file names, a checksum of the bench's + own path, so every row is sorted by its normalised text, in place.""" + proc = b.tool("lanes-edit.sh", "lane-reconcile", lane) + lines = proc.stdout.splitlines(keepends=True) + idx = [i for i, ln in enumerate(lines) if ln.startswith("TREE" + US)] + for i, ln in zip(idx, sorted((lines[i] for i in idx), key=lambda ln: normalise(ln, b))): + lines[i] = ln + proc.stdout = "".join(lines) + return shown(proc, b) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="the golden was captured on Linux, as tests/test_triad_lane_tooling.py's") +def test_t018_single_repository_handoff_and_reconcile_are_c45a452s_byte_for_byte(tmp_path): + """THE INVARIANT, ON A REPOSITORY LAID OUT TO LOOK LIKE A TRIAD: with no + `project.yaml` naming a leg, a `worktrees//code` holding a + submodule's worktree is not polled or reported as a paired tree, and a tree + of a submodule is recorded under the lane's checkout, exactly as + `c45a452`'s tools did - every byte of every output equal to the stored + golden this module's `--capture` wrote from them.""" + assert GOLDEN.is_file(), f"{GOLDEN} is missing; it is generated from c45a452's tools" + want = parse_golden(GOLDEN.read_text(encoding="utf-8")) + got = single_repository_decoys(Bench(tmp_path / "bench", REPO)) + assert [k for k, _ in got] == [k for k, _ in want] + for (key, have), (_k, gold) in zip(got, want): + if have != gold: + diff = "\n".join(difflib.unified_diff(gold.splitlines(), have.splitlines(), + f"c45a452: {key}", f"now: {key}", lineterm="")) + raise AssertionError(f"the single-repository output of '{key}' moved:\n{diff}") + + +# ===================================================================== capture + +def capture(tools: Path, golden: Path) -> None: + """Write the golden file from the tools in `tools` - run ONCE, against + `c45a452`'s (`git archive c45a452 | tar -x -C `), and committed.""" + with tempfile.TemporaryDirectory(prefix="t018-handoff-capture.") as tmp: + text = render_golden(single_repository_decoys(Bench(Path(tmp) / "bench", tools))) + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) in (3, 4) and sys.argv[1] == "--capture": + capture(Path(sys.argv[2]).resolve(), Path(sys.argv[3]) if len(sys.argv) == 4 else GOLDEN) + sys.exit(0) + sys.stderr.write("usage: test_triad_lane_tooling_handoff.py --capture []\n") + sys.exit(64) From 9a5bbe24d02f26a942c07b21748f5a2375faca21 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:15:36 +0000 Subject: [PATCH 09/23] =?UTF-8?q?T018=20(b):=20lane-reconcile=20reads=20a?= =?UTF-8?q?=20triad's=20legs=20=E2=80=94=20each=20leg's=20worktree=20regis?= =?UTF-8?q?trations=20and=20the=20paired=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan task T018 (opensoft/openRepoTools#186), slice (d2), part (b), second half: the third gap docs/triad-lane-tooling-analysis.md §4.4 measured. `lanes-edit.sh lane-reconcile` read only the checkout's own worktree registrations and the two lane roots, so a tree of a leg's repository that no sidecar names was invisible to it. SHAPE-GATED, through `assembly_legs` (2aa8a59's shared reader, called as it is): - EVERY CHECKED-OUT LEG'S REGISTRATIONS, read beside the checkout's own. Each worktree git registers in a leg that no sidecar names is a TREE row: `unmanaged`, or `stale-registration` where its directory is gone. The row names the leg ("git registers it in , the code leg of /project.yaml ()"). The leg's main entry, which git spells as its git directory under `.git/modules/`, is skipped by place. A leg that is not checked out (git answers the assembly there) registers nothing of its own and is passed over. - THE PAIRED ROOT, after the lane roots: `/worktrees/` and `/`, each holding a `.git` of its own that nothing above named (a lost registration, or a clone where a worktree belongs), is `unmanaged`. - PATHS NOBODY READ ARE NOT CLEARED: a project.yaml that cannot be read, a leg whose `git worktree list` fails, or a paired root or feature directory that cannot be listed makes the verdict `indeterminate`. The unreadable manifest is said in the words `lane-worktrees`' sweep refuses with ("the legs of the assembly could not be read"), because the sweep reads this verdict first and refuses on it. The previous run's one failure, tests/test_triad_lane_tooling.py:616, was exactly that: same exit 2, the reconciliation's wording instead of the sweep's. One fact now has one spelling, and that test passes unchanged. Nothing is adopted, deleted or overwritten, as before. THE GATE: a checkout with no project.yaml is asked nothing. One whose project.yaml names no leg but itself gets `assembly_legs`' 8 and nothing else. Every row, count and verdict on a single repository is what it was. THE PROOF, under tests/run.sh, this exact tree (lanes-edit.sh and the test module last written 2026-10-10T02:43:15Z, the run started 02:43:34Z): tests/run.sh -k "t018 or supervised_restart or lanes_index or repo_hygiene or lane_start_claude_current or openrepotools_command" -rs -> 544 passed, 768 deselected in 1318.62s (0:21:58), on a quiet box The 544 include both T018 modules. Two goldens stored from c45a452's tools pass UNREGENERATED: tests/fixtures/t018/single-repository.golden (byte identical) and tests/fixtures/t018-handoff/single-repository-decoys.golden. The latter covers lane-reconcile before and after a handoff on a repository laid out to look like a triad. Also included are the supervised-restart module, the lanes-index module (the real lane-reconcile), lane-start's module (it calls lane-reconcile), hygiene and the installer module. Two new cases are in tests/test_triad_lane_tooling_handoff.py, on openRepoShape's template bytes at the pin: the legs' registrations and the paired root are reported (a spec tree made away from every root, a paired code worktree, a clone in the paired root, a stale registration; neither leg nor its `.git/modules` entry reported), and the indeterminate verdicts (unreadable manifest, unlistable paired root). Against 96da9af's lanes-edit.sh both fail (measured). The earlier run of this half, before the wording fix: 1 failed, 446 passed, 865 deselected in 2718.97s (the :616 case above). Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 110 ++++++++++++++++++++++- tests/test_triad_lane_tooling_handoff.py | 77 ++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index b7b34305..a2523d31 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12760,7 +12760,10 @@ lane_real_path() { # # THE LANE'S TWO WORKTREE ROOTS — the same two `lane-handoff` polls, and they # are here rather than there so the poll and the reconciliation cannot come to -# disagree about where a lane keeps its writers. +# disagree about where a lane keeps its writers. Where the lane's checkout is an +# ASSEMBLY (T018) both also read its paired root, `/worktrees/ +# /`, which is two levels deep and per feature, so it +# is read beside these two (`lane_reconcile`, step 4) rather than as a third. lane_worktree_roots() { # lwr_lane="${1-}"; lwr_dir="${2-}" [ -n "$lwr_dir" ] || return 0 @@ -13070,6 +13073,22 @@ EOF # from the two lane roots on disk. It is REPORTED and never adopted, deleted # or overwritten: which lane a tree belongs to is a person's to say. lrc_unmanaged=0 + # A TRIAD (opensoft/openRepoTools#186, plan task T018). WHERE THE LANE'S + # CHECKOUT IS AN ASSEMBLY — its own `project.yaml` declares a leg other than + # itself, read by `assembly_legs`, the one reader every lane tool asks — what + # its LEGS hold is read too: the worktrees git registers in each leg's + # repository, which `git worktree list` in the assembly never names, and the + # paired feature trees `/worktrees//`. + # Both are reported exactly as the checkout's own registrations and the lane + # roots are, and adopted, deleted or overwritten no more. A checkout with no + # `project.yaml` is asked nothing, so its report is what it always was; a + # manifest, a leg's registrations or the paired root that could not be read + # leaves paths nobody read, and the verdict below says so. + lrc_legs=""; lrc_lgrc=8; lrc_legwhy=""; lrc_tab="$(printf '\t')" + if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ] && { [ -e "$lrc_dir/project.yaml" ] || [ -L "$lrc_dir/project.yaml" ]; }; then + lrc_lgrc=0 + lrc_legs="$(assembly_legs "$lrc_dir")" || lrc_lgrc=$? + fi if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ]; then # THE CHECKOUT'S OWN ROW IS NOT AN UNMANAGED TREE, AND git ANSWERS WITH THE # PHYSICAL PATH. A recorded `dir` reached through a symlink — which is how @@ -13104,6 +13123,46 @@ EOF done </dev/null || :) EOF + fi + # T018: EVERY LEG'S REGISTRATIONS, beside the checkout's own. A leg that is + # not checked out here is an empty directory of the assembly's, where git + # answers the ASSEMBLY — it registers nothing of its own, so it is passed + # over. A leg's main worktree is listed FIRST and spelled as its git + # directory under the assembly's `.git/modules/`, so it is skipped by place, + # not by name. + if [ "$lrc_lgrc" = 0 ]; then + while IFS="$lrc_tab" read -r lrc_lrepo lrc_ldecl lrc_labs; do + [ -n "$lrc_labs" ] && [ -d "$lrc_labs" ] || continue + lrc_ltop="$(git -C "$lrc_labs" rev-parse --show-toplevel 2>/dev/null /dev/null could + # not be read`), because the sweep reads this verdict FIRST and refuses on + # it: one fact, one spelling, whichever reader a person meets. + if [ "$lrc_lgrc" != 0 ] && [ "$lrc_lgrc" != 8 ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="the legs of the assembly $lrc_dir could not be read (its project.yaml is there and unreadable), so which legs it declares — and the worktrees they register and the paired feature trees under $lrc_dir/worktrees — is NOT established, and no clearance is pronounced over paths nobody read (T018, Amendment 7(d))" + fi + if [ -n "$lrc_legwhy" ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="lane $lrc_lane's checkout $lrc_dir is an assembly and not all of what its legs hold could be read ($lrc_legwhy), so no clearance is pronounced over paths nobody read (T018, Amendment 7(d))" + fi printf 'TREES%s%s inventoried%s%s dirty or unpushed%s%s require recovery%s%s unmanaged or stale\n' \ "$US" "$lrc_n" "$US" "$lrc_dirty" "$US" "$lrc_recover" "$US" "$lrc_unmanaged" printf 'VERDICT%s%s%s%s\n' "$US" "$lrc_v" "$US" "$lrc_why" diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 2eeefb86..43cf4e59 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -236,6 +236,83 @@ def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} +# ===================================================================== (3) + +@NEEDS_TEMPLATE +def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): + """(3) THE LEGS' REGISTRATIONS AND THE PAIRED ROOT ARE READ: a spec tree + made away from every root, a paired code tree, a clone where a paired + worktree belongs and a registration whose directory is gone are each + reported - unmanaged or stale - naming the leg, while a tree the inventory + records is not, and neither leg's own checkout is.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + away = leg_tree(b, spec, b.projects / "away-spec", "away") + paired = leg_tree(b, code, a / "worktrees" / "010-v" / "code", "010-v") + clone = a / "worktrees" / "010-v" / "spec" + b.git("clone", "-q", b.remotes / "triad-spec.git", clone) + gone = leg_tree(b, code, b.projects / "gone-code", "gone") + shutil.rmtree(gone) + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + recorded = made.stdout.strip() + + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert proc.returncode == 0, proc.stderr + rows = reconcile_rows(proc.stdout) + assert rows[real(away)][0] == "unmanaged", rows + assert f"git registers it in {spec}, the spec leg of {a}/project.yaml (opensoft/triad-spec)" \ + in rows[real(away)][1], rows + assert rows[real(paired)][0] == "unmanaged", rows + assert f"the code leg of {a}/project.yaml" in rows[real(paired)][1], rows + assert rows[real(clone)][0] == "unmanaged", rows + assert f"paired feature trees ({a}/worktrees)" in rows[real(clone)][1], rows + assert rows[real(gone)][0] == "stale-registration", rows + assert f"git -C {code} worktree prune" in rows[real(gone)][1], rows + assert rows[real(recorded)][0] == "ok", rows + for not_a_tree in (a, code, spec, a / ".git" / "modules" / "code", a / ".git" / "modules" / "spec", + a / "worktrees" / "010-v"): + assert real(not_a_tree) not in rows, rows + assert field(proc.stdout, "TREES") == ["1 inventoried", "0 dirty or unpushed", + "0 require recovery", "4 unmanaged or stale"], proc.stdout + assert field(proc.stdout, "VERDICT")[0] == "ungraceful-stop", proc.stdout + + +@NEEDS_TEMPLATE +def test_t018_reconcile_pronounces_no_clearance_over_legs_nobody_read(triad): + """A manifest, or the paired root, that could not be read leaves paths + nobody inspected: the verdict is `indeterminate`, never a clearance.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + a, code = t["assembly"], t["code"] + paired = leg_tree(b, code, a / "worktrees" / "011-u" / "code", "011-u") + os.chmod(a / "project.yaml", 0) + try: + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(a / "project.yaml", 0o644) + assert proc.returncode == 0, proc.stderr + verdict = field(proc.stdout, "VERDICT") + assert verdict[0] == "indeterminate", proc.stdout + assert (f"the legs of the assembly {a} could not be read (its project.yaml is there and " + "unreadable)") in verdict[1], verdict + assert real(paired) not in reconcile_rows(proc.stdout) + # the paired root that cannot be listed: a clone stands in it, which only + # the listing could find (a registered tree is found through its leg) + b.git("worktree", "remove", paired, cwd=code) + b.git("clone", "-q", b.remotes / "triad-code.git", a / "worktrees" / "012-t" / "code") + os.chmod(a / "worktrees", 0) + try: + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(a / "worktrees", 0o755) + assert proc.returncode == 0, proc.stderr + verdict = field(proc.stdout, "VERDICT") + assert verdict[0] == "indeterminate", proc.stdout + assert f"the assembly's paired root {a}/worktrees could not be listed" in verdict[1], verdict + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From b71e547d0f16693e9500fb5065cbfe0293a66c94 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:46 +0000 Subject: [PATCH 10/23] =?UTF-8?q?WIP=20(untested):=20T018=20(b)=20guidance?= =?UTF-8?q?=20=E2=80=94=20README-lanes=20says=20what=20lane-handoff=20and?= =?UTF-8?q?=20lane-reconcile=20read=20in=20a=20triad,=20writer=202's=20dra?= =?UTF-8?q?ft=20checked=20against=209a5bbe2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writer 2's paragraph (briefs/t018-b-guidance.md, 2026-10-09T21:53Z) after "`lane-end`'s gate" in the T018 section, each sentence checked against the merged lane-handoff (4e39854) and lane_reconcile (9a5bbe2). Two corrections: the unreadable-manifest verdict is spelled in the sweep's words, which the sweep reads first and refuses on (exit 2); and a project.yaml naming no leg but itself is asked one project-legs call (answering 8), not nothing. Untested: committed for an account swap while the merged tree's scoped run was in flight; that run's counts follow in the next commit. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 741f7156..5e0cbfbe 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3922,6 +3922,43 @@ names, with the remedy a person's (commit and push from inside it; no `--yes` is offered when legs are all it counted), and lists a leg that holds work and is not this lane's below the table as NOT counted — whether the gate passes or not. +**`lane-handoff` and `lane-reconcile` (T018 (b)).** Where the lane's checkout is an +assembly, `lane-handoff` also polls the **paired root**: `/worktrees/` +and `/`. It polls every directory there with a `.git` +of its own, exactly as it polls `.claude/worktrees`, because the paired root is per +feature, not per lane, and a writer with nothing committed yet has no trailer to be +found by. So a writer in a paired tree is in the WRITERS block. Every tree it polls is +**recorded under the checkout it belongs to**. A worktree of a leg's repository (a +paired tree, or one `lane-worktrees add --checkout /code` made under the +lane's root) is recorded under that leg, the path `project-legs` prints and `add` +records. Any other tree keeps the lane's `dir`, as before. A leg that is not checked +out here (never initialized, or deinitialized) names no tree. In an assembly several +lanes share, each lane's handoff records every paired tree, as it records every tree in +`.claude/worktrees`. A tree two lanes' inventories both name is `keep` in both sweeps +until a person says whose it is. A worktree a leg registers anywhere else is not polled +as a writer, just as the checkout's own registrations elsewhere are not; +`lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold +beside what the checkout holds. Every worktree git registers in a checked-out leg that +no sidecar names is a `TREE` row: `unmanaged`, or `stale-registration` where its +directory is gone, naming the leg. A checkout in the paired root that nothing else named +is `unmanaged`. Nothing is adopted, deleted or overwritten. + +**A `project.yaml` that cannot be read is never "no legs".** `lane-handoff` still +completes (`R-A11-11`), but it does not poll the paired root. It says so in the top +block's WRITERS section (`- NOT POLLED: …`), files no sidecar for a tree it cannot +place (a tree of the lane's own repository is still recorded), and leaves the lane +`SWAPPING`. The reconciliation's verdict is `indeterminate`, said in the words the +sweep refuses with ("the legs of the assembly `` could not be read"): the sweep +reads that verdict first and refuses on it, exit **2**. The verdict is `indeterminate` +too where a leg's registrations, the paired root or a feature directory under it +cannot be listed. **A checkout with no `project.yaml` is asked nothing**, and one whose +`project.yaml` names no leg but itself is asked only `project-legs`, which answers 8. +The handoff's output, top block and inventory, and the reconciliation, are byte for +byte what they were. `tests/test_triad_lane_tooling_handoff.py` holds them to the +output `c45a452`'s tools printed, on a repository laid out to look like a triad: a +submodule's worktree under `worktrees//code`, and a tree recorded with the +submodule as its checkout. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a From 9991d697849b5aee070ffe1ea3c451ef0314ef05 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:46 +0000 Subject: [PATCH 11/23] =?UTF-8?q?WIP=20(untested):=20T018=20(d)=20?= =?UTF-8?q?=E2=80=94=20add's=20refusal=20on=20an=20unreadable=20manifest?= =?UTF-8?q?=20is=20tested,=20and=20spelled=20as=20the=20sweep=20and=20lane?= =?UTF-8?q?-reconcile=20spell=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_t018_add_refuses_where_the_manifest_cannot_be_read holds 13cb52d's README claim (mode-000 project.yaml: exit 2, no tree, no inventory row). The refusal reads "the legs of the assembly could not be read", the sweep's and 9a5bbe2's words, in place of "the legs of the lane's assembly ". Reached only where /project.yaml is there and project-legs fails on it; a checkout with no project.yaml is asked nothing. Untested: committed for an account swap while the merged tree's scoped run was in flight; that run's counts follow in the next commit. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-worktrees | 2 +- tests/test_triad_lane_tooling.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lane-worktrees b/lane-worktrees index 53a7c42b..ab9bd286 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5900,7 +5900,7 @@ def add_tree(ns) -> int: record_co, leg_line, assembly_hint = checkout, "", "" legs, why = project_legs(helper, lane_dir) if why: - return refuse(f"the legs of the lane's assembly {lane_dir} could not be read, so which " + return refuse(f"the legs of the assembly {lane_dir} could not be read, so which " f"checkout a tree of it belongs to is unknown (Amendment 7(d)): {why}") if legs: made_from = git_common_dir_of(checkout) diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index b19e15d9..42c36529 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -708,6 +708,26 @@ def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) + +@NEEDS_TEMPLATE +def test_t018_add_refuses_where_the_manifest_cannot_be_read(triad): + """Never "no legs" (Amendment 7(d)): which checkout the tree would be + recorded under is unknown, so nothing is made, in the words the sweep + and the reconciliation use for the same fact.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + os.chmod(t["assembly"] / "project.yaml", 0) + try: + proc = b.tool("lane-worktrees", "add", "triad-1", "c9", "--checkout", t["code"]) + finally: + os.chmod(t["assembly"] / "project.yaml", 0o644) + assert proc.returncode == 2, proc.stderr + assert (f"REFUSED, nothing was made: the legs of the assembly {t['assembly']} could not " + "be read") in proc.stderr + assert not (t["assembly"].parent / ".lane-worktrees" / "triad-1" / "c9").exists() + assert not any(p.endswith("/c9") for p in _inventory(b, "triad-1")) + # ===================================================================== capture def capture(tools: Path, golden: Path) -> None: From 941330ad73af6cce16f8204873b7a25d1d362104 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:23:12 +0000 Subject: [PATCH 12/23] =?UTF-8?q?T018=20docs:=20a=20parked=20paired=20tree?= =?UTF-8?q?=20is=20not=20the=20lane's=20to=20the=20sweep=20(=C2=A77=20item?= =?UTF-8?q?=2011,=20for=20lane=201's=20ruling),=20and=20=C2=A76=20says=20w?= =?UTF-8?q?hat=20the=20branch=20now=20ships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/README-lanes.md, T018 section: a parked or resumed paired tree carries no Lane: trailer (park.sh puts the lane in the WIP subject only; resume soft-resets those commits), so the sweep and lane-end's gate count it for nobody unless the lane's inventory names it. The paragraph says what to read before lane-end in an assembly, and how to record such a tree with set-lane-tree so the gate counts it. docs/triad-lane-tooling-analysis.md: a dated §6 note on what the branch ships beyond its first commit, and §7 item 11, the choice between reading park's WIP subject and giving park's commit a trailer, plus whether resume records the tree it rebuilds. Written by the previous writer on 2026-10-11 and left uncommitted; checked here against HEAD 8cb1368, whose lane-worktrees, lane-end and lane-handoff are byte-identical to 9991d69's. Every line reference holds: lane-worktrees :2424-2431, :4473-4494 and :2124, lane-end :2122-2127, the FOREIGN reason text (lane-worktrees:3037-3039), and workBenches ffd5516 park.sh :226 and :860 and resume.sh :221-231 and :746. Three corrections in item 11: the source is review 5481327496 on #196 (a pull-request review, not an issue comment); its finding 2 was derived from code and not run, so "measured" is now "derived", the document's own word for that; and the inventory names a paired tree through lanes-edit.sh set-lane-tree, not through lane-worktrees add, which makes its trees under the lane's own root only. Docs only. No test run for this commit; the branch's one verification run follows the M1(a) and should-fix commits. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 15 +++++++++++ docs/triad-lane-tooling-analysis.md | 40 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5e0cbfbe..b36316ad 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3959,6 +3959,21 @@ output `c45a452`'s tools printed, on a repository laid out to look like a triad: submodule's worktree under `worktrees//code`, and a tree recorded with the submodule as its checkout. +**A parked paired tree is not the lane's to the sweep — a gap, for lane 1's ruling +(docs/triad-lane-tooling-analysis.md §7, item 11).** The sweep and `lane-end`'s gate +give a tree under `/worktrees` to a lane by its HEAD's own `Lane:` trailer +and by nothing else, unless the lane's inventory names it. `make park` commits a dirty +leg tree as `wip: park — lane `, with no trailer, and `resume` +soft-resets those commits, leaving the work uncommitted on the commit below them. So a +parked or resumed paired tree is a FOREIGN row of `lane-worktrees sweep +--dry-run` ("… its HEAD carries no Lane: trailer of this lane's as its own"), counted +for nobody, and a gate that passes says nothing of it. **Before `lane-end` in an +assembly**, run that dry run and read every FOREIGN row under `/worktrees`: +one whose HEAD, or whose branch's upstream, is a `wip: park … — lane ` +commit, or that holds uncommitted edits, may be this lane's parked work. Land it or +hand it over first, or record it with `lanes-edit.sh set-lane-tree +--checkout /` so the gate counts it. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 46a815c5..633415f2 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -557,6 +557,19 @@ Every behaviour change proposed in §4 changes what a command prints or does **t this repository or for the estate's existing triads. Each is written above as a proposal with a sketch, for a ruling. +**2026-10-11: what the branch now ships.** That paragraph described the branch's first +commit. The branch now also carries T018's shape-gated changes (§4.4, 2026-10-09 note): +(a) `lanes-edit.sh project-legs`, the one reader of an assembly's legs, with +`lane-worktrees sweep` and `lane-end`'s gate enumerating the legs, the worktrees they +register and the paired root; (b) `lane-handoff` polling the paired root and recording +each tree under its leg, and `lane-reconcile` reading the legs' registrations and the +paired root (merged from writer 2's `feat/triad-lane-tooling-handoff`); (c) the daily +report counting each leg as a checkout of its own; (d) `lane-worktrees add` recording +the leg it made a tree from. On a single repository every output is byte for byte what +`c45a452` printed, which two stored goldens hold: `tests/fixtures/t018/` and +`tests/fixtures/t018-handoff/`. Usage text moves for every shape (§4.4). The branch merges +main at `f87fd5a` (#190) and changes none of #190's test-root files. + **Overlap with #190, and why this branch carries no test change.** An earlier draft of this branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more completely, under other names. The draft was dropped before it was committed; its diff is @@ -609,3 +622,30 @@ kept only in this lane's scratch directory. `/worktrees//code`, with the spec root at `../spec` (§4.13), and move the third manual read (#190's test_lane_helpers.sh:6595-6596) inside its `if`, or a standalone code-leg run aborts on `ln_doc: unbound variable` (§4.13, measured). +11. **Parked paired trees (T018).** **2026-10-11**, from lane 3's review of #196's T018 + record (review 5481327496 on #196, finding 2), **derived** from the code of this + branch and of workBenches `ffd5516`, not run. The sweep, and so `lane-end`'s + close-out gate, gives a tree under `/worktrees` to a lane only by its + HEAD's own `Lane:` trailer, among the commits origin's default branch lacks: + `lane-worktrees` at 9991d69, :2424-2431 (the paired-root claim) and :4473-4494 + (`own_trailers`, `head_lane_trailer`), and a leg checkout by the same rule, :2124. + workBenches' `park.sh` + (`devBenches/base-image/files/speckit-worktree/templates/specify/extensions/git/scripts/bash/park.sh`) + puts the lane only in the WIP commit's subject, `wip: park — lane ` + (:226), committed with `-m "$WIP_SUBJECT"` and no trailer (:860). `resume.sh` knows + those commits by that subject (:221-231) and `reset --soft HEAD~`s them + (:746), so HEAD is back on the commit below them and the work is uncommitted. A + parked or resumed paired tree is therefore FOREIGN, counted for nobody, and a gate + that passes prints nothing of it: `lane-end`'s pass path (:2122-2127) names only the + unclaimed leg checkouts. It is counted only where the lane's #97 inventory names + it, which happens through `lanes-edit.sh set-lane-tree`, or a `lane-handoff` that + ran while it stood (T018 (b)); `lane-worktrees add` makes its trees under the + lane's own root, never in the paired root. Lane 3 changed no attribution code; `docs/README-lanes.md` + (T018 section) says what to check before `lane-end`. **Choose:** (i) the sweep also + reads park's WIP subject as the lane's mark, which couples `lane-worktrees` to a + workBenches subject format; or (ii) park's WIP commit carries a `Lane: ` + trailer, a workBenches change that resume's subject match does not disturb. Either + way a RESUMED tree has no trailer of its own until its writer next commits, because + its HEAD is back below the WIP commits. So decide too whether `resume` records the + tree it rebuilds in the lane's inventory (`lanes-edit.sh set-lane-tree + --checkout /`), which covers both states. From 4d4957db6e093cb17faa5a61298d4e4d1c4ccf92 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:36:51 +0000 Subject: [PATCH 13/23] T018 (b), pre-PR review M1 option (a): lane-handoff polls the paired root by the sweep's rule, so one lane's handoff never takes another lane's feature trees; and S1, S5, N1-N5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (a). In an assembly, lane-handoff polled and recorded every .git-bearing tree under /worktrees as the running lane's writer. The paired root is per feature, not per lane, so after one handoff another lane's paired work was this lane's inventory: lane-end refused on it, sweep --yes could rescue and remove it, and the next session was told to relaunch writers into it. Now a paired tree is polled and recorded only where this lane's inventory names it (lanes-edit.sh lane-trees, read once), its HEAD's own last Lane: trailer is this lane's on a commit origin's default branch lacks (a bash mirror of lane-worktrees' own_trailers/head_lane_trailer; a clone is never claimed by trailer, as the sweep never adopts one), or the --writers-file names it. Every other checkout there is one "- PAIRED ROOT, not this lane's: — branch …" line under its own heading in the top block, outside WRITERS, not counted and not recorded. Pending lane 1's ruling on #186 between (a) and (b). S1. A paired root, feature directory or leg directory that cannot be listed or searched was skipped in silence by the handoff. It is now a "- NOT POLLED" line, a stderr note and a lane left SWAPPING, as is an inventory that cannot be read. lane-reconcile (indeterminate) and the sweep's _paired (exit 2) gain the one case they also passed over: a leg's directory under a feature that cannot be searched, and in the sweep a root or feature directory that can be listed but not searched. N1. lane-reconcile passes the feature directory to awk through ENVIRON, not -v. N3. The handoff's unreadable-manifest words are the sweep's and lane-reconcile's ("the legs of the assembly could not be read"). N4. add's hint names every leg's --checkout, not legs[-1]. N5. The handoff keeps a recorded checkout of the tree's own repository (a dependency nested in a leg) instead of re-recording the tree under the assembly. Tests. N2: the assembly cases wear conftest's NEEDS_UPSTREAM (composed mode fails rather than skips), and the no-assembly report test needs no marker. New: the two-lane M1 regression; S1's three unlistable directories (handoff and reconcile); N5; S5(b) the sweep's absent row and add's toplevel guard on a deinitialized leg; S5(c) an unreadable leg (counted, lane-end refuses), the submodule-status refusal (unshadowed by reconcile) and _paired's own refusals called directly; and the coordinator's quoted-manifest case (openRepoShape v1.0.0, #395 writes path: "{{SPEC_PATH}}"): project-legs already strips one pair of " or ' quotes, and the test holds it for both quote styles. Two existing handoff tests change for M1 (a): the no-commit paired spec tree is named by a writers file, and the paired code tree of the deinitialized-leg case carries the lane's trailer. A checkout with no project.yaml is untouched: every new path runs only where project-legs answered legs (lane-handoff, lane-reconcile, the sweep) or the manifest declares them (add). Untested in this commit: the branch's one verification run follows on the final tree, and its counts are in the commit that ends this step. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-handoff | 204 ++++++++++++++++++++--- lane-worktrees | 35 +++- lanes-edit.sh | 14 +- tests/test_triad_lane_tooling.py | 202 +++++++++++++++++++--- tests/test_triad_lane_tooling_handoff.py | 150 +++++++++++++++-- 5 files changed, 541 insertions(+), 64 deletions(-) diff --git a/lane-handoff b/lane-handoff index cbb62892..40a5501b 100755 --- a/lane-handoff +++ b/lane-handoff @@ -2017,13 +2017,25 @@ poll_writer() { # # two things follow, and neither touches a checkout with no `project.yaml`, # which is asked nothing and polled and recorded exactly as before: # -# * THE PAIRED ROOT IS POLLED — `/worktrees/` and -# `/`, the standard's three-leg feature -# trees — exactly as `.claude/worktrees` is: every checkout there, because -# the paired root, like that one, is per feature and not per lane, and a -# writer with nothing committed yet has no trailer to be found by. Only a -# directory with a `.git` of its own is a checkout there: anything else -# under it is the assembly's own working tree, where git answers too. +# * THE PAIRED ROOT IS POLLED BY THE SWEEP'S RULE — `/worktrees/ +# ` and `/`, the standard's +# three-leg feature trees. The paired root is per feature and NOT per +# lane: every lane the assembly homes keeps its feature trees there. So a +# tree there is polled and recorded as THIS lane's writer only where one +# of three things holds: this lane's inventory already names it; its +# HEAD's own last `Lane:` trailer is this lane's on a commit origin's +# default branch lacks (`lane-worktrees`' `own_trailers` and +# `head_lane_trailer`, the rule its sweep claims the same root by); or +# the writers file names it, because a writer with nothing committed yet +# has no trailer to be found by. Every other checkout there is ANOTHER +# LANE'S OR NOBODY'S: one informational line in the top block, outside +# WRITERS, never counted and never recorded. A handoff that recorded them +# all made other lanes' feature trees this lane's inventory, so its +# `lane-end` refused on their work, its `sweep --yes` could act on it, and +# the next session was told to relaunch writers into them (the pre-PR +# review of T018, M1, option (a)). Only a directory with a `.git` of its +# own is a checkout there: anything else under it is the assembly's own +# working tree, where git answers too. # * A TREE IS RECORDED UNDER THE CHECKOUT IT BELONGS TO. A tree of a leg's # repository — `lane-worktrees add --checkout /code` makes one # under the lane's own root, and every paired tree is one — was re-recorded @@ -2035,8 +2047,13 @@ poll_writer() { # # (`R-A11-11`): the paired root is then NOT polled, the top block's WRITERS # section says so, no tree is recorded under a checkout nobody could establish, # and the lane stays SWAPPING — a poll that could not look everywhere is not -# the record a recovery reads as a finished swap. +# the record a recovery reads as a finished swap. SO IS A PAIRED ROOT, A +# FEATURE DIRECTORY OR A LEG'S DIRECTORY UNDER IT THAT COULD NOT BE LISTED, and +# an inventory that could not be read (`paired_unread`): each is a place a +# tree of this lane's may stand unseen, exactly as `lane-reconcile` says +# `indeterminate` and the sweep refuses for the same directories. legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common="" +paired_unread=""; paired_others=""; paired_other_count=0; paired_inv="" tab="$(printf '\t')" # THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg # and every worktree of its repository share one (`/.git/modules/ @@ -2060,7 +2077,7 @@ if [ -n "$dir" ] && { [ -e "$dir/project.yaml" ] || [ -L "$dir/project.yaml" ]; 8) leg_rows="" ;; *) leg_rows=""; legs_state=unread - legs_unread="$dir/project.yaml is there and its legs could not be read (\`$LANES_EDIT project-legs $dir\` exited $legs_rc)" + legs_unread="the legs of the assembly $dir could not be read (its project.yaml is there, and \`$LANES_EDIT project-legs $dir\` exited $legs_rc)" dir_common="$(common_dir_of "$dir")" note "$legs_unread — which is never 'no legs' (Amendment 7(d)). So the assembly's paired feature trees under $dir/worktrees were NOT polled, and a tree that is not of $dir's own repository is NOT recorded: read them by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." ;; esac @@ -2084,10 +2101,49 @@ if [ "$legs_state" = known ]; then $leg_rows LEG_ROWS fi +# A PLACE THE POLL COULD NOT LOOK, said where it is found and kept for the top +# block's `- NOT POLLED` line and the lifecycle. +paired_note_unread() { # + paired_unread="${paired_unread:+$paired_unread; }$1" + note "$1 — a read that failed is never an empty one (Amendment 7(d)). So a tree of this lane's there may be missing from the WRITERS section: read it by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." +} +# THIS LANE'S INVENTORY, READ ONCE, where the checkout is an assembly whose legs +# were read: which paired trees are already this lane's, and the checkout each +# recorded tree was filed under (`writer_checkout`). ` TAB `, one +# a line. A checkout with no `project.yaml` never asks it. +if [ "$legs_state" = known ]; then + pi_out=""; pi_rc=0 + pi_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-trees "$lane" 2>/dev/null = 2 && $2 != "" { print $2 "\t" $10 }')" + pi_bad="$(printf '%s\n' "$pi_out" | awk -F'\037' 'NF >= 2 && $2 == "" { printf "%s ", $1 }')" + [ -z "$pi_bad" ] || + paired_note_unread "this lane's worktree inventory holds record(s) that could not be read (${pi_bad% }), so a paired tree only they name could not be told from another lane's" ;; + 8) : ;; + *) paired_note_unread "this lane's worktree inventory could not be read (\`$LANES_EDIT lane-trees $lane\` exited $pi_rc), so a paired tree only it names could not be told from another lane's" ;; + esac +fi +# THE CHECKOUT THIS LANE'S INVENTORY RECORDS FOR A TREE, under either spelling +# of the tree's path, or nothing. +inventory_checkout() { # + [ -n "$paired_inv" ] || return 1 + ic_r="$(physical_of "$1")" + IC_P="$1" IC_R="$ic_r" awk -F'\t' ' + $1 == ENVIRON["IC_P"] || (ENVIRON["IC_R"] != "" && $1 == ENVIRON["IC_R"]) { print $2; f = 1; exit } + END { exit f ? 0 : 1 }' </code/upstream/`) is no declared leg, and re-recording its +# tree under the assembly would lose the checkout `add` recorded; else the +# lane's own `dir`, as always. Where the legs could not be read, only a tree +# of `dir`'s own repository can be vouched for, and anything else answers +# NOTHING (the caller files nothing). writer_checkout() { # wc_c="$(common_dir_of "$1")" if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then @@ -2097,11 +2153,78 @@ writer_checkout() { # $leg_commons WC_ROWS fi - if [ "$legs_state" = known ]; then printf '%s' "${dir:-unknown}"; return 0; fi + if [ "$legs_state" = known ]; then + wc_rec="$(inventory_checkout "$1" + [ -n "$writers_file" ] && [ -r "$writers_file" ] || return 1 + awk -F'\t' -v p="$1" '$1 == p { f = 1; exit } END { exit f ? 0 : 1 }' "$writers_file" +} +# EVERY `Lane:` TRAILER of the messages on stdin, one a line, as +# `lane-worktrees` reads them (`^Lane:\s*(\S+)\s*$`, multi-line). +lane_trailers() { + awk '/^Lane:[[:space:]]*[^[:space:]]+[[:space:]]*$/ { + v = $0; sub(/^Lane:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v }' +} +# THE SWEEP'S OWN CLAIM ON THE PAIRED ROOT (`lane-worktrees`' `_lane_claims`, +# with `own_trailers` and `head_lane_trailer`): HEAD's own last `Lane:` +# trailer is this lane's, and this lane's trailer is among the commits HEAD has +# that origin's default branch lacks — `origin/HEAD`, else `origin/main`; with +# neither there is no base to tell them by, and no claim. Compared without +# case, as the sweep compares. A standalone CLONE is never claimed by its +# trailer, as the sweep never adopts one. +head_trailer_is_lane() { # + [ -d "$1/.git" ] && return 1 + ht_base="$(git -C "$1" symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null || :)" + if [ -z "$ht_base" ] && git -C "$1" rev-parse -q --verify refs/remotes/origin/main >/dev/null 2>&1; then + ht_base=refs/remotes/origin/main + fi + [ -n "$ht_base" ] || return 1 + ht_want="$(printf '%s' "$lane" | tr '[:upper:]' '[:lower:]')" + ht_head="$(git -C "$1" log -1 --format=%B HEAD 2>/dev/null | lane_trailers | tail -n 1 | tr '[:upper:]' '[:lower:]')" + [ -n "$ht_head" ] && [ "$ht_head" = "$ht_want" ] || return 1 + git -C "$1" log --format=%B "$ht_base..HEAD" 2>/dev/null | lane_trailers | tr '[:upper:]' '[:lower:]' | + HT_W="$ht_want" awk '$0 == ENVIRON["HT_W"] { f = 1 } END { exit f ? 0 : 1 }' +} +# ONE CHECKOUT OF THE PAIRED ROOT: polled and recorded as this lane's writer, +# or named once as not this lane's — its branch, its last commit and its HEAD's +# own trailer, which is what a person needs to find its lane — and never +# counted, recorded or offered for a relaunch. +paired_tree() { # + if inventory_checkout "$1" >/dev/null || writers_name "$1" || head_trailer_is_lane "$1"; then + poll_writer "$1" + return 0 + fi + pt_branch="$(git -C "$1" symbolic-ref -q --short HEAD 2>/dev/null)"; pt_brc=$? + case "$pt_brc" in 0) : ;; 1) pt_branch='(detached)' ;; *) pt_branch='?' ;; esac + pt_last="$(git -C "$1" log -1 --format='%h %s' 2>/dev/null || :)" + pt_trailer="$(git -C "$1" log -1 --format=%B HEAD 2>/dev/null | lane_trailers | tail -n 1)" + pt_shown=none + [ -z "$pt_trailer" ] || pt_shown="\`$pt_trailer\`" + paired_other_count=$((paired_other_count + 1)) + say "" + say "PAIRED ROOT, not this lane's: $1 — branch $pt_branch, last commit ${pt_last:-}" + say " HEAD's own Lane: trailer ${pt_trailer:-none}; neither lane $lane's inventory nor its writers file names it, so it is not polled, counted or recorded for $lane" + paired_others="$paired_others$(printf -- '- PAIRED ROOT, not this lane'"'"'s: `%s` — branch `%s`, last commit `%s`, HEAD'"'"'s own `Lane:` trailer %s' \ + "$1" "$pt_branch" "${pt_last:-}" "$pt_shown") +" + return 0 +} + : > "$writers_tmp" if [ -n "$dir" ]; then for w in "$dir"/.claude/worktrees/*; do poll_writer "$w"; done @@ -2109,23 +2232,40 @@ if [ -n "$dir" ]; then for w in "$parent"/.lane-worktrees/"$lane"/*; do poll_writer "$w"; done # THE PAIRED ROOT, where the checkout is an assembly whose legs were read. # `}" +[ "$paired_other_count" = 0 ] || + step "paired root: $paired_other_count tree(s) under $dir/worktrees are not lane $lane's — listed in the top block, never counted or recorded" # ------------------------------------------------------ 2. refresh the handoff # @@ -2191,7 +2331,21 @@ top_block() { printf -- '- NOT POLLED: %s, so the assembly'"'"'s paired feature trees under `%s/worktrees` are not in this list. Read them by hand before relaunching a writer there.\n' \ "$legs_unread" "$dir" fi + if [ -n "$paired_unread" ]; then + printf -- '- NOT POLLED: %s, so a tree of this lane'"'"'s under `%s/worktrees` may be missing from this list. Read it by hand before relaunching a writer there.\n' \ + "$paired_unread" "$dir" + fi printf '\n' + # T018, M1 (a): THE PAIRED ROOT'S OTHER TREES, OUTSIDE WRITERS. They are + # other lanes' feature trees, or nobody's: named so the next session knows + # they are there and whose to ask, never counted above, never recorded, and + # never a writer to relaunch. Nothing is printed where there are none, so + # a checkout with no `project.yaml` gets the block it always got. + if [ -n "$paired_others" ]; then + printf '**PAIRED ROOT at %s: %s tree(s) under `%s/worktrees` that are NOT this lane'"'"'s.** None is in this lane'"'"'s inventory, carries its `Lane:` trailer on a commit origin'"'"'s default branch lacks, or is named by its writers file, so none is counted above or recorded. Each is another lane'"'"'s feature tree, or nobody'"'"'s: NEVER relaunch a writer into one of them.\n\n' \ + "$UTC" "$paired_other_count" "$dir" + printf '%s\n' "$paired_others" + fi } handoff_written=0 @@ -2352,6 +2506,7 @@ if [ "$do_late" = 1 ]; then [ "$handoff_written" = 1 ] || lc_unfinished="the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" [ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" + [ -z "$paired_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }a directory of the assembly's paired root, or this lane's inventory, could not be read, so a paired tree of this lane's may not have been polled" lifecycle_finish "$lc_unfinished" else lifecycle_finish "the late PAUSED record was refused by the helper" @@ -2475,6 +2630,7 @@ lc_unfinished="" [ "$handoff_written" = 1 ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the handoff file was not refreshed" [ -z "$lc_inventory_missed" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the worktree inventory was not recorded for $lc_inventory_missed" [ -z "$legs_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }the assembly's legs could not be read, so its paired feature trees were not polled" +[ -z "$paired_unread" ] || lc_unfinished="${lc_unfinished:+$lc_unfinished; }a directory of the assembly's paired root, or this lane's inventory, could not be read, so a paired tree of this lane's may not have been polled" lifecycle_finish "$lc_unfinished" # ------------------------------------------- 5. the restart line — one command diff --git a/lane-worktrees b/lane-worktrees index ab9bd286..f5c7c830 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -1964,9 +1964,12 @@ class Leg: a dependency NESTED in one - an initialized submodule of the leg's own (`code/upstream/openRepoShape`). It is the ASSEMBLY'S, shared by every lane the assembly homes, as the assembly's own checkout is, so a sweep - NEVER acts on one: it is read - for the worktrees git registers in it, for - its branches, and for its own dirty and unpushed state - and counted for - this lane only where its HEAD's own `Lane:` trailer is this lane's.""" + NEVER acts on the CHECKOUT: it is read - for the worktrees git registers + in it and for its own dirty and unpushed state - and counted for this lane + only where its HEAD's own `Lane:` trailer is this lane's. Its BRANCHES are + another matter: under `--branches` they are rows like the checkout's own + (`_branch_checkouts`), and `--yes` deletes a merged lane branch there + exactly as it does in the checkout, bundling first.""" def __init__(self, path: str, decl: str, repository: str, assembly: str, nested_in: str = ""): @@ -2544,10 +2547,14 @@ class Sweep: /` (and `` itself), that no registration above named - a registration lost, or a clone where a worktree belongs. A directory that cannot be listed refuses, as a lane - root does (#170 G1): an unread root is never an empty one.""" + root does (#170 G1): an unread root is never an empty one. One that can + be listed and not SEARCHED is unread too - what stands in it cannot be + told - and so is a leg's directory under a feature that cannot be + searched: `lane-reconcile` and `lane-handoff` say the same of each.""" root = self.paired_root try: names = sorted(os.listdir(root)) + unsearchable = not os.access(root, os.X_OK) except (FileNotFoundError, NotADirectoryError): return except OSError as exc: @@ -2555,6 +2562,11 @@ class Sweep: f"the assembly's worktree root {root} could not be listed " f"({exc.strerror or exc}), so which feature trees stand in it is unknown") return + if unsearchable: + self.refusal = self.refusal or ( + f"the assembly's worktree root {root} could not be listed (it cannot be " + "searched), so which feature trees stand in it is unknown") + return decls = [g.decl for g in self.legs if not g.nested_in] for name in names: fdir = os.path.join(root, name) @@ -2562,14 +2574,22 @@ class Sweep: continue try: os.listdir(fdir) + why = "" if os.access(fdir, os.X_OK) else "it cannot be searched" except OSError as exc: + why = exc.strerror or str(exc) + if why: self.refusal = self.refusal or ( f"the assembly's feature directory {fdir} could not be listed " - f"({exc.strerror or exc}), so which trees stand in it is unknown") + f"({why}), so which trees stand in it is unknown") continue for full in [fdir] + [os.path.join(fdir, d) for d in decls]: if not os.path.isdir(full) or os.path.islink(full) or real(full) in seen: continue + if not os.access(full, os.X_OK): + self.refusal = self.refusal or ( + f"the assembly's paired tree directory {full} could not be searched, " + "so whether a tree stands in it is unknown") + continue if not os.path.lexists(os.path.join(full, ".git")): continue kind = ("paired-clone" if os.path.isdir(os.path.join(full, ".git")) @@ -5915,8 +5935,9 @@ def add_tree(ns) -> int: assembly_hint = (f"lane {lane}'s checkout {lane_dir} is an ASSEMBLY, and this tree is " "of the assembly itself: a worktree does not check its legs out (" + ", ".join(f"{d}/" for _r, d, _p in legs) + " are empty in it). A " - "tree of a leg is made from the leg: --checkout " - + os.path.join(lane_dir, legs[-1][1])) + "tree of a leg is made from that leg: " + + " or ".join(f"--checkout {os.path.join(lane_dir, d)}" + for _r, d, _p in legs)) # THE RECORD MUST HAVE SOMEWHERE TO GO BEFORE THE TREE IS MADE: a tree # this act could not record is the very thing it exists to stop. tproc = helper.call("lane-trees", lane) diff --git a/lanes-edit.sh b/lanes-edit.sh index a2523d31..c6b37427 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -13197,14 +13197,22 @@ EOF continue fi # THE FEATURE DIRECTORY ITSELF, then each declared leg's path under - # it — a relative one, as the standard writes them. + # it — a relative one, as the standard writes them. The directory is + # passed through the ENVIRONMENT: `awk -v` reads escapes in a value, + # so a name holding `\` would come out as another path. lrc_cands="$lrc_f -$(printf '%s\n' "$lrc_legs" | awk -F'\t' -v f="$lrc_f" '$2 != "" && $2 !~ /^\// { p = $2; sub(/^\.\//, "", p); print f "/" p }')" +$(printf '%s\n' "$lrc_legs" | LRC_F="$lrc_f" awk -F'\t' '$2 != "" && $2 !~ /^\// { p = $2; sub(/^\.\//, "", p); print ENVIRON["LRC_F"] "/" p }')" while IFS= read -r lrc_c; do [ -n "$lrc_c" ] && [ -d "$lrc_c" ] && [ ! -L "$lrc_c" ] || continue - { [ -e "$lrc_c/.git" ] || [ -L "$lrc_c/.git" ]; } || continue lrc_cr="$(lane_real_path "$lrc_c")" case "$lrc_seen" in *" $lrc_c "*|*" $lrc_cr "*) continue ;; esac + # A LEG'S DIRECTORY THAT CANNOT BE SEARCHED hides whether a `.git` + # stands in it: a read nobody made, never an empty directory. + if [ ! -x "$lrc_c" ]; then + lrc_legwhy="${lrc_legwhy:+$lrc_legwhy; }the assembly's paired tree directory $lrc_c could not be searched" + continue + fi + { [ -e "$lrc_c/.git" ] || [ -L "$lrc_c/.git" ]; } || continue printf 'TREE%s%s%sunmanaged%s%s%sit sits in the assembly'\''s paired feature trees (%s/worktrees) and no sidecar of this lane names it; it is left exactly as it is\n' \ "$US" "$(tree_id_for "$lrc_c")" "$US" "$US" "$lrc_c" "$US" "$lrc_dir" lrc_seen="$lrc_seen $lrc_c $lrc_cr " diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index 42c36529..def4b1c2 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -43,18 +43,17 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parent)) -from conftest import REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 +from conftest import NEEDS_UPSTREAM, REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( shutil.which("git") is None or shutil.which("bash") is None, reason="the lane tools are bash and Python over git")] GOLDEN = Path(__file__).resolve().parent / "fixtures" / "t018" / "single-repository.golden" +#: The assembly is built from the pinned openRepoShape's own template bytes, +#: so every assembly case wears conftest's `NEEDS_UPSTREAM`: standalone it +#: skips where the submodule is not checked out, and composed it FAILS. TEMPLATE = UPSTREAM / "templates" / "assembly-root" -NEEDS_TEMPLATE = pytest.mark.skipif( - not (TEMPLATE / "project.yaml").is_file(), - reason="the pinned openRepoShape is not checked out; " - "run `git submodule update --init upstream/openRepoShape`") UUID = "18018018-1111-4180-8180-180180180001" #: Every command the bench installs beside each other, as `--install` does. @@ -461,7 +460,7 @@ def test_t018_the_legs_reader_is_lane_starts_rung_5_line_for_line(): assert theirs == ours -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_project_legs_reads_the_templates_manifest(tmp_path): b = Bench(tmp_path / "bench", REPO) t = build_assembly(b) @@ -483,7 +482,7 @@ def test_t018_project_legs_reads_the_templates_manifest(tmp_path): os.chmod(a / "project.yaml", 0o644) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): b, t = triad proc = b.sweep("triad-1", "--dry-run", "--porcelain") @@ -498,7 +497,7 @@ def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): assert "3 leg checkout(s), 0 counted" in table.stdout -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): """THE GAP §4.4 MEASURED: a paired `worktrees//code` tree, made as Speckit makes one (a worktree of the code leg's repository), holding the @@ -530,7 +529,7 @@ def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): assert other[str(tree)]["disposition"] == "foreign" -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): b, t = triad away = b.projects / "away-code" @@ -546,7 +545,7 @@ def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): assert row["retire"] == "retire" and row["disposition"] == "remove", row -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): """A lane working in the code leg itself: its HEAD's own trailer is this lane's and origin lacks it, so the leg is COUNTED - the gate refuses, and @@ -571,7 +570,7 @@ def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): assert b.tool("lane-end", "triad-2").returncode == 0 -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): b, t = triad (t["code"] / "scratch.txt").write_text("someone's edit\n") @@ -588,7 +587,7 @@ def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): assert "listed and NOT counted" in end.stderr and str(t["dep"]) in end.stderr -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_legs_branches_are_read_under_branches(triad): b, t = triad tmp = b.root / "leg-branch" @@ -602,7 +601,7 @@ def test_t018_the_legs_branches_are_read_under_branches(triad): assert rows[0][2] == "retire" and rows[0][4].startswith("in the code leg of"), rows -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): b, t = triad if os.geteuid() == 0: @@ -616,6 +615,171 @@ def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout +@NEEDS_UPSTREAM +def test_t018_a_leg_git_cannot_read_is_counted_and_holds_lane_end(triad): + """A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is a read that + failed, never an absent or a clean leg (Amendment 7(d)): its row is + `unreadable` and counted, the dry run exits 3, and `lane-end` refuses, + naming it.""" + b, t = triad + (t["spec"] / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + leg = porcelain_rows(proc.stdout, "leg")[str(t["spec"])] + assert leg["disposition"] == "unreadable" and leg["retire"] == "retire", leg + assert "git could not be read through it" in leg["why"], leg + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2, end.stderr + assert "lane triad-1 is NOT ended" in end.stderr and str(t["spec"]) in end.stderr + + +@NEEDS_UPSTREAM +def test_t018_dependencies_a_leg_nests_that_cannot_be_listed_refuse_the_sweep(triad): + """THE SWEEP'S OWN REFUSAL, UNSHADOWED: `git submodule status --recursive` + failing in a checked-out leg - here a nested dependency whose `.git` names + no repository - leaves the assembly's checkouts unknown, so the sweep + refuses, exit 2. `lane-reconcile` does not read nested dependencies, so its + verdict is no `indeterminate` that refuses first: this is `_assembly`'s.""" + b, t = triad + a = t["assembly"] + (t["dep"] / ".git").write_text("gitdir: /nonexistent/t018-dep\n") + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + verdict = next((ln.split(US)[1] for ln in rec.stdout.splitlines() + if ln.split(US)[0] == "VERDICT"), "") + assert verdict and verdict != "indeterminate", rec.stdout + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 2, proc.stdout + proc.stderr + assert f"the legs of the assembly {a} could not be read" in proc.stdout, proc.stdout + assert "the dependencies nested in the code leg" in proc.stdout + assert "could not be listed (git submodule status failed)" in proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_that_is_not_checked_out_is_absent_and_claims_no_tree_of_the_assembly(triad): + """A DEINITIALIZED LEG is an empty directory of the assembly's, where git + answers the ASSEMBLY. The sweep lists it `absent`, counted for nobody; the + assembly's own trees and registrations stay the assembly's rather than + becoming that leg's (`probe_leg`'s toplevel guard); and `add` from the + assembly records the assembly, never the empty leg (`add`'s guard).""" + b, t = triad + a = t["assembly"] + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any((a / "spec").iterdir()) + made = b.tool("lane-worktrees", "add", "triad-1", "root-work") + assert made.returncode == 0, made.stderr + tree = made.stdout.strip() + assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr + assert "the spec leg of" not in made.stderr, made.stderr + assert _inventory(b, "triad-1")[tree] == str(a) + away = b.projects / "away-root" + b.git("worktree", "add", "-q", "-b", "away-root", away, "origin/main", cwd=a) + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + legs = porcelain_rows(proc.stdout, "leg") + assert legs[str(t["spec"])]["disposition"] == "absent", legs + assert legs[str(t["spec"])]["retire"] == "-", legs + assert "not checked out here" in legs[str(t["spec"])]["why"], legs + assert legs[str(t["code"])]["disposition"] == "clean", legs + trees = porcelain_rows(proc.stdout, "tree") + assert trees[tree]["retire"] == "retire", trees + for path in (tree, str(away)): + assert "spec leg" not in trees[path]["why"], trees[path] + assert trees[str(away)]["disposition"] == "foreign", trees + + +def _lane_worktrees_module(): + """`lane-worktrees` as a module, its bytecode written nowhere (as + tests/test_lane_worktrees.py loads it).""" + import importlib.util + from importlib.machinery import SourceFileLoader + loader = SourceFileLoader("lane_worktrees_t018", str(REPO / "lane-worktrees")) + spec = importlib.util.spec_from_loader("lane_worktrees_t018", loader) + module = importlib.util.module_from_spec(spec) + was = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + loader.exec_module(module) + finally: + sys.dont_write_bytecode = was + return module + + +def test_t018_the_sweeps_paired_root_refuses_what_it_cannot_list_or_search(tmp_path): + """THE SWEEP'S OWN PAIRED-ROOT REFUSAL (`Sweep._paired`), which no run + shows: `lane-reconcile` reads the same directories first and says + `indeterminate`, and the sweep refuses on that verdict before it looks. + Called directly: the root, a feature directory, or a leg's directory under + it that cannot be listed or searched each refuse, naming it; readable, + the tree in it is found.""" + if os.geteuid() == 0: + pytest.skip("root lists and searches a mode-000 directory") + import types + lw = _lane_worktrees_module() + root = tmp_path / "worktrees" + feature = root / "001-a" + tree = feature / "code" + tree.mkdir(parents=True) + (tree / ".git").write_text("gitdir: /nonexistent\n") + legs = [types.SimpleNamespace(decl="spec", nested_in=""), + types.SimpleNamespace(decl="code", nested_in="")] + + def paired(): + sweep = types.SimpleNamespace(paired_root=str(root), legs=legs, refusal="") + seen: dict = {} + lw.Sweep._paired(sweep, seen) + return sweep.refusal, seen + + refusal, seen = paired() + assert refusal == "" and list(seen) == [lw.real(str(tree))], (refusal, seen) + for target, mode, words in ((root, 0, "worktree root"), (root, 0o600, "worktree root"), + (feature, 0, "feature directory"), + (feature, 0o600, "feature directory"), + (tree, 0o600, "paired tree directory")): + os.chmod(target, mode) + try: + refusal, seen = paired() + finally: + os.chmod(target, 0o755) + assert f"{words} {target} could not be" in refusal, (target, oct(mode), refusal) + assert not seen, (target, oct(mode), seen) + + +@NEEDS_UPSTREAM +def test_t018_project_legs_reads_quoted_scalars_as_openreposhape_v1_writes_them(triad): + """openRepoShape v1.0.0 (opensoft/openRepoShape#395) writes the assembly + template's scalars QUOTED: `path: "{{SPEC_PATH}}"` under `legs:`, and `id:`, + `elected_on:`, `visibility:` and `tracking_branch:` alike. The one reader + every lane tool asks reads `path: "code"` and `path: 'code'` - and a quoted + `repository:` - exactly as `path: code`, so the sweep's leg rows are the + same three.""" + b, t = triad + a = t["assembly"] + want = [f"opensoft/triad-spec\tspec\t{a}/spec", f"opensoft/triad-code\tcode\t{a}/code"] + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 0 and proc.stdout.splitlines() == want, proc.stdout + proc.stderr + plain = (a / "project.yaml").read_text(encoding="utf-8") + for q in ('"', "'"): + lines, inlegs, quoted = [], False, 0 + for line in plain.splitlines(): + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*:", line): + inlegs = line.startswith("legs:") + top = re.match(r"^(id|elected_on|visibility|tracking_branch):\s*(\S.*?)\s*$", line) + leg = re.match(r"^(\s+(?:repository|path):\s*)(\S.*?)\s*$", line) if inlegs else None + m = top or leg + if m: + head = f"{m.group(1)}: " if top else m.group(1) + line = f"{head}{q}{m.group(2).strip(chr(34) + chr(39))}{q}" + quoted += 1 + lines.append(line) + assert quoted >= 10, quoted + (a / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + assert f"path: {q}spec{q}" in (a / "project.yaml").read_text() + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == want, (q, proc.stdout) + legs = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, "leg") + assert list(legs) == [str(t["spec"]), str(t["code"]), str(t["dep"])], (q, legs) + + # ========================================================= (c) the daily report def _section(text: str, title: str) -> str: @@ -624,7 +788,7 @@ def _section(text: str, title: str) -> str: return text[start:end if end != -1 else len(text)] -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): b, t = triad a = t["assembly"] @@ -646,7 +810,6 @@ def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): assert "| Assembly legs with uncommitted or unpublished work | 1 |" in proc.stdout -@NEEDS_TEMPLATE def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): b = Bench(tmp_path / "bench", REPO) b.seeded("plain", {"p.txt": "p\n"}) @@ -671,7 +834,7 @@ def _inventory(b: Bench, lane: str) -> dict: return rows -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_add_records_the_leg_it_made_a_tree_from(triad): b, t = triad a = t["assembly"] @@ -697,19 +860,20 @@ def test_t018_add_records_the_leg_it_made_a_tree_from(triad): assert row["retire"] == "retire", row -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): b, t = triad a = t["assembly"] made = b.tool("lane-worktrees", "add", "triad-1", "root-work") assert made.returncode == 0, made.stderr assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr - assert f"--checkout {a}/code" in made.stderr + # every leg is named, never only the last one the manifest declares + assert f"--checkout {a}/spec or --checkout {a}/code" in made.stderr, made.stderr assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_add_refuses_where_the_manifest_cannot_be_read(triad): """Never "no legs" (Amendment 7(d)): which checkout the tree would be recorded under is unknown, so nothing is made, in the words the sweep diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 43cf4e59..52106a4f 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -48,9 +48,9 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parent)) -from conftest import REPO, WINDOWS_SKIP # noqa: E402 +from conftest import NEEDS_UPSTREAM, REPO, WINDOWS_SKIP # noqa: E402 from test_triad_lane_tooling import ( # noqa: E402 - NEEDS_TEMPLATE, US, Bench, build_assembly, normalise, parse_golden, render_golden, shown) + US, Bench, build_assembly, normalise, parse_golden, render_golden, shown) pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( shutil.which("git") is None or shutil.which("bash") is None, @@ -95,9 +95,17 @@ def field(out: str, key: str) -> list: return next((ln.split(US)[1:] for ln in out.splitlines() if ln.split(US)[0] == key), []) -def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff") -> subprocess.CompletedProcess: +def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff", + *flags: str) -> subprocess.CompletedProcess: return b.tool("lane-handoff", "--lane", lane, "--transcript", "none", "--agent", "claude", - why, cwd=cwd) + *flags, why, cwd=cwd) + + +def writers_file(b: Bench, lane: str, rows: dict) -> Path: + """A `--writers-file`: ` TAB `, one a line.""" + path = b.root / f"writers-{lane}.tsv" + path.write_text("".join(f"{tree}\t{brief}\n" for tree, brief in rows.items())) + return path def top_block(b: Bench, lane: str) -> str: @@ -120,7 +128,7 @@ def triad(tmp_path): # ============================================================= (1) and (2) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its_leg(triad): """(1) THE PAIRED TREES ARE POLLED: a paired code tree holding the lane's unpushed commit and a paired spec tree holding a writer's first edit are in @@ -147,8 +155,11 @@ def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its # under the paired root and NOT a checkout of its own: the assembly's tree (a / "worktrees" / "007-y" / "notes").mkdir() (a / "worktrees" / "007-y" / "notes" / "n.md").write_text("n\n") + # the spec writer has committed nothing, so no trailer finds it: the + # writers file names it, and that makes it this lane's (M1 (a)) + wf = writers_file(b, "triad-1", {paired_spec: "the 007-y spec writer"}) - proc = handoff(b, "triad-1", a) + proc = handoff(b, "triad-1", a, "t018 handoff", "--writers-file", str(wf)) assert proc.returncode == 0, proc.stdout + proc.stderr for tree, branch in ((lane_code, "c1"), (lane_root, "root"), (paired_code, "007-y"), (paired_spec, "007-y")): @@ -164,6 +175,8 @@ def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its assert (f"- `{paired_code}` — branch `007-y`, last commit" in block and "0 dirty, 1 unpushed" in block.split(f"`{paired_code}`", 1)[1].split("\n", 1)[0]), block assert "1 dirty, 0 unpushed" in block.split(f"`{paired_spec}`", 1)[1].split("\n", 1)[0], block + assert "brief: the 007-y spec writer" in block, block + assert "PAIRED ROOT" not in block and "PAIRED ROOT" not in proc.stdout, block inv = inventory(b, "triad-1") assert inv == {str(lane_code): str(code), str(lane_root): str(a), @@ -178,7 +191,7 @@ def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its assert field(rec.stdout, "TREES")[0] == "4 inventoried", rec.stdout -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad): """A LEG THAT IS NOT CHECKED OUT is an empty directory of the assembly's, where git answers the ASSEMBLY - so read naively, its repository would be @@ -192,13 +205,14 @@ def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad assert made.returncode == 0, made.stderr lane_root = Path(made.stdout.strip()) paired_code = leg_tree(b, code, a / "worktrees" / "008-z" / "code", "008-z") + b.commit(paired_code, "the feature's code", {"z.txt": "z\n"}, lane="triad-1") proc = handoff(b, "triad-1", a) assert proc.returncode == 0, proc.stdout + proc.stderr assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on(triad): """A `project.yaml` that is there and cannot be read is never "no legs" (Amendment 7(d)), and a swap is never left unwritten (`R-A11-11`): the @@ -224,7 +238,8 @@ def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on finally: os.chmod(a / "project.yaml", 0o644) assert proc.returncode == 0, proc.stdout + proc.stderr - assert f"{a}/project.yaml is there and its legs could not be read" in proc.stderr, proc.stderr + assert (f"the legs of the assembly {a} could not be read (its project.yaml is there, " + "and ") in proc.stderr, proc.stderr assert "paired feature trees" in proc.stderr and "were NOT polled" in proc.stderr assert f"WRITER {paired_code} —" not in proc.stdout, proc.stdout assert f"WRITER {lane_code} —" in proc.stdout and f"WRITER {lane_root} —" in proc.stdout @@ -236,9 +251,122 @@ def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} +@NEEDS_UPSTREAM +def test_t018_a_handoff_takes_no_other_lanes_paired_tree(triad): + """M1 OF THE PRE-PR REVIEW, OPTION (a): THE PAIRED ROOT IS POLLED BY THE + SWEEP'S RULE. Two lanes share one assembly, and its paired root holds lane + 2's trees: one whose HEAD's own trailer is lane 2's, unpushed, and one with + nothing committed that only lane 2's writers file names. Lane 1's handoff + neither polls, counts nor records either - each is one `PAIRED ROOT, not + this lane's` line outside WRITERS - so lane 1's `lane-end` passes; and lane + 2's handoff polls and records both, each under its leg.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + theirs = leg_tree(b, code, a / "worktrees" / "020-b" / "code", "020-b") + b.commit(theirs, "lane 2's feature code", {"b.txt": "b\n"}, lane="triad-2") + fresh = leg_tree(b, spec, a / "worktrees" / "021-c" / "spec", "021-c") + (fresh / "draft.md").write_text("lane 2's writer's first edit\n") + wf2 = writers_file(b, "triad-2", {fresh: "lane 2's 021-c spec writer"}) + + one = handoff(b, "triad-1", a) + assert one.returncode == 0, one.stdout + one.stderr + for tree in (theirs, fresh): + assert f"WRITER {tree} —" not in one.stdout, one.stdout + assert f"PAIRED ROOT, not this lane's: {tree} — branch" in one.stdout, one.stdout + assert f"writers polled: 0 under {a}" in one.stderr, one.stderr + assert "paired root: 2 tree(s)" in one.stderr, one.stderr + assert "lifecycle: SWAPPED" in one.stdout, one.stdout + assert "NOT POLLED" not in one.stdout + one.stderr + block = top_block(b, "triad-1") + writers, _sep, rest = block.partition("**PAIRED ROOT at ") + assert "(0 found under" in writers and "- none running at the handoff." in writers, block + assert str(theirs) not in writers and str(fresh) not in writers, block + assert f"2 tree(s) under `{a}/worktrees` that are NOT this lane's" in rest, block + assert (f"- PAIRED ROOT, not this lane's: `{theirs}` — branch `020-b`, last commit " + in rest), block + assert "HEAD's own `Lane:` trailer `triad-2`" in rest, block + assert f"- PAIRED ROOT, not this lane's: `{fresh}` — branch `021-c`" in rest, block + assert "HEAD's own `Lane:` trailer none" in rest, block + assert inventory(b, "triad-1") == {} + end = b.tool("lane-end", "triad-1") + assert end.returncode == 0, end.stdout + end.stderr + + two = handoff(b, "triad-2", a, "t018 handoff", "--writers-file", str(wf2)) + assert two.returncode == 0, two.stdout + two.stderr + for tree in (theirs, fresh): + assert f"WRITER {tree} —" in two.stdout, two.stdout + assert f"writers polled: 2 under {a}" in two.stderr, two.stderr + assert "PAIRED ROOT" not in two.stdout and "PAIRED ROOT" not in top_block(b, "triad-2") + assert inventory(b, "triad-2") == {str(theirs): str(code), str(fresh): str(spec)} + + +@NEEDS_UPSTREAM +def test_t018_a_paired_directory_the_handoff_cannot_list_is_said_and_the_lane_stays_swapping(triad): + """S1 OF THE PRE-PR REVIEW: a paired root, a feature directory, or a leg's + directory under one that could not be listed or searched hid what stood in + it from the poll IN SILENCE, while `lane-reconcile` said `indeterminate` + and the sweep refused. Now each is a `- NOT POLLED` line in WRITERS and a + note on stderr, the handoff still exits 0 (`R-A11-11`), and the lane stays + SWAPPING; the reconciliation is `indeterminate` for every one of them.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root lists and searches a mode-000 directory") + a, code = t["assembly"], t["code"] + mine = leg_tree(b, code, a / "worktrees" / "030-m" / "code", "030-m") + b.commit(mine, "the lane's feature code", {"m.txt": "m\n"}, lane="triad-1") + clone = a / "worktrees" / "031-n" / "spec" + b.git("clone", "-q", b.remotes / "triad-spec.git", clone) + cases = ( + (a / "worktrees", f"the assembly's paired root {a}/worktrees could not be listed", False), + (a / "worktrees" / "030-m", + f"the assembly's feature directory {a}/worktrees/030-m could not be listed", False), + (clone, f"the assembly's paired tree directory {clone} could not be searched", True), + ) + for target, words, mine_seen in cases: + os.chmod(target, 0) + try: + proc = handoff(b, "triad-1", a, "unlistable") + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(target, 0o755) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"WRITER {mine} —" in proc.stdout) is mine_seen, (target, proc.stdout) + assert words in proc.stderr, (target, proc.stderr) + assert "may not have been polled" in proc.stderr, (target, proc.stderr) + assert "lifecycle: SWAPPING" in proc.stdout, (target, proc.stdout) + assert f"- NOT POLLED: {words}" in top_block(b, "triad-1"), target + verdict = field(rec.stdout, "VERDICT") + assert verdict and verdict[0] == "indeterminate", (target, rec.stdout) + assert words in verdict[1], (target, verdict) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"WRITER {mine} — branch 030-m" in proc.stdout, proc.stdout + assert f"PAIRED ROOT, not this lane's: {clone} —" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_tree_of_a_nested_dependency_keeps_the_checkout_add_recorded(triad): + """N5 OF THE PRE-PR REVIEW: a tree `add` made from a dependency nested in a + leg (`--checkout /code/upstream/dep`) is no declared leg's, and + the next handoff re-recorded it under the assembly - the loss (b) fixed for + the legs. A recorded checkout of the tree's own repository is now kept.""" + b, t = triad + dep = t["dep"] + made = b.tool("lane-worktrees", "add", "triad-1", "d1", "--checkout", dep) + assert made.returncode == 0, made.stderr + tree = made.stdout.strip() + assert inventory(b, "triad-1")[tree] == str(dep) + proc = handoff(b, "triad-1", t["assembly"]) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"WRITER {tree} —" in proc.stdout, proc.stdout + assert inventory(b, "triad-1")[tree] == str(dep) + + # ===================================================================== (3) -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): """(3) THE LEGS' REGISTRATIONS AND THE PAIRED ROOT ARE READ: a spec tree made away from every root, a paired code tree, a clone where a paired @@ -278,7 +406,7 @@ def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): assert field(proc.stdout, "VERDICT")[0] == "ungraceful-stop", proc.stdout -@NEEDS_TEMPLATE +@NEEDS_UPSTREAM def test_t018_reconcile_pronounces_no_clearance_over_legs_nobody_read(triad): """A manifest, or the paired root, that could not be read leaves paths nobody inspected: the verdict is `indeterminate`, never a clearance.""" From 6658396e45ddeb125f21e48dbf2ce33126388224 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 19:42:46 +0000 Subject: [PATCH 14/23] T018 docs: README-lanes and the analysis say what M1 (a), S1 and S2 changed; the analysis's false statements about the branch are dated and corrected (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/README-lanes.md, T018 section: - M1 (a): "each lane's handoff records every paired tree" is gone. The handoff polls and records a paired tree only where the lane's inventory names it, its HEAD's own Lane: trailer is the lane's on a commit origin's default branch lacks, or the writers file names it; every other one is a "PAIRED ROOT, not this lane's" line outside WRITERS, not counted or recorded. The sweep's bullet says the handoff no longer widens its paired-root rule, and the parked-tree paragraph says a handoff does not record a parked tree for the lane. - S1: the handoff's NOT POLLED, stderr note and SWAPPING for an unlistable paired root, feature directory or leg directory, or an unreadable inventory; reconcile's indeterminate and the sweep's refusal now also cover a leg directory that cannot be searched. - S2: "no --yes acts on a leg" is now "on a leg checkout": under --branches a leg's branches are rows like the checkout's own, and --yes deletes a merged lane branch there as it does in the checkout, bundling first. - N5: a tree recorded under a checkout of its own repository (a dependency nested in a leg) keeps that checkout through a handoff. docs/triad-lane-tooling-analysis.md (S4): dated 2026-10-13 notes at §0 item 1 and §6. "This branch carries no test change of its own" was true of its first commit only; it now carries two test modules and two goldens and still changes none of the suite's roots. §6 records M1 (a) and S1, and §7 item 11 says the handoff no longer closes the parked-tree gap by itself. Verification, one run through tests/run.sh on this commit's tree, 1511f94 (4d4957d plus these docs; main f87fd5a merged at 8cb1368), 2026-10-13 19:10-19:40Z. Every module but the four below was --ignore'd, with -k "triad_lane_tooling or repo_hygiene or test_the_lane_helper_suite_passes": 181 passed, 1 failed, 1 deselected in 1818.96s. - tests/test_triad_lane_tooling.py, tests/test_triad_lane_tooling_handoff.py (both goldens unregenerated) and tests/test_repo_hygiene.py: all passed. - The shell suite, tests/test_lane_helpers_suite.py:: test_the_lane_helper_suite_passes: 4598 passed, 2 failed. Both are lane-start row-stamp assertions, "…with clause (c)'s directory and window in the tail" and "the row's stamp states clause (c)'s two facts … in its TAIL". lane-start's cut_to_line caps the stamp at 230 characters (lane-start:3502-3510). Under run.sh the suite's $HOME is ~/.local/state/openRepoTools/tmp/-/tmp/tmp.XXXXXXXXXX/home, and the stamp then reaches 231 characters before its window value, so the tail is cut ("…/home/projects/repoA; ..."). With a /tmp-length home it is 165. This branch changes none of lane-start, tests/test_lane_helpers.sh, tests/test_lane_helpers_suite.py or tests/run.sh (git diff f87fd5a is empty for all four), so the two failures do not come from it. An earlier attempt (run1) ran nothing: zsh passed the --ignore list as one argument and pytest exited 4. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 78 +++++++++++++++++++++-------- docs/triad-lane-tooling-analysis.md | 23 ++++++++- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index b36316ad..93f5cd85 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3896,7 +3896,8 @@ one passed `lane-end`. the leg's own, as `code/upstream/openRepoShape` will be), as a **`leg` row** — `clean`, `dirty`, `unpushed`, `dirty+unpushed`, `absent` (not checked out here) or `unreadable`. A leg is the assembly's, shared by every lane the assembly - homes, as the assembly's own checkout is: **no `--yes` acts on one**. It is + homes, as the assembly's own checkout is: **no `--yes` acts on a leg + checkout** (its branches are another matter: see `--branches` below). It is COUNTED (exit 3, and `lane-end`'s gate) only where its HEAD's own `Lane:` trailer is this lane's and it holds edits or commits origin lacks — the rule the sweep already applies to `.claude/worktrees` — or where it could not be read; one @@ -3904,12 +3905,20 @@ one passed `lane-end`. * lists the worktrees git registers in **every leg** — a FOREIGN row names the leg it is registered in — so a tree recorded with the leg as its checkout is the lane's as any recorded tree is; -* takes **`/worktrees`** as a root: a tree there is the lane's by its - HEAD's own `Lane:` trailer and by nothing else, because the paired root, like - `.claude/worktrees`, is per feature and not per lane; a standalone clone there is - FOREIGN; a directory there that cannot be listed refuses, exit **2**; -* with `--branches`, reads every leg's branches too, each row naming its leg; with - `--fetch` (and under `--yes`), asks each leg's origin. +* takes **`/worktrees`** as a root: a tree there that the lane's + inventory does not record is the lane's by its HEAD's own `Lane:` trailer and by + nothing else, because the paired root, like `.claude/worktrees`, is per feature + and not per lane — and `lane-handoff` records a tree there only on that rule, or + where the lane's own writers file names it (below), so the inventory does not + take in other lanes' trees; a standalone clone there + is FOREIGN; the root, a feature directory or a leg's directory under it that + cannot be listed or searched refuses, exit **2**; +* with `--branches`, makes each leg's branches rows like the checkout's own, each + naming its leg, and **`--yes` deletes a merged lane branch there exactly as it + does in the checkout** — bundling first any commits only its reflog names, and + deleting its origin branch too where the row says `delete+remote`. No `--yes` + acts on a leg *checkout*. With `--fetch` (and under `--yes`), it asks each leg's + origin. A manifest, or a leg's own submodule list, that cannot be read refuses, exit **2**. **A checkout with no `project.yaml` is asked nothing** — no helper call, no @@ -3923,19 +3932,38 @@ offered when legs are all it counted), and lists a leg that holds work and is no this lane's below the table as NOT counted — whether the gate passes or not. **`lane-handoff` and `lane-reconcile` (T018 (b)).** Where the lane's checkout is an -assembly, `lane-handoff` also polls the **paired root**: `/worktrees/` -and `/`. It polls every directory there with a `.git` -of its own, exactly as it polls `.claude/worktrees`, because the paired root is per -feature, not per lane, and a writer with nothing committed yet has no trailer to be -found by. So a writer in a paired tree is in the WRITERS block. Every tree it polls is -**recorded under the checkout it belongs to**. A worktree of a leg's repository (a -paired tree, or one `lane-worktrees add --checkout /code` made under the -lane's root) is recorded under that leg, the path `project-legs` prints and `add` -records. Any other tree keeps the lane's `dir`, as before. A leg that is not checked -out here (never initialized, or deinitialized) names no tree. In an assembly several -lanes share, each lane's handoff records every paired tree, as it records every tree in -`.claude/worktrees`. A tree two lanes' inventories both name is `keep` in both sweeps -until a person says whose it is. A worktree a leg registers anywhere else is not polled +assembly, `lane-handoff` also looks in the **paired root**: +`/worktrees/` and `/`, at every +directory there with a `.git` of its own. The paired root is per feature and not per +lane — every lane the assembly homes keeps its feature trees there — so the handoff +takes a tree there as this lane's on the sweep's terms and on no wider ones. It +**polls and records a paired tree only where one of three things holds**: + +* this lane's inventory already names it; +* its HEAD's own last `Lane:` trailer is this lane's, on a commit origin's default + branch lacks — the sweep's own rule for that root; +* the `--writers-file` names it, because a writer with nothing committed yet has no + trailer to be found by. + +Those trees are in the WRITERS block and its count. **Every other checkout there is +another lane's, or nobody's**: one line in the top block under a heading of its own, +outside WRITERS — `- PAIRED ROOT, not this lane's: — branch …`, with its last +commit and its HEAD's own trailer, which is what finds its lane. It is not counted, not +recorded, and never a writer for the next session to relaunch; its own lane's handoff +records it. So one lane's `lane-end` never refuses on another lane's paired work, and +its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, +pending lane 1's ruling on #186: a handoff that recorded every paired tree made other +lanes' trees the running lane's inventory, and a tree two lanes' inventories both name +is `keep`, counted, in both sweeps. + +Every tree it polls is **recorded under the checkout it belongs to**. A worktree of a +leg's repository (a paired tree, or one `lane-worktrees add --checkout +/code` made under the lane's root) is recorded under that leg, the path +`project-legs` prints and `add` records. A tree the inventory already records under a +checkout of the tree's own repository keeps it: a dependency nested in a leg +(`--checkout /code/upstream/`) is no declared leg. Any other tree keeps +the lane's `dir`, as before. A leg that is not checked out here (never initialized, or +deinitialized) names no tree. A worktree a leg registers anywhere else is not polled as a writer, just as the checkout's own registrations elsewhere are not; `lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold beside what the checkout holds. Every worktree git registers in a checked-out leg that @@ -3951,7 +3979,11 @@ place (a tree of the lane's own repository is still recorded), and leaves the la sweep refuses with ("the legs of the assembly `` could not be read"): the sweep reads that verdict first and refuses on it, exit **2**. The verdict is `indeterminate` too where a leg's registrations, the paired root or a feature directory under it -cannot be listed. **A checkout with no `project.yaml` is asked nothing**, and one whose +cannot be listed, or a leg's directory under a feature cannot be searched. For the +same directories, and where the lane's inventory cannot be read (which paired trees it +names would then be unknown), `lane-handoff` gives the same three signs: a +`- NOT POLLED: …` line in WRITERS, a note on stderr, and the lane left `SWAPPING`. +**A checkout with no `project.yaml` is asked nothing**, and one whose `project.yaml` names no leg but itself is asked only `project-legs`, which answers 8. The handoff's output, top block and inventory, and the reconciliation, are byte for byte what they were. `tests/test_triad_lane_tooling_handoff.py` holds them to the @@ -3972,7 +4004,9 @@ assembly**, run that dry run and read every FOREIGN row under `/worktr one whose HEAD, or whose branch's upstream, is a `wip: park … — lane ` commit, or that holds uncommitted edits, may be this lane's parked work. Land it or hand it over first, or record it with `lanes-edit.sh set-lane-tree ---checkout /` so the gate counts it. +--checkout /` so the gate counts it. `lane-handoff` does not record it +for you: on the sweep's terms it records a paired tree with no trailer of the lane's +only where the inventory or the writers file already names it. ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 633415f2..f409cf90 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -33,7 +33,11 @@ checks) and T018 (feature selection, park/resume and bindings). `$selfdir` or `__file__`. All of those helpers move to `code/` together, so that lookup works unchanged. The only code that really reads across legs is in the **tests**. Lane 1's PR #190 (`feat/test-roots-for-the-triad` at `c139e88`) gives the suite those roots, so this - branch carries no test change of its own (§6). + branch carries no test change of its own (§6). **2026-10-13:** that was true of this + branch's first commit only. It now carries T018's shape-gated changes to five tools, + with two test modules of its own (`tests/test_triad_lane_tooling.py`, + `tests/test_triad_lane_tooling_handoff.py`) and their two goldens; it still changes none + of the suite's roots. The §4.4 note of 2026-10-09 and the §6 notes say what it carries. 2. **The installer is the most fragile surface.** The README one-liners and every already-installed copy's own `openRepoTools --install` fetch files by **repository-root path** from `opensoft/openRepoTools@main` (openRepoTools:70-71, :258-275, :1370-1394). Once @@ -570,6 +574,18 @@ the leg it made a tree from. On a single repository every output is byte for byt `tests/fixtures/t018-handoff/`. Usage text moves for every shape (§4.4). The branch merges main at `f87fd5a` (#190) and changes none of #190's test-root files. +**2026-10-13: the paired root in `lane-handoff`, after the pre-PR review.** (b) above is +narrower now (the review's M1, option (a), pending lane 1's ruling on #186). The handoff +polls and records a paired tree only where the lane's inventory names it, its HEAD's own +`Lane:` trailer is the lane's on a commit origin's default branch lacks (the sweep's +rule), or the writers file names it. Every other paired tree is a `PAIRED ROOT, not this +lane's` line in the top block, outside WRITERS, not counted and not recorded. A paired +root, feature directory or leg directory it cannot list or search, and an inventory it +cannot read, are `NOT POLLED` and leave the lane `SWAPPING`, as `lane-reconcile` is +`indeterminate` and the sweep refuses for the same directories. The heading below, +"why this branch carries no test change", means no change to the suite's **roots**: the +branch's own tests are the two modules and goldens named above. + **Overlap with #190, and why this branch carries no test change.** An earlier draft of this branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more completely, under other names. The draft was dropped before it was committed; its diff is @@ -640,7 +656,10 @@ kept only in this lane's scratch directory. unclaimed leg checkouts. It is counted only where the lane's #97 inventory names it, which happens through `lanes-edit.sh set-lane-tree`, or a `lane-handoff` that ran while it stood (T018 (b)); `lane-worktrees add` makes its trees under the - lane's own root, never in the paired root. Lane 3 changed no attribution code; `docs/README-lanes.md` + lane's own root, never in the paired root. **2026-10-13:** since the handoff polls the + paired root by the sweep's rule (§6, note of that date), it records a parked or + resumed tree only where the inventory or the writers file already names it, so the + handoff no longer closes this gap by itself. Lane 3 changed no attribution code; `docs/README-lanes.md` (T018 section) says what to check before `lane-end`. **Choose:** (i) the sweep also reads park's WIP subject as the lane's mark, which couples `lane-worktrees` to a workBenches subject format; or (ii) park's WIP commit carries a `Lane: ` From 9f23a6b098212efa2c839624f98ec68100d6e96f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 20:49:41 +0000 Subject: [PATCH 15/23] WIP (untested): T018: lane-reconcile reports a leg whose .git git cannot read as the sweep does - a TREE row `unreadable` that requires recovery - instead of passing over it in silence lanes-edit.sh, lane_reconcile, the legs' registrations: a declared leg whose git toplevel is not the leg itself was skipped whether or not a .git stood in it, so a leg with a .git of its own that git cannot read (a gitfile naming no repository, or a .git where git answers the assembly) said nothing, while the sweep's `leg` row for it is `unreadable` and counted (exit 3, lane-end refuses). It now takes the sweep's rule (probe_leg: toplevel not the leg, and a .git stands there) and the sweep's word: one TREE row `unreadable`, in the sweep's words ("git could not be read through it (it has a .git of its own and git could not read it: )"), counted under "require recovery", so lane-start's recovery read counts it too. A leg with no .git (not checked out) is passed over as before, and a path a sidecar already names is not reported twice. The verdict is not changed: the sweep counts that leg and goes on, and an `indeterminate` here is what the sweep refuses on (exit 2) before it reads the legs, which would hide its own count and break test_t018_a_leg_git_cannot_read_is_counted_and_holds_lane_end. tests/test_triad_lane_tooling_handoff.py: a triad whose spec leg's .git is a gitfile naming no repository, then an empty .git directory: one `unreadable` row naming the leg, 1 require recovery, the verdict ungraceful-stop, and the sweep still exit 3 with the leg `unreadable` and counted; readable again, and deinitialized, the leg names nothing. docs/README-lanes.md (T018 section): one sentence for the row. A checkout with no project.yaml is untouched: the block runs only where project-legs answered legs (lrc_lgrc 0). lane-handoff's own leg loop (lane-handoff:2093-2095) skips such a leg in the same way; it is not changed here. Untested: the one verification run (run3, on this tree 2f23887) waited in the shared pytest lock queue from 20:50Z and was stopped at 21:21Z, before it took the lock, for the account swap; no count was taken. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 ++- lanes-edit.sh | 27 +++++++++++- tests/test_triad_lane_tooling_handoff.py | 55 +++++++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 93f5cd85..cb75fe3f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3969,7 +3969,10 @@ as a writer, just as the checkout's own registrations elsewhere are not; beside what the checkout holds. Every worktree git registers in a checked-out leg that no sidecar names is a `TREE` row: `unmanaged`, or `stale-registration` where its directory is gone, naming the leg. A checkout in the paired root that nothing else named -is `unmanaged`. Nothing is adopted, deleted or overwritten. +is `unmanaged`. A leg with a `.git` of its own that git cannot read is a `TREE` row +`unreadable` that requires recovery: the sweep's rule and word for its `leg` row, which +it counts. The verdict is not `indeterminate` for it, so the sweep still lists and +counts that leg. Nothing is adopted, deleted or overwritten. **A `project.yaml` that cannot be read is never "no legs".** `lane-handoff` still completes (`R-A11-11`), but it does not poll the paired root. It says so in the top diff --git a/lanes-edit.sh b/lanes-edit.sh index c6b37427..acd4a004 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -13130,11 +13130,36 @@ EOF # over. A leg's main worktree is listed FIRST and spelled as its git # directory under the assembly's `.git/modules/`, so it is skipped by place, # not by name. + # A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is no absent leg: it is + # a read that failed, never an absent or a clean one (Amendment 7(d)), and + # passed over it was a leg this reconciliation said nothing of while the + # sweep counted it. So it is the sweep's rule (`probe_leg`: git's toplevel + # there is not the leg, and a `.git` stands in it) and the sweep's word: a + # TREE row `unreadable`, which requires recovery, as the sweep's `leg` row is + # `unreadable` and counted. It is NO `indeterminate`: the sweep counts that + # leg and goes on (exit 3), and a verdict it refuses on would hide the count. if [ "$lrc_lgrc" = 0 ]; then while IFS="$lrc_tab" read -r lrc_lrepo lrc_ldecl lrc_labs; do [ -n "$lrc_labs" ] && [ -d "$lrc_labs" ] || continue lrc_ltop="$(git -C "$lrc_labs" rev-parse --show-toplevel 2>/dev/null &1 >/dev/null /dev/null tuple: + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert rec.returncode == 0, rec.stderr + return rec, reconcile_rows(rec.stdout) + + def check(answer: str) -> None: + rec, rows = reconciled_rows() + assert list(rows) == [real(spec)], rows + cls, why = rows[real(spec)] + assert cls == "unreadable", rows + assert why.startswith(f"the spec leg of {a}/project.yaml (opensoft/triad-spec): git could " + "not be read through it (it has a .git of its own and git could not " + "read it: "), why + assert answer in why and f"(git -C {spec} status)" in why, (answer, why) + assert field(rec.stdout, "TREES") == ["0 inventoried", "0 dirty or unpushed", + "1 require recovery", "0 unmanaged or stale"], rec.stdout + assert field(rec.stdout, "VERDICT")[0] == "ungraceful-stop", rec.stdout + sweep = b.sweep("triad-1", "--dry-run", "--porcelain") + assert sweep.returncode == 3, sweep.stdout + sweep.stderr + leg = porcelain_rows(sweep.stdout, "leg")[str(spec)] + assert leg["disposition"] == "unreadable" and leg["retire"] == "retire", leg + assert "git could not be read through it" in leg["why"] and answer in leg["why"], leg + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check("/nonexistent/t018-spec") + (spec / ".git").unlink() + (spec / ".git").mkdir() + check(f"git answers {real(a)} there") + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + for state in ("readable again", "deinitialized"): + if state == "deinitialized": + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any(spec.iterdir()) + rec, rows = reconciled_rows() + assert real(spec) not in rows, (state, rows) + assert field(rec.stdout, "TREES")[2] == "0 require recovery", (state, rec.stdout) + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From bf2a220ae804aa963c301a5d0a8791f7f742a438 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 22:27:17 +0000 Subject: [PATCH 16/23] WIP (untested): T018: lane-handoff counts a leg git cannot read as unreadable, as the sweep and lane-reconcile do; verification not run lane-handoff, the legs' common directories (was lines 2093-2095): a declared leg whose git toplevel is not the leg itself was skipped whether or not a .git stood in it. So a leg with a .git of its own that git cannot read (a gitfile naming no repository, or a .git where git answers the assembly) said nothing, while the sweep's `leg` row for it is `unreadable` and counted and lane-reconcile's TREE row (9f23a6b) is `unreadable` under "require recovery". Worse, a tree `lane-worktrees add --checkout ` made from that leg was re-recorded under the ASSEMBLY at the handoff, over the checkout `add` recorded. It now takes the sweep's rule (probe_leg: toplevel not the leg, and a .git stands there) and the sweep's words: one `- UNREADABLE LEG:` line in the top block's WRITERS section ("`` is unreadable, the leg of /project.yaml (): git could not be read through it (it has a .git of its own and git could not read it: )"), a note on stderr, and a count (`legs: N of the assembly's legs under unreadable`). As lane-reconcile keeps its verdict for it, the lifecycle is not held SWAPPING for the leg itself: the poll looked everywhere it looks. writer_checkout files no guessed checkout while a leg is unreadable: a tree of no readable leg's repository, not of the assembly's own and recorded under no checkout of its own repository is named and NOT recorded, so the leg `add` recorded stays, and the lane stays SWAPPING for that tree (the existing "no checkout can be vouched for" rule). A leg with no .git (not checked out) is passed over as before. A checkout with no project.yaml, or one naming no leg but itself, never runs the leg loop: single-repository output is unchanged. docs/README-lanes.md: the handoff paragraph says so. tests/test_triad_lane_tooling_handoff.py: test_t018_the_handoff_reports_a_leg_git_cannot_read_as_the_sweep_counts_it, beside the reconcile one: the spec leg's .git a gitfile naming no repository, then an empty .git directory: one UNREADABLE LEG line, the note and the count, the lane SWAPPED, the reconciliation `unreadable`, 1 require recovery and `resumable`; a tree `add` made from the spec leg keeps the spec leg as its checkout, named and not recorded, the lane SWAPPING; readable again, nothing named and SWAPPED. The deinitialized leg test now also holds that a leg with no .git is never `unreadable`. VERIFICATION NOT RUN. The one run of the T018 selection (verify.sh, run4.log, head 9f23a6b, index tree 0b691c8) waited for the shared pytest lock behind other lanes' runs from 21:59:32Z and ended exit=143 (a SIGTERM this writer did not send) at 22:26:46Z before any test ran; an account swap was called at 22:20:05Z. So this commit and 9f23a6b are both UNTESTED. Before this commit: bash -n lane-handoff is clean, the test file parses. The next run on this tree verifies both. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 8 ++- lane-handoff | 64 +++++++++++++++++-- tests/test_triad_lane_tooling_handoff.py | 81 ++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index cb75fe3f..0a045f6f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3963,7 +3963,13 @@ leg's repository (a paired tree, or one `lane-worktrees add --checkout checkout of the tree's own repository keeps it: a dependency nested in a leg (`--checkout /code/upstream/`) is no declared leg. Any other tree keeps the lane's `dir`, as before. A leg that is not checked out here (never initialized, or -deinitialized) names no tree. A worktree a leg registers anywhere else is not polled +deinitialized) names no tree. A leg with a `.git` of its own that git cannot read is +`unreadable`, the sweep's word: one `- UNREADABLE LEG: …` line in WRITERS and a note on +stderr, counted, and the lane is not held `SWAPPING` for the leg itself. A polled tree +it then cannot place (of no readable leg's repository, not of the assembly's own, and +recorded under no checkout of its own repository) is named and not recorded, so the +checkout `add` recorded stays, and the lane stays `SWAPPING` for that tree. A worktree +a leg registers anywhere else is not polled as a writer, just as the checkout's own registrations elsewhere are not; `lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold beside what the checkout holds. Every worktree git registers in a checked-out leg that diff --git a/lane-handoff b/lane-handoff index 40a5501b..1403ce56 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1980,15 +1980,21 @@ poll_writer() { # # LEG, the checkout `lane-worktrees add --checkout /code` recorded # and every reader of the inventory enumerates (`writer_checkout`, below). # Where no checkout can be vouched for — the assembly's legs could not be - # read and the tree is not of the lane's own repository — NOTHING is filed: - # a guessed checkout would replace the one `add` recorded, so the tree is - # named and the lane stays SWAPPING for it, as for a refused write. + # read, or one of them is unreadable, and the tree is not of the lane's own + # repository nor of a leg git could read — NOTHING is filed: a guessed + # checkout would replace the one `add` recorded, so the tree is named and + # the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then pw_co="${dir:-unknown}" [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" if [ -z "$pw_co" ]; then lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" - note "the worktree inventory entry for $pw_d was NOT written: $dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + if [ "$legs_state" = known ]; then + pw_cant="the assembly has $legs_unreadable_count unreadable leg(s), each named by an UNREADABLE LEG line in the top block, so whether this tree is of such a leg's repository" + else + pw_cant="$dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs" + fi + note "the worktree inventory entry for $pw_d was NOT written: $pw_cant — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." return 0 fi LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-tree "$lane" "$pw_d" \ @@ -2088,11 +2094,44 @@ fi # directory would then be the assembly's own, and every tree of the assembly # would be recorded under that leg. So a leg counts only where git's own # toplevel there is the leg itself. +# +# A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is no absent leg: it is a +# read that failed, never an absent or a clean one (Amendment 7(d)). Passed +# over, it was a leg this handoff said nothing of while the sweep counted it, +# and every tree of its repository was re-recorded under the assembly, over +# the leg `add` had recorded. So it is the sweep's rule (`probe_leg`: git's +# toplevel there is not the leg, and a `.git` stands in it), the sweep's word +# and `lane-reconcile`'s: `unreadable`, one `- UNREADABLE LEG` line a leg in the +# top block's WRITERS section and a note on stderr, and COUNTED +# (`legs_unreadable_count`). The lifecycle is NOT left SWAPPING for the leg +# itself, as the reconciliation's verdict is not `indeterminate` for it: the +# poll looked everywhere it looks. A tree this handoff then cannot place is +# another matter (`writer_checkout`): it is named, not recorded, and the lane +# stays SWAPPING for it, as for any tree no checkout can be vouched for. +legs_unreadable=""; legs_unreadable_count=0 if [ "$legs_state" = known ]; then while IFS="$tab" read -r lg_repo lg_decl lg_abs; do [ -n "$lg_abs" ] && [ -d "$lg_abs" ] || continue - lg_top="$(git -C "$lg_abs" rev-parse --show-toplevel 2>/dev/null /dev/null &1 >/dev/null }" +[ "$legs_unreadable_count" = 0 ] || + step "legs: $legs_unreadable_count of the assembly's legs under $dir unreadable — named in the top block's WRITERS section, never a clean leg" [ "$paired_other_count" = 0 ] || step "paired root: $paired_other_count tree(s) under $dir/worktrees are not lane $lane's — listed in the top block, never counted or recorded" @@ -2335,6 +2383,8 @@ top_block() { printf -- '- NOT POLLED: %s, so a tree of this lane'"'"'s under `%s/worktrees` may be missing from this list. Read it by hand before relaunching a writer there.\n' \ "$paired_unread" "$dir" fi + # AND A LEG GIT COULD NOT READ, one line a leg, in the sweep's words. + [ -z "$legs_unreadable" ] || printf '%s' "$legs_unreadable" printf '\n' # T018, M1 (a): THE PAIRED ROOT'S OTHER TREES, OUTSIDE WRITERS. They are # other lanes' feature trees, or nobody's: named so the next session knows diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 72477905..609a20a0 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -209,6 +209,9 @@ def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad proc = handoff(b, "triad-1", a) assert proc.returncode == 0, proc.stdout + proc.stderr assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + # not checked out is no unreadable leg: it has no `.git` of its own + assert f"the leg {a / 'spec'} is unreadable" not in proc.stderr, proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} @@ -494,6 +497,84 @@ def check(answer: str) -> None: assert field(rec.stdout, "TREES")[2] == "0 require recovery", (state, rec.stdout) +@NEEDS_UPSTREAM +def test_t018_the_handoff_reports_a_leg_git_cannot_read_as_the_sweep_counts_it(triad): + """THE SAME SILENT SKIP IN `lane-handoff`: a leg with a `.git` of its own + that git cannot read was passed over as a leg that is not checked out is, + so the handoff said nothing of it, and a tree `add` made from that leg was + re-recorded under the ASSEMBLY - the loss (2) fixed. Now it is the sweep's + rule and word: one `- UNREADABLE LEG` line in WRITERS, a note on stderr and + a count, for a `.git` naming no repository and for one where git answers + the assembly. The lifecycle is not held for the leg itself (the + reconciliation's verdict is not `indeterminate` for it either): with no + tree of that leg the lane is SWAPPED. A tree of that leg keeps the checkout + `add` recorded - named, NOT recorded, and the lane stays SWAPPING for it - + while a tree of the assembly is recorded as always. Readable again, the + leg names nothing and its tree is recorded under it.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + gitfile = (spec / ".git").read_text() + words = (f"the spec leg of {a}/project.yaml (opensoft/triad-spec): git could not be read " + "through it (it has a .git of its own and git could not read it: ") + + def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: + proc = handoff(b, "triad-1", a, "unreadable leg") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: {words}" in proc.stderr, proc.stderr + assert answer in proc.stderr.split(f"the leg {spec} is unreadable: ", 1)[1], proc.stderr + assert f"legs: 1 of the assembly's legs under {a} unreadable" in proc.stderr, proc.stderr + assert f"writers polled: {len(trees)} under {a}" in proc.stderr, proc.stderr + for tree in trees: + assert f"WRITER {tree} —" in proc.stdout, proc.stdout + missed = f"the worktree inventory entry for {tree} was NOT written: " + if tree in unplaced: + assert (missed + "the assembly has 1 unreadable leg(s), each named by an " + "UNREADABLE LEG line in the top block") in proc.stderr, proc.stderr + else: + assert missed not in proc.stderr, proc.stderr + assert f"lifecycle: {state}" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + line = next((ln for ln in writers.splitlines() if ln.startswith("- UNREADABLE LEG: ")), "") + assert line.startswith(f"- UNREADABLE LEG: `{spec}` is unreadable, {words}"), writers + assert answer in line and f"(`git -C {spec} status`)" in line, line + assert writers.count("- UNREADABLE LEG: ") == 1, writers + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check("/nonexistent/t018-spec", (lane_root,), (), "SWAPPED") + assert inventory(b, "triad-1") == {str(lane_root): str(a)} + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert reconcile_rows(rec.stdout)[real(spec)][0] == "unreadable", rec.stdout + assert field(rec.stdout, "TREES")[2] == "1 require recovery", rec.stdout + assert field(rec.stdout, "VERDICT")[0] == "resumable", rec.stdout + (spec / ".git").unlink() + (spec / ".git").mkdir() + check(f"git answers {real(a)} there", (lane_root,), (), "SWAPPED") + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + want = {str(lane_spec): str(spec), str(lane_root): str(a)} + assert inventory(b, "triad-1") == want + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check("/nonexistent/t018-spec", (lane_spec, lane_root), (lane_spec,), "SWAPPING") + assert inventory(b, "triad-1") == want + + (spec / ".git").write_text(gitfile) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable" not in proc.stderr, proc.stderr + assert "of the assembly's legs under" not in proc.stderr, proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert inventory(b, "triad-1") == want + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From 5d2c1c8dc040664ee8af5aaffac656ecec860ab6 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:19:13 +0000 Subject: [PATCH 17/23] T018: lane-handoff withholds only a tree that could be an unreadable leg's, names a tree git cannot read, and says what it withheld (review of bf2a220, S1, S2, N1-N4) S1. lane-handoff, writer_checkout: while a leg was unreadable, every tree not of the assembly's own repository was withheld, a worktree of an unrelated repository in the lane's own root included, so the lane stayed SWAPPING on every handoff and those records went stale. Now a tree is withheld only where it could be of that leg's repository: its common git directory cannot be read, or lies physically inside the assembly or its git directory (.git/modules/, a leg's own .git) without being the assembly's own, or the inventory records it under an unreadable leg. Any other tree keeps the lane's dir, as when every leg reads. Test: test_t018_an_unreadable_leg_withholds_no_tree_of_another_repository (a worktree of a third repository, recorded before and made after the leg broke; and one a record names under the unreadable leg). S2. lane-handoff, poll_writer: `git rev-parse --git-dir || return 0` dropped any tree whose .git git cannot read, so a damaged .git/modules/ took every tree of that leg out of WRITERS and the count while the lane went SWAPPED. In an assembly (legs_state set) such a tree is now listed and counted, UNREADABLE and (NOT recorded), added to lc_inventory_missed, and named on stderr. A checkout with no project.yaml naming a leg passes over it as before, so the goldens do not move. Test: test_t018_a_tree_git_cannot_read_in_an_assembly_is_named_and_holds_the_lane_swapping (modules/spec deleted after add --checkout spec, with a paired spec tree the inventory names; and a single repository's tree, passed over). N1. The UNREADABLE LEG line's clause now depends on whether any tree was withheld ("the N tree(s) this handoff could not place are named above, each marked (NOT recorded)", or "every tree this handoff polled could still be placed, so none is withheld for it"), and a withheld tree's WRITERS line is marked (NOT recorded). The leg rows are kept as TAB and written by top_block after the poll. N2. lane-handoff's UNREADABLE LEG line and note, and lanes-edit.sh's lane-reconcile TREE row for an unreadable leg, say "before relaunching a writer onto a tree of its repository": a leg is the assembly's. N3. docs/README-lanes.md says the leg count is stderr's `legs: N ...` line, never WRITERS' (N found), and describes S1, S2 and N1. N4. Test: test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_directory (the empty .git directory, where git answers the assembly in the leg, with a tree add made from it). The existing handoff and reconcile unreadable-leg tests now also assert N1 and N2. bash -n passes on lane-handoff and lanes-edit.sh; the added lines use no declare -A, ${x,,}, mapfile, readlink -f, <<< or [[. Authoring smoke check only (the one verification follows on the final head): the three new tests, the two changed ones and the handoff golden test, each called once outside pytest with LANES_* and TMUX* unset, all passed; the three new tests each failed against cf7db4a's tools, on the withheld third-repository tree, the writer count and the (NOT recorded) marker respectively. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 19 ++- lane-handoff | 138 +++++++++++++--- lanes-edit.sh | 2 +- tests/test_triad_lane_tooling_handoff.py | 197 +++++++++++++++++++++++ 4 files changed, 332 insertions(+), 24 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 0a045f6f..259a70cc 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3965,10 +3965,21 @@ checkout of the tree's own repository keeps it: a dependency nested in a leg the lane's `dir`, as before. A leg that is not checked out here (never initialized, or deinitialized) names no tree. A leg with a `.git` of its own that git cannot read is `unreadable`, the sweep's word: one `- UNREADABLE LEG: …` line in WRITERS and a note on -stderr, counted, and the lane is not held `SWAPPING` for the leg itself. A polled tree -it then cannot place (of no readable leg's repository, not of the assembly's own, and -recorded under no checkout of its own repository) is named and not recorded, so the -checkout `add` recorded stays, and the lane stays `SWAPPING` for that tree. A worktree +stderr, and counted on stderr's `legs: N of the assembly's legs under unreadable` +line, never in WRITERS' `(N found)`. The lane is not held `SWAPPING` for the leg +itself. A polled tree that could then be of that leg's repository is one it cannot +place: its common git directory lies inside the assembly or its git directory (a +leg's `.git/modules/`) and is no readable leg's and not the assembly's own, or +the inventory records it under the unreadable leg. Such a tree is named, marked +`(NOT recorded)` in its WRITERS line, and not recorded, so the checkout `add` recorded +stays, and the lane stays `SWAPPING` for that tree. The `UNREADABLE LEG` line says how +many trees were withheld, or that none was. A tree of any other repository, such as a +worktree of an unrelated repository in the lane's own root, is recorded under the +lane's `dir`, as when every leg reads. A tree with a `.git` of its own that git cannot +read at all (a damaged `.git/modules/` takes every worktree of that leg with it) +is listed in WRITERS and counted, marked `(NOT recorded)` and `UNREADABLE`, never +recorded, and the lane stays `SWAPPING` for it. A checkout with no `project.yaml` +naming a leg passes over such a tree, as it always did. A worktree a leg registers anywhere else is not polled as a writer, just as the checkout's own registrations elsewhere are not; `lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold diff --git a/lane-handoff b/lane-handoff index 1403ce56..75b52a37 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1916,10 +1916,49 @@ brief_for() { # } writer_count=0 +pw_withheld_count=0 +# A TREE WITH A `.git` OF ITS OWN THAT GIT CANNOT READ, IN AN ASSEMBLY (T018, +# S2 of the review of bf2a220), is NAMED, never passed over. A leg whose +# module directory (`/.git/modules/`) is damaged takes every +# worktree of its repository with it: the trees `lane-worktrees add --checkout +# /` made, and the paired trees this lane's inventory names. +# Passed over, they vanished from the WRITERS section and from its count while +# the lane went SWAPPED, as if the poll had looked everywhere it looks. So such +# a tree is listed and counted as a writer whose state nobody could read, it is +# NOT recorded (a guessed reading would replace the one its record names), and +# the lane stays SWAPPING for it. Only `poll_writer` calls this, and only where +# the checkout is an assembly: a checkout with no `project.yaml` naming a leg +# passes over such a tree as it always did, which its golden holds. +unreadable_writer() { # + uw_d="$1" + uw_err="$(git -C "$uw_d" rev-parse --git-dir 2>&1 >/dev/null > "$writers_tmp" + if [ -n "$lc_on" ]; then + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$uw_d" + note "the worktree inventory entry for $uw_d was NOT written: it has a .git of its own and git could not read it ($uw_err), so neither what it holds nor which checkout it is recorded under is known, and a guessed reading would replace the one its record names now. This handoff's WRITERS section names it, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + else + note "the tree $uw_d has a .git of its own and git could not read it ($uw_err), so nothing is assumed about it, and this handoff's WRITERS section names it. Read it by hand (git -C $uw_d status) before relaunching a writer onto it." + fi + return 0 +} poll_writer() { # pw_d="$1" [ -d "$pw_d" ] || return 0 - git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1 || return 0 + if ! git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1; then + [ -n "$legs_state" ] || return 0 + { [ -e "$pw_d/.git" ] || [ -L "$pw_d/.git" ]; } || return 0 + unreadable_writer "$pw_d" + return 0 + fi # THE OBSERVATION IS THE HELPER'S, AND THIS COMMAND MAKES NONE OF ITS OWN # (Copilot round 5 on openRepoTools#97). `lanes-edit.sh lane-tree-now` is the # one implementation of *what git says about this tree right now* — the same @@ -1964,8 +2003,19 @@ poll_writer() { # [ -n "$pw_status" ] && printf '%s\n' "$pw_status" | sed 's/^/ /' say " git log @{u}..: ${pw_ahead} unpushed" [ -n "$pw_unpushed" ] && printf '%s\n' "$pw_unpushed" | sed 's/^/ /' - printf -- '- `%s` — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ - "$pw_d" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ + # WHERE IT IS RECORDED, decided before its line is written (below), so that a + # tree this handoff withholds says so in its own line — `(NOT recorded)` — + # rather than reading exactly as a recorded one (N1 of the review of + # bf2a220). A checkout with no `project.yaml` naming a leg withholds nothing, + # and its line is the one it always was. + pw_co=""; pw_withheld="" + if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + pw_co="${dir:-unknown}" + [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" + [ -n "$pw_co" ] || pw_withheld=" (NOT recorded)" + fi + printf -- '- `%s`%s — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ + "$pw_d" "$pw_withheld" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ "${pw_brief:-(fill in: the brief this writer was given)}" >> "$writers_tmp" # AND THE SAME OBSERVATION, MACHINE-READABLE, BESIDE THE LANE AND NOT INSIDE # IT (openRepoTools#91). The WRITERS section above is what a person and the @@ -1985,9 +2035,8 @@ poll_writer() { # # checkout would replace the one `add` recorded, so the tree is named and # the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then - pw_co="${dir:-unknown}" - [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" - if [ -z "$pw_co" ]; then + if [ -n "$pw_withheld" ]; then + pw_withheld_count=$((pw_withheld_count + 1)) lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" if [ "$legs_state" = known ]; then pw_cant="the assembly has $legs_unreadable_count unreadable leg(s), each named by an UNREADABLE LEG line in the top block, so whether this tree is of such a leg's repository" @@ -2058,7 +2107,7 @@ poll_writer() { # # an inventory that could not be read (`paired_unread`): each is a place a # tree of this lane's may stand unseen, exactly as `lane-reconcile` says # `indeterminate` and the sweep refuses for the same directories. -legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common="" +legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common=""; dir_phys="" paired_unread=""; paired_others=""; paired_other_count=0; paired_inv="" tab="$(printf '\t')" # THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg @@ -2124,12 +2173,15 @@ if [ "$legs_state" = known ]; then fi lg_why="the $lg_decl leg of $dir/project.yaml${lg_repo:+ ($lg_repo)}: git could not be read through it (it has a .git of its own and git could not read it: $lg_err)" legs_unreadable_count=$((legs_unreadable_count + 1)) - legs_unreadable="$legs_unreadable$(printf -- '- UNREADABLE LEG: `%s` is unreadable, %s. So NOTHING is assumed about it — not its own state, nor which tree is of its repository: a tree this handoff could not place is named above and NOT recorded. A read that failed is never a clean leg (Amendment 7(d)) — read it by hand (`git -C %s status`) before relaunching a writer onto it.' \ - "$lg_abs" "$lg_why" "$lg_abs") + # ` TAB `, one a line: the top block writes each leg's line + # once the poll has said whether any tree was withheld (N1), and + # `writer_checkout` asks whether a record names one of these legs. + legs_unreadable="$legs_unreadable$lg_abs$tab$lg_why " - # ONLY A TREE OF THE ASSEMBLY'S OWN REPOSITORY IS STILL VOUCHED FOR. + # WHAT IS STILL VOUCHED FOR, AND WHAT COULD BE A LEG'S (`writer_checkout`). [ -n "$dir_common" ] || dir_common="$(common_dir_of "$dir" /.git/modules/`, a leg's own `.git`, or a linked worktree's +# `modules/` under the common directory); or one this lane's inventory records +# under an unreadable leg, or under a checkout inside one. A tree of ANY OTHER +# repository (a worktree of an unrelated repository in the lane's own root) +# is of no leg, so it keeps the lane's `dir`, exactly as where every leg reads +# (S1 of the review of bf2a220): withheld, it left the lane SWAPPING on every +# handoff and its record going stale for as long as the leg stayed broken. +recorded_under_unreadable_leg() { # + ru_p="$(physical_of "$1")" + while IFS="$tab" read -r ru_abs ru_why; do + [ -n "$ru_abs" ] || continue + case "$1/" in "$ru_abs"/*) return 0 ;; esac + ru_lp="$(physical_of "$ru_abs" wc_c="$(common_dir_of "$1")" if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then @@ -2202,8 +2279,15 @@ WC_ROWS printf '%s' "$wc_rec"; return 0 fi if [ "$legs_unreadable_count" -gt 0 ]; then - if [ -n "$wc_c" ] && [ -n "$dir_common" ] && [ "$wc_c" = "$dir_common" ]; then printf '%s' "$dir"; fi - return 0 + if [ -n "$wc_c" ] && [ -n "$dir_common" ] && [ "$wc_c" = "$dir_common" ]; then + printf '%s' "$dir"; return 0 + fi + [ -n "$wc_c" ] && [ -n "$dir_phys" ] || return 0 + case "$wc_c/" in "$dir_phys"/*) return 0 ;; esac + if [ -n "$dir_common" ]; then + case "$wc_c/" in "$dir_common"/*) return 0 ;; esac + fi + if [ -n "$wc_rec" ] && recorded_under_unreadable_leg "$wc_rec" None: "not be read through it (it has a .git of its own and git could not " "read it: "), why assert answer in why and f"(git -C {spec} status)" in why, (answer, why) + # N2 of the review of bf2a220: a leg is the assembly's, not a writer's tree + assert why.endswith("before relaunching a writer onto a tree of its repository"), why assert field(rec.stdout, "TREES") == ["0 inventoried", "0 dirty or unpushed", "1 require recovery", "0 unmanaged or stale"], rec.stdout assert field(rec.stdout, "VERDICT")[0] == "ungraceful-stop", rec.stdout @@ -542,6 +544,22 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: assert line.startswith(f"- UNREADABLE LEG: `{spec}` is unreadable, {words}"), writers assert answer in line and f"(`git -C {spec} status`)" in line, line assert writers.count("- UNREADABLE LEG: ") == 1, writers + # N2 of the review of bf2a220: a leg is the assembly's, not a writer's tree + assert line.endswith("before relaunching a writer onto a tree of its repository."), line + assert proc.stderr.split(f"the leg {spec} is unreadable: ", 1)[1].split("\n", 1)[0] \ + .endswith("before relaunching a writer onto a tree of its repository."), proc.stderr + # N1: the leg's line says what was withheld, and a withheld tree's own line + # says it is NOT recorded, where a recorded tree's line does not + if unplaced: + assert (f"the {len(unplaced)} tree(s) this handoff could not place are named " + "above, each marked (NOT recorded)") in line, line + else: + assert ("every tree this handoff polled could still be placed, so none is " + "withheld for it") in line, line + for tree in trees: + assert (f"- `{tree}` (NOT recorded) — branch `" in writers) is (tree in unplaced), \ + (tree, writers) + assert (f"- `{tree}` — branch `" in writers) is (tree not in unplaced), (tree, writers) (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") check("/nonexistent/t018-spec", (lane_root,), (), "SWAPPED") @@ -575,6 +593,185 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: assert inventory(b, "triad-1") == want +@NEEDS_UPSTREAM +def test_t018_an_unreadable_leg_withholds_no_tree_of_another_repository(triad): + """S1 OF THE REVIEW OF bf2a220: while a leg was unreadable, the handoff + withheld EVERY tree that was not of the assembly's own repository - a + worktree of an unrelated repository in the lane's own root included, as + lane openRepoTools-3's root holds today - so the lane stayed SWAPPING on + every handoff and those trees' records went stale for as long as the leg + stayed broken. Now only a tree that could be of the leg's repository is + withheld: one whose common git directory lies inside the assembly, or one + the inventory records under the unreadable leg. A worktree of a third + repository, recorded before or new, is recorded under the lane's `dir` as + when every leg reads, and with nothing else withheld the lane is SWAPPED.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + third = b.projects / "third" + b.git("clone", "-q", b.seeded("third", {"t.txt": "a third repository\n"}), third) + root = b.projects / ".lane-worktrees" / "triad-1" + before = leg_tree(b, third, root / "nw-before", "nw-before") + proc = handoff(b, "triad-1", a, "every leg reads") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert inventory(b, "triad-1") == {str(before): str(a)} + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + after = leg_tree(b, third, root / "nw-after", "nw-after") + proc = handoff(b, "triad-1", a, "unreadable leg") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + for tree in (before, after): + assert f"WRITER {tree} — branch {tree.name}" in proc.stdout, proc.stdout + assert f"the worktree inventory entry for {tree} was NOT written" not in proc.stderr, \ + proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert "(NOT recorded)" not in writers, writers + assert ("every tree this handoff polled could still be placed, so none is withheld " + "for it") in writers, writers + assert inventory(b, "triad-1") == {str(before): str(a), str(after): str(a)} + + # A RECORD NAMING THE UNREADABLE LEG still withholds its tree, whatever + # repository the tree's own common directory says it is of. + named = leg_tree(b, third, root / "nw-named", "nw-named") + rec = b.tool("lanes-edit.sh", "set-lane-tree", "triad-1", named, "--checkout", spec) + assert rec.returncode == 0, rec.stderr + proc = handoff(b, "triad-1", a, "unreadable leg, and a record naming it") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"the worktree inventory entry for {named} was NOT written: the assembly has 1 " + "unreadable leg(s)") in proc.stderr, proc.stderr + for tree in (before, after): + assert f"the worktree inventory entry for {tree} was NOT written" not in proc.stderr, \ + proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert f"- `{named}` (NOT recorded) — branch `nw-named`" in writers, writers + for tree in (before, after): + assert f"- `{tree}` — branch `{tree.name}`" in writers, writers + assert ("the 1 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert inventory(b, "triad-1") == {str(before): str(a), str(after): str(a), + str(named): str(spec)} + + +@NEEDS_UPSTREAM +def test_t018_a_tree_git_cannot_read_in_an_assembly_is_named_and_holds_the_lane_swapping(triad): + """S2 OF THE REVIEW OF bf2a220: the poll passed over IN SILENCE any tree + with a `.git` git cannot read. With the spec leg's module directory + deleted after `add --checkout spec`, every tree of that leg - the one + `add` made, and a paired spec tree the inventory names - vanished from + WRITERS and from its count, and the lane went SWAPPED. Now each is listed + and counted, `UNREADABLE` and `(NOT recorded)`, its record is kept, and + the lane stays SWAPPING. Scoped to assemblies: a single repository's tree + that git cannot read is passed over, as it always was.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_spec = leg_tree(b, spec, a / "worktrees" / "040-p" / "spec", "040-p") + b.commit(paired_spec, "the feature's spec", {"p.md": "p\n"}, lane="triad-1") + proc = handoff(b, "triad-1", a, "every leg reads") + assert proc.returncode == 0, proc.stdout + proc.stderr + want = {str(lane_spec): str(spec), str(lane_root): str(a), str(paired_spec): str(spec)} + assert inventory(b, "triad-1") == want + + shutil.rmtree(a / ".git" / "modules" / "spec") + proc = handoff(b, "triad-1", a, "the spec leg's module directory is gone") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"writers polled: 3 under {a}" in proc.stderr, proc.stderr + assert f"WRITER {lane_root} — branch root" in proc.stdout, proc.stdout + block = top_block(b, "triad-1") + assert f"(3 found under `{a}`)" in block, block + writers = block.split("**WRITERS at ", 1)[1] + for tree in (lane_spec, paired_spec): + assert (f"WRITER {tree} — UNREADABLE: it has a .git of its own and git could not " + "read it: ") in proc.stdout, proc.stdout + assert (f"the worktree inventory entry for {tree} was NOT written: it has a .git of " + "its own and git could not read it") in proc.stderr, proc.stderr + assert (f"- `{tree}` (NOT recorded) — UNREADABLE: it has a .git of its own and git " + "could not read it (") in writers, writers + assert f"(`git -C {tree} status`)" in writers, writers + assert f"- `{lane_root}` — branch `root`" in writers, writers + assert ("the 2 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + held = proc.stderr.split("the worktree inventory was not recorded for ", 1)[1].split("\n", 1)[0] + assert str(lane_spec) in held and str(paired_spec) in held and str(lane_root) not in held, held + assert inventory(b, "triad-1") == want + + # A SINGLE REPOSITORY PASSES OVER SUCH A TREE, AS IT ALWAYS DID. + b.origin("plain") + s = b.projects / "plain" + b.git("clone", "-q", b.remotes / "plain.git", s) + b.commit(s, "seed", {"README.md": "plain\n"}) + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes("opensoft/plain", s, "plain-1") + made = b.tool("lane-worktrees", "add", "plain-1", "w") + assert made.returncode == 0, made.stderr + w = Path(made.stdout.strip()) + admin = (w / ".git").read_text().split("gitdir:", 1)[1].strip() + shutil.rmtree(admin) + proc = handoff(b, "plain-1", s, "a tree git cannot read") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"writers polled: 0 under {s}" in proc.stderr, proc.stderr + assert "UNREADABLE" not in proc.stdout + proc.stderr + assert "NOT recorded" not in proc.stdout + proc.stderr + top_block(b, "plain-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_directory(triad): + """N4 OF THE REVIEW OF bf2a220: the empty-`.git`-directory case was run + only with no tree of that leg, and its path differs from the gitfile + case's: git answers the ASSEMBLY in the leg, so the common directory of + the checkout the tree's record names is the assembly's own `.git`, not + nothing. A tree `add --checkout spec` made is still withheld - named, + marked `(NOT recorded)`, its record kept under the leg and never moved to + the assembly - and the lane stays SWAPPING; readable again, the tree is + recorded under its leg and the lane is SWAPPED.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + want = {str(lane_spec): str(spec)} + assert inventory(b, "triad-1") == want + gitfile = (spec / ".git").read_text() + (spec / ".git").unlink() + (spec / ".git").mkdir() + # the path this case takes: in the leg, git answers the assembly + assert real(b.git("rev-parse", "--show-toplevel", cwd=spec)) == real(a) + assert real(spec / b.git("rev-parse", "--git-common-dir", cwd=spec)) == real(a / ".git") + proc = handoff(b, "triad-1", a, "the spec leg's .git is an empty directory") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"git answers {real(a)} there" in proc.stderr, proc.stderr + assert f"WRITER {lane_spec} — branch s1" in proc.stdout, proc.stdout + assert (f"the worktree inventory entry for {lane_spec} was NOT written: the assembly has 1 " + "unreadable leg(s)") in proc.stderr, proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert f"- `{lane_spec}` (NOT recorded) — branch `s1`" in writers, writers + assert ("the 1 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert inventory(b, "triad-1") == want + + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert "(NOT recorded)" not in top_block(b, "triad-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert inventory(b, "triad-1") == want + + # ===================================================== the single repository def single_repository_decoys(bench: Bench) -> list: From 9a8f17549b4982d90951203b8a9d78ac0b147029 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:19:52 +0000 Subject: [PATCH 18/23] =?UTF-8?q?T018=20docs:=20record=20lane=201's=20ruli?= =?UTF-8?q?ngs=20of=20comment=206240800023=20in=20the=20analysis's=20?= =?UTF-8?q?=C2=A77=20(items=203,=2011=20and=20a=20new=2012=20for=20M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/triad-lane-tooling-analysis.md §7: a dated head note naming comment 6240800023 on #186 (posted 2026-10-17T20:48Z), and each ruling at its item: - item 3: proposal 1 (the guidance) goes live now; proposals 2 and 3 go live with the T018 PR, on the condition of the read-only before/after measurement over the triads (IRRS, IRSS, MedxEHR, MedxGlass, openDox, openXdox, openWallet and the codexFactory copies), main's tools against T018's, and any verdict that flips to a refusal told to its owning lane before the landing; - item 11: (ii), park's WIP commit carries a `Lane: ` trailer, and resume records the tree it rebuilds with `lanes-edit.sh set-lane-tree --checkout /`; both workBenches changes, one workBenches issue linked from the T018 PR ("workBenches issue: to be linked"); the PR does not wait for it; - item 12, new: the pre-PR review's M1, (a) as implemented in 4d4957d, with the two-lane regression test (test_t018_a_handoff_takes_no_other_lanes_paired_tree). §6's 2026-10-13 note now says the M1 ruling was made, and where. docs/README-lanes.md: the two T018 sentences that called these rulings pending now say they were made, and the parked-tree check before lane-end stands until the workBenches changes land. Docs only. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 13 ++++++--- docs/triad-lane-tooling-analysis.md | 43 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 259a70cc..fc642b57 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3951,8 +3951,9 @@ outside WRITERS — `- PAIRED ROOT, not this lane's: — branch …`, wit commit and its HEAD's own trailer, which is what finds its lane. It is not counted, not recorded, and never a writer for the next session to relaunch; its own lane's handoff records it. So one lane's `lane-end` never refuses on another lane's paired work, and -its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, -pending lane 1's ruling on #186: a handoff that recorded every paired tree made other +its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, as +lane 1 ruled on 2026-10-17 (#186, comment 6240800023, item 1; the analysis's §7 item +12): a handoff that recorded every paired tree made other lanes' trees the running lane's inventory, and a tree two lanes' inventories both name is `keep`, counted, in both sweeps. @@ -4011,8 +4012,12 @@ output `c45a452`'s tools printed, on a repository laid out to look like a triad: submodule's worktree under `worktrees//code`, and a tree recorded with the submodule as its checkout. -**A parked paired tree is not the lane's to the sweep — a gap, for lane 1's ruling -(docs/triad-lane-tooling-analysis.md §7, item 11).** The sweep and `lane-end`'s gate +**A parked paired tree is not the lane's to the sweep — a gap lane 1 has ruled on, and +not yet closed (docs/triad-lane-tooling-analysis.md §7, item 11).** On 2026-10-17 (#186, +comment 6240800023, item 2) lane 1 ruled that park's WIP commit carries a `Lane: ` +trailer and that `resume` records the tree it rebuilds with `lanes-edit.sh +set-lane-tree`. Both are workBenches changes and neither has landed, so what follows +holds until they do. The sweep and `lane-end`'s gate give a tree under `/worktrees` to a lane by its HEAD's own `Lane:` trailer and by nothing else, unless the lane's inventory names it. `make park` commits a dirty leg tree as `wip: park — lane `, with no trailer, and `resume` diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index f409cf90..45d13bc1 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -575,8 +575,9 @@ the leg it made a tree from. On a single repository every output is byte for byt main at `f87fd5a` (#190) and changes none of #190's test-root files. **2026-10-13: the paired root in `lane-handoff`, after the pre-PR review.** (b) above is -narrower now (the review's M1, option (a), pending lane 1's ruling on #186). The handoff -polls and records a paired tree only where the lane's inventory names it, its HEAD's own +narrower now (the review's M1, option (a), then pending lane 1's ruling on #186; ruled +2026-10-17, §7 item 12). The handoff polls and records a paired tree only where the +lane's inventory names it, its HEAD's own `Lane:` trailer is the lane's on a commit origin's default branch lacks (the sweep's rule), or the writers file names it. Every other paired tree is a `PAIRED ROOT, not this lane's` line in the top block, outside WRITERS, not counted and not recorded. A paired @@ -603,6 +604,14 @@ kept only in this lane's scratch directory. ## 7. Decisions for lane 1 (the migration owner) +**2026-10-17: lane 1's rulings on T018.** Lane openRepoTools-1 ruled in comment +**6240800023** on opensoft/openRepoTools#186 ("the four T018 rulings", posted +2026-10-17T20:48Z), answering lane 3's 6240688657, first asked in 6111266988. Each +ruling is quoted at its item below: item 3 (what goes live), item 11 (parked paired +trees), and item 12, added here, for the pre-PR review's M1. The same comment gives +lane 1's word to open the T018 PR once five conditions are met, and lane 1 owns the +landing. + 1. **Bindings (T018):** keep every lane's `dir` at the assembly root and its home at `opensoft/openRepoTools`, and spell leg objects in full. The alternative is lanes homed on a leg, which changes the Claude project folder, and with it memory and `--resume`. @@ -610,6 +619,16 @@ kept only in this lane's scratch directory. `opensoft/openRepoTools-spec:…` at cutover, by release and claim. 3. **Worktree visibility (T018):** adopt §4.4 proposal 1 (guidance) now. Rule on proposals 2 and 3, which change the sweep and the daily report for existing triads too. + **Ruled 2026-10-17 (6240800023, item 3).** Proposal 1, the guidance, goes live now. + Proposals 2 and 3 go live with the T018 PR, on one condition. They change the sweep, + `lane-end`'s gate, the handoff poll, `lane-reconcile`'s TREE rows and the daily report + for every triad on this workstation, and those triads belong to other lanes. So the PR + body must carry a read-only before/after measurement over each of them (IRRS, IRSS, + MedxEHR, MedxGlass, openDox, openXdox, openWallet and the codexFactory copies), + comparing main's tools with T018's. For each triad it lists the sweep rows that + change, every `lane-end` verdict that changes with the lane that owns it, and the + report lines that appear. Any verdict that flips to a refusal is told to its owning + lane, on #186 or in the register, before the landing. 4. **Checkout-copy callers:** opsXfactory-1's handoff and its `land-444.sh` call `~/projects/openRepoTools/lanes-edit.sh`. Re-point them before the live checkout pulls the split. @@ -668,3 +687,23 @@ kept only in this lane's scratch directory. its HEAD is back below the WIP commits. So decide too whether `resume` records the tree it rebuilds in the lane's inventory (`lanes-edit.sh set-lane-tree --checkout /`), which covers both states. + **Ruled 2026-10-17 (6240800023, item 2): (ii), and `resume` records the tree.** + Park's WIP commit carries a `Lane: ` trailer, so attribution stays on one + contract, the trailer, and `lane-worktrees` is not coupled to a workBenches subject + format. `resume` records the tree it rebuilds with `lanes-edit.sh set-lane-tree + --checkout /`, which covers both the parked and the + resumed state. Both are workBenches changes, to be filed as one workBenches issue + that cites this item and is linked from the T018 PR. **workBenches issue: to be + linked.** The PR does not wait for it. Until it lands, `docs/README-lanes.md` (T018 + section) says what to check before `lane-end`. +12. **The paired root in `lane-handoff` (T018, the pre-PR review's M1).** **Ruled + 2026-10-17 (6240800023, item 1): (a), as implemented in `4d4957d`, with the two-lane + regression test** (`test_t018_a_handoff_takes_no_other_lanes_paired_tree`). The + handoff polls and records a paired tree only when one of these holds: this lane's + inventory names it, its HEAD's last `Lane:` trailer is this lane's on a commit + origin's default branch lacks, or the lane's writers file names it. Every other + paired tree is printed as "PAIRED ROOT, not this lane's", and is not counted or + recorded. Lane 1's reason: recording everything is the misattribution that + `.claude/worktrees` already causes in this checkout's directory, where on 2026-10-11 + six other lanes' trees went into lane 1's inventory that way. Option (b), recording + every paired tree as the running lane's, is not taken. From a4e0b17fa58b0e25740af53730cfd18a8f3164d9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:29:46 +0000 Subject: [PATCH 19/23] T018: the daily report lists a leg that is also a canonical clone once (measurement finding 1) lane-worktrees, the report's _branches, _root_main and _aging iterated `canonical + [g.path for g in legs if g.checkout]`. A leg whose .git is a directory (a clone, not a submodule's gitfile) is also canonical, so it was read twice: the read-only triad measurement of 2026-10-17 (finding 1) saw 8 branch rows printed twice on this workstation, and _root_main would do the same for a leg whose main is ahead. _aging's `seen` set hid it there only where the two spellings matched. All three now read Report._checkouts(canonical): the canonical checkouts, then the checked-out legs, each once by os.path.realpath, first occurrence kept. With no leg checked out it returns canonical untouched, so a single-repository report cannot move. _leg_cell also matches a leg by real path, so the row kept (the canonical spelling) still names its leg. Test: test_t018_the_report_lists_a_leg_that_is_also_a_canonical_clone_once (an assembly whose project.yaml declares two legs that are clones: the code leg's unpushed branch, its aged rescue branch and the spec leg's main ahead of origin each appear once, each naming its leg). It needs no template. An authoring smoke check outside pytest: it passes on this tree and fails on 69f0017's lane-worktrees, on the duplicated branch row. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- lane-worktrees | 40 ++++++++++++++++++++--- tests/test_triad_lane_tooling.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/lane-worktrees b/lane-worktrees index f5c7c830..342b2955 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -5391,12 +5391,42 @@ class Report: return legs def _leg_cell(self, path: str) -> str: - """'' for a canonical checkout; for a leg, which assembly it is under.""" - for g in getattr(self, "legs", []): + """'' for a canonical checkout; for a leg, which assembly it is under - + by its real path too, because `_checkouts` keeps the FIRST spelling of + a checkout that is both, and that is the canonical one.""" + legs = getattr(self, "legs", []) + if not legs: + return "" + for g in legs: if g.path == path: return f"the {g.decl} leg of {g.assembly}" + rp = real(path) + for g in legs: + if g.real == rp: + return f"the {g.decl} leg of {g.assembly}" return "" + def _checkouts(self, canonical: list) -> list: + """The checkouts the branch, `main` and rescue sections read: every + canonical checkout, then every leg checked out here (T018), EACH ONCE + BY ITS REAL PATH, in that order. A leg whose `.git` is a directory - a + clone, not a submodule's gitfile - is also one of the estate's + canonical checkouts, and read once as each it printed every branch row + and its `main` row twice (the read-only triad measurement of + 2026-10-17, finding 1). With no leg checked out it is `canonical` + itself, untouched, so a report of single repositories cannot move.""" + legs = [g.path for g in getattr(self, "legs", []) if g.checkout] + if not legs: + return list(canonical) + out, seen = [], set() + for c in canonical + legs: + r = real(c) + if r in seen: + continue + seen.add(r) + out.append(c) + return out + def _legs_section(self) -> None: """T018: every leg checkout holding edits no commit carries or commits no origin ref holds - or that could not be read - under its assembly.""" @@ -5497,7 +5527,7 @@ class Report: rows = [] fmt = ("%(refname:short)%1f%(objectname:short=12)%1f%(upstream:short)%1f" "%(upstream:track)%1f%(committerdate:iso-strict)%1f%(contents:trailers)%00") - for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: + for c in self._checkouts(canonical): proc = git(c, "for-each-ref", f"--format={fmt}", "refs/heads/") if proc.returncode != 0: continue @@ -5534,7 +5564,7 @@ class Report: def _root_main(self, canonical: list) -> None: rows = [] - for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: + for c in self._checkouts(canonical): if git(c, "rev-parse", "-q", "--verify", "refs/heads/main").returncode != 0 or \ git(c, "rev-parse", "-q", "--verify", "refs/remotes/origin/main").returncode != 0: continue @@ -5557,7 +5587,7 @@ class Report: rows = [] rescue_count = 0 seen = set() - for c in canonical + [g.path for g in getattr(self, "legs", []) if g.checkout]: + for c in self._checkouts(canonical): proc = git(c, "for-each-ref", "--format=%(refname)\t%(committerdate:iso-strict)", "refs/heads/rescue/", "refs/remotes/origin/rescue/") for line in proc.stdout.splitlines(): diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py index def4b1c2..3b6551e3 100644 --- a/tests/test_triad_lane_tooling.py +++ b/tests/test_triad_lane_tooling.py @@ -820,6 +820,62 @@ def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): assert "Assembly legs" not in proc.stdout +def test_t018_the_report_lists_a_leg_that_is_also_a_canonical_clone_once(tmp_path): + """FINDING 1 OF THE READ-ONLY TRIAD MEASUREMENT (2026-10-17): a leg whose + `.git` is a DIRECTORY - a clone, not a submodule's gitfile - is also one of + the estate's canonical checkouts, and the report read it once as each, so + its branch rows printed twice (8 rows on that workstation) and its `main` + row would have. Each leg's rows now appear once, still naming the leg, in + the branch, `main` and rescue sections. The manifest is written here, so + no template is needed.""" + b = Bench(tmp_path / "bench", REPO) + a = b.projects / "cl" + b.origin("cl") + b.git("clone", "-q", b.remotes / "cl.git", a) + b.commit(a, "the assembly, its legs clones", { + ".gitignore": "spec/\ncode/\n", + "project.yaml": ("schema_version: 1\nkind: project-manifest\nid: \"cl\"\nlegs:\n" + " - role: assembly\n repository: opensoft/cl\n path: \".\"\n" + " - role: spec\n repository: opensoft/cl-spec\n path: \"spec\"\n" + " - role: code\n repository: opensoft/cl-code\n path: \"code\"\n")}) + b.git("push", "-q", "origin", "main", cwd=a) + spec, code = a / "spec", a / "code" + b.git("clone", "-q", b.seeded("cl-spec", {"s.md": "s\n"}), spec) + b.git("clone", "-q", b.seeded("cl-code", {"c.txt": "c\n"}), code) + assert (spec / ".git").is_dir() and (code / ".git").is_dir() + # the code leg: a branch with no upstream, and a rescue branch past the aging line + b.git("checkout", "-q", "-b", "leg-unpushed", cwd=code) + b.commit(code, "leg work", {"l.txt": "l\n"}, lane="cl-1") + b.git("checkout", "-q", "-b", "rescue/cl-1/old", "main", cwd=code) + old = {"GIT_AUTHOR_DATE": "2020-01-01T00:00:00+00:00", + "GIT_COMMITTER_DATE": "2020-01-01T00:00:00+00:00"} + made = b.run("git", "commit", "-q", "--allow-empty", "-m", "old rescue", cwd=code, env=old) + assert made.returncode == 0, made.stderr + b.git("checkout", "-q", "main", cwd=code) + # the spec leg: local main ahead of origin + b.commit(spec, "spec work on main", {"m.md": "m\n"}, lane="cl-1") + + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + foreign = _section(proc.stdout, "FOREIGN repositories") + assert str(spec) not in foreign and str(code) not in foreign, foreign # both canonical + branches = _section(proc.stdout, "Unmerged branches with a missing, diverged or unpushed upstream") + rows = [ln for ln in branches.splitlines() if ln.startswith(f"- {code} leg-unpushed ·")] + assert len(rows) == 1, branches + assert rows[0].endswith(f"the code leg of {a}"), rows + root_main = _section(proc.stdout, "Root main divergence") + rows = [ln for ln in root_main.splitlines() if ln.startswith(f"- {spec} · local main ahead")] + assert len(rows) == 1, root_main + assert rows[0].endswith(f"the spec leg of {a}"), rows + aging = _section(proc.stdout, "Awaiting disposition") + rows = [ln for ln in aging.splitlines() if ln.startswith(f"- {code} rescue/cl-1/old ·")] + assert len(rows) == 1, aging + assert rows[0].endswith(f"the code leg of {a}"), rows + legs = _section(proc.stdout, "Assembly legs with uncommitted or unpublished work") + assert legs.count(f"- {spec} · the spec leg of {a} ·") == 1, legs + + # ================================================================ (d) add def _inventory(b: Bench, lane: str) -> dict: From a60cf1cc4be3e49d9686763c156a681bce2b5de3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:29:46 +0000 Subject: [PATCH 20/23] T018 docs: the analysis spells bench paths ~/projects/..., as the hygiene rule of #229 item 22 asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f595e44 made tests/test_repo_hygiene.py refuse a committed path that starts at the bench root, except inside a single-quoted grep pattern. docs/triad-lane-tooling-analysis.md had seven such lines (§0, §4.2, §4.5, §4.9, §4.12 and two rows of §4.15). Each now says ~/projects/..., the bench's projects directory, as f595e44 respelled the same path in ideation/; the §4.15 grep pattern keeps the spelling it searched for, under the rule's allowance. A dated note under the header says so. host_absolute_path_hits over every file this branch adds or changes now finds nothing. Docs only. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- docs/triad-lane-tooling-analysis.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md index 45d13bc1..0dfa34f2 100644 --- a/docs/triad-lane-tooling-analysis.md +++ b/docs/triad-lane-tooling-analysis.md @@ -18,6 +18,9 @@ that changed only `specs/`, `openspec/`, `contracts/openreposhape-pin.yaml` and branch stays on `c4864ac`. A claim marked **measured** was run on a throwaway fixture in this lane's scratch directory; one marked **derived** follows from the code or from git's documented behaviour and was not run. +**2026-10-18:** paths on the bench where this was read are written `~/projects/…`, the +bench's projects directory, as `tests/test_repo_hygiene.py` asks of a committed file since +#229 item 22; the one quoted `grep` pattern in §4.15 keeps the spelling it searched for. This document decides nothing. Plan tasks are T006 (root collisions and leg guidance), T007 (installer entry point), T009 (separate roots in fixtures and hygiene), T010 (CI and pin checks) and T018 (feature selection, park/resume and bindings). @@ -69,7 +72,7 @@ checks) and T018 (feature selection, park/resume and bindings). `lanes-index`, `lane-handoff`, `lane-rename`, `lane`, `lanes`, `link-estates`, `repos.tsv`), `openRepoTools --install` and `wip init`, the hooks it merges, the documents that name checkout paths, the tests and CI that assume one root, and the register/handoff data in -`opensoft/brett-wip` that names `/workspace/projects/openRepoTools` (read, never written). +`opensoft/brett-wip` that names `~/projects/openRepoTools` (read, never written). **Out of scope:** the estate verbs `park`, `resume` and `status`. They find estates by `$PROJECTS_DIR` and run `make park`/`make resume`, and they already know three-leg roots @@ -153,7 +156,7 @@ copies in `~/.local/bin` are unaffected. **No change; T006 confirms it.** | Line | What it does | Under the triad | | --- | --- | --- | | :608 | `PROJECTS_ROOT=${PROJECTS_ROOT:-$HOME/projects}` | unchanged | -| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir /workspace/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | +| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir ~/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | | :1118-1128 | "the harness keys a session to the directory it was started in" (Evidence 3) | **This is why a lane's `dir` should stay at the assembly root.** Re-homing a lane into `code/` changes the Claude project folder (`-workspace-projects-openRepoTools` → `-workspace-projects-openRepoTools-code`). That strands the lane's memory, and `--resume ` stops finding the transcript. **T018 proposal:** bindings keep `dir` = assembly root, and nothing is rebound. | | :1180-1199 | The home is written from the directory's `origin` | At the assembly root the origin is still `opensoft/openRepoTools`, so the home is unchanged. | | :4294-4340 | Starts the daily report (`lane-worktrees sweep --all --dry-run --report`) for `$PROJECTS_ROOT` | Inherits the report's blind spot: §4.4. | @@ -257,7 +260,7 @@ line moves for a checkout with no `project.yaml`. | :2754-2765 `alias_lookup`; :2776-2847 `canon_object` | resolves a key's repository through repos.tsv. A spelled `owner/repo` is **always accepted** (:2834) | `opensoft/openRepoTools-code#n` works with no repos.tsv row. The bare `openRepoTools-code#n` is refused until the row exists (§4.9). | | :2791-2813 | `#n` expands against the lane's **home** | Home stays `opensoft/openRepoTools`, so `#n` always means the **assembly's** issue or PR. The code leg numbers its PRs from #1, so `#3` from an openRepoTools lane is the assembly's #3, never the code leg's. **T018 proposal:** code-leg and spec-leg objects are spelled in full. The lane-collision protocol text should say so. | | :2814-2818, :2895-2897 | `owner/repo:openspec/changes/` keys | Rule 1's sibling reads run `gh pr list --repo --search ` (:10249-10272). A change keyed on `opensoft/openRepoTools` therefore searches the **assembly's** PRs, while spec PRs land in `openRepoTools-spec`. **T018 proposal:** at cutover, each open claim on `opensoft/openRepoTools:openspec/changes/` is released and re-claimed as `opensoft/openRepoTools-spec:openspec/changes/`, through `lanes-edit.sh release`/`claim`. The log is append-only and is never rewritten. | -| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `/workspace/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | +| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `~/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | | :12555-12595 `tree_id_for`, `lane_tree_put` | sidecar per tree: absolute `path`, `checkout`, branch, head … | Formats are unchanged. Rows written before cutover stay **true**: they describe worktrees of the monorepo history, which is the assembly's own history (FR-001). | | :12699-12705 `lane_worktree_roots` | `/.claude/worktrees`, `${dir%/*}/.lane-worktrees/` | Same gap as §4.4, proposal 2. | | :16791-16829 `set-lane-tree` | takes an absolute path and an **unvalidated** `--checkout` | Records a code-leg tree today: `--checkout /code`. | @@ -303,7 +306,7 @@ if [ -n "$super" ] && [ -f "$super/project.yaml" ]; then here_root="$super"; fi (:89-295). It links `~/projects//handoffs` → `/handoffs/` for each estate directory that exists (:399-409), and `~/projects/xFactory/lanes-edit.sh` → `$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh` (:421). **Under the triad:** -`/workspace/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, +`~/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, and it stays one at the assembly root. The hook link points at the installed copy. **No change.** @@ -414,7 +417,7 @@ name and register row, never the cwd or the repository layout. **No change.** | docs/README-lanes.md:573-594, :3979-3997 | `tests/run.sh` | code-leg path | T006 | | docs/README-lanes.md:1793 | example `STARTED` line, `dir ~/projects…` | an example; true as long as the assembly stays at its path | none | | docs/README-claude-current.md:172 | "looks for `claude-current` beside itself" | true in `code/` | none | -| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: /workspace/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | +| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: ~/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | **Hygiene caps that T006 will trip:** test_repo_hygiene.py:1735-1736 caps AGENTS.md at 316 lines, and it **is** 316. test_repo_hygiene.py:2232-2233 caps README.md at 486, and it **is** @@ -515,8 +518,8 @@ at brett-wip `c311a78`: | File | Hits | What they are | Carry-over proposal | | --- | ---: | --- | --- | | lanes/LANES.md | 0 | Rows carry home `opensoft/openRepoTools` | none: the assembly keeps the name | -| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir /workspace/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | -| `/workspace/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: /workspace/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | +| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir ~/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | +| `~/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: ~/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | | handoffs/openRepoTools/session-handoff-2026-09-13-lane-openRepoTools-3.md, migration-handoff-2026-10-06.md, …-2026-10-02-…-2.md, …-2026-09-16-…-1.md | 56, 42, 26, 24 | historical reads and paths | none: each live lane's next Rule 3 top block restates the current paths | | handoffs/xFactory/session-handoff-2026-09-05-lane-opsXfactory-1.md | 11 | **"register writes via … `~/projects/openRepoTools/lanes-edit.sh`"**, the **checkout copy** | **Act before cutover:** this lane is live (PAUSED 2026-10-07T11:12:58Z, swap). That path disappears when the live checkout pulls the assembly main. Point its next handoff at the installed copy (`~/.local/bin/lanes-edit.sh` or `~/projects/xFactory/lanes-edit.sh`), and tell its owner. | | handoffs/xFactory/attachments/opsXfactory-1/land-444.sh | 2 | `LANES_EDIT="${LANES_EDIT:-$HOME/projects/openRepoTools/lanes-edit.sh}"` (:329) | Same. A script that defaults to the checkout copy breaks at the split. | From 3d0642c00f2a175b10defbb59f050abdb3b9f45c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 08:33:42 +0000 Subject: [PATCH 21/23] T018: verify 88ee9d9: 224 passed, 1 failed (test_the_lane_helper_suite_passes: two #180 row-stamp cuts, main's own); selection triad_lane_tooling or repo_hygiene or test_the_lane_helper_suite_passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log: briefs/t018-verify-20261018T034203Z.log (lane openRepoTools-3's briefs directory), through briefs/verify.sh and tests/run.sh, with every LANES_* and TMUX* variable unset. start 2026-10-18T03:42:03Z head 88ee9d9 tree-of-index 07fa62ee780876dcfaef17bf94c9a0cd7c47a228 1 failed, 224 passed, 1 deselected in 4045.64s (1:07:25) exit=1 end 2026-10-18T05:34:13Z The run queued behind other lanes' pytest from 03:42Z and took the lock at about 04:26Z. Every test of tests/test_triad_lane_tooling.py, tests/test_triad_lane_tooling_handoff.py and tests/test_repo_hygiene.py passed, both goldens unregenerated among them. The one failure, tests/test_lane_helpers_suite.py:: test_the_lane_helper_suite_passes: the shell suite ended "4620 passed, 2 failed, 0 skipped, 0 pending". Both FAILs are lane-start row-stamp tail cases of #180's group: - log line 100, "...with clause (c)'s directory and window in the tail, for the person reading the row" (repoA): the cell line is cut to "...home/projects/repoA; ..." (228 chars), so "; window " is gone; - log line 1967, "...having found the checkout by its real directory name, not the typed one" (repoCase): the cell line is cut to "...no launch; dir ..." (123 chars), the whole path dropped. Cause: lane-start's cut_to_line caps the state line at 230 characters, cuts there and trims back to the last space, then appends " ...". The "; dir ; window " tail comes last, and here is about 110 characters (the run root under ~/.local/state/openRepoTools/tmp/...), so the tail is cut: in repoA after the path, in repoCase inside it, where trimming to the last space drops the path whole. Both "got" strings are exactly that cut. Not T018's: `git diff origin/main...HEAD -- lane-start tests/test_lane_helpers.sh tests/test_lane_helpers_suite.py` is empty, and T018's lanes-edit.sh hunks (usage, project_legs/assembly_legs, lane_reconcile, a comment on lane_worktree_roots, the guard exemption, the project-legs arm and the refusal list) touch neither set-row-state nor the stamp. Lane 2 measured the same group failing on main's own tree at a TMPDIR length of 86 (#186 comment 6244714538: repoA, repoA11b, repoCase and repoMix); #215 (61a7378) moved only repoA11b to a short fixed path, and repoA11b passed here. CI's tests job, with a short run root, passes this test on main (83d2690: 1498 passed, 19 skipped; f5c3bcd and 2cb14a9: success). Not re-run on main locally: the two cases run only inside the hour-long shell suite. This run covers 9f23a6b and bf2a220 (the unreadable-leg reads) and this round: the S1, S2 and N1-N4 fixes (5d2c1c8), lane 1's rulings in §7 (9a8f175), measurement finding 1 (a4e0b17), the bench-root respelling (a60cf1c), and the merges of main 2732d59, 83d2690, f595e44 (the openRepoShape pin at v1.1.0) and f5c3bcd (cf7db4a, 107958c, 69f0017, 88ee9d9). The earlier run on 107958c (t018-verify-20261018T011535Z.log) was killed at a session exit after one F (first in order: test_the_lane_helper_suite_passes) and 176 passes, with no summary. Correction to a60cf1c's message: the first of the seven respelled lines is in §1 (Scope and method), not §0. Main has since moved to 2cb14a9 and a41adb6; this head does not include them. Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 From ea4db0d1c61548ddce8dbc7eba5a3959456ea9e8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 09:50:36 +0000 Subject: [PATCH 22/23] T018 tests: the unreadable-leg tests ask the git under test what it says, instead of expecting git 2.43's wording (PR #246 CI) PR #246's Linux tests job (run 39113292535, job 117633782691) failed two tests: test_t018_reconcile_reports_a_leg_git_cannot_read_as_the_ sweep_counts_it and test_t018_the_handoff_reports_a_leg_git_cannot_ read_as_the_sweep_counts_it. Both wrote a spec leg's .git as `gitdir: /nonexistent/t018-spec` and expected git's quoted answer to contain that path. Git words that condition by version: - 2.43 (this bench): fatal: not a git repository: /nonexistent/t018-spec - 2.54 (alpine/git:latest, a throwaway container): fatal: not a git repository (or any parent up to mount point /) - 2.55 (CI's runner): fatal: not a git repository: (null) On CI the tools still classified the leg `unreadable`, counted it and named it: lane-handoff's stderr shows "the leg .../triad/spec is unreadable: ... git could not read it: fatal: not a git repository: (null)". The code reads the condition (git's toplevel there is not the leg, and a .git stands in it) and only quotes git's line, so the code is right and the tests were wrong. tests/test_triad_lane_tooling_handoff.py: a new helper, git_says(b, leg), returns what every reader quotes (probe_leg's _first_line, lane-reconcile's and lane-handoff's first non-empty line, or "git answers there"), asked of the git under test. The three gitfile checks use it. The empty-.git-directory checks keep "git answers there", the tools' own words, which passed on CI. No tool changes; S1 and S2 are untouched. Before this commit: under a PATH shim that rewrites only the /nonexistent error line to 2.55's "(null)" form, both tests passed when called directly outside pytest (13 s and 37 s). Lane: openRepoTools-3 Co-Authored-By: Claude Opus 5.5 --- tests/test_triad_lane_tooling_handoff.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py index 5f4d924f..0dbdfad0 100644 --- a/tests/test_triad_lane_tooling_handoff.py +++ b/tests/test_triad_lane_tooling_handoff.py @@ -112,6 +112,23 @@ def top_block(b: Bench, lane: str) -> str: return (b.wip / "handoffs" / f"{lane}.md").read_text().split("\n---\n", 1)[0] +def git_says(b: Bench, leg: Path) -> str: + """WHAT EVERY READER QUOTES for a leg git cannot read - the sweep's + `probe_leg`, `lane-reconcile` and `lane-handoff` alike: git's own answer to + `rev-parse --show-toplevel` in the leg, its first non-empty error line, or + `git answers there` where git names another checkout. Asked of + the git under test, never spelled here, because git words the same + condition differently by version: for a `.git` file naming a directory that + does not exist, 2.43 says `fatal: not a git repository: ` and + 2.55 (CI's ubuntu runner, PR #246) says `fatal: not a git repository: + (null)`. The tools read the CONDITION (git's toplevel there is not the leg, + and a `.git` stands in it); only this quoted clause is git's.""" + proc = b.run("git", "-C", leg, "rev-parse", "--show-toplevel") + if proc.returncode == 0: + return f"git answers {proc.stdout.strip()} there" + return next((ln.strip() for ln in proc.stderr.splitlines() if ln.strip()), "no answer") + + def leg_tree(b: Bench, leg: Path, path: Path, branch: str) -> Path: """A worktree of `leg`'s repository at `path`, on a new branch from origin/main.""" b.git("worktree", "add", "-q", "-b", branch, path, "origin/main", cwd=leg) @@ -484,7 +501,7 @@ def check(answer: str) -> None: assert "git could not be read through it" in leg["why"] and answer in leg["why"], leg (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") - check("/nonexistent/t018-spec") + check(git_says(b, spec)) (spec / ".git").unlink() (spec / ".git").mkdir() check(f"git answers {real(a)} there") @@ -562,7 +579,7 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: assert (f"- `{tree}` — branch `" in writers) is (tree not in unplaced), (tree, writers) (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") - check("/nonexistent/t018-spec", (lane_root,), (), "SWAPPED") + check(git_says(b, spec), (lane_root,), (), "SWAPPED") assert inventory(b, "triad-1") == {str(lane_root): str(a)} rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") assert reconcile_rows(rec.stdout)[real(spec)][0] == "unreadable", rec.stdout @@ -580,7 +597,7 @@ def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: want = {str(lane_spec): str(spec), str(lane_root): str(a)} assert inventory(b, "triad-1") == want (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") - check("/nonexistent/t018-spec", (lane_spec, lane_root), (lane_spec,), "SWAPPING") + check(git_says(b, spec), (lane_spec, lane_root), (lane_spec,), "SWAPPING") assert inventory(b, "triad-1") == want (spec / ".git").write_text(gitfile) From 5dd0df07b6bcf8fd79f1dddc4c05b54d06da8e10 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 17:22:22 +0000 Subject: [PATCH 23/23] Re-run CI on main fe58808: refs/pull/246/merge was stale on 14239b9 No content change: this commit's tree is ea4db0d's. Lane openRepoTools-1, the lander, pushed it on lane openRepoTools-3's word in #186 6250521287, so the landing gate's Linux run tests the merge with current main. Co-Authored-By: Claude Opus 5.5