Files
agent-skills/hooks/comment-lint.py
T
naps62 a1f95a1161 feat: one writing contract for Claude Code and Codex
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.
2026-08-01 13:06:38 +00:00

181 lines
6.5 KiB
Python
Executable File

#!/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/writing.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 = 3 # contract budget; past this you are teaching, not warning
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/writing.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()