Stops Claude adding banner, multi-line, long or narrating code comments

Three Claude Code mods: plugins built from function hooks that run inside Claude Code, in the terminal and the desktop app's Code tab.
| Mod | What it does |
|---|---|
| house-rules | Keeps Claude's git work under your identity: removes AI co-author trailers, renames claude/ and ai/ branches, and blocks commits or PRs made as the wrong person |
| usage-band | Shows 5-hour and 7-day usage limits, context fill, cost and the git branch in a band above the prompt |
| lean-comments | Stops Claude adding banner, multi-line, long or narrating code comments, so comments only explain a non-obvious why in one short line |
Needs Claude Code 2.1.286 or later.
/plugin marketplace add WilsonKinyua/claude-mods
/plugin install house-rules@wilson-mods
/plugin install usage-band@wilson-mods
/plugin install lean-comments@wilson-mods
/reload-plugins
Plugins install at user scope, so they load in every project. Add --scope project to claude plugin install to turn one on for a single repo instead.
Mods run with the same access as Claude Code. Read the source before you install, as you would with any package.
It checks every Bash command Claude runs, including subagents' commands, before the command executes.
Corrected automatically (you get a toast, and Claude is told what actually ran):
-c user.email=…, -c user.name=…, GIT_AUTHOR_* / GIT_COMMITTER_* env vars and any --author that isn't yours are removed.Co-authored-by:, Generated-by:, 🤖 Generated with Claude Code and Claude session links are removed from commit messages and gh pr / gh issue text.git checkout -b claude/fix-login creates feat/fix-login, and a push in the same command follows the rename.Blocked, with the reason given to Claude:
git branch -m command to rename it first.git commit in a repo whose user.email or user.name isn't yours.git config user.email / user.name set to anything else.gh pr create while gh is signed in to another account. This check only runs if you set a GitHub login.Set these in /config, or from a terminal:
claude plugin configure house-rules@wilson-mods
| Setting | Default | Meaning |
|---|---|---|
gitEmail | your global user.email | The only email commits may be authored as |
gitNames | your global user.name | Accepted author names, separated by commas |
githubLogin | empty (no check) | The gh account allowed to open PRs |
blockedBranchPrefixes | claude,ai,bot,copilot,codex,… | Branch prefixes that get renamed or blocked |
branchType | feat | The prefix a blocked branch is renamed to |
If one repo should keep a different identity, such as a work email, run this inside that repo yourself:
git config house-rules.allow-identity true
Claude can't set that key; the mod blocks it.

/usage, or the details button, opens a pane with the context breakdown by category and spend per day.The rings are drawn as SVG in the desktop app and as ◔◑◕ glyphs in the terminal. Limits only appear on a Claude subscription. The today and month totals only count sessions where the mod was loaded.
It checks every Edit and Write Claude makes. Only comments the change adds are checked; existing comments are left alone. By default a change is refused, with a list of the offending comments, and Claude rewrites it. A comment is flagged when it:
// ── Section ────── or # ==========;/** … */ doc blocks;// Step 1: load config or # Helpers.A change that adds more than 3 new comments is flagged too.
Tool directives are never flagged: eslint-disable, @ts-expect-error, noqa, # type: ignore, prettier-ignore, //go:, shebangs, license headers and similar. Markdown and other prose files are skipped.
It understands comment syntax for JS/TS, Go, Java, Kotlin, Swift, C/C++, C#, Rust, Dart, PHP, Python, Ruby, shell, YAML, TOML, SQL, Lua, CSS/SCSS, HTML, Vue, Svelte and Astro.
| Setting | Default | Meaning |
|---|---|---|
mode | block | block refuses the change so Claude rewrites it; warn lets it through and tells Claude what to fix; off disables the mod |
maxLength | 120 | Longest comment text allowed, in characters |
maxNewComments | 3 | How many new comments one change may add |
allowDocComments | false | Let /** … */ doc blocks span several lines, e.g. for a published library's API |
claude --plugin-dir ./plugins/usage-band
claude plugin validate ./plugins/usage-band
Saving a file reloads the mod in the running session. Bump version in the plugin's plugin.json and in .claude-plugin/marketplace.json when you release, so claude plugin marketplace update wilson-mods picks up the change.
hooks/register.ts 188 lines1import type { EngineInterface, PluginOptions, Register, ToolCallResult } from 'claude-code'
2
3type Syntax = { line: string[]; blocks: [string, string][] }
4type Comment = { text: string; lines: number; isDoc: boolean }
5type Settings = { mode: string; maxLength: number; maxNew: number; allowDocComments: boolean }
6
7const SLASH_BLOCK: [string, string] = ['/*', '*/']
8const HTML_BLOCK: [string, string] = ['<!--', '-->']
9const C_LIKE: Syntax = { line: ['//'], blocks: [SLASH_BLOCK] }
10const HASH: Syntax = { line: ['#'], blocks: [] }
11
12const SYNTAX: Record<string, Syntax> = {
13 ...Object.fromEntries(
14 ['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'mts', 'cts', 'go', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'rs', 'dart', 'scala', 'groovy', 'gradle', 'scss', 'less'].map(ext => [ext, C_LIKE]),
15 ),
16 ...Object.fromEntries(['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'r', 'pl', 'ex', 'exs', 'tf', 'dockerfile'].map(ext => [ext, HASH])),
17 php: { line: ['//', '#'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
18 css: { line: [], blocks: [SLASH_BLOCK] },
19 sql: { line: ['--'], blocks: [SLASH_BLOCK] },
20 lua: { line: ['--'], blocks: [] },
21 html: { line: [], blocks: [HTML_BLOCK] },
22 vue: { line: ['//'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
23 svelte: { line: ['//'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
24 astro: { line: ['//'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
25}
26
27const DIRECTIVE = /^(?:eslint|@ts-|prettier-ignore|biome-ignore|istanbul|c8 |noqa|type:\s*ignore|pylint|pyright|mypy|pragma|#?region|#?endregion|@license|@preserve|SPDX|jshint|global |@jsx|@flow|@vite-ignore|webpack|nolint|go:|\+build|-\*-|!|@refresh|rubocop|shellcheck|phpcs|@phpstan|@psalm|@var |@codeCoverageIgnore|NOSONAR|language=)/i
28const DECORATIVE = /[─━═│┃█▓▒░╔╗╚╝]|[-=*#~_+]{4,}/
29const NARRATION = /^(?:step \d+\b|first,? we\b|now,? we\b|here,? we\b|next,? we\b|this (?:function|method|component|class|hook|file|module|block)\b|the following\b|section\b|end of\b|(?:helpers?|imports?|constants?|types?|exports?|utils?|utilities|state|handlers?|styles?|setup|main|config(?:uration)?)\s*:?\s*$)/i
30
31const extension = (path: string) => {
32 const base = path.split('/').pop() ?? ''
33 return base.toLowerCase() === 'dockerfile' ? 'dockerfile' : (base.split('.').pop() ?? '').toLowerCase()
34}
35
36const hasBalancedQuotes = (text: string) => ['"', "'", '`'].every(q => (text.split(q).length - 1) % 2 === 0)
37
38function trailingComment(line: string, tokens: string[]) {
39 for (const token of tokens) {
40 let at = line.indexOf(` ${token}`)
41 while (at > 0) {
42 if (hasBalancedQuotes(line.slice(0, at))) return line.slice(at + 1)
43 at = line.indexOf(` ${token}`, at + 1)
44 }
45 }
46 return undefined
47}
48
49function extractComments(source: string, syntax: Syntax): Comment[] {
50 const comments: Comment[] = []
51 let block: { end: string; lines: string[] } | undefined
52 let run: string[] = []
53
54 const flushRun = () => {
55 if (run.length > 0) comments.push({ text: run.join('\n'), lines: run.length, isDoc: false })
56 run = []
57 }
58
59 source.split('\n').forEach((raw, index) => {
60 const line = raw.trim()
61
62 if (block) {
63 block.lines.push(line)
64 if (line.includes(block.end)) {
65 comments.push({ text: block.lines.join('\n'), lines: block.lines.length, isDoc: block.lines[0]?.startsWith('/**') ?? false })
66 block = undefined
67 }
68 return
69 }
70
71 if (index === 0 && line.startsWith('#!')) return
72
73 const lineToken = syntax.line.find(token => line.startsWith(token))
74 if (lineToken) {
75 run.push(line)
76 return
77 }
78 flushRun()
79
80 const opener = syntax.blocks.find(([open]) => line.startsWith(open))
81 if (opener) {
82 const [open, close] = opener
83 if (line.includes(close, open.length)) {
84 comments.push({ text: line, lines: 1, isDoc: line.startsWith('/**') })
85 } else {
86 block = { end: close, lines: [line] }
87 }
88 return
89 }
90
91 const trailing = trailingComment(line, syntax.line)
92 if (trailing) comments.push({ text: trailing, lines: 1, isDoc: false })
93 })
94
95 flushRun()
96 return comments
97}
98
99const content = (comment: Comment) =>
100 comment.text
101 .split('\n')
102 .map(line => line.replace(/^(?:\/\*\*?|\*\/|\*|\/\/+|#+|--|<!--|-->)\s?/, '').replace(/\s*(?:\*\/|-->)$/, '').trim())
103 .filter(Boolean)
104 .join(' ')
105
106const normalize = (comment: Comment) => content(comment).replace(/\s+/g, ' ').toLowerCase()
107
108function problemsWith(comment: Comment, settings: Settings) {
109 const text = content(comment)
110 if (DIRECTIVE.test(text) || DIRECTIVE.test(comment.text.replace(/^\/\/|^#/, ''))) return []
111
112 const problems: string[] = []
113 if (DECORATIVE.test(comment.text)) problems.push('decorative banner or divider')
114 if (comment.lines > 1 && !(comment.isDoc && settings.allowDocComments)) problems.push(`spans ${comment.lines} lines`)
115 if (text.length > settings.maxLength) problems.push(`${text.length} characters (max ${settings.maxLength})`)
116 if (NARRATION.test(text)) problems.push('narrates or labels the code instead of explaining a non-obvious why')
117 return problems
118}
119
120function settingsFrom(options: PluginOptions): Settings {
121 const number = (value: unknown, fallback: number) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback)
122 return {
123 mode: String(options.mode ?? 'block'),
124 maxLength: number(options.maxLength, 120),
125 maxNew: number(options.maxNewComments, 3),
126 allowDocComments: options.allowDocComments === true,
127 }
128}
129
130export function review(path: string, before: string, after: string, settings: Settings) {
131 const syntax = SYNTAX[extension(path)]
132 if (!syntax) return []
133
134 const existing = new Set(extractComments(before, syntax).map(normalize))
135 const added = extractComments(after, syntax).filter(comment => !existing.has(normalize(comment)))
136 const findings = added
137 .map(comment => ({ comment, problems: problemsWith(comment, settings) }))
138 .filter(finding => finding.problems.length > 0)
139 .map(({ comment, problems }) => `- \`${comment.text.split('\n')[0]?.slice(0, 80)}${comment.lines > 1 ? ' …' : ''}\`: ${problems.join(', ')}`)
140
141 const counted = added.filter(comment => !DIRECTIVE.test(content(comment)))
142 if (counted.length > settings.maxNew) findings.push(`- ${counted.length} new comments in one change (max ${settings.maxNew}); most code needs none`)
143
144 return findings
145}
146
147async function enforce(
148 $: EngineInterface,
149 settings: Settings,
150 path: string,
151 before: string,
152 after: string,
153 run: () => Promise<ToolCallResult>,
154): Promise<ToolCallResult> {
155 const findings = review(path, before, after, settings)
156 if (findings.length === 0) return run()
157
158 const file = path.split('/').pop()
159 const message = [
160 `lean-comments: this change adds comments that break the comment rules in ${file}:`,
161 ...findings,
162 'Only comment a non-obvious why (a constraint, workaround or invariant), as one short sentence on one line. Prefer clearer names over comments. Remove or shorten these, then make the change again.',
163 ].join('\n')
164
165 if (settings.mode !== 'warn') return { deny: message }
166
167 $.ui.toast(`lean-comments: ${findings.length} comment issue${findings.length === 1 ? '' : 's'} in ${file}`)
168 const ran = await run()
169 return ran.deny !== undefined ? ran : { ...ran, context: [...(ran.context ?? []), message] }
170}
171
172export const register: Register = (on, options) => {
173 const settings = settingsFrom(options)
174 if (settings.mode === 'off') return
175
176 on('tool.call', { tool: 'Edit' }, ($, e, next) =>
177 enforce($, settings, e.file_path, e.old_string, e.new_string, () => next(e)),
178 )
179
180 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
181 const before = await $.fs.read(e.file_path).then(
182 text => (typeof text === 'string' ? text : ''),
183 () => '',
184 )
185 return enforce($, settings, e.file_path, before, e.content, () => next(e))
186 })
187}
188