SLOPSHOPPER

jr-deslop

Flags AI-writing tells (em dashes, "delve", "not X, but Y", stock openers...) in the prose files Claude writes. A toast after each file, /jrdeslop for the…

newpanebandguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-deslop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-deslop
│ ┃ jr deslop ✕ › fix the failing auth test and add an audit log call │ ┃ jr deslop 0 tells in 0 files · hints, not │ ┃ No tells yet. Prose files Claude writes or ⏺ Read(src/auth.ts) │ ┃ edits are checked here. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jrdeslop │ ⎿ jr-deslop: jr deslop · 0 tells listed │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr deslop
jr deslop 0 tells in 0 files · hints, not rules No tells yet. Prose files Claude writes or edits are checked here.
README

jr-deslop

Checks the prose Claude writes for the tells of machine-written text, and tells you where they are. It never rewrites anything: you decide.

After Claude writes or edits a .md, .mdx, .markdown, .txt or .rst file:

jr deslop · 6 AI-writing tells in launch-notes.md · /jrdeslop to see them

/jrdeslop lists them by file and line:

jr deslop   6 tells in 1 file · hints, not rules
docs/launch-notes.md
     2  stock opener            In today’s fast-paced world, we delve into…
     2  "delve"                 …fast-paced world, we delve into the details.
     3  filler transition       It’s worth noting that this is not just a tool…
     3  "not X, but Y" framing  …that this is not just a tool, but a platform.

What it looks for

TellExample
Em dash—
"delve"delve, delving
Filler transitionit's worth noting, needless to say
"not X, but Y" framingnot just a tool, but a platform · it isn't X, it's Y
Stock openerin today's fast-paced world
Throat-clearinghere's the thing, let's dive in, let that sink in
Inflated wordtapestry, testament to, seamless, game-changer, unlock the power
Rule of threequickly, safely, and reliably
Stock closerI hope this helps, in conclusion,
French tellsil est important de noter, n'hésitez pas, en constante évolution, ce n'est pas X, c'est Y

Fenced code blocks and inline code are skipped.

Commands

Command
/jrdeslopList the tells found this session
/jrdeslop <file>Check one file now

Install

/plugin install jr-deslop --marketplace jrlprost/claude-code-mods

Access

Reads the prose files Claude has just written or edited, and the file you name. Writes nothing, runs nothing, sends nothing over the network.

Source 5 files
hooks/register.tsx 97 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { PROSE, scan } from './rules'
5import { gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7
8const PANE = 'jr-deslop'
9const hits = atom({ plugin: 'jr-deslop', key: 'hits' } as const, [])
10const commands = { isRegistered: false }
11
12async function ensureCommand($: EngineInterface) {
13  if (commands.isRegistered) return
14  commands.isRegistered = true
15  await $.command.register({ name: 'jrdeslop', description: 'Show AI-writing tells in the prose files written this session; /jrdeslop <file> scans one file' })
16}
17
18/** Scans one file and replaces its hits; returns how many it has. */
19async function rescan($: EngineInterface, file: string) {
20  let text: string
21  try {
22    text = await $.fs.read(file)
23  } catch {
24    return 0
25  }
26  const found = scan(file, text)
27  await update($, hits, list => [...found, ...list.filter(hit => hit.file !== file)].slice(0, 500))
28  return found.length
29}
30
31export const register: Register = on => {
32  on('session.start', async ($, e, next) => {
33    const started = await next(e)
34    await ensureCommand($)
35    return started
36  })
37
38  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
39    if (!commands.isRegistered) {
40      $.clock.after(0, () => {
41        void ensureCommand($)
42      })
43    }
44    return next(e)
45  })
46
47  // After Claude writes or edits a prose file, look it over. The file is never changed.
48  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
49    const ran = await next(e)
50    const file = (e as unknown as { file_path?: string }).file_path
51    if (!file || !PROSE.test(file) || ('deny' in ran && ran.deny) || ('isError' in ran && ran.isError)) return ran
52    const count = await rescan($, file)
53    if (count > 0) $.ui.toast(`jr deslop · ${count} AI-writing tell${count > 1 ? 's' : ''} in ${file.split('/').pop()} · /jrdeslop to see them`)
54    return ran
55  })
56
57  on('command.run', { command: 'jrdeslop' }, async ($, e) => {
58    const file = e.args.trim()
59    if (file) {
60      const count = await rescan($, file)
61      if (count === 0) return { text: `jr deslop · no tells found in ${file}` }
62    }
63    await $.ui.open({ id: PANE, title: 'jr deslop', rows: 16 })
64    const list = await read($, hits)
65    return { text: `jr deslop · ${list.length} tell${list.length === 1 ? '' : 's'} listed` }
66  })
67
68  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
69    const { Box, Text } = $.ui.resolve(e)
70    const list = await read($, hits)
71    const files = [...new Set(list.map(hit => hit.file))]
72    return (
73      <Box flexDirection="column">
74        <Box>
75          {gradientText(Text, 'jr deslop', GRADIENTS.brand)}
76          <Text dimColor>{`   ${list.length} tell${list.length === 1 ? '' : 's'} in ${files.length} file${files.length === 1 ? '' : 's'} · hints, not rules`}</Text>
77        </Box>
78        {list.length === 0 && <Text dimColor>No tells yet. Prose files Claude writes or edits are checked here.</Text>}
79        {files.map(file => (
80          <Box flexDirection="column">
81            <Text bold color="#ffb86b">{file}</Text>
82            {list
83              .filter(hit => hit.file === file)
84              .map(hit => (
85                <Text wrap="truncate-end">
86                  <Text color="#ff5ca8">{`  ${String(hit.line).padStart(4)}  `}</Text>
87                  <Text color="#7f5af0">{hit.label.padEnd(24)}</Text>
88                  <Text dimColor>{hit.excerpt}</Text>
89                </Text>
90              ))}
91          </Box>
92        ))}
93      </Box>
94    )
95  })
96}
97
hooks/rules.ts 47 lines
1import type { JrDeslopHit } from '../types'
2
3export type Rule = { id: string; label: string; pattern: RegExp }
4
5// Tells of machine-written prose. Each is a hint for a human to judge, never an automatic rewrite.
6export const RULES: Rule[] = [
7  { id: 'em-dash', label: 'em dash', pattern: /—/ },
8  { id: 'delve', label: '"delve"', pattern: /\bdelv(?:e|es|ed|ing)\b/i },
9  { id: 'worth-noting', label: 'filler transition', pattern: /\b(?:it(?:'|’)s worth (?:noting|mentioning)|it is worth noting|needless to say|it goes without saying)\b/i },
10  { id: 'not-but', label: '"not X, but Y" framing', pattern: /\bnot (?:just|only|merely|simply) [^.;:!?]{1,50},? but\b|\b(?:isn(?:'|’)t|is not) (?:just |only )?[^.;:!?]{1,40}[,;] it(?:'|’)s\b/i },
11  { id: 'fast-paced', label: 'stock opener', pattern: /\bin today(?:'|’)s (?:fast-paced|digital|ever-changing|rapidly evolving|modern) (?:world|age|landscape|era)\b|\bin an (?:ever-changing|increasingly) [a-z-]+ world\b/i },
12  { id: 'throat-clearing', label: 'throat-clearing', pattern: /\bhere(?:'|’)s (?:the thing|the kicker|the deal|why|what most people miss)\b|\blet(?:'|’)s (?:dive in|dive into|unpack|break (?:this|it) down|delve)\b|\bthe uncomfortable truth\b|\blet that sink in\b|\bmake no mistake\b/i },
13  { id: 'grand-words', label: 'inflated word', pattern: /\b(?:tapestry|testament to|realm of|ever-evolving|navigat(?:e|ing) the (?:complexities|landscape)|game-chang(?:er|ing)|cutting-edge|unlock(?:s|ing)? the (?:power|potential)|elevate your|seamless(?:ly)?|robust and scalable|harness the power)\b/i },
14  { id: 'adverb-triplet', label: 'rule of three', pattern: /\b\w+ly, \w+ly,? and \w+ly\b/i },
15  { id: 'closer', label: 'stock closer', pattern: /\b(?:i hope this helps|in conclusion,|ultimately, |at the end of the day)\b/i },
16  { id: 'fr-filler', label: 'tournure de remplissage', pattern: /\bil est (?:important|essentiel|crucial|intéressant) de (?:noter|souligner|rappeler)\b|\bn(?:'|’)hésitez pas\b/i },
17  { id: 'fr-opener', label: 'formule toute faite', pattern: /\b(?:en constante évolution|dans un monde où|plongeons(?: ensemble)?|à l(?:'|’)ère du numérique)\b/i },
18  { id: 'fr-not-but', label: '"ce n\'est pas X, c\'est Y"', pattern: /\bce n(?:'|’)est pas (?:seulement |juste |qu(?:'|’)un(?:e)? )?[^.;:!?]{1,50}, c(?:'|’)est\b/i },
19]
20
21export const PROSE = /\.(?:md|mdx|markdown|txt|rst)$/i
22
23/** Hits per line, outside fenced code blocks and inline code. */
24export const scan = (file: string, text: string): JrDeslopHit[] => {
25  const hits: JrDeslopHit[] = []
26  let inFence = false
27  text.split('\n').forEach((raw, i) => {
28    if (/^\s*(?:```|~~~)/.test(raw)) {
29      inFence = !inFence
30      return
31    }
32    if (inFence) return
33    const line = raw.replace(/`[^`]*`/g, '')
34    for (const rule of RULES) {
35      const m = line.match(rule.pattern)
36      if (m) hits.push({ file, line: i + 1, rule: rule.id, label: rule.label, excerpt: excerptAround(line, m.index ?? 0) })
37    }
38  })
39  return hits
40}
41
42const excerptAround = (line: string, at: number) => {
43  const start = Math.max(0, at - 24)
44  const end = Math.min(line.length, at + 40)
45  return `${start > 0 ? '…' : ''}${line.slice(start, end).trim()}${end < line.length ? '…' : ''}`
46}
47
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
types/index.d.ts 9 lines
1export type JrDeslopHit = { file: string; line: number; rule: string; label: string; excerpt: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    /** The latest hits per file this session, newest file first. */
6    'jr-deslop': { hits: JrDeslopHit[] }
7  }
8}
9