Every long-running coding agent has the same memory problem: the things it has
learned the hard way — “never run aide --update blind,”
“never symlink this store,” “always clean before this
Maven phase” — either get reloaded into context on every single session
(expensive, and it drowns the genuinely relevant lessons in noise), or they get
forgotten the moment the session that learned them ends.
We solved this for our own Claude Code workspace with what we’ve been calling the trap engine. After three searches for prior art we haven’t found this specific shape anywhere else: compiling narrative, human-authored memory notes into a matcher table that fires — and only fires — at the exact tool call the memory is about.
Not to be confused with “AI agent traps”. Since Google DeepMind’s 2026 taxonomy, that phrase means attacks: adversarial content planted in web pages, documents or an agent’s memory to manipulate it, sometimes lying dormant until a later context triggers it. The traps in this post are the opposite: defensive warnings, written by the agent’s own team from lessons they learned the hard way, and delivered at the tool call where the lesson applies.
Our workspace’s agent memory already had a sensible tiering system: a durable cross-host store (Open Brain), a git-tracked per-project store, and a host-local auto-memory cache. All of it gets read at session start. That’s fine until the store grows — every new memory is space that isn’t going to a memory that’s actually relevant to this session, and past a certain size the whole index gets harder to recall accurately, not easier.
But a chunk of what’s in there isn’t “background knowledge I
should always have” — it’s a trip-wire. It’s
only useful in the ten seconds before a specific command runs. aide --update
on an already-compromised host launders the compromise into the new baseline.
That fact is worthless in context for the other 99% of a session where nobody is
touching aide. It’s only worth anything in the moment right
before that exact command is about to execute.
So we split the trap out of “always loaded” and into “loaded on match.”
A memory file gets one blockquote line directly above the paragraph it’s annotating:
> TRAP tool=Bash action=warn pattern=(?i)\baide\b[^\n]*\s(--update|-u)\b
**Never `aide --update` blind** — it records whatever is on disk as correct,
laundering a compromise into the baseline. Verify first, always.
A compiler (trap-compiler.py) walks every memory store, finds
these annotations, and emits a single traps.jsonl — one row
per trap: which tool it watches, what pattern it matches against (a Bash
command, a file path, or — since September — the text being written;
see below), and the exact paragraph to surface if it fires.
A PreToolUse hook checks every tool call the agent is about to
make against that compiled table. On a match, the paragraph gets injected as
additionalContext — right there, right before the dangerous
call, and nowhere else. The rest of the session never sees it.
The memory file stays the single source of truth. Nothing is duplicated
— editing the paragraph edits the trap, because the annotation sits
directly on top of it. Compilation is all-or-nothing: one malformed annotation
aborts the whole build and keeps yesterday’s traps.jsonl,
rather than silently shipping a table that protects less than it did the day
before. And the engine fails open — a bad regex or a corrupt table logs
an error and gets out of the way; it never blocks a tool call by accident.
While researching whether this had already been done, we found plenty of
prior art for the mechanism — several open-source
PreToolUse-hook guardrails for Claude Code already regex-match
tool calls and block or flag dangerous shapes (prompt-injection scanners,
destructive-command blockers). That half of the idea is well established and
we make no claim to it.
What we didn’t find anywhere is the other half: using that same delivery channel for compiled prose, not a fixed security ruleset. A guardrail encodes a rule someone wrote as a rule. A trap encodes a lesson someone wrote as a memory — often with its own history, its own “why,” its own link to a fuller write-up — and the annotation is just metadata bolted onto something that would exist anyway, for anyone reading the memory store directly. The trap and the guardrail sit right next to each other in our system for exactly this reason: traps inform, at the moment they’re relevant; guards refuse, mechanically, for the handful of things that are checkable with certainty. Different jobs, same delivery mechanism.
A second, wider search turned up several things that overlap part of this. None of them does the whole thing, as far as we could find:
.mdc files with
globs, guide)
and Kiro’s inclusion: fileMatch steering files
(docs) load a block of prose
when the agent works on a matching file. That is the closest match to our
file-path traps. The differences: they are rule files written as rules, not
annotations on memories that already exist. Their docs describe no trigger
on shell commands (58 of our 108 traps are Bash traps) and no match on the
text being written. Nothing notices when a rule quietly stops loading.
Kiro also has an auto mode that picks steering files by
comparing their description with the request, which is closer to skills
than to traps.PreToolUse, but around plan mode, skills and sub-agents rather
than by matching the command, and its injections were silently dropped
until September because of a wrong JSON shape. That is a useful reminder
that a hook which adds context needs a check that the model actually
received it. Mohamed Hendawy’s
memory
system with hook-level rules enforcement may be close too; we
couldn’t read it to check.So the combination still looks unclaimed: prose from an existing memory store, compiled into a deterministic table, fired on the exact shell command or the text being written, with a baseline that notices a trap going missing. “We didn’t find it” is as far as we’ll go, not “first.”
The engine has run on every tool call in every session since 5 August. The
engine’s own log counts 256 sessions and
about 14,000 trap deliveries, from a table that has grown to
108 traps compiled out of 16 memory stores (the host-local
cache, the shared workspace store, and one per project repo that has one).
All 108 are still warn. None blocks anything.
Several things were added once the first version met real use:
wasNull(,
CAST(x AS REAL), setAutoCommit(true). Matching
on the path means \.java$, which fires on every Java edit and
teaches the reader to skip trap output. A subject=content
attribute now matches the text the agent is about to write instead.
27 of the 108 traps use it. The compiler rejects it on tools that carry
no written text, because a trap that can never fire looks exactly like
a trap with nothing to say.scope= regex narrows it,
matched against the session’s working directory or the file path.
The first draft of the scope rule was wrong: our unattended triage
sessions run from the workspace root against a clone at a different path,
so a naive scope went silently dark in exactly the sessions it existed
for. Both path shapes are now pinned by tests, including one that asserts
the naive form does go dark. A bad scope fails toward delivering
the warning, never toward silence.> TRAP line. That trap just stops compiling
and nothing complains. So every trap id is recorded in a baseline, and
--verify-baseline fails if one goes missing. The baseline is
split in two: traps whose memory travels with the git repo are in a
committed file, and traps from the host-local cache are in a gitignored
one, so one host’s private memories can’t turn another host
red. The compiler also refuses to regenerate the baseline while
anything is missing, because regenerating it is the one action that turns
an outage into a permanent silence. Retiring a trap on purpose needs an
explicit --force. That check caught a real case today: a
memory retired on 26 September had left its trap in the baseline.action=deny
is parsed, but downgraded to a warning unless that exact trap id is
listed in an allow-list file. The list is still empty. Anything that
deserves to block has become a purpose-built guard script with its own
tests instead.The design assumed a trap fires rarely. Some don’t. The noisiest trap,
a warning that grep -E 'a\|b' searches for a literal pipe, accounts
for about 5,500 of the 14,000 deliveries, close to 40%. Its pattern matches any
grep -E, including correct ones, when the mistake it warns about
needs -E and an escaped \|. The next-noisiest,
the cd dir && … warning, adds another 2,600. A
warning that fires on correct commands teaches the reader to skim past warnings,
which is the failure the engine was built to avoid.
Content traps have their own version of this. Writing this very update
fired three of them, for wasNull(, setAutoCommit(true)
and CAST, because the words appear in the prose above. A content
match can’t tell code from text about code.
The lesson: a trap’s pattern has to match the mistake, not just the tool the mistake happens in. The engine already logs which trap fired and when, so the log is the review signal: sort by fire count, and the top of the list is where tightening pays. That review is the next piece of work.
If you’re building anything long-running on top of an agent harness and you’ve hit the same wall — memory that’s either always loaded or effectively gone — this is the shape we landed on. Happy to talk through the compiler or the hook mechanics in more detail if it’s useful to anyone building the same thing.
Questions, corrections, or you’ve built something similar? Email me at ghdev10000@gmail.com.