diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-31 06:07:34 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-31 06:07:34 -0500 |
| commit | f57105773366aebdceeafdb51dc3584c42b0e4e4 (patch) | |
| tree | e2291c94586bb962ab732277494958d914eb1b8b /.ai/scripts | |
| parent | 84bd121683add4440a3652b12b70e6e2d45f5384 (diff) | |
| download | rulesets-f57105773366aebdceeafdb51dc3584c42b0e4e4.tar.gz rulesets-f57105773366aebdceeafdb51dc3584c42b0e4e4.zip | |
refactor(startup): extract the template sync into a tested script
The .ai/ sync is how every workflow, protocol and script change reaches every project, and until now it was untested inline bash in startup.org. Yesterday it failed in two ways at once and nothing anywhere reported either.
An uncommitted edit of mine under the synced paths skipped all three rsyncs for a full day, so no project got anything. Five workflow files went stale in .emacs.d alone. One of them was a telegram plugin whose staleness crashed telega-server on every triage run, which is the only reason anyone noticed.
Work found the second failure. The --delete rsync silently reverts a local patch to a rulesets-owned file, so they hit a bug, fixed it, restarted, and came back running the broken version. Their own log still said it was patched.
I want both guards changed. Neither change is safe to make against inline bash that runs in every session, so this commit only moves the logic and pins what it does.
sync-templates is a faithful extraction, rough edges included. I verified it differentially rather than by reading: the old block and the new script over the same fixtures produce identical stdout, exit status and resulting tree.
Two of the fifteen characterization tests pin the failures above. "ONE dirty file blocks ALL THREE rsyncs" is the blast radius in one assertion, and the narrowing lands by turning it red on purpose. "a locally-edited template is silently overwritten, with no record kept" pins work's regression, asserting the output is indistinguishable from an ordinary sync and that no warning exists anywhere.
A third test pins a false-success path I found but deliberately did not fix: the success line prints unconditionally, so a run whose rsyncs all failed still reports a clean sync. That matters next rather than now, because a last-synced manifest written from that branch would stamp success onto a sync that did nothing. The script carries the warning at the line itself.
startup.org keeps a fallback for the case where it holds this file but the script is missing. It announces the skip and names the recovery command, because that state cannot heal itself: the fallback runs instead of the sync, so no later sync can deliver what is missing.
Diffstat (limited to '.ai/scripts')
| -rwxr-xr-x | .ai/scripts/sync-templates | 75 | ||||
| -rw-r--r-- | .ai/scripts/tests/sync-templates.bats | 233 |
2 files changed, 308 insertions, 0 deletions
diff --git a/.ai/scripts/sync-templates b/.ai/scripts/sync-templates new file mode 100755 index 0000000..c4db212 --- /dev/null +++ b/.ai/scripts/sync-templates @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# sync-templates — copy rulesets' canonical .ai/ templates into this project. +# +# Extracted verbatim from startup.org Phase A step 3 (2026-07-31). This is the +# mechanism that distributes every workflow, protocol and script change to every +# project, and until the extraction it was untested inline bash running in every +# session. The extraction exists so the guard changes that follow can be tested +# before they reach a file whose failure mode is "no project starts". +# +# Behavior is deliberately identical to the inline block it replaces, including +# its rough edges. Anything that looks like a defect here is characterized by a +# test rather than fixed in passing — a change of behavior belongs in its own +# commit, not smuggled into an extraction. +# +# Usage: sync-templates [project-root] (default: $PWD) +# Output: one line naming the outcome, matching the previous inline wording +# Exit: 0 always, as the inline block did — the outcome is on stdout +# +# Two guards decide whether the sync runs at all: +# +# Rulesets dirty under the synced paths → skip. rsync -a --delete copies the +# working tree by disk presence, so an in-flight edit in rulesets would land +# downstream as drift the project never authored. +# +# Project branch behind its upstream → skip. Syncing onto a stale committed +# .ai/ baseline measures the diff against old content, so it comes out huge +# and conflicts once the branch reconciles to an upstream that already +# carries the newer templates. +# +# The rulesets location is injectable (SYNC_RULESETS_DIR) so the guards can be +# exercised against a fixture instead of the real checkout. + +rs="${SYNC_RULESETS_DIR:-$HOME/code/rulesets}" +proj="${1:-$PWD}" + +cd "$proj" || { + echo "sync-templates: cannot enter '$proj'" >&2 + exit 0 +} + +synced_dirty=$(cd "$rs" && git status --porcelain -- \ + claude-templates/.ai/protocols.org \ + claude-templates/.ai/workflows/ \ + claude-templates/.ai/scripts/ 2>/dev/null) + +# behind==0 (up-to-date or ahead-only) means HEAD contains all of upstream, so +# the baseline is current. No upstream (new/unpushed branch) → rev-list fails → +# proj_behind stays 0 → the sync runs. +proj_behind=0 +if [ -d .git ]; then + counts=$(git rev-list --left-right --count '@{u}...HEAD' 2>/dev/null) \ + && [ "$(printf '%s' "$counts" | cut -f1)" -gt 0 ] 2>/dev/null \ + && proj_behind=1 +fi + +if [ -n "$synced_dirty" ]; then + echo "rulesets has uncommitted changes under the synced template paths — skipping .ai/ sync this session (catches up when rulesets is clean):" + echo "$synced_dirty" | sed 's/^/ /' +elif [ "$proj_behind" -eq 1 ]; then + echo "project branch is behind upstream — skipping .ai/ sync this session (templates never land on a stale baseline; the sync runs once the branch is current)" +else + rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org + rsync -a --delete "$rs/claude-templates/.ai/workflows/" .ai/workflows/ + # Running rulesets' own pytest leaves these in the canonical scripts/tests/, + # and rsync -a copies by disk presence regardless of .gitignore, so without + # the excludes every project's tree collects machine-specific cache files. + rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' \ + "$rs/claude-templates/.ai/scripts/" .ai/scripts/ + # Known false-success path, inherited and characterized rather than fixed + # here: this line prints unconditionally, so a run where all three rsyncs + # failed (an absent canonical source, say) still reports a successful sync. + # A last-synced manifest must not be written from this branch as it stands — + # it would stamp success onto a sync that did nothing. + echo ".ai/ synced from templates" +fi diff --git a/.ai/scripts/tests/sync-templates.bats b/.ai/scripts/tests/sync-templates.bats new file mode 100644 index 0000000..e4e92cf --- /dev/null +++ b/.ai/scripts/tests/sync-templates.bats @@ -0,0 +1,233 @@ +#!/usr/bin/env bats +# Characterization tests for sync-templates — the mechanism that distributes +# every template change to every project. +# +# These pin CURRENT behavior (record-not-spec) ahead of the guard changes the +# 2026-07-30 propagation incident calls for. That incident is what these exist +# for: an uncommitted edit in rulesets silently blocked all three rsyncs for a +# whole day, and five workflow files went stale in one downstream project alone +# with nothing anywhere reporting it. Any change to this script's guards has to +# come with a red test here first. +# +# Everything runs against fixture directories via SYNC_RULESETS_DIR, so no test +# touches the real rulesets checkout or any real project. + +setup() { + # This suite lives beside the script it tests and travels with it, so it + # resolves the script relative to itself rather than to a repo root. + SYNC="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/sync-templates" + WORK="$(mktemp -d)" + RS="$WORK/rulesets" + PROJ="$WORK/proj" + export SYNC_RULESETS_DIR="$RS" + + # A minimal rulesets fixture: a git repo with the three synced source paths. + mkdir -p "$RS/claude-templates/.ai/workflows" "$RS/claude-templates/.ai/scripts" + printf 'canonical protocols\n' > "$RS/claude-templates/.ai/protocols.org" + printf 'canonical startup\n' > "$RS/claude-templates/.ai/workflows/startup.org" + printf 'canonical helper\n' > "$RS/claude-templates/.ai/scripts/helper" + # Real rulesets gitignores the python cache paths, so they never make the + # tree dirty. Without this the fixture diverges from production in a way + # that silently disarms the exclusion test: the cache files read as + # untracked, guard one fires, the sync never runs, and assertions that the + # cache did NOT arrive pass because nothing arrived at all. + printf '__pycache__/\n.pytest_cache/\n*.pyc\n' > "$RS/.gitignore" + _mk_repo "$RS" + + # A consuming project with the destination dirs. + mkdir -p "$PROJ/.ai/workflows" "$PROJ/.ai/scripts" +} + +teardown() { rm -rf "$WORK"; } + +_mk_repo() { + local d="$1" + git init -q "$d" + git -C "$d" config user.email t@example.com + git -C "$d" config user.name tester + git -C "$d" config commit.gpgsign false + git -C "$d" config gc.auto 0 + git -C "$d" config maintenance.auto false + git -C "$d" add -A + # --allow-empty: the project fixture holds only empty directories, which git + # has nothing to commit, and these tests need it to be a repo with a HEAD. + git -C "$d" commit -q --allow-empty -m init +} + +# --- the happy path ------------------------------------------------------------ + +@test "clean rulesets and a non-git project: syncs all three paths" { + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] + [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "canonical startup" ] + [ "$(cat "$PROJ/.ai/scripts/helper")" = "canonical helper" ] +} + +@test "--delete removes a retired template file from the project" { + printf 'retired\n' > "$PROJ/.ai/workflows/gone.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] + [ ! -e "$PROJ/.ai/workflows/gone.org" ] +} + +@test "the scripts sync excludes python cache artifacts" { + mkdir -p "$RS/claude-templates/.ai/scripts/__pycache__" \ + "$RS/claude-templates/.ai/scripts/.pytest_cache" + printf 'junk\n' > "$RS/claude-templates/.ai/scripts/__pycache__/x.pyc" + printf 'junk\n' > "$RS/claude-templates/.ai/scripts/.pytest_cache/y" + printf 'junk\n' > "$RS/claude-templates/.ai/scripts/stray.pyc" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + # Assert the sync RAN before asserting what it didn't copy. Without this the + # absences below are satisfied by a skipped sync, and the whole test passes + # with every --exclude flag deleted from the script. + [[ "$output" == *"synced from templates"* ]] + [ -e "$PROJ/.ai/scripts/helper" ] + [ ! -e "$PROJ/.ai/scripts/__pycache__" ] + [ ! -e "$PROJ/.ai/scripts/.pytest_cache" ] + [ ! -e "$PROJ/.ai/scripts/stray.pyc" ] +} + +@test "project-owned directories are never touched by the sync" { + mkdir -p "$PROJ/.ai/project-workflows" "$PROJ/.ai/project-scripts" + printf 'mine\n' > "$PROJ/.ai/project-workflows/local.org" + printf 'mine\n' > "$PROJ/.ai/project-scripts/local.py" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] + [ "$(cat "$PROJ/.ai/project-workflows/local.org")" = "mine" ] + [ "$(cat "$PROJ/.ai/project-scripts/local.py")" = "mine" ] +} + +# --- guard one: rulesets dirty under the synced paths -------------------------- + +@test "a dirty synced path in rulesets skips the sync and names the file" { + printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" + printf 'stale\n' > "$PROJ/.ai/workflows/startup.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"uncommitted changes under the synced template paths"* ]] + [[ "$output" == *"startup.org"* ]] + # Nothing propagated — this is the whole-window blast radius from 2026-07-30. + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ] +} + +@test "ONE dirty file blocks ALL THREE rsyncs, not just its own" { + # The 2026-07-30 incident in one assertion: an edit to a workflow file also + # withheld protocols.org and every script. This is current behavior, and the + # narrowing change lands by making this test fail on purpose. + printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" + printf 'stale protocols\n' > "$PROJ/.ai/protocols.org" + printf 'stale helper\n' > "$PROJ/.ai/scripts/helper" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [ "$(cat "$PROJ/.ai/protocols.org")" = "stale protocols" ] + [ "$(cat "$PROJ/.ai/scripts/helper")" = "stale helper" ] +} + +@test "an untracked file under a synced path also blocks the sync" { + printf 'new template\n' > "$RS/claude-templates/.ai/workflows/brand-new.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"uncommitted changes under the synced template paths"* ]] +} + +@test "rulesets dirt OUTSIDE the synced paths does not block the sync" { + printf 'scratch\n' > "$RS/scratch.txt" + printf 'edit\n' >> "$RS/claude-templates/bin-ish.txt" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] +} + +# --- guard two: the project branch is behind its upstream ---------------------- + +@test "a project behind its upstream skips the sync" { + _mk_repo "$PROJ" + git init -q --bare "$WORK/remote" + git -C "$PROJ" remote add origin "$WORK/remote" + git -C "$PROJ" push -q -u origin HEAD + git -C "$PROJ" commit -q --allow-empty -m ahead + git -C "$PROJ" push -q origin HEAD + git -C "$PROJ" reset -q --hard HEAD~1 + printf 'stale\n' > "$PROJ/.ai/workflows/startup.org" + + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"behind upstream"* ]] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ] +} + +@test "a project AHEAD of its upstream still syncs" { + _mk_repo "$PROJ" + git init -q --bare "$WORK/remote" + git -C "$PROJ" remote add origin "$WORK/remote" + git -C "$PROJ" push -q -u origin HEAD + git -C "$PROJ" commit -q --allow-empty -m ahead + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] +} + +@test "a git project with no upstream syncs (rev-list fails, guard stays off)" { + _mk_repo "$PROJ" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] +} + +# --- edges --------------------------------------------------------------------- + +@test "a nonexistent project directory reports and exits 0 without syncing" { + run bash "$SYNC" "$WORK/does-not-exist" + [ "$status" -eq 0 ] + [[ "$output" == *"cannot enter"* ]] + [[ "$output" != *"synced from templates"* ]] + [ ! -e "$WORK/does-not-exist" ] +} + +@test "the sync is idempotent — a second run changes nothing" { + bash "$SYNC" "$PROJ" + first="$(find "$PROJ/.ai" -type f -exec sha256sum {} + | sort)" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] + second="$(find "$PROJ/.ai" -type f -exec sha256sum {} + | sort)" + [ "$first" = "$second" ] +} + +@test "a missing canonical source still reports success — the false-success path" { + # Faithfully inherited from the inline block, and pinned here because the + # manifest step depends on it: a last-synced record written after this + # branch would stamp a successful sync onto one where all three rsyncs + # failed. The guard work has to fix this before it can trust the record. + # Point at a rulesets that isn't there at all, rather than deleting the + # canonical subtree inside a live repo — that would show up as staged + # deletions and trip guard one, which is a different path entirely. + SYNC_RULESETS_DIR="$WORK/no-such-rulesets" run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"synced from templates"* ]] + # Nothing arrived, and the success line says otherwise. Asserted against a + # file that DOES arrive on a real sync — protocols.org is absent before any + # sync too, so its absence alone would prove nothing. + [ ! -e "$PROJ/.ai/scripts/helper" ] +} + +@test "a locally-edited template is silently overwritten, with no record kept" { + # Work's 2026-07-30 regression, pinned: a project patches a rulesets-owned + # file, the next sync reverts it to canonical, and nothing anywhere says so. + # The output is indistinguishable from an ordinary successful sync. + bash "$SYNC" "$PROJ" + printf 'local fix for a real bug\n' > "$PROJ/.ai/workflows/startup.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "canonical startup" ] + [[ "$output" == *"synced from templates"* ]] + # No warning, no backup, no manifest — the loss leaves no trace at all. + [[ "$output" != *"overwrote"* ]] + [[ "$output" != *"local edit"* ]] + [ ! -d "$PROJ/.ai/.sync-backups" ] +} |
