unslop
You used AI. Now it reads like AI. unslop catches the tells in your writing, code, and UI (the em dash, the generic phrasing, the stock look) so it reads like you, not a model.
Use AI to draft something and it comes out sounding like AI: the same words, the same rhythm, the same stock look everyone else gets. That default is the problem. unslop is three tools that catch it, one for writing, one for code, one for UI. Each ships the same shape: an auto-trigger, a Build mode that specifies your way out of the median before you generate anything, an Audit mode with a deterministic scanner plus a human pass for what a regex can't see, and an unslop-ignore escape hatch for a tell you chose on purpose.
None of the three hands you a house style. They remove the cues that make something read machine-written, and where that default exists only because nobody chose otherwise, they force a real decision instead. The choice itself, your voice, your conventions, your brand, stays yours.
What you get
/a-unslop-text: for reader-facing prose (docs, posts, commit bodies, emails)./a-unslop-code: for source that reads as tutorial-shaped or machine-written./a-unslop-ui: for web UI stuck on a stock default look.
a-unslop-text catches the em dash, banned corporate and AI diction (delve, leverage, utilize, robust, paradigm), the "it's not just X, it's Y" cadence, sycophantic openers, bookending, and leftover assistant boilerplate. It also flags the over-corrected 2026 register, staccato fragments and fake typos deployed to beat a detector, so fixing one tell doesn't just swap it for another.
The text skill goes one step past the ban list, because a draft with every tell removed is clean but still nobody's. Its voice-profile.md reference is a method for building a voice profile from your own pre-AI writing: mine 10 to 15 real emails and docs for the moves you repeat, map your registers, set a language ceiling (non-native writers: clean C1, because the model's polished native English is itself a tell under your byline), and cap your signature phrases so the result never turns into an impression of you. Build mode loads the profile whenever the byline is yours; a matching scanner rule catches literary vocabulary a plainer writer would never use.
a-unslop-code sorts every tell into three buckets before fixing anything: bugs (swallowed errors, a made-up API, a left-in // rest of your code stub), substance (tutorial-shaped boilerplate, over-engineering, code that ignores the surrounding repo), and cosmetic (emoji in source, comments that narrate the obvious, a generic process_data name, pasted chat text). It says plainly that the loudest tells are structural and need a compiler or a human, not a regex, and orders the fix accordingly: run the code first, scan second, read the diff third.
a-unslop-ui flags both the 2024 default (shadcn/Tailwind stock theme, AI-purple, gradient hero text, unprompted neon glow, emoji-as-icons, the centered-hero-plus-three-cards skeleton) and the 2026 "tasteful default" (cream background, serif display face, sage-green accent) that anti-slop advice itself created. It prescribes no palette or font. It asks that whatever you land on be a choice you can name a reason for, which is the one thing a default never is.
Each scanner reports file, line, and fix, plus a severity-weighted score, and exits non-zero on high-severity findings so it can gate a CI job. All three tell catalogs and scanner designs are adapted from the vibecoded-design-tells research (MIT), a large-scale study of what people actually name when they call something AI-written, AI-coded, or AI-designed.
.claude-plugin/plugin.json
{
"name": "unslop",
"description": "Three anti-slop scanners in one bundle: a-unslop-text catches AI-diction and cadence tells in prose, a-unslop-code catches bugs and tutorial-shaped patterns in source, a-unslop-ui catches stock-default looks in web UI. Each ships a deterministic scanner plus a Build mode that heads off the tell before it ships. The text skill also includes a build-your-own voice-profile method so drafts read as their author, not just clean.",
"version": "1.4.0",
"author": {
"name": "Ivan Misic",
"url": "https://ivanmisic.net"
},
"homepage": "https://ivanmisic.net/toolshed/plugins/unslop",
"repository": "https://github.com/imisic/claude-marketplace",
"license": "MIT",
"keywords": [
"writing",
"editing",
"ai-detection",
"code-quality",
"ui",
"design"
]
}
CHANGELOG.md
# unslop changelog ## 1.4.0 The text scanner and its reference catalog now carry the latest rules, exceptions, and structural review guidance. ## 1.3.1 The README now says which commands you get after installing. ## 1.3.0 The skills in this plugin now carry an `agents/openai.yaml` sidecar, so they show up in Codex's skill picker with a proper name and one-line description instead of a raw folder name. All three stay implicitly invocable, so Codex reaches for them while you write, the same as Claude Code does. ## Earlier Versions before this predate this file. See the git history at https://github.com/imisic/claude-marketplace.
README.md
# unslop Use AI to draft something and it comes out sounding like AI: the same words, the same rhythm, the same stock look everyone else gets. unslop is three scanners that catch that default, one for prose, one for code, one for UI, bundled as one plugin. Each is a full skill: an auto-trigger, a Build mode (specify your way out of the default before you generate anything), an Audit mode (a deterministic scanner plus a human pass for what a regex cannot see), and an `unslop-ignore` escape hatch for a tell you chose on purpose. None of the three hand you a house style. They remove the cues that make something read as the model's median output and, where that median exists because nobody chose otherwise, they force a deliberate choice instead. The choice itself (your voice, your code conventions, your brand) stays yours. ## The three skills | Skill | Surface | Scanner | |---|---|---| | `a-unslop-text` | Prose: docs, READMEs, posts, commit bodies, emails | `unslop_text_scan.py` | | `a-unslop-code` | Source code, any language | `unslop_code_scan.py` | | `a-unslop-ui` | Web UI: HTML/CSS/JS/JSX/Vue/Svelte/Astro | `devibe_scan.py` | Each scanner reports findings with file, line, and fix, plus a severity-weighted score, and exits non-zero on high-severity findings so it can gate a CI job. ## a-unslop-text Catches the em dash, banned corporate/AI diction (`delve`, `leverage`, `utilize`, `robust`, `paradigm`...), the "it's not just X, it's Y" cadence, sycophantic openers, bookending, and leftover assistant boilerplate. Flags the over-corrected 2026 register too (forced staccato fragments, fake typos) so you don't trade one tell for another. Also ships `references/voice-profile.md`: a method for building a voice profile from your own pre-AI writing (the moves you repeat, a register dial, a language ceiling), because a draft with every tell removed is clean but still nobody's. Build mode loads the profile when the byline is yours. A matching scanner rule flags literary vocabulary (`whilst`, `endeavour`, `notwithstanding`) that outs ghostwritten text when the author's real register is plainer, which is common for non-native writers. ```bash python3 skills/a-unslop-text/scripts/unslop_text_scan.py <path> python3 skills/a-unslop-text/scripts/unslop_text_scan.py <path> --severity high ``` ## a-unslop-code Sorts every tell into bugs (swallowed errors, a made-up API, a left-in stub), substance (tutorial-shaped boilerplate, over-engineering, code that ignores the repo), and cosmetic (emoji in source, narrating comments, generic `process_data` names, pasted chat text). Says plainly that the loudest tells are structural and need a compiler or a human, not a regex. ```bash python3 skills/a-unslop-code/scripts/unslop_code_scan.py <path> python3 skills/a-unslop-code/scripts/unslop_code_scan.py <path> --severity high ``` ## a-unslop-ui Flags both the 2024 default (shadcn/Tailwind stock theme, AI-purple, gradient text, unprompted neon glow, emoji-as-icons, the centered-hero-plus-three-cards skeleton) and the 2026 "tasteful default" (cream background, serif display face, sage-green accent) that anti-slop advice itself created. Prescribes no palette or font; asks that whatever you use be a choice you can justify. ```bash python3 skills/a-unslop-ui/scripts/devibe_scan.py <path> python3 skills/a-unslop-ui/scripts/devibe_scan.py <path> --severity high ``` ## Use Each skill auto-triggers on its own cues (writing prose, generating or reviewing code, building or styling UI), or invoke it directly by name. Run the matching scanner as an audit gate; read the linked `references/tells.md` (and `references/choosing-a-look.md` / `references/writing-with-intent.md` / `references/voice-profile.md` / `references/fitting-the-codebase.md`) for the tells a scanner cannot see. ## Install ``` /plugin marketplace add imisic/claude-marketplace /plugin install unslop@imisic ``` Then the commands are `/a-unslop-text`, `/a-unslop-code` and `/a-unslop-ui`. You install the plugin, you run the skills. The longer write-up lives on the storefront: [ivanmisic.net/toolshed/plugins/unslop](https://ivanmisic.net/toolshed/plugins/unslop). ## Credits All three tell catalogs and scanner designs are adapted (MIT) from the vibecoded-design-tells research by JCarterJohnson (https://github.com/JCarterJohnson/vibecoded-design-tells), a large-scale analysis of what people actually name as AI-writing, AI-code, and AI-website giveaways. Each `SKILL.md` and scanner header credits it directly.
skills/a-unslop-code/SKILL.md
--- name: a-unslop-code description: >- Finds what makes source code read as AI-written and points you at the parts that actually ship bugs. Sorts every tell into three buckets and fixes them in that order: bugs (swallowed errors, a made-up API, a left-in "rest of the code" stub), substance (tutorial-shaped boilerplate, over-engineering, code that ignores the surrounding repo), and cosmetic (emoji in source, a comment narrating the obvious, a generic process_data name, chat text pasted into a file). Ships a scanner for the surface tells and says plainly that the loudest ones are structural and need a compiler or a human. Use when writing, generating, reviewing, refactoring, or auditing code, and when the user says it "looks AI-written," "reads like a tutorial," "is too generic," or "de-slop this." Trigger even if "AI tell" is never said. --- # a-unslop-code Part of the unslop family (see `a-unslop-text`, `a-unslop-ui`). Same shape: an auto-trigger, a Build mode and an Audit mode, a scanner that gates on a high-severity exit code, and an `unslop-ignore` line escape. This one is about code. **What it is not.** It does not write good code and it has no house style. It removes the surface cues that read as machine-generated, and it points at the deeper tells that ship bugs, the ones no scanner catches. Whether the code is correct and right for the problem is still your call. ## Sort every tell before you fix it One question sets the order of work: is the code wrong, wrong for the job, or does it just look machine-made? - **Bug.** A bare `except` eats the failure you needed. A made-up API will not run. A `// rest of your code` stub means the file is unfinished. Fix these because the code is broken, the same as any bug, whether or not anyone suspected AI. - **Substance.** Not a local bug, wrong for the job: tutorial-shaped boilerplate, an abstraction with one caller, a 2000-line change that should have been 50 because it ignored the repo. A compiler passes these; a reader comparing the diff to the neighbouring code does not. - **Cosmetic.** The chat voice leaking into the file: emoji, a comment that restates the next line, a `process_data` doing eleven things, pasted "Good catch!" text. Worth removing, but nothing breaks if one survives. The ranking behind this is blunt: the structural tells (tutorial shape, made-up APIs) lead by a wide margin, and the cosmetic ones people argue about sit far down. So the surface pass is the cheap part, not the job. A clean scan is not clean code. ## How it fits your setup If your project already has house rules for substance (smallest code that solves the problem, no abstraction for single-use code, surgical changes, comment the why not the what, no commented-out code, no v2/v3 files, remove orphaned imports), this skill does not restate them. It is the audit layer that catches a violation of them, plus the scanner for the cosmetic tells. If no-emoji and no-em-dash are already house rules for you, the scanner flags emoji in source on top of that. ## Build mode Most "looks AI-written" output is an under-specified prompt, not a style problem. The average of public code is the tutorial. Before generating, set three things: - **The surrounding code.** Give the model the file it will sit beside, the nearest sibling that does something similar, and how the repo handles errors, logging, and naming. This one input does more than the rest combined. The single most repeated fix in the data was "make it follow the existing code instead of guessing the average." - **The real requirement.** What the code has to do, including the failure modes and the real integration, not the demo. A vague ask gets the sample-app shape: one page, dummy data, no backend. - **Calls that exist.** Generated code invents plausible APIs. Plan to run it and check every import and method against real docs. `references/fitting-the-codebase.md` is the method. ## Audit mode Order matters, because the scanner is blind to the worst tell. 1. **Run what only code lets you run.** Code can be executed, and that is the strongest tool you have. Build it, type-check it, lint it, resolve every import and call against real docs. This is how the made-up API surfaces, and no regex sees it. Python: `py_compile`, `ruff` or `pyflakes`, `mypy`, plus a real import. JS/TS: `tsc --noEmit`, `eslint`, `node --check`. Go: `go build ./...` and `go vet`. Rust: `cargo check` and `cargo clippy`. If it will not build, or a call resolves to nothing, fix that first. 2. **Run the scanner** for the surface tells. ```bash python3 scripts/unslop_code_scan.py <path> # report + slop score python3 scripts/unslop_code_scan.py <path> --severity high # strongest only python3 scripts/unslop_code_scan.py <path> --json # for CI ``` It covers Python, JS/TS, Java, Go, Rust, Ruby, PHP, C/C++, C#, and more. Each finding carries a class (bug or cosmetic) and a fix. Exit code is the high count, so CI can gate on it. Fix every bug-class finding regardless of severity. 3. **Read the diff** for what neither step sees: tutorial shape, over-engineering, and whether the code matches the repo. These are the loudest tells and they need your eyes. **Escape hatch.** A line with `unslop-ignore` is skipped, for a real decision (a broad catch at a boundary, an emoji in a CLI banner). ## The over-correction trap Told to "write clean code" or "make this not look AI," a model over-produces: a null check for a case that cannot happen, a type on every local, a comment on every block, a factory for a thing with one caller. That is performed seniority, and it reads as AI for the same reason boilerplate does. The cure is the same anchor as the rest: match the level the surrounding code operates at. Add nothing the neighbouring code would not have. ## Fixing well Renaming `process_data` to `processDataFunction` is a different tell, not a fix. Name it for what it does, the way the repo names things. Code has little aesthetic room: the question is almost always "is it correct, and does it match this project," not "which look do I want." ## Reporting Lead with the verdict and the single highest-impact change. Findings by priority with file:line and the fix, bug-class first. Close with the slop score and a reminder that the structural tells still need a compiler or a human. ## Credits The tell catalog and its ranking come from the vibecoded-design-tells research by JCarterJohnson (https://github.com/JCarterJohnson/vibecoded-design-tells, MIT), which mined Reddit for what developers name as AI-code giveaways. This skill rewrites that material into the unslop family: one skill per surface, a shared bug/substance/cosmetic split as its spine, and a shared Build/Audit shape with the text and ui skills. `scripts/unslop_code_scan.py` is an adapted fork of that project's scanner (MIT); the credit is in its header.
skills/a-unslop-code/agents/openai.yaml
interface: display_name: "Unslop Code" short_description: "Catch AI tells and real bugs in source"
skills/a-unslop-code/references/fitting-the-codebase.md
# Making code fit the repo Code reads as AI because the model reaches for the most common pattern in its training set instead of the one your project uses. Any fixed style applied by default is just a different average. So this is how to make code fit the project, which is the property that stops it reading as machine-made. Code has little aesthetic room. The question is almost never which look you want; it is whether the code is correct and matches what the project already does. Fit is the standard, not taste. ## Feed it the repo The input that does the most work is the repo itself. Before generating, give the model the file it will sit beside, the nearest sibling that already does something similar, and the conventions (how errors are handled, how things log, how modules are structured, the naming). A change that follows the patterns is small and invisible. One that ignores them is the 2000-line change that should have been 50. On a brand-new project with no precedent, make one deliberate decision and write it down (a short conventions note the model can read), rather than letting each file invent its own. The tell is not any one pattern; it is the absence of a chosen one. ## State the requirement, not the demo Name the real inputs, the real failure modes, the real integration before generating. Tutorial-shaped code is the loudest tell because the model falls back to the sample-app shape when the ask is vague. If you cannot state the requirement, you will get the demo. ## Run what only code lets you run The tells that bite are the ones no scanner sees, and code has one advantage prose and design do not: you can execute it. Two checks catch most: - Does it call anything that does not exist? Build it, type-check it, resolve every import and method against real docs, not the model's confidence. `tsc --noEmit` and `eslint`, `mypy` and a real import, `go build ./...` and `go vet`, `cargo check` and `cargo clippy`. - Does it match how this repo does things? Read the diff against the neighbouring code. A new logging style or a new structure mid-file is the mismatch tell. If you cannot explain a line the model wrote, do not ship it. ## Do not over-correct "Write clean code" or "do not look AI" backfires into over-production: checks for cases that cannot happen, a type on every local, a comment on every block, a layer for one caller. That is performed seniority, and it reads as AI for the same reason boilerplate does. Match the level the surrounding code operates at. Add nothing the neighbouring code would not have. ## Escape hatch A pattern chosen on purpose is not a tell. A project may want a broad catch at a boundary, an emoji in a CLI banner, a `process_data` step in a throwaway script. The skill flags unchosen defaults, not banned constructs. Mark a real decision `unslop-ignore`. ## One line Feed the model the surrounding code and tell it to match it, then run the result and check every call is real. The scanner removes the surface tells; this removes the ones that ship bugs.
skills/a-unslop-code/references/tells.md
# AI-code tells: the catalog
The human-readable view. The scanner (`scripts/unslop_code_scan.py`) is the enforced layer for the surface tells; this file explains them and lists the ones a regex cannot see. The ranking and the shares come from the research credited in `SKILL.md`; the structure and prose here are this skill's.
Sort by class first, not by whether a regex can see it. A bug means the code is wrong. A substance problem means it is wrong for the job. A cosmetic just looks machine-made. Fix bugs and substance because the code is broken or wrong-for-the-job. Strip cosmetics because they read as AI. A clean cosmetic pass is not a substitute for the other two.
## The loud ones (no regex sees these)
The research is blunt: the tells people name most are about shape, and a scanner is blind to all of them. These need a compiler or your eyes.
- **Tutorial-shaped boilerplate** (the top tell, ~19% verified). Substance. The model emits the average of public code: the sample app, dummy data, no real backend. Fix: build the real thing against the real requirement. The scanner's dummy-data markers (`lorem ipsum`, `YOUR_API_KEY`, `John Doe`) are a weak proxy only.
- **Made-up APIs and libraries** (~11%). Bug, the one that bites in production. A plausible call to something that does not exist, or logic that reads right and runs wrong. Only building, type-checking, running, or resolving the import catches it. Run it.
- **Over-engineering** (~8%). Substance. Layers and abstractions for a simple task. Every extra layer is more surface for a bug. Ask whether an `if`/`else` would do; delete the abstraction with one caller.
- **Ignoring the surrounding repo** (~4%). Substance, and the spine of the fix. Code that works in isolation and invents a new logging style, a new error pattern, a new structure mid-file. The 2000-line change that should have been 50. Fix: feed the model the existing code and match it.
- **Mixed skill level** (~2%). Substance. Advanced patterns next to beginner mistakes, and no one can explain it because no single person wrote it. If you cannot explain a line, do not ship it.
- **Too clean, no human mess** (weak, contested). A meta-signal: passes the linter, no quirks, logic still wrong. For the reviewer only: a clean surface does not end the review.
## The surface ones (the scanner catches these)
Each tagged bug or cosmetic.
- **Swallowed errors** (bug). A bare `except:`, `except Exception: pass`, an empty `catch {}`, an empty Go `if err != nil {}`. Eats the failure you needed. Fix: catch the specific case, handle it, let the unexpected surface. Rust `.unwrap()`/`let _ =` and Go's multi-line empty block are a human pass (too overloaded to regex without noise).
- **Placeholder stubs** (bug). `// rest of your code`, `# your logic here`, `// implementation goes here`, `# TODO: implement`. The file is unfinished. Fix: write the real code.
- **Emoji in source** (cosmetic, the highest-precision cosmetic tell). Any emoji in a comment, string, or log. Fix: remove it. A real CLI checkmark chosen on purpose gets `unslop-ignore`.
- **Narrating comments** (cosmetic). A comment that restates the next line (`# increment i`) or steps through (`# Step 1`, `# Now we...`). Fix: comment why, not what.
- **Generic placeholder names** (cosmetic, often hiding a substance problem). `process_data`, `handle_data`, `do_stuff`, `do_something`. A `process_data` that does eleven things is the canonical case. Fix: name it for its job; if you cannot, it does too much.
- **Pasted chat artifacts** (cosmetic). A code fence inside a source file, `Here's the updated code`, `As an AI`, `Good catch!`, a trailing `Note:`/`Remember:` preamble. Fix: delete the assistant's voice.
- **Whole-sentence identifiers** (cosmetic, weak). `getUserDataFromApiResponseHandler`. The model pads names to self-document. Fix: trim to the precise noun or verb. Descriptive is good; a paragraph is not.
## Language coverage
A tell in a comment or string looks the same in every language, so emoji, stubs, narrating comments, and chat artifacts are caught anywhere. Syntax-keyed tells are uneven by design, because forcing a regex past what it does reliably costs precision:
- Swallowed errors: matched for Python, JS/TS, Java, C#, Ruby try/catch and Go's single-line empty error block. Rust `.unwrap()`/`let _ =` and Go's `_`-discard are a hand read.
- Generic names: keyed on `def`/`function`/`func`/`fn`/`fun`/`sub`.
- An over-broad-but-handled `catch (Exception)` that logs and moves on is not flagged; it can be a real boundary.
If your language is outside that set, read for swallowed errors and generic names by hand.
## Do not chase (cleared by the research)
Three popular complaints did not survive verification. The scanner leaves them alone:
- Left-in debug logging (`print`/`console.log`, chatty `Successfully...` lines): the tagged cases were workflow opinions, not code. Bare logging is not flagged.
- Reinventing the wheel: mostly misread as duplication or made-up libraries.
- Over-defensive validation: half the complaints were the opposite (no validation at all). Do not flag it in review. Do still avoid producing it (see the over-correction trap in `SKILL.md`).
Flag what the research supports, at the weight it supports. Over-flagging trains people to ignore the tool.
skills/a-unslop-code/scripts/unslop_code_scan.py
#!/usr/bin/env python3
"""
unslop_code_scan.py - scan source code for the mechanical "tells" that give away
AI-written code, across languages.
Forked (MIT) from github.com/JCarterJohnson/vibecoded-design-tells; logic and rules unchanged.
Plain Python, standard library only. The patterns and their severity come from a Reddit
analysis (11,906 on-topic posts + 11,306 comments across 55 AI / coding / SaaS subreddits,
2020 to 2026) of what developers actually name as a giveaway that code was written by an
LLM. Every candidate was classified by an LLM into a fixed taxonomy of 19 tells, then each
top tell was adversarially verified by re-reading the quotes. Severity follows the verified
share, so the report tells you where to spend effort.
What it can and cannot see. The loudest tells in the data are about shape and substance:
boilerplate / tutorial-shaped code (verified 18.6%, the #1 tell), hallucinated APIs (11.2%),
over-engineering (7.8%), ignoring the surrounding codebase (3.5%), mixed skill level (1.9%).
A regex cannot judge any of those; they are documented in references/tells.md for a human or
a compiler. This scanner catches the mechanical, surface tells: leftover chat/assistant text,
placeholder comments, emoji, swallowed errors, narrating comments, and generic placeholder
names.
Two axes, not one. Severity is how loudly a finding reads as an AI tell (how conclusive it is
when present). Class is whether it is a bug or a cosmetic. They are independent: a swallowed
error is a quiet tell but a real bug; an emoji is a loud tell but harmless. Fix every bug-class
finding because it is wrong, not because it looks AI-written; treat the cosmetic ones as the
lighter pass.
The highest-impact bug, hallucinated APIs, is invisible to this scanner. Catch it the way only
code allows: build it, type-check it, run it, resolve every import and call against real docs.
The scanner is the cheap second pass, never the first.
Language coverage. Some tells are language-universal because they live in comments or strings:
emoji, leftover chat artifacts, placeholder/ellipsis comments, narrating comments. Others key
on syntax and are language-specific. Swallowed errors are matched for Python / JS / TS / Java /
C# / Ruby try-catch and for Go's single-line empty `if err != nil {}`; Rust's `.unwrap()` /
`let _ =` and Go's `_`-discard are too overloaded to flag by regex without noise, so they are a
human pass (see references/tells.md). Generic-name detection keys on def / function / func /
fn / fun / sub.
Usage:
python3 unslop_code_scan.py <path> # scan a dir or file
python3 unslop_code_scan.py <path> --severity high # only the strongest signals
python3 unslop_code_scan.py <path> --json # machine-readable (for CI)
python3 unslop_code_scan.py <path> --max 8 # cap examples shown per rule
A line containing unslop-ignore is skipped, for a pattern you are using on purpose.
Exit code is the number of HIGH-severity findings (0 = none), so CI can gate on it.
"""
import os, re, sys, json, argparse
EXTS = {".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".go", ".rs", ".rb", ".php",
".c", ".h", ".cpp", ".cc", ".hpp", ".cs", ".kt", ".kts", ".swift", ".scala",
".m", ".mm", ".sh", ".bash", ".lua", ".dart", ".vue", ".svelte", ".sql", ".r"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "out", "vendor", "target",
"coverage", ".venv", "venv", "__pycache__", ".idea", ".gradle", "bin", "obj"}
W = {"high": 3, "medium": 2, "low": 1}
CMT = r"(?://|#|/\*|\*|--|<!--)" # comment openers across common languages
EMOJI = ("\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F000-\U0001F0FF"
"\U00002B00-\U00002BFF\U0001F900-\U0001F9FF\U00002764")
# Each rule: id, label, severity, class, share (the verified data share), fix, patterns.
# Severity follows the verified shares, with the two conclusive paste-in artifacts raised
# to HIGH because when they survive into committed code they are unmistakable.
# Class is the orthogonal axis the goal cares about: "bug" means the code is wrong (it eats a
# failure, ships unfinished, or will not run) and you fix it on those grounds alone; "cosmetic"
# means it is the model's chat voice leaking into the file, a lighter surface pass. Severity and
# class are independent: a swallowed error is MEDIUM severity but a bug; a chat artifact is HIGH
# severity but cosmetic. The biggest bug (hallucinated APIs) is structural and not in this list.
RULES = [
# ---------- HIGH: the conclusive paste-in artifacts ----------
{"id": "chat-artifact", "label": "Leftover chat / assistant / markdown artifact in the code", "sev": "high", "class": "cosmetic",
"share": "verified 1.2%, but unmistakable when present",
"fix": "Delete the assistant's voice before committing: code fences, 'Here's the updated code', 'As an AI', 'Good catch!', a trailing 'Note:'.",
"pats": [r"^\s*```", r"\bhere'?s the (updated|complete|full|fixed|revised|new) (code|version|implementation|file)\b",
r"\bas an? (ai|a\.i\.) (language )?model\b", r"\bas a large language model\b",
r"\b(good|great) catch!", r"\byou'?re absolutely right\b",
r"^\s*" + CMT + r"\s*(note|remember|important|keep in mind|tip)\s*:",
r"\b(certainly|sure)! here('?s| is)\b", r"\bi'?ll (add|update|fix|implement) .{0,40}\bnow\b",
r"\bi hope this helps\b", r"\blet me know if you'?d?\s*(like|need|want)\b"]},
{"id": "placeholder-comment", "label": "Placeholder / ellipsis comment left in (\"// rest of your code\")", "sev": "high", "class": "bug",
"share": "verified 1.6%, precision ~100%",
"fix": "Write the actual code. These '... rest of the code' stubs mean the file is unfinished, not just untidy.",
"pats": [r"" + CMT + r"\s*\.{2,}\s*(rest|the rest|your|remaining|existing|previous|other)\b",
r"" + CMT + r"\s*(rest|remainder) of (your |the |my )?(code|implementation|logic|function|file|owl)\b",
r"" + CMT + r"\s*(your|the) (code|logic|implementation|stuff) (goes )?here\b",
r"" + CMT + r"\s*(add|insert|implement|put) (your )?(code|logic|implementation) here\b",
r"" + CMT + r"\s*(implementation|code|logic) (goes |go )?here\b",
r"" + CMT + r"\s*existing code (here|unchanged|stays|remains)\b",
r"" + CMT + r"\s*\.\.\. ?\((?:rest|your|the|existing)[^)]*\)",
r"" + CMT + r"\s*TODO:?\s*(implement|add|fill in|finish)\b"]},
# ---------- MEDIUM: the surface tells ----------
{"id": "emoji-in-code", "label": "Emoji in code, comments, strings, logs, or commit text", "sev": "medium", "class": "cosmetic",
"share": "verified 3.9%, precision ~77% (the highest-precision cosmetic tell)",
"fix": "Remove emoji from source. They survive from the model's chat output and read as vibe-coded.",
"pats": [r"[" + EMOJI + r"]"]},
{"id": "swallowed-errors", "label": "Catch-all / swallowed errors (bare except, empty catch, empty Go err block)", "sev": "medium", "class": "bug",
"share": "verified 3.1% (try/except wrapped around everything)",
"fix": "Catch specific exceptions and handle them. A bare except, an empty catch, or an empty `if err != nil {}` eats the one clue you needed.",
"pats": [r"^\s*except\s*:", r"^\s*except\s+(Exception|BaseException)\s*:\s*(pass|\.\.\.|$)",
r"\bcatch\s*\([^)]*\)\s*\{\s*\}", r"\bcatch\s*\{\s*\}",
r"\brescue\s*=>\s*\w+\s*$", r"\bcatch\s*\([^)]*\)\s*\{\s*//",
r"\bif\s+err\s*!=\s*nil\s*\{\s*\}", r"\bif\s+err\s*!=\s*nil\s*\{\s*//"]},
{"id": "narrating-comment", "label": "Narrating comment that restates the obvious / step-by-step '# Step 1'", "sev": "medium", "class": "cosmetic",
"share": "verified 8.5% (over-commenting; the regex catches the obvious-restatement subset)",
"fix": "Cut comments that say what the next line plainly does. Comment why, not what.",
"pats": [r"" + CMT + r"\s*(step\s*\d+\b|now we\b|first,|next,|then,|finally,)",
r"" + CMT + r"\s*(increment|decrement|initialize|declare|define|create|instantiate|loop (over|through)|iterate over|return the|set the|get the|assign|call the)\b",
r"" + CMT + r"\s*this (function|method|line|loop|variable|class|block) (does|is|will|handles|returns|creates)\b",
r"" + CMT + r"\s*(import|importing) (the |required )?(libraries|modules|dependencies)\b"]},
{"id": "generic-naming", "label": "Generic placeholder function name (process_data, handleData, doStuff)", "sev": "medium", "class": "cosmetic",
"share": "verified 1.9%, precision ~100% (process_data() is the canonical example)",
"fix": "Name it for what it actually does in this domain. process_data() that does 11 things is the AI tell.",
"pats": [r"\b(def|function|func|fn|fun|sub)\s+(process_?[Dd]ata|handle_?[Dd]ata|do_?[Ss]tuff|do_?[Ss]omething|my_?[Ff]unction|process_?[Ii]tem|process_?[Ii]nput|main_?[Ff]unction)\b",
r"\b(process_?[Dd]ata|handle_?[Dd]ata|do_?[Ss]tuff|do_?[Ss]omething)\s*\("]},
# ---------- LOW: weak / inflated signals ----------
{"id": "verbose-naming", "label": "Over-verbose, robotically self-documenting identifier", "sev": "low", "class": "cosmetic",
"share": "verified 0.4% (inflated; people mostly argue FOR descriptive names)", "cs": True,
"fix": "A name that is a whole sentence (getUserDataFromApiResponseHandler) reads as machine-generated. Trim it.",
"pats": [r"\b[a-z]+([A-Z][a-z0-9]+){4,}\b", r"\b[a-z]+(_[a-z0-9]+){5,}\b"]},
{"id": "boilerplate-marker", "label": "Boilerplate / dummy-data marker (placeholder keys, sample data, 'Successfully' logs)", "sev": "low", "class": "cosmetic",
"share": "weak proxy for the #1 tell (tutorial-shaped code), which is otherwise not regex-detectable",
"fix": "Replace generated dummy data and placeholders with the real thing. Tutorial-shaped sample data is the loudest tell, but only a human can judge the shape.",
"pats": [r"\blorem ipsum\b", r"\b(your|my)[-_]?api[-_]?key\b", r"\bYOUR_API_KEY\b", r"\bexample\.com\b",
r"\b(John|Jane) (Doe|Smith)\b", r"['\"]sk-(xxx|your|placeholder|123)",
r"(console\.log|print|println|fmt\.Print\w*|System\.out\.print\w*)\s*\(\s*['\"](โ
|๐|Successfully|Done!|Here we go)"]},
]
def compile_rules(min_sev):
order = ["high", "medium", "low"]
floor = order.index(min_sev) if min_sev else len(order) - 1
out = []
for r in RULES:
if order.index(r["sev"]) > floor:
continue
r = dict(r)
flags = 0 if r.get("cs") else re.IGNORECASE
r["rx"] = [re.compile(p, flags) for p in r["pats"]]
out.append(r)
return out
def iter_files(path):
if os.path.isfile(path):
yield path; return
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if f.endswith(".min.js") or f.endswith(".min.css") or f.endswith(".map"):
continue
if os.path.splitext(f)[1].lower() in EXTS:
yield os.path.join(root, f)
def scan(path, min_sev):
rules = compile_rules(min_sev)
findings = []
for fp in iter_files(path):
try:
with open(fp, "r", encoding="utf-8", errors="ignore") as fh:
lines = fh.readlines()
except OSError:
continue # unreadable file: skip, not fatal
if len(lines) == 1 and len(lines[0]) > 5000: # likely minified
continue
for i, line in enumerate(lines, 1):
if "unslop-ignore" in line.lower():
continue
for r in rules:
for rx in r["rx"]:
m = rx.search(line)
if m:
findings.append({"rule": r["id"], "label": r["label"], "sev": r["sev"],
"class": r["class"], "share": r["share"], "fix": r["fix"],
"file": fp, "line": i,
"match": m.group(0).strip()[:50], "snippet": line.strip()[:160]})
break
return findings
def verdict(by_sev, weighted):
if by_sev.get("high", 0) >= 3 or weighted >= 15:
return "STRONG AI-written-code tells"
if by_sev.get("high", 0) >= 1 or weighted >= 6:
return "Some AI tells present"
if weighted > 0:
return "Mostly clean, minor tells"
return "Clean, no surface tells detected"
def main():
ap = argparse.ArgumentParser(description="Scan source code for AI-written-code tells.")
ap.add_argument("path")
ap.add_argument("--severity", choices=["high", "medium", "low"], default="low",
help="minimum severity to report (default: low = everything)")
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--max", type=int, default=10, help="max examples shown per rule (text mode)")
args = ap.parse_args()
if not os.path.exists(args.path):
print(f"path not found: {args.path}", file=sys.stderr); sys.exit(2)
findings = scan(args.path, args.severity)
by_sev, by_class, by_rule = {}, {}, {}
for f in findings:
by_sev[f["sev"]] = by_sev.get(f["sev"], 0) + 1
by_class[f["class"]] = by_class.get(f["class"], 0) + 1
by_rule.setdefault(f["rule"], []).append(f)
weighted = sum(W[s] * n for s, n in by_sev.items())
files_scanned = sum(1 for _ in iter_files(args.path))
if args.json:
print(json.dumps({"path": args.path, "files_scanned": files_scanned, "counts": by_sev,
"class_counts": by_class, "slop_score": weighted,
"verdict": verdict(by_sev, weighted), "findings": findings}, indent=2))
sys.exit(by_sev.get("high", 0))
sev_order = {"high": 0, "medium": 1, "low": 2}
rule_ids = sorted(by_rule, key=lambda rid: (sev_order[by_rule[rid][0]["sev"]], -len(by_rule[rid])))
print(f"\n unslop-code scan: {args.path}")
print(f" files scanned: {files_scanned} findings: {len(findings)} slop score: {weighted}")
print(f" verdict: {verdict(by_sev, weighted)}")
print(f" high: {by_sev.get('high',0)} medium: {by_sev.get('medium',0)} low: {by_sev.get('low',0)}")
print(f" bug-class: {by_class.get('bug',0)} (wrong code, fix regardless of severity) "
f"cosmetic: {by_class.get('cosmetic',0)} (surface giveaways)\n")
if not findings:
print(" No surface tells flagged. The loudest tells are structural and a regex cannot see\n"
" them: boilerplate / tutorial-shaped code, hallucinated APIs, over-engineering, and\n"
" code that ignores the surrounding repo. Hallucinated APIs are a bug, not a style\n"
" issue: build it, type-check it, run it, resolve every import. Then read the diff for\n"
" the rest by hand against references/tells.md.\n")
return
for rid in rule_ids:
items = by_rule[rid]
f0 = items[0]
print(f" [{f0['sev'].upper()} ยท {f0['class']}] {f0['label']} ({len(items)} hit{'s' if len(items)!=1 else ''}) [{f0['share']}]")
print(f" fix: {f0['fix']}")
for it in items[:args.max]:
print(f" {it['file']}:{it['line']} ({it['match']}) {it['snippet']}")
if len(items) > args.max:
print(f" ... +{len(items) - args.max} more")
print()
top = [by_rule[rid][0]['label'] for rid in rule_ids[:3]]
print(" Top things to change: " + "; ".join(top))
print(" Fix the bug-class findings first; they are wrong, not just AI-looking.")
print(" The big tells (boilerplate, hallucinated APIs, over-engineering) the regex cannot see:")
print(" build / type-check / run for the hallucinated calls, read the diff for the rest. See references/tells.md.\n")
sys.exit(by_sev.get("high", 0))
if __name__ == "__main__":
main()
skills/a-unslop-text/SKILL.md
--- name: a-unslop-text description: >- A canonical-voice and anti-slop layer for prose. Strips the cues that make writing read as AI-generated (the em dash, banned corporate/AI diction like delve, leverage, utilize, robust, paradigm, synergy, the "it's not just X, it's Y" cadence, sycophantic openers, bookending, leftover assistant boilerplate) and holds a deliberate, register- appropriate human voice instead. Ships a merged banned-vocabulary catalog and a scanner that gates on the hard bans. Use whenever writing, drafting, editing, rewriting, reviewing, or auditing any reader-facing prose (a doc, README, blog post, email, commit body, research note, exec-site copy), and especially when the text should sound like you rather than a model, or when someone says it "sounds like AI / ChatGPT," "de-slop this," "make it sound like me," or "is this slop." Ships a build-your-own voice-profile method (references/voice-profile.md) so generated text reads as its actual author, not just clean. Trigger even if "AI tell" is never said. --- # a-unslop-text Read this first, because the common misunderstanding sinks the whole thing. **This skill does not write well for you, and it does not impose a single house voice.** It does two narrow things. It removes the specific cues that make text read as machine-written, and where the text reads as AI because nobody chose a voice, it forces a deliberate, register-appropriate one. The argument and what you actually want to say are still yours. A guardrail is not a writer. Most of the work is the first thing, and most of the first thing is mechanical. The em dash becomes a comma or a period. `delve into` becomes `look at`. The sycophantic opener gets cut. The `as an AI` boilerplate gets deleted. Those are not style calls; there is no voice to invent. Lead with the plain fix. Reserve the deliberate-voice work for the stylistic choices. ## How this fits alongside a house style guide This is the depth layer, not the floor. If you already have an always-on style floor (a house style doc, a project CLAUDE.md rule: no em dash, no bookending, contractions, match length to the question), that floor still applies whether or not this skill loaded. This skill owns the full banned-vocabulary catalog and the scanner, so a single list stays canonical instead of drifting across every project that keeps its own copy. If you also have a write-time hook that blocks the em dash on public-facing content, that hook and this skill's scanner share the same rule and neither re-derives it: the hook stops it at write time, the scanner catches it at audit time everywhere else. Per-project overlays sit on top of this layer, not under it: a project's own terminology, provenance rules, and register choice, or a first-person brand voice a project already keeps. Those overlays describe the project; this skill describes the prose. ## The trap to avoid The failure mode of every anti-AI-writing effort is swapping one default register for another. The smooth corporate voice (the em dash, `delve`, a bulleted list for everything, a tidy "in conclusion") was the 2024 default. The 2026 over-correction is its mirror: staccato three-word fragments, forced lowercase, a `here's the thing` cold open, a swear bolted on, fake typos to beat a detector. Readers clock the second just as fast. Treat the over-corrected register as its own tell. The fix for a dash is a comma in a sentence you would actually write, not an ellipsis and not a contortion around the gap. See `references/writing-with-intent.md`. ## Not an AI detector Detectors return a probability and are wrong often enough to ruin a real writer's day. This skill does not guess who wrote something. It finds configured style violations and advisory patterns, removes them, and then asks for a human read on rhythm, substance, and voice. It adds a deterministic scanner you can run in CI and an explicit warning against the over-corrected register. ## Build mode Most "sounds like AI" outcomes are a specification problem, not a wording one. An unspecified prompt gets the model's median voice. So before drafting anything for a reader, pin the register (A casual / B conversational-professional / C expository / D formal), then a speaker inside it, then state the claim in a sentence. `references/writing-with-intent.md` is the method. Then write, avoiding the tells in `references/tells.md`. The ban list alone gets you to clean, not to you. So before drafting under a real person's byline, check whether they have a voice profile (moves extracted from their real pre-AI writing, a register dial, a language ceiling); ask if you don't know. If they do, the speaker step is already answered: load the profile and hold it. If they don't, do not silently produce generic text under their name. Offer the choice: build one now with them (about an hour following `references/voice-profile.md`, and it moves a draft further toward its author than everything else in this skill combined), or proceed and say plainly the result will read as clean, not as them. Keep any resulting profile private; the method is shareable, the output is personal data. If you already have a default register for chat and most docs (say, terse: verdict first, short sentences, fragments for punch, no preamble), that is a chosen register, not the model's default, so hold it. Do not pad it back toward the median, and do not over-correct it into performative fragments. ## Audit mode When reviewing or cleaning existing prose, or on "does this sound like AI / de-slop this / make it sound like me," run the scanner first, then fix in priority order. ```bash python3 scripts/unslop_text_scan.py <path> # report + slop score python3 scripts/unslop_text_scan.py <path> --severity high # only the hard bans (gated tier) python3 scripts/unslop_text_scan.py <path> --json # for CI ``` It scans `.md .markdown .mdx .txt .rst .html`, reports each finding with file, line, the matched text, and the fix, plus a configured-style score and a density per 1,000 authored words (quoted lines are excluded from the denominator, so a piece that is mostly quotation is not diluted). Exit code is `1` when any high-severity violation exists, `0` when none exists, and `2` for invalid input, so CI can gate on it. Some rules are frequency-sensitive and stay silent until a word appears twice in the same file, because one register-appropriate `robust` is not a signal and four of them are. `references/tells.md` marks which rules work that way. Be honest about what the scanner is: a lexical style filter. It catches the em dash, the high-signal diction, the antithesis cadence, the formatting tics. It cannot judge authorship, factual value, originality, uniform rhythm, or whether a paragraph earns its space. A clean scan means the configured lexical layer is clean, not that the writing reads as a person. After every scan, run the structural and concision pass in `references/tells.md`. For the concision pass, test every paragraph and sentence: keep it only if it adds a claim, evidence, a decision, an exception, necessary context, or a reader action. Cut it when removing it changes nothing. Do not shorten away a requirement, a factual caveat, or a source. **Escape hatch.** A line containing `unslop-ignore` or `voice-allow` is skipped, for a tell used on purpose (a verbatim citation, a section name, a deliberate register). Use it sparingly; if you reach for it more than once or twice per pass, the rule probably needs adjusting. **Fixing well.** Do not fix `delve into` by swapping in `dive into`, which is another tell. Use the plain verb you would actually say. A fix that introduces the over-corrected register (choppy fragments, fake typos) is not a fix. ## What it does not flag Grounded in what readers actually cite, not a keyword pass. Lone `however`, `thus`, a single register-appropriate `crucial`, a real thematic-break `---`: these are usually the writer's own prose, not a tell. The scanner leaves them alone (the DO NOT CHASE section of `references/tells.md`). Over-flagging trains people to ignore the tool, so the scanner stays narrow. ## Reporting an audit Lead with the verdict and the single highest-impact change. Then findings by priority with file:line and the fix. Close with the configured-style score and the top three changes, reported as a style measurement and not as a claim about who wrote the text. Plain and specific. The goal is prose that reads like one person who meant it, which is the one thing the scanner cannot do for them. ## Credits The over-correction framing, the register model, and the scanner design are adapted from the vibecoded-design-tells research by JCarterJohnson (https://github.com/JCarterJohnson/vibecoded-design-tells, MIT). The banned-vocabulary list and its tiers here are this skill's own merge of several style guides into one canonical source; the shape and the over-correction idea come from that project. `scripts/unslop_text_scan.py` is an adapted fork of its text scanner (MIT); the credit is in its header.
skills/a-unslop-text/agents/openai.yaml
interface: display_name: "Unslop Text" short_description: "Catch the AI tells in prose"
skills/a-unslop-text/references/tells.md
# The prose tells: the catalog This is the human-readable view of the vocabulary and patterns in `scripts/unslop_text_scan.py`. The documentation and the implementation must agree. Treat any mismatch as a defect; neither silently overrides the other. It merges what used to be several separate per-project banned-word lists, already drifting apart from each other, into one canonical catalog. Read by tier, not as a flat blocklist. HIGH is a configured violation and gates the scanner's exit code. MEDIUM is advisory and often frequency-sensitive: the scanner stays quiet on a single use and reports the cluster. LOW is weak or contextual. The structural review at the bottom covers rhythm, substance, and concision that no regex can judge, and that is usually the real giveaway. Every configured term below is in `backticks` so this file scans clean against its own scanner. ## HIGH: gated violations ### The em dash and en dash `โ` and `โ`, anywhere, including the HTML entities `—` / `–`. This is the one rule flagged even inside quotes, because the rule is simply not to ship one. The fix is a comma, a period, or parentheses, in a sentence you would actually write. Not a colon (people flag that now too) and not a sentence visibly contorted around the gap. If you also run a write-time hook that blocks the em dash on public-facing content, that hook and this scanner share the rule; neither re-derives it. <!-- voice-allow: this line defines the banned dash characters --> ### High-signal vocabulary Distinctive enough to gate on a single use. Use the plain word or state the concrete behavior. | Banned | Use instead | |-|-| | `delve`, `dive into`, `deep dive`, `let's unpack this`, `unpack the issue` | look at, examine, go through | | `paradigm` | approach, model | | `synergy` / `synergies` | describe the specific interaction | | `holistic` | whole, end-to-end | | `cutting-edge` | new, the specific capability | | `game-changer` | what actually changes | | `unleash` | the specific result | | `realm`, `tapestry`, `beacon`, `testament`, `myriad`, `plethora` | the plain word; usually cut | | `best-in-class` | what makes it good | If one of these seems necessary, the sentence is hedging. Rewrite for the underlying thought. ### Plain-word vocabulary (advisory as a cluster) These are ordinary formal English. One register-appropriate use is not an authorship signal, so the scanner stays silent until two or more appear in the same file. Prefer the plain word anyway. | Word | Prefer | |-|-| | `leverage` (verb) | use, apply, build on | | `utilize` / `utilise` | use | | `robust` | reliable, handles edge cases, or describe the behaviour | | `comprehensive` | covers the named scope | | `seamless` | works without an extra step | | `streamline` | cut steps, simplify | | `foster` | build, support, grow | | `empower` | let, give, enable | | `unlock` | name the result | | `elevate` | improve, raise | If your project wants any of these gated on a single use, raise that rule's severity in the scanner rather than keeping a second list somewhere. ### Contextual consultant compounds The compound gates; the same word used literally does not. `competitive landscape`, `regulatory landscape`, `market landscape`, `navigate the complexities`, `product ecosystem`, `partner ecosystem`, `strategic alignment`, `customer alignment`. Name the competitors, the decision, the products, or the relationship instead. Literal uses pass and are not tells: `landscape orientation`, a browser navigating to a page, a biological ecosystem, aligning two columns. ### Stacked connective openers `Moreover`, `Furthermore`, `Additionally`, `Consequently`, `Notably` as a sentence opener. They almost always mean "extending a thought without a new idea." Just start the sentence. ### The antithesis cadence `it's not just X, it's Y`, `not X, but Y`, `not because X but because Y`. The negate-then-assert shape manufactures profundity for free. If Y is the point, say Y. ### The assistant voice leaking in `as an AI language model`, a refusal (`I cannot assist`), a `knowledge cutoff` line, `I don't have personal opinions`. Delete every trace before anything ships. ### Sycophancy and the apology reflex `Great question`, `Certainly!`, `You're absolutely right`, `I'd be happy to`, `happy to help`, `good catch`, and the apology paragraph (`I apologize for the confusion`). Open with the point. When wrong, fix it in one line, no paragraph. ### Filler and preamble patterns `it's important to note`, `it's worth mentioning`, `in today's [anything]`, `let me break this down`, `Imagine a world where`, `Picture this`, `That said,`, `With that in mind,`, `At the end of the day,`, `needless to say`. State the thing. ### Bookending and trailing offers `I hope this helps`, `Let me know if you need more`, `Would you like me to`, `is there anything else`, `feel free to reach out`, `hope this email finds you well`, `happy to answer` as a closer. If the answer is done, stop. ### Contextual consultant-speak compounds The same banned words in their consultant compounds: `competitive landscape`, `regulatory landscape`, `navigate the complexities`, `product ecosystem`, `strategic alignment`, `in alignment`, `cross-product synergies`. The scanner catches the single words; watch for these compounds by eye. ## MEDIUM: use sparingly (advisory) - **Frequency-sensitive words**, fine once and advisory in a cluster: `crucial`, `vital`, `essential`, `essentially`, `fundamentally`, `various`, `versatile`, `significant`, `genuinely`, `nuance`, `meticulous`, `showcase`, `straightforward`, `precisely`, `dramatically`, `incredibly`, `particularly`, `ultimately`. The scanner reports them only when two or more appear in one file. Keep at most one per piece. - **Softeners** that undercut an instruction: `simply run X`, `just click Y`. "Run X" is stronger and does not imply it is trivial. - **Bolded lead-in labels**: `**Word:**` then a sentence. Chat formatting, not prose. Reserve bold for real terms of art or warnings. - **Listicle scaffolding**: `5 ways to`, `7 signs`, `3 reasons`. Write prose; reserve bullets for list-like content. - **Decorative emoji** as a bullet, an icon, or in a heading. Banned unless the user used emoji first in the same conversation. An emoji inside a sentence where a person would use one is fine. - **The `In conclusion` / `In summary` closer**. End on a real last point. - **Hedging when you have a view**: `you might want to consider`, `it may be worth`, `perhaps`, `on one hand ... on the other`, `it depends`. Take a position. - **Marketing hype**: `revolutionary`, `transformative`, `supercharge`, `to the next level`, `say goodbye to`, `look no further`. - **Literary/native flourish (byline mismatch)**: `whilst`, `amongst`, `endeavour`, `ascertain`, `elucidate`, `quintessential`, `notwithstanding`, `henceforth`, `hitherto`, `whereby`, `thereby`, `wherein`, `albeit`, `one might`. Not slop in general English. But when the named author's real register is plainer (a non-native writer, a terse operator), grammatically perfect literary vocabulary outs the text as ghostwritten. Set a language ceiling in your own voice profile (build it via `references/voice-profile.md`) and use the everyday word. ## LOW: weak or contextual (advisory) - **Inflated generic diction**: `facilitate`, `paramount`, `pivotal`, `multifaceted`, `intricacies`. Prefer the plain word. - **Work-announcement preambles**: `Let me take a look`, `I'll start by`, `Here's a breakdown of`. Do the thing; the reader sees the result. - **Fake-relatability openers**: `Honestly,`, `Look, I get it`, `Let's be real`. ## DO NOT CHASE (deliberately not flagged) These match a keyword pass but are almost always the writer's own ordinary prose, not a tell. The scanner does not flag them, and you should not either: - `however`, `thus`, `hence` as normal connectives. A lone one is just a connective. - `when it comes to`, a single `crucial`, or a single `comprehensive` in a long piece where it fits the register. - Technical uses such as `unpack a tuple`, `navigate to the next page`, `landscape orientation`. The scanner gates the rhetorical use, not the literal operation. - A horizontal rule (`---`) used as a real thematic break. Flagging these trains people to ignore the tool. The DO-NOT-CHASE line is as load-bearing as the bans. ## STRUCTURAL (human pass: the scanner is blind here) These are named as often as the mechanical tells and no regex catches them. Read the draft and check by ear. - **Uniform rhythm.** Sentences of similar length and shape, evenly paced. The evenness is the tell. Vary length on purpose; let one run long and the next stop short. - **Rhythmic triads.** Three items, or three same-structure sentences in a row, just to wrap a paragraph with a bow. A natural list of three is fine; a rhetorical one is not. Vary the count. - **Sycophancy as tone.** Reflexive agreement, no position. Disagree when you disagree. - **Saying nothing at length.** Fluent, grammatical, no claim. If a paragraph could be cut with nothing lost, cut it. - **Low substance per word.** Each paragraph should add a claim, evidence, a decision, an exception, necessary context, or a reader action. Cut setup, repeated conclusions, process narration, and examples that do not change the point. Keep requirements and source caveats even when they run long. - **Same paragraph openings.** Don't start every paragraph the same way. Starting three or more with `This` is the common version. - **The ". That's [adjective]." tic.** "Resolution times dropped 82%. That's impressive." Fine once; at three it is a pattern. Fold into the previous sentence or let the number speak. - **"Think of it as / like" as an analogy launcher.** Make the analogy directly: "DNS is your network's phone book," not "Think of DNS like a phone book." - **Setup paragraph before the punch.** If the strongest line is buried after scene-setting, lead with it. - **Overdone voice mimicry.** A draft that strings the author's signature phrases into every paragraph is an impression, not a voice. Cap borrowed phrases at one or two per piece; moves carry a voice, phrases are incidental. See `references/voice-profile.md`. ### One exemption, important A writer may default to terse prose: short sentences, fragments for emphasis, blunt verdicts, no warm-up. That is a chosen register (see `writing-with-intent.md`), not the model's default, so it is **not** a tell and the "vary your rhythm" note does not apply to it. The over-correction trap is the opposite failure: prose straining to look un-AI (staccato three-word fragments on every beat, forced lowercase, a `Honestly` cold open, a swear bolted on, conspicuous dash-avoidance). That strain is its own tell. The fix is a real voice held consistently, not a costume of "not-AI." A concrete over-correction case, from de-slopping a launch post for a forum audience. The rewrite stripped every sentence to a subject-less fragment: `For people who've never opened a terminal. Goes from installing to a website online. Covers X, Y, Z.` That reads exactly as fake as the slop it replaced. Reader-facing copy needs normal connective tissue, the way a person actually types: `It's aimed at people who don't code. Takes you from installing the tool all the way to putting a real website online.` Fragments belong in a terse chat or working-doc register, not in social copy meant to pass as a person posting. Register decides, and the register for a public post is not the register for your own notes.
skills/a-unslop-text/references/voice-profile.md
# Build a voice profile from your own writing
**This is a method, not a template.** You do not fill this file in. Following the steps below produces a *separate* file, your voice profile, which you keep in your own private config and point the skill at. This method file is generic and shareable; the profile it produces is personal data, so keep it private and never ship it in a public bundle.
The ban catalog has a ceiling. Remove every tell from a draft and you get text that is clean, correct, and still nobody's: the model's median with the fingerprints wiped. What makes text read as a specific person was never the absence of `delve`; it is a set of habits the author does not know they have. This file is the method for extracting those habits from your real writing and turning them into a profile the Build mode can load. An hour with your own sent emails buys more authenticity than any banned-word list.
## Step 1: gather real samples
Collect 10 to 15 pieces you actually wrote, before AI assistants were involved, across more than one register: work emails you sent, feedback you gave on someone's work, a doc or spec you wrote alone, chat messages, a post if you have one. Unedited and unpolished beats curated. The typos stay in for now; they carry information for step 6.
Two things matter about the sample set. It must be yours alone (in shared documents, other contributors' sections sneak in; drop anything you did not write yourself). And it should span situations, because the interesting part is what changes between them and what does not.
## Step 2: extract moves, not phrases
A move is a repeatable decision about how you handle a situation in prose. Phrases are what the move leaves behind. Profile the moves; list the phrases only as evidence.
Questions that surface moves, with the kind of answer you are looking for:
- **How do you open?** Position first ("Agree, with one change...") or context first ("Some background before I answer...")? Whichever it is, it is probably consistent, and it is probably the strongest single marker you have.
- **How do you disagree?** Flat contradiction, a softened "not sure this holds because...", a question that carries the objection ("would this survive two concurrent users?")?
- **Do your suggestions travel with their reasons?** Some writers attach a "because" to every proposal; others state the proposal bare and defend it only if challenged.
- **Where does your opinion live?** In the main clause, in a parenthetical aside, in a dry closing fragment?
- **How do you handle uncertainty?** Hedge words, explicit confidence levels, silence until sure?
- **What do you do with numbers?** Some writers reach for a figure wherever one exists; others reach for an adjective.
- **How do you close?** A named owner and a next step, a one-word sign-off, nothing at all?
Write each move as one line with one or two short real examples from your samples. Eight to twelve moves is a full fingerprint; more than that and you are cataloging noise.
## Step 3: map your registers
You are not one voice; you are one identity at different intensities. Map your real destinations (email, chat, docs, posts, specs) onto the A/B/C/D register model in `writing-with-intent.md`, and note what changes between them: where the softeners drop away, where fragments appear, where the wit is allowed out. The profile should say which moves are always on and which belong to one register only.
## Step 4: set a language ceiling
This is the step most people skip and the one that catches the most ghostwriting. The model's natural register is polished, native, faintly literary English. If that is not how you write, every draft published under your name is dressed in someone else's vocabulary, and readers who know you notice even when they cannot say what is off.
State the ceiling explicitly. For a non-native author the effective instruction is: **write in clean C1 English**. Grammar and spelling always correct, but everyday professional vocabulary, main clause first, at most one subordinate clause, no rare idioms a language learner would not use, plain intensity words. Correct is not the same as fancy. For a native author with a plain register, the same idea applies with a different label: cap the vocabulary at how you talk in a meeting, not how you would write an essay.
The scanner's `c2-flourish` rule backs this ceiling from the ban side, but the ceiling belongs in the profile because it is a positive instruction, not a list of exceptions.
## Step 5: signature phrases, capped
List the phrases that recur in your samples. Then cap them: at most one or two per generated piece, and only where they would fall naturally. This anti-parroting rule is load-bearing. A draft that hits four of your signature phrases is an impression of you, which reads worse than a plain draft; the moves carry the voice, the phrases are incidental. (Same logic as the over-correction trap in `tells.md`: an overdone costume is its own tell.)
## Step 6: list what not to copy
Now use the typos. Recurring misspellings, dropped articles, run-on chains, inconsistent capitalization: these are typing artifacts and second-language slips, not voice. The profile should name them explicitly as things never to reproduce, because a model shown your raw samples will otherwise imitate them, and deliberate fake errors are a known over-correction tell. The recognizable signal survives cleanup; the slips do not need to.
## Step 7: set precedence
One line at the end of the profile: the ban catalog (`tells.md` and the scanner) always wins. The profile chooses among what the bans allow; it never licenses a banned form. If one of your real habits collides with a hard ban (plenty of real people close emails with a phrase that is now an assistant tell), the ban wins for generated text, because the reader cannot tell your habit from the machine's.
## The template
```markdown
# <name>'s voice profile
Private. Derived <date> from <n> samples (<kinds>). Never ships in a public bundle.
## Who is speaking
<two or three sentences: role, stance, what they connect when they argue>
## The moves
<8-12 one-liners, each with a short example>
## Register dial
<destination -> register letter + which moves intensify or drop>
## Language ceiling
<e.g. "Write in clean C1 English: ..." with the concrete rules>
## Signature phrases (evidence, not injection)
<the list, then: max 1-2 per piece, never mechanically placed>
## What not to copy
<typing artifacts, normalized away>
## Precedence
tells.md and the scanner always win.
```
## Using it
Wire the profile into the Build mode flow: pin the register per piece as usual, and when the byline is the profile's owner, the speaker step is already answered. Load the profile and hold it. Then audit as always; the scanner catches the bans, and your own read catches whether the person on the page is you.
skills/a-unslop-text/references/writing-with-intent.md
# Writing in a chosen voice This file does not hand you a voice to copy. Any fixed style, repeated, becomes the next default and the next tell. The smooth corporate voice was the 2024 default; the clipped, lowercase, swear-to-seem-real voice is the 2026 over-correction, and it reads as a machine trying not to look like a machine just as fast. So this is a way to decide what the writing is, not a thing to imitate. The goal is one outcome: prose where every choice has a reason specific to this piece. That is the one property a default can never have. ## Pin a register, then a speaker A register sets what "plain" means and what counts as a tell. Decide which one the piece is in before anything else: - **A, casual.** Texts, DMs, personal posts. Contractions, fragments, slang, dropped subjects. - **B, conversational-professional** (work email, Slack, updates, changelogs, most chat replies): plain, direct, contractions, light warmth, no throat-clearing. - **C, expository** (essays, READMEs, blog posts, deep-dive docs): a clear argument, varied rhythm, a point of view, paragraphs over bullets. Where most unslopping happens. - **D, formal** (specs, briefs, ADRs, research docs): structured, sourced, impersonal. Formality here is correct, not a tell. The register is the guard against over-correcting. A fragment is native in A and a tell in D. A contraction is right in A through C and wrong in D. A `lol` is voice in A and costume in C. A research doc or ADR is mostly C and D; a first-person blog is mostly C with a named speaker; chat replies are B. Same banned list, different register on top. Then a speaker inside the register. For a project that already has a voice, anchor to it. If you must choose, pick a named direction (plain-technical, blunt operator, dry and concrete) over "professional and engaging," which means nothing. ## The claim Before the body, state the one thing the piece asserts, in a sentence. AI prose reads as empty because it is fluent with nothing to say. If you cannot write the claim, there is nothing to write yet. Every paragraph earns its place against that claim; if one could be cut with nothing lost, cut it. Say it with less: cut the paragraph that restates the claim instead of extending it. ## Structure follows the argument The order is set by what the reader needs first, not a template. That is how you avoid the intro / three-even-body-paragraphs / "in conclusion" skeleton: the shape appears when structure is chosen by default. Most pieces do not need a summary at the end. Reserve bullets for list-like content; argument lives in paragraphs. ## Rhythm and diction Vary sentence length on purpose. Use the plain word: `use` not `utilize`, `look at` not `delve into`. Cut the antithesis cadence and the throat-clearing opener. The point goes in the first sentence, not after a warm-up. ## The over-correction trap (the thing to actually watch) Told to "sound less like AI," a model swings the other way: staccato three-word fragments on every beat, forced lowercase, a `here's the thing` cold open, a swear dropped in to seem casual, every natural dash replaced by an ellipsis or a contorted sentence. People clock that just as fast. Evenness is the tell whichever length it lands on; all-short is as mechanical as all-medium. The fix is a real register held consistently, not the absence of voice dressed up as casual. ## A terse register is a choice, not a tell If your default for chat and most docs is terse (short sentences, fragments for punch, the verdict first, no preamble), that can be a deliberate register, so the rhythm-variation note does not have to flag it. Do not "fix" a chosen terse register by padding it back toward the median, and do not over-correct it into performative fragments either. Hold the register; vary length within it when a thought needs room. ## The escape hatch A register or word chosen on purpose is not a tell. A formal ADR is formal. A verbatim citation reproduces external wording. A section name has to match a source. When a flagged form is a real decision, keep it and mark the line `unslop-ignore` (or `voice-allow`, a variant some docs already use), so the audit stays honest. ## Clarity wins If a rule here produces a sentence that reads worse than the alternative, the rule loses. Rewrite the sentence and, if the rule keeps fighting clarity, change the rule (`tells.md` plus the scanner). The rules exist to make writing better, not to constrain it when they fight clarity. ## The one-line version Pin the register, state the claim in a sentence, then write the way that speaker talks and read it back. The skill removes the tells; this is how you replace them with something chosen.
skills/a-unslop-text/scripts/unslop_text_scan.py
#!/usr/bin/env python3
"""
unslop_text_scan.py - scan prose for configured style violations and advisory patterns.
This implementation and references/tells.md are two views of the same policy. A mismatch
is a defect, not a reason for one to silently override the other. Plain Python, standard
library only.
Forked (MIT) from github.com/JCarterJohnson/vibecoded-design-tells (unslop-text). The
scanning machinery (quote/code-span skipping, density weighting, severity floor, JSON
mode, exit-code gating) is upstream's; the RULES are this skill's own merge.
What it can and cannot see. Mechanical style rules live here: the dash rule, high-signal
phrases, plain-word vocabulary, and formatting tics. A regex cannot judge authorship,
originality, factual value, rhythm, or whether a paragraph earns its space. Those need
the structural pass in references/tells.md.
How it reads a file. It lints YOUR running prose. A quoted line (starts with > or sits
inside "double quotes") and a literal example (inside `backticks` or a fenced block) is
skipped, because flagging a cliche you are quoting in order to discuss it would be wrong.
The one exception is the em dash, flagged everywhere, because the rule is simply not to
ship one.
Escape hatch. A line containing unslop-ignore OR voice-allow is skipped, for a tell
used on purpose (a verbatim citation, a section name, a deliberate register). The
voice-allow token matches the <!-- voice-allow: reason --> comment style some docs
already use, so those need no rewrite.
Usage:
python3 unslop_text_scan.py <path> # scan a file or dir
python3 unslop_text_scan.py <path> --severity high # only the hard bans (gated tier)
python3 unslop_text_scan.py <path> --json # machine-readable (for CI)
python3 unslop_text_scan.py <path> --max 8 # cap examples shown per rule
Exit code is 1 when any HIGH-severity finding exists, 0 when none exists, and 2 for invalid
input. A boolean failure avoids shell exit-code wraparound on files with many findings.
"""
import os, re, sys, json, argparse
EXTS = {".md", ".markdown", ".mdx", ".txt", ".text", ".rst", ".html", ".htm"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "out", "vendor",
"coverage", "__pycache__", ".venv", "venv"}
W = {"high": 3, "medium": 2, "low": 1}
EMOJI = ("\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F000-\U0001F0FF"
"\U00002190-\U000021FF\U00002B00-\U00002BFF\U0000FE00-\U0000FE0F\U00002764")
# Each rule has an id, label, severity, note, fix, and patterns. The optional min_hits
# field requires N occurrences in one file before the rule reports at all, which is how a
# frequency-sensitive word (fine once, slop in a cluster) is expressed without a second
# copy of its pattern list. HIGH gates the exit code. MEDIUM and LOW are advisory.
# The em dash is raw=True: the rule covers quoted text too.
RULES = [
# ---------- HIGH: gated violations ----------
{"id": "em-dash", "label": "Em dash or en dash (hard ban, anywhere)", "sev": "high",
"note": "hard ban", "raw": True,
"fix": "Cut it. Use a comma, a period, or parentheses. Not a colon (also flagged now).",
"pats": [r"โ", r"โ", r"—", r"–"]},
{"id": "high-signal-vocab", "label": "High-signal generic or consultant diction", "sev": "high",
"note": "configured high-signal pattern",
"fix": "Use the plain word or state the concrete behavior. See the high-signal table in tells.md.",
"pats": [r"\bdelv(e|es|ing|ed)\b",
r"\b(dives? in(to)?|let'?s dive|diving in|dive deep(er)?( into)?)\b",
r"\bparadigm(s|atic)?\b", r"\bsynerg(y|ies|istic)\b",
r"\bholistic(ally)?\b", r"\bcutting[- ]?edge\b", r"\bgame[- ]?chang(er|ers|ing)\b",
r"\bunleash(es|ing|ed)?\b", r"\brealm\b", r"\btapestr(y|ies)\b", r"\bbeacon\b",
r"\btestament\b", r"\bmyriad\b", r"\bplethora\b", r"\bbest[- ]?in[- ]?class\b",
r"\b(?:let'?s\s+unpack(?:\s+(?:this|that|the (?:topic|issue|idea|question|concept|complexity|details?)))?|unpack\s+(?:this|that|the (?:topic|issue|idea|question|concept|complexity|details?)))\b"]},
{"id": "house-vocab", "label": "Plain-word vocabulary (a cluster reads as generated)", "sev": "medium",
"min_hits": 2,
"note": "one register-appropriate use passes; a cluster does not",
"fix": "Prefer the plain word or describe the behavior: leverage->use, utilize->use, robust->reliable, comprehensive->covers X/Y/Z. A single use is not an authorship signal; several in one document is.",
"pats": [r"\bleverag(e|es|ing|ed)\b", r"\butili[sz](e|es|ing|ed|ation|ate)\b",
r"\brobust\b", r"\bcomprehensive\b", r"\bseamless(ly)?\b",
r"\bstreamlin(e|es|ing|ed)\b", r"\bfoster(s|ing|ed)?\b",
r"\bempower(s|ing|ed|ment)?\b", r"\bunlock(s|ing|ed)?\b",
r"\belevat(e|es|ing|ed)\b"]},
{"id": "consultant-compound", "label": "Contextual consultant-speak compound", "sev": "high",
"note": "phrase-level rule; the same words used literally pass",
"fix": "Name the competitors, the decision, the products, or the relationship instead of the consultant phrase. 'the competitive landscape' is 'who we compete with'.",
"pats": [r"\b(competitive|regulatory|market|vendor|technology|business) landscape\b",
r"\bnavigate the (complexity|complexities|landscape)\b",
r"\b(product|partner|partnership|technology|developer) ecosystem\b",
r"\b(strategic|customer|organi[sz]ational) alignment\b"]},
{"id": "transition-openers", "label": "Stacked connective as a sentence opener (Moreover, Furthermore, Additionally, Consequently, Notably)", "sev": "high",
"note": "hard ban",
"fix": "Just start the new sentence. These mean 'extending a thought without a new idea'.",
"pats": [r"(^|\.\s+|\n)\s*(moreover|furthermore|additionally|consequently|notably)\b"]},
{"id": "antithesis", "label": "\"not just X, it's Y\" / \"not X, but Y\" antithesis cadence", "sev": "high",
"note": "manufactured-profundity cadence",
"fix": "State the point plainly. If Y is the point, say Y; drop the negation in front.",
"pats": [r"\b(it'?s|its|it is|that'?s|this is|they'?re)\s+not\s+(just|only|merely|simply)\b[^.?!\n]{0,60}\bit'?s\b",
r"\bnot\s+(just|only|merely|simply)\s+(a |an |the )?[\w-]+,?\s+but\b",
r"\bnot\s+because\s+[^.?!\n]{0,50}\bbut\s+because\b",
r"\bisn'?t\s+(just|only|merely)\b[^.?!\n]{0,60}\bit'?s\b"]},
{"id": "assistant-boilerplate", "label": "Leftover assistant boilerplate (\"as an AI language model\", a refusal, a knowledge-cutoff line)", "sev": "high",
"note": "the assistant talking; delete",
"fix": "Delete every trace of the assistant voice: disclaimers, refusals, cutoff dates, 'I'm just an AI'.",
"pats": [r"\bas an?\s+(ai|a\.i\.)\s+(language\s+)?model\b", r"\bas a large language model\b",
r"\bi (cannot|can'?t|am unable to)\s+(assist|help|fulfil|fulfill|comply|provide)\b",
r"\bknowledge cut[- ]?off\b", r"\bas of my last (knowledge )?(update|training)\b",
r"\bi (do not|don'?t) have (personal|the ability|access|feelings|opinions)\b"]},
{"id": "sycophancy-apology", "label": "Sycophantic opener / apology reflex (\"Great question!\", \"You're absolutely right\", \"I apologize for the confusion\")", "sev": "high",
"note": "flattery / apology paragraph",
"fix": "Drop it; open with the point. When wrong, acknowledge in one line and fix. No apology paragraph.",
"pats": [r"\b(great|good|excellent|that'?s a (great|good))\s+question\b",
r"(^|[\"'`(]\s*)(certainly|absolutely|sure thing|of course)\s*[!,]",
r"\bi'?d be (happy|glad|delighted) to\b", r"\bhappy to help\b",
r"\byou'?re absolutely right\b", r"\bwhat a (great|fascinating|wonderful)\b",
r"\bi apologi[sz]e for (the|any) (confusion|misunderstanding|mistake)\b",
r"\b(good|great) catch\b"]},
{"id": "banned-pattern", "label": "Filler / preamble pattern (\"it's important to note\", \"in today's ...\", \"let me break this down\")", "sev": "high",
"note": "filler",
"fix": "Cut the preamble and state the thing. If it's worth noting, just note it.",
"pats": [r"\bit'?s (important|worth) (to note|noting|mentioning|pointing out)\b",
r"\b(it'?s |it is )?important to (note|remember|understand|recognize)\b",
r"\bin today'?s\b", r"\blet me break (this|it) down\b",
r"\bimagine a world where\b", r"\bpicture this\b",
r"\bat the end of the day\b", r"\bthat being said\b", r"\bneedless to say\b",
r"\bwith that in mind\b"]},
{"id": "bookend-offer", "label": "Bookending / trailing offer (\"I hope this helps\", \"Let me know if ...\", \"Would you like me to\")", "sev": "high",
"note": "bookend; stop at the last real sentence",
"fix": "Delete the sign-off and the meta-offer. If the answer is done, stop.",
"pats": [r"\bi hope this helps\b", r"\bhope (this|it|you'?re) .{0,20}(helps|finds? you well)\b",
r"\blet me know if you'?d?\s*(like|need|want|have)\b",
r"\bwould you like me to\b", r"\bis there anything else\b",
r"\bfeel free to (ask|reach|let me)\b",
r"\bhappy to answer\b"]},
# ---------- MEDIUM: use sparingly / formatting tic (advisory) ----------
{"id": "frequency-words", "label": "Frequency-sensitive word cluster", "sev": "medium",
"min_hits": 2,
"note": "one passes; two or more are advisory",
"fix": "Keep at most one. crucial/vital/essential->important or describe the stakes; essentially/fundamentally->cut; various->name them.",
"pats": [r"\bcrucial(ly)?\b", r"\bvital(ly)?\b", r"\bessential(ly)?\b", r"\bfundamental(ly)?\b",
r"\bvarious\b", r"\bversatile\b", r"\bsignificant(ly)?\b", r"\bgenuinely\b",
r"\bnuanc(e|es|ed)\b", r"\bmeticulous(ly)?\b", r"\bshowcas(e|es|ing|ed)\b",
r"\bstraightforward\b", r"\bprecisely\b", r"\bdramatically\b", r"\bincredibly\b",
r"\bparticularly\b", r"\bultimately\b"]},
{"id": "softener", "label": "Softener (\"simply\" / \"just\" before a verb) that undercuts the instruction", "sev": "medium",
"note": "softener",
"fix": "Drop it. 'Simply run X' is 'Run X'. The word adds nothing and implies it is trivial.",
"pats": [r"\bsimply\s+(run|use|add|do|call|set|click|open|type|install|copy|edit|change)\b",
r"\bjust\s+(run|use|add|do|call|set|click|open|type|install|copy)\b"]},
{"id": "bolded-lead-in", "label": "Bolded lead-in label (**Word:** then a sentence)", "sev": "medium",
"note": "chat-formatting tic", "skip_list": True,
"fix": "Write a normal sentence without the boldface label. Reserve bold for true terms of art or warnings. (A `- **Label**:` bullet in a definition list is fine and not flagged.)",
"pats": [r"(^|\s)\*\*[^*\n]{2,40}:\*\*\s", r"(^|\s)\*\*[^*\n]{2,40}\*\*\s*:"]},
{"id": "listicle-scaffold", "label": "Listicle scaffolding (\"5 ways to ...\", \"7 signs ...\")", "sev": "medium",
"note": "list-as-default-shape",
"fix": "Write prose. Reserve bullets for genuinely list-like content.",
# High-signal nouns (ways/tips/signs/...) match anywhere. Ambiguous count-nouns
# (things/steps/rules) also appear in plain prose ("15 rules files", "3 steps in the
# pipeline"), so they only flag in a heading or before a listicle continuation word.
# This kills the "N rules"/"N things" false positive without missing real listicles.
"pats": [r"\b\d+\s+(ways|tips|signs|reasons|tricks|secrets|lessons|mistakes)\b",
r"(^|\n)\s{0,3}#{1,4}\s+\d+\s+(ways|tips|signs|reasons|things|steps|tricks|secrets|lessons|mistakes|rules)\b",
r"\b\d+\s+(things|steps|rules)\s+(to|for|that|you|your|why|i|we|every)\b"]},
{"id": "emoji-decoration", "label": "Emoji as a bullet, icon, or in a heading", "sev": "medium",
"note": "decorative emoji (banned unless the user used them first)",
"fix": "Use plain headers and real list markers. An emoji in a sentence where a person would use one is fine; emoji as structure is not.",
"pats": [r"^\s{0,3}#{1,6}\s*[" + EMOJI + r"]", r"^\s{0,3}[" + EMOJI + r"]\s+\S",
r"[" + EMOJI + r"]\s*\*\*", r"\*\*\s*[" + EMOJI + r"]"]},
{"id": "in-conclusion", "label": "\"In conclusion\" / \"in summary\" / \"to summarize\" closer", "sev": "medium",
"note": "signposted recap",
"fix": "End on a real last point. If the reader needs a summary, the piece is too long.",
"pats": [r"\bin (conclusion|summary)\b", r"\bto (summari[sz]e|conclude|wrap (this |it )?up)\b", r"\bin closing\b"]},
{"id": "hedge", "label": "Hedging when you have a view (\"you might want to consider\", \"it may be worth\", \"perhaps\", \"on one hand ... on the other\")", "sev": "medium",
"note": "hedge; take a position",
"fix": "State the take. List a real trade-off if it matters, but say what you would do.",
"pats": [r"\byou might want to consider\b", r"\byou could potentially\b",
r"\bit may be worth\b", r"\bperhaps you\b",
r"\bon (the )?one hand\b[^.\n]{0,120}\bon the other\b", r"\bit (really )?depends\b"]},
{"id": "social-template", "label": "Worn social-post template (\"the guide I wish existed\", \"I kept seeing X, so I built Y\")", "sev": "medium",
"note": "Reddit/HN launch-post cliche; in short outreach copy treat as a hard ban",
"fix": "Say what the thing is and what it covers. The origin story ('I saw a gap, so I made this') is the most-reused launch template on the internet; cut it.",
"pats": [r"\bi wish existed\b", r"\bwish i('d| had) (had|found)\b",
r"\bi kept (seeing|getting|hearing)\b[^.?!\n]{0,80}\bso i\b",
r"\bi (saw|noticed|found) (a |the )?(gap|problem|need)\b[^.?!\n]{0,80}\bso i (built|made|created|wrote)\b"]},
{"id": "split-antithesis", "label": "Cross-sentence antithesis (\"The value was not X. It was Y.\" / \"not the X part. The X was ...\")", "sev": "medium",
"note": "the not-X-but-Y cadence split over two sentences; the one-sentence form is the high-tier 'antithesis' check",
"fix": "Say Y directly and drop the negated setup sentence.",
"pats": [r"\b(was|is|were|are)(n'?t| not)\s+[^.?!\n]{2,50}\.\s+(it|that|this)\s+(was|is|'s)\b",
r"\bnot the ([a-z][a-z ]{2,30}?)\.\s+the \1\b"]},
{"id": "hype-marketing", "label": "Marketing hype (revolutionary, transformative, supercharge, to the next level)", "sev": "medium",
"note": "landing-page voice",
"fix": "Say what the thing literally does, with a specific. Strip the promotional adjective.",
"pats": [r"\brevolution(ary|i[sz]e)\b", r"\btransform(ative)\b", r"\btransform your (life|business|workflow)\b",
r"\bto the next level\b", r"\bsupercharge\b", r"\bsay goodbye to\b", r"\blook no further\b"]},
{"id": "c2-flourish", "label": "Literary/native flourish (whilst, endeavour, notwithstanding: check it against the byline)", "sev": "medium",
"note": "byline mismatch; see references/voice-profile.md",
"fix": "Use the everyday word: whilst->while, amongst->among, endeavour->try, ascertain->find out, notwithstanding->even so. If the named author's real register is plainer than this vocabulary (a non-native writer, a terse operator), the polish itself is the tell.",
"pats": [r"\bwhilst\b", r"\bamongst\b", r"\bendeavou?r(s|ed|ing)?\b", r"\bascertain(s|ed|ing)?\b",
r"\belucidat(e|es|ed|ing)\b", r"\bquintessential(ly)?\b", r"\bnotwithstanding\b",
r"\bhenceforth\b", r"\bhitherto\b", r"\bwhereby\b", r"\bthereby\b", r"\bwherein\b",
r"\balbeit\b", r"\bone might\b"]},
# ---------- LOW: weak / contextual (advisory) ----------
{"id": "generic-diction", "label": "Inflated generic diction (facilitate, paramount, pivotal, multifaceted, intricacies)", "sev": "low",
"note": "prefer the plain word",
"fix": "facilitate->help/let, paramount->matters most, pivotal->describe the moment, multifaceted->has several parts.",
"pats": [r"\bfacilitat(e|es|ing|ed)\b", r"\bparamount\b", r"\bpivotal\b",
r"\bmultifaceted\b", r"\bintricac(y|ies)\b"]},
{"id": "preamble", "label": "Work-announcement preamble (\"Let me ...\", \"I'll start by ...\", \"Here's what I ...\")", "sev": "low",
"note": "narrating intent; just do it",
"fix": "Do the thing. The reader sees the result. No 'Let me take a look', no 'Here's a breakdown of'.",
"pats": [r"(^|\n)\s*let me (take a look|start|check|see|explain|walk you)\b",
r"(^|\n)\s*i'?ll (start by|begin by|go ahead)\b",
r"(^|\n)\s*here'?s (a breakdown|what i (found|changed|did))\b"]},
{"id": "honestly-opener", "label": "Fake-relatability opener (\"Honestly, ...\", \"Look, I get it\")", "sev": "low",
"note": "throat-clearing",
"fix": "Cut it and start with the point.",
"pats": [r"(^|\n)\s*honestly,\s", r"\blook,\s+i (get it|know)\b", r"\blet'?s be (honest|real)\b"]},
]
def compile_rules(min_sev):
order = ["high", "medium", "low"]
floor = order.index(min_sev) if min_sev else len(order) - 1
out = []
for r in RULES:
if order.index(r["sev"]) > floor:
continue
r = dict(r)
r["min_hits"] = r.get("min_hits", 1)
r["rx"] = [re.compile(p, re.IGNORECASE) for p in r["pats"]]
out.append(r)
return out
def iter_files(path):
if os.path.isfile(path):
yield path; return
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if os.path.splitext(f)[1].lower() in EXTS:
yield os.path.join(root, f)
def strip_noise(line, in_code, in_quote):
"""Blank what the author is quoting or showing as a literal example, so the prose
rules lint the author's own sentences. Inline-code spans (backticks) and double-quoted
spans are removed, and the open/closed state carries across lines. State resets at every
blank line, so an unbalanced quote can never swallow more than one paragraph."""
out = []
for ch in line:
if in_code:
if ch == "`":
in_code = False
out.append(" ")
elif ch == "`":
in_code = True
out.append(" ")
elif ch in "\"โโ":
in_quote = not in_quote
out.append(" ")
else:
out.append(" " if in_quote else ch)
return "".join(out), in_code, in_quote
def scan(path, min_sev):
rules = compile_rules(min_sev)
findings = []
total_words = 0
for fp in iter_files(path):
try:
with open(fp, "r", encoding="utf-8", errors="ignore") as fh:
raw_lines = fh.readlines()
except OSError:
continue # unreadable file: skip, not fatal
# skip a leading YAML frontmatter block; it is metadata, not prose
fm_end = 0
if raw_lines and raw_lines[0].strip() == "---":
for j in range(1, len(raw_lines)):
if raw_lines[j].strip() == "---":
fm_end = j + 1
break
in_fence = in_code = in_quote = False
for i, raw in enumerate(raw_lines, 1):
if i <= fm_end:
continue
stripped = raw.strip()
if not stripped:
in_code = in_quote = False
continue
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
in_code = in_quote = False
continue
if in_fence:
continue
if "unslop-ignore" in raw.lower() or "voice-allow" in raw.lower():
_, in_code, in_quote = strip_noise(raw, in_code, in_quote)
continue
is_quote = stripped.startswith(">")
is_list = bool(re.match(r"^([-*+]\s|\d+\.\s)", stripped))
prose, in_code, in_quote = strip_noise(raw, in_code, in_quote)
# Density is per authored word, so quoted lines do not inflate the denominator
# and dilute the score of a short piece that is mostly quotation.
if not is_quote:
total_words += len(re.findall(r"\b[\w'-]+\b", prose))
for r in rules:
target = raw if r.get("raw") else prose
if not r.get("raw") and is_quote:
continue
if r.get("skip_list") and is_list:
continue
# Every distinct match on the line counts, not just the first, so min_hits
# sees the real occurrence count. Keyed by span so two patterns matching the
# same text report once.
matches = {}
for rx in r["rx"]:
for m in rx.finditer(target):
matches[(m.start(), m.end(), m.group(0))] = m
for _, m in sorted((key, match) for key, match in matches.items()):
findings.append({"rule": r["id"], "label": r["label"], "sev": r["sev"],
"note": r["note"], "fix": r["fix"], "file": fp, "line": i,
"match": m.group(0).strip()[:60], "snippet": stripped[:160],
"_min_hits": r["min_hits"]})
# A min_hits rule reports only once its per-file count clears the threshold, so a single
# register-appropriate use stays silent while a cluster surfaces every hit.
counts = {}
for finding in findings:
key = (finding["file"], finding["rule"])
counts[key] = counts.get(key, 0) + 1
eligible = []
for finding in findings:
key = (finding["file"], finding["rule"])
if counts[key] < finding["_min_hits"]:
continue
finding = dict(finding)
finding.pop("_min_hits", None)
eligible.append(finding)
return eligible, total_words
def density(weighted, words):
return weighted / max(words, 1) * 1000.0
def verdict(by_sev, weighted, words):
hi, med = by_sev.get("high", 0), by_sev.get("medium", 0)
if weighted == 0:
return "Clean under the configured rules"
dens = density(weighted, words)
if hi == 0 and med == 0:
return "Mostly clean, minor style advisories"
if hi >= 3 or weighted >= 15 or (words >= 300 and dens >= 10):
return "High concentration of configured style violations"
if hi >= 1:
return "Configured style violations present"
if weighted >= 6 and not (words >= 600 and dens < 2.0):
return "Several advisory style patterns present"
return "Mostly clean, minor style advisories"
def main():
ap = argparse.ArgumentParser(description="Scan prose against the configured writing-style rules.")
ap.add_argument("path")
ap.add_argument("--severity", choices=["high", "medium", "low"], default="low",
help="minimum severity to report (default: low = everything)")
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--max", type=int, default=10, help="max examples shown per rule (text mode)")
args = ap.parse_args()
if not os.path.exists(args.path):
print(f"path not found: {args.path}", file=sys.stderr); sys.exit(2)
findings, total_words = scan(args.path, args.severity)
by_sev, by_rule = {}, {}
for f in findings:
by_sev[f["sev"]] = by_sev.get(f["sev"], 0) + 1
by_rule.setdefault(f["rule"], []).append(f)
weighted = sum(W[s] * n for s, n in by_sev.items())
files_scanned = sum(1 for _ in iter_files(args.path))
dens = round(density(weighted, total_words), 1)
if args.json:
print(json.dumps({"path": args.path, "files_scanned": files_scanned,
"words": total_words, "counts": by_sev,
"configured_style_score": weighted,
"density_per_1k_words": dens,
"verdict": verdict(by_sev, weighted, total_words),
"findings": findings}, indent=2))
sys.exit(1 if by_sev.get("high", 0) else 0)
sev_order = {"high": 0, "medium": 1, "low": 2}
rule_ids = sorted(by_rule, key=lambda rid: (sev_order[by_rule[rid][0]["sev"]], -len(by_rule[rid])))
print(f"\n unslop-text scan: {args.path}")
print(f" files scanned: {files_scanned} words: {total_words} findings: {len(findings)}")
print(f" configured style score: {weighted} density: {dens}/1k authored words")
print(f" verdict: {verdict(by_sev, weighted, total_words)}")
print(f" high: {by_sev.get('high',0)} medium: {by_sev.get('medium',0)} low: {by_sev.get('low',0)}")
print(" (high is the gated tier; medium/low are advisory)\n")
if not findings:
print(" Nothing flagged. The scanner cannot judge authorship, rhythm, originality,\n"
" factual value, or whether each paragraph earns its space. Run the structural\n"
" and concision pass in references/tells.md.\n")
return
for rid in rule_ids:
items = by_rule[rid]
f0 = items[0]
print(f" [{f0['sev'].upper()}] {f0['label']} ({len(items)} hit{'s' if len(items)!=1 else ''}) [{f0['note']}]")
print(f" fix: {f0['fix']}")
for it in items[:args.max]:
print(f" {it['file']}:{it['line']} ({it['match']}) {it['snippet']}")
if len(items) > args.max:
print(f" ... +{len(items) - args.max} more")
print()
top = [by_rule[rid][0]['label'] for rid in rule_ids[:3]]
print(" Top things to change: " + "; ".join(top))
print(" Rhythm, substance, factual value, and authorship need a human pass. See references/tells.md.\n")
sys.exit(1 if by_sev.get("high", 0) else 0)
if __name__ == "__main__":
main()
skills/a-unslop-text/tests/test_unslop_text_scan.py
import importlib.util
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "unslop_text_scan.py"
SPEC = importlib.util.spec_from_file_location("unslop_text_scan", SCRIPT)
SCANNER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SCANNER)
class ScannerTests(unittest.TestCase):
def scan_text(self, text):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sample.md"
path.write_text(text, encoding="utf-8")
return SCANNER.scan(str(path), "low")
def test_literal_landscape_navigation_and_ecosystem_pass(self):
findings, _ = self.scan_text(
"Use landscape orientation for the image. "
"The app navigates to the next page. "
"A pond ecosystem has insects."
)
self.assertEqual([], findings)
def test_consultant_compound_is_high(self):
findings, _ = self.scan_text("The competitive landscape keeps changing.")
self.assertEqual(["consultant-compound"], [item["rule"] for item in findings])
self.assertEqual("high", findings[0]["sev"])
def test_rhetorical_unpack_fails_but_tuple_operation_passes(self):
rhetorical, _ = self.scan_text("Let's unpack this before deciding.")
technical, _ = self.scan_text("The function returns three values; unpack the tuple first.")
self.assertEqual(["high-signal-vocab"], [item["rule"] for item in rhetorical])
self.assertEqual([], technical)
def test_house_words_need_a_cluster_and_count_same_line(self):
one, _ = self.scan_text("The plan is robust.")
two, _ = self.scan_text("The robust plan is comprehensive.")
self.assertEqual([], one)
self.assertEqual(2, len(two))
self.assertEqual({"medium"}, {item["sev"] for item in two})
def test_frequency_rule_requires_two_hits(self):
one, _ = self.scan_text("This is crucial.")
two, _ = self.scan_text("This is crucial and ultimately useful.")
self.assertFalse(any(item["rule"] == "frequency-words" for item in one))
self.assertEqual(2, len([item for item in two if item["rule"] == "frequency-words"]))
def test_frontmatter_quotes_code_and_allow_comments_are_ignored(self):
findings, words = self.scan_text(
"---\ntitle: Delve into this\n---\n"
"> Delve into this quoted sentence.\n"
"`delve into this literal`\n"
"Delve into this allowed example. <!-- unslop-ignore -->\n"
"One visible sentence.\n"
)
self.assertEqual([], findings)
self.assertEqual(3, words)
def test_dash_house_rule_still_applies_inside_quotes(self):
findings, _ = self.scan_text('A quoted "range โ still blocked" appears here.')
self.assertEqual(["em-dash"], [item["rule"] for item in findings])
def test_worn_origin_template_is_flagged(self):
findings, _ = self.scan_text("I saw a gap in the available tools, so I built this one.")
self.assertEqual(["social-template"], [item["rule"] for item in findings])
self.assertEqual("medium", findings[0]["sev"])
def test_dropped_pronoun_opener_passes(self):
findings, _ = self.scan_text(
"Made a free guide for people setting up their first server."
)
self.assertEqual([], findings)
def test_many_high_findings_return_boolean_failure(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "many.md"
path.write_text("โ" * 300, encoding="utf-8")
result = subprocess.run(
[sys.executable, str(SCRIPT), str(path), "--json"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(1, result.returncode)
if __name__ == "__main__":
unittest.main()
skills/a-unslop-ui/SKILL.md
--- name: a-unslop-ui description: >- Finds the cues that make a website read as AI-generated and forces a deliberate, project- specific design choice instead of the model's default. It does not impose a look. It flags both the old default (shadcn/Tailwind stock theme, AI-purple, gradient hero text, gradient body text, unprompted neon glow, emoji-as-icons, the centered-hero-plus-three-cards skeleton) and the newer cream-plus-serif-plus-green "tasteful default" that anti-slop advice itself created, so you do not trade one default for another. Ships a CI-gateable scanner for the mechanical tells and says which ones (layout, spacing, overflow) only your eyes can catch. Use when building, styling, reviewing, refactoring, or auditing any website, landing page, web app UI, dashboard, or component, and when the user wants it to look custom rather than AI-made, or mentions slop, vibe-coded, Tailwind, or shadcn. Trigger even if "vibe-coded" is never said. --- # a-unslop-ui Part of the unslop family (see `a-unslop-text`, `a-unslop-code`). Same shape: an auto-trigger, Build and Audit modes, a scanner that gates on a high-severity exit code, an `unslop-ignore` escape. This one is about web UI. It works on any web codebase, whatever sites or apps you run it against, and the method and scanner do not assume a particular stack. **What it is not.** It does not give you a good design and it has no preferred look. It removes the cues that read as AI-made, and where the model would reach for a default it forces a choice the project actually made. Taste, brand, and layout judgment stay yours. ## The trap: do not swap one default for another Every anti-slop effort fails the same way, by replacing one default look with another. The 2024 default was the purple-to-blue gradient on a dark hero. The 2026 default is a warm cream background, a serif display face (Instrument Serif, Fraunces), and a sage or forest-green accent, which is the current Claude/Anthropic house look. Swapping the first for the second is not unslopping. People clock it just as fast, and it reads as AI for the same reason: nobody chose it, the model did. So this skill prescribes no palette, no font, no layout. It detects the defaults, old and new, and asks that any replacement be a choice the project made on purpose. The one rule is "make a deliberate choice and be able to say why," which is the one thing a default never is. If the brand really is purple, neon, or a cream serif look, that is not slop; mark it `unslop-ignore` and move on. A tell is an unspecified default, not a banned colour. ## How it differs from the built-in frontend-design skill Claude ships a frontend-design skill, and it is good, but it produces the cream-plus-serif-plus- green look this one now flags. They work together: let the design skill build, let this one keep it honest. What this adds: a deterministic scanner that gates a build, a ranking so effort goes where the real complaints are, and an explicit check on the new "tasteful default" so you do not launder slop into nicer slop. ## Build mode Most "looks AI" output is an under-specified prompt. An unspecified prompt gets the median of the training data, and everyone's median is the same. Before generating, set four things: - **A reference.** One real site, brand, or screenshot whose design language to follow. This one input does more than the rest combined. If there is none, pick a named direction (editorial, brutalist, dense-utilitarian, warm-consumer, technical-mono), not "modern and clean," which means nothing. - **A colour.** A real brand colour or a chosen one, stated. Not the framework default, not the cream/sage default. - **A typeface.** A specific face or pairing chosen for this project, with a reason. The autopilot picks are Inter and Geist on the sans side, Instrument Serif, Fraunces, Playfair on the "tasteful" side; both are defaults, so reach for them only as a real choice. - **A layout intent.** What the page is for and what the reader should do first. Structure follows the goal, which is how you avoid the hero-plus-three-cards skeleton. With no brief, do not produce one median result. Produce a deliberate one and say what you chose, or offer two or three distinct directions. The value is breaking the monoculture. `references/choosing-a-look.md` is the method; the tells to avoid are in `references/tells.md`. ## Audit mode On "does this look AI" or "de-slop this," run the scanner, then fix in priority order. ```bash python3 scripts/devibe_scan.py <path> # report + vibe score python3 scripts/devibe_scan.py <path> --severity high # strongest only python3 scripts/devibe_scan.py <path> --json # for CI ``` It scans `.html .css .scss .js .jsx .ts .tsx .vue .svelte .astro`, reports each finding with file, line, and fix, and gives a vibe score. Exit code is the high count, so CI can gate on it. It catches the mechanical tells: colours, fonts, gradients, the cream-plus-serif combo. It cannot see layout coherence, spacing, or text overflowing its container, and those are also why a page reads as AI, so after the scan check them by eye against the catalog. **Escape hatch.** A line with `unslop-ignore` is skipped, for a real brand decision. **Fixing well.** Do not fix `bg-purple-600` by swapping in `bg-emerald-700`, which is a different default. Apply the project's real colour, or ask what it should be. A fix that introduces a new unspecified default is not a fix. ## What it does not flag Grounded in the ranking, not vibes. Mesh, aurora, and blob backgrounds barely register (mostly a keyword artifact). Bento grids and glassmorphism are low and contested, so they are allowed. Dark mode itself is fine; only the unprompted glow is a tell. shadcn and Tailwind are fine; their untouched defaults are the tell. Over-flagging trains people to ignore the tool, so the scanner stays narrow. ## Reporting Lead with the verdict and the single highest-impact change. Findings by priority with file:line and the fix. Close with the vibe score and the top three changes. The goal is a site that looks like a person made a decision, the one thing the scanner cannot do for you. ## Credits The tell catalog and its ranking come from the vibecoded-design-tells research by JCarterJohnson (https://github.com/JCarterJohnson/vibecoded-design-tells, MIT), a Reddit study of what people flag as AI-built websites. This skill rewrites that material into the unslop family and a shared Build/Audit shape with the text and code skills. `scripts/devibe_scan.py` is an adapted fork of that project's scanner (MIT); the credit is in its header.
skills/a-unslop-ui/agents/openai.yaml
interface: display_name: "Unslop UI" short_description: "Catch stock-default looks in web UI"
skills/a-unslop-ui/references/choosing-a-look.md
# Choosing a look on purpose This file gives you a way to decide, not a thing to copy. Any specific recommendation, repeated, becomes the next default and the next tell. The first version of this skill recommended cream backgrounds, forest green, and Fraunces, which is exactly the look people now read as AI-tasteful on sight. So: a method. The goal is one outcome: a design where every major choice has a reason specific to this project. That is the one property a default cannot have. ## Start from a reference The input that does the most work is a real reference. Ask for one site, brand, or screenshot whose feel they want, and anchor colour, type, and density to it. If they cannot name one, that is the conversation to have before writing CSS, because without it you are choosing the median for them. With no reference, pick a named direction and commit: dense and utilitarian (a trading terminal), editorial and text-forward (a magazine), warm and consumer (a friendly app), stark and technical (developer tooling), expressive and brand-loud. Naming it forces specificity and steers off the centre. ## Colour Decide a primary from the project, not the framework. A real brand colour is best. If you are choosing, sample from something concrete (a product photo, a logo, a place) so the palette has a source. Build a neutral ramp you picked, not stock slate or stock cream. The test is not "is this nice," it is "can I say why this project uses it." Avoid both current defaults: framework indigo/violet, and tasteful cream/sage. Either is fine as a stated decision, neither as a fallback. ## Type Pick type for a reason and pair it. The reason can be tone (a geometric sans for precision, a humanist serif for warmth), but it has to be a reason. A display face over a separate body face reads as more considered than one face everywhere. Steer off the autopilot picks on both sides: Inter and Geist (the "no choice" default), Instrument Serif, Fraunces, Playfair (the "tasteful choice" default). Not banned, overexposed. Use them only as a real decision, ideally not for the headline that sets the tone. ## Layout Structure follows the goal, not a template. Decide what the page is for and what the reader does first, and let that set the sections. That is how you avoid the centred hero plus three identical cards: the skeleton appears when structure is chosen by default. Show the real product (a true screenshot, real numbers) over abstract icon cards. Vary section shape and density down the page. ## Motion, radius, detail Decide these consciously and sparingly. Motion should communicate something and honour `prefers-reduced-motion`. Radius is a small intentional scale, not one value on everything. Glow, gradients, and effects are decisions to justify, not defaults to accept. ## One line Get a real reference and match it. Everything else here is what to do when you cannot. The skill removes the tells; this is how you replace them with something chosen.
skills/a-unslop-ui/references/tells.md
# AI-website tells: the catalog
The human-readable view. The scanner (`scripts/devibe_scan.py`) is the enforced layer for the mechanical tells; this file explains them, gives the code signatures, and lists the ones only your eyes catch. The ranking comes from the research credited in `SKILL.md`; the structure and prose here are this skill's.
The loudest complaint in the data is not any single feature. It is that the sites are recognizable on sight, "they all look the same." Among specific features, ordered by how often people name them:
## Co-top priority
### The "tasteful default" (cream + serif + green), the 2026 tell
The look the previous wave of anti-slop advice (including Claude's frontend-design skill) converged on, so it is now the clearest "AI tried to be tasteful" signal. A warm cream or beige page, a serif display face (Instrument Serif, Fraunces, Playfair), a sage or forest-green accent, often a generated product-screenshot card on the right. It reads as AI for the same reason the purple gradient did: nobody chose it. It lands harder than the gradient because it looks like taste.
- Signatures: cream page background (`#faf8f5`, `#f5f1e8`, `bg-stone-50`, `bg-amber-50`); serif display (`Instrument Serif`, `Fraunces`, `Playfair Display`, `Cormorant`, `DM Serif`); sage/forest primary (emerald or green 700-900). Any two of the three together is the strong signal.
- Fix: not a different nice palette. Anchor to the real brand or a reference; with none, pick a direction that is specific and uncommon. A genuine warm-editorial brand marks it `unslop-ignore`.
### Default shadcn / Tailwind look
The single most-named concrete cause of "they all look the same." The stock slate/zinc/gray cards, the default ring and border, the uniform `p-6` rhythm, the stock `rounded-lg`. shadcn is not the problem; shipping it untouched is.
- Signatures: heavy repeated `bg-slate-*`/`bg-zinc-*`/`bg-gray-*` cards with `border rounded-lg shadow-sm`; `components.json` with `baseColor: "slate"`/`"zinc"` and untouched `cssVars`; `--radius: 0.5rem` left at default; the stock `rounded-lg border bg-card text-card-foreground shadow-sm` Card repeated with no theming.
- Fix: theme the tokens before building. A real `--primary`, a chosen `--radius`, your own neutral ramp and spacing. Test: could someone tell your Card from the shadcn docs Card in a screenshot?
## High
### AI purple (violet / indigo primary)
The top colour tell, and it co-occurs with gradients more than any other pair. Models reach for Tailwind's indigo/violet when no brand colour is given, so purple-as-primary reads as "nobody chose this."
- Signatures: `indigo-*`/`violet-*`/`purple-*`/`fuchsia-*` as the primary, CTA, or link (not an accent dot); primaries like `#6366f1`, `#7c3aed`, `#8b5cf6`, `#a855f7`; `--primary`/`--brand` at hue around 255 to 280.
- Fix: a brand colour outside that band. If purple is the real brand, pick a specific off-default purple and pair it with a non-default neutral.
### Gradients, gradient text
The purple-to-blue (or purple-to-pink) hero gradient, gradient-filled headings, gradient buttons. Gradient body text especially reads as generated, since almost no real brand does it on running copy.
- Signatures: `bg-clip-text text-transparent` (or CSS `-webkit-background-clip: text`); `from-purple-* to-blue-*`, `from-violet-* to-indigo-*`; `bg-gradient-to-*` repeated across hero, buttons, cards.
- Fix: solid fills by default. At most one restrained accent gradient, analogous and low-contrast. Never on running headings or paragraphs.
## Medium
- **Too many animations.** Fade-in on every section, scale-up on every card hover, scrolljacking. A minor and noisier signal (the keyword catches tool mentions and praise too). Signatures: repeated `initial={{ opacity: 0 }}`/`whileInView`/`whileHover={{ scale }}`, `data-aos="fade-up"` everywhere, `hover:scale-1xx` on every card. Fix: motion only when it communicates state; honour `prefers-reduced-motion`; if every section animates the same way, cut it.
- **Rounded corners and pill buttons on everything.** One large radius on cards, inputs, buttons, images, plus fully-pill buttons. Signatures: broad `rounded-2xl`/`rounded-3xl`/`rounded-full`, `border-radius: 9999px` on every button. Fix: a small radius scale applied by role. (The scanner skips `rounded-full` on small `h-`/`w-` boxes, since those are dots and avatars.)
- **Dark mode with unprompted neon glow.** Dark mode is fine; the glow added when nobody asked is the tell. Signatures: `shadow-[0_0_*]`, `drop-shadow-[0_0_*]`, a saturated low-blur `box-shadow`/`text-shadow`, neon `text-cyan-400`/`text-green-400` on `bg-black`. Fix: remove glow you did not design. Dark mode should rely on contrast and spacing.
- **Emoji as icons or bullets.** Rocket, sparkle, lightning, lock characters used as feature-card icons or section bullets. It needs no asset pipeline, so the model reaches for it. Signatures: emoji in `<h1>`/`<h2>`/`<h3>`, in feature titles, or as list markers. Fix: a real SVG icon set (Lucide, Phosphor, Heroicons) or none. Emoji in genuine body copy is fine.
- **Generic default fonts.** Inter, Geist, Roboto, or `system-ui` as the only face is "nobody chose the type." There are two defaults now: Inter/Geist (the "no choice" default) and Instrument Serif/Fraunces (the "tasteful choice" default, see the 2026 tell). Both are autopilot. Signatures: `font-family: Inter`/`Geist`/`Roboto`; `next/font/google` importing one with no real second face; `font-sans` left at default. Fix: a face with character, chosen for a reason, paired display over body.
- **The centered hero + three feature cards + CTA skeleton.** The most common generated landing page. The structure is the tell, before any colour. Signature: a `text-center` hero with a big headline and two buttons, then `grid grid-cols-1 md:grid-cols-3` of icon-title-blurb cards. Fix: break the grid. Asymmetric hero, a real product screenshot, varied sections.
## Layout-quality tells (no scanner sees these)
Not colour or font, but a large part of why a page reads as AI. Check by eye on every build.
- Text overflow and clipping: a heading or label running past or behind its container, a fixed-width card that does not handle long content. Give text room, let it wrap, test with real strings.
- Inconsistent spacing: a page mixing `p-3` here, `p-7` there, an arbitrary `mt-[37px]`, with no spacing scale. The eye reads that as machine-made.
- Misalignment: edges that almost line up, inconsistent gutters. Align to a grid.
- No hierarchy: every section the same weight. Decide what the reader sees first and make the layout say so.
Fixing colour and font on an incoherent layout still leaves a site that reads as AI. The scanner gives a clean surface; these give a coherent structure.
## Lower-signal and copy tells
Real but minor; fix if cheap.
- Centred everything and endless whitespace; stock undraw-style illustrations (use real screenshots).
- Hero-copy cliches: `Transform your X`, `Supercharge`, `Unleash`, `Effortlessly`, `Your X, reimagined`. Write specific copy about what the thing does. (The text skill flags these too.)
## Do not chase (cleared by the research)
- Mesh/blob/aurora backgrounds: rejected as a keyword artifact (most matches were github `/blob/` URLs). Not a real complaint.
- Bento grids: dead last, and people defend them. Not a tell.
- Dark mode itself: only the unprompted glow is flagged.
- shadcn/Tailwind themselves: the defaults are the tell, not the tools. A themed shadcn site is invisible to this complaint.
Flag what the ranking supports, at the weight it supports.
skills/a-unslop-ui/scripts/devibe_scan.py
#!/usr/bin/env python3
"""
devibe_scan.py - scan a web codebase for "vibe-coded" / AI-generated design tells.
Forked (MIT) from github.com/JCarterJohnson/vibecoded-design-tells.
Plain Python, standard library only. Detection patterns and their severity come from a
Reddit analysis (~3.2M posts / 3,033 on-topic comments across 47 subreddits) of what
people actually flag as making a site look AI-generated. Severity follows how often each
tell is named in that data, so the report tells you where to spend effort.
Usage:
python3 devibe_scan.py <path> # scan a dir or file
python3 devibe_scan.py <path> --severity high # only high-signal tells
python3 devibe_scan.py <path> --json # machine-readable (for CI)
python3 devibe_scan.py <path> --max 8 # cap examples shown per rule
Exit code is the number of HIGH-severity findings (0 = none), so CI can gate on it.
"""
import os, re, sys, json, argparse
EXTS = {".html", ".htm", ".css", ".scss", ".sass", ".less", ".js", ".jsx",
".ts", ".tsx", ".vue", ".svelte", ".astro", ".mdx"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "out", "vendor",
"coverage", ".svelte-kit", ".astro", ".turbo", ".cache", "__pycache__"}
W = {"high": 3, "medium": 2, "low": 1}
# Each rule: id, label, severity, fix, and patterns (compiled case-insensitive).
# Keep patterns specific enough to avoid drowning the report in false positives.
RULES = [
# ---- HIGH: the top concrete tells ----
{"id": "shadcn-default-card", "label": "Untouched shadcn default Card / theme", "sev": "high",
"fix": "Theme the tokens (primary, radius, neutrals, spacing). Stock defaults are the giveaway, not shadcn.",
"pats": [r"rounded-lg\s+border\s+bg-card\s+text-card-foreground\s+shadow-sm",
r"\"baseColor\"\s*:\s*\"(slate|zinc|gray|neutral|stone)\"",
r"--radius\s*:\s*0\.5rem"]},
{"id": "ai-purple", "label": "AI purple / indigo / violet as primary color", "sev": "high",
"fix": "Pick a brand color outside the violet/indigo/purple band. It is Tailwind's default, so it reads as 'nobody chose this'.",
"pats": [r"\b(bg|text|from|via|to|border|ring|fill|stroke|decoration|outline)-(indigo|violet|purple|fuchsia)-(400|500|600|700|800)\b",
r"#(6366f1|4f46e5|818cf8|7c3aed|6d28d9|8b5cf6|a855f7|9333ea|7e22ce|c026d3|d946ef)\b"]},
{"id": "gradient-text", "label": "Gradient-filled text (heading/hero)", "sev": "high",
"fix": "Solid color on headings and copy. Gradient body text is one of the strongest AI tells.",
"pats": [r"bg-clip-text\s+[^\"'`]*text-transparent", r"text-transparent\s+[^\"'`]*bg-clip-text",
r"-webkit-background-clip\s*:\s*text", r"\bbackground-clip\s*:\s*text"]},
{"id": "purple-blue-gradient", "label": "Purple-to-blue/pink gradient", "sev": "high",
"fix": "Default to solid fills. If you must gradient, keep stops analogous and low-contrast, never the rainbow purple-to-blue.",
"pats": [r"from-(purple|violet|indigo|fuchsia)-\d+\s+(via-[a-z]+-\d+\s+)?to-(blue|indigo|pink|cyan|sky)-\d+",
r"linear-gradient\([^)]*#(6366f1|7c3aed|8b5cf6|a855f7)[^)]*\)"]},
{"id": "claude-default-look", "label": "The 'tasteful default' look (cream background + serif display)", "sev": "high",
"fix": "This is the 2026 tell, not the fix. Anchor color and type to the real brand or a reference. If cream + serif is a genuine decision, mark the line unslop-ignore.",
"pats": [r"#(faf8f5|f5f1e8|f3eee3|fdfbf7|f7f3ec|faf6ef|f6f1e7|fbf7f0|f4efe4)\b",
r"\bbg-(stone|amber|orange)-(50|100)\b",
r"\b(Instrument\s*Serif|Fraunces|Playfair\s*Display|Cormorant|Spectral|DM\s*Serif)\b"]},
# ---- MEDIUM ----
{"id": "hero-three-cards", "label": "Centered hero + three-feature-card grid skeleton", "sev": "medium",
"fix": "Break the grid. Asymmetric hero with a real screenshot; vary sections instead of stacked 3-up icon cards.",
"pats": [r"grid-cols-1\s+(sm:grid-cols-2\s+)?md:grid-cols-3"]},
{"id": "rounded-everything", "label": "Large rounded corners / pill buttons everywhere", "sev": "medium",
"fix": "Use a small, intentional radius scale by role. Not everything maximally rounded; pills only occasionally.",
"pats": [r"\brounded-(2xl|3xl|full)\b", r"border-radius\s*:\s*(999\d*px|9999px)"],
# rounded-full on a small sized box is a status dot / avatar / icon, not a pill button. Skip those.
"suppress": r"\b[hw]-(\d|10|11|12|14|16)(\.5)?\b"},
{"id": "fade-in-animations", "label": "Boilerplate fade-in / hover-grow / scroll animation", "sev": "medium",
"fix": "Motion only when it communicates something; gate behind prefers-reduced-motion. Minor tell, noisier signal.",
"pats": [r"initial=\{\{\s*opacity:\s*0", r"whileInView", r"whileHover=\{\{\s*scale",
r"data-aos\s*=", r"\bhover:scale-1\d{2}\b"]},
{"id": "neon-glow", "label": "Unprompted neon glow shadow", "sev": "medium",
"fix": "Remove glow you did not deliberately design. Dark mode should rely on contrast, not glow.",
"pats": [r"shadow-\[0_0_", r"drop-shadow-\[0_0_", r"text-shadow\s*:[^;]*\d+px[^;]*(rgba|#|hsl)",
r"box-shadow\s*:[^;]*\b0\s+0\s+\d{2,}px"]},
{"id": "emoji-as-icons", "label": "Emoji used as icons / section bullets", "sev": "medium",
"fix": "Use a real SVG icon set (Lucide/Phosphor/Heroicons) or none. Emoji-as-UI signals low effort.",
"pats": [r"[\U0001F680โจโก\U0001F525\U0001F4A1\U0001F512โ
\U0001F3AF\U0001F31F\U0001F6E1\U0001F4C8\U0001F511\U0001F389\U0001F680]"]},
{"id": "generic-font", "label": "Generic default font (Inter / Geist / Roboto / system)", "sev": "medium",
"fix": "Choose a typeface with character and pair a display + body face. The starter font reads as 'no choice made'.",
"pats": [r"font-family\s*:\s*['\"]?(Inter|Geist|Roboto)\b",
r"\b(Inter|Geist|Geist_Mono|Roboto)\s*\(",
r"fontFamily\s*:\s*\{[^}]*['\"](Inter|Geist|Roboto)"]},
# ---- LOW: copy + minor ----
{"id": "hype-copy", "label": "Generated marketing copy cliche", "sev": "low",
"fix": "Say what the product literally does, with specifics. Cut the template hype words.",
"pats": [r"\bTransform your\b", r"\bSupercharge\b", r"\bUnleash\b", r"\bEffortlessly\b",
r"\breimagined\b", r"take your [^.]{0,30}to the next level", r"\bGame-?changer\b"]},
{"id": "stock-illustration", "label": "Generic blob / stock illustration source", "sev": "low",
"fix": "Use real screenshots or commissioned art instead of undraw-style blobs.",
"pats": [r"undraw", r"storyset", r"\bdrawkit\b"]},
]
def compile_rules(min_sev):
order = ["high", "medium", "low"]
floor = order.index(min_sev) if min_sev else len(order) - 1
out = []
for r in RULES:
if order.index(r["sev"]) > floor:
continue
r = dict(r)
r["rx"] = [re.compile(p, re.IGNORECASE) for p in r["pats"]]
r["suppress_rx"] = re.compile(r["suppress"], re.IGNORECASE) if r.get("suppress") else None
out.append(r)
return out
def iter_files(path):
if os.path.isfile(path):
yield path; return
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if f.endswith(".min.js") or f.endswith(".min.css"):
continue
if os.path.splitext(f)[1].lower() in EXTS:
yield os.path.join(root, f)
def scan(path, min_sev):
rules = compile_rules(min_sev)
findings = []
for fp in iter_files(path):
try:
with open(fp, "r", encoding="utf-8", errors="ignore") as fh:
lines = fh.readlines()
except OSError:
continue # unreadable file: skip, not fatal
if len(lines) == 1 and len(lines[0]) > 5000: # likely minified
continue
for i, line in enumerate(lines, 1):
if "unslop-ignore" in line.lower(): # respect intentional choices
continue
for r in rules:
if r["suppress_rx"] and r["suppress_rx"].search(line):
continue
for rx in r["rx"]:
m = rx.search(line)
if m:
findings.append({"rule": r["id"], "label": r["label"], "sev": r["sev"],
"fix": r["fix"], "file": fp, "line": i,
"snippet": line.strip()[:160]})
break
return findings
def verdict(by_sev, weighted):
if by_sev.get("high", 0) >= 3 or weighted >= 15:
return "STRONG AI-default look"
if by_sev.get("high", 0) >= 1 or weighted >= 6:
return "Some AI defaults present"
if weighted > 0:
return "Mostly clean, minor tells"
return "Clean, no tells detected"
def main():
ap = argparse.ArgumentParser(description="Scan a web codebase for vibe-coded design tells.")
ap.add_argument("path")
ap.add_argument("--severity", choices=["high", "medium", "low"], default="low",
help="minimum severity to report (default: low = everything)")
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--max", type=int, default=10, help="max examples shown per rule (text mode)")
args = ap.parse_args()
if not os.path.exists(args.path):
print(f"path not found: {args.path}", file=sys.stderr); sys.exit(2)
findings = scan(args.path, args.severity)
by_sev = {}
by_rule = {}
for f in findings:
by_sev[f["sev"]] = by_sev.get(f["sev"], 0) + 1
by_rule.setdefault(f["rule"], []).append(f)
weighted = sum(W[s] * n for s, n in by_sev.items())
files_scanned = sum(1 for _ in iter_files(args.path))
if args.json:
print(json.dumps({"path": args.path, "files_scanned": files_scanned,
"counts": by_sev, "vibe_score": weighted,
"verdict": verdict(by_sev, weighted), "findings": findings}, indent=2))
sys.exit(by_sev.get("high", 0))
sev_order = {"high": 0, "medium": 1, "low": 2}
rule_ids = sorted(by_rule, key=lambda rid: (sev_order[by_rule[rid][0]["sev"]], -len(by_rule[rid])))
print(f"\n unslop-ui scan: {args.path}")
print(f" files scanned: {files_scanned} findings: {len(findings)} vibe score: {weighted}")
print(f" verdict: {verdict(by_sev, weighted)}")
print(f" high: {by_sev.get('high',0)} medium: {by_sev.get('medium',0)} low: {by_sev.get('low',0)}\n")
if not findings:
print(" Nothing flagged. Either it is clean or the tells are layout/motion ones a regex"
"\n cannot see. Eyeball the hero layout and animations against references/tells.md.\n")
return
for rid in rule_ids:
items = by_rule[rid]
f0 = items[0]
tag = f0["sev"].upper()
print(f" [{tag}] {f0['label']} ({len(items)} hit{'s' if len(items)!=1 else ''})")
print(f" fix: {f0['fix']}")
for it in items[:args.max]:
print(f" {it['file']}:{it['line']} {it['snippet']}")
if len(items) > args.max:
print(f" ... +{len(items) - args.max} more")
print()
top = [by_rule[rid][0]['label'] for rid in rule_ids[:3]]
print(" Top things to change: " + "; ".join(top))
print(" Layout and motion tells need eyes too. See references/tells.md.\n")
sys.exit(by_sev.get("high", 0))
if __name__ == "__main__":
main()
Everything mine on this shelf comes from my own working setup, shared for learning. Test it and adapt it to your project before relying on it; you run it at your own risk.