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

Keeps em dashes out of everything Claude writes, and British spelling out of prose, commit messages and pull requests.
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.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.
From the ccmods folder:
claude plugin validate prose-guard
claude plugin test prose-guardhooks/register.ts 172 lines1import 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