Replace em and en dashes in assistant prose before the row is stored

Claude Code mods: in-process TypeScript plugin hooks, published as a plugin marketplace.
Mods need Claude Code 2.1.287 or later. The module hook API is early access and changes between releases, so expect breakage on upgrade.
/plugin marketplace add mark-liu/cc-mods
/plugin install dash-scrub@cc-mods
/plugin install reminder-trim@cc-mods
Rewrites em and en dashes in assistant prose before the message is stored, so the transcript and the rendered reply never carry them.
110 + dash + 155 reads 110-155- bullet; a trailing dash is droppedScope: assistant text blocks only (session.append, response door). Files, commit messages and tool inputs are not touched.
Layout: pure logic in hooks/scrub.ts, the hook in hooks/register.ts, tests in hooks/scrub.test.ts.
Keeps the engine's task and todo list reminders ("The task tools haven't been used recently...") out of the model's context. They fire every few turns in any session that does not use the task tools: about 2,500 injections a month on one heavy user's machine.
task_reminder and todo_reminder through prompt.attachment; every other attachment passes throughclaude plugin validate plugins/<name>
claude plugin test plugins/<name>
Load a working copy without installing: CLAUDE_CODE_PLUGIN_DIRS=/path/to/plugins/<name> claude.
MIT
hooks/register.ts 21 lines1import type { Register } from 'claude-code'
2
3import { scrub } from './scrub.ts'
4
5export const register: Register = on => {
6 on('session.append', { door: 'response' }, ($, e, next) => {
7 if (e.message.type !== 'assistant') return next(e)
8
9 let isChanged = false
10 const content = e.message.content.map(block => {
11 if (block.type !== 'text' || typeof block.text !== 'string') return block
12 const text = scrub(block.text)
13 if (text === block.text) return block
14 isChanged = true
15 return { ...block, text }
16 })
17
18 return isChanged ? next({ ...e, message: { ...e.message, content } }) : next(e)
19 })
20}
21hooks/scrub.ts 41 lines1// Built from code points: literal dashes in source trip the dash ban on this file.
2export const EN = String.fromCharCode(0x2013)
3export const EM = String.fromCharCode(0x2014)
4
5const D = `[${EN}${EM}]`
6// Code spans, backtick and tilde fences, and raw URLs pass through untouched.
7const PROTECTED = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`|https?:\/\/[^\s)>\]]+)/
8const DASH = new RegExp(D)
9// Unspaced only: "Phase 1 - 2 weeks" is a clause break, "110-155" is a range.
10const RANGE = new RegExp(`(\\d)${D}(\\d)`, 'g')
11const LEADING = new RegExp(`^([ \\t]*)${D}[ \\t]*`, 'gm')
12const SPACED = new RegExp(`[ \\t]+${D}[ \\t]+`, 'g')
13const TRAILING = new RegExp(`[ \\t]*${D}[ \\t]*$`, 'gm')
14const BARE_EM = new RegExp(EM, 'g')
15const BARE_EN = new RegExp(EN, 'g')
16
17function scrubProse(text: string): string {
18 return text
19 .replace(RANGE, '$1-$2')
20 .replace(LEADING, '$1- ')
21 .replace(TRAILING, '')
22 .replace(SPACED, ', ')
23 .replace(BARE_EM, ', ')
24 .replace(BARE_EN, '-')
25 .replace(/[ \t]*,[ \t]*([,.;:!?])/g, '$1')
26}
27
28/** Rewrites em/en dashes in prose, leaving code, fences and URLs untouched. */
29export function scrub(text: string): string {
30 if (!DASH.test(text)) return text
31 const parts = text.split(PROTECTED)
32 return parts
33 .map((part, i) => {
34 if (i % 2 === 1) return part
35 // A segment after a URL or span starts mid-line; the stub keeps LEADING off it.
36 const isLineStart = i === 0 || /\n$/.test(parts[i - 1] ?? '')
37 return isLineStart ? scrubProse(part) : scrubProse(`x${part}`).slice(1)
38 })
39 .join('')
40}
41