Rewrites em dashes out of Claude's replies, file writes and edits, and commit/PR messages before they land.

Removes em dashes from what Claude writes, before it lands. About 80 lines of TypeScript.
| Where | What gets fixed |
|---|---|
| Write tool | the file content |
| Edit tool | new_string only (old_string must match the file, so it is never touched) |
| Bash | only git commit and gh pr / gh issue create, edit, comment; every other command runs as is |
| Replies | text blocks in Claude's responses, before they are stored |
Rewrite rules (<code>—</code> is the em dash, <code>–</code> the en dash):
<!-- Dashes in this file are HTML entities on purpose. With literal characters, dashless rewrites its own examples whenever Claude edits this file. -->
| Input | Output |
|---|---|
| <code>a — b</code>, <code>a—b</code> | a, b |
| <code>— item</code> at line start | item (indentation kept) |
| <code>done —</code> at line end | done |
| <code>note: — x</code> | note: x |
| <code>a – b</code> (spaced en dash) | a, b |
| <code>1990–2000</code> (range) | unchanged |
--force, -- file | unchanged |
The status line shows a running count: dashless: 3 em dashes fixed.
Tested by running the same prompt twice in a throwaway git repo (write a file, commit it, reply with a sentence, each with an em dash). Without the mod 4 em dashes landed; with it, 0.
Run its tests: claude plugin test dashless
hooks/register.ts 67 lines1import type { Register } from 'claude-code'
2import { fixDashes } from './fix'
3
4const COMMIT_OR_PR = /\bgit\s+commit\b|\bgh\s+(pr|issue)\s+(create|edit|comment)\b/
5
6let total = 0
7
8export const register: Register = (on) => {
9 on('tool.call', { tool: 'Write' }, ($, e, next) => {
10 try {
11 const fixed = fixDashes(e.content)
12 if (fixed.count > 0) {
13 total += fixed.count
14 $.ui.status(`dashless: ${total} em dashes fixed`)
15 return next({ ...e, content: fixed.text })
16 }
17 } catch {}
18 return next(e)
19 })
20
21 on('tool.call', { tool: 'Edit' }, ($, e, next) => {
22 try {
23 const fixed = fixDashes(e.new_string)
24 if (fixed.count > 0) {
25 total += fixed.count
26 $.ui.status(`dashless: ${total} em dashes fixed`)
27 return next({ ...e, new_string: fixed.text })
28 }
29 } catch {}
30 return next(e)
31 })
32
33 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
34 try {
35 if (COMMIT_OR_PR.test(e.command)) {
36 const fixed = fixDashes(e.command)
37 if (fixed.count > 0) {
38 total += fixed.count
39 $.ui.status(`dashless: ${total} em dashes fixed`)
40 return next({ ...e, command: fixed.text })
41 }
42 }
43 } catch {}
44 return next(e)
45 })
46
47 on('session.append', ($, e, next) => {
48 try {
49 if (e.door === 'response') {
50 let changed = 0
51 const content = e.message.content.map((block) => {
52 if (block.type !== 'text' || typeof block.text !== 'string') return block
53 const fixed = fixDashes(block.text)
54 changed += fixed.count
55 return fixed.count > 0 ? { ...block, text: fixed.text } : block
56 })
57 if (changed > 0) {
58 total += changed
59 $.ui.status(`dashless: ${total} em dashes fixed`)
60 return next({ ...e, message: { ...e.message, content } })
61 }
62 }
63 } catch {}
64 return next(e)
65 })
66}
67hooks/fix.ts 15 lines1// Em dash is written as a unicode escape so this file never contains the character.
2const DASH = /[ \t]*(?:\u2014|(?<=[ \t])\u2013(?=[ \t]))[ \t]*/g
3
4export function fixDashes(text: string): { text: string; count: number } {
5 let count = 0
6 const out = text.replace(DASH, (match, offset: number, whole: string) => {
7 count++
8 const next = whole[offset + match.length]
9 if (offset === 0 || whole[offset - 1] === '\n') return match.match(/^[ \t]*/)![0]
10 if (next === undefined || next === '\n' || next === '\r') return ''
11 return /[,;:.(]/.test(whole[offset - 1] ?? '') ? ' ' : ', '
12 })
13 return count === 0 ? { text, count: 0 } : { text: out, count }
14}
15