a1f95a1161
Replaces public-comms.md and code-comments.md (749 words, ~25 rules, almost all prohibitions, no examples) with a single writing.md that leads with worked examples. The rule is selection, not compression: keep output short by cutting whole ideas that don't change what the reader does next, then write what survives as plain sentences. Not by dropping articles or writing fragments, which Anthropic's Fable 5 guide calls out as the wrong lever. Prompt style leaks into output style, so the file is written in the voice it asks for. Drops the BLUF ask-line rule entirely: everyone on a PR already knows who reviews and who merges, and read literally it produced openers like "Ask: reviewers please merge". Links the contract to ~/.agents/AGENTS.md, which Codex had nothing in at all, and to the nix module for the NixOS machine.
188 lines
6.0 KiB
Python
Executable File
188 lines
6.0 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 "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("<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 (target {TARGET_WORDS}, hard cap "
|
|
f"{MAX_ABOVE_FOLD_WORDS}). Move logs, file:line cites, version tables "
|
|
"and verification runs into <details>."
|
|
)
|
|
|
|
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()
|