From 4957c60c9ee985628ad59344e593d20a18ca8fdb Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Apr 2026 17:06:10 -0500 Subject: feat(hooks): add global hooks — PreCompact priorities + git/gh confirm modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new machine-wide hooks installed via `make install-hooks`: - `precompact-priorities.sh` (PreCompact) — injects a priority block into the compaction prompt so the generated summary retains information most expensive to reconstruct: unanswered questions, root causes with file:line, subagent findings as primary evidence, exact numbers/IDs, A-vs-B decisions, open TODOs, classified-data handling. - `git-commit-confirm.py` (PreToolUse/Bash) — gates `git commit` behind a confirmation modal showing parsed message, staged files, diff stats, author. Parses both HEREDOC and `-m`/`--message` forms. - `gh-pr-create-confirm.py` (PreToolUse/Bash) — gates `gh pr create` behind a modal showing title, base ← head, reviewers, labels, assignees, milestone, draft flag, body (HEREDOC or quoted). Makefile: adds `install-hooks` / `uninstall-hooks` targets and extends `list` with a Hooks section. Install prints the settings.json snippet (in `hooks/settings-snippet.json`) to merge into `~/.claude/settings.json`. Also: `languages/elisp/claude/hooks/validate-el.sh` now emits JSON with `hookSpecificOutput.additionalContext` on failure (via new `fail_json()` helper) so Claude sees a structured error in context, in addition to the existing stderr output and exit 2. Patterns synthesized clean-room from fcakyon/claude-codex-settings (Apache-2.0). Each hook is original content. --- hooks/git-commit-confirm.py | 183 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 hooks/git-commit-confirm.py (limited to 'hooks/git-commit-confirm.py') diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py new file mode 100755 index 0000000..bea6410 --- /dev/null +++ b/hooks/git-commit-confirm.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: gate `git commit` behind a confirmation modal. + +Reads tool-call JSON from stdin. If the Bash command is a `git commit`, +parse the message (HEREDOC or -m forms), list staged files and diff +stats, and emit JSON with permissionDecision=ask and a formatted reason +so the user sees what will actually be committed. + +For non-commit Bash calls, exit 0 with no output — default permission +rules apply. + +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 json +import re +import subprocess +import sys + + +MAX_FILES_SHOWN = 25 +MAX_MESSAGE_LINES = 30 + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return 0 # malformed; don't block + + 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() + + reason = format_confirmation(message, staged, stats, author) + + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": reason, + } + } + print(json.dumps(output)) + return 0 + + +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 < 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 +) -> str: + lines = ["Create commit?", ""] + + 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("Confirm the message, author, and file list before proceeding.") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3