aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/.ai/scripts')
-rwxr-xr-xclaude-templates/.ai/scripts/sync-templates131
-rw-r--r--claude-templates/.ai/scripts/tests/sync-templates.bats319
-rw-r--r--claude-templates/.ai/scripts/tests/test-todo-cleanup.el2
-rw-r--r--claude-templates/.ai/scripts/todo-cleanup.el2
4 files changed, 452 insertions, 2 deletions
diff --git a/claude-templates/.ai/scripts/sync-templates b/claude-templates/.ai/scripts/sync-templates
new file mode 100755
index 0000000..b9769f3
--- /dev/null
+++ b/claude-templates/.ai/scripts/sync-templates
@@ -0,0 +1,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
diff --git a/claude-templates/.ai/scripts/tests/sync-templates.bats b/claude-templates/.ai/scripts/tests/sync-templates.bats
new file mode 100644
index 0000000..6651c99
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/sync-templates.bats
@@ -0,0 +1,319 @@
+#!/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 file is withheld from the sync and named in the output" {
+ 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" == *"withheld"* ]]
+ [[ "$output" == *"startup.org"* ]]
+ # The in-flight edit still must not land downstream.
+ [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ]
+}
+
+@test "ONE dirty file no longer blocks the other two rsyncs" {
+ # The 2026-07-30 incident, inverted. An edit to a workflow file used to
+ # withhold protocols.org and every script for every project; now it withholds
+ # only itself.
+ 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")" = "canonical protocols" ]
+ [ "$(cat "$PROJ/.ai/scripts/helper")" = "canonical helper" ]
+}
+
+@test "a dirty file's clean siblings under the SAME path still sync" {
+ printf 'canonical wrap\n' > "$RS/claude-templates/.ai/workflows/wrap.org"
+ git -C "$RS" add -A && git -C "$RS" commit -q -m 'add wrap'
+ printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org"
+ printf 'stale wrap\n' > "$PROJ/.ai/workflows/wrap.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ # The narrowing is per-file, not per-directory: only startup.org is held back.
+ [ "$(cat "$PROJ/.ai/workflows/wrap.org")" = "canonical wrap" ]
+}
+
+@test "an excluded file is not deleted by --delete either" {
+ # rsync honors --exclude on both sides, so a withheld file that exists
+ # downstream must survive the run rather than being swept as a stray.
+ printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org"
+ printf 'project copy\n' > "$PROJ/.ai/workflows/startup.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -e "$PROJ/.ai/workflows/startup.org" ]
+ [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "project copy" ]
+}
+
+@test "a dirty protocols.org withholds only itself; workflows and scripts sync" {
+ printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/protocols.org"
+ printf 'stale protocols\n' > "$PROJ/.ai/protocols.org"
+ printf 'stale startup\n' > "$PROJ/.ai/workflows/startup.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/workflows/startup.org")" = "canonical startup" ]
+ [ "$(cat "$PROJ/.ai/scripts/helper")" = "canonical helper" ]
+}
+
+@test "dirty files across two synced paths withhold both, sync the third" {
+ printf 'in-flight\n' >> "$RS/claude-templates/.ai/workflows/startup.org"
+ printf 'in-flight\n' >> "$RS/claude-templates/.ai/scripts/helper"
+ printf 'stale startup\n' > "$PROJ/.ai/workflows/startup.org"
+ printf 'stale helper\n' > "$PROJ/.ai/scripts/helper"
+ printf 'stale protocols\n' > "$PROJ/.ai/protocols.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale startup" ]
+ [ "$(cat "$PROJ/.ai/scripts/helper")" = "stale helper" ]
+ [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ]
+}
+
+@test "an untracked file under a synced path is withheld, not blocking" {
+ printf 'new template\n' > "$RS/claude-templates/.ai/workflows/brand-new.org"
+ printf 'stale protocols\n' > "$PROJ/.ai/protocols.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"withheld"* ]]
+ # An unfinished new template must not ship half-written...
+ [ ! -e "$PROJ/.ai/workflows/brand-new.org" ]
+ # ...and must not hold back everything else.
+ [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ]
+}
+
+@test "an untracked DIRECTORY under a synced path is withheld whole" {
+ # git collapses an untracked dir to one porcelain line with a trailing slash
+ # ("?? .../plugins/"), so the exclude has to match the directory rather than
+ # the files inside it. A half-written plugin dir must not ship.
+ mkdir -p "$RS/claude-templates/.ai/workflows/plugins"
+ printf 'half written\n' > "$RS/claude-templates/.ai/workflows/plugins/new.org"
+ printf 'stale protocols\n' > "$PROJ/.ai/protocols.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ ! -e "$PROJ/.ai/workflows/plugins" ]
+ [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ]
+}
+
+@test "a file dirty in the index (staged, uncommitted) is withheld too" {
+ printf 'staged edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org"
+ git -C "$RS" add claude-templates/.ai/workflows/startup.org
+ printf 'stale\n' > "$PROJ/.ai/workflows/startup.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ]
+}
+
+@test "a renamed template withholds both the old and the new path" {
+ git -C "$RS" mv claude-templates/.ai/workflows/startup.org \
+ claude-templates/.ai/workflows/renamed.org
+ printf 'stale startup\n' > "$PROJ/.ai/workflows/startup.org"
+ run bash "$SYNC" "$PROJ"
+ [ "$status" -eq 0 ]
+ # The new name must not ship mid-rename, and the old copy must not be swept
+ # while the rename is still uncommitted.
+ [ ! -e "$PROJ/.ai/workflows/renamed.org" ]
+ [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale startup" ]
+}
+
+@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" ]
+}
diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
index a92d238..1e964b3 100644
--- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
+++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
@@ -1086,7 +1086,7 @@ line) is left untouched — the strip stops at the first non-planning line."
;;
;; todo-cleanup rewrites todo.org in place and left no copy behind, while both
;; sibling org-mutators back up to /tmp first. It is also the one that runs most
-;; often (every wrap, every sentry fire). Emacs's own backup does not fire under
+;; often (every wrap, every sentry cycle). Emacs's own backup does not fire under
;; --batch -q, so there was genuinely no undo short of git.
(ert-deftest tc-backup-written-before-a-real-mutation ()
diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el
index cb333e2..516e9b1 100644
--- a/claude-templates/.ai/scripts/todo-cleanup.el
+++ b/claude-templates/.ai/scripts/todo-cleanup.el
@@ -843,7 +843,7 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors
Matches `lint-org.el' and `wrap-org-table.el', the other tools that rewrite
these org files. todo-cleanup runs the most often of the three (every wrap,
-every sentry fire), and Emacs's own backup does not fire under --batch -q, so
+every sentry cycle), and Emacs's own backup does not fire under --batch -q, so
without this a mechanical rewrite has no undo short of git — which recovers
only to the last commit and loses intra-session work."
(let* ((base (format "%s%s.before-todo-cleanup.%s"