A deterministic prose linter for the mechanical tells of machine-generated writing. No model, no network: it runs in CI and pre-commit and fails the build on a banned tell.
Machine-generated prose has tells: the em-dash aside, the filler verb, the marketing
adjective, the not just X but Y padding, the throat-clearing opener, the G1/NG2 label
stapled to every item of a list. A model can rewrite them away, but that costs a model call
on every check and gives a different result each run.
deslopper catches the mechanical tells with plain patterns, so the check is free, instant,
and identical every time. That makes it safe as a gate in CI and pre-commit, where a model
pass does not belong. Use it for the deterministic floor, and leave the model rewrite for
the judgement a regex cannot make.
Vale and proselint check spelling, house style, and readability. deslopper has a narrower scope: it detects the tells of machine-generated writing. It ships as a single Python package with no dependencies, and its checks are deterministic, so the same input always produces the same findings. If you already run a style linter, deslopper complements it rather than replacing it.
deslopper needs no runtime dependencies beyond Python 3.9+. It is published to PyPI and to a Homebrew tap on each tagged release.
Run it straight from PyPI with uv:
uvx deslopper lint
or with pipx:
pipx run deslopper lint
Install it with pipx:
pipx install deslopper
with plain pip:
pip install deslopper
or from the Homebrew tap:
brew install jv-k/tap/deslopper
Pin a version in CI for reproducible builds:
uvx deslopper@0.2.0 lint --format github
The repo ships a skill in skills/deslopper/ for coding agents that follow the skills convention, such as Claude Code.
Install it with the skills CLI:
npx skills add jv-k/deslopper
Or copy skills/deslopper/ into ~/.claude/skills/ to have it in every project, or into
a repo's .claude/skills/ for that repo alone.
The skill activates when the agent writes or edits Markdown or MDX, or when you ask it to
de-slop existing text. On activation the agent runs deslopper rules to load the live
tell list, so it follows your config and presets, writes within those rules, and lints the
files it touched before finishing. Error-tier findings are fixed and re-linted until
clean. Warn-tier findings are fixed only when the rewrite is clearly better, and are
otherwise reported to you. The skill is the model-side complement to the CI gate, and the
gate stays the deterministic floor.
deslopper lint [PATHS...] [--strict] [--config P] [--format text|github|json] [--triage]
deslopper check [PATHS...] [--config P] # report only, exits 0 on findings
deslopper rules [--config P] # list the active tells
deslopper init # write a starter config
deslopper eval 'COMMAND' [--keep] [--plainness] # judge a rewrite command, see below
deslopper completions [bash|zsh|fish] # print a completion script for your shell
With no paths, deslopper lints the configured Markdown and MDX globs, through git ls-files
in a work tree or a filesystem walk otherwise.
Tab completion comes from deslopper completions, which detects your shell from $SHELL
when you leave the argument off. Run deslopper completions --help for where each shell
expects the script. Homebrew installs the bash, zsh, and fish completions automatically,
so this command is only needed for the pip, pipx, and uv installs.
Output is coloured on a terminal and plain when piped. NO_COLOR turns styling off
everywhere, FORCE_COLOR=1 turns it on without a terminal, and the github and json
formats are never styled.
The error tier (em dashes, the section sign, middle-dot separators, curly quotes) fails the
run. The warn tier prints but passes unless you add --strict. Exit codes: 0 clean, 1 a failing-tier
finding or an unreadable file, 2 a usage or configuration error.
The recommended preset ships these, one row per tell with an example of the prose it
catches. The em dash, section sign, middle dot, and curly quotes are caught in prose and
also when spelled as HTML entities. The table is generated from the preset by
scripts/readme_tells.py and pinned by tests, including one that checks each example
fires its tell, and deslopper rules prints the live list for whatever config is active.
❌ error, fails the run. --strict.
| Tell | Tier | Example | Message |
|---|---|---|---|
em-dash |
❌ | A quick fix — just restart. |
em dash in prose, use a colon, comma, parentheses, or two sentences |
section-sign |
❌ | See § 4.2 for details. |
section sign, write 'section' |
middle-dot |
❌ | fast · simple · tested |
middle dot or bullet in prose, join the items with a comma or plain words, or separate with ◦ |
curly-quote |
❌ | It’s “done” now. |
curly quote, use a straight quote |
bold-bullet-lead |
- **Blazing speed** builds finish in seconds |
bolded bullet lead, reserve bold for a rare callout not a per-item label | |
id-label-lead |
- FR-1 The app shall sync. |
id label on a list item, number the list plainly | |
semicolon |
It compiles; it ships. |
semicolon in prose, prefer a full stop | |
not-just-x-but-y |
not just fast but correct |
"not just X but Y" padding, make the point once | |
filler-verb |
This leverages the cache. |
filler verb, use a plain verb or cut | |
marketing-adjective |
a seamless, robust workflow |
marketing adjective, say what is true | |
throat-clearing |
It's worth noting that tests pass. |
throat-clearing or transition opener, start with the point | |
vague-intensifier |
significantly faster |
vague intensifier with no number behind it | |
emoji |
Done ✅ |
emoji or decorative checkmark in body text | |
chatbot-phrase |
I hope this helps! |
chatbot phrase, delete it | |
sycophancy |
Great question! |
sycophantic tone, respond directly | |
fancy-is |
The CLI boasts three modes. |
fancy way to say 'is', say 'is' or 'has' | |
puffery |
A testament to good design. |
puffery, state what happened | |
vague-attribution |
Experts believe it scales. |
vague attribution, name the source or cut | |
inflated-word |
A crucial, intricate detail. |
inflated word, use a plain one | |
trailing-participle |
It retries, ensuring delivery. |
trailing participle clause, say it straight or cut |
An opt-in layer of higher-false-positive tells for teams that would rather triage noise than miss a tell. Extend both presets, in this order:
{
"extends": ["deslopper:recommended", "deslopper:aggressive"]
}
The layer is additive: no tell in it shares a name with a recommended tell, so nothing is replaced. Words that double as real technical vocabulary (vector, primitive, surface, harness, scaffolding) are excluded on purpose, because a regex cannot tell the metaphor from the term.
| Tell | Tier | Example | Message |
|---|---|---|---|
abstract-metaphor |
Our north star is the flywheel. |
abstract metaphor, pick the concrete word | |
cutoff-disclaimer |
Specific details are limited. |
cutoff disclaimer, find the fact or cut | |
formulaic-challenge |
Despite challenges, it continues to thrive. |
formulaic challenge framing, give the specific fact | |
generic-conclusion |
The future looks bright. |
generic conclusion, state a plan or fact | |
boldface-overuse |
**Fast**, **safe**, **simple**. |
three or more bold spans on one line, bold at most one thing |
deslopper lint --format json prints one object for tooling:
{
"findings": [
{
"path": "docs/guide.md",
"line": 6,
"col": 27,
"tier": "warn",
"name": "semicolon",
"message": "semicolon in prose, prefer a full stop"
}
],
"unreadable": [],
"summary": { "errors": 0, "warnings": 1, "unreadable": 0 }
}
The package ships JSON Schemas for both sides of the contract: output.schema.json for this object and config.schema.json for the config file.
A tell is a regex, and a regex cannot tell a decorative em dash from one inside a quoted
title, or delve the filler verb from Delve the debugger. On a repo with real prose the
warn tier fills up with findings a careful editor would keep. deslopper lint --triage
runs the scan as usual, then sends each flagged line, with the line above and below it, to
the typesafe-ai/jev model through the Vercel AI Gateway
and asks one question per finding:
is this a machine-writing tic to rewrite, or a deliberate use to keep? Each finding comes
back with a verdict and its probability, in every format:
docs/guide.md:6:27 [warn] semicolon: semicolon in prose, prefer a full stop [rewrite 0.88]
docs/guide.md:9:14 [warn] filler-verb: filler verb, say what it does [keep 0.97]
On a terminal the probability leads each line as a five-slot bar, green from 0.9, yellow
from 0.7, red below, and the tail keeps only the verdict: ❚❚❚❚❚ docs/guide.md:9:14 … [keep].
Piped output keeps the number. The json format adds verdict and probability to each
judged finding, and the github format folds the verdict into the annotation message. The
summary line reports how many findings were keep and rewrite, and the tokens and gateway
cost the run spent.
Triage is annotation only. The exit code is exactly what the scan alone returns, so a
keep verdict never makes a lint pass and the deterministic gate holds in CI. Findings on a
disabled line are never sent, and a file with no findings makes no request. The flag is
command-line only, never a config key, so no repo can switch on network calls for everyone
who lints in it, and check does not accept it.
The gateway key comes from AI_GATEWAY_API_KEY. Without it, --triage exits 2 with a
one-line hint and prints no findings. One request is made per file with findings. A probe
of nine findings cost $0.000066 and took under half a second. A request that fails prints
one error line to stderr, leaves that file's findings without verdicts, and the run
finishes with the scan's exit code.
The linter is the deterministic floor. The rewrite that clears a backlog is a model pass,
and deslopper eval tests whether yours works: it seeds a temporary sandbox with slop
fixtures that trip every tell in the recommended and aggressive presets, runs your rewrite
command over the sandbox, and judges the result.
deslopper eval 'my-rewrite {dir}'
deslopper eval ./scripts/deslop.sh # the sandbox path is appended when {dir} is absent
Two judges rule on the outcome. Efficacy lints the rewritten fixtures: zero error-tier findings is the hard gate, and the warn count must land strictly below the raw baseline, which leaves the model some room without letting it tread water. Preservation extracts a digest of the protected content (fenced code and front matter verbatim, the heading outline, table rows, link destinations) before and after the rewrite, and any difference fails the run. As a self-check, the raw fixtures are linted first: if they produce no errors the harness is broken and the run aborts before spending tokens.
Exit codes: 0 pass, 1 efficacy failure, 2 usage or configuration error, 3 preservation
failure, 4 broken harness or a rewrite command that exited nonzero. A failure names the
surviving findings by file and line. Pass --keep to keep the sandbox for inspection.
A third judge, plainness, is opt-in with --plainness. It asks Jev, a small evaluation
model behind the Vercel AI Gateway, to score each fixture on how plainly it reads, once on
the raw fixtures before the rewrite and once after, and prints one line per fixture as
before -> after plus the mean. The score is reported and never gated: it cannot change
the verdict or the exit code, so two eval runs can be compared on more than pass or fail
without a probabilistic judge failing a run the deterministic judges passed. It needs
AI_GATEWAY_API_KEY exported and exits 2 without it, before the rewrite command runs. The
two requests send the fixtures whole and cost on the order of a thousand input tokens
and a fraction of a cent per run, which the mean line reports. A gateway failure prints
one error line and the eval finishes with the two deterministic judges.
An eval run invokes your rewrite command for real, with the minutes and tokens that implies. Run it on demand when you change the rewrite prompt or model. It has no place as a per-commit or per-PR gate.
Run deslopper as a gate with GitHub Actions. Until it is on PyPI, install it from the public
repo. Switch to the pinned PyPI form (uvx deslopper@x.y.z ...) once it ships.
# .github/workflows/prose.yml
name: Prose
on: pull_request
permissions:
contents: read
jobs:
deslop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v8.2.0
- run: uvx --from git+https://github.com/jv-k/deslopper@main deslopper lint --format githubOn a repo whose prose predates the rules, lint only the files changed in the PR, so the backlog does not block every commit:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v8.2.0
- run: |
files=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- '*.md' '*.markdown' '*.mdx')
[ -z "$files" ] && exit 0
uvx --from git+https://github.com/jv-k/deslopper@main deslopper lint --format github $filesFor a local gate, run the same lint from pre-commit:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: deslopper
name: deslopper
entry: deslopper lint
language: system
types: [markdown]
pass_filenames: falseThe hook shells out to whichever deslopper is on your path, so install it first through
pipx or Homebrew. pass_filenames: false makes the hook lint the configured globs rather
than the staged paths, which keeps the local verdict identical to CI. Explicit paths are
linted as given, skipping the config excludes, so a staged-files hook would flag files
your config meant to leave alone.
Drop a deslopper.config.json to retune the bundled recommended rule set:
{
"extends": ["deslopper:recommended"],
"tells": {
"disable": ["semicolon"],
"override": { "filler-verb": { "tier": "error" } },
"add": [
{ "name": "no-foo", "tier": "warn", "kind": "regex", "pattern": "foo", "message": "no foo" }
]
}
}
A tell is keyed by its name and phase. Two tells can share a name across phases (the em-dash
entity form and literal form do), so target one with name@phase, for example
em-dash@pre-entity.
A tell has a tier, either error or warn and nothing else. Its phase, pre-entity
or post-entity, says when it scans relative to HTML-entity masking, and only a
pre-entity tell can catch a tic spelled as an entity. A scope of all reports every
match on a line, first stops after one. The kind picks the matcher: regex is the
default, and the bold-bullet and id-label kinds read their pattern's capture groups.
The match itself is either pattern, one regex, or words, a list of regex fragments
joined into a single boundaried alternation, so an entry like utili[sz]es? is a fragment
and not a literal string.
Fenced code, inline code, front matter, and HTML entities are masked before tells scan, so no tell fires inside them.
extends names the presets to build on, opted into as deslopper:<name>. Two presets
ship today: recommended, the default, and aggressive, the opt-in layer described
above. The whole file is described by the
config schema.
Exempt an example from the lint with an HTML comment. The directive keyword keeps the
deslop-lint- prefix on purpose, so existing documents keep working:
<!-- deslop-lint-disable-line -->
<!-- deslop-lint-disable --> ... <!-- deslop-lint-enable -->
Add a tell, add a preset, or change the code. See CONTRIBUTING.md. Tells
live in src/deslopper/presets/recommended.json. A preset is a <name>.json file in
src/deslopper/presets/, opted into from a config as deslopper:<name>.
MIT.

