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
Install
npx skills add https://github.com/huytieu/COG-second-brain/tree/main/skills/slop-gate
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install huytieu-cog-second-brain@llmmart
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.
Reviews (0)
No reviews yet.
No comments yet.