From the Lab

AI Writing Linter

eslint for AI writing

pythonclaude-skillsregex-rules
$ cat problem.md

The Problem

AI writing has a tell. Formulaic openings, ritual conclusions, "boasts a rich tapestry," "plays a vital role" — you know it when you see it.

The obvious fix is to tell the model "sound human, drop the fluff." It doesn't stick. The tells leak right back in on the next draft, because the model is defaulting to what reads as "helpful" rather than what reads as you. You can't reliably prompt your way out of it.

$ cat solution.md

The Solution

So I stopped asking and started linting. Instead of hoping the model behaves, I built a deterministic checker for the patterns that scream "an AI wrote this" — eslint, but for prose.

Two composable layers:

  • check_text.py (the base) — catches the universal tells: puffery, importance-emphasis, ritual conclusions, hedging, AI-vocabulary clusters, title-case headings, em-dash overuse. Works on any text.
  • check_voice.py (a voice layer on top) — imports the base ruleset, promotes severity where you're stricter, and adds your own patterns: throat-clearing openers, label-colon fragments, a 2-em-dash-per-piece budget, thesis-opening detection.

Severity levels, --json, real exit codes. It runs before a draft ships and fixing the violations is mandatory, not a suggestion. It's CI for writing — the part most people skip.

It's public — grab the gist and point it at your own drafts.

$ cat stack.md

Technical Stack

Deliberately tiny — standard library only, no dependencies:

  • Python + regex — a documented rule library, each pattern tagged with a category and severity
  • Base + override architecture — the voice layer imports the base and layers on top, instead of forking one giant list. Keep shared rules in one place; layer your own voice on top.
  • Exit codes — non-zero on any error-severity hit, so it drops straight into a pre-commit hook or CI

The base+override split is the engineering. It's what makes this a reusable tool instead of a personal word-blocklist.

$ cat impact.md

Impact

The base catches the robot; the voice layer keeps you sounding like you. Run it on a draft and you get a line-numbered list of exactly what to cut — not a vague "make it more human."

The reusable part is the shape: a shared ruleset anyone can use, plus a thin personal layer on top. Fork the voice file, keep the base, and it's your linter. That's the bit people stop short of — they tweak a prompt when they could be running a check.

THE RECEIPTS

Base + override

check_voice.py   (your voice: throat-clearing, em-dash budget, …)
      │  import check_text
      ▼
check_text.py     (universal AI tells: puffery, hedging, ritual endings, …)
      ▼
your draft  →  line-numbered violations + exit code

The base is shared and reusable. The voice layer is thin and personal. Swap in your own patterns without touching the base.

Linting an AI-tell sentence

$ echo "This destination boasts a rich tapestry of culture and
  plays a vital role in the region." | python check_text.py -

Found 4 pattern(s): 3 error, 1 info

Line 1 [error] puffery: 'boasts a'         -> "boasts a"
Line 1 [error] puffery: 'rich [heritage]'  -> "rich tapestry"
Line 1 [error] importance_emphasis:
               'plays a [vital] role'       -> "plays a vital role"
Line 1 [info]  ai_vocabulary: tapestry     -> "tapestry"

Specific, line-numbered, and non-zero exit — so the draft can't ship until it's fixed.

Run it

# base linter — any text
python check_text.py draft.md
echo "text" | python check_text.py -

# voice layer (keeps check_text.py next to it)
python check_voice.py draft.md --severity warning
python check_voice.py draft.md --json

Non-zero exit on any error, so it works as a pre-commit hook or in CI. Public gist: ai-writing-linter.

Want to build your own? Read the full tutorial

← Back to the Lab