Skip to main content

cringe-filter

cringe-filter checks a draft for the habits that make text read as if Claude wrote it: em dashes, the it isn't X, it's Y frame, bold lead-ins, status emoji, and replies far longer than they need to be. You tell it where the text is going, and it tells you what to fix and how much more often Claude does each thing than a person writing in that place.

The rules come from measurement. We compared 570k words that one researcher (called "the writer" below) wrote in GitHub replies, issues and reviews with 1.35M words Claude wrote in the same threads. The writer's Discussions posts, LinkedIn posts, email, messages, papers and tutorials cover the other contexts. The lint rules describe Claude's habits, so they work on anyone's draft. score and prompt go further and pull a draft toward the writer's own habits for that kind of text. While this is hyper-specific to a single writer, the package comes with the option to replace the default corpus with custom content (i.e., your own).

Python 3.9+. The optional rewrite command also needs the anthropic package.

Install

pip install cringe-filter

Or run it without installing:

uvx cringe-filter contexts

Quick start

Here is a GitHub reply with four of Claude's quirks in it, saved as reply.md:

The parser now handles empty rows — it isn't a hack, it's the real fix.

**Key changes:**
- ✅ Added a guard for empty rows

Lint it for the place it is going:

$ cringe-filter lint -c github reply.md
reply.md:1: error em-dash [10.2x, CI 7.13-16.66]: Em dash. Use a period, a colon, or commas.
    parser now handles empty rows — it isn't a hack, it's the rea
reply.md:1: warn isnt-x-its-y [3.49x, CI 1.71-12.05]: The 'it isn't X, it's Y' frame. State Y and stop.
    r now handles empty rows — it isn't a hack, it's the real fix. **Key changes:
reply.md:3: warn bold-run [7.22x, CI 5.29-9.98]: Bold emphasis.
    t a hack, it's the real fix. **Key changes:** - ✅ Added a guard for empty r
reply.md:4: warn emoji-status [14.66x, CI 8.15-33.65]: Status emoji.
    real fix. **Key changes:** - ✅ Added a guard for empty rows

4 findings, 1 errors  (context: github)

[10.2x, CI 7.13-16.66] means Claude used em dashes 10.2 times as often as the writer did in GitHub replies, with a family-wise bootstrap interval. An error makes the command exit with code 1, so you can run it in CI or a pre-commit hook. The warn and info findings are yours to judge.

Written closer to how the writer replies, it passes:

I think the parser was dropping empty rows. Could you check whether a
guard there fixes it for you?

Contexts

People typically write differently in a GitHub reply vs. an academic manuscript, so every command takes a context, each with its own length budget, formatting rules and example passages:

$ cringe-filter contexts
context     label                                            docs    words  median  budget
github      GitHub reply in your own repos                   7235   378991      27     110
discussion  GitHub Discussions post                           456    56929      51     297
third-party Issue or comment in someone else's repo          2269   136266      30     126
email       Email                                             144    11972      57     170
message     Short message (DM, chat reply, comment)           757    23572      22      65
linkedin    LinkedIn post                                      89     9784      54     283
tutorial    Tutorial or docs page                             161    54974     357    1500
paper       Scientific prose                                  206    70341     359    6000
proposal    Proposal                                           39    13272     359    6000
any         Unspecified                                      9960   572186      28   10000

median is the writer's median length in words (per section for papers and proposals), and lint warns past budget. Aliases such as dm, docs, bug-report, pr and grant work too. With --url, the destination picks the context:

cringe-filter lint --url https://github.com/pytorch/pytorch/issues/1 draft.md

profile -c <context> prints the rate of each surface marker for the writer and for Claude in one context.

Commands

lint

lint makes no model calls, so you can run it on every file you touch:

cringe-filter lint --context github draft.md
git diff --name-only | grep '\.md$' | xargs cringe-filter lint -c tutorial --quiet

Revisions tend to put the tells back. When agents in the lab's repos revised paragraphs after a reviewer's comment, em dashes went from 261 to 329 and semicolons from 371 to 470. --against shows only what the new version added:

cringe-filter lint -c paper --against draft-v1.tex draft-v2.tex

A finding tagged [preventive, no corpus support] is a pattern that never separated the two authors in that context, and it stays at info. Where the writer uses a pattern more than Claude does, the rule is off. That is why lint never flags "ensure", "leverage", "comprehensive" or "streamline".

A short phrase in double quotes is read as a mention and skipped. At info, lint also flags a sentence longer than 90% of the writer's and a run of seven or more words said twice.

To silence a false positive, add a comment. disable-line covers its own line, disable-next-line the line after it, and disable-file the whole file:

<!-- cringe-filter: disable-line em-dash -->
<!-- cringe-filter: disable-next-line bold-run, md-header -->
<!-- cringe-filter: disable-file -->

In LaTeX, write % cringe-filter: disable-line em-dash. lint reads a .tex file as the prose in the PDF. It skips the preamble, comments, math, tables, footnotes and the keys of \cite, \ref and \label, treats --- as an em dash and -- as an en dash, and keeps the line numbers. A .tex file with no context is linted as paper.

score

score adds up how far each feature pulls the draft toward Claude or toward the writer. On the reply from the quick start:

$ cringe-filter score -c github reply.md
context: github (GitHub reply in your own repos)   words: 25   sentences: 2, longest 15
style score: +11.83 log-odds, reads like Claude (a ranking of what to fix, not a calibrated probability)
length: 25 words; the writer's median here is 27, p90 110, budget 110
Burrows' Delta over the 150 most frequent words: 0.39 to the writer's register, 0.42 to Claude (closer to the writer)
feature                                        n  yours/1k writer/1k  Claude/1k  log-odds
(per 1000 words; structure rows per 1000 sentences or prose paragraphs)
status emoji                                   1     40.00      0.03       1.21     +3.60
em dashes                                      1     40.00      1.83      18.43     +1.90
dashes between words                           1    500.00     36.20     349.60     +1.64
bold runs                                      1     40.00      2.93      21.47     +1.53
isn't X it's Y                                 1     40.00      0.03       0.12     +1.34
...

"Positive" tends to read more like Claude. The rewritten reply scores -21.12. Each row is a log-likelihood ratio of Claude's rate against the writer's, and the rows overlap, so use the total to decide what to fix first. It is not a probability, and the JSON output says calibrated: false.

The Delta line takes the 150 most common words in the corpus, mostly function words, and compares how often the draft uses them with each author's average. The rows counted per 1000 sentences describe how the sentences are built, such as how often the subject is a person or a sentence opens on "The". Email and tutorials have no rows of that kind.

prompt

prompt turns the measurements into instructions any model can follow:

cringe-filter prompt --context linkedin draft.txt        # the full prompt, with your draft
cringe-filter prompt --context linkedin --system-only    # the filter alone
cringe-filter prompt -c github --evidence draft.md       # plus the numbers behind it
cringe-filter prompt -c github --json draft.md           # {system, user, evidence}

The prompt starts with two real passages that match your draft's length and break none of the rules. Then it lists priorities for the context, the tells to avoid and a few notes. It leaves out the rates, because models follow numeric rules badly, and --evidence prints them for you. One priority covers sentence structure, such as making a person the subject. --no-structure leaves it out.

rewrite

rewrite sends the prompt to Claude and lints the result:

pip install 'cringe-filter[rewrite]'
export ANTHROPIC_API_KEY=...
cringe-filter rewrite --context email draft.txt
cringe-filter rewrite -c github --model claude-sonnet-5 --effort low draft.md
cringe-filter rewrite -c github --dry-run draft.md      # print the prompt instead
cringe-filter rewrite -c paper --minimal draft.tex      # edit, do not rewrite

If the result still has lint errors or measured warnings, or it dropped a number, link, code span or path (in LaTeX, also a citation, reference or math span), rewrite makes one more pass. It stops at two model calls, because repeated self-revision makes good text worse. Length alone does not trigger the second pass.

The default model is claude-opus-5 with adaptive thinking at medium effort. A declined request is re-run on the server-side fallback model unless you pass --no-fallback. The rewrite goes to stdout, and the pass count, scores and remaining findings go to stderr.

Use --minimal, on rewrite or prompt, for a draft headed to review, like a manuscript or a proposal. It asks for the smallest edit a reviewer would make, from the draft's lint findings and a short checklist of what the lab's reviewers asked agents to change.

audit

audit turns the lint findings into an edit spec: each sentence to fix, with its line and the reason, and the flagged sentences to leave alone. Give the spec and the draft to any model along with prompt --minimal:

cringe-filter audit -c paper draft.tex > spec.md

Some findings are judgment calls, because the writer also uses the X, not Y contrast and the X is what did Y cleft, mostly to give instructions. With --model, audit asks a model to score each one from 0 to 100 for how likely it is that Y was set up only to be knocked down. Hits under --threshold (30 by default) move to the leave-alone list. Without --model, nothing is dropped and you decide. The model can be anything with an OpenAI-compatible endpoint. The default is Ollama on your own machine, so a private draft stays private. --endpoint points it at llama.cpp's llama-server, LM Studio or vLLM instead:

ollama pull qwen2.5:14b
cringe-filter audit -c github --model qwen2.5:14b draft.md

A frontier model separated Claude's contrasts from the writer's on this question. The 1.5B and 3B models that fit on two CPU cores did worse.

instructions

instructions writes a GitHub Copilot instruction file for each context into .github/instructions/, or the folder you pass to --dir:

cringe-filter instructions paper proposal tutorial
cringe-filter instructions paper --apply-to "manuscript/**"

Copilot's cloud agent and code review apply each file to the paths its applyTo globs match. By default these are .tex and .bib files for paper, folders named for a proposal or grant for proposal, and Markdown, RST and notebooks for tutorial. --apply-to replaces the globs, and --exclude-agent code-review keeps code review from reading a file. Replies are not files, so for those contexts put the output of prompt -c <context> --system-only in .github/copilot-instructions.md instead.

Inside a coding agent

An agent that drafts prose is already a model, so it does not need rewrite. SKILL.md is a Claude Code skill that has the agent run lint and prompt and then fix its own draft. Copy it to .claude/skills/cringe-filter/SKILL.md in any repository where this package is installed. Clients without skills can use the MCP server.

Where the numbers come from

The corpus and the pipeline that builds cringe_filter/data/profile.json live in a private repository, because the corpus includes mail and direct messages. Each rebuild opens a pull request here, so the rules follow the data without anyone editing them. The example passages in the package come only from public repositories and public LinkedIn posts. Mail and messages contribute only counts.

To use your own profile, point CRINGE_FILTER_PROFILE at it. An exemplars/ folder next to it replaces the packaged passages that have the same file names:

export CRINGE_FILTER_PROFILE=~/private/voice/profile.json

How it was measured covers the statistics, the held-out tests and what is in the profile. To run the tests:

python -m unittest discover -s tests

Limits

  • Most contexts are scored against Claude's GitHub replies, because that is the only place both authors wrote about the same work. Scoring an email that way asks whether it reads like the model or like the writer's email, which is useful, but the two are different genres.
  • Papers and proposals are scored against Claude's own manuscripts and agents' proposals. The default writer's side of proposal is based on two proposals.
  • These have primarily been based on interactions with Opus 4.8, 5, 5.1, 5.5, and Fable 5, 5.1. As new models are released, behavior will vary (hopefully improving).
  • Small contexts carry little signal for rare phrases. The LinkedIn corpus has 89 posts, and where a context has no verdict for a phrase, lint falls back to the verdict for the whole corpus.
  • Getting past AI detectors is not the goal. The better test: would this sound pretentious to someone who already knows what they are doing?

Metadata

Release files for cringe-filter 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cringe-filter 0.2.0
File Size Uploaded
cringe_filter-0.2.0.tar.gz 162.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cringe-filter 0.2.0
File Interpreter ABI Platform
cringe_filter-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 324.7 kB

Release files / cringe_filter-0.2.0.tar.gz

Download URL cringe_filter-0.2.0.tar.gz
Size 162.1 kB
Tags Source
SHA-256 checksum
How to use checksums
926d4a338ef09de0e74c3e86a21bffc7fb8134ef14f2ac3c0c5d516c182f9531
BLAKE2b-256 checksum
How to use checksums
84326916974f26e3a41967fbf4737727b962ef3271bed1e1eb42b04df817a5d5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.

Transparency log

Release files / cringe_filter-0.2.0-py3-none-any.whl

Download URL cringe_filter-0.2.0-py3-none-any.whl
Size 162.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b39b25770f02907e9ed273f826e8958ec2c002bd4007241646c4b048fbd33154
BLAKE2b-256 checksum
How to use checksums
913d598c43aa1db62205b86a306680853f14ba2197d0369a4229272d52293fd4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page