diff options
Diffstat (limited to 'claude-templates')
| -rw-r--r-- | claude-templates/.ai/protocols.org | 2 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/sync-templates | 75 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/sync-templates.bats | 233 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/helper-mode.org | 29 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/startup.org | 43 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/wrap-it-up.org | 32 | ||||
| -rwxr-xr-x | claude-templates/bin/ai | 209 |
7 files changed, 584 insertions, 39 deletions
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org index bf9f420..b291d9e 100644 --- a/claude-templates/.ai/protocols.org +++ b/claude-templates/.ai/protocols.org @@ -106,7 +106,7 @@ The epoch is baked into the id by the spawner, never minted inside =session-cont Resolve the path with =.ai/scripts/session-context-path= rather than hardcoding =.ai/session-context.org=; it prints the right path for the current =AI_AGENT_ID=. Fall back to =.ai/session-context.org= if the script isn't present (older checkouts mid-sync). Everything below — the record/recovery purpose, the update triggers, the startup existence check, the wrap-up rename — operates on that resolved path. The prose says "session-context.org" as the default name; read it as "the resolved active path" when =AI_AGENT_ID= is set. -A helper instance (a second agent running in this project while a primary session is live) follows a different contract: it skips the pulls and rsync, makes only scoped single-heading edits to shared files, leaves all git mutation to the primary, and wraps up by archiving its own context file without committing. The full rules — read/write tiers, data-integrity, light startup, helper wrap-up — live in [[file:workflows/helper-mode.org][workflows/helper-mode.org]]. A session is a helper only when something routes it there (the =ai --helper= launcher, startup's roster check, or an explicit "you are a helper" instruction); the routing itself ships behind the helper-instance feature gate and isn't live yet. +A helper instance (a second agent running in this project while a primary session is live) follows a different contract: it skips the pulls and rsync, makes only scoped single-heading edits to shared files, leaves all git mutation to the primary, and wraps up by archiving its own context file without committing. The full rules — read/write tiers, data-integrity, light startup, helper wrap-up — live in [[file:workflows/helper-mode.org][workflows/helper-mode.org]]. A session is a helper only when something routes it there: the =ai --helper= launcher (live — it checks the roster, assigns the id, and opens the helper in its own tmux window) or an explicit "you are a helper" instruction. Startup's roster check is *not* built, so a bare =claude= launched into a project that already has a live session will run full primary startup regardless. Launch helpers with =ai --helper=. This file serves two purposes with one mechanism: 1. *Crash recovery* — if the session dies mid-work, the live file is all that's left. On 2026-01-22 a session crashed during a 20-minute design discussion and all context was lost because this file wasn't being updated. diff --git a/claude-templates/.ai/scripts/sync-templates b/claude-templates/.ai/scripts/sync-templates new file mode 100755 index 0000000..c4db212 --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/sync-templates.bats b/claude-templates/.ai/scripts/tests/sync-templates.bats new file mode 100644 index 0000000..e4e92cf --- /dev/null +++ b/claude-templates/.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" ] +} diff --git a/claude-templates/.ai/workflows/helper-mode.org b/claude-templates/.ai/workflows/helper-mode.org index a6acfa7..b32d574 100644 --- a/claude-templates/.ai/workflows/helper-mode.org +++ b/claude-templates/.ai/workflows/helper-mode.org @@ -12,13 +12,14 @@ The governing fact behind every rule below: the session-context split isolates e * When to Use This Workflow -No operator trigger phrase. A helper reaches this contract one of three ways: +No operator trigger phrase. A helper reaches this contract one of two ways: - The =ai --helper= launcher routes here after the roster confirms a live agent (the deterministic path). -- Startup's roster check finds the session is not alone and routes here instead of running normal startup (the safety net for a raw =claude= launch). - An explicit "you are a helper, follow helper-mode.org" instruction (the manual fallback). -If none of those applies — the roster shows the session is alone — this is a primary session. Run normal [[file:startup.org][startup.org]], not this. +There is deliberately no third way, and the gap matters: *startup does not check the roster*. A bare =claude= launched into a project that already has a live session runs full primary startup — pulls, rsync, inbox processing — without ever reaching this file. That safety net is designed (see Status below) but unbuilt, so nothing catches a raw launch. Use =ai --helper=. + +If neither route applies, this is a primary session. Run normal [[file:startup.org][startup.org]], not this. * Identity @@ -92,10 +93,28 @@ A helper does not run normal startup. It runs a light version: When the helper's work is done: -1. Re-run the roster (=.ai/scripts/agent-roster=) to learn whether a primary is still live. +1. Re-run the roster to learn whether a primary is still live. Pass the project root explicitly — =agent-roster= defaults to =$PWD= and keeps only agents at or inside that root, so calling it from a subdirectory hides a primary sitting at the root and reports "alone": + + #+begin_src bash + root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + if [ -x "$root/.ai/scripts/agent-roster" ]; then + "$root/.ai/scripts/agent-roster" "$root"; rc=$? + else + rc=2 + fi + echo "roster rc=$rc" + #+end_src + + Read rc as =wrap-it-up.org= Step 0 does: 1 means a primary is still live, 0 means this helper is orphaned, and 2 (or an absent script) means unavailable — which takes the same archive-only path as 1, because leaving work uncommitted is recoverable and committing under a live primary is not. 2. *Primary still live (the normal case):* finalize the Summary in the helper's own =.ai/session-context.d/<id>.org=, archive it to =.ai/sessions/YYYY-MM-DD-HH-MM-<id>-<description>.org=, and stop. Do NOT commit, push, or run hygiene — the primary's next commit picks up the archived file and any scoped edits the helper left in the tree. 3. *Orphaned helper (roster shows the helper is now alone):* the primary already exited, so the helper assumes full closing duties — the git ban lifts because the concurrency that justified it is gone. Commit and push the tree (including the helper's own edits, which would otherwise strand as a dirty tree), per the normal wrap-up flow in [[file:wrap-it-up.org][wrap-it-up.org]]. * Status -Phase 1.5 of the generic-agent-runtime spec. This contract is the canonical home; the spawn paths (=ai --helper=, startup's roster branch) and the [[file:wrap-it-up.org][wrap-it-up.org]] helper branch route here. Those wiring pieces ship behind the spec's bats-then-drills-then-pilot gate and are not yet live; until then, the manual "you are a helper" instruction is how a session adopts this contract. +Phase 1.5 of the generic-agent-runtime spec. This contract is the canonical home; the spawn paths and the [[file:wrap-it-up.org][wrap-it-up.org]] helper branch route here. + +Live now: =ai --helper <project>= (roster check, id assignment, helper opener, its own tmux window), the explicit "you are a helper" instruction, and the wrap-it-up.org Step 0 helper branch. + +Not built yet, and worth knowing because it is the gap you can fall into: *startup has no roster check*. A second session launched as a bare =claude= in a project that already has one runs full primary startup — pulls, rsync, inbox processing — with no idea another agent is live. Until that safety net exists, =ai --helper= is not merely the preferred path, it is the only one that makes a helper without being told. + +Also unbuilt: the live-helper gate that pauses a primary's file-wide hygiene passes (=todo-cleanup.el=, =lint-org.el=, =wrap-org-table.el=) while a helper is mid-edit. Data-integrity rule 1 above describes the intended behavior; nothing enforces it yet, so a primary running hygiene can still clobber a helper's just-written scoped edit. diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org index 2262eea..bc89256 100644 --- a/claude-templates/.ai/workflows/startup.org +++ b/claude-templates/.ai/workflows/startup.org @@ -137,39 +137,24 @@ These calls have no dependencies on each other. Issue them all together in one m #+end_src 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. A second guard skips the same rsyncs when the *project* branch is behind its upstream (=git rev-list --left-right --count @{u}...HEAD= with =behind > 0=): syncing templates onto a stale committed =.ai/= baseline measures the diff against old content, so it comes out huge and conflicts when the branch later reconciles to upstream, whose history already carries the newer templates. It composes with the rulesets-clean guard — a stable rulesets source and a current project branch are both required before the sync runs. - #+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) - # Skip the sync when the project branch hasn't reached its upstream. Syncing - # templates onto a behind baseline measures the diff against stale committed - # .ai/, producing confusing drift that conflicts when the branch reconciles — - # the newer .ai/ is already in upstream. 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, 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 + The logic lives in =.ai/scripts/sync-templates=, not inline here. It was extracted 2026-07-31 after an uncommitted edit in rulesets silently blocked all three rsyncs for a full day — five workflow files went stale in one downstream project alone, with nothing anywhere reporting it. A mechanism that distributes correctness fixes to every project needs tests, and inline bash in an org file cannot have them. The behavior is unchanged by the extraction (verified differentially, output and resulting tree both byte-identical); the guards it applies are described below. - 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)" + #+begin_src bash + if [ -x .ai/scripts/sync-templates ]; then + .ai/scripts/sync-templates else - 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" + echo "sync-templates not present — .ai/ sync SKIPPED and cannot self-heal; recover with: bash ~/code/rulesets/scripts/audit.sh --apply --force" fi #+end_src + The fallback should never fire in the ordinary rollout. A project still on the pre-extraction startup.org runs the old inline block this session, which delivers both the script and this file together, and the next session finds the script in place. It covers only the split case — the =workflows/= rsync landing while the =scripts/= one didn't — where a project would otherwise hold this file with no script to call. + + *That state does not self-heal, which is why the message names a command.* The fallback runs /instead of/ the sync, so there is no later sync to deliver the missing script: the project would announce one line per session forever while its templates froze. Recovery is out-of-band, via =scripts/audit.sh --apply --force= in rulesets, which rsyncs =scripts/= directly. =--force= is there because audit skips a tracked project holding uncommitted =.ai/= changes, which a project wedged across several sessions is likely to be. + + One caveat on that recovery, worth knowing before running it: audit's =scripts/= rsync carries none of the =__pycache__= / =.pytest_cache= / =*.pyc= excludes this sync does, so it can deposit python cache artifacts that then need removing by hand. Tracked separately; it is a pre-existing gap in audit rather than something this path introduced. + + Announcing the skip loudly with its remedy is the point; a silent skip is the exact failure this extraction exists to stop. + 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. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File). @@ -213,7 +198,7 @@ These calls have no dependencies on each other. Issue them all together in one m Fleet descriptions ("the fleet is ratio and velox") and runtime derivations ("run =uname -n= to find the hostname") don't match — only current-identity assertions do. Fixture-verified under bash and zsh. -Notes on the rsync commands: +Notes on what =sync-templates= does (the rsync behavior it carries): - 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. diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org index a9a5895..ecd3d22 100644 --- a/claude-templates/.ai/workflows/wrap-it-up.org +++ b/claude-templates/.ai/workflows/wrap-it-up.org @@ -29,6 +29,8 @@ The wrap-up is complete when: The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted. +*A helper session meets a shorter list.* Criteria 1 and 2 apply to its own context file (archived under =.ai/sessions/YYYY-MM-DD-HH-MM-<id>-<description>.org=), and 6 applies. Criteria 3, 4, and 5 do not: hygiene, the Linear pass, and all git mutation belong to the primary, so a helper that satisfied criterion 5 would have violated its contract to get there. Step 0 routes this. + * Teardown mode (set from the trigger phrase) The wrap itself — Steps 1 through 5 — is identical in every mode. The trigger phrase only decides what Step 6 does once commit + push and the valediction are done. Resolve the mode from the phrase before starting: @@ -43,7 +45,35 @@ This depends on three functions in =.emacs.d/modules/ai-term.el= (=cj/ai-term-qu * The Workflow -** Step 0: Refuse if sentry is live +** Step 0: Helper branch — a helper wraps only itself + +Resolve first whether this session is a helper, because a helper's wrap is a different and much shorter workflow. Everything from Step 1 down — the hygiene passes, the inbox check, the commit, the push, the clean-tree certificate — is primary-only under the role contract in [[file:helper-mode.org][helper-mode.org]], and running any of it from a helper is exactly the concurrency failure that contract exists to prevent. + +A session is a helper when =AI_HELPER=1= in its environment (=ai --helper= sets it) or when it adopted helper-mode.org this session by instruction. If neither holds, this is a primary: skip to Step 0.5 and wrap normally. + +#+begin_src bash +echo "AI_HELPER=${AI_HELPER:-unset} AI_AGENT_ID=${AI_AGENT_ID:-unset}" +#+end_src + +For a helper, re-run the roster — the answer decides which wrap applies: + +#+begin_src bash +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if [ -x "$root/.ai/scripts/agent-roster" ]; then + "$root/.ai/scripts/agent-roster" "$root"; rc=$? +else + rc=2 +fi +echo "roster rc=$rc" +#+end_src + +Pass the project root explicitly. =agent-roster= defaults to =$PWD= and keeps only agents whose cwd is at or inside that root, so running it from a subdirectory hides a primary sitting at the root — and the "alone" that produces is read below as *orphaned*, which is the one branch that commits and pushes. Capture =rc= inside the branch too: =[ -x … ] && …; echo $?= reports the status of the whole list, so an absent script reads as 1 (others live) rather than 2 (unavailable). + +- *Primary still live (rc 1)* — the normal case. Finalize the =* Summary= in the helper's own context file — same contract as Step 1, KB receipt line included (resolve it with =AI_AGENT_ID=<id> .ai/scripts/session-context-path=), archive it to =.ai/sessions/YYYY-MM-DD-HH-MM-<id>-<description>.org= so it can't collide with the primary's archive name, deliver the valediction, and stop. Do NOT commit, push, or run any hygiene pass. The helper's scoped edits stay in the tree and the primary's next commit carries them along with the archived file — say so in the valediction, so Craig knows the work is real but not yet pushed. +- *Alone (rc 0) — orphaned helper* — the primary exited first, so the git ban lifts: the concurrency that justified it is gone, and stopping here would strand the helper's edits as a dirty tree nobody owns. Run the full wrap below starting at Step 0.5, exactly as a primary would. +- *Roster unavailable (rc 2, or the script absent)* — take the archive-only path, the same as primary-still-live. Leaving work for the next session to commit is recoverable; guessing "orphaned" and committing underneath a live primary is not. + +** Step 0.5: Refuse if sentry is live Before anything else, check whether sentry is running in this project. Sentry holds the working tree on its =sentry/<date>-<host>= branch and commits unattended; wrapping underneath it would archive the session anchor and tear down the buffer while the loop is still firing into it. If sentry's single-runner lock is held, stop and point at the shutdown path: diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 65d0ab7..3440ee2 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -18,6 +18,17 @@ # ollama; model per AI_LOCAL_MODEL, default gpt-oss:120b). # Also settable via AI_RUNTIME. # +# ai --helper <dir> Open a SECOND session in a project that already has a +# live one, under the helper-mode.org role contract: reads +# freely, makes only scoped edits, never mutates git, and +# skips git prep because the primary owns pulls. Runs +# agent-roster first — with no other agent live it warns +# and falls back to a normal primary launch (which does +# run git prep). Run it from a terminal of your own: the +# roster excludes its caller's own process ancestry, so +# invoking it from inside an agent session hides that +# session and silently downgrades to a primary launch. +# # ai --attach Attach to the existing 'ai' session without changes. # # ai -h | --help Show this help. @@ -110,8 +121,18 @@ build_instructions() { printf 'This is %s %s project. Follow all instructions in .ai/protocols.org.' "$(uname -n)" "$name" } +# The opening line for a helper session. Deliberately does NOT name +# protocols.org: a helper must not run normal startup (pulls, rsync, inbox +# processing all belong to the primary), and helper-mode.org sends it to +# protocols.org itself once the role contract is loaded. +build_helper_instructions() { + local name="$1" + printf 'This is %s %s project. You are a helper session: another agent is already live here. Read and follow .ai/workflows/helper-mode.org — it is your role contract. Do not run the normal startup workflow.' \ + "$(uname -n)" "$name" +} + usage() { - sed -n '2,23p' "$0" | sed 's|^# \?||' + sed -n '2,34p' "$0" | sed 's|^# \?||' exit 0 } @@ -146,14 +167,92 @@ _git_prep_action() { fi } +# Decide what a `--helper` launch actually becomes, from the roster's verdict. +# Input is agent-roster's exit status — 0 alone, 1 others live, 2 unavailable — +# or the literal "absent" when no roster script is installed. Echoes one of: +# helper — confirmed: another agent is live here +# primary — refuted: nobody else is here, so --helper is a no-op +# helper-unverified — the roster couldn't answer +# Unverifiable resolves toward helper on purpose. `--helper` is the operator +# asserting a primary is live, and helper mode is the strictly less destructive +# guess: a helper that turns out to be alone merely does less, while a primary +# that turns out not to be alone runs pulls and rsync under a live session. +_helper_launch_mode() { + case "$1" in + 1) echo helper ;; + 0) echo primary ;; + *) echo helper-unverified ;; + esac +} + +# A helper's agent id: helper-<rand4>, per helper-mode.org's identity rule. +# Four hex digits is enough — the id only has to be unique among the agents +# live in one project at one moment, and the archived session file carries the +# date and time as well. Two draws because bash's RANDOM is 15-bit, so a single +# one would never set the top bit and the first hex digit would always be 0-7. +_helper_id() { + printf 'helper-%04x\n' $(( ((RANDOM << 1) ^ RANDOM) & 0xffff )) +} + +# Reduce an id to the characters session-context-path keeps, so the launcher and +# the path resolver agree on what a given id means. This is also a safety fix, +# not just tidiness: the id is interpolated into the command line typed into the +# pane, so an id carrying a space or a ';' would split the assignment off from +# the command and run something else instead of launching the helper. +# printf without a newline on purpose: tr -c would translate a trailing newline +# into an underscore too, silently appending one to every sanitized id. +_sanitize_agent_id() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' + printf '\n' +} + +# Resolve the id for a helper launch: an explicitly-exported one when it is +# free, otherwise a fresh one. +# +# The reuse check is the load-bearing part. A helper's own pane exports +# AI_AGENT_ID, so `ai --helper` invoked from inside a helper inherits its +# parent's id rather than being given one deliberately. Honoring that blindly +# points two live agents at one .ai/session-context.d/<id>.org, which is the +# lost-update collision the whole helper contract exists to avoid. +_resolve_helper_id() { + local dir="$1" + local want="${AI_AGENT_ID:-}" + local tries=0 + + if [ -n "$want" ]; then + want="$(_sanitize_agent_id "$want")" + if [ ! -e "$dir/.ai/session-context.d/$want.org" ]; then + printf '%s\n' "$want" + return + fi + echo "ai: agent id '$want' is already live in $(basename "$dir") — assigning a fresh one" >&2 + fi + + # A minted id gets the same free-anchor check as a supplied one. The odds of + # a chance collision are small, but a guard that only covers the path the + # caller controls leaves the collision it exists to prevent reachable. + # Bounded so a full or unreadable directory can't spin here. + while [ "$tries" -lt 8 ]; do + want="$(_helper_id)" + [ -e "$dir/.ai/session-context.d/$want.org" ] || break + tries=$((tries + 1)) + done + printf '%s\n' "$want" +} + # Re-order "name<TAB>wid" lines (stdin) into the launcher's window order: # non-project windows alphabetically, then project windows alphabetically. # $1 is a newline-separated list of project window names. +# +# A helper window is named "<project>:<agent-id>", so it matches on the prefix +# before the first colon rather than on the whole name. That keeps it sorted +# next to the project it helps instead of landing among the unrelated windows. _order_windows() { local project_names="$1" wname wid others="" projects="" while IFS=$'\t' read -r wname wid; do [ -z "$wname" ] && continue - if printf '%s\n' "$project_names" | grep -qxF "$wname"; then + if printf '%s\n' "$project_names" | grep -qxF "$wname" || + printf '%s\n' "$project_names" | grep -qxF "${wname%%:*}"; then projects+="${wname}"$'\t'"${wid}"$'\n' else others+="${wname}"$'\t'"${wid}"$'\n' @@ -394,6 +493,36 @@ prep_git_single() { esac } +# Run the project's agent-roster and turn its verdict into a launch decision. +# The decision is the only thing on stdout; warnings go to stderr so callers +# can capture one without the other. +_resolve_helper_launch() { + # Two statements on purpose: a name assigned in a `local` is not yet visible + # to a later assignment in that same `local`, so building the roster path in + # this line would read the CALLER's $dir — right only by coincidence. + local dir="$1" + local roster="$dir/.ai/scripts/agent-roster" rc decision + if [ -x "$roster" ]; then + # The roster prints the other agents it found; only its exit code matters + # here, and its stdout must not reach a --print-launch caller's output. + "$roster" "$dir" >/dev/null 2>&1 + rc=$? + else + rc=absent + fi + + decision="$(_helper_launch_mode "$rc")" + case "$decision" in + primary) + echo "ai: --helper found no other agent live in $(basename "$dir") — opening a normal primary session instead" >&2 + ;; + helper-unverified) + echo "ai: could not verify another agent is live in $(basename "$dir") — roster unavailable; opening a helper anyway" >&2 + ;; + esac + echo "$decision" +} + # ---------- modes ---------- attach_mode() { @@ -447,6 +576,55 @@ single_mode() { attach_session } +# Open a helper session: a second agent in a project that already has a live +# one. Two deliberate differences from single_mode. It never focuses an +# existing window — a second session is the entire point, and focusing the +# primary's window is the one outcome that can't be what was asked for. And it +# never runs git prep, because every pull belongs to the primary under the +# helper contract. +helper_mode() { + local arg="$1" dir name id wid wname decision instructions + dir="$(cd "$arg" 2>/dev/null && pwd)" || { + echo "ai: cannot access '$arg'" >&2 + return 1 + } + + if [ ! -f "$dir/.ai/protocols.org" ]; then + echo "ai: $dir has no .ai/protocols.org — not an agent-template project" >&2 + return 1 + fi + + name="$(basename "$dir")" + + # Nobody else is here, so there is nothing to be a helper to. Fall through to + # the normal launch rather than opening a crippled session. + decision="$(_resolve_helper_launch "$dir")" + if [ "$decision" = primary ]; then + single_mode "$arg" + return $? + fi + + id="$(_resolve_helper_id "$dir")" + wname="$name:$id" + instructions=$(build_helper_instructions "$name") + + if tmux has-session -t "$SESSION" 2>/dev/null; then + wid=$(tmux new-window -a -t "$SESSION:{end}" -n "$wname" -c "$dir" -P -F '#{window_id}') + sleep 0.1 + else + wid=$(tmux new-session -d -s "$SESSION" -n "$wname" -c "$dir" -P -F '#{window_id}') + fi + + # The id rides in the launched process's environment, which is what + # session-context-path reads to resolve .ai/session-context.d/<id>.org. + tmux send-keys -t "$wid" \ + "${LAUNCH_PREFIX}AI_AGENT_ID=$id AI_HELPER=1 $AGENT_CMD \"$instructions\"" Enter + + sort_windows + tmux select-window -t "$wid" + attach_session +} + # Multi-select via fzf (the original aix flow). multi_mode() { local filtered=() selections first_wid="" @@ -537,6 +715,15 @@ print_launch_mode() { exit 1 fi name="$(basename "$dir")" + + # The roster runs here too, so the printed line reflects the decision a real + # run would make — including the downgrade to a primary launch. + if [ -n "$HELPER_MODE" ] && [ "$(_resolve_helper_launch "$dir")" != primary ]; then + printf 'AI_AGENT_ID=%s AI_HELPER=1 %s "%s"\n' \ + "$(_resolve_helper_id "$dir")" "$AGENT_CMD" "$(build_helper_instructions "$name")" + exit 0 + fi + printf '%s "%s"\n' "$AGENT_CMD" "$(build_instructions "$name")" exit 0 } @@ -549,12 +736,17 @@ print_launch_mode() { # dispatch runs exactly as before; when sourced, it's skipped. main() { print_launch="" + HELPER_MODE="" runtime_explicit="${AI_RUNTIME:+1}" while [ $# -gt 0 ]; do case "$1" in -h | --help) usage ;; + --helper) + HELPER_MODE=1 + shift + ;; --runtime) [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2 @@ -585,6 +777,13 @@ main() { resolve_agent_cmd + # A helper is always scoped to one named project. There is no roster to check + # and no primary to help without one, so this can't fall back to the picker. + if [ -n "$HELPER_MODE" ] && [ -z "${1:-}" ]; then + echo "ai: --helper needs a project directory" >&2 + exit 2 + fi + if [ -n "$print_launch" ]; then [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2 @@ -610,7 +809,11 @@ main() { *) check_deps for arg in "$@"; do - single_mode "$arg" + if [ -n "$HELPER_MODE" ]; then + helper_mode "$arg" + else + single_mode "$arg" + fi done ;; esac |
