08195c1cd4
comms-lint.py and comment-lint.py lived only in ~/.claude, which is not a git repo. Contract prose lived in ~/.claude/CLAUDE.md. Neither survived a machine rebuild. Prose moves to claude-md/ fragments, imported via @name.md. Linters move to hooks/. link.sh and home.nix distribute both. settings.json wiring stays manual — it holds machine-local MCP/statusline config this repo must not own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaGR6ERGCfzWTtuv2rebGe
156 lines
4.6 KiB
Python
Executable File
156 lines
4.6 KiB
Python
Executable File
#!/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("<details")
|
|
return body if idx == -1 else body[:idx]
|
|
|
|
|
|
def lint(body):
|
|
problems = []
|
|
fold = above_fold(body)
|
|
words = len(fold.split())
|
|
|
|
if words > 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 "
|
|
"<details><summary>Evidence</summary> 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()
|