Reading a diff and hunting a regression are different skills, and only one of them has a weekly exercise. Agents are worst at hunting, because a hunt searches over states you must observe, not over text. The skill also decays fastest in a codebase where nobody on the team typed most of the history. Thirty minutes, one real regression, a known answer, and the agent closed.
github.com/tacoda/fulcorum-bisect-drillThree things happened at once, and they all push the same direction.
First, history got faster and less familiar. A team shipping agent-assisted changes produces more commits. Fewer of those commits came from a person who still remembers writing them. So the old fallback for a regression, asking whoever wrote it, has a lower hit rate every quarter.
Second, debugging is the task language models are worst at, and the reason is structural rather than temporary. Writing code is generation. Finding a regression is a search over states you have to observe: run it, look, narrow, run it again. Ask a model where the defect is, and it produces a confident, well-argued, plausible location. That location is exactly the failure mode it has everywhere else, but here it can cost you an afternoon.
Third, and worst, the skill only gets practised during incidents. Nobody bisects for fun. So the reps arrive at the least convenient moment: under time pressure, in front of an audience. An incident is a poor classroom.
Moving the reps off the incident. Thirty minutes a month, a real regression with a known answer, no stakes. The night it matters, the procedure is already boring.
The timeboxes matter. Most failed hunts fail by spending twenty-five minutes on the first phase and calling it thinking.
If there is no reliable reproducer at 0:08, stop the drill and build one with the time left. A hunt that starts from an unreliable reproducer converges on a random commit and reports it with total confidence. That outcome is worse than not finishing, so learning to abort at this point is itself a result worth having.
Two methods. The first costs nothing and is better material; use the second only when you have to.
Find a commit in your history that fixed a real regression. Call it F. Then the hunt
is the range ending at F^, the commit just before the fix. F's own
message, or its linked issue, already records the answer. Setup cost is close to zero, and the defect has
the shape of your codebase's own defects. The debrief also comes with a story about what the
defect cost the company.
# F is the commit that fixed it. Hand the hunter a clone at F^
git clone . /tmp/hunt-2026-08
git -C /tmp/hunt-2026-08 checkout -B main F^
# the hunter gets exactly this and nothing else:
"Discount codes stopped applying to orders over $100.
Worked in the 2.3 release. Find the commit."
When you have no suitable history, insert a defect behind two hundred commits of real work. Branch at an old commit, add the defect, then replay every later commit onto that branch.
OLD=$(git rev-parse main~200)
git checkout -b drill $OLD
# edit one file, one behavior, no new tests
git commit -am "Adjust threshold handling"
# replay OLD..main on top of the planted commit
git rebase --onto drill $OLD main
Pick a file that later commits almost never touch, or the rebase will fight you. The seeding rules from the mentor's playbook apply unchanged: one defect, and it must survive the suite. The correct version should also sit a line or two away from the wrong one.
Never say how far back it goes. A hunter who knows the range is two hundred commits, not two thousand, starts reasoning about which commits look suspicious. Bisect exists precisely so that nobody has to do that reasoning.
Written at 0:13, before the first bisect step. Its whole value is that it can be wrong in public.
REPRODUCER exact command, expected output, actual output
verified: 5/5 fail on bad · 0/5 fail on good
BOUNDS bad ________ (verified by hand)
good ________ (verified by hand)
range ______ commits → predicted steps: ⌈log₂ n⌉ = ____
GUESS subsystem: ______________
because: ________________
TRIPWIRES if the answer is a merge commit, I will ______
if the answer is a lockfile bump, I will ______
if the answer is a formatting commit, I will ______
The verified line at the top is the one that does the work. Do five runs on the bad commit and five on the good one, before anything else. A reproducer that fails four times out of five still produces an answer, and nothing afterwards tells that answer apart from a real one.
The tripwires exist because all three of those results mean one thing: your bound or your reproducer is wrong. In the moment, a hunter rationalises every one of them instead. Writing down the response in advance is what makes it survive contact with a plausible story.
Two hundred commits is eight tests. A thousand is just ten. Write the number down before you start, and the search becomes an arithmetic fact instead of a feeling about progress. That fact is also the earliest warning that you have stopped bisecting and started guessing.
The most common way a bisect wastes an hour is starting from a good commit that was never good.
The bad end is usually easy: the code fails now, and that failure is why you are here. The good end is where people guess. "It worked in the 2.3 release" is a memory, and a memory about software behavior from three months ago is not evidence. Check out the tag, run the reproducer, watch it pass. Then it is a bound.
If the reproducer fails at the supposed good end too, that failure is not a setback. Instead, it is the most informative result of the session: the defect is older than you thought. Every theory anyone has offered so far was about the wrong window, so widen the bound and verify it again.
git bisect start
git bisect bad HEAD # verified: reproducer fails
git bisect good v2.3.0 # verified: reproducer passes
# git now checks out a midpoint and tells you the step count
# Bisecting: 99 revisions left to test after this (roughly 7 steps)
git bisect reset # when done, always
Two commands worth knowing before you need them. git bisect log prints the
session so far, and git bisect replay <file> re-runs a logged session. Together
they turn a misclassified step at position four into a thirty-second recovery rather than a
restart. Dump the log, edit out the bad line, then replay it.
git bisect run turns eight manual checkouts into one command, and its exit codes
are the whole interface.
#!/bin/sh
# Build. If this revision can't even build, it is UNTESTABLE, not bad.
make build >/dev/null 2>&1 || exit 125
out=$(./bin/app price --qty 3 --code SAVE20 2>/dev/null)
[ "$out" = "23.97" ] && exit 0 # good
exit 1 # bad
| Exit | Means | Use it when |
|---|---|---|
| 0 | good | The reproducer passes at this commit. |
| 1 – 127, not 125 | bad | The reproducer fails at this commit. |
| 125 | skip | Bisect cannot test this commit: no build, missing dependency, unrelated breakage. |
| 128 and up | abort | Something is wrong with the harness itself. Stop the bisect. |
Keeping the script outside the working tree is not fussiness. Bisect checks out old commits, so it replaces or deletes any script that lives in the repo. That script disappears the moment you cross the commit that introduced it. A bisect that dies halfway for no clear reason has often rediscovered that fact.
git bisect start && git bisect bad HEAD && git bisect good v2.3.0
git bisect run /tmp/check.sh
Marking an unbuildable commit as bad produces a wrong answer that looks right. No other mistake in a bisect does it so often. The commit that fails to compile has nothing to do with your regression. Calling it bad tells git the transition happened before that commit, so skip is not a weaker answer than bad, but the true one.
Plenty of real regressions are judgment calls: a layout looks wrong, a report is off by an amount nobody can specify, something got slow. Bisect still works. The predicate just lives in your head.
Run the bisect by hand, and mark each checkout git bisect good or git bisect
bad yourself. The risk shifts from automation to consistency, because step seven, twenty
minutes in, needs the same judgment as step one. Write the criterion
down before starting, in one sentence, and reread it at every step. "Total on the summary row
differs from the sum of the line items" is a criterion. "Looks wrong" is not, and it will drift
without you noticing.
git bisect start --term-old=fast --term-new=slow lets you mark
commits in words that match the question.
git bisect start --first-parent searches only the mainline. You land on a merge
commit rather than on a commit inside a branch. Coarser and often faster, and you can
bisect inside that merge afterwards.
Bisect is a proof procedure with preconditions. When those preconditions do not hold, bisect does not fail. It hands you a specific commit hash and a plausible story.
Bisect answers "where did the behavior change", which is not the same question as "where is the defect". Most of the time they coincide. But the times they do not coincide are the times the hunt is worth doing well. A hunter who cannot tell the two questions apart will fix the wrong file with great confidence.
The same shape as the reverse-review rubric, on purpose, so a team running both is not learning two grading systems.
| Point | Earned when | Lost when |
|---|---|---|
| Bounded | Both ends verified by hand, reproducer checked 5/5 and 0/5, before searching. | The hunter assumed the good end. Costs the whole hunt roughly half the time. |
| Converged | Reached the transition in about ⌈log₂ n⌉ steps, using the bisect throughout. | Abandoned the search partway to check a commit that "looked suspicious". |
| Distinguished | Said whether the commit introduced the defect or revealed it, and gave the reason. | Stopped at "this commit is the defect". |
| Explained | Named the mechanism: which line, what behavior changed, and why the suite stayed green. | Named a commit but not a mechanism. The commit is the location; the mechanism is the finding. |
As with reverse-review, the total is not the interesting number. Losing bounded repeatedly is a discipline problem with an easy fix. Losing distinguished repeatedly is a much deeper thing, because the hunter is treating a search result as a diagnosis. That habit survives into every incident they will ever run.
A hunter who guesses right and converges in six minutes scores worse than one who takes twenty-five. The slower hunter can say why every step was where it was. Guessing works often enough to reinforce itself, and it does not scale to the hunt that matters.
The ban is a training decision, not a position on tooling. Saying so matters, because the dogmatic version of this page would be wrong.
You do arithmetic without a calculator while learning arithmetic, and with one for the rest of your life. The drill closes the agent for thirty minutes a month, because an agent will run the whole search and hand back an answer. A rep you did not perform is not a rep. In a real incident, use everything you have.
| Hand to the agent | Keep |
|---|---|
| Writing the test script and its exit codes | Deciding what "bad" means |
| Summarising what an unfamiliar commit was trying to do | Judging whether it introduced or revealed |
| Explaining a subsystem you have never opened | Knowing whether the reproducer is trustworthy |
| Drafting the postmortem once you know the mechanism | The mechanism |
Every item in the right-hand column is a judgment about evidence you observed yourself, and the drill trains every one of them. The argument for the exercise is not that agents are bad at hunting, though they are. The real point is that the parts you still supply are the parts nobody practises.
Monthly is right. Good hunts are scarce, and unlike a diff you cannot generate more of them cheaply. Start by converting your last three postmortems into three dealt hunts. That is a year of drills from work you have already paid for.