tool-result-traps

v2026.09.24

Tool results that mean something other than they look — a pattern that never compiled, a glob that cannot match a directory, one tier searched but reported absent, a rejected flag reading as "no results". Use when an empty result is about to gate an action, land in a doc, or be reported as done.

GitHub
Install command
npx skhub add laurigates/tool-result-traps
Markdown
SKILL.md

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 somethingReading a single known file whose path you just listed
Declaring a bulk rename, migration, or sweep finishedMid-sweep, still transforming matches
A verification loop's input set is built from a relative pathThe path was resolved absolutely in the same command that used it
Batching parallel tool calls whose siblings may exit non-zeroA single call, or a batch of confirmed-present paths
A Workflow script's agents report thin or generic findings on rich materialAgents are returning specific, grounded detail
An rg/git grep result contradicts something you read directlyOutput 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 directoryThe glob is **/<name>/**, or the search is on content not names
The command that produced the empty result had its stderr redirected awayYou 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.

  • rg is recursive by default — there is no -r to add. The instinct is imported from grep -r, and that's the trap.
  • Never bundle -r into a cluster. If an rg result contradicts something you read directly, suspect the flags before you suspect the file.
  • Prefer the Grep tool over rg in Bash: it has no --replace surface, 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 -w for word boundaries in git grep, or -P for full PCRE. Reach for plain grep -E / rg outside 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 -25 returned exit 0 and was written up as "suite green" — the 0 was tail's. The 25-line window also showed only the trailing cargo test --examples invocation (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 from just itself.

  • Redirect, don't pipe, whenever the status matters: cmd > out.log 2>&1; echo "EXIT=$?" — then read the file.
  • set -o pipefail fixes 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 | tail reports 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:

  1. A glob containing no / is matched against the basename only — at any depth. This is why -g '*.md' works recursively.
  2. 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-triage command existed. rg --files -g '*sentry-triage*' over ~/repos ~/.claude returned 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> or find -type d -name when 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:

TierLocation
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 ~/repos covers 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/code over 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 as copy-paste-commands.md.
  • Append ; echo "EXIT=$?" to any one-shot mutating gh/git call 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/null is 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, or gh pr view <N> --json files for per-file additions and deletions.

For the worktree-shell wedge, the vacuous path-scoped verification, the Workflow args JSON-string trap, the control that must exercise the failing part of the pattern (the parseFloat case), and the parallel-batch / agent fan-out hazards, see REFERENCE.md.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

agent-patterns-plugin/skills/tool-result-traps

Default branch

main

Latest commit

1668324

Tree SHA

b2d4cc3