diff options
Diffstat (limited to 'claude-templates/.ai/scripts')
| -rw-r--r-- | claude-templates/.ai/scripts/lint-org.el | 49 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/sync-templates | 131 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/sync-templates.bats | 319 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-lint-org.el | 69 |
4 files changed, 553 insertions, 15 deletions
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 33dc52f..fe4c31f 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.ai/scripts/lint-org.el @@ -77,17 +77,36 @@ The CLI defaults this to t (a linter reports, it doesn't write); (defvar lo-current-file nil "Path of the file currently being processed.") -(defun lo--spec-file-p () - "Non-nil when the current file lives under a docs/specs/ directory. -The four todo-format-family checkers encode todo.org completion conventions -and misfire on a spec: a spec's Decisions section legitimately carries a -level-2 DONE with no CLOSED cookie, and its review-history section carries -level-2 dated headings. docs/specs/ is the canonical spec home per the -docs-lifecycle rule, so a path segment match is the scope test. Link, -table, and structural checks still run on specs — only the todo-format -family is scoped out." +(defun lo--todo-format-exempt-p () + "Non-nil when the current file's conventions are not todo.org's. +The five todo-format-family checkers encode todo.org completion conventions, +so they misfire wherever a file legitimately uses those shapes for something +else. Two such places, both matched by canonical path: + + docs/specs/ A spec's Decisions section carries a level-2 DONE + with no CLOSED cookie, and its review-history + section carries level-2 dated headings. The + canonical spec home per the docs-lifecycle rule. + + .ai/session-context.org A session anchor carries level-2 dated headings by + .ai/session-context.d/ instruction: protocols.org prescribes `** Topic' + .ai/sessions/ Session Log headers at natural seams with optional + timestamps. So every log entry reads as a + completion defect telling the author to convert it + to DONE + CLOSED:, which would be wrong. All three + paths are the same file at different points in its + life — live, per-agent under AI_AGENT_ID, and the + archive wrap-up renames it into. + +The exemption is these paths, not all of .ai/: notes.org lives there and does +follow todo.org's conventions. Link, table, and structural checks still run on +everything — only the todo-format family is scoped out." (and lo-current-file - (string-match-p "/docs/specs/" (expand-file-name lo-current-file)))) + (let ((f (expand-file-name lo-current-file))) + (or (string-match-p "/docs/specs/" f) + (string-match-p "/\\.ai/session-context\\.org\\'" f) + (string-match-p "/\\.ai/session-context\\.d/" f) + (string-match-p "/\\.ai/sessions/" f))))) (defvar lo-followups-file nil "When non-nil, after a non-check run any judgment items are appended to this path as an org section dated today. The file is created if missing.") @@ -728,11 +747,11 @@ left unmodified and mechanical entries are recorded with :preview t." (lo--check-empty-headings) (lo--check-malformed-priority-cookies) ;; The todo-format family encodes todo.org completion conventions and - ;; misfires on a spec (a Decisions section's undated DONE, a - ;; review-history dated heading, a phases task with no LAST_REVIEWED). - ;; Scope them out of docs/specs/; link, table, and structural checks - ;; above still run there. - (unless (lo--spec-file-p) + ;; misfires wherever those shapes mean something else — a spec's + ;; undated DONE and review-history dated heading, a session anchor's + ;; dated Session Log entries. `lo--todo-format-exempt-p' owns the + ;; list; link, table, and structural checks above still run there. + (unless (lo--todo-format-exempt-p) (lo--check-level2-dated-headers) (lo--check-level2-done-without-closed) (lo--check-task-missing-last-reviewed) 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-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el index ceee209..10a4fa4 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -1098,3 +1098,72 @@ phases section may carry ** TODO [#x] items that aren't backlog tasks." (out (lo-test--run-at "todo.org" c)) (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) (should (memq 'task-missing-last-reviewed cs)))) + +;;; todo-format checkers skip session anchors too (protocols.org) +;; +;; A session anchor carries level-2 dated headings BY INSTRUCTION, not by +;; accident: protocols.org prescribes "** Topic section headers at natural +;; seams ... Timestamps optional — use when genuinely useful" for the Session +;; Log. So level-2-dated-header reads every log entry as a completion defect +;; and tells the author to convert it to DONE + CLOSED:, which would be wrong. +;; Reported from work 2026-08-03: six judgment items on one 427-line anchor, +;; and sentry pass 8 surfaces them nightly in every project. +;; +;; Three anchor paths, all the same shape: the live anchor, the per-agent +;; anchors under AI_AGENT_ID, and the archives wrap-up renames them into. + +(defconst lo-test--anchor-log + "* Session Log\n** 2026-08-03 Sun @ 14:02:11 -0500 Diagnosed the sync guard\nNarrative.\n" + "A Session Log entry: a level-2 dated header, the documented correct form.") + +(ert-deftest lo-level2-dated-header-skips-the-live-anchor () + (let* ((out (lo-test--run-at ".ai/session-context.org" lo-test--anchor-log)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level-2-dated-header cs)))) + +(ert-deftest lo-level2-dated-header-skips-per-agent-anchors () + "AI_AGENT_ID resolves the anchor into .ai/session-context.d/<id>.org." + (let* ((out (lo-test--run-at ".ai/session-context.d/velox.rulesets.claude.1718400000.org" + lo-test--anchor-log)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level-2-dated-header cs)))) + +(ert-deftest lo-level2-dated-header-skips-archived-anchors () + "Wrap-up renames the anchor into .ai/sessions/ unchanged, so the archive has +the same shape as the live file and lints the same way." + (let* ((out (lo-test--run-at ".ai/sessions/2026-08-03-14-02-some-work.org" + lo-test--anchor-log)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level-2-dated-header cs)))) + +(ert-deftest lo-whole-todo-format-family-skips-anchors () + "The five are scoped together, as they are for specs — a Session Log is free +to carry any of these shapes while narrating what happened." + (let* ((c (concat "* Session Log\n" + "** DONE Shipped the guard\n" + "** 2026-08-03 Sun @ 14:02:11 -0500 Did a thing\n" + "SCHEDULED: <2026-08-10 Mon>\n" + "** TODO [#B] A note to self\n" + "*** DONE A sub-step\n")) + (out (lo-test--run-at ".ai/session-context.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level2-done-without-closed cs)) + (should-not (memq 'level-2-dated-header cs)) + (should-not (memq 'dated-log-heading-active-timestamp cs)) + (should-not (memq 'task-missing-last-reviewed cs)) + (should-not (memq 'subtask-done-not-dated cs)))) + +(ert-deftest lo-link-checks-still-fire-on-anchors () + "Only the todo-format family is scoped out. An anchor's links are worth +checking — it is where a session records where its artifacts landed." + (let* ((c "* Session Log\n** Topic\n[[file:does-not-exist-xyz.org][link]]\n") + (out (lo-test--run-at ".ai/session-context.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'link-to-local-file cs)))) + +(ert-deftest lo-a-plain-ai-file-is-not-exempt () + "The exemption is the three anchor paths, not all of .ai/ — notes.org lives +there and follows todo.org's conventions." + (let* ((out (lo-test--run-at ".ai/notes.org" lo-test--anchor-log)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'level-2-dated-header cs)))) |
