diff --git a/README.md b/README.md index ea0805d..6bcd734 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Single source of truth for custom agent skills + commands. Shared across **Claud ``` skills/ # SKILL.md dirs — Claude Code AND Codex both read these (open Agent Skills standard) commands/ # slash commands — Claude Code only (Codex ignores) +hooks/ # Claude Code hooks — see hooks/README.md, wiring is manual +claude-md/ # CLAUDE.md fragments — linked to ~/.claude/, imported via @name.md bin/link.sh # bootstrap symlinks for non-Nix machines nix/home.nix # home-manager module for NixOS machines flake.nix # exposes homeModules.default @@ -23,7 +25,9 @@ git clone https://git.naps.pt/yolo/agent-skills.git ~/tea/yolo/agent-skills ~/tea/yolo/agent-skills/bin/link.sh ``` -Symlinks each skill into `~/.claude/skills/` and `~/.agents/skills/`, commands into `~/.claude/commands/`. 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. +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`. ### NixOS machine (home-manager) @@ -60,3 +64,14 @@ Drop a new `skills//SKILL.md` (+ optional `scripts/`, `references/`, `asse | `blitz` | drive a whole milestone to done | | `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 + +Two prose contracts in `claude-md/`, each with a matching enforcer in `hooks/`: + +| 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 | + +Prose alone drifts; the linters make the contract binding. Details in `hooks/README.md`. diff --git a/bin/link.sh b/bin/link.sh index 735a61a..8e565a3 100755 --- a/bin/link.sh +++ b/bin/link.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Symlink centralized skills/commands into each agent's config dir. +# Symlink centralized skills/commands/hooks/context into each agent's config dir. # For non-Nix machines (NixOS boxes use nix/home.nix instead). # Idempotent. Backs up any pre-existing real dir once to .bak. set -euo pipefail @@ -11,6 +11,11 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)" CLAUDE_SKILLS="$HOME/.claude/skills" 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 + +# 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. # backups go OUTSIDE the skill dirs — a *.bak left inside gets discovered as a # duplicate skill by both Claude Code and Codex. @@ -27,7 +32,7 @@ link() { # link echo "linked $dst -> $src" } -mkdir -p "$CLAUDE_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" +mkdir -p "$CLAUDE_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" "$CLAUDE_HOOKS" for d in "$REPO"/skills/*/; do name="$(basename "$d")" @@ -40,4 +45,15 @@ for f in "$REPO"/commands/*.md; do link "$f" "$CLAUDE_CMDS/$(basename "$f")" done +for f in "$REPO"/hooks/*.py "$REPO"/hooks/*.sh; do + [ -e "$f" ] || continue + link "$f" "$CLAUDE_HOOKS/$(basename "$f")" +done + +for f in "$REPO"/claude-md/*.md; do + [ -e "$f" ] || continue + link "$f" "$CLAUDE_HOME/$(basename "$f")" +done + 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 new file mode 100644 index 0000000..53b8851 --- /dev/null +++ b/claude-md/code-comments.md @@ -0,0 +1,40 @@ +## 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 new file mode 100644 index 0000000..20673b8 --- /dev/null +++ b/claude-md/public-comms.md @@ -0,0 +1,23 @@ +## 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/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..22b2d4b --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,40 @@ +# hooks + +Claude Code hooks. Claude-only — Codex ignores. `bin/link.sh` / `nix/home.nix` symlink these into `~/.claude/hooks/`; **wiring is manual**, see below. + +| 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`. | + +Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`. + +## Wiring + +`~/.claude/settings.json` is machine-local (MCP servers, statusline, per-box hooks), so this repo does not own it. Merge into `.hooks`: + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/comms-lint.py" }] } + ], + "PostToolUse": [ + { "matcher": "Write|Edit|MultiEdit", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/comment-lint.py" }] } + ] + } +} +``` + +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. + +## Testing a hook + +Feed it the payload shape Claude Code sends: + +```sh +echo '{"tool_name":"Bash","tool_input":{"command":"gh pr comment 1 --body \"short\""}}' \ + | ~/.claude/hooks/comms-lint.py; echo "exit=$?" +``` diff --git a/hooks/comment-lint.py b/hooks/comment-lint.py new file mode 100755 index 0000000..1d27d6c --- /dev/null +++ b/hooks/comment-lint.py @@ -0,0 +1,180 @@ +#!/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/code-comments.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 = 5 # past this you are teaching an agent that can already read the repo + +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/code-comments.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() diff --git a/hooks/comms-lint.py b/hooks/comms-lint.py new file mode 100755 index 0000000..0214126 --- /dev/null +++ b/hooks/comms-lint.py @@ -0,0 +1,155 @@ +#!/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. +Exit 0 = allow. Exit 2 = block, stderr goes back to Claude as feedback. +Fails open on anything it cannot parse. +""" +import json +import os +import re +import shlex +import sys + +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) + +BANNED_HEADINGS = [ + "what is actually the case", + "what this is not for", + "the third option", + "the problem with", + "background and context", + "some thoughts", +] + +CMD_RE = re.compile(r"\bgh\s+(issue|pr)\s+(create|edit|comment|review)\b") + + +def fail_open(msg=""): + if msg and os.environ.get("COMMS_LINT_DEBUG"): + print(f"comms-lint: {msg}", file=sys.stderr) + sys.exit(0) + + +def extract_body(command): + """Return body text, or None if it cannot be determined.""" + try: + tokens = shlex.split(command) + except ValueError: + return None + + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok in ("--body-file", "-F", "--body-file="): + if i + 1 >= len(tokens): + return None + path = tokens[i + 1] + if path == "-": + return None # piped from stdin, unavailable here + try: + with open(os.path.expanduser(path), encoding="utf-8") as fh: + return fh.read() + except OSError: + return None + if tok.startswith("--body-file="): + path = tok.split("=", 1)[1] + try: + with open(os.path.expanduser(path), encoding="utf-8") as fh: + return fh.read() + except OSError: + return None + if tok in ("--body", "-b"): + if i + 1 >= len(tokens): + return None + return tokens[i + 1] + if tok.startswith("--body="): + return tok.split("=", 1)[1] + i += 1 + return None # no body flag: editor-driven, nothing to lint + + +def above_fold(body): + idx = body.lower().find(" 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." + ) + + 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)." + ) + + bold = body.count("**") // 2 + if bold > MAX_BOLD_SPANS: + problems.append( + f"{bold} bold spans (limit {MAX_BOLD_SPANS}). Emphasis inflation — " + "when everything is bold nothing is." + ) + + lowered = fold.lower() + for heading in BANNED_HEADINGS: + if re.search(r"^#{1,6}\s*\**\s*" + re.escape(heading), lowered, re.M): + problems.append( + f'Essay heading "{heading}". Headings name their contents: ' + "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 + + +def main(): + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + fail_open("unparseable payload") + + if payload.get("tool_name") != "Bash": + fail_open() + + command = payload.get("tool_input", {}).get("command", "") + if not CMD_RE.search(command): + fail_open() + + body = extract_body(command) + if body is None: + fail_open("no lintable body") + + problems = lint(body) + if not problems: + sys.exit(0) + + lines = ["Blocked by public-comms contract (~/.claude/public-comms.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) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/nix/home.nix b/nix/home.nix index 9e4cf94..5738432 100644 --- a/nix/home.nix +++ b/nix/home.nix @@ -13,5 +13,14 @@ ".claude/skills" = { source = "${agent-skills}/skills"; recursive = true; }; ".agents/skills" = { source = "${agent-skills}/skills"; recursive = true; }; ".claude/commands" = { source = "${agent-skills}/commands"; recursive = true; }; + ".claude/hooks" = { source = "${agent-skills}/hooks"; recursive = true; }; + + # 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"; }; } +# Hook wiring lives in ~/.claude/settings.json, which this module does not own. +# See hooks/README.md for the snippet.