Every agent lane forfeits symbol search for the code it just wrote: worktrees live under .claude/, which is permanently unindexable #238

Closed
opened 2026-09-09 12:37:44 +02:00 by buildagent · 3 comments
Member

Split out of #201, which is now closed. #201 stated two problems and both are fixed; this is the part that survived, and it is the one the original reporter actually cared about — "it forced that lane onto grep for code it had just written."

The structural fact

Worktrees in this project are created under .claude/worktrees/:

/home/master/code/rust/cosi-mcp                                            [master]
/home/master/code/rust/cosi-mcp/.claude/worktrees/agent-ad4c228f72fc77751  [worktree-...]
/home/master/code/rust/cosi-mcp/.claude/worktrees/agent-aeb3a4a400d921b01  [worktree-...]

.claude is a dot-directory, and the walker allowlists only .github, .gitlab and .forgejo. Measured, with the tool's own proof:

index_coverage(".../.claude/worktrees/agent-.../crates/guest/src/budget.rs")
 -> verdict: "never"
    reason:  "hidden"
    stage:   "walk"
    proof.at:     ".claude"
    proof.detail: "hidden directory; only .github, .gitlab and .forgejo are allowlisted"
    hint:    "Permanent. Use shell for this path, or change the rule that excludes it."

613 files with indexable extensions in a single lane worktree, times however many lanes are live (2 right now, routinely more).

What works and what does not — the split matters

  • read_code works. Verified live: an absolute path into a lane worktree routes to primary by longest root prefix and reads the bytes off disk. read_code serves source bytes and does not consult the index, so the dot-directory rule never bites it.
  • search_symbols, search_text, file_outline, and every graph tool do not, because they read rows, and there are no rows.

So a lane can read a file it already knows the path of, and cannot find anything. That is the wrong half to have working for an agent that just wrote 600 files of new code.

Why this is ours and not a workflow complaint

CLAUDE.md makes preferring the index a hard rule that overrides the bypass-permissions guidance, and states the measured reason (1.00 precision vs ripgrep's 0.52, a quarter of the round trips). Every lane we spawn is then placed somewhere that rule cannot be followed, and the tool correctly tells it to use the shell. We are instructing agents to use an instrument and then standing them where it does not reach.

The honesty is genuinely good here — verdict: never with the rule that fired and a hint naming both remedies is exactly right, and this issue is not a complaint about the disclosure. It is that the disclosure is telling us something true about our own workflow.

What must NOT be done

  • Do not allowlist .claude/worktrees/. Each worktree is a near-complete copy of the repo. With N lanes live, every symbol in the project fans out N+1 times, ref_count becomes meaningless, and search_symbols returns N copies of every hit. This would be strictly worse than not indexing them.
  • Do not auto-link every worktree invisibly. #201's body already ruled this out and gave the reason: a worktree at a different commit is a different tree, and linking it silently reintroduces #182 — answers from a tree the caller did not ask about, with nothing saying so. Any reachability must disclose which tree answered.
  • Do not resolve relative paths against the process CWD. Also ruled out in #201, for the same reason it was ruled out there: it silently changes what a relative path means for every existing caller.
  • Do not fix it by moving worktrees out of .claude/. That is the harness's directory choice, not ours to depend on, and a fix that works only while a third party keeps a path convention is not a fix.

Shape of a fix

The one shape that survives all four constraints is explicit, disclosed, per-session linking: a worktree becomes a named project (not part of primary), reachable by project= like any other link, with its identity in answer_provenance.indexed_trees — which already exists and already reports head per tree, so the #182 requirement is met by machinery in the tree today.

Open questions the fix has to answer, not assume:

  • Who registers it — the lane itself on startup, .code-index.toml, or the server detecting that a [[links]] candidate is a worktree of primary?
  • Does fan-out include it by default? Probably not: a lane wants its own tree, and other lanes' half-finished trees are noise. Default-off with an explicit project= is the conservative reading, and matches how links already behave when a caller does not ask.
  • What is the cost of indexing a 613-file tree per lane, and does it need its own DB?

What a fix must prove

  • A lane can search_symbols a symbol it created in its own worktree, and the reply names which tree answered.
  • Anti-vacuity: a symbol that exists ONLY in the worktree must not appear in a primary-scoped query, and a symbol that exists only in primary must not vanish. Without both arms, "index everything into primary" passes.
  • Two concurrent lane worktrees do not contaminate each other's results, and neither contaminates primary's ref_count.
  • A worktree at a different commit from primary is reported with its own head, per #182.
  • A removed worktree does not leave rows behind — the lane worktrees here are created and destroyed constantly.

#201 (closed; this is its residual), #182 (which tree produced the answer), #33 (the CI dot-dir allowlist, which is the precedent for allowlisting — and the precedent for why a blanket allowlist was not what #33 did), #191.

Found by dogfooding during the #228 integration, 2026-09-09, against code-index-mcp 0.26.1 (94d7de6).

Split out of #201, which is now closed. #201 stated two problems and both are fixed; this is the part that survived, and it is the one the original reporter actually cared about — *"it forced that lane onto `grep` for code it had just written."* ## The structural fact Worktrees in this project are created under `.claude/worktrees/`: ``` /home/master/code/rust/cosi-mcp [master] /home/master/code/rust/cosi-mcp/.claude/worktrees/agent-ad4c228f72fc77751 [worktree-...] /home/master/code/rust/cosi-mcp/.claude/worktrees/agent-aeb3a4a400d921b01 [worktree-...] ``` `.claude` is a dot-directory, and the walker allowlists only `.github`, `.gitlab` and `.forgejo`. Measured, with the tool's own proof: ``` index_coverage(".../.claude/worktrees/agent-.../crates/guest/src/budget.rs") -> verdict: "never" reason: "hidden" stage: "walk" proof.at: ".claude" proof.detail: "hidden directory; only .github, .gitlab and .forgejo are allowlisted" hint: "Permanent. Use shell for this path, or change the rule that excludes it." ``` **613** files with indexable extensions in a single lane worktree, times however many lanes are live (2 right now, routinely more). ## What works and what does not — the split matters - **`read_code` works.** Verified live: an absolute path into a lane worktree routes to primary by longest root prefix and reads the bytes off disk. `read_code` serves source bytes and does not consult the index, so the dot-directory rule never bites it. - **`search_symbols`, `search_text`, `file_outline`, and every graph tool do not**, because they read rows, and there are no rows. So a lane can read a file it already knows the path of, and cannot *find* anything. That is the wrong half to have working for an agent that just wrote 600 files of new code. ## Why this is ours and not a workflow complaint CLAUDE.md makes preferring the index a **hard rule that overrides the bypass-permissions guidance**, and states the measured reason (1.00 precision vs ripgrep's 0.52, a quarter of the round trips). Every lane we spawn is then placed somewhere that rule cannot be followed, and the tool correctly tells it to use the shell. We are instructing agents to use an instrument and then standing them where it does not reach. The honesty is genuinely good here — `verdict: never` with the rule that fired and a `hint` naming both remedies is exactly right, and this issue is not a complaint about the disclosure. It is that the disclosure is telling us something true about our own workflow. ## What must NOT be done - **Do not allowlist `.claude/worktrees/`.** Each worktree is a near-complete copy of the repo. With N lanes live, every symbol in the project fans out N+1 times, `ref_count` becomes meaningless, and `search_symbols` returns N copies of every hit. This would be strictly worse than not indexing them. - **Do not auto-link every worktree invisibly.** #201's body already ruled this out and gave the reason: a worktree at a different commit is a *different tree*, and linking it silently reintroduces #182 — answers from a tree the caller did not ask about, with nothing saying so. Any reachability must disclose which tree answered. - **Do not resolve relative paths against the process CWD.** Also ruled out in #201, for the same reason it was ruled out there: it silently changes what a relative path means for every existing caller. - **Do not fix it by moving worktrees out of `.claude/`.** That is the harness's directory choice, not ours to depend on, and a fix that works only while a third party keeps a path convention is not a fix. ## Shape of a fix The one shape that survives all four constraints is **explicit, disclosed, per-session linking**: a worktree becomes a *named project* (not part of primary), reachable by `project=` like any other link, with its identity in `answer_provenance.indexed_trees` — which already exists and already reports `head` per tree, so the #182 requirement is met by machinery in the tree today. Open questions the fix has to answer, not assume: - Who registers it — the lane itself on startup, `.code-index.toml`, or the server detecting that a `[[links]]` candidate is a worktree of primary? - Does fan-out include it by default? Probably **not**: a lane wants *its own* tree, and other lanes' half-finished trees are noise. Default-off with an explicit `project=` is the conservative reading, and matches how links already behave when a caller does not ask. - What is the cost of indexing a 613-file tree per lane, and does it need its own DB? ## What a fix must prove - A lane can `search_symbols` a symbol it created in its own worktree, and the reply names which tree answered. - **Anti-vacuity:** a symbol that exists ONLY in the worktree must not appear in a primary-scoped query, and a symbol that exists only in primary must not vanish. Without both arms, "index everything into primary" passes. - Two concurrent lane worktrees do not contaminate each other's results, and neither contaminates primary's `ref_count`. - A worktree at a different commit from primary is reported with its own `head`, per #182. - A removed worktree does not leave rows behind — the lane worktrees here are created and destroyed constantly. ## Related #201 (closed; this is its residual), #182 (which tree produced the answer), #33 (the CI dot-dir allowlist, which is the precedent for allowlisting — and the precedent for why a *blanket* allowlist was not what #33 did), #191. Found by dogfooding during the #228 integration, 2026-09-09, against `code-index-mcp 0.26.1 (94d7de6)`.
Author
Member

Independent second hit, and a cheaper fix than the one this issue proposes

The #204 lane hit this from inside a worktree while I was filing it, without knowing this issue existed. Same verdict: "never", reason: "hidden", proof.at: ".claude". Two independent encounters in one afternoon, which is the frequency argument this issue was missing.

Their report adds a sharper framing than mine and a cheaper fix, so both go here.

The sharper framing: it is not that search fails, it is that it answers confidently

search_symbols("clause_prose")   // a function that exists on disk, open in the editor
 -> symbol_not_found
    empty_population.basis: "measured"
    note: "this zero is a measured absence across everything indexed"

That sentence is true and misleading at the same time, which is the worst combination this project has a name for. "Measured across everything indexed" is accurate; nothing in empty_population says the tree you are editing is not in any indexed root. A caller reads a confident measured zero for a file they just wrote.

This is the ordering from #220, one tool over: a blind spot that reports as a measurement is worse than one that reports as a limit. #220 fixed that for read_code's error prose; search_symbols's empty_population still has it.

The cheaper fix, and it may make the per-session linking unnecessary for most of the pain

I proposed disclosed per-session linking, which is real work. The lane points out most of the damage is done by the silence, not the absence:

index_coverage answers it correctly and cheaply, so the fix is cheap too — empty_population could name the indexed roots, or search_symbols could carry the same hidden/at: .claude proof when the caller's cwd is outside them.

That is right, and it splits this issue cleanly into two:

  1. Make the zero honest (cheap). empty_population names the indexed roots, or carries the walker proof index_coverage already produces. An agent then knows in one call to fall back to the shell, instead of concluding the symbol does not exist. This is the disclosure half and it needs no indexing decision.
  2. Make the tree reachable (the per-session linking above). Still worth doing, still constrained by all four "do not" items — but it stops being urgent once (1) lands, because the failure becomes legible.

Do (1) first. It is small, it is the same mechanism index_coverage already implements, and it converts the current failure from wrong answer to correct refusal with a next step. The lane's closing line is the argument: "This is why I used grep throughout; that was the correct fallback, not a preference." Today an agent can only learn that by luck.

One more datum from their run

Their read-only queries about committed code were correct, because they were based on the same commit the index had read (answer_provenance.indexed_trees.primary.head: 915c85087b9f, uncommitted_changes: false). They note the hazard that follows:

a lane based on anything else would be silently reading a different tree

So the failure is not uniform. A worktree at the same commit as primary gets right answers about committed code and wrong answers about its own new code; a worktree at a different commit gets silently wrong answers about both. answer_provenance already carries the head needed to detect this — it is reported, just not compared against anything the caller is working in.

## Independent second hit, and a cheaper fix than the one this issue proposes The #204 lane hit this from inside a worktree while I was filing it, without knowing this issue existed. Same `verdict: "never", reason: "hidden", proof.at: ".claude"`. Two independent encounters in one afternoon, which is the frequency argument this issue was missing. Their report adds a sharper framing than mine and a cheaper fix, so both go here. ### The sharper framing: it is not that search fails, it is that it answers confidently ``` search_symbols("clause_prose") // a function that exists on disk, open in the editor -> symbol_not_found empty_population.basis: "measured" note: "this zero is a measured absence across everything indexed" ``` That sentence is **true and misleading at the same time**, which is the worst combination this project has a name for. "Measured across everything indexed" is accurate; nothing in `empty_population` says *the tree you are editing is not in any indexed root*. A caller reads a confident measured zero for a file they just wrote. This is the ordering from #220, one tool over: **a blind spot that reports as a measurement is worse than one that reports as a limit.** #220 fixed that for `read_code`'s error prose; `search_symbols`'s `empty_population` still has it. ### The cheaper fix, and it may make the per-session linking unnecessary for most of the pain I proposed disclosed per-session linking, which is real work. The lane points out most of the damage is done by the *silence*, not the absence: > `index_coverage` answers it correctly and cheaply, so the fix is cheap too — `empty_population` could name the indexed roots, or `search_symbols` could carry the same `hidden`/`at: .claude` proof when the caller's cwd is outside them. That is right, and it splits this issue cleanly into two: 1. **Make the zero honest** (cheap). `empty_population` names the indexed roots, or carries the walker proof `index_coverage` already produces. An agent then knows in one call to fall back to the shell, instead of concluding the symbol does not exist. This is the disclosure half and it needs no indexing decision. 2. **Make the tree reachable** (the per-session linking above). Still worth doing, still constrained by all four "do not" items — but it stops being urgent once (1) lands, because the failure becomes legible. **Do (1) first.** It is small, it is the same mechanism `index_coverage` already implements, and it converts the current failure from *wrong answer* to *correct refusal with a next step*. The lane's closing line is the argument: *"This is why I used `grep` throughout; that was the correct fallback, not a preference."* Today an agent can only learn that by luck. ### One more datum from their run Their read-only queries about *committed* code were correct, because they were based on the same commit the index had read (`answer_provenance.indexed_trees.primary.head: 915c85087b9f`, `uncommitted_changes: false`). They note the hazard that follows: > a lane based on anything else would be silently reading a different tree So the failure is not uniform. A worktree at the same commit as primary gets right answers about committed code and wrong answers about its own new code; a worktree at a *different* commit gets silently wrong answers about both. `answer_provenance` already carries the `head` needed to detect this — it is reported, just not compared against anything the caller is working in.
Author
Member

Part 1 fixed on master at da7b34a. First, a correction to this issue, and the error is mine.

I measured against a stale binary and over-scoped the defect

I filed this from live probes against the installed MCP server. Verified just now:

installed:  code-index-mcp 0.26.1 (94d7de6)   2026-09-07 23:20
master:     bb47b99                            2026-09-09 14:39
behind:     30 commits

git merge-base --is-ancestor 6c454eb 94d7de6  ->  NO

6c454eb is #220, which already attaches answer_provenance to symbol_not_found and already ships sibling_worktrees: n. It is in the tree and not in the binary I probed. So the "confident measured zero with nothing at all to qualify it" I reported was a fixed defect, and anyone reading this issue would have over-scoped the work.

This is the staleness trap I have spent today closing on issue text, arriving through the instrument instead. An open issue is a dated snapshot of the code; a running binary is a dated snapshot too, and I checked the first and not the second. I have re-verified the rest of today's live claims against 94d7de6: 730bf15 (the #202/#201/#149 payload-honesty work) is in it, so those confirmations stand. This issue is the only one affected.

What the real residual was — one sentence

After #220 the reply carried the qualifying integer, but empty_population.note still asserted "a measured absence across everything indexed" while a sibling block said the population was incomplete. The qualifier lived in a different object from the claim it qualifies — and that sibling block's documented subject is which binary and which commit answered, not what was searched.

So the fix is: the sentence that makes the universal claim carries its own exception, in the same object.

  • probe_empty_population takes an unsearched parameter; when present the quantifier narrows from "across everything indexed" to "across the indexed roots named in unsearched, which are not every checkout of them".
  • EmptyPopulation::unsearched names each searched root by path. The old hint said "Searched: primary" — and a caller cannot compare their own cwd against the word primary.
  • UnsearchedTrees::from_roots (errors.rs:236) is the anti-vacuity rule as a pure function: None unless some root has a checkout the walk did not enter, or could not be asked about.
  • The producer reads tree_state's cache — the same entry answer_provenance fills for the same reply — so no extra git spawn, and a successful call never reaches it.

It generalises, measured rather than argued

  • grep "measured absence\|everything indexed" over server.rs finds exactly two sites that assert the universal, and both are inside probe_empty_population — one function serving both search_symbols and search_text. One clause, two tools, and each of the four legs (fan-out and project-pinned, per tool) separately mutation-graded.
  • Graph tools structurally cannot have this defect. find_callers, find_references, explain_dependency take a symbol_id, which only a prior indexed hit can mint — you cannot reach them for unindexed code.
  • Path-shaped tools already disclose correctly, verified live from the lane's own worktree: file_outline returns did_you_mean: ["primary=… — where … was resolved"], and list_files returns total: 0 with every entry in not_indexed carrying reason: "hidden".

So my comment's claim that "search_symbols, search_text, file_outline, and every graph tool do not [work]" was true about rows and misleading about honesty. Only the two name-shaped tools were lying.

Why not "is the caller inside an indexed root?" — it cannot be built. readlink /proc/<pid>/cwd on both live code-index-mcp processes returns the primary root while the caller sits in a worktree, and the server implements no roots capability, so no channel carries the caller's directory.

Cost — zero on every common path

Byte-for-byte against a binary built from 5ac45d9 (git archive into a separate tree, no CARGO_TARGET_DIR), same fixture, same probe:

reply before after
measured zero, no other checkout (the common path) 727 727
successful search_symbols 1602 1602
successful search_text 379 379
measured zero, one checkout unsearched (earned) 748 1399

The earned case is +651 bytes, once, only on a miss, only where a checkout provably exists — trimmed once after first measuring at +764. The token ratchet stayed green.

Mutations — 11 run, 0 survivors

I re-ran M1 myself, since it is the arm that decides whether this is a disclosure or wallpaper. from_roots disclosing unconditionally:

a_root_with_no_other_checkout_discloses_nothing ... FAILED
  a repository whose only checkout IS the indexed root has nothing unsearched:
  disclosing anyway is wallpaper, and it costs every measured zero this server returns
test result: FAILED. 10 passed; 1 failed

Restored by cp, md5 verified, re-run 11 passed; 0 failed.

The other ten cover: a measured 0 counted as unsearched (M2); unmeasured collapsing into measured zero (M3); a key growing on a clean reply (M4); the qualifier living only in a sibling block — "the #220 defect one field over" (M8); and a filtered zero attracting a worktree paragraph that explains nothing (M6).

M9–M11 exist because the lane's first draft left them ungraded: the shared-clause claim rested on one tool and one leg. Arms added, then mutated to prove they bite. That is the right instinct — a claim that one clause serves two tools is not evidence until both tools are graded.

Rejected, with the reason

git worktree list --porcelain is already run and already cached, and also prints HEAD <oid> per entry, so the block could say "K of the N unsearched trees are at a different commit". Rejected: it changes SiblingWorktrees, a shipped #220 wire shape, for a fact that does not change the caller's next action (fall back to shell either way), and it costs bytes on the earned path. It belongs to part (2), where the head genuinely disambiguates which tree answered.

A doc defect found and fixed in passing

docs/answer-provenance.md told readers to run code-index link add <path-to-your-worktree>. The CLI is code-index link add <name> <path>. The advertised remedy for this very issue did not run as written.

Follow-up candidate, not fixed

file_outline's no_outline hint says "If the file was just created, the index may not have caught up" for a path the walker permanently refuses — framing a permanent exclusion as transient. #220 deliberately keeps no_outline out of MEASUREMENT_KEYS, so this is a scope call rather than an oversight.

Gates

fmt 0 · clippy -D warnings 0 · cargo doc --document-private-items under RUSTDOCFLAGS="-D warnings" 0 · cargo test --workspace exit 0 (329 ok blocks) · COSI_E2E_LEG=daemon exit 0 (62 ok blocks) — all four new tests pass on the daemon leg too. tests/corpus/baseline.json untouched. Post-merge by me: zero_basis_e2e 11 passed, answer_provenance_e2e 18 passed, both EXIT=0.

Part 1 is done; this issue stays open for part 2 (making worktrees reachable as named projects), which is unchanged and still constrained by all four "do not" items.

Part 1 fixed on `master` at `da7b34a`. **First, a correction to this issue, and the error is mine.** ## I measured against a stale binary and over-scoped the defect I filed this from live probes against the installed MCP server. Verified just now: ``` installed: code-index-mcp 0.26.1 (94d7de6) 2026-09-07 23:20 master: bb47b99 2026-09-09 14:39 behind: 30 commits git merge-base --is-ancestor 6c454eb 94d7de6 -> NO ``` `6c454eb` is #220, which **already** attaches `answer_provenance` to `symbol_not_found` and already ships `sibling_worktrees: n`. It is in the tree and not in the binary I probed. So the "confident measured zero with nothing at all to qualify it" I reported was a **fixed** defect, and anyone reading this issue would have over-scoped the work. This is the staleness trap I have spent today closing on *issue text*, arriving through the instrument instead. An open issue is a dated snapshot of the code; **a running binary is a dated snapshot too**, and I checked the first and not the second. I have re-verified the rest of today's live claims against `94d7de6`: `730bf15` (the #202/#201/#149 payload-honesty work) **is** in it, so those confirmations stand. This issue is the only one affected. ## What the real residual was — one sentence After #220 the reply carried the qualifying integer, but `empty_population.note` still asserted *"a measured absence across everything indexed"* while a sibling block said the population was incomplete. **The qualifier lived in a different object from the claim it qualifies** — and that sibling block's documented subject is *which binary and which commit answered*, not *what was searched*. So the fix is: **the sentence that makes the universal claim carries its own exception, in the same object.** - `probe_empty_population` takes an `unsearched` parameter; when present the quantifier narrows from *"across everything indexed"* to *"across the indexed roots named in `unsearched`, which are not every checkout of them"*. - `EmptyPopulation::unsearched` names each searched root **by path**. The old `hint` said *"Searched: primary"* — and a caller cannot compare their own cwd against the word `primary`. - `UnsearchedTrees::from_roots` (`errors.rs:236`) is the anti-vacuity rule **as a pure function**: `None` unless some root has a checkout the walk did not enter, or could not be asked about. - The producer reads `tree_state`'s cache — the same entry `answer_provenance` fills for the same reply — so no extra `git` spawn, and a successful call never reaches it. ## It generalises, measured rather than argued - `grep "measured absence\|everything indexed"` over `server.rs` finds **exactly two** sites that assert the universal, and **both are inside `probe_empty_population`** — one function serving both `search_symbols` and `search_text`. One clause, two tools, and each of the four legs (fan-out and `project`-pinned, per tool) separately mutation-graded. - **Graph tools structurally cannot have this defect.** `find_callers`, `find_references`, `explain_dependency` take a `symbol_id`, which only a prior *indexed* hit can mint — you cannot reach them for unindexed code. - **Path-shaped tools already disclose correctly**, verified live from the lane's own worktree: `file_outline` returns `did_you_mean: ["primary=… — where … was resolved"]`, and `list_files` returns `total: 0` with every entry in `not_indexed` carrying `reason: "hidden"`. So my comment's claim that *"`search_symbols`, `search_text`, `file_outline`, and every graph tool do not [work]"* was true about rows and **misleading about honesty**. Only the two name-shaped tools were lying. **Why not "is the caller inside an indexed root?"** — it cannot be built. `readlink /proc/<pid>/cwd` on both live `code-index-mcp` processes returns the primary root while the caller sits in a worktree, and the server implements no `roots` capability, so no channel carries the caller's directory. ## Cost — zero on every common path Byte-for-byte against a binary built from `5ac45d9` (`git archive` into a separate tree, no `CARGO_TARGET_DIR`), same fixture, same probe: | reply | before | after | |---|---|---| | measured zero, no other checkout (**the common path**) | **727** | **727** | | successful `search_symbols` | 1602 | 1602 | | successful `search_text` | 379 | 379 | | measured zero, one checkout unsearched (**earned**) | 748 | 1399 | The earned case is +651 bytes, once, only on a miss, only where a checkout provably exists — trimmed once after first measuring at +764. The token ratchet stayed green. ## Mutations — 11 run, 0 survivors I re-ran **M1** myself, since it is the arm that decides whether this is a disclosure or wallpaper. `from_roots` disclosing unconditionally: ``` a_root_with_no_other_checkout_discloses_nothing ... FAILED a repository whose only checkout IS the indexed root has nothing unsearched: disclosing anyway is wallpaper, and it costs every measured zero this server returns test result: FAILED. 10 passed; 1 failed ``` Restored by `cp`, md5 verified, re-run `11 passed; 0 failed`. The other ten cover: a measured `0` counted as unsearched (M2); `unmeasured` collapsing into `measured zero` (M3); a key growing on a clean reply (M4); the qualifier living only in a sibling block — *"the #220 defect one field over"* (M8); and a filtered zero attracting a worktree paragraph that explains nothing (M6). **M9–M11 exist because the lane's first draft left them ungraded**: the shared-clause claim rested on one tool and one leg. Arms added, then mutated to prove they bite. That is the right instinct — a claim that one clause serves two tools is not evidence until both tools are graded. ## Rejected, with the reason `git worktree list --porcelain` is already run and already cached, and also prints `HEAD <oid>` per entry, so the block *could* say "K of the N unsearched trees are at a different commit". Rejected: it changes `SiblingWorktrees`, a shipped #220 wire shape, for a fact that does not change the caller's next action (fall back to shell either way), and it costs bytes on the earned path. It belongs to part (2), where the head genuinely disambiguates which tree answered. ## A doc defect found and fixed in passing `docs/answer-provenance.md` told readers to run `code-index link add <path-to-your-worktree>`. The CLI is `code-index link add <name> <path>`. **The advertised remedy for this very issue did not run as written.** ## Follow-up candidate, not fixed `file_outline`'s `no_outline` hint says *"If the file was just created, the index may not have caught up"* for a path the walker **permanently** refuses — framing a permanent exclusion as transient. #220 deliberately keeps `no_outline` out of `MEASUREMENT_KEYS`, so this is a scope call rather than an oversight. ## Gates `fmt` 0 · `clippy -D warnings` 0 · `cargo doc --document-private-items` under `RUSTDOCFLAGS="-D warnings"` 0 · `cargo test --workspace` exit 0 (329 ok blocks) · `COSI_E2E_LEG=daemon` exit 0 (62 ok blocks) — all four new tests pass on the daemon leg too. `tests/corpus/baseline.json` untouched. Post-merge by me: `zero_basis_e2e` 11 passed, `answer_provenance_e2e` 18 passed, both EXIT=0. **Part 1 is done; this issue stays open for part 2** (making worktrees reachable as named projects), which is unchanged and still constrained by all four "do not" items.
Author
Member

Part 2 done on master at d4f121a (merged de98a4f). Both halves of this issue are now closed.

The shape, and how each of the four constraints is met structurally

COSI_WORKTREES_DIR names a directory. Each child that proves it is a worktree of this repository — its .git file's gitdir: points into this repo's own worktrees/ — becomes a linked project.

constraint from part 1 how it holds
do not allowlist .claude/worktrees/ each worktree is its own index; primary's ref_count is untouched
do not auto-link invisibly (#182) registration is opt-in, and answer_provenance.indexed_trees names each tree with its own head
do not resolve relative paths against CWD no CWD resolution anywhere
do not depend on a path convention eligibility is a git fact. .claude/worktrees appears nowhere in the code

The three open questions, answered rather than assumed:

  • Who registers it — the operator, via an env var, following the COSI_CORPUS_DIR/#191 precedent that argued through the same constraints.
  • Fan-out — no. A new Relationship::Worktree carries the rule, so it attaches to the fact (a second checkout of one repo) rather than to how the link was made. Disclosed in projects_excluded_from_fan_out on success, and named in the hint on a miss.
  • Cost — measured: 822 files / 17,578 symbols / ~132 MB per lane, in <worktree>/.code-index/index.db.

Verified live, from inside a lane

search_symbols("render_link_package_set_semantics", project="agent-a268e9b8e3d9cc8ee") returned a function the lane had just written and not committed, at its real line. project="primary" and the fan-out both returned symbol_not_found. answer_provenance.indexed_trees named both trees with their own heads and uncommitted_changes: true for the lane against false for primary.

That is this issue's original complaint — "it forced that lane onto grep for code it had just written" — answered end to end.

I verified the constraint that matters, and my first attempt was the wrong mutation

The rule most at risk is "do not depend on a path convention", so I ran it. Defaulting the env var to a bare relative .claude/worktrees SURVIVED — because that resolves against the server's CWD, not the fixture root, so it found nothing. Redone root-relative:

an_unset_variable_links_nothing ... FAILED
  an unset COSI_WORKTREES_DIR must produce NO links even when a worktree sits in the
  obvious place — #201 ruled out silent linking because a worktree at a different
  commit is a different tree
  ... "linked_projects":[{"name":"shadow", "relationship":"worktree", ...}]

A mutation one construction off tests a different thing and looks like a refutation. Restored by cp, md5 verified.

The lane hit the same trap first and recorded it in the test's own doc: its original fixture put every worktree in <tmp>/lanes, so a default pointing at <root>/.claude/worktrees found nothing — "the test's NAME claimed a constraint its POPULATION could not reach." It now plants a real bait worktree in the most plausible default location, plus the reciprocal arm proving the bait is reachable when the operator asks.

Two more mutation results worth reading

  • M3 (worktree_links links nothing) reddened 7 tests and exposed a vacuity in the lane's own new test: the_two_trees_do_not_leak_into_each_other stayed GREEN, because with no link there is nothing to leak. A reachability precondition was added; M3 now reddens it.
  • M15 was unrunnable as stated — caching the scan across startups cannot be tested in-process because the two phases are separate processes. The doc was rewritten to say so and to name what actually grades it, rather than leaving a mutation claim the file could not honour.

Two defects found and fixed on the way

  • Introduced by this work and caught live: empty_population.unsearched.searched_roots promises "every indexed root this query was answered from" and was listing the worktree the fan-out had skipped — in the same reply whose hint said Searched: primary. Now scoped to the consulted set.
  • Pre-existing, recorded not fixed: an env-discovered directory that is refused answers project_not_available, whose hint says "configured in [[links]]" — false twice. Shared with corpus_links.

Known limit, disclosed rather than hidden

The scan runs once at startup, so a worktree created later in the session is not linked. That gap is not silent: part 1's empty_population.unsearched still names the roots it did search, and the link description says so in as many words.

Verification: fmt 0 · clippy -D warnings 0 · rustdoc -D warnings 0 · cargo test --workspace 336 suites, 0 failed · COSI_E2E_LEG=daemon 67 suites, 0 failed — the headline test runs on the configured leg per I035. baseline.json and ratchet.json untouched. Post-merge by me: worktree_projects_e2e 11 passed, link_payload_scaling_e2e 3 passed.

Closing.

**Part 2 done on `master` at `d4f121a` (merged `de98a4f`). Both halves of this issue are now closed.** ## The shape, and how each of the four constraints is met structurally `COSI_WORKTREES_DIR` names a directory. Each child that **proves** it is a worktree of this repository — its `.git` file's `gitdir:` points into this repo's own `worktrees/` — becomes a linked project. | constraint from part 1 | how it holds | |---|---| | do not allowlist `.claude/worktrees/` | each worktree is **its own index**; primary's `ref_count` is untouched | | do not auto-link invisibly (#182) | registration is **opt-in**, and `answer_provenance.indexed_trees` names each tree with its own `head` | | do not resolve relative paths against CWD | no CWD resolution anywhere | | do not depend on a path convention | eligibility is a **git fact**. `.claude/worktrees` appears nowhere in the code | The three open questions, answered rather than assumed: - **Who registers it** — the operator, via an env var, following the `COSI_CORPUS_DIR`/#191 precedent that argued through the same constraints. - **Fan-out** — **no**. A new `Relationship::Worktree` carries the rule, so it attaches to the *fact* (a second checkout of one repo) rather than to how the link was made. Disclosed in `projects_excluded_from_fan_out` on success, and named in the `hint` on a miss. - **Cost** — measured: **822 files / 17,578 symbols / ~132 MB** per lane, in `<worktree>/.code-index/index.db`. ## Verified live, from inside a lane `search_symbols("render_link_package_set_semantics", project="agent-a268e9b8e3d9cc8ee")` returned a function the lane had **just written and not committed**, at its real line. `project="primary"` and the fan-out both returned `symbol_not_found`. `answer_provenance.indexed_trees` named both trees with their own heads and `uncommitted_changes: true` for the lane against `false` for primary. That is this issue's original complaint — *"it forced that lane onto `grep` for code it had just written"* — answered end to end. ## I verified the constraint that matters, and my first attempt was the wrong mutation The rule most at risk is "do not depend on a path convention", so I ran it. Defaulting the env var to a **bare relative** `.claude/worktrees` **SURVIVED** — because that resolves against the server's CWD, not the fixture root, so it found nothing. Redone **root-relative**: ``` an_unset_variable_links_nothing ... FAILED an unset COSI_WORKTREES_DIR must produce NO links even when a worktree sits in the obvious place — #201 ruled out silent linking because a worktree at a different commit is a different tree ... "linked_projects":[{"name":"shadow", "relationship":"worktree", ...}] ``` A mutation one *construction* off tests a different thing and looks like a refutation. Restored by `cp`, md5 verified. The lane hit the same trap first and recorded it in the test's own doc: its original fixture put every worktree in `<tmp>/lanes`, so a default pointing at `<root>/.claude/worktrees` found nothing — **"the test's NAME claimed a constraint its POPULATION could not reach."** It now plants a real bait worktree in the most plausible default location, plus the reciprocal arm proving the bait *is* reachable when the operator asks. ## Two more mutation results worth reading - **M3** (`worktree_links` links nothing) reddened 7 tests **and exposed a vacuity in the lane's own new test**: `the_two_trees_do_not_leak_into_each_other` stayed GREEN, because with no link there is nothing to leak. A reachability precondition was added; M3 now reddens it. - **M15** was **unrunnable as stated** — caching the scan across startups cannot be tested in-process because the two phases are separate processes. The doc was rewritten to say so and to name what actually grades it, rather than leaving a mutation claim the file could not honour. ## Two defects found and fixed on the way - **Introduced by this work and caught live:** `empty_population.unsearched.searched_roots` promises *"every indexed root this query was answered from"* and was listing the worktree the fan-out had **skipped** — in the same reply whose `hint` said `Searched: primary`. Now scoped to the consulted set. - **Pre-existing, recorded not fixed:** an env-discovered directory that is refused answers `project_not_available`, whose hint says *"configured in `[[links]]`"* — false twice. Shared with `corpus_links`. ## Known limit, disclosed rather than hidden The scan runs once at startup, so a worktree created **later in the session** is not linked. That gap is not silent: part 1's `empty_population.unsearched` still names the roots it did search, and the link description says so in as many words. Verification: `fmt` 0 · `clippy -D warnings` 0 · rustdoc `-D warnings` 0 · `cargo test --workspace` **336 suites, 0 failed** · `COSI_E2E_LEG=daemon` **67 suites, 0 failed** — the headline test runs on the configured leg per I035. `baseline.json` and `ratchet.json` untouched. Post-merge by me: `worktree_projects_e2e` 11 passed, `link_payload_scaling_e2e` 3 passed. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#238
No description provided.