feat(hooks): track writing contracts + linters in repo
comms-lint.py and comment-lint.py lived only in ~/.claude, which is not a git repo. Contract prose lived in ~/.claude/CLAUDE.md. Neither survived a machine rebuild. Prose moves to claude-md/ fragments, imported via @name.md. Linters move to hooks/. link.sh and home.nix distribute both. settings.json wiring stays manual — it holds machine-local MCP/statusline config this repo must not own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaGR6ERGCfzWTtuv2rebGe
This commit is contained in:
@@ -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/<name>/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 `<details>` |
|
||||
| `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`.
|
||||
|
||||
+18
-2
@@ -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 <name>.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 <src> <dst>
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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 `<details><summary>Evidence</summary>` 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.
|
||||
@@ -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/<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.
|
||||
|
||||
## 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=$?"
|
||||
```
|
||||
Executable
+180
@@ -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()
|
||||
Executable
+155
@@ -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("<details")
|
||||
return body if idx == -1 else body[:idx]
|
||||
|
||||
|
||||
def lint(body):
|
||||
problems = []
|
||||
fold = above_fold(body)
|
||||
words = len(fold.split())
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user