SLOPSHOPPER

style-tripwire

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

newguardtoast
v0.1.0MITupdated 2026-10-07KrisnaSantosa15/style-tripwire
A shopper browsing a rack in a slop shop
README

style-tripwire

Your house style, enforced on Claude before the code lands.

Claude Code mod Claude Code 2.1.287+ GitHub stars Last commit License: MIT

The Problem

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.

How style-tripwire Solves It

It turns each rule into a regex tripwire on Claude's own tool calls:

  1. Block: an edit or commit message that breaks a rule never lands. Claude gets the reason and fixes the line itself.
  2. Warn: softer rules let the change through and tell Claude what to clean up.
  3. Only new lines: existing code never trips it, so it works on legacy codebases from day one.
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.

Install

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.

Set Your Rules

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"
    }
  ]
}
FieldWhat it does
nameShown to Claude and in the toast.
patternA regex, tested against each line Claude adds.
messageWhat Claude should do instead.
flagsRegex flags, like "i". Optional.
filesA regex on the file path. Leave it out to check every file.
commitstrue to also check git commit commands.
action"block" (default) stops the call. "warn" lets it through and tells Claude.

What It Checks

  • Edit: only the lines Claude adds.
  • Write: only lines that weren't in the file already.
  • Bash: 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.

Contributing

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

License

MIT. Use it, fork it, ship it.

Source 1 files
hooks/register.ts 113 lines
1import 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