diff --git a/README.md b/README.md index a00c377..9858ecc 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ git clone https://git.naps.pt/yolo/agent-skills.git ~/tea/yolo/agent-skills Symlinks each skill into `~/.claude/skills/` and `~/.agents/skills/`, commands into `~/.claude/commands/`, hooks into `~/.claude/hooks/`, `claude-md/` fragments into `~/.claude/`. Idempotent; any pre-existing real dir is moved to `~/.agent-skills-backup/` (outside the discovery path, so it isn't picked up as a duplicate skill). Re-run after adding a skill. -Hooks and fragments need one manual step each: the `settings.json` snippet in `hooks/README.md`, and an `@public-comms.md` / `@code-comments.md` import line in your `~/.claude/CLAUDE.md`. +Hooks and fragments need one manual step each: the `settings.json` snippet in `hooks/README.md`, and an `@writing.md` import line in your `~/.claude/CLAUDE.md`. Codex needs no step — `~/.agents/AGENTS.md` is linked directly. ### NixOS machine (home-manager) @@ -66,13 +66,26 @@ Drop a new `skills//SKILL.md` (+ optional `scripts/`, `references/`, `asse | `linear-common` | shared config/setup/worktree conventions + local verification budget (dependency of work/yolo/blitz/nightshift) | | `crit`, `humanizer`, `impeccable`, `improve-codebase-architecture` | misc | -## Writing contracts +## Writing contract -Two prose contracts in `claude-md/`, each with a matching enforcer in `hooks/`: +One contract, `claude-md/writing.md`, linked three ways: `~/.claude/writing.md` +(imported via `@writing.md`), `~/.agents/AGENTS.md` (Codex, which has no +`@import`), and the nix module for NixOS machines. -| fragment | enforcer | scope | -|----------|----------|-------| -| `public-comms.md` | `comms-lint.py` | GitHub issues/PRs/review comments — BLUF, ≤300 words above the fold, evidence in `
` | -| `code-comments.md` | `comment-lint.py` | code comments — volume and purpose, not wording | +The rule it encodes 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, abbreviating, or +writing fragments — that is shorter and worse. Anthropic's Fable 5 and Opus 5 +prompting guides both say this explicitly. -Prose alone drifts; the linters make the contract binding. Details in `hooks/README.md`. +Two enforcers in `hooks/`: + +| enforcer | scope | +|----------|-------| +| `comms-lint.py` | GitHub issues/PRs/review comments — 150 words (hard cap 300), evidence in `
`, no reviewer-addressing opener, plain diction | +| `comment-lint.py` | code comments — 1-3 lines, volume and purpose, not wording | + +Written in the voice it asks for, and leads with a worked example of each. +Prompt style leaks into output style, and examples steer harder than +prohibitions, so the file is short and shows rather than forbids. Prose alone +drifts; the linters make it binding. Details in `hooks/README.md`. diff --git a/bin/link.sh b/bin/link.sh index 8e565a3..e83f046 100755 --- a/bin/link.sh +++ b/bin/link.sh @@ -13,6 +13,7 @@ CODEX_SKILLS="$HOME/.agents/skills" CLAUDE_CMDS="$HOME/.claude/commands" # commands are Claude-only; Codex ignores CLAUDE_HOOKS="$HOME/.claude/hooks" # hooks are Claude-only CLAUDE_HOME="$HOME/.claude" # CLAUDE.md fragments, pulled in via @name.md +AGENTS_HOME="$HOME/.agents" # Codex global config root # Per-FILE links, never a whole-dir link: ~/.claude/hooks and ~/.claude itself hold # machine-local files this repo does not own, and a dir symlink would hide them. @@ -32,7 +33,7 @@ link() { # link echo "linked $dst -> $src" } -mkdir -p "$CLAUDE_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" "$CLAUDE_HOOKS" +mkdir -p "$CLAUDE_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" "$CLAUDE_HOOKS" "$AGENTS_HOME" for d in "$REPO"/skills/*/; do name="$(basename "$d")" @@ -55,5 +56,8 @@ for f in "$REPO"/claude-md/*.md; do link "$f" "$CLAUDE_HOME/$(basename "$f")" done +# Codex reads ~/.agents/AGENTS.md; it has no @import, so the contract is the file. +link "$REPO/claude-md/writing.md" "$AGENTS_HOME/AGENTS.md" + echo "done." echo "hooks still need wiring in ~/.claude/settings.json — see hooks/README.md" diff --git a/claude-md/code-comments.md b/claude-md/code-comments.md deleted file mode 100644 index 53b8851..0000000 --- a/claude-md/code-comments.md +++ /dev/null @@ -1,40 +0,0 @@ -## Code comments -Governs VOLUME and PURPOSE. Caveman governs wording. Enforced by `~/.claude/hooks/comment-lint.py`. - -**Assume the reader is an agent with the whole repo, not a human with one file open.** It can -read call sites, `docs/`, the tests and `git log` faster than it can read your prose. So a -comment is not for teaching. It exists for ONE job: to stop a confident wrong edit. - -Write a comment only when an agent with repo access and thirty seconds of grep would still get -it wrong. Four cases qualify: - -1. **Landmine.** The code looks redundant, wrong, or reorderable and is not, AND no test catches - breaking it. Say what breaks. This is the highest-value comment in the codebase. - `// MUST read lastGoodPrice before fetchPrice - same eth_call state; no test catches this.` -2. **Fact not in the repo.** Deployed bytecode, a chain quirk, a library's undocumented - behaviour. One line and a path into `docs/`. -3. **Unit, encoding or epoch** the type cannot carry - 18-dp, wei, ms, which instant a timestamp - is anchored to. -4. **Cross-file invariant a caller can break**, stated as MUST / MUST NOT. -5. **A rejected alternative that was actually tried**, one line: what was tried, why it lost. An - agent reading only the code sees an obvious improvement and re-does the failed work. Keep this - ONLY where the losing attempt is not recoverable elsewhere — if `docs/` or an ADR records it, - cite that instead. Subjective domains (game feel, UI, copy) usually have no such record, so the - comment is the only copy: `// Outward was tried: brightest when it fights the silhouette.` - -**Delete everything else.** Mechanism the code already shows; what another module does; why this -design over another; measurement narratives; consequence chains; anything a test asserts; -anything `git log` records. The agent derives all of it on demand, and prose that duplicates code -is prose that will contradict it after the next refactor. - -**Budget: 1-3 lines.** Past 3 lines you are teaching, not warning - the only exception is a -landmine that genuinely needs the mechanism spelled out to be actionable. Whole-file headers get -3 lines: what this is, and the one trap. Not a table of contents. - -Prefer moving substance INTO `docs/` and leaving a path. An agent will follow the path; it costs -one read and the doc does not rot against the code. - -**No emphasis.** No bold, no italics, no superlatives. ALL-CAPS only for MUST / NEVER on a real -invariant, or to name the trap. - -**Deleting a comment is cheap and reversible - it is in git.** When unsure, delete. diff --git a/claude-md/public-comms.md b/claude-md/public-comms.md deleted file mode 100644 index 20673b8..0000000 --- a/claude-md/public-comms.md +++ /dev/null @@ -1,23 +0,0 @@ -## Public comms — issues, PRs, review comments -Overrides the caveman skill's "Code/commits/PRs: write normal" boundary. Normal prose, but these rules bind. Enforced by `~/.claude/hooks/comms-lint.py`. - -**Structure (BLUF — bottom line up front):** -- Line 1 is the ask. What decision or action do I want, from whom. Never make the reader reach the end to find it. -- Line 2-3 is the consequence in plain words a non-engineer PM would follow. "If we find a bug after launch we cannot fix it" — not "nothing bound to a compliance is upgradeable". -- Then: problem, options, proposed work. In that order. -- ≤300 words above the fold. Everything else — file:line cites, version tables, upstream verification, transcript evidence — goes inside `
Evidence` or a follow-up comment. -- State the appetite when proposing work: roughly how much time this is worth. - -**Language:** -- One concept per sentence. Break compound sentences with two or more subordinate clauses. -- Every term of art gets a plain-language gloss on first use, or gets cut. Assume the curse of knowledge is operating — I am the last to notice which labels are load-bearing jargon. -- No self-invented shorthand ("degrade-don't-brick guard", "blocking-adjacent", "identity-aggregating"). Say the thing. -- Bold ≤4 spans per document. No italics for emphasis or tone. -- Headings name their contents (Problem / Options / Work / Evidence). Not essay headings ("What is actually the case", "The third option", "What this is not for"). - -**Content:** -- Three or more alternatives go in a table: Option | What we do | Cost | What we get. Never prose sections. -- Scope exclusions get one line. Do not re-litigate a decided ADR or pre-empt objections nobody raised. -- Body reflects current truth. Superseded reasoning moves to a comment — never leave a dead decision above the live one. - -Refs: BLUF (US Army), inverted pyramid, Pinker's curse of knowledge, Google Technical Writing One, Shape Up pitch. diff --git a/claude-md/writing.md b/claude-md/writing.md new file mode 100644 index 0000000..92eaae2 --- /dev/null +++ b/claude-md/writing.md @@ -0,0 +1,108 @@ +## Writing + +Applies to everything you author: replies to me, PRs, issues, review +comments, docs, code comments. + +Keep it short by cutting whole ideas, not by cutting words. Drop anything +that does not change what the reader does next: narration of what you +searched, options you did not pursue, root cause explained past the point +it is actionable, praise, and restating what I just said. Write what +survives as plain sentences. + +Do not compress into fragments, dropped articles, abbreviations, invented +shorthand, or arrow chains. Short and unreadable is worse than one +sentence longer and clear. If you must choose, choose clear. + +Unselected: + +> I looked at the CI config and the cache key. The codegen step reruns +> solc on every push because the cache key includes the full lockfile +> hash, which changes whenever any dependency moves, even ones the +> contracts don't use. This means we pay a 6-minute compile on nearly +> every PR. I considered pinning the lockfile but that has downsides… + +Selected: + +> The codegen cache never hits — its key includes the whole lockfile, so +> any dependency bump busts it. Costs ~6 min per PR. + +### Talking to me + +Lead with the answer. First sentence says what happened or what you found. + +Ranked shortlist over exhaustive list, capped at ~5 unless I ask. One line +per item. No severity labels, emoji, or section scaffolding unless I ask. + +Fetching a lot of data is not a reason to show it. Filter to what is +actionable. + +If I am wrong, say so directly. If I am right, no praise. No preamble. + +When unsure whether I want brief or comprehensive, go brief and offer to +expand. + +### PRs, issues, review replies + +Never ask for a review, an approval, or a merge. Everyone involved already +knows who reviews and who merges. Never label the ask either ("Ask:", +"Decision needed from reviewers:"). + +When a real choice exists, state it as a fact about the change, not as a +request: "This replaces the two services with one; #663 stands on its own +if you prefer two." + +Budget: 150 words. Hard cap 300, and past 150 you need a reason. + +Open with what changed. If the reader must do something, that goes first +instead. Evidence — logs, file:line, version tables, verification runs — +goes in `
`. + +Use the shortest word that is exact. Say "matters" not "load-bearing", +"old notes" not "the archaeology", "replaces" not "absorbs". No metaphor. +One idea per sentence. Cut terms of art; gloss only one that must stay. + +Good: + +> Adds `cluster_name`, `db_name`, `db_user` to the app spec. +> +> `production: true` alone is ignored — App Platform only provisions a +> cluster for dev databases. Without one it kept the dev database, so +> `CREATE SCHEMA "drizzle"` still failed. +> +> Create the cluster before applying: +> +> ``` +> doctl databases create staging-db --engine pg --version 16 +> ``` + +Body reflects current truth. Superseded reasoning moves to a comment. + +Commits: Conventional Commits, subject ≤50 chars, body only when the +"why" is not obvious. Review comments: one line per finding — location, +problem, fix. + +### Code comments + +Budget: 1-3 lines. Write one only when an agent with the repo and thirty +seconds of grep would still get it wrong. That means one of: + +- a landmine no test catches +- a fact not in the repo (deployed bytecode, chain quirk, library bug) +- a unit or epoch the type cannot carry (wei, ms, 18-dp) +- a MUST or MUST NOT a caller can break + +Good: + +> `// MUST read lastGoodPrice before fetchPrice — same eth_call state.` + +Everything else: delete. Design rationale and rejected alternatives go in +`docs/`; leave a path. History is in git. No emphasis; ALL-CAPS only for +MUST / NEVER. + +Deleting a comment is cheap and reversible. When unsure, delete. + +### Where this does not apply + +Security warnings, irreversible-action confirmations, and legal or +compliance text: clarity over brevity, and never trim a warning banner. +Executable code is never altered for style. diff --git a/hooks/README.md b/hooks/README.md index 22b2d4b..5857df4 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -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/.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 diff --git a/hooks/comment-lint.py b/hooks/comment-lint.py index 1d27d6c..3c73695 100755 --- a/hooks/comment-lint.py +++ b/hooks/comment-lint.py @@ -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) diff --git a/hooks/comms-lint.py b/hooks/comms-lint.py index 0214126..a58ab20 100755 --- a/hooks/comms-lint.py +++ b/hooks/comms-lint.py @@ -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 " - "
Evidence 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
." ) - 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) diff --git a/nix/home.nix b/nix/home.nix index 5738432..8e12170 100644 --- a/nix/home.nix +++ b/nix/home.nix @@ -18,8 +18,10 @@ # CLAUDE.md fragments land in ~/.claude root, pulled in by `@name.md` imports. # Listed one by one: recursive on ~/.claude would fight every other tool # writing there (settings.json, projects/, file-history/). - ".claude/public-comms.md".source = "${agent-skills}/claude-md/public-comms.md"; - ".claude/code-comments.md".source = "${agent-skills}/claude-md/code-comments.md"; + ".claude/writing.md".source = "${agent-skills}/claude-md/writing.md"; + + # Codex has no @import, so the contract is the file itself. + ".agents/AGENTS.md".source = "${agent-skills}/claude-md/writing.md"; }; } # Hook wiring lives in ~/.claude/settings.json, which this module does not own.