SLOPSHOPPER

Style Lint

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

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-09ramankrishna/house-style-lint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · house-style-lint
│ ┃ Style Lint ✕ › fix the failing auth test and add an audit log call │ ┃ No style rules yet. │ ┃ /style init writes a starter ⏺ Read(src/auth.ts) │ ┃ .house-style.json. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /style │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Style Lint
No style rules yet. /style init writes a starter .house-style.json.
README

Style Lint

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.

How it works

It works in three layers, from cheapest to strictest:

  1. Tell. Claude reads your rules beside each prompt, so most text is right the first time.
  2. Block. When Claude writes text into a checked file and it breaks a rule, the edit is refused. Claude is shown the line, the rule and the fix, and rewrites it.
  3. Report. A chat reply cannot be blocked, so a reply that breaks a rule gets a line under it saying how many slips it had.

/style opens a pane with each rule, how often it was broken this session, and where.

Install

/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.

Write your rules

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:

  • An edit is only blamed for what it adds. If a file already has an em dash and Claude edits the sentence next to it, the edit goes through. Only a new offence is refused.
  • Claude must ask before changing .house-style.json. It cannot quietly delete a rule that is in its way.

What it runs, reads and stores

  • Reads .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.
  • Writes .house-style.json once, only when you run /style init and the file does not exist.
  • Adds text to your prompts. With tellClaude on, each prompt carries your rules to Claude as hidden context. That costs a few tokens per rule per turn.
  • Stores nothing between sessions. The list of slips lives in the session and ends with it.
  • Sends nothing. No network calls, no model calls of its own, no telemetry.

Like every mod, it runs with the same access to your machine as Claude Code itself. The full statement is in PRIVACY.md.

Limits

  • It matches text, it does not understand it. A rule against naming a provider will also fire on a sentence that says not to name it. Write patterns narrowly.
  • Replies are reported, not blocked. By the time a reply can be checked, you have already read it.
  • Only the Edit and Write tools are checked. Text written by a shell command, such as echo into a file, is not.
  • A whole-file rewrite is checked whole. When Claude rewrites a file from scratch, old offences in it count, because the mod cannot see the old version.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 3 files
hooks/register.tsx 267 lines
1// 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}
267
hooks/style.ts 248 lines
1// 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
248
types/index.d.ts 32 lines
1/** 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