aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/sync-templates
blob: b9769f3cb6fec3aa498750fe2270d56fd2164fae (plain)
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
#!/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, and they work differently. One withholds files; the other skips
# the whole run:
#
#   Rulesets dirty under the synced paths → withhold exactly those files.
#   rsync -a --delete copies the working tree by disk presence, so an in-flight
#   edit in rulesets would otherwise land downstream as drift the project never
#   authored. Each dirty path becomes an --exclude, which rsync honors on both
#   sides: the file is neither overwritten nor deleted downstream, and every
#   clean file still propagates. This was a global skip until 2026-07-31, which
#   meant one uncommitted file in rulesets froze every template for every
#   project until it was committed.
#
#   Project branch behind its upstream → skip the whole sync. 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. This one stays all-or-nothing because
#   the staleness is in the destination, not in any particular source file.
#
# 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
}

# The dirty set under the synced paths, one repo-relative path per line.
# core.quotePath=false keeps a non-ASCII filename literal instead of \xNN-escaped,
# so the path we build an --exclude from is the path on disk.
synced_dirty=$(cd "$rs" && git -c core.quotePath=false status --porcelain -- \
    claude-templates/.ai/protocols.org \
    claude-templates/.ai/workflows/ \
    claude-templates/.ai/scripts/ 2>/dev/null)

# Turn that set into per-rsync --exclude flags rather than a global skip. An
# excluded path is neither overwritten nor deleted on the receiving side, so an
# in-flight edit stays in-flight while every file it doesn't touch propagates
# normally. The all-or-nothing skip this replaces is what caused the 2026-07-30
# outage: one uncommitted workflow file withheld every template from every
# project for a full day.
protocols_dirty=0
wf_excludes=()
sc_excludes=()
withheld=()

while IFS= read -r line; do
    [ -z "$line" ] && continue
    path="${line:3}"
    # A rename reports "old -> new". Withhold both sides: the new name is
    # half-landed, and sweeping the old copy downstream would delete a file the
    # project still runs while the rename sits uncommitted.
    if [[ "$path" == *" -> "* ]]; then
        paths=("${path%% -> *}" "${path##* -> }")
    else
        paths=("$path")
    fi
    for p in "${paths[@]}"; do
        p="${p%\"}"; p="${p#\"}"
        case "$p" in
            claude-templates/.ai/protocols.org)
                # A single-file rsync has nothing to exclude within, so this one
                # transfer is skipped outright while the other two still run.
                protocols_dirty=1
                withheld+=("$p")
                ;;
            claude-templates/.ai/workflows/*)
                # Leading / anchors the pattern to the transfer root, so a dirty
                # workflows/foo.org can't also suppress scripts/tests/foo.org.
                wf_excludes+=("--exclude=/${p#claude-templates/.ai/workflows/}")
                withheld+=("$p")
                ;;
            claude-templates/.ai/scripts/*)
                sc_excludes+=("--exclude=/${p#claude-templates/.ai/scripts/}")
                withheld+=("$p")
                ;;
        esac
    done
done <<< "$synced_dirty"

# 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 [ "$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
    [ "$protocols_dirty" -eq 0 ] && rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org
    rsync -a --delete "${wf_excludes[@]}" "$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' \
        "${sc_excludes[@]}" "$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"
    if [ ${#withheld[@]} -gt 0 ]; then
        echo "  withheld — uncommitted in rulesets, lands once committed:"
        printf '    %s\n' "${withheld[@]}"
    fi
fi