Holds Claude to your writing rules: banned words and characters are caught before they reach your files.

A mod for Claude Code that holds Claude to your writing rules.
Every team has a list. No em dashes. Never name the hosting provider. Don't say "leverage". You can put the list in a prompt, and Claude will follow it until the session gets long. Style Lint checks the text itself, every time Claude writes to a file, and refuses the edit when a rule is broken.
It asks no model anything of its own. Every check is a plain text or pattern match you can read in hooks/style.ts.
It works in three layers, from cheapest to strictest:
/style opens a pane with each rule, how often it was broken this session, and where.
/plugin install house-style-lint --marketplace ramankrishna/house-style-lint
Answer y to add the marketplace, then pick a scope. Needs Claude Code v2.1.287 or later.
Nothing is checked until the project has a rules file.
Run /style init for a starter, or write .house-style.json at the project root yourself:
{
"mode": "block",
"files": ["*.md", "*.mdx", "*.txt", "*.html", "*.tsx"],
"banned": [
{ "text": "—", "why": "No em dashes", "fix": "Use a comma, a colon or a period." },
{ "text": "Fly.io", "why": "Never name the hosting provider" },
{ "text": "/\\b(delve|leverage|utilize)\\b/i", "why": "Plain words only", "fix": "Say \"dig into\" or \"use\"." }
],
"checkAnswers": true,
"tellClaude": true
}
| Field | Meaning | Default | | :- | :- | :- | | mode | block refuses the edit; warn lets it through and reports it | block | | files | Which files are checked, by pattern | Markdown, text, HTML and common component files | | banned | Your rules. text is plain text matched without regard to case, or a pattern between slashes. why and fix are shown to Claude | none | | checkAnswers | Report slips in chat replies | true | | tellClaude | Add the rules to each prompt | true |
Two details worth knowing:
.house-style.json. It cannot quietly delete a rule that is in its way..house-style.json at the project root, and the text of each edit Claude makes to a checked file, at the moment Claude Code decides whether the edit may run..house-style.json once, only when you run /style init and the file does not exist.tellClaude on, each prompt carries your rules to Claude as hidden context. That costs a few tokens per rule per turn.Like every mod, it runs with the same access to your machine as Claude Code itself. The full statement is in PRIVACY.md.
echo into a file, is not.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 267 lines1// Style Lint: your house style, checked on every piece of text Claude writes.
2//
3// Claude is told the rules beside each prompt, an edit that breaks one is
4// refused with the offending lines so it can rewrite, and a reply that breaks
5// one is reported under the answer. Nothing here asks a model anything of its
6// own or leaves the machine.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Entry, Hit } from '../types'
12import {
13 added,
14 answerLine,
15 blockText,
16 briefing,
17 clip,
18 CONFIG,
19 isChecked,
20 parseConfig,
21 relative,
22 scan,
23 STARTER,
24} from './style'
25import type { Config } from './style'
26
27const PANE = 'style'
28
29const log = atom({ plugin: 'house-style-lint', key: 'log' } as const, [])
30const setup = atom({ plugin: 'house-style-lint', key: 'setup' } as const, {
31 hasFile: false,
32 mode: 'block',
33 rules: [],
34 problem: null,
35})
36
37// What the hooks read without a call on `$` on every tool call.
38let root = ''
39let config: Config | null = null
40
41const field = (input: unknown, name: string): string => {
42 const value = (input as Record<string, unknown> | null)?.[name]
43
44 return typeof value === 'string' ? value : ''
45}
46
47/** Reads `.house-style.json` at the project root; with none, nothing is checked. */
48async function load($: EngineInterface): Promise<void> {
49 root = await $.session.root()
50
51 const path = `${root}/${CONFIG}`
52 const hasFile = await $.fs.exists(path)
53 let text = ''
54
55 if (hasFile) {
56 try {
57 text = await $.fs.read(path)
58 } catch {
59 text = ''
60 }
61 }
62
63 const parsed = hasFile ? parseConfig(text) : null
64
65 config = parsed === null || typeof parsed === 'string' ? null : parsed
66 await update($, setup, () => ({
67 hasFile,
68 mode: config?.mode ?? 'block',
69 rules: (config?.banned ?? []).map(entry => ({ text: entry.text, why: entry.why })),
70 problem: typeof parsed === 'string' ? parsed : null,
71 }))
72}
73
74async function init($: EngineInterface): Promise<string> {
75 root = await $.session.root()
76
77 const path = `${root}/${CONFIG}`
78
79 if (await $.fs.exists(path)) return `${CONFIG} already exists. Edit it, then run /style.`
80
81 await $.fs.write(path, `${JSON.stringify(STARTER, null, 2)}\n`)
82 await load($)
83
84 return `Wrote ${CONFIG} with two example rules. Replace them with your own.`
85}
86
87const note = ($: EngineInterface, hits: readonly Hit[], outcome: Entry['outcome']) =>
88 update($, log, was => [...was, ...hits.map(hit => ({ ...hit, outcome }))].slice(-60))
89
90/** The banned phrases a file edit would add, or none when the file is not checked. */
91function findInEdit(tool: string, input: unknown): { path: string; hits: Hit[] } {
92 const path = relative(root, field(input, 'file_path'))
93
94 if (config === null || path === '' || !isChecked(config.files, path)) return { path, hits: [] }
95
96 return {
97 path,
98 hits:
99 tool === 'Write'
100 ? scan(config.banned, field(input, 'content'), path)
101 : added(config.banned, field(input, 'old_string'), field(input, 'new_string'), path),
102 }
103}
104
105const openPane = ($: EngineInterface) =>
106 $.ui.open({ id: PANE, title: 'Style Lint', focus: true, closeOnEscape: true })
107
108export const register: Register = on => {
109 on('session.start', async ($, e, next) => {
110 await $.command.register({
111 name: 'style',
112 description: 'Show the style rules Claude writes under here, and where it slipped',
113 argumentHint: '[init]',
114 })
115 await load($)
116
117 return next(e)
118 })
119
120 // /clear, /resume and /branch reset the session's state and raise no session.start.
121 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
122 await load($)
123
124 return next(e)
125 })
126
127 on('command.run', { command: 'style' }, async ($, e) => {
128 if (e.args.trim() === 'init') return { text: await init($) }
129
130 await load($)
131 await openPane($)
132
133 return {}
134 })
135
136 // Tell Claude the rules up front: following them the first time costs less
137 // than a refused edit and a rewrite.
138 on('prompt.submit', async ($, e, next) => {
139 // An edit you make by hand between turns takes hold on the next one.
140 await load($)
141
142 if (config === null || !config.tellClaude || config.banned.length === 0) return next(e)
143
144 return next({ ...e, context: [...(e.context ?? []), briefing(config)] })
145 })
146
147 on('tool.check', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
148 const decided = await next(e)
149 const path = relative(root, field(e.input, 'file_path'))
150
151 // The rules are yours: Claude asks before it changes them.
152 if (config !== null && path === CONFIG && decided.decision === 'allow') {
153 return { decision: 'ask', reason: `Style Lint: this changes ${CONFIG}, the style rules themselves.` }
154 }
155
156 const { hits } = findInEdit(e.tool, e.input)
157
158 if (hits.length === 0) return decided
159
160 if (config?.mode === 'warn') {
161 await note($, hits, 'warned')
162 $.ui.toast(`${hits.length} style ${hits.length === 1 ? 'slip' : 'slips'} written to ${path}`)
163
164 return decided
165 }
166
167 await note($, hits, 'blocked')
168
169 return { decision: 'deny', reason: blockText(path, hits) }
170 }).catch(($, e, next) => {
171 const { path, hits } = findInEdit(e.tool, e.input)
172
173 return hits.length > 0 && config?.mode === 'block'
174 ? { decision: 'deny', reason: blockText(path, hits) }
175 : next(e)
176 })
177
178 on('turn.complete', async ($, e, next) => {
179 const done = await next(e)
180
181 if (config === null || !config.checkAnswers || e.agentId !== undefined || e.reason !== 'answer') {
182 return done
183 }
184
185 const hits = scan(config.banned, e.answer, 'reply')
186
187 if (hits.length === 0) return done
188
189 await note($, hits, 'reply')
190
191 const line = answerLine(hits)
192
193 return { ...done, text: done.text === e.answer ? line : `${done.text}\n${line}` }
194 })
195
196 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
197 const { Box, Text } = $.ui.resolve(e)
198 const now = await read($, setup)
199 const entries = await read($, log)
200 const width = Math.max(24, e.props.bodyColumns - 10)
201 const count = (why: string): number => entries.filter(entry => entry.why === why).length
202
203 return (
204 <Box flexDirection="column" rowGap={1}>
205 {now.problem !== null && (
206 <Text color="error">
207 {CONFIG} is not usable: {now.problem}
208 </Text>
209 )}
210 {!now.hasFile && (
211 <Box flexDirection="column">
212 <Text>No style rules yet.</Text>
213 <Text dimColor>/style init writes a starter {CONFIG}.</Text>
214 </Box>
215 )}
216 {now.rules.length > 0 && (
217 <Box flexDirection="column">
218 <Text dimColor>
219 {now.mode === 'block'
220 ? 'An edit that breaks a rule is refused, and Claude rewrites it.'
221 : 'An edit that breaks a rule goes through and is reported here.'}
222 </Text>
223 {now.rules.map(rule => (
224 <Box flexDirection="row" columnGap={2}>
225 <Box width={4} flexShrink={0}>
226 {count(rule.why) > 0 ? (
227 <Text color="warning">{count(rule.why)}</Text>
228 ) : (
229 <Text dimColor>0</Text>
230 )}
231 </Box>
232 <Text>{clip(rule.why, width)}</Text>
233 </Box>
234 ))}
235 </Box>
236 )}
237 {now.hasFile && (
238 <Box flexDirection="column">
239 {entries.length === 0 && <Text dimColor>No slips this session.</Text>}
240 {entries
241 .slice(-8)
242 .reverse()
243 .map(entry => (
244 <Box flexDirection="column">
245 <Box flexDirection="row" columnGap={2}>
246 {entry.outcome === 'blocked' ? (
247 <Text color="success">BLOCKED</Text>
248 ) : entry.outcome === 'warned' ? (
249 <Text color="warning">WRITTEN</Text>
250 ) : (
251 <Text color="warning">IN A REPLY</Text>
252 )}
253 <Text>
254 {clip(entry.where, 30)}
255 {entry.outcome === 'reply' ? '' : `:${entry.line}`}
256 </Text>
257 </Box>
258 <Text dimColor>{clip(entry.excerpt, width + 8)}</Text>
259 </Box>
260 ))}
261 </Box>
262 )}
263 </Box>
264 )
265 })
266}
267hooks/style.ts 248 lines1// The style rules, with no engine in them: every function here is pure, so
2// the tests and the hooks read the same judgement.
3
4import type { Hit } from '../types'
5
6export const CONFIG = '.house-style.json'
7
8export type Banned = {
9 /** Plain text, matched without regard to case, or a pattern written `/like this/i`. */
10 text: string
11 why: string
12 fix: string
13}
14
15export type Config = {
16 /** `block` refuses the edit so Claude rewrites it; `warn` lets it through and reports. */
17 mode: 'block' | 'warn'
18 /** Which files are checked, by pattern. */
19 files: string[]
20 banned: Banned[]
21 /** Whether replies in chat are checked and reported too. */
22 checkAnswers: boolean
23 /** Whether Claude is told the rules beside each prompt. */
24 tellClaude: boolean
25}
26
27export const DEFAULT_FILES = ['*.md', '*.mdx', '*.txt', '*.html', '*.tsx', '*.jsx', '*.vue', '*.svelte']
28
29export const STARTER: Config = {
30 mode: 'block',
31 files: DEFAULT_FILES,
32 banned: [
33 { text: '—', why: 'No em dashes', fix: 'Use a comma, a colon or a period.' },
34 {
35 text: '/\\b(delve|leverage|utilize)\\b/i',
36 why: 'Plain words only',
37 fix: 'Say "dig into" or "use".',
38 },
39 ],
40 checkAnswers: true,
41 tellClaude: true,
42}
43
44const isRecord = (value: unknown): value is Record<string, unknown> =>
45 typeof value === 'object' && value !== null && !Array.isArray(value)
46
47const isStrings = (value: unknown): value is string[] =>
48 Array.isArray(value) && value.every(one => typeof one === 'string')
49
50const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
51
52/** The pattern a banned entry stands for, always global; null when it is not valid. */
53export function patternOf(text: string): RegExp | null {
54 const found = /^\/(.+)\/([a-z]*)$/.exec(text)
55
56 try {
57 if (found === null) return new RegExp(escapeRegExp(text), 'gi')
58
59 const flags = found[2] ?? ''
60
61 return new RegExp(found[1] ?? '', flags.includes('g') ? flags : `${flags}g`)
62 } catch {
63 return null
64 }
65}
66
67/** Reads `.house-style.json`; answers the config, or one line saying what is wrong. */
68export function parseConfig(text: string): Config | string {
69 let data: unknown
70
71 try {
72 data = JSON.parse(text)
73 } catch {
74 return 'not valid JSON'
75 }
76
77 if (!isRecord(data)) return 'expected an object with "banned"'
78
79 const mode = data.mode ?? 'block'
80 const files = data.files ?? DEFAULT_FILES
81 const rawBanned = data.banned ?? []
82
83 if (mode !== 'block' && mode !== 'warn') return '"mode" must be "block" or "warn"'
84 if (!isStrings(files)) return '"files" must be a list of patterns'
85 if (!Array.isArray(rawBanned)) return '"banned" must be a list'
86
87 const banned: Banned[] = []
88
89 for (const raw of rawBanned) {
90 const entry = typeof raw === 'string' ? { text: raw } : raw
91
92 if (!isRecord(entry) || typeof entry.text !== 'string' || entry.text === '') {
93 return 'each banned entry needs a "text"'
94 }
95 if (patternOf(entry.text) === null) return `"${entry.text}" is not a valid pattern`
96
97 banned.push({
98 text: entry.text,
99 why: typeof entry.why === 'string' && entry.why !== '' ? entry.why : `No "${entry.text}"`,
100 fix: typeof entry.fix === 'string' ? entry.fix : '',
101 })
102 }
103
104 return {
105 mode,
106 files,
107 banned,
108 checkAnswers: data.checkAnswers !== false,
109 tellClaude: data.tellClaude !== false,
110 }
111}
112
113// ------------------------------------------------------------------ files
114
115function globOf(pattern: string): RegExp {
116 const body = pattern
117 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
118 .replace(/\*\*/g, '\u0000')
119 .replace(/\*/g, '[^/]*')
120 .replace(/\u0000/g, '.*')
121
122 return new RegExp(`^${body}$`)
123}
124
125/** Whether a path, relative to the project, is one of the checked files. */
126export function isChecked(files: readonly string[], path: string): boolean {
127 const clean = path.replace(/^\.\//, '')
128 const base = clean.split('/').pop() ?? clean
129
130 return files.some(pattern =>
131 pattern.includes('/') ? globOf(pattern).test(clean) : globOf(pattern).test(base),
132 )
133}
134
135export function relative(root: string, path: string): string {
136 const base = root.endsWith('/') ? root : `${root}/`
137
138 return root !== '' && path.startsWith(base) ? path.slice(base.length) : path
139}
140
141// --------------------------------------------------------------- scanning
142
143/** The line a match sits on, cut to a readable width around the match. */
144function excerptOf(text: string, at: number, length: number): string {
145 const start = text.lastIndexOf('\n', at - 1) + 1
146 const endOfLine = text.indexOf('\n', at)
147 const line = text.slice(start, endOfLine === -1 ? text.length : endOfLine)
148 const column = at - start
149 const from = Math.max(0, column - 30)
150 const to = Math.min(line.length, column + length + 30)
151
152 return `${from > 0 ? '...' : ''}${line.slice(from, to).trim()}${to < line.length ? '...' : ''}`
153}
154
155type Found = { rule: number; at: number; length: number }
156
157function findAll(banned: readonly Banned[], text: string): Found[] {
158 const found: Found[] = []
159
160 banned.forEach((entry, rule) => {
161 const pattern = patternOf(entry.text)
162
163 if (pattern === null) return
164
165 for (const match of text.matchAll(pattern)) {
166 if (match[0] === '') continue
167
168 found.push({ rule, at: match.index, length: match[0].length })
169 }
170 })
171
172 return found.sort((a, b) => a.at - b.at)
173}
174
175function hitsOf(banned: readonly Banned[], text: string, found: readonly Found[], where: string): Hit[] {
176 return found.map(one => ({
177 where,
178 why: banned[one.rule]?.why ?? '',
179 fix: banned[one.rule]?.fix ?? '',
180 line: text.slice(0, one.at).split('\n').length,
181 excerpt: excerptOf(text, one.at, one.length),
182 }))
183}
184
185/** Every banned phrase in a text. */
186export function scan(banned: readonly Banned[], text: string, where: string): Hit[] {
187 return hitsOf(banned, text, findAll(banned, text), where)
188}
189
190/**
191 * The banned phrases an edit adds: for each rule, the matches in the new text
192 * beyond how many the old text already held, so an edit near an old offence
193 * is not blamed for it.
194 */
195export function added(banned: readonly Banned[], before: string, after: string, where: string): Hit[] {
196 const old = findAll(banned, before)
197 const now = findAll(banned, after)
198 const fresh = now.filter((one, index) => {
199 const earlier = now.slice(0, index).filter(other => other.rule === one.rule).length
200 const allowance = old.filter(other => other.rule === one.rule).length
201
202 return earlier >= allowance
203 })
204
205 return hitsOf(banned, after, fresh, where)
206}
207
208// ------------------------------------------------------------------ words
209
210const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
211
212/** What Claude reads when an edit is refused: each offence and how to fix it. */
213export function blockText(path: string, hits: readonly Hit[]): string {
214 const lines = hits
215 .slice(0, 8)
216 .map(hit => `- line ${hit.line}: ${hit.why}. Found: "${hit.excerpt}"${hit.fix !== '' ? ` ${hit.fix}` : ''}`)
217 const more = hits.length > 8 ? [`- and ${hits.length - 8} more`] : []
218
219 return [
220 `Style Lint: this edit to ${path} was not made. It breaks the project's style rules in ${plural(hits.length, 'place')}:`,
221 ...lines,
222 ...more,
223 'Rewrite the text to follow the rules and make the edit again.',
224 ].join('\n')
225}
226
227/** The line shown under a reply that broke the rules. */
228export function answerLine(hits: readonly Hit[]): string {
229 const reasons = [...new Set(hits.map(hit => hit.why))]
230
231 return `style: ${plural(hits.length, 'slip')} in this reply (${reasons.slice(0, 3).join('; ')}${reasons.length > 3 ? '; ...' : ''})`
232}
233
234/** What Claude reads beside each prompt, so the rules are followed the first time. */
235export function briefing(config: Config): string {
236 return [
237 'House style for this project. It applies to text you write into files and to your replies:',
238 ...config.banned.map(entry => {
239 const what = /^\/.+\/[a-z]*$/.test(entry.text) ? entry.why : `Never write "${entry.text}" (${entry.why})`
240
241 return `- ${what}.${entry.fix !== '' ? ` ${entry.fix}` : ''}`
242 }),
243 ].join('\n')
244}
245
246export const clip = (text: string, width: number): string =>
247 text.length > width ? `${text.slice(0, Math.max(1, width - 3))}...` : text
248types/index.d.ts 32 lines1/** One banned phrase found in a text. */
2export type Hit = {
3 /** The file's path, or `reply` for a chat answer. */
4 where: string
5 /** The rule's reason. */
6 why: string
7 fix: string
8 line: number
9 /** The text around the match. */
10 excerpt: string
11}
12
13/** One found phrase and what became of it. */
14export type Entry = Hit & {
15 outcome: 'blocked' | 'warned' | 'reply'
16}
17
18/** What `.house-style.json` says, as the session last read it. */
19export type Setup = {
20 hasFile: boolean
21 mode: 'block' | 'warn'
22 rules: { text: string; why: string }[]
23 /** What is wrong with the file, or null. */
24 problem: string | null
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'house-style-lint': { log: Entry[]; setup: Setup }
30 }
31}
32