diff options
Diffstat (limited to 'hooks')
| -rw-r--r-- | hooks/README.md | 118 | ||||
| -rw-r--r-- | hooks/_common.py | 75 | ||||
| -rwxr-xr-x | hooks/destructive-bash-confirm.py | 174 | ||||
| -rwxr-xr-x | hooks/gh-pr-create-confirm.py | 172 | ||||
| -rwxr-xr-x | hooks/git-commit-confirm.py | 231 | ||||
| -rwxr-xr-x | hooks/precompact-priorities.sh | 122 | ||||
| -rw-r--r-- | hooks/settings-snippet.json | 21 |
7 files changed, 913 insertions, 0 deletions
diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..09abe09 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,118 @@ +# Global Hooks + +Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to every project on this machine. These complement the per-project hooks installed by language bundles (e.g., `languages/elisp/claude/hooks/validate-el.sh`). + +## What's here + +| Hook | Trigger | Purpose | +|---|---|---| +| `precompact-priorities.sh` | `PreCompact` | Injects a priority-preservation block into Claude's compaction prompt so the generated summary retains information most expensive to reconstruct (unanswered questions, root causes with `file:line`, subagent findings, exact numbers/IDs, A-vs-B decisions, open TODOs, classified-data handling). | +| `git-commit-confirm.py` | `PreToolUse(Bash)` | Silent-unless-suspicious gate on `git commit`. Only prompts when the message contains AI-attribution patterns, the message can't be parsed (editor would open), no files are staged, or the git author is unusable. Clean commits pass through without a modal. Parses both HEREDOC and `-m`/`--message` forms. | +| `gh-pr-create-confirm.py` | `PreToolUse(Bash)` | Gates `gh pr create` behind a confirmation modal showing title, baseβhead, reviewers, labels, assignees, milestone, draft flag, and body (HEREDOC or quoted). | +| `destructive-bash-confirm.py` | `PreToolUse(Bash)` | Gates destructive commands (`git push --force`, `git reset --hard`, `git clean -f`, `git branch -D`, `rm -rf`) with a modal showing the command, local context (branch, uncommitted file counts, targeted paths), and a warning banner. Elevates severity when force-pushing protected branches or targeting root/home/wildcard paths. | + +Shared library (not a hook): `_common.py` β `read_payload()`, `respond_ask()`, `scan_attribution()`. Installed as a sibling symlink so the two Python hooks can `from _common import β¦` at runtime. + +Both confirm hooks emit a `systemMessage` warning alongside the confirmation modal when they detect AI-attribution patterns (`Co-Authored-By: Claude`, π€, "Generated with Claude Code", etc.) in the commit message or PR title/body β useful as an automated policy check for environments where AI credit is forbidden. + +## Install + +### One-liner (from this repo) + +```bash +make -C ~/code/rulesets install-hooks +``` + +That symlinks each hook into `~/.claude/hooks/` and prints the `settings.json` snippet you need to merge into `~/.claude/settings.json` to wire them up. + +### Manual install + +```bash +mkdir -p ~/.claude/hooks +ln -sf ~/code/rulesets/hooks/precompact-priorities.sh ~/.claude/hooks/precompact-priorities.sh +ln -sf ~/code/rulesets/hooks/git-commit-confirm.py ~/.claude/hooks/git-commit-confirm.py +ln -sf ~/code/rulesets/hooks/gh-pr-create-confirm.py ~/.claude/hooks/gh-pr-create-confirm.py +``` + +Then merge into `~/.claude/settings.json`: + +```json +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/precompact-priorities.sh" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/git-commit-confirm.py" + }, + { + "type": "command", + "command": "~/.claude/hooks/gh-pr-create-confirm.py" + } + ] + } + ] + } +} +``` + +Note: if `~/.claude/settings.json` already has `hooks` entries, merge arrays rather than replacing them. Both `git-commit-confirm.py` and `gh-pr-create-confirm.py` are safe to run on every `Bash` tool call β they no-op on anything that isn't their target command. + +## Verify + +After installing + reloading Claude Code (or using `/hooks` to reload): + +```bash +# Test git commit gating +echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"test\""}}' \ + | ~/.claude/hooks/git-commit-confirm.py + +# Test gh pr create gating +echo '{"tool_name":"Bash","tool_input":{"command":"gh pr create --title test --body body"}}' \ + | ~/.claude/hooks/gh-pr-create-confirm.py + +# Test precompact block (just prints the rules) +~/.claude/hooks/precompact-priorities.sh | head -20 +``` + +Each should produce JSON output (the first two) or markdown (the third). + +## Per-project vs global + +These three live in `~/.claude/hooks/` because they're editor-agnostic and language-agnostic β you want them firing on every project. Per-language hooks (like `validate-el.sh` for Elisp or future equivalents for Python / TypeScript / Go) live in `languages/<lang>/claude/hooks/` and install *per-project* via `make install-<lang> PROJECT=<path>`. + +## Hook output contract + +The Python hooks emit JSON to stdout with `hookSpecificOutput`: + +- `hookEventName: "PreToolUse"` +- `permissionDecision: "ask"` +- `permissionDecisionReason: "<formatted modal text>"` + +Claude Code reads that and surfaces the modal to the user before running the tool. If the user declines, the tool call is cancelled. If they accept, it proceeds normally. + +The PreCompact hook emits markdown prose to stdout, which Claude Code appends to the default compaction prompt before generating the summary. + +## Dependencies + +- `python3` β for the two Python hooks (any modern version; stdlib only) +- `bash` β for `precompact-priorities.sh` +- `git` β the commit hook queries `git diff --cached` and `git config user.name` / `user.email` +- No external Python packages required + +## Sources + +- PreCompact priority-preservation pattern + git/gh confirmation modal pattern: clean-room synthesis from fcakyon's `claude-codex-settings` (Apache-2.0), extended and adapted. See `docs/architecture/v2-todo.org` or skill-evaluation memory for context. +- Each hook is original content; patterns are ideas, not copied prose. diff --git a/hooks/_common.py b/hooks/_common.py new file mode 100644 index 0000000..d4bf520 --- /dev/null +++ b/hooks/_common.py @@ -0,0 +1,75 @@ +"""Shared helpers for Claude Code PreToolUse confirmation hooks. + +Not a hook itself β imported by sibling scripts in ~/.claude/hooks/ +(installed as symlinks by `make install-hooks`). Python resolves imports +relative to the invoked script's directory, so sibling symlinks just work. + +Provides: + read_payload() β dict parsed from stdin (empty dict on failure) + respond_ask(...) β emit a PreToolUse permissionDecision=ask response + scan_attribution() β detect AI-attribution patterns in commit/PR text + +AI-attribution scanning targets structural leak patterns (trailers, +footers, robot emoji) β NOT bare mentions of 'Claude' or 'Anthropic', +which are legitimate words and would false-positive on diffs discussing +the tools themselves. +""" + +import json +import re +import sys +from typing import Optional + + +ATTRIBUTION_PATTERNS: list[tuple[str, str]] = [ + (r"Co-Authored-By:\s*(?:Claude|Anthropic|GPT|AI\b|an? LLM)", + "Co-Authored-By trailer crediting an AI"), + (r"π€", + "robot emoji (π€)"), + (r"Generated (?:with|by) (?:Claude|Anthropic|AI|an? LLM)", + "'Generated with AI' footer"), + (r"Created (?:with|by) (?:Claude|Anthropic|AI|an? LLM)", + "'Created with AI' footer"), + (r"Assisted by (?:Claude|Anthropic|AI|an? LLM)", + "'Assisted by AI' credit"), + (r"\[\s*(?:Claude|AI|LLM)\s*(?:Code)?\s*\]", + "[Claude] / [AI] bracketed tag"), +] + + +def read_payload() -> dict: + """Parse tool-call JSON from stdin. Return {} on any parse failure.""" + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return {} + + +def respond_ask(reason: str, system_message: Optional[str] = None) -> None: + """Emit a PreToolUse response asking the user to confirm. + + `reason` fills the modal body (permissionDecisionReason). + `system_message`, if set, surfaces a secondary banner/warning to the + user in a slot distinct from the modal. + """ + output: dict = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": reason, + } + } + if system_message: + output["systemMessage"] = system_message + print(json.dumps(output)) + + +def scan_attribution(text: str) -> list[str]: + """Return human-readable descriptions of any AI-attribution hits.""" + if not text: + return [] + hits: list[str] = [] + for pattern, description in ATTRIBUTION_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + hits.append(description) + return hits diff --git a/hooks/destructive-bash-confirm.py b/hooks/destructive-bash-confirm.py new file mode 100755 index 0000000..c1cf5f9 --- /dev/null +++ b/hooks/destructive-bash-confirm.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: gate destructive commands behind a modal. + +Detects and asks for confirmation before: + - git push --force / -f / --force-with-lease (overwrites remote history) + - git reset --hard (discards working-tree) + - git clean -f (deletes untracked files) + - git branch -D (force-deletes branches) + - rm -rf (any flag combo containing both -r/-R and -f) + +Each pattern emits a modal with the command, local context (current +branch, uncommitted line count, targeted paths, etc.), and a warning +banner via systemMessage. First match wins β a command with multiple +destructive patterns fires on the first detected. + +Non-destructive Bash calls exit 0 silent. +""" + +import re +import subprocess +import sys +from typing import Optional + +from _common import read_payload, respond_ask + + +PROTECTED_BRANCHES = {"main", "master", "develop", "release", "prod", "production"} + + +def main() -> int: + payload = read_payload() + if payload.get("tool_name") != "Bash": + return 0 + + cmd = payload.get("tool_input", {}).get("command", "") + detection = detect_destructive(cmd) + if not detection: + return 0 + + kind, context = detection + reason = format_confirmation(kind, cmd, context) + banner = context.pop("_banner", f"DESTRUCTIVE: {kind}") + + respond_ask(reason, system_message=banner) + return 0 + + +def detect_destructive(cmd: str) -> Optional[tuple[str, dict]]: + """Return (kind, context) for the first destructive pattern matched.""" + + if is_force_push(cmd): + branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"]).strip() + ctx: dict = {"branch": branch or "(detached)"} + if branch in PROTECTED_BRANCHES: + ctx["_banner"] = ( + f"DESTRUCTIVE: force-push to PROTECTED branch '{branch}' β " + f"rewrites shared history." + ) + return "git push --force", ctx + + if re.search(r"(?:^|[\s;&|()])git\s+reset\s+(?:\S+\s+)*--hard\b", cmd): + staged = count_lines(run_git(["diff", "--cached", "--stat"])) + unstaged = count_lines(run_git(["diff", "--stat"])) + return "git reset --hard", { + "staged_files": max(staged - 1, 0), + "unstaged_files": max(unstaged - 1, 0), + } + + if re.search(r"(?:^|[\s;&|()])git\s+clean\s+(?:\S+\s+)*-[a-zA-Z]*f", cmd): + untracked = run_git(["ls-files", "--others", "--exclude-standard"]) + return "git clean -f", { + "untracked_files": len(untracked.splitlines()), + } + + if m := re.search(r"(?:^|[\s;&|()])git\s+branch\s+(?:\S+\s+)*-D\s+(\S+)", cmd): + target = m.group(1) + unmerged = run_git( + ["log", f"main..{target}", "--oneline"] + ).strip() if target else "" + ctx = {"branch_to_delete": target} + if unmerged: + ctx["unmerged_commits"] = len(unmerged.splitlines()) + return "git branch -D", ctx + + rm_targets = detect_rm_rf(cmd) + if rm_targets is not None: + ctx = {"targets": rm_targets or ["(none parsed)"]} + dangerous = [ + t for t in rm_targets + if t in ("/", "~", "$HOME", ".", "..", "*") + or t.startswith("/") + or t.startswith("~") + ] + if dangerous: + ctx["_banner"] = ( + f"DESTRUCTIVE: rm -rf targeting root/home/wildcard paths: " + f"{', '.join(dangerous)}" + ) + return "rm -rf", ctx + + return None + + +def is_force_push(cmd: str) -> bool: + """Match `git push` with any force variant.""" + if not re.search(r"(?:^|[\s;&|()])git\s+(?:\S+\s+)*push\b", cmd): + return False + # Look for --force / --force-with-lease / -f as a standalone flag + # (avoid matching -f inside a longer token that isn't a flag chain) + return bool( + re.search(r"(?:\s|^)--force(?:-with-lease)?\b", cmd) + or re.search(r"(?:\s|^)-[a-zA-Z]*f[a-zA-Z]*\b", cmd[cmd.find("push"):]) + ) + + +def detect_rm_rf(cmd: str) -> Optional[list[str]]: + """If cmd invokes `rm` with both -r/-R and -f flags, return its targets.""" + m = re.search(r"(?:^|[\s;&|()])rm\s+(.+)$", cmd) + if not m: + return None + + rest = m.group(1).split() + flag_chars = "" + i = 0 + while i < len(rest) and rest[i].startswith("-") and rest[i] != "--": + flag_chars += rest[i][1:] + i += 1 + if rest[i:i+1] == ["--"]: + i += 1 + + has_r = bool(re.search(r"[rR]", flag_chars)) + has_f = "f" in flag_chars + if not (has_r and has_f): + return None + + return rest[i:] + + +def run_git(args: list) -> str: + try: + out = subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + timeout=3, + ) + return out.stdout + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return "" + + +def count_lines(text: str) -> int: + return len([ln for ln in text.splitlines() if ln.strip()]) + + +def format_confirmation(kind: str, cmd: str, context: dict) -> str: + lines = [f"Run destructive command β {kind}?", ""] + lines.append("Command:") + lines.append(f" {cmd}") + lines.append("") + + if context: + lines.append("Context:") + for key, val in context.items(): + lines.append(f" {key}: {val}") + lines.append("") + + lines.append("This operation is destructive and typically irreversible.") + lines.append("Confirm before proceeding.") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/gh-pr-create-confirm.py b/hooks/gh-pr-create-confirm.py new file mode 100755 index 0000000..e3c2f13 --- /dev/null +++ b/hooks/gh-pr-create-confirm.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: gate `gh pr create` behind a confirmation modal. + +Parses title, body, base, head, reviewers, labels, draft flag from the +`gh pr create` command and renders a modal so the user sees exactly what +will be opened. + +Wire in ~/.claude/settings.json alongside git-commit-confirm.py: + + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/gh-pr-create-confirm.py" + } + ] + } + ] + } + } +""" + +import re +import sys + +from _common import read_payload, respond_ask, scan_attribution + + +MAX_BODY_LINES = 20 + + +def main() -> int: + payload = read_payload() + if payload.get("tool_name") != "Bash": + return 0 + + cmd = payload.get("tool_input", {}).get("command", "") + if not re.search(r"(?:^|[\s;&|()])gh\s+pr\s+create\b", cmd): + return 0 + + fields = parse_pr_create(cmd) + reason = format_pr_confirmation(fields) + + # Scan both title and body β PRs leak attribution in either slot. + scan_text = "\n".join(filter(None, [fields.get("title"), fields.get("body")])) + hits = scan_attribution(scan_text) + system_message = ( + f"WARNING β PR title/body contains AI-attribution patterns: " + f"{'; '.join(hits)}. Policy forbids AI credit in PRs." + if hits else None + ) + + respond_ask(reason, system_message=system_message) + return 0 + + +def parse_pr_create(cmd: str) -> dict: + fields: dict = { + "title": None, + "body": None, + "base": None, + "head": None, + "reviewers": [], + "labels": [], + "assignees": [], + "milestone": None, + "draft": False, + } + + # Title β quoted string after --title / -t + t = re.search(r"--title\s+([\"'])(.*?)\1", cmd, re.DOTALL) + if not t: + t = re.search(r"\s-t\s+([\"'])(.*?)\1", cmd, re.DOTALL) + if t: + fields["title"] = t.group(2) + + # Body β HEREDOC inside $() first, then plain quoted string, then --body-file + body_heredoc = re.search( + r"--body\s+\"\$\(cat\s*<<-?\s*['\"]?(\w+)['\"]?\s*\n(.*?)\n\s*\1\s*\)\"", + cmd, + re.DOTALL, + ) + if body_heredoc: + fields["body"] = body_heredoc.group(2).strip() + else: + b = re.search(r"--body\s+([\"'])(.*?)\1", cmd, re.DOTALL) + if b: + fields["body"] = b.group(2).strip() + else: + bf = re.search(r"--body-file\s+(\S+)", cmd) + if bf: + fields["body"] = f"(body read from file: {bf.group(1)})" + + # Base / head + base = re.search(r"--base\s+(\S+)", cmd) + if not base: + base = re.search(r"\s-B\s+(\S+)", cmd) + if base: + fields["base"] = base.group(1) + + head = re.search(r"--head\s+(\S+)", cmd) + if not head: + head = re.search(r"\s-H\s+(\S+)", cmd) + if head: + fields["head"] = head.group(1) + + # Multi-valued flags (comma-separated or repeated) + for name, key in ( + ("reviewer", "reviewers"), + ("label", "labels"), + ("assignee", "assignees"), + ): + pattern = rf"--{name}[=\s]([\"']?)([^\s\"']+)\1" + for match in re.finditer(pattern, cmd): + fields[key].extend(match.group(2).split(",")) + + # Milestone + m = re.search(r"--milestone[=\s]([\"'])?([^\s\"']+)\1?", cmd) + if m: + fields["milestone"] = m.group(2) + + # Draft flag + if re.search(r"--draft\b", cmd): + fields["draft"] = True + + return fields + + +def format_pr_confirmation(fields: dict) -> str: + lines = ["Create pull request?", ""] + + if fields["draft"]: + lines.append("[DRAFT]") + lines.append("") + + lines.append(f"Title: {fields['title'] or '(not parsed)'}") + + base = fields["base"] or "(default β usually main)" + head = fields["head"] or "(current branch)" + lines.append(f"Base β Head: {base} β {head}") + + if fields["reviewers"]: + lines.append(f"Reviewers: {', '.join(fields['reviewers'])}") + if fields["assignees"]: + lines.append(f"Assignees: {', '.join(fields['assignees'])}") + if fields["labels"]: + lines.append(f"Labels: {', '.join(fields['labels'])}") + if fields["milestone"]: + lines.append(f"Milestone: {fields['milestone']}") + + lines.append("") + if fields["body"]: + lines.append("Body:") + body_lines = fields["body"].splitlines() + for line in body_lines[:MAX_BODY_LINES]: + lines.append(f" {line}") + if len(body_lines) > MAX_BODY_LINES: + lines.append(f" ... ({len(body_lines) - MAX_BODY_LINES} more lines)") + else: + lines.append("Body: (not parsed)") + + lines.append("") + lines.append("Confirm target branch, title, body, and reviewers before proceeding.") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py new file mode 100755 index 0000000..2441d23 --- /dev/null +++ b/hooks/git-commit-confirm.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: silent-unless-suspicious gate on `git commit`. + +Reads tool-call JSON from stdin. If the Bash command is a `git commit`, +parse the message, run safety checks, and only emit a confirmation modal +when one of them fires: + + - AI-attribution patterns in the commit message (Co-Authored-By: Claude, + robot emoji, etc.) β the primary leak we want to catch + - Message could not be parsed from the command line (no -m / HEREDOC; + likely to drop into $EDITOR, which silently blocks Claude) + - Zero staged files (the commit will fail; better to ask why) + - git author unusable (user.name / user.email not configured) + +On a clean, well-formed commit, exit 0 with no output β the commit runs +without a modal. Non-git-commit Bash calls also exit 0 silent. + +Previously this hook asked on every commit; that produced too many benign +modals for Craig's workflow. The attribution-scan safety is preserved; +the always-on review is not. + +Wire in ~/.claude/settings.json (or per-project .claude/settings.json): + + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/git-commit-confirm.py" + } + ] + } + ] + } + } +""" + +import re +import subprocess +import sys + +from _common import read_payload, respond_ask, scan_attribution + + +MAX_FILES_SHOWN = 25 +MAX_MESSAGE_LINES = 30 + +UNPARSEABLE_MESSAGE = ( + "(commit message not parseable from command line; " + "will be edited interactively)" +) + + +def main() -> int: + payload = read_payload() + if payload.get("tool_name") != "Bash": + return 0 + + cmd = payload.get("tool_input", {}).get("command", "") + if not is_git_commit(cmd): + return 0 + + message = extract_commit_message(cmd) + staged = get_staged_files() + stats = get_diff_stats() + author = get_author() + + issues = collect_issues(message, staged, author) + if not issues: + return 0 # silent pass-through on clean commits + + reason = format_confirmation(message, staged, stats, author, issues) + + attribution_hits = [ + i for i in issues if i.startswith("AI-attribution") + ] + system_message = ( + f"WARNING β {attribution_hits[0]}. " + "Policy forbids AI credit in commits." + if attribution_hits else None + ) + + respond_ask(reason, system_message=system_message) + return 0 + + +def collect_issues(message: str, staged: list[str], author: str) -> list[str]: + """Return a list of human-readable issues worth asking the user about. + + Empty list β silent pass-through. Any hits β modal. + """ + issues: list[str] = [] + + hits = scan_attribution(message) + if hits: + issues.append("AI-attribution pattern in message: " + "; ".join(hits)) + + if message == UNPARSEABLE_MESSAGE: + issues.append( + "commit message not parseable from command β editor will open" + ) + + if not staged: + issues.append("no staged files β the commit will fail") + + if author.startswith("(") and author.endswith(")"): + issues.append(f"git author unusable: {author}") + + return issues + + +def is_git_commit(cmd: str) -> bool: + """True if the command invokes `git commit` (possibly with env/cd prefix).""" + # Strip leading assignments and subshells; find a `git commit` word boundary + return bool(re.search(r"(?:^|[\s;&|()])git\s+(?:-[^\s]+\s+)*commit\b", cmd)) + + +def extract_commit_message(cmd: str) -> str: + """Parse the commit message from either HEREDOC or -m forms.""" + # HEREDOC form: -m "$(cat <<'EOF' ... EOF)" or -m "$(cat <<EOF ... EOF)" + heredoc = re.search( + r"<<-?\s*['\"]?(\w+)['\"]?\s*\n(.*?)\n\s*\1\b", + cmd, + re.DOTALL, + ) + if heredoc: + return heredoc.group(2).strip() + + # One or more -m flags (simple single/double quotes) + flags = re.findall(r"-m\s+([\"'])(.*?)\1", cmd, re.DOTALL) + if flags: + # Multiple -m flags join with blank line (git's own behavior) + return "\n\n".join(msg for _, msg in flags).strip() + + # --message=... form + long_form = re.findall(r"--message[=\s]([\"'])(.*?)\1", cmd, re.DOTALL) + if long_form: + return "\n\n".join(msg for _, msg in long_form).strip() + + return UNPARSEABLE_MESSAGE + + +def get_staged_files() -> list[str]: + try: + out = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + capture_output=True, + text=True, + timeout=5, + ) + return [line for line in out.stdout.splitlines() if line.strip()] + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return [] + + +def get_diff_stats() -> str: + try: + out = subprocess.run( + ["git", "diff", "--cached", "--shortstat"], + capture_output=True, + text=True, + timeout=5, + ) + return out.stdout.strip() or "(no staged changes β commit may fail)" + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return "(could not read diff stats)" + + +def get_author() -> str: + """Report the git author identity that will own the commit.""" + try: + name = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + timeout=3, + ).stdout.strip() + email = subprocess.run( + ["git", "config", "user.email"], + capture_output=True, + text=True, + timeout=3, + ).stdout.strip() + if name and email: + return f"{name} <{email}>" + return "(git user.name / user.email not configured)" + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return "(could not read git config)" + + +def format_confirmation( + message: str, files: list[str], stats: str, author: str, issues: list[str] +) -> str: + lines = ["Create commit? (flagged for review)", ""] + + lines.append("Issues detected:") + for issue in issues: + lines.append(f" ! {issue}") + lines.append("") + + lines.append("Author:") + lines.append(f" {author}") + lines.append("") + + lines.append("Message:") + msg_lines = message.splitlines() or ["(empty)"] + for line in msg_lines[:MAX_MESSAGE_LINES]: + lines.append(f" {line}") + if len(msg_lines) > MAX_MESSAGE_LINES: + lines.append(f" ... ({len(msg_lines) - MAX_MESSAGE_LINES} more lines)") + lines.append("") + + lines.append(f"Staged files ({len(files)}):") + for f in files[:MAX_FILES_SHOWN]: + lines.append(f" - {f}") + if len(files) > MAX_FILES_SHOWN: + lines.append(f" ... and {len(files) - MAX_FILES_SHOWN} more") + lines.append("") + + lines.append(f"Stats: {stats}") + + lines.append("") + lines.append("Review the issues above before proceeding.") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/precompact-priorities.sh b/hooks/precompact-priorities.sh new file mode 100755 index 0000000..3ebee8f --- /dev/null +++ b/hooks/precompact-priorities.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# PreCompact hook: inject priority-preservation instructions into the +# compaction prompt so the generated summary retains information that's +# disproportionately expensive to reconstruct after compact. +# +# Wire in ~/.claude/settings.json (or per-project .claude/settings.json): +# +# { +# "hooks": { +# "PreCompact": [ +# { +# "hooks": [ +# { +# "type": "command", +# "command": "~/.claude/hooks/precompact-priorities.sh" +# } +# ] +# } +# ] +# } +# } +# +# The hook writes to stdout; Claude Code appends this to its default +# compact prompt. It doesn't read stdin. Safe to install globally. + +cat <<'PRIORITIES' + +--- + +When producing the compact summary, preserve the following verbatim or +near-verbatim. Do not paraphrase, compress, or drop these categories β they +are disproportionately expensive to reconstruct after compaction. + +These instructions patch the default compact-prompt sections; they do not +replace other parts of it. + +### A. Unanswered questions + +For every question the user asked, mark it as *answered*, *partially +answered*, or *unanswered*. List every unanswered question verbatim under +a "Pending Questions" heading in the summary. Do not drop a question +just because the conversation moved on; unanswered questions are the +single most common thing that gets lost across compactions. + +### B. Root causes, not symptoms + +- Distinguish *confirmed* root causes from *ruled-out hypotheses*. +- Cite confirmed causes with `path/to/file:line_number`. +- Keep ruled-out hypotheses under a short "Investigated and excluded" + list so they don't get re-tried after compact. +- Preserve error messages, stack frames, exit codes, and error IDs + verbatim β never paraphrase them. + +### C. Exact numbers and identifiers + +Retain exact digits and strings for all of: + +- Commit SHAs, PR numbers, issue numbers, ticket IDs +- Run IDs, job IDs, container IDs +- Dataset names, model IDs, version numbers, release tags +- Measured latencies, throughput, token counts, costs, file sizes +- Line counts, port numbers, IP addresses +- Credentials format markers (not the credential itself β see Β§E) + +These anchor future recall. Rounded or paraphrased numbers force +re-measurement. + +### D. File path tiers + +Group touched files by tier rather than flattening them into one list: + +- **Critical** β files modified, or identified as the source of the bug. + List with `path/to/file:line`. +- **Referenced** β files read for context but not modified. List the paths. +- **Mentioned** β files discussed but not opened. List by name. + +The tiers matter for resumption: "critical" tells the next session where +the work is, "referenced" tells it what to re-open on demand, "mentioned" +is breadcrumb. + +### E. Subagent findings as primary evidence + +For every Task / Agent tool call, preserve the sub-agent's final report +in full or near-full. Subagent runs are expensive to re-execute; treat +their findings as primary evidence, not compressible chatter. Include: + +- The sub-agent's summary heading +- Key findings verbatim +- Any cited code, file paths, or URLs exactly as returned +- The invoking prompt (brief) so the next session knows why the agent ran + +If multiple sub-agents ran in parallel, preserve each β do not merge their +findings into a synthesized paragraph. + +### F. A-vs-B comparisons and decisions + +When two or more options were weighed: + +- Preserve the options (labeled A, B, C as appropriate) +- Preserve the decision criteria used +- State which option won and why +- Preserve rejected alternatives with the reason for rejection + +Decisions are load-bearing for future work. Losing the rationale forces +re-analysis or, worse, re-deciding the same question differently. + +### G. Open TODO items + +Any TODO lists, task lists, "next steps," or explicit follow-ups mentioned +in the conversation β preserve the items verbatim. Do not aggregate them +as "user has some follow-up items." A TODO without its exact text is +noise. + +### H. Sensitive data handling + +If credentials, tokens, API keys, PII, or classified markers appeared in +the conversation: preserve the *shape* (e.g., "AWS access key starting +with AKIA...") but never the full secret. If an operational question +depended on a specific value that can't be preserved safely, record that +the value exists and where it came from so the next session can re-fetch +rather than re-guess. +PRIORITIES diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json new file mode 100644 index 0000000..11459f2 --- /dev/null +++ b/hooks/settings-snippet.json @@ -0,0 +1,21 @@ +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/precompact-priorities.sh" } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/git-commit-confirm.py" }, + { "type": "command", "command": "~/.claude/hooks/gh-pr-create-confirm.py" }, + { "type": "command", "command": "~/.claude/hooks/destructive-bash-confirm.py" } + ] + } + ] + } +} |
