Claude Cursor Skill

slop-gate

Deterministic pre-publish scan that refuses AI-slop tells in anything about to be written, published, or sent: files, artifacts, slide titles, table headers, diagram labels, chat messages, commit messages. Use before publishing or sending any deliverable, in CI, or wired as a Cla

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download huytieu-cog-second-brain-skills_slop-gate-4cdb601.zip · 10 KB
Part of huytieu/cog-second-brain — 108 skills

Install

skills CLI npx skills add https://github.com/huytieu/COG-second-brain/tree/main/skills/slop-gate
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install huytieu-cog-second-brain@llmmart
Git git clone https://github.com/huytieu/COG-second-brain.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole huytieu/cog-second-brain collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

slop-gate

no-ai-slop is an editor you invoke. This is a gate that runs whether or not anyone remembers it. It scans outgoing text, refuses on a tell, and returns the hit list so the rewrite is targeted.

Two rules make it usable rather than annoying: hard tells fail on a single hit, and words that are decoration at volume but legitimate once are counted rather than banned. Text inside quotes or backticks is stripped before scanning, so a piece that quotes a tell as an exhibit does not fail on its own examples.

Why a gate and not a rule

A rules file is advisory, and advisory rules fail in a specific, repeatable way: they fire in the step the model thinks of as writing and not in the steps it thinks of as something else. A deck built by an agent can carry clean paragraphs under headline slide titles, because the titles were produced in a layout step where the writing rules never ran. Every surface nobody named explicitly sits on the unchecked side:

slide titles, kickers          table headers, row keys       commit messages
tile and card labels           diagram node and edge labels  code comments
chart titles and legends       button copy, subtitles        memory files
alt text, image captions       Slack, email, issue bodies    PR descriptions

The gate closes that by scanning the text on its way out, whatever produced it.

Run it

python3 scripts/scan.py DRAFT.md              # one file
python3 scripts/scan.py content/**/*.md       # many
cat draft.md | python3 scripts/scan.py -      # stdin

Exit code 0 is clear, 1 is tells found, so it drops into CI or a pre-commit hook unchanged. Every hit prints its label and the surrounding text.

Wire it as a hook (Claude Code, optional)

COG ships no hooks by default. If you want the check enforced rather than remembered, add this to .claude/settings.json yourself. --hook reads the hook payload on stdin and answers with a permission decision.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|mcp__.*slack.*send.*|mcp__atlassian__(create|update)Confluence.*",
        "hooks": [{ "type": "command",
                    "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/skills/slop-gate/scripts/scan.py\" --hook" }]
      }
    ],
    "Stop": [
      { "hooks": [{ "type": "command",
                    "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/skills/slop-gate/scripts/scan.py\" --hook" }] }
    ]
  }
}

The Stop entry scans the agent's own reply, which is where most of these land in a working session. Expect it to fire on you within the hour; that is the point of it.

What fails on one hit

Tell Specimen
Em dash any —
Honesty framing "honestly", "the honest part:", "if I'm being honest"
"not X, it's Y" "It's not about speed, it's about trust"
"X, not Y" (trailing form) "a test, not a permission", "machinery, not memory"
Rhetorical heading "Why this matters", "The bottom line", "What comes next"
"The <Noun>" heading "The Wedge", "The Shot", "The Gamechanger"
Verdict kicker "That is the point.", "That is all a title is for."
Fake-profound closer "let that sink in", "this changes everything"
Throat-clearing "Here's the thing", "It's worth noting", "At the end of the day"
Sycophancy "Great question", "You were right to push back", "That's on me"
Recap ending a final paragraph opening "In conclusion" or "Ultimately"
Emoji heading ## 🚀 Results

What is counted, not banned

robust, seamless, leverage, utilize, delve, empower, streamline, genuinely, quietly, load-bearing, deep dive, clean, plain, simple, elegant, powerful, lightweight, tapestry, game-changer, paradigm shift, cutting-edge

Three or more in one piece fails, on the reading that density means decoration. One is a word choice. The test for any single use: remove the adjective, and if the reader would do nothing differently, it was decoration. "A robust retry" fails that test; "a retry that survives a mail-slot timeout" passes, because it names the behaviour. Keep the word when it is the term of art ("robust statistics").

Exhibits

A piece that must quote a tell (a style guide, a post about slop, a test fixture) carries slop-ok: <reason> anywhere in the file, usually as an HTML comment or a front-matter key. Quoted and backticked spans are already exempt, so the marker is only needed when a tell appears in the author's own voice on purpose.

Never add the marker to get past a gate you disagree with. Rewrite instead, or change the pattern list and say why.

What it cannot see

The gate is regular expressions, so it catches surface and near-surface tells. It is blind to the structural ones: a declared count that the source does not contain, a framework invented to organise two paragraphs, uniform sentence rhythm, a paragraph that restates the previous one. Those need a reader. Use no-ai-slop in detect mode, and prefer a reviewer from a different model family than the author, because a same-family reviewer shares the author's sense of what a finished answer looks like and waves through the same shapes.

For the tics that are yours rather than universal, measure them: voice-baseline.

Comparing models against the gate

scripts/model_compare.py replays your own Claude Code transcripts through scan.py and reports, per model, the share of chat replies and Markdown writes the gate would have refused and the hit rate per rule. It also reports workload per human prompt (tool calls, active minutes, tool error rate, subagent spawns) and punctuation per 10k words. When a hook refuses a reply, the transcript keeps the refused attempt as well as the rewrite, so the counts include what the model wrote before the gate saw it.

python3 scripts/model_compare.py --since 2026-09-15T00:00:00+00:00 --rules

Pick a window in which the rules file and hook did not change. A rule that tightened mid-window makes the earlier model look worse than it was.

Files (cog-second-brain)
  • scripts
    • model_compare.py 13.2 KB
      #!/usr/bin/env python3
      """Compare models across your own Claude Code transcripts (~/.claude/projects/**/*.jsonl).
      
      Workload (tool calls and active minutes per human prompt, tool error rate, delegation),
      writing style per 10k words, and with --rules the share of replies and Markdown writes
      that scan.py would have refused, per rule.
      
      usage: model_compare.py [--models m1,m2,...] [--exclude <session-id-prefix> ...]
                              [--since <ISO>] [--until <ISO>] [--rules]
      Default models: every model with at least 200 responses in the transcripts.
      Run the same window for every model: a rules file or hook that changed mid-window is a confound.
      
      Rules:
      - Lines are deduped by `uuid`; assistant usage is counted once per message.id.
      - A session belongs to a model when that model wrote >= 80% of its assistant messages.
      - Subagent transcripts (path contains /subagents/) are reported separately.
      - Human prompt: user text that is not hook feedback, a system/teammate message, a skill preamble or an interrupt marker.
      - Active time: sum of gaps between consecutive events, dropping gaps over 10 minutes.
      """
      import json, glob, os, re, sys, collections
      from datetime import datetime
      
      MODELS = None
      EXCLUDE = [a for i, a in enumerate(sys.argv) if i > 0 and sys.argv[i - 1] == "--exclude"]
      SINCE = next((datetime.fromisoformat(a) for i, a in enumerate(sys.argv) if i > 0 and sys.argv[i - 1] == "--since"), None)
      UNTIL = next((datetime.fromisoformat(a) for i, a in enumerate(sys.argv) if i > 0 and sys.argv[i - 1] == "--until"), None)
      CORR = re.compile(r"^\s*(no[,. !]|nope|wrong|that'?s not|not what|why did you|you didn'?t|stop |don'?t|undo|revert|i said|still (wrong|broken|not)|it'?s (still )?(wrong|broken)|doesn'?t work|not working)", re.I)
      NOT_HUMAN = ("Stop hook feedback", "<", "Base directory", "[Request interrupted", "Another Claude session", "[SYSTEM")
      STYLE = {
          "em dash": r"—",
          "X, not Y": r"\b[a-z][a-z-]+, not (a |an |the |just |only )?[a-z][a-z -]{1,30}[.;:]",
          "semicolon": r";\s",
          "parenthetical": r"\([^)\n]{3,80}\)",
          "bullet line": r"(?m)^\s*[-*] ",
      }
      
      
      def ts(o):
          try:
              return datetime.fromisoformat(o["timestamp"].replace("Z", "+00:00"))
          except Exception:
              return None
      
      
      def prose(t):
          t = re.sub(r"```.*?```", " ", t, flags=re.S)
          t = re.sub(r"`[^`\n]*`", " ", t)
          return t
      
      
      def user_text(o):
          c = o.get("message", {}).get("content")
          if isinstance(c, str):
              return c
          return " ".join(x.get("text", "") for x in (c or []) if isinstance(x, dict) and x.get("type") == "text")
      
      
      def _models():
          arg = next((a for i, a in enumerate(sys.argv) if i > 0 and sys.argv[i - 1] == "--models"), None)
          if arg:
              return arg.split(",")
          seen = collections.Counter()
          for f in glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")):
              for line in open(f, errors="ignore"):
                  if '"model"' in line and '"assistant"' in line:
                      m = re.search(r'"model":"(claude-[^"]+)"', line)
                      if m:
                          seen[m.group(1)] += 1
          return sorted(m for m, n in seen.items() if n >= 200)
      
      
      MODELS = _models()
      agg = collections.defaultdict(collections.Counter)
      style = collections.defaultdict(collections.Counter)
      
      for f in glob.glob(os.path.expanduser("~/.claude/projects/**/*.jsonl"), recursive=True):
          if any(e in f for e in EXCLUDE):
              continue
          lane = "sub" if "/subagents/" in f else "main"
          seen, rows = set(), []
          for line in open(f, errors="ignore"):
              try:
                  o = json.loads(line)
              except Exception:
                  continue
              if UNTIL and o.get("timestamp") and ts(o) and ts(o) > UNTIL:
                  continue
              if SINCE and o.get("timestamp") and ts(o) and ts(o) < SINCE:
                  continue
              u = o.get("uuid")
              if u and u in seen:
                  continue
              seen.add(u)
              rows.append(o)
          mc = collections.Counter(o["message"].get("model") for o in rows if o.get("type") == "assistant")
          mc = collections.Counter({m: n for m, n in mc.items() if m in MODELS})
          if not mc:
              continue
          model, n = mc.most_common(1)[0]
          if n / sum(mc.values()) < 0.8:
              continue
          k = (model, lane)
          c = agg[k]
          c["sessions"] += 1
          msgs, tool_ids, last_t, bash_ids = {}, {}, None, set()
          for o in rows:
              t = ts(o)
              if t and last_t and (t - last_t).total_seconds() < 600:
                  c["active_s"] += (t - last_t).total_seconds()
              if t:
                  last_t = t
              if o.get("type") == "assistant":
                  msg = o["message"]
                  if msg.get("model") != model:
                      continue
                  mid = msg.get("id") or o.get("uuid")
                  e = msgs.setdefault(mid, {"usage": msg.get("usage") or {}, "tools": 0, "think": False})
                  for b in msg.get("content", []):
                      bt = b.get("type")
                      if bt == "tool_use":
                          e["tools"] += 1
                          name = b.get("name", "")
                          tool_ids[b.get("id")] = name
                          c["tool_calls"] += 1
                          c["tool:" + ("mcp" if name.startswith("mcp__") else name)] += 1
                          if name in ("Write", "Edit") and str((b.get("input") or {}).get("file_path", "")).endswith(".md"):
                              txt = prose((b.get("input") or {}).get("content") or (b.get("input") or {}).get("new_string") or "")
                              style[(model, "files")]["words"] += len(txt.split())
                              for lab, rx in STYLE.items():
                                  style[(model, "files")][lab] += len(re.findall(rx, txt))
                      elif bt == "thinking":
                          e["think"] = True
                      elif bt == "text":
                          txt = b.get("text", "")
                          c["text_blocks"] += 1
                          c["text_words"] += len(txt.split())
                          if lane == "main":
                              p = prose(txt)
                              style[(model, "chat")]["words"] += len(p.split())
                              for lab, rx in STYLE.items():
                                  style[(model, "chat")][lab] += len(re.findall(rx, p))
              elif o.get("type") == "user":
                  cont = o.get("message", {}).get("content")
                  if isinstance(cont, list):
                      for x in cont:
                          if isinstance(x, dict) and x.get("type") == "tool_result" and x.get("tool_use_id") in tool_ids:
                              name = tool_ids[x["tool_use_id"]]
                              if name == "Bash":
                                  c["bash"] += 1
                              if x.get("is_error"):
                                  c["tool_err"] += 1
                                  if name == "Bash":
                                      c["bash_err"] += 1
                  s = user_text(o)
                  if not s or lane != "main":
                      continue
                  if s.startswith("Stop hook feedback") and "Slop gate" in s:
                      c["slop_blocks"] += 1
                  if "[Request interrupted" in s:
                      c["interrupts"] += 1
                  if s.startswith(NOT_HUMAN) or "teammate-message" in s:
                      continue
                  c["human"] += 1
                  if CORR.search(s):
                      c["corrections"] += 1
          for e in msgs.values():
              u = e["usage"]
              c["msgs"] += 1
              c["out_tok"] += u.get("output_tokens", 0)
              c["ctx_tok"] += u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0) + u.get("cache_creation_input_tokens", 0)
              c["think_msgs"] += e["think"]
              if e["tools"]:
                  c["tool_msgs"] += 1
                  c["parallel_msgs"] += e["tools"] > 1
      
      
      def ratio(k, a, b, mul=1.0):
          d = agg[k][b]
          return agg[k][a] / d * mul if d else 0.0
      
      
      ROWS = [
          ("sessions", lambda k: agg[k]["sessions"]),
          ("assistant messages", lambda k: agg[k]["msgs"]),
          ("human prompts", lambda k: agg[k]["human"]),
          ("human prompts / session", lambda k: ratio(k, "human", "sessions")),
          ("tool calls / human prompt", lambda k: ratio(k, "tool_calls", "human")),
          ("active minutes / human prompt", lambda k: ratio(k, "active_s", "human", 1 / 60)),
          ("tool error %", lambda k: ratio(k, "tool_err", "tool_calls", 100)),
          ("bash error %", lambda k: ratio(k, "bash_err", "bash", 100)),
          ("parallel-tool msgs % of tool msgs", lambda k: ratio(k, "parallel_msgs", "tool_msgs", 100)),
          ("context tokens / msg (k)", lambda k: ratio(k, "ctx_tok", "msgs", 1 / 1000)),
          ("output tokens / msg", lambda k: ratio(k, "out_tok", "msgs")),
          ("msgs with thinking %", lambda k: ratio(k, "think_msgs", "msgs", 100)),
          ("Agent spawns / 100 human prompts", lambda k: ratio(k, "tool:Agent", "human", 100)),
          ("words / text block", lambda k: ratio(k, "text_words", "text_blocks")),
          ("Read share of tool calls %", lambda k: ratio(k, "tool:Read", "tool_calls", 100)),
          ("human corrections / 100 prompts", lambda k: ratio(k, "corrections", "human", 100)),
          ("interrupts / 100 prompts", lambda k: ratio(k, "interrupts", "human", 100)),
          ("slop-gate reply blocks / 100 text blocks", lambda k: ratio(k, "slop_blocks", "text_blocks", 100)),
      ]
      
      for lane in ("main", "sub"):
          print(f"\n== {lane} sessions")
          print("%-42s" % "", *["%12s" % m.replace("claude-", "") for m in MODELS])
          for lab, fn in ROWS:
              vals = [fn((m, lane)) for m in MODELS]
              print("%-42s" % lab, *[("%12d" % v) if isinstance(v, int) else ("%12.1f" % v) for v in vals])
      
      print("\n== style, per 10k words (chat = lead replies, files = Markdown written via Write/Edit, code stripped)")
      print("%-42s" % "", *["%12s" % (m.replace("claude-", "")) for m in MODELS])
      for kind in ("chat", "files"):
          print("%-42s" % f"[{kind}] words", *["%12d" % style[(m, kind)]["words"] for m in MODELS])
          for lab in STYLE:
              print("%-42s" % f"[{kind}] {lab}", *["%12.1f" % (style[(m, kind)][lab] * 1e4 / max(style[(m, kind)]["words"], 1)) for m in MODELS])
      
      # ---- rule compliance against scan.py ----
      # A "unit" is one lead chat reply (all text blocks of one response) or one Markdown Write/Edit.
      # Reports, per model, the share of units the gate would have refused and the per-rule hit rate.
      if "--rules" in sys.argv:
          import importlib.util
          spec = importlib.util.spec_from_file_location("sg", os.path.join(os.path.dirname(os.path.abspath(__file__)), "scan.py"))
          sg = importlib.util.module_from_spec(spec); spec.loader.exec_module(sg)
          sg.SOFT = [("filler word", sg.FILLER.pattern, sg.FILLER.flags)]
          VOICE = [
              ("verdict kicker closer (That is the point/lie)", r"\bThat (is|was|'s) (the (point|lie|whole (point|thing))|all [a-z ]{2,30} is for)\.", re.I),
              ("ends on a question to the reader", r"\?\s*$", 0),
              ("'ship' used", r"\bship(s|ped|ping)?\b", re.I),
          ]
          units = collections.defaultdict(list)
          for f in glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")):
              if any(e in f for e in EXCLUDE):
                  continue
              per_msg = collections.defaultdict(str); mmodel = {}
              for line in open(f, errors="ignore"):
                  try:
                      o = json.loads(line)
                  except Exception:
                      continue
                  if o.get("type") != "assistant" or (UNTIL and ts(o) and ts(o) > UNTIL) or (SINCE and ts(o) and ts(o) < SINCE):
                      continue
                  m = o["message"].get("model")
                  if m not in MODELS:
                      continue
                  mid = o["message"].get("id")
                  for b in o["message"].get("content", []):
                      if b.get("type") == "text":
                          per_msg[mid] += b.get("text", "") + "\n"; mmodel[mid] = m
                      elif b.get("type") == "tool_use" and b.get("name") in ("Write", "Edit"):
                          ti = b.get("input") or {}
                          if str(ti.get("file_path", "")).endswith(".md"):
                              t = ti.get("content") or ti.get("new_string") or ""
                              if "slop-ok:" not in t and "no-ai-slop" not in str(ti.get("file_path")):
                                  units[(m, "files")].append(t)
              for mid, t in per_msg.items():
                  units[(mmodel[mid], "chat")].append(t)
          labels = sorted({l for l, _, _ in sg.HARD}) + ["filler words >= 3 (SOFT limit)"] + [l for l, _, _ in VOICE]
          for kind in ("chat", "files"):
              print(f"\n== rule compliance [{kind}]: % of units with >= 1 hit")
              print("%-46s" % "units", *["%12d" % len(units[(m, kind)]) for m in MODELS])
              res = {}
              for m in MODELS:
                  cnt = collections.Counter(); blocked = 0
                  for t in units[(m, kind)]:
                      body = sg.strip_quoted(t); hit_any = False
                      for lab, rx, fl in sg.HARD:
                          if re.search(rx, body, fl):
                              cnt[lab] += 1; hit_any = True
                      soft = sum(len(re.findall(rx, body, fl)) for _, rx, fl in sg.SOFT)
                      if soft >= sg.SOFT_LIMIT:
                          cnt["filler words >= 3 (SOFT limit)"] += 1; hit_any = True
                      for lab, rx, fl in VOICE:
                          if re.search(rx, body, fl):
                              cnt[lab] += 1
                      blocked += hit_any
                  res[m] = (cnt, blocked, max(len(units[(m, kind)]), 1))
              print("%-46s" % "would be refused by the gate %", *["%12.1f" % (100 * res[m][1] / res[m][2]) for m in MODELS])
              for lab in labels:
                  print("%-46s" % lab[:46], *["%12.2f" % (100 * res[m][0][lab] / res[m][2]) for m in MODELS])
      
    • scan.py 8.1 KB
      #!/usr/bin/env python3
      """slop-gate: deterministic scan for AI-slop tells in anything about to be published.
      
      Usage
      -----
          python3 scan.py FILE [FILE...]      # scan files
          cat draft.md | python3 scan.py -    # scan stdin
          python3 scan.py --hook              # Claude Code PreToolUse/Stop hook mode (JSON on stdin)
      
      Exit codes: 0 clean, 1 tells found (so it works in CI and in a pre-commit hook).
      
      Two tiers
      ---------
      HARD tells fail on a single hit. They have no legitimate use in finished prose.
      FILLER words fail at SOFT_LIMIT or more, because one is a word choice and five is
      decoration.
      
      Text inside backticks or quotation marks is stripped before scanning, so a piece
      that quotes a tell as an exhibit does not fail on its own examples.
      """
      import json
      import os
      import re
      import sys
      
      SOFT_LIMIT = 3
      
      HARD = [
          ("em dash", r"—", 0),
          ("honesty framing",
           r"\b(honestly|to be honest|if I'm being honest|the honest (part|truth|answer|reason)|I'll be honest)\b", re.I),
          ("not-X-it's-Y contrast",
           r"\b(it'?s|this is|that'?s|the (question|point|problem|issue)) (is )?not (just |only |about )?[^.\n]{2,60}[.,;] ?(it'?s|but|it is) ", re.I),
          ("not-X-it's-Y contrast", r"\bnot just [^.\n]{2,40}, (it'?s|but) ", re.I),
          ("not-X-it's-Y contrast",
           r"\b(it|this|that) (is|was) not (about |just |only )?[^.,;\n]{2,50}, (it|this|that) (is|was) ", re.I),
          ("X-not-Y contrast", r"\b[a-z][a-z-]+, not (a |an |the |just |only )?[a-z][a-z -]{1,30}[.;:]", 0),
          ("X-not-Y contrast", r"\b(rather than|instead of) [a-z][a-z -]{1,30}[.;]", re.I),
          ("rhetorical heading",
           r"^\s*(#{1,6}|<h[1-6][^>]*>)\s*(why (this|it) matters|the (key|real) (insight|point|opportunity|truth)|what this is not|the bottom line|the deeper point|the uncomfortable truth|here'?s the thing|the takeaway|so what|what comes next|what'?s next|where (it|this|we) go(es)? (from here|next))\b", re.I | re.M),
          ("The <Noun> heading", r"^\s*(#{1,6}|<h[1-6][^>]*>)\s*The [A-Z][a-z]+\s*(</h[1-6]>)?\s*$", re.M),
          ("verdict kicker",
           r"\bThat (is|was|'s) (the (point|lie|whole (point|thing)|part [a-z ]{3,30})|all [a-z ]{2,30} is for)\.", re.I),
          ("fake-profound closer",
           r"\b(let that sink in|this changes everything|what nobody (tells|talks about)|the part (everyone|most people) miss(es)?|and that'?s not nothing)\b", re.I),
          ("throat-clearing",
           r"\b(here'?s the thing|let me be clear|it'?s worth noting|it is worth noting|it'?s important to note|at the end of the day|in today'?s (fast-paced|ever-evolving|digital))\b", re.I),
          ("sycophancy",
           r"\b(great question|you'?re (absolutely )?right to push back|that'?s on me|you were right to)\b", re.I),
          ("summary-recap ending", r"^\s*(in conclusion|ultimately|overall|to sum up|in summary)\b", re.I | re.M),
          ("emoji heading", r"^\s*#{1,6}\s*[\U0001F300-\U0001FAFF☀-➿]", re.M),
      ]
      
      FILLER = re.compile(
          r"\b(delve|delves|delving|leverag(e|es|ing)|utiliz(e|es|ing)|seamless(ly)?|robust(ly)?"
          r"|tapestry|game[- ]changer|paradigm shift|cutting-edge|empower(s|ing)?|streamlin(e|es|ing)"
          r"|load-bearing|unlock the (potential|power)|genuinely|quietly|deep dive|dive in(to)?"
          r"|clean|plain|simple|elegant|powerful|lightweight)\b", re.I)
      
      QUOTED = re.compile(r"`[^`\n]*`|\"[^\"\n]{0,160}\"|“[^”\n]{0,160}”|^```.*?^```", re.M | re.S)
      
      TEXT_EXT = {".md", ".mdx", ".html", ".htm", ".txt", ".svg", ".rst", ""}
      SKIP_PATH = ("no-ai-slop", "slop-gate", "voice-baseline", "/memory/", "/hooks/", "/logs/",
                   "node_modules", "/site/", "/build/", "/dist/")
      
      
      def strip_quoted(text):
          """Quoted, fenced and code-formatted spans are exhibits, not the author's voice."""
          return QUOTED.sub(" ", text)
      
      
      def scan(text, soft_limit=SOFT_LIMIT):
          body = strip_quoted(text)
          hits = []
          for label, rx, flags in HARD:
              for m in re.finditer(rx, body, flags):
                  frag = body[max(0, m.start() - 28):m.end() + 28].replace("\n", " ").strip()
                  hits.append("[%s] ...%s..." % (label, frag))
                  if len(hits) >= 12:
                      return hits
          soft = []
          for m in re.finditer(FILLER, body):
              frag = body[max(0, m.start() - 28):m.end() + 28].replace("\n", " ").strip()
              soft.append("[filler word, %d of %d allowed] ...%s..." % (len(soft) + 1, soft_limit, frag))
          if len(soft) >= soft_limit:
              hits.extend(soft[:6])
          return hits
      
      
      def exempt(path, text):
          if "slop-ok:" in text:
              return True
          if not path:
              return False
          if any(s in path for s in SKIP_PATH):
              return True
          ext = os.path.splitext(path)[1].lower()
          return bool(ext) and ext not in TEXT_EXT
      
      
      # --------------------------------------------------------------------------- hook mode
      def hook():
          """Claude Code hook mode. Reads the hook payload on stdin, writes a decision on stdout."""
          try:
              d = json.loads(sys.stdin.read())
          except Exception:
              print("{}")
              return 0
      
          if d.get("hook_event_name") == "Stop":
              if d.get("stop_hook_active"):
                  print("{}")
                  return 0
              text, last = "", None
              try:
                  with open(d.get("transcript_path", ""), encoding="utf-8") as f:
                      for line in f:
                          try:
                              o = json.loads(line)
                          except Exception:
                              continue
                          if o.get("type") == "assistant":
                              last = o
                  for c in (last or {}).get("message", {}).get("content", []):
                      if c.get("type") == "text":
                          text += c.get("text", "") + "\n"
              except Exception:
                  pass
              hits = scan(text)
              if hits:
                  print(json.dumps({"decision": "block",
                                    "reason": "slop-gate (reply): rewrite without these tells, then stop.\n"
                                              + "\n".join(hits)}))
              else:
                  print("{}")
              return 0
      
          tool = d.get("tool_name", "")
          ti = d.get("tool_input") or {}
          path = ti.get("file_path") or ti.get("path") or ""
          text = ""
          if tool == "Write":
              text = ti.get("content", "") or ""
          elif tool == "Edit":
              text = ti.get("new_string", "") or ""
              if path and os.path.isfile(path):
                  try:
                      if "slop-ok:" in open(path, encoding="utf-8").read():
                          print("{}")
                          return 0
                  except Exception:
                      pass
          else:
              # Any message-sending tool: Slack, Confluence, Jira, Linear, email.
              for key in ("message", "text", "body", "content"):
                  if ti.get(key):
                      text = ti[key] if isinstance(ti[key], str) else json.dumps(ti[key])
                      break
              path = ""
              if not text:
                  print("{}")
                  return 0
      
          if exempt(path, text) or not text:
              print("{}")
              return 0
      
          hits = scan(text)
          if hits:
              reason = ("slop-gate: %s blocked. Rewrite without these tells. "
                        "For a deliberate exhibit add `slop-ok: <reason>` to the file.\n" % tool) + "\n".join(hits)
              print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse",
                                                       "permissionDecision": "deny",
                                                       "permissionDecisionReason": reason}}))
          else:
              print("{}")
          return 0
      
      
      def main():
          args = [a for a in sys.argv[1:] if a != "--"]
          if "--hook" in args:
              return hook()
          if not args or "--help" in args or "-h" in args:
              print(__doc__)
              return 0
          failed = 0
          for path in args:
              text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
              label = "stdin" if path == "-" else path
              if path != "-" and exempt(path, text):
                  print("%s: exempt" % label)
                  continue
              hits = scan(text)
              if hits:
                  failed = 1
                  print("%s: %d tell(s)" % (label, len(hits)))
                  for h in hits:
                      print("  " + h)
              else:
                  print("%s: clear" % label)
          return failed
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 6.4 KB
    ---
    name: slop-gate
    description: Deterministic pre-publish scan that refuses AI-slop tells in anything about to be written, published, or sent: files, artifacts, slide titles, table headers, diagram labels, chat messages, commit messages. Use before publishing or sending any deliverable, in CI, or wired as a Claude Code hook so the check runs without being remembered. The editorial counterpart is no-ai-slop, which rewrites; this one blocks.
    ---
    
    <!-- slop-ok: this skill quotes the tells it blocks -->
    
    # slop-gate
    
    `no-ai-slop` is an editor you invoke. This is a gate that runs whether or not anyone
    remembers it. It scans outgoing text, refuses on a tell, and returns the hit list so
    the rewrite is targeted.
    
    Two rules make it usable rather than annoying: hard tells fail on a single hit, and
    words that are decoration at volume but legitimate once are counted rather than
    banned. Text inside quotes or backticks is stripped before scanning, so a piece that
    quotes a tell as an exhibit does not fail on its own examples.
    
    ## Why a gate and not a rule
    
    A rules file is advisory, and advisory rules fail in a specific, repeatable way: they
    fire in the step the model thinks of as writing and not in the steps it thinks of as
    something else. A deck built by an agent can carry clean paragraphs under headline
    slide titles, because the titles were produced in a layout step where the writing
    rules never ran. Every surface nobody named explicitly sits on the unchecked side:
    
        slide titles, kickers          table headers, row keys       commit messages
        tile and card labels           diagram node and edge labels  code comments
        chart titles and legends       button copy, subtitles        memory files
        alt text, image captions       Slack, email, issue bodies    PR descriptions
    
    The gate closes that by scanning the text on its way out, whatever produced it.
    
    ## Run it
    
    ```bash
    python3 scripts/scan.py DRAFT.md              # one file
    python3 scripts/scan.py content/**/*.md       # many
    cat draft.md | python3 scripts/scan.py -      # stdin
    ```
    
    Exit code 0 is clear, 1 is tells found, so it drops into CI or a pre-commit hook
    unchanged. Every hit prints its label and the surrounding text.
    
    ## Wire it as a hook (Claude Code, optional)
    
    COG ships no hooks by default. If you want the check enforced rather than remembered,
    add this to `.claude/settings.json` yourself. `--hook` reads the hook payload on stdin
    and answers with a permission decision.
    
    ```json
    {
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Write|Edit|mcp__.*slack.*send.*|mcp__atlassian__(create|update)Confluence.*",
            "hooks": [{ "type": "command",
                        "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/skills/slop-gate/scripts/scan.py\" --hook" }]
          }
        ],
        "Stop": [
          { "hooks": [{ "type": "command",
                        "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/skills/slop-gate/scripts/scan.py\" --hook" }] }
        ]
      }
    }
    ```
    
    The `Stop` entry scans the agent's own reply, which is where most of these land in a
    working session. Expect it to fire on you within the hour; that is the point of it.
    
    ## What fails on one hit
    
    | Tell | Specimen |
    |---|---|
    | Em dash | any `—` |
    | Honesty framing | "honestly", "the honest part:", "if I'm being honest" |
    | "not X, it's Y" | "It's not about speed, it's about trust" |
    | "X, not Y" (trailing form) | "a test, not a permission", "machinery, not memory" |
    | Rhetorical heading | "Why this matters", "The bottom line", "What comes next" |
    | "The \<Noun\>" heading | "The Wedge", "The Shot", "The Gamechanger" |
    | Verdict kicker | "That is the point.", "That is all a title is for." |
    | Fake-profound closer | "let that sink in", "this changes everything" |
    | Throat-clearing | "Here's the thing", "It's worth noting", "At the end of the day" |
    | Sycophancy | "Great question", "You were right to push back", "That's on me" |
    | Recap ending | a final paragraph opening "In conclusion" or "Ultimately" |
    | Emoji heading | `## 🚀 Results` |
    
    ## What is counted, not banned
    
    `robust, seamless, leverage, utilize, delve, empower, streamline, genuinely, quietly,
    load-bearing, deep dive, clean, plain, simple, elegant, powerful, lightweight,
    tapestry, game-changer, paradigm shift, cutting-edge`
    
    Three or more in one piece fails, on the reading that density means decoration. One
    is a word choice. The test for any single use: remove the adjective, and if the reader
    would do nothing differently, it was decoration. "A robust retry" fails that test;
    "a retry that survives a mail-slot timeout" passes, because it names the behaviour.
    Keep the word when it is the term of art ("robust statistics").
    
    ## Exhibits
    
    A piece that must quote a tell (a style guide, a post about slop, a test fixture)
    carries `slop-ok: <reason>` anywhere in the file, usually as an HTML comment or a
    front-matter key. Quoted and backticked spans are already exempt, so the marker is
    only needed when a tell appears in the author's own voice on purpose.
    
    Never add the marker to get past a gate you disagree with. Rewrite instead, or change
    the pattern list and say why.
    
    ## What it cannot see
    
    The gate is regular expressions, so it catches surface and near-surface tells. It is
    blind to the structural ones: a declared count that the source does not contain, a
    framework invented to organise two paragraphs, uniform sentence rhythm, a paragraph
    that restates the previous one. Those need a reader. Use `no-ai-slop` in detect mode,
    and prefer a reviewer from a different model family than the author, because a
    same-family reviewer shares the author's sense of what a finished answer looks like
    and waves through the same shapes.
    
    For the tics that are yours rather than universal, measure them: `voice-baseline`.
    
    ## Comparing models against the gate
    
    `scripts/model_compare.py` replays your own Claude Code transcripts through `scan.py` and reports, per model, the share of chat replies and Markdown writes the gate would have refused and the hit rate per rule. It also reports workload per human prompt (tool calls, active minutes, tool error rate, subagent spawns) and punctuation per 10k words. When a hook refuses a reply, the transcript keeps the refused attempt as well as the rewrite, so the counts include what the model wrote before the gate saw it.
    
    ```bash
    python3 scripts/model_compare.py --since 2026-09-15T00:00:00+00:00 --rules
    ```
    
    Pick a window in which the rules file and hook did not change. A rule that tightened mid-window makes the earlier model look worse than it was.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related