diff options
| -rw-r--r-- | .ai/workflows/startup.org | 2 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/startup.org | 2 | ||||
| -rwxr-xr-x | scripts/sync-language-bundle.sh | 142 | ||||
| -rw-r--r-- | scripts/tests/sync-language-bundle.bats | 153 |
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"* ]] +} |
