aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-22 13:06:12 -0500
committerCraig Jennings <c@cjennings.net>2026-05-22 13:06:12 -0500
commit1ceed704147a1cd6c2c9cd332d3aa33ea43bda83 (patch)
treea76c66f491833e3403ba68b0d982c29a0cf78b48
parent79dad4659b8ac97ce65d958efdcd1deed66b3661 (diff)
downloadrulesets-1ceed704147a1cd6c2c9cd332d3aa33ea43bda83.tar.gz
rulesets-1ceed704147a1cd6c2c9cd332d3aa33ea43bda83.zip
feat(startup): sync language bundles per project on session launch
Startup synced the .ai/ templates into the current project every session but never checked the language bundle (elisp, python) installed in .claude/. Bundle drift went unnoticed until someone re-ran make install-lang by hand: a generic rule added to claude-rules/ after the last install, or a changed validator hook. scripts/sync-language-bundle.sh closes that gap. It fingerprints which bundle a project has by the presence of the language's own rule files (elisp.md, python-testing.md), then reconciles against the canonical source: auto-fix for rulesets-owned files (.claude/rules/*.md, .claude/hooks/*, githooks/*), surface-only for settings.json, which a project may have customized. CLAUDE.md is left alone. It's seed-only in install-lang and project-owned afterward, the same reason diff-lang skips it. Startup Phase A step 12 calls it for the current project, guarded so older checkouts that lack the script still boot. It writes only under .claude/ and githooks/, disjoint from the .ai/ rsync paths, so the parallel batch stays safe. A script rather than a make target keeps the Makefile-parse layer off the boot path. The absolute rulesets path it depends on is the same one the rsyncs already carry. Tested: 11 bats cases (no-bundle, clean, drifted rule/hook auto-fixed, surfaced settings.json asserted unmodified, absent CLAUDE.md not flagged, python detection, $PWD default, bad path). A smoke run against a copy of a real elisp project's .claude/ caught a perpetual "CLAUDE.md missing" alarm, which is what drove dropping CLAUDE.md from the surface set.
-rw-r--r--.ai/workflows/startup.org2
-rw-r--r--claude-templates/.ai/workflows/startup.org2
-rwxr-xr-xscripts/sync-language-bundle.sh142
-rw-r--r--scripts/tests/sync-language-bundle.bats153
4 files changed, 299 insertions, 0 deletions
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index 3503a60..8aa420c 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -104,6 +104,7 @@ These calls have no dependencies on each other. Issue them all together in one m
9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
10. Read =.ai/project-workflows/startup-extras.org= if it exists.
11. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
+12. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
@@ -133,6 +134,7 @@ This phase touches the user and runs sequentially:
- Mention Pending Decisions from notes.org.
- Briefly note significant template updates noticed during sync (new workflows, protocol changes).
- *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing.
+ - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install-<lang> PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing.
- *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it.
2. *Process inbox if non-empty.* Mandatory — don't ask, just do it. For each file: determine action, recommend filing, get approval, move. For =.eml= files use the extract script (not raw Read):
#+begin_src bash
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 3503a60..8aa420c 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -104,6 +104,7 @@ These calls have no dependencies on each other. Issue them all together in one m
9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
10. Read =.ai/project-workflows/startup-extras.org= if it exists.
11. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
+12. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
@@ -133,6 +134,7 @@ This phase touches the user and runs sequentially:
- Mention Pending Decisions from notes.org.
- Briefly note significant template updates noticed during sync (new workflows, protocol changes).
- *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing.
+ - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install-<lang> PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing.
- *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it.
2. *Process inbox if non-empty.* Mandatory — don't ask, just do it. For each file: determine action, recommend filing, get approval, move. For =.eml= files use the extract script (not raw Read):
#+begin_src bash
diff --git a/scripts/sync-language-bundle.sh b/scripts/sync-language-bundle.sh
new file mode 100755
index 0000000..8858f74
--- /dev/null
+++ b/scripts/sync-language-bundle.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+# Per-project language-bundle freshness check, designed to run at startup.
+#
+# Detects which language bundle(s) a project has by fingerprint (no marker
+# file), then reconciles them against the canonical rulesets source:
+# - AUTO-FIX (rulesets-owned, safe to overwrite): .claude/rules/*.md,
+# .claude/hooks/*, githooks/*
+# - SURFACE (project may customize — reported, never written):
+# .claude/settings.json
+# CLAUDE.md is intentionally not tracked: it is seed-only in install-lang
+# and project-owned afterward (diff-lang skips it for the same reason).
+#
+# Usage: sync-language-bundle.sh [project-path] (default: $PWD)
+#
+# Exit: 0 no bundle, or clean / rules+hooks auto-fixed (resolved)
+# 3 manual action recommended (settings.json / CLAUDE.md drift)
+# 1 usage / path error
+#
+# Quiet when there is nothing to report. Resolves the canonical source
+# relative to its own location, so it always reads the current checkout.
+
+set -u
+
+PROJECT="${1:-$PWD}"
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+LANG_ROOT="$REPO_ROOT/languages"
+GENERIC_RULES="$REPO_ROOT/claude-rules"
+
+if [ ! -d "$PROJECT" ]; then
+ echo "ERROR: project path does not exist: $PROJECT" >&2
+ exit 1
+fi
+PROJECT="$(cd "$PROJECT" && pwd)"
+
+[ -d "$LANG_ROOT" ] || exit 0 # no bundles available — nothing to do
+
+OUT="" # buffered report, printed once at the end
+FIXED=0
+MANUAL=0
+HEADER_DONE=0
+CUR_HEADER=""
+
+ensure_header() {
+ if [ "$HEADER_DONE" -eq 0 ]; then
+ OUT+="$CUR_HEADER"$'\n'
+ HEADER_DONE=1
+ fi
+}
+
+# fix SRC DST [exec] — overwrite DST from SRC if missing or differing.
+fix() {
+ local src="$1" dst="$2" want_exec="${3:-}"
+ [ -f "$src" ] || return 0
+ if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" >/dev/null 2>&1; then
+ mkdir -p "$(dirname "$dst")"
+ cp "$src" "$dst"
+ [ "$want_exec" = exec ] && chmod +x "$dst"
+ ensure_header
+ OUT+=" fixed ${dst#"$PROJECT"/}"$'\n'
+ FIXED=$((FIXED + 1))
+ fi
+}
+
+# surface SRC DST — report DST drift without writing.
+surface() {
+ local src="$1" dst="$2" reason=""
+ [ -f "$src" ] || return 0
+ if [ ! -f "$dst" ]; then
+ reason="missing"
+ elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then
+ reason="differs"
+ else
+ return 0
+ fi
+ ensure_header
+ OUT+=" drift ${dst#"$PROJECT"/} ($reason — not auto-fixed)"$'\n'
+ MANUAL=$((MANUAL + 1))
+}
+
+for src_lang in "$LANG_ROOT"/*/; do
+ [ -d "$src_lang" ] || continue
+ src_lang="${src_lang%/}"
+ lang="$(basename "$src_lang")"
+ lang_rules="$src_lang/claude/rules"
+ [ -d "$lang_rules" ] || continue
+
+ # Fingerprint: project has this bundle iff any of the language's own
+ # rule files is present in .claude/rules/.
+ detected=0
+ for rf in "$lang_rules"/*.md; do
+ [ -f "$rf" ] || continue
+ if [ -f "$PROJECT/.claude/rules/$(basename "$rf")" ]; then
+ detected=1
+ break
+ fi
+ done
+ [ "$detected" -eq 1 ] || continue
+
+ CUR_HEADER="language bundle '$lang' — $PROJECT:"
+ HEADER_DONE=0
+ manual_before=$MANUAL
+
+ # AUTO-FIX: generic rules (shared) + language rules
+ for f in "$GENERIC_RULES"/*.md; do
+ [ -f "$f" ] || continue
+ fix "$f" "$PROJECT/.claude/rules/$(basename "$f")"
+ done
+ for f in "$lang_rules"/*.md; do
+ [ -f "$f" ] || continue
+ fix "$f" "$PROJECT/.claude/rules/$(basename "$f")"
+ done
+
+ # AUTO-FIX: language hooks (executable)
+ if [ -d "$src_lang/claude/hooks" ]; then
+ while IFS= read -r f; do
+ rel="${f#"$src_lang"/claude/}"
+ fix "$f" "$PROJECT/.claude/$rel" exec
+ done < <(find "$src_lang/claude/hooks" -type f)
+ fi
+
+ # AUTO-FIX: githooks (executable)
+ if [ -d "$src_lang/githooks" ]; then
+ while IFS= read -r f; do
+ rel="${f#"$src_lang"/githooks/}"
+ fix "$f" "$PROJECT/githooks/$rel" exec
+ done < <(find "$src_lang/githooks" -type f)
+ fi
+
+ # SURFACE-ONLY: settings.json may carry project customization, so report
+ # rather than overwrite. (CLAUDE.md is intentionally untracked here — it's
+ # seed-only in install-lang and project-owned after, same as diff-lang skips it.)
+ surface "$src_lang/claude/settings.json" "$PROJECT/.claude/settings.json"
+
+ if [ "$MANUAL" -gt "$manual_before" ]; then
+ OUT+=" → reconcile: make install-$lang PROJECT=."$'\n'
+ fi
+done
+
+[ -n "$OUT" ] && printf '%s' "$OUT"
+[ "$MANUAL" -gt 0 ] && exit 3
+exit 0
diff --git a/scripts/tests/sync-language-bundle.bats b/scripts/tests/sync-language-bundle.bats
new file mode 100644
index 0000000..9fd1108
--- /dev/null
+++ b/scripts/tests/sync-language-bundle.bats
@@ -0,0 +1,153 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/sync-language-bundle.sh — per-project language-bundle
+# freshness check for startup.
+#
+# Strategy: scaffold a synthetic project in a temp dir with a clean bundle
+# (mirroring how install-lang.sh leaves .claude/), then perturb files and
+# run the real script against it. Canonical source stays the real one (the
+# script resolves languages/ + claude-rules/ relative to its own location).
+
+REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+SCRIPT="$REAL_REPO/scripts/sync-language-bundle.sh"
+
+setup() {
+ PROJ="$(mktemp -d -t synclang-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$PROJ"
+}
+
+# Mirror install-lang.sh: copy the bundle's files into a synthetic project.
+install_bundle() {
+ local lang="$1" proj="$2"
+ mkdir -p "$proj/.claude/rules"
+ cp "$REAL_REPO/claude-rules"/*.md "$proj/.claude/rules/"
+ cp "$REAL_REPO/languages/$lang/claude/rules/"*.md "$proj/.claude/rules/" 2>/dev/null || true
+ if [ -d "$REAL_REPO/languages/$lang/claude/hooks" ]; then
+ mkdir -p "$proj/.claude/hooks"
+ cp -r "$REAL_REPO/languages/$lang/claude/hooks/." "$proj/.claude/hooks/"
+ fi
+ if [ -f "$REAL_REPO/languages/$lang/claude/settings.json" ]; then
+ cp "$REAL_REPO/languages/$lang/claude/settings.json" "$proj/.claude/settings.json"
+ fi
+ if [ -d "$REAL_REPO/languages/$lang/githooks" ]; then
+ mkdir -p "$proj/githooks"
+ cp -r "$REAL_REPO/languages/$lang/githooks/." "$proj/githooks/"
+ fi
+ # CLAUDE.md deliberately not seeded — the sync must treat its absence as
+ # fine (it's seed-only / project-owned, not bundle-tracked).
+}
+
+matches_canonical() { # project-relative-path canonical-abs-path
+ diff -q "$PROJ/$1" "$2" >/dev/null 2>&1
+}
+
+# --- Normal: no bundle / clean ---
+
+@test "sync: project with no bundle is a quiet no-op (exit 0)" {
+ mkdir -p "$PROJ/.claude/rules" # generic rules only, no language fingerprint
+ cp "$REAL_REPO/claude-rules"/commits.md "$PROJ/.claude/rules/"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "sync: clean elisp bundle is a quiet no-op (exit 0)" {
+ install_bundle 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
+ [ ! -f "$PROJ/CLAUDE.md" ]
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [[ "$output" != *"CLAUDE.md"* ]]
+}
+
+# --- Auto-fix: rules ---
+
+@test "sync: drifted language rule is auto-fixed and restored" {
+ install_bundle elisp "$PROJ"
+ echo ";; junk drift" >> "$PROJ/.claude/rules/elisp.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"fixed"* ]]
+ [[ "$output" == *".claude/rules/elisp.md"* ]]
+ matches_canonical ".claude/rules/elisp.md" "$REAL_REPO/languages/elisp/claude/rules/elisp.md"
+}
+
+@test "sync: drifted generic rule is auto-fixed and restored" {
+ 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"
+}
+
+@test "sync: missing rule is re-copied" {
+ install_bundle elisp "$PROJ"
+ rm "$PROJ/.claude/rules/elisp-testing.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/rules/elisp-testing.md" ]
+ matches_canonical ".claude/rules/elisp-testing.md" "$REAL_REPO/languages/elisp/claude/rules/elisp-testing.md"
+}
+
+# --- Auto-fix: hooks ---
+
+@test "sync: drifted hook is auto-fixed and stays executable" {
+ install_bundle elisp "$PROJ"
+ echo "# junk" >> "$PROJ/.claude/hooks/validate-el.sh"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"validate-el.sh"* ]]
+ matches_canonical ".claude/hooks/validate-el.sh" "$REAL_REPO/languages/elisp/claude/hooks/validate-el.sh"
+ [ -x "$PROJ/.claude/hooks/validate-el.sh" ]
+}
+
+# --- Surface-only: settings.json ---
+
+@test "sync: drifted settings.json is surfaced, NOT modified, exit 3" {
+ install_bundle elisp "$PROJ"
+ echo '{"_drift": true}' > "$PROJ/.claude/settings.json"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 3 ]
+ [[ "$output" == *"settings.json"* ]]
+ [[ "$output" == *"not auto-fixed"* ]]
+ # file left untouched — still carries the perturbation
+ grep -q "_drift" "$PROJ/.claude/settings.json"
+}
+
+# --- Other language ---
+
+@test "sync: python bundle detected via python-testing.md and fixed" {
+ install_bundle python "$PROJ"
+ echo "junk" >> "$PROJ/.claude/rules/python-testing.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"python-testing.md"* ]]
+ matches_canonical ".claude/rules/python-testing.md" "$REAL_REPO/languages/python/claude/rules/python-testing.md"
+}
+
+# --- Boundary / Error ---
+
+@test "sync: defaults to \$PWD when no path given" {
+ install_bundle elisp "$PROJ"
+ echo "junk" >> "$PROJ/.claude/rules/elisp.md"
+ run bash -c "cd '$PROJ' && bash '$SCRIPT'"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"elisp.md"* ]]
+}
+
+@test "sync: nonexistent project path errors with exit 1" {
+ run bash "$SCRIPT" "/no/such/path/here"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"does not exist"* ]]
+}