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/gh-pr-create-confirm.py | 173 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100755 hooks/gh-pr-create-confirm.py (limited to 'hooks/gh-pr-create-confirm.py') diff --git a/hooks/gh-pr-create-confirm.py b/hooks/gh-pr-create-confirm.py new file mode 100755 index 0000000..b983352 --- /dev/null +++ b/hooks/gh-pr-create-confirm.py @@ -0,0 +1,173 @@ +#!/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 json +import re +import sys + + +MAX_BODY_LINES = 20 + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return 0 + + 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) + + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": reason, + } + } + print(json.dumps(output)) + 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()) -- cgit v1.2.3