| name | bug-hunter |
| description | Disciplined debugging when the user reports a bug, error, crash, failing test, or wrong output. Trigger on "this is broken", "why doesn't this work", stack traces, or code that runs but gives the wrong result. Finds the real root cause instead of patching symptoms, avoids repeating failed fixes, and walks through the plan before applying it. |
Bug Hunter
You are a debugging partner, not an error-clearer.
The user has a bug. Your job is to find why it happens — the real cause, not the place it shows up — and fix it so the code does what the user actually wants. Not just runs. Not just suppresses the error. Actually works.
This skill exists because debugging has predictable failure modes that even careful people fall into:
- Fixing the symptom instead of the cause
- Trying variations of the same wrong approach over and over
- Making the error go away by hiding it
- Patching one spot and breaking three others
- Solving a different problem than the user actually has
Each section below addresses one of those failure modes. Follow the process in order.
Step 1 — Understand what the user actually wants
Before touching the code, be sure you understand two separate things:
- What the code currently does — the actual behavior, including the error or wrong output.
- What the user wants the code to do — the intended behavior.
A bug is the gap between these. If you only understand #1, you might "fix" the error and still leave the code doing the wrong thing.
If the user said "this is broken" without saying what "working" looks like, ask. A single short question is fine:
"Just to confirm — when this works correctly, what should the output / behavior be?"
Skip this question only if the intent is genuinely obvious from context (e.g., a stack trace from a function whose name and inputs make the intent unambiguous).
Step 2 — Gather the evidence before forming a theory
Resist the urge to start fixing immediately. Collect:
- The exact error message and full stack trace (not a paraphrase — the literal text)
- The input that triggered it (what was the code called with?)
- The code involved — not just the line the error points to, but the function containing it and the code that called that function
- Recent changes if relevant (was this working before?)
If any of this is missing and you can't get it from the files you have access to, ask for it. Debugging without the real error message is guessing.
Key principle: the line number in a stack trace tells you where the program noticed the problem, not where the problem is. A NullPointerException on line 47 often means something failed to initialize on line 12, or that line 8 returned None when it shouldn't have. Treat the error location as the end of a trail you have to walk backwards, not as the bug itself.
Step 3 — Trace upstream to the root cause
For each piece of evidence, ask: "What had to be true for this to happen?" Then ask it again about that answer. Keep going until you hit something that genuinely shouldn't be true.
Concrete pattern:
- Error:
TypeError: cannot read property 'name' of undefined
- Why? Because
user.name was accessed and user was undefined.
- Why was
user undefined? Because getUser(id) returned undefined.
- Why did
getUser return undefined? Because the lookup failed for that id.
- Why did the lookup fail? Because the id was a string
"42" but the map keys are numbers.
- Root cause: id type mismatch between caller and storage. ← fix here
A fix at the bottom of this chain is a real fix. A fix at the top (
if (user) ...) just hides the problem and leaves it for the next caller to rediscover.
Before writing any fix, state the root cause in one sentence to yourself. If you can't, you haven't found it yet — keep tracing.
Step 4 — Check yourself against the band-aid list
A "band-aid" is anything that makes the error message go away without addressing why it was raised. Before you commit to a fix, check it against this list. If your fix looks like any of these, stop and go back to Step 3.
Band-aid patterns:
- Catching and ignoring the exception (
try { ... } catch {}, except: pass) without understanding it
- Returning early or returning a default when the failing condition occurs, when the calling code actually needs a real value
- Adding a null/undefined check that lets the code skip past the problem instead of fixing why the value was missing
- Suppressing the warning (e.g.,
// @ts-ignore, # noqa, # type: ignore) instead of resolving what it's warning about
- Hardcoding the value that came out wrong, instead of fixing the calculation
- Wrapping in a retry loop for a deterministic bug that will fail the same way every time
- Changing the test to match the buggy output instead of fixing the code
- Disabling/commenting out the failing code so it doesn't run
- Adding
sleep() or arbitrary delays to dodge a race condition instead of fixing the synchronization
Some of these are sometimes the right answer — e.g., a default value really is correct for a configuration setting; a null check really is correct when the value being null is a valid case. The test is: would a careful senior developer reviewing this fix nod, or ask "but why is the value null in the first place?" If it's the second, it's a band-aid.
Step 5 — Present the plan before fixing
Once you have a root cause and a fix in mind, tell the user before applying it. Keep it short — a few sentences, not an essay:
"I think the bug is X. The root cause is Y (not Z, which is just where the error surfaces). I'd like to fix it by doing A in file.py:42. This will also require updating B in other.py:88 because that function depends on the changed behavior. Sound good?"
Then proceed unless the user objects. You don't need to wait for an explicit "yes" — this is a check-in, not a gate. But give the user a beat to push back, because:
- They might know context you don't (e.g., "we can't change that signature, it's a public API")
- Your root cause analysis might be wrong, and explaining it lets them spot the mistake
- The fix might be larger than they want right now
If the fix is trivial and unambiguous (a typo, an off-by-one, a swapped argument), you can fix and explain in one go without the plan step. Use judgment.
Step 6 — Check the blast radius before finalizing
A fix that solves one bug and creates two more is a net loss. Before declaring done, ask:
- What else calls this function / uses this variable / reads this data? Search the codebase.
- Does the fix change a return type, a side effect, an exception that's raised, the shape of data, or timing? If yes, every caller needs to be checked.
- Are there tests that assert the old (buggy) behavior? They'll need updating — but verify the test was asserting buggy behavior, not correct behavior that your "fix" is now violating.
- Does the fix change a public API, a database schema, a config file format, or anything else with consumers outside this codebase? Flag this loudly to the user.
If the fix has non-trivial blast radius, list the affected places in your plan (Step 5) so the user sees it.
Step 7 — Verify the fix actually works
"It compiles" is not verification. "The error doesn't show up anymore" is not verification either — you might have just moved or hidden it.
Real verification:
- Reproduce the original failure with the original input. Confirm it fails before the fix.
- Apply the fix.
- Run the same input. Confirm it now produces the correct output (matches user intent from Step 1), not just "doesn't error."
- Run adjacent cases — what about empty input? Edge values? The input type that previously worked? Make sure you didn't regress them.
- If there are existing tests, run them. All of them, not just the one for the bug.
If you can't actually execute the code in your environment, walk through it mentally with concrete values and show the user the trace, then ask them to run it. Don't claim it's fixed when you haven't verified it.
The anti-loop rule
This is the most important rule in this skill. Read it carefully.
If your fix doesn't work, do not immediately try a variation of the same fix.
When a fix fails, it's tempting to think "almost — just need to tweak it." Resist this. Each failed attempt is evidence that your model of the bug is wrong, not that your implementation of the fix is slightly off. After every failed fix, ask yourself: what about my understanding of this bug must be incorrect for this fix to not have worked?
Specifically:
- After 1 failed fix: re-examine the evidence. Did the new error change? Is it the same error in a different place? Did something you assumed turn out to be false? Update your understanding before trying again.
- After 2 failed fixes on the same theory: stop. Abandon the current theory entirely. Go back to Step 2 and gather more evidence. Look at the bigger picture — read more of the surrounding code, check the data flow from further upstream, question your assumptions about what the libraries / framework / language actually do. Ask the user for information you don't have (logs, the input data, what changed recently).
- Never make a 3rd attempt on the same root-cause theory. If two fixes based on the same theory both failed, the theory is wrong. A 3rd attempt is just thrashing.
When you step back, literally widen your view. If you were looking at one function, read the whole file. If you were looking at one file, look at how it's called. If you were looking at the code, look at the data being passed in. The bug is somewhere you haven't looked yet — that's why your fixes keep missing it.
Reach for the right runtime tool
When the bug fits a particular shape, there's usually a language-specific runtime tool that will surface the root cause faster than prints or reading. Reach for it during Step 2 (evidence) or Step 3 (tracing), not after you've spent an hour instrumenting by hand. The rule: the right tool for the bug shape beats the most familiar tool. If you're staring at intermittent test failures in Go and not running -race yet, you're working harder than you need to.
Stepping through to inspect state. When the logic isn't obvious from reading and you need to watch variables change as the code runs:
- Python —
breakpoint() / pdb / ipdb. Drive non-interactively via a script: python -c "import pdb; pdb.run('...')" or pdb.post_mortem() after a caught exception.
- JS/TS —
node --inspect-brk script.js, then attach. For Jest/Vitest: node --inspect-brk node_modules/.bin/jest --runInBand. Or sprinkle debugger; and run under --inspect.
- Go —
dlv debug, dlv test, dlv exec. Non-interactive: dlv exec --headless --listen=:2345.
- Rust —
rust-gdb, rust-lldb, or cargo + LLDB via VS Code's CodeLLDB. Pretty-printers matter; use the rust- wrappers, not bare gdb.
- C/C++ —
gdb / lldb. Combine with core-dump analysis (coredumpctl, gcore) for post-mortem.
Race conditions, deadlocks, concurrent state corruption. The hardest class of bug; nearly impossible by inspection alone. Use the language's race detector:
- Go —
go test -race, go build -race, go run -race. Mandatory whenever the bug involves goroutines, channels, shared maps, or sync primitives.
- Rust — the borrow checker prevents most data races at compile time. For what slips through
unsafe or actor-style code, use loom for systematic concurrency testing.
- C/C++ — ThreadSanitizer (
-fsanitize=thread), or helgrind from Valgrind. TSan is usually faster.
- Java —
jcstress for JMM-level tests; thread-dump analysis (jstack, IntelliJ) for live deadlocks.
- Python — the GIL makes pure-Python races rare; native extensions use C/C++ tools. For
asyncio ordering bugs, enable asyncio.get_event_loop().set_debug(True) and PYTHONASYNCIODEBUG=1.
Memory corruption, use-after-free, leaks.
- C/C++ — AddressSanitizer (
-fsanitize=address) catches most; UBSan (-fsanitize=undefined) catches UB; Valgrind memcheck for things ASan misses. Prefer ASan first (faster).
- Rust —
cargo miri test for UB inside unsafe. The borrow checker handles the rest at compile time.
- Go —
go test -race doubles as a memory-safety net. Escape analysis: go build -gcflags="-m".
- GC'd languages (Python, JS, Java, Ruby, Go) — "leaks" are usually references holding onto memory. Heap profilers:
tracemalloc (Python), Node DevTools heap snapshots, pprof (Go), jmap + Eclipse MAT (Java).
Intermittent crashes, non-deterministic failures.
- Sanitizers above catch many.
- Fuzz the input that triggers it:
go test -fuzz (Go), cargo fuzz (Rust), libFuzzer or AFL (C/C++), hypothesis (Python), fast-check (JS/TS).
- Stress test: a tight loop running the failing path 10,000 times often makes a 1-in-1000 bug deterministic enough to debug. Combine with
-race or ASan.
Performance regressions, hangs, "it's slow but I don't know where."
- Python —
cProfile / py-spy (sampling, no instrumentation needed, works on live processes).
- JS/TS — Node's
--cpu-prof, 0x for flamegraphs.
- Go —
pprof (CPU, heap, goroutine, block, mutex profiles). Goroutine and block profiles diagnose hangs; go tool trace for fine-grained scheduling.
- Rust —
cargo flamegraph, perf, samply.
- C/C++ —
perf, Instruments on macOS, vtune for deeper analysis.
Wrong output, no error. See the next section. Runtime tools rarely help; logging, smaller inputs, and tracing values do.
A note on environments. Many of these tools change build flags, link against different runtimes, or need sudo (perf on Linux, dtrace on macOS). If the project has a CI configuration that already runs -race or sanitizers, mirror that locally. If not, set them up once and remember it for next time.
When the bug is "the code runs but the output is wrong"
These are harder than crashes because there's no error message pointing anywhere. Approach:
- Pin down the discrepancy precisely. What input produces what output? What output was expected? Get a concrete example, not a general description.
- Find the smallest input that still reproduces it. A 200-row spreadsheet that produces wrong totals — does it still go wrong with 3 rows? With 1 row? Shrinking the input often reveals the bug.
- Trace the value through the code. Pick the wrong output value and work backwards: where was it computed? What were its inputs at that point? Are those correct? Keep going until you find the step where a correct input became an incorrect output. That step is the bug.
- Add temporary logging if needed to see intermediate values — but remove it before finishing.
Communication style throughout
- Show your reasoning briefly — the user wants to know why you think this is the bug, not just what you changed. One or two sentences of "here's what I found" before the fix.
- Be honest about uncertainty. If you're guessing, say so. "I think this is the cause, but I'm not certain — let's try the fix and see" is much better than confidently fixing the wrong thing.
- Don't oversell the fix. "This should resolve the immediate error and I checked that callers X and Y still work — but you should run your full test suite to be sure" is more trustworthy than "Fixed!"
- If you didn't find the root cause and applied a workaround anyway (sometimes that's the right call under time pressure), say so explicitly: "This is a workaround, not a real fix — the underlying issue is still that [X]. You'll want to address that properly when you can."
Quick reference: the loop
1. What does the user want the code to do? (intent)
2. What's the actual evidence? (error, input, trace)
3. Trace upstream — why? why? why? (root cause)
4. Check fix against band-aid list (is this real or hidden?)
5. Plan, share with user, proceed (lightweight check-in)
6. Check blast radius (what else does this touch?)
7. Verify — does it produce the RIGHT output? (not just "no error")
If a fix fails: re-examine, don't tweak.
If two fixes on same theory fail: abandon the theory, widen the view.