#+TITLE: Startup Workflow #+AUTHOR: Craig Jennings & Claude #+DATE: 2026-04-25 * Summary This workflow runs automatically at the beginning of EVERY session. It gives Claude project context, syncs templates, discovers available workflows, and determines session priorities. Do NOT ask Craig if he wants to run it — just execute it. The workflow is structured into four phases. *Phase A.0* is a sequential pre-flight; *Phase A and Phase B should each run as a single batch of parallel tool calls* — sending one message with multiple Bash / Read calls in it, not sequential round-trips. Phase C is interactive and runs sequentially. Quick contract — runs / produces: - *Phase A.0* (sequential): refresh rulesets, then the project repo. - *Phase A* (parallel batch): timestamp, session-context check, guarded =.ai/= sync, recent sessions, inbox-status, cross-agent status, notes.org, staleness, language-bundle freshness. - *Phase B* (parallel batch): read the crash-recovery anchor if present, the recent session summaries, new inbox items, pending cross-agent messages. - *Phase C* (interactive): surface findings, process the inbox, run project startup-extras, ask priorities. * Execution ** Phase A.0 — Pre-flight: refresh rulesets and project repo (sequential, runs first) Two refreshes happen before Phase A. Both are sequential pre-steps, not part of Phase A's parallel batch. Run them as two separate Bash calls in order. *** Refresh rulesets (canonical =.ai/= source) Phase A's rsync commands copy from =~/code/rulesets/claude-templates/.ai/= into the project's =.ai/= directory. If the rulesets checkout is behind its own =origin/main=, the rsync silently reverts committed template updates in the project, dirtying the working tree. Pull rulesets first so the rsync runs against current content. Inside a rulesets session, the project-repo refresh below covers this — the rulesets pull here is harmless (a no-op second pull) but redundant. Outside rulesets (every other project's session), this is the only mechanism that pulls template updates. #+begin_src bash rs="$HOME/code/rulesets" if [ -d "$rs/.git" ]; then if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3 else echo "rulesets: dirty working tree — using as-is, skipping pull" fi else echo "rulesets: not a git checkout — skipping" fi #+end_src Behavior: - *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. - *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start). - *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is. *** Install rulesets symlinks into ~/.claude (idempotent) A skill, rule, or bin script added to rulesets and pushed reaches each machine's *files* on the next pull, but not its =~/.claude= *symlink* — =make install= only links what isn't already linked, and =git pull= doesn't run it. So a newly-added skill stays silently uninstalled until someone re-runs =make install= by hand. The flush skill sat in that gap from 2026-06-02 until a manual install on 2026-06-05. Running =make install= here, right after the rulesets pull, closes it: "add a skill, commit, push" becomes enough for it to reach every machine on the next session. =make install= is idempotent — it skips every already-linked target, links only what's new, WARNs on a non-symlink collision, and only ever writes symlinks under =~/.claude= and =~/.local/bin=, so it's safe and reversible. It covers skills, rules, claude config, default hooks, and bin scripts in one pass, so the same step also picks up a newly-added rule, hook, or script, not just a skill. (Hooks joined the set 2026-06-11 after =session-clear-resume.sh= sat in exactly this gap: its settings.json entry traveled via the tracked symlink on 2026-06-02, but the hook symlink itself only landed where someone ran =make install-hooks= by hand, so the hook errored silently on every =/clear= elsewhere.) #+begin_src bash if [ -d "$HOME/code/rulesets" ]; then make -C "$HOME/code/rulesets" install 2>&1 \ | grep -E '^[[:space:]]+(link|relink|WARN)' \ || echo "make install: nothing new to link" else echo "rulesets: not present — skipping make install" fi #+end_src The =grep= keeps the all-skip case quiet — a clean machine prints only "nothing new to link". Any =link= / =relink= / =WARN= line is surfaced for Phase C to act on. The placement mirrors the rulesets-pull dependency: the pull brings a new skill's files in, so the install that links them belongs immediately after. It runs in every project's session, not just rulesets sessions, which is the point — the link reaches whatever machine the session is on. *** Refresh project repo (cwd) Pull down whatever's been pushed to the project's remotes since the last session — could be commits Craig made on another machine, teammate pushes, or any branch that advanced upstream. Without this, the session starts from a stale local view and any new branch work happens on top of an out-of-date base. #+begin_src bash if [ -d .git ]; then git fetch --all --prune 2>&1 | tail -5 current=$(git symbolic-ref --short HEAD 2>/dev/null) dirty=0 if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then dirty=1 fi git for-each-ref --format='%(refname:short)' refs/heads/ | while read branch; do upstream=$(git rev-parse --abbrev-ref "${branch}@{upstream}" 2>/dev/null) || continue counts=$(git rev-list --left-right --count "${upstream}...${branch}" 2>/dev/null) || continue behind=$(echo "$counts" | cut -f1) ahead=$(echo "$counts" | cut -f2) if [ "$behind" -gt 0 ] && [ "$ahead" -eq 0 ]; then if [ "$branch" = "$current" ]; then if [ "$dirty" -eq 0 ]; then git merge --ff-only "$upstream" >/dev/null 2>&1 \ && echo " $branch: fast-forwarded $behind commits" else echo " $branch: behind $behind — dirty tree, fetched only" fi else git fetch . "${upstream}:${branch}" >/dev/null 2>&1 \ && echo " $branch: fast-forwarded $behind commits (non-checkout)" fi elif [ "$ahead" -gt 0 ] && [ "$behind" -gt 0 ]; then echo " $branch: diverged ($ahead ahead, $behind behind) — leaving alone" fi done else echo "project repo: not a git checkout — skipping" fi #+end_src Behavior, per branch: - *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD. - *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state. - *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree. - *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge. - *Ahead only* or *up to date* → silent no-op. Phase A's rsyncs depend on the rulesets refresh completing first. The project-repo refresh has no such dependency, but lives here for symmetry with the wrap-up's "push all local branches" step. ** Phase A — Initial fan-out (one parallel batch) These calls have no dependencies on each other. Issue them all together in one message: 1. =date "+%A %Y-%m-%d %H:%M %Z"= — accurate timestamp. 2. Check whether the active session-context file exists. Resolve the =AI_AGENT_ID=-aware path first (see protocols.org "Agent-scoped path"), then test it — the fallback keeps older projects without the helper working: #+begin_src bash sc=$(.ai/scripts/session-context-path 2>/dev/null || echo .ai/session-context.org) [ -e "$sc" ] && echo "present: $sc" || echo "absent: $sc" #+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. #+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. 11. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the inbox-zero startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox-zero.org=). Read-only; never files at startup. 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. ** Phase B — Dependent fan-out (one parallel batch) These calls depend on Phase A outputs, but are independent of each other. Issue them as a single parallel batch once Phase A returns: 1. *Read =.ai/session-context.org= if Phase A reported it exists.* The file is the crash-recovery anchor — if it's there, the previous session was interrupted and the context lives only in this file. 2. *Read each of the 5 most recent session files* from Phase A's =\ls -t .ai/sessions/= output. Read just the =* Summary= section of each — not the full file. The Summary gives Active Goal / Decisions / Data Collected / Findings / Files Modified / Next Steps. That's enough to pick up where things left off. Drill into a specific =* Session Log= later only if you need the /why/ or sequence on something. *If Phase A's listing came back empty, sanity-check with =\ls -la .ai/sessions/= before treating empty as definitive — sessions/ should normally be populated, and an empty result usually means the listing got swallowed somewhere, not that the directory is genuinely empty.* 3. *Read each new inbox file* from Phase A's =\ls -la inbox/= output. For =.eml= files, defer to Phase C — those need the extract script (below) rather than a raw Read. 4. *Process pending cross-agent messages.* For each project with a pending count >0 in Phase A's =cross-agent-status= output (typically the current project; cross-project pending is surfaced too but only acted on if the user asks), run =cross-agent-recv = on the file path =cross-agent-status= named. The script returns a structured decision (=process= / =dedup= / =query= / =reject=) per the protocol. For =process=, read the message body to determine the action. For =query=, prepare a clarifying reply. For =reject=, surface to user with the reason. For =dedup=, no action — silent retry already handled. Surface all decisions in Phase C alongside other findings. Rationale: Reads are independent and benign. Batching them means the whole session-history view + inbox view lands in one round-trip instead of one per file. ** Phase C — Synthesis + interactive This phase touches the user and runs sequentially: 1. *Surface findings from Phase A and B:* - If =session-context.org= existed, summarize what was in flight at the crash point and ask whether to resume. - Surface Active Reminders from notes.org immediately. - Mention Pending Decisions from notes.org. - Briefly note significant template updates noticed during sync (new workflows, protocol changes). - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "== top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing. - *Roam inbox nudge.* If the Phase A roam-inbox count is greater than zero, read =~/org/roam/inbox.org=, split total vs items related to this project (claimed by the =:= prefix, plus any unprefixed item whose topic plainly concerns this project), and surface one line: "Roam inbox: == total, == appear related to this project — say 'inbox zero' to file them." Offer it as a priority option; never auto-file. If the count is zero or the file is absent, say nothing. See =inbox-zero.org=. - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install- PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing. - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. - *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free. #+begin_src bash if [ ! -d claude-templates/.ai ] && [ -d "$HOME/code/rulesets/claude-templates/.ai" ]; then n=$(git status --porcelain -- .ai/protocols.org .ai/workflows/ .ai/scripts/ 2>/dev/null | wc -l) [ "$n" -gt 0 ] && echo "synced-.ai-dirty: $n uncommitted template-sync file(s)" fi #+end_src If it reports a count, surface one line: wrap-up's Step 4.0 will commit it as =chore: sync .ai tooling from templates=, or offer to commit it now. If silent, say nothing. This is the crashed-session counterpart to the wrap-up commit step (the primary fix). From the 2026-05-31 jr-estate + work handoffs. - *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it. 2. *Process inbox if non-empty.* Mandatory — don't ask, just delegate to [[file:process-inbox.org][process-inbox.org]]. That workflow owns the value gate (advances an existing TODO / improves the project / serves the mission), the per-source rejection flow (Craig / project handoff / script), the priority-scheme check before filing, and the =.eml= extraction path. Single source of truth for the discipline. 3. *Execute project-specific startup extras* (the contents of =.ai/project-workflows/startup-extras.org= read in Phase A). If the file didn't exist, skip. 4. *Ask about priorities.* "What would you like to work on, or is there something urgent you need?" - If urgent: proceed immediately. - If not: surface the top 3 priority A or B tasks in todo.org plus recent work as context. Rationale: User-facing work and decisions can't be parallelized — they have to happen one at a time so the user can react. * Reference ** Workflow discovery (on demand, not at startup) Two directories hold workflows: - =.ai/workflows/= — template workflows (synced from claude-templates, never edit in project). - =.ai/project-workflows/= — project-specific workflows (never touched by sync). *Engine + plugin naming convention.* A workflow file may be an *engine* that owns *source plugins*. The convention is dot-delimited: =engine-name.org= is the engine; =engine-name.plugin-name.org= is a plugin of it. The first dot after the engine name is the engine/plugin boundary; hyphens stay *inside* a segment (=triage-intake.personal-gmail.org= is engine =triage-intake=, plugin =personal-gmail=). The glob =engine-name.*.org= matches the plugins but not the engine (the engine has no second dot-segment), so an engine never loads itself. Deeper dots (=engine-name.plugin-name.sub-name.org=) are reserved for sub-adapters — unused for now, accommodated at no cost. This generalizes: any future engine =foo.org= owns =foo.*.org= plugins. Plugins are not independently triggerable — they have no index entry, and a "run the [engine] workflow" request always routes to the engine, never a plugin. When the user says "let's run/do the [X] workflow" (or otherwise references a workflow by topic): 1. *Read =.ai/workflows/INDEX.org=.* It maps trigger phrases to workflow filenames so you don't have to read each workflow's "When to Use" section to route. Project-workflows aren't in the index — handle those via =ls= in step 2. 2. *List both directories:* =ls -1 .ai/workflows/ .ai/project-workflows/ 2>/dev/null=. 3. *Drift check.* Compare the =.org= files in =.ai/workflows/= against the entries in INDEX.org (excluding INDEX.org itself). If an index entry points at a deleted file, surface the mismatch and offer to update INDEX.org. If a file in the directory has no index entry, first test whether it's a *source plugin* — any file matching =.*.org= (a second dot-segment after an indexed engine name) belongs to that engine and is correctly absent from the index; do not flag it. Flag only genuine orphans: a non-plugin =.org= with no entry. Don't silently route around real drift. 4. *Match the request* against the index trigger phrases first, then against project-workflow filenames if no index hit. A request matching an engine routes to =engine-name.org=, never to one of its =engine-name.plugin-name.org= plugins — the engine loads its own plugins at run time. 5. *Read and execute* the matching file. 6. *No match* → offer to create via =create-workflow=; new workflows go to =.ai/project-workflows/= and project-specific ones don't go in INDEX.org. 7. *Project extension.* As one of the very last steps in the matched workflow's flow, check if =.ai/project-workflows/= contains a file with the same name as the template that just ran. If yes, read and execute it as *additional* steps appended to the workflow — not a replacement. The project file contains add-on steps that pick up where the template's main flow ends. Surface the extension once per session ("Project has additional steps for send-email.org — running them now"). Only applies when the matched workflow came from =.ai/workflows/=; project-only workflows have no template to extend. This mirrors the startup-extras.org pattern: projects extend template behavior without forking the template repo. The index is the catalog; the directory is the truth. Drift between them is a bug — catching it on demand keeps the index honest without paying the read cost on every session. ** Common Mistakes 1. *Running Phase A sequentially.* Send all Phase A calls in one message — sequential rsync + ls + read costs round-trips for nothing. 2. *Reading the entire notes.org file* — only Project-Specific Context, Active Reminders, Pending Decisions. 3. *Skipping template sync* — projects fall behind on rule changes. 4. *Skipping Phase A.0* — rsync runs against a stale rulesets checkout and silently reverts committed template updates in the project's =.ai/=, dirtying the working tree at session start. 5. *Auto-stashing or auto-merging in Phase A.0* — don't. If rulesets has uncommitted edits or a non-fast-forward history, leave it alone and let the rsync run against the working tree as-is. 6. *Not checking for session-context.org* — lose context from crashed sessions. 7. *Forgetting to surface Active Reminders* — Craig misses critical items. 8. *Asking if Craig wants inbox processed* — it's mandatory, not optional. 9. *Announcing "session start complete"* — just begin working on the chosen task. 10. *Reading full session files instead of just the Summary section* — wastes context for past noise that lives in the Session Log.