SLOPSHOPPER

dash-guard

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

newguardcommand
★ 5v0.2.1MITupdated 2026-10-05mutlumehmet/claude-plugins/plugins/dash-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dash-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /dash-guard ⎿ dash-guard: dash-guard is on for this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dash-guard

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.

What it does

  • Checks Write and Edit on Markdown and text files, and any MCP tool you name (a document, an email, a message).
  • Leaves code alone: code files, fenced blocks, inline code, HTML comments, and CLI flags such as git reset --hard.
  • Lines that already had a dash before the edit are not reported, so old files can still be edited.
  • /dash-guard off|on for the session.
  • It is a style rule, not a safety check: if the mod fails, the write goes through.

Settings

SettingDefaultWhat it does
bannedall threeComma separated: em, en, double
toolsemptyA regular expression of MCP tool names to check too, such as `^mcp__notion__.*(create\update).*$`

What it reads and does

  • Reads: the text Claude is about to write with Write and Edit, the current file for an Edit (to tell new dashes from ones already there), and the text of MCP tools named in the tools setting.
  • Does: refuses a write that adds an em dash, an en dash or a double hyphen in prose, and tells Claude which lines to rewrite.
  • Hooks: 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.
  • Privacy: see PRIVACY.md.
  • Never: sends anything anywhere (no network calls, no telemetry).

Install

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.

Known gaps

  • Chat replies are not checked, only what is written to files and tools.
  • A shell heredoc (cat > file) goes around it.

About

Mehmet Mutlu: work that's shipped, at mehmetmutlu.dev

Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.

License

MIT

Source 1 files
hooks/register.js 132 lines
1// 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