aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 17:06:10 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 17:06:10 -0500
commit4957c60c9ee985628ad59344e593d20a18ca8fdb (patch)
treee8d6659dd2d7dd24126782fa83ccccffc6c6f836
parentab4a07b3c081609a81ee049ec9bbe6ccded09b54 (diff)
downloadrulesets-4957c60c9ee985628ad59344e593d20a18ca8fdb.tar.gz
rulesets-4957c60c9ee985628ad59344e593d20a18ca8fdb.zip
feat(hooks): add global hooks — PreCompact priorities + git/gh confirm modals
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.
-rw-r--r--Makefile48
-rw-r--r--hooks/README.md113
-rwxr-xr-xhooks/gh-pr-create-confirm.py173
-rwxr-xr-xhooks/git-commit-confirm.py183
-rwxr-xr-xhooks/precompact-priorities.sh122
-rw-r--r--hooks/settings-snippet.json20
-rwxr-xr-xlanguages/elisp/claude/hooks/validate-el.sh33
7 files changed, 682 insertions, 10 deletions
diff --git a/Makefile b/Makefile
index d37e955..1e9a1e8 100644
--- a/Makefile
+++ b/Makefile
@@ -3,12 +3,14 @@ SHELL := /bin/bash
SKILLS_DIR := $(HOME)/.claude/skills
RULES_DIR := $(HOME)/.claude/rules
+HOOKS_DIR := $(HOME)/.claude/hooks
SKILLS := c4-analyze c4-diagram debug add-tests respond-to-review review-code fix-issue security-check \
arch-design arch-decide arch-document arch-evaluate \
brainstorm codify root-cause-trace five-whys prompt-engineering \
playwright-js playwright-py frontend-design pairwise-tests \
finish-branch
RULES := $(wildcard claude-rules/*.md)
+HOOKS := $(wildcard hooks/*.sh hooks/*.py)
LANGUAGES := $(notdir $(wildcard languages/*))
# Pick target project — use PROJECT= or interactive fzf over local .git dirs.
@@ -35,7 +37,7 @@ $(if $(shell command -v pacman 2>/dev/null),sudo pacman -S --noconfirm $(1),\
$(error No supported package manager found (brew/apt-get/pacman)))))
endef
-.PHONY: help install uninstall list \
+.PHONY: help install uninstall list install-hooks uninstall-hooks \
install-lang install-elisp install-python list-languages \
diff lint deps
@@ -168,6 +170,50 @@ list: ## Show global install status
echo " - $$name"; \
fi \
done
+ @echo ""
+ @echo "Hooks:"
+ @for hook in $(HOOKS); do \
+ name=$$(basename $$hook); \
+ if [ -L "$(HOOKS_DIR)/$$name" ]; then \
+ echo " ✓ $$name (installed)"; \
+ else \
+ echo " - $$name"; \
+ fi \
+ done
+
+install-hooks: ## Symlink global hooks into ~/.claude/hooks/ + print settings.json snippet
+ @mkdir -p $(HOOKS_DIR)
+ @echo "Hooks:"
+ @for hook in $(HOOKS); do \
+ name=$$(basename $$hook); \
+ if [ -L "$(HOOKS_DIR)/$$name" ]; then \
+ echo " skip $$name (already linked)"; \
+ elif [ -e "$(HOOKS_DIR)/$$name" ]; then \
+ echo " WARN $$name exists and is not a symlink — skipping"; \
+ else \
+ ln -s "$(CURDIR)/$$hook" "$(HOOKS_DIR)/$$name"; \
+ echo " link $$name → $(HOOKS_DIR)/$$name"; \
+ fi \
+ done
+ @echo ""
+ @echo "Merge this into ~/.claude/settings.json (preserve any existing hooks arrays):"
+ @echo ""
+ @cat hooks/settings-snippet.json
+ @echo ""
+ @echo "After merging, reload Claude Code (open /hooks menu once, or restart the session)."
+
+uninstall-hooks: ## Remove global hook symlinks from ~/.claude/hooks/
+ @for hook in $(HOOKS); do \
+ name=$$(basename $$hook); \
+ if [ -L "$(HOOKS_DIR)/$$name" ]; then \
+ rm "$(HOOKS_DIR)/$$name"; \
+ echo " rm $$name"; \
+ else \
+ echo " skip $$name (not a symlink)"; \
+ fi \
+ done
+ @echo ""
+ @echo "Note: this does NOT edit ~/.claude/settings.json — remove the hook entries manually."
##@ Per-project language bundles
diff --git a/hooks/README.md b/hooks/README.md
new file mode 100644
index 0000000..847b81b
--- /dev/null
+++ b/hooks/README.md
@@ -0,0 +1,113 @@
+# 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)` | Gates `git commit` behind a confirmation modal showing the parsed message, staged files, diff stats, and git author. 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). |
+
+## 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/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())
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 <<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 "(commit message not parseable from command line; will be edited interactively)"
+
+
+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
+) -> 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())
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..2a8ac54
--- /dev/null
+++ b/hooks/settings-snippet.json
@@ -0,0 +1,20 @@
+{
+ "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" }
+ ]
+ }
+ ]
+ }
+}
diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh
index 6f93d48..803badf 100755
--- a/languages/elisp/claude/hooks/validate-el.sh
+++ b/languages/elisp/claude/hooks/validate-el.sh
@@ -1,14 +1,32 @@
#!/usr/bin/env bash
# Validate and test .el files after Edit/Write/MultiEdit.
# PostToolUse hook: receives tool-call JSON on stdin.
-# Silent on success; on failure, prints emacs output and exits 2
-# so Claude sees the error and can correct it.
+#
+# On success: exit 0 silent.
+# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
+# sees a structured error in its context, THEN exit 2 to block the tool
+# pipeline. stderr still echoes the error for terminal visibility.
#
# Phase 1: check-parens + byte-compile
-# Phase 2: for modules/*.el, run matching tests/test-<stem>*.el
+# Phase 2: for non-test .el files, run matching tests/test-<stem>*.el
set -u
+# Emit a JSON failure payload and exit 2. Arguments:
+# $1 — short failure type (e.g. "PAREN CHECK FAILED")
+# $2 — file path
+# $3 — emacs output (error body)
+fail_json() {
+ local ctx
+ ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
+ | jq -Rs .)"
+ cat <<EOF
+{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
+EOF
+ printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2
+ exit 2
+}
+
# Portable project root: prefer Claude Code's env var, fall back to deriving
# from this script's location ($project/.claude/hooks/validate-el.sh).
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
@@ -25,8 +43,7 @@ case "$f" in
# Byte-compile here would load the full package graph. Parens only.
if ! output="$(emacs --batch --no-site-file --no-site-lisp "$f" \
--eval '(check-parens)' 2>&1)"; then
- printf 'PAREN CHECK FAILED: %s\n%s\n' "$f" "$output" >&2
- exit 2
+ fail_json "PAREN CHECK FAILED" "$f" "$output"
fi
;;
*.el)
@@ -38,8 +55,7 @@ case "$f" in
"$f" \
--eval '(check-parens)' \
--eval "(or (byte-compile-file \"$f\") (kill-emacs 1))" 2>&1)"; then
- printf 'VALIDATION FAILED: %s\n%s\n' "$f" "$output" >&2
- exit 2
+ fail_json "VALIDATION FAILED" "$f" "$output"
fi
;;
esac
@@ -79,8 +95,7 @@ if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then
--eval '(package-initialize)' \
-l ert "${load_args[@]}" \
--eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then
- printf 'TESTS FAILED for %s (%d test file(s)):\n%s\n' "$f" "$count" "$output" >&2
- exit 2
+ fail_json "TESTS FAILED ($count test file(s))" "$f" "$output"
fi
fi