aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/install-ai.sh18
-rwxr-xr-xscripts/install-lang.sh41
-rwxr-xr-xscripts/lint.sh44
-rwxr-xr-xscripts/roam-sync.sh9
-rwxr-xr-xscripts/signal-receive.sh51
-rwxr-xr-xscripts/sweep-gitignore-tooling.sh32
-rwxr-xr-xscripts/sync-language-bundle.sh23
-rw-r--r--scripts/systemd/signal-receive.service10
-rw-r--r--scripts/systemd/signal-receive.timer10
-rw-r--r--scripts/tests/agent-page.bats68
-rw-r--r--scripts/tests/agent-text.bats78
-rw-r--r--scripts/tests/ai-launcher-characterization.bats365
-rw-r--r--scripts/tests/ai-launcher-helper.bats328
-rw-r--r--scripts/tests/ai-wrap-teardown-hook.bats118
-rw-r--r--scripts/tests/audit.bats19
-rw-r--r--scripts/tests/before-close-queue.bats44
-rwxr-xr-xscripts/tests/git-worktree-gate.bats146
-rw-r--r--scripts/tests/inbox-boundary-check-hook.bats83
-rw-r--r--scripts/tests/install-ai.bats38
-rw-r--r--scripts/tests/install-hooks-link.bats15
-rw-r--r--scripts/tests/install-lang-collision.bats53
-rw-r--r--scripts/tests/install-lang-completeness.bats93
-rw-r--r--scripts/tests/install-lang.bats62
-rw-r--r--scripts/tests/lint-coverage.bats54
-rw-r--r--scripts/tests/pre-commit-secret-scan.bats55
-rw-r--r--scripts/tests/rename-ai-artifact.bats12
-rw-r--r--scripts/tests/signal-receive.bats66
-rw-r--r--scripts/tests/sweep-gitignore-tooling.bats41
-rw-r--r--scripts/tests/sync-language-bundle.bats75
29 files changed, 1940 insertions, 111 deletions
diff --git a/scripts/install-ai.sh b/scripts/install-ai.sh
index 0c90f64..8c04e22 100755
--- a/scripts/install-ai.sh
+++ b/scripts/install-ai.sh
@@ -179,6 +179,24 @@ case "$track_mode" in
;;
esac
+# Ignore temp/ in BOTH track and gitignore modes (not the "not-a-git-repo"
+# case). temp/ is the home for ephemeral, disposable, regenerable artifacts —
+# throwaway regardless of whether the project tracks its .ai/ tooling, so it
+# rides neither the track .gitkeep step nor the gitignore-only tooling block.
+# working/ is deliberately NOT ignored: it's the tracked home of in-progress
+# work, version-controlled from creation (see working-files.md). Idempotent:
+# accepts either the unanchored `temp/` or anchored `/temp/` form.
+if [ -n "$track_mode" ]; then
+ gi="$project/.gitignore"
+ if ! { [ -f "$gi" ] && { grep -qFx 'temp/' "$gi" || grep -qFx '/temp/' "$gi"; }; }; then
+ {
+ [ -s "$gi" ] && echo ""
+ echo "# Ephemeral working artifacts (throwaway; see working-files.md)"
+ echo "temp/"
+ } >> "$gi"
+ fi
+fi
+
# Banner.
echo
echo "Done."
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh
index 6e8d806..3aaa76e 100755
--- a/scripts/install-lang.sh
+++ b/scripts/install-lang.sh
@@ -104,12 +104,16 @@ fi
echo "Installing '$LANG' ruleset into $PROJECT"
-# 1. Generic rules from claude-rules/ (shared across all languages)
-if [ -d "$REPO_ROOT/claude-rules" ]; then
- mkdir -p "$PROJECT/.claude/rules"
- cp "$REPO_ROOT/claude-rules"/*.md "$PROJECT/.claude/rules/" 2>/dev/null || true
- count=$(ls -1 "$REPO_ROOT/claude-rules"/*.md 2>/dev/null | wc -l)
- echo " [ok] .claude/rules/ — $count generic rule(s) from claude-rules/"
+# 1. Generic rules are NOT copied here. They install once at ~/.claude/rules/
+# via `make install` and load in every session on the machine. Copying them per
+# project loaded them twice, and project rules outrank user-level ones — so a
+# stale project copy quietly overrode the fresh global rule. The bundle owns its
+# own language rules only; sync-language-bundle.sh sweeps copies left by earlier
+# installs. A machine that wants the generic rules runs `make install`.
+mkdir -p "$PROJECT/.claude/rules"
+if [ ! -d "$HOME/.claude/rules" ]; then
+ echo " [!!] ~/.claude/rules/ is missing — run 'make install' so the generic"
+ echo " rules are available; this bundle installs language rules only."
fi
# 2. .claude/ — language-specific rules, hooks, settings (authoritative, always overwrite)
@@ -195,5 +199,30 @@ if [ -f "$SRC/gitignore-add.txt" ]; then
fi
fi
+# --- Bundle completeness check ---
+# Every component copy above is guarded by a plain existence test, so a bundle
+# missing one installs silently and reports success. That is how the python and
+# typescript bundles shipped for nearly two months with no pre-commit hook, and
+# therefore no credential scan, on every project that installed them: nothing
+# ever said the component was absent.
+#
+# This can't be fixed by never missing a component — someone adding the seventh
+# bundle will miss one too. It's fixed by the install saying so. Warn, never
+# block: a partial bundle is still worth installing, and turning this into a
+# failure would just teach people to skip the installer.
+missing=""
+[ -f "$SRC/githooks/pre-commit" ] || missing="${missing} - githooks/pre-commit (secret scan on commit)"$'\n'
+[ -f "$SRC/claude/settings.json" ] || missing="${missing} - claude/settings.json (permissions + PostToolUse hook wiring)"$'\n'
+ls "$SRC"/claude/hooks/*.sh >/dev/null 2>&1 || missing="${missing} - claude/hooks/*.sh (validate-on-edit hook)"$'\n'
+[ -f "$SRC/CLAUDE.md" ] || missing="${missing} - CLAUDE.md (seed project instructions)"$'\n'
+
+if [ -n "$missing" ]; then
+ echo ""
+ echo "WARNING: the '$LANG' bundle is incomplete. Not installed, because the bundle doesn't ship them:" >&2
+ printf '%s' "$missing" >&2
+ echo "The install above succeeded; these components are simply absent upstream." >&2
+ echo "Add them under languages/$LANG/ in rulesets, then re-run this install." >&2
+fi
+
echo ""
echo "Install complete."
diff --git a/scripts/lint.sh b/scripts/lint.sh
index 61a27a1..ca6abbd 100755
--- a/scripts/lint.sh
+++ b/scripts/lint.sh
@@ -21,10 +21,22 @@ warn() {
errors=$((errors + 1))
}
+# Print a rule file's body with any leading YAML frontmatter stripped, so the
+# structural checks below see the Markdown regardless of whether the file
+# carries a `paths:` block. Claude Code reads the frontmatter; the heading check
+# should not care that it is there.
+md_body() {
+ awk 'NR==1 && $0=="---" {fm=1; next}
+ fm && $0=="---" {fm=0; next}
+ !fm {print}' "$1"
+}
+
check_md_heading() {
local f="$1"
[ -f "$f" ] || return 0
- if ! head -1 "$f" | grep -q '^# '; then
+ # First non-blank line, so a blank separator after frontmatter doesn't read as
+ # a missing heading.
+ if ! md_body "$f" | grep -m1 -v '^[[:space:]]*$' | grep -q '^# '; then
warn "$f — missing top-level heading"
fi
}
@@ -37,6 +49,27 @@ check_md_applies_to() {
fi
}
+# A rule whose prose declares a file-type scope must also carry `paths:`
+# frontmatter, or Claude Code loads it into every session regardless of what the
+# prose says. Three rules declared a narrow scope this way and were loaded
+# universally for as long as they shipped, because the declaration lived only in
+# a line the loader never reads. The prose is for the human; the frontmatter is
+# what actually scopes the load. Keep them saying the same thing.
+check_md_paths_frontmatter() {
+ local f="$1" applies
+ [ -f "$f" ] || return 0
+ applies=$(grep -m1 '^Applies to:' "$f" 2>/dev/null) || return 0
+ # A scope naming a concrete extension (`**/*.org`, `**/*.el`) is path-scopable.
+ # A bare `**/*` is genuinely universal and wants no frontmatter.
+ case "$applies" in
+ *'**/*.'*)
+ if ! head -1 "$f" | grep -q '^---$'; then
+ warn "$f — declares a file-type scope in prose but has no 'paths:' frontmatter; it loads in every session"
+ fi
+ ;;
+ esac
+}
+
check_hook() {
local f="$1"
[ -f "$f" ] || return 0
@@ -81,6 +114,7 @@ for f in claude-rules/*.md; do
[ -f "$f" ] || continue
check_md_heading "$f"
check_md_applies_to "$f"
+ check_md_paths_frontmatter "$f"
done
# Per-language rule files
@@ -114,6 +148,14 @@ for s in scripts/*.sh; do
check_hook "$s"
done
+# Scripts `make install` symlinks onto PATH. Extensionless by convention, so
+# they need their own glob — scripts/*.sh above never matched them, which left
+# the most exposed shell in the repo as the only shell with no gate over it.
+for s in claude-templates/bin/*; do
+ [ -f "$s" ] || continue
+ check_hook "$s"
+done
+
# Markdown link validation across rules and skills
for f in claude-rules/*.md */SKILL.md; do
[ -f "$f" ] || continue
diff --git a/scripts/roam-sync.sh b/scripts/roam-sync.sh
index 55422ec..ef43c8f 100755
--- a/scripts/roam-sync.sh
+++ b/scripts/roam-sync.sh
@@ -3,8 +3,13 @@
#
# Commit any local changes, rebase onto the remote, push. Run by the
# roam-sync systemd user timer (scripts/systemd/) every 15 minutes so
-# Craig's hand edits travel without a manual git step. Agents don't need
-# this — they pull/commit/push inline per claude-rules/knowledge-base.md.
+# Craig's hand edits travel without a manual git step. This script is the
+# roam repo's only committer (the 2026-06-24 one-git-owner rule): the tree
+# is chronically dirty from live captures, so a second committer risks
+# sweeping an in-flight capture into a stray commit. Agents edit the working
+# tree under the roam-write lock, then trigger this unit (systemctl --user
+# start roam-sync.service) instead of committing themselves — see
+# claude-rules/knowledge-base.md and the inbox workflow's roam mode.
#
# On a rebase conflict: abort the rebase (never leave the repo mid-rebase
# for a timer to mangle), keep the local commit, exit 1 so the failure is
diff --git a/scripts/signal-receive.sh b/scripts/signal-receive.sh
new file mode 100755
index 0000000..8c3ef01
--- /dev/null
+++ b/scripts/signal-receive.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# signal-receive.sh — drain the Signal pager account's inbound queue.
+#
+# The pager identity (+15045173983) lives on velox (primary) and any linked
+# device (ratio). The Signal protocol expects a registered account to receive
+# regularly; when it goes quiet, signal-cli prints a staleness warning
+# ("Messages have been last received N days ago") and the account drifts toward
+# an unhealthy state. This script pulls anything queued and exits, keeping the
+# account warm. Run by the signal-receive systemd user timer (scripts/systemd/)
+# on every machine holding the account — the roam-sync-shaped fix for the
+# receive-staleness caveat on the pager channel.
+#
+# Draining the queue is also what surfaces Craig's replies: a reply he types in
+# Signal arrives as a data message here, so a warm account is a prerequisite for
+# the read-replies half of the pager (see docs/design/…-signal-pager-runbook.org).
+#
+# The units stow via the shared dotfiles `common` package, so the timer may land
+# on a machine that does not hold the account. That is fine: the script no-ops
+# cleanly (exit 0) when the account is not registered locally, rather than
+# erroring every cadence.
+#
+# Usage: signal-receive.sh [account] [timeout-seconds]
+# account defaults to the pager identity below
+# timeout-seconds seconds to wait for new messages (default 10)
+#
+# Exit: 0 on a clean receive (including "nothing queued" and "account not on this
+# machine"), non-zero only if signal-cli itself errors, so a real failure is
+# visible in `systemctl --user status signal-receive`.
+
+set -euo pipefail
+
+account="${1:-+15045173983}"
+timeout="${2:-10}"
+
+if ! command -v signal-cli >/dev/null 2>&1; then
+ echo "signal-receive: signal-cli not on PATH — nothing to do" >&2
+ exit 0
+fi
+
+# No-op cleanly where the account isn't registered (a machine that stows the
+# common units but isn't a pager device). Only a genuine receive error should
+# surface as a failure.
+if ! signal-cli listAccounts 2>/dev/null | grep -q "$account"; then
+ echo "signal-receive: $account not registered on this machine — nothing to do" >&2
+ exit 0
+fi
+
+# `receive` prints envelopes to stdout and returns 0 once the queue drains or
+# the timeout elapses. --send-read-receipts tells Craig's phone his replies were
+# read by the pager, matching normal Signal behavior.
+exec signal-cli -a "$account" receive --timeout "$timeout" --send-read-receipts
diff --git a/scripts/sweep-gitignore-tooling.sh b/scripts/sweep-gitignore-tooling.sh
index 68bfe2d..7194d6c 100755
--- a/scripts/sweep-gitignore-tooling.sh
+++ b/scripts/sweep-gitignore-tooling.sh
@@ -153,7 +153,37 @@ for project in "${projects[@]}"; do
done
done
+# temp/ backfill — mode-independent. Unlike the personal-tooling set above
+# (gitignore-mode only, track-mode deliberately skipped), temp/ holds ephemeral
+# artifacts in every project regardless of whether it tracks its .ai/, so both
+# track- and gitignore-mode projects get it. A separate pass, not an IGNORE_SET
+# member — folding it into that set would miss every track-mode project, which
+# is exactly the set that needs it. working/ is never ignored: it's the tracked
+# home of in-progress work.
+temp_swept=0
+for project in "${projects[@]}"; do
+ [ -d "$project/.git" ] || continue
+ name="$(basename "$project")"
+ gi="$project/.gitignore"
+
+ if [ -f "$gi" ] && { grep -qFx 'temp/' "$gi" || grep -qFx '/temp/' "$gi"; }; then
+ continue
+ fi
+
+ if [ "$dry_run" -eq 1 ]; then
+ echo "DRY $name — would add: temp/"
+ else
+ {
+ [ -s "$gi" ] && echo ""
+ echo "# Ephemeral working artifacts (throwaway; see working-files.md; swept $(date +%Y-%m-%d))"
+ echo "temp/"
+ } >> "$gi"
+ echo "temp $name — added: temp/"
+ fi
+ temp_swept=$((temp_swept + 1))
+done
+
echo
-echo "Summary: $swept swept, $complete already complete, $skipped skipped (of ${#projects[@]} projects)."
+echo "Summary: $swept swept, $complete already complete, $skipped skipped (of ${#projects[@]} projects); $temp_swept temp/ backfilled."
[ "$dry_run" -eq 1 ] && echo "(dry-run — no files written)"
exit 0
diff --git a/scripts/sync-language-bundle.sh b/scripts/sync-language-bundle.sh
index 45f8259..fe922af 100755
--- a/scripts/sync-language-bundle.sh
+++ b/scripts/sync-language-bundle.sh
@@ -105,7 +105,7 @@ inbox_drop() {
# shared generic rules, its hooks/githooks, and surfaces settings.json.
process_bundle() {
local kind="$1" src="${2%/}"
- local name rules rf f rel manual_before
+ local name rules rf f rel manual_before base
name="$(basename "$src")"
rules="$src/claude/rules"
[ -d "$rules" ] || return 0
@@ -126,12 +126,27 @@ process_bundle() {
HEADER_DONE=0
manual_before=$MANUAL
- # AUTO-FIX: language bundles carry the shared generic rules; team overlays
- # carry only their own rule(s).
+ # SWEEP: generic rules are installed once at ~/.claude/rules/ by `make
+ # install` and load in every session. Language bundles used to copy them into
+ # each project too, which made Claude Code load them twice — and project rules
+ # take priority over user-level ones, so a stale project copy silently
+ # overrode the fresh global rule until the next startup healed it. The bundle
+ # now owns only its own language rules, and sweeps the duplicates it shipped
+ # before.
+ #
+ # The sweep only fires when the global rule is actually present to take over.
+ # On a machine mid-bootstrap, or one where `make install` has not run, the
+ # project copy is the only copy and removing it would leave no rule at all.
if [ "$kind" = language ]; then
for f in "$GENERIC_RULES"/*.md; do
[ -f "$f" ] || continue
- fix "$f" "$PROJECT/.claude/rules/$(basename "$f")"
+ base="$(basename "$f")"
+ [ -f "$PROJECT/.claude/rules/$base" ] || continue
+ [ -f "$HOME/.claude/rules/$base" ] || continue
+ rm -f "$PROJECT/.claude/rules/$base"
+ ensure_header
+ OUT+=" swept .claude/rules/$base (duplicate of ~/.claude/rules/)"$'\n'
+ FIXED=$((FIXED + 1))
done
fi
for f in "$rules"/*.md; do
diff --git a/scripts/systemd/signal-receive.service b/scripts/systemd/signal-receive.service
new file mode 100644
index 0000000..d63a5c8
--- /dev/null
+++ b/scripts/systemd/signal-receive.service
@@ -0,0 +1,10 @@
+# Signal pager receive — keep the pager account warm on every machine holding it.
+# Stowed via the dotfiles `common` package, so it lands on both daily drivers;
+# the script no-ops cleanly where the account isn't registered. Enable per machine:
+# systemctl --user daemon-reload && systemctl --user enable --now signal-receive.timer
+[Unit]
+Description=Drain the Signal pager account inbound queue (keeps it warm)
+
+[Service]
+Type=oneshot
+ExecStart=%h/code/rulesets/scripts/signal-receive.sh
diff --git a/scripts/systemd/signal-receive.timer b/scripts/systemd/signal-receive.timer
new file mode 100644
index 0000000..3fdaee0
--- /dev/null
+++ b/scripts/systemd/signal-receive.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Keep the Signal pager account warm every 15 minutes
+
+[Timer]
+OnBootSec=3min
+OnUnitActiveSec=15min
+RandomizedDelaySec=60
+
+[Install]
+WantedBy=timers.target
diff --git a/scripts/tests/agent-page.bats b/scripts/tests/agent-page.bats
deleted file mode 100644
index 071e4b9..0000000
--- a/scripts/tests/agent-page.bats
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/env bats
-# agent-page — the runtime-neutral phone pager. Pages Craig over Signal from
-# any machine on the tailnet: runs signal-cli directly on velox (where the
-# pager identity lives), ssh-relays to velox from everywhere else. These tests
-# stub ssh/uname/signal-cli on PATH to verify command construction without a
-# network or a phone.
-
-setup() {
- REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
- PAGE="$REPO_ROOT/claude-templates/bin/agent-page"
- STUBS="$(mktemp -d)"
- LOG="$STUBS/calls.log"
- cat > "$STUBS/ssh" <<EOF
-#!/bin/bash
-echo "ssh \$*" >> "$LOG"
-exit 0
-EOF
- cat > "$STUBS/signal-cli" <<EOF
-#!/bin/bash
-echo "signal-cli \$*" >> "$LOG"
-exit 0
-EOF
- chmod +x "$STUBS/ssh" "$STUBS/signal-cli"
-}
-
-teardown() {
- rm -rf "$STUBS"
-}
-
-@test "no message exits 2 with usage" {
- run bash "$PAGE"
- [ "$status" -eq 2 ]
- [[ "$output" == *"usage"* ]]
-}
-
-@test "relays through ssh to velox with the pager account and Craig's UUID" {
- PATH="$STUBS:$PATH" run bash "$PAGE" build finished
- [ "$status" -eq 0 ]
- grep -q "^ssh .*velox" "$LOG"
- grep -q "15045173983" "$LOG"
- grep -q "b1b5601e-6126-47f8-afaa-0a59f5188fde" "$LOG"
- # printf %q escapes the space, so the relayed message reads build\ finished.
- grep -qF 'build\ finished' "$LOG"
-}
-
-@test "on velox itself, calls signal-cli directly (no ssh)" {
- cat > "$STUBS/uname" <<'EOF'
-#!/bin/bash
-[ "$1" = "-n" ] && { echo velox; exit 0; }
-exec /usr/bin/uname "$@"
-EOF
- chmod +x "$STUBS/uname"
- PATH="$STUBS:$PATH" run bash "$PAGE" hello
- [ "$status" -eq 0 ]
- grep -q "^signal-cli " "$LOG"
- ! grep -q "^ssh " "$LOG"
-}
-
-@test "a failed relay reports the desktop fallback and propagates failure" {
- cat > "$STUBS/ssh" <<'EOF'
-#!/bin/bash
-exit 255
-EOF
- chmod +x "$STUBS/ssh"
- PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing
- [ "$status" -ne 0 ]
- [[ "$output" == *"notify"* ]]
-}
diff --git a/scripts/tests/agent-text.bats b/scripts/tests/agent-text.bats
new file mode 100644
index 0000000..e5d80fc
--- /dev/null
+++ b/scripts/tests/agent-text.bats
@@ -0,0 +1,78 @@
+#!/usr/bin/env bats
+# agent-text — the runtime-neutral Signal phone messenger ("text me"). Reaches
+# Craig over Signal from any machine on the tailnet: sends directly wherever the
+# account is registered locally (velox, or any linked device), and ssh-relays to
+# velox from a machine that doesn't hold the account. These tests stub
+# ssh/signal-cli on PATH to verify command construction without a network or a
+# phone. The signal-cli stub answers `listAccounts` to control which branch the
+# dispatch takes. The final test covers the deprecated agent-page shim.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ PAGE="$REPO_ROOT/claude-templates/bin/agent-text"
+ SHIM="$REPO_ROOT/claude-templates/bin/agent-page"
+ STUBS="$(mktemp -d)"
+ LOG="$STUBS/calls.log"
+ cat > "$STUBS/ssh" <<EOF
+#!/bin/bash
+echo "ssh \$*" >> "$LOG"
+exit 0
+EOF
+ # HAS_ACCOUNT controls the listAccounts answer: "1" → the account is
+ # local (send direct), unset/"0" → not local (relay).
+ cat > "$STUBS/signal-cli" <<EOF
+#!/bin/bash
+if [ "\$1" = "listAccounts" ]; then
+ [ "\${HAS_ACCOUNT:-0}" = "1" ] && echo "Number: +15045173983"
+ exit 0
+fi
+echo "signal-cli \$*" >> "$LOG"
+exit 0
+EOF
+ chmod +x "$STUBS/ssh" "$STUBS/signal-cli"
+}
+
+teardown() {
+ rm -rf "$STUBS"
+}
+
+@test "no message exits 2 with usage" {
+ run bash "$PAGE"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"usage"* ]]
+}
+
+@test "relays through ssh to velox when the account is not local" {
+ HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" build finished
+ [ "$status" -eq 0 ]
+ grep -q "^ssh .*velox" "$LOG"
+ grep -q "15045173983" "$LOG"
+ grep -q "b1b5601e-6126-47f8-afaa-0a59f5188fde" "$LOG"
+ # printf %q escapes the space, so the relayed message reads build\ finished.
+ grep -qF 'build\ finished' "$LOG"
+}
+
+@test "sends directly (no ssh) when the pager account is registered locally" {
+ HAS_ACCOUNT=1 PATH="$STUBS:$PATH" run bash "$PAGE" hello
+ [ "$status" -eq 0 ]
+ grep -q "^signal-cli -a +15045173983 send " "$LOG"
+ ! grep -q "^ssh " "$LOG"
+}
+
+@test "a failed relay reports the desktop fallback and propagates failure" {
+ cat > "$STUBS/ssh" <<'EOF'
+#!/bin/bash
+exit 255
+EOF
+ chmod +x "$STUBS/ssh"
+ HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"notify"* ]]
+}
+
+@test "the deprecated agent-page shim delegates to agent-text" {
+ HAS_ACCOUNT=1 PATH="$STUBS:$PATH" run bash "$SHIM" via shim
+ [ "$status" -eq 0 ]
+ # Reaches the same direct-send path as agent-text.
+ grep -q "^signal-cli -a +15045173983 send " "$LOG"
+}
diff --git a/scripts/tests/ai-launcher-characterization.bats b/scripts/tests/ai-launcher-characterization.bats
new file mode 100644
index 0000000..5b93ff3
--- /dev/null
+++ b/scripts/tests/ai-launcher-characterization.bats
@@ -0,0 +1,365 @@
+#!/usr/bin/env bats
+# Characterization tests for the ai launcher's internal functions.
+#
+# These pin the launcher's CURRENT behavior (record-not-spec, per testing.md)
+# before the hardening refactor, so the extraction of pure cores and the
+# footgun fixes can proceed against a green net. The pure/near-pure functions
+# are exercised by sourcing bin/ai (the run-vs-sourced guard keeps dispatch
+# off) and calling them directly; the tmux-coupled pipeline is exercised
+# against a throwaway tmux server on a PRIVATE socket (TMUX_TMPDIR under the
+# test tmpdir, TMUX unset) so nothing can reach Craig's live 'ai' session.
+
+# The launcher stores candidates as literal "~/..." display strings and expands
+# them downstream; the assertions below compare against those literal tildes on
+# purpose, so SC2088 (tilde-in-quotes) does not apply. SC2030/SC2031 fire on
+# the shared `candidates` global because bats runs each @test in its own
+# subshell — the array is set and read within that same subshell, so the
+# warning is a false positive here.
+# shellcheck disable=SC2088,SC2030,SC2031
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ AI="$REPO_ROOT/claude-templates/bin/ai"
+ WORK="$(mktemp -d)"
+ # Isolate every tmux call in this file onto a private server.
+ export TMUX_TMPDIR="$WORK"
+ unset TMUX
+ # Source for direct function access. The guard skips main() when sourced.
+ # shellcheck disable=SC1090
+ source "$AI"
+}
+
+teardown() {
+ tmux kill-server 2>/dev/null || true
+ rm -rf "$WORK"
+}
+
+# --- git repo helpers ---------------------------------------------------------
+
+# A committed repo with no remote/upstream. Auto-maintenance off so background
+# git can't race the teardown rm -rf (the rename-ai flake, fixed 2026-07-19).
+_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" commit -q --allow-empty -m init
+}
+
+# A repo with a bare origin and a tracked branch, in sync at one commit.
+_mk_repo_upstream() {
+ local d="$1" remote="$1.remote"
+ git init -q --bare "$remote"
+ _mk_repo "$d"
+ git -C "$d" remote add origin "$remote"
+ git -C "$d" push -q -u origin HEAD
+}
+
+# --- usage() ------------------------------------------------------------------
+
+@test "usage: -h prints the help banner and exits 0" {
+ run bash "$AI" -h
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Usage:"* ]]
+ [[ "$output" == *"--attach"* ]]
+ [[ "$output" == *"Single-project mode"* ]]
+}
+
+# --- maybe_add_candidate() ----------------------------------------------------
+
+@test "maybe_add_candidate: a HOME-rooted project dir is added as ~/rel" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/code/proj/.ai"
+ touch "$HOME/code/proj/.ai/protocols.org"
+ candidates=()
+ maybe_add_candidate "$HOME/code/proj"
+ [ "${#candidates[@]}" -eq 1 ]
+ [ "${candidates[0]}" = "~/code/proj" ]
+}
+
+@test "maybe_add_candidate: a dir without .ai/protocols.org is filtered out" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/code/plain"
+ candidates=()
+ maybe_add_candidate "$HOME/code/plain" || true
+ [ "${#candidates[@]}" -eq 0 ]
+}
+
+@test "maybe_add_candidate: a non-HOME dir keeps its full path after ~/ (current quirk)" {
+ HOME="$WORK/home"
+ mkdir -p "$WORK/outside/.ai"
+ touch "$WORK/outside/.ai/protocols.org"
+ candidates=()
+ maybe_add_candidate "$WORK/outside"
+ # $HOME/ prefix doesn't match, so the path is left whole behind the literal ~/.
+ [ "${candidates[0]}" = "~/$WORK/outside" ]
+}
+
+# --- git_status_indicator() ---------------------------------------------------
+
+@test "git_status_indicator: a non-git dir yields no annotation" {
+ mkdir -p "$WORK/plain"
+ run git_status_indicator "$WORK/plain"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "git_status_indicator: clean repo with no upstream reads (no upstream)" {
+ _mk_repo "$WORK/r"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (no upstream)" ]
+}
+
+@test "git_status_indicator: an untracked file with no upstream reads (no upstream dirty)" {
+ _mk_repo "$WORK/r"
+ touch "$WORK/r/scratch"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (no upstream dirty)" ]
+}
+
+@test "git_status_indicator: inbox-only delivery is visible but not called dirty" {
+ _mk_repo "$WORK/r"
+ mkdir -p "$WORK/r/inbox"
+ touch "$WORK/r/inbox/from-home.org"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (no upstream inbox)" ]
+}
+
+@test "git_status_indicator: in-sync tracked repo reads (✓)" {
+ _mk_repo_upstream "$WORK/r"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (✓)" ]
+}
+
+@test "git_status_indicator: one unpushed commit reads (↑1)" {
+ _mk_repo_upstream "$WORK/r"
+ git -C "$WORK/r" commit -q --allow-empty -m ahead
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (↑1)" ]
+}
+
+@test "git_status_indicator: one commit behind upstream reads (↓1)" {
+ _mk_repo_upstream "$WORK/r"
+ git -C "$WORK/r" commit -q --allow-empty -m b
+ git -C "$WORK/r" push -q origin HEAD
+ git -C "$WORK/r" reset -q --hard HEAD~1
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (↓1)" ]
+}
+
+@test "auto_pull_if_clean fast-forwards with an untracked inbox delivery" {
+ _mk_repo_upstream "$WORK/r"
+ git clone -q "$WORK/r.remote" "$WORK/writer"
+ git -C "$WORK/writer" config user.email t@example.com
+ git -C "$WORK/writer" config user.name tester
+ git -C "$WORK/writer" commit -q --allow-empty -m remote
+ git -C "$WORK/writer" push -q
+ git -C "$WORK/r" fetch -q
+ mkdir -p "$WORK/r/inbox"
+ printf 'handoff\n' >"$WORK/r/inbox/from-home.org"
+
+ run auto_pull_if_clean "$WORK/r"
+ [ "$status" -eq 0 ]
+ [ "$(git -C "$WORK/r" rev-parse HEAD)" = "$(git -C "$WORK/r" rev-parse '@{u}')" ]
+ [ -f "$WORK/r/inbox/from-home.org" ]
+}
+
+@test "auto_pull_if_clean refuses a non-inbox untracked file" {
+ _mk_repo_upstream "$WORK/r"
+ git clone -q "$WORK/r.remote" "$WORK/writer"
+ git -C "$WORK/writer" config user.email t@example.com
+ git -C "$WORK/writer" config user.name tester
+ git -C "$WORK/writer" commit -q --allow-empty -m remote
+ git -C "$WORK/writer" push -q
+ git -C "$WORK/r" fetch -q
+ printf 'scratch\n' >"$WORK/r/scratch"
+ before="$(git -C "$WORK/r" rev-parse HEAD)"
+
+ run auto_pull_if_clean "$WORK/r"
+ [ "$status" -eq 0 ]
+ [ "$(git -C "$WORK/r" rev-parse HEAD)" = "$before" ]
+}
+
+# --- annotate_candidates() ----------------------------------------------------
+
+@test "annotate_candidates: appends each candidate's status suffix" {
+ _mk_repo_upstream "$WORK/home/code/clean"
+ mkdir -p "$WORK/home/code/plain"
+ HOME="$WORK/home"
+ candidates=("~/code/clean" "~/code/plain")
+ annotate_candidates
+ [ "${candidates[0]}" = "~/code/clean (✓)" ]
+ # A non-git candidate gets an empty suffix (git_status_indicator returns "").
+ [ "${candidates[1]}" = "~/code/plain" ]
+}
+
+# --- read_selections() --------------------------------------------------------
+
+@test "read_selections: strips the ' (annotation)' suffix from each line" {
+ read_selections "$(printf '~/code/a (✓)\n~/projects/b (↑1 dirty)')"
+ [ "${#selected[@]}" -eq 2 ]
+ [ "${selected[0]}" = "~/code/a" ]
+ [ "${selected[1]}" = "~/projects/b" ]
+}
+
+@test "read_selections: a line with no annotation is kept verbatim" {
+ read_selections "~/code/plain"
+ [ "${selected[0]}" = "~/code/plain" ]
+}
+
+@test "read_selections: empty input yields a single empty element (current quirk)" {
+ read_selections ""
+ [ "${#selected[@]}" -eq 1 ]
+ [ -z "${selected[0]}" ]
+}
+
+# --- build_candidates() -------------------------------------------------------
+
+@test "build_candidates: discovers .ai projects under HOME, filters, sorts" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/.emacs.d/.ai" "$HOME/code/beta/.ai" "$HOME/code/alpha/.ai" \
+ "$HOME/code/plain" "$HOME/projects/gamma/.ai"
+ touch "$HOME/.emacs.d/.ai/protocols.org" \
+ "$HOME/code/beta/.ai/protocols.org" \
+ "$HOME/code/alpha/.ai/protocols.org" \
+ "$HOME/projects/gamma/.ai/protocols.org"
+ # '|| true' suspends bats's errexit so the function runs to completion as it
+ # does in production (bin/ai runs without set -e). Its non-zero exit is
+ # incidental — it inherits the last maybe_add_candidate's filter result,
+ # which callers ignore; see the file's top-of-script NOTE.
+ build_candidates || true
+ # .emacs.d first (probed first), then ~/code sorted, then ~/projects.
+ [ "${candidates[0]}" = "~/.emacs.d" ]
+ [ "${candidates[1]}" = "~/code/alpha" ]
+ [ "${candidates[2]}" = "~/code/beta" ]
+ [ "${candidates[3]}" = "~/projects/gamma" ]
+ [ "${#candidates[@]}" -eq 4 ]
+}
+
+@test "build_candidates: a HOME with no .ai projects yields an empty list" {
+ HOME="$WORK/empty-home"
+ mkdir -p "$HOME/code/plain"
+ build_candidates || true
+ [ "${#candidates[@]}" -eq 0 ]
+}
+
+# --- functional: tmux-coupled pipeline (private socket) -----------------------
+
+@test "functional create_window: adds a named window and returns its id" {
+ export AGENT_CMD="true"
+ tmux new-session -d -s ai -n base -c "$WORK"
+ run create_window "$WORK" "proj-x"
+ [ "$status" -eq 0 ]
+ [ -n "$output" ]
+ tmux list-windows -t ai -F '#{window_name}' | grep -qx proj-x
+}
+
+@test "functional find_window_id: returns the id for a name, empty for a miss" {
+ tmux new-session -d -s ai -n only -c "$WORK"
+ run find_window_id only
+ [ -n "$output" ]
+ run find_window_id nope
+ [ -z "$output" ]
+}
+
+@test "functional sort_windows: others alpha-first, then projects alpha" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/code/alpha/.ai" "$HOME/code/beta/.ai"
+ touch "$HOME/code/alpha/.ai/protocols.org" "$HOME/code/beta/.ai/protocols.org"
+ tmux new-session -d -s ai -n zzz-other -c "$WORK"
+ tmux new-window -t ai -n beta
+ tmux new-window -t ai -n alpha
+ tmux new-window -t ai -n aaa-other
+ run sort_windows
+ [ "$status" -eq 0 ]
+ order="$(tmux list-windows -t ai -F '#{window_name}' | paste -sd, -)"
+ [ "$order" = "aaa-other,zzz-other,alpha,beta" ]
+}
+
+@test "functional attach_mode: no session prints an error and exits 1" {
+ run bash "$AI" --attach
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"no 'ai' session"* ]]
+}
+
+# --- extracted pure cores -----------------------------------------------------
+
+@test "_git_prep_action: no upstream is always 'none'" {
+ run _git_prep_action 0 0 0 5
+ [ "$output" = none ]
+}
+
+@test "_git_prep_action: clean and purely behind is 'pull'" {
+ run _git_prep_action 1 0 0 3
+ [ "$output" = pull ]
+}
+
+@test "_git_prep_action: in sync with upstream is 'none'" {
+ run _git_prep_action 1 0 0 0
+ [ "$output" = none ]
+}
+
+@test "_git_prep_action: ahead is 'report', never 'pull'" {
+ run _git_prep_action 1 0 2 0
+ [ "$output" = report ]
+}
+
+@test "_git_prep_action: dirty is 'report', never 'pull'" {
+ run _git_prep_action 1 1 0 0
+ [ "$output" = report ]
+}
+
+@test "_git_prep_action: behind AND dirty is 'report' — a dirty repo is never auto-pulled" {
+ run _git_prep_action 1 1 0 3
+ [ "$output" = report ]
+}
+
+@test "_git_prep_action: diverged (ahead and behind) is 'report'" {
+ run _git_prep_action 1 0 2 3
+ [ "$output" = report ]
+}
+
+@test "_order_windows: others alpha, then projects alpha" {
+ local listing names out
+ listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha\t@3\naaa-other\t@4')"
+ names="$(printf 'alpha\nbeta')"
+ out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)"
+ [ "$out" = "aaa-other,zzz-other,alpha,beta" ]
+}
+
+@test "_order_windows: all-projects input keeps only the projects, sorted" {
+ local out
+ out="$(printf 'beta\t@1\nalpha\t@2\n' | _order_windows "$(printf 'alpha\nbeta')" | cut -f1 | paste -sd, -)"
+ [ "$out" = "alpha,beta" ]
+}
+
+@test "_order_windows: empty input yields empty output" {
+ run _order_windows "$(printf 'alpha\nbeta')" <<<""
+ [ -z "$output" ]
+}
+
+@test "_order_windows: a name matches only as a whole line, not as a substring" {
+ # 'alph' must not match project 'alpha' (grep -qxF is a full-line match).
+ local out
+ out="$(printf 'alph\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)"
+ # 'alph' is not a project, so it lands in others, still present.
+ [ "$out" = "alph" ]
+}
+
+@test "_match_window_id: returns the id for a name, empty for a miss" {
+ local listing out
+ listing="$(printf 'one\t@1\ntwo\t@2\n')"
+ out="$(printf '%s\n' "$listing" | _match_window_id two)"
+ [ "$out" = "@2" ]
+ out="$(printf '%s\n' "$listing" | _match_window_id nope)"
+ [ -z "$out" ]
+}
+
+@test "_match_window_id: first match wins and stops" {
+ local out
+ out="$(printf 'dup\t@1\ndup\t@2\n' | _match_window_id dup)"
+ [ "$out" = "@1" ]
+}
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"* ]]
+}
diff --git a/scripts/tests/ai-wrap-teardown-hook.bats b/scripts/tests/ai-wrap-teardown-hook.bats
index 05c49f1..12ac941 100644
--- a/scripts/tests/ai-wrap-teardown-hook.bats
+++ b/scripts/tests/ai-wrap-teardown-hook.bats
@@ -7,10 +7,19 @@
setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
SCRIPT="$REPO_ROOT/hooks/ai-wrap-teardown.sh"
+ DISARM="$REPO_ROOT/hooks/session-start-disarm.sh"
+ GATE="$REPO_ROOT/claude-templates/bin/git-worktree-gate"
TMPDIR_T="$(mktemp -d)"
PROJ="proj-$$-$BATS_TEST_NUMBER" # unique so /tmp sentinels don't collide
CWD="$TMPDIR_T/$PROJ"
mkdir -p "$CWD"
+ git init -q "$CWD"
+ git -C "$CWD" config user.email test@example.com
+ git -C "$CWD" config user.name tester
+ printf 'base\n' >"$CWD/tracked"
+ git -C "$CWD" add tracked
+ git -C "$CWD" commit -qm init
+ bash "$GATE" certify "$CWD"
TEARDOWN_SENTINEL="/tmp/ai-wrap-teardown-${PROJ}"
SHUTDOWN_SENTINEL="/tmp/ai-wrap-shutdown-${PROJ}"
@@ -35,7 +44,7 @@ teardown() {
run_hook() {
# invoke with the stubbed emacsclient on PATH, feeding Stop-hook JSON
printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
- | PATH="$BIN:$PATH" bash "$SCRIPT"
+ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT"
}
@test "no sentinel: silent no-op, emacsclient never called" {
@@ -81,7 +90,7 @@ run_hook() {
: > "$TEARDOWN_SENTINEL"
status=0
output="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
- | PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$?
+ | GIT_WORKTREE_GATE="$GATE" PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$?
[ "$status" -eq 0 ]
[ ! -f "$TEARDOWN_SENTINEL" ]
}
@@ -89,13 +98,114 @@ run_hook() {
@test "falls back to PWD basename when cwd is absent from JSON" {
# No cwd key: hook uses $PWD. Run from CWD so basename resolves to PROJ.
: > "$TEARDOWN_SENTINEL"
- run env "PATH=$BIN:$PATH" bash -c "cd '$CWD' && printf '{}' | bash '$SCRIPT'"
+ run env "GIT_WORKTREE_GATE=$GATE" "PATH=$BIN:$PATH" \
+ bash -c "cd '$CWD' && printf '{}' | bash '$SCRIPT'"
[ "$status" -eq 0 ]
grep -q "cj/ai-term-quit \"$PROJ\"" "$EC_LOG"
}
@test "emits no stderr noise on a normal stop" {
err="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
- | PATH="$BIN:$PATH" bash "$SCRIPT" 2>&1 >/dev/null)"
+ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT" 2>&1 >/dev/null)"
[ -z "$err" ]
}
+
+@test "dirty worktree blocks teardown, preserves sentinel, and names the path" {
+ printf 'late\n' >>"$CWD/tracked"
+ : >"$TEARDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ echo "$output" | jq -e '.decision == "block"'
+ echo "$output" | jq -e '.reason | test("tracked")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+ [ ! -f "$EC_LOG" ]
+}
+
+@test "changed HEAD after certification blocks teardown" {
+ git -C "$CWD" commit -q --allow-empty -m later
+ : >"$TEARDOWN_SENTINEL"
+ run run_hook
+ echo "$output" | jq -e '.reason | test("HEAD changed")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+ [ ! -f "$EC_LOG" ]
+}
+
+@test "missing certificate blocks teardown" {
+ rm -f "$(git -C "$CWD" rev-parse --absolute-git-dir)/ai-wrap-clean"
+ : >"$TEARDOWN_SENTINEL"
+ run run_hook
+ echo "$output" | jq -e '.reason | test("no clean-tree certificate")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "Codex payload receives continue false and a stop reason" {
+ printf 'late\n' >>"$CWD/tracked"
+ : >"$TEARDOWN_SENTINEL"
+ run bash -c \
+ "printf '{\"cwd\":\"$CWD\",\"hook_event_name\":\"Stop\",\"model\":\"gpt-test\"}' \
+ | GIT_WORKTREE_GATE='$GATE' PATH='$BIN:$PATH' bash '$SCRIPT'"
+ [ "$status" -eq 0 ]
+ echo "$output" | jq -e '.continue == false'
+ echo "$output" | jq -e '.stopReason | test("tracked")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "successful teardown consumes the clean-tree certificate" {
+ : >"$TEARDOWN_SENTINEL"
+ cert="$(git -C "$CWD" rev-parse --absolute-git-dir)/ai-wrap-clean"
+ [ -f "$cert" ]
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ ! -f "$cert" ]
+}
+
+# --- Cross-session leakage (the 2026-07-27 work-session kill) ---------------
+#
+# A sentinel is deliberately preserved when certification fails, so a blocked
+# wrap can retry within the same session once the tree is clean. But nothing
+# bounded that to the session: the sentinel outlived it and detonated in
+# whatever session next happened to have a clean tree. work's 11:37 wrap left
+# one armed; the 13:20 session committed during startup, went clean, and was
+# torn down mid-work. archsetup's had been armed for two days on a live
+# terminal.
+#
+# A new session means the wrap that armed the sentinel is gone, so its pending
+# teardown is moot. session-start-disarm.sh clears it.
+
+@test "session-start disarm: removes a teardown sentinel left by a prior session" {
+ : > "$TEARDOWN_SENTINEL"
+ printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" \
+ | bash "$DISARM"
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "session-start disarm: removes a shutdown sentinel too" {
+ : > "$SHUTDOWN_SENTINEL"
+ printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" \
+ | bash "$DISARM"
+ [ ! -f "$SHUTDOWN_SENTINEL" ]
+}
+
+@test "session-start disarm: silent and exit 0 when nothing is armed" {
+ run bash -c "printf '{\"cwd\":\"$CWD\",\"hook_event_name\":\"SessionStart\"}' | bash '$DISARM'"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "session-start disarm: only clears this project, not another's" {
+ other="/tmp/ai-wrap-teardown-someotherproj"
+ : > "$TEARDOWN_SENTINEL"
+ : > "$other"
+ printf '{"cwd":"%s","hook_event_name":"SessionStart"}' "$CWD" | bash "$DISARM"
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+ [ -f "$other" ]
+ rm -f "$other"
+}
+
+@test "within-session retry still works: sentinel survives a blocked stop" {
+ : > "$TEARDOWN_SENTINEL"
+ echo dirt > "$CWD/untracked.txt"
+ run run_hook
+ [ -f "$TEARDOWN_SENTINEL" ]
+ rm -f "$CWD/untracked.txt"
+}
diff --git a/scripts/tests/audit.bats b/scripts/tests/audit.bats
index 0b062fb..c6123e1 100644
--- a/scripts/tests/audit.bats
+++ b/scripts/tests/audit.bats
@@ -40,9 +40,28 @@ git_init_with_ai_tracked() {
local proj_dir="$1"
(cd "$proj_dir" \
&& git init -q \
+ && git config maintenance.auto false \
+ && git config gc.auto 0 \
&& git add -A \
&& git -c user.email=test@test -c user.name=test commit -q -m initial)
}
+# maintenance.auto/gc.auto are off because `git commit` otherwise spawns
+# `git maintenance run --auto --quiet --detach` (traced on git 2.55). The commit
+# returns while that detached process is still writing .git/objects/pack, so
+# teardown's `rm -rf` intermittently failed with "Directory not empty" and bats
+# reported a passing test as failed. Measured at ~8% of runs. Killing the
+# background writer is the fix; a retry loop in teardown would only hide it.
+#
+# What triggers it: the fixture rsyncs ~170 workflow/script files before
+# committing, and maintenance's loose-objects task fires at a default threshold
+# of 100. (Not gc.auto's 6700 — that never fires here, which is why disabling
+# gc.auto alone was never the explanation.) maintenance.auto false is what
+# suppresses it on 2.55; gc.auto 0 is the portable guard for older git, where
+# commit calls gc --auto directly and maintenance.auto does not exist. Either
+# is sufficient on its own here.
+#
+# Other bats suites that git-init fixtures are currently safe only by staying
+# under that 100-object threshold — an implicit dependency, not a guarantee.
@test "audit: clean projects report ok with exit 0" {
scaffold_synced_ai "$TEST_HOME/code/alpha"
diff --git a/scripts/tests/before-close-queue.bats b/scripts/tests/before-close-queue.bats
new file mode 100644
index 0000000..9c968f7
--- /dev/null
+++ b/scripts/tests/before-close-queue.bats
@@ -0,0 +1,44 @@
+#!/usr/bin/env bats
+# The "Colloquialisms and Expansions" convention and its "the list" before-close
+# queue must stay wired into the synced template: the protocols.org reference
+# section, and the wrap-it-up.org Step 1 sub-step that drains the queue before
+# the Summary. Guards against either being dropped in a future edit or sync.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ PROTO="$REPO_ROOT/claude-templates/.ai/protocols.org"
+ WRAP="$REPO_ROOT/claude-templates/.ai/workflows/wrap-it-up.org"
+ PROTO_MIRROR="$REPO_ROOT/.ai/protocols.org"
+ WRAP_MIRROR="$REPO_ROOT/.ai/workflows/wrap-it-up.org"
+}
+
+@test "protocols.org documents the Colloquialisms and Expansions convention" {
+ grep -qF '* Colloquialisms and Expansions' "$PROTO"
+ grep -q 'the list' "$PROTO"
+ grep -q 'Before-Close Queue' "$PROTO"
+ grep -qF 'tell <project>' "$PROTO"
+ grep -q 'inbox-send' "$PROTO"
+}
+
+@test "the colloquialisms reference scopes the queue to the session anchor" {
+ grep -q 'session-context.org' "$PROTO"
+ # A must-outlive item is a todo.org task, not a list item.
+ grep -q 'must outlive the session' "$PROTO"
+}
+
+@test "wrap-it-up Step 1 works the Before-Close Queue before the Summary" {
+ grep -qF 'Work the Before-Close Queue' "$WRAP"
+ grep -q 'oldest-first' "$WRAP"
+ grep -q 'silent no-op' "$WRAP"
+ # The queue must be worked before the Summary is written, so its edits ride
+ # this wrap's commit. Assert it sits ahead of the first Summary sub-step.
+ local queue_line kb_line
+ queue_line=$(grep -n 'Work the Before-Close Queue' "$WRAP" | head -1 | cut -d: -f1)
+ kb_line=$(grep -n 'Early KB reflection' "$WRAP" | head -1 | cut -d: -f1)
+ [ "$queue_line" -lt "$kb_line" ]
+}
+
+@test "the convention is mirrored to the committed .ai copy" {
+ diff -q "$PROTO" "$PROTO_MIRROR"
+ diff -q "$WRAP" "$WRAP_MIRROR"
+}
diff --git a/scripts/tests/git-worktree-gate.bats b/scripts/tests/git-worktree-gate.bats
new file mode 100755
index 0000000..7d0d8a1
--- /dev/null
+++ b/scripts/tests/git-worktree-gate.bats
@@ -0,0 +1,146 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ GATE="$REPO_ROOT/claude-templates/bin/git-worktree-gate"
+ WORK="$(mktemp -d)"
+ REPO="$WORK/repo"
+ git init -q "$REPO"
+ git -C "$REPO" config user.email test@example.com
+ git -C "$REPO" config user.name tester
+ printf 'base\n' >"$REPO/tracked"
+ git -C "$REPO" add tracked
+ git -C "$REPO" commit -qm init
+}
+
+teardown() {
+ rm -rf "$WORK"
+}
+
+@test "strict accepts a completely clean worktree" {
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "strict rejects unstaged, staged, and untracked changes with paths" {
+ printf 'changed\n' >>"$REPO/tracked"
+ printf 'new\n' >"$REPO/staged"
+ git -C "$REPO" add staged
+ printf 'loose\n' >"$REPO/loose"
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"wrap blocked"* ]]
+ [[ "$output" == *"tracked"* ]]
+ [[ "$output" == *"staged"* ]]
+ [[ "$output" == *"loose"* ]]
+}
+
+@test "sync-safe permits untracked inbox deliveries and strict still rejects them" {
+ mkdir -p "$REPO/inbox/nested"
+ printf 'handoff\n' >"$REPO/inbox/nested/from-home.org"
+
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 0 ]
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"inbox/nested/from-home.org"* ]]
+}
+
+@test "sync-safe rejects tracked changes even inside inbox" {
+ mkdir -p "$REPO/inbox"
+ printf 'tracked\n' >"$REPO/inbox/tracked.org"
+ git -C "$REPO" add inbox/tracked.org
+ git -C "$REPO" commit -qm inbox
+ printf 'changed\n' >>"$REPO/inbox/tracked.org"
+
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"inbox/tracked.org"* ]]
+}
+
+@test "sync-safe rejects untracked files outside inbox" {
+ printf 'scratch\n' >"$REPO/scratch"
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"scratch"* ]]
+}
+
+@test "ignored files do not block either policy" {
+ printf 'cache/\n' >"$REPO/.gitignore"
+ git -C "$REPO" add .gitignore
+ git -C "$REPO" commit -qm ignore
+ mkdir -p "$REPO/cache"
+ printf 'generated\n' >"$REPO/cache/output"
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 0 ]
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 0 ]
+}
+
+@test "reports unusual filenames without losing the entry" {
+ odd=$'line break\nname'
+ printf 'odd\n' >"$REPO/$odd"
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"line"* ]]
+ [[ "$output" == *"name"* ]]
+}
+
+@test "certify and verify bind a clean worktree to its current HEAD" {
+ run bash "$GATE" certify "$REPO"
+ [ "$status" -eq 0 ]
+ run bash "$GATE" verify "$REPO"
+ [ "$status" -eq 0 ]
+
+ git -C "$REPO" commit -q --allow-empty -m later
+ run bash "$GATE" verify "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"HEAD changed"* ]]
+}
+
+@test "verify rejects changes made after certification" {
+ run bash "$GATE" certify "$REPO"
+ [ "$status" -eq 0 ]
+ printf 'late\n' >>"$REPO/tracked"
+ run bash "$GATE" verify "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"tracked"* ]]
+}
+
+@test "dirty submodule blocks strict and sync-safe" {
+ CHILD="$WORK/child"
+ git init -q "$CHILD"
+ git -C "$CHILD" config user.email test@example.com
+ git -C "$CHILD" config user.name tester
+ printf 'child\n' >"$CHILD/file"
+ git -C "$CHILD" add file
+ git -C "$CHILD" commit -qm init
+ git -C "$REPO" -c protocol.file.allow=always submodule add -q "$CHILD" sub
+ git -C "$REPO" commit -qam submodule
+ printf 'dirty\n' >>"$REPO/sub/file"
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"sub"* ]]
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 1 ]
+}
+
+@test "a low-level git status failure blocks instead of looking clean" {
+ printf 'not an index\n' >"$WORK/bad-index"
+ run env GIT_INDEX_FILE="$WORK/bad-index" bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"git status failed"* ]]
+}
+
+@test "an in-progress sequencer operation blocks a clean-looking tree" {
+ gitdir="$(git -C "$REPO" rev-parse --absolute-git-dir)"
+ mkdir -p "$gitdir/sequencer"
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"sequencer"* ]]
+}
diff --git a/scripts/tests/inbox-boundary-check-hook.bats b/scripts/tests/inbox-boundary-check-hook.bats
new file mode 100644
index 0000000..58668a5
--- /dev/null
+++ b/scripts/tests/inbox-boundary-check-hook.bats
@@ -0,0 +1,83 @@
+#!/usr/bin/env bats
+# hooks/inbox-boundary-check.sh — Stop hook that soft-nudges the agent to
+# process pending inbox/ handoffs before yielding. Blocks the stop ONCE (emits
+# a block decision + reason) when inbox-status reports pending items; on the
+# harness re-entry (stop_hook_active: true) it steps aside so an unprocessable
+# item or a mid-task pause never wedges. Self-skips where there's no inbox/ or
+# no inbox-status. The real inbox-status is copied into the test project so the
+# hook runs against real code, not a stub.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ SCRIPT="$REPO_ROOT/hooks/inbox-boundary-check.sh"
+ INBOX_STATUS="$REPO_ROOT/claude-templates/.ai/scripts/inbox-status"
+ TMPDIR_T="$(mktemp -d)"
+ CWD="$TMPDIR_T/proj"
+ mkdir -p "$CWD/.ai/scripts"
+ cp "$INBOX_STATUS" "$CWD/.ai/scripts/inbox-status"
+ chmod +x "$CWD/.ai/scripts/inbox-status"
+}
+
+teardown() {
+ rm -rf "$TMPDIR_T"
+}
+
+# Feed Stop-hook JSON on stdin. $1 = stop_hook_active (default false).
+run_hook() {
+ local active="${1:-false}"
+ printf '{"cwd":"%s","hook_event_name":"Stop","stop_hook_active":%s}' \
+ "$CWD" "$active" | bash "$SCRIPT"
+}
+
+@test "pending handoffs block the stop with a count in the reason" {
+ mkdir -p "$CWD/inbox"
+ printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org"
+ printf 'y\n' >"$CWD/inbox/2026-07-19-from-work-other.org"
+ run run_hook
+ [ "$status" -eq 0 ]
+ # Valid JSON with a block decision.
+ echo "$output" | jq -e '.decision == "block"'
+ echo "$output" | jq -e '.reason | test("2 pending")'
+ echo "$output" | jq -e '.reason | test("inbox.org")'
+}
+
+@test "a clean inbox (only artifacts) emits nothing" {
+ mkdir -p "$CWD/inbox"
+ touch "$CWD/inbox/.gitkeep"
+ printf 'done\n' >"$CWD/inbox/PROCESSED-2026-07-19-old.org"
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "soft-nudge: stop_hook_active=true steps aside even with pending items" {
+ mkdir -p "$CWD/inbox"
+ printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org"
+ run run_hook true
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "no inbox/ directory is a silent no-op" {
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "inbox-status absent degrades to a silent no-op" {
+ mkdir -p "$CWD/inbox"
+ printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org"
+ rm -f "$CWD/.ai/scripts/inbox-status"
+ # Also ensure nothing named inbox-status is on PATH for this run.
+ run env PATH="/usr/bin:/bin" bash -c '
+ printf "{\"cwd\":\"'"$CWD"'\",\"hook_event_name\":\"Stop\"}" | bash "'"$SCRIPT"'"'
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "the emitted reason names the project for context" {
+ mkdir -p "$CWD/inbox"
+ printf 'x\n' >"$CWD/inbox/2026-07-19-from-home-thing.org"
+ run run_hook
+ echo "$output" | jq -e '.reason | test("proj")'
+}
diff --git a/scripts/tests/install-ai.bats b/scripts/tests/install-ai.bats
index 9c6040c..1549184 100644
--- a/scripts/tests/install-ai.bats
+++ b/scripts/tests/install-ai.bats
@@ -176,7 +176,7 @@ EOF
# --- claude-templates/bin/install-ai launcher --------------------------------
#
# The launcher is the PATH-facing front door (make install symlinks it into
-# ~/.local/bin/install-ai, same loop as `ai` and `agent-page`). It resolves its
+# ~/.local/bin/install-ai, same loop as `ai` and `agent-text`). It resolves its
# own real path through the symlink and execs scripts/install-ai.sh, so the
# repo-root computation survives being invoked as a symlink from anywhere.
@@ -200,3 +200,39 @@ LAUNCHER="$REAL_REPO/claude-templates/bin/install-ai"
[ "$status" -eq 0 ]
[[ "$output" == *"Bootstrap .ai/"* ]]
}
+
+# --- temp/ ephemeral-artifacts ignore (working/ tracked-from-creation ruling) ---
+
+@test "install-ai --gitignore: ignores temp/ but never working/" {
+ mkdir -p "$TEST_HOME/code/fresh"
+ (cd "$TEST_HOME/code/fresh" && git init -q)
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/fresh"
+
+ [ "$status" -eq 0 ]
+ grep -qFx "temp/" "$TEST_HOME/code/fresh/.gitignore"
+ # working/ is the tracked home of in-progress work — it must never be ignored.
+ ! grep -qEx "/?working/?" "$TEST_HOME/code/fresh/.gitignore"
+}
+
+@test "install-ai --track: ignores temp/ even in track mode" {
+ mkdir -p "$TEST_HOME/code/tracked"
+ (cd "$TEST_HOME/code/tracked" && git init -q)
+
+ run bash "$INSTALL_AI" --track "$TEST_HOME/code/tracked"
+
+ [ "$status" -eq 0 ]
+ # temp/ is ephemeral regardless of whether the project tracks its .ai/ tooling.
+ grep -qFx "temp/" "$TEST_HOME/code/tracked/.gitignore"
+}
+
+@test "install-ai: temp/ ignore is idempotent (not re-added)" {
+ mkdir -p "$TEST_HOME/code/fresh"
+ (cd "$TEST_HOME/code/fresh" && git init -q)
+ printf 'temp/\n' > "$TEST_HOME/code/fresh/.gitignore"
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/fresh"
+
+ [ "$status" -eq 0 ]
+ [ "$(grep -cFx 'temp/' "$TEST_HOME/code/fresh/.gitignore")" -eq 1 ]
+}
diff --git a/scripts/tests/install-hooks-link.bats b/scripts/tests/install-hooks-link.bats
index 80ac8dd..5368781 100644
--- a/scripts/tests/install-hooks-link.bats
+++ b/scripts/tests/install-hooks-link.bats
@@ -20,6 +20,7 @@ run_install() {
RULES_DIR="$TMPHOME/rules" \
HOOKS_DIR="$TMPHOME/hooks" \
CLAUDE_DIR="$TMPHOME/claude" \
+ CODEX_DIR="$TMPHOME/codex" \
LOCAL_BIN="$TMPHOME/bin"
}
@@ -28,6 +29,20 @@ run_install() {
[ "$status" -eq 0 ]
[ -L "$TMPHOME/hooks/session-clear-resume.sh" ]
[ -L "$TMPHOME/hooks/precompact-priorities.sh" ]
+ [ -L "$TMPHOME/hooks/rulesets-write-boundary.py" ]
+}
+
+@test "install links Codex Stop-hook configuration" {
+ run run_install
+ [ "$status" -eq 0 ]
+ [ -L "$TMPHOME/codex/hooks.json" ]
+ grep -q "ai-wrap-teardown.sh" "$TMPHOME/codex/hooks.json"
+}
+
+@test "install links the shared Git worktree gate beside the launcher" {
+ run run_install
+ [ "$status" -eq 0 ]
+ [ -L "$TMPHOME/bin/git-worktree-gate" ]
}
@test "install does not link opt-in hooks" {
diff --git a/scripts/tests/install-lang-collision.bats b/scripts/tests/install-lang-collision.bats
index 36abb5b..e03e136 100644
--- a/scripts/tests/install-lang-collision.bats
+++ b/scripts/tests/install-lang-collision.bats
@@ -5,10 +5,14 @@
# (appended, deduped) and CLAUDE.md is seed-only, so both compose across
# bundles. Three do not:
#
-# claude/settings.json elisp, bash, go — cp -rT, silently overwritten
-# githooks/pre-commit elisp, bash, go — cp -rT, silently overwritten
+# claude/settings.json all 5 bundles — cp -rT, silently overwritten
+# githooks/pre-commit all 5 bundles — cp -rT, silently overwritten
# coverage-makefile.txt 4 bundles — [skip]ped, fragment dropped
#
+# The first two read "elisp, bash, go" until 2026-07-23, when python and
+# typescript gained the components they had been missing. The consequence is
+# that no two shipping bundles compose any more; see the polyglot task.
+#
# Installing a second bundle used to replace the first's settings.json and
# pre-commit while printing [ok], so a project could lose its paren check or
# secret scan and read the output as success. The guard refuses instead, naming
@@ -68,8 +72,11 @@ teardown() {
}
@test "install-lang: bundles colliding only on coverage-makefile.txt are refused too" {
- # python and typescript ship no settings.json or githooks, but both ship a
- # coverage fragment. The second one's used to be silently dropped.
+ # Named for the pre-2026-07-23 reason: python and typescript shipped no
+ # settings.json or githooks, so the coverage fragment was their only overlap
+ # and the second one's used to be silently dropped. They now overlap on all
+ # three, so this exercises the multi-file refusal path — the coverage
+ # fragment must still be named among them.
bash "$INSTALL" python "$PROJ"
run bash "$INSTALL" typescript "$PROJ"
[ "$status" -ne 0 ] || { echo "typescript installed over python's coverage fragment"; return 1; }
@@ -77,17 +84,41 @@ teardown() {
}
@test "install-lang: two bundles that share no overwritten file install together" {
- # bash ships settings.json + githooks and no coverage fragment; python ships
- # only the coverage fragment. Nothing overlaps, so the guard must stay out of
- # the way. This is the path where a false refusal would be easiest to
- # introduce: the bundle IS detected, and only the empty file-list stops it.
+ # The guard must stay out of the way when nothing overlaps. This is the path
+ # where a false refusal would be easiest to introduce: the bundle IS
+ # detected, and only the empty file-list stops it.
+ #
+ # This used to be tested with bash + python, which composed because python
+ # shipped no settings.json and no githooks. That was the incomplete-bundle
+ # bug (fixed 2026-07-23), not a design property — so no pair of *shipping*
+ # bundles is non-colliding any more, and the case needs a synthetic bundle.
+ # See the polyglot task in todo.org: whether every pair now colliding is
+ # acceptable is an open question, but the guard's own no-false-refusal
+ # behavior is not, and stays pinned here.
+ fake="${BATS_TEST_DIRNAME}/../../languages/zz-test-rulesonly"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz-testing.md"
+
bash "$INSTALL" bash "$PROJ"
- run bash "$INSTALL" python "$PROJ"
+ run bash "$INSTALL" zz-test-rulesonly "$PROJ"
+ rm -rf "$fake"
+
[ "$status" -eq 0 ] || { echo "guard falsely refused a non-colliding pair: $output"; return 1; }
- [ -f "$PROJ/coverage-makefile.txt" ] || { echo "python's coverage fragment did not land"; return 1; }
# bash's config survives untouched.
grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json"
- [ -f "$PROJ/.claude/rules/bash.md" ] && [ -f "$PROJ/.claude/rules/python-testing.md" ]
+ [ -f "$PROJ/.claude/rules/bash.md" ] && [ -f "$PROJ/.claude/rules/zz-testing.md" ]
+}
+
+@test "install-lang: completing python made it collide with bash (documents the tradeoff)" {
+ # Pins the consequence of the 2026-07-23 bundle completion so it can't drift
+ # back unnoticed: python now ships settings.json + githooks/pre-commit, so
+ # bash + python is a genuine overwrite conflict and the guard refuses it.
+ # Whether that's the right trade is Craig's open call; that it IS the current
+ # behavior is what this test records.
+ bash "$INSTALL" bash "$PROJ"
+ run bash "$INSTALL" python "$PROJ"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"collision"* ]]
}
# ---- The escape hatch ----
diff --git a/scripts/tests/install-lang-completeness.bats b/scripts/tests/install-lang-completeness.bats
new file mode 100644
index 0000000..42832dc
--- /dev/null
+++ b/scripts/tests/install-lang-completeness.bats
@@ -0,0 +1,93 @@
+#!/usr/bin/env bats
+#
+# Tests for install-lang.sh's bundle-completeness warning.
+#
+# Background: the python and typescript bundles shipped for nearly two months
+# with no githooks/pre-commit, so any project installing them got no
+# credential scan on commit. install-lang guarded each component copy with a
+# plain `[ -d ... ]`, so a missing component was indistinguishable from a
+# complete install — it printed nothing and exited 0.
+#
+# The fix is not "never miss a component" (a person will), it's "say so when
+# you do". These tests pin that: a complete bundle installs quietly, an
+# incomplete one names exactly what's absent, and neither case fails the
+# install — a warning must not become a new way to block work.
+
+REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+INSTALL="$REPO_ROOT/scripts/install-lang.sh"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t install-lang-bats.XXXXXX)"
+ PROJECT="$TEST_DIR/proj"
+ mkdir -p "$PROJECT"
+ git init -q "$PROJECT"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# ---- Normal: a complete bundle is quiet ------------------------------
+
+@test "install-lang: a complete bundle warns about nothing" {
+ run env LANG_ARG=bash bash "$INSTALL" bash "$PROJECT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"incomplete"* ]]
+}
+
+@test "install-lang: python is now a complete bundle" {
+ run bash "$INSTALL" python "$PROJECT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"incomplete"* ]]
+ [ -f "$PROJECT/githooks/pre-commit" ]
+ [ -f "$PROJECT/.claude/settings.json" ]
+ [ -f "$PROJECT/.claude/hooks/validate-python.sh" ]
+}
+
+@test "install-lang: typescript is now a complete bundle" {
+ run bash "$INSTALL" typescript "$PROJECT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"incomplete"* ]]
+ [ -f "$PROJECT/githooks/pre-commit" ]
+ [ -f "$PROJECT/.claude/settings.json" ]
+ [ -f "$PROJECT/.claude/hooks/validate-typescript.sh" ]
+}
+
+@test "install-lang: the installed pre-commit is executable" {
+ run bash "$INSTALL" python "$PROJECT"
+ [ "$status" -eq 0 ]
+ [ -x "$PROJECT/githooks/pre-commit" ]
+}
+
+# ---- Error: an incomplete bundle announces itself --------------------
+
+@test "install-lang: a bundle missing githooks/ warns and names it" {
+ fake="$REPO_ROOT/languages/zz-test-partial"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL" zz-test-partial "$PROJECT"
+ rm -rf "$fake"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"incomplete"* ]]
+ [[ "$output" == *"githooks/pre-commit"* ]]
+}
+
+@test "install-lang: the warning names every missing component, not just the first" {
+ fake="$REPO_ROOT/languages/zz-test-partial"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL" zz-test-partial "$PROJECT"
+ rm -rf "$fake"
+ [[ "$output" == *"githooks/pre-commit"* ]]
+ [[ "$output" == *"settings.json"* ]]
+}
+
+@test "install-lang: an incomplete bundle still installs (warn, never block)" {
+ fake="$REPO_ROOT/languages/zz-test-partial"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL" zz-test-partial "$PROJECT"
+ rm -rf "$fake"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/.claude/rules/zz.md" ]
+}
diff --git a/scripts/tests/install-lang.bats b/scripts/tests/install-lang.bats
index 8518852..c00b915 100644
--- a/scripts/tests/install-lang.bats
+++ b/scripts/tests/install-lang.bats
@@ -79,19 +79,42 @@ teardown() {
grep -qxF "coverage/" "$PROJECT/.gitignore"
}
-@test "install-lang python: seeds the language-neutral default CLAUDE.md when the bundle ships none" {
- run bash "$INSTALL_LANG" python "$PROJECT"
+@test "install-lang: seeds the language-neutral default CLAUDE.md when the bundle ships none" {
+ # Every shipping bundle now carries its own CLAUDE.md (python and typescript
+ # gained theirs 2026-07-23), so the fallback needs a synthetic bundle to
+ # exercise. Keep testing it: the fallback is what stops a bundle added later,
+ # before its CLAUDE.md is written, from inheriting another language's header.
+ fake="$REAL_REPO/languages/zz-test-noclaude"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL_LANG" zz-test-noclaude "$PROJECT"
+ rm -rf "$fake"
[ "$status" -eq 0 ]
[ -f "$PROJECT/CLAUDE.md" ]
- # The default names no language, so it can't mislabel a python (or bash, or
- # multi-bundle) project the way inheriting elisp's "Elisp project" header did.
- ! grep -qi "Python project" "$PROJECT/CLAUDE.md"
+ # The default names no language, so it can't mislabel a project the way
+ # inheriting elisp's "Elisp project" header did.
! grep -qi "Elisp project" "$PROJECT/CLAUDE.md"
grep -qF "names no language" "$PROJECT/CLAUDE.md"
[[ "$output" == *"language-neutral default"* ]]
}
+@test "install-lang python: seeds the bundle's own CLAUDE.md, not the default" {
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qF "Python project." "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"CLAUDE.md installed (python)"* ]]
+}
+
+@test "install-lang typescript: seeds the bundle's own CLAUDE.md, not the default" {
+ run bash "$INSTALL_LANG" typescript "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qF "TypeScript/JavaScript project." "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"CLAUDE.md installed (typescript)"* ]]
+}
+
@test "install-lang elisp: seeds the bundle's own CLAUDE.md, not the default" {
run bash "$INSTALL_LANG" elisp "$PROJECT"
@@ -147,3 +170,32 @@ teardown() {
grep -qxF ".claude/" "$PROJECT/.gitignore"
grep -qxF "cover.out" "$PROJECT/.gitignore"
}
+
+@test "install-lang python: full bundle lands (rules, hook, settings, githook, CLAUDE.md, coverage)" {
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/.claude/rules/python-testing.md" ]
+ # PostToolUse validate hook, executable and wired into settings
+ [ -x "$PROJECT/.claude/hooks/validate-python.sh" ]
+ grep -qF "validate-python.sh" "$PROJECT/.claude/settings.json"
+ # Pre-commit githook — the secret scan. Absent until 2026-07-23.
+ [ -x "$PROJECT/githooks/pre-commit" ]
+ grep -qF "potential secret" "$PROJECT/githooks/pre-commit"
+ # Coverage slice
+ [ -f "$PROJECT/.claude/scripts/coverage-summary.py" ]
+ grep -qxF ".claude/" "$PROJECT/.gitignore"
+}
+
+@test "install-lang typescript: full bundle lands (rules, hook, settings, githook, CLAUDE.md, coverage)" {
+ run bash "$INSTALL_LANG" typescript "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/.claude/rules/typescript-testing.md" ]
+ [ -x "$PROJECT/.claude/hooks/validate-typescript.sh" ]
+ grep -qF "validate-typescript.sh" "$PROJECT/.claude/settings.json"
+ [ -x "$PROJECT/githooks/pre-commit" ]
+ grep -qF "potential secret" "$PROJECT/githooks/pre-commit"
+ [ -f "$PROJECT/.claude/scripts/coverage-summary.js" ]
+ grep -qxF ".claude/" "$PROJECT/.gitignore"
+}
diff --git a/scripts/tests/lint-coverage.bats b/scripts/tests/lint-coverage.bats
new file mode 100644
index 0000000..130212d
--- /dev/null
+++ b/scripts/tests/lint-coverage.bats
@@ -0,0 +1,54 @@
+#!/usr/bin/env bats
+#
+# Coverage tests for scripts/lint.sh.
+#
+# lint.sh sweeps scripts/*.sh, languages/*/claude/hooks/*.sh, and
+# languages/*/githooks/* through check_hook (shebang present, executable bit
+# set). It never touched claude-templates/bin/ — the four scripts `make install`
+# symlinks into ~/.local/bin, so the most exposed shell in the repo was the only
+# shell with no gate over it (found 2026-07-24).
+#
+# These tests pin the coverage itself rather than the current cleanliness: they
+# plant a deliberately broken file in each swept location and assert lint.sh
+# complains. A location that stops being swept fails here.
+
+REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+LINT="$REPO_ROOT/scripts/lint.sh"
+
+teardown() {
+ [ -n "${PLANTED:-}" ] && rm -f "$PLANTED"
+ PLANTED=""
+}
+
+@test "lint: a bin/ script missing its shebang is flagged" {
+ PLANTED="$REPO_ROOT/claude-templates/bin/zz-test-noshebang"
+ printf 'echo hi\n' > "$PLANTED"
+ chmod +x "$PLANTED"
+ run bash "$LINT"
+ [[ "$output" == *"zz-test-noshebang"* ]] || {
+ echo "lint.sh did not report the planted bin/ script — that path is unswept"
+ echo "$output"
+ return 1
+ }
+}
+
+@test "lint: a bin/ script that is not executable is flagged" {
+ PLANTED="$REPO_ROOT/claude-templates/bin/zz-test-noexec"
+ printf '#!/usr/bin/env bash\necho hi\n' > "$PLANTED"
+ chmod -x "$PLANTED"
+ run bash "$LINT"
+ [[ "$output" == *"zz-test-noexec"* ]]
+}
+
+@test "lint: the existing scripts/ sweep still works (guards the regression)" {
+ PLANTED="$REPO_ROOT/scripts/zz-test-noshebang.sh"
+ printf 'echo hi\n' > "$PLANTED"
+ chmod +x "$PLANTED"
+ run bash "$LINT"
+ [[ "$output" == *"zz-test-noshebang.sh"* ]]
+}
+
+@test "lint: the real tree passes (no planted file)" {
+ run bash "$LINT"
+ [ "$status" -eq 0 ]
+}
diff --git a/scripts/tests/pre-commit-secret-scan.bats b/scripts/tests/pre-commit-secret-scan.bats
index 013129e..4647556 100644
--- a/scripts/tests/pre-commit-secret-scan.bats
+++ b/scripts/tests/pre-commit-secret-scan.bats
@@ -14,7 +14,13 @@
# random base64 blob matched. Measured at ~6% of 100KB blobs; case-sensitive
# matching drops it to 0 across ~10MB.
-VARIANTS="elisp bash go"
+# Discovered, never enumerated. This list read "elisp bash go" while python and
+# typescript also shipped pre-commit hooks, so every "in every variant" test
+# below silently skipped two bundles from the day they were added — the same
+# enumerate-instead-of-discover failure these tests exist to catch. A new bundle
+# is now covered the moment it has a hook.
+VARIANTS="$(cd "${BATS_TEST_DIRNAME}/../../languages" && \
+ for d in */githooks/pre-commit; do [ -f "$d" ] && printf '%s ' "${d%%/*}"; done)"
setup() {
REPO="$(mktemp -d)"
@@ -142,3 +148,50 @@ run_hook() {
[ "$status" -eq 0 ] || { echo "$v blocked a removal: $output"; return 1; }
done
}
+
+# ---- Fail-closed: a broken git must never read as "nothing to scan" ----
+
+# Put a stub `git` ahead of the real one that fails only the staged-diff call
+# and delegates everything else, so just the pipeline under test breaks.
+break_git() {
+ mkdir -p "$REPO/bin"
+ cat > "$REPO/bin/git" <<'STUB'
+#!/usr/bin/env bash
+if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ]; then
+ echo "simulated git failure" >&2
+ exit 128
+fi
+exec /usr/bin/git "$@"
+STUB
+ chmod +x "$REPO/bin/git"
+}
+
+@test "secret-scan: a broken git refuses rather than passing blind, in every variant" {
+ # The scan built its input as `git diff ... | grep ... || true`. With no
+ # pipefail, a git failure yielded an empty string, so the scan searched
+ # nothing, found nothing, and reported clean with a real secret staged.
+ # Found by .emacs.d in elisp 2026-07-24; all five variants had it.
+ stage 'aws_key = "AKIAIOSFODNN7EXAMPLE"'
+ break_git
+ for v in $VARIANTS; do
+ run env PATH="$REPO/bin:$PATH" bash \
+ "${BATS_TEST_DIRNAME}/../../languages/$v/githooks/pre-commit"
+ [ "$status" -ne 0 ] || {
+ echo "$v FAILED OPEN: exited 0 with a secret staged and git broken"
+ return 1
+ }
+ done
+}
+
+@test "secret-scan: the refusal says why, in every variant" {
+ stage 'aws_key = "AKIAIOSFODNN7EXAMPLE"'
+ break_git
+ for v in $VARIANTS; do
+ run env PATH="$REPO/bin:$PATH" bash \
+ "${BATS_TEST_DIRNAME}/../../languages/$v/githooks/pre-commit"
+ [[ "$output" == *"cannot read"* ]] || {
+ echo "$v refused without naming the cause: $output"
+ return 1
+ }
+ done
+}
diff --git a/scripts/tests/rename-ai-artifact.bats b/scripts/tests/rename-ai-artifact.bats
index f00c92f..ea7f36e 100644
--- a/scripts/tests/rename-ai-artifact.bats
+++ b/scripts/tests/rename-ai-artifact.bats
@@ -27,7 +27,17 @@ setup() {
printf 'Old session mentioning foo and foo.org — this is history.\n' > "$base/sessions/2026-01-01-old.org"
done
printf 'See foo.org and foo-helper.py. Also foobar.org stays.\n' > "$REPO/notes.org"
- ( cd "$REPO" && git init -q && git add -A && git -c user.email=t@t -c user.name=t commit -qm init )
+ # Disable background auto-maintenance/gc before any git command can arm it:
+ # otherwise a post-commit `git maintenance run --auto` writes into .git after
+ # the test body and races teardown's `rm -rf`, which then fails intermittently
+ # with ".git: Directory not empty". Diagnose-not-mask: kill the writer, don't
+ # retry the rm.
+ ( cd "$REPO" \
+ && git init -q \
+ && git config gc.auto 0 \
+ && git config maintenance.auto false \
+ && git add -A \
+ && git -c user.email=t@t -c user.name=t commit -qm init )
}
teardown() {
diff --git a/scripts/tests/signal-receive.bats b/scripts/tests/signal-receive.bats
new file mode 100644
index 0000000..8fe4d67
--- /dev/null
+++ b/scripts/tests/signal-receive.bats
@@ -0,0 +1,66 @@
+#!/usr/bin/env bats
+# signal-receive.sh — drains the Signal pager account's inbound queue to keep it
+# warm (the roam-sync-shaped fix for receive-staleness) and to surface Craig's
+# replies. Run on velox by the signal-receive systemd timer. These tests stub
+# signal-cli on PATH to verify command construction without a network or the
+# real account.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ RECV="$REPO_ROOT/scripts/signal-receive.sh"
+ STUBS="$(mktemp -d)"
+ LOG="$STUBS/calls.log"
+ # HAS_ACCOUNT (default 1) controls the listAccounts answer so the local-account
+ # guard can be exercised: "1" lists both test accounts as present, "0" lists
+ # none (so the guard no-ops).
+ cat > "$STUBS/signal-cli" <<EOF
+#!/bin/bash
+if [ "\$1" = "listAccounts" ]; then
+ if [ "\${HAS_ACCOUNT:-1}" = "1" ]; then
+ echo "Number: +15045173983"
+ echo "Number: +19995550000"
+ fi
+ exit 0
+fi
+echo "signal-cli \$*" >> "$LOG"
+exit 0
+EOF
+ chmod +x "$STUBS/signal-cli"
+}
+
+teardown() {
+ rm -rf "$STUBS"
+}
+
+@test "defaults to the pager account, a 10s timeout, and read receipts" {
+ PATH="$STUBS:$PATH" run bash "$RECV"
+ [ "$status" -eq 0 ]
+ grep -q -- "-a +15045173983 receive --timeout 10 --send-read-receipts" "$LOG"
+}
+
+@test "honors an explicit account and timeout" {
+ PATH="$STUBS:$PATH" run bash "$RECV" +19995550000 25
+ [ "$status" -eq 0 ]
+ grep -q -- "-a +19995550000 receive --timeout 25" "$LOG"
+}
+
+@test "no-ops (exit 0) when the account is not registered on this machine" {
+ # Guards the runbook claim that the timer no-ops on a common-package machine
+ # that stows the units but holds no pager account.
+ HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$RECV"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"not registered"* ]]
+ # The receive must not have run.
+ ! grep -q "receive" "$LOG"
+}
+
+@test "no-ops (exit 0) when signal-cli is not on PATH" {
+ # Empty stub dir with no signal-cli; echo/exit/command are bash builtins, so
+ # the script still runs and hits its signal-cli-absent branch, which no-ops.
+ rm -f "$STUBS/signal-cli"
+ # Invoke bash by absolute path so `run` finds it regardless of the empty
+ # PATH the script itself sees.
+ PATH="$STUBS" run "$(command -v bash)" "$RECV"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"signal-cli"* ]]
+}
diff --git a/scripts/tests/sweep-gitignore-tooling.bats b/scripts/tests/sweep-gitignore-tooling.bats
index f18eac5..240c3be 100644
--- a/scripts/tests/sweep-gitignore-tooling.bats
+++ b/scripts/tests/sweep-gitignore-tooling.bats
@@ -190,3 +190,44 @@ make_project() {
[ "$status" -eq 0 ]
[[ "$output" != *"publicly reachable"* ]]
}
+
+# --- temp/ ephemeral-artifacts backfill (mode-independent) ---
+
+@test "sweep: adds temp/ to a gitignore-mode project" {
+ make_project gimode $'.ai/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ grep -qFx "temp/" "$ROOT/gimode/.gitignore"
+}
+
+@test "sweep: adds temp/ to a TRACK-mode project too (temp/ is mode-independent)" {
+ make_project trackmode $'# build\nout/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ # The tooling set is skipped in track mode, but temp/ is not.
+ grep -qFx "temp/" "$ROOT/trackmode/.gitignore"
+ ! grep -qFx ".ai/" "$ROOT/trackmode/.gitignore"
+}
+
+@test "sweep: never adds working/" {
+ make_project gimode $'.ai/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ ! grep -qEx "/?working/?" "$ROOT/gimode/.gitignore"
+}
+
+@test "sweep: temp/ backfill is idempotent" {
+ make_project gimode $'.ai/\ntemp/\n'
+ bash "$SWEEP" "$ROOT" >/dev/null
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [ "$(grep -cFx 'temp/' "$ROOT/gimode/.gitignore")" -eq 1 ]
+}
diff --git a/scripts/tests/sync-language-bundle.bats b/scripts/tests/sync-language-bundle.bats
index 1871444..0eb3ae2 100644
--- a/scripts/tests/sync-language-bundle.bats
+++ b/scripts/tests/sync-language-bundle.bats
@@ -19,7 +19,20 @@ teardown() {
rm -rf "$PROJ"
}
-# Mirror install-lang.sh: copy the bundle's files into a synthetic project.
+# Mirror what a CURRENT install-lang.sh leaves: language rules only, no copies
+# of the generic rules (those live once at ~/.claude/rules/).
+install_bundle_current() {
+ install_bundle "$1" "$2"
+ local f
+ for f in "$REAL_REPO/claude-rules"/*.md; do
+ [ -f "$f" ] || continue
+ rm -f "$2/.claude/rules/$(basename "$f")"
+ done
+}
+
+# Mirror what an OLDER install-lang.sh left behind: language rules PLUS copies
+# of every generic rule. This is the state the sweep exists to clean up, and
+# real projects are still in it until their next startup.
install_bundle() {
local lang="$1" proj="$2"
mkdir -p "$proj/.claude/rules"
@@ -67,14 +80,14 @@ install_team_overlay() {
}
@test "sync: clean elisp bundle is a quiet no-op (exit 0)" {
- install_bundle elisp "$PROJ"
+ install_bundle_current elisp "$PROJ"
run bash "$SCRIPT" "$PROJ"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "sync: absent CLAUDE.md is not flagged as drift (seed-only/project-owned)" {
- install_bundle elisp "$PROJ" # helper never seeds CLAUDE.md
+ install_bundle_current elisp "$PROJ" # helper never seeds CLAUDE.md
[ ! -f "$PROJ/CLAUDE.md" ]
run bash "$SCRIPT" "$PROJ"
[ "$status" -eq 0 ]
@@ -94,13 +107,17 @@ install_team_overlay() {
matches_canonical ".claude/rules/elisp.md" "$REAL_REPO/languages/elisp/claude/rules/elisp.md"
}
-@test "sync: drifted generic rule is auto-fixed and restored" {
+# Generic rules are no longer auto-fixed in place: they are swept, because the
+# global copy at ~/.claude/rules/ is the one that loads. A drifted project copy
+# is not repaired, it is removed — which is the stronger fix, since the drifted
+# copy outranked the global rule while it existed.
+@test "sync: a drifted generic rule copy is swept, not repaired" {
install_bundle elisp "$PROJ"
echo "junk" >> "$PROJ/.claude/rules/commits.md"
run bash "$SCRIPT" "$PROJ"
[ "$status" -eq 0 ]
- [[ "$output" == *".claude/rules/commits.md"* ]]
- matches_canonical ".claude/rules/commits.md" "$REAL_REPO/claude-rules/commits.md"
+ [[ "$output" == *"swept"* ]]
+ [ ! -f "$PROJ/.claude/rules/commits.md" ]
}
@test "sync: missing rule is re-copied" {
@@ -276,3 +293,49 @@ install_team_overlay() {
[ ! -f "$PROJ/.claude/rules/commits.md" ]
[ ! -f "$PROJ/.claude/rules/testing.md" ]
}
+
+# --- generic-rule de-duplication -------------------------------------------
+#
+# Generic rules live at ~/.claude/rules/ (symlinked by `make install`) and load
+# in every session. Copying them into each project as well made Claude Code
+# load them twice, and project copies take priority — so a stale project copy
+# silently overrode the fresh global one. The bundle now ships only its own
+# language rules and sweeps the duplicates it previously installed.
+
+@test "sync: sweeps generic rule copies that duplicate the global set" {
+ install_bundle python "$PROJ"
+ [ -f "$PROJ/.claude/rules/commits.md" ]
+ HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules"
+ cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/"
+ run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ ! -f "$PROJ/.claude/rules/commits.md" ]
+ [ ! -f "$PROJ/.claude/rules/todo-format.md" ]
+}
+
+@test "sync: keeps the language bundle's own rules while sweeping generics" {
+ install_bundle python "$PROJ"
+ HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules"
+ cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/"
+ run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/rules/python-testing.md" ]
+}
+
+@test "sync: keeps a project-owned overlay rule the bundle does not own" {
+ install_bundle python "$PROJ"
+ printf '# Publishing\n\nApplies to: `**/*`\n' > "$PROJ/.claude/rules/publishing.md"
+ HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules"
+ cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/"
+ run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/rules/publishing.md" ]
+}
+
+@test "sync: does NOT sweep when the global rule is absent (nothing takes over)" {
+ install_bundle python "$PROJ"
+ HOME_RULES="$(mktemp -d)" # no ~/.claude/rules/ at all
+ run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/rules/commits.md" ]
+}