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.

The call still runs

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

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.

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

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