aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-30 21:41:06 -0500
committerCraig Jennings <c@cjennings.net>2026-05-30 21:41:06 -0500
commit9a1bea9bfc066312bf9743dc23c88f191a36cc16 (patch)
tree1916eb07c27e83b3f82bc5743587517061acd899
parent1f79945dd4ab386a9ea4ac58fb2161c174a26bba (diff)
downloadrulesets-9a1bea9bfc066312bf9743dc23c88f191a36cc16.tar.gz
rulesets-9a1bea9bfc066312bf9743dc23c88f191a36cc16.zip
fix(startup): skip the .ai/ template sync when rulesets has uncommitted WIP
From jr-estate's handoff: Phase A's =rsync -a --delete= copies the rulesets working tree by disk presence, so a downstream session that starts while rulesets has in-flight WIP pulls that WIP into its own =.ai/workflows/= and =.ai/scripts/=, where it reads as drift the user never authored. I guarded the three rsyncs behind a =git status --porcelain= check on the synced source paths (=claude-templates/.ai/{protocols.org,workflows/,scripts/}=). It syncs when those are clean and skips with a message when dirty, catching up on the next clean session. The check is scoped to those paths, so unrelated rulesets dirt (a stray session-context.org, scratch files) doesn't block the sync. The handoff's secondary anomaly (two workflow files that didn't reach jr-estate) was a timeline artifact, not a Phase A bug. Both were added in 664bf01 on 2026-05-29, after jr-estate's rsync had already run, so they correctly didn't exist to copy yet.
-rw-r--r--.ai/workflows/startup.org38
-rw-r--r--claude-templates/.ai/workflows/startup.org38
-rw-r--r--todo.org4
3 files changed, 59 insertions, 21 deletions
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index 0cd7b88..9b95b17 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -95,22 +95,40 @@ These calls have no dependencies on each other. Issue them all together in one m
1. =date "+%A %Y-%m-%d %H:%M %Z"= — accurate timestamp.
2. Check whether =.ai/session-context.org= exists (e.g. =[ -e .ai/session-context.org ] && echo present || echo absent=).
-3. =rsync -a ~/code/rulesets/claude-templates/.ai/protocols.org .ai/protocols.org=.
-4. =rsync -a --delete ~/code/rulesets/claude-templates/.ai/workflows/ .ai/workflows/=.
-5. =rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' ~/code/rulesets/claude-templates/.ai/scripts/ .ai/scripts/=.
-6. =\ls -t .ai/sessions/ 2>/dev/null | head -5= — list 5 most recent session files. The backslash bypasses any =ls= alias in the user's profile. Without it, bare =ls -t= silently returns no output under =exa= (a common =ls= replacement) — which makes a sessions directory full of files look empty, and the agent then skips Phase B step 2.
-7. =\ls -la inbox/ 2>/dev/null= — inventory the inbox. Same reason for the backslash escape, applied uniformly across the Phase A =ls= calls.
-8. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
-9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
-10. Read =.ai/project-workflows/startup-extras.org= if it exists.
-11. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
-12. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
+3. *Sync =.ai/= from templates — but only when the synced source paths in rulesets are clean.* Guard the three rsyncs behind a check that =claude-templates/.ai/{protocols.org,workflows/,scripts/}= have no uncommitted changes. Otherwise Phase A copies in-flight rulesets WIP (tracked edits or new untracked files) into this project's =.ai/workflows/= and =.ai/scripts/=, where it shows up as drift the user didn't author. Skipping once is cheap — the next session with rulesets clean catches up. The check is scoped to the synced paths, so unrelated rulesets dirt (a stray =session-context.org=, scratch files) doesn't needlessly block the sync.
+
+ #+begin_src bash
+ rs="$HOME/code/rulesets"
+ synced_dirty=$(cd "$rs" && git status --porcelain -- \
+ claude-templates/.ai/protocols.org \
+ claude-templates/.ai/workflows/ \
+ claude-templates/.ai/scripts/ 2>/dev/null)
+ if [ -z "$synced_dirty" ]; then
+ rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org
+ rsync -a --delete "$rs/claude-templates/.ai/workflows/" .ai/workflows/
+ rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' \
+ "$rs/claude-templates/.ai/scripts/" .ai/scripts/
+ echo ".ai/ synced from templates"
+ else
+ 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/^/ /'
+ fi
+ #+end_src
+
+4. =\ls -t .ai/sessions/ 2>/dev/null | head -5= — list 5 most recent session files. The backslash bypasses any =ls= alias in the user's profile. Without it, bare =ls -t= silently returns no output under =exa= (a common =ls= replacement) — which makes a sessions directory full of files look empty, and the agent then skips Phase B step 2.
+5. =\ls -la inbox/ 2>/dev/null= — inventory the inbox. Same reason for the backslash escape, applied uniformly across the Phase A =ls= calls.
+6. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
+7. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
+8. Read =.ai/project-workflows/startup-extras.org= if it exists.
+9. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
+10. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
- =--delete= on the directory syncs lets retired template files actually disappear from each project on next startup.
- protocols.org is a single file, no =--delete= needed.
- The =scripts/= sync excludes Python build artifacts (=__pycache__/=, =.pytest_cache/=, =*.pyc=). Running rulesets' own pytest leaves these in =claude-templates/.ai/scripts/tests/=, and =rsync -a= copies by disk presence regardless of =.gitignore=, so without the excludes every consuming project's tree gets polluted with machine-specific cache files. The excludes also protect existing dest copies from =--delete= cleanup, so a project that already received the cache must remove it once by hand.
+- The sync is guarded to skip when rulesets has uncommitted changes under the synced source paths. =rsync -a --delete= copies the working tree by disk presence, so without the guard a downstream session started while rulesets had in-flight WIP would pull that WIP into its =.ai/workflows/= and =.ai/scripts/=, surfacing as drift the user never authored (and tempting a fake "chore: sync .ai tooling" commit). The guard is scoped to the synced paths, not the whole repo, so unrelated rulesets dirt doesn't block the sync. From the jr-estate handoff 2026-05-29.
- The sync touches only =protocols.org=, =workflows/=, and =scripts/=. The project-owned dirs =project-workflows/= and =project-scripts/= are deliberately *outside* the synced set, so a project's own workflows and scripts survive startup. This is why a project script that a workflow imports must live in =.ai/project-scripts/=, never =.ai/scripts/= — the latter is wiped to match the template by =--delete= on every startup. Naming: a script imported as a Python module needs an importable name (underscores, e.g. =zlibrary_api.py=); a CLI-invoked script can stay kebab-case like the template tooling (=cmail-action.py=).
Rationale: Every call in Phase A is read-only or writes to a distinct path. Running them sequentially wastes round-trips; running them in parallel gives Claude the complete starting picture in one round-trip.
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 0cd7b88..9b95b17 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -95,22 +95,40 @@ These calls have no dependencies on each other. Issue them all together in one m
1. =date "+%A %Y-%m-%d %H:%M %Z"= — accurate timestamp.
2. Check whether =.ai/session-context.org= exists (e.g. =[ -e .ai/session-context.org ] && echo present || echo absent=).
-3. =rsync -a ~/code/rulesets/claude-templates/.ai/protocols.org .ai/protocols.org=.
-4. =rsync -a --delete ~/code/rulesets/claude-templates/.ai/workflows/ .ai/workflows/=.
-5. =rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' ~/code/rulesets/claude-templates/.ai/scripts/ .ai/scripts/=.
-6. =\ls -t .ai/sessions/ 2>/dev/null | head -5= — list 5 most recent session files. The backslash bypasses any =ls= alias in the user's profile. Without it, bare =ls -t= silently returns no output under =exa= (a common =ls= replacement) — which makes a sessions directory full of files look empty, and the agent then skips Phase B step 2.
-7. =\ls -la inbox/ 2>/dev/null= — inventory the inbox. Same reason for the backslash escape, applied uniformly across the Phase A =ls= calls.
-8. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
-9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
-10. Read =.ai/project-workflows/startup-extras.org= if it exists.
-11. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
-12. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
+3. *Sync =.ai/= from templates — but only when the synced source paths in rulesets are clean.* Guard the three rsyncs behind a check that =claude-templates/.ai/{protocols.org,workflows/,scripts/}= have no uncommitted changes. Otherwise Phase A copies in-flight rulesets WIP (tracked edits or new untracked files) into this project's =.ai/workflows/= and =.ai/scripts/=, where it shows up as drift the user didn't author. Skipping once is cheap — the next session with rulesets clean catches up. The check is scoped to the synced paths, so unrelated rulesets dirt (a stray =session-context.org=, scratch files) doesn't needlessly block the sync.
+
+ #+begin_src bash
+ rs="$HOME/code/rulesets"
+ synced_dirty=$(cd "$rs" && git status --porcelain -- \
+ claude-templates/.ai/protocols.org \
+ claude-templates/.ai/workflows/ \
+ claude-templates/.ai/scripts/ 2>/dev/null)
+ if [ -z "$synced_dirty" ]; then
+ rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org
+ rsync -a --delete "$rs/claude-templates/.ai/workflows/" .ai/workflows/
+ rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' \
+ "$rs/claude-templates/.ai/scripts/" .ai/scripts/
+ echo ".ai/ synced from templates"
+ else
+ 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/^/ /'
+ fi
+ #+end_src
+
+4. =\ls -t .ai/sessions/ 2>/dev/null | head -5= — list 5 most recent session files. The backslash bypasses any =ls= alias in the user's profile. Without it, bare =ls -t= silently returns no output under =exa= (a common =ls= replacement) — which makes a sessions directory full of files look empty, and the agent then skips Phase B step 2.
+5. =\ls -la inbox/ 2>/dev/null= — inventory the inbox. Same reason for the backslash escape, applied uniformly across the Phase A =ls= calls.
+6. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
+7. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
+8. Read =.ai/project-workflows/startup-extras.org= if it exists.
+9. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
+10. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
- =--delete= on the directory syncs lets retired template files actually disappear from each project on next startup.
- protocols.org is a single file, no =--delete= needed.
- The =scripts/= sync excludes Python build artifacts (=__pycache__/=, =.pytest_cache/=, =*.pyc=). Running rulesets' own pytest leaves these in =claude-templates/.ai/scripts/tests/=, and =rsync -a= copies by disk presence regardless of =.gitignore=, so without the excludes every consuming project's tree gets polluted with machine-specific cache files. The excludes also protect existing dest copies from =--delete= cleanup, so a project that already received the cache must remove it once by hand.
+- The sync is guarded to skip when rulesets has uncommitted changes under the synced source paths. =rsync -a --delete= copies the working tree by disk presence, so without the guard a downstream session started while rulesets had in-flight WIP would pull that WIP into its =.ai/workflows/= and =.ai/scripts/=, surfacing as drift the user never authored (and tempting a fake "chore: sync .ai tooling" commit). The guard is scoped to the synced paths, not the whole repo, so unrelated rulesets dirt doesn't block the sync. From the jr-estate handoff 2026-05-29.
- The sync touches only =protocols.org=, =workflows/=, and =scripts/=. The project-owned dirs =project-workflows/= and =project-scripts/= are deliberately *outside* the synced set, so a project's own workflows and scripts survive startup. This is why a project script that a workflow imports must live in =.ai/project-scripts/=, never =.ai/scripts/= — the latter is wiped to match the template by =--delete= on every startup. Naming: a script imported as a Python module needs an importable name (underscores, e.g. =zlibrary_api.py=); a CLI-invoked script can stay kebab-case like the template tooling (=cmail-action.py=).
Rationale: Every call in Phase A is read-only or writes to a distinct path. Running them sequentially wastes round-trips; running them in parallel gives Claude the complete starting picture in one round-trip.
diff --git a/todo.org b/todo.org
index 621c9fe..5b7c924 100644
--- a/todo.org
+++ b/todo.org
@@ -1176,11 +1176,13 @@ Broader refactor proposes runtimes/ adapter manifests, generic install commands,
Before any implementation: needs a real review pass on the spec, and a decision on whether to do Phase 1 alone (low risk, fixes the race) vs commit to the larger arc.
-** TODO [#B] Startup Phase A rsync propagates dirty rulesets WIP into downstream projects :feature:
+** DONE [#B] Startup Phase A rsync propagates dirty rulesets WIP into downstream projects :feature:
+CLOSED: [2026-05-30 Sat]
:PROPERTIES:
:CREATED: [2026-05-29 Fri]
:LAST_REVIEWED: 2026-05-29
:END:
+Fixed via option 1 (skip-when-dirty), scoped to the synced source paths: startup.org Phase A now guards the protocols/workflows/scripts rsyncs behind a =git status --porcelain= check on =claude-templates/.ai/{protocols.org,workflows/,scripts/}=, skipping the sync when any are dirty. The propagation anomaly (cross-project-broadcast.org / page-signal.org not reaching jr-estate) was a timeline artifact: both files were added in 664bf01 on 2026-05-29, after jr-estate's Phase A rsync had already run — correct behavior, not a bug.
From jr-estate handoff 2026-05-29. When rulesets has uncommitted WIP at the moment a downstream project starts a session, Phase A.0 reports "dirty, skipping pull" and proceeds. Phase A's =rsync -a --delete= then runs against the dirty rulesets working tree and copies the WIP state into the downstream project's =.ai/workflows/= and =.ai/scripts/=. The downstream project's =git status= then shows drift the user did not author. Two bad recovery paths: commit the drift as "chore: sync .ai tooling from templates" (creates fake commit history about template state) or leave it dirty (noisy wrap-ups, pressure to commit anyway).