Refuses prose that Claude writes with an em dash, an en dash or a double hyphen, and tells Claude which lines to rewrite

A Claude Code mod that refuses prose Claude writes with an em dash, an en dash or a double hyphen used as punctuation, and tells Claude which lines to rewrite. Part of claude-plugins.
git reset --hard./dash-guard off|on for the session.| Setting | Default | What it does | |
|---|---|---|---|
banned | all three | Comma separated: em, en, double | |
tools | empty | A regular expression of MCP tool names to check too, such as `^mcp__notion__.*(create\ | update).*$` |
tools setting.tool.call on Write, Edit and the matching MCP tools may refuse that one call; it never changes a call's input. command.run answers its own /dash-guard command and no other.Needs Claude Code 2.1.287 or later (mods). Tested on 2.1.289.
/plugin marketplace add mutlumehmet/claude-plugins
/plugin install dash-guard@mehmetmutlu
A mod runs inside Claude Code with your permissions. Read the code before you install any mod. Settings are asked for at install, or later with /plugin configure dash-guard@mehmetmutlu.
cat > file) goes around it.Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.
hooks/register.js 132 lines1// dash-guard: refuses prose that Claude writes with an em dash, an en dash or
2// a double hyphen (which of the three: the banned setting), and tells
3// Claude which lines to rewrite. Code is left alone: fenced blocks, inline
4// code, HTML comments, and files that are not prose. Lines that already had a
5// dash before this call are not reported, so old files can still be edited.
6//
7// It is a style rule, not a safety gate, so a hook failure lets the call
8// through (no .catch).
9
10const PROSE_FILE = /\.(md|mdx|markdown|txt)$/i
11const MAX_REPORTED = 8
12
13// Exactly two hyphens used as punctuation: "a -- b", "a--b", "end--". A CLI flag such as
14// " --force" (a space or start of line before, a letter after) is not punctuation.
15const DOUBLE_HYPHEN = /(?<!-)--(?![-\w])|(?<=\w)--(?=\w)/
16
17let enabled = true
18// From the settings: which marks are refused, and which MCP tools are checked besides Write and Edit
19let banned = new Set(['em', 'en', 'double'])
20let mcpTools = null
21const LABEL = { em: 'em dashes', en: 'en dashes', double: 'double hyphens' }
22
23export function configure(options) {
24 const want = String(options?.banned ?? '').split(',').map((x) => x.trim().toLowerCase()).filter((x) => x in LABEL)
25 banned = new Set(want.length ? want : ['em', 'en', 'double'])
26 mcpTools = null
27 const pattern = String(options?.tools ?? '').trim()
28 if (pattern) {
29 try {
30 mcpTools = new RegExp(pattern)
31 } catch {
32 // An invalid pattern checks Write and Edit only
33 }
34 }
35}
36
37export function register(on, options) {
38 configure(options)
39 on('session.start', async ($, e, next) => {
40 await $.command.register({
41 name: 'dash-guard',
42 description: 'dash-guard on, off, or status for this session',
43 argumentHint: '[on|off]',
44 })
45 return next(e)
46 })
47
48 on('command.run', { command: 'dash-guard' }, async ($, e) => {
49 const arg = String(e.args ?? '').trim().toLowerCase()
50 if (arg === 'off') enabled = false
51 if (arg === 'on') enabled = true
52 return { text: 'dash-guard is ' + (enabled ? 'on' : 'off') + ' for this session' }
53 })
54
55 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
56 if (!enabled || !PROSE_FILE.test(String(e.file_path ?? ''))) return next(e)
57 let before = ''
58 if (e.tool === 'Edit') {
59 before = e.old_string ?? ''
60 } else {
61 try {
62 before = await $.fs.read(e.file_path)
63 } catch {
64 // A new file: nothing was there before
65 }
66 }
67 const text = e.tool === 'Edit' ? e.new_string : e.content
68 const found = findDashes(text, before)
69 return found.length ? { deny: denyText(e.file_path, found) } : next(e)
70 })
71
72 // MCP tools that write prose (a document, an email, a message), named by the tools setting
73 on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
74 if (!enabled || !mcpTools || !mcpTools.test(e.tool)) return next(e)
75 const { tool, tool_use_id, agentId, ...args } = e
76 const found = []
77 for (const s of strings(args)) found.push(...findDashes(s, ''))
78 return found.length ? { deny: denyText(tool.replace(/^mcp__/, ''), found) } : next(e)
79 })
80}
81
82// Every string inside the arguments, however deeply nested
83function strings(v) {
84 if (typeof v === 'string') return [v]
85 if (Array.isArray(v)) return v.flatMap(strings)
86 if (v && typeof v === 'object') return Object.values(v).flatMap(strings)
87 return []
88}
89
90// Blanks out code so its dashes are not reported: fenced blocks (also indented ones inside a
91// list), inline code, HTML comments.
92// Newlines are kept, so line numbers still match the original text.
93export function stripCode(text) {
94 const blank = (m) => m.replace(/[^\n]/g, ' ')
95 return String(text)
96 .replace(/^[ \t]*(```|~~~)[^\n]*\n[\s\S]*?(^[ \t]*\1[^\n]*$|(?![\s\S]))/gm, blank)
97 .replace(/<!--[\s\S]*?-->/g, blank)
98 .replace(/`[^`\n]+`/g, blank)
99}
100
101// Lines of `text` holding an em dash, an en dash or exactly two hyphens, skipping code and
102// any line that already appears in `before`. Returns [{ line, kind, text }].
103export function findDashes(text, before) {
104 const old = new Set(String(before ?? '').split('\n').map((l) => l.trim()))
105 const raw = String(text ?? '').split('\n')
106 const clean = stripCode(text).split('\n')
107 const found = []
108 clean.forEach((l, i) => {
109 const kinds = []
110 if (banned.has('em') && l.includes('—')) kinds.push('em dash')
111 if (banned.has('en') && l.includes('–')) kinds.push('en dash')
112 if (banned.has('double') && DOUBLE_HYPHEN.test(l)) kinds.push('double hyphen')
113 if (kinds.length && !old.has(raw[i].trim())) found.push({ line: i + 1, kind: kinds.join(', '), text: raw[i].trim() })
114 })
115 return found
116}
117
118function denyText(where, found) {
119 const shown = found.slice(0, MAX_REPORTED).map((f) => ' line ' + f.line + ' (' + f.kind + '): ' + clip(f.text))
120 const more = found.length > MAX_REPORTED ? [' and ' + (found.length - MAX_REPORTED) + ' more'] : []
121 return [
122 'dash-guard: nothing was written to ' + where + '. The user\'s style rule forbids ' + [...banned].map((k) => LABEL[k]).join(', ') + ' in prose.',
123 ...shown,
124 ...more,
125 'Rewrite those lines: split into a new sentence, or use a comma, colon or parentheses. Put CLI flags and code in backticks. Then try again.',
126 ].join('\n')
127}
128
129function clip(s) {
130 return s.length > 140 ? s.slice(0, 140) + '...' : s
131}
132