SLOPSHOPPER

dash-scrub

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

new
v0.1.0MITupdated 2026-10-02mark-liu/cc-mods/plugins/dash-scrub
A shopper browsing a rack in a slop shop
README

cc-mods

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.

Install

/plugin marketplace add mark-liu/cc-mods
/plugin install dash-scrub@cc-mods
/plugin install reminder-trim@cc-mods

Plugins

dash-scrub

Rewrites em and en dashes in assistant prose before the message is stored, so the transcript and the rendered reply never carry them.

  • Unspaced dash between digits becomes a hyphen: 110 + dash + 155 reads 110-155
  • Spaced or bare dash in prose becomes a comma; a leading dash becomes a - bullet; a trailing dash is dropped
  • Code spans, backtick and tilde fences, and URLs pass through untouched; lines are never joined

Scope: 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.

reminder-trim

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.

  • Drops task_reminder and todo_reminder through prompt.attachment; every other attachment passes through
  • The transcript keeps the engine's record; only the model's copy is left out

Develop

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

Load a working copy without installing: CLAUDE_CODE_PLUGIN_DIRS=/path/to/plugins/<name> claude.

Licence

MIT

Source 2 files
hooks/register.ts 21 lines
1import 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}
21
hooks/scrub.ts 41 lines
1// 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