1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
|
#!/usr/bin/env bash
# audit.sh — cross-project .ai/ drift detector.
#
# Walks every .ai/-using project on the machine, diffs the synced template
# files against the canonical source under rulesets/claude-templates/.ai/,
# and reports drift line-by-line: ok / drift / applied / skipped / FAIL.
#
# Read-only by default. Pass --apply to rsync drift into each project's
# working tree (no auto-commit). Pass --force to also rsync into projects
# with uncommitted .ai/ changes (default: skip those).
#
# Exit 0 when everything's ok. Exit 1 if anything drifted, was applied,
# was skipped, or failed.
#
# Run from the repo root via `make audit`.
set -uo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
CANONICAL="$REPO/claude-templates/.ai"
apply=0
force=0
run_doctor=1
for arg in "$@"; do
case "$arg" in
--apply) apply=1 ;;
--force) force=1 ;;
--no-doctor) run_doctor=0 ;;
-h|--help)
cat <<EOF
Usage: $(basename "$0") [--apply] [--force] [--no-doctor]
--apply rsync drift into each project's .ai/ (working tree only).
--force rsync even into projects with uncommitted .ai/ changes.
--no-doctor skip the final make doctor sub-invocation.
Without --apply, reports drift only; no files are written.
EOF
exit 0
;;
*)
echo "ERROR: unknown flag: $arg (use --help for usage)" >&2
exit 2
;;
esac
done
ok_count=0
applied_count=0
drift_count=0
skipped_count=0
fail_count=0
print_ok() { printf ' ok %s\n' "$1"; ok_count=$((ok_count+1)); }
print_drift() { printf ' drift %s\n' "$1"; drift_count=$((drift_count+1)); }
print_applied() { printf ' applied %s\n' "$1"; applied_count=$((applied_count+1)); }
print_skipped() { printf ' skipped %s\n' "$1"; skipped_count=$((skipped_count+1)); }
print_fail() { printf ' FAIL %s\n' "$1"; fail_count=$((fail_count+1)); }
# Count content-level differences between canonical and project paths.
# Uses diff -rq (content comparison) rather than rsync --itemize-changes
# because the latter counts attribute-only drift (mtime, permissions) which
# the audit doesn't care about. Output of diff -rq is one line per:
# - "Files X and Y differ" (content differs)
# - "Only in X: file" (file missing on the other side)
count_drift_items() {
local src="$1" dst="$2"
if [ ! -e "$src" ]; then
echo 0
return
fi
if [ ! -e "$dst" ]; then
if [ -d "$src" ]; then
find "$src" -mindepth 1 2>/dev/null | wc -l
else
echo 1
fi
return
fi
if [ -d "$src" ]; then
diff -rq "$src" "$dst" 2>/dev/null | wc -l
else
if cmp -s "$src" "$dst"; then echo 0; else echo 1; fi
fi
}
# Total drift across all three sync paths for one project.
project_drift_count() {
local proj="$1"
local p w s
p=$(count_drift_items "$CANONICAL/protocols.org" "$proj/.ai/protocols.org")
w=$(count_drift_items "$CANONICAL/workflows/" "$proj/.ai/workflows/")
s=$(count_drift_items "$CANONICAL/scripts/" "$proj/.ai/scripts/")
echo $((p + w + s))
}
apply_rsync() {
local proj="$1"
rsync -a "$CANONICAL/protocols.org" "$proj/.ai/protocols.org" 2>/dev/null
rsync -a --delete "$CANONICAL/workflows/" "$proj/.ai/workflows/" 2>/dev/null
rsync -a --delete "$CANONICAL/scripts/" "$proj/.ai/scripts/" 2>/dev/null
}
# ----- 1. Canonical source check -----
echo "Canonical source:"
if [ ! -d "$CANONICAL" ]; then
echo " FAIL $CANONICAL not found (fold not applied?)"
echo
echo "Summary: 0 ok, 0 drift, 0 applied, 0 skipped, 1 failed"
exit 1
fi
if [ -d "$REPO/.git" ]; then
upstream=$(cd "$REPO" && git rev-parse --abbrev-ref '@{u}' 2>/dev/null || true)
if [ -n "$upstream" ]; then
counts=$(cd "$REPO" && git rev-list --left-right --count "${upstream}...HEAD" 2>/dev/null || echo "0 0")
behind=$(echo "$counts" | cut -f1)
if [ "$behind" -gt 0 ]; then
printf ' WARN rulesets is %d commits behind %s — pull first for accurate drift\n' "$behind" "$upstream"
else
printf ' ok rulesets is current (%s)\n' "$upstream"
fi
else
echo " ok rulesets has no upstream (local-only)"
fi
else
echo " WARN rulesets is not a git checkout"
fi
echo
# ----- 2. Project discovery + per-project audit -----
echo "Per-project .ai/ drift:"
# Find every .ai/ dir under the conventional roots (max depth 3, prune obvious dead ends).
mapfile -t projects < <(
find "$HOME/code" "$HOME/projects" "$HOME/.emacs.d" \
-maxdepth 3 -type d -name .ai 2>/dev/null \
| sed 's|/\.ai$||' \
| sort
)
for proj in "${projects[@]}"; do
# Skip:
# - the rulesets repo itself (canonical .ai/ lives at the repo root, not under a project)
# - the canonical-source subdir (rulesets/claude-templates/.ai/ is the source, not a target)
# - the legacy standalone claude-templates checkout (frozen during fold transition)
case "$proj" in
"$REPO") continue ;;
"$REPO/claude-templates") continue ;;
"$HOME/projects/claude-templates") continue ;;
esac
# Display path: strip $HOME prefix to ~/, otherwise leave alone.
if [ "${proj#$HOME/}" != "$proj" ]; then
short="~/${proj#$HOME/}"
else
short="$proj"
fi
# Step 1: .ai/ exists (find guarantees this, but check anyway).
if [ ! -d "$proj/.ai" ]; then
print_fail "$short .ai/ missing"
continue
fi
# Step 2 + 3: tracking + dirty check (tracked projects only).
dirty=0
if [ -d "$proj/.git" ]; then
if (cd "$proj" && ! git check-ignore .ai/ >/dev/null 2>&1); then
if [ -n "$(cd "$proj" && git status --porcelain .ai/ 2>/dev/null)" ]; then
dirty=1
fi
fi
fi
if [ "$dirty" -eq 1 ] && [ "$force" -eq 0 ]; then
print_skipped "$short uncommitted .ai/ (use --force)"
continue
fi
# Step 4: drift detection.
drift=$(project_drift_count "$proj")
if [ "$drift" -eq 0 ]; then
print_ok "$short"
continue
fi
# Drift detected.
if [ "$apply" -eq 0 ]; then
print_drift "$short $drift items differ"
continue
fi
# Step 5: apply rsync.
apply_rsync "$proj"
# Step 6: verify convergence.
remaining=$(project_drift_count "$proj")
if [ "$remaining" -gt 0 ]; then
print_fail "$short rsync didn't converge ($remaining items still differ)"
continue
fi
print_applied "$short $drift items changed"
done
# ----- summary -----
echo
if [ "$apply" -eq 1 ]; then
echo "Summary: $ok_count ok, $applied_count applied, $skipped_count skipped, $fail_count failed"
else
echo "Summary: $ok_count ok, $drift_count drift, $skipped_count skipped, $fail_count failed"
fi
# ----- 3. Optional doctor sub-invocation -----
if [ "$run_doctor" -eq 1 ]; then
echo
echo "==="
echo
bash "$REPO/scripts/doctor.sh"
doctor_exit=$?
else
doctor_exit=0
fi
# Exit code: 1 if anything non-ok in audit, OR doctor failed.
total_non_ok=$((applied_count + drift_count + skipped_count + fail_count))
if [ "$total_non_ok" -gt 0 ] || [ "$doctor_exit" -ne 0 ]; then
exit 1
fi
exit 0
|