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.
This commit is contained in:
naps62
2026-08-01 13:06:38 +00:00
parent 17677c18df
commit a1f95a1161
9 changed files with 197 additions and 101 deletions
+3 -3
View File
@@ -4,8 +4,8 @@ Claude Code hooks. Claude-only — Codex ignores. `bin/link.sh` / `nix/home.nix`
| 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`. |
| `comms-lint.py` | `PreToolUse` / `Bash` | Gates `gh issue\|pr create\|edit\|comment\|review`. Lints body against `claude-md/writing.md` (150-word target / 300 hard cap above fold, no reviewer-addressing opener, plain diction, ≤4 bold spans, no essay headings). Exit 2 blocks, stderr becomes feedback. |
| `comment-lint.py` | `PostToolUse` / `Write\|Edit\|MultiEdit` | Lints newly-added comment lines in code files against `claude-md/writing.md`. Exit 2 = revise nudge (edit already applied). Long-comment-run finding (>3 lines) is advisory, delivered via `additionalContext`. |
Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`.
@@ -28,7 +28,7 @@ Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `
}
```
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.
Contract prose lives in `claude-md/writing.md`, linked to `~/.claude/writing.md` and imported from `~/.claude/CLAUDE.md` via `@writing.md`. Both linters cite that path in their block message — moving the fragment means updating the linter string too.
## Testing a hook
+3 -3
View File
@@ -2,7 +2,7 @@
"""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.
~/.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.
@@ -13,7 +13,7 @@ import os
import re
import sys
MAX_COMMENT_RUN = 5 # past this you are teaching an agent that can already read the repo
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",
@@ -157,7 +157,7 @@ def main():
name = os.path.basename(path)
if problems:
lines = [f"Comment contract (~/.claude/code-comments.md) — {name}:", ""]
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)
+53 -21
View File
@@ -1,7 +1,7 @@
#!/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.
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.
"""
@@ -11,12 +11,40 @@ import re
import shlex
import sys
TARGET_WORDS = 150
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)
# 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",
@@ -86,16 +114,28 @@ def lint(body):
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."
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>."
)
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)."
)
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:
@@ -112,14 +152,6 @@ def lint(body):
"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
@@ -144,7 +176,7 @@ def main():
if not problems:
sys.exit(0)
lines = ["Blocked by public-comms contract (~/.claude/public-comms.md):", ""]
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)