aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/audit.sh4
-rwxr-xr-xscripts/install-ai.sh25
-rwxr-xr-xscripts/install-lang.sh129
-rwxr-xr-xscripts/lint.sh47
-rwxr-xr-x[-rw-r--r--]scripts/remove.sh0
-rwxr-xr-xscripts/roam-sync.sh9
-rwxr-xr-xscripts/signal-receive.sh51
-rwxr-xr-xscripts/sweep-gitignore-tooling.sh91
-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-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
31 files changed, 2440 insertions, 33 deletions
diff --git a/scripts/audit.sh b/scripts/audit.sh
index 86eeb76..a7b8089 100755
--- a/scripts/audit.sh
+++ b/scripts/audit.sh
@@ -144,10 +144,14 @@ for proj in "${projects[@]}"; do
# - the rulesets repo itself (canonical .ai/ lives at the repo root, not under a project)
# - the canonical-source subdir (rulesets/claude-templates/.ai/ is the source, not a target)
# - the legacy standalone claude-templates checkout (frozen during fold transition)
+ # - retired projects under any .retired/ ancestor (~/projects/.retired/<name>) —
+ # shelved, never a live sync target; nested one level deeper than live projects,
+ # so the maxdepth-3 find reaches them and they must be excluded explicitly
case "$proj" in
"$REPO") continue ;;
"$REPO/claude-templates") continue ;;
"$HOME/projects/claude-templates") continue ;;
+ */.retired/*) continue ;;
esac
# Display path: strip $HOME prefix to ~/, otherwise leave alone.
diff --git a/scripts/install-ai.sh b/scripts/install-ai.sh
index 7007eed..8c04e22 100755
--- a/scripts/install-ai.sh
+++ b/scripts/install-ai.sh
@@ -141,6 +141,13 @@ today="$(date +%Y-%m-%d)"
sed "s|\[Project Name\]|$project_name|g; s|\[Date\]|$today|g" \
"$CANONICAL/notes.org" > "$project/.ai/notes.org"
+# Seed AGENTS.md — the runtime-neutral agent entry file (thin pointer at
+# protocols.org, rules, and /name resolution) for Codex-style harnesses.
+# Seed-only, like CLAUDE.md: project-owned after bootstrap, never overwritten.
+if [ ! -e "$project/AGENTS.md" ]; then
+ cp "$REPO/claude-templates/AGENTS.md" "$project/AGENTS.md"
+fi
+
# Tracking setup.
case "$track_mode" in
track)
@@ -172,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 0fc9ea8..3aaa76e 100755
--- a/scripts/install-lang.sh
+++ b/scripts/install-lang.sh
@@ -33,14 +33,87 @@ fi
# Resolve to absolute path
PROJECT="$(cd "$PROJECT" && pwd)"
+# 0. Cross-bundle collision guard.
+#
+# Several bundles ship a file at the same path. gitignore-add.txt is appended
+# and deduped, and CLAUDE.md is seed-only, so both compose across bundles. Three
+# do not: claude/settings.json and githooks/* are installed with `cp -rT`
+# (always overwrite), and coverage-makefile.txt is seeded under a fixed name. So
+# installing a second bundle replaced the first's hook wiring and pre-commit
+# while printing [ok], so a project could lose its paren check or secret scan
+# and read the output as success. Refuse instead, naming what would go.
+#
+# Detection is by rule fingerprint, matching sync-language-bundle.sh: a project
+# has bundle X iff one of X's own rule files is in .claude/rules/. No marker
+# file, so this works on installs that predate the guard.
+project_has_bundle() {
+ local b="$1" rf
+ for rf in "$REPO_ROOT/languages/$b/claude/rules"/*.md; do
+ [ -f "$rf" ] || continue
+ [ -f "$PROJECT/.claude/rules/$(basename "$rf")" ] && return 0
+ done
+ return 1
+}
+
+# Files $1's bundle and $LANG both ship, and that install would overwrite.
+shared_overwritten_files() {
+ local other="$1" rel
+ for rel in claude/settings.json coverage-makefile.txt; do
+ [ -f "$SRC/$rel" ] && [ -f "$REPO_ROOT/languages/$other/$rel" ] \
+ && echo " $(printf '%s' "$rel" | sed 's|^claude/|.claude/|')"
+ done
+ if [ -d "$SRC/githooks" ] && [ -d "$REPO_ROOT/languages/$other/githooks" ]; then
+ for rel in "$SRC/githooks"/*; do
+ [ -f "$rel" ] || continue
+ [ -f "$REPO_ROOT/languages/$other/githooks/$(basename "$rel")" ] \
+ && echo " githooks/$(basename "$rel")"
+ done
+ fi
+}
+
+if [ "$FORCE" != "1" ]; then
+ collisions=""
+ for other_dir in "$REPO_ROOT/languages"/*/; do
+ other="$(basename "$other_dir")"
+ [ "$other" = "$LANG" ] && continue
+ [ -d "$other_dir/claude/rules" ] || continue
+ project_has_bundle "$other" || continue
+ files="$(shared_overwritten_files "$other")"
+ [ -n "$files" ] && collisions="${collisions}The '$other' bundle is already installed here. Installing '$LANG' would replace:
+${files}
+"
+ done
+
+ if [ -n "$collisions" ]; then
+ {
+ echo "ERROR: bundle collision. Refusing to install '$LANG' into $PROJECT"
+ echo
+ printf '%s' "$collisions"
+ echo "Both bundles ship these files, and installing overwrites rather than merges,"
+ echo "so the bundle already here would silently lose them."
+ echo
+ echo "Whether a project can carry two bundles at once is an open question."
+ echo "Until it's settled, install one bundle per project."
+ echo
+ echo "To override: re-run with FORCE=1. That also re-seeds CLAUDE.md from the"
+ echo "bundle template, which overwrites any project-specific edits to it."
+ } >&2
+ exit 1
+ fi
+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)
@@ -66,13 +139,26 @@ if [ -d "$SRC/githooks" ]; then
fi
fi
-# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1
+# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1.
+# Prefer the bundle's own template; fall back to the language-neutral
+# default so a bundle that ships none still seeds an accurate, non-
+# mislabeling header instead of nothing. The default names no language,
+# so multi-bundle and wrong-bundle installs don't inherit a false header.
+CLAUDE_SRC=""
+CLAUDE_KIND=""
if [ -f "$SRC/CLAUDE.md" ]; then
+ CLAUDE_SRC="$SRC/CLAUDE.md"
+ CLAUDE_KIND="$LANG"
+elif [ -f "$REPO_ROOT/languages/default-CLAUDE.md" ]; then
+ CLAUDE_SRC="$REPO_ROOT/languages/default-CLAUDE.md"
+ CLAUDE_KIND="language-neutral default"
+fi
+if [ -n "$CLAUDE_SRC" ]; then
if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then
echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)"
else
- cp "$SRC/CLAUDE.md" "$PROJECT/CLAUDE.md"
- echo " [ok] CLAUDE.md installed"
+ cp "$CLAUDE_SRC" "$PROJECT/CLAUDE.md"
+ echo " [ok] CLAUDE.md installed ($CLAUDE_KIND)"
fi
fi
@@ -113,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 ae30aa5..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
@@ -99,6 +133,9 @@ for claude_md in languages/*/CLAUDE.md; do
check_md_heading "$claude_md"
done
+# Language-neutral default CLAUDE.md (install-lang's fallback when a bundle ships none)
+[ -f languages/default-CLAUDE.md ] && check_md_heading languages/default-CLAUDE.md
+
# Hook scripts
for h in languages/*/claude/hooks/*.sh languages/*/githooks/*; do
[ -f "$h" ] || continue
@@ -111,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/remove.sh b/scripts/remove.sh
index 3d8b7e4..3d8b7e4 100644..100755
--- a/scripts/remove.sh
+++ b/scripts/remove.sh
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 63fc066..7194d6c 100755
--- a/scripts/sweep-gitignore-tooling.sh
+++ b/scripts/sweep-gitignore-tooling.sh
@@ -10,13 +10,19 @@
#
# For each AI project (a directory with .ai/protocols.org) under the search
# roots, if it's a git checkout in gitignore mode (.ai/ already appears in its
-# .gitignore), ensure .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored.
-# Append only the missing lines, so a re-run is a no-op.
+# .gitignore, in either the unanchored `.ai/` or anchored `/.ai/` form), ensure
+# .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored. Append only the
+# missing lines, in whichever style the file already uses, so a re-run is a
+# no-op.
#
# Track-mode projects (.ai/ NOT in .gitignore) are skipped by design: they
# track their tooling on purpose — team repos sharing config with teammates who
# don't run rulesets, or private-remote personal repos where the history IS the
-# project.
+# project. But a track-mode project whose tracked tooling is reachable from a
+# non-cjennings.net remote gets a loud WARN: per convention the tooling set is
+# gitignored anywhere the repo can reach a public host, and a server-side
+# mirror hook can publish even a "private" remote (the 2026-06-30 .emacs.d
+# exposure rode exactly that).
#
# A line added here only stops *future* commits. If a target path is already
# tracked, the ignore has no effect until it's untracked; the sweep warns so
@@ -30,6 +36,14 @@ set -euo pipefail
IGNORE_SET=('.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md')
+# A pattern counts as present in either the unanchored (`.ai/`) or anchored
+# (`/.ai/`) form — both ignore the root-level path; treating them as different
+# is what silently skipped anchored-style projects.
+has_ignore() {
+ local pat="$1" gi="$2"
+ grep -qFx "$pat" "$gi" || grep -qFx "/$pat" "$gi"
+}
+
dry_run=0
roots=()
for arg in "$@"; do
@@ -72,16 +86,41 @@ for project in "${projects[@]}"; do
continue
fi
- # Gitignore mode iff .ai/ is already ignored. Otherwise track-mode: leave it.
- if [ ! -f "$gi" ] || ! grep -qFx '.ai/' "$gi"; then
- echo "skip $name — track-mode (.ai/ not gitignored)"
+ # Gitignore mode iff .ai/ is already ignored (either style). Otherwise
+ # track-mode: leave the .gitignore alone, but warn when tracked tooling can
+ # reach a non-cjennings.net remote — a track-mode repo on a public host (or
+ # behind an invisible server-side mirror) is the exposure the convention
+ # exists to prevent.
+ if [ ! -f "$gi" ] || ! has_ignore '.ai/' "$gi"; then
+ tracked_tooling=()
+ for pat in "${IGNORE_SET[@]}"; do
+ path="${pat%/}"
+ if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then
+ tracked_tooling+=("$path")
+ fi
+ done
+ # Private = the cjennings.net server, whether addressed by FQDN or by the
+ # bare `cjennings` ssh-config alias (git@cjennings:repo.git).
+ public_remote="$(git -C "$project" remote -v 2>/dev/null \
+ | awk '{print $2}' | grep -vE '(@|://)cjennings(\.net)?[:/]' | sort -u | head -1 || true)"
+ if [ "${#tracked_tooling[@]}" -gt 0 ] && [ -n "$public_remote" ]; then
+ echo "skip $name — track-mode (.ai/ not gitignored)"
+ echo " WARN $name: tracked tooling (${tracked_tooling[*]}) is publicly reachable via $public_remote — gitignore the set and 'git -C $project rm --cached -r <path>' unless this is a deliberate team-shared config"
+ else
+ echo "skip $name — track-mode (.ai/ not gitignored)"
+ fi
skipped=$((skipped + 1))
continue
fi
+ # Append in the style the file already uses: anchored if its .ai/ marker
+ # line is the anchored form.
+ prefix=""
+ grep -qFx '/.ai/' "$gi" && prefix="/"
+
needed=()
for pat in "${IGNORE_SET[@]}"; do
- grep -qFx "$pat" "$gi" || needed+=("$pat")
+ has_ignore "$pat" "$gi" || needed+=("${prefix}${pat}")
done
if [ "${#needed[@]}" -eq 0 ]; then
@@ -103,16 +142,48 @@ for project in "${projects[@]}"; do
swept=$((swept + 1))
# Warn on any newly-ignored path that's already tracked — the ignore won't
- # untrack it.
+ # untrack it. Strip the anchored prefix before asking git: the pattern
+ # `/CLAUDE.md` is the repo-relative path `CLAUDE.md`.
for pat in "${needed[@]}"; do
- path="${pat%/}"
+ path="${pat#/}"
+ path="${path%/}"
if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then
echo " WARN $name: $path is currently tracked — 'git -C $project rm --cached -r $path' to untrack"
fi
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-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" ]
+}