Skip to content

The oscillation detector hook: stopping Claude Code from edit-thrashing your .NET solution

Edit-thrashing is the quiet failure mode of coding agents — A→B→A loops that burn tokens without progress. The hook that stops it, full implementation included.

Vukasin Vulovic 7 min read
The oscillation detector hook: stopping Claude Code from edit-thrashing your .NET solution

If you run coding agents against a real codebase for long enough, you will eventually watch one argue with itself. The agent edits a line, runs the tests, edits the line back, runs the tests, edits it forward again — each step confidently narrated, none of them progress. We started calling this failure mode edit-thrashing, and the mechanism that stops it the oscillation detector hook. As far as we could tell when we built ours, no public implementation of this pattern existed in the Claude Code ecosystem — so this post does three things: defines the failure mode precisely, walks through our production implementation, and shows where it sits in the larger guardrail system we run on every .NET engagement.

The failure mode: A → B → A

Edit-thrashing has a specific anatomy. The agent holds two constraints that contradict each other — usually without knowing it — and satisfies whichever one it looked at last. Change the code to satisfy constraint one, test run fails on constraint two, change it back, first test fails again. The agent is not being stubborn; it is doing perfectly local optimization with no memory that it has been in this exact state before.

.NET solutions produce these contradictions generously. Two xUnit fixtures that make opposite assumptions about DateTimeKind — one seeded with Utc, one comparing against Local. A legacy project where half the partial classes follow one naming convention and half follow another, so any “fix” breaks the mirror image. Nullable reference annotations enabled in one project and off in the project that consumes it. Fifteen years of accumulated convention drift means the contradiction is usually real — the agent has found a genuine inconsistency — but resolving it by alternately satisfying each side is the one strategy guaranteed to fail.

The cost is worse than the wasted tokens. A session that burns forty minutes flip-flopping on one file erodes a team’s trust in agents faster than any outright failure would — because it looks busy. The narration reads like progress. Only the diff tells the truth: net zero.

Why “prompt it better” doesn’t work

The tempting fix is a CLAUDE.md paragraph: “If you notice yourself reversing a previous edit, stop and reconsider.” We tried. The problem is that prompts are advisory, and under context pressure, advice loses to the most recent error message every time. The agent mid-thrash genuinely believes each reversal is a new idea — it has no mechanical memory of its own edit history, and by iteration three the original edit has scrolled far out of its attention.

The fix has to supply that memory from outside the model. In Claude Code, that layer is hooks — deterministic scripts that run on tool events and can block a call outright. A hook does not advise. It refuses.

The design: block the reversal before it lands

Our first sketch was a PostToolUse watcher — observe diffs after each edit, stop the session when a pattern recurs. What we shipped instead is a PreToolUse circuit breaker, and the difference matters: it intercepts the Edit call, computes a hash of the proposed transition, and checks whether that transition reverses a recent one. If it does, the edit is blocked before it executes. The bad edit never lands, the file never churns, and the diagnostic reaches the agent at the exact moment of decision rather than one step too late.

The core is a transition hash. Every edit is a transition from old_string to new_string; hash a sample of both and you have a fingerprint of the change:

// djb2 — cheap, good enough for fingerprinting, no crypto needed
function hashString(str) {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) + hash) + str.charCodeAt(i);
    hash = hash & hash;
  }
  return hash.toString(36);
}

function editHash(oldStr, newStr) {
  const sample = oldStr.slice(0, 200) + '|||' + newStr.slice(0, 200);
  return hashString(sample);
}

Two hundred characters of each side is enough to identify a transition, and because Claude Code sends exact old_string/new_string values, there is no whitespace-normalization dance to get wrong.

Detection is then a set-membership question: does the hash of the reverse transition — editHash(newString, oldString) — appear in this file’s recent edit history?

function detectOscillation(history, file, currentOldStr, currentNewStr) {
  const now = Date.now();
  const currentReverse = editHash(currentNewStr, currentOldStr);

  const recentEdits = history.edits
    .filter(e => e.file === file && (now - e.timestamp) < RESET_WINDOW_MS);

  if (recentEdits.length < 1) return false;

  for (let i = recentEdits.length - 1; i >= 0; i--) {
    if (recentEdits[i].transition_hash === currentReverse) {
      return recentEdits.length + 1;
    }
  }
  return false;
}

If the agent previously changed A→B, and now proposes B→A within the reset window, the reversal is caught. The hook blocks with exit code 2 and — this is the part that does the real work — a diagnostic engineered to redirect rather than merely refuse:

console.log(JSON.stringify({
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    decision: 'block',
    reason: `OSCILLATION DETECTED in ${fileName}: you've changed this code back ` +
      `and forth ${count} times. Stop and rethink your approach. Consider: ` +
      `(1) what is the root cause of the conflict? (2) is there a different ` +
      `solution that avoids toggling between these two states? (3) should you ` +
      `ask the user for clarification before proceeding?`
  }
}));
process.exit(2);

That reason string is not an error message; it is a redirect. In our sessions, the agent’s most common response to it is option three — asking the user about the contradiction — which was the correct move forty minutes of hypothetical thrash ago.

Registration is one entry in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Edit",
      "hooks": [{ "type": "command", "command": "node scripts/hooks/oscillation-detector.js" }]
    }]
  }
}

Production decisions worth stealing

The interesting engineering is in the edges, not the hash. Five decisions from running this against real client solutions:

Fail open, always. The entire hook body is wrapped so that any internal exception exits 0 — allow. A guardrail that can break the loop it guards is not a guardrail; it is a new failure mode. If the history file is corrupt, unreadable, or the JSON parse throws, the edit proceeds and the detector silently resets.

Skip replace_all. Bulk refactors legitimately apply the same transition many times across a file. Treating them as oscillation produces false positives exactly when the agent is doing its most mechanical, most correct work.

Per-session history, keyed by parent PID. Edit history lives in a temp file namespaced by process.ppid — concurrent Claude Code sessions on the same machine never contaminate each other’s detectors, and the OS cleans up the evidence.

A reset window. Ours defaults to ten minutes (OSCILLATION_RESET_MS to tune). An edit reversed an hour later is not thrash — it is a decision. The window keeps the detector’s definition of “recent” honest.

A hard cap on history per file. Twenty entries, oldest evicted. The hook runs on every edit in the session; it must stay in single-digit milliseconds or the guardrail becomes the bottleneck.

What it catches on a real .NET solution

The canonical catch: a test suite with the DateTimeKind contradiction above. The agent sets the property to DateTimeKind.Utc, fixture two fails, it sets it to Local, fixture one fails, and on the third flip the hook blocks with the oscillation diagnostic. The agent stops, re-reads both fixtures, reports the actual conflict — these two tests encode incompatible assumptions; which one reflects intended behavior? — and the human resolves in one message what the loop would never have resolved at all. The same shape shows up with EF Core migration naming (regenerate, rename, regenerate), and with #nullable toggles at project boundaries.

Prior art and adjacent work

The closest public work in the .NET AI-quality space is Aaron Stannard’s dotnet-slopwatch and crap-analysis — both aimed at detecting low-quality AI-generated code patterns analytically. They are quality gates: they judge output. The oscillation detector is a quality circuit breaker: it interrupts a failing process mid-flight. The two compose naturally and solve different problems.

Generic mitigations — step-count caps, token budgets — bound total work but cannot distinguish productive iteration from thrash; they kill good long sessions and bad ones alike. Transition-hash reversal detection is, to our knowledge, not something anyone had published for Claude Code when we built it, which is why we gave the pattern a name: the oscillation detector hook.

Where it sits in the twelve layers

This hook is layer nine of the twelve-layer system we install on .NET codebases — one mechanism in a stack where the investigation gate prevents edits without evidence, the build gate keeps failures step-local, and the detector described here keeps iteration convergent. No single layer is sufficient; the composition is the point. The full system, including the maturity path for adopting it incrementally, is covered in our field guide to AI-augmented .NET development.

We do this kind of guardrail work as part of every Sprint Zero — instrumenting an existing repository so the agents your team already uses stop making these mistakes quietly. If you build your own detector from the code above instead, we consider that a perfectly good outcome too.