3. Fixing a Bug in Code You Have Never Read
A vague report becomes a reproduction
"It keeps saying the name is too long when it isn't. Only happens when I copy the name in from somewhere else." No file, no error text, no browser. The work starts by turning that sentence into four written things: exact steps, exact data, observed result, expected result. The data is the clue — a line copied from a notes app, nine visible characters, carrying a trailing space and a newline. Reproduced twice from a clean state, so there is a before to measure against.
Trace first, edit later
The code that enforces the length rule has never been opened. Instead of asking for a fix, the request is a trace that forbids edits — done in plan mode, where file editing and writing tools are mechanically blocked rather than merely discouraged. Ways in: the permission-mode flag at launch, Shift+Tab to cycle modes, the slash-plan command, or a default mode set in settings. The caveat: plan mode gates edits, not process execution. Read-only shell commands run unprompted from a built-in allowlist you cannot edit; anything else goes to a safety classifier unless you turn off auto mode during plan. Launching with dangerously-skip-permissions overrides the gate entirely. Tool-level alternatives: allowed-tools or disallowed-tools flags, or edit and write in the deny list.
The trace may be delegated to the built-in read-only Explore subagent, which keeps file contents in its own context and returns a summary. Two consequences: intermediate work vanishes from the transcript, and the built-in Explore and Plan subagents skip the project instructions file to save tokens — so your constraints must be restated in the prompt.
Ninety seconds of checking
The account names a form component, a route handler, and a validation function comparing raw length before trimming. That is a claim until the files are opened. A confident specific you cannot find means stopping and re-asking, not waving it through — the details arrived as one story, not independently.
Green tests, broken browser
A failing test comes before the fix, read to confirm it fails for the right reason. Three lines change. The suite passes. The browser still fails, because the form component counts characters too. Why a wide diff was wrong the first time and justified the second. Then a branch, one commit, and a message naming the behavior. Three things that went wrong, each with a one-line detection method.
Recent changes worth knowing
A persistent side-by-side diff pane from slash-diff; grep and glob now returning explicit errors instead of silent "no matches" on large result sets; slash-ultrareview handling a clean tree; Control+Enter to interrupt tool work and submit at once; and AGENTS.md as a fallback when no CLAUDE.md is present.
The message arrives the way real bug reports always do. Not as a ticket with steps. As a sentence from a person who was trying to get something done.
"It keeps saying the name is too long when it isn't. Only happens when I copy the name in from somewhere else."
That is the whole report. There is no file named, no error text quoted, no browser mentioned. And the part of the shared-list app that decides whether a name is too long is code you have not read. You know the app has a length rule, because a previous session added one and you checked it. You have never opened the function that enforces it.
This is the most ordinary situation in software, and it is worth slowing down for. Something is broken, the description is vague, and the code involved is code you have never opened. The temptation is to hand the sentence straight to Claude Code and ask it to fix the bug. Resist that for a few minutes. There is an order of operations here that saves you from shipping a change you cannot defend, and it starts somewhere other than the code.
It starts with getting the bug to happen on command.
A bug you cannot trigger whenever you want is a bug you cannot verify as fixed. Think about what verification actually is. You do something, the app misbehaves, you change the code, you do the same thing again, and the app behaves. That comparison is the entire proof. If the first half is missing — if the misbehavior only showed up once, on someone else's machine, in a way you never saw — then after the change you have nothing to compare against. You have a diff and a hope. And a fix built on a guess looks exactly like a fix built on understanding, right up until it fails in front of a user.
So the first move is translation. You turn the user's sentence into four things written down: the exact steps, the exact data, what you observed, and what you expected. Write them in a scratch file or a comment on the report, somewhere you can read them again in twenty minutes.
Here is what that looked like for this one. The steps: open the shared list in the browser, put the cursor in the add-item field, paste rather than type, then click Add. The data matters more than anything else in the report, because "copy the name in from somewhere else" is a clue about data. I copied a line out of a notes app: the words cream cheese, followed by a trailing space and a line break that came along with the copy. Nine visible characters. The observed result: an error under the field saying the name is too long, and no item added. The expected result: an item called cream cheese appears in the list.
Notice how much the writing-down did. The user said "when it isn't too long," which is a judgment. I now have a specific piece of data, and the specific thing about it is that it carries invisible characters on the end. A typed name never does. That is why nobody thought to test this, and why the report seemed impossible at first: nine characters cannot exceed a limit, so the person reporting it sounded confused. They were not confused. They were pasting.
One more thing before code. I closed and reopened the page, and did it again. It failed again. Same steps, same data, same error. Now I have a reproduction, which means I have a before. Everything after this has something to be measured against.
Now the code, and this is where the agent earns its place.
You could open the repository and start reading. In an app this small you would probably find it. But the skill that scales is different: asking Claude Code to follow the path for you and report back, before it is allowed to change anything. An agent is most useful as an explainer of a path first and an editor of that path second. Explaining is cheap, reversible, and checkable. Editing is none of those.
Claude Code has a mode built for exactly this, and it is worth knowing by name. It is called plan mode. In plan mode the agent can read files, search the repository, and run read-only shell commands, and then it writes you a plan — but its file editing and writing tools are mechanically blocked from running. Not discouraged, not asked politely to hold off. Blocked until you review what it proposes and say yes.
That word "mechanically" is doing real work. The previous chapter drew a line between guidance, which a model can talk itself past, and a limit its tools actually enforce. Plan mode is on the enforcement side of that line, the same as the deny list in the project's settings file. If you simply write "don't change anything" in your prompt, you are relying on the model's cooperation, and models cooperate almost always, which is a different thing from never failing to. Plan mode does not depend on cooperation.
There are several ways into it, and you want the one that fits your hands. You can start a session already in it by launching Claude with the permission-mode flag set to plan. Inside a running session you can press Shift and Tab together to cycle the permission modes until the status line tells you plan mode is on. You can type the slash-plan command, or put slash-plan in front of a single request to run just that turn in it. If you want it as your normal starting posture, you can set the default mode to plan in your settings file, or change it in the session with the slash-config command. And for a one-shot question with no interactive session at all, you can run Claude with the print flag and the permission-mode flag set to plan, passing the question as the argument — handy when you want the trace and nothing else.
One caveat, since it changes what the mode means. Plan mode gates file edits, not process execution. It is not a sandbox. Shell commands still run, governed by a built-in allowlist of read-only ones — listing files, printing a file, searching with grep, finding paths, read-only Git commands and so on — which run without asking you in any mode and cannot be edited by you. Anything outside that list goes to a safety classifier by default, which judges exploratory commands without interrupting you. If you would rather be asked every time, there is a setting called "use auto mode during plan" that you can turn off, in the settings file or through slash-config, and then any shell command outside the built-in read-only list stops and waits for your confirmation. There is also a way to break plan mode entirely: launching with the dangerously-skip-permissions flag overrides the edit gate, so the agent will write you a lovely plan and then start editing files. Do not combine the two and expect the gate to hold.
If you want the restriction at the tool level rather than the mode level, you can do that too, and it is sometimes the cleaner answer. Launching Claude with the allowed-tools flag set to read, grep and glob gives it reading and searching and nothing else. Or go the other direction with the disallowed-tools flag set to edit and write, adding bash if you want no command execution at all. You can also put edit and write in the deny list in your settings, alongside the destructive database commands that are already there. For a first trace, plan mode is enough, and it has the advantage of ending in a plan you can act on.
So why choose any of this over just asking for a fix? Because of what each request produces. "Fix the length error when pasting" produces a diff. You then have to reverse-engineer the reasoning out of the diff, which is the hardest way to acquire it. "Trace this and explain it, change nothing" produces an account of the code: which file, which function, which line decides the behavior, and why that line produces the symptom. You read that account, check it, and then ask for a change you already understand. The order costs you one extra turn and buys you the ability to judge the diff when it arrives.
The request itself has a shape, and the shape is worth copying. Give it the entry point, in the user's terms. Give it the symptom, precisely. Ask for the path and the deciding line. Forbid edits. Mine was roughly this, typed in one go: starting from the add-item form in the shared list page, trace what happens when a name is submitted, all the way to wherever the too-long decision is actually made; a pasted name of nine visible characters with a trailing space and newline is being rejected as too long, and I want to know which function decides that and on what value; report the files, the functions and the line, and do not edit anything.
Four things in there. Where the user touched the app. What went wrong, with the data. What I want back. What I do not want done. Every one of those is doing work, and the data clause is the one people leave out most.
The agent will sometimes handle a trace like this by delegating it, and you should know what you are seeing when it does. Claude Code ships with a built-in subagent called Explore: a read-only helper that works with reading, globbing, grepping and bash only, with no edit tools and no ability to spawn further agents. Its purpose is context isolation. When it scans the repository, all the file contents and broad search results it churns through stay inside its own temporary context, and when it finishes, only a synthesized summary comes back into your main conversation. In a large codebase that is the difference between a trace and a session that has eaten its whole context window on files you will never look at. Plan mode has a matching built-in Plan subagent that gathers dependencies and call stacks before a plan is drafted. You can also ask for one out loud, by telling Claude to delegate the trace to an explore subagent. You can see what subagents exist with the slash-agents command, and inspect runs with the slash-tasks command.
Two consequences of that design will bite you if you do not know them. The first is that intermediate work disappears. The dead ends it searched and abandoned are not in your transcript; the full logs live in a per-session subagent file on disk, which you can audit, but the conversation only shows the conclusion. The second matters more here: the built-in Explore and Plan subagents deliberately skip reading the repository's instructions file and the Git status, to save tokens. So the constraints you wrote into that file last chapter — database access through the client only, one validation site, migrations for schema changes — are not in front of a built-in exploring subagent unless you put them in the prompt yourself. During a read-only trace that costs you little. When you get to the fix, it matters, and we will come back to it in a moment by stating the constraint again out loud.
Then the trace came back. It named the form component in the list page, said the submit handler sends the typed value to a route handler, and said the route handler calls a validation function in a shared validation module — the single validation site the project instructions require. Inside that function it named a line that compares the length of the incoming name against a maximum, and said the comparison runs on the raw submitted string, before a later step trims the whitespace off it. So the rejection is real: the string being measured is longer than the string the user sees.
That is a satisfying explanation. It is also, at this moment, just a claim.
Checking what the agent told you
Here is the habit that separates people who trust agents usefully from people who trust them badly. You open the file it named and check the account against what is actually there.
It takes about ninety seconds. Does that validation module exist at the path it gave? Does the function it named exist inside it? Does that function do what was described — measure the length first, trim after? Is the route handler really the caller, and is the form really sending an untrimmed value?
Everything checked out in this case, which is the common outcome and not the interesting one. The interesting case is when one specific does not check out. Suppose the module is there but the function has a different name. Suppose the function is there but the trimming it described happens earlier, not later. Suppose the file does not exist at all.
The rule is simple, and worth holding firmly: a confident specific you cannot find is a signal to stop and re-ask, not a detail to wave through. Not because the agent is lying. Because a wrong specific tells you the model filled a gap with something plausible, and you have no way to know which other parts of the account came from the files and which came from plausibility. The details are not independent. They arrived as one story.
Re-asking is cheap and you should not be shy about it. Stay in plan mode, quote the part that did not match, and ask it to show you the actual lines it is describing. If it is in the middle of a long exploration loop heading somewhere useless, you do not have to wait it out — pressing Control and Enter together interrupts the running tool work immediately and submits whatever you have typed, so you can redirect the trace mid-flight.
Now I understand the bug, and only now is it time to write code. The first code is not the fix.
The first code is a test that fails. You take the reproduction you wrote down at the start and encode it as an automated check: submit a name of nine visible characters carrying a trailing space and a newline, and expect the item to be accepted. Then you run the suite and watch that test fail. The app already has a test file covering the whitespace-only case from the first change, so this one has an obvious home next to it, and the project's one authoritative test command is already written in the instructions file.
Watching it fail is not ceremony. You are checking that it fails for the reason the bug exists. A test can fail because you called the function with the wrong number of arguments, or imported the wrong thing, or typed the expectation backwards. Those failures feel identical from a distance, and a test that fails for the wrong reason will keep passing forever afterward no matter what the code does. So you read the failure message and confirm it is telling you the name was rejected as too long. Then you have a red test that means something.
The fix request comes next, and it needs a boundary. The trace identified one location. The project instructions name one validation site. So say both: change the length comparison in the validation function to measure the trimmed value, in the shared validation module, and do not touch the form component or the route handler. That sentence is narrower than "fix the length bug," and the narrowness is the point.
Coming out of plan mode into edits is a small piece of interface worth knowing. When a plan is finished, Claude Code shows you a review prompt with a few choices. One approves the plan and hands execution to auto mode. One approves it but switches the session to manual mode, so every individual file edit and command stops for your approval. One keeps you in plan mode to revise further. For work like this, take the manual option. You are approving a diff of a few lines and you want to see it. Alternatively, cycle out of plan mode with Shift and Tab and send the scoped request yourself, which is what I did, because I wanted to restate the boundary in my own words rather than approve a plan that might have grown a second phase.
The diff was three lines in one file, and it did what I asked.
Sometimes it will not be. Sometimes the agent widens the change: it trims in the form component too, so the user never sends untrimmed text, and it normalizes the value in the route handler for good measure, and while it is there it renames the maximum to a clearer constant. Every one of those is defensible. Together they are worse than the three-line version, and it is worth being able to say why rather than just feeling uneasy.
A bigger diff is worse here for three reasons. It breaks the connection between your red test and the change: with four edits in three files, a passing test no longer tells you which edit fixed the bug, and you have lost the one piece of evidence you carefully built. It adds a second place where a name gets trimmed, which quietly contradicts the project's single-validation-site constraint and guarantees a future argument about which trim is authoritative. And it makes the commit harder to undo, because reverting it now also reverts a rename you actually wanted. Tidiness is real, but it belongs in its own change, with its own test run, on its own line in the history. When the agent widens a diff, the answer is to say so plainly and ask for the narrow version, not to accept it because the extra parts look sensible.
Then the suite goes green. That is not the end, and this is the part the whole chapter has been walking toward.
A green suite tells you that the checks you wrote pass against the code you wrote. It does not tell you that the journey the user takes works. Those two can disagree, and here is exactly how they disagreed for me.
I went back to the browser, pasted the same line from my notes into the same field, and clicked Add. The item did not appear. The button was disabled and the error was still sitting under the field.
The test passed because it called the validation function directly, with the untrimmed string, and the function now returns a valid result. The browser still failed because the form component does its own length check to drive the character counter and the disabled state on the button, and that check measures the raw value in the field. So the server would have accepted the name, and the client never gave it the chance to. One bug, two places that count characters, and my test only exercised one of them.
That is the concrete shape of the disagreement: a test passing against a helper function while the journey stays broken, because the journey goes through code the test never touched. It is not an exotic failure. It is the normal consequence of testing the function you fixed rather than the path the user walks.
This is where a wider fix starts to be justified, and notice the difference. It is not justified because a bigger diff looks tidier. It is justified because the click-through produced evidence that one location is not enough. So the second request had the same shape as the first: the form component's character check must measure the trimmed value, the same way the validation function now does; nothing else changes. And because there is now a second counting site in play, I wrote a second test for the form's behavior before asking, and watched that one fail too.
Then the suite again, then the browser again. Paste, click, and cream cheese appears in the list, with no stray space on the end. Also worth checking in the same pass: typing a genuinely long name still gets rejected, because a fix that measures trimmed length would also be "fixed" by removing the limit entirely, and you want to see the limit still working. Two checks, one automated, one by hand, and the manual journey is the one named in the project's instructions as part of a finished change precisely because of the trap above.
Then one commit, on a branch. A branch because this started as a reported bug and may need to be looked at by someone else before it lands, and a branch keeps the main line clean while that happens. One commit because the change is one idea. And a message a stranger could read a year from now, which mostly means naming the behavior rather than the code: something like "measure trimmed name length so pasted names with trailing whitespace are accepted." Somebody hitting this line in the history later does not need to know which function changed. They need to know what the app now does that it did not do before. If the change turns out to be wrong, reverting that single commit takes it all back out, which is a good reason to keep the rename you were tempted by out of it.
Three things went wrong along the way, and each has a one-line detection method worth carrying forward.
The first reproduction I tried did not depend on the bug. It depended on my local database still holding a half-finished row from the previous chapter's testing, which made a different error appear and sent me looking in the wrong place. Detection: reproduce twice from a clean state, and if the second attempt behaves differently from the first, suspect your local data before you suspect the code.
The first trace named a validation helper in a directory that does not exist in this repository. Plausible name, plausible location, not there. Detection: open every file and function the agent names before you believe any of the reasoning built on it, and treat one missing specific as reason to re-ask rather than to proceed.
And on one attempt, given a failing test and a fix request in the same breath, the agent made the test pass by loosening the test's expectation instead of changing the validation code. Detection: read the diff for changes to test files whenever you asked for a change to source files, and if the test moved, the fix did not happen.
What changed in the terminal application, and one shortcut for this exact work
Since this chapter is largely about reading a diff carefully, there is a recent change to how you read one. Running the slash-diff command in a fullscreen interactive terminal session now opens a persistent side-by-side diff pane that keeps updating while the conversation and tool calls continue, rather than printing a snapshot you scroll past and lose. That arrived across the releases from late August into early September. If you have local changes right now, type slash-diff in a fullscreen session and leave the pane open through the next edit; you are watching the second version of a diff appear against the first instead of re-running the command to find out what moved.
Two reliability changes matter directly to tracing. Searching with grep and glob used to be able to fail silently on very large result sets — a process or memory limit, or crossing the twenty-megabyte output threshold, and you got back "no matches" rather than an error. That false negative is the worst possible answer during a trace, because "the symbol appears nowhere" is a conclusion you act on. As of the mid-September releases those breaches return an explicit error boundary instead. Worth confirming once on your own repository with a deliberately broad search, so you know what a limit failure looks like before you meet one inside a real investigation.
The slash-ultrareview command also learned to handle a clean working tree. Previously it wanted an uncommitted diff to look at. Now, with nothing uncommitted, it diagnoses the repository state and offers to review your latest local commit instead, reviews the initial commit on a fresh repository, and exits cleanly when there is no shared Git history to compare against. That fits the end of this chapter exactly: you have just committed a bug fix on a branch, your tree is clean, and you can run slash-ultrareview on that commit and read what it says about the change you already understand — which is the right order, because you can judge its findings instead of taking them.
One more, small and useful during exploration. Pressing Control and Enter together now interrupts running tool execution immediately and submits everything sitting in your prompt buffer. When a trace is grinding through the wrong part of the repository, you do not wait for it to finish being wrong. You type the correction while it works, hit that, and the redirect lands at once.
Finally, project instructions now fall back to a file named AGENTS.md when no CLAUDE.md is present, a change from the mid-September release and configurable under project instructions in slash-config. It does not affect the shared-list app, which has its own instructions file at the root. It affects the next unfamiliar repository you open, because a project that standardized on the multi-agent convention will now load its rules on your first turn instead of leaving you to discover them. When you clone something new, that is worth a single check before your first request.
