Skip to content

Shadowing

Kevin Straub edited this page Aug 1, 2026 · 5 revisions

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 rule

A server declares what it shadows, in its own config entry:

"codeindex": {
  "command": "",
  "shadow": [{
    "tool": ["Grep", "Glob"],
    "bash": "(?:^|[\\n;]|&&)\\s*(grep|rg|ugrep)\\b",
    "pathIn": ["~/src/bigrepo", "~/src/other"],
    "hint": "That repo is indexed. `mduct call codeindex search query=…` finds it with surrounding context.",
    "budget": 2,
    "refillMin": 30
  }]
}
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.

When a rule fires

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"]
Loading

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.

It is a redirect, not a ban

The hint arrives as a denial, because that is the one channel guaranteed to reach the model. The message says plainly that the tool isn't blocked: run the same command again and it goes through. Roughly half of all grep calls are the right call, and a rule that stopped those would be worse than no rule at all.

The bucket

A per-session counter sounds right until you look at a real session: two days, 816 tool calls. 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.

Write the regex carefully

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 837 Bash calls: the naive \bgrep\b matched 340 commands where only 255 were real searches. The other 85 were 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.

Measure it

mduct shadow
server         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.

Wiring

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.

Clone this wiki locally