Tool Result Traps
Promoted from the always-loaded ~/.claude/rules/tool-use-patterns.md rule,
whose stub points here. The section bodies below are that rule's verbatim text.
One law across all of them: an empty result, a green exit, and a well-formed
line of output are each claims about mechanics, not about content. Every
trap here produces output that is indistinguishable from a correct answer —
no error, no warning, nothing on stderr — and the damage lands when that
output is used as a verdict: "no duplicate exists", "the rename is complete",
"nothing was lost", "the agent got its input". In one case the diagnostic was
written — and the caller's own 2>/dev/null is what made it disappear.
The recurring fix is equally uniform: control-test any negative that gates an action. Re-run the same command shape against something you know is present. If the control also comes back empty, the tool is broken, not the tree clean. One control run is cheap; a wrong negative is a confidently-reported non-finding.
When to Use This Skill
| Use this skill when... | Skip when... |
|---|---|
| A zero-match or empty result is about to be reported as "clean", "complete", or "none found" | The result is non-empty and you are acting on what it contains |
| Deduping before filing an issue, or concluding nobody reported something | Reading a single known file whose path you just listed |
| Declaring a bulk rename, migration, or sweep finished | Mid-sweep, still transforming matches |
| A verification loop's input set is built from a relative path | The path was resolved absolutely in the same command that used it |
| Batching parallel tool calls whose siblings may exit non-zero | A single call, or a batch of confirmed-present paths |
A Workflow script's agents report thin or generic findings on rich material |
Agents are returning specific, grounded detail |
An rg/git grep result contradicts something you read directly |
Output matches an independent read of the same file |
| Concluding a skill, command, recipe, or binary "does not exist" | You enumerated every tier it could be defined in |
A -g/--glob filter is doing the narrowing, and the name you want is a directory |
The glob is **/<name>/**, or the search is on content not names |
| The command that produced the empty result had its stderr redirected away | You read the command's stderr, or have seen it fail before |
The traps
Grep / rg — -r is --replace, not a bundled short flag
rg's -r takes an argument: it rewrites every match in the output. Bundling
it into a short-flag cluster silently consumes the next letter as the replacement
string, so the tool prints fabricated lines that look like real file contents.
# Wrong — reads as "recursive + line numbers"; actually means --replace=n
rg -rn "yolo" .
./conf/cli_clients/gemini.json: "--n" ← the file says "--yolo"; rg rewrote it
# Right
rg -n "yolo" .
./conf/cli_clients/gemini.json: "--yolo"
The failure is silent and confident: no error, no warning, and the output is
well-formed — it just doesn't match the file on disk. Observed 2026-07 building a
false picture of a config file that was then nearly acted on; caught only because
the doctored line contradicted an earlier direct Read of the same file.
rgis recursive by default — there is no-rto add. The instinct is imported fromgrep -r, and that's the trap.- Never bundle
-rinto a cluster. If anrgresult contradicts something you read directly, suspect the flags before you suspect the file. - Prefer the Grep tool over
rgin Bash: it has no--replacesurface, so this class of error cannot occur.
git grep -E has no \b — the pattern matches nothing, silently
git grep's -E is POSIX ERE, where \b is undefined. A word-boundary pattern
therefore matches nothing at all — no error, nothing on stderr, just an empty
result and exit 1. Exit 1 from grep means "no matches", which is precisely what
a genuinely clean result looks like, so nothing anywhere signals that the pattern
was never valid.
git grep -nE '\bprisma\b' -- package.json # rc=1, 0 lines, stderr empty
git grep -nP '\bprisma\b' -- package.json # rc=0, 19 lines ← PCRE, works
git grep -nwE 'prisma' -- package.json # rc=0, 19 lines ← -w, works
grep -nE '\bprisma\b' package.json # rc=0, 19 lines ← GNU/BSD extension
The trap is that \b does work in plain grep/rg, so the habit is
well-formed everywhere except the one tool that silently drops it. Worst case is
a completion verdict on a bulk sweep: git grep -E '\btrends\b' returning
empty reads as "the rename is complete" when it never searched for anything.
Observed 2026-08 on a 112-file rename; caught only by the known-good control run
that never-fabricate-test-identifiers.md requires.
- Use
-wfor word boundaries ingit grep, or-Pfor full PCRE. Reach for plaingrep -E/rgoutside a git-tracked scope. - A zero-match sweep verdict must be control-tested — re-run the same pattern shape against a term you know is present. If the control is also empty, the pattern is broken, not the tree clean.
- Piping (
| wc -l) masks the exit code entirely, so even the rc=1 tell is gone — see A pipe discards the command's exit code below for the general case.
A wrapped string defeats a source grep — the code is unchanged, the grep says fixed
A string literal split across source lines for line-length reasons exists nowhere contiguously in the file, so grepping the rendered message finds nothing — and that zero reads as "the text is gone, someone fixed it."
Observed 2026-08 (loractl). Checking whether an error still blamed f16 range overflow unconditionally,
git grep -c 'exceeded f16'returned nothing and was recorded as "reworded — task closable." The message was fully intact; the source wraps it as"...an activation exceeded \+f16's range; try f32...", so the phrase spans two lines. A second claim was mis-cleared the same way in the same pass, and both were recovered only by the control test.
- Grep a fragment that cannot straddle a wrap — one distinctive word, or the
symbol that owns the message (
check_step_loss), never the whole sentence. - Then read the hit. The search locates the text; the verdict comes from reading it.
- The control test in the section above catches this class. Run it on any negative that closes a task or reports something already fixed.
A pipe discards the command's exit code — | tail reports success for a failed run
A shell pipeline exits with the status of its last command, so <cmd> | tail,
| head, | grep, | wc -l all throw away the status of the thing you ran. The
result is not merely lossy, it is confidently wrong: a failing build reports
success. This is the general case of the | wc -l note in the grep section above.
Observed 2026-08 (loractl).
just test 2>&1 | tail -25returned exit 0 and was written up as "suite green" — the 0 wastail's. The 25-line window also showed only the trailingcargo test --examplesinvocation (four targets, 0 tests each) while the real results had scrolled past, so both halves of the report were wrong. It nearly gated a commit on an unverified suite. Re-run with a redirect: 384 passed across 79 targets, status fromjustitself.
- Redirect, don't pipe, whenever the status matters:
cmd > out.log 2>&1; echo "EXIT=$?"— then read the file. set -o pipefailfixes the status but not the truncation, and it does not apply to a command the harness runs on your behalf.- A CI watch has the same shape:
gh pr checks <n> --watch | tailreports the watcher's status, not the checks'. Read the states back explicitly (--json name,state) before calling a PR green.
A -g '*name*' glob cannot match a directory name
Two composing rules decide what a glob matches, and neither is visible in the output:
- A glob containing no
/is matched against the basename only — at any depth. This is why-g '*.md'works recursively. - A glob containing any
/is anchored to the full path from the search root, and*does not cross/. Only**spans depth.
So a name you are hunting that is a directory — a skill dir, a package dir, a fixture dir — is unreachable by the glob everyone reaches for first:
tree: skills/sentry-triage/SKILL.md
rg -uu --files -g '*.md' -> skills/sentry-triage/SKILL.md ← basename
rg -uu --files -g '*sentry-triage*' -> (nothing) ← basename is SKILL.md
rg -uu --files -g '*sentry-triage*/**' -> (nothing) ← has '/', now anchored at root
rg -uu --files -g '*/SKILL.md' -> (nothing) ← '*' can't cross the 2nd '/'
rg -uu --files -g '**/sentry-triage/**' -> skills/sentry-triage/SKILL.md ← works
The failure is worse than an empty result. -g '*name*' still matches
sibling files whose basename contains the string, so a real tree returns
sentry-triage-notes.md — one plausible hit. An empty result at least invites
suspicion; a partial one reads as "the search ran fine, the file isn't there,"
and it is the sibling that sells it.
Observed 2026-08 (
repos-claude-config#32): confirming whether a/sentry-triagecommand existed.rg --files -g '*sentry-triage*'over~/repos ~/.claudereturned only a stray.md, which was written into a published doc and a PR body as "no command by that name exists". It existed —ForumViriumHelsinki/infrastructure/.claude/skills/sentry-triage/SKILL.md. Caught only by a known-good control (repo-activity, a skill known to be on disk) returning zero through the identical glob.
- Matching a directory name →
-g '**/<name>/**'. Nothing shorter works. - Prefer
rg -l <pattern>orfind -type d -namewhen hunting a name — a content search has no basename rule to trip over. - Control-test with a name you know is present, through the byte-identical glob. The control is what separates "not there" from "unmatchable".
One search tier is not the search universe
A Claude Code skill or command resolves from three independent tiers, and finding nothing in one says nothing whatsoever about the others:
| Tier | Location |
|---|---|
| User-global | ~/.claude/skills/, ~/.claude/commands/ |
| Plugin | ~/.claude/plugins/cache/<marketplace>/<plugin>/skills/ |
| Project | <repo>/.claude/skills/, <repo>/.claude/commands/ |
The project tier is the one that gets missed, because it is not under
~/.claude/ at all — it ships inside whatever repo happens to be the checkout,
so the same command exists or doesn't depending on where a session is rooted.
That is a live precondition for a scheduled task or a cloud routine: a
project-scoped command resolves only when its repo is the clone.
The same shape recurs wherever definitions are tiered — shell functions vs.
$PATH binaries, just -g recipes vs. a local justfile, global vs. project
MCP servers, user vs. repo git config.
- Enumerate all three before concluding a command does not exist. In this
portfolio,
rg -uu --files -g '**/<name>/**' ~/.claude ~/reposcovers the user and project tiers in one pass (note the glob form — see the trap above). - A marketplace search is not a project search.
gh api search/codeover the plugin repo answers the plugin tier only. - State the tier you searched when reporting a negative. "Not in
~/.claude/" is a fact; "does not exist" is a claim about all three.
A rejected flag looks exactly like "no results"
Any cmd … | jq/grep whose non-zero exit yields empty stdout masquerades as
a legitimate empty result. Worst case is a dedup step: you conclude nobody
reported the bug and file a duplicate.
Live instance: gh search issues --state all is invalid (that flag takes only
{open|closed}; all belongs to gh issue list). It prints usage to stderr,
so a --jq pipeline emits nothing — six consecutive false "no duplicate"
verdicts. Use gh api --paginate "repos/O/R/issues?state=all" + grep instead
(it returns PRs too; discriminate on .pull_request).
The same trap on a write, which is worse. A rejected flag on a command
meant to change something reports nothing and changes nothing, so "no output"
reads as success. Observed 2026-08: gh issue comment <n> --body … --jq .html_url — gh issue comment has no --jq (it prints a URL, not JSON). The
call emitted nothing and posted no comment, so the cross-link between two
freshly-filed issues simply did not exist. Caught only by reading the issue back
afterwards. On a read you get a wrong answer; on a write you get a silently
skipped action you will later report as done.
Worse still: an accepted flag that takes your stdin marker literally. The two cases above at least do nothing. A flag that is valid but means something other than what you assumed writes wrong content, successfully — exit 0, a URL printed, nothing to notice. Observed 2026-08:
# Wrong — --body takes a literal string, so the body becomes "-"
gh pr create --title "…" --body - <<'EOF'
## What
…
EOF
gh's --body is a plain string; only --body-file documents "-" as
stdin (same split on gh pr create, gh issue create, gh pr comment — check
with gh <cmd> --help | grep -- --body). The heredoc was piped to a stdin
nobody read, - became the entire PR description, and the PR rendered as one
empty bullet. Caught only when a human said the description looked wrong.
- Write the body to a file and pass
--body-file <path>(or--body-file -if you really want stdin). This also dodges the multi-line quoting mess — same instinct ascopy-paste-commands.md. - Append
; echo "EXIT=$?"to any one-shot mutatinggh/gitcall whose output you are not otherwise reading. - Verify the side effect, not the exit code, for anything you will tell the
user is complete — re-read the comment, the label, the pushed ref. For a body
you authored, read it back:
gh pr view <n> --json body --jq '.body | length'against a length you expect. A 1-char body is the tell.
Control-test every negative that gates an action. Re-run the same command
shape against a term you know is present; if the control also returns nothing,
the tool is broken, not the result empty. One control run caught all six above.
This is never-fabricate-test-identifiers.md's known-good control, applied to
search.
Your own 2>/dev/null turns a loud rejection into a clean negative
The section above is about a tool that stayed quiet. This one is its mirror, and the difference is the whole point: the tool did its job. It rejected the command, printed a diagnostic, and exited non-zero — and the caller's own redirect threw all three away. No tool-side improvement reaches this: the message was written and then discarded downstream of the tool.
# Wrong — gh pr diff takes no pathspec, and the rejection goes to /dev/null
gh pr diff 36 -- .env.example 2>/dev/null | grep -E "^[+-]"
← empty stdout, reads as "this PR does not touch that file"
# The same command with the redirect dropped
gh pr diff 36 -- .env.example
accepts at most 1 arg(s), received 2 ← rc=1, and it said so all along
Observed 2026-08 reviewing a PR that claimed Closes #29: the empty result was
one step from being reported as "the change was never made". The file was
+34/-5. The exit code was lost as well — a pipeline reports the status of its
last command, so grep's status is what survived, not gh's.
The control test does not catch this class. Re-running the same shape against a file the PR definitely touches comes back empty too — the shape is broken for every input, so the control agrees with the false negative and confirms it. What breaks it open is dropping the redirect, not changing the input.
- Suppress stderr only on a command whose failure mode you have already seen.
2>/dev/nullis a claim that you know what would have been printed. On a first-time shape — a new flag, a new subcommand, a line copied from elsewhere — it mutes the one channel that would say the command never ran. - Re-run without the redirect before believing a negative that gates an
action. One run, and the diagnostic is either there or it is not. Prefer
keeping stderr and reading it (
2>&1) over muting it while you are still learning a command's shape. - Correct forms for the case above:
gh pr diff <N>and filter the unified diff yourself, orgh pr view <N> --json filesfor per-file additions and deletions.
For the worktree-shell wedge, the vacuous path-scoped verification, the
Workflow args JSON-string trap, and the parallel-batch / agent fan-out
hazards, see REFERENCE.md.