-
Notifications
You must be signed in to change notification settings - Fork 0
Shadowing
A prompt block is read once at the start of a session and then loses to habit.
Measured on one real two-day session: 21 calls to a configured code-index server
against 270 grep invocations into the very repositories that server had
indexed, with the index block sitting in context the entire time. The agent
knew. It reached for grep anyway, because grep is a loaded tool and the
other thing is a line of prose.
Shadowing moves the reminder to the one place that reliably works: the moment the other tool is about to run.
A server declares what it shadows, in its own config entry:
| Field | Meaning |
|---|---|
tool |
Exact tool names to shadow, as the harness names them. |
bash |
Regex against a Bash command. |
pathIn |
Gate: only fire where this server is actually useful. Matched against the command text and the directory the command runs in. |
hint |
What the user sees. Put the runnable command here, not advice. |
budget |
Bucket capacity — how many hints may land back to back. Default 1. |
refillMin |
Minutes to refill one hint. Default 0 = never refills. |
mduct knows nothing about what any server does. It matches the declared patterns
and quotes the hint. Delete the server from the config and the whole mechanism
goes quiet.
flowchart TD
C["a tool call happens"] --> M{"tool name or<br/>bash regex matches?"}
M -->|no| P["pass through"]
M -->|yes| G{"pathIn declared?"}
G -->|no| B
G -->|yes| T{"command names<br/>paths that exist?"}
T -->|yes| I{"any of them<br/>inside pathIn?"}
T -->|no| W{"does it run<br/>inside pathIn?"}
I -->|no| P
I -->|yes| B
W -->|no| P
W -->|yes| B
B{"tokens left<br/>in the bucket?"} -->|no| P
B -->|yes| H["show the hint,<br/>spend one token"]
Existing path arguments beat the working directory, because a session's cwd is usually a fixed project root and the command may be reaching somewhere else entirely.
The hint arrives as additionalContext, which rides along with the tool result.
The command executes, and the note is there for the next decision. Nothing is
blocked and no turn is spent.
It was a denial first, on the assumption that blocking was the only channel that
reliably reaches the model. That was wrong, and it cost a wasted turn on every
nudge — including the ones that fired on a perfectly good grep, which is
roughly half of them.
Deliberately no permissionDecision: "allow" either: that would auto-approve a
call that should have asked, and a hint must never widen permissions. A rule
that genuinely has to stop something sets block: true and gets the denial.
The bucket is about attention, not damage: the note costs context and repeating it on every grep is noise. A per-session counter sounds right until you look at a real session — two days, 816 tool calls, and one hint at call five teaches nobody anything.
So it is a token bucket. budget allows a short burst and refillMin brings
hints back while the session continues. With budget: 2, refillMin: 30 a long
working day gets fifteen or twenty openings instead of one. Still nothing next
to 270 greps, but enough to bend a habit.
refillMin: 0 is a plain fixed budget per session, and it is the default.
ps aux | grep x filters the output of a command. It searches no repository,
and a rule that fires on it is pure friction.
Anchor to a command start:
"bash": "(?:^|[\\n;]|&&)\\s*(grep|rg|ugrep)\\b"Measured against one session's 950 Bash calls: the naive \bgrep\b matched 312
of them, the anchored pattern 246. The 66 it drops are ps aux | grep,
docker ps | grep, tail … | grep, tsc | grep -v. Noise, every one of them.
pathIn needs the same care. A session's working directory is often a fixed
project root, so a gate that only looks there fires for commands reaching
somewhere else entirely. mduct handles the common cases itself: it follows a
leading cd, and it prefers the path arguments the command actually names,
filtered by whether they exist. Existence is what separates a target from a
pattern, since grep -rn "a/b" app/ has two slash-bearing tokens and only one
of them is a directory. Keep the gate as narrow as the server's usefulness.
mduct shadowserver nudges converted
codeindex 14 9
23 events — /home/you/.cache/mduct/shadow.jsonl
A nudge counts as converted when a call to that server follows it in the same
session. If converted stays well below nudges, the hint is being ignored and
you are buying friction for nothing. Change the hint, narrow the rule, or drop
it.
The log is plain JSONL, one line per event. Delete it to start a clean run.
The PreToolUse matcher is derived from your rules when you run
mduct hook install claude, so a config without a shadow block costs nothing:
no extra process per Bash call. Change the rules and the next session start
tells you when the installed matcher no longer covers them.