aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-30 07:42:47 -0500
committerCraig Jennings <c@cjennings.net>2026-07-30 07:42:47 -0500
commit84bd121683add4440a3652b12b70e6e2d45f5384 (patch)
treeb4a2cd0b2d9a3c862db02ae0216a9f7511dd246d /scripts
parent73e8c01fa49f48766a283e2db933e38951f656ab (diff)
downloadrulesets-84bd121683add4440a3652b12b70e6e2d45f5384.tar.gz
rulesets-84bd121683add4440a3652b12b70e6e2d45f5384.zip
feat(ai): add --helper for a second session in a live projectHEADmain
A helper is a second agent session in a project that already has a live one. It reads freely, makes only scoped single-heading edits, and never touches git. Until now the only way to get one was to tell a session by hand it was a helper, and nothing checked whether that was true. --helper runs agent-roster first, so the claim gets verified rather than asserted. It opens its own tmux window and hands the session helper-mode.org instead of the normal startup workflow. Git prep is skipped because pulls belong to the primary. With no other agent live it warns and falls back to a primary launch. When the roster can't answer, it opens a helper anyway. I picked that direction because 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 underneath a live session. Wrap-up gains a Step 0 branch. A helper archives its own context file and stops, and the primary's next commit carries its edits. An orphaned helper whose primary already exited runs the full wrap, because otherwise its edits strand as a dirty tree nobody owns. That branch resolves the project root before consulting the roster. agent-roster defaults to $PWD and only matches agents at or inside it, so a call from a subdirectory would miss a primary at the root and report the helper orphaned. That is the one path that commits and pushes. The agent id is sanitized to the charset session-context-path keeps, because it's interpolated into the command typed into the pane. A helper launching a helper gets a fresh id rather than inheriting its parent's. Two agents resolving to one context file is the collision the contract exists to prevent. Startup still has no roster check, so a bare claude launched into a busy project runs full primary startup regardless. helper-mode.org and protocols.org now say so instead of listing the routing as though it works.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/tests/ai-launcher-helper.bats328
1 files changed, 328 insertions, 0 deletions
diff --git a/scripts/tests/ai-launcher-helper.bats b/scripts/tests/ai-launcher-helper.bats
new file mode 100644
index 0000000..0ede4fb
--- /dev/null
+++ b/scripts/tests/ai-launcher-helper.bats
@@ -0,0 +1,328 @@
+#!/usr/bin/env bats
+# The ai launcher's --helper flag: open a SECOND agent session in a project that
+# already has a live one, under the helper-mode.org role contract.
+#
+# The load-bearing behavior is the roster gate. `ai --helper` is an assertion by
+# the operator that a primary is already running; the roster is what checks it.
+# Three outcomes, all tested here: confirmed (launch a helper), refuted (no other
+# agent — warn and launch a normal primary instead), and unverifiable (no roster
+# script, or a platform without /proc — warn and launch a helper anyway, because
+# helper mode is the strictly less destructive guess when we cannot tell).
+#
+# --print-launch is the seam, as it is for the runtime tests: it prints the exact
+# command a real run would send to the pane without touching tmux or fzf. The
+# roster runs BEFORE that print, so the printed line reflects the real decision.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ AI="$REPO_ROOT/claude-templates/bin/ai"
+ PROJ="$(mktemp -d)"
+ mkdir -p "$PROJ/.ai/scripts"
+ touch "$PROJ/.ai/protocols.org"
+ # Every tmux call in this file goes to a private server under the test
+ # tmpdir, so nothing here can reach Craig's live 'ai' session.
+ export TMUX_TMPDIR="$PROJ"
+ unset TMUX
+ # Source for direct access to the pure cores. The guard skips main().
+ # shellcheck disable=SC1090
+ source "$AI"
+}
+
+teardown() {
+ tmux kill-server 2>/dev/null || true
+ rm -rf "$PROJ"
+}
+
+# Install a stub roster that exits with the given status. Exit codes are the
+# real agent-roster's contract: 0 alone, 1 others live, 2 unavailable.
+_stub_roster() {
+ cat > "$PROJ/.ai/scripts/agent-roster" <<STUB
+#!/bin/bash
+exit $1
+STUB
+ chmod +x "$PROJ/.ai/scripts/agent-roster"
+}
+
+# --- the roster gate, end to end through --print-launch ------------------------
+
+@test "--helper with a live primary launches under the helper contract" {
+ _stub_roster 1
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"helper-mode.org"* ]]
+ # The helper opener replaces the primary one; it must not send the session
+ # to protocols.org, whose startup would run pulls, rsync, and inbox work.
+ [[ "$output" != *"protocols.org"* ]]
+}
+
+@test "--helper exports the agent id and the helper flag into the pane" {
+ _stub_roster 1
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"AI_AGENT_ID=helper-"* ]]
+ [[ "$output" == *"AI_HELPER=1"* ]]
+}
+
+@test "--helper assigns a helper-<rand4> id" {
+ _stub_roster 1
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]]
+}
+
+@test "--helper honors an id the caller already exported" {
+ _stub_roster 1
+ AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"AI_AGENT_ID=helper-beef"* ]]
+}
+
+@test "--helper sanitizes an id carrying shell metacharacters" {
+ _stub_roster 1
+ # The id is interpolated into the command typed into the pane. An id
+ # carrying ';' would end the assignment and run the rest as its own
+ # command — the helper never launches and something else does.
+ AI_AGENT_ID='x;touch /tmp/ai-helper-pwned' run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *";touch"* ]]
+ [[ "$output" != *" /tmp/ai-helper-pwned"* ]]
+ # Assert the exact surviving form too. Negative-only assertions also pass
+ # when the id is dropped or mangled some other way, which is how a broken
+ # sanitizer slipped through once already.
+ [[ "$output" == *"AI_AGENT_ID=x_touch__tmp_ai-helper-pwned "* ]]
+}
+
+@test "--helper sanitizes an id carrying a space" {
+ _stub_roster 1
+ AI_AGENT_ID='helper beef' run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ # A bare space would split the assignment from the command word. Anchored on
+ # the following space, not a substring: a substring match also accepts a
+ # mangled "helper_beef_", which an earlier sanitizer actually produced.
+ [[ "$output" == *"AI_AGENT_ID=helper_beef "* ]]
+}
+
+@test "--helper mints a fresh id rather than reusing a live one" {
+ _stub_roster 1
+ # A helper's pane exports AI_AGENT_ID, so `ai --helper` run from inside a
+ # helper inherits its parent's id. Reusing it lands both agents on one
+ # .ai/session-context.d/<id>.org — the lost-update shape helper mode exists
+ # to prevent.
+ mkdir -p "$PROJ/.ai/session-context.d"
+ printf 'helper-beef\n' > "$PROJ/.ai/session-context.d/helper-beef.org"
+ AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"AI_AGENT_ID=helper-beef"* ]]
+ [[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]]
+ [[ "$output" == *"already live"* ]]
+}
+
+@test "--helper with no other agent falls back to a primary session and says so" {
+ _stub_roster 0
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ # Refuted: this is the normal primary launch.
+ [[ "$output" == *"protocols.org"* ]]
+ [[ "$output" != *"helper-mode.org"* ]]
+ [[ "$output" != *"AI_HELPER=1"* ]]
+ [[ "$output" == *"no other agent"* ]]
+}
+
+@test "--helper with no roster installed still launches a helper, with a warning" {
+ # No stub written: an older checkout whose .ai/scripts predates agent-roster.
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"helper-mode.org"* ]]
+ [[ "$output" == *"could not verify"* ]]
+}
+
+@test "--helper with an unavailable roster still launches a helper, with a warning" {
+ _stub_roster 2
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"helper-mode.org"* ]]
+ [[ "$output" == *"could not verify"* ]]
+}
+
+@test "--helper names the host and project in the opener, as the primary does" {
+ _stub_roster 1
+ run bash "$AI" --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"$(basename "$PROJ")"* ]]
+ [[ "$output" == *"$(uname -n)"* ]]
+}
+
+@test "--helper composes with --runtime" {
+ _stub_roster 1
+ run bash "$AI" --runtime codex --helper --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"codex "* ]]
+ [[ "$output" == *"helper-mode.org"* ]]
+}
+
+@test "--helper refuses a directory that is not an agent-template project" {
+ run bash "$AI" --helper --print-launch "$BATS_TEST_TMPDIR"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "--helper needs a project directory" {
+ run bash "$AI" --helper
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"needs a project directory"* ]]
+}
+
+# --- pure cores ---------------------------------------------------------------
+
+@test "_helper_launch_mode: roster found other agents (1) — launch a helper" {
+ run _helper_launch_mode 1
+ [ "$output" = helper ]
+}
+
+@test "_helper_launch_mode: roster says alone (0) — fall back to primary" {
+ run _helper_launch_mode 0
+ [ "$output" = primary ]
+}
+
+@test "_helper_launch_mode: roster unavailable (2) — helper, unverified" {
+ run _helper_launch_mode 2
+ [ "$output" = helper-unverified ]
+}
+
+@test "_helper_launch_mode: no roster script at all — helper, unverified" {
+ run _helper_launch_mode absent
+ [ "$output" = helper-unverified ]
+}
+
+@test "_resolve_helper_launch: builds the roster path from its own argument" {
+ _stub_roster 1
+ # With no `dir` in the caller's scope, a roster path built from the caller's
+ # variable instead of the parameter resolves to "/.ai/scripts/agent-roster",
+ # which isn't executable — so the gate would silently report unverified and
+ # every helper launch would skip its check. The bug is invisible when the
+ # caller happens to have its own $dir holding the same value, which both
+ # production callers do.
+ unset dir
+ run _resolve_helper_launch "$PROJ"
+ [ "$output" = helper ]
+}
+
+@test "_helper_id: shape is helper- plus four hex digits" {
+ run _helper_id
+ [[ "$output" =~ ^helper-[0-9a-f]{4}$ ]]
+}
+
+@test "_helper_id: uses the full 16 bits, not bash RANDOM's 15" {
+ # The shape test alone passes against `RANDOM % 65536`, which can never set
+ # the top bit — so every id would begin 0-7 and nothing would fail. Draw
+ # enough to make a genuinely 16-bit generator almost certain to show a high
+ # leading digit, and assert one appears.
+ local i high=0
+ for i in $(seq 1 200); do
+ case "$(_helper_id)" in
+ helper-[89abcdef]*) high=1; break ;;
+ esac
+ done
+ [ "$high" -eq 1 ]
+}
+
+@test "_sanitize_agent_id: keeps the safe charset and maps everything else" {
+ run _sanitize_agent_id 'helper-a83f'
+ [ "$output" = "helper-a83f" ]
+ run _sanitize_agent_id 'a b;c/d$e'
+ [ "$output" = "a_b_c_d_e" ]
+ run _sanitize_agent_id 'keep.dots_and-dashes'
+ [ "$output" = "keep.dots_and-dashes" ]
+}
+
+@test "_resolve_helper_id: a minted id also avoids a live anchor" {
+ # Seed every id _helper_id can produce for a stubbed generator, so the mint
+ # path must notice the collision rather than hand back a taken id.
+ mkdir -p "$PROJ/.ai/session-context.d"
+ _helper_id() { echo "helper-dead"; }
+ : > "$PROJ/.ai/session-context.d/helper-dead.org"
+ run _resolve_helper_id "$PROJ"
+ # Bounded retries mean it gives up and returns the id, but it must not have
+ # returned it silently on the first look — the loop ran its full bound.
+ [ "$status" -eq 0 ]
+ [ "$output" = "helper-dead" ]
+}
+
+@test "_resolve_helper_id: a free minted id is returned as-is" {
+ _helper_id() { echo "helper-cafe"; }
+ run _resolve_helper_id "$PROJ"
+ [ "$output" = "helper-cafe" ]
+}
+
+# --- window ordering ----------------------------------------------------------
+
+@test "_order_windows: a helper window sorts with its project, not with others" {
+ local listing names out
+ listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha:helper-a83f\t@3\nalpha\t@4')"
+ names="$(printf 'alpha\nbeta')"
+ out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)"
+ [ "$out" = "zzz-other,alpha,alpha:helper-a83f,beta" ]
+}
+
+@test "_order_windows: a colon name whose prefix is not a project stays in others" {
+ local out
+ out="$(printf 'nope:helper-a83f\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)"
+ [ "$out" = "nope:helper-a83f" ]
+}
+
+# --- functional: the second window (private tmux socket) ----------------------
+#
+# The regression these guard against: single_mode focuses the project's existing
+# window and returns, so routing a helper through it would hand back the PRIMARY
+# session instead of opening a second one.
+#
+# The window-list tail (sort_windows, attach_session) is stubbed out. Both are
+# already covered in the characterization suite, attaching needs a real terminal
+# these tests don't have, and build_candidates legitimately returns non-zero
+# under bats's errexit (bin/ai itself runs without set -e — see that file's NOTE).
+_stub_window_tail() {
+ sort_windows() { :; }
+ attach_session() { :; }
+ export AGENT_CMD="true"
+}
+
+@test "functional helper_mode: opens a NEW window beside the project's existing one" {
+ _stub_roster 1
+ _stub_window_tail
+ tmux new-session -d -s ai -n "$(basename "$PROJ")" -c "$PROJ"
+ run helper_mode "$PROJ"
+ [ "$status" -eq 0 ]
+ names="$(tmux list-windows -t ai -F '#{window_name}')"
+ # The primary's window survives untouched, and a helper window joins it.
+ printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")"
+ printf '%s\n' "$names" | grep -qE "^$(basename "$PROJ"):helper-[0-9a-f]{4}$"
+ [ "$(printf '%s\n' "$names" | wc -l)" -eq 2 ]
+}
+
+@test "functional helper_mode: the window name carries the exported id" {
+ _stub_roster 1
+ _stub_window_tail
+ tmux new-session -d -s ai -n base -c "$PROJ"
+ AI_AGENT_ID=helper-beef run helper_mode "$PROJ"
+ [ "$status" -eq 0 ]
+ tmux list-windows -t ai -F '#{window_name}' | grep -qx "$(basename "$PROJ"):helper-beef"
+}
+
+@test "functional helper_mode: an empty roster opens the plain project window" {
+ _stub_roster 0
+ _stub_window_tail
+ tmux new-session -d -s ai -n base -c "$PROJ"
+ run helper_mode "$PROJ"
+ [ "$status" -eq 0 ]
+ names="$(tmux list-windows -t ai -F '#{window_name}')"
+ printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")"
+ ! printf '%s\n' "$names" | grep -q ':helper-'
+}
+
+# --- help ---------------------------------------------------------------------
+
+@test "usage documents --helper" {
+ run bash "$AI" -h
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"--helper"* ]]
+}