Enforces git identity, attribution and branch-naming rules on Bash calls

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 203 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3const DEFAULT_PREFIXES = 'claude,ai,bot,bots,copilot,cursor,codex,gpt,openai,anthropic,assistant,agent,llm,devin,project-thread'
4const ALLOW_KEY = 'house-rules.allow-identity'
5
6const AT_COMMAND = String.raw`(?:^|[;&|(]\s*)`
7const ARG = String.raw`(?:"[^"]*"|'[^']*'|[^\s;&|]+)`
8const NAME = String.raw`([^\s;&|'"]+)`
9const END = String.raw`(?=[\s;&|'")]|$)`
10const unquote = (value: string) => value.replace(/^(["'])(.*)\1$/, '$2')
11const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
12
13const TYPE_PREFIX = /^(feat|fix|test|refactor|docs|chore)\//
14
15type Rules = { blocked: RegExp; branchType: string }
16type Identity = { email: string; names: string[]; githubLogin: string }
17
18const list = (value: PluginOptions[string] | undefined) =>
19 (Array.isArray(value) ? value : String(value ?? '').split(','))
20 .map(item => String(item).trim())
21 .filter(Boolean)
22
23function rulesFrom(options: PluginOptions): Rules {
24 const prefixes = list(options.blockedBranchPrefixes ?? DEFAULT_PREFIXES).map(escapeRegExp)
25 return {
26 blocked: prefixes.length > 0 ? new RegExp(String.raw`^(${prefixes.join('|')})[/-]`, 'i') : /$^/,
27 branchType: String(options.branchType ?? 'feat').replace(/\/+$/, '') || 'feat',
28 }
29}
30
31async function identityFrom($: EngineInterface, options: PluginOptions): Promise<Identity> {
32 const globalConfig = (key: string) =>
33 $.process.run(['git', 'config', '--global', key]).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''))
34 const email = String(options.gitEmail ?? '').trim() || (await globalConfig('user.email'))
35 const names = list(options.gitNames)
36 return {
37 email,
38 names: names.length > 0 ? names : [await globalConfig('user.name')].filter(Boolean),
39 githubLogin: String(options.githubLogin ?? '').trim(),
40 }
41}
42
43const ATTRIBUTION = String.raw`(?:🤖\s*)?(?:co-authored-by:|generated-by:|assisted-by:|claude-session:|generated (?:with|by) \[?claude)`
44const ATTRIBUTION_LINE = new RegExp(String.raw`^[ \t]*(?:${ATTRIBUTION}|[^\n]*claude\.ai/code/session)[^\n]*(?:\n|$)`, 'gim')
45const ATTRIBUTION_ESCAPED = new RegExp(String.raw`(?:\\n)+${ATTRIBUTION}[^"'\\\n]*`, 'gi')
46const ATTRIBUTION_FLAG = new RegExp(String.raw`\s+(?:-m|--message|--trailer)(?:=|\s+)(["'])${ATTRIBUTION}[^"']*\1`, 'gi')
47const WRITES_MESSAGE = /\b(?:git\s+(?:[^;&|\n]*\s)?(?:commit|tag|notes|merge|cherry-pick|revert)|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|merge))\b/
48
49const CREATES_BRANCH = [
50 new RegExp(String.raw`\bgit\s+(?:checkout|switch)\s+(?:[^;&|\n]*?\s)?(?:-b|-B|-c|-C|--create|--force-create)(?:=|\s+)${NAME}${END}`, 'g'),
51 new RegExp(String.raw`\bgit\s+branch\s+(?:-m|-M|--move|-c|-C|--copy)\s+(?:\S+\s+)?${NAME}${END}`, 'g'),
52 new RegExp(String.raw`\bgit\s+branch\s+(?:(?:-f|--force|--track|--no-track)\s+)*(?!-)${NAME}${END}`, 'g'),
53 new RegExp(String.raw`\bgit\s+worktree\s+add\s+(?:[^;&|\n]*?\s)?-[bB]\s+${NAME}${END}`, 'g'),
54]
55
56function publishedBranches(command: string, rules: Rules) {
57 const refs: string[] = []
58 for (const match of command.matchAll(/\bgit\s+push\b([^;&|\n]*)/g)) {
59 for (const token of (match[1] ?? '').trim().split(/\s+/)) refs.push(unquote(token.split(':').pop() ?? '').replace(/[`,;)]+$/, ''))
60 }
61 for (const match of command.matchAll(/\bgh\s+pr\s+create\b[^;&|\n]*?--head(?:=|\s+)([^\s;&|]+)/g)) refs.push(unquote(match[1] ?? ''))
62 return refs.filter(ref => rules.blocked.test(ref))
63}
64
65
66function suggestedName(branch: string, rules: Rules) {
67 const rest = branch.replace(rules.blocked, '')
68 return TYPE_PREFIX.test(rest) ? rest : `${rules.branchType}/${rest}`
69}
70
71function stripIdentityOverrides(command: string, email: string, notes: Set<string>) {
72 return command
73 .replace(new RegExp(String.raw`\s+-c\s+(?:(["'])user\.(?:name|email)=[^"']*\1|user\.(?:name|email)=${ARG})`, 'g'), () => {
74 notes.add('removed a `-c user.*` identity override')
75 return ''
76 })
77 .replace(new RegExp(String.raw`\bGIT_(?:AUTHOR|COMMITTER)_(?:NAME|EMAIL)=${ARG}\s+`, 'g'), () => {
78 notes.add('removed a GIT_AUTHOR/COMMITTER env override')
79 return ''
80 })
81 .replace(new RegExp(String.raw`\s+--author(?:=|\s+)${ARG}`, 'g'), match => {
82 if (email && match.includes(email)) return match
83 notes.add('removed an `--author` override')
84 return ''
85 })
86}
87
88function stripAttribution(command: string, notes: Set<string>) {
89 if (!WRITES_MESSAGE.test(command)) return command
90
91 const note = () => {
92 notes.add('removed AI attribution / co-author trailers')
93 return ''
94 }
95
96 const keepClosingQuote = (line: string) => {
97 note()
98 const quote = /["']+\s*$/.exec(line.replace(/\n$/, ''))?.[0] ?? ''
99 return quote && line.endsWith('\n') ? `${quote}\n` : quote
100 }
101
102 return command.replace(ATTRIBUTION_FLAG, note).replace(ATTRIBUTION_ESCAPED, note).replace(ATTRIBUTION_LINE, keepClosingQuote)
103}
104
105function renameNewBranches(command: string, rules: Rules, notes: Set<string>) {
106 const renames = new Map<string, string>()
107
108 for (const pattern of CREATES_BRANCH) {
109 for (const match of command.matchAll(pattern)) {
110 const branch = match[1] ?? ''
111 if (rules.blocked.test(branch)) renames.set(branch, suggestedName(branch, rules))
112 }
113 }
114
115 let out = command
116 for (const [from, to] of renames) {
117 out = out.replace(new RegExp(String.raw`(?<=[\s:'"=/]|^)${escapeRegExp(from)}${END}`, 'g'), to)
118 notes.add(`renamed branch \`${from}\` to \`${to}\``)
119 }
120
121 return out
122}
123
124function workingDir(command: string) {
125 const gitDir = /\bgit\s+-C\s+("[^"]*"|'[^']*'|\S+)/.exec(command)
126 if (gitDir) return unquote(gitDir[1] ?? '')
127
128 const cds = [...command.matchAll(/(?:^|[;&|]\s*)cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)/g)]
129 const last = cds.at(-1)?.[1]
130 return last === undefined ? undefined : unquote(last)
131}
132
133export const register: Register = (on, options) => {
134 const rules = rulesFrom(options)
135 let identity: Identity | undefined
136 let verifiedLogin: string | undefined
137
138 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
139 const original: string = e.command
140 if (!/\b(git|gh)\b/.test(original)) return next(e)
141
142 if (new RegExp(String.raw`${AT_COMMAND}git\s+config\s+(?:--\S+\s+)*${escapeRegExp(ALLOW_KEY)}\s+\S`, 'im').test(original)) {
143 return { deny: `house-rules: only the user may set ${ALLOW_KEY}. Ask them to run it themselves.` }
144 }
145
146 identity ??= await identityFrom($, options)
147 const { email: expectedEmail, names, githubLogin } = identity
148
149 const setsIdentity = new RegExp(String.raw`${AT_COMMAND}git\s+config\s+(?:--(?:local|global|worktree|system|file\s+\S+)\s+)*user\.(email|name)\s+(${ARG})`, 'm').exec(original)
150 if (setsIdentity) {
151 const [, field, raw] = setsIdentity
152 const value = unquote(raw ?? '')
153 const expected = field === 'email' ? expectedEmail : names[0]
154 const isAllowed = field === 'email' ? !expectedEmail || value === expectedEmail : names.length === 0 || names.includes(value)
155 if (!isAllowed) {
156 return { deny: `house-rules: git user.${field} must stay ${expected}. Never set it to "${value}"; ask the user if this repo needs a different identity.` }
157 }
158 }
159
160 const notes = new Set<string>()
161 const command = renameNewBranches(stripAttribution(stripIdentityOverrides(original, expectedEmail, notes), notes), rules, notes)
162 const cwd = workingDir(command)
163 const git = (...args: string[]) => $.process.run(['git', ...args], { cwd }).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''))
164
165 const pushedBranch = publishedBranches(command, rules)[0]
166 const isPublishing = /\bgit\s+push\b|\bgh\s+pr\s+create\b/.test(command)
167 const currentBranch = isPublishing && !pushedBranch ? await git('rev-parse', '--abbrev-ref', 'HEAD') : ''
168 const badBranch = pushedBranch ?? (rules.blocked.test(currentBranch) ? currentBranch : undefined)
169 if (badBranch) {
170 return { deny: `house-rules: branch "${badBranch}" uses an AI/bot prefix. Rename it first with \`git branch -m ${badBranch} ${suggestedName(badBranch, rules)}\` (pick feat/, fix/, test/, refactor/, docs/ or chore/ to match the work), then push.` }
171 }
172
173 if (/\bgit\s+(?:[^;&|\n]*\s)?commit\b/.test(command) && (await git('config', '--get', ALLOW_KEY)) !== 'true') {
174 const [email, name] = await Promise.all([git('config', 'user.email'), git('config', 'user.name')])
175 const isWrongEmail = expectedEmail !== '' && email !== expectedEmail
176 const isWrongName = names.length > 0 && !names.includes(name)
177 if (isWrongEmail || isWrongName) {
178 return { deny: `house-rules: this repo would commit as "${name} <${email}>", not "${names[0] ?? name} <${expectedEmail || email}>". Do not override it inline; stop and ask the user how to proceed.` }
179 }
180 }
181
182 if (githubLogin && /\bgh\s+pr\s+create\b/.test(command)) {
183 const login = verifiedLogin ?? (await $.process.run(['gh', 'api', 'user', '-q', '.login'], { cwd }).then(r => r.stdout.trim()))
184 if (login !== githubLogin) {
185 return { deny: `house-rules: gh is authenticated as "${login || 'unknown'}", not ${githubLogin}. Stop and ask the user before opening a PR.` }
186 }
187 verifiedLogin = login
188 }
189
190 if (notes.size === 0) return next(e)
191
192 const summary = [...notes].join('; ')
193 $.ui.toast(`house-rules: ${summary}`)
194 const ran = await next({ ...e, command })
195 if (ran.deny !== undefined) return ran
196
197 return {
198 ...ran,
199 context: [...(ran.context ?? []), `house-rules corrected this command before it ran (${summary}). It ran as:\n${command}`],
200 }
201 })
202}
203