aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--README.org7
-rwxr-xr-xscripts/install-lang.sh16
-rwxr-xr-xscripts/sync-language-bundle.sh23
-rw-r--r--scripts/tests/sync-language-bundle.bats75
5 files changed, 112 insertions, 17 deletions
diff --git a/.gitignore b/.gitignore
index 94b983f..dd66346 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/README.org b/README.org
index f445c26..f8e5d8f 100644
--- a/README.org
+++ b/README.org
@@ -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" ]
+}