SLOPSHOPPER

anti-slop

Fewer comments that narrate the edit and fewer stock phrases, in English and Russian: a note to Claude right after an edit that says "added X" or "as…

newguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · anti-slop
› 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 › /anti-slop ⎿ anti-slop: This session: 0 comment notes, 0 phrases rewritten. ⎿ anti-slop: All sessions on this machine (approximate): 0 comment notes, 0 phrases rewritten. ⎿ anti-slop: Comments: narrating comments only; at most 1 per file and 3 per session. ⎿ anti-slop: Rewritten in stored replies: "Хороший вопрос" → dropped, "Отличный вопрос" → dropped, "You're absolutely right ⎿ anti-slop: Asked to avoid: "Хороший вопрос", "Отличный вопрос", "You're absolutely right", "You are absolutely right", "Т ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods by Evgeny Padezhnov

Unofficial — not made or endorsed by Anthropic.

Before/after examples, install steps and the measurements behind each plugin: padezhnov.com/en/tools.

Claude Code mods

ModWhat it does
anti-slopFlags comments that narrate the edit ("added X", "as requested") with a note to Claude right after the edit, and drops a few stock openers like "You're absolutely right" from stored replies. English and Russian.
native-editsFile changes through Edit and Write, not sed -i and heredocs: every change gets a diff, can be rewound and follows the path's rules.
/plugin marketplace add padezhnov/claude-code-mods
/plugin install anti-slop@padezhnov
/plugin install native-edits@padezhnov

Mods are Claude Code plugins whose hooks are TypeScript functions running inside Claude Code (since 2.1.287). These need Claude Code 2.1.287 or newer; native-edits' prompt fix applies from 2.1.289. Neither mod makes network requests. anti-slop is about 500 lines of TypeScript; native-edits about 2,200, most of it a shell parser (plugins/native-edits/hooks/shell.ts) — read them before installing.

Settings: /plugin → pick the mod → configure, or pluginConfigs.<mod> in your Claude Code settings. Turn off or remove: /plugin disable anti-slop@padezhnov, /plugin uninstall anti-slop@padezhnov (same for native-edits), or set its mode to off.


anti-slop

Two long-running complaints about Claude Code: it writes comments that narrate the edit instead of explaining the code, and ignores instructions to stop (#65961); and it leans on stock phrases, such as opening with "You're absolutely right" or saying "load-bearing" (#53454 is about the latter). English and Russian are both understood.

  • Comments. After an edit succeeds, the comment lines it added are checked for ones that tell the history of the change: "Added retry logic", "I've removed…", "as requested", "used to return null", "until now…", "Добавил…", "Исправлено:", "по просьбе…". Claude gets a short note right then to rephrase those lines to describe the code as it is now, keeping the why. At most one note per file and three per session; the edit is never refused. Only files in common languages are checked (see syntaxOf in plugins/anti-slop/hooks/slop.ts). Quoted text, descriptions of what code does ("Updated by cron", "Token used to call the API", "Добавлено в v2 API"), tool directives, prose files and /tmp or scratchpad files are left alone.
  • Too many comments is opt-in (comments: density): three or more short one-line captions right above code, at 30% or more of the code lines the edit added.
  • Phrases. By default "Хороший вопрос", "Отличный вопрос", "You're absolutely right" or "You are absolutely right" where it starts a sentence is dropped from the reply as the conversation stores it (the copy Claude reads back on later turns), so Claude does not keep echoing itself; a sentence that needs the phrase ("You're absolutely right that the cache is stale") is kept. A short system-prompt section asks Claude to avoid all listed phrases in replies, code, comments and commit messages, and not to open a reply by agreeing with or praising you; "Ты прав", "Вы правы" and "load-bearing" are only covered by that request. Code spans, fences and text that looks like JSON are never rewritten, and what already streamed to your screen may still show the original.
  • Changes made through Bash (sed -i, heredocs) are not seen; use it with native-edits, which sends those to Edit and Write.
  • /anti-slop shows the settings and what it caught, for this session and, approximately, for all sessions on this machine. Settings: comments (narration, density or off), minComments, commentPercent, beforeNow (also count Russian "раньше…" / "теперь…" history comments; off because many teams write these on purpose), phrases (phrase=>replacement, phrase=> to drop an opener, a bare phrase to only avoid), rewrite.

Replayed against my own Claude Code transcripts (one developer, about four weeks; 2,816 successful Edit and Write calls in that extract): 0.1 would have sent 141 notes; of 30 picked at random, none pointed at comments that only restate the code, 3 at a history sentence inside a longer explanation, and the rest at explanations of why, file headers and section markers. 0.2 sends 3 (4 before the caps), each for an "until now…" or "it used to…" sentence inside a long explanatory comment; no "// Added X" style comment turned up outside /tmp. The phrase rewrite changed 15 openers in 29,966 stored text blocks. Treat the comment rule as a light guard, not a cleaner.


native-edits

In auto mode (Claude Code deciding permissions itself) and with bypassed permissions, Claude Code tells the model, in so many words, to "make small, mechanical file changes with sed, heredocs, or short scripts instead of the dedicated Read, Edit, or Write tools". So it does: on my machines about half of all local file changes went through Bash. /rewind cannot undo those changes (#87575), they skip the nested CLAUDE.md and path rules that Edit and Write load (#90450), and any hook or mod that checks Edit and Write never sees them. Related requests to keep Claude on its own file tools: #19649, #87971, #88041. Claude Code does show a diff of what a Bash command changed, but only in auto and bypass mode and only inside a git repository.

  • The cause. The auto-mode note is rewritten before the model reads it: reading and searching through Bash (cat, sed -n, grep) stays, file changes go to Edit (or Write for a new file). Claude Code adds that note in auto mode and with bypassed permissions, not in every session; in the same sessions it drops the "Edit files: Use Edit (NOT sed/awk)" lines from the Bash tool's description, and the mod puts an equivalent line back. Only the wording 2.1.289–2.1.294 uses is replaced; another build's note is left exactly as it is, and the guard below still applies.
  • The guard. A Bash command that writes a file in your project is not run. Claude is told which files and to use Edit or Write instead; you are never asked. It catches sed -i, perl -i, ruby -i, awk -i inplace and yq -i (every target, also through find -exec, xargs and loops), cat > f <<EOF, echo/printf redirections, tee, x > f.tmp && mv f.tmp f when x is text Claude wrote or a filter of f itself, writes inside shell functions, and Python, Node or Deno snippets that rewrite a file or write literal text (open(p, 'w'), writeFileSync), including a script written to /tmp and run in the same command. Shell variables in those snippets are filled in as the shell would (open('$S/x', 'w') with S=/tmp/… is a scratch file).
  • When Claude has no Edit or Write tool (--disallowedTools Edit Write, a headless run with Bash only), the write runs: a refusal would leave no way to make the change. A subagent's tools are not visible to the mod, so a subagent is refused once per command and told that the same command runs on a second try.
  • What it leaves alone: reading and searching; /tmp, $TMPDIR and the session scratchpad; /dev/null and 2>&1; files outside the project (setting scope); anything run on another machine (ssh, scp, rsync, docker exec); output of programs (git diff > x.patch, npm test > out.txt, curl … > page.html, curl … | jq . > f.tmp && mv f.tmp f, a script dumping computed JSON); content only known at run time (echo "$(date)" >> f, an unquoted heredoc with $VAR); paths only known at run time (xargs -I{}, an unset variable); and allowPaths (default **/*.log,/node_modules/,/dist/,/build/).
  • A bulk change (sed -i over 15 files) is refused like any other: Claude then makes it with Edit. Use warn or allowPaths if you would rather let those through.
  • /native-edits shows how many writes were refused, warned or let through, whether the auto-mode note was rewritten, and any allowPaths glob it could not read (an invalid glob is ignored, the rest still apply). Settings: mode (deny, warn runs the command and tells Claude afterwards, off), scope (project or everywhere), allowPaths, rewritePrompt.

Checked in a live session: asked to "change red to blue in file.txt using sed -i", Haiku's sed -i was refused, it read the file and made the change with Edit. With Edit and Write disallowed, the same sed -i ran. A python3 - <<EOF writing to $T/probe.txt with T set to a scratch directory ran. In auto mode, Opus quoted the rewritten note back instead of the original.

Replayed against all 20,197 Bash commands in my own transcripts (an earlier extract: 429 sessions and subagents): 2,090 commands would have been refused, writing 3,079 files, against 2,721 Edit and Write calls in the same sessions. Of 60 random refusals checked by hand, 59 were plain edits or new files Claude wrote through the shell and one was borderline (a manifest rewritten with computed hashes). Of 40 random commands that look like writes but were let through, none was a missed edit: they ran on another machine, saved a program's output, or wrote nothing. Writes to a path only known at run time (62 files, about 2% of the 3,079) are let through by design.


Security

What each mod reads and changes, and how to report a problem: SECURITY.md.

Development

./scripts/check.sh   # claude plugin validate + tsc + claude plugin test (may make one short claude -p call to refresh the mods switch)

MIT licensed.

Source 2 files
hooks/register.ts 159 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { avoidSection, commentNote, isScratch, judgeComments, parsePhrases, rewriteBlocks, syntaxOf } from './slop'
4import type { CommentRules, Replacement } from './slop'
5
6/** Openers are dropped; the rest are only asked to be avoided, so agent JSON and articles are never rewritten. */
7export const DEFAULT_PHRASES = "Хороший вопрос=>, Отличный вопрос=>, You're absolutely right=>, You are absolutely right=>, Ты прав, Вы правы, load-bearing"
8const STATS = 'stats'
9/** At most this many comment notes in one session, and one per file. */
10const SESSION_CAP = 3
11
12type Settings = {
13  comments: 'narration' | 'density' | 'off'
14  rules: CommentRules
15  avoid: string[]
16  replace: Replacement[]
17  rewrite: boolean
18}
19
20/** Kept in the plugin store, shared by every session on this machine, without locking. */
21type Stats = { nudges: number; rewrites: number; phrases: Record<string, number> }
22
23const EMPTY: Stats = { nudges: 0, rewrites: 0, phrases: {} }
24
25export function settingsOf(options: PluginOptions): Settings {
26  const spec = typeof options.phrases === 'string' ? options.phrases : DEFAULT_PHRASES
27  const { avoid, replace } = parsePhrases(spec)
28  const min = typeof options.minComments === 'number' && Number.isFinite(options.minComments) ? options.minComments : 3
29  const pct = typeof options.commentPercent === 'number' && Number.isFinite(options.commentPercent) ? options.commentPercent : 30
30  // 0.1's "nudge" (narration and density together) maps to the new default.
31  const comments = options.comments === 'off' ? 'off' : options.comments === 'density' ? 'density' : 'narration'
32  return {
33    comments,
34    rules: {
35      density: comments === 'density',
36      minComments: Math.max(1, Math.round(min)),
37      ratio: Math.min(100, Math.max(1, pct)) / 100,
38      beforeNow: options.beforeNow === true,
39    },
40    avoid,
41    replace,
42    rewrite: options.rewrite !== false,
43  }
44}
45
46async function statsOf($: EngineInterface): Promise<Stats> {
47  const raw = (await $.store.get(STATS)) as Partial<Stats> | undefined
48  return { nudges: raw?.nudges ?? 0, rewrites: raw?.rewrites ?? 0, phrases: raw?.phrases ?? {} }
49}
50
51async function record($: EngineInterface, nudges: number, by: Record<string, number>) {
52  const stats = await statsOf($)
53  const phrases = { ...stats.phrases }
54  let rewrites = 0
55  for (const [p, n] of Object.entries(by)) {
56    phrases[p] = (phrases[p] ?? 0) + n
57    rewrites += n
58  }
59  await $.store.set(STATS, { nudges: stats.nudges + nudges, rewrites: stats.rewrites + rewrites, phrases })
60}
61
62async function previousText($: EngineInterface, path: string): Promise<string> {
63  try {
64    return await $.fs.read(path)
65  } catch {
66    return ''
67  }
68}
69
70export const register: Register = (on, options) => {
71  const settings = settingsOf(options)
72  // Per session: the files already nudged (per agent) and how many notes went out.
73  const nudgedFiles = new Set<string>()
74  const session = { nudges: 0, rewrites: 0 }
75  const resetSession = () => {
76    nudgedFiles.clear()
77    session.nudges = 0
78    session.rewrites = 0
79  }
80
81  on('session.start', async ($, e, next) => {
82    resetSession()
83    await $.command.register({ name: 'anti-slop', description: 'Anti-slop: what it caught so far, and the settings', immediate: true })
84    return next(e)
85  })
86
87  // /clear and a resume end the session but keep the process: no session.start follows.
88  on('session.end', async ($, e, next) => {
89    resetSession()
90    return next(e)
91  })
92
93  // The words to avoid, as a system prompt section of their own.
94  if (settings.avoid.length > 0) {
95    on('prompt.compose', async ($, e, next) => {
96      const composed = await next(e)
97      return { sections: [...composed.sections, { id: 'anti-slop:style', text: avoidSection(settings.avoid), scope: 'session' as const }] }
98    })
99  }
100
101  // Comments: never refuse an edit (that only burns tokens); tell the model
102  // right after it, when the note is most likely to be acted on.
103  if (settings.comments !== 'off') {
104    on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
105      const input = e as unknown as { tool: string; agentId?: string; file_path: string; old_string?: string; new_string?: string; content?: string }
106      if (isScratch(input.file_path) || syntaxOf(input.file_path) === null) return next(e)
107      const key = `${input.agentId ?? 'main'}:${input.file_path}`
108      if (session.nudges >= SESSION_CAP || nudgedFiles.has(key)) return next(e)
109      const before = input.tool === 'Write' ? await previousText($, input.file_path) : (input.old_string ?? '')
110      const after = input.tool === 'Write' ? (input.content ?? '') : (input.new_string ?? '')
111
112      const ran = await next(e)
113      if (ran.deny !== undefined || ran.isError === true) return ran
114
115      const verdict = judgeComments(input.file_path, before, after, settings.rules)
116      if (verdict === null || session.nudges >= SESSION_CAP || nudgedFiles.has(key)) return ran
117      nudgedFiles.add(key)
118      session.nudges += 1
119      await record($, 1, {})
120      return { ...ran, context: [...(ran.context ?? []), commentNote(input.file_path, verdict)] }
121    }).catch(($, e, next) => next(e))
122  }
123
124  // The phrases: rewritten in the reply as stored, which is what the model
125  // reads back on later turns (so it stops echoing itself).
126  if (settings.rewrite && settings.replace.length > 0) {
127    on('session.append', { door: 'response' }, async ($, e, next) => {
128      const content = e.message.content
129      if (!Array.isArray(content)) return next(e)
130      const r = rewriteBlocks(content, settings.replace)
131      if (r.hits === 0) return next(e)
132      session.rewrites += r.hits
133      await record($, 0, r.by)
134      return next({ ...e, message: { ...e.message, content: r.content as typeof content } })
135    }).catch(($, e, next) => next(e))
136  }
137
138  on('command.run', { command: 'anti-slop' }, async $ => {
139    const stats = await statsOf($)
140    const rule =
141      settings.comments === 'off'
142        ? 'off'
143        : `${settings.comments === 'density' ? `narrating comments, or ${settings.rules.minComments}+ one-line captions at ${Math.round(settings.rules.ratio * 100)}%+ of the code lines` : 'narrating comments only'}; at most 1 per file and ${SESSION_CAP} per session`
144    const counted = Object.entries(stats.phrases).sort((a, b) => b[1] - a[1])
145    // 0.1 kept only the total: the rest of it has no phrase to name.
146    const before02 = stats.rewrites - counted.reduce((sum, [, n]) => sum + n, 0)
147    const perPhrase = [...counted.map(([p, n]) => `"${p}" ${n}`), ...(before02 > 0 && counted.length > 0 ? [`${before02} before 0.2`] : [])].join(', ')
148    return {
149      text: [
150        `This session: ${session.nudges} comment notes, ${session.rewrites} phrases rewritten.`,
151        `All sessions on this machine (approximate): ${stats.nudges} comment notes, ${stats.rewrites} phrases rewritten${perPhrase ? ` (${perPhrase})` : ''}.`,
152        `Comments: ${rule}.`,
153        `Rewritten in stored replies: ${settings.rewrite ? settings.replace.map(r => `"${r.phrase}" → ${r.with === '' ? 'dropped' : `"${r.with}"`}`).join(', ') || 'nothing' : 'off'}.`,
154        `Asked to avoid: ${settings.avoid.map(a => `"${a}"`).join(', ') || 'nothing'}.`,
155      ].join('\n'),
156    }
157  })
158}
159
hooks/slop.ts 332 lines
1// Pure helpers: no `$`, so the tests can pin them down directly.
2
3/** Files where prose is the content: comments are not counted there. */
4const PROSE = /\.(md|mdx|markdown|txt|rst|adoc|org|tex|json|lock|csv|svg)$/i
5
6/** Scratch files: nobody reads their comments later. */
7const SCRATCH = /^(?:\/private)?\/(?:tmp|var\/tmp|var\/folders)\/|\/scratchpad\//
8
9type Syntax = { line: RegExp[]; block?: { open: RegExp; close: RegExp } }
10
11const C_LIKE: Syntax = { line: [/^\s*\/\//], block: { open: /^\s*\/\*/, close: /\*\/\s*$/ } }
12const HASH: Syntax = { line: [/^\s*#(?![!\[])/] }
13const DASHES: Syntax = { line: [/^\s*--(?!-)/] }
14const MARKUP: Syntax = { line: [], block: { open: /^\s*<!--/, close: /-->\s*$/ } }
15
16const BY_EXT: Record<string, Syntax> = {}
17for (const ext of 'js jsx ts tsx mjs cjs mts cts java c h cc cpp hpp cs go rs swift kt kts scala dart php groovy zig sol css scss less'.split(' ')) BY_EXT[ext] = C_LIKE
18for (const ext of 'py rb sh bash zsh fish pl r ex exs yaml yml toml tf hcl nix ps1 cmake makefile dockerfile conf ini'.split(' ')) BY_EXT[ext] = HASH
19for (const ext of 'sql lua hs elm ada'.split(' ')) BY_EXT[ext] = DASHES
20for (const ext of 'html htm xml vue svelte astro'.split(' ')) BY_EXT[ext] = MARKUP
21
22/** Comments that are instructions to tools, not prose for people. */
23const DIRECTIVE =
24  /(eslint-|prettier-ignore|@ts-(ignore|expect-error|nocheck)|noqa|type:\s*ignore|pylint:|istanbul|c8 ignore|nolint|SPDX-License|#region|#endregion|biome-ignore|rubocop:|shellcheck|-\*- coding|@flow|@jsx|@vite-ignore|webpackChunkName|NOSONAR|TODO\(|FIXME\()/i
25
26// ---------------------------------------------------------------- narration
27// A comment narrates the edit when it talks about the change itself ("Added
28// retry logic", "as requested", "used to return null") rather than the code.
29// Words like "updated" or "removed" also describe what code does ("removed
30// items are re-fetched", "customer.subscription.updated"), so a verb only
31// counts at the very start of the comment, followed by what was changed.
32
33const MARK = String.raw`(?:\/\/+|#+|--+|\/?\*+|<!--)`
34const START = String.raw`^(?:${MARK})?[ \t]*`
35const NOT_LETTER = String.raw`(?![\p{L}\p{N}_])`
36
37const DETERMINER = String.raw`(?:the|a|an|its|their|his|her|our|your|each|every|any|all|this|that|these|those)\b`
38
39const EN_VERB = new RegExp(
40  `${START}(?:(?:i|we)(?:[ \\t]+(?:have[ \\t]+|just[ \\t]+)?|['’]ve[ \\t]+))?` +
41    String.raw`(?:added|removed|changed|updated|renamed|refactored|moved|replaced|fixed|switched)[ \t]+` +
42    // "Fixed — …", "Moved = …": a label, not a sentence.
43    String.raw`(?![—–=:-])` +
44    // "Updated by cron", "Removed on logout", "Switched off in prod", "Fixed window": what the code does.
45    String.raw`(?!(?:by|in|on|at|after|before|when|whenever|if|via|during|until|once|here|there|off|only|permanently|window|point|size)\b)` +
46    // "Added to the queue", "Removed from the DOM", "Replaced with a stub" — but "Changed from map to forEach" is the edit.
47    String.raw`(?!(?:to|from|with|into)[ \t]+${DETERMINER})` +
48    // "Removed items stay…", "changed files are re-linted": a participle.
49    String.raw`(?![\w-]+[ \t]+(?:are|is|stay|stays|remain|remains|get|gets|go|goes|will|can|must|should|have|has|were|was)\b)` +
50    String.raw`\S`,
51  'i',
52)
53const EN_LABEL = new RegExp(String.raw`${START}(?:new|old|changed|fixed|updated|added|removed):`, 'i')
54const EN_ANYWHERE = /\b(?:now (?:uses|returns|handles|calls|checks|supports)|until now|as requested(?![ \t]+by\b)|as you asked|per (?:your|the user'?s?) request)\b/i
55/** "Previously, …" / "Previously this returned …", at the start of a sentence; not "Previously computed totals". */
56const EN_PREVIOUSLY = new RegExp(
57  String.raw`(?:${START}|[.;:(—][ \t]*)previously(?:[ \t]*,|[ \t]+(?:this|it|we|i|they|there|that|the|a|an)\b)`,
58  'i',
59)
60/** "This change makes …" at the start of the comment; not "this change handler fires twice". */
61const EN_THIS_CHANGE = new RegExp(
62  String.raw`${START}this[ \t]+(?:change|edit|update)(?:[ \t]*[,:;.—-]|[ \t]+(?:makes|adds|removes|fixes|changes|moves|replaces|switches|keeps|ensures|means)\b)`,
63  'i',
64)
65/** "It used to land …", "Used to return null": at the start, or after a pronoun; not "Token used to call the API". */
66const EN_USED_TO = new RegExp(String.raw`(?:${START}|\b(?:it|this|that|which|we|they|there)[ \t]+)used to (?:be|return|call|land|send|run|go)\b`, 'i')
67
68const NOT_WOULD = String.raw`(?![ \t]+бы${NOT_LETTER})`
69const RU_VERB = new RegExp(`${START}(?:добавил|исправил|заменил|убрал|переименовал)${NOT_LETTER}${NOT_WOULD}`, 'iu')
70/** "# Добавлена проверка", "Исправлено: таймаут"; not "Добавлено в v2 API" (when it appeared, not this edit). */
71const RU_PASSIVE = new RegExp(
72  `${START}(?:добавлен[аоы]?|исправлен[аоы]?)${NOT_LETTER}${NOT_WOULD}(?![ \\t]+(?:в|во|на|при|для|после|до|начиная|с|со)${NOT_LETTER})`,
73  'iu',
74)
75const RU_ASKED = new RegExp(String.raw`(?<![\p{L}\p{N}_])(?:по просьбе|как (?:ты|вы) просил[аи]?)${NOT_LETTER}`, 'iu')
76
77/** Quoted text is someone else's words ("as requested" in a list of examples), not the comment's. */
78function unquoted(line: string): string {
79  // An odd number of '"': the line goes on a quote opened on the line before.
80  const s = (line.match(/"/g)?.length ?? 0) % 2 === 1 ? line.replace(/^[^"]*"/, '""') : line
81  return s.replace(/"[^"\n]*"|«[^»\n]*»|“[^”\n]*”|`[^`\n]*`|(?<![\p{L}\p{N}])'[^'\n]*'(?![\p{L}\p{N}])/gu, '""')
82}
83/** Opt-in: "раньше …" / "теперь …" also describe the code's history on purpose. */
84const RU_BEFORE_NOW = new RegExp(
85  String.raw`(?:${START}|[.;:!?(—][ \t]*)(?:раньше|прежде|до этого)${NOT_LETTER}(?!,?[ \t]+чем)` + String.raw`|(?<![\p{L}\p{N}_])теперь${NOT_LETTER}`,
86  'iu',
87)
88
89/** Whether one comment line narrates the edit instead of explaining the code. */
90export function narrates(line: string, beforeNow = false): boolean {
91  const s = line.trim()
92  const own = unquoted(s)
93  if (EN_VERB.test(s) || EN_LABEL.test(s) || EN_THIS_CHANGE.test(s)) return true
94  if (EN_ANYWHERE.test(own) || EN_PREVIOUSLY.test(own) || EN_USED_TO.test(own)) return true
95  if (RU_VERB.test(s) || RU_PASSIVE.test(s) || RU_ASKED.test(own)) return true
96  return beforeNow && RU_BEFORE_NOW.test(own)
97}
98
99// ------------------------------------------------------------------ counting
100
101export function syntaxOf(path: string): Syntax | null {
102  if (PROSE.test(path)) return null
103  const base = path.split('/').pop() ?? ''
104  const ext = base.includes('.') ? (base.split('.').pop() ?? '').toLowerCase() : base.toLowerCase()
105  return BY_EXT[ext] ?? null
106}
107
108/** Whether a file is scratch (/tmp, a session scratchpad): never judged. */
109export function isScratch(path: string): boolean {
110  return SCRATCH.test(path)
111}
112
113type Kind = 'code' | 'comment' | 'directive'
114
115function classify(source: string, syntax: Syntax): { text: string; kind: Kind; opensBlock: boolean; inBlock: boolean }[] {
116  const out: { text: string; kind: Kind; opensBlock: boolean; inBlock: boolean }[] = []
117  let inBlock = false
118  for (const raw of source.split('\n')) {
119    const text = raw.trim()
120    if (text === '') continue
121    let comment = false
122    let opensBlock = false
123    const wasInBlock = inBlock
124    if (inBlock) {
125      comment = true
126      if (syntax.block?.close.test(text)) inBlock = false
127    } else if (syntax.block && syntax.block.open.test(text)) {
128      comment = true
129      opensBlock = true
130      inBlock = !syntax.block.close.test(text.replace(syntax.block.open, ''))
131    } else if (syntax.line.some(re => re.test(text))) {
132      comment = true
133    }
134    out.push({ text, kind: !comment ? 'code' : DIRECTIVE.test(text) ? 'directive' : 'comment', opensBlock, inBlock: wasInBlock || inBlock })
135  }
136  return out
137}
138
139export type CommentCount = {
140  /** Comment lines, tool directives left out. */
141  comments: number
142  code: number
143  /** One-line comments of at most 64 characters right above a line of code: "// Get the user". */
144  captions: number
145  narrating: string[]
146}
147
148const CAPTION_MAX = 64
149
150/** Counts comment, caption and code lines in a piece of source, by the file's syntax. */
151export function countComments(source: string, syntax: Syntax, beforeNow = false): CommentCount {
152  const lines = classify(source, syntax)
153  let comments = 0
154  let code = 0
155  let captions = 0
156  const narrating: string[] = []
157  lines.forEach((l, i) => {
158    if (l.kind === 'code') {
159      code += 1
160      return
161    }
162    if (l.kind === 'directive') return
163    comments += 1
164    if (narrates(l.text, beforeNow)) narrating.push(l.text.length > 80 ? `${l.text.slice(0, 79)}…` : l.text)
165    const prev = lines[i - 1]
166    const next = lines[i + 1]
167    const single = (prev === undefined || prev.kind === 'code') && next?.kind === 'code'
168    const oneLiner = !l.opensBlock || !l.inBlock
169    if (single && oneLiner && l.text.length <= CAPTION_MAX && !l.text.startsWith('/**')) captions += 1
170  })
171  return { comments, code, captions, narrating }
172}
173
174/** Lines of `after` that are not in `before`: what an edit added, roughly. */
175export function addedText(before: string, after: string): string {
176  const old = new Map<string, number>()
177  for (const l of before.split('\n')) old.set(l.trim(), (old.get(l.trim()) ?? 0) + 1)
178  const added: string[] = []
179  for (const l of after.split('\n')) {
180    const k = l.trim()
181    const n = old.get(k) ?? 0
182    if (n > 0) old.set(k, n - 1)
183    else added.push(l)
184  }
185  return added.join('\n')
186}
187
188export type CommentRules = {
189  /** Also nudge an edit that captions its code line by line. */
190  density: boolean
191  /** Captions before the density rule applies. */
192  minComments: number
193  /** …and captions per line of code. */
194  ratio: number
195  /** Count "раньше …" / "теперь …" as narration. */
196  beforeNow: boolean
197}
198
199export const DEFAULT_RULES: CommentRules = { density: false, minComments: 3, ratio: 0.3, beforeNow: false }
200
201export type CommentVerdict = { kind: 'narration'; narrating: string[] } | { kind: 'density'; captions: number; code: number } | null
202
203/** Whether an edit added comments that narrate the change, or (opt-in) caption every few lines. */
204export function judgeComments(path: string, before: string, after: string, rules: CommentRules = DEFAULT_RULES): CommentVerdict {
205  if (isScratch(path)) return null
206  const syntax = syntaxOf(path)
207  if (syntax === null) return null
208  const count = countComments(addedText(before, after), syntax, rules.beforeNow)
209  if (count.narrating.length > 0) return { kind: 'narration', narrating: count.narrating }
210  if (rules.density && count.captions >= rules.minComments && count.code > 0 && count.captions / count.code >= rules.ratio) {
211    return { kind: 'density', captions: count.captions, code: count.code }
212  }
213  return null
214}
215
216export function commentNote(path: string, v: NonNullable<CommentVerdict>): string {
217  if (v.kind === 'narration') {
218    return (
219      `anti-slop: this edit to ${path} added comment lines that tell the history of the edit (${v.narrating.slice(0, 2).map(n => `"${n}"`).join(', ')}). ` +
220      `In your next edit, rephrase those lines to describe the code as it is now: keep the why, drop the history of the change. ` +
221      `Leave the other comments as they are, and do not mention this note.`
222    )
223  }
224  return (
225    `anti-slop: this edit to ${path} added ${v.captions} one-line comments captioning ${v.code} lines of code. ` +
226    `Keep a comment only when it says something the code cannot: why it is done this way, a constraint, a workaround. ` +
227    `Remove comments that restate the code, narrate the edit ("added", "now uses", "changed to"), or talk to the reader. ` +
228    `Fix it in your next edit, without mentioning this note.`
229  )
230}
231
232// ------------------------------------------------------------------ phrases
233
234export type Replacement = { phrase: string; with: string }
235
236/** "Хороший вопрос=>, load-bearing=>critical, delve" → pairs; a bare phrase means "avoid, no rewrite". */
237export function parsePhrases(spec: string): { avoid: string[]; replace: Replacement[] } {
238  const avoid: string[] = []
239  const replace: Replacement[] = []
240  for (const item of spec.split(',').map(s => s.trim()).filter(Boolean)) {
241    const [phrase, to] = item.split('=>').map(s => s.trim())
242    if (!phrase) continue
243    avoid.push(phrase)
244    if (to !== undefined) replace.push({ phrase, with: to })
245  }
246  return { avoid, replace }
247}
248
249const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "['’]")
250
251/** A word character for the phrase boundaries, in any script; "-" too, so "load-bearing-wall" is not "load-bearing". */
252const W = String.raw`[\p{L}\p{N}_-]`
253
254/** Text that looks like machine-readable data (a JSON answer from an agent): never rewritten. */
255export function looksLikeData(text: string): boolean {
256  return /^\s*[[{]/.test(text)
257}
258
259export type Rewrite = { text: string; hits: number; by: Record<string, number> }
260
261/**
262 * Applies the replacements to prose, keeping code spans and fences as they are.
263 * A replacement to nothing drops the phrase where it opens a sentence, with
264 * its trailing punctuation or dash, and capitalizes what follows.
265 */
266export function rewritePhrases(text: string, replace: readonly Replacement[]): Rewrite {
267  const by: Record<string, number> = {}
268  let hits = 0
269  if (replace.length === 0) return { text, hits, by }
270  const hit = (phrase: string) => {
271    hits += 1
272    by[phrase] = (by[phrase] ?? 0) + 1
273  }
274  const parts = text.split(/(```[\s\S]*?```|`[^`\n]*`)/)
275  const out = parts.map((part, i) => {
276    if (i % 2 === 1) return part
277    let s = part
278    for (const r of replace) {
279      const word = escape(r.phrase)
280      if (r.with === '') {
281        const re = new RegExp(
282          String.raw`(^|[.!?]\s+|\n[ \t]*)${word}(?!${W})` +
283            // "You're absolutely right that the cache is stale": the phrase carries the sentence, leave it.
284            String.raw`(?![ \t]+(?:that|about|to|который)(?!${W}))` +
285            String.raw`(?:[ \t]*[.!?,:;…]+|[ \t]+[—–])?(?:[ \t]*\p{Extended_Pictographic}[\p{Extended_Pictographic}\u{FE0F}\u{200D}]*)?\s*(\p{L}?)`,
286          'giu',
287        )
288        s = s.replace(re, (_m, lead: string, next: string) => {
289          hit(r.phrase)
290          return `${lead}${next.toUpperCase()}`
291        })
292      } else {
293        const re = new RegExp(String.raw`(?<!${W})${word}(?!${W})`, 'giu')
294        s = s.replace(re, m => {
295          hit(r.phrase)
296          return m[0] === m[0]?.toUpperCase() && m[0] !== m[0]?.toLowerCase() ? r.with.charAt(0).toUpperCase() + r.with.slice(1) : r.with
297        })
298      }
299    }
300    return s
301  })
302  return { text: out.join(''), hits, by }
303}
304
305/**
306 * Rewrites the text blocks of one stored reply. Tool calls, thinking and text
307 * that looks like JSON stay as they are, and a block the rewrite would empty
308 * keeps its text: the API refuses an empty text block.
309 */
310export function rewriteBlocks<B>(content: readonly B[], replace: readonly Replacement[]): { content: B[]; hits: number; by: Record<string, number> } {
311  const by: Record<string, number> = {}
312  let hits = 0
313  const out = content.map(block => {
314    if (block === null || typeof block !== 'object' || (block as { type?: unknown }).type !== 'text') return block
315    const text = (block as { text?: unknown }).text
316    if (typeof text !== 'string' || looksLikeData(text)) return block
317    const r = rewritePhrases(text, replace)
318    if (r.hits === 0 || r.text.trim() === '') return block
319    hits += r.hits
320    for (const [p, n] of Object.entries(r.by)) by[p] = (by[p] ?? 0) + n
321    return { ...block, text: r.text }
322  })
323  return { content: out, hits, by }
324}
325
326export function avoidSection(avoid: readonly string[]): string {
327  return (
328    `Style: do not use these words or phrases in replies, code, comments or commit messages: ${avoid.map(a => `"${a}"`).join(', ')}. ` +
329    `Say the plain thing instead. Do not open a reply by agreeing with or praising the user.`
330  )
331}
332