Symptom Gone Isn't Done: A Diagnosis Gate for Claude Code
I’m porting a C library — Graphviz — to TypeScript with agentic tooling, mostly to learn how this kind of work goes when it’s AI-augmented. I chose Graphviz deliberately, because it has a solid oracle: the C binary emits exact layout coordinates for any input, so “did the port reproduce the behavior” is a question I can answer against the oracle — conformant or divergent — instead of by eyeballing renders.
That oracle turns the project into a steady stream of behavioral diffs, and every diff forces the same judgment: accept the difference as a fair cost of running on a different platform, or dig deeper for the real cause. Deciding which is the easy part. Getting the agent to actually dig — to do the hard, repetitive, unglamorous work of bottoming out a cause instead of patching the symptom in front of it — is the part that needed engineering.
A month ago I argued that writing “keep functions short” in your CLAUDE.md is a preference, and a hook that blocks on it is a gate. Prose instructs; tools enforce. This is the same idea pointed at a harder target: debugging.
“Find the root cause, not the symptom” is the single most valuable sentence you can put in a debugging prompt. It also has no teeth. The model reads it, agrees with it, and then — under context pressure, three iterations into a failing test — quietly declares victory the moment the symptom disappears. The symptom going away and the cause being understood are different events, and only one of them is observable from the outside.
The bug that started it
This crystallized on one bug. A few dot graphs with geometrically symmetric edge channels were drawing a single spline arc a point or two off from C Graphviz — the kind of thing you wave away as a rounding wobble. Instead the agent chased it to the bottom, and the bottom was lower than I expected: when the routing channel is symmetric, the two candidate points where the bezier gets split are an exact mathematical tie, and the winner is decided by roughly 1e-14 of floating-point cancellation noise. C breaks that tie with macOS’s Apple hypot, a proprietary implementation that — measured on Graphviz’s coordinate regime — bit-matches no portable hypot: V8’s Math.hypot agrees about 63% of the time, an Arm-style correctly-rounded one 84%, fdlibm 90%, plain sqrt(dx²+dy²) 94%. C isn’t even self-consistent — it splits two mirror-image arcs toward opposite corners.
That is a root cause you cannot fix. It’s a platform constraint below the code, established by a controlled experiment rather than asserted — so the right move was to document it as a known, accepted divergence and not perturb the shared tie-break primitive every routed edge flows through. Symptom-chasing would have “fixed” the one arc and silently regressed others. Note what that investigation produced: a named mechanism, the alternatives measured rather than hand-waved, and a proof of irreducibility. That is what “find the root cause” is supposed to look like — and exactly what an agent skips when nothing makes it the default. So I tried to make the discipline structural.
The change
I added a diagnosis mode to my global Claude Code config. It’s keyed to an event, not a task: the moment an observed discrepancy appears — a failing test, an oracle mismatch, behavior that contradicts stated intent — the rule says you are not done when the symptom is gone. You are done when you can produce this artifact:
- Mechanism — the specific cause, in one or two sentences.
- Origin — the
file:linewhere it starts. - Causal chain — why the observed symptom follows from that cause.
- Ruled out — what you eliminated to get here, and the evidence that eliminated it.
That last field is the one that matters, and I’ll come back to why.
The artifact lives in four places, because one is never enough:
| Where | Why it has to be there |
|---|---|
rules/diagnosis.md |
The authoritative standard, for inline work in the main loop. |
A resident line in CLAUDE.md |
Rules get read task-scoped; a discrepancy is an event, not a task type. The trigger has to be always-in-context or it won’t fire when it’s needed. |
The debugger subagent |
Subagents start blank — they don’t inherit the rule. The artifact is inlined into the agent itself. |
The /fix skill’s diagnose phase |
An incomplete artifact — no origin, or an empty “ruled out” — bounces back for more instrumentation instead of advancing to the fix loop. This is the actual gate. |
Push the trigger, don’t wait to be pulled. Inline the standard everywhere context doesn’t flow. Gate the one workflow where the model is most tempted to skip ahead.
Did it actually change anything?
Here’s where I have to be honest, because a post about root-cause discipline that fakes its own evidence would be its own punchline.
I have a large side project — a TypeScript port of Graphviz — where every autonomous session writes a decision journal as it works. That gives me weeks of dated, append-only records of an agent diagnosing real bugs. So I compared journals from missions run about two weeks ago against missions run today, after the gate went in.
My first expectation was the easy narrative: sloppy before, rigorous after. That narrative is false, and the journals say so. Two weeks ago the agent was already doing real diagnosis. A representative entry from June 16 (full row in the decision journal), untouched by any of this:
Discovery: faithful path declines EVERY plain regular edge. Root cause:
boundMissingchecks onlyED_spl(e)one level deep, but Cgetsplinepointswalks theto_origchain — a bound edge’s spline lives on itsto_origoriginal. Verified by probe:topBoundreturns a VIRTUAL bound whoseto_origholds the spline. Csplines.c:1363 getsplinepointsloopsto_orig.
Mechanism, origin, causal chain, verification against the reference. That’s a clean diagnosis, and it predates the gate by two weeks. The discipline was already there — because I’d been hand-coaching it into mission briefs for a month. Which is exactly the problem: it depended on me remembering to ask, and on whoever wrote the brief spelling it out. Inconsistent, and bottlenecked on a human.
What I actually found when I counted is subtler and more interesting. Across six older journals and five from today:
| Signal | Before (~2 wks ago) | After (today) |
|---|---|---|
| “Root cause” stated | 5 | 6 |
file:line origin citations |
15 | 20 |
| Explicit “ruled out / eliminated” reasoning | 0 | 3 |
| “CONFIRMED by reading both sources” | 0 | 1 |
Finding causes and citing origins were already strong. The thing that was reliably missing was the negative space — the record of what was eliminated. The old journals confirmed what the cause was; they almost never showed the work of proving it was the cause rather than a plausible one. That’s the difference between “I found a cause” and “I found the cause,” and it’s precisely the field I made non-optional.
A diagnosis from today (decision journal), on a bug where Japanese text blew up a layout by 144 points:
Root cause CONFIRMED by reading both sources: the port’s text measurer iterates per UTF-16 code unit vs C per UTF-8 byte. A CJK 3-byte glyph counts as 1 space (port) vs 3 © → ~1/3 width → nodes collapse to minimum → 144pt layout shift. Verified: 下駄配列 oracle width 40.01 (≈12 spaces) vs port 27 (min, ≈4 spaces). Blast radius = 23 non-ASCII corpus graphs.
Both the unit (a 3-byte glyph), the mechanism (byte count vs code-unit count), the chain (width → collapse → 144pt), and the confirmation by reading both implementations rather than guessing from one. The fix was a two-line change. The fix was never the hard part.
The honest caveat
This is not a controlled experiment, and I won’t dress it up as one. The recent missions are bug-fix missions; the older ones are feature ports. Bug fixes invite more diagnosis by their nature, so some of that delta is selection, not causation. The sample is small. If you came here for a clean A/B, I don’t have one.
What I do have is a directional result that landed exactly where the change predicted: the gate didn’t manufacture rigor from nothing — the rigor was mostly already there. It made the one step the model likes to skip, the ruled-out ledger, show up in the record. And it moved that enforcement out of my head and into the config, where it fires whether or not I remember to ask.
That’s the whole reason to prefer a gate over a sentence. A preference is only as reliable as the attention paying it. The point of writing the discipline down as structure is that structure doesn’t get tired three iterations into a failing test. “Symptom gone” stops being a stop condition the moment “symptom gone isn’t done” is something the tool checks instead of something you hope it remembers.