#!/usr/bin/env python3 """PreToolUse gate for public comms: gh issue/pr create|edit|comment|review. Lints the body against the "Writing" contract in ~/.claude/writing.md. Exit 0 = allow. Exit 2 = block, stderr goes back to Claude as feedback. Fails open on anything it cannot parse. """ import json import os import re import shlex import sys TARGET_WORDS = 150 MAX_ABOVE_FOLD_WORDS = 300 MAX_BOLD_SPANS = 4 # Everyone on the PR already knows who reviews and who merges, so any request # for one states the obvious. A literal reading of the old BLUF rule produced # openers like "Ask: reviewers please merge" on PRs the author merges themselves. ASKS_FOR_MERGE = [ (r"^\s*#*\s*\**\s*(ask|decision needed)\b\s*[:\-]", "labels the ask"), (r"\breviewers?,?\s+please\b", 'addresses "reviewers please"'), (r"\bplease\s+(review|merge|approve|take a look)\b", "asks for review/merge/approval"), (r"\brequesting\s+(review|approval)\b", "requests review/approval"), (r"\bdecision needed from\b", "addresses a decision to reviewers"), (r"\b(ready|safe|ok) to merge\b", "asks for a merge"), (r"\bcan (someone|you) (merge|review|approve)\b", "asks for review/merge"), ] # Anchored to the very start of the body, not every line: "Merge, then run X" # reads as an ask, while "Merge conflicts were resolved by ..." mid-body does not. OPENS_WITH_MERGE = re.compile(r"\A\s*#*\s*\**\s*merge\b", re.I) # Writerly diction. Each maps to the plain word the contract asks for. JARGON = [ (r"\bload[- ]bearing\b", "matters / required"), (r"\bthe archaeology\b", "the old notes"), (r"\babsorbs?\b", "replaces / includes"), (r"\bblast radius\b", "what else breaks"), (r"\bsurface area\b", "scope"), (r"\bin anger\b", "in production"), (r"\bnon-trivial\b", "say how big"), (r"\bfirst[- ]class citizen\b", "supported"), (r"\bsource of truth\b", "where it is defined"), (r"\bcognitive (load|overhead)\b", "harder to read"), ] BANNED_HEADINGS = [ "what is actually the case", "what this is not for", "the third option", "the problem with", "background and context", "some thoughts", ] CMD_RE = re.compile(r"\bgh\s+(issue|pr)\s+(create|edit|comment|review)\b") def fail_open(msg=""): if msg and os.environ.get("COMMS_LINT_DEBUG"): print(f"comms-lint: {msg}", file=sys.stderr) sys.exit(0) def extract_body(command): """Return body text, or None if it cannot be determined.""" try: tokens = shlex.split(command) except ValueError: return None i = 0 while i < len(tokens): tok = tokens[i] if tok in ("--body-file", "-F", "--body-file="): if i + 1 >= len(tokens): return None path = tokens[i + 1] if path == "-": return None # piped from stdin, unavailable here try: with open(os.path.expanduser(path), encoding="utf-8") as fh: return fh.read() except OSError: return None if tok.startswith("--body-file="): path = tok.split("=", 1)[1] try: with open(os.path.expanduser(path), encoding="utf-8") as fh: return fh.read() except OSError: return None if tok in ("--body", "-b"): if i + 1 >= len(tokens): return None return tokens[i + 1] if tok.startswith("--body="): return tok.split("=", 1)[1] i += 1 return None # no body flag: editor-driven, nothing to lint def above_fold(body): idx = body.lower().find(" MAX_ABOVE_FOLD_WORDS: problems.append( f"{words} words above the fold (target {TARGET_WORDS}, hard cap " f"{MAX_ABOVE_FOLD_WORDS}). Move logs, file:line cites, version tables " "and verification runs into
." ) checks = [(p, w) for p, w in ASKS_FOR_MERGE] if OPENS_WITH_MERGE.search(fold): checks.insert(0, (r"\A", "opens by asking for a merge")) for pattern, what in checks: if re.search(pattern, fold, re.I | re.M): problems.append( f"Body {what}. Everyone already knows who reviews and who merges. " "Open with what changed; if a real choice exists, state it as a " "fact about the change, not a request." ) break for pattern, plain in JARGON: m = re.search(pattern, fold, re.I) if m: problems.append(f'"{m.group(0)}" — use the plain word: {plain}.') bold = body.count("**") // 2 if bold > MAX_BOLD_SPANS: problems.append( f"{bold} bold spans (limit {MAX_BOLD_SPANS}). Emphasis inflation — " "when everything is bold nothing is." ) lowered = fold.lower() for heading in BANNED_HEADINGS: if re.search(r"^#{1,6}\s*\**\s*" + re.escape(heading), lowered, re.M): problems.append( f'Essay heading "{heading}". Headings name their contents: ' "Problem / Options / Work / Evidence." ) return problems def main(): try: payload = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): fail_open("unparseable payload") if payload.get("tool_name") != "Bash": fail_open() command = payload.get("tool_input", {}).get("command", "") if not CMD_RE.search(command): fail_open() body = extract_body(command) if body is None: fail_open("no lintable body") problems = lint(body) if not problems: sys.exit(0) lines = ["Blocked by writing contract (~/.claude/writing.md):", ""] lines += [f" - {p}" for p in problems] lines += ["", "Rewrite the body and retry. Do not bypass this check."] print("\n".join(lines), file=sys.stderr) sys.exit(2) if __name__ == "__main__": main()