Add composable multi-language linting and formatting system

PostToolUse hook auto-formats files after Edit/Write/MultiEdit with
git-blob checkpoints for safe revert. Pre-commit hook for staged files.
Canonical formatter scripts for py, sh, ts, sql, json (+ symlinks for
js, yaml, md). Install and setup scripts for project opt-in.

Includes best-practices/linting.md, HOOKS.md docs, README.md, and
session log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul O'Reilly
2026-03-13 11:50:36 +13:00
parent a463dc0793
commit d3a92326de
18 changed files with 700 additions and 5 deletions

View File

@@ -7,6 +7,32 @@ Claude Code hooks that run automatically in response to events. Source of truth
| Hook | Event | File | | Hook | Event | File |
|------|-------|------| |------|-------|------|
| Pre-compact backup | PreCompact (auto + manual) | `hooks/pre-compact-backup.sh` | | Pre-compact backup | PreCompact (auto + manual) | `hooks/pre-compact-backup.sh` |
| Post-edit lint | PostToolUse (Edit/Write/MultiEdit) | `hooks/post-edit-lint.sh` |
| Pre-commit lint | Git pre-commit | `hooks/pre-commit-lint.sh` |
## Post-edit Lint
Formats and lints files after every Edit/Write/MultiEdit. Dispatches to project-local `formatters/` directory (symlinks to `claude-foundations/formatters/`).
**Flow:**
1. Extracts `file_path` and `cwd` from stdin JSON
2. Walks up from `cwd` to find `formatters/` directory
3. Creates a `git hash-object` checkpoint (`.pre-lint` file with blob SHA)
4. Runs the matching formatter script
5. On clean pass: removes checkpoint, exits 0 (silent)
6. On lint errors: keeps checkpoint, exits 2 (feeds errors back to Claude via stderr)
**Requires:** `python3` (for JSON parsing from stdin). Formatter tools are optional — missing tools silently pass.
**Revert:** `git cat-file blob $(cat <file>.pre-lint) > <file>`
## Pre-commit Lint
Git pre-commit hook that runs project formatters on staged files. Reuses the same formatter scripts.
**Flow:** iterates staged files → runs matching formatter → re-stages formatted files → exits non-zero if lint errors remain (blocks commit).
**Install:** symlink `.git/hooks/pre-commit``pre-commit-lint.sh`, or use `scripts/setup-formatters.sh`.
## Pre-compact Backup ## Pre-compact Backup
@@ -18,10 +44,23 @@ Saves a copy of the session transcript before context compaction so no conversat
## Installation ## Installation
The easiest way is to run the install script:
```bash
cd ~/dev/claude/projects/claude-foundations
scripts/install-hooks.sh
```
This symlinks all hooks into `~/.claude/hooks/` and prints the `settings.json` config to add.
### Manual installation
1. Symlink each hook into `~/.claude/hooks/`: 1. Symlink each hook into `~/.claude/hooks/`:
```bash ```bash
mkdir -p ~/.claude/hooks mkdir -p ~/.claude/hooks
ln -sf "$(pwd)/hooks/pre-compact-backup.sh" ~/.claude/hooks/pre-compact-backup.sh ln -sf "$(pwd)/hooks/pre-compact-backup.sh" ~/.claude/hooks/pre-compact-backup.sh
ln -sf "$(pwd)/hooks/post-edit-lint.sh" ~/.claude/hooks/post-edit-lint.sh
ln -sf "$(pwd)/hooks/pre-commit-lint.sh" ~/.claude/hooks/pre-commit-lint.sh
``` ```
2. Add the hook configuration to `~/.claude/settings.json`: 2. Add the hook configuration to `~/.claude/settings.json`:
@@ -31,17 +70,28 @@ Saves a copy of the session transcript before context compaction so no conversat
"PreCompact": [ "PreCompact": [
{ {
"matcher": "auto", "matcher": "auto",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15 }] "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }]
}, },
{ {
"matcher": "manual", "matcher": "manual",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15 }] "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/post-edit-lint.sh", "timeout": 30, "statusMessage": "Formatting and linting..." }]
} }
] ]
} }
} }
``` ```
3. For per-project formatters, run:
```bash
scripts/setup-formatters.sh <project-dir> <ext> [<ext> ...]
```
## Adding New Hooks ## Adding New Hooks
1. Create the script in `hooks/` 1. Create the script in `hooks/`

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# claude-foundations
Shared infrastructure for Claude Code sessions: hooks, formatters, best practices, and the knowledge distillation pipeline.
## Repository Structure
```
claude-foundations/
formatters/ # Canonical formatter scripts (one per extension)
py # ruff format + ruff check
sh # shfmt + shellcheck
ts # biome format + biome check
js -> ts # symlink (biome handles both)
sql # sqlfluff fix + sqlfluff lint
json # prettier
yaml -> json # symlink (prettier auto-detects)
md -> json # symlink
hooks/
post-edit-lint.sh # PostToolUse: auto-format/lint on Edit/Write
pre-commit-lint.sh # Git pre-commit: lint staged files
pre-compact-backup.sh # PreCompact: backup transcript before compaction
scripts/
install-hooks.sh # Symlink hooks into ~/.claude/hooks/
setup-formatters.sh # Set up formatters for a project
best-practices/ # Generalised best practices (one file per topic)
memory/ # Session logs and reflections
settings.yaml # Knowledge pipeline configuration
CLAUDE.md # Global project guidelines (symlinked to ~/dev/claude/)
HOOKS.md # Hook documentation
TODO.md # Improvement backlog
```
## Quick Start
### 1. Install hooks (one-time)
```bash
cd ~/dev/claude/projects/claude-foundations
scripts/install-hooks.sh
```
This symlinks all hooks into `~/.claude/hooks/` and prints the `settings.json` config to add. The PostToolUse hook is what triggers auto-formatting on every Edit/Write.
### 2. Opt a project into auto-formatting
```bash
scripts/setup-formatters.sh ~/dev/claude/projects/<project-name> <ext> [<ext> ...]
```
For example, to enable Python and shell formatting for `cluster-bootstrap`:
```bash
scripts/setup-formatters.sh ~/dev/claude/projects/cluster-bootstrap py sh
```
This creates a `formatters/` directory in the target project with symlinks back to the canonical formatter scripts here. It also installs a git pre-commit hook if one doesn't exist.
**Available formatters:** `py`, `sh`, `ts`, `js`, `sql`, `json`, `yaml`, `md`
Run with no arguments to see the full list:
```bash
scripts/setup-formatters.sh
```
### 3. Install required tools
Each formatter gracefully skips if its tool isn't installed, but for full functionality:
| Formatter | Tools needed | Install |
|-----------|-------------|---------|
| `py` | ruff | `pip install ruff` or `pipx install ruff` |
| `sh` | shfmt, shellcheck | `sudo apt install shfmt shellcheck` |
| `ts`/`js` | biome | `npm i -g @biomejs/biome` |
| `sql` | sqlfluff | `pip install sqlfluff` |
| `json`/`yaml`/`md` | prettier | `npm i -g prettier` |
### 4. Add per-project config (optional)
Formatters respect project-level config files:
| Formatter | Config file | Purpose |
|-----------|------------|---------|
| `py` | `pyproject.toml` | ruff rules, line length, target Python version |
| `sh` | `.editorconfig` | shfmt indent style, binary ops, switch cases |
| `ts`/`js` | `biome.json` | biome rules, formatting options |
| `sql` | `.sqlfluff` | SQL dialect, rules |
| `json`/`yaml`/`md` | `.prettierrc` | prettier options |
Without config files, tools use their defaults.
### 5. Verify setup
Use the `/linter` skill inside a Claude Code session:
```
/linter
```
This scans the project, shows which formatters are active, which tools are installed, and suggests fixes for any gaps.
## How It Works
### PostToolUse hook (auto-format on edit)
When Claude edits or writes a file:
1. The `post-edit-lint.sh` hook fires
2. It walks up from the working directory to find `formatters/`
3. If a formatter exists for the file's extension, it:
- Creates a checkpoint (git blob hash stored in `.pre-lint`)
- Runs the formatter (format in place + lint)
- On clean pass: removes checkpoint, exits silently
- On lint errors: keeps checkpoint, exits with errors shown to Claude
### Pre-commit hook (lint on commit)
When you `git commit`, the `pre-commit-lint.sh` hook:
1. Finds staged files with matching formatters
2. Runs each formatter
3. Re-stages formatted files
4. Blocks the commit if lint errors remain
### Reverting after lint
If you don't like what the formatter did:
```bash
git cat-file blob "$(cat <file>.pre-lint)" > <file>
rm <file>.pre-lint
```
## Scripts
| Script | Purpose |
|--------|---------|
| `scripts/install-hooks.sh` | Symlink all hooks to `~/.claude/hooks/` and print settings.json config |
| `scripts/setup-formatters.sh` | Create formatter symlinks in a target project |

10
TODO.md
View File

@@ -2,9 +2,9 @@
Prioritised by impact and effort. Each item references its detailed topic file in `improvements/`. Prioritised by impact and effort. Each item references its detailed topic file in `improvements/`.
## 1. Configure PostToolUse auto-formatting hook ## 1. ~~Configure PostToolUse auto-formatting hook~~ → DONE
**Impact: High | Effort: Low** **Impact: High | Effort: Low**
Auto-format code after every Edit/Write. Removes all formatting rules from CLAUDE.md, saves tokens, and guarantees consistent output. Start with `shfmt` for shell and expand. Composable multi-language linting system implemented. Canonical formatters in `formatters/` (py, sh, ts, sql, json + symlinks for js, yaml, md). PostToolUse dispatcher hook with checkpoint/revert. Pre-commit hook. Setup scripts. `/linter` skill for auditing.
See: [hooks-and-automation.md](improvements/hooks-and-automation.md) See: [hooks-and-automation.md](improvements/hooks-and-automation.md)
## 2. ~~Create global `~/.claude/CLAUDE.md`~~ → DONE (parent symlink) ## 2. ~~Create global `~/.claude/CLAUDE.md`~~ → DONE (parent symlink)
@@ -61,3 +61,7 @@ See: [planning-and-workflow.md](improvements/planning-and-workflow.md)
- **Parent CLAUDE.md symlink** — `~/dev/claude/CLAUDE.md` → claude-foundations, all projects inherit automatically. - **Parent CLAUDE.md symlink** — `~/dev/claude/CLAUDE.md` → claude-foundations, all projects inherit automatically.
- **PreCompact transcript backup hook** — `~/.claude/hooks/pre-compact-backup.sh` with auto+manual matchers. - **PreCompact transcript backup hook** — `~/.claude/hooks/pre-compact-backup.sh` with auto+manual matchers.
- **claude-foundations Gitea repo** — Created under skynet org, pushed. - **claude-foundations Gitea repo** — Created under skynet org, pushed.
## Completed (2026-03-13)
- **PostToolUse auto-formatting hook** — Composable multi-language linting system: 5 formatter scripts + 3 symlinks, PostToolUse dispatcher with git-blob checkpoints, pre-commit hook, setup scripts, `/linter` skill, `best-practices/linting.md`.

View File

@@ -15,3 +15,4 @@ Generalised best practices extracted from real project work. Each topic file is
- [Milestones & Reflections](milestones.md) — Milestone workflow, verification, reflection process - [Milestones & Reflections](milestones.md) — Milestone workflow, verification, reflection process
- [Debugging Methodology](debugging.md) — Systematic diagnosis, full-chain testing, common pitfalls - [Debugging Methodology](debugging.md) — Systematic diagnosis, full-chain testing, common pitfalls
- [Claude Code Skills](skills-development.md) — Skill authoring, context injection, tool restrictions - [Claude Code Skills](skills-development.md) — Skill authoring, context injection, tool restrictions
- [Linting & Formatting](linting.md) — Tool choices per language, PostToolUse hook, pre-commit integration, formatter contract

94
best-practices/linting.md Normal file
View File

@@ -0,0 +1,94 @@
# Linting & Formatting
Automated code formatting and linting integrated into the Claude Code workflow via PostToolUse hooks and git pre-commit hooks.
## Architecture
```
claude-foundations/formatters/ ← canonical formatter scripts (one per extension)
project/formatters/ ← symlinks to the formatters this project uses
~/.claude/hooks/post-edit-lint.sh ← dispatches to project formatters on Edit/Write
.git/hooks/pre-commit ← symlink to pre-commit-lint.sh
```
Projects opt in by symlinking only the formatters they need. No formatter = no-op.
## Tool Choices by Language
| Language | Formatter | Linter | Config File |
|----------|-----------|--------|-------------|
| Python | `ruff format` | `ruff check` | `pyproject.toml` |
| Shell | `shfmt` | `shellcheck` | `.editorconfig` |
| TypeScript/JavaScript | `biome format` | `biome check` | `biome.json` |
| SQL | `sqlfluff fix` | `sqlfluff lint` | `.sqlfluff` |
| JSON/YAML/Markdown | `prettier` | — | `.prettierrc` |
### Why these tools?
- **ruff**: Rust-based, extremely fast, replaces black + isort + flake8 + pyflakes in one tool
- **biome**: Rust-based, replaces prettier + eslint for JS/TS in one tool
- **shfmt + shellcheck**: The standard combo for shell scripts; shfmt formats, shellcheck catches bugs
- **prettier**: Handles JSON/YAML/MD well; biome doesn't cover these yet
## Formatter Script Contract
Every script in `formatters/` follows the same interface:
- **Input:** `$1` = absolute file path
- **Behaviour:** format the file in place, then lint it
- **Stdout:** suppressed
- **Stderr:** lint warnings/errors that couldn't be auto-fixed
- **Exit code:** `0` if clean, `1` if lint errors remain
- **Missing tools:** exit `0` silently (`command -v` check)
- **No `set -e`:** individual commands may fail; execution must continue
## PostToolUse Integration
The `post-edit-lint.sh` hook fires on Edit/Write/MultiEdit:
1. Extracts `file_path` and `cwd` from stdin JSON
2. Walks up from `cwd` to find `formatters/` directory
3. Creates a checkpoint using `git hash-object` (fast, no commits)
4. Runs the matching formatter
5. On clean pass: removes checkpoint, exits 0 (silent)
6. On lint errors: keeps checkpoint, exits 2 (feeds errors to Claude)
Exit code 2 is special for PostToolUse — it feeds stderr back to Claude as feedback without blocking the edit.
## Checkpoint Mechanism
Uses `git hash-object -w` to store pre-format content as an orphan blob (~1ms, no commits, no stash). The `.pre-lint` file stores only a 40-char SHA. Falls back to `cp` outside git repos.
```bash
# Revert after lint errors:
git cat-file blob "$(cat file.py.pre-lint)" > file.py
rm file.py.pre-lint
```
## Subagent Fix Pattern
When lint errors occur, Claude sees the errors via stderr. The recommended workflow:
1. Claude reports the errors to the user
2. If the user says "fix", Claude spawns a subagent via the Agent tool
3. The subagent reads the file and errors, makes Edit calls to fix them
4. Each Edit triggers the hook again (re-format, re-lint) in the subagent
5. Fix iterations stay in the subagent's context, not the main conversation
## Pre-commit Integration
`pre-commit-lint.sh` reuses the same formatter scripts:
1. Iterates staged files (Added/Modified only)
2. Runs matching formatters
3. Re-stages formatted files
4. Exits non-zero if lint errors remain (blocks commit)
Install: symlink `.git/hooks/pre-commit``pre-commit-lint.sh`, or use `setup-formatters.sh` which does this automatically.
## Setup Checklist
1. Run `scripts/setup-formatters.sh <project> <ext> [<ext> ...]` to symlink formatters
2. Install the required tools (`ruff`, `shfmt`, `shellcheck`, `biome`, `prettier`, `sqlfluff`)
3. Create per-project config files as needed (`pyproject.toml`, `.editorconfig`, etc.)
4. Run `scripts/install-hooks.sh` to install the PostToolUse hook (one-time global setup)
5. Use `/linter scan` to verify everything is connected

1
formatters/js Symbolic link
View File

@@ -0,0 +1 @@
ts

19
formatters/json Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Formatter: JSON/YAML/Markdown — prettier
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
# Missing tools: exit 0 silently
FILE="$1"
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0
command -v prettier >/dev/null 2>&1 || exit 0
# Format in place (prettier auto-detects parser from extension)
ERRORS=$(prettier --write "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "$ERRORS" >&2
exit 1
fi
exit 0

1
formatters/md Symbolic link
View File

@@ -0,0 +1 @@
json

25
formatters/py Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Formatter: Python — ruff format + ruff check
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
# Missing tools: exit 0 silently
FILE="$1"
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0
command -v ruff >/dev/null 2>&1 || exit 0
# Format in place
ruff format --quiet "$FILE" 2>/dev/null
# Auto-fix what we can
ruff check --fix --quiet "$FILE" 2>/dev/null
# Final lint pass — remaining errors go to stderr
ERRORS=$(ruff check "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "$ERRORS" >&2
exit 1
fi
exit 0

31
formatters/sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Formatter: Shell — shfmt + shellcheck
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
# Missing tools: exit 0 silently
FILE="$1"
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0
HAD_ERRORS=0
# Format with shfmt if available
if command -v shfmt >/dev/null 2>&1; then
shfmt -w "$FILE" 2>/dev/null
fi
# Lint with shellcheck if available
if command -v shellcheck >/dev/null 2>&1; then
ERRORS=$(shellcheck -f gcc "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "$ERRORS" >&2
HAD_ERRORS=1
fi
fi
# If neither tool is installed, silently pass
if ! command -v shfmt >/dev/null 2>&1 && ! command -v shellcheck >/dev/null 2>&1; then
exit 0
fi
exit $HAD_ERRORS

22
formatters/sql Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Formatter: SQL — sqlfluff fix + sqlfluff lint
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
# Missing tools: exit 0 silently
FILE="$1"
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0
command -v sqlfluff >/dev/null 2>&1 || exit 0
# Auto-fix in place
sqlfluff fix --force --quiet "$FILE" 2>/dev/null
# Final lint pass — remaining errors go to stderr
ERRORS=$(sqlfluff lint "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "$ERRORS" >&2
exit 1
fi
exit 0

25
formatters/ts Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Formatter: TypeScript/JavaScript — biome format + biome check
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
# Missing tools: exit 0 silently
FILE="$1"
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0
command -v biome >/dev/null 2>&1 || exit 0
# Format in place
biome format --write "$FILE" 2>/dev/null
# Auto-fix what we can
biome check --fix "$FILE" 2>/dev/null
# Final lint pass — remaining errors go to stderr
ERRORS=$(biome check "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "$ERRORS" >&2
exit 1
fi
exit 0

1
formatters/yaml Symbolic link
View File

@@ -0,0 +1 @@
json

86
hooks/post-edit-lint.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# PostToolUse hook: format and lint edited/written files.
# Dispatches to project-local formatters/ directory (symlinks to claude-foundations/formatters/).
#
# Input: JSON on stdin with tool_name, file_path, cwd fields
# Exit 0: silent pass (no formatter, clean lint, or missing tools)
# Exit 2: lint errors remain — stderr fed back to Claude as feedback
# Parse stdin JSON for file_path and cwd
INPUT="$(cat)"
FILE_PATH="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path',''))" 2>/dev/null)"
CWD="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null)"
# No file path — nothing to do
[ -z "$FILE_PATH" ] && exit 0
# Make path absolute if relative
if [[ "$FILE_PATH" != /* ]]; then
FILE_PATH="${CWD:-.}/$FILE_PATH"
fi
# File must exist
[ -f "$FILE_PATH" ] || exit 0
# Extract extension (normalise yml → yaml)
EXT="${FILE_PATH##*.}"
case "$EXT" in
yml) EXT="yaml" ;;
esac
# No extension — nothing to do
[ -z "$EXT" ] || [ "$EXT" = "$FILE_PATH" ] && exit 0
# Find the project's formatters/ directory by walking up from cwd
find_formatters_dir() {
local dir="${1:-$CWD}"
while [ "$dir" != "/" ] && [ -n "$dir" ]; do
if [ -d "$dir/formatters" ]; then
echo "$dir/formatters"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
FORMATTERS_DIR="$(find_formatters_dir "$CWD")"
[ -z "$FORMATTERS_DIR" ] && exit 0
# Check if a formatter exists for this extension
FORMATTER="$FORMATTERS_DIR/$EXT"
[ -x "$FORMATTER" ] || exit 0
# --- Checkpoint: save pre-format content ---
if git -C "$(dirname "$FILE_PATH")" rev-parse --git-dir >/dev/null 2>&1; then
# Inside a git repo — use git hash-object for efficient checkpoint
BLOB="$(git -C "$(dirname "$FILE_PATH")" hash-object -w "$FILE_PATH" 2>/dev/null)"
if [ -n "$BLOB" ]; then
echo "$BLOB" > "${FILE_PATH}.pre-lint"
fi
else
# Not a git repo — fall back to file copy
cp "$FILE_PATH" "${FILE_PATH}.pre-lint"
fi
# --- Run formatter ---
LINT_OUTPUT=$("$FORMATTER" "$FILE_PATH" 2>&1)
FORMATTER_EXIT=$?
if [ $FORMATTER_EXIT -eq 0 ]; then
# Clean — remove checkpoint
rm -f "${FILE_PATH}.pre-lint"
exit 0
fi
# --- Lint errors remain ---
# Keep .pre-lint for revert, exit 2 to feed errors back to Claude
{
echo "Lint errors in ${FILE_PATH}:"
echo "$LINT_OUTPUT"
echo ""
echo "To fix: use the Agent tool to resolve these lint errors (keeps fix loop out of main context)."
echo "To revert formatting: git cat-file blob \$(cat ${FILE_PATH}.pre-lint) > ${FILE_PATH}"
} >&2
exit 2

54
hooks/pre-commit-lint.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Git pre-commit hook: format and lint staged files using project formatters.
# Reuses the same formatter scripts from formatters/.
# Exits non-zero if lint errors remain after auto-fix (blocks commit).
# Find formatters/ directory relative to repo root
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
[ -z "$REPO_ROOT" ] && exit 0
FORMATTERS_DIR="$REPO_ROOT/formatters"
[ -d "$FORMATTERS_DIR" ] || exit 0
HAD_ERRORS=0
ERROR_OUTPUT=""
# Iterate staged files (only Added/Modified, not Deleted)
while IFS= read -r FILE; do
[ -z "$FILE" ] && continue
ABS_FILE="$REPO_ROOT/$FILE"
[ -f "$ABS_FILE" ] || continue
# Extract extension (normalise yml → yaml)
EXT="${FILE##*.}"
case "$EXT" in
yml) EXT="yaml" ;;
esac
[ -z "$EXT" ] || [ "$EXT" = "$FILE" ] && continue
# Check for formatter
FORMATTER="$FORMATTERS_DIR/$EXT"
[ -x "$FORMATTER" ] || continue
# Run formatter
ERRORS=$("$FORMATTER" "$ABS_FILE" 2>&1)
EXIT_CODE=$?
# Re-stage the file (formatter may have modified it)
git add "$ABS_FILE"
if [ $EXIT_CODE -ne 0 ]; then
HAD_ERRORS=1
ERROR_OUTPUT="${ERROR_OUTPUT}${FILE}:\n${ERRORS}\n\n"
fi
done < <(git diff --cached --name-only --diff-filter=AM)
if [ $HAD_ERRORS -ne 0 ]; then
echo "Pre-commit lint errors — commit blocked:" >&2
echo -e "$ERROR_OUTPUT" >&2
echo "Fix the errors above and try again." >&2
exit 1
fi
exit 0

View File

@@ -0,0 +1,22 @@
# Session Log — 2026-03-13
## Summary
Implemented the composable multi-language linting and formatting system for Claude Code. Created formatter scripts, PostToolUse/pre-commit hooks, install scripts, `/linter` skill, best-practices documentation, and a README with project opt-in instructions.
## Decisions
- Decision: Formatter scripts exit 1 on lint errors, dispatcher hook decides exit code (exit 2 for PostToolUse feedback) — Rationale: separates formatter logic from hook semantics; same scripts work for both PostToolUse and pre-commit
- Decision: Checkpoint uses `git hash-object -w` with `.pre-lint` sidecar file — Rationale: fast (~1ms), no commits/stash, orphan blobs auto-GC'd; falls back to `cp` outside git repos
- Decision: Projects opt in via symlinks in `formatters/` rather than config — Rationale: zero-config, visible in `ls`, no parsing needed; hook walks up directory tree to find `formatters/`
- Decision: Symlinked README.md into `~/dev/claude/` for visibility — Rationale: makes setup guide discoverable from the top-level working directory
## Key Context
- claude-foundations lives at `~/dev/claude/projects/claude-foundations/` (not `~/dev/claude/claude-foundations/`)
- PostToolUse exit code 2 feeds stderr back to Claude as feedback without blocking the edit
- Agent-type hooks are read-only (no Edit/Write) — lint fixing must happen via Agent tool subagent, not hooks
- All hooks in an array run in parallel, not sequentially
- Formatter contract: `$1` = absolute path, format in place, stderr for errors, exit 0/1, no `set -e`, silent on missing tools
## Process Notes
- Plan was thorough and complete — implementation was straightforward with minimal deviation
- Smoke tests confirmed all four paths: clean file, missing tools, no formatters dir, no extension
- No actual lint errors were triggered during testing since test files were already clean

46
scripts/install-hooks.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Symlink all hooks from claude-foundations/hooks/ into ~/.claude/hooks/
# and print the settings.json configuration to add.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
HOOKS_SRC="$(cd "$SCRIPT_DIR/../hooks" && pwd)"
HOOKS_DST="$HOME/.claude/hooks"
mkdir -p "$HOOKS_DST"
echo "Installing hooks from $HOOKS_SRC$HOOKS_DST"
echo ""
for HOOK in "$HOOKS_SRC"/*.sh; do
NAME="$(basename "$HOOK")"
ln -sf "$HOOK" "$HOOKS_DST/$NAME"
echo "$NAME"
done
echo ""
echo "Hooks symlinked. Ensure your ~/.claude/settings.json includes:"
echo ""
cat <<'EOF'
{
"hooks": {
"PreCompact": [
{
"matcher": "auto",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }]
},
{
"matcher": "manual",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/post-edit-lint.sh", "timeout": 30, "statusMessage": "Formatting and linting..." }]
}
]
}
}
EOF

74
scripts/setup-formatters.sh Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Set up formatters for a project by symlinking selected formatters from claude-foundations.
#
# Usage: setup-formatters.sh <project-dir> <ext> [<ext> ...]
# Example: setup-formatters.sh ~/dev/claude/projects/my-project py sh
#
# This creates a formatters/ directory in the target project and symlinks
# the requested formatter scripts from claude-foundations/formatters/.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FOUNDATIONS_FORMATTERS="$(cd "$SCRIPT_DIR/../formatters" && pwd)"
if [ $# -lt 2 ]; then
echo "Usage: $(basename "$0") <project-dir> <ext> [<ext> ...]"
echo ""
echo "Available formatters:"
for f in "$FOUNDATIONS_FORMATTERS"/*; do
[ -e "$f" ] || continue
NAME="$(basename "$f")"
if [ -L "$f" ]; then
TARGET="$(readlink "$f")"
echo " $NAME$TARGET"
else
echo " $NAME"
fi
done
exit 1
fi
PROJECT_DIR="$1"
shift
if [ ! -d "$PROJECT_DIR" ]; then
echo "Error: $PROJECT_DIR is not a directory" >&2
exit 1
fi
DEST="$PROJECT_DIR/formatters"
mkdir -p "$DEST"
# Compute relative path from project formatters/ to foundations formatters/
REL_PATH="$(python3 -c "import os.path; print(os.path.relpath('$FOUNDATIONS_FORMATTERS', '$DEST'))")"
echo "Setting up formatters in $DEST"
echo ""
for EXT in "$@"; do
SRC="$FOUNDATIONS_FORMATTERS/$EXT"
if [ ! -e "$SRC" ]; then
echo "$EXT — not found in $FOUNDATIONS_FORMATTERS" >&2
continue
fi
ln -sf "$REL_PATH/$EXT" "$DEST/$EXT"
echo "$EXT"
done
echo ""
echo "Done. Add formatters/ to your .gitignore or commit the symlinks."
# Also set up pre-commit hook if git repo
if git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
GIT_DIR="$(git -C "$PROJECT_DIR" rev-parse --git-dir)"
PRECOMMIT="$GIT_DIR/hooks/pre-commit"
PRECOMMIT_SRC="$SCRIPT_DIR/../hooks/pre-commit-lint.sh"
if [ ! -e "$PRECOMMIT" ]; then
ln -sf "$(cd "$(dirname "$PRECOMMIT_SRC")" && pwd)/$(basename "$PRECOMMIT_SRC")" "$PRECOMMIT"
echo "Git pre-commit hook installed: $PRECOMMIT → pre-commit-lint.sh"
else
echo "Git pre-commit hook already exists at $PRECOMMIT (not overwritten)."
fi
fi