# Row 91, a project clone in each role workspace, build round 3 (Darkwing) Issue #1585. Brief: `docs/plans/2026-10-12_pm-task-flow.md`, "A project checkout in each role workspace", with Sage's decision 126 (brief blob `844832c3`). Reviewer: Dewey (decision 130). Gate as revs 881 to 883 set it. Rounds 1 and 2 are in `agents/darkwing/work/queue-91/` and `r2/`, with request comments 28067 and 28087. Round 2's `build.md` describes the row as a whole: the outcomes, decision 126's three additions, the launcher and prompt changes. That all still holds. This file covers what round 3 changes. ## Round 3 Round 3 closes the escape Filbert found in round 2. `finding.md` here says what ran, where, and which guard it got past. In short, `git status --ignore-submodules=none` started a second git inside a submodule the session had made in its clone, and that git ran a clean filter from the submodule's own config, on the host. None of round 2's checks read a submodule's config. Sage approved the approach (decision 130 thread): a clone with a submodule is kept, and no host git recurses into one. ## Candidate and base Base `2b2a9e14`. I built round 3 on `fbbcf0b7`. Before opening the round I checked `fbbcf0b7..HEAD` on the manifest paths: row 88's landing (`2b2a9e14`) changed `packages/cli/README.md`, one of them, in the command list near the top, not in this row's lines. So I moved round 3 to `2b2a9e14`. The `fbbcf0b7` patch applies there with `git apply --index` and no fuzz, and the tests, mutants and gate below all ran on `2b2a9e14`. `build.patch` is `git diff 2b2a9e14` of the same eight paths as round 2, the two new files included. Apply it with `git apply --index` to a detached worktree of `2b2a9e14`; `sha256sum -c candidate-manifest.sha256` then gives 8/8 OK. Against round 2's manifest (`6b58085b`), three lines change: `packages/seat/src/checkout.mjs`, `packages/seat/tests/checkout.test.mjs` and `packages/seat/README.md` for this round's edits, and `packages/cli/README.md` for row 88's lines only. The launcher, its test and the two bundle files are byte for byte round 2's. ## What changed `packages/seat/src/checkout.mjs`: - `unsafe()` returns `submodule` when `.git/modules` exists (lstat, any kind of entry), after the walk and before the commondir check. A clone never has one; `git submodule` makes it. - `update()` runs `git ls-files --stage -z` right after `unsafe()`. A failure keeps the clone as `status-failed`. Any entry with mode `160000`, a gitlink, keeps it as `submodule`. This runs before `rootBranch`, `symbolic-ref` and `status`. `ls-files` reads only the index, so no filter, hook or submodule runs there. - `status` passes `--ignore-submodules=all` instead of `=none`. - `SAFE` gains `-c submodule.recurse=false`, so no git call recurses. - The header comment says what the checks cover and why a submodule is kept. `packages/seat/README.md`: the reasons table gains `submodule`; `status-failed` names `git ls-files`; one paragraph after the guards says why the host never recurses into a submodule. `packages/seat/tests/checkout.test.mjs`: six new cases in "a clone the session rigged is kept, and git runs nothing and writes nothing outside it". Each builds a repository at `/sub` whose own config names a clean filter that runs the bait script, with a same-size edit to the filtered file so any status inside it must run the filter. | case | reason | |---|---| | a gitlink staged, no .gitmodules | `submodule` | | a gitlink and .gitmodules staged | `submodule` | | a gitlink to a repository with its own gitlink (nested, filter in the inner one only) | `submodule` | | a gitlink and .gitmodules committed | `submodule` | | a gitlink and submodule.active in config | `config-changed` | | a .git/modules with no gitlink | `submodule` | As for every case in that test, each one checks the outcome, that the bait's marker file is absent, and that the bait repository's fingerprint is unchanged. Sage asked for "a bare gitlink, a gitlink with config, and a populated .git/modules with no gitlink": those are the first, the fifth and the sixth. ## Mutants `mutants.sh` is round 1's 31 mutants plus seven for round 3, with the same `mut.mjs`. All 38 ran on the round 3 candidate on `2b2a9e14`, from about 13:09Z to 13:10:48Z, `mutants.txt`: 32 killed, 6 survived. Round 1's results are unchanged (the same three survivors, explained in round 2's `build.md`). | mutant | what it does | result | |---|---|---| | gitlink-check-off | drops the `160000` check | killed, the four gitlink cases | | modules-check-off | drops the `.git/modules` return | killed, "a .git/modules with no gitlink" | | status-recurses | `--ignore-submodules=none` | survived | | safe-no-submodule-recurse | drops `-c submodule.recurse=false` | survived | | lsfiles-failure-passes | drops the `ls-files` failure return | survived | | recursion-unguarded | gitlink check off and `=none` | killed, the four gitlink cases, reason `dirty` | | gitlink-check-after-status | gitlink check moved after a `=none` status | killed, the four gitlink cases, on "nothing the session wrote ran" | The last one is the order test Sage asked for: with the check after the status, the outcome is still `submodule` but the marker is there, so only the order of the two calls stops the escape, and the test sees it. The three survivors are second-line guards, expected to survive. `status-recurses` and `safe-no-submodule-recurse` can't matter while the gitlink check runs first, since `status` then never sees a gitlink. `lsfiles-failure-passes` needs an index `ls-files` can't read; a later `status` on that index fails too, so the clone is still kept as `status-failed`. One thing the logs don't show: under `gitlink-check-off` and `recursion-unguarded` the outcome assertion fails first, so the marker assertion never runs. Whether `--ignore-submodules=all` alone stops the filter is therefore not tested here. I didn't swap the two assertions, because that needs another test run and the slot is released. It would make each escape fail on the marker first, if Dewey wants it. ## Gate `gate.sh`, `gate-summary.txt`. 13:04:52Z to 13:07:13Z on 2026-10-13, in the slot Sage cleared for me at 12:56Z, after the busy check. Base `2b2a9e14` with this `build.patch` staged; the script checks the manifest 8/8 OK first. Sequential in a clean worktree with `node_modules` linked and `DOCKER_HOST=unix:///nonexistent`. All 14 rc=0: | suite | result | |---|---| | seat `node --test tests/*.test.mjs` | 89 pass, 0 fail, 0 skipped | | cli | 131 pass, 0 fail, 0 skipped | | bus | 115 pass, 0 fail, 0 skipped | | harness | 67 pass, 0 fail, 0 skipped | | tasks | 89 pass, 0 fail, 0 skipped | | `test-auth.sh` | 15/0 | | `test-conductor.sh` | 17/0 | | `test-config.sh` | 24/0 | | `test-discord.sh` | 66/0 | | `test-extension-package.sh` | 18/0 | | `test-foundation.sh` | 44/0 | | `test-queue.sh` | 27/0 | | `test-release.sh` | 4/0 | | `test-task.sh` | 26/0 | Seat is round 2's 83 plus the six new cases. Cli (+6) and bus (+4) are row 88's tests on the new base. The Docker-off skips are the same as in round 2's `build.md`. No Docker network was made, and no process was left with a cwd in my worktrees. ## Limit A project whose root has submodules gets a clone with gitlinks in its index from the first `git clone` (no `--recurse-submodules`). From then on every launch keeps it as `submodule` and never fast-forwards it. That fails closed. The one project in the `mosaic-stack` business file today, `stack` (this repository), has no `.gitmodules`. If one does later, it needs its own rule, not a looser check. ## Found, not fixed Round 2's note on `recover()` in the launcher still stands. ## Secrets scan I scanned the packet for token, secret, password, api key and private key markers. The matches are round 2's test stand-ins in `build.patch` (`CLAUDE_CODE_OAUTH_TOKEN = "stand-in"`, the fake `bot:s3cr3t-token@git.example` origin and `http.extraHeader` in the "no credential or forge remote" test). `finding.md` holds no command strings from the probe and no probe output. ## Correction (2026-10-13T13:14:59Z) Every "row 88" above should read row 94 (#1588, messages). `2b2a9e14` is row 94's landing; row 88 has not landed. Sage caught it. The rebase, the manifest and the gate are unchanged.