From f1c2e5ef122eb49e1f2e55c37186c0ff3fb982c4 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Wed, 30 Sep 2026 12:21:08 -0600 Subject: [PATCH] Add Claude Code hooks for the repo's never-do rules Project hooks in .claude/settings.json, backed by scripts in .claude/hooks/ (now un-ignored): - pre-tool-guard.sh (PreToolUse): denies `git add -A`/`--all`/`.`, a force push without --force-with-lease, edits under build/ (Apollo generated code), and hand edits to the npm lockfiles. - pre-commit-ktfmt.sh (PreToolUse on `git commit`): when Kotlin is staged, runs ktfmtCheck (and build-logic's) and blocks the commit with the ktfmtFormat command to run. - post-edit-check.sh (PostToolUse): rejects vector drawables with ?attr/ or @android: references, which crash at runtime, and reminds that styles.css duplicates WebSkin's page colour. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/post-edit-check.sh | 21 +++++++++++++++++ .claude/hooks/pre-commit-ktfmt.sh | 22 +++++++++++++++++ .claude/hooks/pre-tool-guard.sh | 39 +++++++++++++++++++++++++++++++ .claude/settings.json | 38 ++++++++++++++++++++++++++++++ .gitignore | 11 +++++---- 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100755 .claude/hooks/post-edit-check.sh create mode 100755 .claude/hooks/pre-commit-ktfmt.sh create mode 100755 .claude/hooks/pre-tool-guard.sh diff --git a/.claude/hooks/post-edit-check.sh b/.claude/hooks/post-edit-check.sh new file mode 100755 index 0000000..8462169 --- /dev/null +++ b/.claude/hooks/post-edit-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# PostToolUse check for Edit and Write. Catches the edits that build clean and fail only at runtime. +set -euo pipefail + +path=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty') +[ -n "$path" ] && [ -f "$path" ] || exit 0 + +case "$path" in + */composeResources/drawable*/*.xml) + # CMP's vector parser resolves neither; both crash at runtime with a clean build. + if hits=$(grep -nE '\?attr/|@android:' "$path"); then + jq -n --arg r "$path uses ?attr/ or @android: references, which Compose Multiplatform cannot resolve (runtime crash, clean build). Use literal colours such as #FFFFFFFF and let Icon tint from LocalContentColor. Offending lines: +$hits" '{decision: "block", reason: $r}' + fi + ;; + */web/src/commonMain/resources/styles.css | */ui/theme/WebSkin.kt) + # The page colour is duplicated so it paints before any Kotlin runs. + jq -n '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: "styles.css duplicates the page colour from WebSkin.kt (it paints before any Kotlin runs). If you changed that colour, change it in the other file too, or every cold load flashes the wrong colour."}}' + ;; +esac +exit 0 diff --git a/.claude/hooks/pre-commit-ktfmt.sh b/.claude/hooks/pre-commit-ktfmt.sh new file mode 100755 index 0000000..188a9e7 --- /dev/null +++ b/.claude/hooks/pre-commit-ktfmt.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# PreToolUse on `git commit`: CI runs ktfmtCheck, so fail the commit here instead of a CI round trip. +# Only runs Gradle when Kotlin sources are staged. +set -uo pipefail + +cd "$CLAUDE_PROJECT_DIR" || exit 0 +staged=$(git diff --cached --name-only --diff-filter=ACMR) +grep -Eq '\.kts?$' <<<"$staged" || exit 0 + +failed=() +if grep -Ev '^build-logic/' <<<"$staged" | grep -Eq '\.kts?$'; then + ./gradlew -q ktfmtCheck >/dev/null 2>&1 || failed+=("./gradlew ktfmtFormat") +fi +if grep -Eq '^build-logic/.*\.kts?$' <<<"$staged"; then + ./gradlew -q -p build-logic ktfmtCheck >/dev/null 2>&1 || failed+=("./gradlew -p build-logic ktfmtFormat") +fi + +if [ ${#failed[@]} -gt 0 ]; then + jq -n --arg r "ktfmtCheck failed on staged Kotlin. Run: ${failed[*]}, then re-stage the files by path and commit again." \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}' +fi +exit 0 diff --git a/.claude/hooks/pre-tool-guard.sh b/.claude/hooks/pre-tool-guard.sh new file mode 100755 index 0000000..9958a00 --- /dev/null +++ b/.claude/hooks/pre-tool-guard.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# PreToolUse guard for Bash, Edit and Write. Denies the operations this repo documents as never-do, +# each of which fails later and somewhere else rather than at the point of the mistake. +set -euo pipefail + +input=$(cat) +tool=$(jq -r '.tool_name // empty' <<<"$input") + +deny() { + jq -n --arg r "$1" \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}' + exit 0 +} + +case "$tool" in + Bash) + cmd=$(jq -r '.tool_input.command // empty' <<<"$input") + # `git add -A` / `.` / `--all` sweeps Xcode xcuserdata/ on branches below the iOS PR's gitignore. + if grep -Eq '(^|[;&|[:space:]])git[[:space:]]+add([[:space:]]+[^;&|]*)?[[:space:]](-A|--all|\.)([[:space:]]|$|[;&|])' <<<"$cmd"; then + deny "Stage by explicit path, never 'git add -A', '--all' or '.': on some branches the sweep picks up Xcode xcuserdata/. Check 'git status --short' first." + fi + # Stacked PRs: a bare force push can clobber a rebase done elsewhere. + if grep -Eq '(^|[;&|[:space:]])git[[:space:]]+push([[:space:]]|$)' <<<"$cmd" && + grep -Eq '[[:space:]](--force|-f)([[:space:]]|$)' <<<"$cmd" && + ! grep -q -- '--force-with-lease' <<<"$cmd"; then + deny "Use 'git push --force-with-lease', never a bare --force or -f." + fi + ;; + Edit | Write | MultiEdit) + path=$(jq -r '.tool_input.file_path // empty' <<<"$input") + case "$path" in + */build/*) + deny "$path is build output (Apollo generated code included). Change the source or the .graphql operation instead." ;; + */kotlin-js-store/*.lock) + deny "Never hand-edit the npm lockfiles. Regenerate both with './gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock'." ;; + esac + ;; +esac +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index fde9230..575f9d3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -26,5 +26,43 @@ "Bash(adb shell pidof *)", "Bash(plutil -p *)" ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-tool-guard.sh", + "timeout": 10 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git commit *)", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-commit-ktfmt.sh", + "timeout": 600, + "statusMessage": "Checking ktfmt on staged Kotlin…" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-check.sh", + "timeout": 10 + } + ] + } + ] } } diff --git a/.gitignore b/.gitignore index ac7bb51..6412274 100644 --- a/.gitignore +++ b/.gitignore @@ -18,13 +18,14 @@ xcuserdata/ *.xcscmblueprint .swiftpm/ -# Claude Code. `rules/`, `skills/`, `agents/` and `settings.json` are project documentation and -# configuration and are committed — the rules load by path and hold the invariants that fail -# silently, AGENTS.md routes to the skills by task, the agents encode this build's traps and output -# contracts, and all four are reviewable in a PR like any other doc. Everything else -# here — `settings.local.json` above all — is per-user state. +# Claude Code. `rules/`, `hooks/`, `skills/`, `agents/` and `settings.json` are project documentation +# and configuration and are committed — the rules load by path and hold the invariants that fail +# silently, the hooks enforce the never-do rules, AGENTS.md routes to the skills by task, the agents +# encode this build's traps and output contracts, and all five are reviewable in a PR like any other +# doc. Everything else here — `settings.local.json` above all — is per-user state. .claude/* !.claude/rules/ +!.claude/hooks/ !.claude/skills/ !.claude/agents/ !.claude/settings.json