Agents Read Files Through the Shell: Where File-Anchored Memory Goes Blind
Memory anchored to a file is usually triggered by the agent's file tools, because those calls carry a clean path. Agents do most of their file access with shell commands instead. Two independent counts, the harness's own release history, and a design that resolves shell commands against the anchors that already exist.
A file-anchored note is a caveat with a path attached: this config is generated, edit the template; this test fails on the build host because of the timezone. It is supposed to reach the agent at the moment the agent touches that file. The natural place to catch that moment is the agent client's file tools (Read, Edit, Write), because those calls hand over a structured file path, and it is where vectr and Claude Code's own rule loader both started. Coding agents read and change a large share of their files with shell commands instead: cat, head, sed -n, grep, sed -i, a redirect, an edit run on another host over ssh. A trigger wired to the file tools never looks at those calls.
The share is not small. Counting my own agent sessions by command prefix, shell calls that start with a file-reading command outnumber Read-tool calls by about three to one, 11,685 to 3,786. Shell writes are fewer than tool writes, 1,543 against 2,648, which still makes them more than a third of the writes counted. A count published on a Claude Code issue in October, from a very different setup, points the same way.
vectr, the memory tool I build, has this gap on its public main branch: a file-anchored note fires on a Read of a config file and stays silent on a cat of it. Claude Code's path-scoped rules had the same gap and have been narrowing it release by release; on current versions a plain single-file read loads them, while pipes, a cd and every shell write still load nothing.
A path trigger should fire on any tool call that touches the path, and it should not assume the file is local. One way to get there without guessing is to resolve each shell command against the set of paths that already carry notes, rather than hunting for strings that look like paths. That design is not in any vectr release, and it has blind spots of its own, which fail just as quietly.
What File-Anchored Memory Is, and Why Hooks Key It to File Tools
A note anchored to a file has one useful moment. At session start it is noise, because the agent does not yet know it will open the file. After the edit it is late. The trigger's whole job is to find the moment just before the agent reads or changes the file.
The usual way to find it is a hook. Claude Code's PreToolUse event runs a command before each tool call, hands it a JSON description of the call, and adds the text the command returns in its JSON output to the model's context. A matcher selects which calls the hook sees, and the hooks reference says what it filters on: the tool name. The description carries tool_input, the call's arguments. For Read, Edit and Write those include file_path. For the Bash tool they include command, the string the shell will run.
That asymmetry is why file-anchored memory gets wired to file tools. A file_path is already a path: no parsing, no chance of mistaking a sed expression for a filename. A command string has to be interpreted first, and interpretation can be wrong. Skipping it looks safe because the cost is invisible. A hook that does not fire writes nothing anywhere.
A house has a motion light wired to its front door. Open the door and the hall light comes on. Most of the household comes home through the garage, which opens into the same hall. The light works every time the front door opens, and the hall is dark most evenings. Nobody complains, because a light that never came on leaves no trace that it should have.
The fix is a sensor in the hall, which responds to someone being there whichever door they used. Where the analogy breaks is the hard part: a hall sensor detects a body directly, and a trigger on a shell call has to work out from a string which file is involved.
Where vectr's File-Anchored Notes Go Blind
vectr stores notes an agent writes while it works and delivers them back through hooks, or through an experimental local proxy for clients without hooks. Its file-anchored kind is called gotcha, and the instructions vectr installs for agents describe it as a caveat that "resurfaces exactly when that file is touched". On the public main branch at commit 726e798 that holds for three tools.
The installer registers one PreToolUse hook with the matcher Edit|Write|Read|Bash (main.py, lines 1164-1175). The comment above it says Read was added because "a file-reading tool has just as deterministic a tool_input.file_path as Edit/Write", and Bash for a different purpose: notes tied to a family of commands, such as a caveat about the test runner. The same hook, with its earlier Edit|Write matcher, is walked through in Claude Code Hooks: A Practical Deep-Dive on Deterministic Agent Behavior.
The hook's handler then splits on the tool name:
tool_name = (event.get("tool_name") or "").strip()
tool_input = event.get("tool_input") or {}
if tool_name == "Bash":
command = (tool_input.get("command") or "").strip()
if not command:
return
payload = {"command": command, "hook_event": "PreToolUse"}
if session_id:
payload["session_id"] = session_id
notes = _fetch_recall(port, payload)
_emit_hook_context("PreToolUse", notes)
return
file_path = (tool_input.get("file_path") or "").strip()
if not file_path:
return
payload = {"file_path": file_path, "kind": "gotcha", "hook_event": "PreToolUse"}
A Bash call forwards its command string and matches command triggers, then returns. The file-anchored lookup, the request with kind set to gotcha, runs only when tool_input has a file_path (the full branch, lines 426-455). The comment above it is accurate: a Bash call "carries no tool_input.file_path at all, only tool_input.command". With no file path there is no anchored lookup, even when the command is cat deploy/config.yaml.
The proxy window and edit retirement
The proxy draws the same boundary. From each request the client sends to the model it builds a window, a bounded slice of the recent conversation, and takes anchors from the tool calls in it using fixed keys: _FILE_PATH_KEYS = ("file_path", "path", "notebook_path") (request_window.py, line 25). The module's docstring states the rule, extraction is "keyed on known tool-input fields only", "never a regex scan of free text for path- or identifier-shaped strings" (lines 7-8). A Bash call has none of those keys, so a cat contributes no anchor. The rule against scanning free text is right for prose, where a path-shaped string is usually a mention. In a shell command, a path-shaped token is usually a file about to be opened.
Retirement follows the same line. Since release 1.7.0, a note delivered because its anchor file was touched stays eligible while the file is only being read, and retires the first time a request's window "shows the file actually edited" (CHANGELOG, lines 805-810). In code, "edited" means one thing: the window records a path as edited only when the tool call's name is in _EDIT_TOOL_NAMES = ("Edit", "Write", "MultiEdit", "apply_patch") (lines 151-159), and the gate keeps every anchored note whose path no edit tool touched in that request from being charged (gate.py, lines 466-473). A sed -i on the anchored file is an actual edit. The window never shows it, so a note first delivered by a Read stays eligible until some edit tool touches the file, which may be never.
Tool call on deploy/config.yaml | Hook delivers the note | Proxy window sees the anchor | Counts as an edit |
|---|---|---|---|
| Read tool | Yes | Yes | No (a read) |
| Edit or Write tool | Yes | Yes | Yes |
cat deploy/config.yaml | No | No | No (a read) |
sed -n '1,80p' deploy/config.yaml | No | No | No (a read) |
grep -n timeout deploy/config.yaml | No | No | No (a read) |
sed -i 's/30/60/' deploy/config.yaml | No | No | No |
echo 'retries: 3' >> deploy/config.yaml | No | No | No |
ssh buildhost cat deploy/config.yaml | No | No | No (a read) |
vectr main at 726e798. A note with an explicit command trigger can still fire on a Bash call, but it fires for the command family (every sed), not for this file.
Counting Agent File Access by Tool: Shell Reads Against Read Calls
The first count is mine, from my own agent sessions, and crude by design. A Bash call whose command starts with a file-reading program (cat, head, tail, sed, awk, grep, rg, jq and similar) counted as a shell read, and a Read call as a tool read. Shell writes were Bash calls using sed -i, tee or an output redirect; tool writes were Edit and Write calls. Records were de-duplicated by unique record id, because a session transcript can hold the same record more than once.
| Through the shell | Through file tools | Shell share | |
|---|---|---|---|
| Reads | 11,685 | 3,786 | 75.5% |
| Writes | 1,543 | 2,648 | 36.8% |
| All file access counted | 13,228 | 6,434 | 67.3% |
My own sessions, counted by command prefix and de-duplicated by record id. Not every shell call counted here names a file.
Let T be the number of accesses to a file through file tools and S the number through the shell. A trigger that reads only file_path can see at most
For reads in this count that is 3,786 / 15,471 = 0.245. For writes it is 2,648 / 4,191 = 0.632. Across both, 6,434 / 19,662 = 0.327: about one access in three.
Two assumptions sit under those numbers. One is that every counted shell call names a file, which inflates S: a jq on piped input names none. The other is that anchored files see the same tool mix as files in general. Nobody has measured that, and I doubt it holds evenly; a config the agent edits often may draw more Edit calls, a log it tails almost none. Read the ratio as the size of the problem, not as the coverage of any one note.
The prefix count misses access in the other direction too: a Python script that opens a config, a build target that regenerates a file, a git checkout that rewrites a tree. I would not defend the third significant figure of any of this. The order of magnitude is the finding, and it says the file tools are the minority path.
An Independent Count of Shell File Access From a Different Setup
The second count is public. JhouCode, who maintains hipocampo, an MIT-licensed memory system built on hooks, posted two comments on anthropics/claude-code#78795, a feature request I opened in July for memory files that load on a trigger. The first, on 7 October, describes a PostToolUse hook with the matcher Edit|Write|Bash that checks each touched path against a table of patterns and delivers the warning for any row that matches.
The second comment, on 12 October, opens with the setup, and the setup shapes every number in it. The client runs from the filesystem root rather than a project folder, and most of the work lives on other hosts reached over ssh. Locally as well, the comment reports, "the agent here reads mostly through the shell": 1,253 Read calls in the last 30 days against at least 3,151 shell commands that open a file. The 3,151 comes from a prefix match, so it is a lower bound, and the ratio is at least 2.5 to 1.
The same comment reports a measurement, taken in August, of Claude Code's loader for nested CLAUDE.md files. From the root every project's CLAUDE.md is nested, so it loads only when something under it is read. Of 196 nested injections across 89 transcripts, 126 followed a Read and 55 a Bash call, and 40 of those 55 never named the path; the comment classes them as re-injections. A plain cd into the folder delivered nothing. The comment adds its own scope limit: a session started inside the repo would load that file at startup, so these figures describe behaviour from the root.
Two counts with different setups and different methods land on the same side: most file reads go through the shell. The second comment's advice for a native feature follows from that. The trigger "should not depend on Read, and should not assume the file is local: match Read, Edit/Write and the command text." Its hook also escalates. Once a warning has been ignored, a PreToolUse hook returns permissionDecision: "ask" before the next change, so the person running the agent has to approve it.
Claude Code's Path-Scoped Rules Have Been Closing the Same Gap
Claude Code ships its own file-anchored memory. A rule file in .claude/rules/ can carry paths frontmatter, a list of glob patterns, and the memory documentation covers the syntax. In August, as #88565 quotes it, the documentation said that "Path-scoped rules trigger when Claude reads files matching the pattern, not on every tool use". Whether a cat counts as reading a file has been settled in public, one release at a time.
Path-scoped rule loading, release by release
| Date (UTC) | Source | What changed or was reported |
|---|---|---|
| 8 Jan 2026 | #16853 opened | Path-scoped rules "are never automatically loaded into context when reading or editing files that match" |
| 19 Aug 2026 | #16853 closed | The isolated failure was paths written as a YAML list, fixed in 2.1.84; a symlinked-path case was fixed in 2.1.198 |
| 21 Aug 2026 | #88565 opened | Rules load only on Read, Write or Edit; a file changed through Bash never loads them, and auto mode instructs Bash |
| 2 Oct 2026 | 2.1.288 | Write and Edit now load rules in their scope "(previously only Read loaded them)" |
| 7 Oct 2026 | 2.1.293 | Rules and nested CLAUDE.md load on "a single-file cat, head, tail, sed -n or grep command in the Bash tool" |
| 13 Oct 2026 | Comment on #88565 | Measured on 2.1.296: what loads rules and what loads nothing |
Issue dates from GitHub; release dates are each release's UTC publication date.
#16853 turned out to be about syntax: its closing comment, from a maintainer, traces the failure to a paths list that never matched. The tool question arrives with #88565, which quotes the auto mode system prompt "verbatim from the binary" of version 2.1.238:
Do your work through the Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find, and make file changes with sed, heredocs, or short scripts, rather than using the dedicated Read, Edit, or Write tools.
In that mode the instructed path for changing a file was the one path on which rules never loaded. The report's example: an infrastructure repo governed its central config with three path-scoped rules, the agent edited the config with a python3 one-liner through Bash, none of the three loaded, and the change shipped with its companion documents in the wrong format.
In January the 2.1.21 release note says Claude was improved "to prefer file operation tools (Read, Edit, Write) over bash equivalents (cat, sed, awk)". By August, auto mode's system prompt said the opposite. Neither change had anything to do with memory, and each moved the share of file access a tool-keyed trigger can see. Whoever edits the next system prompt will not be thinking about your trigger either.
Where rule loading stops on current versions
Releases 2.1.288 and 2.1.293 then moved the harness's loader toward the file. The 13 October comment measured where it stopped, on 2.1.296, with fresh sessions and an InstructionsLoaded hook as ground truth. Rules loaded on Read, on Write, and on "a plain single-file cat/head/tail/nl/bat/sed -n, or grep/rg with a match", including inside a ; or && list. Nothing loaded on "pipes, ||, redirects (2>/dev/null), extra flags (cat -A), cd dir && cat file, grep -r, a grep with no match, and every Bash write".
So the harness has taken the conservative half: a command it can recognise outright as a read of one named file. Anything with a second moving part is left out, and so is every write. For rules I think that is a defensible place to stop. For file-anchored caveats it is the wrong place, because a caveat usually exists for the moment just before the file changes.
The same comment reports a workaround that keeps Bash: a root CLAUDE.md brief asking for one plain read of a file in each folder before working there. In auto mode, over 5 edit tasks run 3 times each, the rule loaded in 5 of 15 runs without the brief and 15 of 15 with it. The comment calls it a "Small sample, and it's a workaround, not a fix".
JhouCode's loader figures predate 2.1.293, so a current version would load on some of those shell reads. None of this reaches hooks. #89716 notes that the permission layer applies Read and Edit deny rules, in the permissions reference's words as the issue quotes them, "to file commands Claude Code recognizes in Bash, such as cat, head, tail, and sed", while a hook gets only the raw command: "The gap this report is about is that hooks cannot reach that recognition." A memory tool that delivers through hooks has to do that recognition itself.
Match the Shell Command Against Anchors That Already Exist
The fix keeps vectr's rule against guessing and applies it to the right object. The command is split into the words the shell would see, and each word is checked against the paths that already carry notes. A word that names no anchor contributes nothing, so the match never has to decide what an arbitrary string means. None of this is in a vectr release. It is a design, and the sketch below is what I ran to test it.
- Tokenize with Python's shlex in POSIX mode with punctuation characters on, so
"my dir/notes.txt"stays one word and|,>,>>and&&split off even when glued to a filename. - Drop operators. For a word containing
=(--config=x,CONFIG=x), also test the part after the first equals sign. - Normalize each word as a path: join a relative word to the working directory and collapse
.and... - Exact-match against stored anchor paths and path-trigger globs. A word that is itself a glob, such as
deploy/*.yaml, is used the other way round: each anchor is tested against it. - Deliver what a file-tool call on each matched path would deliver, in the same hook response.
import os, shlex
from pathlib import PurePosixPath
OPERATORS = {"|", "||", "&", "&&", ";", "(", ")", "<", ">", ">>", "<<", ">&", "<&", "|&"}
GLOB_CHARS = set("*?[")
def shell_tokens(command):
lex = shlex.shlex(command, posix=True, punctuation_chars=True)
lex.whitespace_split = True
try:
return list(lex)
except ValueError: # unbalanced quote: fire nothing
return []
def glob_match(path, pattern):
return PurePosixPath(path).match(pattern) # * stays inside one directory
def anchors_touched(command, cwd, anchors, globs=()):
hits = []
for tok in shell_tokens(command):
if tok in OPERATORS:
continue
words = [tok]
if "=" in tok: # --config=x, CONFIG=x
words.append(tok.split("=", 1)[1])
for word in words:
if not word:
continue
path = os.path.normpath(os.path.join(cwd, os.path.expanduser(word)))
if path in anchors or any(glob_match(path, g) for g in globs):
found = [path]
elif GLOB_CHARS & set(word): # cat deploy/*.yaml
found = sorted(a for a in anchors if glob_match(a, path))
else:
found = [] # names no anchor: contributes nothing
for p in found:
if p not in hits:
hits.append(p)
return hits
In the hook, the Bash branch would forward the command along with the event's cwd, which the hooks reference defines as the "Current working directory when the hook is invoked" and which vectr already records for each shell call it logs (hook_cli.py, line 343). The match belongs in the daemon, which holds the anchor set and already tokenizes commands with shlex for its command triggers (app/cmdnorm.py). The Bash branch already makes one request per call, so this adds a field, not a round trip. The proxy is harder: a request to the model carries no working directory per tool call. It can match absolute paths exactly and root relative ones at the workspace, as vectr's path triggers have done for relative inputs since 1.11.0 (CHANGELOG, lines 400-404), which is right while the shell sits at the root and wrong after a cd.
Let A be the set of anchored paths, and let a command split into tokens t1 ... tk. The literal part of the match is
Here norm is step 3. The anchor set is small and known in advance, so the cost is k set lookups per command whatever its size, plus a test per path-trigger glob, and a test per anchor for a token that is itself a glob. A path regex has a harder question to answer for every token: is s/30/60/ a path, is 1,80p, is /dev/null? Every wrong answer becomes a lookup or a fire. Exact matching never asks. A false fire needs a token that normalizes to an anchored path in a command that does not open the file, like echo deploy/config.yaml or git log -- deploy/config.yaml. Both mention the very file the note is about, which is the cheapest way to be wrong.
Regex rows and changed-file detection, compared
Two other designs are public. hipocampo matches each row's regular expression against the file path or the raw command text (alvo_do_hook in gatilho.py), and its docstring is candid about the price: "The false positive (grep router.py) is accepted and bounded by the once-per-session marker." #88565 proposes that after a Bash call the client find which files changed on disk and run the same paths matcher over them; a reply on that issue points out that "a changed-files detector misses cat entirely".
| Design | Shell reads | Shell writes | Fires before the call | Files a script opens | False positives come from |
|---|---|---|---|---|---|
file_path only (vectr main) | No | No | Yes | No | Nothing |
| Pattern regex over the command text (hipocampo) | Yes | Yes | Warning after, escalation before | No | Any text that matches a row's pattern |
| Changed files after the call (#88565 proposal) | No | Yes, local | No, one call later | Yes, if it writes | Anything else that changes the tree during the call |
| Token match against known anchors | Literal paths | Literal paths | Yes | No | Commands that name an anchored path without opening it |
The regex design has one real advantage over the token match: a row pattern can match a path inside a quoted remote command, which a token match sees as one word.
What a Token Match Catches in Shell Commands, and What It Misses
I ran the sketch above against the commands below, with the working directory /repo and two anchors: /repo/deploy/config.yaml and /repo/my dir/notes.txt.
| Command | Result | Why |
|---|---|---|
cat deploy/config.yaml | Fires | Literal relative path |
grep -n timeout deploy/config.yaml | head | Fires | The pipe is its own token |
cat deploy/../deploy/config.yaml 2>/dev/null | Fires | .. collapsed; 2, > and /dev/null are separate tokens |
sed -i 's/30/60/' deploy/config.yaml | Fires | Same path as a read |
echo 'retries: 3' >>deploy/config.yaml | Fires | A glued redirect still splits off its target |
cat "my dir/notes.txt" | Fires | Quotes honoured |
CONFIG=deploy/config.yaml ./run.sh | Fires | The part after = is tested too |
cat deploy/*.yaml | Fires | Anchor tested against the glob |
cat <<EOF > deploy/config.yaml (heredoc) | Fires | Redirect target; the body's lines are tokens too |
ls -la && git status | Nothing | No token names an anchor |
cat $CONF, cat $(git ls-files) | Misses | The path exists only at run time |
cd deploy && cat config.yaml | Misses | Resolved against /repo, not /repo/deploy |
grep -rn timeout deploy/ | Misses | A directory operand names no anchor (deliberate) |
find . -name config.yaml | xargs cat | Misses | The file list is produced at run time |
python build.py | Misses | The script opens the config itself |
ssh buildhost cat deploy/config.yaml | Fires | On the local anchor: right only if both hosts hold the same tree |
ssh buildhost "sed -i 's/30/60/' /srv/app/deploy/config.yaml" | Misses | The quoted remote command is one token |
cat 'unbalanced | Nothing | The tokenizer raises; the match fires nothing |
Output of the sketch above on Python 3.14. Highlighted rows are the limits.
Runtime paths, cd and remote hosts
A variable or command substitution produces its path when the command runs, after the hook has returned, and a find or xargs pipeline builds its file list then too. A script that opens files itself gives the hook nothing to read; only a filesystem-level signal could see python build.py open its config. The directory miss is deliberate. grep -rn timeout deploy/ does read the anchored file, but firing every note under a named directory would bury the one that matters.
An earlier cd in the same command is worse than a miss, because it can resolve to the wrong file: if /repo/config.yaml were also anchored, cd deploy && cat config.yaml would fire that note. Following a top-level cd through a && or ; list is tractable; following one through a subshell, pushd or a function is not. Claude Code's loader, in the 13 October measurement, loads nothing for cd dir && cat file. A heredoc is caught at its redirect target, but its body is tokenized too, so a heredoc that mentions an anchored path fires that note.
Remote paths are where "should not assume the file is local" bites. An unquoted ssh buildhost cat deploy/config.yaml resolves against the local working directory and fires the local anchor: right when both machines hold the same checkout, wrong when they do not. A quoted remote command is one token and misses. Handling remote files means anchors that can name a host, so buildhost:/srv/app/deploy/config.yaml is a different anchor from the local file, and re-tokenizing the remote command of an ssh call to match it only against that host's anchors. Absolute remote paths can be matched that way. Relative ones cannot, because the hook does not know the remote working directory.
A highlighted row raises nothing. The note does not arrive, and the transcript looks the same as a session where no note existed. The file-tool-only trigger fails in exactly that way, which is how its gap went uncounted. Any version of this fix needs a fire count per tool from the day it ships.
Pick or type a command and compare what a file-tool-only trigger sees with what a token match finds. Put a cd before the cat, or use a variable or a quoted remote command, and the match goes quiet without any error.
A small JavaScript port of the Python sketch: quotes, escapes and operators are handled; variables, globs and substitutions are not expanded, which is the point. Cyan tokens matched an anchor exactly; purple tokens are globs an anchor matched.
Should a Shell Write Retire a File-Anchored Note?
The token match says a command names an anchored file, not whether it changed it. sed -n and sed -i name the file in the same position, and cp deploy/config.yaml /tmp/backup.yaml names it as the source of a copy. For delivery the difference does not matter: a caveat is worth showing before a read as well as a write. For retirement it decides everything, because retirement is what stops the note being delivered again.
The two mistakes have different prices. Count a shell call as an edit when it was not one, and the note retires early: the agent backs up the config, the caveat is charged, and it is gone when the real edit comes. Miss a real shell write, and the note stays eligible and may be delivered again on the next touch. The first loses the note. The second costs some context and loses nothing.
hipocampo faced the same choice when deciding whether an ignored warning should escalate, and wrote the asymmetry into its code: "Bash is a READ by default, on purpose: erring low lets one ride pass; erring high blocks the owner's work by mistake. The two errors do not cost the same." (e_escrita in gatilho.py). Its pilot also caught a compound command misread as a write: "A write in one piece contaminated the read in another". It now checks writes per segment of a compound command (escrita_do_item).
For vectr that argues for counting only writes the tokens prove, a sed -i whose file operand is the anchor or a redirect whose target is the anchor, and treating everything else as a read. Whether even those should retire the note is open. A sed -i shows that the file changed, not that the change the caveat was about has been made. Edit-tool calls carry the same ambiguity today and retire the note anyway.
Testing Shell-Aware Anchors and Recounting Fires per Tool
The tests follow the rows above, and each asserts the notes delivered rather than the paths matched:
cat deploy/config.yamlfrom/repoandcat config.yamlfrom/repo/deploydeliver the same note, and so do the absolute form and./deploy/../deploy/config.yaml.sed -i 's/a/b/' deploy/config.yaml,>> deploy/config.yamland the glued>deploy/config.yamldeliver it.cat "my dir/notes.txt"delivers the note for the quoted path. The unquoted form delivers nothing, which is right: the shell would openmyanddir/notes.txt.ls -la && git statusruns no anchored lookup, and the hook's output is byte-identical to the output without the change. An unbalanced quote delivers nothing and raises nothing.cd deploy && cat config.yamlis pinned as a known miss, so a later change to that behaviour fails a test instead of passing unnoticed.
Agent Memory Fails in Three Places and Gets Measured as One argues that once delivery moves into the harness, the never-asked failure "moves into the harness, as a trigger that should have fired and did not". A shell access the file-tool trigger skips is that failure, and an outcome score cannot tell it apart from a note that was delivered and ignored. That is why the recount has to be per tool.
Recounting anchored-note fires per tool
Tests show the match does what it says. Whether it closes the gap is a measurement on the same kind of sessions as the counts of file access by tool, and before the change shell deliveries are zero by construction.
For tool u, let Fu be deliveries of anchored notes and Au accesses to anchored files. The quantity to compare is the delivery rate
for u = Read, Edit/Write and Bash. The trap is ABash. Counted with the token match itself, it agrees with FBash by construction and the rate comes out near 1 whatever the match misses. The denominator needs a looser, independent instrument, such as Bash calls whose command text contains an anchored file's basename. That overcounts mentions, so rBash below rRead mixes real misses with the instrument's own slack. A large gap is still a real one, and the transcripts behind it show which limit caused each miss.
Count the hook and the proxy separately. They resolve relative paths differently, so a gap between their shell delivery rates measures how often the agent works away from the workspace root.
Key File-Anchored Memory on the Path, Not on the Tool
vectr's instructions promise a note that "resurfaces exactly when that file is touched". The implementation keeps the promise for three tools, and in my count those tools carry about one file read in four and under two writes in three.
Claude Code's rule loader spent this year moving from the tool toward the file, and it now stops at reads it can recognise outright. I think that line is right for rules and wrong for caveats, because a caveat is usually about a change, and shell writes were more than a third of the writes counted here. Matching shell tokens against the anchors that already exist covers literal paths without guessing at anything else. What it leaves dark, the variables, the scripts, the cd, the remote edits, should be counted per tool rather than assumed small.
Sources
- vectr source at commit 726e798: agent/hook_cli.py, lines 426-455.
- vectr source at commit 726e798: main.py, lines 1164-1175.
- vectr source at commit 726e798: agent/proactive/request_window.py, lines 1-33 and lines 151-159.
- vectr source at commit 726e798: agent/proactive/gate.py, lines 466-473.
- vectr source at commit 726e798: agent/templates/claude_md.md, line 64 and CHANGELOG.md, lines 805-810.
- JhouCode, comments on anthropics/claude-code#78795, 7 October 2026 and 12 October 2026.
- hipocampo at commit b305c8c: scripts/gatilho.py, lines 140-203, docs/CONTEXTO.md §2.2 and README known limits.
- Claude Code documentation, How Claude remembers your project and Hooks reference.
- anthropics/claude-code issues #16853 (opened 8 January 2026, closed 19 August 2026), #88565 (opened 21 August 2026) with the 13 October 2026 measurement on 2.1.296, and #89716 (opened 26 August 2026).
- Claude Code release notes 2.1.21 (28 January 2026), 2.1.288 (2 October 2026) and 2.1.293 (7 October 2026).
- Python documentation, shlex: simple lexical analysis.