diff options
Diffstat (limited to 'scripts')
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" ] +} |
