[BUG] Path-scoped rules never match outside the project root, making the auto-memory directory unreachable
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
paths: globs in .claude/rules/ are matched against the project-relative
path. A file outside the project root has no project-relative path, so it can
never match — at any rule scope, with any pattern, including an absolute one.
The concrete consequence is that the auto-memory directory is unreachable by
path-scoped rules. Auto-memory lives at~/.claude/projects/<sanitized-cwd>/memory/*.md. Those files are markdown, sit
under a literal .claude/ path segment, and are read and written by Claude
constantly — so **/.claude/**/*.md reads as though it must match them. It
never does, and neither does an absolute /Users/<me>/.claude/**/*.md.
There is no diagnostic anywhere. A pattern that can never match is
indistinguishable from one that simply hasn't matched yet: no warning at load,
no error, and rules are not listed in /context. The failure is silent in
exactly the way that makes you believe a convention is in force when it isn't.
What Should Happen?
Any of these would resolve it, in descending order of preference:
- Evaluate the glob against the absolute path when the file lies outside the
project root (or when the pattern itself is absolute), so out-of-root files
are addressable at all.
- Special-case the auto-memory directory. It is a first-class Claude Code
surface with its own autoMemoryDirectory / autoMemoryEnabled settings and
a documented layout, and the harness reads and writes it on its own — yet it
is the one directory a rule provably cannot be scoped to.
- Failing either, document that path scoping is bounded by the project root,
and warn when a rule's patterns cannot match anything.
Today the only way to govern memory-file editing is a rule with no paths:
key, which then loads into every session in every project on the machine. For
guidance that is only relevant while touching memory files, that is a large
standing context cost with no way to scope it down.
Steps to Reproduce
Controlled so that the only variable is which side of the project root the
file sits on — same rule, same glob, same filename.
- Create
~/.claude/rules/_probe.md:
---
paths:
- "**/probe-target/**/*.md"
---
PROBE-SENTINEL: this rule loaded via path glob match.
- Create two identical targets, one inside the project root and one outside:
mkdir -p "$PWD/probe-target" /tmp/probe-outside/probe-target
echo "# x" > "$PWD/probe-target/x.md"
echo "# x" > /tmp/probe-outside/probe-target/x.md
- In a session rooted at that project, read the outside file first
(/tmp/probe-outside/probe-target/x.md), then the inside one
($PWD/probe-target/x.md).
Order matters: a rule loads at most once per session, so reading the in-root
file first masks the result entirely. This is what makes the bug easy to
misdiagnose.
Observed
| Read | Rule injected? |
| --- | --- |
| /tmp/probe-outside/probe-target/x.md | no |
| <project>/probe-target/x.md | yes |
Same rule file, same glob, same basename. The project-root boundary is the only
discriminator.
The same negative result holds for a real auto-memory file
(~/.claude/projects/<slug>/memory/*.md) using both **/.claude/**/*.md and an
absolute /Users/<me>/.claude/**/*.md, verified from a cold start with the
memory read issued before any in-project read.
Related
- #87217 — reports that
paths:on a user-level rule silently disables
it, loading at neither session_start nor path_glob_match. I cannot
reproduce that on 2.1.241. Three separate user-level rules in
~/.claude/rules/ with paths: all loaded correctly on a matching in-root
read, using both a directory-component glob (**/probe-target/**/*.md) and a
bare-extension glob (**/*.probeext) of the shape that report used. That
issue may be fixed since 2.1.233, or may turn out to be this bug seen from a
different angle if the reporter's targets were outside their project root.
Worth re-checking against a current build before further work on it.
- #79489 —
CLAUDE.mdin--add-dirfolders silently skipped. Same family:
instruction files outside the project root are quietly ignored.
- #16299 — the inverse symptom (path-scoped rules loading unconditionally).
Not tested: whether a directory added via --add-dir counts as in-root for glob
evaluation. If it does, that is a usable workaround worth documenting.
Claude Code Version
2.1.241
Is this a regression?
No / unknown — I have no evidence this ever worked.
Operating System
macOS (Darwin 25.5.0)
5 Comments
Additional evidence: this is not specific to rules. The same project-root
boundary silently disables LSP diagnostics, which is an entirely separate
subsystem. That suggests the defect is the boundary itself rather than anything
in the
paths:matcher.Repro
tyis wired as an LSP server through theastral@astral-shplugin(
extensionToLanguage: {".py": "python", ".pyi": "python"}). The harnessconsumes
publishDiagnosticsfrom it and surfaces the result to the model as a<new-diagnostics>block — independent of theLSPtool, which exposes onlypull operations (
goToDefinition,findReferences, …) and has no diagnosticsoperation of its own.
.pyfile containing obvious type errors inside the project root..pyfile, with equivalent errors, outside theproject root.
at the same boundary.
Observed
| File |
ty checkon the CLI | Surfaced as<new-diagnostics>|| --- | --- | --- |
|
<project>/_lsp_probe.py| 4 diagnostics | all 4 ||
<outside-root>/_lsp_probe_outside.py| 4 diagnostics | none |The outside file is not clean —
tyreportsFound 4 diagnosticsfor it on thecommand line. The harness simply never surfaces them.
Worth noting the outside path used here was the session's own scratchpad
directory, which Claude Code provisions per-session and instructs the model to
use for temporary work. So even a directory the harness created and pointed the
model at is invisible to the diagnostics channel once it falls outside the
project root.
Reproduction gotcha
Diagnostics fire on neither
WritenorEditdirectly; they arrive at thenext tool boundary, consistent with the
uvx ty@latest servercold start plusa deferred flush. An in-root probe therefore looks negative for the first couple
of tool calls. Combined with the once-per-session rule-loading behaviour noted
in the original report, both halves of this bug present as false negatives
unless you control for timing and ordering — which is likely why it has been
hard to characterise.
Why this widens the report
Auto-memory files are now confirmed unreachable by two independent channels:
Any future mechanism keyed on file path will presumably inherit the same limit.
So the useful framing is not "
paths:globs are project-relative" but"files outside the project root are invisible to the harness's instruction and
feedback channels", with
~/.claude/projects/<slug>/memory/as the case thatmatters most, since the harness reads and writes it on its own.
Suggested retitle if maintainers agree with the broader scope:
*"Files outside the project root are invisible to path-scoped rules and LSP
diagnostics, making the auto-memory directory unreachable."*
Environment unchanged: Claude Code 2.1.241, macOS (Darwin 25.5.0).
Two more reproducible observations, both relevant to anyone trying to work
around this.
An absolute pattern does not help
The obvious workaround for an out-of-root target is to write the pattern as an
absolute path. It does not fire:
Reading
/Users/<me>/.claude/projects/<slug>/memory/<file>.mdwith that rule inplace produces no load, from a cold start, with the memory read issued before
any in-root read.
!negation is silently ignoredThe other obvious workaround — keep a broad pattern and subtract the parts you
do not want — also fails, and fails silently:
Reading
<project>/probe2/excluded/b.mdloads the rule anyway. The negationhas no effect and produces no warning. Both files were inside the project root,
so this is independent of the boundary issue in the original report — it is a
second silent no-op in the same feature.
This matters for the workaround path: with out-of-root matching unavailable, the
remaining option is a rule with no
paths:key at all, which then loads intoevery session in every project. The natural way to claw back some scoping is a
broad pattern minus exclusions, and that does not work either.
Inferred shape, from behavior only
Taken together —
..-relative targets never matching, absolute patterns nevermatching, and project-scoped rules working normally on the same reads — the
observable behavior is consistent with the touched file being resolved to a
path relative to the project directory before patterns are applied, with
anything that cannot be expressed that way dropped rather than reported.
I want to be clear that this is an inference from black-box testing, not a claim
about the implementation.
Suggested minimum fix
Even without changing the matching semantics, surfacing the drop would remove
most of the cost here. A pattern that cannot match anything for structural
reasons — resolves outside the root, is absolute, or is a negation that is not
honored — could warn once at load. Today all three are indistinguishable from a
pattern that simply has not matched yet, which is what makes this take several
sessions to characterise.
Environment unchanged: Claude Code 2.1.241, macOS (Darwin 25.5.0).
Confirming the boundary from a different angle, and offering the shape of your fix (1), since I hit the mirror image of this bug and the fix is about ten lines.
I run file-anchored memory injection over PreToolUse, where the same two path forms collide in the opposite direction: a real hook sends an absolute path, while anchors and globs are naturally authored workspace-relative. Matching only one form silently never fires, which is your bug with the operands swapped. What has held is to not choose: build a tuple of candidate forms for the incoming path, and fire a declared glob if it matches any candidate.
The property your report needs falls out of the
exceptbranch: a file outside the root simply contributes one candidate instead of two, and an absolute pattern still matches it. Nothing is special-cased, no rule author has to know which side of the root a file sits on, and existing relative patterns keep working unchanged. That is option (1) at roughly the cost of atryblock, and I think it makes option (2) unnecessary: special-casing the auto-memory directory fixes the one instance and leaves--add-dirroots, sibling repos and worktrees still unaddressable.Two smaller notes:
Your point about read order is the part I would keep loudest. Once-per-session firing makes the negative result invisible if the in-root read happens first, so any test in this feature class has to control ordering. I would not trust a "the rule did not fire" observation from a session that had already read anything matching, in either direction.
On the load-time warning in option (3): it does not need a runtime signal at all. Whether a pattern can ever match is a static property of the pattern plus the configured roots, so it is decidable when the rule is parsed, before any file is read. That makes it a clean startup warning rather than a diagnostic someone has to go looking for after the fact.
Untested here as well, but the
--add-dirquestion you raised is the one I would want answered first, because it decides whether a workaround exists today. #79489 does not make me optimistic.Source for the candidate-forms approach, in case it is useful:
agent/working_context_store/_store.py.@swapnanil
I used restarts of Claude Code to test the boundaries of this. All assertions should be treated as validated in the absence of a proof to the contrary.
Fair, and on re-reading your repro I should not have raised it: step 3 already reads the out-of-root file first and the in-root file second in the same session, which is the paired control I was asking for. The restarts are a second layer on top of a design that was already sound. My apologies for the noise.
To be useful rather than just agreeing, I ran the one thing your report still lists as Not tested: whether a directory added via
--add-dircounts as in-root for glob evaluation. You flagged it as the question that decides whether a workaround exists today.It does not. There is no workaround via
--add-dir.Setup
Byte-identical targets (same sha), same glob, same basename, differing only in which side of the project root they sit on:
Two rules, both
paths: ["**/adddir-probe/**/*.md"], with distinct sentinels: one user-level in~/.claude/rules/, one project-level in<P>/root/.claude/rules/. I tested both because your repro covered the user-level case only.Read order is add-dir target first, project-root target second, so the in-root read serves as the positive control given once-per-session loading.
Result
Replicated across two independent sessions, on 2.1.241:
| Read | Rule loaded |
| --- | --- |
|
<P>/added/adddir-probe/x.md(via--add-dir) | neither ||
<P>/root/adddir-probe/x.md(project root) | both, user-level and project-level |Both reads succeeded,
is_errorunset, identical 4-line content returned.Read off the transcript, not the model's self-report
A loaded rule lands in the session transcript as a record with
type: "attachment"andattachment.type: "nested_memory", carryingpath,contentanddisplayPath. That gives an objective signal for this feature class instead of asking the model what it noticed:No
nested_memoryrecord follows the--add-dirread. Two follow the in-root read, four records later in the same session.One control worth flagging, because I got it wrong first
My first run concluded
--add-dirwas in effect because the read succeeded. That inference is invalid: re-running the same read without--add-diralso succeeded, since--allowedTools Readpermits it either way. Read success proves nothing about whether the flag registered.The control that does work is to have the session print its own environment's additional-working-directories list before touching any file. It came back ending in
<P>/added, which establishes the flag was live in the session where the rule then failed to fire. Without that step the negative is unfalsifiable, which is the same trap that makes this whole bug class hard to characterise.Two things this adds to your report
/tmpas an additional working directory, and on macOS/tmpresolves to/private/tmp, so both targets sat under an add-dir root. Only the one under the actual project root fired. The project-root boundary is the sole discriminator, exactly as your LSP diagnostics result suggests.So the retitle you proposed looks right to me, and option (1) in your original list is the fix that covers
--add-dirroots, sibling repos and worktrees rather than just the auto-memory directory.