[BUG] Path-scoped rules never match outside the project root, making the auto-memory directory unreachable

Status Open
Reported on v2.1.241
Maintainer reply None cached
Activity 5 comments · opened Aug 23, 2026

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:

  1. 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.

  1. 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.

  1. 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.

  1. Create ~/.claude/rules/_probe.md:
---
paths:
  - "**/probe-target/**/*.md"
---
PROBE-SENTINEL: this rule loaded via path glob match.
  1. 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
  1. 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.

  • #79489CLAUDE.md in --add-dir folders 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)

View original on GitHub ↗

5 Comments

blightbow · 7 days ago

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

ty is wired as an LSP server through the astral@astral-sh plugin
(extensionToLanguage: {".py": "python", ".pyi": "python"}). The harness
consumes publishDiagnostics from it and surfaces the result to the model as a
<new-diagnostics> block — independent of the LSP tool, which exposes only
pull operations (goToDefinition, findReferences, …) and has no diagnostics
operation of its own.

  1. Write a .py file containing obvious type errors inside the project root.
  2. Write an equivalent .py file, with equivalent errors, outside the

project root.

  1. Edit both in the same cycle, then issue any unrelated tool call so both flush

at the same boundary.

Observed

| File | ty check on 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 — ty reports Found 4 diagnostics for it on the
command 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 Write nor Edit directly; they arrive at the
next tool boundary, consistent with the uvx ty@latest server cold start plus
a 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:

  • path-scoped rules (original report), and
  • LSP diagnostics (above).

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 that
matters 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).

blightbow · 7 days ago

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:

---
paths:
  - "/Users/<me>/.claude/**/*.md"
---

Reading /Users/<me>/.claude/projects/<slug>/memory/<file>.md with that rule in
place produces no load, from a cold start, with the memory read issued before
any in-root read.

! negation is silently ignored

The other obvious workaround — keep a broad pattern and subtract the parts you
do not want — also fails, and fails silently:

---
paths:
  - "**/probe2/**/*.md"
  - "!**/probe2/excluded/**"
---

Reading <project>/probe2/excluded/b.md loads the rule anyway. The negation
has 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 into
every 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 never
matching, 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).

swapnanil · 6 days ago

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.

candidates = [file_path]                      # as given; absolute, from a real hook
try:
    rel = str(Path(file_path).resolve().relative_to(Path(root).resolve()))
except (ValueError, OSError):
    rel = None                                # outside the root: no relative form exists
if rel and rel not in candidates:
    candidates.append(rel)

The property your report needs falls out of the except branch: 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 a try block, and I think it makes option (2) unnecessary: special-casing the auto-memory directory fixes the one instance and leaves --add-dir roots, 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-dir question 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.

blightbow · 6 days ago

@swapnanil

I would not trust a "the rule did not fire" observation from a session that had already read anything matching, in either direction.

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.

swapnanil · 6 days ago

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-dir counts 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:

<P>/root/adddir-probe/x.md      # project root (cwd)
<P>/added/adddir-probe/x.md     # passed via --add-dir

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.

cwd=<P>/root
claude -p --add-dir <P>/added --allowedTools Read \
  --strict-mcp-config --mcp-config '{"mcpServers":{}}' \
  --output-format stream-json --verbose

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_error unset, 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" and attachment.type: "nested_memory", carrying path, content and displayPath. That gives an objective signal for this feature class instead of asking the model what it noticed:

12 assistant   Read(added/adddir-probe/x.md)
13 attachment  hook_non_blocking_error
14 user        tool_result: file content, is_error unset
16 assistant   Read(root/adddir-probe/x.md)
20 attachment  nested_memory   <- user-level rule
21 attachment  nested_memory   <- project-level rule

No nested_memory record follows the --add-dir read. 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-dir was in effect because the read succeeded. That inference is invalid: re-running the same read without --add-dir also succeeded, since --allowedTools Read permits 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

  1. The boundary applies to project-level rules as well, not only user-level ones.
  2. The session env in my run also carried /tmp as an additional working directory, and on macOS /tmp resolves 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-dir roots, sibling repos and worktrees rather than just the auto-memory directory.