SLOPSHOPPER

slopless

Blocks AI sounding text in social media writing (X replies, X posts, LinkedIn posts) and injects the writing rules.

newguardstatusprompt
v0.1.2MITupdated 2026-10-030xGondarxyz/slopless
A shopper browsing a rack in a slop shop
README

slopless

A Claude Code mod that stops Claude from writing AI sounding posts and replies for X, LinkedIn and other social media. MIT licensed.

I let Claude write 48 replies on X for me. They read like this:

$200 MRR and 200 active subscribers as a side project is real traction. That's enough to validate the product works. Now the question is whether to double down or keep compounding on the side.

They got 2 to 7 views each. So I read 421 replies under 8 indie hacker posts and compared the ones people liked with the ones nobody touched. The liked ones asked a real question, shared the replier's own number, gave the fix, joked about one detail or pushed back in a few words. The ignored ones sounded like the reply above. rules.md is what came out of that.

What it does

WhereWhat happens
System prompta short notice that social text follows rules.md
Your promptif it mentions tweets, X posts or replies, LinkedIn, TikTok, Reddit, captions and so on, rules.md and your voice file are attached
Write and Edit.txt and .md files under a folder whose name contains social, linkedin, tweet or twitter are checked. ALL CAPS file names (README.md, PLAN.md) count as notes and are skipped
Bashcommands that write into those folders are checked
Browsertext Claude types through Claude in Chrome (type, form_input, browser_batch, scripts that insert text) is checked. Hashtags are allowed when the tab is on youtube.com (titles and descriptions there need them); all other rules still apply

When the text breaks a rule, the tool call is blocked and Claude gets the hits:

slopless blocked this text. Rewrite it from scratch, do not reword around the rule.
- verdict: "real traction"
- advice: "double down"
- word: "compounding"

What hooks/lint.ts blocks:

RuleExamples
contrastnot just, rather than, instead of, matters more than, wins over
templateis the product, doing all the work, the hard part, where the time goes, this is why
verdictreal traction, real validation, smart way to, sharp framing
advicedouble down, now figure out, the question is whether
wordleverage, moat, compounding, seamless, robust, crucial, ROI and about 20 more
formatem dashes, hyphens between words, hashtags, emoji, profanity
lolmore than once in a text, or in more than 1 of the last 10 texts

The hyphen, emoji and profanity bans are my own taste. Delete those lines in hooks/lint.ts if you want them back.

Regexes only catch the obvious stuff. A moral one liner at the end of a post, or a reply that explains the post back to its author, gets through the linter. rules.md covers those, and Claude reads it before it writes.

Tested

Same prompt, run headless with Sonnet 5.5, with and without the mod: write my reply to "Huge milestone for my side project: $200 MRR and over 200 active subscribers", make it sound insightful, say this is real traction and the question is whether to double down.

Without slopless:

This is real traction. 200 people paying is a different signal from 200 people signing up. It validates the product: strangers value it enough to hand over money every month. Now the interesting question is whether to double down.

With slopless:

$200 across 200 subs is about $1 each, is that a cheap plan or mostly discounts?

And when I told it to save "This is real traction. The moat was never the code, it is the distribution. Now double down." word for word into a posts folder, the write was blocked and nothing was saved.

Voice file

Optional. A markdown file with facts about you: what you built, your real numbers, what you never talk about. Claude may only claim experience from it. Point the mod at it:

echo '{"voiceFile": "/absolute/path/VOICE.md"}' | claude plugin configure slopless@slopless --values-stdin

Claude can't turn it off

The mod blocks claude plugin disable and uninstall for itself, edits to its own files, and settings edits that switch it off. You can still turn it off yourself with /plugin.

Install

Inside Claude Code:

/plugin marketplace add 0xGondarxyz/slopless
/plugin install slopless@slopless

Or try it without installing:

git clone https://github.com/0xGondarxyz/slopless
claude --plugin-dir slopless

Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.

Run its tests: claude plugin test slopless

Sister mod: dashless, which rewrites em dashes out of everything Claude writes.

Source 2 files
hooks/register.ts 245 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { lint, hasLol } from './lint'
3import type { Hit } from './lint'
4
5const TRIGGER =
6  /\b(tweets?|tweeting|twitter|x\.com|linkedin|instagram|tiktok|reddit|social media|socials|captions?|hashtags?)\b|\bx (post(s|ed|ing)?|repl(y|ies)|comments?|threads?)\b|\bon x\b/i
7const SOCIAL_SEGMENT = /social|linkedin|tweet|twitter/i
8const NOTE_NAME = /^(?=.*[A-Z])[A-Z0-9_]+$/
9const WRITES = />|\btee\b|\bcp\s|\bmv\s|\bsed\s+-i|<<|python/
10const ROOT_TOUCH = />|\btee\b|\bcp\s|\bmv\s|\brm\s|\bsed\s+-i|<<|python|\bchmod\b|\bln\s/
11const PLUGIN_OFF = /\bclaude\s+plugins?\s+(disable|uninstall|remove|rm)\b[^\n]*slopless/i
12const SETTINGS_OFF = /"slopless[^"]*"\s*:\s*false/
13const WRITES_TO_PAGE =
14  /insertText|execCommand|\.value\s*=|textContent\s*=|innerText\s*=|innerHTML\s*=|contenteditable|tweetTextarea|ClipboardEvent|DataTransfer/i
15const CHROME = 'mcp__claude-in-chrome__'
16const NO_OFF = 'slopless cannot be turned off by the model. Ask the user to do it with /plugin.'
17
18let blocked = 0
19let rulesShown = false
20
21const words = (s: string) => s.split(/\s+/).filter(Boolean).length
22
23function isSocialFile(path: string): boolean {
24  const parts = path.split('/')
25  const base = parts[parts.length - 1] ?? ''
26  if (!/\.(txt|md)$/i.test(base)) return false
27  if (NOTE_NAME.test(base.replace(/\.(txt|md)$/i, ''))) return false
28  return parts.some((p) => SOCIAL_SEGMENT.test(p))
29}
30
31function bashTouchesSocial(cmd: string): boolean {
32  // Quoted prose (commit messages, titles) names a platform without writing to it.
33  return cmd
34    .replace(/'[^'\n]*'|"(?:[^"\\\n]|\\.)*"/g, (q) => (/[ \t]/.test(q) ? ' ' : q))
35    .split(/\s+/)
36    .map((t) => t.replace(/^['"(]+|['"),;:.!?]+$/g, ''))
37    .filter((t) => /[/._-]/.test(t))
38    .some((t) => t.split('/').some((p) => SOCIAL_SEGMENT.test(p)))
39}
40
41function jsLiterals(code: string): string[] {
42  const out: string[] = []
43  const re = /'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"|`((?:[^`\\]|\\.)*)`/g
44  let m: RegExpExecArray | null
45  while ((m = re.exec(code))) {
46    const s = m[1] ?? m[2] ?? m[3] ?? ''
47    if (words(s) >= 3) out.push(s)
48  }
49  return out
50}
51
52type BrowserText = { text: string; tabId?: number }
53
54function browserTexts(name: string, input: Record<string, unknown>): BrowserText[] {
55  const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
56  const forAction = (tool: string, inp: Record<string, unknown>): string[] => {
57    if (tool === 'computer') {
58      const t = str(inp.text)
59      return inp.action === 'type' && t ? [t] : []
60    }
61    if (tool === 'form_input') {
62      const v = str(inp.value)
63      return v ? [v] : []
64    }
65    if (tool === 'javascript_tool') {
66      const t = str(inp.text)
67      return t && WRITES_TO_PAGE.test(t) ? jsLiterals(t) : []
68    }
69    return []
70  }
71  const out: BrowserText[] = []
72  const add = (texts: string[], inp: Record<string, unknown>) => {
73    const tabId = typeof inp.tabId === 'number' ? inp.tabId : undefined
74    for (const text of texts) out.push({ text, tabId })
75  }
76  if (name.startsWith(CHROME)) {
77    const tool = name.slice(CHROME.length)
78    if (tool === 'browser_batch') {
79      const actions = Array.isArray(input.actions) ? input.actions : []
80      for (const a of actions) {
81        if (a && typeof a === 'object') {
82          const item = a as { name?: unknown; input?: unknown }
83          if (typeof item.name === 'string' && item.input && typeof item.input === 'object') {
84            const tn = item.name.startsWith(CHROME) ? item.name.slice(CHROME.length) : item.name
85            add(forAction(tn, item.input as Record<string, unknown>), item.input as Record<string, unknown>)
86          }
87        }
88      }
89    } else {
90      add(forAction(tool, input), input)
91    }
92  } else if (name.startsWith('mcp__') && /(type|fill|insert)/i.test(name)) {
93    add([str(input.text), str(input.value)].filter((s): s is string => !!s), {})
94  }
95  return out.filter((t) => words(t.text) >= 3)
96}
97
98function isYouTubeHost(url: string): boolean {
99  try {
100    const host = new URL(url).hostname.toLowerCase()
101    return host === 'youtube.com' || host.endsWith('.youtube.com')
102  } catch {
103    return false
104  }
105}
106
107// Tab id to URL for the browser group. Fails closed: any error or bad JSON gives no tabs.
108async function tabUrls($: EngineInterface): Promise<Map<number, string>> {
109  const urls = new Map<number, string>()
110  try {
111    const res = await $.mcp.call('claude-in-chrome', 'tabs_context_mcp', {})
112    if (res.isError) return urls
113    const block = (res.content as { type?: string; text?: string }[]).find((c) => c.type === 'text' && typeof c.text === 'string')
114    if (!block?.text) return urls
115    const ctx = JSON.parse(block.text.split('\n')[0] ?? '') as { availableTabs?: { tabId?: number; url?: string }[] }
116    for (const t of ctx.availableTabs ?? []) if (typeof t.tabId === 'number' && typeof t.url === 'string') urls.set(t.tabId, t.url)
117  } catch {}
118  return urls
119}
120
121async function readRules($: EngineInterface, voiceFile: string): Promise<string[]> {
122  const parts: string[] = []
123  try {
124    parts.push(await $.fs.read(`${$.plugin.root}/rules.md`))
125  } catch {}
126  if (voiceFile) {
127    try {
128      parts.push(await $.fs.read(voiceFile))
129    } catch {}
130  }
131  return parts
132}
133
134export const register: Register = (on, options) => {
135  const voiceFile = typeof options.voiceFile === 'string' ? options.voiceFile : ''
136
137  on('prompt.compose', ($, e, next) => {
138    const text =
139      'slopless is active. Text for social media (replies, X posts, LinkedIn posts, captions) must follow the slopless rules. ' +
140      'The rules are attached to prompts about social media and to slopless messages. ' +
141      'Writes to social folders and text typed into a browser are checked and blocked when they break the rules.'
142    return (async () => {
143      const res = await next(e)
144      return { ...res, sections: [...res.sections, { id: 'slopless:notice', text, scope: 'session' as const }] }
145    })()
146  })
147
148  on('prompt.submit', async ($, e, next) => {
149    try {
150      if (TRIGGER.test(e.text)) {
151        const parts = await readRules($, voiceFile)
152        if (parts.length > 0) {
153          rulesShown = true
154          return next({ ...e, context: [...(e.context ?? []), ...parts.filter((p) => p.length > 0)] })
155        }
156      }
157    } catch {}
158    return next(e)
159  })
160
161  on('tool.call', async ($, e, next) => {
162    const name = String(e.tool)
163    const input = e as unknown as Record<string, unknown>
164    const root = $.plugin.root
165    const str = (v: unknown) => (typeof v === 'string' ? v : '')
166
167    // Self protection.
168    if (name === 'Bash') {
169      const cmd = str(input.command)
170      if (PLUGIN_OFF.test(cmd) || (root && cmd.includes(root) && ROOT_TOUCH.test(cmd))) return { deny: NO_OFF }
171    }
172    if (name === 'Write' || name === 'Edit') {
173      const path = str(input.file_path)
174      const body = name === 'Write' ? str(input.content) : str(input.new_string)
175      if (root && (path === root || path.startsWith(`${root}/`))) return { deny: NO_OFF }
176      if (/settings(\.local)?\.json$/.test(path) && SETTINGS_OFF.test(body)) return { deny: NO_OFF }
177    }
178
179    // Collect text to lint.
180    let texts: BrowserText[] = []
181    let shell = false
182    if (name === 'Write' || name === 'Edit') {
183      const path = str(input.file_path)
184      if (isSocialFile(path)) texts = [{ text: name === 'Write' ? str(input.content) : str(input.new_string) }]
185    } else if (name === 'Bash') {
186      const cmd = str(input.command)
187      if (bashTouchesSocial(cmd) && WRITES.test(cmd)) {
188        texts = [{ text: cmd }]
189        shell = true
190      }
191    } else if (name.startsWith('mcp__')) {
192      texts = browserTexts(name, input)
193    }
194    if (texts.length === 0) return next(e)
195
196    const hits: Hit[] = []
197    const lolHistory = await readHistory($)
198    let pendingLol: boolean[] = []
199    let tabs: Map<number, string> | undefined
200    for (const { text: t, tabId } of texts) {
201      let textHits = lint(t, { shell })
202      if (tabId !== undefined && textHits.some((h) => h.rule === 'hashtag')) {
203        tabs ??= await tabUrls($)
204        if (isYouTubeHost(tabs.get(tabId) ?? '')) textHits = textHits.filter((h) => h.rule !== 'hashtag')
205      }
206      const lol = hasLol(t)
207      if (lol && [...lolHistory, ...pendingLol].slice(-9).some(Boolean)) {
208        textHits.push({ rule: 'lol', match: 'lol: used in one of the last 10 texts' })
209      }
210      if (textHits.length === 0) pendingLol = [...pendingLol, lol]
211      for (const h of textHits) if (!hits.some((x) => x.rule === h.rule && x.match === h.match)) hits.push(h)
212    }
213
214    if (hits.length === 0 && rulesShown) {
215      try {
216        await $.store.set('lolHistory', [...lolHistory, ...pendingLol].slice(-10))
217      } catch {}
218      return next(e)
219    }
220
221    blocked += 1
222    $.ui.status(`slopless: ${blocked} blocked`)
223    let msg =
224      hits.length === 0
225        ? 'slopless: read these rules, then write the text again following them.'
226        : 'slopless blocked this text. Rewrite it from scratch, do not reword around the rule.\n' +
227          hits.map((h) => `- ${h.rule}: "${h.match}"`).join('\n')
228    if (!rulesShown) {
229      rulesShown = true
230      const parts = await readRules($, voiceFile)
231      if (parts.length > 0) msg += `\n\n${parts.join('\n\n')}`
232    }
233    return { deny: msg }
234  })
235}
236
237async function readHistory($: EngineInterface): Promise<boolean[]> {
238  try {
239    const v = await $.store.get('lolHistory')
240    return Array.isArray(v) ? v.map(Boolean).slice(-10) : []
241  } catch {
242    return []
243  }
244}
245
hooks/lint.ts 144 lines
1export type Hit = { rule: string; match: string }
2export type LintOptions = { shell?: boolean }
3
4const RULES: [string, RegExp][] = [
5  ['dash', /\u2014/],
6  ['dash', / \u2013 /],
7  ['dash', /(^|\s)--(?=\s|$)|\w--/],
8  ['hyphen', /[A-Za-z]-[A-Za-z]/],
9  ['hashtag', /(^|\s)#[A-Za-z]\w*/],
10  ['emoji', /\p{Extended_Pictographic}/u],
11  ['profanity', /\bfuck\w*/],
12  ['contrast', /\bnot (just|only|merely|simply)\b/],
13  ['contrast', /\bnot [^.,;:!?\n]{1,40}, (but|it'?s|they'?re|that'?s)\b/],
14  ['contrast', /\b(it'?s|that'?s|this is|they'?re) not [^.!?\n]{1,40}[.;] (it'?s|that'?s|this is|they'?re)\b/],
15  ['contrast', /, not (a |an |the )?[a-z]+/],
16  ['contrast', /\brather than\b/],
17  ['contrast', /\binstead of\b/],
18  ['contrast', /\bmatters? more than\b/],
19  ['contrast', /\bmore than just\b/],
20  ['contrast', /\bwins over\b/],
21  ['contrast', /\bwins because\b/],
22  ['contrast', /\b(always|still|quietly) (wins?|beats?)\b/],
23  ['contrast', /\bwill always beat\b/],
24  ['contrast', /\bbeats (giving|having|being|doing|a|an|the)\b/],
25  ['template', /\bis the product\b/],
26  ['template', /\bthe moat\b/],
27  ['template', /\bwas never (the|about)\b/],
28  ['template', /\bthat'?s what makes\b/],
29  ['template', /\bis what (keeps|makes|separates|kills|matters)\b/],
30  ['template', /\bthe real (edge|unlock|win|question|problem|work|lesson|story|moat|game)\b/],
31  ['template', /\bhere'?s the thing\b/],
32  ['template', /\bthe craft is\b/],
33  ['template', /\bthe hard part\b/],
34  ['template', /\bwhere the time goes\b/],
35  ['template', /\bheavy lifting\b/],
36  ['template', /\bdoing all the (\w+ )?work\b/],
37  ['template', /\bis carrying\b/],
38  ['template', /\bcarrying the (whole|entire)\b/],
39  ['template', /\btreat it like one\b/],
40  ['template', /\blet that sink in\b/],
41  ['template', /\bthe lesson\?/],
42  ['template', /\bkey takeaway\b/],
43  ['template', /\bhere'?s what i learned\b/],
44  ['template', /\bthe best part\?/],
45  ['template', /\bgame ?changer\b/],
46  ['template', /\bin today'?s\b/],
47  ['template', /\bit'?s worth noting\b/],
48  ['template', /\blet'?s dive\b/],
49  ['template', /\bhard to fake\b/],
50  ['template', /\bthis is why\b/],
51  ['template', /\bwins? the math\b/],
52  ['template', /\blesson for (anyone|everyone)\b/],
53  ['template', /\bthe part i('?d| would)? (like|love|copy)\b/],
54  ['template', /\bsave yourself the\b/],
55  ['template', /\bexcited to share\b/],
56  ['template', /\bhumbled\b/],
57  ['template', /(^|\n)\s*(agree|thoughts)\?\s*$/m],
58  ['verdict', /\breal traction\b/],
59  ['verdict', /\breal validation\b/],
60  ['verdict', /\bis brutal\b/],
61  ['verdict', /\bsmart (way|move)\b/],
62  ['verdict', /\bthe right (split|call|move)\b/],
63  ['verdict', /\bsharp framing\b/],
64  ['verdict', /\bno.?brainer\b/],
65  ['verdict', /\bis huge\b/],
66  ['advice', /\bnow figure out\b/],
67  ['advice', /\bthe question is whether\b/],
68  ['advice', /\bnow the question\b/],
69  ['advice', /\bdouble down\b/],
70  ['word', /\bleverage[ds]?\b/],
71  ['word', /\bcompound(s|ing|ed)?\b/],
72  ['word', /\bmoats?\b/],
73  ['word', /\btraction\b/],
74  ['word', /\bintentional(ly)?\b/],
75  ['word', /\bcraft\b/],
76  ['word', /\bdifferentiator\b/],
77  ['word', /\bfundamentally\b/],
78  ['word', /\bultimate\b/],
79  ['word', /\bquietly\b/],
80  ['word', /\blandscape\b/],
81  ['word', /\bdelve\b/],
82  ['word', /\btapestry\b/],
83  ['word', /\btestament\b/],
84  ['word', /\bpivotal\b/],
85  ['word', /\bcrucial\b/],
86  ['word', /\bseamless(ly)?\b/],
87  ['word', /\brobust\b/],
88  ['word', /\belevate\b/],
89  ['word', /\bempower\b/],
90  ['word', /\bdistribution engine\b/],
91  ['word', /\bsynergy\b/],
92  ['word', /\bat scale\b/],
93  ['word', /\bnext level\b/],
94  ['word', /\blevel up\b/],
95  ['word', /\bROI\b/],
96  ['word', /\bthe (real |biggest )?unlock\b/],
97]
98
99const LOL = /\blol\b/gi
100
101function strip(text: string, shell: boolean): string {
102  const base = text
103    .replace(/```[\s\S]*?(```|$)/g, ' ')
104    .replace(/`[^`\n]*`/g, ' ')
105    .replace(/https?:\/\/\S+/gi, ' ')
106    .replace(/\bwww\.\S+/gi, ' ')
107    .replace(/@\w+/g, ' ')
108  return base
109    .split(/(\s+)/)
110    .map((tok) => {
111      if (/^\s*$/.test(tok)) return tok
112      if (/[\/\\~]/.test(tok) || /\w+\.\w{2,}/.test(tok)) return ' '
113      // In a shell command, bare flags, file names, owner:branch and --key=value ("-p", "social-media-posts") are not prose.
114      if (shell && /^[\w.:=-]+$/.test(tok) && tok.includes('-')) return ' '
115      return tok
116    })
117    .join('')
118}
119
120export function lint(text: string, opts: LintOptions = {}): Hit[] {
121  const clean = strip(text, opts.shell === true)
122  const hits: Hit[] = []
123  const seen = new Set<string>()
124  const add = (rule: string, raw: string) => {
125    const match = raw.trim().slice(0, 60)
126    const key = `${rule}\u0000${match.toLowerCase()}`
127    if (seen.has(key)) return
128    seen.add(key)
129    hits.push({ rule, match })
130  }
131  for (const [rule, re] of RULES) {
132    const flags = re.flags.includes('i') ? re.flags : `${re.flags}i`
133    const m = new RegExp(re.source, flags).exec(clean)
134    if (m) add(rule, m[0])
135  }
136  const lols = clean.match(LOL)
137  if (lols && lols.length > 1) add('lol', `lol x${lols.length} in one text`)
138  return hits
139}
140
141export function hasLol(text: string): boolean {
142  return /\blol\b/i.test(text)
143}
144