station · ivanmisic.net log in
§02 unslop ↵ back to plugins
Mine Plugins

unslop

Three scanners for the cues that make writing, code, and web UI read as AI-generated, each with a Build mode that heads off the tell before it ships.

Install
/plugin install unslop@imisic

Three skills, one plugin, one shape repeated three times: an auto-trigger, a Build mode that specifies your way out of the model's 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 as the model's default output, 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.

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.

What's in the bundle 14 files
.claude-plugin/plugin.json 422 B
{
  "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.",
  "version": "1.0.0",
  "author": {
    "name": "Ivan Misic"
  }
}
README.md 3.5 KB
# unslop

Three scanners for the cues that make writing, code, and web UI read as AI-generated, 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.

```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/fitting-the-codebase.md`) for the tells a scanner cannot see.

## Install

```
/plugin marketplace add imisic/claude-marketplace
/plugin install unslop@imisic
```

## 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 6.8 KB
---
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/references/fitting-the-codebase.md 3.0 KB
# 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 5.2 KB
# 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. (Already a CLAUDE.md rule.)
- **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 15.7 KB
#!/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 7.2 KB
---
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." 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 whether a machine wrote something. It finds specific, cited tells and removes them. 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`.

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 slop score and a density per 1,000 words. Exit code is the high-severity (hard-ban) count, so CI can gate on it.

Be honest about what the scanner is: a lexical filter. It catches the em dash, the banned diction, the antithesis cadence, the formatting tics. It cannot see the structural tells (uniform rhythm, sycophancy as tone, saying nothing at length), which are often the real giveaway. A clean scan means the lexical layer is clean, not that the writing reads as a person. After every scan, read the draft and check the structural section of `references/tells.md` by ear.

**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 slop score and the top three changes. 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/references/tells.md 8.3 KB
# The prose tells: the catalog

This is the human-readable view of the banned vocabulary and patterns. The enforced list is `scripts/unslop_text_scan.py`; this file explains it. If the two disagree, the scanner wins.

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 hard ban and gates the scanner's exit code. MEDIUM is "fine once, a cluster is slop." LOW is weak or contextual. The structural tells at the bottom are the ones a regex cannot see, and they are usually the real giveaway.

Every banned term below is in `backticks` so this file scans clean against its own scanner.

## HIGH: hard bans (the scanner gates on these)

### The em dash and en dash

`—` and `–`, anywhere, including the HTML entities `&mdash;` / `&ndash;`. 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 -->

### Banned vocabulary

Corporate-speak and AI diction. Use the plain word.

| Banned | Use instead |
|-|-|
| `delve`, `dive into`, `deep dive`, `unpack` | look at, examine, go through |
| `leverage` (verb) | use, apply, build on |
| `utilize` / `utilise` | use |
| `robust` | reliable, handles edge cases, or describe the behaviour |
| `comprehensive` | covers X, Y, and Z |
| `paradigm` | approach, model |
| `synergy` / `synergies` | describe the specific interaction |
| `ecosystem` (unless literal) | set of products, stack |
| `navigate` (figurative) | handle, describe the actual decision |
| `landscape` (figurative) | competitors, the specific thing |
| `seamless` | works without a step, no handoff |
| `streamline` | cut steps, simplify |
| `foster` | build, support, grow |
| `holistic` | whole, end-to-end |
| `cutting-edge` | new, the specific capability |
| `game-changer` | what actually changes |
| `empower` | let, give, enable |
| `unlock` / `unleash` | the specific result |
| `elevate` | improve, raise |
| `realm`, `tapestry`, `beacon`, `testament`, `myriad`, `plethora` | the plain word; usually cut |
| `best-in-class` | what makes it good |

If a banned word seems necessary, the sentence is hedging. Rewrite for the underlying thought.

### 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`. 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 slop in a cluster: `crucial`, `vital`, `essential`, `essentially`, `fundamentally`, `various`, `versatile`, `significant`, `genuinely`, `nuance`, `meticulous`, `showcase`, `straightforward`, `precisely`, `dramatically`, `incredibly`, `particularly`, `ultimately`. 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`.

## 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 `comprehensive` in a long piece where it fits the register.
- 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.
- **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.

### 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."
skills/a-unslop-text/references/writing-with-intent.md 4.6 KB
# 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 20.9 KB
#!/usr/bin/env python3
"""
unslop_text_scan.py - scan prose for the tells that make writing read as AI-generated,
keyed to a merged, canonical banned-vocabulary and voice-rules catalog.

This is the deterministic source of truth for the banned vocabulary and patterns.
references/tells.md is the human-readable view of the same list; if the two ever
disagree, this file wins. 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. The mechanical tells live here: the em dash, the banned
diction, the "not X, it's Y" cadence, assistant boilerplate, the formatting tics. The
highest-value tells are STRUCTURAL and a regex cannot see them: uniform rhythm, sycophancy
as tone, saying nothing at length. Those are in references/tells.md for a human pass. A
clean scan means the lexical layer is clean, not that the writing reads as a person.

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 the number of HIGH-severity findings (0 = none), so CI can gate on it. HIGH
is the hard-ban tier (em dash, banned vocab, the assistant voice). MEDIUM and LOW are
advisory: they show in the report but do not fail the gate.
"""
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: id, label, severity, note (shown in the report), fix, and patterns.
# Tiers: HIGH = hard ban (gates the exit code), MEDIUM = use sparingly
# / formatting tic (advisory), LOW = weak or contextual (advisory, easy to over-call).
# Words cleared as "do not chase" (lone however/thus/when it comes to) are deliberately
# absent; see references/tells.md. The em dash is raw=True (flagged inside quotes too).
RULES = [
    # ---------- HIGH: hard bans ----------
    {"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"&mdash;", r"&ndash;"]},
    {"id": "banned-vocab", "label": "Corporate / AI diction (hard-banned vocabulary)", "sev": "high",
     "note": "hard ban",
     "fix": "Use the plain word: delve->look at, leverage->use, utilize->use, robust->reliable, comprehensive->covers X/Y/Z, paradigm->approach, navigate->handle. Full 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"\bleverag(e|es|ing|ed)\b", r"\butili[sz](e|es|ing|ed|ation|ate)\b",
              r"\brobust\b", r"\bcomprehensive\b", r"\bparadigm\b", r"\bsynerg(y|ies|istic)\b",
              r"\becosystem\b", r"\bnavigat(e|es|ing|ed)\b", r"\blandscape\b",
              r"\bseamless(ly)?\b", r"\bstreamlin(e|es|ing|ed)\b", r"\bfoster(s|ing|ed)?\b",
              r"\bholistic(ally)?\b", r"\bcutting[- ]?edge\b", r"\bgame[- ]?chang(er|ers|ing)\b",
              r"\bempower(s|ing|ed|ment)?\b", r"\bunlock(s|ing|ed)?\b", r"\bunleash(es|ing|ed)?\b",
              r"\belevat(e|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"\bunpack(s|ing|ed)?\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"]},

    # ---------- MEDIUM: use sparingly / formatting tic (advisory) ----------
    {"id": "frequency-words", "label": "Frequency-sensitive word (fine once; a cluster reads as slop)", "sev": "medium",
     "note": "max ~once per piece",
     "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.",
     "pats": [r"(^|\s)#{0,4}\s*\d+\s+(ways|tips|signs|reasons|things|steps|tricks|secrets|lessons|mistakes|rules)\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": "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"]},

    # ---------- 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["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
            total_words += len(stripped.split())
            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)
            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
                for rx in r["rx"]:
                    m = rx.search(target)
                    if m:
                        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]})
                        break
    return findings, 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, no tells detected"
    dens = density(weighted, words)
    if hi == 0 and med == 0:
        return "Mostly clean, minor tells"
    if hi >= 3 or weighted >= 15 or (words >= 300 and dens >= 10):
        return "STRONG AI-writing tells"
    if hi >= 1:
        return "Some AI tells present"
    if weighted >= 6 and not (words >= 600 and dens < 2.0):
        return "Some AI tells present"
    return "Mostly clean, minor tells"

def main():
    ap = argparse.ArgumentParser(description="Scan prose for AI-writing tells (canonical voice 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, "slop_score": weighted,
                          "density_per_1k_words": dens,
                          "verdict": verdict(by_sev, weighted, total_words),
                          "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-text scan: {args.path}")
    print(f"  files scanned: {files_scanned}   words: {total_words}   findings: {len(findings)}")
    print(f"  slop score: {weighted}   density: {dens}/1k 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 hard-ban tier; medium/low are advisory)\n")
    if not findings:
        print("  Nothing flagged. Either it is clean or the tells are structural ones a regex\n"
              "  cannot see (uniform rhythm, sycophancy, saying nothing at length). Read it aloud\n"
              "  and check those by eye against 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("  Structural tells (rhythm, sycophancy, emptiness) need a human pass. See references/tells.md.\n")
    sys.exit(by_sev.get("high", 0))

if __name__ == "__main__":
    main()
skills/a-unslop-ui/SKILL.md 6.5 KB
---
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/references/choosing-a-look.md 2.9 KB
# 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 7.4 KB
# 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 11.4 KB
#!/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.