#!/usr/bin/env python3 """PreToolUse gate for public comms: gh issue/pr create|edit|comment|review. Lints the body against the "Public comms" contract in ~/.claude/public-comms.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 MAX_ABOVE_FOLD_WORDS = 300 MAX_BOLD_SPANS = 4 BLUF_MIN_WORDS = 80 # short comments are exempt from the BLUF-line rule BLUF_WINDOW = 200 # chars from the top the ask must appear in ASK_RE = re.compile(r"\b(ask|asking|decision needed|proposal|approve|proposing)\b", re.I) 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 (limit {MAX_ABOVE_FOLD_WORDS}). " "Move file:line cites, version tables and verification detail into " "
Evidence or a follow-up comment." ) if words >= BLUF_MIN_WORDS and not ASK_RE.search(body[:BLUF_WINDOW]): problems.append( f"No ask in the first {BLUF_WINDOW} characters. Line 1 must state the " "decision or action wanted, and from whom (BLUF)." ) 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." ) # three or more prose-enumerated options with no table if re.search(r"^#{1,6}\s*\**\s*(option\s+)?[abc][.)]\s", fold, re.M | re.I): if "|" not in fold: problems.append( "Options enumerated as prose sections. Use a table: " "Option | What we do | Cost | What we get." ) 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 public-comms contract (~/.claude/public-comms.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()