aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 11:57:23 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 11:57:23 -0500
commit18fcaf9f27d03849487078b30f667c3b574e6554 (patch)
tree67e3ede717fbf9fbfe10c46042451e15abeeb91f
parentf8c593791ae051b07dba2606c18f1deb7589825e (diff)
downloadrulesets-18fcaf9f27d03849487078b30f667c3b574e6554.tar.gz
rulesets-18fcaf9f27d03849487078b30f667c3b574e6554.zip
feat: add per-project language bundles + elisp ruleset
Introduces a second install mode alongside the existing global symlinks: per-project language bundles that copy a language-specific Claude Code setup (rules, hooks, settings, pre-commit) into a target project. Layout additions: languages/elisp/ - Emacs Lisp bundle (rules, hooks, settings, CLAUDE.md) scripts/install-lang.sh - shared install logic Makefile additions: make help - unified help text make install-lang LANG=<lang> PROJECT=<path> [FORCE=1] make install-elisp PROJECT=<path> [FORCE=1] (shortcut) make list-languages - show available bundles Elisp bundle contents: - CLAUDE.md template (seed on first install, preserved on update) - .claude/rules/elisp.md, elisp-testing.md, verification.md - .claude/hooks/validate-el.sh (check-parens, byte-compile, run matching tests) - .claude/settings.json (permission allowlist, hook wiring) - githooks/pre-commit (secret scan + staged-file paren check) - gitignore-add.txt (append .claude/settings.local.json) Hooks use \$CLAUDE_PROJECT_DIR with a script-relative fallback, so the same bundle works on any machine or clone path. Install activates git hooks via core.hooksPath=githooks automatically. Re-running install is idempotent; CLAUDE.md is never overwritten without FORCE=1.
-rw-r--r--Makefile35
-rw-r--r--README.org86
-rw-r--r--languages/elisp/CLAUDE.md66
-rw-r--r--languages/elisp/claude/hooks/validate-el.sh79
-rw-r--r--languages/elisp/claude/rules/elisp-testing.md78
-rw-r--r--languages/elisp/claude/rules/elisp.md75
-rw-r--r--languages/elisp/claude/rules/verification.md42
-rw-r--r--languages/elisp/claude/settings.json70
-rwxr-xr-xlanguages/elisp/githooks/pre-commit50
-rw-r--r--languages/elisp/gitignore-add.txt3
-rwxr-xr-xscripts/install-lang.sh97
11 files changed, 680 insertions, 1 deletions
diff --git a/Makefile b/Makefile
index 92933b7..e42662e 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,27 @@ SKILLS_DIR := $(HOME)/.claude/skills
RULES_DIR := $(HOME)/.claude/rules
SKILLS := c4-analyze c4-diagram debug add-tests respond-to-review review-pr fix-issue security-check
RULES := $(wildcard claude-rules/*.md)
+LANGUAGES := $(notdir $(wildcard languages/*))
-.PHONY: install uninstall list
+.PHONY: help install uninstall list \
+ install-lang list-languages install-elisp
+
+help:
+ @echo "rulesets — Claude Code skills, rules, and language bundles"
+ @echo ""
+ @echo " Global install (symlinks into ~/.claude/):"
+ @echo " make install - Install skills and rules globally"
+ @echo " make uninstall - Remove the symlinks"
+ @echo " make list - Show install status"
+ @echo ""
+ @echo " Per-project language rulesets:"
+ @echo " make install-lang LANG=<lang> PROJECT=<path> [FORCE=1]"
+ @echo " make install-elisp PROJECT=<path> [FORCE=1] (shortcut)"
+ @echo " make list-languages - Show available language bundles"
+ @echo ""
+ @echo " FORCE=1 overwrites an existing CLAUDE.md (other files always overwrite)."
+ @echo ""
+ @echo "Available languages: $(LANGUAGES)"
install:
@mkdir -p $(SKILLS_DIR) $(RULES_DIR)
@@ -77,3 +96,17 @@ list:
echo " - $$name"; \
fi \
done
+
+# --- Per-project language rulesets ---
+
+list-languages:
+ @echo "Available language rulesets (languages/):"
+ @for lang in $(LANGUAGES); do echo " - $$lang"; done
+
+install-lang:
+ @test -n "$(LANG)" || { echo "ERROR: set LANG=<language> (try: make list-languages)"; exit 1; }
+ @test -n "$(PROJECT)" || { echo "ERROR: set PROJECT=<path>"; exit 1; }
+ @bash scripts/install-lang.sh "$(LANG)" "$(PROJECT)" "$(FORCE)"
+
+install-elisp:
+ @$(MAKE) install-lang LANG=elisp PROJECT="$(PROJECT)" FORCE="$(FORCE)"
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..f46eef2
--- /dev/null
+++ b/README.org
@@ -0,0 +1,86 @@
+#+TITLE: Rulesets
+#+AUTHOR: Craig Jennings
+
+Claude Code skills, rules, and per-language project bundles.
+
+* Layout
+
+| Directory | Purpose |
+|-----------------+------------------------------------------------------------------|
+| =claude-rules/= | Generic rules symlinked into =~/.claude/rules/= (apply globally) |
+| =<skill>/= | Skill directories symlinked into =~/.claude/skills/= |
+| =languages/= | Per-language project bundles (rules + hooks + settings) |
+| =scripts/= | Install helpers |
+
+* Two install modes
+
+** Global (machine-wide)
+
+Symlinks skills and generic rules into =~/.claude/=. Run once per machine.
+
+#+begin_src bash
+make install # symlink skills and rules into ~/.claude/
+make uninstall # remove the symlinks
+make list # show what's installed
+#+end_src
+
+Skills and generic rules apply to every Claude Code session on this machine.
+
+** Per-project language bundles
+
+Copies a language-specific ruleset into a target project. Re-run to refresh.
+
+#+begin_src bash
+make install-elisp PROJECT=~/projects/my-elisp-thing
+# or, explicit:
+make install-lang LANG=elisp PROJECT=~/projects/my-elisp-thing
+
+make list-languages # show available bundles
+#+end_src
+
+What gets installed:
+- =.claude/rules/*.md= — project-scoped rules (language-specific + verification)
+- =.claude/hooks/= — PostToolUse validation scripts
+- =.claude/settings.json= — permission allowlist + hook wiring
+- =githooks/= — git hooks (activated via =core.hooksPath=)
+- =CLAUDE.md= — seeded on first install only (use =FORCE=1= to overwrite)
+- =.gitignore= — appends personal-override entries (deduped)
+
+The install is re-runnable. Running it again refreshes files in place; personal
+tweaks live in =.claude/settings.local.json= and are not touched.
+
+* Available languages
+
+| Language | Path | Notes |
+|----------+------------------+----------------------------------------------|
+| elisp | =languages/elisp/= | Emacs Lisp — ERT, check-parens, byte-compile |
+
+Add more by creating =languages/<name>/= with the same structure.
+
+* Bundle structure
+
+Each language bundle under =languages/<lang>/= follows:
+
+#+begin_example
+languages/<lang>/
+├── CLAUDE.md # project instructions template (seed only)
+├── claude/ # copied into <project>/.claude/
+│ ├── rules/*.md
+│ ├── hooks/*.sh
+│ └── settings.json
+├── githooks/ # copied into <project>/githooks/
+│ └── pre-commit
+└── gitignore-add.txt # lines appended to <project>/.gitignore
+#+end_example
+
+* Design principles
+
+- *Authoritative source*: =.claude/= and =githooks/= overwrite on every install.
+ If you edit them in-project, your changes will be lost on next install. Put
+ per-project customizations in =.claude/settings.local.json= (gitignored) or
+ project-specific files outside =.claude/=.
+- *CLAUDE.md is precious*: it's the one file with project-specific prose, so
+ install never overwrites it unless =FORCE=1=.
+- *Portable paths*: hooks use =$CLAUDE_PROJECT_DIR= (Claude Code sets it) with
+ a script-relative fallback. No hardcoded usernames or paths.
+- *Idempotent*: re-running install is always safe. No state beyond file contents.
diff --git a/languages/elisp/CLAUDE.md b/languages/elisp/CLAUDE.md
new file mode 100644
index 0000000..9b95e4f
--- /dev/null
+++ b/languages/elisp/CLAUDE.md
@@ -0,0 +1,66 @@
+# CLAUDE.md
+
+## Project
+
+Elisp project. Customize this section with your own description, layout, and conventions.
+
+**Typical layout:**
+- `init.el`, `early-init.el` — entry points (Emacs config projects)
+- `modules/*.el` — feature modules
+- `tests/test-*.el` — ERT unit tests
+- `tests/testutil-*.el` — shared test fixtures and mocks
+
+## Build & Test Commands
+
+If the project has a Makefile, document targets here. Common pattern:
+
+```bash
+make test # All tests
+make test-file FILE=tests/test-foo.el # One file
+make test-name TEST=pattern # Match test names
+make validate-parens # Balanced parens in modules
+make validate-modules # Load all modules to verify they compile
+make compile # Byte-compile (writes .elc)
+make lint # checkdoc + package-lint + elisp-lint
+```
+
+Alternative build tools: `eldev`, `cask`, or direct `emacs --batch` invocations.
+
+## Language Rules
+
+See rule files in `.claude/rules/`:
+- `elisp.md` — code style and patterns
+- `elisp-testing.md` — ERT conventions
+- `verification.md` — verify-before-claim-done discipline
+
+## Git Workflow
+
+- Conventional commit prefixes: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
+- Pre-commit hook in `githooks/` scans for secrets and runs `check-parens` on staged `.el` files
+- Activate on fresh clone: `git config core.hooksPath githooks`
+
+## Problem-Solving Approach
+
+Investigate before fixing. When diagnosing a bug:
+1. Read the relevant module and trace what actually happens
+2. Identify the root cause, not a surface symptom
+3. Write a failing test that captures the correct behavior
+4. Fix, then re-run tests
+
+## Testing Discipline
+
+TDD is the default: write a failing test before any implementation. If you can't write the test, you don't yet understand the change. Details in `.claude/rules/elisp-testing.md`.
+
+## Editing Discipline
+
+A PostToolUse hook runs `check-parens` + `byte-compile-file` on every `.el` file after Edit/Write/MultiEdit. Byte-compile warnings (free variables, wrong argument counts) are signal — read them.
+
+Prefer Write over cumulative Edits for nontrivial new code. Small functions (under 15 lines) are near-impossible to get wrong; deeply nested code is where paren errors hide.
+
+## What Not to Do
+
+- Don't add features beyond what was asked
+- Don't refactor surrounding code when fixing a bug
+- Don't add comments to code you didn't change
+- Don't create abstractions for one-time operations
+- Don't commit `.env` files, credentials, or API keys — pre-commit hook catches common patterns but isn't a substitute for care
diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh
new file mode 100644
index 0000000..5fd4241
--- /dev/null
+++ b/languages/elisp/claude/hooks/validate-el.sh
@@ -0,0 +1,79 @@
+#!/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.
+#
+# Phase 1: check-parens + byte-compile
+# Phase 2: for modules/*.el, run matching tests/test-<stem>*.el
+
+set -u
+
+# 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)}"
+
+f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
+[ -z "$f" ] && exit 0
+[ "${f##*.}" = "el" ] || exit 0
+
+MAX_AUTO_TEST_FILES=20 # skip if more matches than this (large test suites)
+
+# --- Phase 1: syntax + byte-compile ---
+case "$f" in
+ */init.el|*/early-init.el)
+ # 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
+ fi
+ ;;
+ *.el)
+ if ! output="$(emacs --batch --no-site-file --no-site-lisp \
+ -L "$PROJECT_ROOT" \
+ -L "$PROJECT_ROOT/modules" \
+ -L "$PROJECT_ROOT/tests" \
+ "$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
+ fi
+ ;;
+esac
+
+# --- Phase 2: test runner ---
+# Determine which tests (if any) apply to this edit.
+tests=()
+case "$f" in
+ "$PROJECT_ROOT/modules/"*.el)
+ stem="$(basename "${f%.el}")"
+ mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort)
+ ;;
+ "$PROJECT_ROOT/tests/testutil-"*.el)
+ stem="$(basename "${f%.el}")"
+ stem="${stem#testutil-}"
+ mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort)
+ ;;
+ "$PROJECT_ROOT/tests/test-"*.el)
+ tests=("$f")
+ ;;
+esac
+
+count="${#tests[@]}"
+if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then
+ load_args=()
+ for t in "${tests[@]}"; do load_args+=("-l" "$t"); done
+ if ! output="$(emacs --batch --no-site-file --no-site-lisp \
+ -L "$PROJECT_ROOT" \
+ -L "$PROJECT_ROOT/modules" \
+ -L "$PROJECT_ROOT/tests" \
+ -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
+ fi
+fi
+
+exit 0
diff --git a/languages/elisp/claude/rules/elisp-testing.md b/languages/elisp/claude/rules/elisp-testing.md
new file mode 100644
index 0000000..fcad9de
--- /dev/null
+++ b/languages/elisp/claude/rules/elisp-testing.md
@@ -0,0 +1,78 @@
+# Elisp Testing Rules
+
+Applies to: `**/tests/*.el`
+
+## Framework: ERT
+
+Use `ert-deftest` for all tests. One test = one scenario.
+
+## File Layout
+
+- `tests/test-<module>.el` — tests for `modules/<module>.el`
+- `tests/test-<module>--<helper>.el` — tests for a specific private helper (matches `<module>--<helper>` function naming)
+- `tests/testutil-<module>.el` — fixtures and mocks for one module
+- `tests/testutil-general.el`, `testutil-filesystem.el`, `testutil-org.el` — cross-module helpers
+
+Tests must `(require 'module-name)` before the testutil file that stubs its internals, unless documented otherwise. Order matters — a testutil that defines a stub can be shadowed by a later `require` of the real module.
+
+## Test Naming
+
+```elisp
+(ert-deftest test-<module>-<function>-<scenario> ()
+ "Normal/Boundary/Error: brief description."
+ ...)
+```
+
+Put the category (Normal, Boundary, Error) in the docstring so the category is grep-able.
+
+## Required Coverage
+
+Every non-trivial function needs at least:
+- One **Normal** case (happy path)
+- One **Boundary** case (empty, nil, min, max, unicode, long string)
+- One **Error** case (invalid input, missing resource, failure mode)
+
+Missing a category is a test gap. If three cases look near-identical, parametrize with a loop or `dolist` rather than copy-pasting.
+
+## TDD Workflow
+
+Write the failing test first. A failing test proves you understand the change. Assume the bug is in production code until the test proves otherwise — never fix the test before proving the test is wrong.
+
+For untested code, write a **characterization test** that captures current behavior before you change anything. It becomes the safety net for the refactor.
+
+## Mocking
+
+Mock at boundaries:
+- Shell: `cl-letf` on `shell-command`, `shell-command-to-string`, `call-process`
+- File I/O when tests shouldn't touch disk
+- Network: URL retrievers, HTTP clients
+- Time: `cl-letf` on `current-time`, `format-time-string`
+
+Never mock:
+- The code under test
+- Core Emacs primitives (buffer ops, string ops, lists)
+- Your own domain logic — restructure it to be testable instead
+
+## Idioms
+
+- `cl-letf` for scoped overrides (self-cleaning)
+- `with-temp-buffer` for buffer manipulation tests
+- `make-temp-file` with `.el` suffix for on-disk fixtures
+- Tests must run in any order; no shared mutable state
+
+## Running Tests
+
+```bash
+make test # All
+make test-file FILE=tests/test-foo.el # One file
+make test-name TEST=pattern # Match by test name pattern
+```
+
+A PostToolUse hook runs matching tests automatically after edits to a module, when the match count is small enough to be fast.
+
+## Anti-Patterns
+
+- Hardcoded timestamps — generate relative to `current-time` or mock
+- Testing implementation details (private storage structure) instead of behavior
+- Mocking the thing you're testing
+- Skipping a failing test without an issue to track it
diff --git a/languages/elisp/claude/rules/elisp.md b/languages/elisp/claude/rules/elisp.md
new file mode 100644
index 0000000..e641058
--- /dev/null
+++ b/languages/elisp/claude/rules/elisp.md
@@ -0,0 +1,75 @@
+# Elisp / Emacs Rules
+
+Applies to: `**/*.el`
+
+## Style
+
+- 2-space indent, no tabs
+- Hyphen-case for identifiers: `cj/do-thing`, not `cj/doThing`
+- Naming prefixes:
+ - `cj/name` — user-facing functions and commands (bound to keys, called from init)
+ - `cj/--name` — private helpers (double-dash signals "internal")
+ - `<module>/name` — module-scoped where appropriate (e.g., `calendar-sync/parse-ics`)
+- File header: `;;; foo-config.el --- brief description -*- lexical-binding: t -*-`
+- `(provide 'foo-config)` at the bottom of every module
+- `lexical-binding: t` is mandatory — no file without it
+
+## Function Design
+
+- Keep functions under 15 lines where possible
+- One responsibility per function
+- Extract helpers instead of nesting deeply — 5+ levels of nesting is a refactor signal
+- Prefer named helpers over lambdas for anything nontrivial
+- No premature abstraction — three similar lines beats a clever macro
+
+Small functions are the single strongest defense against paren errors. Deeply nested code is where AI and humans both fail.
+
+## Requires and Loading
+
+- Every `(require 'foo)` must correspond to a loadable file on the load-path
+- Byte-compile warnings about free variables usually indicate a missing `require` or a typo in a symbol name — read them
+- Use `use-package` for external (MELPA/ELPA) packages
+- Use plain `(require 'foo-config)` for internal modules
+- For optional features, `(when (require 'foo nil t) ...)` degrades gracefully if absent
+
+## Lexical-Binding Traps
+
+- `(boundp 'x)` where `x` is a lexical variable always returns nil. Bind with `defvar` at top level if you need `boundp` to work, or use the value directly.
+- `setq` on an undeclared free variable is a warning — use `let` for locals or `defvar` for module-level state
+- Closures capture by reference. Avoid capturing mutating loop variables in nested defuns.
+
+## Regex Gotchas
+
+- `\s` is NOT whitespace in Emacs regex. Use `[ \t]` or `\\s-` (syntax class).
+- `^` in `string-match` matches after `\n` OR at position 0 — use `(= (match-beginning 0) start)` for positional checks when that matters.
+- `replace-regexp-in-string` interprets backslashes in the replacement. Pass `t t` (FIXEDCASE LITERAL) when the replacement contains literal backslashes.
+
+## Keybindings
+
+- `keymap-global-set` for global; `keymap-set KEYMAP ...` for mode-local
+- Group module-specific bindings inside the module's file
+- Autoload cookies (`;;;###autoload`) don't activate through plain `(require ...)` — use the form directly, not an autoloaded wrapper
+
+## Module Template
+
+```elisp
+;;; foo-config.el --- Foo feature configuration -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; One-line description.
+
+;;; Code:
+
+;; ... code ...
+
+(provide 'foo-config)
+;;; foo-config.el ends here
+```
+
+Then `(require 'foo-config)` in `init.el` (or a config aggregator).
+
+## Editing Workflow
+
+- A PostToolUse hook runs `check-parens` and `byte-compile-file` on every `.el` save
+- If it blocks, read the error — don't retry blindly
+- Prefer Write over repeated Edits for nontrivial new code; incremental edits accumulate subtle paren mismatches
diff --git a/languages/elisp/claude/rules/verification.md b/languages/elisp/claude/rules/verification.md
new file mode 100644
index 0000000..8993736
--- /dev/null
+++ b/languages/elisp/claude/rules/verification.md
@@ -0,0 +1,42 @@
+# Verification Before Completion
+
+Applies to: `**/*`
+
+## The Rule
+
+Do not claim work is done without fresh verification evidence. Run the command, read the output, confirm it matches the claim, then — and only then — declare success.
+
+This applies to every completion claim:
+- "Tests pass" → Run the test suite. Read the output. Confirm all green.
+- "Linter is clean" → Run the linter. Read the output. Confirm no warnings.
+- "Build succeeds" → Run the build. Read the output. Confirm no errors.
+- "Bug is fixed" → Run the reproduction steps. Confirm the bug is gone.
+- "No regressions" → Run the full test suite, not just the tests you added.
+
+## What Fresh Means
+
+- Run the verification command **now**, in the current session
+- Do not rely on a previous run from before your changes
+- Do not assume your changes didn't break something unrelated
+- Do not extrapolate from partial output — read the whole result
+
+## Red Flags
+
+If you find yourself using these words, you haven't verified:
+
+- "should" ("tests should pass")
+- "probably" ("this probably works")
+- "I believe" ("I believe the build is clean")
+- "based on the changes" ("based on the changes, nothing should break")
+
+Replace beliefs with evidence. Run the command.
+
+## Before Committing
+
+Before any commit:
+1. Run the test suite — confirm all tests pass
+2. Run the linter — confirm no new warnings
+3. Run the type checker — confirm no new errors
+4. Review the diff — confirm only intended changes are staged
+
+Do not commit based on the assumption that nothing broke. Verify.
diff --git a/languages/elisp/claude/settings.json b/languages/elisp/claude/settings.json
new file mode 100644
index 0000000..cca0eaa
--- /dev/null
+++ b/languages/elisp/claude/settings.json
@@ -0,0 +1,70 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(make)",
+ "Bash(make help)",
+ "Bash(make targets)",
+ "Bash(make test)",
+ "Bash(make test *)",
+ "Bash(make test-all)",
+ "Bash(make test-unit)",
+ "Bash(make test-integration)",
+ "Bash(make test-file *)",
+ "Bash(make test-name *)",
+ "Bash(make validate-parens)",
+ "Bash(make validate-modules)",
+ "Bash(make compile)",
+ "Bash(make lint)",
+ "Bash(make profile)",
+ "Bash(emacs --batch *)",
+ "Bash(emacs -Q --batch *)",
+ "Bash(git status)",
+ "Bash(git status *)",
+ "Bash(git diff)",
+ "Bash(git diff *)",
+ "Bash(git log)",
+ "Bash(git log *)",
+ "Bash(git show)",
+ "Bash(git show *)",
+ "Bash(git blame *)",
+ "Bash(git branch)",
+ "Bash(git branch -v)",
+ "Bash(git branch -a)",
+ "Bash(git branch --list *)",
+ "Bash(git remote)",
+ "Bash(git remote -v)",
+ "Bash(git remote show *)",
+ "Bash(git ls-files *)",
+ "Bash(git rev-parse *)",
+ "Bash(git cat-file *)",
+ "Bash(git stash list)",
+ "Bash(git stash show *)",
+ "Bash(jq *)",
+ "Bash(date)",
+ "Bash(date *)",
+ "Bash(which *)",
+ "Bash(file *)",
+ "Bash(ls)",
+ "Bash(ls *)",
+ "Bash(wc *)",
+ "Bash(du *)",
+ "Bash(readlink *)",
+ "Bash(realpath *)",
+ "Bash(basename *)",
+ "Bash(dirname *)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-el.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/languages/elisp/githooks/pre-commit b/languages/elisp/githooks/pre-commit
new file mode 100755
index 0000000..909cde2
--- /dev/null
+++ b/languages/elisp/githooks/pre-commit
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Pre-commit hook: secret scan + paren validation on staged .el files.
+# Use `git commit --no-verify` to bypass for confirmed false positives.
+
+set -u
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+# --- 1. Secret scan ---
+# Patterns for common credentials. Scans only added lines in the staged diff.
+SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])'
+
+secret_hits="$(git diff --cached -U0 --diff-filter=AM \
+ | grep '^+' | grep -v '^+++' \
+ | grep -iEn "$SECRET_PATTERNS" || true)"
+
+if [ -n "$secret_hits" ]; then
+ echo "pre-commit: potential secret in staged changes:" >&2
+ echo "$secret_hits" >&2
+ echo "" >&2
+ echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2
+ echo "bypass with: git commit --no-verify" >&2
+ exit 1
+fi
+
+# --- 2. Paren check on staged .el files ---
+staged_el="$(git diff --cached --name-only --diff-filter=AM | grep '\.el$' || true)"
+
+if [ -n "$staged_el" ]; then
+ paren_fail=""
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ if ! out="$(emacs --batch --no-site-file --no-site-lisp "$f" \
+ --eval '(check-parens)' 2>&1)"; then
+ paren_fail="${paren_fail}${f}:
+${out}
+
+"
+ fi
+ done <<< "$staged_el"
+
+ if [ -n "$paren_fail" ]; then
+ printf 'pre-commit: paren check failed:\n\n%s' "$paren_fail" >&2
+ exit 1
+ fi
+fi
+
+exit 0
diff --git a/languages/elisp/gitignore-add.txt b/languages/elisp/gitignore-add.txt
new file mode 100644
index 0000000..d570607
--- /dev/null
+++ b/languages/elisp/gitignore-add.txt
@@ -0,0 +1,3 @@
+# Claude Code — personal overrides (not part of the ruleset)
+/.claude/settings.local.json
+/.claude/.cache/
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh
new file mode 100755
index 0000000..f9b7a31
--- /dev/null
+++ b/scripts/install-lang.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# Install a language ruleset into a target project.
+# Usage: install-lang.sh <language> <project-path> [force]
+#
+# Copies the language's ruleset files into the project. Re-runnable
+# (authoritative source overwrites). CLAUDE.md is preserved unless
+# force=1, to avoid trampling project-specific customizations.
+
+set -euo pipefail
+
+LANG="${1:-}"
+PROJECT="${2:-}"
+FORCE="${3:-}"
+
+if [ -z "$LANG" ] || [ -z "$PROJECT" ]; then
+ echo "Usage: $0 <language> <project-path> [force]" >&2
+ exit 1
+fi
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SRC="$REPO_ROOT/languages/$LANG"
+
+if [ ! -d "$SRC" ]; then
+ echo "ERROR: no ruleset for language '$LANG' (expected $SRC)" >&2
+ exit 1
+fi
+
+if [ ! -d "$PROJECT" ]; then
+ echo "ERROR: project path does not exist: $PROJECT" >&2
+ exit 1
+fi
+
+# Resolve to absolute path
+PROJECT="$(cd "$PROJECT" && pwd)"
+
+echo "Installing '$LANG' ruleset into $PROJECT"
+
+# 1. .claude/ — rules, hooks, settings (authoritative, always overwrite)
+if [ -d "$SRC/claude" ]; then
+ mkdir -p "$PROJECT/.claude"
+ cp -rT "$SRC/claude" "$PROJECT/.claude"
+ if [ -d "$PROJECT/.claude/hooks" ]; then
+ find "$PROJECT/.claude/hooks" -type f -name '*.sh' -exec chmod +x {} \;
+ fi
+ echo " [ok] .claude/ installed"
+fi
+
+# 2. githooks/ — pre-commit etc.
+if [ -d "$SRC/githooks" ]; then
+ mkdir -p "$PROJECT/githooks"
+ cp -rT "$SRC/githooks" "$PROJECT/githooks"
+ find "$PROJECT/githooks" -type f -exec chmod +x {} \;
+ if [ -d "$PROJECT/.git" ]; then
+ git -C "$PROJECT" config core.hooksPath githooks
+ echo " [ok] githooks/ installed, core.hooksPath=githooks"
+ else
+ echo " [ok] githooks/ installed (not a git repo — skipped core.hooksPath)"
+ fi
+fi
+
+# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1
+if [ -f "$SRC/CLAUDE.md" ]; then
+ if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then
+ echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)"
+ else
+ cp "$SRC/CLAUDE.md" "$PROJECT/CLAUDE.md"
+ echo " [ok] CLAUDE.md installed"
+ fi
+fi
+
+# 4. .gitignore — append missing lines (deduped, skip comments)
+if [ -f "$SRC/gitignore-add.txt" ]; then
+ touch "$PROJECT/.gitignore"
+ added=0
+ while IFS= read -r line || [ -n "$line" ]; do
+ # Skip blank lines and comments in the source file
+ [ -z "$line" ] && continue
+ case "$line" in \#*) continue ;; esac
+ # Only add if not already present
+ if ! grep -qxF "$line" "$PROJECT/.gitignore"; then
+ # If this is the first line we're adding, prepend a header
+ if [ "$added" -eq 0 ]; then
+ printf '\n# --- %s ruleset ---\n' "$LANG" >> "$PROJECT/.gitignore"
+ fi
+ echo "$line" >> "$PROJECT/.gitignore"
+ added=$((added + 1))
+ fi
+ done < "$SRC/gitignore-add.txt"
+ if [ "$added" -gt 0 ]; then
+ echo " [ok] .gitignore: $added line(s) added"
+ else
+ echo " [skip] .gitignore entries already present"
+ fi
+fi
+
+echo ""
+echo "Install complete."