Skip to content

Instantly share code, notes, and snippets.

@mariusv
Last active September 22, 2026 19:49
Show Gist options
  • Select an option

  • Save mariusv/689d1b30c57a49a6412703e35ad21096 to your computer and use it in GitHub Desktop.

Select an option

Save mariusv/689d1b30c57a49a6412703e35ad21096 to your computer and use it in GitHub Desktop.
CC hook for comments

Comment cop

comment-cop.py enforces a why-only comment policy for Claude Code and Codex.

It blocks newly added change-history comments, obvious code narration, comment blocks longer than two lines, and comment-heavy changes. Directives, short TODOs, generated-code notices, and commented configuration are ignored. A per-turn Git baseline keeps the final audit from blaming an agent for pre-existing worktree changes.

Install the script at ~/.claude/hooks/comment-cop.py, merge claude-settings.json into ~/.claude/settings.json, and install codex-hooks.json as ~/.codex/hooks.json. Add AGENTS.md to ~/.codex/AGENTS.md so Codex sees the preference before it edits.

Run python3 ~/.claude/hooks/comment-cop.py --self-test to test the checker. Codex requires new or changed command hooks to be reviewed with /hooks before they run.

Personal coding preferences

  • Add comments only for non-obvious decisions, constraints, safety invariants, or workarounds that the code cannot express.
  • Keep comments to one or two lines. Do not narrate the code, record edit history, or surround straightforward configuration with prose.
{
"description": "Enforce Marius's why-only code comment policy.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/comment-cop.py\"",
"timeout": 10,
"statusMessage": "Recording comment baseline"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/comment-cop.py\"",
"timeout": 10,
"statusMessage": "Checking comments"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/comment-cop.py\"",
"timeout": 10,
"additionalContextLimit": 500
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/comment-cop.py\"",
"timeout": 10,
"statusMessage": "Auditing added comments"
}
]
}
]
}
}
#!/usr/bin/env python3
"""Enforce a why-only policy for comments added by coding agents."""
from __future__ import annotations
import collections
import hashlib
import itertools
import json
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
SLASH = {
".go", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".java", ".rs",
".c", ".h", ".hpp", ".cpp", ".cc", ".proto", ".kt", ".swift", ".scala", ".dart",
}
HASH = {
".py", ".sh", ".bash", ".zsh", ".yaml", ".yml", ".tf", ".tfvars",
".toml", ".rb", ".mk", ".pl", ".ps1", ".conf", ".cfg",
}
DASH = {".sql", ".lua", ".hs"}
HASH_NAMES = {"Makefile", "Dockerfile", "Justfile", "Vagrantfile"}
POLICY = (
"Comments explain only non-obvious decisions, constraints, safety invariants, or workarounds. "
"Do not narrate what code does or record change history; git holds history. Keep comments to "
"one or two tight lines unless a longer explanation is genuinely necessary."
)
CHANGE_HISTORY = re.compile(
r"\b(previously|used to|no longer (?:needed|used|required|exists|applies)|"
r"we (?:removed|added|changed|moved|renamed|dropped|switched)|"
r"removed (?:in favor|because)|renamed (?:from|to)|replaced by|"
r"this (?:was|used to)|has been (?:removed|moved|renamed|replaced))\b",
re.I,
)
NARRATION = re.compile(
r"^(?:(?:this|the|these|those)\s+.{0,60}\s+"
r"(?:is|are|does|do|will|contains|returns|sets|gets|creates|updates|deletes|"
r"loops|iterates|checks|verifies|loads|stores|writes|reads|calls|uses|handles|"
r"initializes|converts|parses|builds|starts|stops|opens|closes)|"
r"(?:set|get|create|update|delete|return|loop|iterate|check|verify|load|save|"
r"store|call|add|remove|initialize|convert|parse|build|handle|run|start|stop|"
r"open|close|write|read)\b)",
re.I,
)
WHY = re.compile(
r"\b(because|otherwise|unless|until|to (?:avoid|prevent|preserve|ensure|keep)|"
r"must|cannot|can't|required|invariant|race|deadlock|data loss|durab(?:le|ility)|"
r"compatibility|security|atomic(?:ity|ally)?|workaround)\b",
re.I,
)
EXEMPT = re.compile(
r"^(?:!|todo\b|fixme\b|hack\b|xxx\b|spdx-|copyright\b|license\b|"
r"code generated\b|go:|nolint\b|nosec\b|noqa\b|type:\s*ignore\b|"
r"eslint\b|prettier\b|shellcheck\b|yamllint\b|hadolint\b|pragma\b|"
r"fmt:|line\b|sourceMappingURL=|region\b|endregion\b|cspell\b)",
re.I,
)
COMMENTED_CODE = re.compile(r"^\s*(?:\S+\s*[:=]|-\s|[{}\[\]])")
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
@dataclass(frozen=True)
class AddedLine:
path: str
number: int
text: str
group: int
@dataclass(frozen=True)
class Finding:
path: str
number: int
rule: str
text: str
def fingerprint(self) -> str:
normalized = " ".join(self.text.split()).lower()
return f"{self.path}\0{self.rule}\0{normalized}"
def marker_for(path: str) -> str | None:
base = os.path.basename(path)
if base in HASH_NAMES:
return "#"
ext = os.path.splitext(base)[1].lower()
if ext in SLASH:
return "//"
if ext in HASH:
return "#"
if ext in DASH:
return "--"
return None
def comment_body(line: str, marker: str) -> str | None:
stripped = line.strip()
if stripped.startswith(marker):
return stripped[len(marker):].strip()
if marker == "//" and (stripped.startswith("/*") or stripped.startswith("*")):
return stripped.lstrip("/* ").rstrip("*/ ").strip()
return None
def inline_body(line: str, marker: str) -> str | None:
token = " " + marker
pos = line.find(token)
if pos < 0:
return None
return line[pos + len(token):].strip()
def analyze(lines: list[AddedLine]) -> list[Finding]:
findings: list[Finding] = []
for path, path_lines_iter in _group_by(lines, lambda line: line.path):
marker = marker_for(path)
if not marker:
continue
path_lines = list(path_lines_iter)
for _, group_iter in _group_by(path_lines, lambda line: line.group):
group_lines = list(group_iter)
comment_count = 0
code_count = 0
runs: list[list[AddedLine]] = []
run: list[AddedLine] = []
for line in group_lines:
body = comment_body(line.text, marker)
if body is None:
if run:
runs.append(run)
run = []
if line.text.strip():
code_count += 1
inline = inline_body(line.text, marker)
if inline and CHANGE_HISTORY.search(inline):
findings.append(Finding(line.path, line.number, "change-history", inline))
continue
if not body or EXEMPT.search(body) or COMMENTED_CODE.match(body):
if run:
runs.append(run)
run = []
continue
comment_count += 1
if run and line.number != run[-1].number + 1:
runs.append(run)
run = []
run.append(line)
findings.extend(_prose_findings(line, body))
if run:
runs.append(run)
for comment_run in runs:
if len(comment_run) >= 3:
findings.append(Finding(
path,
comment_run[0].number,
"long-block",
f"{len(comment_run)} consecutive comment lines",
))
if comment_count >= 3 and comment_count > code_count:
first = next((line for line in group_lines if comment_body(line.text, marker) is not None), group_lines[0])
findings.append(Finding(
path,
first.number,
"comment-density",
f"{comment_count} comment lines for {code_count} code lines in one change",
))
return _deduplicate(findings)
def _prose_findings(line: AddedLine, body: str) -> list[Finding]:
if CHANGE_HISTORY.search(body):
return [Finding(line.path, line.number, "change-history", body)]
if NARRATION.search(body) and not WHY.search(body):
return [Finding(line.path, line.number, "code-narration", body)]
return []
def _group_by(items, key):
return itertools.groupby(items, key)
def _deduplicate(findings: list[Finding]) -> list[Finding]:
seen: set[tuple[str, int, str]] = set()
result: list[Finding] = []
for finding in findings:
key = (finding.path, finding.number, finding.rule)
if key not in seen:
seen.add(key)
result.append(finding)
return result
def parse_apply_patch(patch: str) -> list[AddedLine]:
lines: list[AddedLine] = []
path = ""
number = 1
group = 0
for raw in patch.splitlines():
if raw.startswith("*** Update File: ") or raw.startswith("*** Add File: "):
path = raw.split(": ", 1)[1]
number = 1
group += 1
continue
match = HUNK.match(raw)
if match:
number = int(match.group(1))
group += 1
continue
if not path:
continue
if raw.startswith("+") and not raw.startswith("+++"):
lines.append(AddedLine(path, number, raw[1:], group))
number += 1
elif raw.startswith(" "):
number += 1
elif raw.startswith("-"):
continue
return lines
def parse_git_diff(diff: str) -> list[AddedLine]:
lines: list[AddedLine] = []
path = ""
number = 1
group = 0
for raw in diff.splitlines():
if raw.startswith("+++ "):
path = raw[4:]
if path == "/dev/null":
path = ""
continue
match = HUNK.match(raw)
if match:
number = int(match.group(1))
group += 1
continue
if not path:
continue
if raw.startswith("+") and not raw.startswith("+++"):
lines.append(AddedLine(path, number, raw[1:], group))
number += 1
elif raw.startswith(" "):
number += 1
elif raw.startswith("-"):
continue
return lines
def git(cwd: str, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-c", "core.quotePath=false", *args],
cwd=cwd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
def worktree_findings(cwd: str) -> list[Finding]:
root_result = git(cwd, "rev-parse", "--show-toplevel")
if root_result.returncode:
return []
root = root_result.stdout.strip()
diff_result = git(root, "diff", "--no-ext-diff", "--unified=0", "--no-color", "--no-prefix", "HEAD", "--")
lines = parse_git_diff(diff_result.stdout) if diff_result.returncode == 0 else []
untracked = git(root, "ls-files", "--others", "--exclude-standard", "-z")
if untracked.returncode == 0:
group = max((line.group for line in lines), default=0)
for relative in filter(None, untracked.stdout.split("\0")):
if marker_for(relative) is None:
continue
path = Path(root, relative)
try:
if path.stat().st_size > 2_000_000:
continue
content = path.read_text(errors="replace").splitlines()
except OSError:
continue
group += 1
lines.extend(AddedLine(relative, number, text, group) for number, text in enumerate(content, 1))
return analyze(lines)
def state_path(session_id: str) -> Path:
state_dir = Path(tempfile.gettempdir(), f"comment-cop-{os.getuid()}")
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
digest = hashlib.sha256(session_id.encode()).hexdigest()
return state_dir / f"{digest}.json"
def record_baseline(data: dict) -> None:
session_id = str(data.get("session_id") or "")
cwd = str(data.get("cwd") or os.getcwd())
if not session_id:
return
counts = collections.Counter(finding.fingerprint() for finding in worktree_findings(cwd))
state_path(session_id).write_text(json.dumps(counts, sort_keys=True))
def new_findings(data: dict) -> list[Finding]:
session_id = str(data.get("session_id") or "")
cwd = str(data.get("cwd") or os.getcwd())
if not session_id:
return []
path = state_path(session_id)
if not path.exists():
return []
try:
baseline = collections.Counter(json.loads(path.read_text()))
except (OSError, ValueError):
return []
current = worktree_findings(cwd)
remaining = baseline.copy()
result: list[Finding] = []
for finding in current:
fingerprint = finding.fingerprint()
if remaining[fingerprint]:
remaining[fingerprint] -= 1
else:
result.append(finding)
return result
def direct_edit_findings(data: dict) -> list[Finding]:
tool_input = data.get("tool_input") or {}
tool_name = data.get("tool_name") or ""
if tool_name == "apply_patch":
return analyze(parse_apply_patch(str(tool_input.get("command") or "")))
path = str(tool_input.get("file_path") or "")
marker = marker_for(path)
if not marker:
return []
if tool_name == "Edit":
text = str(tool_input.get("new_string") or "")
old = {line.strip() for line in str(tool_input.get("old_string") or "").splitlines()}
else:
text = str(tool_input.get("content") or "")
old = set()
try:
old = {line.strip() for line in Path(path).read_text(errors="replace").splitlines()}
except OSError:
pass
lines = [
AddedLine(path, number, line, 1)
for number, line in enumerate(text.splitlines(), 1)
if line.strip() not in old
]
return analyze(lines)
def format_findings(findings: list[Finding], limit: int = 8) -> str:
shown = findings[:limit]
details = "; ".join(
f"{finding.path}:{finding.number} [{finding.rule}] {finding.text!r}"
for finding in shown
)
if len(findings) > limit:
details += f"; and {len(findings) - limit} more"
return f"comment-cop: {details}. {POLICY} Remove or tighten these comments, then retry."
def policy_context() -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Code comment policy (hook-enforced): " + POLICY,
}
}
def run_event(data: dict) -> dict | None:
event = data.get("hook_event_name")
if event == "SessionStart":
record_baseline(data)
return None
if event == "UserPromptSubmit":
record_baseline(data)
return policy_context()
if event == "PreToolUse":
findings = direct_edit_findings(data)
if findings:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": format_findings(findings),
}
}
return None
if event == "Stop":
if data.get("stop_hook_active"):
return {}
findings = new_findings(data)
if findings:
return {"decision": "block", "reason": format_findings(findings)}
return {}
return None
def self_test() -> None:
cases = [
(
"long yaml",
["# The migrate role reports like any other.", "# It inherits no envFrom.", "# The SDK falls back.", "envFrom:"],
True,
),
(
"four-line go",
["// The restored base may contain a later row.", "// Never overwrite it.", "// The conflict handler covers constraints.", "// The final comparison is authoritative.", "insert()"],
True,
),
("narration", ["// Loop through all rows.", "for rows.Next() {}"], True),
("history", ["// We removed the fallback because it was unsafe.", "run()"], True),
("why", ["// Keep the predecessor until the handoff is durable.", "drop()"], False),
("directive", ["//nolint:gosec", "run()"], False),
("commented config", ["# timeout: 10s", "enabled: true"], False),
]
for name, content, expected in cases:
lines = [AddedLine("test.go" if name not in {"long yaml", "commented config"} else "test.yaml", i, line, 1) for i, line in enumerate(content, 1)]
actual = bool(analyze(lines))
if actual != expected:
raise AssertionError(f"{name}: expected {expected}, got {actual}")
patch = "*** Begin Patch\n*** Update File: x.go\n@@ -1 +1,2 @@\n run()\n+// Set the value.\n*** End Patch"
if not analyze(parse_apply_patch(patch)):
raise AssertionError("apply_patch parsing did not find narration")
with tempfile.TemporaryDirectory() as directory:
subprocess.run(["git", "init", "-q"], cwd=directory, check=True)
subprocess.run(["git", "config", "user.email", "comment-cop@example.invalid"], cwd=directory, check=True)
subprocess.run(["git", "config", "user.name", "comment-cop"], cwd=directory, check=True)
source = Path(directory, "main.go")
source.write_text("package main\n")
subprocess.run(["git", "add", "main.go"], cwd=directory, check=True)
subprocess.run(["git", "commit", "-qm", "base"], cwd=directory, check=True)
source.write_text("package main\n// Loop through rows.\n")
event = {"session_id": "self-test", "cwd": directory}
record_baseline(event)
source.write_text("package main\n// Loop through rows.\n// Set the timeout.\n")
findings = new_findings(event)
if len(findings) != 1 or findings[0].text != "Set the timeout.":
raise AssertionError(f"baseline isolation failed: {findings!r}")
print("comment-cop self-test passed")
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
self_test()
return
if len(sys.argv) > 1 and sys.argv[1] == "--check-diff":
findings = worktree_findings(os.getcwd())
if findings:
print(format_findings(findings))
raise SystemExit(1)
return
data = json.load(sys.stdin)
output = run_event(data)
if output is not None:
print(json.dumps(output))
if __name__ == "__main__":
try:
main()
except Exception as error:
if "--self-test" in sys.argv:
raise
print(json.dumps({"systemMessage": f"comment-cop failed open: {error}"}))
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/agent-hooks/comment-cop.py\"",
"timeout": 10,
"statusMessage": "Recording comment baseline"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/agent-hooks/comment-cop.py\"",
"timeout": 10,
"statusMessage": "Checking comments"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/agent-hooks/comment-cop.py\"",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/agent-hooks/comment-cop.py\"",
"timeout": 10,
"statusMessage": "Auditing added comments"
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment