aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-10 01:14:46 -0500
committerCraig Jennings <c@cjennings.net>2026-06-10 01:14:46 -0500
commitcc72aa635f733da36010567c8718b1ede7622c52 (patch)
tree10d6d6bf0c86fd284349e1c7dac83cb6cbc07c70
parentc401d6d3807a2ffb76a9ec4af8dc783b2c918bfd (diff)
downloadrulesets-cc72aa635f733da36010567c8718b1ede7622c52.tar.gz
rulesets-cc72aa635f733da36010567c8718b1ede7622c52.zip
feat(install-ai): gitignore the full personal-tooling set, add backfill sweep
A gitignore-mode project only ignored .ai/. CLAUDE.md was left untracked but not ignored, so an accidental git add or a codify run could still commit a personal CLAUDE.md, the private rule copies under .claude/, or an AGENTS.md. install-ai now ignores the whole set (.ai/, .claude/, CLAUDE.md, AGENTS.md) at bootstrap, line-idempotent so an existing .gitignore isn't duplicated. .claude/ goes in the set because it's rulesets-owned (copies of claude-rules/*.md plus the language bundle's rules, hooks, and settings), re-synced from rulesets every startup, so git isn't how it travels. Ignoring it also keeps those private rule copies out of the repo, which ignoring CLAUDE.md alone would miss. The gate is unchanged: track-mode projects (personal/doc repos, team repos sharing config) keep tracking the set. sweep-gitignore-tooling.sh backfills the set across existing gitignore-mode projects, idempotent and skipping track-mode by design. It warns when a now-ignored path is already tracked, since the ignore won't untrack it. protocols.org states the policy once.
-rw-r--r--.ai/protocols.org2
-rw-r--r--claude-templates/.ai/protocols.org2
-rwxr-xr-xscripts/install-ai.sh29
-rwxr-xr-xscripts/sweep-gitignore-tooling.sh118
-rw-r--r--scripts/tests/install-ai.bats21
-rw-r--r--scripts/tests/sweep-gitignore-tooling.bats111
6 files changed, 275 insertions, 8 deletions
diff --git a/.ai/protocols.org b/.ai/protocols.org
index cf1b7f6..15d1476 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -517,6 +517,8 @@ Claude needs to add information to =.ai/notes.org=. For large amounts of informa
- Personal/documentation projects usually commit =.ai/= — the project history IS the project
- =docs/= (when it exists) is typically tracked either way — it's real documentation
+**The gitignore set follows that same decision.** A project that gitignores =.ai/= (the code-project case) gitignores the whole personal-tooling set: =.ai/=, =.claude/=, =CLAUDE.md=, =AGENTS.md=. =.claude/= is rulesets-owned — copies of =claude-rules/*.md= plus the language bundle's rules, hooks, and settings — and re-synced from rulesets on every startup, so git isn't how it travels between machines; ignoring it also keeps those private rule copies out of the repo, which ignoring =CLAUDE.md= alone would miss. A track-mode project (personal/doc repos, or a team repo that shares config with teammates who don't run rulesets) tracks the set instead. =install-ai.sh= writes the full set at bootstrap in gitignore mode; =scripts/sweep-gitignore-tooling.sh= backfills it idempotently across existing gitignore-mode projects when the set grows.
+
**Credential-leak concern: gate it on project type, not on the credential itself.** A tracked secret, token, or credentials doc is only a public-leak risk where the repo can reach a public remote — that is, *code projects pushed to public GitHub*, which is exactly why those gitignore =.ai/= and =.claude/=. For *personal / documentation projects* (the =~/projects/= set: elibrary, home, finances, health, philosophy, etc.), the git remote is a private single-user repo on =cjennings.net=, so tracked credentials inside =.ai/= files are fine — that's the design, the project history IS the project. Do NOT raise a leak warning or suggest gitignoring a secret for these. When the question "is this a leak / should we gitignore this secret?" comes up, decide it on *which kind of project and remote* this is, never on the mere presence of a credential in a tracked file.
**When to break out documents:**
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index cf1b7f6..15d1476 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -517,6 +517,8 @@ Claude needs to add information to =.ai/notes.org=. For large amounts of informa
- Personal/documentation projects usually commit =.ai/= — the project history IS the project
- =docs/= (when it exists) is typically tracked either way — it's real documentation
+**The gitignore set follows that same decision.** A project that gitignores =.ai/= (the code-project case) gitignores the whole personal-tooling set: =.ai/=, =.claude/=, =CLAUDE.md=, =AGENTS.md=. =.claude/= is rulesets-owned — copies of =claude-rules/*.md= plus the language bundle's rules, hooks, and settings — and re-synced from rulesets on every startup, so git isn't how it travels between machines; ignoring it also keeps those private rule copies out of the repo, which ignoring =CLAUDE.md= alone would miss. A track-mode project (personal/doc repos, or a team repo that shares config with teammates who don't run rulesets) tracks the set instead. =install-ai.sh= writes the full set at bootstrap in gitignore mode; =scripts/sweep-gitignore-tooling.sh= backfills it idempotently across existing gitignore-mode projects when the set grows.
+
**Credential-leak concern: gate it on project type, not on the credential itself.** A tracked secret, token, or credentials doc is only a public-leak risk where the repo can reach a public remote — that is, *code projects pushed to public GitHub*, which is exactly why those gitignore =.ai/= and =.claude/=. For *personal / documentation projects* (the =~/projects/= set: elibrary, home, finances, health, philosophy, etc.), the git remote is a private single-user repo on =cjennings.net=, so tracked credentials inside =.ai/= files are fine — that's the design, the project history IS the project. Do NOT raise a leak warning or suggest gitignoring a secret for these. When the question "is this a leak / should we gitignore this secret?" comes up, decide it on *which kind of project and remote* this is, never on the mere presence of a credential in a tracked file.
**When to break out documents:**
diff --git a/scripts/install-ai.sh b/scripts/install-ai.sh
index a5b38a8..7007eed 100755
--- a/scripts/install-ai.sh
+++ b/scripts/install-ai.sh
@@ -30,8 +30,11 @@ Bootstrap .ai/ in PROJECT_PATH from canonical content at $CANONICAL.
--track Track .ai/ in the project's git history (with .gitkeep
files inside otherwise-empty sessions/, references/,
retrospectives/).
- --gitignore Append .ai/ to the project's .gitignore so session
- records stay local.
+ --gitignore Gitignore the project's personal tooling (.ai/, .claude/,
+ CLAUDE.md, AGENTS.md) so it stays local. .claude/ is
+ rulesets-owned and re-synced each startup, so git isn't how
+ it travels; ignoring it also keeps private rule copies out
+ of the repo.
If neither flag is given, the script prompts interactively.
If PROJECT_PATH is omitted, fzf-picks from ~/code/* + ~/projects/*
@@ -147,14 +150,24 @@ case "$track_mode" in
touch "$project/.ai/retrospectives/.gitkeep"
;;
gitignore)
- if [ -f "$project/.gitignore" ] && grep -qFx '.ai/' "$project/.gitignore"; then
- : # already present
- else
+ # Personal-tooling files that stay out of git in gitignore mode. .ai/
+ # holds session records; .claude/ is rulesets-owned bundle content
+ # (re-synced each startup, and it carries private rule copies); CLAUDE.md
+ # and AGENTS.md are personal tooling, project-owned but never committed.
+ # Append only the lines not already present, so a project that already
+ # ignores some of them isn't duplicated.
+ gi="$project/.gitignore"
+ needed=()
+ for pat in '.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md'; do
+ if [ -f "$gi" ] && grep -qFx "$pat" "$gi"; then continue; fi
+ needed+=("$pat")
+ done
+ if [ "${#needed[@]}" -gt 0 ]; then
{
- [ -s "$project/.gitignore" ] && echo ""
+ [ -s "$gi" ] && echo ""
echo "# Claude Code per-project tooling"
- echo ".ai/"
- } >> "$project/.gitignore"
+ printf '%s\n' "${needed[@]}"
+ } >> "$gi"
fi
;;
esac
diff --git a/scripts/sweep-gitignore-tooling.sh b/scripts/sweep-gitignore-tooling.sh
new file mode 100755
index 0000000..63fc066
--- /dev/null
+++ b/scripts/sweep-gitignore-tooling.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# sweep-gitignore-tooling.sh — backfill the personal-tooling gitignore set
+# across existing gitignore-mode AI projects.
+#
+# install-ai.sh sets up the ignore set at bootstrap, but a project bootstrapped
+# before the set grew (or one that only ever ignored .ai/) is left exposed — an
+# accidental `git add` or a /codify run can commit a personal CLAUDE.md, the
+# private rule copies under .claude/, or an AGENTS.md. This sweep backfills the
+# full set into every gitignore-mode project that's behind.
+#
+# 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.
+#
+# 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.
+#
+# 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
+# the path can be `git rm --cached`-ed by hand.
+#
+# Usage: sweep-gitignore-tooling.sh [--dry-run] [SEARCH_ROOT ...]
+# --dry-run Report what would change without writing.
+# SEARCH_ROOT ... Directories to scan (default: ~/code ~/projects ~/.emacs.d).
+
+set -euo pipefail
+
+IGNORE_SET=('.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md')
+
+dry_run=0
+roots=()
+for arg in "$@"; do
+ case "$arg" in
+ --dry-run) dry_run=1 ;;
+ -h|--help)
+ sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ -*)
+ echo "ERROR: unknown flag: $arg (use --help for usage)" >&2
+ exit 2
+ ;;
+ *) roots+=("$arg") ;;
+ esac
+done
+
+if [ "${#roots[@]}" -eq 0 ]; then
+ roots=("$HOME/code" "$HOME/projects" "$HOME/.emacs.d")
+fi
+
+# Discover AI projects: a directory holding .ai/protocols.org.
+mapfile -t projects < <(
+ for root in "${roots[@]}"; do
+ [ -d "$root" ] || continue
+ find "$root" -maxdepth 3 -type f -path '*/.ai/protocols.org' 2>/dev/null \
+ | sed 's|/\.ai/protocols\.org$||'
+ done | sort -u
+)
+
+swept=0 skipped=0 complete=0
+
+for project in "${projects[@]}"; do
+ name="$(basename "$project")"
+ gi="$project/.gitignore"
+
+ if [ ! -d "$project/.git" ]; then
+ echo "skip $name — not a git checkout"
+ skipped=$((skipped + 1))
+ 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)"
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ needed=()
+ for pat in "${IGNORE_SET[@]}"; do
+ grep -qFx "$pat" "$gi" || needed+=("$pat")
+ done
+
+ if [ "${#needed[@]}" -eq 0 ]; then
+ echo "ok $name — already complete"
+ complete=$((complete + 1))
+ continue
+ fi
+
+ if [ "$dry_run" -eq 1 ]; then
+ echo "DRY $name — would add: ${needed[*]}"
+ else
+ {
+ echo ""
+ echo "# Claude Code per-project tooling (swept $(date +%Y-%m-%d))"
+ printf '%s\n' "${needed[@]}"
+ } >> "$gi"
+ echo "swept $name — added: ${needed[*]}"
+ fi
+ swept=$((swept + 1))
+
+ # Warn on any newly-ignored path that's already tracked — the ignore won't
+ # untrack it.
+ for pat in "${needed[@]}"; do
+ path="${pat%/}"
+ 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
+
+echo
+echo "Summary: $swept swept, $complete already complete, $skipped skipped (of ${#projects[@]} projects)."
+[ "$dry_run" -eq 1 ] && echo "(dry-run — no files written)"
+exit 0
diff --git a/scripts/tests/install-ai.bats b/scripts/tests/install-ai.bats
index dca70ea..8e91770 100644
--- a/scripts/tests/install-ai.bats
+++ b/scripts/tests/install-ai.bats
@@ -36,12 +36,33 @@ teardown() {
[ -d "$TEST_HOME/code/fresh/.ai/retrospectives" ]
[ -f "$TEST_HOME/code/fresh/.ai/protocols.org" ]
[ -f "$TEST_HOME/code/fresh/.ai/notes.org" ]
+ # Gitignore mode ignores the whole personal-tooling set, not just .ai/.
grep -qFx ".ai/" "$TEST_HOME/code/fresh/.gitignore"
+ grep -qFx ".claude/" "$TEST_HOME/code/fresh/.gitignore"
+ grep -qFx "CLAUDE.md" "$TEST_HOME/code/fresh/.gitignore"
+ grep -qFx "AGENTS.md" "$TEST_HOME/code/fresh/.gitignore"
# Top-level inbox/ is created so the project is an inbox-send target.
[ -d "$TEST_HOME/code/fresh/inbox" ]
[ -f "$TEST_HOME/code/fresh/inbox/.gitkeep" ]
}
+@test "install-ai --gitignore: appends only the missing tooling lines" {
+ mkdir -p "$TEST_HOME/code/partial"
+ (cd "$TEST_HOME/code/partial" && git init -q)
+ # Project already ignores .ai/ and CLAUDE.md from a prior convention.
+ printf '# Personal tooling\n.ai/\nCLAUDE.md\n' > "$TEST_HOME/code/partial/.gitignore"
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/partial"
+
+ [ "$status" -eq 0 ]
+ # The two already-present lines are not duplicated.
+ [ "$(grep -cFx '.ai/' "$TEST_HOME/code/partial/.gitignore")" -eq 1 ]
+ [ "$(grep -cFx 'CLAUDE.md' "$TEST_HOME/code/partial/.gitignore")" -eq 1 ]
+ # The two missing lines were added.
+ grep -qFx ".claude/" "$TEST_HOME/code/partial/.gitignore"
+ grep -qFx "AGENTS.md" "$TEST_HOME/code/partial/.gitignore"
+}
+
@test "install-ai: creates top-level inbox/ in --track mode too" {
mkdir -p "$TEST_HOME/code/inbox-track"
(cd "$TEST_HOME/code/inbox-track" && git init -q)
diff --git a/scripts/tests/sweep-gitignore-tooling.bats b/scripts/tests/sweep-gitignore-tooling.bats
new file mode 100644
index 0000000..a28087e
--- /dev/null
+++ b/scripts/tests/sweep-gitignore-tooling.bats
@@ -0,0 +1,111 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/sweep-gitignore-tooling.sh — backfill the personal-tooling
+# gitignore set across existing gitignore-mode AI projects.
+#
+# Strategy: scaffold fake AI projects (a .ai/protocols.org marker + a git repo)
+# under a temp root, run the sweep against that root, and assert on each
+# project's .gitignore.
+
+REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+SWEEP="$REAL_REPO/scripts/sweep-gitignore-tooling.sh"
+
+setup() {
+ ROOT="$(mktemp -d -t sweep-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$ROOT"
+}
+
+# Scaffold a project: $1 = name, $2 = initial .gitignore content (may be empty),
+# $3 = "git" to init a repo (default), "nogit" to skip.
+make_project() {
+ local name="$1" gitignore="${2-}" git="${3-git}"
+ mkdir -p "$ROOT/$name/.ai"
+ echo "marker" > "$ROOT/$name/.ai/protocols.org"
+ [ -n "$gitignore" ] && printf '%s' "$gitignore" > "$ROOT/$name/.gitignore"
+ [ "$git" = "git" ] && (cd "$ROOT/$name" && git init -q)
+ return 0
+}
+
+@test "sweep: gitignore-mode project missing all three gets them appended" {
+ make_project gimode $'# tooling\n.ai/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ grep -qFx ".claude/" "$ROOT/gimode/.gitignore"
+ grep -qFx "CLAUDE.md" "$ROOT/gimode/.gitignore"
+ grep -qFx "AGENTS.md" "$ROOT/gimode/.gitignore"
+}
+
+@test "sweep: is idempotent — a second run adds nothing" {
+ make_project gimode $'.ai/\n'
+ bash "$SWEEP" "$ROOT" >/dev/null
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already complete"* ]]
+ # No line is duplicated.
+ [ "$(grep -cFx '.claude/' "$ROOT/gimode/.gitignore")" -eq 1 ]
+ [ "$(grep -cFx 'CLAUDE.md' "$ROOT/gimode/.gitignore")" -eq 1 ]
+ [ "$(grep -cFx 'AGENTS.md' "$ROOT/gimode/.gitignore")" -eq 1 ]
+}
+
+@test "sweep: track-mode project (.ai/ not ignored) is skipped untouched" {
+ make_project trackmode $'# build\nout/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"track-mode"* ]]
+ # .gitignore is untouched — none of the tooling lines were added.
+ ! grep -qFx ".claude/" "$ROOT/trackmode/.gitignore"
+ ! grep -qFx "CLAUDE.md" "$ROOT/trackmode/.gitignore"
+}
+
+@test "sweep: partial project gets only the missing lines" {
+ make_project partial $'.ai/\n.claude/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [ "$(grep -cFx '.claude/' "$ROOT/partial/.gitignore")" -eq 1 ]
+ grep -qFx "CLAUDE.md" "$ROOT/partial/.gitignore"
+ grep -qFx "AGENTS.md" "$ROOT/partial/.gitignore"
+}
+
+@test "sweep: --dry-run reports without writing" {
+ make_project gimode $'.ai/\n'
+
+ run bash "$SWEEP" --dry-run "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"would add"* ]]
+ [[ "$output" == *"no files written"* ]]
+ # Nothing was actually written.
+ ! grep -qFx ".claude/" "$ROOT/gimode/.gitignore"
+}
+
+@test "sweep: warns when a now-ignored path is already tracked" {
+ make_project tracked $'.ai/\n'
+ echo "# project rules" > "$ROOT/tracked/CLAUDE.md"
+ (cd "$ROOT/tracked" && git add CLAUDE.md && git -c user.email=t@t -c user.name=t commit -qm seed)
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"WARN"* ]]
+ [[ "$output" == *"CLAUDE.md is currently tracked"* ]]
+}
+
+@test "sweep: non-git project with .ai/ marker is skipped" {
+ make_project nogit $'.ai/\n' "" nogit
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"not a git checkout"* ]]
+}