SLOPSHOPPER

edit-loop

Adds a note to the fifth edit of one file in one turn, so the model re-reads the code path and states the root cause instead of trying again.

newguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · edit-loop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /edit-loop ⎿ edit-loop: on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

edit-loop

When a fix does not work, the model tends to edit the same file again, and again, each time with a slightly different guess. Five edits of one file in one turn usually mean it is guessing instead of understanding. This mod notices that moment and tells the model to stop, re-read the code and name the root cause before the next edit. It stops nothing.

What it does

  1. It watches the Edit, Write and NotebookEdit tools. Each successful call counts as one edit of its file; a denied or failed call does not count.
  2. Counts are kept per loop and file: the main loop and each subagent count separately. A new turn starts every count at zero.
  3. The third edit of one file in a turn warns you alone:

edit-loop: 3rd edit of hooks/a.ts in this turn

The model reads nothing at this count.

  1. The fifth edit of one file in a turn gets this note right after its result:

edit-loop: this turn edited hooks/a.ts 5 times. Stop editing it, re-read the code path and state the root cause before the next edit.

The path is relative to the git repository the session started in when the file is inside it, so a session opened in plugins/a shows a file of plugins/b as plugins/b/x.ts. Outside a git repository the path is relative to the directory the session started in. That root is read once at the session's start, because a Bash cd moves the session's own directory. The note comes once per file and turn; the sixth and later edits get none.

  1. At the same moment you get one line in the transcript, so you see what the model was told. It holds the finding alone, without the instruction:

edit-loop: 5th edit of hooks/a.ts in this turn

The note and the line are separate channels: the model never reads the line, and you never read the note.

  1. With the sidebar open, both lines go into its stream instead, and the transcript stays clean. There the ordinal (3rd, 5th) carries the colour, yellow at the third edit and red at the fifth, the path stays in the default colour, and in this turn is faint. An entry stays until newer ones push it off the pane. Without the sidebar, the lines land in the transcript as above.

In the live check the model edited one file six times in one turn. It read the note after the fifth edit, re-read the file, explained why the edits were intended, and quoted the note word for word. The other five edits got no note.

Command

/edit-loop on or off /edit-loop on | off on by default

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install edit-loop@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=edit-loop}, turn.start, tool.call{tool=Edit}, tool.call{tool=Write}, tool.call{tool=NotebookEdit} ❯ ./register.ts calls: $.command.register, $.process.run (via shownRootOf), $.session.cwd (via afterEdit, shownRootOf), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand), $.ui.log (via toPerson)

Reach L2: it runs a process.

  1. Reads: the file path of each Edit, Write and NotebookEdit call; the session's directory and its git repository root
  2. Runs: git rev-parse --show-toplevel once at the session's start, to show paths against the repository root
  3. Sends: a note to the model after the fifth edit of one file in a turn, and one line to the transcript at the third and the fifth; nothing leaves the machine
  4. Persists: in $.store, the on/off setting; the counts live in memory for one turn
  5. Hostile input: the path is only compared and printed in the note, never opened

Limits

  • An edit through Bash (sed -i, a heredoc, a script) is not counted.
  • Five edits of one file can be intended, for example a long file written in parts. The note asks for a reason; it stops nothing.
  • The count follows the path as the tool call names it, so one file under two spellings (a link, ..) counts twice.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 114 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { countEdit, logText, noteText, sectionKey, shownPath, sidebarLines, THRESHOLD, WARN_THRESHOLD, type Counts, type Line } from './loop.ts'
3
4const ENABLED_KEY = 'enabled'
5
6const USAGE = 'expects nothing (the status), on or off'
7
8/**
9 * The edit counts of the running turn, the on/off setting, and the directory paths are shown against,
10 * read once at the session's start (`shownRootOf`), not from `$.session.cwd()` at each edit, because a
11 * Bash `cd` moves the session's directory and would then leave every path outside it written in full.
12 */
13type State = { counts: Counts; enabled: boolean; root?: string }
14
15/**
16 * Reads the on/off setting from the store, which every window shares, so a change made in another
17 * window applies here at the next hook that acts on it.
18 */
19async function readSettings($: EngineInterface, state: State): Promise<void> {
20  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
21}
22
23/**
24 * The git repository the session started in, so a file in a sibling directory of a session opened in a
25 * subdirectory still reads short; the session's own directory where git does not answer.
26 */
27async function shownRootOf($: EngineInterface): Promise<string> {
28  const cwd = await $.session.cwd()
29  try {
30    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
31    const root = top.stdout.trim()
32    return top.exitCode === 0 && root !== '' ? root : cwd
33  } catch {
34    // No git here, or the command did not run: paths are shown against the session's directory.
35    return cwd
36  }
37}
38
39/**
40 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
41 * transcript line, as before. The model's note is another channel and does not change here.
42 */
43async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
44  try {
45    const taken = await $.sidebar.set({
46      consumer: 'edit-loop',
47      key: sectionKey(key),
48      title,
49      lines,
50      until: 'stream',
51    })
52    if (taken) return
53  } catch {
54    // The sidebar mod is not installed.
55  }
56  $.ui.log(line)
57}
58
59/**
60 * Counts a finished edit. The third edit of one file warns the person in yellow, the fifth turns the
61 * finding red and is the one the model reads; every other edit passes without a word.
62 */
63async function afterEdit($: EngineInterface, state: State, agentId: string | undefined, path: string, r: ToolCallResult): Promise<ToolCallResult> {
64  if (r.deny !== undefined || r.isError === true) return r
65  await readSettings($, state)
66  if (!state.enabled) return r
67  const count = countEdit(state.counts, agentId, path)
68  if (count !== WARN_THRESHOLD && count !== THRESHOLD) return r
69  const shown = shownPath(path, state.root ?? (await $.session.cwd()))
70  if (count === WARN_THRESHOLD) {
71    await toPerson($, shown, 'edits piling up', sidebarLines(shown, count), logText(shown, count))
72    return r
73  }
74  // The note goes to the model, the line to the person: neither reads the other's channel.
75  await toPerson($, shown, 'edit loop', sidebarLines(shown), logText(shown))
76  return { ...r, context: [...(r.context ?? []), noteText(shown)] }
77}
78
79async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
80  const word = args.trim()
81  if (word === 'on' || word === 'off') {
82    await $.store.set(ENABLED_KEY, word === 'on')
83    state.enabled = word === 'on'
84    return word === 'on' ? 'on: the fifth edit of one file in a turn gets a note' : 'off: edits are not counted'
85  }
86  if (word !== '') return USAGE
87  await readSettings($, state)
88  return state.enabled ? 'on' : 'off'
89}
90
91export const register: Register = on => {
92  const state: State = { counts: new Map(), enabled: true }
93
94  on('session.start', async ($, e, next) => {
95    const r = await next(e)
96    await $.command.register({ name: 'edit-loop', description: 'A note at the fifth edit of one file in a turn: status, on, off (edit-loop)', argumentHint: '[on | off]' })
97    await readSettings($, state)
98    state.root = await shownRootOf($)
99    return r
100  })
101
102  // The engine prints the plugin name in front of command text, so the texts do not repeat it.
103  on('command.run', { command: 'edit-loop' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
104
105  on('turn.start', async (_, e, next) => {
106    state.counts.clear()
107    return next(e)
108  })
109
110  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.agentId, e.file_path, await next(e)))
111  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.agentId, e.file_path, await next(e)))
112  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => afterEdit($, state, e.agentId, e.notebook_path, await next(e)))
113}
114
hooks/loop.ts 58 lines
1/** How many times each loop edited each file in the running turn, and the note at the threshold. */
2
3/** The edit that reaches this count gets the note the model reads. */
4export const THRESHOLD = 5
5
6/** The edit that reaches this count gets the first warning, to the person only. */
7export const WARN_THRESHOLD = 3
8
9/** Edits per loop and file in the running turn; the main loop is `main`, a subagent its id. */
10export type Counts = Map<string, number>
11
12const key = (agentId: string | undefined, path: string): string => `${agentId ?? 'main'}\0${path}`
13
14/** Counts one edit and answers how many times this loop has edited that file in this turn. */
15export function countEdit(counts: Counts, agentId: string | undefined, path: string): number {
16  const k = key(agentId, path)
17  const n = (counts.get(k) ?? 0) + 1
18  counts.set(k, n)
19  return n
20}
21
22/** `path` shown relative to the session directory when it is inside it. */
23export function shownPath(path: string, cwd: string): string {
24  const base = `${cwd.replace(/\/+$/, '')}/`
25  return path.startsWith(base) ? path.slice(base.length) : path
26}
27
28export function noteText(path: string): string {
29  return `edit-loop: this turn edited ${path} ${THRESHOLD} times. Stop editing it, re-read the code path and state the root cause before the next edit.`
30}
31
32/** The transcript line: the finding alone, without the instruction the model reads. The engine adds the mod name. */
33export function logText(path: string, count = THRESHOLD): string {
34  return sidebarLines(path, count).map(l => l.text).join('\n')
35}
36
37/** How the sidebar colours a line or a part of one. */
38type Tone = 'ok' | 'warn' | 'error' | 'dim'
39export type Part = { text: string; kind?: Tone }
40/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
41export type Line = { text: string; kind?: Tone; parts?: Part[] }
42
43const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
44
45/** A line made of parts, its `text` their texts joined. */
46const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
47
48/** `logText` as a sidebar line: the ordinal yellow at the warning and red at the note, the path default, the rest faint. */
49export function sidebarLines(path: string, count = THRESHOLD): Line[] {
50  const ordinal = `${count}${count === WARN_THRESHOLD ? 'rd' : 'th'}`
51  return [partsLine([part(ordinal, count === WARN_THRESHOLD ? 'warn' : 'error'), part(` edit of ${path}`, undefined), part(' in this turn', 'dim')])]
52}
53
54/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
55export function sectionKey(text: string): string {
56  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
57}
58