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
This commit is contained in:
naps62
2026-07-27 14:34:29 +00:00
parent 715420d0a9
commit 08195c1cd4
8 changed files with 481 additions and 3 deletions
+40
View File
@@ -0,0 +1,40 @@
# hooks
Claude Code hooks. Claude-only — Codex ignores. `bin/link.sh` / `nix/home.nix` symlink these into `~/.claude/hooks/`; **wiring is manual**, see below.
| hook | event | what |
|------|-------|------|
| `comms-lint.py` | `PreToolUse` / `Bash` | Gates `gh issue\|pr create\|edit\|comment\|review`. Lints body against `claude-md/public-comms.md` (BLUF, ≤300 words above fold, ≤4 bold spans, no essay headings, table for 3+ options). Exit 2 blocks, stderr becomes feedback. |
| `comment-lint.py` | `PostToolUse` / `Write\|Edit\|MultiEdit` | Lints newly-added comment lines in code files against `claude-md/code-comments.md`. Exit 2 = revise nudge (edit already applied). Long-comment-run finding is advisory, delivered via `additionalContext`. |
Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`.
## Wiring
`~/.claude/settings.json` is machine-local (MCP servers, statusline, per-box hooks), so this repo does not own it. Merge into `.hooks`:
```json
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/comms-lint.py" }] }
],
"PostToolUse": [
{ "matcher": "Write|Edit|MultiEdit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/comment-lint.py" }] }
]
}
}
```
Contract prose lives in `claude-md/`, linked to `~/.claude/<name>.md` and imported from `~/.claude/CLAUDE.md` via `@public-comms.md` / `@code-comments.md`. Both linters cite those paths in their block message — moving a fragment means updating the linter string too.
## Testing a hook
Feed it the payload shape Claude Code sends:
```sh
echo '{"tool_name":"Bash","tool_input":{"command":"gh pr comment 1 --body \"short\""}}' \
| ~/.claude/hooks/comms-lint.py; echo "exit=$?"
```
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""PostToolUse nudge for code comments: Write / Edit / MultiEdit.
Lints ONLY the newly-added text against the "Code comments" contract in
~/.claude/code-comments.md, so legacy files are not re-flagged on every touch.
Exit 0 = silent. Exit 2 = stderr goes back to Claude as feedback; the edit is
already applied, so this is a revise-it nudge, not a block.
Fails open on anything it cannot parse.
"""
import json
import os
import re
import sys
MAX_COMMENT_RUN = 5 # past this you are teaching an agent that can already read the repo
CODE_EXT = {
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".sol",
".sh", ".bash", ".zsh", ".c", ".h", ".cc", ".cpp", ".hpp", ".java", ".kt",
".rb", ".lua", ".zig", ".nix", ".swift", ".php", ".cs", ".scala", ".ex",
}
COMMENT_RE = re.compile(r"^\s*(//+|#+|/\*+|\*+/?|--|;;?)\s?(.*)$")
# Only match syntax the language actually has. In a hash-comment language a `//` or `*` line is
# almost always a string literal holding code for another language, and linting it flags text the
# script is deleting rather than text it is adding.
HASH_ONLY_EXT = {".py", ".sh", ".bash", ".zsh", ".rb", ".ex", ".nix"}
HASH_RE = re.compile(r"^\s*(#+)\s?(.*)$")
# Design rationale, alternatives, history: capped at 1-2 lines, belongs in docs.
DOC_IN_SOURCE = [
(r"rejected alternative", "rejected alternatives belong in docs/ or an ADR"),
(r"alternatives?\s+(considered|rejected|from)", "alternatives belong in docs/ or an ADR"),
(r"\bit used to\b", "history belongs in the commit message"),
(r"\bused to (say|be|read|return|live|call)\b", "history belongs in the commit message"),
(r"\b(this|it|that) was\b.{0,50}\buntil\b", "history belongs in the commit message"),
(r"\bpreviously[, ]+(this|it|we|the)\b", "history belongs in the commit message"),
(r"\b(originally|historically)\b", "history belongs in the commit message"),
(r"\bthe reason (we|this module|this file|it is here|this exists)\b",
"rationale belongs in docs/ or an ADR"),
(r"\bwe (chose|picked|went with|settled on)\b", "rationale belongs in docs/ or an ADR"),
(r"\bwhy (it|this) (exists|is here|lives here)\b", "rationale belongs in docs/ or an ADR"),
(r"\brather than (standing apart|widening)\b", "rationale belongs in docs/ or an ADR"),
]
# A line that points at the doc is the fix the rule asks for, not a violation of it.
CITES_DOC = re.compile(r"docs?/|\.md\b|\bADR[- ]?\d|\bsee `", re.I)
# Deliberately narrow. `genuinely` was tried here and removed: "genuinely liquidatable",
# "a genuinely DIVERGED node" and "genuinely 1:1" all mean actually-not-apparently, so flagging it
# only rewrites correct prose.
SUPERLATIVE = [
r"\bsingle most\b",
r"\bmost consequential\b",
r"\bthe (whole|entire) point\b",
r"\bworth (money|noting)\b",
]
def fail_open(msg=""):
if msg and os.environ.get("COMMENT_LINT_DEBUG"):
print(f"comment-lint: {msg}", file=sys.stderr)
sys.exit(0)
def added_text(tool_input):
"""Text this edit introduced, or None if there is nothing to lint."""
if "content" in tool_input:
return tool_input["content"]
if "new_string" in tool_input:
return tool_input["new_string"]
edits = tool_input.get("edits")
if isinstance(edits, list):
parts = [e.get("new_string", "") for e in edits if isinstance(e, dict)]
return "\n".join(parts) if parts else None
return None
def comment_lines(text, ext=""):
"""[(index, body)] for lines that are wholly a comment."""
pattern = HASH_RE if ext in HASH_ONLY_EXT else COMMENT_RE
out = []
for i, line in enumerate(text.splitlines()):
m = pattern.match(line)
if m and line.strip() not in ("*/", "/*"):
out.append((i, m.group(2)))
return out
def longest_run(indices):
best = run = 0
prev = None
for i in indices:
run = run + 1 if prev is not None and i == prev + 1 else 1
best = max(best, run)
prev = i
return best
def lint(text, ext=""):
"""(problems, notes). Problems demand a revise; notes are advisory only."""
lines = comment_lines(text, ext)
if not lines:
return [], []
problems = []
notes = []
run = longest_run([i for i, _ in lines])
if run > MAX_COMMENT_RUN:
notes.append(
f"{run}-line comment block just written. Fine IF it explains code a reader "
"would otherwise misread (subtle math, ordering, encoding, a line that looks "
"wrong). Not fine if it argues for a design — that is a doc."
)
blob = "\n".join(body for _, body in lines)
lowered = blob.lower()
prose = "\n".join(body for _, body in lines if not CITES_DOC.search(body))
seen = set()
for pattern, advice in DOC_IN_SOURCE:
m = re.search(pattern, prose.lower())
if m and advice not in seen:
seen.add(advice)
problems.append(f'"{m.group(0)}" in a comment — {advice}. Cite the path instead.')
hits = [m.group(0) for p in SUPERLATIVE for m in [re.search(p, lowered)] if m]
if hits:
problems.append(f"Superlatives in comments: {', '.join(sorted(set(hits)))}. Cut them.")
if "**" in blob:
problems.append("Bold inside a comment. No emphasis in comments.")
return problems, notes
def main():
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
fail_open("unparseable payload")
if payload.get("tool_name") not in ("Write", "Edit", "MultiEdit"):
fail_open()
tool_input = payload.get("tool_input", {})
path = tool_input.get("file_path", "")
if os.path.splitext(path)[1] not in CODE_EXT:
fail_open(f"not a code file: {path}")
text = added_text(tool_input)
if not text:
fail_open("no added text")
problems, notes = lint(text, os.path.splitext(path)[1])
name = os.path.basename(path)
if problems:
lines = [f"Comment contract (~/.claude/code-comments.md) — {name}:", ""]
lines += [f" - {p}" for p in problems + notes]
lines += ["", "Trim what you just wrote, or say why it stays."]
print("\n".join(lines), file=sys.stderr)
sys.exit(2)
if notes:
# Advisory only: a long comment can be correct, so this reaches context
# without forcing a revise cycle.
print(json.dumps({
"suppressOutput": True,
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": f"Comment check ({name}): " + " ".join(notes),
},
}))
sys.exit(0)
if __name__ == "__main__":
main()
+155
View File
@@ -0,0 +1,155 @@
#!/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()