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…

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.
| Tell | Example |
|---|---|
| Em dash | — |
| "delve" | delve, delving |
| Filler transition | it's worth noting, needless to say |
| "not X, but Y" framing | not just a tool, but a platform · it isn't X, it's Y |
| Stock opener | in today's fast-paced world |
| Throat-clearing | here's the thing, let's dive in, let that sink in |
| Inflated word | tapestry, testament to, seamless, game-changer, unlock the power |
| Rule of three | quickly, safely, and reliably |
| Stock closer | I hope this helps, in conclusion, |
| French tells | il 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.
| Command | |
|---|---|
/jrdeslop | List the tells found this session |
/jrdeslop <file> | Check one file now |
/plugin install jr-deslop --marketplace jrlprost/claude-code-mods
Reads the prose files Claude has just written or edited, and the file you name. Writes nothing, runs nothing, sends nothing over the network.
hooks/register.tsx 97 lines1import { 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}
97hooks/rules.ts 47 lines1import 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}
47hooks/jr/draw.tsx 35 lines1// 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)
35hooks/jr/gradient.ts 75 lines1// 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}
75types/index.d.ts 9 lines1export 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