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:
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user