Skip to content
Abdulkader Safi

AI Writing Rules

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

19 August 2026 Updated 19 August 2026 3 min read
A plate of horizontal bars comparing uniform machine sentence cadence against varied human cadence, with detected tells marked in red.

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.

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.