Files
agent-skills/hooks/comms-lint.py
T
naps62 08195c1cd4 feat(hooks): track writing contracts + linters in repo
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
2026-07-27 14:34:29 +00:00

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()