aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/bin
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/bin')
-rwxr-xr-xclaude-templates/bin/agent-page12
-rwxr-xr-xclaude-templates/bin/agent-text57
-rwxr-xr-xclaude-templates/bin/ai394
-rwxr-xr-xclaude-templates/bin/git-worktree-gate185
-rwxr-xr-xclaude-templates/bin/install-ai23
5 files changed, 573 insertions, 98 deletions
diff --git a/claude-templates/bin/agent-page b/claude-templates/bin/agent-page
new file mode 100755
index 0000000..728ee78
--- /dev/null
+++ b/claude-templates/bin/agent-page
@@ -0,0 +1,12 @@
+#!/bin/bash
+# agent-page — deprecated alias for agent-text.
+#
+# The Signal phone tool was renamed agent-text on 2026-07-20, when the
+# notification vocabulary split into "text me" (Signal) and "page me" (desktop).
+# This shim keeps old callers and other machines working until they re-install
+# and pick up agent-text directly. Remove it in a later cleanup once nothing
+# references agent-page.
+#
+# Source: ~/code/rulesets/claude-templates/bin/agent-page
+
+exec "$(dirname "$(readlink -f "$0")")/agent-text" "$@"
diff --git a/claude-templates/bin/agent-text b/claude-templates/bin/agent-text
new file mode 100755
index 0000000..86aa933
--- /dev/null
+++ b/claude-templates/bin/agent-text
@@ -0,0 +1,57 @@
+#!/bin/bash
+# agent-text — text Craig's phone over Signal, from any machine or agent runtime.
+# The Signal half of the notification vocabulary: "text me" reaches the phone,
+# "page me" is the desktop channel (notify). See protocols.org "Reaching Craig".
+#
+# Usage: agent-text <message...>
+#
+# The Signal identity (+15045173983) is registered in velox's signal-cli, and
+# any daily driver linked as a device of that account (ratio, 2026-07-20) can
+# send directly too. So the dispatch is: if the account is registered in the
+# local signal-cli, send directly; otherwise ssh-relay the send to velox over
+# the tailnet. A direct send from a linked device still lands when velox is
+# down (the reason ratio was linked). The recipient is Craig's Signal account
+# UUID; his phone number reads as unregistered in Signal's directory, so never
+# target the number. Verified end to end 2026-07-13 (velox) and 2026-07-20
+# (ratio, direct).
+#
+# This is the AWAY channel. At his desk, use the desktop channel instead:
+# notify info "Title" "Message" --persist
+# See protocols.org "Reaching Craig" for choosing between them.
+#
+# Known caveats (full runbook in rulesets docs/design/): a relay from a
+# non-linked machine needs velox up on the tailnet, and each device holding the
+# account wants a periodic `receive` (staleness warnings appear otherwise); the
+# signal-receive timer handles that.
+#
+# Source: ~/code/rulesets/claude-templates/bin/agent-text
+# Install: make -C ~/code/rulesets install
+
+SIGNAL_ACCOUNT="+15045173983"
+CRAIG_UUID="b1b5601e-6126-47f8-afaa-0a59f5188fde"
+VELOX_HOST="velox.tailf3bb8c.ts.net"
+
+if [ $# -eq 0 ]; then
+ echo "usage: agent-text <message...>" >&2
+ exit 2
+fi
+
+msg="$*"
+
+# The account is local if this machine's signal-cli holds it: the registered
+# primary (velox) or any linked device. Those send directly.
+if signal-cli listAccounts 2>/dev/null | grep -q "$SIGNAL_ACCOUNT"; then
+ signal-cli -a "$SIGNAL_ACCOUNT" send -m "$msg" "$CRAIG_UUID"
+ rc=$?
+else
+ # printf %q hardens the message for the remote shell.
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
+ "$VELOX_HOST" \
+ "signal-cli -a $SIGNAL_ACCOUNT send -m $(printf '%q' "$msg") $CRAIG_UUID"
+ rc=$?
+fi
+
+if [ "$rc" -ne 0 ]; then
+ echo "agent-text: phone message failed (velox down or unreachable?); fall back to the desktop channel: notify info 'Message' '<message>' --persist" >&2
+fi
+exit "$rc"
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai
index 994dc1f..65d0ab7 100755
--- a/claude-templates/bin/ai
+++ b/claude-templates/bin/ai
@@ -1,16 +1,23 @@
#!/bin/bash
-# ai — Claude Code session launcher (unified aix + hey)
+# ai — agent session launcher (unified aix + hey)
#
# Usage:
-# ai Select one or more projects via fzf and open each in
-# an 'ai' tmux session window (creates session if needed).
+# ai Pick the agent first (claude, codex/ChatGPT, or any
+# local ollama model), then select one or more projects
+# via fzf; each opens in an 'ai' tmux session window.
# Git-aware: fetches, annotates with ↑/↓/dirty, auto-pulls
-# clean-and-behind repos before opening.
+# clean-and-behind repos before opening. --runtime or
+# AI_RUNTIME skips the agent pick.
#
# ai <dir>... Single-project mode. Opens each given directory directly
# in the 'ai' session (new window or switch to existing).
# Use '.' for current directory. Git prep per dir.
#
+# ai --runtime <rt> Launch with a different agent CLI: claude (default),
+# codex, or local (codex --oss against this machine's
+# ollama; model per AI_LOCAL_MODEL, default gpt-oss:120b).
+# Also settable via AI_RUNTIME.
+#
# ai --attach Attach to the existing 'ai' session without changes.
#
# ai -h | --help Show this help.
@@ -25,7 +32,65 @@
# would kill the script.
SESSION="ai"
-CLAUDE_CMD="claude"
+RUNTIME="${AI_RUNTIME:-claude}"
+LOCAL_MODEL="${AI_LOCAL_MODEL:-gpt-oss:120b}"
+
+# Map the runtime name to the agent CLI a pane launches. All three take the
+# opening instructions as a positional prompt. AGENT_BIN is the binary the
+# dependency check probes; AGENT_CMD is the full launch command (the local
+# runtime rides codex's open-source provider against the machine's ollama —
+# model per AI_LOCAL_MODEL, default gpt-oss:120b, verified on ratio's
+# Strix Halo 2026-07-13).
+resolve_agent_cmd() {
+ case "$RUNTIME" in
+ claude)
+ AGENT_BIN="claude"
+ AGENT_CMD="claude"
+ ;;
+ codex)
+ AGENT_BIN="codex"
+ AGENT_CMD="codex"
+ ;;
+ local)
+ AGENT_BIN="codex"
+ AGENT_CMD="codex --oss --local-provider=ollama -m $LOCAL_MODEL"
+ ;;
+ *)
+ echo "ai: unknown runtime '$RUNTIME' — valid runtimes: claude, codex, local" >&2
+ exit 2
+ ;;
+ esac
+}
+
+# One line per launchable agent, claude first (Enter-Enter keeps the old
+# muscle memory). Local models appear only when both codex (the CLI that
+# drives them) and a live ollama answer; a dead server just drops the lines.
+build_runtime_choices() {
+ command -v claude >/dev/null 2>&1 && echo "claude — Claude Code"
+ command -v codex >/dev/null 2>&1 && echo "codex — ChatGPT (Codex CLI)"
+ if command -v codex >/dev/null 2>&1 && command -v ollama >/dev/null 2>&1; then
+ timeout 3 ollama list 2>/dev/null | tail -n +2 | awk 'NF {print "local:" $1 " — ollama"}'
+ fi
+}
+
+# Interactive runtime pick for the bare-`ai` flow. Sets RUNTIME (and
+# LOCAL_MODEL for a local pick) and re-resolves the agent command.
+# Returns 1 when the pick is cancelled.
+pick_runtime() {
+ local choice
+ choice=$(build_runtime_choices | fzf --height=30% --reverse --prompt='agent> ') || return 1
+ [ -z "$choice" ] && return 1
+ case "$choice" in
+ claude*) RUNTIME="claude" ;;
+ codex*) RUNTIME="codex" ;;
+ local:*)
+ RUNTIME="local"
+ LOCAL_MODEL="${choice#local:}"
+ LOCAL_MODEL="${LOCAL_MODEL%% *}"
+ ;;
+ esac
+ resolve_agent_cmd
+}
# Run in the pane's shell just before Claude launches. `stty susp undef` clears
# the tty's SIGTSTP (C-z) character for this pane only, so an accidental C-z is
@@ -46,16 +111,63 @@ build_instructions() {
}
usage() {
- sed -n '2,20p' "$0" | sed 's|^# \?||'
+ sed -n '2,23p' "$0" | sed 's|^# \?||'
exit 0
}
-for cmd in fzf tmux claude; do
- if ! command -v "$cmd" &>/dev/null; then
- echo "ai: $cmd is not installed" >&2
- exit 1
+check_deps() {
+ for cmd in fzf tmux "$AGENT_BIN"; do
+ if ! command -v "$cmd" &>/dev/null; then
+ echo "ai: $cmd is not installed" >&2
+ exit 1
+ fi
+ done
+}
+
+# ---------- pure decision cores (no tmux/git I/O; unit-tested directly) ----------
+
+# Decide what a git-prep pass should do from a repo's already-computed state.
+# Inputs: has_upstream (1/0), dirty (1/0), ahead, behind. Echoes one of:
+# none — no upstream, or in sync: nothing to do
+# pull — clean and purely behind: safe to fast-forward
+# report — ahead, dirty, or behind-while-dirty: show a summary, don't pull
+_git_prep_action() {
+ local has_upstream="$1" dirty="$2" ahead="$3" behind="$4"
+ [ "$has_upstream" -eq 1 ] || {
+ echo none
+ return
+ }
+ if [ "$dirty" -eq 0 ] && [ "$ahead" -eq 0 ] && [ "$behind" -gt 0 ]; then
+ echo pull
+ elif [ "$ahead" -gt 0 ] || [ "$behind" -gt 0 ] || [ "$dirty" -eq 1 ]; then
+ echo report
+ else
+ echo none
fi
-done
+}
+
+# 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.
+_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
+ projects+="${wname}"$'\t'"${wid}"$'\n'
+ else
+ others+="${wname}"$'\t'"${wid}"$'\n'
+ fi
+ done
+ others=$(printf '%s' "$others" | sort -t$'\t' -k1,1f)
+ projects=$(printf '%s' "$projects" | sort -t$'\t' -k1,1f)
+ printf '%s\n%s\n' "$others" "$projects" | sed '/^$/d'
+}
+
+# Emit the window id whose name (field 1 of "name<TAB>wid" stdin) equals $1.
+_match_window_id() {
+ awk -F'\t' -v n="$1" '$1 == n { print $2; exit }'
+}
# ---------- shared helpers ----------
@@ -73,13 +185,16 @@ create_window() {
wid=$(tmux new-window -a -t "$SESSION:{end}" -n "$name" -c "$dir" -P -F '#{window_id}')
sleep 0.1
instructions=$(build_instructions "$name")
- tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter
+ tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter
echo "$wid"
}
# Add a directory to candidates only if it's a Claude-template project.
maybe_add_candidate() {
local dir="$1"
+ # The "~/" is a deliberate literal display prefix, re-expanded downstream via
+ # ${c/#\~/$HOME}; it must not expand here, so SC2088 doesn't apply.
+ # shellcheck disable=SC2088
[ -f "$dir/.ai/protocols.org" ] && candidates+=("~/${dir#"$HOME"/}")
}
@@ -116,12 +231,36 @@ fetch_candidates() {
wait
}
+# Resolve the shared state gate installed beside this launcher. Keeping the
+# policy in one executable prevents startup, the picker, and wrap-up from
+# developing different meanings of "safe to sync."
+_git_gate_path() {
+ local gate="${GIT_WORKTREE_GATE:-}"
+ [ -n "$gate" ] || gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/git-worktree-gate"
+ [ -x "$gate" ] && printf '%s\n' "$gate"
+}
+
+# True (exit 0) when strict wrap would reject the worktree.
+_git_is_dirty() {
+ local dir="$1" gate
+ gate="$(_git_gate_path)" || return 0
+ ! "$gate" strict "$dir" >/dev/null 2>&1
+}
+
+# True (exit 0) when startup sync must stop. Untracked inbox deliveries are
+# safe queue input; every tracked, staged, or other untracked change blocks.
+_git_blocks_sync() {
+ local dir="$1" gate
+ gate="$(_git_gate_path)" || return 0
+ ! "$gate" sync-safe "$dir" >/dev/null 2>&1
+}
+
# Return " (↑N ↓N dirty)" or " (✓)" if clean.
git_status_indicator() {
local dir="$1" upstream ahead=0 behind=0 parts=()
[ -d "$dir/.git" ] || return 0
- upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
+ upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true)
if [ -n "$upstream" ]; then
ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0)
behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0)
@@ -131,10 +270,12 @@ git_status_indicator() {
parts+=("no upstream")
fi
- if ! git -C "$dir" diff --quiet 2>/dev/null \
- || ! git -C "$dir" diff --cached --quiet 2>/dev/null \
- || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
- parts+=("dirty")
+ if _git_is_dirty "$dir"; then
+ if _git_blocks_sync "$dir"; then
+ parts+=("dirty")
+ else
+ parts+=("inbox")
+ fi
fi
if [ ${#parts[@]} -gt 0 ]; then
@@ -156,27 +297,21 @@ annotate_candidates() {
candidates=("${annotated[@]}")
}
-# Pull if clean, behind, not ahead. No-op otherwise.
+# Pull if sync-safe, behind, not ahead. Inbox-only queue input is sync-safe.
auto_pull_if_clean() {
local dir="$1" upstream ahead behind
[ -d "$dir/.git" ] || return 0
+ _git_blocks_sync "$dir" && return 0
- if ! git -C "$dir" diff --quiet 2>/dev/null \
- || ! git -C "$dir" diff --cached --quiet 2>/dev/null \
- || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
- return 0
- fi
-
- upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
+ upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true)
[ -z "$upstream" ] && return 0
ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0)
- [ "${ahead:-0}" -gt 0 ] 2>/dev/null && return 0
-
behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0)
- [ "${behind:-0}" -eq 0 ] 2>/dev/null && return 0
- git -C "$dir" pull --ff-only --quiet 2>/dev/null || true
+ # dirty=0 and has_upstream=1 are guaranteed by the early returns above.
+ [ "$(_git_prep_action 1 0 "${ahead:-0}" "${behind:-0}")" = pull ] &&
+ git -C "$dir" pull --ff-only --quiet 2>/dev/null || true
}
# Strip " (annotation)" suffix from fzf output so downstream gets raw paths.
@@ -189,7 +324,7 @@ read_selections() {
# Re-order windows: non-project windows at base-index, projects alphabetically after.
sort_windows() {
- local windows others="" projects="" base_idx project_names=""
+ local windows base_idx project_names="" ordered
base_idx=$(tmux show-option -gv base-index 2>/dev/null || echo 0)
windows=$(tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}')
@@ -198,83 +333,65 @@ sort_windows() {
project_names+="$(basename "${c/#\~/$HOME}")"$'\n'
done
- while IFS=$'\t' read -r wname wid; do
- [ -z "$wname" ] && continue
- if echo "$project_names" | grep -qxF "$wname"; then
- projects+="${wname}"$'\t'"${wid}"$'\n'
- else
- others+="${wname}"$'\t'"${wid}"$'\n'
- fi
- done <<<"$windows"
- others=$(echo -n "$others" | sort -t$'\t' -k1,1f)
- projects=$(echo -n "$projects" | sort -t$'\t' -k1,1f)
-
- local all
- all=$(printf '%s\n' "$others" "$projects" | sed '/^$/d')
+ ordered=$(printf '%s\n' "$windows" | _order_windows "$project_names")
+ [ -z "$ordered" ] && return 0
+ # First pass parks every window above the live range so the second pass can
+ # reassign the target indices without colliding with a window already there.
local i=900
while IFS=$'\t' read -r _n wid; do
+ [ -z "$wid" ] && continue
tmux move-window -s "$wid" -t "$SESSION:$i"
i=$((i + 1))
- done <<<"$all"
+ done <<<"$ordered"
i=$base_idx
- if [ -n "$others" ]; then
- while IFS=$'\t' read -r _n wid; do
- tmux move-window -s "$wid" -t "$SESSION:$i"
- i=$((i + 1))
- done <<<"$others"
- fi
- if [ -n "$projects" ]; then
- while IFS=$'\t' read -r _n wid; do
- tmux move-window -s "$wid" -t "$SESSION:$i"
- i=$((i + 1))
- done <<<"$projects"
- fi
+ while IFS=$'\t' read -r _n wid; do
+ [ -z "$wid" ] && continue
+ tmux move-window -s "$wid" -t "$SESSION:$i"
+ i=$((i + 1))
+ done <<<"$ordered"
}
# Find existing window id in ai session by window name; empty if none.
find_window_id() {
- local name="$1"
- tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null \
- | awk -F'\t' -v n="$name" '$1 == n {print $2; exit}'
+ tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null |
+ _match_window_id "$1"
}
# Git prep for a single directory. Uses FETCH_HEAD cache to skip back-to-back
# fetches. Pulls automatically if clean-and-behind; prints one-line summary
# if diverged/dirty/ahead.
prep_git_single() {
- local dir="$1" gitdir upstream ahead=0 behind=0 dirty="" age fetch_stale=1 parts=()
+ local dir="$1" gitdir upstream ahead=0 behind=0 dirty=0 age fetch_stale=1 parts=()
git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
gitdir=$(git -C "$dir" rev-parse --git-dir 2>/dev/null)
if [ -f "$gitdir/FETCH_HEAD" ]; then
- age=$(( $(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0) ))
+ age=$(($(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0)))
[ "$age" -lt 600 ] && fetch_stale=0
fi
[ "$fetch_stale" -eq 1 ] && git -C "$dir" fetch --quiet 2>/dev/null || true
- upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
+ upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true)
[ -z "$upstream" ] && return 0
ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0)
behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0)
-
- if ! git -C "$dir" diff --quiet 2>/dev/null \
- || ! git -C "$dir" diff --cached --quiet 2>/dev/null \
- || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
- dirty="dirty"
- fi
-
- if [ -z "$dirty" ] && [ "${ahead:-0}" -eq 0 ] && [ "${behind:-0}" -gt 0 ]; then
- echo "ai: pulling $behind commit(s) from $upstream..." >&2
- git -C "$dir" pull --ff-only --quiet
- elif [ "${ahead:-0}" -gt 0 ] || [ "${behind:-0}" -gt 0 ] || [ -n "$dirty" ]; then
- [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead")
- [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind")
- [ -n "$dirty" ] && parts+=("$dirty")
- echo "ai: $(basename "$dir") — ${parts[*]}" >&2
- fi
+ _git_blocks_sync "$dir" && dirty=1
+
+ case "$(_git_prep_action 1 "$dirty" "${ahead:-0}" "${behind:-0}")" in
+ pull)
+ echo "ai: pulling $behind commit(s) from $upstream..." >&2
+ git -C "$dir" pull --ff-only --quiet
+ ;;
+ report)
+ [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead")
+ [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind")
+ [ "$dirty" -eq 1 ] && parts+=("dirty")
+ echo "ai: $(basename "$dir") — ${parts[*]}" >&2
+ ;;
+ esac
}
# ---------- modes ----------
@@ -291,7 +408,10 @@ attach_mode() {
# Open a single project (or focus existing window).
single_mode() {
local arg="$1" dir name wid existing
- dir="$(cd "$arg" 2>/dev/null && pwd)" || { echo "ai: cannot access '$arg'" >&2; return 1; }
+ 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 a Claude-template project" >&2
@@ -319,7 +439,7 @@ single_mode() {
local instructions
wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}')
instructions=$(build_instructions "$name")
- tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter
+ tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter
fi
sort_windows
@@ -379,12 +499,12 @@ multi_mode() {
local instructions
first_wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}')
instructions=$(build_instructions "$name")
- tmux send-keys -t "$first_wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter
+ tmux send-keys -t "$first_wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter
for entry in "${selected[@]:1}"; do
dir="${entry/#\~/$HOME}"
name="$(basename "$dir")"
auto_pull_if_clean "$dir"
- create_window "$dir" "$name" > /dev/null
+ create_window "$dir" "$name" >/dev/null
done
else
# Add windows to existing session
@@ -403,21 +523,99 @@ multi_mode() {
attach_session
}
+# Print the launch command a real run would send to the pane, then exit.
+# Exists for the launcher's bats tests: exercises runtime resolution and the
+# opening line with no tmux or fzf involved.
+print_launch_mode() {
+ local arg="$1" dir name
+ dir="$(cd "$arg" 2>/dev/null && pwd)" || {
+ echo "ai: cannot access '$arg'" >&2
+ exit 1
+ }
+ if [ ! -f "$dir/.ai/protocols.org" ]; then
+ echo "ai: $dir has no .ai/protocols.org — not an agent-template project" >&2
+ exit 1
+ fi
+ name="$(basename "$dir")"
+ printf '%s "%s"\n' "$AGENT_CMD" "$(build_instructions "$name")"
+ exit 0
+}
+
# ---------- dispatch ----------
-case "${1:-}" in
- -h|--help)
- usage
- ;;
- --attach)
- attach_mode
- ;;
- "")
- multi_mode
- ;;
- *)
- for arg in "$@"; do
- single_mode "$arg"
- done
- ;;
-esac
+# Argument parsing + mode dispatch. Wrapped so the file can be sourced (by the
+# launcher's bats tests) to exercise individual functions without running a
+# real launch. When executed as a program, BASH_SOURCE[0] equals $0 and the
+# dispatch runs exactly as before; when sourced, it's skipped.
+main() {
+ print_launch=""
+ runtime_explicit="${AI_RUNTIME:+1}"
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ -h | --help)
+ usage
+ ;;
+ --runtime)
+ [ -z "${2:-}" ] && {
+ echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2
+ exit 2
+ }
+ RUNTIME="$2"
+ runtime_explicit=1
+ shift 2
+ ;;
+ --runtime=*)
+ RUNTIME="${1#--runtime=}"
+ runtime_explicit=1
+ shift
+ ;;
+ --print-launch)
+ print_launch=1
+ shift
+ ;;
+ --print-runtimes)
+ build_runtime_choices
+ exit 0
+ ;;
+ *)
+ break
+ ;;
+ esac
+ done
+
+ resolve_agent_cmd
+
+ if [ -n "$print_launch" ]; then
+ [ $# -eq 0 ] && {
+ echo "ai: --print-launch needs a project directory" >&2
+ exit 2
+ }
+ print_launch_mode "$1"
+ fi
+
+ case "${1:-}" in
+ --attach)
+ check_deps
+ attach_mode
+ ;;
+ "")
+ # Bare `ai`: pick the agent first (skipped when --runtime or AI_RUNTIME
+ # already chose), then the familiar project multi-select.
+ if [ -z "$runtime_explicit" ]; then
+ pick_runtime || exit 0
+ fi
+ check_deps
+ multi_mode
+ ;;
+ *)
+ check_deps
+ for arg in "$@"; do
+ single_mode "$arg"
+ done
+ ;;
+ esac
+}
+
+if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
+ main "$@"
+fi
diff --git a/claude-templates/bin/git-worktree-gate b/claude-templates/bin/git-worktree-gate
new file mode 100755
index 0000000..e453fd1
--- /dev/null
+++ b/claude-templates/bin/git-worktree-gate
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+# git-worktree-gate — one definition of safe Git state for startup and wrap.
+#
+# Modes:
+# strict [DIR] Require an entirely empty worktree.
+# sync-safe [DIR] Permit untracked inbox/ deliveries, but nothing else.
+# certify [DIR] Strict-check, then record the verified HEAD in the git dir.
+# verify [DIR] Strict-check and require the recorded HEAD to still match.
+#
+# Ignored files are deliberately outside Git's clean-worktree contract.
+
+set -u
+
+mode="${1:-}"
+repo="${2:-.}"
+
+usage() {
+ echo "usage: git-worktree-gate {strict|sync-safe|certify|verify} [DIR]" >&2
+ exit 2
+}
+
+case "$mode" in
+ strict|sync-safe|certify|verify) ;;
+ *) usage ;;
+esac
+
+root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null)" || {
+ echo "git-worktree-gate: $repo is not inside a Git worktree" >&2
+ exit 2
+}
+gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || {
+ echo "git-worktree-gate: cannot resolve the Git directory for $root" >&2
+ exit 2
+}
+certificate="$gitdir/ai-wrap-clean"
+
+quote_path() {
+ printf '%q' "$1"
+}
+
+operation_in_progress() {
+ local marker
+ for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
+ [ -e "$gitdir/$marker" ] && {
+ printf '%s' "$marker"
+ return 0
+ }
+ done
+ for marker in rebase-merge rebase-apply sequencer; do
+ [ -d "$gitdir/$marker" ] && {
+ printf '%s' "$marker"
+ return 0
+ }
+ done
+ return 1
+}
+
+describe_entry() {
+ local xy="$1" path="$2" original="${3:-}"
+ local index="${xy:0:1}" worktree="${xy:1:1}" label=""
+
+ if [ "$xy" = "??" ]; then
+ label="untracked; add and commit it, move it outside the repository, or remove it if unwanted"
+ elif [ "$xy" = "!!" ]; then
+ label="ignored"
+ elif [[ "$xy" = *U* || "$xy" = "AA" || "$xy" = "DD" ]]; then
+ label="unmerged; resolve the conflict and commit the result"
+ elif [ "$index" != " " ] && [ "$worktree" != " " ]; then
+ label="staged and unstaged changes; review both layers, then commit or restore them"
+ elif [ "$index" != " " ]; then
+ label="staged change; commit it or unstage and restore it"
+ else
+ label="unstaged tracked change; commit it or restore it"
+ fi
+
+ printf ' %s ' "$xy"
+ quote_path "$path"
+ if [ -n "$original" ]; then
+ printf ' (from '
+ quote_path "$original"
+ printf ')'
+ fi
+ printf ' — %s\n' "$label"
+}
+
+check_state() {
+ local policy="$1" xy path original="" blocked=0 op=""
+ local status_tmp="" status_err="" status_detail=""
+ local -a report=()
+
+ if op="$(operation_in_progress)"; then
+ report+=(" Git operation in progress: $op — finish or abort it")
+ blocked=1
+ fi
+
+ status_tmp="$(mktemp "$gitdir/ai-worktree-status.tmp.XXXXXX")" || {
+ echo "wrap blocked: cannot allocate a Git-state check file" >&2
+ return 1
+ }
+ status_err="$(mktemp "$gitdir/ai-worktree-status.err.XXXXXX")" || {
+ rm -f "$status_tmp"
+ echo "wrap blocked: cannot allocate a Git-state error file" >&2
+ return 1
+ }
+
+ if ! git -C "$root" status --porcelain=v1 -z \
+ --untracked-files=all --ignore-submodules=none \
+ >"$status_tmp" 2>"$status_err"; then
+ status_detail="$(head -1 "$status_err")"
+ [ -n "$status_detail" ] || status_detail="unknown Git error"
+ report+=(" git status failed — $status_detail")
+ blocked=1
+ else
+ while IFS= read -r -d '' entry; do
+ xy="${entry:0:2}"
+ path="${entry:3}"
+ original=""
+ if [[ "${xy:0:1}" = "R" || "${xy:0:1}" = "C" ]]; then
+ IFS= read -r -d '' original || true
+ fi
+
+ if [ "$policy" = "sync-safe" ] \
+ && [ "$xy" = "??" ] \
+ && [[ "$path" = inbox/* ]]; then
+ continue
+ fi
+
+ report+=("$(describe_entry "$xy" "$path" "$original")")
+ blocked=1
+ done <"$status_tmp"
+ fi
+ rm -f "$status_tmp" "$status_err"
+
+ if [ "$blocked" -ne 0 ]; then
+ if [ "$policy" = "sync-safe" ]; then
+ echo "sync blocked: rulesets has changes other than untracked inbox deliveries" >&2
+ else
+ echo "wrap blocked: Git worktree is not completely clean" >&2
+ fi
+ printf '%s\n' "${report[@]}" >&2
+ return 1
+ fi
+ return 0
+}
+
+case "$mode" in
+ strict)
+ check_state strict
+ ;;
+ sync-safe)
+ check_state sync-safe
+ ;;
+ certify)
+ check_state strict || exit 1
+ head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || {
+ echo "wrap blocked: cannot resolve HEAD" >&2
+ exit 1
+ }
+ tmp="$(mktemp "$gitdir/ai-wrap-clean.tmp.XXXXXX")" || exit 1
+ chmod 600 "$tmp"
+ {
+ printf 'head=%s\n' "$head"
+ printf 'root=%s\n' "$root"
+ } >"$tmp"
+ mv "$tmp" "$certificate"
+ ;;
+ verify)
+ check_state strict || exit 1
+ [ -f "$certificate" ] || {
+ echo "wrap blocked: no clean-tree certificate exists; rerun the final wrap verification" >&2
+ exit 1
+ }
+ certified_head="$(sed -n 's/^head=//p' "$certificate" | head -1)"
+ certified_root="$(sed -n 's/^root=//p' "$certificate" | head -1)"
+ current_head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || exit 1
+ [ "$certified_root" = "$root" ] || {
+ echo "wrap blocked: clean-tree certificate belongs to a different worktree" >&2
+ exit 1
+ }
+ [ -n "$certified_head" ] && [ "$certified_head" = "$current_head" ] || {
+ echo "wrap blocked: HEAD changed after clean-tree certification; rerun the final wrap verification" >&2
+ exit 1
+ }
+ ;;
+esac
diff --git a/claude-templates/bin/install-ai b/claude-templates/bin/install-ai
new file mode 100755
index 0000000..3283c4a
--- /dev/null
+++ b/claude-templates/bin/install-ai
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# install-ai — PATH-facing launcher for the fresh-project bootstrapper.
+#
+# make install symlinks this into ~/.local/bin/install-ai (same bin loop that
+# links `ai` and `agent-text`), so `install-ai [--track|--gitignore] [PROJECT]`
+# runs from anywhere. The real logic lives in scripts/install-ai.sh; this
+# resolves its own location through the ~/.local/bin symlink and execs that
+# script by its true repo path, so the script's own repo-root computation
+# (dirname "$0"/..) stays correct. dotfiles needs no copy — the symlink always
+# points at the canonical.
+set -euo pipefail
+
+# Resolve this file through any symlink chain to its real location in the repo.
+source="${BASH_SOURCE[0]}"
+while [ -L "$source" ]; do
+ dir="$(cd -P "$(dirname "$source")" && pwd)"
+ source="$(readlink "$source")"
+ [[ "$source" != /* ]] && source="$dir/$source"
+done
+bindir="$(cd -P "$(dirname "$source")" && pwd)" # <repo>/claude-templates/bin
+repo="$(cd -P "$bindir/../.." && pwd)" # <repo>
+
+exec "$repo/scripts/install-ai.sh" "$@"