diff options
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | README.org | 7 | ||||
| -rwxr-xr-x | scripts/install-lang.sh | 16 | ||||
| -rwxr-xr-x | scripts/sync-language-bundle.sh | 23 | ||||
| -rw-r--r-- | scripts/tests/sync-language-bundle.bats | 75 |
5 files changed, 112 insertions, 17 deletions
@@ -20,3 +20,11 @@ # (only the .gpg counterpart is safe to commit) mcp/secrets.env mcp/gcp-oauth.keys.json + +# Live session anchor — ephemeral, archived under a different name into +# .ai/sessions/ at wrap. Tracking .ai/ (this repo does; most projects gitignore +# it) meant the anchor showed as untracked for the whole session, which made +# git-worktree-gate report rulesets sync-blocked and every other project skip +# its rulesets pull until wrap. +.ai/session-context.org +.ai/session-context.d/ @@ -39,7 +39,12 @@ make list-languages # show available bundles #+end_src What gets installed: -- =.claude/rules/*.md= — project-scoped rules (language-specific + verification) +- =.claude/rules/*.md= — the language's own rules only. The generic rules in + =claude-rules/= are *not* copied per project: =make install= links them once + into =~/.claude/rules/=, where they load in every session on the machine. + Copying them here too loaded them twice, and project rules outrank user-level + ones, so a stale project copy silently overrode the fresh global rule. + =sync-language-bundle.sh= sweeps copies left by earlier installs. - =.claude/hooks/= — PostToolUse validation scripts - =.claude/settings.json= — permission allowlist + hook wiring - =githooks/= — git hooks (activated via =core.hooksPath=) diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh index 653747d..3aaa76e 100755 --- a/scripts/install-lang.sh +++ b/scripts/install-lang.sh @@ -104,12 +104,16 @@ fi echo "Installing '$LANG' ruleset into $PROJECT" -# 1. Generic rules from claude-rules/ (shared across all languages) -if [ -d "$REPO_ROOT/claude-rules" ]; then - mkdir -p "$PROJECT/.claude/rules" - cp "$REPO_ROOT/claude-rules"/*.md "$PROJECT/.claude/rules/" 2>/dev/null || true - count=$(ls -1 "$REPO_ROOT/claude-rules"/*.md 2>/dev/null | wc -l) - echo " [ok] .claude/rules/ — $count generic rule(s) from claude-rules/" +# 1. Generic rules are NOT copied here. They install once at ~/.claude/rules/ +# via `make install` and load in every session on the machine. Copying them per +# project loaded them twice, and project rules outrank user-level ones — so a +# stale project copy quietly overrode the fresh global rule. The bundle owns its +# own language rules only; sync-language-bundle.sh sweeps copies left by earlier +# installs. A machine that wants the generic rules runs `make install`. +mkdir -p "$PROJECT/.claude/rules" +if [ ! -d "$HOME/.claude/rules" ]; then + echo " [!!] ~/.claude/rules/ is missing — run 'make install' so the generic" + echo " rules are available; this bundle installs language rules only." fi # 2. .claude/ — language-specific rules, hooks, settings (authoritative, always overwrite) diff --git a/scripts/sync-language-bundle.sh b/scripts/sync-language-bundle.sh index 45f8259..fe922af 100755 --- a/scripts/sync-language-bundle.sh +++ b/scripts/sync-language-bundle.sh @@ -105,7 +105,7 @@ inbox_drop() { # shared generic rules, its hooks/githooks, and surfaces settings.json. process_bundle() { local kind="$1" src="${2%/}" - local name rules rf f rel manual_before + local name rules rf f rel manual_before base name="$(basename "$src")" rules="$src/claude/rules" [ -d "$rules" ] || return 0 @@ -126,12 +126,27 @@ process_bundle() { HEADER_DONE=0 manual_before=$MANUAL - # AUTO-FIX: language bundles carry the shared generic rules; team overlays - # carry only their own rule(s). + # SWEEP: generic rules are installed once at ~/.claude/rules/ by `make + # install` and load in every session. Language bundles used to copy them into + # each project too, which made Claude Code load them twice — and project rules + # take priority over user-level ones, so a stale project copy silently + # overrode the fresh global rule until the next startup healed it. The bundle + # now owns only its own language rules, and sweeps the duplicates it shipped + # before. + # + # The sweep only fires when the global rule is actually present to take over. + # On a machine mid-bootstrap, or one where `make install` has not run, the + # project copy is the only copy and removing it would leave no rule at all. if [ "$kind" = language ]; then for f in "$GENERIC_RULES"/*.md; do [ -f "$f" ] || continue - fix "$f" "$PROJECT/.claude/rules/$(basename "$f")" + base="$(basename "$f")" + [ -f "$PROJECT/.claude/rules/$base" ] || continue + [ -f "$HOME/.claude/rules/$base" ] || continue + rm -f "$PROJECT/.claude/rules/$base" + ensure_header + OUT+=" swept .claude/rules/$base (duplicate of ~/.claude/rules/)"$'\n' + FIXED=$((FIXED + 1)) done fi for f in "$rules"/*.md; do diff --git a/scripts/tests/sync-language-bundle.bats b/scripts/tests/sync-language-bundle.bats index 1871444..0eb3ae2 100644 --- a/scripts/tests/sync-language-bundle.bats +++ b/scripts/tests/sync-language-bundle.bats @@ -19,7 +19,20 @@ teardown() { rm -rf "$PROJ" } -# Mirror install-lang.sh: copy the bundle's files into a synthetic project. +# Mirror what a CURRENT install-lang.sh leaves: language rules only, no copies +# of the generic rules (those live once at ~/.claude/rules/). +install_bundle_current() { + install_bundle "$1" "$2" + local f + for f in "$REAL_REPO/claude-rules"/*.md; do + [ -f "$f" ] || continue + rm -f "$2/.claude/rules/$(basename "$f")" + done +} + +# Mirror what an OLDER install-lang.sh left behind: language rules PLUS copies +# of every generic rule. This is the state the sweep exists to clean up, and +# real projects are still in it until their next startup. install_bundle() { local lang="$1" proj="$2" mkdir -p "$proj/.claude/rules" @@ -67,14 +80,14 @@ install_team_overlay() { } @test "sync: clean elisp bundle is a quiet no-op (exit 0)" { - install_bundle elisp "$PROJ" + install_bundle_current elisp "$PROJ" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [ -z "$output" ] } @test "sync: absent CLAUDE.md is not flagged as drift (seed-only/project-owned)" { - install_bundle elisp "$PROJ" # helper never seeds CLAUDE.md + install_bundle_current elisp "$PROJ" # helper never seeds CLAUDE.md [ ! -f "$PROJ/CLAUDE.md" ] run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] @@ -94,13 +107,17 @@ install_team_overlay() { matches_canonical ".claude/rules/elisp.md" "$REAL_REPO/languages/elisp/claude/rules/elisp.md" } -@test "sync: drifted generic rule is auto-fixed and restored" { +# Generic rules are no longer auto-fixed in place: they are swept, because the +# global copy at ~/.claude/rules/ is the one that loads. A drifted project copy +# is not repaired, it is removed — which is the stronger fix, since the drifted +# copy outranked the global rule while it existed. +@test "sync: a drifted generic rule copy is swept, not repaired" { install_bundle elisp "$PROJ" echo "junk" >> "$PROJ/.claude/rules/commits.md" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] - [[ "$output" == *".claude/rules/commits.md"* ]] - matches_canonical ".claude/rules/commits.md" "$REAL_REPO/claude-rules/commits.md" + [[ "$output" == *"swept"* ]] + [ ! -f "$PROJ/.claude/rules/commits.md" ] } @test "sync: missing rule is re-copied" { @@ -276,3 +293,49 @@ install_team_overlay() { [ ! -f "$PROJ/.claude/rules/commits.md" ] [ ! -f "$PROJ/.claude/rules/testing.md" ] } + +# --- generic-rule de-duplication ------------------------------------------- +# +# Generic rules live at ~/.claude/rules/ (symlinked by `make install`) and load +# in every session. Copying them into each project as well made Claude Code +# load them twice, and project copies take priority — so a stale project copy +# silently overrode the fresh global one. The bundle now ships only its own +# language rules and sweeps the duplicates it previously installed. + +@test "sync: sweeps generic rule copies that duplicate the global set" { + install_bundle python "$PROJ" + [ -f "$PROJ/.claude/rules/commits.md" ] + HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules" + cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/" + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ ! -f "$PROJ/.claude/rules/commits.md" ] + [ ! -f "$PROJ/.claude/rules/todo-format.md" ] +} + +@test "sync: keeps the language bundle's own rules while sweeping generics" { + install_bundle python "$PROJ" + HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules" + cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/" + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/rules/python-testing.md" ] +} + +@test "sync: keeps a project-owned overlay rule the bundle does not own" { + install_bundle python "$PROJ" + printf '# Publishing\n\nApplies to: `**/*`\n' > "$PROJ/.claude/rules/publishing.md" + HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules" + cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/" + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/rules/publishing.md" ] +} + +@test "sync: does NOT sweep when the global rule is absent (nothing takes over)" { + install_bundle python "$PROJ" + HOME_RULES="$(mktemp -d)" # no ~/.claude/rules/ at all + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/rules/commits.md" ] +} |
