Abdulkader Safi

The prose linter's first real catch was in its own research folder

I had writing rules in a document and kept pasting it into projects, where it worked until the session got long. Turning it into a hook meant learning that a PostToolUse hook exiting 0 talks to nobody. Then the checker flagged its own documentation, which was fair, and found a three-item list in the file arguing against three-item lists.

3 min read

Share
A plate of horizontal bars comparing uniform machine sentence cadence against varied human cadence, with detected tells marked in red.

I have had a writing-rules document for a while. Plain English, no em dashes, no "not just X, it's Y", cut the word if the meaning survives. I pasted it into project after project.

It worked while it sat in context. Then a session got long, or I started a new project and forgot to include it, and the output drifted straight back to the register the rules exist to prevent. A document that has to be remembered is a document that gets ignored.

So I turned it into a plugin. The interesting parts were not the rules.

A hook that exits 0 talks to nobody

The plan was a PostToolUse hook that reads every markdown file Claude writes and reports what it finds. My first version printed findings to stdout and exited 0.

Nothing happened. The findings went into the transcript and the model carried on as though the file were clean, because that is exactly what stdout on a zero exit means.

{
  "matcher": "Write|Edit|MultiEdit|NotebookEdit",
  "hooks": [
    { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/slop-check.sh\"" }
  ]
}

Exit 2 is the difference. On exit 2 the hook's stderr goes back into the conversation as something to act on, so the model reads the findings and fixes them before continuing. The entire enforcement mechanism is one integer.

That framing changed what the checker had to be. A tool that interrupts a task on every finding has to be right, because a linter that cries wolf gets its hook deleted within a day.

Then it flagged everything

The first run over the repo was a wall of red, and almost none of it was prose.

Shell heredocs full of the word "utilize". Code samples with Title Case identifiers. A regex containing the literal string "in order to", flagged as an inflated verb, which it was, in a list of inflated verbs.

Fenced blocks and inline code now get blanked before anything is scanned, with newlines preserved so the reported line numbers stay honest:

def strip_noise(text):
    """Blank out fenced code and inline code so we only lint prose."""
    text = re.sub(r"(?ms)^```.*?^```", lambda m: "\n" * m.group(0).count("\n"), text)
    return re.sub(r"`[^`\n]+`", " ", text)

The research folder was a harder call. It is 29 files, one per pattern, and every one of them quotes bad examples on purpose. Flagging them is technically correct and completely useless. They carry an opt-out marker now, as a whole-file switch rather than per-line, on the theory that a file needing line-by-line exemptions is a file that should be split.

Four patterns that got through the first pass

I wrote a deliberately awful test document with fourteen planted tells and ran the checker at it. It found ten.

Negative parallelism. The clearest AI sentence shape there is, and my pattern only handled "is not". The sample said "This isn't just a tool, it's a paradigm shift" and sailed through, because I had never written the contraction into the alternation.

Transition stacking. I was matching Furthermore and Moreover at the start of a line. In real prose they open a sentence in the middle of a paragraph. Matching at sentence boundaries instead:

TRANSITIONS = re.compile(r"(?:^|(?<=[.!?])\s)\s*(?:Furthermore|Moreover|Additionally|…)\b")

Three-item lists. The research puts the threshold at more than one polished triplet per 200 words, so I required two before flagging. On a 200-word document that means one triplet never fires, which is the exact case where a triplet is most obvious. It scales with word count now.

Inline-header bullets. I matched - **Label:** text and the sample used **Label:** text with no bullet, which looks equally generated.

Second run: fourteen out of fourteen, and silent on a clean sample.

The catch that mattered

A tool that lints prose has to survive its own linting. So I ran it across the research folder before switching the opt-out on.

One finding.

Writing rules: 1 issue(s) in 25-deletion-test.md. Fix before moving on.

  rule of three: break the triplet: use one, two, or four
    L23: Vocabulary, tricolons, and hedging

That file is called 25-deletion-test.md and it exists to explain that prose can pass every word-level filter and still say nothing. The flagged line was mine, written maybe two hours earlier, and it read: "Vocabulary, tricolons, and hedging are how emptiness disguises itself."

A perfect tricolon, in the file arguing against tricolons, written by someone who had spent the afternoon reading about tricolons.

That is the honest argument for the whole thing. I know these patterns better than almost anything else I have studied this year, I was actively looking for them, and I still produced one inside the document warning about them. Knowing a rule and following it are separate skills, and only one of them can be automated.

What it does not do

It reads .md, .mdx, .markdown, .txt and .rst, and nothing else. Code identifiers are never touched.

Seventeen regex rules cover the things a pattern can catch: em dashes, curly quotes, the vocabulary list, dodged copulas, vague attribution, wrap-up endings, leaked model artifacts and the rest. Three statistical rules cover what regex cannot, and burstiness is the one I care about most.

Burstiness is sentence-length standard deviation over the mean. Human prose measures 0.6 to 1.2 and model output measures 0.2 to 0.4. Several analyses now call cadence uniformity the strongest remaining tell, ahead of em dashes, and the reason is straightforward: everybody scrubs em dashes and nobody varies their sentence length on purpose.

What it cannot check is whether a paragraph says anything. The deletion test is in the research and it stays a human job.

Where it stands

Three surfaces. A SessionStart hook that loads 270 words of rules into every session, a PostToolUse hook running the checker, and a skill holding the full ruleset with an index into the 29 pattern files. Plus /deslop to rewrite a file and re-check it.

Python 3, standard library only, no packages to install. Missing Python exits 0 quietly so the session rules keep working.

The README passes its own checker, which felt like the minimum bar for shipping it.

Last updated 19 Aug 2026 · filed under Plugin, tools, ai, python, claude, claude code

Every entry on this project

1 build note, in order

Including the ones where nothing worked. You are on part 1.

Read the series →

FAQ

Frequently asked questions

Why does a Claude Code PostToolUse hook need to exit 2 to be useful?

Because the exit code decides who reads the output. On exit 0 anything the hook prints to stdout lands in the transcript and the model never sees it, so a hook that reports problems that way changes nothing about what happens next. On exit 2 the hook's stderr is fed back into the conversation as something the model has to act on. If you are writing a hook meant to correct behaviour rather than just log it, exit 2 with the message on stderr is the whole mechanism, and getting that wrong produces a hook that appears to run correctly and has no effect at all.

How do you stop a prose linter flagging code samples in markdown?

Blank the code out before scanning rather than trying to make each rule code-aware. Replace fenced blocks with the same number of newlines they contained, so the reported line numbers still match the real file, then replace inline code spans with a space. Doing it once at the top means every rule downstream sees prose only. Without it a shell command containing a banned word, or an identifier written in title case, gets reported as a writing problem, and a few of those are enough for someone to disable the tool.

What is burstiness and why is it a better AI writing signal than em dashes?

Burstiness is the standard deviation of sentence length divided by the mean, so it measures how much sentence length varies rather than how long sentences are. Human prose typically lands between 0.6 and 1.2 while model output sits around 0.2 to 0.4, producing page after page of similar-length sentences. It beats the em dash as a signal for one practical reason: the em dash became famous, so people strip them deliberately, while almost nobody rewrites a draft to vary its rhythm. Only measure it on documents with roughly ten sentences or more, since the statistic is meaningless on short text.

Should documentation that contains bad examples be exempt from its own style checks?

Yes, and a whole-file switch is usually the right granularity. A file whose purpose is cataloguing a pattern consists almost entirely of that pattern, so per-line exemptions turn into noise scattered through the document and stop meaning anything. A single marker in the file, checked before any rule runs, keeps the intent readable to whoever opens it next. If a file genuinely needs exemptions on scattered individual lines, that is usually a signal the file is doing two jobs and should be split rather than annotated.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →