aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile7
-rwxr-xr-xgithooks/pre-commit15
-rwxr-xr-xscripts/sync-check.sh72
-rw-r--r--scripts/tests/sync-check.bats86
-rw-r--r--todo.org3
5 files changed, 182 insertions, 1 deletions
diff --git a/Makefile b/Makefile
index 2ade6e1..3838597 100644
--- a/Makefile
+++ b/Makefile
@@ -431,6 +431,13 @@ audit: ## Verify project .ai/ dirs against canonical ([APPLY=1] [FORCE=1] [NO_DO
$(if $(FORCE),--force) \
$(if $(NO_DOCTOR),--no-doctor)
+sync-check: ## Verify claude-templates/.ai/ canonical matches .ai/ mirror ([FIX=1])
+ @bash scripts/sync-check.sh $(if $(FIX),--fix)
+
+install-githooks: ## Point this repo's core.hooksPath at githooks/ (idempotent — enables pre-commit sync-check)
+ @git config core.hooksPath githooks
+ @echo "core.hooksPath=githooks (pre-commit will run scripts/sync-check.sh)"
+
install-ai: ## Bootstrap .ai/ in a fresh project ([PROJECT=<path>] [TRACK=1 | GITIGNORE=1])
@bash scripts/install-ai.sh \
$(if $(TRACK),--track) \
diff --git a/githooks/pre-commit b/githooks/pre-commit
new file mode 100755
index 0000000..ece0c8a
--- /dev/null
+++ b/githooks/pre-commit
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+#
+# pre-commit hook — block commits when the canonical/mirror pair drifts.
+#
+# Enable by running: git config core.hooksPath githooks
+# (handled by make install-hooks; idempotent on re-run.)
+
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+"$repo_root/scripts/sync-check.sh" || {
+ echo "" >&2
+ echo "pre-commit: canonical/mirror drift detected. Commit blocked." >&2
+ exit 1
+}
diff --git a/scripts/sync-check.sh b/scripts/sync-check.sh
new file mode 100755
index 0000000..8fb9ab0
--- /dev/null
+++ b/scripts/sync-check.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+#
+# sync-check.sh — verify canonical claude-templates/.ai/ matches the .ai/ mirror.
+#
+# Usage:
+# scripts/sync-check.sh # exit 0 if synced, 1 if drift
+# scripts/sync-check.sh --fix # rsync canonical → mirror, then re-check
+#
+# Runs as a pre-commit hook (githooks/pre-commit) to catch drift before
+# commit, and as a manual check via `make sync-check`.
+#
+# The three synced paths are:
+# claude-templates/.ai/protocols.org ↔ .ai/protocols.org
+# claude-templates/.ai/workflows/ ↔ .ai/workflows/
+# claude-templates/.ai/scripts/ ↔ .ai/scripts/
+#
+# Source of truth is the canonical (claude-templates/) side. The mirror
+# exists so rulesets-as-a-project has a working copy. Drift in either
+# direction is a defect — both have to land in the same commit.
+
+set -euo pipefail
+
+if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || [ -z "$repo_root" ]; then
+ echo "sync-check: not inside a git checkout" >&2
+ exit 2
+fi
+
+canonical="$repo_root/claude-templates/.ai"
+mirror="$repo_root/.ai"
+
+if [ ! -d "$canonical" ] || [ ! -d "$mirror" ]; then
+ echo "sync-check: not a rulesets-shaped repo (missing claude-templates/.ai or .ai)" >&2
+ exit 2
+fi
+
+paths=(protocols.org workflows scripts)
+
+check_drift() {
+ local drift=0
+ for relpath in "${paths[@]}"; do
+ if ! diff -rq "$canonical/$relpath" "$mirror/$relpath" >/dev/null 2>&1; then
+ echo "drift: claude-templates/.ai/$relpath ↔ .ai/$relpath" >&2
+ diff -rq "$canonical/$relpath" "$mirror/$relpath" 2>&1 | head -20 >&2
+ drift=1
+ fi
+ done
+ return "$drift"
+}
+
+if check_drift; then
+ exit 0
+fi
+
+if [ "${1:-}" = "--fix" ]; then
+ echo "" >&2
+ echo "sync-check --fix: syncing canonical → mirror..." >&2
+ rsync -a "$canonical/protocols.org" "$mirror/protocols.org"
+ rsync -a --delete "$canonical/workflows/" "$mirror/workflows/"
+ rsync -a --delete "$canonical/scripts/" "$mirror/scripts/"
+ if check_drift; then
+ echo "sync-check --fix: resolved." >&2
+ echo "Re-stage the synced files and retry the commit." >&2
+ exit 0
+ else
+ echo "sync-check --fix: drift persists after sync. Inspect manually." >&2
+ exit 1
+ fi
+fi
+
+echo "" >&2
+echo "Run 'scripts/sync-check.sh --fix' (or 'make sync-check FIX=1') to resolve." >&2
+exit 1
diff --git a/scripts/tests/sync-check.bats b/scripts/tests/sync-check.bats
new file mode 100644
index 0000000..df775b3
--- /dev/null
+++ b/scripts/tests/sync-check.bats
@@ -0,0 +1,86 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/sync-check.sh
+#
+# Sandboxes a fake rulesets-shaped git repo under $BATS_TMPDIR, drops the
+# real script in via PATH override, and exercises clean / drift / --fix
+# behavior.
+
+setup() {
+ SANDBOX="$BATS_TEST_TMPDIR/repo"
+ SCRIPT_SRC="$BATS_TEST_DIRNAME/../sync-check.sh"
+ mkdir -p "$SANDBOX/scripts" "$SANDBOX/claude-templates/.ai/workflows" \
+ "$SANDBOX/claude-templates/.ai/scripts" "$SANDBOX/.ai/workflows" \
+ "$SANDBOX/.ai/scripts"
+ cp "$SCRIPT_SRC" "$SANDBOX/scripts/sync-check.sh"
+ chmod +x "$SANDBOX/scripts/sync-check.sh"
+ cd "$SANDBOX"
+ git init -q
+ git config user.email test@example.com
+ git config user.name test
+ echo "protocols content" > claude-templates/.ai/protocols.org
+ echo "protocols content" > .ai/protocols.org
+ echo "workflow a" > claude-templates/.ai/workflows/a.org
+ echo "workflow a" > .ai/workflows/a.org
+ echo "script b" > claude-templates/.ai/scripts/b.sh
+ echo "script b" > .ai/scripts/b.sh
+}
+
+@test "clean tree: exit 0" {
+ run scripts/sync-check.sh
+ [ "$status" -eq 0 ]
+}
+
+@test "drift in protocols.org: exit 1, names the file" {
+ echo "drifted" > .ai/protocols.org
+ run scripts/sync-check.sh
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "drift in workflows/: exit 1, names workflows" {
+ echo "workflow a modified" > .ai/workflows/a.org
+ run scripts/sync-check.sh
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"workflows"* ]]
+}
+
+@test "drift in scripts/: exit 1, names scripts" {
+ echo "script b modified" > .ai/scripts/b.sh
+ run scripts/sync-check.sh
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"scripts"* ]]
+}
+
+@test "drift with --fix: syncs and exits 0" {
+ echo "drifted" > .ai/protocols.org
+ run scripts/sync-check.sh --fix
+ [ "$status" -eq 0 ]
+ run cat .ai/protocols.org
+ [ "$output" = "protocols content" ]
+}
+
+@test "extra file in mirror: --delete removes it" {
+ echo "stale" > .ai/workflows/stale.org
+ run scripts/sync-check.sh --fix
+ [ "$status" -eq 0 ]
+ [ ! -f .ai/workflows/stale.org ]
+}
+
+@test "missing canonical: exit 2 with clear error" {
+ rm -rf claude-templates
+ run scripts/sync-check.sh
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"not a rulesets-shaped repo"* ]]
+}
+
+@test "outside a git checkout: exit 2" {
+ cd "$BATS_TEST_TMPDIR"
+ mkdir not-a-repo
+ cp "$SCRIPT_SRC" not-a-repo/sync-check.sh
+ chmod +x not-a-repo/sync-check.sh
+ cd not-a-repo
+ run ./sync-check.sh
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"not inside a git checkout"* ]]
+}
diff --git a/todo.org b/todo.org
index a15fe6c..0df9f6d 100644
--- a/todo.org
+++ b/todo.org
@@ -1323,7 +1323,8 @@ Teach startup/routing to read =Summary= only at routing time, then =Execution= o
After the pilot, evaluate: did the savings show up in real session token use? Did the structure constrain the workflow expressiveness too much? If yes to savings and no to constraint, expand to the next-largest workflows. If not, document why and stop. Don't templatize universally — shorter workflows don't need tiering.
-** TODO [#C] Canonical/mirror drift detection via pre-commit hook or =make sync-check= :feature:quick:solo:
+** DONE [#C] Canonical/mirror drift detection via pre-commit hook or =make sync-check= :feature:quick:solo:
+CLOSED: [2026-05-28 Thu]
:PROPERTIES:
:CREATED: [2026-05-28 Thu]
:LAST_REVIEWED: 2026-05-28