From 0b17fd13009885cda09e62184d8db9d89d1c1b7a Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 16:46:16 -0400 Subject: [PATCH 01/11] refactor(agents-init): extract discovery prune set into _agents_init_find --- functions/_agents_init_find.fish | 44 ++++++++++++++++++++++++++++++++ functions/agents-init.fish | 19 +++----------- tests/test-agents-init.fish | 15 +++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 functions/_agents_init_find.fish diff --git a/functions/_agents_init_find.fish b/functions/_agents_init_find.fish new file mode 100644 index 0000000..6f889bd --- /dev/null +++ b/functions/_agents_init_find.fish @@ -0,0 +1,44 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_init_find ... +# +# DESCRIPTION +# Runs find over with the prune set agents-init discovery uses, +# then applies to every entry that survives. Pruned: +# dot-directories (.git, .claude, .github -- tool state, not scoped +# project dirs), any AGENTS/ (a mirror, never a source), node_modules, +# generated-output directories (build, dist, out, target -- an +# instruction file there is a build artifact), and nested repositories, +# submodules and worktrees (their own .git marks another project). +# -mindepth 1 keeps itself, which has a .git, from pruning the +# whole walk. +# +# Shared by agents-init (instruction-file discovery) and agents-cleanup +# (symlinks into AGENTS/), so the two never disagree about which part of +# the tree belongs to the project. +# +# ARGUMENTS +# root Absolute path to the project root +# find-expression find primaries applied to every unpruned entry; must +# carry its own action (for example -print) +# +# EXIT STATUS +# 0 find completed +# 1 No root given, or find failed +# +# RETURNS +# Whatever prints. +# +# EXAMPLE +# _agents_init_find /path/to/project \( -name AGENTS.md -o -name CLAUDE.md \) -print +# _agents_init_find /path/to/project -type l -print +function _agents_init_find --argument-names root + test -n "$root"; or return 1 + find "$root" -mindepth 1 \ + -type d \( -name '.*' -o -name AGENTS -o -name node_modules \ + -o -name build -o -name dist -o -name out -o -name target \ + -o -exec test -e '{}/.git' \; \) -prune -o \ + $argv[2..] +end diff --git a/functions/agents-init.fish b/functions/agents-init.fish index bf0dfcb..d8ea2d3 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -5,7 +5,7 @@ # 12-ai-and-developer-tools # # DEPENDENCIES -# _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore +# _agents_init_find, _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore # # CLASSIFICATION # self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling) @@ -238,22 +238,11 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # # Discovery stays inside this project: a non-git root (a lone # agent file in, say, ~) syncs only itself -- walking it would - # reach into every unrelated tree below. In a git root, pruned: - # any AGENTS/ (a mirror, never a source), dot-directories (.git, - # .claude, .github: tool state, not scoped project dirs), - # node_modules, generated-output directories (build, dist, out, - # target: an instruction file there is a build artifact, never a - # source -- pruned outright, before tracked-file protection would - # even be consulted), and nested repos/submodules/worktrees (their - # own .git marks another project). -mindepth 1 keeps the root - # itself, which has a .git, from pruning the whole walk. + # reach into every unrelated tree below. In a git root the walk + # uses the shared prune set; see _agents_init_find. set -l found if test $in_git -eq 1 - set found (find "$root" -mindepth 1 \ - -type d \( -name '.*' -o -name AGENTS -o -name node_modules \ - -o -name build -o -name dist -o -name out -o -name target \ - -o -exec test -e '{}/.git' \; \) -prune -o \ - \( -name AGENTS.md -o -name CLAUDE.md \) -print) + set found (_agents_init_find "$root" \( -name AGENTS.md -o -name CLAUDE.md \) -print) end set -l rels "." for f in $found diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 19ab86a..1b5af41 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -461,5 +461,20 @@ _agents_init_path_is_protected $g1 "$g1/a[1]/AGENTS.md" set -l protected $status check "glob false-match: untracked a[1]/AGENTS.md is not protected" 1 "$protected" +echo "" +echo "== _agents_init_find: shared prune set ==" + +set -l pf (new_repo) +mkdir -p $pf/src $pf/node_modules/pkg $pf/.claude $pf/AGENTS $pf/build $pf/nested +git -C $pf/nested init -q +ln -s ../x $pf/src/link +ln -s x $pf/node_modules/pkg/link +ln -s x $pf/.claude/link +ln -s x $pf/AGENTS/link +ln -s x $pf/build/link +ln -s x $pf/nested/link +check "find: only the unpruned symlink" $pf/src/link (_agents_init_find $pf -type l -print | string join ,) +check "find: no root -> status 1" 1 (_agents_init_find ""; echo $status) + cleanup report -- 2.54.0 From a90b1ad923dd4c7850527ea6c228ef3b0df48ce6 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 16:48:56 -0400 Subject: [PATCH 02/11] refactor(agents-init): extract stub AGENTS.md into _agents_init_stub --- functions/_agents_init_stub.fish | 36 +++++++++++++++++++ functions/_agents_init_sync_instructions.fish | 14 ++------ tests/test-agents-init.fish | 1 + 3 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 functions/_agents_init_stub.fish diff --git a/functions/_agents_init_stub.fish b/functions/_agents_init_stub.fish new file mode 100644 index 0000000..a8c7130 --- /dev/null +++ b/functions/_agents_init_stub.fish @@ -0,0 +1,36 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_init_stub +# +# DESCRIPTION +# Prints the AGENTS.md that agents-init writes for a project with no +# agent instructions of its own: a heading and a directive telling +# agents to edit AGENTS/AGENTS.md rather than the root symlink. +# +# One source for the text: _agents_init_sync_instructions writes it, and +# agents-cleanup compares against it byte for byte to recognize a stub +# that never held user content. +# +# EXIT STATUS +# 0 Always +# +# RETURNS +# The stub, on stdout. +# +# EXAMPLE +# _agents_init_stub >AGENTS/AGENTS.md +function _agents_init_stub + printf '%s\n' \ + '# AGENTS.md' \ + '' \ + '> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' \ + '> You may be reading this file via a symlink (`AGENTS.md`) in' \ + '> the root of the project. Your environment'\''s file-editing tools cannot write' \ + '> through symlinks and will throw an error.' \ + '>' \ + '> **DO NOT** attempt to write to or edit `AGENTS.md` in the' \ + '> project root. If you need to update these instructions, you **MUST write' \ + '> directly to `AGENTS/AGENTS.md`**.' +end diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish index a72926d..92a5e3f 100644 --- a/functions/_agents_init_sync_instructions.fish +++ b/functions/_agents_init_sync_instructions.fish @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # DEPENDENCIES -# _agents_init_path_is_protected +# _agents_init_path_is_protected, _agents_init_stub # # CLASSIFICATION # self-limiting(rm,mkdir), bypasses-shadow(mv) @@ -169,17 +169,7 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel end echo "→ Moved $disp_claude → $mirror_rel/AGENTS.md" else if test "$rel" = "." - printf '%s\n' \ - '# AGENTS.md' \ - '' \ - '> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' \ - '> You may be reading this file via a symlink (`AGENTS.md`) in' \ - '> the root of the project. Your environment'\''s file-editing tools cannot write' \ - '> through symlinks and will throw an error.' \ - '>' \ - '> **DO NOT** attempt to write to or edit `AGENTS.md` in the' \ - '> project root. If you need to update these instructions, you **MUST write' \ - '> directly to `AGENTS/AGENTS.md`**.' >"$mirror_agents" + _agents_init_stub >"$mirror_agents" echo "→ Created AGENTS/AGENTS.md with agent directive" end end diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 1b5af41..18d8081 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -55,6 +55,7 @@ check "fresh root: mirror AGENTS.md created" true (test -f $r1/AGENTS/AGENTS.md; check "fresh root: no mirror CLAUDE.md" false (test -e $r1/AGENTS/CLAUDE.md; and echo true; or echo false) check "fresh root: project AGENTS.md links to mirror" AGENTS/AGENTS.md (readlink $r1/AGENTS.md) check "fresh root: no project CLAUDE.md" false (test -e $r1/CLAUDE.md; and echo true; or echo false) +check "fresh root: mirror AGENTS.md is exactly the shared stub" (_agents_init_stub | string collect) (string collect <$r1/AGENTS/AGENTS.md) echo "" echo "== _agents_init_sync_instructions: idempotent second run ==" -- 2.54.0 From 6ff5546f0949ca9284016128edce10dc75f5e63d Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 16:52:01 -0400 Subject: [PATCH 03/11] feat(agents-init): honor agents-init.disabled / .agents-disabled, add --enable --- functions/agents-init.fish | 49 +++++++++++++++++++++++++++++++++---- tests/test-agents-init.fish | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/functions/agents-init.fish b/functions/agents-init.fish index d8ea2d3..400e5d4 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -11,8 +11,8 @@ # self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling) # # SYNOPSIS -# agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] -# [-q | --quiet] [-s | --silent] [-h | --help] +# agents-init [-a | --agents] [-p | --plugins] [-e | --enable] +# [-v | --verbose] [-q | --quiet] [-s | --silent] [-h | --help] # # DESCRIPTION # Scaffolds an AGENTS/ sub-repository inside a project directory. Creates @@ -35,6 +35,13 @@ # no-op, so running an agent CLI in an arbitrary directory does not # create a repository there. # +# A project marked disabled is skipped entirely. agents-cleanup sets the +# per-clone git config key agents-init.disabled; a .agents-disabled file +# in the project root, which a team may commit, has the same effect. +# Either one turns every wrapper launch into a silent no-op there. +# --enable clears the git key and scaffolds; the file has to be deleted +# by hand, because it is a decision shared with every clone. +# # File layout after setup: # AGENTS/AGENTS.md canonical root agent spec (real file) # AGENTS//AGENTS.md canonical spec for any subdir with its own @@ -95,6 +102,8 @@ # -a, --agents Set up AGENTS/ repo + AGENTS.md symlinks (root and every # discovered subdirectory) only # -p, --plugins Set up AGENTS/ repo + plans/specs/devlogs dirs + docs/ symlinks only +# -e, --enable Clear the git key agents-cleanup set, then scaffold as +# normal (refused while .agents-disabled exists) # -v, --verbose Print all per-step output (default) # -q, --quiet Print one summary line only if changes were made # -s, --silent Suppress all output; errors only (standard UNIX convention) @@ -103,7 +112,8 @@ # EXIT STATUS # 0 Setup completed successfully # 1 Fatal error (git init failed, move failed, the AGENTS/ commit was -# rejected, or an unresolved rebase blocked it) +# rejected, or an unresolved rebase blocked it), or --enable refused +# because .agents-disabled exists # # EXAMPLE # agents-init @@ -122,11 +132,11 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec files and plugin dirs' __fish_palette - argparse h/help a/agents p/plugins v/verbose q/quiet s/silent -- $argv + argparse h/help a/agents p/plugins e/enable v/verbose q/quiet s/silent -- $argv or return 1 if set -q _flag_help - echo "$c_head""Usage:$c_reset $c_cmd""agents-init$c_reset $c_flag""[-a] [-p] [-v] [-q] [-s] [-h | --help]$c_reset" + echo "$c_head""Usage:$c_reset $c_cmd""agents-init$c_reset $c_flag""[-a] [-p] [-e] [-v] [-q] [-s] [-h | --help]$c_reset" echo echo " Scaffold an AGENTS/ sub-repository for tracking agent specifications." echo @@ -134,6 +144,7 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message" echo " $c_flag-a$c_reset, $c_flag--agents$c_reset Set up AGENTS.md symlinks only" echo " $c_flag-p$c_reset, $c_flag--plugins$c_reset Set up plans/specs/devlogs dirs and docs/ symlinks only" + echo " $c_flag-e$c_reset, $c_flag--enable$c_reset Re-enable a project agents-cleanup disabled" echo " $c_flag-v$c_reset, $c_flag--verbose$c_reset Print all per-step output (default)" echo " $c_flag-q$c_reset, $c_flag--quiet$c_reset Print one summary line only if changes were made" echo " $c_flag-s$c_reset, $c_flag--silent$c_reset Suppress all output; only errors are printed" @@ -179,6 +190,34 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi end end + # ─────────────────────────── Opt-out marker ─────────────────────────── + # agents-cleanup marks a project it has undone so launches stop + # re-scaffolding it: a per-clone git config key, or a .agents-disabled + # file a team may commit. The file is a shared decision, so --enable + # refuses rather than silently overriding it. + set -l marker_file "$root/.agents-disabled" + set -l key_set 0 + if test $in_git -eq 1 + set -l key (git -C "$root" config --type=bool --get agents-init.disabled 2>/dev/null) + test "$key" = true; and set key_set 1 + end + if set -q _flag_enable + if test -e "$marker_file" + echo "$c_err""Error: .agents-disabled disables agents-init for every clone; delete it (and commit the deletion) to re-enable$c_reset" >&2 + return 1 + end + if test $in_git -eq 1; and git -C "$root" config --local --get agents-init.disabled >/dev/null 2>&1 + git -C "$root" config --local --unset agents-init.disabled + test $verbose -eq 1; and echo "$c_ok→ Re-enabled agents-init (unset git config agents-init.disabled)$c_reset" + end + else if test -e "$marker_file" + test $verbose -eq 1; and echo "$c_dim→ agents-init is disabled here by .agents-disabled; delete that file to re-enable$c_reset" + return 0 + else if test $key_set -eq 1 + test $verbose -eq 1; and echo "$c_dim→ agents-init is disabled here (git config agents-init.disabled); run agents-init --enable to re-enable$c_reset" + return 0 + end + set -l agents_dir "$root/AGENTS" set -l plugins_dir "$agents_dir/plugins" diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 18d8081..7b9616a 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -477,5 +477,49 @@ ln -s x $pf/nested/link check "find: only the unpruned symlink" $pf/src/link (_agents_init_find $pf -type l -print | string join ,) check "find: no root -> status 1" 1 (_agents_init_find ""; echo $status) +echo "" +echo "== agents-init: opt-out marker ==" + +set -l m1 (new_repo) +git -C $m1 config agents-init.disabled true +pushd $m1 >/dev/null +set -l mrc1 (agents-init --silent 2>/dev/null; echo $status) +popd >/dev/null +check "git key: exits 0" 0 "$mrc1" +check "git key: nothing scaffolded" false (test -e $m1/AGENTS -o -L $m1/AGENTS.md; and echo true; or echo false) + +set -l m2 (new_repo) +touch $m2/.agents-disabled +pushd $m2 >/dev/null +set -l mrc2 (agents-init --silent 2>/dev/null; echo $status) +popd >/dev/null +check "marker file: exits 0" 0 "$mrc2" +check "marker file: nothing scaffolded" false (test -e $m2/AGENTS -o -L $m2/AGENTS.md; and echo true; or echo false) + +set -l m3 (new_repo) +git -C $m3 config agents-init.disabled true +pushd $m3 >/dev/null +set -l mrc3 (agents-init --enable --silent 2>/dev/null; echo $status) +popd >/dev/null +check "--enable: exits 0" 0 "$mrc3" +check "--enable: git key unset" 1 (git -C $m3 config --get agents-init.disabled >/dev/null; echo $status) +check "--enable: scaffold created" AGENTS/AGENTS.md (readlink $m3/AGENTS.md) + +set -l m4 (new_repo) +touch $m4/.agents-disabled +pushd $m4 >/dev/null +set -l mrc4 (agents-init --enable --silent 2>/dev/null; echo $status) +popd >/dev/null +check "--enable with marker file: exits 1" 1 "$mrc4" +check "--enable with marker file: nothing scaffolded" false (test -e $m4/AGENTS -o -L $m4/AGENTS.md; and echo true; or echo false) +check "--enable with marker file: file kept" true (test -e $m4/.agents-disabled; and echo true; or echo false) + +set -l m5 (new_repo) +git -C $m5 config agents-init.disabled true +pushd $m5 >/dev/null +set -l mout5 (agents-init 2>/dev/null) +popd >/dev/null +check "git key, verbose: note names the marker" true (string match -q -- '*agents-init.disabled*' "$mout5"; and echo true; or echo false) + cleanup report -- 2.54.0 From ae45339d6a04539662f50a1c2d1858378e195b66 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 16:55:54 -0400 Subject: [PATCH 04/11] feat(agents-cleanup): add preflight, dry run and opt-out marker --- functions/agents-cleanup.fish | 286 +++++++++++++++++++++++++++++++++ tests/test-agents-cleanup.fish | 131 +++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 functions/agents-cleanup.fish create mode 100644 tests/test-agents-cleanup.fish diff --git a/functions/agents-cleanup.fish b/functions/agents-cleanup.fish new file mode 100644 index 0000000..95fb386 --- /dev/null +++ b/functions/agents-cleanup.fish @@ -0,0 +1,286 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# CATEGORY +# 12-ai-and-developer-tools +# +# DEPENDENCIES +# _agents_init_find, _agents_init_stub, _agents_repo_slug, _agents_repo_sync +# +# CLASSIFICATION +# destructive, self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling) +# +# SYNOPSIS +# agents-cleanup [-n | --dry-run] [--drop-extras] [--marker-file] +# [-v | --verbose] [-q | --quiet] [-s | --silent] [-h | --help] +# +# DESCRIPTION +# Reverses agents-init in the current project, and marks the project so +# agents-init -- and therefore every claude/agy launch -- leaves it +# alone from then on. +# +# Every symlink that resolves into AGENTS/ is replaced by the real file +# or directory it points to. When two links share a target (docs/plans +# and docs/superpowers/plans), the shallower one receives the content +# and the other is removed; a link to a target holding only .gitkeep is +# removed with nothing put in its place. An AGENTS.md that is exactly +# the stub agents-init writes is deleted; any other AGENTS.md loses only +# the SYSTEM DIRECTIVE blockquote that pointed agents at AGENTS/AGENTS.md. +# No CLAUDE.md is recreated. +# +# Before anything is moved, pending AGENTS/ changes are committed and +# the full history is written to a verified git bundle under +# $XDG_STATE_HOME/agents-cleanup/ (default ~/.local/state). AGENTS/ is +# then removed, along with docs/superpowers/ and docs/ if left empty, +# and every "Added by agents-init" block is stripped from .gitignore. +# Nothing is committed to the outer repository. +# +# Files inside AGENTS/ that no project symlink points to -- other than +# agents-init's own .version, .agents-tools/ and .gitkeep files -- stop +# the cleanup before anything changes. They are listed; --drop-extras +# discards them instead. +# +# The disabled marker is the per-clone git config key +# agents-init.disabled, set on every run. --marker-file also writes +# .agents-disabled in the project root, which agents-init honors too and +# which may be committed to opt every clone out; it is the only marker +# available outside a git repository. In a project with no AGENTS/, only +# the marker is set -- a pre-emptive opt-out. agents-init --enable +# clears the git key again. +# +# Re-running is safe: an interrupted cleanup resumes where it stopped, +# and a finished one only confirms the marker. +# +# ARGUMENTS +# -n, --dry-run Print the plan and change nothing +# --drop-extras Discard unlinked files in AGENTS/ instead of refusing +# --marker-file Also write .agents-disabled (required outside git) +# -v, --verbose Print all per-step output (default) +# -q, --quiet Print one summary line only if changes were made +# -s, --silent Suppress all output; errors only +# -h, --help Show this help message and exit +# +# EXIT STATUS +# 0 Cleanup finished, or nothing was left to do +# 1 Refused (outside git without --marker-file, unresolved rebase in +# AGENTS/, unlinked files in AGENTS/) or a step failed +# +# EXAMPLE +# agents-cleanup --dry-run +# agents-cleanup +# agents-cleanup --marker-file +# +# NOTES +# Restore an archived AGENTS/ with: git clone AGENTS, then +# agents-init --enable. The full write-up -- what is kept, what is +# removed, and how the markers interact -- is in +# docs/manual/16-agent-tooling.md. Update that section in the same +# change whenever this function's behavior changes. +function agents-cleanup --description 'undo agents-init: restore real files, archive and remove AGENTS/, disable agents-init' + __fish_palette + + argparse h/help n/dry-run drop-extras marker-file v/verbose q/quiet s/silent -- $argv + or return 1 + + if set -q _flag_help + echo "$c_head""Usage:$c_reset $c_cmd""agents-cleanup$c_reset $c_flag""[-n] [--drop-extras] [--marker-file] [-v] [-q] [-s] [-h | --help]$c_reset" + echo + echo " Undo agents-init here: restore real files, archive and remove AGENTS/," + echo " and stop agents-init from scaffolding this project again." + echo + echo "$c_head""Options:$c_reset" + echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message" + echo " $c_flag-n$c_reset, $c_flag--dry-run$c_reset Print the plan and change nothing" + echo " $c_flag--drop-extras$c_reset Discard unlinked files in AGENTS/ instead of refusing" + echo " $c_flag--marker-file$c_reset Also write .agents-disabled (required outside git)" + echo " $c_flag-v$c_reset, $c_flag--verbose$c_reset Print all per-step output (default)" + echo " $c_flag-q$c_reset, $c_flag--quiet$c_reset Print one summary line only if changes were made" + echo " $c_flag-s$c_reset, $c_flag--silent$c_reset Suppress all output; only errors are printed" + echo + echo " Re-enable later with $c_cmd""agents-init --enable$c_reset." + return 0 + end + + set -l verbose 1 + set -l quiet 0 + if set -q _flag_silent + set verbose 0 + else if set -q _flag_quiet + set verbose 0 + set quiet 1 + end + + # ──────────────────────────── Project root ──────────────────────────── + set -l root (git rev-parse --show-toplevel 2>/dev/null) + set -l in_git 1 + if test -z "$root" + set in_git 0 + if not set -q _flag_marker_file + echo "$c_err""Error: not a git repository, so there is no .git/config to hold the marker; re-run with --marker-file to write .agents-disabled instead$c_reset" >&2 + return 1 + end + set root (pwd) + end + + # ──────────────────────── Phase 1: preflight ───────────────────────── + # Read-only. Every refusal below returns before the first change. + set -l agents_dir "$root/AGENTS" + set -l has_agents 0 + set -l has_repo 0 + if test -d "$agents_dir" + set has_agents 1 + # Resolved, because link targets are compared after realpath, which + # also resolves any symlinked parent of $root. + set agents_dir (realpath -- "$agents_dir") + test -d "$agents_dir/.git"; and set has_repo 1 + end + + if test $has_repo -eq 1 + if test -d "$agents_dir/.git/rebase-merge"; or test -d "$agents_dir/.git/rebase-apply" + echo "$c_err""Error: AGENTS/ has an unresolved rebase; finish or abort it first$c_reset" >&2 + return 1 + end + end + + # Links into AGENTS/, shallowest first (then lexical): when two links + # share a target, the original location -- docs/plans rather than + # docs/superpowers/plans -- is the one that receives the content. + set -l keep # links to materialize + set -l keep_tgt # their resolved targets, index-aligned with $keep + set -l drop # links removed with nothing put in their place + if test $has_agents -eq 1 + set -l agents_re '^'(string escape --style=regex -- "$agents_dir")'(/|$)' + set -l links + for l in (_agents_init_find "$root" -type l -print) + string match -qr -- $agents_re (realpath -m -- "$l"); and set -a links "$l" + end + set links (for l in $links + printf '%s\t%s\n' (count (string split / -- "$l")) "$l" + end | sort -t\t -k1,1n -k2 | string replace -r '^\d+\t' '') + + for l in $links + set -l t (realpath -m -- "$l") + if not test -e "$t" + set -a drop "$l" # dangling + else if contains -- "$t" $keep_tgt + set -a drop "$l" # duplicate of a shallower link + else if test -d "$t"; and test -z "$(find "$t" ! -type d ! -name .gitkeep -print -quit)" + set -a drop "$l" # only .gitkeep: nothing to restore + else + set -a keep "$l" + set -a keep_tgt "$t" + end + end + end + + # Files in AGENTS/ that no kept link carries out, minus agents-init's + # own tooling. Refused by default: nothing says where they belong. + set -l extras + set -l extras_ignored + if test $has_agents -eq 1 + set -l cover_re + for t in $keep_tgt + set -a cover_re '^'(string escape --style=regex -- "$t")'(/|$)' + end + for f in (find "$agents_dir" -path "$agents_dir/.git" -prune -o ! -type d -print) + set -l rel (string replace -- "$agents_dir/" "" "$f") + switch "$rel" + case .version '.agents-tools/*' .gitkeep '*/.gitkeep' + continue + end + set -l covered 0 + for re in $cover_re + if string match -qr -- $re "$f" + set covered 1 + break + end + end + test $covered -eq 1; and continue + set -a extras "$rel" + if test $has_repo -eq 1; and git -C "$agents_dir" check-ignore -q -- "$rel" 2>/dev/null + set -a extras_ignored "$rel" + end + end + end + + if set -q extras[1]; and not set -q _flag_drop_extras + echo "$c_err""Error: AGENTS/ holds files no project symlink points to:$c_reset" >&2 + for e in $extras + if contains -- "$e" $extras_ignored + echo " AGENTS/$e (ignored -- not in bundle)" >&2 + else + echo " AGENTS/$e" >&2 + end + end + echo "Move them out of AGENTS/ by hand, or re-run with --drop-extras (the history bundle keeps every file not marked ignored)." >&2 + return 1 + end + + set -l state_dir $XDG_STATE_HOME + test -n "$state_dir"; or set state_dir "$HOME/.local/state" + set -l bundle "$state_dir/agents-cleanup/"(_agents_repo_slug "$root")"-"(date +%Y%m%d-%H%M%S)".bundle" + set -l gitignore "$root/.gitignore" + + if set -q _flag_dry_run + set -q _flag_silent; and return 0 + echo "$c_head""Dry run -- nothing changed. Plan:$c_reset" + test $in_git -eq 1; and echo " set git config agents-init.disabled true" + set -q _flag_marker_file; and echo " write .agents-disabled" + test $has_repo -eq 1; and echo " archive AGENTS/ history to $bundle" + for i in (seq (count $keep)) + echo " restore "(string replace -- "$root/" "" "$keep[$i]")" from AGENTS/"(string replace -- "$agents_dir/" "" "$keep_tgt[$i]") + end + for l in $drop + echo " remove link "(string replace -- "$root/" "" "$l") + end + for e in $extras + echo " discard AGENTS/$e" + end + test $has_agents -eq 1; and echo " remove AGENTS/" + if test -f "$gitignore"; and grep -q 'Added by agents-init' "$gitignore" + echo " remove agents-init blocks from .gitignore" + end + return 0 + end + + # ─────────────────────────── Phase 2: mark ─────────────────────────── + # Before any destructive step: a claude/agy launch mid-cleanup must not + # re-scaffold, and a re-run after a failure resumes instead of fighting + # agents-init. + set -l changed 0 + set -l archived + if test $in_git -eq 1 + set -l cur (git -C "$root" config --type=bool --get agents-init.disabled 2>/dev/null) + if test "$cur" != true + if not git -C "$root" config --local agents-init.disabled true + echo "$c_err""Error: could not set git config agents-init.disabled$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Set git config agents-init.disabled true$c_reset" + end + end + if set -q _flag_marker_file; and not test -e "$root/.agents-disabled" + if not printf '%s\n' \ + '# agents-init skips this project while this file exists.' \ + '# Written by agents-cleanup. Commit it to opt every clone out;' \ + '# delete it (and commit the deletion) to re-enable.' >"$root/.agents-disabled" + echo "$c_err""Error: could not write .agents-disabled$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Wrote .agents-disabled$c_reset" + end + + # ────────────────────────────── Summary ─────────────────────────────── + if test $changed -eq 0 + test $verbose -eq 1; and echo "$c_dim→ Nothing to clean up; agents-init is already disabled here$c_reset" + else if test $quiet -eq 1 + if test -n "$archived" + echo "$c_ok→ Cleaned up AGENTS scaffolding (history: $archived)$c_reset" + else + echo "$c_ok→ Cleaned up AGENTS scaffolding$c_reset" + end + end + return 0 +end diff --git a/tests/test-agents-cleanup.fish b/tests/test-agents-cleanup.fish new file mode 100644 index 0000000..973660f --- /dev/null +++ b/tests/test-agents-cleanup.fish @@ -0,0 +1,131 @@ +#!/usr/bin/env fish +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Hermetic tests for agents-cleanup: every case builds its own throwaway +# git repo under mktemp, scaffolds it with the real agents-init, and runs +# agents-cleanup against it. XDG_STATE_HOME is a fresh temp dir per case, +# so bundles never land in the real state dir. Nothing touches this +# checkout. +# +# Runs isolated (no `# MODE:` marker, which means isolated). +# +# Usage: fish tests/test-agents-cleanup.fish + +source (realpath (dirname (status filename)))/lib.fish +set -p fish_function_path $repo_root/functions + +set -gx GIT_AUTHOR_NAME t +set -gx GIT_AUTHOR_EMAIL t@t +set -gx GIT_COMMITTER_NAME t +set -gx GIT_COMMITTER_EMAIL t@t +set -gx GIT_CONFIG_COUNT 2 +set -gx GIT_CONFIG_KEY_0 commit.gpgsign +set -gx GIT_CONFIG_VALUE_0 false +set -gx GIT_CONFIG_KEY_1 init.defaultBranch +set -gx GIT_CONFIG_VALUE_1 main + +set -g TMPDIRS + +function new_repo + set -l d (mktemp -d) + set -ga TMPDIRS $d + git -C $d init -q + git -C $d config user.email t@t + git -C $d config user.name t + git -C $d config commit.gpgsign false + git -C $d config core.hooksPath /dev/null + printf '%s\n' $d +end + +function cleanup + for d in $TMPDIRS + test -n "$d"; and rm -rf $d + end +end + +# A fresh bundle destination per case, so bundle counts never leak. +function fresh_state + set -gx XDG_STATE_HOME (mktemp -d) + set -ga TMPDIRS $XDG_STATE_HOME +end + +# A project with a user .gitignore line, a root and a scoped AGENTS.md, +# and a real docs/plans -- scaffolded by the real agents-init, so it +# carries every link shape: root and subdir AGENTS.md, docs/plans (the +# original location) and docs/superpowers/plans (its duplicate), and +# docs/superpowers/specs (a .gitkeep-only target). +function scaffolded_repo + set -l d (new_repo) + echo keep-me >$d/.gitignore + echo user-root >$d/AGENTS.md + mkdir -p $d/functions $d/docs/plans + echo user-scoped >$d/functions/AGENTS.md + echo a-plan >$d/docs/plans/p.md + pushd $d >/dev/null + agents-init --silent 2>/dev/null + popd >/dev/null + printf '%s\n' $d +end + +# Content hash of a tree, .git directories excluded: proves a refusal or +# a dry run changed nothing on disk. +function tree_hash --argument-names d + tar --sort=name --exclude=.git -C $d -cf - . | sha256sum +end + +section "agents-cleanup: outside git" +fresh_state +set -l n1 (mktemp -d) +set -a TMPDIRS $n1 +pushd $n1 >/dev/null +set -l nrc1 (set -lx GIT_CEILING_DIRECTORIES (path dirname $n1); agents-cleanup --silent 2>/dev/null; echo $status) +set -l nfile1 (test -e $n1/.agents-disabled; and echo true; or echo false) +set -l nrc2 (set -lx GIT_CEILING_DIRECTORIES (path dirname $n1); agents-cleanup --marker-file --silent 2>/dev/null; echo $status) +popd >/dev/null +check "non-git without --marker-file: exits 1" 1 "$nrc1" +check "non-git without --marker-file: writes nothing" false "$nfile1" +check "non-git with --marker-file: exits 0" 0 "$nrc2" +check "non-git with --marker-file: .agents-disabled written" true (test -f $n1/.agents-disabled; and echo true; or echo false) + +section "agents-cleanup: never-scaffolded project" +fresh_state +set -l p1 (new_repo) +pushd $p1 >/dev/null +set -l prc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "no AGENTS/: exits 0" 0 "$prc" +check "no AGENTS/: git key set" true (git -C $p1 config --type=bool --get agents-init.disabled) +check "no AGENTS/: nothing created" "" (command ls -A $p1 | string match -v .git) + +section "agents-cleanup: unlinked files refuse" +fresh_state +set -l x1 (scaffolded_repo) +echo notes >$x1/AGENTS/notes.md +set -l xbefore (tree_hash $x1) +pushd $x1 >/dev/null +set -l xerr (agents-cleanup 2>&1 >/dev/null) +set -l xrc $status +popd >/dev/null +check "extras: exits 1" 1 "$xrc" +check "extras: tree unchanged" "$xbefore" (tree_hash $x1) +check "extras: git key not set" 1 (git -C $x1 config --get agents-init.disabled >/dev/null; echo $status) +check "extras: names the file" true (string match -q -- '*AGENTS/notes.md*' "$xerr"; and echo true; or echo false) + +section "agents-cleanup: --dry-run" +fresh_state +set -l d1 (scaffolded_repo) +set -l dbefore (tree_hash $d1) +pushd $d1 >/dev/null +set -l dout (agents-cleanup --dry-run 2>/dev/null) +set -l drc $status +popd >/dev/null +check "dry-run: exits 0" 0 "$drc" +check "dry-run: tree unchanged" "$dbefore" (tree_hash $d1) +check "dry-run: git key not set" 1 (git -C $d1 config --get agents-init.disabled >/dev/null; echo $status) +check "dry-run: plans restoring docs/plans" true (string match -q -- '*restore docs/plans from AGENTS/plans*' "$dout"; and echo true; or echo false) +check "dry-run: plans dropping the duplicate link" true (string match -q -- '*remove link docs/superpowers/plans*' "$dout"; and echo true; or echo false) +check "dry-run: plans removing AGENTS/" true (string match -q -- '*remove AGENTS/*' "$dout"; and echo true; or echo false) + +cleanup +report -- 2.54.0 From 0f9e9e81faaace84dd3967b43d0544ee4847f991 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 16:59:23 -0400 Subject: [PATCH 05/11] fix(agents-cleanup): cover rebase refusal and marker paths; prune .git by name --- functions/agents-cleanup.fish | 4 +-- tests/test-agents-cleanup.fish | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/functions/agents-cleanup.fish b/functions/agents-cleanup.fish index 95fb386..57db568 100644 --- a/functions/agents-cleanup.fish +++ b/functions/agents-cleanup.fish @@ -182,7 +182,7 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc for t in $keep_tgt set -a cover_re '^'(string escape --style=regex -- "$t")'(/|$)' end - for f in (find "$agents_dir" -path "$agents_dir/.git" -prune -o ! -type d -print) + for f in (find "$agents_dir" -name .git -prune -o ! -type d -print) set -l rel (string replace -- "$agents_dir/" "" "$f") switch "$rel" case .version '.agents-tools/*' .gitkeep '*/.gitkeep' @@ -250,7 +250,7 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc set -l changed 0 set -l archived if test $in_git -eq 1 - set -l cur (git -C "$root" config --type=bool --get agents-init.disabled 2>/dev/null) + set -l cur (git -C "$root" config --local --type=bool --get agents-init.disabled 2>/dev/null) if test "$cur" != true if not git -C "$root" config --local agents-init.disabled true echo "$c_err""Error: could not set git config agents-init.disabled$c_reset" >&2 diff --git a/tests/test-agents-cleanup.fish b/tests/test-agents-cleanup.fish index 973660f..33e369d 100644 --- a/tests/test-agents-cleanup.fish +++ b/tests/test-agents-cleanup.fish @@ -125,7 +125,71 @@ check "dry-run: tree unchanged" "$dbefore" (tree_hash $d1) check "dry-run: git key not set" 1 (git -C $d1 config --get agents-init.disabled >/dev/null; echo $status) check "dry-run: plans restoring docs/plans" true (string match -q -- '*restore docs/plans from AGENTS/plans*' "$dout"; and echo true; or echo false) check "dry-run: plans dropping the duplicate link" true (string match -q -- '*remove link docs/superpowers/plans*' "$dout"; and echo true; or echo false) +check "dry-run: plans dropping the .gitkeep-only link" true (string match -q -- '*remove link docs/superpowers/specs*' "$dout"; and echo true; or echo false) check "dry-run: plans removing AGENTS/" true (string match -q -- '*remove AGENTS/*' "$dout"; and echo true; or echo false) +section "agents-cleanup: unresolved rebase refuses" +fresh_state +set -l r1 (scaffolded_repo) +mkdir $r1/AGENTS/.git/rebase-merge +set -l rbefore (tree_hash $r1) +pushd $r1 >/dev/null +agents-cleanup >/dev/null 2>&1 +set -l rrc $status +popd >/dev/null +check "rebase: exits 1" 1 "$rrc" +check "rebase: tree unchanged" "$rbefore" (tree_hash $r1) +check "rebase: git key not set" 1 (git -C $r1 config --get agents-init.disabled >/dev/null; echo $status) + +section "agents-cleanup: --marker-file inside git" +fresh_state +set -l m1 (scaffolded_repo) +pushd $m1 >/dev/null +set -l mout (agents-cleanup --dry-run --marker-file 2>/dev/null) +popd >/dev/null +check "marker dry-run: plans writing the file" true (string match -q -- '*write .agents-disabled*' "$mout"; and echo true; or echo false) +set -l m2 (new_repo) +pushd $m2 >/dev/null +set -l mrc (agents-cleanup --marker-file --silent 2>/dev/null; echo $status) +popd >/dev/null +check "marker run: exits 0" 0 "$mrc" +check "marker run: .agents-disabled written" true (test -f $m2/.agents-disabled; and echo true; or echo false) +check "marker run: git key set" true (git -C $m2 config --type=bool --get agents-init.disabled) + +section "agents-cleanup: --dry-run --drop-extras" +fresh_state +set -l e1 (scaffolded_repo) +echo notes >$e1/AGENTS/notes.md +set -l ebefore (tree_hash $e1) +pushd $e1 >/dev/null +set -l eout (agents-cleanup --dry-run --drop-extras 2>/dev/null) +set -l erc $status +popd >/dev/null +check "drop-extras dry-run: exits 0" 0 "$erc" +check "drop-extras dry-run: plans discarding the file" true (string match -q -- '*discard AGENTS/notes.md*' "$eout"; and echo true; or echo false) +check "drop-extras dry-run: tree unchanged" "$ebefore" (tree_hash $e1) + +section "agents-cleanup: glob characters in the project path" +fresh_state +set -l g0 (mktemp -d) +set -a TMPDIRS $g0 +set -l g1 "$g0/p[x]" +mkdir $g1 +git -C $g1 init -q +git -C $g1 config user.email t@t +git -C $g1 config user.name t +git -C $g1 config commit.gpgsign false +git -C $g1 config core.hooksPath /dev/null +echo a-plan >$g1/README.md +pushd $g1 >/dev/null +agents-init --silent 2>/dev/null +set -l gout (agents-cleanup --dry-run 2>/dev/null) +set -l grc $status +popd >/dev/null +check "glob path: AGENTS/ scaffolded" true (test -d $g1/AGENTS/.git; and echo true; or echo false) +check "glob path: dry-run exits 0" 0 "$grc" +check "glob path: plan removes AGENTS/" true (string match -q -- '*remove AGENTS/*' "$gout"; and echo true; or echo false) +check "glob path: plan never lists .git/" false (string match -q -- '*.git/*' "$gout"; and echo true; or echo false) + cleanup report -- 2.54.0 From beb6518ab955d64eb94e71d6854cb12f63fa7399 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 17:01:47 -0400 Subject: [PATCH 06/11] feat(agents-cleanup): archive, materialize links, remove AGENTS/, strip .gitignore --- functions/agents-cleanup.fish | 166 +++++++++++++++++++++++++++++++++ tests/test-agents-cleanup.fish | 102 ++++++++++++++++++++ 2 files changed, 268 insertions(+) diff --git a/functions/agents-cleanup.fish b/functions/agents-cleanup.fish index 57db568..c39d93c 100644 --- a/functions/agents-cleanup.fish +++ b/functions/agents-cleanup.fish @@ -272,6 +272,172 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc test $verbose -eq 1; and echo "$c_ok→ Wrote .agents-disabled$c_reset" end + # ────────────────────────── Phase 3: archive ───────────────────────── + # Nothing is moved until the history is safely out of AGENTS/. + if test $has_repo -eq 1 + # 2>/dev/null: the helper's own message would leak past --silent (a + # command substitution's stderr ignores the caller's redirect); the + # error below says the same thing. + set -l sync_out (_agents_repo_sync "$agents_dir" "chore: final sync before agents-cleanup" 2>/dev/null) + if test $status -ne 0 + echo "$c_err""Error: could not commit pending AGENTS/ changes; nothing moved or removed$c_reset" >&2 + return 1 + end + if test -n "$sync_out" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$sync_out$c_reset" + end + + if git -C "$agents_dir" rev-parse -q --verify HEAD >/dev/null + if not mkdir -p (path dirname "$bundle") + echo "$c_err""Error: could not create "(path dirname "$bundle")"; nothing moved or removed$c_reset" >&2 + return 1 + end + if not git -C "$agents_dir" bundle create -q "$bundle" --all 2>/dev/null + echo "$c_err""Error: could not write $bundle; nothing moved or removed$c_reset" >&2 + return 1 + end + if not git -C "$agents_dir" bundle verify -q "$bundle" >/dev/null 2>&1 + echo "$c_err""Error: $bundle failed verification; nothing moved or removed$c_reset" >&2 + return 1 + end + set changed 1 + set archived "$bundle" + if test $verbose -eq 1 + echo "$c_ok→ Archived AGENTS/ history to $bundle$c_reset" + echo "$c_dim restore with: git clone $bundle AGENTS$c_reset" + end + else + test $verbose -eq 1; and echo "$c_dim→ AGENTS/ has no commits; nothing to archive$c_reset" + end + else if test $has_agents -eq 1 + test $verbose -eq 1; and echo "$c_dim→ AGENTS/ is not a git repository; nothing to archive$c_reset" + end + + # ──────────────────────── Phase 4: materialize ─────────────────────── + set -l directive 'SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING' + for i in (seq (count $keep)) + set -l link $keep[$i] + set -l rel (string replace -- "$root/" "" "$link") + if not rm -f "$link"; or not command mv "$keep_tgt[$i]" "$link" + echo "$c_err""Error: could not restore $rel from AGENTS/; re-run to resume$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Restored $rel$c_reset" + + if test -d "$link" + rm -f "$link/.gitkeep" + else if test (path basename "$link") = AGENTS.md + if _agents_init_stub | cmp -s - "$link" + rm -f "$link" + test $verbose -eq 1; and echo "$c_ok→ Removed $rel (agents-init stub, no user content)$c_reset" + else if grep -qF -- "$directive" "$link" + # Drop the first run of '>' lines carrying the directive, and + # the blank lines after it. Not "the first lines": real files + # put a heading above it. + set -l body (awk -v d="$directive" ' + { line[NR] = $0 } + END { + s = 0 + for (i = 1; i <= NR; i++) { + if (line[i] !~ /^>/) continue + j = i; hit = 0 + while (j <= NR && line[j] ~ /^>/) { if (index(line[j], d)) hit = 1; j++ } + if (hit) { s = i; e = j; break } + i = j + } + if (!s) { for (i = 1; i <= NR; i++) print line[i]; exit } + while (e <= NR && line[e] ~ /^[ \t]*$/) e++ + for (i = 1; i < s; i++) print line[i] + for (i = e; i <= NR; i++) print line[i] + }' "$link") + if test $status -ne 0 + echo "$c_err""Error: could not rewrite $rel$c_reset" >&2 + return 1 + end + if set -q body[1]; and string match -qr -- '\S' $body + printf '%s\n' $body >"$link" + test $verbose -eq 1; and echo "$c_ok→ Removed the AGENTS/ directive from $rel$c_reset" + else + rm -f "$link" + test $verbose -eq 1; and echo "$c_ok→ Removed $rel (only the directive, no user content)$c_reset" + end + end + end + end + + for link in $drop + set -l rel (string replace -- "$root/" "" "$link") + if not rm -f "$link" + echo "$c_err""Error: could not remove link $rel$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Removed link $rel$c_reset" + end + + # ─────────────────────── Phase 5: remove AGENTS/ ───────────────────── + if test $has_agents -eq 1 + if not rm -rf "$agents_dir" + echo "$c_err""Error: could not remove AGENTS/$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Removed AGENTS/$c_reset" + # agents-init creates these; drop them only if nothing else lives there. + for d in "$root/docs/superpowers" "$root/docs" + if test -d "$d"; and rmdir "$d" 2>/dev/null + test $verbose -eq 1; and echo "$c_ok→ Removed empty "(string replace -- "$root/" "" "$d")"/$c_reset" + end + end + end + + # ───────────────────────── Phase 6: .gitignore ─────────────────────── + # Matched by pattern, not the exact strings _agents_init_ensure_gitignore + # writes, so blocks from older header variants go too. A header with no + # footer before the next header is left in place (and reported). + if test -f "$gitignore"; and grep -q 'Added by agents-init' "$gitignore" + set -l kept (awk ' + { line[NR] = $0 } + END { + n = 0 + for (i = 1; i <= NR; i++) { + if (line[i] ~ /^#.*Added by agents-init/) { + for (j = i + 1; j <= NR; j++) { + if (line[j] ~ /Added by agents-init/) { j = NR + 1; break } + if (line[j] ~ /^#[ \t]*(─)+[ \t]*$/) break + } + if (j <= NR) { + if (n > 0 && out[n] ~ /^[ \t]*$/) n-- + i = j + continue + } + print "agents-cleanup: unterminated agents-init block in .gitignore left in place" > "/dev/stderr" + } + out[++n] = line[i] + } + for (i = 1; i <= n; i++) print out[i] + }' "$gitignore") + if test $status -ne 0 + echo "$c_err""Error: could not rewrite .gitignore$c_reset" >&2 + return 1 + end + if set -q kept[1]; and string match -qr -- '\S' $kept + printf '%s\n' $kept >"$gitignore" + else if test $in_git -eq 1; and git -C "$root" ls-files --error-unmatch -- .gitignore >/dev/null 2>&1 + # Tracked: someone committed it, so it is not agents-init's to + # delete. Leave it empty; the change shows in git status. + true >"$gitignore" + else + # Untracked and nothing but agents-init's blocks: agents-init + # created it. + rm -f "$gitignore" + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Removed agents-init blocks from .gitignore$c_reset" + end + # ────────────────────────────── Summary ─────────────────────────────── if test $changed -eq 0 test $verbose -eq 1; and echo "$c_dim→ Nothing to clean up; agents-init is already disabled here$c_reset" diff --git a/tests/test-agents-cleanup.fish b/tests/test-agents-cleanup.fish index 33e369d..7c49a86 100644 --- a/tests/test-agents-cleanup.fish +++ b/tests/test-agents-cleanup.fish @@ -191,5 +191,107 @@ check "glob path: dry-run exits 0" 0 "$grc" check "glob path: plan removes AGENTS/" true (string match -q -- '*remove AGENTS/*' "$gout"; and echo true; or echo false) check "glob path: plan never lists .git/" false (string match -q -- '*.git/*' "$gout"; and echo true; or echo false) +section "agents-cleanup: full cleanup" +fresh_state +set -l c1 (scaffolded_repo) +pushd $c1 >/dev/null +set -l crc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "full: exits 0" 0 "$crc" +check "full: AGENTS/ removed" false (test -e $c1/AGENTS; and echo true; or echo false) +check "full: root AGENTS.md is a real file" false (test -L $c1/AGENTS.md; and echo true; or echo false) +check "full: root content kept" user-root (string collect <$c1/AGENTS.md) +check "full: subdir AGENTS.md real, content kept" user-scoped (test -L $c1/functions/AGENTS.md; or string collect <$c1/functions/AGENTS.md) +check "full: docs/plans restored as a real dir" a-plan (test -L $c1/docs/plans; or string collect <$c1/docs/plans/p.md) +check "full: no .gitkeep in docs/plans" false (test -e $c1/docs/plans/.gitkeep; and echo true; or echo false) +check "full: docs/superpowers removed" false (test -e $c1/docs/superpowers -o -L $c1/docs/superpowers; and echo true; or echo false) +check "full: git key set" true (git -C $c1 config --type=bool --get agents-init.disabled) +check "full: .gitignore keeps only user lines" keep-me (string collect <$c1/.gitignore) +check "full: nothing committed to the outer repo" "" (git -C $c1 rev-list --all 2>/dev/null) +set -l cb $XDG_STATE_HOME/agents-cleanup/*.bundle +check "full: one bundle written" 1 (count $cb) +set -l restored (mktemp -d) +set -a TMPDIRS $restored +git clone -q $cb[1] $restored/AGENTS 2>/dev/null +check "full: bundle restores the history" user-root (string collect <$restored/AGENTS/AGENTS.md) + +pushd $c1 >/dev/null +agents-init --silent 2>/dev/null +popd >/dev/null +check "full: agents-init no longer scaffolds" false (test -e $c1/AGENTS; and echo true; or echo false) + +pushd $c1 >/dev/null +set -l c2out (agents-cleanup --quiet 2>&1) +set -l c2rc $status +popd >/dev/null +check "rerun: exits 0" 0 "$c2rc" +check "rerun: quiet prints nothing" "" "$c2out" + +section "agents-cleanup: stub AGENTS.md" +fresh_state +set -l s1 (new_repo) +pushd $s1 >/dev/null +agents-init --silent 2>/dev/null +agents-cleanup --silent 2>/dev/null +popd >/dev/null +check "stub: deleted" false (test -e $s1/AGENTS.md -o -L $s1/AGENTS.md; and echo true; or echo false) +check "stub: untracked blocks-only .gitignore deleted" false (test -e $s1/.gitignore; and echo true; or echo false) + +section "agents-cleanup: tracked blocks-only .gitignore is emptied, not deleted" +fresh_state +set -l g1 (new_repo) +pushd $g1 >/dev/null +agents-init --silent 2>/dev/null +git add .gitignore +git commit -qm gi +agents-cleanup --silent 2>/dev/null +popd >/dev/null +check "tracked .gitignore: still exists" true (test -f $g1/.gitignore; and echo true; or echo false) +check "tracked .gitignore: emptied" "" (string collect <$g1/.gitignore) +check "tracked .gitignore: index untouched (unstaged change only)" " M .gitignore" (git -C $g1 status --porcelain -- .gitignore) + +section "agents-cleanup: directive stripped from a user AGENTS.md" +fresh_state +set -l t1 (new_repo) +printf '%s\n' '# Title' '' '> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> write to AGENTS/AGENTS.md' '' body >$t1/AGENTS.md +pushd $t1 >/dev/null +agents-init --silent 2>/dev/null +agents-cleanup --silent 2>/dev/null +popd >/dev/null +check "directive: blockquote and its trailing blank line removed" (printf '%s\n' '# Title' '' body | string collect) (string collect <$t1/AGENTS.md) + +section "agents-cleanup: --drop-extras" +fresh_state +set -l x2 (scaffolded_repo) +echo notes >$x2/AGENTS/notes.md +pushd $x2 >/dev/null +set -l x2rc (agents-cleanup --drop-extras --silent 2>/dev/null; echo $status) +popd >/dev/null +check "drop-extras: exits 0" 0 "$x2rc" +check "drop-extras: AGENTS/ removed" false (test -e $x2/AGENTS; and echo true; or echo false) +set -l xb $XDG_STATE_HOME/agents-cleanup/*.bundle +set -l xr (mktemp -d) +set -a TMPDIRS $xr +git clone -q $xb[1] $xr/AGENTS 2>/dev/null +check "drop-extras: dropped file is in the bundle" notes (string collect <$xr/AGENTS/notes.md) + +section "agents-cleanup: rejected final commit" +fresh_state +set -l f1 (scaffolded_repo) +set -l hooks (mktemp -d) +set -a TMPDIRS $hooks +printf '%s\n' '#!/bin/sh' 'exit 1' >$hooks/pre-commit +chmod +x $hooks/pre-commit +git -C $f1/AGENTS config core.hooksPath $hooks +echo change >>$f1/AGENTS/AGENTS.md +pushd $f1 >/dev/null +set -l frc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "rejected commit: exits 1" 1 "$frc" +check "rejected commit: AGENTS/ kept" true (test -d $f1/AGENTS/.git; and echo true; or echo false) +check "rejected commit: root link untouched" AGENTS/AGENTS.md (readlink $f1/AGENTS.md) +set -l fb $XDG_STATE_HOME/agents-cleanup/*.bundle +check "rejected commit: no bundle" 0 (count $fb) + cleanup report -- 2.54.0 From 45005ff71e9089421e86d988ce054db638f8cd58 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 17:07:08 -0400 Subject: [PATCH 07/11] fix(agents-cleanup): keep links on failed moves, check rewrites, refuse symlinked AGENTS/ --- functions/agents-cleanup.fish | 75 ++++++++++++++++++++++------- tests/test-agents-cleanup.fish | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/functions/agents-cleanup.fish b/functions/agents-cleanup.fish index c39d93c..6e4d905 100644 --- a/functions/agents-cleanup.fish +++ b/functions/agents-cleanup.fish @@ -127,6 +127,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc set -l agents_dir "$root/AGENTS" set -l has_agents 0 set -l has_repo 0 + if test -L "$agents_dir" + echo "$c_err""Error: AGENTS is a symlink to "(readlink "$agents_dir")"; replace it with a real directory (or remove the link) first$c_reset" >&2 + return 1 + end if test -d "$agents_dir" set has_agents 1 # Resolved, because link targets are compared after realpath, which @@ -208,11 +212,17 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc for e in $extras if contains -- "$e" $extras_ignored echo " AGENTS/$e (ignored -- not in bundle)" >&2 + else if test $has_repo -eq 0 + echo " AGENTS/$e (not in bundle)" >&2 else echo " AGENTS/$e" >&2 end end - echo "Move them out of AGENTS/ by hand, or re-run with --drop-extras (the history bundle keeps every file not marked ignored)." >&2 + if test $has_repo -eq 1 + echo "Move them out of AGENTS/ by hand, or re-run with --drop-extras (the history bundle keeps every file not marked ignored)." >&2 + else + echo "Move them out of AGENTS/ by hand, or re-run with --drop-extras (AGENTS/ is not a git repository, so there is no bundle and they are lost)." >&2 + end return 1 end @@ -319,10 +329,22 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc for i in (seq (count $keep)) set -l link $keep[$i] set -l rel (string replace -- "$root/" "" "$link") - if not rm -f "$link"; or not command mv "$keep_tgt[$i]" "$link" + set -l was (readlink "$link") + if not test -e "$keep_tgt[$i]" + echo "$c_err""Error: AGENTS/ target for $rel is missing; nothing changed for it$c_reset" >&2 + return 1 + end + if not rm -f "$link" echo "$c_err""Error: could not restore $rel from AGENTS/; re-run to resume$c_reset" >&2 return 1 end + if not command mv "$keep_tgt[$i]" "$link" + # Put the link back: without it the target looks like an unlinked + # extra and a re-run would refuse. + ln -s -- "$was" "$link" + echo "$c_err""Error: could not restore $rel from AGENTS/; link left in place$c_reset" >&2 + return 1 + end set changed 1 test $verbose -eq 1; and echo "$c_ok→ Restored $rel$c_reset" @@ -357,8 +379,13 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc return 1 end if set -q body[1]; and string match -qr -- '\S' $body - printf '%s\n' $body >"$link" - test $verbose -eq 1; and echo "$c_ok→ Removed the AGENTS/ directive from $rel$c_reset" + if test "$(printf '%s\n' $body | string collect)" != "$(string collect <"$link")" + if not printf '%s\n' $body >"$link" + echo "$c_err""Error: could not rewrite $rel$c_reset" >&2 + return 1 + end + test $verbose -eq 1; and echo "$c_ok→ Removed the AGENTS/ directive from $rel$c_reset" + end else rm -f "$link" test $verbose -eq 1; and echo "$c_ok→ Removed $rel (only the directive, no user content)$c_reset" @@ -398,6 +425,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc # writes, so blocks from older header variants go too. A header with no # footer before the next header is left in place (and reported). if test -f "$gitignore"; and grep -q 'Added by agents-init' "$gitignore" + # The unterminated-block warning is verbose-only, so a quiet or silent + # re-run stays silent. + set -l awk_err /dev/stderr + test $verbose -eq 1; or set awk_err /dev/null set -l kept (awk ' { line[NR] = $0 } END { @@ -418,24 +449,34 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc out[++n] = line[i] } for (i = 1; i <= n; i++) print out[i] - }' "$gitignore") + }' "$gitignore" 2>$awk_err) if test $status -ne 0 echo "$c_err""Error: could not rewrite .gitignore$c_reset" >&2 return 1 end - if set -q kept[1]; and string match -qr -- '\S' $kept - printf '%s\n' $kept >"$gitignore" - else if test $in_git -eq 1; and git -C "$root" ls-files --error-unmatch -- .gitignore >/dev/null 2>&1 - # Tracked: someone committed it, so it is not agents-init's to - # delete. Leave it empty; the change shows in git status. - true >"$gitignore" - else - # Untracked and nothing but agents-init's blocks: agents-init - # created it. - rm -f "$gitignore" + # Identical output means only unterminated blocks were found: nothing + # to write, nothing to report, and a re-run stays silent. + if test "$(printf '%s\n' $kept | string collect)" != "$(string collect <"$gitignore")" + if set -q kept[1]; and string match -qr -- '\S' $kept + if not printf '%s\n' $kept >"$gitignore" + echo "$c_err""Error: could not rewrite .gitignore$c_reset" >&2 + return 1 + end + else if test $in_git -eq 1; and git -C "$root" ls-files --error-unmatch -- .gitignore >/dev/null 2>&1 + # Tracked: someone committed it, so it is not agents-init's to + # delete. Leave it empty; the change shows in git status. + if not true >"$gitignore" + echo "$c_err""Error: could not rewrite .gitignore$c_reset" >&2 + return 1 + end + else + # Untracked and nothing but agents-init's blocks: agents-init + # created it. + rm -f "$gitignore" + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Removed agents-init blocks from .gitignore$c_reset" end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Removed agents-init blocks from .gitignore$c_reset" end # ────────────────────────────── Summary ─────────────────────────────── diff --git a/tests/test-agents-cleanup.fish b/tests/test-agents-cleanup.fish index 7c49a86..a1d4606 100644 --- a/tests/test-agents-cleanup.fish +++ b/tests/test-agents-cleanup.fish @@ -293,5 +293,91 @@ check "rejected commit: root link untouched" AGENTS/AGENTS.md (readlink $f1/AGEN set -l fb $XDG_STATE_HOME/agents-cleanup/*.bundle check "rejected commit: no bundle" 0 (count $fb) +section "agents-cleanup: failed move keeps the link, re-run resumes" +fresh_state +set -l w1 (scaffolded_repo) +chmod a-w $w1/AGENTS/functions +pushd $w1 >/dev/null +set -l w1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "failed mv: exits 1" 1 "$w1rc" +check "failed mv: link still a symlink" true (test -L $w1/functions/AGENTS.md; and echo true; or echo false) +chmod u+w $w1/AGENTS/functions +pushd $w1 >/dev/null +set -l w2rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "failed mv re-run: exits 0" 0 "$w2rc" +check "failed mv re-run: real file, content kept" user-scoped (test -L $w1/functions/AGENTS.md; or string collect <$w1/functions/AGENTS.md) + +section "agents-cleanup: failed directive rewrite keeps AGENTS/" +fresh_state +set -l k1 (new_repo) +printf '%s\n' '# Title' '' '> **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> x' '' body >$k1/AGENTS.md +pushd $k1 >/dev/null +agents-init --silent 2>/dev/null +popd >/dev/null +chmod a-w $k1/AGENTS/AGENTS.md +pushd $k1 >/dev/null +set -l k1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +chmod u+w $k1/AGENTS/AGENTS.md $k1/AGENTS.md 2>/dev/null +check "failed rewrite: exits 1" 1 "$k1rc" +check "failed rewrite: AGENTS/ kept" true (test -d $k1/AGENTS; and echo true; or echo false) + +section "agents-cleanup: unterminated .gitignore block is left alone, quietly" +fresh_state +set -l u1 (new_repo) +printf '%s\n' user-line '' '# ──── Added by agents-init ────' AGENTS/ >$u1/.gitignore +set -l ubefore (string collect <$u1/.gitignore) +pushd $u1 >/dev/null +set -l u1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "unterminated: exits 0" 0 "$u1rc" +check "unterminated: file byte-identical" "$ubefore" (string collect <$u1/.gitignore) +pushd $u1 >/dev/null +set -l u2out (agents-cleanup --quiet 2>&1) +popd >/dev/null +check "unterminated: quiet re-run prints nothing" "" "$u2out" + +section "agents-cleanup: symlinked AGENTS refuses" +fresh_state +set -l l1 (new_repo) +set -l ext (mktemp -d) +set -a TMPDIRS $ext +echo precious >$ext/file +ln -s $ext $l1/AGENTS +pushd $l1 >/dev/null +set -l l1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "symlinked AGENTS: exits 1" 1 "$l1rc" +check "symlinked AGENTS: target file survives" precious (string collect <$ext/file) + +section "agents-cleanup: extras without a bundle" +fresh_state +set -l n2 (new_repo) +mkdir $n2/AGENTS +echo stray >$n2/AGENTS/stray.md +pushd $n2 >/dev/null +set -l n2err (agents-cleanup 2>&1 >/dev/null) +popd >/dev/null +check "no-repo extras: labelled not in bundle" true (string match -q -- '*stray.md (not in bundle)*' "$n2err"; and echo true; or echo false) +check "no-repo extras: hint does not promise a bundle" false (string match -q -- '*bundle keeps*' "$n2err"; and echo true; or echo false) + +section "agents-cleanup: user AGENTS.md variants" +fresh_state +set -l v1 (new_repo) +printf '%s\n' '> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> x' >$v1/AGENTS.md +set -l v2 (new_repo) +printf '%s\n' '# Mine' '' 'no directive here' >$v2/AGENTS.md +set -l v2before (string collect <$v2/AGENTS.md) +for v in $v1 $v2 + pushd $v >/dev/null + agents-init --silent 2>/dev/null + agents-cleanup --silent 2>/dev/null + popd >/dev/null +end +check "directive-only: deleted" false (test -e $v1/AGENTS.md -o -L $v1/AGENTS.md; and echo true; or echo false) +check "no directive: content identical" "$v2before" (string collect <$v2/AGENTS.md) + cleanup report -- 2.54.0 From d3b8bc59bb16085b9336e39a560b15fac529f82a Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 17:11:17 -0400 Subject: [PATCH 08/11] docs(manual): document agents-cleanup and the agents-init opt-out --- docs/fish-config.md | 162 +++++++++++++++++++++++++++++++- docs/manual/16-agent-tooling.md | 72 +++++++++++++- 2 files changed, 228 insertions(+), 6 deletions(-) diff --git a/docs/fish-config.md b/docs/fish-config.md index 545bbd3..9c5c171 100644 --- a/docs/fish-config.md +++ b/docs/fish-config.md @@ -2470,10 +2470,83 @@ functions). They are active in all interactive sessions. ## 5.12 AI and Developer Tools +### agents-cleanup + + Synopsis: agents-cleanup [-n | --dry-run] [--drop-extras] [--marker-file] + [-v | --verbose] [-q | --quiet] [-s | --silent] [-h | --help] + + Reverses agents-init in the current project, and marks the project so + agents-init -- and therefore every claude/agy launch -- leaves it + alone from then on. + + Every symlink that resolves into AGENTS/ is replaced by the real file + or directory it points to. When two links share a target (docs/plans + and docs/superpowers/plans), the shallower one receives the content + and the other is removed; a link to a target holding only .gitkeep is + removed with nothing put in its place. An AGENTS.md that is exactly + the stub agents-init writes is deleted; any other AGENTS.md loses only + the SYSTEM DIRECTIVE blockquote that pointed agents at AGENTS/AGENTS.md. + No CLAUDE.md is recreated. + + Before anything is moved, pending AGENTS/ changes are committed and + the full history is written to a verified git bundle under + $XDG_STATE_HOME/agents-cleanup/ (default ~/.local/state). AGENTS/ is + then removed, along with docs/superpowers/ and docs/ if left empty, + and every "Added by agents-init" block is stripped from .gitignore. + Nothing is committed to the outer repository. + + Files inside AGENTS/ that no project symlink points to -- other than + agents-init's own .version, .agents-tools/ and .gitkeep files -- stop + the cleanup before anything changes. They are listed; --drop-extras + discards them instead. + + The disabled marker is the per-clone git config key + agents-init.disabled, set on every run. --marker-file also writes + .agents-disabled in the project root, which agents-init honors too and + which may be committed to opt every clone out; it is the only marker + available outside a git repository. In a project with no AGENTS/, only + the marker is set -- a pre-emptive opt-out. agents-init --enable + clears the git key again. + + Re-running is safe: an interrupted cleanup resumes where it stopped, + and a finished one only confirms the marker. + + Arguments: + -n, --dry-run Print the plan and change nothing + --drop-extras Discard unlinked files in AGENTS/ instead of refusing + --marker-file Also write .agents-disabled (required outside git) + -v, --verbose Print all per-step output (default) + -q, --quiet Print one summary line only if changes were made + -s, --silent Suppress all output; errors only + -h, --help Show this help message and exit + + Exit Status: + 0 Cleanup finished, or nothing was left to do + 1 Refused (outside git without --marker-file, unresolved rebase in + AGENTS/, unlinked files in AGENTS/) or a step failed + + Notes: + Restore an archived AGENTS/ with: git clone AGENTS, then + agents-init --enable. The full write-up -- what is kept, what is + removed, and how the markers interact -- is in + docs/manual/16-agent-tooling.md. Update that section in the same + change whenever this function's behavior changes. + + Example: + agents-cleanup --dry-run + agents-cleanup + agents-cleanup --marker-file + +**Dependencies:** `_agents_init_find`, `_agents_init_stub`, `_agents_repo_slug`, `_agents_repo_sync` + +**Classification:** `destructive`, `self-limiting(rm,mkdir,grep)`, `bypasses-shadow(mv)`, `manual-section(16-agent-tooling)` + +**See also:** 16. AI AGENT TOOLING (`docs/manual/16-agent-tooling.md`) + ### agents-init - Synopsis: agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] - [-q | --quiet] [-s | --silent] [-h | --help] + Synopsis: agents-init [-a | --agents] [-p | --plugins] [-e | --enable] + [-v | --verbose] [-q | --quiet] [-s | --silent] [-h | --help] Scaffolds an AGENTS/ sub-repository inside a project directory. Creates a self-contained git repo for agent specifications, moves any existing @@ -2495,6 +2568,13 @@ functions). They are active in all interactive sessions. no-op, so running an agent CLI in an arbitrary directory does not create a repository there. + A project marked disabled is skipped entirely. agents-cleanup sets the + per-clone git config key agents-init.disabled; a .agents-disabled file + in the project root, which a team may commit, has the same effect. + Either one turns every wrapper launch into a silent no-op there. + --enable clears the git key and scaffolds; the file has to be deleted + by hand, because it is a decision shared with every clone. + File layout after setup: AGENTS/AGENTS.md canonical root agent spec (real file) AGENTS//AGENTS.md canonical spec for any subdir with its own @@ -2555,6 +2635,8 @@ functions). They are active in all interactive sessions. -a, --agents Set up AGENTS/ repo + AGENTS.md symlinks (root and every discovered subdirectory) only -p, --plugins Set up AGENTS/ repo + plans/specs/devlogs dirs + docs/ symlinks only + -e, --enable Clear the git key agents-cleanup set, then scaffold as + normal (refused while .agents-disabled exists) -v, --verbose Print all per-step output (default) -q, --quiet Print one summary line only if changes were made -s, --silent Suppress all output; errors only (standard UNIX convention) @@ -2563,7 +2645,8 @@ functions). They are active in all interactive sessions. Exit Status: 0 Setup completed successfully 1 Fatal error (git init failed, move failed, the AGENTS/ commit was - rejected, or an unresolved rebase blocked it) + rejected, or an unresolved rebase blocked it), or --enable refused + because .agents-disabled exists Notes: This header covers usage only. The full concept/behavior/purpose @@ -2580,7 +2663,7 @@ functions). They are active in all interactive sessions. agents-init --plugins agents-init --quiet -**Dependencies:** `_agents_init_sync_instructions`, `_agents_repo_install_tools`, `_agents_repo_sync`, `_agents_init_ensure_gitignore` +**Dependencies:** `_agents_init_find`, `_agents_init_sync_instructions`, `_agents_repo_install_tools`, `_agents_repo_sync`, `_agents_init_ensure_gitignore` **Classification:** `self-limiting(rm,mkdir,grep)`, `bypasses-shadow(mv)`, `manual-section(16-agent-tooling)` @@ -5214,7 +5297,7 @@ Antigravity/agy) working in a project checked out from this configuration: where their instructions live, how they get there, and the safety rules that keep an agent's launch-time bookkeeping from touching a repository's own tracked history. Command-line usage for the functions named here -(`agents-init`, `agents-vault`) is generated from their own doc headers — +(`agents-init`, `agents-cleanup`, `agents-vault`) is generated from their own doc headers — see Section 5. @@ -5479,6 +5562,75 @@ real directory by that name — nothing forces those paths to exist for a project that never used them. +## Opting a project out: agents-cleanup + +`agents-cleanup` reverses everything `agents-init` did in a project and +stops it from happening again. Run it from anywhere inside the project: + + agents-cleanup --dry-run + agents-cleanup + +Every symlink that points into `AGENTS/` is replaced by the real file or +directory it points to, so the project ends up with ordinary files where +the links were. Where two links shared one directory (`docs/plans` and +`docs/superpowers/plans`), the shallower one — the location that existed +before `agents-init` — gets the content and the other link is removed. A +link to a directory holding nothing but `.gitkeep` is simply removed. No +`CLAUDE.md` is recreated. + +A root `AGENTS.md` that is exactly the seed file `agents-init` writes for +a fresh project is deleted, since it never held anything of yours. Any +other `AGENTS.md` keeps its content and loses only the "SYSTEM DIRECTIVE" +blockquote telling agents to edit `AGENTS/AGENTS.md` — a directory that no +longer exists. + +Before anything moves, pending changes in `AGENTS/` are committed and the +whole history is written to a verified git bundle under +`~/.local/state/agents-cleanup/` (or `$XDG_STATE_HOME/agents-cleanup/`). +An `AGENTS/` that is not a git repository has no bundle, so `--drop-extras` +there deletes the files for good. +Then `AGENTS/` is removed, along with `docs/superpowers/` and `docs/` if +they are left empty, and every `Added by agents-init` block is stripped +from `.gitignore`. Nothing is committed to the project itself: the +restored files show up as ordinary changes for you to commit or not. + +WARNING: Files inside `AGENTS/` that no project link points to — notes, +scratch files, anything you put there by hand — stop the cleanup before +it changes anything, and are listed. Move them out yourself, or pass +`--drop-extras` to discard them. `agents-cleanup` refuses to run when +`AGENTS/` is itself a symlink to a directory elsewhere, since removing it +would remove that directory; replace the link with a real directory first. +Discarded files survive only in the bundle, and files `AGENTS/.gitignore` +ignores are not in the bundle at all; the listing marks those. + +### The disabled marker + +`agents-init` skips a project, silently on every `claude`/`agy` launch, +when either marker is present: + +- The git config key `agents-init.disabled`, which `agents-cleanup` always + sets. It lives in `.git/config`: it is never committed, applies to this + clone only, and survives moving or renaming the project. A fresh clone + is scaffolded again on its first launch. +- A `.agents-disabled` file in the project root, written by + `agents-cleanup --marker-file`. It is not committed for you; commit it + to opt every clone out. Outside a git repository it is the only marker + available, so there `--marker-file` is required. + +Running `agents-cleanup` in a project that was never scaffolded only sets +the marker — a way to opt a project out in advance. + +### Undoing a cleanup + + git clone ~/.local/state/agents-cleanup/-.bundle AGENTS + agents-init --enable + +`agents-init --enable` clears the git key and scaffolds as usual, +re-linking the files restored from the bundle. It refuses while +`.agents-disabled` exists: that file is a decision shared with every +clone, so delete it (and commit the deletion) by hand. + + ## The launch lifecycle The `claude` and `agy` wrapper functions each run `agents-init --quiet` diff --git a/docs/manual/16-agent-tooling.md b/docs/manual/16-agent-tooling.md index 30cb976..bb6db7c 100644 --- a/docs/manual/16-agent-tooling.md +++ b/docs/manual/16-agent-tooling.md @@ -6,6 +6,7 @@ sidebar: helpKeywords: - agent - agents-init +- agents-cleanup - agents-vault - AGENTS.md - claude-code @@ -17,7 +18,7 @@ Antigravity/agy) working in a project checked out from this configuration: where their instructions live, how they get there, and the safety rules that keep an agent's launch-time bookkeeping from touching a repository's own tracked history. Command-line usage for the functions named here -(`agents-init`, `agents-vault`) is generated from their own doc headers — +(`agents-init`, `agents-cleanup`, `agents-vault`) is generated from their own doc headers — see Section 5. @@ -282,6 +283,75 @@ real directory by that name — nothing forces those paths to exist for a project that never used them. +## Opting a project out: agents-cleanup + +`agents-cleanup` reverses everything `agents-init` did in a project and +stops it from happening again. Run it from anywhere inside the project: + + agents-cleanup --dry-run + agents-cleanup + +Every symlink that points into `AGENTS/` is replaced by the real file or +directory it points to, so the project ends up with ordinary files where +the links were. Where two links shared one directory (`docs/plans` and +`docs/superpowers/plans`), the shallower one — the location that existed +before `agents-init` — gets the content and the other link is removed. A +link to a directory holding nothing but `.gitkeep` is simply removed. No +`CLAUDE.md` is recreated. + +A root `AGENTS.md` that is exactly the seed file `agents-init` writes for +a fresh project is deleted, since it never held anything of yours. Any +other `AGENTS.md` keeps its content and loses only the "SYSTEM DIRECTIVE" +blockquote telling agents to edit `AGENTS/AGENTS.md` — a directory that no +longer exists. + +Before anything moves, pending changes in `AGENTS/` are committed and the +whole history is written to a verified git bundle under +`~/.local/state/agents-cleanup/` (or `$XDG_STATE_HOME/agents-cleanup/`). +An `AGENTS/` that is not a git repository has no bundle, so `--drop-extras` +there deletes the files for good. +Then `AGENTS/` is removed, along with `docs/superpowers/` and `docs/` if +they are left empty, and every `Added by agents-init` block is stripped +from `.gitignore`. Nothing is committed to the project itself: the +restored files show up as ordinary changes for you to commit or not. + +WARNING: Files inside `AGENTS/` that no project link points to — notes, +scratch files, anything you put there by hand — stop the cleanup before +it changes anything, and are listed. Move them out yourself, or pass +`--drop-extras` to discard them. `agents-cleanup` refuses to run when +`AGENTS/` is itself a symlink to a directory elsewhere, since removing it +would remove that directory; replace the link with a real directory first. +Discarded files survive only in the bundle, and files `AGENTS/.gitignore` +ignores are not in the bundle at all; the listing marks those. + +### The disabled marker + +`agents-init` skips a project, silently on every `claude`/`agy` launch, +when either marker is present: + +- The git config key `agents-init.disabled`, which `agents-cleanup` always + sets. It lives in `.git/config`: it is never committed, applies to this + clone only, and survives moving or renaming the project. A fresh clone + is scaffolded again on its first launch. +- A `.agents-disabled` file in the project root, written by + `agents-cleanup --marker-file`. It is not committed for you; commit it + to opt every clone out. Outside a git repository it is the only marker + available, so there `--marker-file` is required. + +Running `agents-cleanup` in a project that was never scaffolded only sets +the marker — a way to opt a project out in advance. + +### Undoing a cleanup + + git clone ~/.local/state/agents-cleanup/-.bundle AGENTS + agents-init --enable + +`agents-init --enable` clears the git key and scaffolds as usual, +re-linking the files restored from the bundle. It refuses while +`.agents-disabled` exists: that file is a decision shared with every +clone, so delete it (and commit the deletion) by hand. + + ## The launch lifecycle The `claude` and `agy` wrapper functions each run `agents-init --quiet` -- 2.54.0 From e7683d1379e73604cd7b5cf6adcbaa21817aeac5 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 17:25:28 -0400 Subject: [PATCH 09/11] fix(agents-repo-sync): refuse a .git that is not a valid gitdir A half-removed AGENTS/.git passed the test -d guard, so git walked up and committed (and bundled) the enclosing repository. Require rev-parse --git-dir to report .git; agents-cleanup uses the same test. --- functions/_agents_repo_sync.fish | 14 ++++++++++---- functions/agents-cleanup.fish | 4 +++- tests/test-agents-cleanup.fish | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish index eb2772b..5832ef4 100644 --- a/functions/_agents_repo_sync.fish +++ b/functions/_agents_repo_sync.fish @@ -6,7 +6,9 @@ # # DESCRIPTION # Stages everything in and commits it with . Shared by -# agents-init and agents-vault. +# agents-init, agents-vault and agents-cleanup. must be the root of +# its own repository: a .git directory that is not a valid gitdir is +# refused, because git would otherwise commit the enclosing repository. # # It never touches the network, and that is the point rather than an # omission. Both callers run on every agent launch, synchronously, ahead @@ -36,7 +38,8 @@ # # EXIT STATUS # 0 Committed, or nothing needed committing -# 1 is not a git repository, arguments were missing, or the commit +# 1 is not the root of its own git repository (a broken .git +# directory inside another repository counts as not), arguments were missing, or the commit # itself failed (e.g. a pre-commit/commit-msg hook rejected it) # 2 A rebase is in progress; nothing committed, nothing touched # @@ -48,9 +51,12 @@ # _agents_repo_sync /path/to/AGENTS "chore: sync AGENTS repository" function _agents_repo_sync --argument-names dir msg test -n "$dir" -a -n "$msg"; or return 1 - test -d "$dir/.git"; or return 1 + # Not `test -d "$dir/.git"`: a half-deleted .git directory passes that, and + # git then walks up, finds the enclosing repository and commits *that*. + # A valid repository rooted at reports its git dir as plain ".git". + test "$(git -C "$dir" rev-parse --git-dir 2>/dev/null)" = .git; or return 1 - # The guard above proved .git is a directory, so these are the same two + # The guard above proved has its own .git, so these are the same two # paths `agents-vault --status` reports an unresolved rebase from. if test -d "$dir/.git/rebase-merge"; or test -d "$dir/.git/rebase-apply" echo "_agents_repo_sync: unresolved rebase in $dir; nothing committed" >&2 diff --git a/functions/agents-cleanup.fish b/functions/agents-cleanup.fish index 6e4d905..c4feacc 100644 --- a/functions/agents-cleanup.fish +++ b/functions/agents-cleanup.fish @@ -136,7 +136,9 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc # Resolved, because link targets are compared after realpath, which # also resolves any symlinked parent of $root. set agents_dir (realpath -- "$agents_dir") - test -d "$agents_dir/.git"; and set has_repo 1 + # A .git that is not a valid gitdir (half-removed) is "not a repository"; + # git would otherwise resolve to the enclosing project. + test "$(git -C "$agents_dir" rev-parse --git-dir 2>/dev/null)" = .git; and set has_repo 1 end if test $has_repo -eq 1 diff --git a/tests/test-agents-cleanup.fish b/tests/test-agents-cleanup.fish index a1d4606..7e72ab2 100644 --- a/tests/test-agents-cleanup.fish +++ b/tests/test-agents-cleanup.fish @@ -379,5 +379,19 @@ end check "directive-only: deleted" false (test -e $v1/AGENTS.md -o -L $v1/AGENTS.md; and echo true; or echo false) check "no directive: content identical" "$v2before" (string collect <$v2/AGENTS.md) +section "agents-cleanup: invalid AGENTS/.git never commits the outer repo" +fresh_state +set -l i1 (scaffolded_repo) +echo secret >$i1/untracked-secret.txt +rm -rf $i1/AGENTS/.git/HEAD $i1/AGENTS/.git/objects +set -l i1before (git -C $i1 rev-list --all | count) +pushd $i1 >/dev/null +set -l i1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "invalid .git: exits 0" 0 "$i1rc" +check "invalid .git: outer repo untouched" "$i1before" (git -C $i1 rev-list --all | count) +check "invalid .git: no bundle" 0 (count $XDG_STATE_HOME/agents-cleanup/*.bundle 2>/dev/null) +check "invalid .git: AGENTS/ removed" false (test -e $i1/AGENTS; and echo true; or echo false) + cleanup report -- 2.54.0 From 5e4d0fa4c863f1527084ee9a68b0b8ce2e282d28 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 17:28:50 -0400 Subject: [PATCH 10/11] fix(agents-cleanup): address final review findings - keep the verbose log intact: awk writes /dev/stderr itself instead of a fish-level reopen that truncates a log-file stderr - refuse nested git repositories in AGENTS/ even with --drop-extras - drop a link to AGENTS/ itself instead of moving the whole repository - inventory links even when AGENTS/ is already gone (inside git only) - never overwrite a same-second bundle; report a failed link restore - quiet summary includes the restore command - tests: hermetic git config, skip chmod cases as root, .gitignore order --- functions/agents-cleanup.fish | 91 +++++++++++++++----- tests/test-agents-cleanup.fish | 152 +++++++++++++++++++++++++++------ 2 files changed, 199 insertions(+), 44 deletions(-) diff --git a/functions/agents-cleanup.fish b/functions/agents-cleanup.fish index c4feacc..128c4f6 100644 --- a/functions/agents-cleanup.fish +++ b/functions/agents-cleanup.fish @@ -22,8 +22,10 @@ # Every symlink that resolves into AGENTS/ is replaced by the real file # or directory it points to. When two links share a target (docs/plans # and docs/superpowers/plans), the shallower one receives the content -# and the other is removed; a link to a target holding only .gitkeep is -# removed with nothing put in its place. An AGENTS.md that is exactly +# and the other is removed; a link to a target holding only .gitkeep, a +# dangling link, or a link to AGENTS/ itself is removed with nothing put +# in its place. Dangling links are removed even when AGENTS/ is already +# gone. An AGENTS.md that is exactly # the stub agents-init writes is deleted; any other AGENTS.md loses only # the SYSTEM DIRECTIVE blockquote that pointed agents at AGENTS/AGENTS.md. # No CLAUDE.md is recreated. @@ -38,14 +40,17 @@ # Files inside AGENTS/ that no project symlink points to -- other than # agents-init's own .version, .agents-tools/ and .gitkeep files -- stop # the cleanup before anything changes. They are listed; --drop-extras -# discards them instead. +# discards them instead. A nested git repository inside AGENTS/ is always +# refused, --drop-extras or not: the bundle keeps only a pointer to it, so +# move it out first. # # The disabled marker is the per-clone git config key # agents-init.disabled, set on every run. --marker-file also writes # .agents-disabled in the project root, which agents-init honors too and # which may be committed to opt every clone out; it is the only marker -# available outside a git repository. In a project with no AGENTS/, only -# the marker is set -- a pre-emptive opt-out. agents-init --enable +# available outside a git repository, where the project root is taken to +# be the current directory -- run it from there. In a project with no +# AGENTS/, only the marker is set -- a pre-emptive opt-out. agents-init --enable # clears the git key again. # # Re-running is safe: an interrupted cleanup resumes where it stopped, @@ -63,7 +68,8 @@ # EXIT STATUS # 0 Cleanup finished, or nothing was left to do # 1 Refused (outside git without --marker-file, unresolved rebase in -# AGENTS/, unlinked files in AGENTS/) or a step failed +# AGENTS/, unlinked files or a nested repository in AGENTS/) or a step +# failed # # EXAMPLE # agents-cleanup --dry-run @@ -154,7 +160,12 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc set -l keep # links to materialize set -l keep_tgt # their resolved targets, index-aligned with $keep set -l drop # links removed with nothing put in their place - if test $has_agents -eq 1 + # Also runs with no AGENTS/ (deleted by hand): every such link then + # dangles and is dropped. Not outside git, though: there (pwd) is only a + # guess at the project root, so without an AGENTS/ in it the walk could + # cover an arbitrary tree. + if test $has_agents -eq 1; or test $in_git -eq 1 + test $has_agents -eq 1; or set agents_dir (realpath -m -- "$agents_dir") set -l agents_re '^'(string escape --style=regex -- "$agents_dir")'(/|$)' set -l links for l in (_agents_init_find "$root" -type l -print) @@ -168,6 +179,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc set -l t (realpath -m -- "$l") if not test -e "$t" set -a drop "$l" # dangling + else if test "$t" = "$agents_dir" + # A link to AGENTS/ itself: materializing it would move the + # whole repository. Drop-only, like any link with nothing to restore. + set -a drop "$l" else if contains -- "$t" $keep_tgt set -a drop "$l" # duplicate of a shallower link else if test -d "$t"; and test -z "$(find "$t" ! -type d ! -name .gitkeep -print -quit)" @@ -183,12 +198,26 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc # own tooling. Refused by default: nothing says where they belong. set -l extras set -l extras_ignored + set -l nested if test $has_agents -eq 1 set -l cover_re for t in $keep_tgt set -a cover_re '^'(string escape --style=regex -- "$t")'(/|$)' end - for f in (find "$agents_dir" -name .git -prune -o ! -type d -print) + # A nested repository is recorded by `git add -A` as a bare gitlink, + # so neither the bundle nor --drop-extras treats it as files: it is + # listed on its own and never walked into. + for f in (find "$agents_dir" -mindepth 1 -name .git -prune -o -type d -exec test -e '{}/.git' \; -print -prune) + set -l covered 0 + for re in $cover_re + if string match -qr -- $re "$f" + set covered 1 + break + end + end + test $covered -eq 1; or set -a nested (string replace -- "$agents_dir/" "" "$f") + end + for f in (find "$agents_dir" -mindepth 1 \( -name .git -o -type d -exec test -e '{}/.git' \; \) -prune -o ! -type d -print) set -l rel (string replace -- "$agents_dir/" "" "$f") switch "$rel" case .version '.agents-tools/*' .gitkeep '*/.gitkeep' @@ -209,6 +238,15 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc end end + if set -q nested[1] + echo "$c_err""Error: AGENTS/ holds nested git repositories; the history bundle records only a pointer to them, so they would be lost:$c_reset" >&2 + for n in $nested + echo " AGENTS/$n/ (nested repository -- not in bundle)" >&2 + end + echo "Move them out of AGENTS/ first; --drop-extras does not cover them." >&2 + return 1 + end + if set -q extras[1]; and not set -q _flag_drop_extras echo "$c_err""Error: AGENTS/ holds files no project symlink points to:$c_reset" >&2 for e in $extras @@ -230,7 +268,14 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc set -l state_dir $XDG_STATE_HOME test -n "$state_dir"; or set state_dir "$HOME/.local/state" - set -l bundle "$state_dir/agents-cleanup/"(_agents_repo_slug "$root")"-"(date +%Y%m%d-%H%M%S)".bundle" + set -l bundle_base "$state_dir/agents-cleanup/"(_agents_repo_slug "$root")"-"(date +%Y%m%d-%H%M%S) + # `git bundle create` overwrites: never reuse a name from the same second. + set -l bundle "$bundle_base.bundle" + set -l n 0 + while test -e "$bundle" + set n (math $n + 1) + set bundle "$bundle_base-$fish_pid-$n.bundle" + end set -l gitignore "$root/.gitignore" if set -q _flag_dry_run @@ -343,8 +388,13 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc if not command mv "$keep_tgt[$i]" "$link" # Put the link back: without it the target looks like an unlinked # extra and a re-run would refuse. - ln -s -- "$was" "$link" - echo "$c_err""Error: could not restore $rel from AGENTS/; link left in place$c_reset" >&2 + if ln -s -- "$was" "$link" + echo "$c_err""Error: could not restore $rel from AGENTS/; link left in place$c_reset" >&2 + else + set -l where "AGENTS/"(string replace -- "$agents_dir/" "" "$keep_tgt[$i]") + test -n "$archived"; and set where "$where and in the bundle" + echo "$c_err""Error: could not restore $rel and could not put its link back; the content is still at $where$c_reset" >&2 + end return 1 end set changed 1 @@ -414,7 +464,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc end set changed 1 test $verbose -eq 1; and echo "$c_ok→ Removed AGENTS/$c_reset" - # agents-init creates these; drop them only if nothing else lives there. + end + # agents-init creates these; drop them only if nothing else lives there. + # Also after dropping links with no AGENTS/ left, which empties them too. + if test $has_agents -eq 1; or set -q drop[1] for d in "$root/docs/superpowers" "$root/docs" if test -d "$d"; and rmdir "$d" 2>/dev/null test $verbose -eq 1; and echo "$c_ok→ Removed empty "(string replace -- "$root/" "" "$d")"/$c_reset" @@ -428,10 +481,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc # footer before the next header is left in place (and reported). if test -f "$gitignore"; and grep -q 'Added by agents-init' "$gitignore" # The unterminated-block warning is verbose-only, so a quiet or silent - # re-run stays silent. - set -l awk_err /dev/stderr - test $verbose -eq 1; or set awk_err /dev/null - set -l kept (awk ' + # re-run stays silent. awk writes /dev/stderr itself: a fish-level + # 2>/dev/stderr reopens the path, which truncates a log file when + # stderr is one (`agents-cleanup &>log`). + set -l kept (awk -v warn=$verbose ' { line[NR] = $0 } END { n = 0 @@ -446,12 +499,12 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc i = j continue } - print "agents-cleanup: unterminated agents-init block in .gitignore left in place" > "/dev/stderr" + if (warn) print "agents-cleanup: unterminated agents-init block in .gitignore left in place" > "/dev/stderr" } out[++n] = line[i] } for (i = 1; i <= n; i++) print out[i] - }' "$gitignore" 2>$awk_err) + }' "$gitignore") if test $status -ne 0 echo "$c_err""Error: could not rewrite .gitignore$c_reset" >&2 return 1 @@ -486,7 +539,7 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc test $verbose -eq 1; and echo "$c_dim→ Nothing to clean up; agents-init is already disabled here$c_reset" else if test $quiet -eq 1 if test -n "$archived" - echo "$c_ok→ Cleaned up AGENTS scaffolding (history: $archived)$c_reset" + echo "$c_ok→ Cleaned up AGENTS scaffolding (history: $archived; restore with: git clone $archived AGENTS)$c_reset" else echo "$c_ok→ Cleaned up AGENTS scaffolding$c_reset" end diff --git a/tests/test-agents-cleanup.fish b/tests/test-agents-cleanup.fish index 7e72ab2..6eef668 100644 --- a/tests/test-agents-cleanup.fish +++ b/tests/test-agents-cleanup.fish @@ -19,6 +19,10 @@ set -gx GIT_AUTHOR_NAME t set -gx GIT_AUTHOR_EMAIL t@t set -gx GIT_COMMITTER_NAME t set -gx GIT_COMMITTER_EMAIL t@t +# No user or system config: a developer's global hooks, signing or +# init.defaultBranch must not reach these repos. +set -gx GIT_CONFIG_GLOBAL /dev/null +set -gx GIT_CONFIG_NOSYSTEM 1 set -gx GIT_CONFIG_COUNT 2 set -gx GIT_CONFIG_KEY_0 commit.gpgsign set -gx GIT_CONFIG_VALUE_0 false @@ -295,34 +299,42 @@ check "rejected commit: no bundle" 0 (count $fb) section "agents-cleanup: failed move keeps the link, re-run resumes" fresh_state -set -l w1 (scaffolded_repo) -chmod a-w $w1/AGENTS/functions -pushd $w1 >/dev/null -set -l w1rc (agents-cleanup --silent 2>/dev/null; echo $status) -popd >/dev/null -check "failed mv: exits 1" 1 "$w1rc" -check "failed mv: link still a symlink" true (test -L $w1/functions/AGENTS.md; and echo true; or echo false) -chmod u+w $w1/AGENTS/functions -pushd $w1 >/dev/null -set -l w2rc (agents-cleanup --silent 2>/dev/null; echo $status) -popd >/dev/null -check "failed mv re-run: exits 0" 0 "$w2rc" -check "failed mv re-run: real file, content kept" user-scoped (test -L $w1/functions/AGENTS.md; or string collect <$w1/functions/AGENTS.md) +if test (id -u) -eq 0 + echo " SKIP failed move: chmod cannot block root" +else + set -l w1 (scaffolded_repo) + chmod a-w $w1/AGENTS/functions + pushd $w1 >/dev/null + set -l w1rc (agents-cleanup --silent 2>/dev/null; echo $status) + popd >/dev/null + check "failed mv: exits 1" 1 "$w1rc" + check "failed mv: link still a symlink" true (test -L $w1/functions/AGENTS.md; and echo true; or echo false) + chmod u+w $w1/AGENTS/functions + pushd $w1 >/dev/null + set -l w2rc (agents-cleanup --silent 2>/dev/null; echo $status) + popd >/dev/null + check "failed mv re-run: exits 0" 0 "$w2rc" + check "failed mv re-run: real file, content kept" user-scoped (test -L $w1/functions/AGENTS.md; or string collect <$w1/functions/AGENTS.md) +end section "agents-cleanup: failed directive rewrite keeps AGENTS/" fresh_state -set -l k1 (new_repo) -printf '%s\n' '# Title' '' '> **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> x' '' body >$k1/AGENTS.md -pushd $k1 >/dev/null -agents-init --silent 2>/dev/null -popd >/dev/null -chmod a-w $k1/AGENTS/AGENTS.md -pushd $k1 >/dev/null -set -l k1rc (agents-cleanup --silent 2>/dev/null; echo $status) -popd >/dev/null -chmod u+w $k1/AGENTS/AGENTS.md $k1/AGENTS.md 2>/dev/null -check "failed rewrite: exits 1" 1 "$k1rc" -check "failed rewrite: AGENTS/ kept" true (test -d $k1/AGENTS; and echo true; or echo false) +if test (id -u) -eq 0 + echo " SKIP failed rewrite: chmod cannot block root" +else + set -l k1 (new_repo) + printf '%s\n' '# Title' '' '> **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> x' '' body >$k1/AGENTS.md + pushd $k1 >/dev/null + agents-init --silent 2>/dev/null + popd >/dev/null + chmod a-w $k1/AGENTS/AGENTS.md + pushd $k1 >/dev/null + set -l k1rc (agents-cleanup --silent 2>/dev/null; echo $status) + popd >/dev/null + chmod u+w $k1/AGENTS/AGENTS.md $k1/AGENTS.md 2>/dev/null + check "failed rewrite: exits 1" 1 "$k1rc" + check "failed rewrite: AGENTS/ kept" true (test -d $k1/AGENTS; and echo true; or echo false) +end section "agents-cleanup: unterminated .gitignore block is left alone, quietly" fresh_state @@ -393,5 +405,95 @@ check "invalid .git: outer repo untouched" "$i1before" (git -C $i1 rev-list --al check "invalid .git: no bundle" 0 (count $XDG_STATE_HOME/agents-cleanup/*.bundle 2>/dev/null) check "invalid .git: AGENTS/ removed" false (test -e $i1/AGENTS; and echo true; or echo false) +section "agents-cleanup: verbose output survives a log-file stderr" +fresh_state +set -l o1 (scaffolded_repo) +set -l o1log (mktemp) +set -a TMPDIRS $o1log +# A subshell: only a shell whose own stderr is the file reproduces the +# truncation (an in-process redirect is emulated and never reopens it). +fish -c "set -p fish_function_path $repo_root/functions; cd $o1; agents-cleanup" >$o1log 2>&1 +check "log file: archive line kept" true (string match -q -- '*Archived AGENTS/ history*' (string collect <$o1log); and echo true; or echo false) +check "log file: restore hint kept" true (string match -q -- '*restore with: git clone*' (string collect <$o1log); and echo true; or echo false) + +section "agents-cleanup: nested repository under AGENTS/ is never dropped" +fresh_state +set -l q1 (scaffolded_repo) +mkdir -p $q1/AGENTS/scratch/tool +git -C $q1/AGENTS/scratch/tool init -q +echo precious >$q1/AGENTS/scratch/tool/file.txt +set -l q1before (tree_hash $q1) +pushd $q1 >/dev/null +set -l q1err (agents-cleanup --drop-extras 2>&1 >/dev/null) +set -l q1rc $status +popd >/dev/null +check "nested repo: exits 1 even with --drop-extras" 1 "$q1rc" +check "nested repo: tree unchanged" "$q1before" (tree_hash $q1) +check "nested repo: file survives" precious (string collect <$q1/AGENTS/scratch/tool/file.txt) +check "nested repo: message names it" true (string match -q -- '*AGENTS/scratch/tool/*nested repository*' "$q1err"; and echo true; or echo false) +check "nested repo: git key not set" 1 (git -C $q1 config --get agents-init.disabled >/dev/null; echo $status) + +section "agents-cleanup: a link to AGENTS/ itself is dropped, not moved" +fresh_state +set -l z1 (scaffolded_repo) +ln -s ../AGENTS $z1/docs/agents +pushd $z1 >/dev/null +set -l z1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "self link: exits 0" 0 "$z1rc" +check "self link: docs/agents gone" false (test -e $z1/docs/agents -o -L $z1/docs/agents; and echo true; or echo false) +check "self link: docs/plans restored real" a-plan (test -L $z1/docs/plans; or string collect <$z1/docs/plans/p.md) + +section "agents-cleanup: AGENTS/ deleted by hand, dangling links still go" +fresh_state +set -l h1 (scaffolded_repo) +rm -rf $h1/AGENTS +pushd $h1 >/dev/null +set -l h1dry (agents-cleanup --dry-run 2>/dev/null) +set -l h1rc (agents-cleanup --silent 2>/dev/null; echo $status) +popd >/dev/null +check "no AGENTS/, dangling: dry-run plans removing links" true (string match -q -- '*remove link AGENTS.md*' "$h1dry"; and echo true; or echo false) +check "no AGENTS/, dangling: exits 0" 0 "$h1rc" +check "no AGENTS/, dangling: AGENTS.md link gone" false (test -e $h1/AGENTS.md -o -L $h1/AGENTS.md; and echo true; or echo false) +check "no AGENTS/, dangling: docs/superpowers gone" false (test -e $h1/docs/superpowers -o -L $h1/docs/superpowers; and echo true; or echo false) +check "no AGENTS/, dangling: git key set" true (git -C $h1 config --type=bool --get agents-init.disabled) + +section "agents-cleanup: outside git, no AGENTS/ here: links are not inventoried" +fresh_state +set -l b1 (mktemp -d) +set -a TMPDIRS $b1 +mkdir -p $b1/sub +ln -s ../AGENTS/x $b1/sub/dangling +pushd $b1 >/dev/null +set -l b1rc (set -lx GIT_CEILING_DIRECTORIES (path dirname $b1); agents-cleanup --marker-file --silent 2>/dev/null; echo $status) +popd >/dev/null +check "non-git no AGENTS/: exits 0" 0 "$b1rc" +check "non-git no AGENTS/: stray link untouched" true (test -L $b1/sub/dangling; and echo true; or echo false) + +section "agents-cleanup: quiet summary carries the restore command" +fresh_state +set -l y1 (scaffolded_repo) +pushd $y1 >/dev/null +set -l y1out (agents-cleanup --quiet 2>&1) +popd >/dev/null +check "quiet: names the restore command" true (string match -q -- '*git clone *.bundle AGENTS*' "$y1out"; and echo true; or echo false) + +section "agents-cleanup: user .gitignore lines around two agents-init blocks" +fresh_state +set -l w9 (new_repo) +pushd $w9 >/dev/null +agents-init --silent 2>/dev/null +popd >/dev/null +set -l blk (string collect <$w9/.gitignore) +printf '%s\n' before1 before2 >$w9/.gitignore +printf '%s\n' "$blk" >>$w9/.gitignore +printf '%s\n' between >>$w9/.gitignore +printf '%s\n' "$blk" >>$w9/.gitignore +printf '%s\n' after1 after2 >>$w9/.gitignore +pushd $w9 >/dev/null +agents-cleanup --silent 2>/dev/null +popd >/dev/null +check "gitignore: exactly the user lines remain, in order" (printf '%s\n' before1 before2 between after1 after2 | string collect) (string collect <$w9/.gitignore) + cleanup report -- 2.54.0 From 88b43d582f7f573fed3c9a3197034f199fe52a39 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 17:29:07 -0400 Subject: [PATCH 11/11] docs(manual): agents-cleanup nested repos, link edge cases, non-git root --- docs/fish-config.md | 51 +++++++++++++++++++++------------ docs/manual/16-agent-tooling.md | 33 ++++++++++++--------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/docs/fish-config.md b/docs/fish-config.md index 9c5c171..9e0acaf 100644 --- a/docs/fish-config.md +++ b/docs/fish-config.md @@ -2482,8 +2482,10 @@ functions). They are active in all interactive sessions. Every symlink that resolves into AGENTS/ is replaced by the real file or directory it points to. When two links share a target (docs/plans and docs/superpowers/plans), the shallower one receives the content - and the other is removed; a link to a target holding only .gitkeep is - removed with nothing put in its place. An AGENTS.md that is exactly + and the other is removed; a link to a target holding only .gitkeep, a + dangling link, or a link to AGENTS/ itself is removed with nothing put + in its place. Dangling links are removed even when AGENTS/ is already + gone. An AGENTS.md that is exactly the stub agents-init writes is deleted; any other AGENTS.md loses only the SYSTEM DIRECTIVE blockquote that pointed agents at AGENTS/AGENTS.md. No CLAUDE.md is recreated. @@ -2498,14 +2500,17 @@ functions). They are active in all interactive sessions. Files inside AGENTS/ that no project symlink points to -- other than agents-init's own .version, .agents-tools/ and .gitkeep files -- stop the cleanup before anything changes. They are listed; --drop-extras - discards them instead. + discards them instead. A nested git repository inside AGENTS/ is always + refused, --drop-extras or not: the bundle keeps only a pointer to it, so + move it out first. The disabled marker is the per-clone git config key agents-init.disabled, set on every run. --marker-file also writes .agents-disabled in the project root, which agents-init honors too and which may be committed to opt every clone out; it is the only marker - available outside a git repository. In a project with no AGENTS/, only - the marker is set -- a pre-emptive opt-out. agents-init --enable + available outside a git repository, where the project root is taken to + be the current directory -- run it from there. In a project with no + AGENTS/, only the marker is set -- a pre-emptive opt-out. agents-init --enable clears the git key again. Re-running is safe: an interrupted cleanup resumes where it stopped, @@ -2523,7 +2528,8 @@ functions). They are active in all interactive sessions. Exit Status: 0 Cleanup finished, or nothing was left to do 1 Refused (outside git without --marker-file, unresolved rebase in - AGENTS/, unlinked files in AGENTS/) or a step failed + AGENTS/, unlinked files or a nested repository in AGENTS/) or a step + failed Notes: Restore an archived AGENTS/ with: git clone AGENTS, then @@ -5565,7 +5571,8 @@ project that never used them. ## Opting a project out: agents-cleanup `agents-cleanup` reverses everything `agents-init` did in a project and -stops it from happening again. Run it from anywhere inside the project: +stops it from happening again. Run it from anywhere inside the project +(from the project root when it is not a git repository): agents-cleanup --dry-run agents-cleanup @@ -5575,33 +5582,39 @@ directory it points to, so the project ends up with ordinary files where the links were. Where two links shared one directory (`docs/plans` and `docs/superpowers/plans`), the shallower one — the location that existed before `agents-init` — gets the content and the other link is removed. A -link to a directory holding nothing but `.gitkeep` is simply removed. No -`CLAUDE.md` is recreated. +link to a directory holding nothing but `.gitkeep`, a dangling link, and a +link to `AGENTS/` itself are simply removed; dangling links go even if you +already deleted `AGENTS/` by hand. No `CLAUDE.md` is recreated. A root `AGENTS.md` that is exactly the seed file `agents-init` writes for a fresh project is deleted, since it never held anything of yours. Any other `AGENTS.md` keeps its content and loses only the "SYSTEM DIRECTIVE" blockquote telling agents to edit `AGENTS/AGENTS.md` — a directory that no -longer exists. +longer exists. If the directive was all it held, the file is deleted. Before anything moves, pending changes in `AGENTS/` are committed and the whole history is written to a verified git bundle under `~/.local/state/agents-cleanup/` (or `$XDG_STATE_HOME/agents-cleanup/`). -An `AGENTS/` that is not a git repository has no bundle, so `--drop-extras` -there deletes the files for good. Then `AGENTS/` is removed, along with `docs/superpowers/` and `docs/` if they are left empty, and every `Added by agents-init` block is stripped -from `.gitignore`. Nothing is committed to the project itself: the -restored files show up as ordinary changes for you to commit or not. +from `.gitignore`. A `.gitignore` that held only those blocks is deleted, +unless it is tracked, in which case it is emptied and the change shows in +`git status`. Nothing is committed to the project itself: the restored +files show up as ordinary changes for you to commit or not. WARNING: Files inside `AGENTS/` that no project link points to — notes, scratch files, anything you put there by hand — stop the cleanup before it changes anything, and are listed. Move them out yourself, or pass -`--drop-extras` to discard them. `agents-cleanup` refuses to run when -`AGENTS/` is itself a symlink to a directory elsewhere, since removing it -would remove that directory; replace the link with a real directory first. -Discarded files survive only in the bundle, and files `AGENTS/.gitignore` -ignores are not in the bundle at all; the listing marks those. +`--drop-extras` to discard them. `--dry-run` refuses the same way, unless +you also give it `--drop-extras`. A git repository nested inside `AGENTS/` +is always refused, even with `--drop-extras`: the bundle records only a +pointer to it, so move it out first. `agents-cleanup` also refuses to run +when `AGENTS/` is itself a symlink to a directory elsewhere, since removing +it would remove that directory; replace the link with a real directory +first. Discarded files survive only in the bundle, and files +`AGENTS/.gitignore` ignores are not in the bundle at all; the listing marks +those. An `AGENTS/` that is not a git repository has no bundle, so +`--drop-extras` there deletes the files for good. ### The disabled marker diff --git a/docs/manual/16-agent-tooling.md b/docs/manual/16-agent-tooling.md index bb6db7c..09ff725 100644 --- a/docs/manual/16-agent-tooling.md +++ b/docs/manual/16-agent-tooling.md @@ -286,7 +286,8 @@ project that never used them. ## Opting a project out: agents-cleanup `agents-cleanup` reverses everything `agents-init` did in a project and -stops it from happening again. Run it from anywhere inside the project: +stops it from happening again. Run it from anywhere inside the project +(from the project root when it is not a git repository): agents-cleanup --dry-run agents-cleanup @@ -296,33 +297,39 @@ directory it points to, so the project ends up with ordinary files where the links were. Where two links shared one directory (`docs/plans` and `docs/superpowers/plans`), the shallower one — the location that existed before `agents-init` — gets the content and the other link is removed. A -link to a directory holding nothing but `.gitkeep` is simply removed. No -`CLAUDE.md` is recreated. +link to a directory holding nothing but `.gitkeep`, a dangling link, and a +link to `AGENTS/` itself are simply removed; dangling links go even if you +already deleted `AGENTS/` by hand. No `CLAUDE.md` is recreated. A root `AGENTS.md` that is exactly the seed file `agents-init` writes for a fresh project is deleted, since it never held anything of yours. Any other `AGENTS.md` keeps its content and loses only the "SYSTEM DIRECTIVE" blockquote telling agents to edit `AGENTS/AGENTS.md` — a directory that no -longer exists. +longer exists. If the directive was all it held, the file is deleted. Before anything moves, pending changes in `AGENTS/` are committed and the whole history is written to a verified git bundle under `~/.local/state/agents-cleanup/` (or `$XDG_STATE_HOME/agents-cleanup/`). -An `AGENTS/` that is not a git repository has no bundle, so `--drop-extras` -there deletes the files for good. Then `AGENTS/` is removed, along with `docs/superpowers/` and `docs/` if they are left empty, and every `Added by agents-init` block is stripped -from `.gitignore`. Nothing is committed to the project itself: the -restored files show up as ordinary changes for you to commit or not. +from `.gitignore`. A `.gitignore` that held only those blocks is deleted, +unless it is tracked, in which case it is emptied and the change shows in +`git status`. Nothing is committed to the project itself: the restored +files show up as ordinary changes for you to commit or not. WARNING: Files inside `AGENTS/` that no project link points to — notes, scratch files, anything you put there by hand — stop the cleanup before it changes anything, and are listed. Move them out yourself, or pass -`--drop-extras` to discard them. `agents-cleanup` refuses to run when -`AGENTS/` is itself a symlink to a directory elsewhere, since removing it -would remove that directory; replace the link with a real directory first. -Discarded files survive only in the bundle, and files `AGENTS/.gitignore` -ignores are not in the bundle at all; the listing marks those. +`--drop-extras` to discard them. `--dry-run` refuses the same way, unless +you also give it `--drop-extras`. A git repository nested inside `AGENTS/` +is always refused, even with `--drop-extras`: the bundle records only a +pointer to it, so move it out first. `agents-cleanup` also refuses to run +when `AGENTS/` is itself a symlink to a directory elsewhere, since removing +it would remove that directory; replace the link with a real directory +first. Discarded files survive only in the bundle, and files +`AGENTS/.gitignore` ignores are not in the bundle at all; the listing marks +those. An `AGENTS/` that is not a git repository has no bundle, so +`--drop-extras` there deletes the files for good. ### The disabled marker -- 2.54.0