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…

Unofficial — not made or endorsed by Anthropic.
Before/after examples, install steps and the measurements behind each plugin: padezhnov.com/en/tools.

| Mod | What it does |
|---|---|
| anti-slop | Flags 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-edits | File 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.
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.
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.comments: density): three or more short one-line captions right above code, at 30% or more of the code lines the edit added.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.
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.
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.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).--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./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/).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.
What each mod reads and changes, and how to report a problem: SECURITY.md.
./scripts/check.sh # claude plugin validate + tsc + claude plugin test (may make one short claude -p call to refresh the mods switch)
MIT licensed.
hooks/register.ts 159 lines1import 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}
159hooks/slop.ts 332 lines1// 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