Your house style as regex tripwires: Claude's edits and commit messages that break a rule are blocked or flagged before they land.

Your house style, enforced on Claude before the code lands.
You told Claude "no em dashes". Twice. It wrote one anyway. Then it left a dd() in a PHP file and a TODO in the commit you were about to push.
Style rules in CLAUDE.md are suggestions. Claude reads them, means well, and forgets them twenty tool calls later. You catch it in review, ask for a fix, and the cycle repeats.
It turns each rule into a regex tripwire on Claude's own tool calls:
style-tripwire: this change trips the house style rules. Fix it and try again.
- no-em-dash: Use a comma or a hyphen instead of an em dash.
in: Fast — really fast.
Prerequisites: Claude Code 2.1.287 or later (claude --version).
/plugin marketplace add KrisnaSantosa15/style-tripwire
/plugin install style-tripwire@style-tripwire
Then add your rules (below). They're read on every call, so no restart is needed.
Verify: add the no-em-dash rule from the example, then ask Claude to write a sentence with an em dash into a file. The write is refused.
Put rules in .claude/style-tripwire.json for one project, or ~/.claude/style-tripwire.json for all of them. Both are read, so you can mix them.
{
"rules": [
{
"name": "no-em-dash",
"pattern": "—",
"message": "Use a comma or a hyphen instead of an em dash.",
"commits": true
},
{
"name": "no-debug-dump",
"pattern": "\\b(dd|dump|var_dump)\\(",
"message": "Remove the debug dump.",
"files": "\\.php$"
},
{
"name": "todo",
"pattern": "TODO",
"message": "Open an issue instead of leaving a TODO.",
"action": "warn"
}
]
}
| Field | What it does |
|---|---|
name | Shown to Claude and in the toast. |
pattern | A regex, tested against each line Claude adds. |
message | What Claude should do instead. |
flags | Regex flags, like "i". Optional. |
files | A regex on the file path. Leave it out to check every file. |
commits | true to also check git commit commands. |
action | "block" (default) stops the call. "warn" lets it through and tells Claude. |
git commit commands, against rules with "commits": true.One rule is on out of the box: no merge conflict markers. Set "defaults": false to turn it off.
A broken config file or an invalid pattern gets you a toast, and the call goes through unchecked. The tripwire never blocks your work over its own bug.
Issues and pull requests are welcome. Add a test for any behavior you change.
git clone https://github.com/KrisnaSantosa15/style-tripwire.git
cd style-tripwire && claude plugin validate . && claude plugin test .
Try your changes live with claude --plugin-dir ..
MIT. Use it, fork it, ship it.
hooks/register.ts 113 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Rule = {
4 name: string
5 pattern: string
6 flags?: string
7 message: string
8 files?: string
9 commits?: boolean
10 action?: 'block' | 'warn'
11}
12type Config = { defaults?: boolean; rules?: Rule[] }
13type Compiled = Rule & { regex: RegExp; fileRegex?: RegExp }
14type Hit = { rule: Compiled; line: string }
15
16const NAME = 'style-tripwire'
17
18const DEFAULT_RULES: Rule[] = [
19 {
20 name: 'merge-conflict-marker',
21 pattern: '^(<{7}|>{7})( |$)',
22 message: 'Resolve the merge conflict instead of writing its markers.',
23 },
24]
25
26const readConfig = async ($: EngineInterface, path: string): Promise<Config> => {
27 if (!(await $.fs.exists(path))) return {}
28 try {
29 return JSON.parse(await $.fs.read(path)) as Config
30 } catch {
31 $.ui.toast(`${NAME}: ${path} is not valid JSON, so it was ignored`)
32 return {}
33 }
34}
35
36const compile = ($: EngineInterface, rule: Rule): Compiled[] => {
37 try {
38 return [{
39 ...rule,
40 regex: new RegExp(rule.pattern, (rule.flags ?? '').replace('g', '')),
41 fileRegex: rule.files === undefined ? undefined : new RegExp(rule.files),
42 }]
43 } catch {
44 $.ui.toast(`${NAME}: rule "${rule.name}" has an invalid pattern, so it was skipped`)
45 return []
46 }
47}
48
49const loadRules = async ($: EngineInterface): Promise<Compiled[]> => {
50 const home = await $.env.get('HOME')
51 const global = home === undefined ? {} : await readConfig($, `${home}/.claude/${NAME}.json`)
52 const project = await readConfig($, `.claude/${NAME}.json`)
53 const defaults = project.defaults ?? global.defaults ?? true
54 const rules = [...(defaults ? DEFAULT_RULES : []), ...(global.rules ?? []), ...(project.rules ?? [])]
55
56 return rules.flatMap(rule => compile($, rule))
57}
58
59const addedLines = (before: string, after: string) => {
60 const existing = new Set(before.split('\n'))
61 return after.split('\n').filter(line => !existing.has(line))
62}
63
64const findHits = (rules: Compiled[], lines: string[]): Hit[] =>
65 rules.flatMap(rule => {
66 const line = lines.find(candidate => rule.regex.test(candidate))
67 return line === undefined ? [] : [{ rule, line: line.trim().slice(0, 120) }]
68 })
69
70const describe = (hits: Hit[]) =>
71 hits.map(({ rule, line }) => `- ${rule.name}: ${rule.message}\n in: ${line}`).join('\n')
72
73
74export const register: Register = on => {
75 on('tool.call', async ($, e, next) => {
76 let lines: string[] | undefined
77 let path: string | undefined
78
79 if (e.tool === 'Edit') {
80 path = e.file_path
81 lines = addedLines(e.old_string, e.new_string)
82 } else if (e.tool === 'Write') {
83 path = e.file_path
84 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
85 lines = addedLines(before, e.content)
86 } else if (e.tool === 'Bash' && /\bgit\s+commit\b/.test(e.command)) {
87 lines = e.command.split('\n')
88 }
89
90 if (lines === undefined) return next(e)
91
92 const rules = (await loadRules($)).filter(rule =>
93 path === undefined ? rule.commits === true : rule.fileRegex === undefined || rule.fileRegex.test(path),
94 )
95 const hits = findHits(rules, lines)
96 const blocking = hits.filter(hit => (hit.rule.action ?? 'block') === 'block')
97
98 if (blocking.length > 0) {
99 return { deny: `${NAME}: this change trips the house style rules. Fix it and try again.\n${describe(blocking)}` }
100 }
101 if (hits.length === 0) return next(e)
102
103 $.ui.toast(`${NAME}: ${hits.map(hit => hit.rule.name).join(', ')}`)
104 const ran = await next(e)
105 if (ran.deny !== undefined) return ran
106
107 return { ...ran, context: [...(ran.context ?? []), `${NAME} warnings, worth fixing:\n${describe(hits)}`] }
108 }).catch(($, e, next) => {
109 $.ui.toast(`${NAME}: the check failed, so this call went through unchecked`)
110 return next(e)
111 })
112}
113