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