aboutsummaryrefslogtreecommitdiff
path: root/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'hooks')
-rw-r--r--hooks/README.md118
-rw-r--r--hooks/_common.py75
-rwxr-xr-xhooks/destructive-bash-confirm.py174
-rwxr-xr-xhooks/gh-pr-create-confirm.py172
-rwxr-xr-xhooks/git-commit-confirm.py231
-rwxr-xr-xhooks/precompact-priorities.sh122
-rw-r--r--hooks/settings-snippet.json21
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" }
+ ]
+ }
+ ]
+ }
+}