Reads each comment Claude adds, lets through the ones that tell a reader what the code cannot, skips docstrings, and names the lines to cut.

Claude writes more comments than a reader needs. This mod reads each comment Claude adds, lets through the ones that tell a reader something the code cannot, leaves docstrings alone, and tells Claude which lines to take out.
A line count cannot tell a good comment from a bad one. This mod charges a comment for what it adds:
| Kind | What it is | Cost |
|---|---|---|
| keep | A measured figure, a pitfall, a rule that must hold, a reason, a link, or a tool directive | Nothing |
| plain | Says something the code does not, but nothing a reader must know | 1 each, against a limit of 3, or more for a big edit |
| noise | Repeats the code under it, or tells the story of the edit | Trips the check alone |
| doc | A docstring right on a declaration | Skipped |
Claude is told which lines to delete and why:
smart-comments: 3 new comment lines in src/a.ts; L3 is fine.
Delete or rewrite L1 (repeats the code), L5 (tells the edit story).
A comment should say what the code cannot: a figure, a pitfall or a rule. Docstrings are fine.
The first test that fits decides.
userCache gives "user" and "cache".Test 3 runs before test 4, so "ensure user is not null" over if (user !== null) is noise in spite of its rule word. A line that carries on a kept sentence is kept too.
There is no model call. Every test is text matching.
An edit may add maxPlain plain comment lines, or one for each twenty lines of new code when that is more. So a 10-line edit may add 3, and a 200-line file may add 10. A limit of 0 stays 0.
sed -i, a heredoc) is checked when Claude stops, not at the edit. It counts only when all three hold: git shows it as changed, it was clean when your prompt arrived, and a shell command of the turn names it. So your own edits, and files a generator or formatter wrote, are left alone. A new file that a cp, mv, rsync or ln put in place is left alone too: it came with its comments.Write, Edit and MultiEdit are watched, and so are MCP tools named write, edit, batch_edit or append that take a path or file_path.
Allowed by default. A fixed rule decides what one is:
/** */ block with a declaration on the very next line./** /, /! */ or /// block with a function, type, macro or top-level variable on the very next line./// lines with an item or attribute on the next line, and //! lines anywhere.// run right above a func, type, var or const, starting with its name.A /** */ block over a plain statement, over a local const, or with a blank line under it is not a docstring. It is judged like any other comment.
| Option | Default | What it does |
|---|---|---|
maxPlain | 3 | Plain comment lines an edit, or a file over one turn, may add. 0 to 20. |
allowDocstrings | on | Off, a docstring is judged like any other comment. |
notify | off | On, a toast tells you when the mod flags comments in an edit or sends Claude back at the end of a turn. |
// and /* */: C, C++, TS, JS, Rust, Go, Java, Kotlin, Swift, C#, PHP, Scala, Dart, Zig.#: TOML, YAML, Python, shell, Ruby, Perl, R, Elixir, Nix.--: Lua, SQL, Haskell.Other files are skipped. So are files of installed skills and plugins (~/.agents/skills, ~/.claude/skills, ~/.claude/plugins). When a folder there is a link into a repo you work in, the real file is read.
loadUser) reads as plain, not noise.# line inside a multi-line string is read as a comment.Foo bar(1, 2);) lets a fake docstring through.claude plugin install smart-comments@vii-modshooks/register.ts 212 lines1import type { Hook, Register } from 'claude-code'
2
3import { addedCode, changedRange, crowdAround, freshLines } from '../src/diff'
4import { judgeFile, roomFor } from '../src/judge'
5import type { Judged } from '../src/judge'
6import { FOLLOW_UP, editNote, flaggedIn, turnPrompt } from '../src/note'
7import type { FileReport } from '../src/note'
8import { readOptions } from '../src/options'
9import { absolute, isInstalled } from '../src/paths'
10import { isCopied, mentions, parseStatus } from '../src/shell'
11import { syntaxOf } from '../src/syntax'
12import type { Syntax } from '../src/syntax'
13
14type Engine = Parameters<Hook<'tool.call'>>[0]
15
16const FILE_TOOLS: ReadonlySet<string> = new Set(['Write', 'Edit', 'MultiEdit'])
17const MCP_FILE_TOOL = /^mcp__.+__(write|edit|batch_edit|append)$/
18const SHELL_TOOL = 'Bash'
19// A file past this size is not read.
20const MAX_CHARS = 400_000
21const REALPATH_MS = 2000
22const GIT_MS = 3000
23const MAX_SHELL_FILES = 40
24
25// The file a call writes, as the call spells it, or null for a call that writes none.
26const targetOf = (e: { tool: string }): string | null => {
27 if (!FILE_TOOLS.has(e.tool) && !MCP_FILE_TOOL.test(e.tool)) return null
28 const args = e as { file_path?: unknown; path?: unknown }
29 const path = args.file_path ?? args.path
30 return typeof path === 'string' && path !== '' ? path : null
31}
32
33const textOf = async ($: Engine, path: string): Promise<string | null> => {
34 try {
35 const text = await $.fs.read(path)
36 return text.length > MAX_CHARS ? null : text
37 } catch {
38 return null
39 }
40}
41
42// Whether the file belongs to an installed skill or plugin. A path spelled inside one may be a link
43// into a checkout: it is followed, and the file it leads to decides.
44const isSomeoneElses = async ($: Engine, path: string): Promise<boolean> => {
45 const home = (await $.env.get('HOME')) ?? ''
46 if (!isInstalled(path, home)) return false
47 try {
48 const run = await $.process.run(['realpath', '--', path], { timeoutMs: REALPATH_MS })
49 const real = run.stdout.trim()
50 return run.exitCode !== 0 || real === '' || isInstalled(real, home)
51 } catch {
52 return true
53 }
54}
55
56const git = async ($: Engine, root: string, args: readonly string[]): Promise<string | null> => {
57 try {
58 const run = await $.process.run(['git', '-C', root, ...args], { timeoutMs: GIT_MS })
59 return run.exitCode === 0 && !run.isStdoutTruncated ? run.stdout : null
60 } catch {
61 return null
62 }
63}
64
65// The files git sees as changed, by absolute path, each marked new or not. Null outside a repository.
66type Changed = { root: string; files: Map<string, boolean> }
67
68const changedFiles = async ($: Engine): Promise<Changed | null> => {
69 try {
70 const root = (await $.session.repo())?.root
71 if (root === undefined) return null
72 const out = await git($, root, ['status', '--porcelain', '-z', '--untracked-files=all'])
73 return out === null ? null : { root, files: new Map(parseStatus(out).map(f => [`${root}/${f.path}`, f.isNew])) }
74 } catch {
75 return null
76 }
77}
78
79// A file as it was before Claude wrote it.
80type Before = { syn: Syntax; before: string | null }
81type Touched = Before & { shown: string }
82
83// The file a call is about to write, read before it runs. Null for a call that is not watched.
84const watch = async ($: Engine, e: { tool: string }): Promise<(Touched & { path: string }) | null> => {
85 const shown = targetOf(e)
86 const syn = shown === null ? null : syntaxOf(shown)
87 if (shown === null || syn === null) return null
88 const path = absolute(shown, await $.session.cwd())
89 if (await isSomeoneElses($, path)) return null
90 const before = await textOf($, path)
91 // A file that is there but cannot be read, or is too big, is not a new file: its comments are its own.
92 if (before === null && (await $.fs.exists(path))) return null
93 return { shown, syn, path, before }
94}
95
96type Gained = { all: Judged[]; added: Judged[]; max: number }
97
98// What the turn knows when it ends: its shell commands, the files git saw as changed when its prompt
99// arrived, and the files it wrote through a file tool.
100type Turn = { shell: readonly string[]; start: Changed | null; touched: ReadonlyMap<string, Touched> }
101
102// Files a shell command of the turn named and changed. Only a file that was clean at the prompt counts,
103// so its text in HEAD is its text from before the turn.
104const shellReports = async ($: Engine, turn: Turn, gained: (path: string, b: Before, after: string) => Gained): Promise<FileReport[]> => {
105 if (turn.shell.length === 0 || turn.start === null) return []
106 const now = await changedFiles($)
107 if (now === null) return []
108 const out: FileReport[] = []
109 for (const [path, isNew] of now.files) {
110 if (out.length >= MAX_SHELL_FILES) break
111 const syn = syntaxOf(path)
112 if (syn === null || turn.start.files.has(path) || turn.touched.has(path) || !mentions(turn.shell, path)) continue
113 if (isNew && isCopied(turn.shell, path)) continue
114 if (await isSomeoneElses($, path)) continue
115 const shown = path.slice(now.root.length + 1)
116 const before = isNew ? null : await git($, now.root, ['show', `HEAD:${shown}`])
117 const after = await textOf($, path)
118 if (after === null || (!isNew && before === null)) continue
119 const { added, max } = gained(path, { syn, before }, after)
120 out.push({ path: shown, judged: added, max })
121 }
122 return out
123}
124
125export const register: Register = (on, options) => {
126 const config = readOptions(options)
127 const touched = new Map<string, Touched>()
128 // The shell commands of this turn, and the files git saw as changed when the turn's prompt arrived.
129 let shell: string[] = []
130 let atPrompt: Promise<Changed | null> | null = null
131 let isAsked = false
132
133 // The comments a file gained, judged, with the room its new code earns for plain ones.
134 const gained = (path: string, b: Before, after: string): Gained => {
135 const lines = freshLines(b.before, after, b.syn)
136 const all = judgeFile(path, after, config.allowDocs)
137 return {
138 all,
139 added: all.filter(j => lines.has(j.line) && j.kind !== 'doc'),
140 max: roomFor(config.max, addedCode(b.before, after, b.syn)),
141 }
142 }
143
144 // Every turn of the main loop starts the count again, but the one this mod's follow-up starts: it
145 // keeps `isAsked`, so the turn that cleans up is not sent back itself.
146 on('turn.start', ($, e, next) => {
147 touched.clear()
148 shell = []
149 atPrompt = changedFiles($)
150 if (!e.text.includes(FOLLOW_UP)) isAsked = false
151 return next(e)
152 })
153
154 // The file is read before the call and after it, so every file tool is handled the same way. The call
155 // runs exactly once: a failure of the check on either side leaves its result as it is.
156 on('tool.call', async ($, e, next) => {
157 const command = e.tool === SHELL_TOOL ? (e as { command?: unknown }).command : undefined
158 if (typeof command === 'string') shell.push(command)
159 const file = await watch($, e).catch(() => null)
160 const out = await next(e)
161 if (file === null || out.deny !== undefined || out.isError === true) return out
162 try {
163 const { shown, syn, path, before } = file
164 const after = await textOf($, path)
165 if (after === null || after === before) return out
166 if (!touched.has(path)) touched.set(path, { shown, syn, before })
167
168 const { all, added, max } = gained(path, { syn, before }, after)
169 const crowd = added.length > 0 ? crowdAround(after, all, changedRange(before, after)) : null
170 const note = editNote({ path: shown, judged: added, max, crowd, allowDocs: config.allowDocs })
171 if (note === null) return out
172 if (config.notify) {
173 const flagged = flaggedIn(added, max, crowd)
174 $.ui.toast(`smart-comments: ${flagged} comment line${flagged === 1 ? '' : 's'} flagged in ${shown}`)
175 }
176 return { ...out, context: [...(out.context ?? []), note] }
177 } catch {
178 return out
179 }
180 })
181
182 // When the main loop answers, each file is judged against its text from before the turn. One prompt
183 // for each turn of the user's, so a second miss does not loop.
184 on('turn.complete', async ($, e, next) => {
185 if (e.agentId === undefined && e.reason === 'answer' && !isAsked && (touched.size > 0 || shell.length > 0)) {
186 try {
187 const files: FileReport[] = []
188 for (const [path, t] of touched) {
189 const after = await textOf($, path)
190 if (after === null) continue
191 const { added, max } = gained(path, t, after)
192 files.push({ path: t.shown, judged: added, max })
193 }
194 const start = atPrompt === null ? null : await atPrompt
195 files.push(...(await shellReports($, { shell, start, touched }, gained)))
196 const prompt = turnPrompt(files, config.allowDocs)
197 if (prompt !== null) {
198 isAsked = true
199 if (config.notify) {
200 const failing = files.filter(f => flaggedIn(f.judged, f.max, null) > 0).length
201 $.ui.toast(`smart-comments: sent Claude back to fix comments in ${failing} file${failing === 1 ? '' : 's'}`)
202 }
203 void $.prompt.submit({ text: prompt }).catch(() => undefined)
204 }
205 } catch {
206 // A turn that cannot be checked ends as it would without the mod.
207 }
208 }
209 return next(e)
210 })
211}
212src/diff.ts 73 lines1import type { Judged } from './judge'
2import { commentsOf } from './syntax'
3import type { Syntax } from './syntax'
4
5// The lines of `after` whose comment `before` did not have. A comment that only moved is not new; a
6// second copy of one is. `before` is null for a file that did not exist.
7export const freshLines = (before: string | null, after: string, syn: Syntax): Set<number> => {
8 const had = new Map<string, number>()
9 if (before !== null) for (const c of commentsOf(before, syn).list) had.set(c.raw, (had.get(c.raw) ?? 0) + 1)
10 const fresh = new Set<number>()
11 for (const c of commentsOf(after, syn).list) {
12 const left = had.get(c.raw) ?? 0
13 if (left > 0) had.set(c.raw, left - 1)
14 else fresh.add(c.line)
15 }
16 return fresh
17}
18
19const codeOf = (text: string, syn: Syntax): string[] => {
20 const { lines } = commentsOf(text, syn)
21 return text
22 .split('\n')
23 .filter((l, i) => l.trim() !== '' && !lines.has(i + 1))
24 .map(l => l.trim())
25}
26
27// How many lines of code `after` has that `before` did not: blank lines and lines that are all
28// comment are not counted.
29export const addedCode = (before: string | null, after: string, syn: Syntax): number => {
30 const had = new Map<string, number>()
31 if (before !== null) for (const l of codeOf(before, syn)) had.set(l, (had.get(l) ?? 0) + 1)
32 let added = 0
33 for (const l of codeOf(after, syn)) {
34 const left = had.get(l) ?? 0
35 if (left > 0) had.set(l, left - 1)
36 else added++
37 }
38 return added
39}
40
41export type Range = { from: number; to: number }
42
43// The lines of `after`, 1-based, between the head and the tail it shares with `before`. Where lines
44// were only taken out, the one line at the cut.
45export const changedRange = (before: string | null, after: string): Range => {
46 const b = after.split('\n')
47 if (before === null) return { from: 1, to: b.length }
48 const a = before.split('\n')
49 let head = 0
50 while (head < a.length && head < b.length && a[head] === b[head]) head++
51 let tail = 0
52 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
53 const from = Math.min(head + 1, b.length)
54 return { from, to: Math.max(from, b.length - tail) }
55}
56
57export type Crowd = Range & { comments: number; lines: number }
58
59// Lines looked at on each side of an edit.
60const REACH = 10
61const MIN_LINES = 8
62const MAX_SHARE = 0.4
63
64// The stretch around an edit, when more than four in ten of its lines are comments that add little.
65export const crowdAround = (text: string, judged: readonly Judged[], changed: Range): Crowd | null => {
66 const all = text.split('\n')
67 const from = Math.max(1, changed.from - REACH)
68 const to = Math.min(all.length, changed.to + REACH)
69 const lines = all.slice(from - 1, to).filter(l => l.trim() !== '').length
70 const comments = judged.filter(j => j.line >= from && j.line <= to && (j.kind === 'plain' || j.kind === 'noise')).length
71 return lines >= MIN_LINES && comments / lines > MAX_SHARE ? { from, to, comments, lines } : null
72}
73src/judge.ts 145 lines1import { commentsOf, extOf, syntaxOf } from './syntax'
2import type { Comment } from './syntax'
3import { hasRuleWord, newness } from './words'
4
5// `keep` costs nothing, `plain` counts against the limit, `noise` trips the check alone, `doc` is skipped.
6export type Kind = 'keep' | 'plain' | 'noise' | 'doc'
7
8export type Judged = { line: number; raw: string; kind: Kind; why: string }
9
10type Verdict = { kind: Kind; why: string }
11
12const MAX_NEW = 1 / 3
13// Code lines under a comment that it is compared with.
14const CONTEXT_LINES = 2
15
16const DIRECTIVE =
17 /^(eslint|tslint|prettier|biome|@ts-|ts-(ignore|expect|nocheck)|istanbul|c8 |v8 |noqa|type: ?ignore|pylint|mypy|pragma|clippy|rustfmt|go:|nolint|shellcheck|safety:|region\b|endregion\b|copyright|spdx|license|todo\b|fixme\b|<reference\b|<amd-|frozen_string_literal|fmt:|ruff:|pyright:|rubocop:|deno-(lint|fmt)|cspell|swiftlint|\+build|@(flow|jsx|generated|vitest-|jest-|vite-))/i
18
19const HISTORY: readonly RegExp[] = [
20 /\b(previously|originally|formerly|anymore|any more)\b|\bno longer\b(?! than\b)/i,
21 // "used to" right after a pronoun is history; after a noun it names a purpose, as in "a table used to dedupe peers".
22 /(^|\b(it|this|that|we|they|which|there|code|function|method) )used to\b|\bused to (be|have)\b/i,
23 /\b(was|were|been|got) (changed|moved|renamed|removed|added|replaced|refactored|rewritten|updated)\b/i,
24 /^(changed|moved|renamed|removed|added|replaced|refactored|updated|switched|migrated|extracted|rewrote|simplified|cleaned up)\b/i,
25 /^fixed\b(?)/i,
26 /\b(instead of the (old|previous)|the old (code|version|way|implementation|approach|call))\b/i,
27 /\bas (requested|asked|discussed)\b|\bper (the )?(review|request|feedback|user)\b/i,
28 /^now\b|\bnow (uses?|returns?|handles?|supports?|we|it)\b/i,
29 /^new:|^newly (added|introduced|created)\b/i,
30]
31
32const FIGURE =
33 /\d+(\.\d+)?\s?(ms|s|sec|min|h|hz|kb|mb|gb|kib|mib|gib|px|%|x|fps|bytes?|tokens?|chars?|lines?|rows?|items?|entries|times|tries)\b|\d{2,}|\d\.\d|\bO\(/i
34const LINK = /https?:\/\/|#\d+\b|\b[A-Z]{2,}-\d+\b|\bRFC ?\d+|§/
35const REASON =
36 /\b(so that|to (avoid|prevent|stop)|in case|by the time|or else|in order to|as long as|even (if|when|though)|rather than|on purpose|by design|relies on|depends on)\b/i
37
38const CONTRACT =
39 /\b(or (null|undefined)|(null|undefined|none) (when|for|if)|[01]-based|which is (how|why)|so (a|an|the|that|they|it|its|their|each|every|two|there|one|no) )\b|^(exit \d|\$\d|arguments?:)|\bin (bytes|bits|characters|columns|cells|rows|pixels|seconds|milliseconds|[km]ib)\b/i
40
41const judgeOne = (c: Comment, code: string): Verdict => {
42 if (c.shebang || DIRECTIVE.test(c.text)) return { kind: 'keep', why: 'a tool directive' }
43 if (HISTORY.some(r => r.test(c.text))) return { kind: 'noise', why: 'tells the edit story' }
44 const fresh = newness(c.text, code)
45 if (fresh !== null && fresh <= MAX_NEW) return { kind: 'noise', why: 'repeats the code' }
46 if (FIGURE.test(c.text)) return { kind: 'keep', why: 'a measured figure' }
47 if (LINK.test(c.text)) return { kind: 'keep', why: 'a link' }
48 if (CONTRACT.test(c.text)) return { kind: 'keep', why: 'a contract or a consequence' }
49 if (hasRuleWord(c.text) || REASON.test(c.text)) return { kind: 'keep', why: 'a rule or a reason' }
50 return { kind: 'plain', why: 'adds little' }
51}
52
53// A line that declares something a docstring can sit on. A local const, let or var is not one.
54const DECLARES =
55 /^(export|default|declare|async|function|class|interface|type|enum|abstract|public|private|protected|static|readonly|override|get|set|pub|fn|struct|impl|trait|mod|unsafe|func|package|namespace|module)\b|^@\w|^#\[|^[\w$]+\s*(<[^>]*>)?\(.*\)\s*(:\s*[^{]+)?\{$|^[\w$]+\??:\s/
56const TOP_LEVEL_BINDING = /^(const|let|var)\b/
57// A block statement has the shape of a method: a word, brackets, a brace.
58const CONTROL = /^(if|for|while|switch|catch)\b/
59
60const C_FAMILY: ReadonlySet<string> = new Set(['c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx', 'ipp', 'inl'])
61const C_STATEMENT = /^(return|if|else|while|for|switch|case|do|throw|delete|new|goto|sizeof|co_return|co_await)\b/
62const C_DECLARES =
63 /^(struct|union|typedef|template|using|extern|virtual|explicit|inline|constexpr|#\s*define)\b|^(?:[\w:]+(?:<[^>]*>)?[\s*&]+)+[\w:~]+\s*\([^;{]*\)[\w\s]*[{;]?$/
64// C and C++: a variable at the top level. Tested on the untrimmed line.
65const C_GLOBAL = /^(?:[\w:]+(?:<[^>]*>)?[\s*&]+)+\w+(\[[^\]]*\])?\s*(=.*)?;$/
66const GO_NAMED = /^(?:func\s+(?:\([^)]*\)\s*)?|type\s+|var\s+|const\s+)(\w+)/
67
68const declares = (source: string, ext: string): boolean => {
69 // The indent is kept, the line end is not: a CRLF file leaves a carriage return on each line.
70 const line = source.trimEnd()
71 const t = line.trim()
72 if (CONTROL.test(t)) return false
73 if (DECLARES.test(t) || TOP_LEVEL_BINDING.test(line)) return true
74 return C_FAMILY.has(ext) && !C_STATEMENT.test(t) && (C_DECLARES.test(t) || C_GLOBAL.test(line))
75}
76
77// Every comment of a file with its verdict, in line order. Empty for a file whose comments are not read.
78export const judgeFile = (path: string, text: string, allowDocs: boolean): Judged[] => {
79 const syn = syntaxOf(path)
80 if (syn === null) return []
81 const ext = extOf(path)
82 const lines = text.split('\n')
83 const { list, lines: commentLines } = commentsOf(text, syn)
84
85 // The first line under the run of comment lines that `line` is in.
86 const under = (line: number): string => {
87 let n = line
88 while (commentLines.has(n + 1)) n++
89 return lines[n] ?? ''
90 }
91 const below = (line: number): string => {
92 const got: string[] = []
93 for (let i = line; i < lines.length && got.length < CONTEXT_LINES; i++) {
94 const source = lines[i] ?? ''
95 if (source.trim() !== '' && !commentLines.has(i + 1)) got.push(source)
96 }
97 return got.join('\n')
98 }
99 // A docstring sits right on a declaration. In Go it is a plain comment run that starts with the name.
100 const isDoc = (c: Comment): boolean => {
101 if (c.code !== '') return false
102 if (c.doc) return c.raw.startsWith('//!') || declares(under(c.line), ext)
103 if (ext !== 'go') return false
104 let top = c.line
105 while (commentLines.has(top - 1)) top--
106 const name = GO_NAMED.exec(under(c.line).trim())?.[1]
107 return name !== undefined && list.find(x => x.line >= top)?.text.split(/\s/)[0] === name
108 }
109
110 const out: Judged[] = []
111 // The verdict of the line above, when it was kept and its sentence has not ended.
112 let open: { line: number; why: string } | null = null
113 for (const c of list) {
114 if (allowDocs && isDoc(c)) {
115 open = null
116 out.push({ line: c.line, raw: c.raw, kind: 'doc', why: 'a docstring' })
117 continue
118 }
119 let verdict = judgeOne(c, c.code !== '' ? c.code : below(c.line))
120 if (verdict.kind === 'plain' && open !== null && open.line === c.line - 1) verdict = { kind: 'keep', why: open.why }
121 open = verdict.kind === 'keep' && c.code === '' && !/[.!?:]$/.test(c.text) ? { line: c.line, why: verdict.why } : null
122 out.push({ line: c.line, raw: c.raw, ...verdict })
123 }
124 return out
125}
126
127export type Tally = { keep: number; plain: number; noise: number }
128
129export const tallyOf = (judged: readonly Judged[]): Tally => ({
130 keep: judged.filter(j => j.kind === 'keep').length,
131 plain: judged.filter(j => j.kind === 'plain').length,
132 noise: judged.filter(j => j.kind === 'noise').length,
133})
134
135const CODE_PER_PLAIN = 20
136
137// How many plain comments an edit may add: the limit, or one for each twenty lines of new code when
138// that is more. A limit of zero stays zero.
139export const roomFor = (max: number, codeLines: number): number => (max === 0 ? 0 : Math.max(max, Math.floor(codeLines / CODE_PER_PLAIN)))
140
141export const trips = (judged: readonly Judged[], max: number): boolean => {
142 const t = tallyOf(judged)
143 return t.noise > 0 || t.plain > max
144}
145src/note.ts 72 lines1import type { Crowd } from './diff'
2import { tallyOf } from './judge'
3import type { Judged } from './judge'
4
5const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
6
7// Line numbers as text, with lines in a row as one range: L1, L3-5.
8const linesOf = (judged: readonly Judged[]): string => {
9 const lines = judged.map(j => j.line)
10 const out: string[] = []
11 for (let from = 0; from < lines.length; ) {
12 let to = from
13 while (lines[to + 1] === lines[to]! + 1) to++
14 out.push(to === from ? `L${lines[from]}` : `L${lines[from]}-${lines[to]}`)
15 from = to + 1
16 }
17 return out.join(', ')
18}
19
20const byFault = (noise: readonly Judged[]): string =>
21 [...new Set(noise.map(j => j.why))].map(why => `${linesOf(noise.filter(j => j.why === why))} (${why})`).join(', ')
22
23const closing = (allowDocs: boolean): string => `A comment should say what the code cannot: a figure, a pitfall or a rule.${allowDocs ? ' Docstrings are fine.' : ''}`
24
25// How many of a file's new comment lines Claude is asked to act on: the noise, and the plain lines
26// when they are over the limit or sit in a crowded stretch.
27export const flaggedIn = (judged: readonly Judged[], max: number, crowd: Crowd | null): number => {
28 const t = tallyOf(judged)
29 return t.noise + (t.plain > max || crowd !== null ? t.plain : 0)
30}
31
32// What is wrong with the new comments of one file, or null when nothing is. `judged` holds the new
33// comments only.
34const fileLines = (path: string, judged: readonly Judged[], max: number, crowd: Crowd | null): string[] | null => {
35 const tally = tallyOf(judged)
36 const noise = judged.filter(j => j.kind === 'noise')
37 const plain = judged.filter(j => j.kind === 'plain')
38 if (flaggedIn(judged, max, crowd) === 0) return null
39
40 const kept = judged.filter(j => j.kind === 'keep')
41 const fine = kept.length === 0 ? '' : `; ${linesOf(kept)} ${kept.length === 1 ? 'is' : 'are'} fine`
42 const out = [`smart-comments: ${plural(tally.keep + tally.plain + tally.noise, 'new comment line')} in ${path}${fine}.`]
43 if (noise.length > 0) out.push(`Delete or rewrite ${byFault(noise)}.`)
44 if (plain.length > max) {
45 const stay = max === 0 ? 'delete' : `keep ${max === 1 ? 'one' : max} of`
46 out.push(`${plural(plain.length, 'line')} ${plain.length === 1 ? 'adds' : 'add'} little, and the limit is ${max}: ${stay} ${linesOf(plain)}.`)
47 } else if (crowd !== null && plain.length > 0) {
48 out.push(`L${crowd.from}-${crowd.to} is crowded, ${crowd.comments} of ${crowd.lines} lines are comments that add little: add no more like ${linesOf(plain)}.`)
49 }
50 return out
51}
52
53export type EditReport = { path: string; judged: readonly Judged[]; max: number; crowd: Crowd | null; allowDocs: boolean }
54
55// The note Claude reads after an edit, or null when the edit's comments pass.
56export const editNote = (r: EditReport): string | null => {
57 const lines = fileLines(r.path, r.judged, r.max, r.crowd)
58 return lines === null ? null : [...lines, closing(r.allowDocs)].join('\n')
59}
60
61// The first line of the follow-up prompt. A turn whose prompt holds it was started by this mod.
62export const FOLLOW_UP = 'smart-comments: comments you added this turn need one more pass.'
63
64export type FileReport = { path: string; judged: readonly Judged[]; max: number }
65
66// The prompt sent when a turn ends with files that still trip, or null when none does.
67export const turnPrompt = (files: readonly FileReport[], allowDocs: boolean): string | null => {
68 const blocks = files.map(f => fileLines(f.path, f.judged, f.max, null)).filter(b => b !== null)
69 if (blocks.length === 0) return null
70 return [FOLLOW_UP, ...blocks.flat(), `${closing(allowDocs)} Change these lines and nothing else.`].join('\n')
71}
72src/options.ts 16 lines1export type Config = { max: number; allowDocs: boolean; notify: boolean }
2
3const DEFAULT_MAX = 3
4const MIN_MAX = 0
5const MAX_MAX = 20
6
7export const readOptions = (o: Readonly<Record<string, unknown>>): Config => {
8 const max = o['maxPlain']
9 const docs = o['allowDocstrings']
10 return {
11 max: typeof max === 'number' && Number.isFinite(max) ? Math.min(MAX_MAX, Math.max(MIN_MAX, Math.floor(max))) : DEFAULT_MAX,
12 allowDocs: typeof docs === 'boolean' ? docs : true,
13 notify: o['notify'] === true,
14 }
15}
16src/paths.ts 17 lines1// Folders, under the home directory, that hold installed skills and plugins.
2const INSTALLED = ['.agents/skills', '.claude/skills', '.claude/plugins']
3
4// Whether a path, as spelled, is inside an installed skill or plugin. A link is not followed here.
5export const isInstalled = (path: string, home: string): boolean => home !== '' && INSTALLED.some(dir => path.startsWith(`${home}/${dir}/`))
6
7// The path from the root, with `.`, `..` and doubled slashes taken out, so two spellings of one file
8// give one path. A link is not followed.
9export const absolute = (path: string, cwd: string): string => {
10 const parts: string[] = []
11 for (const part of (path.startsWith('/') ? path : `${cwd}/${path}`).split('/')) {
12 if (part === '..') parts.pop()
13 else if (part !== '' && part !== '.') parts.push(part)
14 }
15 return `/${parts.join('/')}`
16}
17src/shell.ts 37 lines1// A file `git status` lists as changed. `path` is relative to the repository root.
2export type StatusFile = { path: string; isNew: boolean }
3
4// Reads `git status --porcelain -z`: entries end in NUL, and a rename or copy is followed by its old
5// name. Deleted files are left out.
6export const parseStatus = (out: string): StatusFile[] => {
7 const parts = out.split('\0')
8 const files: StatusFile[] = []
9 for (let i = 0; i < parts.length; i++) {
10 const entry = parts[i] ?? ''
11 if (entry.length < 4) continue
12 const status = entry.slice(0, 2)
13 if (status.includes('R') || status.includes('C')) i++
14 if (status.includes('D')) continue
15 files.push({ path: entry.slice(3), isNew: status === '??' || status.includes('A') })
16 }
17 return files
18}
19
20const nameOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
21
22// Whether a shell command of the turn names the file. A file no command names was made by a tool the
23// command ran, such as a generator or a formatter, and is not judged.
24export const mentions = (commands: readonly string[], path: string): boolean => {
25 const name = nameOf(path)
26 return name !== '' && commands.some(c => c.includes(name))
27}
28
29const COPIES = /(^|[\s;&|(])(cp|mv|rsync|ln|git\s+mv)\s/
30
31// Whether a command that names the file copies or moves files. A new file put in place that way
32// came with its comments: nobody wrote them this turn.
33export const isCopied = (commands: readonly string[], path: string): boolean => {
34 const name = nameOf(path)
35 return name !== '' && commands.some(c => c.includes(name) && COPIES.test(c))
36}
37src/syntax.ts 75 lines1export type Syntax = { line: string; block: readonly [string, string] | null }
2
3type Family = Syntax & { ext: readonly string[] }
4
5const C_BLOCK = ['/*', '*/'] as const
6
7const FAMILIES: readonly Family[] = [
8 {
9 line: '//',
10 block: C_BLOCK,
11 ext: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'rs', 'go', 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx', 'ipp', 'inl', 'java', 'kt', 'swift', 'cs', 'php', 'scala', 'dart', 'zig'],
12 },
13 { line: '#', block: null, ext: ['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yaml', 'yml', 'toml', 'pl', 'r', 'ex', 'exs', 'nix'] },
14 { line: '--', block: null, ext: ['lua', 'sql', 'hs'] },
15]
16
17export const extOf = (path: string): string => (/\.([a-z0-9]+)$/i.exec(path)?.[1] ?? '').toLowerCase()
18
19// The comment syntax of a file, by its ending. Null for a file whose comments are not read.
20export const syntaxOf = (path: string): Syntax | null => FAMILIES.find(f => f.ext.includes(extOf(path))) ?? null
21
22export type Comment = {
23 // 1-based.
24 line: number
25 text: string
26 // The comment as written, marker included, without the code before it.
27 raw: string
28 // The code a trailing comment shares its line with; empty for a line that is all comment.
29 code: string
30 // Written in docstring form: `/**`, `/*!`, `///` or `//!`.
31 doc: boolean
32 shebang: boolean
33}
34
35// `lines` holds every line that is all comment, the empty lines of a block too.
36export type Comments = { list: Comment[]; lines: Set<number> }
37
38// A marker after an odd count of quotes sits inside a string.
39const evenQuotes = (s: string): boolean => ['"', "'", '`'].every(q => s.split(q).length % 2 === 1)
40
41export const commentsOf = (text: string, syn: Syntax): Comments => {
42 const list: Comment[] = []
43 const lines = new Set<number>()
44 const [open, close] = syn.block ?? ['', '']
45 let inBlock = false
46 let docBlock = false
47 text.split('\n').forEach((source, i) => {
48 const t = source.trim()
49 const push = (body: string, code = '', doc = false): void => {
50 if (code === '') lines.add(i + 1)
51 const clean = body.replace(/^[\s*!/#-]+/, '').trim()
52 if (clean !== '') list.push({ line: i + 1, text: clean, raw: t.slice(code.length).trim(), code, doc, shebang: false })
53 }
54 if (inBlock) {
55 const end = t.indexOf(close)
56 if (end >= 0) inBlock = false
57 push(end >= 0 ? t.slice(0, end) : t, '', docBlock)
58 } else if (open !== '' && t.startsWith(open)) {
59 docBlock = /^\/\*[*!](?!\/)/.test(t)
60 const rest = t.slice(open.length)
61 const end = rest.indexOf(close)
62 if (end < 0) inBlock = true
63 push(end >= 0 ? rest.slice(0, end) : rest, '', docBlock)
64 } else if (t.startsWith(syn.line)) {
65 lines.add(i + 1)
66 if (i === 0 && t.startsWith('#!')) list.push({ line: 1, text: t, raw: t, code: '', doc: false, shebang: true })
67 else push(t.slice(syn.line.length), '', syn.line === '//' && /^\/\/[/!](?!\/)/.test(t))
68 } else {
69 const at = t.indexOf(` ${syn.line} `)
70 if (at > 0 && evenQuotes(t.slice(0, at))) push(t.slice(at + syn.line.length + 1), t.slice(0, at))
71 }
72 })
73 return { list, lines }
74}
75src/words.ts 64 lines1// A rough stem: enough for "users" to meet "user" and "caches" to meet "cache".
2const stem = (word: string): string => {
3 let s = word
4 if (s.length > 4 && s.endsWith('ing')) s = s.slice(0, -3)
5 else if (s.length > 4 && s.endsWith('ed')) s = s.slice(0, -2)
6 else if (s.length > 4 && s.endsWith('es')) s = s.slice(0, -2)
7 else if (s.length > 3 && s.endsWith('s') && !s.endsWith('ss')) s = s.slice(0, -1)
8 return s.length > 3 && s.endsWith('e') ? s.slice(0, -1) : s
9}
10
11// The stemmed words of prose or code. A name is split at its humps and underscores.
12export const wordsOf = (s: string): string[] =>
13 (
14 s
15 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
16 .toLowerCase()
17 .match(/[a-z]{2,}/g) ?? []
18 ).map(stem)
19
20const setOf = (list: string): ReadonlySet<string> => new Set(list.split(' ').map(stem))
21
22const FILLER = setOf(
23 'the an of to in on for and or is are be this that these those it its with from by as at into over out up we our if then so each all any here there will when which whether what who how can may also just very than them they their has have was were been being param return returns',
24)
25
26// Verbs and nouns that say an action happens without saying which.
27const EMPTY = setOf(
28 'get set do does make call use run perform handle check create add update initialize init define declare loop iterate go start begin end put take give helper function method variable value',
29)
30
31const RULE = setOf(
32 'must never always only unless not cannot before after until under below above exceed otherwise because since avoid ensure require assume invariant guarantee careful beware trap gotcha workaround hack race deadlock overflow underflow leak unsafe safety important warning note else',
33)
34
35export const hasRuleWord = (text: string): boolean => wordsOf(text).some(w => RULE.has(w))
36
37// Words a keyword or operator in the code stands for.
38const SPOKEN: readonly (readonly [RegExp, string])[] = [
39 [/\+\+|\+= ?1\b/, 'increment'],
40 [/--|-= ?1\b/, 'decrement'],
41 [/\b(import|require|use)\b/, 'import load'],
42 [/\b(throw|raise)\b/, 'throw raise error'],
43 [/\b(catch|except|rescue)\b/, 'catch error exception'],
44 [/\b(print|println|console\.log|echo)\b/, 'print log output'],
45 [/\b(delete|del)\b/, 'delete remove'],
46 [/\bawait\b/, 'wait await'],
47 [/\bnew\b|\{\}|\[\]/, 'new empty instance'],
48]
49
50const codeWords = (code: string): Set<string> => {
51 const known = new Set(wordsOf(code))
52 for (const [seen, said] of SPOKEN) if (seen.test(code)) for (const w of wordsOf(said)) known.add(w)
53 return known
54}
55
56// The share of a comment's words that the code beside it does not already say: 0 when the code says all
57// of them, 1 when it says none. Null for a comment with no word that could carry meaning.
58export const newness = (text: string, code: string): number | null => {
59 const words = [...new Set(wordsOf(text))].filter(w => !FILLER.has(w) && !EMPTY.has(w) && !RULE.has(w))
60 if (words.length === 0) return null
61 const known = codeWords(code)
62 return words.filter(w => !known.has(w)).length / words.length
63}
64