aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/tests')
-rw-r--r--scripts/tests/agent-text.bats78
-rw-r--r--scripts/tests/ai-launcher-characterization.bats365
-rw-r--r--scripts/tests/ai-launcher-runtime.bats97
-rw-r--r--scripts/tests/ai-wrap-teardown-hook.bats211
-rw-r--r--scripts/tests/audit.bats36
-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-agents-entry.bats51
-rw-r--r--scripts/tests/install-ai.bats87
-rw-r--r--scripts/tests/install-hooks-link.bats15
-rw-r--r--scripts/tests/install-lang-collision.bats143
-rw-r--r--scripts/tests/install-lang-completeness.bats93
-rw-r--r--scripts/tests/install-lang.bats99
-rw-r--r--scripts/tests/lint-coverage.bats54
-rw-r--r--scripts/tests/pre-commit-secret-scan.bats197
-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.bats122
-rw-r--r--scripts/tests/sync-language-bundle.bats75
20 files changed, 2067 insertions, 7 deletions
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-runtime.bats b/scripts/tests/ai-launcher-runtime.bats
new file mode 100644
index 0000000..3f0ad69
--- /dev/null
+++ b/scripts/tests/ai-launcher-runtime.bats
@@ -0,0 +1,97 @@
+#!/usr/bin/env bats
+# The ai launcher's --runtime flag: pick the agent CLI (claude, codex) that
+# a project window launches. Part of the generic-agent-runtime arc — the
+# tmux-side counterpart of .emacs.d's ai-term multi-LLM handoff. The
+# --print-launch mode exists for exactly these tests: it prints the launch
+# command a real run would send to the pane, without touching tmux or fzf.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ AI="$REPO_ROOT/claude-templates/bin/ai"
+ PROJ="$(mktemp -d)"
+ mkdir -p "$PROJ/.ai"
+ touch "$PROJ/.ai/protocols.org"
+ STUB_BIN="$(mktemp -d)"
+}
+
+teardown() {
+ rm -rf "$PROJ" "$STUB_BIN"
+}
+
+@test "default runtime launches claude" {
+ run bash "$AI" --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == claude\ * ]]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "--runtime codex launches codex with the same opening line" {
+ run bash "$AI" --runtime codex --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == codex\ * ]]
+ [[ "$output" == *"protocols.org"* ]]
+ [[ "$output" == *"$(basename "$PROJ")"* ]]
+}
+
+@test "AI_RUNTIME env selects the runtime without the flag" {
+ AI_RUNTIME=codex run bash "$AI" --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == codex\ * ]]
+}
+
+@test "--runtime local launches codex --oss over ollama with the default local model" {
+ run bash "$AI" --runtime local --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "codex --oss --local-provider=ollama -m gpt-oss:120b "* ]]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "AI_LOCAL_MODEL overrides the local runtime's model" {
+ AI_LOCAL_MODEL=qwen3-coder:30b run bash "$AI" --runtime local --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "codex --oss --local-provider=ollama -m qwen3-coder:30b "* ]]
+}
+
+@test "an unknown runtime errors and names the valid ones" {
+ run bash "$AI" --runtime frobnitz --print-launch "$PROJ"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"claude"* ]]
+ [[ "$output" == *"codex"* ]]
+}
+
+@test "--print-launch refuses a non-project directory" {
+ run bash "$AI" --print-launch "$BATS_TEST_TMPDIR"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "--print-runtimes lists claude, codex, and one line per ollama model" {
+ cat > "$STUB_BIN/ollama" <<'STUB'
+#!/bin/bash
+if [ "$1" = "list" ]; then
+ printf 'NAME ID SIZE MODIFIED\n'
+ printf 'gpt-oss:120b aaa 65 GB 1 hour ago\n'
+ printf 'qwen3-coder:30b bbb 18 GB 1 hour ago\n'
+fi
+STUB
+ chmod +x "$STUB_BIN/ollama"
+ PATH="$STUB_BIN:$PATH" run bash "$AI" --print-runtimes
+ [ "$status" -eq 0 ]
+ [ "${lines[0]%% *}" = "claude" ]
+ [[ "$output" == *"codex"* ]]
+ [[ "$output" == *"local:gpt-oss:120b"* ]]
+ [[ "$output" == *"local:qwen3-coder:30b"* ]]
+}
+
+@test "a dead ollama server just drops the local lines" {
+ cat > "$STUB_BIN/ollama" <<'STUB'
+#!/bin/bash
+echo "Error: could not connect to a running Ollama instance" >&2
+exit 1
+STUB
+ chmod +x "$STUB_BIN/ollama"
+ PATH="$STUB_BIN:$PATH" run bash "$AI" --print-runtimes
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"claude"* ]]
+ [[ "$output" != *"local:"* ]]
+}
diff --git a/scripts/tests/ai-wrap-teardown-hook.bats b/scripts/tests/ai-wrap-teardown-hook.bats
new file mode 100644
index 0000000..12ac941
--- /dev/null
+++ b/scripts/tests/ai-wrap-teardown-hook.bats
@@ -0,0 +1,211 @@
+#!/usr/bin/env bats
+# hooks/ai-wrap-teardown.sh — Stop hook that tears down the ai-term session
+# (or powers off) after a wrap-up, gated on a sentinel wrap-it-up drops. On a
+# normal stop (no sentinel) it is a silent no-op. The emacsclient call is
+# stubbed here so the test records the elisp form without a live daemon.
+
+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}"
+
+ # Stub emacsclient on PATH: record the elisp form it was called with.
+ BIN="$TMPDIR_T/bin"
+ mkdir -p "$BIN"
+ EC_LOG="$TMPDIR_T/emacsclient.log"
+ cat >"$BIN/emacsclient" <<EOF
+#!/usr/bin/env bash
+# args: -e <form>
+shift # drop -e
+printf '%s\n' "\$1" >> "$EC_LOG"
+EOF
+ chmod +x "$BIN/emacsclient"
+}
+
+teardown() {
+ rm -rf "$TMPDIR_T"
+ rm -f "$TEARDOWN_SENTINEL" "$SHUTDOWN_SENTINEL"
+}
+
+run_hook() {
+ # invoke with the stubbed emacsclient on PATH, feeding Stop-hook JSON
+ printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
+ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT"
+}
+
+@test "no sentinel: silent no-op, emacsclient never called" {
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [ ! -f "$EC_LOG" ]
+}
+
+@test "teardown sentinel: calls cj/ai-term-quit with the project basename" {
+ : > "$TEARDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-quit \"$PROJ\"" "$EC_LOG"
+}
+
+@test "teardown sentinel is removed after firing" {
+ : > "$TEARDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "shutdown sentinel: calls cj/ai-term-shutdown-countdown" {
+ : > "$SHUTDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-shutdown-countdown" "$EC_LOG"
+}
+
+@test "shutdown supersedes teardown when both sentinels exist" {
+ : > "$TEARDOWN_SENTINEL"
+ : > "$SHUTDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-shutdown-countdown" "$EC_LOG"
+ ! grep -q "cj/ai-term-quit" "$EC_LOG"
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+ [ ! -f "$SHUTDOWN_SENTINEL" ]
+}
+
+@test "emacsclient absent: clears the sentinel and exits 0 (graceful)" {
+ : > "$TEARDOWN_SENTINEL"
+ status=0
+ output="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
+ | GIT_WORKTREE_GATE="$GATE" PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$?
+ [ "$status" -eq 0 ]
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+}
+
+@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 "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" \
+ | 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 3df69c9..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"
@@ -113,6 +132,23 @@ git_init_with_ai_tracked() {
! grep -q "# uncommitted" "$TEST_HOME/code/alpha/.ai/protocols.org"
}
+@test "audit: retired projects are excluded entirely" {
+ # ~/projects/.retired/<name> holds shelved projects. The audit must
+ # never sync into them — they're not live targets. A drifted retired
+ # project must not appear in the output or the counts.
+ scaffold_synced_ai "$TEST_HOME/projects/.retired/oldproj"
+ echo "# drift marker" >> "$TEST_HOME/projects/.retired/oldproj/.ai/protocols.org"
+ scaffold_synced_ai "$TEST_HOME/code/alpha"
+
+ run bash "$AUDIT" --no-doctor
+
+ # The retired project is invisible to the audit, in any state.
+ [[ "$output" != *".retired/oldproj"* ]]
+ # The live project is still audited and clean.
+ [[ "$output" == *"ok ~/code/alpha"* ]]
+ [[ "$output" == *"Summary: 1 ok, 0 drift, 0 skipped, 0 failed"* ]]
+}
+
@test "audit: loop continues past .ai/-missing failure" {
# Edge case 2 from todo.org:1766. The defensive [ ! -d "$proj/.ai" ]
# branch fires when a discovered .ai/ disappears between find and
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-agents-entry.bats b/scripts/tests/install-agents-entry.bats
new file mode 100644
index 0000000..03343a6
--- /dev/null
+++ b/scripts/tests/install-agents-entry.bats
@@ -0,0 +1,51 @@
+#!/usr/bin/env bats
+# make install must link the runtime-neutral agent entry file (AGENTS.md)
+# into CODEX_DIR so Codex-style harnesses bootstrap from the same
+# protocols/rules/skills the Claude side reads. The thin-pointer shape and
+# the decision trail live in docs/design/2026-07-13-runtime-portability-
+# inventories.org and the generic-agent-runtime task.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ TMPHOME="$(mktemp -d)"
+}
+
+teardown() {
+ rm -rf "$TMPHOME"
+}
+
+run_install() {
+ make -C "$REPO_ROOT" install \
+ SKILLS_DIR="$TMPHOME/skills" \
+ RULES_DIR="$TMPHOME/rules" \
+ HOOKS_DIR="$TMPHOME/hooks" \
+ CLAUDE_DIR="$TMPHOME/claude" \
+ CODEX_DIR="$TMPHOME/codex" \
+ LOCAL_BIN="$TMPHOME/bin"
+}
+
+@test "install links AGENTS.md into CODEX_DIR" {
+ run run_install
+ [ "$status" -eq 0 ]
+ [ -L "$TMPHOME/codex/AGENTS.md" ]
+ grep -q "protocols.org" "$TMPHOME/codex/AGENTS.md"
+}
+
+@test "install is idempotent on the agent entry (second run skips)" {
+ run run_install
+ [ "$status" -eq 0 ]
+ run run_install
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"skip AGENTS.md (already linked)"* ]]
+ [ -L "$TMPHOME/codex/AGENTS.md" ]
+}
+
+@test "install warns on a non-symlink AGENTS.md collision and leaves it alone" {
+ mkdir -p "$TMPHOME/codex"
+ echo "hand-written entry" > "$TMPHOME/codex/AGENTS.md"
+ run run_install
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"WARN AGENTS.md exists and is not a symlink"* ]]
+ [ ! -L "$TMPHOME/codex/AGENTS.md" ]
+ grep -q "hand-written entry" "$TMPHOME/codex/AGENTS.md"
+}
diff --git a/scripts/tests/install-ai.bats b/scripts/tests/install-ai.bats
index 8e91770..1549184 100644
--- a/scripts/tests/install-ai.bats
+++ b/scripts/tests/install-ai.bats
@@ -149,3 +149,90 @@ EOF
[ -d "$TEST_HOME/code/pickme/.ai" ]
[ ! -d "$TEST_HOME/code/skipme/.ai" ]
}
+
+@test "install-ai: seeds AGENTS.md at the project root" {
+ 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 ]
+ [ -f "$TEST_HOME/code/fresh/AGENTS.md" ]
+ grep -q "protocols.org" "$TEST_HOME/code/fresh/AGENTS.md"
+}
+
+@test "install-ai: never overwrites an existing AGENTS.md" {
+ mkdir -p "$TEST_HOME/code/fresh"
+ (cd "$TEST_HOME/code/fresh" && git init -q)
+ echo "project-owned entry file" > "$TEST_HOME/code/fresh/AGENTS.md"
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/fresh"
+
+ [ "$status" -eq 0 ]
+ grep -q "project-owned entry file" "$TEST_HOME/code/fresh/AGENTS.md"
+ ! grep -q "protocols.org" "$TEST_HOME/code/fresh/AGENTS.md"
+}
+
+# --- 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-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.
+
+LAUNCHER="$REAL_REPO/claude-templates/bin/install-ai"
+
+@test "install-ai launcher: exists and is executable" {
+ [ -x "$LAUNCHER" ]
+}
+
+@test "install-ai launcher: --help reaches install-ai.sh through the launcher" {
+ run bash "$LAUNCHER" --help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Bootstrap .ai/"* ]]
+}
+
+@test "install-ai launcher: works when invoked as a symlink from another dir" {
+ # Reproduces the ~/.local/bin symlink: a link elsewhere must still resolve
+ # the repo root, not break on dirname of the link path.
+ ln -s "$LAUNCHER" "$TEST_HOME/install-ai"
+ run bash "$TEST_HOME/install-ai" --help
+ [ "$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
new file mode 100644
index 0000000..e03e136
--- /dev/null
+++ b/scripts/tests/install-lang-collision.bats
@@ -0,0 +1,143 @@
+#!/usr/bin/env bats
+# Tests for install-lang's cross-bundle collision guard.
+#
+# Several bundles ship files at the same path. gitignore-add.txt merges
+# (appended, deduped) and CLAUDE.md is seed-only, so both compose across
+# bundles. Three do not:
+#
+# 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
+# what would be replaced. FORCE=1 still overrides.
+
+INSTALL="${BATS_TEST_DIRNAME}/../install-lang.sh"
+
+setup() {
+ PROJ="$(mktemp -d)"
+ git init -q "$PROJ"
+}
+
+teardown() {
+ [ -n "${PROJ:-}" ] && rm -rf "$PROJ"
+}
+
+# ---- Normal: single-bundle installs are unaffected ----
+
+@test "install-lang: a fresh single-bundle install succeeds" {
+ run bash "$INSTALL" elisp "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/settings.json" ]
+ grep -q 'validate-el.sh' "$PROJ/.claude/settings.json"
+}
+
+@test "install-lang: reinstalling the SAME bundle is idempotent, not a collision" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" elisp "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"collision"* ]]
+ grep -q 'validate-el.sh' "$PROJ/.claude/settings.json"
+}
+
+# ---- The guard: a second, different bundle must not silently replace ----
+
+@test "install-lang: a second bundle sharing settings.json and githooks is refused" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ"
+ [ "$status" -ne 0 ] || { echo "second bundle installed without refusal"; return 1; }
+ [[ "$output" == *"elisp"* ]] || { echo "refusal does not name the existing bundle"; return 1; }
+}
+
+@test "install-lang: the refusal names each file that would be replaced" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ"
+ [[ "$output" == *"settings.json"* ]] || { echo "refusal omits settings.json"; return 1; }
+ [[ "$output" == *"pre-commit"* ]] || { echo "refusal omits githooks/pre-commit"; return 1; }
+}
+
+@test "install-lang: a refused install leaves the first bundle intact" {
+ bash "$INSTALL" elisp "$PROJ"
+ bash "$INSTALL" bash "$PROJ" || true
+ grep -q 'validate-el.sh' "$PROJ/.claude/settings.json" \
+ || { echo "elisp settings.json was replaced despite refusal"; return 1; }
+ grep -q 'check-parens' "$PROJ/githooks/pre-commit" \
+ || { echo "elisp pre-commit was replaced despite refusal"; return 1; }
+}
+
+@test "install-lang: bundles colliding only on coverage-makefile.txt are refused too" {
+ # 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; }
+ [[ "$output" == *"coverage-makefile.txt"* ]]
+}
+
+@test "install-lang: two bundles that share no overwritten file install together" {
+ # 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" zz-test-rulesonly "$PROJ"
+ rm -rf "$fake"
+
+ [ "$status" -eq 0 ] || { echo "guard falsely refused a non-colliding pair: $output"; 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/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 ----
+
+@test "install-lang: FORCE=1 overrides the collision guard" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ" 1
+ [ "$status" -eq 0 ] || { echo "FORCE=1 did not override: $output"; return 1; }
+ grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json"
+}
+
+@test "install-lang: the refusal points at FORCE=1 and warns it re-seeds CLAUDE.md" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ"
+ # Assert the refusal fired first: the pre-existing "[skip] CLAUDE.md already
+ # exists (use FORCE=1 to overwrite)" line mentions both strings on its own, so
+ # without this the test passes against an unguarded install.
+ [ "$status" -ne 0 ] || { echo "no refusal fired"; return 1; }
+ refusal="$(printf '%s\n' "$output" | grep -v '^ \[skip\]')"
+ [[ "$refusal" == *"FORCE=1"* ]] || { echo "refusal does not name the override"; return 1; }
+ [[ "$refusal" == *"CLAUDE.md"* ]] || { echo "refusal does not warn about the CLAUDE.md re-seed"; return 1; }
+}
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 ecfbe01..c00b915 100644
--- a/scripts/tests/install-lang.bats
+++ b/scripts/tests/install-lang.bats
@@ -79,6 +79,76 @@ teardown() {
grep -qxF "coverage/" "$PROJECT/.gitignore"
}
+@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 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"
+
+ [ "$status" -eq 0 ]
+ grep -qF "Elisp project." "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"CLAUDE.md installed (elisp)"* ]]
+}
+
+@test "install-lang python: does not overwrite an existing CLAUDE.md without FORCE" {
+ echo "MY OWN CLAUDE" > "$PROJECT/CLAUDE.md"
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qxF "MY OWN CLAUDE" "$PROJECT/CLAUDE.md"
+}
+
+@test "install-lang bash: full bundle lands (rules, hook, settings, githook, CLAUDE.md)" {
+ run bash "$INSTALL_LANG" bash "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ # Language + testing rules — the bundle's sync fingerprint
+ [ -f "$PROJECT/.claude/rules/bash.md" ]
+ [ -f "$PROJECT/.claude/rules/bash-testing.md" ]
+ # PostToolUse validate hook, executable and wired into settings
+ [ -x "$PROJECT/.claude/hooks/validate-bash.sh" ]
+ grep -qF "validate-bash.sh" "$PROJECT/.claude/settings.json"
+ # Pre-commit githook
+ [ -x "$PROJECT/githooks/pre-commit" ]
+ # The bundle ships its own CLAUDE.md, so it wins over the neutral default
+ grep -qF "Bash/shell project" "$PROJECT/CLAUDE.md"
+ # Gitignore footprint
+ grep -qxF ".claude/" "$PROJECT/.gitignore"
+}
+
@test "install-lang go: full bundle lands (rules, hook, settings, githook, CLAUDE.md, coverage)" {
run bash "$INSTALL_LANG" go "$PROJECT"
@@ -100,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
new file mode 100644
index 0000000..4647556
--- /dev/null
+++ b/scripts/tests/pre-commit-secret-scan.bats
@@ -0,0 +1,197 @@
+#!/usr/bin/env bats
+# Tests for the secret-scan block shared by the elisp, bash, and go pre-commit
+# hooks. The block greps added lines in the staged diff for credential
+# patterns; a hit blocks the commit (exit 1), a clean scan falls through to the
+# variant's language check (exit 0).
+#
+# Every case stages a .txt file, so the language checks that follow the scan
+# (check-parens, shellcheck, gofmt) all skip and the scan is what's under test.
+#
+# The two boundary cases exist because of a live false-positive in a downstream
+# project: an embedded PNG sprite data URI blocked a real commit and forced
+# --no-verify. Root cause was `grep -iE` applying case-insensitivity to the
+# fixed-case AWS token AKIA[0-9A-Z]{16}, so any mixed-case 20-char run inside a
+# random base64 blob matched. Measured at ~6% of 100KB blobs; case-sensitive
+# matching drops it to 0 across ~10MB.
+
+# 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)"
+ cd "$REPO" || return 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+}
+
+teardown() {
+ cd /tmp || true
+ [ -n "${REPO:-}" ] && rm -rf "$REPO"
+}
+
+# Stage $2 as the content of file $1 (default staged.txt).
+stage() {
+ local file="${2:-staged.txt}"
+ printf '%s\n' "$1" > "$file"
+ git add "$file"
+}
+
+# Run a variant's hook in the temp repo. $1 = variant name.
+run_hook() {
+ bash "${BATS_TEST_DIRNAME}/../../languages/$1/githooks/pre-commit"
+}
+
+# ---- Normal: real secrets still block, clean content still passes ----
+
+@test "secret-scan: clean content passes in every variant" {
+ stage 'const greeting = "hello world";'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v blocked clean content: $output"; return 1; }
+ done
+}
+
+@test "secret-scan: a real uppercase AWS access key blocks in every variant" {
+ stage 'aws_key = "AKIAIOSFODNN7EXAMPLE"'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v missed an AWS key"; return 1; }
+ [[ "$output" == *"potential secret"* ]]
+ done
+}
+
+@test "secret-scan: a keyword=value credential blocks in every variant" {
+ stage 'api_key: "sk_live_9f3b2a7c1e4d8f0a6b5"'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v missed an api_key assignment"; return 1; }
+ done
+}
+
+@test "secret-scan: a PEM private-key header blocks in every variant" {
+ stage '-----BEGIN RSA PRIVATE KEY-----'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v missed a PEM header"; return 1; }
+ done
+}
+
+# ---- Boundary: the case-sensitivity fix ----
+
+@test "secret-scan: a lowercase akia-like run does not block (AWS keys are uppercase)" {
+ # Under `grep -iE` this matched the AKIA token and blocked a legitimate commit.
+ stage 'const blob = "akiaiosfodnn7examplexyz";'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v false-positived on a lowercase run: $output"; return 1; }
+ done
+}
+
+@test "secret-scan: an embedded base64 data URI carrying a mixed-case akia run does not block" {
+ # The live failure: a sprite blob whose random base64 contained a mixed-case
+ # 20-char run. Case-sensitive matching is what clears it.
+ stage 'const SPRITE = "data:image/png;base64,iVBORw0KGgoAkIaIOSFODNN7ExAMPLEqQmCC";'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v false-positived on a sprite data URI: $output"; return 1; }
+ done
+}
+
+# ---- Boundary: the scan must not go blind on data-URI lines ----
+
+@test "secret-scan: a real credential sharing a line with a base64 data URI still blocks" {
+ # Minified bundles put a whole file on one line, so a data URI and a real key
+ # can share it. Skipping any line containing ';base64,' would hide the key.
+ stage 'const S="data:image/png;base64,iVBORw0KGgoAAAANS";const c={api_key:"sk_live_9f3b2a7c1e4d8f0a6b5"};'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v went blind on a data-URI line and missed the key"; return 1; }
+ done
+}
+
+@test "secret-scan: a line matching both passes is reported once, not twice" {
+ # The scan runs a case-sensitive and a case-insensitive pass. A line carrying
+ # both an AWS key and a keyword=value credential hits both; reporting it twice
+ # reads as two separate leaks.
+ stage 'api_key = "AKIAIOSFODNN7EXAMPLE_padding"'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ]
+ hits="$(printf '%s\n' "$output" | grep -c 'AKIAIOSFODNN7EXAMPLE_padding')"
+ [ "$hits" -eq 1 ] || { echo "$v reported the line $hits times, want 1"; return 1; }
+ done
+}
+
+# ---- Error / edge ----
+
+@test "secret-scan: an empty staged diff passes" {
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v failed on an empty diff: $output"; return 1; }
+ done
+}
+
+@test "secret-scan: a secret only on a removed line does not block" {
+ # The scan reads added lines. Deleting a key should never block the deletion.
+ stage 'aws_key = "AKIAIOSFODNN7EXAMPLE"'
+ git commit -qm "seed" --no-verify
+ printf 'clean\n' > staged.txt
+ git add staged.txt
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$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 a28087e..240c3be 100644
--- a/scripts/tests/sweep-gitignore-tooling.bats
+++ b/scripts/tests/sweep-gitignore-tooling.bats
@@ -109,3 +109,125 @@ make_project() {
[ "$status" -eq 0 ]
[[ "$output" == *"not a git checkout"* ]]
}
+
+@test "sweep: anchored /.ai/ is recognized as gitignore-mode, appends anchored" {
+ make_project anchored $'/.ai/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"anchored — track-mode"* ]]
+ grep -qFx "/.claude/" "$ROOT/anchored/.gitignore"
+ grep -qFx "/CLAUDE.md" "$ROOT/anchored/.gitignore"
+ grep -qFx "/AGENTS.md" "$ROOT/anchored/.gitignore"
+}
+
+@test "sweep: anchored partial project gets only the missing lines" {
+ make_project anchoredpartial $'/.ai/\n/.claude/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ # /.claude/ already present in anchored form — not re-added in either form.
+ [ "$(grep -cFx '/.claude/' "$ROOT/anchoredpartial/.gitignore")" -eq 1 ]
+ ! grep -qFx ".claude/" "$ROOT/anchoredpartial/.gitignore"
+ grep -qFx "/CLAUDE.md" "$ROOT/anchoredpartial/.gitignore"
+ grep -qFx "/AGENTS.md" "$ROOT/anchoredpartial/.gitignore"
+}
+
+@test "sweep: anchored gitignore-mode is idempotent" {
+ make_project anchored2 $'/.ai/\n'
+ bash "$SWEEP" "$ROOT" >/dev/null
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already complete"* ]]
+ [ "$(grep -cFx '/.claude/' "$ROOT/anchored2/.gitignore")" -eq 1 ]
+}
+
+@test "sweep: track-mode with tracked tooling and a non-cjennings.net remote warns" {
+ make_project publictrack $'out/\n'
+ echo "# project rules" > "$ROOT/publictrack/CLAUDE.md"
+ (cd "$ROOT/publictrack" \
+ && git add CLAUDE.md \
+ && git -c user.email=t@t -c user.name=t commit -qm seed \
+ && git remote add origin git@github.com:someone/publictrack.git)
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"WARN"* ]]
+ [[ "$output" == *"publicly reachable"* ]]
+ # Still track-mode: nothing written to its .gitignore.
+ ! grep -qFx ".claude/" "$ROOT/publictrack/.gitignore"
+}
+
+@test "sweep: track-mode with tracked tooling on a cjennings.net remote stays quiet" {
+ make_project privatetrack $'out/\n'
+ echo "# project rules" > "$ROOT/privatetrack/CLAUDE.md"
+ (cd "$ROOT/privatetrack" \
+ && git add CLAUDE.md \
+ && git -c user.email=t@t -c user.name=t commit -qm seed \
+ && git remote add origin git@cjennings.net:privatetrack.git)
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"publicly reachable"* ]]
+}
+
+@test "sweep: the bare cjennings ssh-alias remote counts as private too" {
+ make_project aliastrack $'out/\n'
+ echo "# project rules" > "$ROOT/aliastrack/CLAUDE.md"
+ (cd "$ROOT/aliastrack" \
+ && git add CLAUDE.md \
+ && git -c user.email=t@t -c user.name=t commit -qm seed \
+ && git remote add origin git@cjennings:aliastrack.git)
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$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" ]
+}