SLOPSHOPPER

prose-guard

Blocks em dashes in edits, shell commands and replies, and British spellings in prose files, commit messages and pull requests.

newguard
v0.1.0no licenseupdated 2026-10-02cprentice9/ccmods/prose-guard
A shopper browsing a rack in a slop shop
README

prose-guard

Keeps em dashes out of everything Claude writes, and British spelling out of prose, commit messages and pull requests.

What it does

  • Replies. Each em dash in a reply turns into a comma as the reply streams, so you never see one. Thinking gets the same rewrite. If a dash still gets through, the mod sends the reply back once and asks for a rewrite.
  • Edits and shell commands. A Write, Edit, MultiEdit or NotebookEdit that adds an em dash is refused, and so is a Bash command that contains one. The refusal tells Claude to use periods or commas instead. Edits that keep or remove existing dashes pass.
  • British spelling. Writes and edits to Markdown, MDX, text and reStructuredText files are refused when they add a British spelling, and so are git commit, gh pr create and gh pr edit commands and the GitHub MCP pull request tools. The refusal lists each word with its American spelling. The list covers the common -our, -re, -ise and doubled-l forms and a few dozen other words, all in hooks/register.ts. Code files are left alone, so identifiers and stored values never trip it.

Loading it

It isn't on a marketplace. Clone the repo and load the folder:

git clone https://github.com/cprentice9/ccmods.git
claude --plugin-dir ccmods/prose-guard

The ccmods README explains how to load it in every session, including desktop app sessions.

Checking it

From the ccmods folder:

claude plugin validate prose-guard
claude plugin test prose-guard
Source 1 files
hooks/register.ts 172 lines
1import type { Register } from 'claude-code'
2
3const DASH = String.fromCharCode(0x2014)
4const countDashes = (s: unknown) => (typeof s === 'string' ? s.split(DASH).length - 1 : 0)
5const SPACED_DASH = new RegExp(`[ \\t]*${DASH}[ \\t]*`, 'g')
6const TRAILING_DASH = new RegExp(`[ \\t${DASH}]+$`)
7
8const DASH_DENY =
9  'This adds an em dash. Rewrite it with periods or commas. ' +
10  "To match the character in a shell command, write it as an escape such as $'\\xe2\\x80\\x94'."
11
12const REPLY_BLOCK =
13  'Your reply contained an em dash. Send the reply again, rewritten with periods or commas and no em dashes.'
14
15// British spelling -> American spelling, one entry per inflected form.
16const BRITISH = new Map<string, string>()
17const add = (uk: string, us: string) => BRITISH.set(uk, us)
18
19for (const w of ['colour', 'behaviour', 'favour', 'honour', 'labour', 'neighbour', 'flavour', 'humour', 'rumour', 'harbour'])
20  for (const end of ['', 's', 'ed', 'ing', 'er', 'ers', 'able', 'al', 'ful', 'hood', 'ite', 'ites'])
21    add(w + end, w.replace('our', 'or') + end)
22
23for (const w of ['centre', 'theatre', 'metre', 'litre', 'fibre'])
24  for (const [uk, us] of [['re', 'er'], ['res', 'ers'], ['red', 'ered'], ['ring', 'ering']])
25    add(w.slice(0, -2) + uk, w.slice(0, -2) + us)
26
27for (const stem of [
28  'organis', 'recognis', 'realis', 'apologis', 'prioritis', 'summaris', 'optimis', 'initialis', 'serialis',
29  'normalis', 'customis', 'minimis', 'maximis', 'categoris', 'finalis', 'utilis', 'authoris', 'emphasis',
30  'standardis', 'synchronis', 'visualis',
31])
32  for (const end of ['e', 'es', 'ed', 'ing', 'er', 'ers', 'able', 'ation', 'ations'])
33    add(stem + end, stem.slice(0, -1) + 'z' + end)
34
35// "analyses" is left out: it is also the plural of "analysis".
36for (const end of ['e', 'ed', 'ing', 'er', 'ers']) add('analys' + end, 'analyz' + end)
37
38for (const stem of ['cancel', 'travel', 'model', 'label', 'level', 'signal', 'fuel'])
39  for (const end of ['ed', 'ing', 'er', 'ers']) add(stem + 'l' + end, stem + end)
40
41for (const w of ['licence', 'defence', 'offence', 'pretence']) {
42  add(w, w.slice(0, -2) + 'se')
43  add(w + 's', w.slice(0, -2) + 'ses')
44}
45
46for (const [uk, us] of [
47  ['grey', 'gray'], ['greys', 'grays'], ['greyed', 'grayed'], ['greying', 'graying'], ['greyer', 'grayer'], ['greyish', 'grayish'],
48  ['catalogue', 'catalog'], ['catalogues', 'catalogs'], ['catalogued', 'cataloged'], ['cataloguing', 'cataloging'],
49  ['programme', 'program'], ['programmes', 'programs'],
50  ['aluminium', 'aluminum'], ['whilst', 'while'], ['ageing', 'aging'],
51  ['artefact', 'artifact'], ['artefacts', 'artifacts'],
52  ['cheque', 'check'], ['cheques', 'checks'],
53  ['tyre', 'tire'], ['tyres', 'tires'],
54  ['manoeuvre', 'maneuver'], ['manoeuvres', 'maneuvers'], ['manoeuvred', 'maneuvered'], ['manoeuvring', 'maneuvering'],
55  ['sceptic', 'skeptic'], ['sceptics', 'skeptics'], ['sceptical', 'skeptical'], ['scepticism', 'skepticism'],
56] as [string, string][])
57  add(uk, us)
58
59const britishCounts = (s: unknown) => {
60  const counts = new Map<string, number>()
61  if (typeof s !== 'string') return counts
62  for (const word of s.toLowerCase().match(/[a-z]+/g) ?? [])
63    if (BRITISH.has(word)) counts.set(word, (counts.get(word) ?? 0) + 1)
64  return counts
65}
66
67// British words whose count rises from the old texts to the new ones.
68const addedBritish = (pairs: [unknown, unknown][]) => {
69  const delta = new Map<string, number>()
70  for (const [oldText, newText] of pairs) {
71    for (const [w, n] of britishCounts(newText)) delta.set(w, (delta.get(w) ?? 0) + n)
72    for (const [w, n] of britishCounts(oldText)) delta.set(w, (delta.get(w) ?? 0) - n)
73  }
74  return [...delta].filter(([, n]) => n > 0).map(([w]) => w)
75}
76
77const britishDeny = (words: string[]) =>
78  'This adds British spelling. Use American spelling: ' +
79  words.map(w => `${w} -> ${BRITISH.get(w)}`).join(', ') + '.'
80
81const PROSE_FILE = /\.(md|mdx|markdown|txt|rst)$/i
82const COMMIT_OR_PR = /\bgit\s+(?:-[Cc]\s+\S+\s+)*commit\b|\bgh\s+pr\s+(?:create|edit)\b/
83const PR_TOOLS = ['mcp__github__create_pull_request', 'mcp__github__update_pull_request']
84
85export const register: Register = on => {
86  on('tool.call', async ($, e, next) => {
87    // MultiEdit and the GitHub tools may not be in this build's typed tool list.
88    const t = e as unknown as Record<string, any>
89    let dashes = 0
90    let british: string[] = []
91    switch (t.tool) {
92      case 'Write': {
93        const before = await $.fs.read(t.file_path).catch(() => '')
94        dashes = countDashes(t.content) - countDashes(before)
95        if (PROSE_FILE.test(t.file_path)) british = addedBritish([[before, t.content]])
96        break
97      }
98      case 'Edit':
99        dashes = countDashes(t.new_string) - countDashes(t.old_string)
100        if (PROSE_FILE.test(t.file_path)) british = addedBritish([[t.old_string, t.new_string]])
101        break
102      case 'MultiEdit': {
103        const edits: { old_string?: string; new_string?: string }[] = t.edits ?? []
104        dashes = edits.reduce((n, x) => n + countDashes(x.new_string) - countDashes(x.old_string), 0)
105        if (PROSE_FILE.test(t.file_path)) british = addedBritish(edits.map(x => [x.old_string, x.new_string]))
106        break
107      }
108      case 'NotebookEdit':
109        dashes = countDashes(t.new_source)
110        break
111      case 'Bash':
112        dashes = countDashes(t.command)
113        if (COMMIT_OR_PR.test(t.command ?? '')) british = addedBritish([['', t.command]])
114        break
115      default:
116        if (PR_TOOLS.includes(t.tool)) {
117          dashes = countDashes(t.title) + countDashes(t.body)
118          british = addedBritish([['', t.title], ['', t.body]])
119        }
120    }
121    const reasons = []
122    if (dashes > 0) reasons.push(DASH_DENY)
123    if (british.length > 0) reasons.push(britishDeny(british))
124    return reasons.length > 0 ? { deny: reasons.join(' ') } : next(e)
125  })
126
127  // Each em dash in a reply becomes a comma as the reply streams, so none is
128  // drawn or recorded. Thinking gets the same rewrite, since the app shows it
129  // as progress notes; the engine still records the signed original. A
130  // piece's trailing spaces and dashes wait for the next piece of the same
131  // block, since the rest of the dash may arrive there.
132  on('turn.step', async function* ($, e, next) {
133    let held = ''
134    let heldIndex = -1
135    for await (const c of next(e)) {
136      if (c.kind !== 'text' && c.kind !== 'thinking') {
137        yield c
138        continue
139      }
140      const joined = (c.index === heldIndex ? held : '') + c.text
141      held = joined.match(TRAILING_DASH)?.[0] ?? ''
142      heldIndex = c.index
143      const text = joined.slice(0, joined.length - held.length)
144      yield { ...c, text: text.replace(SPACED_DASH, ', ') }
145    }
146  })
147
148  // Stop is the one event that can send a finished reply back to the model.
149  // The stream above leaves no dash for it to find; it stays as a backstop.
150  on('classic.Stop', async ($, e, next) => {
151    const result = await next(e)
152    // One rewrite per reply, so a stubborn dash cannot loop forever.
153    if (e.stop_hook_active || result.block !== undefined) return result
154    if (countDashes(await turnText($, e.last_assistant_message)) === 0) return result
155    return { ...result, block: REPLY_BLOCK }
156  })
157}
158
159// Text of every assistant message since the last real user message, plus the
160// final reply, which the transcript may not hold yet.
161async function turnText($: { session: { messages: () => Promise<unknown> } }, last: string | undefined) {
162  const texts = [last ?? '']
163  const messages = (await $.session.messages()) as { role: string; text: string; toolResults?: unknown[] }[]
164  for (let i = messages.length - 1; i >= 0; i--) {
165    const m = messages[i]!
166    if (m.role === 'user') {
167      if (!m.toolResults?.length) break
168    } else texts.push(m.text)
169  }
170  return texts.join('\n')
171}
172