From 731661b58181a002092163077fc1de8942aa2c61 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 19:25:01 -0400 Subject: [PATCH 01/12] feat(agents-init): add per-directory AGENTS.md/CLAUDE.md sync helper --- functions/_agents_init_sync_instructions.fish | 201 ++++++++++++++++++ tests/test-agents-init.fish | 146 +++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 functions/_agents_init_sync_instructions.fish create mode 100644 tests/test-agents-init.fish diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish new file mode 100644 index 0000000..86675b7 --- /dev/null +++ b/functions/_agents_init_sync_instructions.fish @@ -0,0 +1,201 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# CLASSIFICATION +# self-limiting(rm,mkdir), bypasses-shadow(mv) +# +# SYNOPSIS +# _agents_init_sync_instructions +# +# DESCRIPTION +# Normalizes one directory's agent instruction file(s) into the +# AGENTS.md-only shape: //AGENTS.md becomes a symlink to the +# real file at //AGENTS.md (or, for the root itself, +# /AGENTS.md directly), and no CLAUDE.md survives anywhere +# for that directory -- neither at the project level nor inside the +# mirror. +# +# Four states of are handled, in order, so later steps only ever +# see a settled mirror: +# +# 1. The mirror itself is inverted (CLAUDE.md real, AGENTS.md symlinked +# to it). Flipped in place: same bytes, new name. +# 2. The mirror has no real AGENTS.md yet, and the project directory +# has one or both files. A lone real file (either name) is adopted +# as the mirror's AGENTS.md -- a lone CLAUDE.md is renamed, never +# preserved under its own name. Both real and byte-identical: the +# AGENTS.md side is adopted and the duplicate CLAUDE.md is dropped. +# Both real and different: neither is touched and a warning is +# printed to stderr -- this function has no way to know which side +# is authoritative, and silently keeping one would silently discard +# the other. +# 3. Any CLAUDE.md still left in the mirror once AGENTS.md is settled +# (belt-and-suspenders past step 1) is removed. +# 4. The project-level AGENTS.md symlink is (re)created if missing or +# stale, and any CLAUDE.md left at the project level is removed. +# +# ARGUMENTS +# root Absolute path to the project root +# agents_dir Absolute path to the project's AGENTS/ sub-repo +# rel Path of the directory being synced, relative to root +# ("." for the root itself) +# +# EXIT STATUS +# 0 is settled (including the both-real-and-different skip, which +# is not a failure of this function) +# 1 A filesystem operation (mkdir/mv/rm/ln) failed +# +# RETURNS +# One "→ ..." line per change made, on stdout; nothing when was +# already settled. A skip warning goes to stderr, never stdout, so it is +# never mistaken for a change. +# +# EXAMPLE +# _agents_init_sync_instructions /path/to/project /path/to/project/AGENTS . +# _agents_init_sync_instructions /path/to/project /path/to/project/AGENTS functions +function _agents_init_sync_instructions --argument-names root agents_dir rel + test -n "$root" -a -n "$agents_dir" -a -n "$rel"; or return 1 + + set -l proj_dir "$root" + set -l mirror_dir "$agents_dir" + if test "$rel" != "." + set proj_dir "$root/$rel" + set mirror_dir "$agents_dir/$rel" + end + + set -l proj_agents "$proj_dir/AGENTS.md" + set -l proj_claude "$proj_dir/CLAUDE.md" + set -l mirror_agents "$mirror_dir/AGENTS.md" + set -l mirror_claude "$mirror_dir/CLAUDE.md" + + # Display names for progress lines: bare at the root, "/..." below it. + set -l disp_agents AGENTS.md + set -l disp_claude CLAUDE.md + set -l mirror_rel AGENTS + if test "$rel" != "." + set disp_agents "$rel/AGENTS.md" + set disp_claude "$rel/CLAUDE.md" + set mirror_rel "AGENTS/$rel" + end + + mkdir -p "$mirror_dir" + or begin + echo "_agents_init_sync_instructions: could not create $mirror_dir" >&2 + return 1 + end + + # ── 1: an inverted mirror (CLAUDE.md real, AGENTS.md symlinked to it) ── + if test -f "$mirror_claude"; and not test -L "$mirror_claude" + if test -L "$mirror_agents" + rm -f "$mirror_agents" + or begin + echo "_agents_init_sync_instructions: could not remove $mirror_agents" >&2 + return 1 + end + end + if not test -e "$mirror_agents" + command mv "$mirror_claude" "$mirror_agents" + or begin + echo "_agents_init_sync_instructions: could not rename $mirror_claude" >&2 + return 1 + end + echo "→ Renamed $mirror_rel/CLAUDE.md → AGENTS.md" + end + end + + # ── 2: adopt real project-level files, only if the mirror has none yet ── + if not test -f "$mirror_agents" + set -l has_agents 0 + set -l has_claude 0 + test -f "$proj_agents"; and not test -L "$proj_agents"; and set has_agents 1 + test -f "$proj_claude"; and not test -L "$proj_claude"; and set has_claude 1 + + if test $has_agents -eq 1; and test $has_claude -eq 1 + if command diff -q "$proj_agents" "$proj_claude" >/dev/null 2>&1 + command mv "$proj_agents" "$mirror_agents" + or begin + echo "_agents_init_sync_instructions: could not move $proj_agents" >&2 + return 1 + end + rm -f "$proj_claude" + or begin + echo "_agents_init_sync_instructions: could not remove $proj_claude" >&2 + return 1 + end + echo "→ Moved $disp_agents → $mirror_rel/AGENTS.md (dropped identical CLAUDE.md)" + else + echo "_agents_init_sync_instructions: $proj_agents and $proj_claude differ; leaving both, resolve by hand" >&2 + return 0 + end + else if test $has_agents -eq 1 + command mv "$proj_agents" "$mirror_agents" + or begin + echo "_agents_init_sync_instructions: could not move $proj_agents" >&2 + return 1 + end + echo "→ Moved $disp_agents → $mirror_rel/AGENTS.md" + else if test $has_claude -eq 1 + command mv "$proj_claude" "$mirror_agents" + or begin + echo "_agents_init_sync_instructions: could not move $proj_claude" >&2 + return 1 + 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" + echo "→ Created AGENTS/AGENTS.md with agent directive" + end + end + + # ── 3: the mirror never carries a CLAUDE.md once AGENTS.md is settled ── + if test -f "$mirror_agents"; and test -e "$mirror_claude" -o -L "$mirror_claude" + rm -f "$mirror_claude" + or begin + echo "_agents_init_sync_instructions: could not remove $mirror_claude" >&2 + return 1 + end + echo "→ Removed $mirror_rel/CLAUDE.md" + end + + # Nothing more to do for a conflicted or still-empty directory. + test -f "$mirror_agents"; or return 0 + + # ── 4: ensure the project-level AGENTS.md symlink, drop project CLAUDE.md ── + set -l target "AGENTS/AGENTS.md" + if test "$rel" != "." + set -l up (string repeat -n (count (string split / -- $rel)) "../") + set target "$up""AGENTS/$rel/AGENTS.md" + end + set -l need_link 1 + if test -L "$proj_agents" + test (readlink "$proj_agents") = "$target"; and set need_link 0 + end + if test $need_link -eq 1 + rm -f "$proj_agents" + ln -s "$target" "$proj_agents" + or begin + echo "_agents_init_sync_instructions: could not link $proj_agents" >&2 + return 1 + end + echo "→ Linked $disp_agents → $target" + end + + if test -e "$proj_claude" -o -L "$proj_claude" + rm -f "$proj_claude" + or begin + echo "_agents_init_sync_instructions: could not remove $proj_claude" >&2 + return 1 + end + echo "→ Removed $disp_claude" + end +end diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish new file mode 100644 index 0000000..b71369b --- /dev/null +++ b/tests/test-agents-init.fish @@ -0,0 +1,146 @@ +#!/usr/bin/env fish +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Hermetic tests for agents-init's AGENTS.md/CLAUDE.md handling: the +# per-directory sync helper (_agents_init_sync_instructions) and the +# repo-wide discovery loop in agents-init that drives it. Every test +# builds its own throwaway git repo under mktemp; nothing touches this +# checkout. +# +# Runs isolated (no `# MODE:` marker, which means isolated). +# +# Usage: fish tests/test-agents-init.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 + +echo "== _agents_init_sync_instructions: fresh root ==" + +set -l r1 (new_repo) +mkdir -p $r1/AGENTS +set -l out1 (_agents_init_sync_instructions $r1 $r1/AGENTS .) +set -l rc1 $status +check "fresh root: exits 0" 0 "$rc1" +check "fresh root: mirror AGENTS.md created" true (test -f $r1/AGENTS/AGENTS.md; and echo true; or echo false) +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) + +echo "" +echo "== _agents_init_sync_instructions: idempotent second run ==" + +set -l out1b (_agents_init_sync_instructions $r1 $r1/AGENTS .) +check "idempotent: second call prints nothing" "" "$out1b" +check "idempotent: still linked" true (test -L $r1/AGENTS.md; and echo true; or echo false) + +echo "" +echo "== _agents_init_sync_instructions: root collapse (today's repo shape) ==" + +set -l r2 (new_repo) +mkdir -p $r2/AGENTS +echo hello >$r2/AGENTS/AGENTS.md +ln -s AGENTS.md $r2/AGENTS/CLAUDE.md +ln -s AGENTS/AGENTS.md $r2/AGENTS.md +ln -s AGENTS/CLAUDE.md $r2/CLAUDE.md +set -l out2 (_agents_init_sync_instructions $r2 $r2/AGENTS .) +set -l rc2 $status +check "root collapse: exits 0" 0 "$rc2" +check "root collapse: mirror CLAUDE.md gone" false (test -e $r2/AGENTS/CLAUDE.md; and echo true; or echo false) +check "root collapse: project CLAUDE.md gone" false (test -e $r2/CLAUDE.md; and echo true; or echo false) +check "root collapse: project AGENTS.md still links correctly" AGENTS/AGENTS.md (readlink $r2/AGENTS.md) +check "root collapse: mirror content preserved" hello (cat $r2/AGENTS/AGENTS.md) + +echo "" +echo "== _agents_init_sync_instructions: subdir with only a real CLAUDE.md ==" + +set -l r3 (new_repo) +mkdir -p $r3/AGENTS $r3/functions +echo scoped >$r3/functions/CLAUDE.md +set -l out3 (_agents_init_sync_instructions $r3 $r3/AGENTS functions) +set -l rc3 $status +check "subdir lone CLAUDE.md: exits 0" 0 "$rc3" +check "subdir lone CLAUDE.md: mirror AGENTS.md created" scoped (cat $r3/AGENTS/functions/AGENTS.md) +check "subdir lone CLAUDE.md: no mirror CLAUDE.md" false (test -e $r3/AGENTS/functions/CLAUDE.md; and echo true; or echo false) +check "subdir lone CLAUDE.md: project AGENTS.md links to mirror" ../AGENTS/functions/AGENTS.md (readlink $r3/functions/AGENTS.md) +check "subdir lone CLAUDE.md: no project CLAUDE.md" false (test -e $r3/functions/CLAUDE.md; and echo true; or echo false) + +echo "" +echo "== _agents_init_sync_instructions: inverted mirror (docs/, functions/ today) ==" + +set -l r4 (new_repo) +mkdir -p $r4/AGENTS/docs $r4/docs +echo docsreal >$r4/AGENTS/docs/CLAUDE.md +ln -s CLAUDE.md $r4/AGENTS/docs/AGENTS.md +ln -s CLAUDE.md $r4/docs/AGENTS.md +ln -s ../AGENTS/docs/CLAUDE.md $r4/docs/CLAUDE.md +set -l out4 (_agents_init_sync_instructions $r4 $r4/AGENTS docs) +set -l rc4 $status +check "inverted mirror: exits 0" 0 "$rc4" +check "inverted mirror: mirror AGENTS.md real" docsreal (cat $r4/AGENTS/docs/AGENTS.md) +check "inverted mirror: mirror CLAUDE.md gone" false (test -e $r4/AGENTS/docs/CLAUDE.md; and echo true; or echo false) +check "inverted mirror: project AGENTS.md relinked directly" ../AGENTS/docs/AGENTS.md (readlink $r4/docs/AGENTS.md) +check "inverted mirror: project CLAUDE.md gone" false (test -e $r4/docs/CLAUDE.md; and echo true; or echo false) + +echo "" +echo "== _agents_init_sync_instructions: both real, different content ==" + +set -l r5 (new_repo) +mkdir -p $r5/AGENTS $r5/conflict +echo agents-version >$r5/conflict/AGENTS.md +echo claude-version >$r5/conflict/CLAUDE.md +set -l err5 (mktemp) +set -ga TMPDIRS $err5 +_agents_init_sync_instructions $r5 $r5/AGENTS conflict 2>$err5 +set -l rc5 $status +check "conflict: exits 0 (non-fatal skip)" 0 "$rc5" +check "conflict: warns to stderr" true (string match -q '*differ*' -- (cat $err5); and echo true; or echo false) +check "conflict: project AGENTS.md untouched" agents-version (cat $r5/conflict/AGENTS.md) +check "conflict: project CLAUDE.md untouched" claude-version (cat $r5/conflict/CLAUDE.md) +check "conflict: nothing mirrored" false (test -e $r5/AGENTS/conflict/AGENTS.md; and echo true; or echo false) + +echo "" +echo "== _agents_init_sync_instructions: both real, identical content ==" + +set -l r6 (new_repo) +mkdir -p $r6/AGENTS $r6/dup +echo same >$r6/dup/AGENTS.md +echo same >$r6/dup/CLAUDE.md +set -l out6 (_agents_init_sync_instructions $r6 $r6/AGENTS dup) +set -l rc6 $status +check "duplicate: exits 0" 0 "$rc6" +check "duplicate: mirrored" same (cat $r6/AGENTS/dup/AGENTS.md) +check "duplicate: project CLAUDE.md dropped" false (test -e $r6/dup/CLAUDE.md; and echo true; or echo false) +check "duplicate: project AGENTS.md links to mirror" ../AGENTS/dup/AGENTS.md (readlink $r6/dup/AGENTS.md) + +cleanup +report -- 2.54.0 From a776d4d12d19d33327dc7907dc16257b95f54182 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 19:32:36 -0400 Subject: [PATCH 02/12] fix(agents-init): add missing branch test coverage and explicit terminal return --- functions/_agents_init_sync_instructions.fish | 2 ++ tests/test-agents-init.fish | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish index 86675b7..ffee030 100644 --- a/functions/_agents_init_sync_instructions.fish +++ b/functions/_agents_init_sync_instructions.fish @@ -198,4 +198,6 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel end echo "→ Removed $disp_claude" end + + return 0 end diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index b71369b..9537ba2 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -142,5 +142,19 @@ check "duplicate: mirrored" same (cat $r6/AGENTS/dup/AGENTS.md) check "duplicate: project CLAUDE.md dropped" false (test -e $r6/dup/CLAUDE.md; and echo true; or echo false) check "duplicate: project AGENTS.md links to mirror" ../AGENTS/dup/AGENTS.md (readlink $r6/dup/AGENTS.md) +echo "" +echo "== _agents_init_sync_instructions: subdir with only a real AGENTS.md ==" + +set -l r7 (new_repo) +mkdir -p $r7/AGENTS $r7/onlyagents +echo onlyagents >$r7/onlyagents/AGENTS.md +set -l out7 (_agents_init_sync_instructions $r7 $r7/AGENTS onlyagents) +set -l rc7 $status +check "subdir lone AGENTS.md: exits 0" 0 "$rc7" +check "subdir lone AGENTS.md: mirror AGENTS.md created" onlyagents (cat $r7/AGENTS/onlyagents/AGENTS.md) +check "subdir lone AGENTS.md: no mirror CLAUDE.md" false (test -e $r7/AGENTS/onlyagents/CLAUDE.md; and echo true; or echo false) +check "subdir lone AGENTS.md: project AGENTS.md links to mirror" ../AGENTS/onlyagents/AGENTS.md (readlink $r7/onlyagents/AGENTS.md) +check "subdir lone AGENTS.md: no project CLAUDE.md" false (test -e $r7/onlyagents/CLAUDE.md; and echo true; or echo false) + cleanup report -- 2.54.0 From ecfb93a8183ae17bba3403f18bb6743e569f79b7 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 19:36:24 -0400 Subject: [PATCH 03/12] feat(agents-init): discover and normalize AGENTS.md/CLAUDE.md in every subdirectory --- functions/agents-init.fish | 154 ++++++++++++------------------------ tests/test-agents-init.fish | 43 ++++++++++ 2 files changed, 94 insertions(+), 103 deletions(-) diff --git a/functions/agents-init.fish b/functions/agents-init.fish index 95da318..26ab458 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -5,7 +5,7 @@ # 12-ai-and-developer-tools # # DEPENDENCIES -# _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore +# _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore # # CLASSIFICATION # self-limiting(rm,mkdir), bypasses-shadow(mv) @@ -17,8 +17,12 @@ # DESCRIPTION # Scaffolds an AGENTS/ sub-repository inside a project directory. Creates # a self-contained git repo for agent specifications, moves any existing -# agent-related files into it, and replaces them with symlinks so the outer -# project never tracks agent files directly. +# agent-related files into it, and replaces them with symlinks so the +# outer project never tracks agent files directly. This applies at the +# project root and, automatically, to any subdirectory that carries its +# own scoped AGENTS.md or CLAUDE.md -- discovered by scanning the tree +# (pruning .git/, node_modules/, and AGENTS/ itself), not a hardcoded +# list. # # Scaffolding runs only inside a git repository, or in a directory that # already has an AGENTS.md, CLAUDE.md, or AGENTS/. Elsewhere it is a @@ -26,11 +30,12 @@ # create a repository there. # # File layout after setup: -# AGENTS/AGENTS.md canonical agent spec (real file) -# AGENTS/CLAUDE.md real file (if CLAUDE.md existed separately) -# or symlink → AGENTS.md (single-source case) +# AGENTS/AGENTS.md canonical root agent spec (real file) +# AGENTS//AGENTS.md canonical spec for any subdir with its own +# scoped instructions (real file, discovered +# automatically -- see above) # /AGENTS.md → AGENTS/AGENTS.md -# /CLAUDE.md → AGENTS/CLAUDE.md +# //AGENTS.md → AGENTS//AGENTS.md # AGENTS/plans superpowers plans (real dir, .gitkeep) # AGENTS/specs superpowers specs (real dir, .gitkeep) # AGENTS/devlogs agent development logs (real dir, .gitkeep) @@ -42,6 +47,12 @@ # docs/specs → ../AGENTS/specs (only if docs/specs existed) # docs/devlogs → ../AGENTS/devlogs (only if docs/devlogs existed) # +# No CLAUDE.md survives anywhere in a managed tree: claude-code reads +# AGENTS.md natively when CLAUDE.md is absent, so CLAUDE.md exists here +# purely as a retirement target -- any found (root or subdirectory, real +# file or leftover symlink) is folded into the AGENTS.md-only shape +# above by _agents_init_sync_instructions. +# # plans/ and specs/ are merged from every legacy location (docs/, # docs/superpowers/, and the old AGENTS/plugins/ layout) into the # canonical AGENTS/; the AGENTS/plugins/ layer is removed. @@ -75,7 +86,8 @@ # Called automatically by the claude and agy wrappers on every invocation. # # ARGUMENTS -# -a, --agents Set up AGENTS/ repo + AGENTS.md / CLAUDE.md symlinks only +# -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 # -v, --verbose Print all per-step output (default) # -q, --quiet Print one summary line only if changes were made @@ -105,7 +117,7 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi 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-a$c_reset, $c_flag--agents$c_reset Set up AGENTS.md / CLAUDE.md symlinks only" + 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-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" @@ -201,109 +213,45 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # ──────────────────────────── --agents mode ────────────────────────────── if test $do_agents -eq 1 - # Detect which root-level files are real (not symlinks) - set -l has_agents 0 - set -l has_claude 0 - if test -f "$root/AGENTS.md"; and not test -L "$root/AGENTS.md" - set has_agents 1 - end - if test -f "$root/CLAUDE.md"; and not test -L "$root/CLAUDE.md" - set has_claude 1 + # Discover every directory carrying agent instructions -- root + # included, subdirectories found automatically rather than by a + # hardcoded list. A real file, an already-migrated symlink, or a + # leftover inverted-mirror survivor all match, so one pass covers + # fresh, migrated, and legacy state alike. AGENTS/ itself is + # pruned: it is the mirror, never a source to discover. + set -l found (find "$root" \ + \( -name .git -o -path "$agents_dir" -o -name node_modules \) -prune -o \ + \( -name AGENTS.md -o -name CLAUDE.md \) -print) + set -l rels "." + for f in $found + set -l d (path dirname "$f") + set -l rel (string replace "$root/" "" "$d") + test "$rel" = "$d"; and set rel "." + contains -- "$rel" $rels; or set -a rels "$rel" end - # ── Move real files into AGENTS/ ────────────────────────────────────── - if test $has_agents -eq 1; and test $has_claude -eq 1 - # Both exist: preserve each as its own file in AGENTS/ - if not test -f "$agents_dir/AGENTS.md" - if not command mv "$root/AGENTS.md" "$agents_dir/AGENTS.md" - echo "$c_err""Error: could not move AGENTS.md → AGENTS/AGENTS.md$c_reset" >&2 - return 1 - end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Moved AGENTS.md → AGENTS/AGENTS.md$c_reset" - end - if not test -f "$agents_dir/CLAUDE.md"; and not test -L "$agents_dir/CLAUDE.md" - if not command mv "$root/CLAUDE.md" "$agents_dir/CLAUDE.md" - echo "$c_err""Error: could not move CLAUDE.md → AGENTS/CLAUDE.md$c_reset" >&2 - return 1 - end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Moved CLAUDE.md → AGENTS/CLAUDE.md$c_reset" - end - else if test $has_agents -eq 1 - if not test -f "$agents_dir/AGENTS.md" - if not command mv "$root/AGENTS.md" "$agents_dir/AGENTS.md" - echo "$c_err""Error: could not move AGENTS.md → AGENTS/AGENTS.md$c_reset" >&2 - return 1 - end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Moved AGENTS.md → AGENTS/AGENTS.md$c_reset" - end - else if test $has_claude -eq 1 - # Only CLAUDE.md: treat it as the agent spec - if not test -f "$agents_dir/AGENTS.md" - if not command mv "$root/CLAUDE.md" "$agents_dir/AGENTS.md" - echo "$c_err""Error: could not move CLAUDE.md → AGENTS/AGENTS.md$c_reset" >&2 - return 1 - end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Moved CLAUDE.md → AGENTS/AGENTS.md$c_reset" - end - else - # Neither exists: create AGENTS/AGENTS.md with the agent directive - if not test -f "$agents_dir/AGENTS.md" - printf '%s\n' \ - '# AGENTS.md' \ - '' \ - '> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' \ - '> You may be reading this file via a symlink (`CLAUDE.md` or `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 `CLAUDE.md` or `AGENTS.md` in the' \ - '> project root. If you need to update these instructions, you **MUST write' \ - '> directly to `AGENTS/AGENTS.md`**.' >"$agents_dir/AGENTS.md" - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Created AGENTS/AGENTS.md with agent directive$c_reset" - end - end - - # ── Ensure AGENTS/CLAUDE.md exists ──────────────────────────────────── - # When both files existed, AGENTS/CLAUDE.md is already a real file. - # Otherwise, create it as a symlink → AGENTS.md (within AGENTS/). - if not test -f "$agents_dir/CLAUDE.md"; and not test -L "$agents_dir/CLAUDE.md" - if not ln -s AGENTS.md "$agents_dir/CLAUDE.md" - echo "$c_err""Error: could not create AGENTS/CLAUDE.md symlink$c_reset" >&2 + for rel in $rels + set -l out (_agents_init_sync_instructions "$root" "$agents_dir" "$rel") + set -l rc $status + if test $rc -ne 0 + echo "$c_err""Error: could not sync AGENTS.md for $rel$c_reset" >&2 return 1 end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Linked AGENTS/CLAUDE.md → AGENTS/AGENTS.md$c_reset" - end - - # Root symlinks point at files, not directories, so they cannot use - # _agents_repo_ensure_symlink (which is directory-only by design). - for pair in "AGENTS.md:AGENTS/AGENTS.md" "CLAUDE.md:AGENTS/CLAUDE.md" - set -l name (string split -f1 ':' -- $pair) - set -l want (string split -f2 ':' -- $pair) - set -l need 0 - if not test -L "$root/$name" - set need 1 - else if test (readlink "$root/$name") != "$want" - rm -f "$root/$name" - set need 1 - end - if test $need -eq 1 - if not ln -s "$want" "$root/$name" - echo "$c_err""Error: could not create $name symlink$c_reset" >&2 - return 1 - end + if test -n "$out" set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Linked $name → $want$c_reset" + if test $verbose -eq 1 + for line in $out + echo "$c_ok$line$c_reset" + end + end end end # ── .gitignore ──────────────────────────────────────────────────────── - set -l _gi (_agents_init_ensure_gitignore "$root" "agents-init --agents" "AGENTS/" "/AGENTS.md" "/CLAUDE.md") + # Unanchored: matches AGENTS.md at every depth, so a newly + # discovered subdirectory needs no additional gitignore entry. + # CLAUDE.md is dropped entirely -- nothing creates one anymore. + set -l _gi (_agents_init_ensure_gitignore "$root" "agents-init --agents" "AGENTS/" "AGENTS.md") if test -n "$_gi" set changed 1 test $verbose -eq 1; and echo $_gi diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 9537ba2..34e3581 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -156,5 +156,48 @@ check "subdir lone AGENTS.md: no mirror CLAUDE.md" false (test -e $r7/AGENTS/onl check "subdir lone AGENTS.md: project AGENTS.md links to mirror" ../AGENTS/onlyagents/AGENTS.md (readlink $r7/onlyagents/AGENTS.md) check "subdir lone AGENTS.md: no project CLAUDE.md" false (test -e $r7/onlyagents/CLAUDE.md; and echo true; or echo false) +echo "" +echo "== agents-init: end-to-end CLAUDE.md retirement ==" + +set -l e1 (new_repo) +echo root-real >$e1/CLAUDE.md +mkdir -p $e1/functions +echo scoped-real >$e1/functions/CLAUDE.md +pushd $e1 >/dev/null +set -l ercA (agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "e2e: exits 0" 0 "$ercA" +check "e2e: root CLAUDE.md gone" false (test -e $e1/CLAUDE.md; and echo true; or echo false) +check "e2e: root AGENTS.md links to mirror" AGENTS/AGENTS.md (readlink $e1/AGENTS.md) +check "e2e: root content preserved" root-real (cat $e1/AGENTS.md) +check "e2e: functions CLAUDE.md gone" false (test -e $e1/functions/CLAUDE.md; and echo true; or echo false) +check "e2e: functions AGENTS.md links to mirror" ../AGENTS/functions/AGENTS.md (readlink $e1/functions/AGENTS.md) +check "e2e: functions content preserved" scoped-real (cat $e1/functions/AGENTS.md) +check "e2e: no CLAUDE.md left anywhere under AGENTS/" "" (find $e1/AGENTS -name CLAUDE.md) +check "e2e: gitignore covers AGENTS.md unanchored" true (grep -qx 'AGENTS.md' $e1/.gitignore; and echo true; or echo false) + +pushd $e1 >/dev/null +set -l ercB (agents-init --agents --quiet 2>/dev/null) +popd >/dev/null +check "e2e: idempotent second run prints nothing" "" "$ercB" + +echo "" +echo "== agents-init: this-repo-shaped inversion is fixed live ==" + +set -l e2 (new_repo) +mkdir -p $e2/AGENTS/docs $e2/docs +echo docs-content >$e2/AGENTS/docs/CLAUDE.md +ln -s CLAUDE.md $e2/AGENTS/docs/AGENTS.md +ln -s CLAUDE.md $e2/docs/AGENTS.md +ln -s ../AGENTS/docs/CLAUDE.md $e2/docs/CLAUDE.md +pushd $e2 >/dev/null +set -l ercC (agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "inversion fix: exits 0" 0 "$ercC" +check "inversion fix: mirror AGENTS.md real" docs-content (cat $e2/AGENTS/docs/AGENTS.md) +check "inversion fix: mirror CLAUDE.md gone" false (test -e $e2/AGENTS/docs/CLAUDE.md; and echo true; or echo false) +check "inversion fix: project docs/AGENTS.md relinked directly" ../AGENTS/docs/AGENTS.md (readlink $e2/docs/AGENTS.md) +check "inversion fix: project docs/CLAUDE.md gone" false (test -e $e2/docs/CLAUDE.md; and echo true; or echo false) + cleanup report -- 2.54.0 From 4240a04754b64fefbf6d1f1df5b6b8558c88d7ca Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 19:46:16 -0400 Subject: [PATCH 04/12] fix(agents-init): clean up stale anchored gitignore patterns on migration --- functions/agents-init.fish | 23 +++++++++++++++++++++++ tests/test-agents-init.fish | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/functions/agents-init.fish b/functions/agents-init.fish index 26ab458..4ac1e98 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -247,6 +247,29 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi end end + # ── Migrate stale anchored gitignore lines ────────────────────────────── + # A project scaffolded by the old agents-init already has anchored + # /AGENTS.md and/or /CLAUDE.md lines in .gitignore. git check-ignore + # sees those as covering the literal path "AGENTS.md", so the new + # unanchored pattern below would be judged already-covered and never + # added -- leaving any newly discovered subdirectory AGENTS.md with no + # gitignore coverage at all. Strip the stale exact lines first so the + # unanchored pattern always gets a chance to be added. No-op when + # neither stale line is present. + set -l gitignore "$root/.gitignore" + if test -f "$gitignore" + if grep -qxF "/AGENTS.md" "$gitignore" + sed -i '/^\/AGENTS\.md$/d' "$gitignore" + set changed 1 + test $verbose -eq 1; and echo "$c_warn→ Removed stale /AGENTS.md line from .gitignore$c_reset" + end + if grep -qxF "/CLAUDE.md" "$gitignore" + sed -i '/^\/CLAUDE\.md$/d' "$gitignore" + set changed 1 + test $verbose -eq 1; and echo "$c_warn→ Removed stale /CLAUDE.md line from .gitignore$c_reset" + end + end + # ── .gitignore ──────────────────────────────────────────────────────── # Unanchored: matches AGENTS.md at every depth, so a newly # discovered subdirectory needs no additional gitignore entry. diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 34e3581..e0a319b 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -199,5 +199,21 @@ check "inversion fix: mirror CLAUDE.md gone" false (test -e $e2/AGENTS/docs/CLAU check "inversion fix: project docs/AGENTS.md relinked directly" ../AGENTS/docs/AGENTS.md (readlink $e2/docs/AGENTS.md) check "inversion fix: project docs/CLAUDE.md gone" false (test -e $e2/docs/CLAUDE.md; and echo true; or echo false) +echo "" +echo "== agents-init: stale anchored gitignore lines migrated ==" + +set -l e3 (new_repo) +mkdir -p $e3/AGENTS +echo real-agents >$e3/AGENTS/AGENTS.md +ln -s AGENTS/AGENTS.md $e3/AGENTS.md +printf '%s\n' AGENTS/ "/AGENTS.md" "/CLAUDE.md" >$e3/.gitignore +pushd $e3 >/dev/null +set -l ercD (agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "stale gitignore: exits 0" 0 "$ercD" +check "stale gitignore: anchored /AGENTS.md line removed" false (grep -qxF '/AGENTS.md' $e3/.gitignore; and echo true; or echo false) +check "stale gitignore: anchored /CLAUDE.md line removed" false (grep -qxF '/CLAUDE.md' $e3/.gitignore; and echo true; or echo false) +check "stale gitignore: unanchored AGENTS.md pattern present" true (grep -qxF 'AGENTS.md' $e3/.gitignore; and echo true; or echo false) + cleanup report -- 2.54.0 From 4e5f0078cee50f9f61754e69873b64c75ab1de7f Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 19:52:25 -0400 Subject: [PATCH 05/12] docs: drop CLAUDE.md wording from wrapper and manual prose --- .../08-components-reference/01-c1-command-shadows.md | 4 ++-- functions/agy.fish | 4 ++-- functions/claude.fish | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/manual/08-components-reference/01-c1-command-shadows.md b/docs/manual/08-components-reference/01-c1-command-shadows.md index b3488b1..7322274 100644 --- a/docs/manual/08-components-reference/01-c1-command-shadows.md +++ b/docs/manual/08-components-reference/01-c1-command-shadows.md @@ -24,7 +24,7 @@ all of these commands. grep/fgrep/egrep forced --color=auto system grep variants dir / vdir forced --color=auto system dir / vdir help config intercepts "help config" → config-help fish builtin help - claude auto-links AGENTS.md as CLAUDE.md before launch command claude + claude ensures AGENTS/ is scaffolded before launch command claude edit multi-editor launcher (GUI/term + fallbacks) $EDITOR/nvim/nano/vi When C1 is disabled, `rm` uses bare `command rm` with no wrapper — files @@ -64,7 +64,7 @@ and the `help config` interception. ## dev-tools -`claude` (AGENTS.md/CLAUDE.md auto-linking) and `edit` (multi-editor +`claude` (AGENTS/ scaffolding) and `edit` (multi-editor launcher), plus `agy`. ## For function authors diff --git a/functions/agy.fish b/functions/agy.fish index 6b304d9..b6d1e49 100644 --- a/functions/agy.fish +++ b/functions/agy.fish @@ -17,8 +17,8 @@ # Wrapper for the agy Antigravity AI CLI that ensures the AGENTS/ # sub-repository is initialized and any agent-made changes are committed # before launch. Delegates all scaffold and commit logic to agents-init -# --quiet (full setup), which ensures AGENTS/ is scaffolded and CLAUDE.md -# is symlinked to AGENTS/AGENTS.md in the current project. +# --quiet (full setup), which ensures AGENTS.md (root and every scoped +# subdirectory) is symlinked into AGENTS/ in the current project. # # Also syncs the host-scoped agent memory vault (agents-vault). agy has # no session-end hook, so its memory is captured on the next launch diff --git a/functions/claude.fish b/functions/claude.fish index ca245b0..d52ab2c 100644 --- a/functions/claude.fish +++ b/functions/claude.fish @@ -20,8 +20,9 @@ # Wrapper for the claude CLI that ensures the AGENTS/ sub-repository is # initialized and any agent-made changes are committed before launch. # Delegates all scaffold and commit logic to agents-init --quiet (full -# setup), which ensures AGENTS/ is scaffolded and CLAUDE.md is symlinked -# to AGENTS/AGENTS.md in the current project. +# setup), which ensures AGENTS.md (root and every scoped subdirectory) +# is symlinked into AGENTS/ in the current project. claude-code reads +# AGENTS.md natively, so no CLAUDE.md is created or maintained. # # Also syncs the host-scoped agent memory vault (agents-vault), which # tracks curated memory living outside the project tree. The vault @@ -44,7 +45,7 @@ # claude # claude --resume # claude "Explain the recent changes" -function claude --wraps=claude --description 'claude wrapper: auto-links AGENTS.md as CLAUDE.md' +function claude --wraps=claude --description 'claude wrapper: ensures AGENTS/ is scaffolded before launch' if not __fish_config_op_enabled (status current-function) command claude $argv return $status -- 2.54.0 From c3f134b5b48e1790d409bba44b21d2acc7ae3cfa Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 20:00:52 -0400 Subject: [PATCH 06/12] fix(agents-init): tag grep in CLASSIFICATION header --- functions/agents-init.fish | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/agents-init.fish b/functions/agents-init.fish index 4ac1e98..c56231f 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -8,7 +8,7 @@ # _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore # # CLASSIFICATION -# self-limiting(rm,mkdir), bypasses-shadow(mv) +# self-limiting(rm,mkdir,grep), bypasses-shadow(mv) # # SYNOPSIS # agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] -- 2.54.0 From 6a410a0fce203a3f0437d8ad29bf617dc476d0aa Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 20:12:41 -0400 Subject: [PATCH 07/12] fix(agents-init): contain discovery to the project tree, prevent silent overwrite of real files --- functions/_agents_init_sync_instructions.fish | 17 +++- functions/agents-init.fish | 24 ++++-- tests/test-agents-init.fish | 84 +++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish index ffee030..444012c 100644 --- a/functions/_agents_init_sync_instructions.fish +++ b/functions/_agents_init_sync_instructions.fish @@ -32,7 +32,10 @@ # 3. Any CLAUDE.md still left in the mirror once AGENTS.md is settled # (belt-and-suspenders past step 1) is removed. # 4. The project-level AGENTS.md symlink is (re)created if missing or -# stale, and any CLAUDE.md left at the project level is removed. +# stale, and any CLAUDE.md left at the project level is removed. A +# real project-level file found here (written after the mirror +# settled) is removed only if byte-identical to the mirror; if it +# differs, nothing is touched and a warning goes to stderr, as in 2. # # ARGUMENTS # root Absolute path to the project root @@ -176,6 +179,18 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel set -l up (string repeat -n (count (string split / -- $rel)) "../") set target "$up""AGENTS/$rel/AGENTS.md" end + # A real (non-symlink) file here arrived after the mirror settled. Same + # rule as step 2: byte-identical to the mirror is a duplicate and is + # replaced below; different means touch nothing and warn. + for f in $proj_agents $proj_claude + if test -f "$f"; and not test -L "$f" + if not command diff -q "$f" "$mirror_agents" >/dev/null 2>&1 + echo "_agents_init_sync_instructions: $f and $mirror_agents differ; leaving both, resolve by hand" >&2 + return 0 + end + end + end + set -l need_link 1 if test -L "$proj_agents" test (readlink "$proj_agents") = "$target"; and set need_link 0 diff --git a/functions/agents-init.fish b/functions/agents-init.fish index c56231f..f7c541b 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -152,7 +152,9 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # directory created an AGENTS/ repo, two root symlinks, and a docs/ # tree there. 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 test -e (pwd)/AGENTS.md -o -e (pwd)/CLAUDE.md -o -d (pwd)/AGENTS set root (pwd) else @@ -217,11 +219,23 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # included, subdirectories found automatically rather than by a # hardcoded list. A real file, an already-migrated symlink, or a # leftover inverted-mirror survivor all match, so one pass covers - # fresh, migrated, and legacy state alike. AGENTS/ itself is - # pruned: it is the mirror, never a source to discover. - set -l found (find "$root" \ - \( -name .git -o -path "$agents_dir" -o -name node_modules \) -prune -o \ - \( -name AGENTS.md -o -name CLAUDE.md \) -print) + # fresh, migrated, and legacy state alike. + # + # 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, 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. + 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 -exec test -e '{}/.git' \; \) -prune -o \ + \( -name AGENTS.md -o -name CLAUDE.md \) -print) + end set -l rels "." for f in $found set -l d (path dirname "$f") diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index e0a319b..9aaf89a 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -215,5 +215,89 @@ check "stale gitignore: anchored /AGENTS.md line removed" false (grep -qxF '/AGE check "stale gitignore: anchored /CLAUDE.md line removed" false (grep -qxF '/CLAUDE.md' $e3/.gitignore; and echo true; or echo false) check "stale gitignore: unanchored AGENTS.md pattern present" true (grep -qxF 'AGENTS.md' $e3/.gitignore; and echo true; or echo false) +echo "" +echo "== agents-init: discovery stays out of nested repos and dot-dirs ==" + +set -l e4 (new_repo) +mkdir -p $e4/sub $e4/.claude $e4/vendor/other/AGENTS +git -C $e4/sub init -q +mkdir -p $e4/.gemini +echo nested-claude >$e4/sub/CLAUDE.md +echo tool-claude >$e4/.claude/CLAUDE.md +echo tool-agents >$e4/.gemini/AGENTS.md +echo foreign-mirror >$e4/vendor/other/AGENTS/CLAUDE.md +echo own-docs >$e4/vendor/CLAUDE.md +pushd $e4 >/dev/null +set -l ercE (agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "containment: exits 0" 0 "$ercE" +check "containment: nested repo got no AGENTS.md" false (test -e $e4/sub/AGENTS.md -o -L $e4/sub/AGENTS.md; and echo true; or echo false) +check "containment: nested repo CLAUDE.md still real" nested-claude (test -L $e4/sub/CLAUDE.md; or cat $e4/sub/CLAUDE.md) +check "containment: no mirror for nested repo" false (test -e $e4/AGENTS/sub; and echo true; or echo false) +check "containment: .claude/CLAUDE.md untouched" tool-claude (test -L $e4/.claude/CLAUDE.md; or cat $e4/.claude/CLAUDE.md) +check "containment: .gemini/AGENTS.md untouched" tool-agents (test -L $e4/.gemini/AGENTS.md; or cat $e4/.gemini/AGENTS.md) +check "containment: no mirror for .claude" false (test -e $e4/AGENTS/.claude; and echo true; or echo false) +check "containment: foreign AGENTS/ dir untouched" foreign-mirror (test -L $e4/vendor/other/AGENTS/CLAUDE.md; or cat $e4/vendor/other/AGENTS/CLAUDE.md) +check "containment: ordinary subdir still discovered" own-docs (cat $e4/AGENTS/vendor/AGENTS.md) +check "containment: ordinary subdir linked" ../AGENTS/vendor/AGENTS.md (readlink $e4/vendor/AGENTS.md) + +echo "" +echo "== agents-init: non-git root syncs only itself ==" + +set -l n1 (mktemp -d) +set -ga TMPDIRS $n1 +mkdir -p $n1/sub +echo root-agents >$n1/AGENTS.md +echo sub-agents >$n1/sub/AGENTS.md +mkdir -p $n1/sub2 +echo sub-claude >$n1/sub2/CLAUDE.md +pushd $n1 >/dev/null +set -l ercF (set -lx GIT_CEILING_DIRECTORIES (path dirname $n1); agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "non-git: exits 0" 0 "$ercF" +check "non-git: root adopted into mirror" root-agents (cat $n1/AGENTS/AGENTS.md) +check "non-git: root linked" AGENTS/AGENTS.md (readlink $n1/AGENTS.md) +check "non-git: subdir AGENTS.md untouched" sub-agents (test -L $n1/sub/AGENTS.md; or cat $n1/sub/AGENTS.md) +check "non-git: subdir CLAUDE.md untouched" sub-claude (test -L $n1/sub2/CLAUDE.md; or cat $n1/sub2/CLAUDE.md) +check "non-git: no mirror for subdirs" false (test -e $n1/AGENTS/sub -o -e $n1/AGENTS/sub2; and echo true; or echo false) + +echo "" +echo "== _agents_init_sync_instructions: real file written after mirror settled ==" + +set -l s1 (new_repo) +mkdir -p $s1/AGENTS +echo settled >$s1/AGENTS/AGENTS.md +echo settled >$s1/AGENTS.md +echo settled >$s1/CLAUDE.md +set -l outS1 (_agents_init_sync_instructions $s1 $s1/AGENTS . 2>/dev/null) +set -l rcS1 $status +check "settled identical: exits 0" 0 "$rcS1" +check "settled identical: AGENTS.md replaced by link" AGENTS/AGENTS.md (readlink $s1/AGENTS.md) +check "settled identical: duplicate CLAUDE.md dropped" false (test -e $s1/CLAUDE.md; and echo true; or echo false) +check "settled identical: mirror intact" settled (cat $s1/AGENTS/AGENTS.md) + +set -l s2 (new_repo) +mkdir -p $s2/AGENTS +echo settled >$s2/AGENTS/AGENTS.md +echo rewritten >$s2/AGENTS.md +set -l errS2 (_agents_init_sync_instructions $s2 $s2/AGENTS . 2>&1 >/dev/null) +set -l rcS2 $status +check "settled differs (AGENTS.md): exits 0" 0 "$rcS2" +check "settled differs (AGENTS.md): real file kept" rewritten (test -L $s2/AGENTS.md; or cat $s2/AGENTS.md) +check "settled differs (AGENTS.md): warned on stderr" true (string match -q '*differ*' -- "$errS2"; and echo true; or echo false) +check "settled differs (AGENTS.md): mirror intact" settled (cat $s2/AGENTS/AGENTS.md) + +set -l s3 (new_repo) +mkdir -p $s3/AGENTS +echo settled >$s3/AGENTS/AGENTS.md +ln -s AGENTS/AGENTS.md $s3/AGENTS.md +echo recreated >$s3/CLAUDE.md +set -l errS3 (_agents_init_sync_instructions $s3 $s3/AGENTS . 2>&1 >/dev/null) +set -l rcS3 $status +check "settled differs (CLAUDE.md): exits 0" 0 "$rcS3" +check "settled differs (CLAUDE.md): real file kept" recreated (cat $s3/CLAUDE.md) +check "settled differs (CLAUDE.md): warned on stderr" true (string match -q '*differ*' -- "$errS3"; and echo true; or echo false) +check "settled differs (CLAUDE.md): link intact" AGENTS/AGENTS.md (readlink $s3/AGENTS.md) + cleanup report -- 2.54.0 From 2baa91f27550315a0190680a4816ce5972077d5c Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 20:59:42 -0400 Subject: [PATCH 08/12] feat(agents-init): protect deliberately git-tracked instruction files from being replaced A real AGENTS.md/CLAUDE.md that is in git's index, in a project with a non-empty .gitignore, is now left in place (with a stderr warning) by steps 2 and 4 of _agents_init_sync_instructions instead of being moved into AGENTS/ and symlinked. Discovery also prunes build/, dist/, out/ and target/ outright. --- functions/_agents_init_path_is_protected.fish | 45 ++++++ functions/_agents_init_sync_instructions.fish | 51 +++++-- functions/agents-init.fish | 22 ++- tests/test-agents-init.fish | 140 ++++++++++++++++++ 4 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 functions/_agents_init_path_is_protected.fish diff --git a/functions/_agents_init_path_is_protected.fish b/functions/_agents_init_path_is_protected.fish new file mode 100644 index 0000000..8eae44a --- /dev/null +++ b/functions/_agents_init_path_is_protected.fish @@ -0,0 +1,45 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_init_path_is_protected +# +# DESCRIPTION +# Decides whether a real (non-symlink) file should be left alone rather +# than adopted into the AGENTS/ mirror or replaced with a symlink, +# because it looks like a deliberately tracked project file rather than +# an incidental one this project hasn't yet engaged agents-init's +# convention for. +# +# A file is protected only when BOTH are true: +# - it is tracked in git's index at -- staged or committed, via +# `git ls-files`. A file that has never been `git add`ed (even if it +# sits right next to tracked files) is not tracked by this +# definition, and neither is one that is merely gitignored. +# - /.gitignore exists and is non-empty -- a project with no +# ignore rules at all has never engaged with the convention this +# tool manages, so a tracked file there is more likely incidental +# (e.g. the very first agents-init run, before anyone thought to +# ignore it) than a deliberate choice to keep tracking it. +# +# Neither check alone is enough: an untracked file is always safe +# regardless of .gitignore state (nothing has been committed to protect), +# and a tracked file in a project with no established ignore +# conventions is treated as adoptable rather than deliberate. +# +# ARGUMENTS +# root Absolute path to the project root (may or may not be a git repo) +# path Absolute path to the file being considered +# +# EXIT STATUS +# 0 Protected -- leave this file alone +# 1 Not protected -- safe to adopt/replace +# +# EXAMPLE +# _agents_init_path_is_protected /path/to/project /path/to/project/functions/CLAUDE.md +function _agents_init_path_is_protected --argument-names root path + test -n "$root" -a -n "$path"; or return 1 + git -C "$root" ls-files --error-unmatch -- "$path" >/dev/null 2>&1; or return 1 + test -s "$root/.gitignore"; or return 1 + return 0 +end diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish index 444012c..a72926d 100644 --- a/functions/_agents_init_sync_instructions.fish +++ b/functions/_agents_init_sync_instructions.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# DEPENDENCIES +# _agents_init_path_is_protected +# # CLASSIFICATION # self-limiting(rm,mkdir), bypasses-shadow(mv) # @@ -15,15 +18,23 @@ # for that directory -- neither at the project level nor inside the # mirror. # +# The exception is a real file that is deliberately git-tracked -- in +# git's index, in a project whose .gitignore is non-empty (see +# _agents_init_path_is_protected). Such a file is never adopted, +# relinked, or removed: whenever steps 2 or 4 find one, they leave that +# directory's instruction files exactly as they are and warn on stderr. +# # Four states of are handled, in order, so later steps only ever # see a settled mirror: # # 1. The mirror itself is inverted (CLAUDE.md real, AGENTS.md symlinked # to it). Flipped in place: same bytes, new name. # 2. The mirror has no real AGENTS.md yet, and the project directory -# has one or both files. A lone real file (either name) is adopted -# as the mirror's AGENTS.md -- a lone CLAUDE.md is renamed, never -# preserved under its own name. Both real and byte-identical: the +# has one or both files. If either real file is protected, both are +# left untouched, the mirror is not populated, and a warning naming +# the protected file(s) goes to stderr. Otherwise a lone real file +# (either name) is adopted as the mirror's AGENTS.md -- a lone +# CLAUDE.md is renamed, never preserved under its own name. Both real and byte-identical: the # AGENTS.md side is adopted and the duplicate CLAUDE.md is dropped. # Both real and different: neither is touched and a warning is # printed to stderr -- this function has no way to know which side @@ -34,8 +45,10 @@ # 4. The project-level AGENTS.md symlink is (re)created if missing or # stale, and any CLAUDE.md left at the project level is removed. A # real project-level file found here (written after the mirror -# settled) is removed only if byte-identical to the mirror; if it -# differs, nothing is touched and a warning goes to stderr, as in 2. +# settled) is checked for protection first, as in 2 -- a protected +# one is left alone even if byte-identical to the mirror. An +# unprotected one is removed only if byte-identical to the mirror; if +# it differs, nothing is touched and a warning goes to stderr, as in 2. # # ARGUMENTS # root Absolute path to the project root @@ -44,8 +57,8 @@ # ("." for the root itself) # # EXIT STATUS -# 0 is settled (including the both-real-and-different skip, which -# is not a failure of this function) +# 0 is settled (including the both-real-and-different and +# protected-file skips, which are not failures of this function) # 1 A filesystem operation (mkdir/mv/rm/ln) failed # # RETURNS @@ -113,6 +126,17 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel test -f "$proj_agents"; and not test -L "$proj_agents"; and set has_agents 1 test -f "$proj_claude"; and not test -L "$proj_claude"; and set has_claude 1 + # A deliberately git-tracked file is left alone -- and so is its + # sibling, since adopting one of a pair would still relink or drop + # the tracked one. + set -l protected + test $has_agents -eq 1; and _agents_init_path_is_protected "$root" "$proj_agents"; and set -a protected $disp_agents + test $has_claude -eq 1; and _agents_init_path_is_protected "$root" "$proj_claude"; and set -a protected $disp_claude + if set -q protected[1] + echo "_agents_init_sync_instructions: "(string join ', ' -- $protected)" tracked by git; leaving this directory's instruction files untouched" >&2 + return 0 + end + if test $has_agents -eq 1; and test $has_claude -eq 1 if command diff -q "$proj_agents" "$proj_claude" >/dev/null 2>&1 command mv "$proj_agents" "$mirror_agents" @@ -180,8 +204,17 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel set target "$up""AGENTS/$rel/AGENTS.md" end # A real (non-symlink) file here arrived after the mirror settled. Same - # rule as step 2: byte-identical to the mirror is a duplicate and is - # replaced below; different means touch nothing and warn. + # rules as step 2. A deliberately git-tracked one is left alone first, + # even if byte-identical: turning a tracked regular file into a symlink + # is itself a change to it. Otherwise, byte-identical to the mirror is a + # duplicate and is replaced below; different means touch nothing and warn. + set -l protected + test -f "$proj_agents"; and not test -L "$proj_agents"; and _agents_init_path_is_protected "$root" "$proj_agents"; and set -a protected $disp_agents + test -f "$proj_claude"; and not test -L "$proj_claude"; and _agents_init_path_is_protected "$root" "$proj_claude"; and set -a protected $disp_claude + if set -q protected[1] + echo "_agents_init_sync_instructions: "(string join ', ' -- $protected)" tracked by git; leaving this directory's instruction files untouched" >&2 + return 0 + end for f in $proj_agents $proj_claude if test -f "$f"; and not test -L "$f" if not command diff -q "$f" "$mirror_agents" >/dev/null 2>&1 diff --git a/functions/agents-init.fish b/functions/agents-init.fish index f7c541b..dd3a4ed 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -20,9 +20,15 @@ # agent-related files into it, and replaces them with symlinks so the # outer project never tracks agent files directly. This applies at the # project root and, automatically, to any subdirectory that carries its -# own scoped AGENTS.md or CLAUDE.md -- discovered by scanning the tree -# (pruning .git/, node_modules/, and AGENTS/ itself), not a hardcoded -# list. +# own scoped AGENTS.md or CLAUDE.md -- discovered by scanning the tree, +# not a hardcoded list. The scan prunes dot-directories (.git/, .claude/, +# ...), nested repos, AGENTS/ itself, node_modules/, and generated-output +# directories (build/, dist/, out/, target/). +# +# A real instruction file that the project deliberately tracks -- in +# git's index, in a project whose .gitignore is non-empty -- is left +# exactly where it is, with a warning, rather than moved into AGENTS/ and +# replaced by a symlink. See _agents_init_path_is_protected. # # Scaffolding runs only inside a git repository, or in a directory that # already has an AGENTS.md, CLAUDE.md, or AGENTS/. Elsewhere it is a @@ -226,13 +232,17 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # 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, 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. + # 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. 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) end diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 9aaf89a..59c3d3d 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -299,5 +299,145 @@ check "settled differs (CLAUDE.md): real file kept" recreated (cat $s3/CLAUDE.md check "settled differs (CLAUDE.md): warned on stderr" true (string match -q '*differ*' -- "$errS3"; and echo true; or echo false) check "settled differs (CLAUDE.md): link intact" AGENTS/AGENTS.md (readlink $s3/AGENTS.md) +echo "" +echo "== _agents_init_sync_instructions: deliberately git-tracked files are protected ==" + +# Tracked (committed) + populated .gitignore: left alone. +set -l p1 (new_repo) +mkdir -p $p1/AGENTS +echo node_modules/ >$p1/.gitignore +echo team-rules >$p1/AGENTS.md +git -C $p1 add .gitignore AGENTS.md +git -C $p1 commit -qm init +set -l errP1 (_agents_init_sync_instructions $p1 $p1/AGENTS . 2>&1 >/dev/null) +set -l rcP1 $status +check "tracked+ignore: exits 0" 0 "$rcP1" +check "tracked+ignore: still a real file" team-rules (test -L $p1/AGENTS.md; or cat $p1/AGENTS.md) +check "tracked+ignore: mirror not populated" false (test -e $p1/AGENTS/AGENTS.md; and echo true; or echo false) +check "tracked+ignore: still tracked, unmodified" "" (git -C $p1 status --porcelain -- AGENTS.md) +check "tracked+ignore: warned on stderr, naming the file" true (string match -q '*AGENTS.md tracked by git*' -- "$errP1"; and echo true; or echo false) + +# Untracked because gitignored + populated .gitignore: adopted normally. +set -l p2 (new_repo) +mkdir -p $p2/AGENTS +echo 'AGENTS.md' >$p2/.gitignore +git -C $p2 add .gitignore +git -C $p2 commit -qm init +echo ignored-local >$p2/AGENTS.md +set -l outP2 (_agents_init_sync_instructions $p2 $p2/AGENTS . 2>/dev/null) +check "gitignored untracked: adopted into mirror" ignored-local (cat $p2/AGENTS/AGENTS.md) +check "gitignored untracked: linked" AGENTS/AGENTS.md (readlink $p2/AGENTS.md) + +# Tracked but no .gitignore at all (bootstrap): adopted anyway. +set -l p3 (new_repo) +mkdir -p $p3/AGENTS +echo bootstrap >$p3/AGENTS.md +git -C $p3 add AGENTS.md +git -C $p3 commit -qm init +set -l outP3 (_agents_init_sync_instructions $p3 $p3/AGENTS . 2>/dev/null) +check "tracked, no .gitignore: adopted into mirror" bootstrap (cat $p3/AGENTS/AGENTS.md) +check "tracked, no .gitignore: linked" AGENTS/AGENTS.md (readlink $p3/AGENTS.md) + +# Tracked but .gitignore empty: same bootstrap rule. +set -l p3b (new_repo) +mkdir -p $p3b/AGENTS +touch $p3b/.gitignore +echo bootstrap-empty >$p3b/AGENTS.md +git -C $p3b add .gitignore AGENTS.md +git -C $p3b commit -qm init +set -l outP3b (_agents_init_sync_instructions $p3b $p3b/AGENTS . 2>/dev/null) +check "tracked, empty .gitignore: adopted into mirror" bootstrap-empty (cat $p3b/AGENTS/AGENTS.md) +check "tracked, empty .gitignore: linked" AGENTS/AGENTS.md (readlink $p3b/AGENTS.md) + +# Staged, never committed + populated .gitignore: staged is enough. +set -l p4 (new_repo) +mkdir -p $p4/AGENTS +echo node_modules/ >$p4/.gitignore +echo staged-only >$p4/AGENTS.md +git -C $p4 add AGENTS.md +set -l errP4 (_agents_init_sync_instructions $p4 $p4/AGENTS . 2>&1 >/dev/null) +check "staged-only: still a real file" staged-only (test -L $p4/AGENTS.md; or cat $p4/AGENTS.md) +check "staged-only: mirror not populated" false (test -e $p4/AGENTS/AGENTS.md; and echo true; or echo false) +check "staged-only: warned on stderr" true (string match -q '*tracked by git*' -- "$errP4"; and echo true; or echo false) + +# Never added, not matched by .gitignore, populated .gitignore: adopted. +set -l p5 (new_repo) +mkdir -p $p5/AGENTS +echo node_modules/ >$p5/.gitignore +git -C $p5 add .gitignore +git -C $p5 commit -qm init +echo brand-new >$p5/CLAUDE.md +set -l outP5 (_agents_init_sync_instructions $p5 $p5/AGENTS . 2>/dev/null) +check "never added: adopted into mirror" brand-new (cat $p5/AGENTS/AGENTS.md) +check "never added: linked" AGENTS/AGENTS.md (readlink $p5/AGENTS.md) +check "never added: CLAUDE.md gone" false (test -e $p5/CLAUDE.md; and echo true; or echo false) + +# A pair where only CLAUDE.md is tracked: both left, only CLAUDE.md named. +set -l p5b (new_repo) +mkdir -p $p5b/AGENTS +echo node_modules/ >$p5b/.gitignore +echo pair >$p5b/CLAUDE.md +git -C $p5b add .gitignore CLAUDE.md +git -C $p5b commit -qm init +echo pair >$p5b/AGENTS.md +set -l errP5b (_agents_init_sync_instructions $p5b $p5b/AGENTS . 2>&1 >/dev/null) +check "pair, one tracked: AGENTS.md left real" pair (test -L $p5b/AGENTS.md; or cat $p5b/AGENTS.md) +check "pair, one tracked: CLAUDE.md left real" pair (test -L $p5b/CLAUDE.md; or cat $p5b/CLAUDE.md) +check "pair, one tracked: mirror not populated" false (test -e $p5b/AGENTS/AGENTS.md; and echo true; or echo false) +check "pair, one tracked: names only the tracked file" true (string match -q '*: CLAUDE.md tracked by git*' -- "$errP5b"; and echo true; or echo false) + +# Settled mirror (step 4): a tracked real file arriving later is protected, +# whether it differs from the mirror or is byte-identical to it. +set -l p7 (new_repo) +mkdir -p $p7/AGENTS +echo settled >$p7/AGENTS/AGENTS.md +ln -s AGENTS/AGENTS.md $p7/AGENTS.md +echo node_modules/ >$p7/.gitignore +echo team-claude >$p7/CLAUDE.md +git -C $p7 add .gitignore CLAUDE.md +git -C $p7 commit -qm init +set -l errP7 (_agents_init_sync_instructions $p7 $p7/AGENTS . 2>&1 >/dev/null) +check "settled, tracked differs: exits 0" 0 "$status" +check "settled, tracked differs: CLAUDE.md kept" team-claude (test -L $p7/CLAUDE.md; or cat $p7/CLAUDE.md) +check "settled, tracked differs: protection wins over diff warning" true (string match -q '*CLAUDE.md tracked by git*' -- "$errP7"; and echo true; or echo false) +check "settled, tracked differs: mirror intact" settled (cat $p7/AGENTS/AGENTS.md) + +set -l p7b (new_repo) +mkdir -p $p7b/AGENTS +echo settled >$p7b/AGENTS/AGENTS.md +echo node_modules/ >$p7b/.gitignore +echo settled >$p7b/AGENTS.md +git -C $p7b add .gitignore AGENTS.md +git -C $p7b commit -qm init +set -l errP7b (_agents_init_sync_instructions $p7b $p7b/AGENTS . 2>&1 >/dev/null) +check "settled, tracked identical: still a real file" true (test -f $p7b/AGENTS.md; and not test -L $p7b/AGENTS.md; and echo true; or echo false) +check "settled, tracked identical: warned on stderr" true (string match -q '*AGENTS.md tracked by git*' -- "$errP7b"; and echo true; or echo false) + +echo "" +echo "== agents-init: tracked subdir file protected, generated dirs pruned ==" + +set -l e5 (new_repo) +echo node_modules/ >$e5/.gitignore +mkdir -p $e5/team $e5/build $e5/dist $e5/out $e5/target +echo team-shared >$e5/team/CLAUDE.md +for g in build dist out target + echo gen-$g >$e5/$g/AGENTS.md +end +git -C $e5 add .gitignore team/CLAUDE.md build/AGENTS.md +git -C $e5 commit -qm init +pushd $e5 >/dev/null +set -l ercG (agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "subdir protection: exits 0" 0 "$ercG" +check "subdir protection: team/CLAUDE.md still real" team-shared (test -L $e5/team/CLAUDE.md; or cat $e5/team/CLAUDE.md) +check "subdir protection: no team/AGENTS.md created" false (test -e $e5/team/AGENTS.md -o -L $e5/team/AGENTS.md; and echo true; or echo false) +check "subdir protection: no mirror file for team" false (test -e $e5/AGENTS/team/AGENTS.md; and echo true; or echo false) +check "subdir protection: team/CLAUDE.md unmodified in git" "" (git -C $e5 status --porcelain -- team/CLAUDE.md) +check "subdir protection: root still scaffolded" AGENTS/AGENTS.md (readlink $e5/AGENTS.md) +for g in build dist out target + check "pruned $g/: AGENTS.md untouched" gen-$g (test -L $e5/$g/AGENTS.md; or cat $e5/$g/AGENTS.md) + check "pruned $g/: no mirror" false (test -e $e5/AGENTS/$g; and echo true; or echo false) +end + cleanup report -- 2.54.0 From de922772266e7679afdee9403b9203f5b449c2dd Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 21:07:57 -0400 Subject: [PATCH 09/12] fix(agents-init): use --literal-pathspecs so glob characters in a path can't false-match tracked files --- functions/_agents_init_path_is_protected.fish | 2 +- tests/test-agents-init.fish | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/functions/_agents_init_path_is_protected.fish b/functions/_agents_init_path_is_protected.fish index 8eae44a..1517d40 100644 --- a/functions/_agents_init_path_is_protected.fish +++ b/functions/_agents_init_path_is_protected.fish @@ -39,7 +39,7 @@ # _agents_init_path_is_protected /path/to/project /path/to/project/functions/CLAUDE.md function _agents_init_path_is_protected --argument-names root path test -n "$root" -a -n "$path"; or return 1 - git -C "$root" ls-files --error-unmatch -- "$path" >/dev/null 2>&1; or return 1 + git -C "$root" --literal-pathspecs ls-files --error-unmatch -- "$path" >/dev/null 2>&1; or return 1 test -s "$root/.gitignore"; or return 1 return 0 end diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 59c3d3d..19ab86a 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -439,5 +439,27 @@ for g in build dist out target check "pruned $g/: no mirror" false (test -e $e5/AGENTS/$g; and echo true; or echo false) end +echo "" +echo "== _agents_init_path_is_protected: glob characters in filenames not false-matched ==" + +# Glob character filenames (e.g. a[1]) should be treated literally, not as glob patterns. +# A committed file a1/AGENTS.md should NOT falsely protect an untracked a[1]/AGENTS.md +# when checking if a[1]/AGENTS.md is protected. +set -l g1 (new_repo) +mkdir -p $g1/a1 +echo committed-a1 >$g1/a1/AGENTS.md +git -C $g1 add a1/AGENTS.md +git -C $g1 commit -qm init +mkdir -p "$g1/a[1]" +echo untracked-bracket >"$g1/a[1]/AGENTS.md" +echo node_modules/ >$g1/.gitignore +git -C $g1 add .gitignore +git -C $g1 commit -qm add-ignore +# Before the fix, this would return 0 (protected) due to glob matching a1/AGENTS.md +# After the fix, it should return 1 (not protected) since a[1]/AGENTS.md is untracked +_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" + cleanup report -- 2.54.0 From e01e83bd36652bd1682d22560a32f25b615fd852 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 21:47:26 -0400 Subject: [PATCH 10/12] docs: add AI Agent Tooling manual section Documents agents-init/agents-vault's concept, purpose, and complete behavior as its own manual section (16), separate from the auto-generated function reference: the AGENTS.md convention and CLAUDE.md retirement, the AGENTS/ sub-repository (layout, versioning, hooks), per-directory discovery and its four-state normalization, the two safety mechanisms (discovery containment, deliberately-tracked-file protection), the plans/specs/devlogs wiring, and the launch lifecycle. Inserted before Attribution/License (now 17/18) rather than mid-document, since that's the only placement that doesn't touch any of the manual's prose cross-references to other section numbers. docs/fish-config.index updated to match (new keywords, renumbered attribution/license entries; agents-init and agy were already indexed to their own function-reference entries and are left pointing there, not redirected to this new page). --- docs/fish-config.index | 23 +- docs/manual/16-agent-tooling.md | 270 ++++++++++++++++++ .../{16-attribution.md => 17-attribution.md} | 4 +- docs/manual/{17-license.md => 18-license.md} | 4 +- 4 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 docs/manual/16-agent-tooling.md rename docs/manual/{16-attribution.md => 17-attribution.md} (74%) rename docs/manual/{17-license.md => 18-license.md} (76%) diff --git a/docs/fish-config.index b/docs/fish-config.index index 4d0c204..4dc83ed 100644 --- a/docs/fish-config.index +++ b/docs/fish-config.index @@ -423,14 +423,21 @@ contributing=# 15. CONTRIBUTING contribute=# 15. CONTRIBUTING forge=# 15. CONTRIBUTING -# ── Section 16: Attribution ─────────────────────────────────── -attribution=# 16. ATTRIBUTION -credits=# 16. ATTRIBUTION +# ── Section 16: AI Agent Tooling ────────────────────────────── +agent=# 16. AI AGENT TOOLING +agent-tooling=# 16. AI AGENT TOOLING +agents.md=# 16. AI AGENT TOOLING +claude-code=# 16. AI AGENT TOOLING +antigravity=# 16. AI AGENT TOOLING -# ── Section 17: License ─────────────────────────────────────── -license=# 17. LICENSE -licensing=# 17. LICENSE -agpl=# 17. LICENSE -copyright=# 17. LICENSE +# ── Section 17: Attribution ─────────────────────────────────── +attribution=# 17. ATTRIBUTION +credits=# 17. ATTRIBUTION + +# ── Section 18: License ──────────────────────────────────────── +license=# 18. LICENSE +licensing=# 18. LICENSE +agpl=# 18. LICENSE +copyright=# 18. LICENSE diff --git a/docs/manual/16-agent-tooling.md b/docs/manual/16-agent-tooling.md new file mode 100644 index 0000000..205a9f9 --- /dev/null +++ b/docs/manual/16-agent-tooling.md @@ -0,0 +1,270 @@ +--- +title: AI Agent Tooling +manTitle: 16. AI AGENT TOOLING +sidebar: + order: 20 +helpKeywords: +- agent +- agents-init +- agents-vault +- AGENTS.md +- claude-code +- agy +- antigravity +--- +This section explains the machinery behind AI coding agents (Claude Code, +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 — +see Section 5. + + +## The problem this solves + +An AI coding agent needs a persistent, project-scoped place to keep +instructions, memory, and working notes. Committing that material directly +into a project's normal history mixes two concerns that change at +different rates and for different reasons: the project's own code, and an +agent's evolving working state. It also means every project accumulates +its own copy of agent tooling (hooks, version files, convention +documents) that has nothing to do with that project's actual purpose. + +`agents-init` and `agents-vault` exist to keep that material out of the +main repository while still making it feel local: an agent reads and +writes `AGENTS.md` exactly where it would expect to find it, but the real +content and its history live in a separate, self-contained git repository +that the main project never tracks. + + +## The AGENTS.md convention + +`AGENTS.md` is a plain-text file at a project's root (and, as this +configuration extends the idea, at the root of any subdirectory with its +own scoped conventions) that an AI agent reads for repository-specific +instructions. It has become a convention shared across coding agents, not +one tool's proprietary format. + +Claude Code originally required its own `CLAUDE.md` filename specifically. +It now reads `AGENTS.md` natively whenever no `CLAUDE.md` is present, which +retired the need for this configuration to create, maintain, or symlink +`CLAUDE.md` at all. A project scaffolded by `agents-init` today carries +only `AGENTS.md` — at the root, and in any subdirectory that has grown its +own scoped conventions (`functions/`, `docs/`, and so on, in this +repository's own case). A leftover `CLAUDE.md` from before this change is +retired automatically the next time `agents-init` runs: renamed, not +preserved under its old name, so nothing is ever left tracking two copies +of the same instructions under two different filenames. + + +## The AGENTS/ sub-repository + +`agents-init` scaffolds a directory named `AGENTS/` at a project's root. +It is a self-contained git repository — its own `.git`, its own commit +history, its own hooks — and it is gitignored from the project it lives +inside. The project's own `AGENTS.md` (and every subdirectory's) is a +symlink into it: + + $PROJECT/AGENTS/ + ├── AGENTS.md Canonical root agent spec (real file) + ├── functions/AGENTS.md Canonical spec for functions/, and likewise for any other scoped subdirectory + ├── plans/ Superpowers implementation plans + ├── specs/ Superpowers design specs + ├── devlogs/ Agent development logs + ├── .version MAJOR.MINOR.PATCH structure version + └── .agents-tools/ Version-bump script and git hook shims (committed) + +An agent editing `$PROJECT/AGENTS.md` is, transparently, editing +`$PROJECT/AGENTS/AGENTS.md` — the file-editing tools most agents ship with +resolve a symlinked directory's contents normally, but they cannot write +*through* a symlinked file itself, which is why the seed content +`agents-init` writes for a brand-new project spells this out directly to +the agent reading it. + +IMPORTANT: This means an agent must never try to write to a *symlink +named* `AGENTS.md` directly. The seed instructions `agents-init` writes +for a fresh project tell the agent this explicitly, pointing it at the +real file inside `AGENTS/`. + +### Version tracking and hooks + +Every `AGENTS/` repository carries a `.version` file (seeded `1.0.0`) and +a self-contained version bumper, wired through `core.hooksPath` rather +than the ordinary `.git/hooks/` directory: + + - A **pre-commit** hook bumps `.version` on every commit: the MINOR + field moves when the set of tracked top-level directories changes + (a new subdirectory convention was adopted, or one was dropped), the + PATCH field otherwise. The MAJOR field is manual-only. + - A **prepare-commit-msg** hook appends `(vX.Y.Z)` to the commit + subject, so the version history is legible from `git log` alone. + +Each hook shim then chains to whatever hook of the same name the +project's *global* or *system* `core.hooksPath` already points at — a +credential scanner like ggshield, Git LFS, or anything else already +wired in ahead of this. Pointing `core.hooksPath` at `.agents-tools/hooks` +locally does not shadow those; it runs both. + +The `.agents-tools/` scripts themselves are copied in from this +configuration's own `scripts/agents-tools/` and refreshed automatically +whenever their version marker moves, so every project's `AGENTS/` +repository stays current with this configuration without any manual step. + +Downstream tooling that wants to know whether a project's `AGENTS/` +*structure* changed — as opposed to just its content — can read the +`.version` file's MINOR field directly rather than diffing the tree. + + +## Per-directory discovery + +The convention is not limited to a project's root. Any directory that +carries its own `AGENTS.md` — `functions/`, `docs/`, or a subdirectory of +a much larger project with genuinely distinct conventions of its own — +gets the identical treatment: a real file inside `AGENTS//`, +and a symlink at the project location pointing back to it. `agents-init` +finds these automatically on every run, rather than working from a fixed +list, by walking the project tree for any file literally named +`AGENTS.md` or `CLAUDE.md`. + +Each directory found is settled into exactly one of four states, in +order, so a later run only ever sees a directory that is already +consistent: + + 1. **An inverted mirror** (an older layout, where `CLAUDE.md` was the + real file inside `AGENTS/` and `AGENTS.md` was symlinked to it) is + flipped in place — same bytes, new name. + 2. **A real file at the project level, with no real file inside + `AGENTS/` yet**, is adopted: a lone `AGENTS.md` moves in as-is; a + lone `CLAUDE.md` is renamed on the way in, never preserved under its + own name. When both `AGENTS.md` and `CLAUDE.md` are real files at + once, byte-identical content is deduplicated (the `AGENTS.md` side is + kept); different content is left exactly as it is, with a warning — + this function has no way to know which one is authoritative, and + guessing wrong would silently discard the other. + 3. **A stray `CLAUDE.md` inside `AGENTS/`** left over once `AGENTS.md` + is settled there is removed — nothing named `CLAUDE.md` survives + inside the mirror. + 4. **The project-level symlink** is created or repaired if missing or + stale, and any `CLAUDE.md` still at the project level is removed. A + real file that turns up here *after* the mirror already settled (for + instance, an agent's own `/init`-style command writing a fresh + `CLAUDE.md`) is held to the same identical-or-differ rule as step 2: + a duplicate is dropped, anything different is left alone with a + warning rather than silently overwritten. + + +## Safety: what discovery will never touch + +Because discovery walks the whole project tree rather than a fixed list, +it deliberately prunes several classes of directory before it ever +considers what's inside them: + + - **Anything outside the project entirely.** A directory that has no + git repository of its own, but happens to carry a lone `AGENTS.md` or + `CLAUDE.md` (a home directory scaffolded this way, for instance), is + synced at that single location only — no recursive walk runs at all. + Recursive discovery only ever runs inside a real git repository. + - **Nested repositories.** Any subdirectory that is itself a git + repository — a submodule, a nested clone, a plugin checked out inside + a tool's own state directory — belongs to a different project and is + never walked into. + - **Dot-directories.** Anything named starting with `.` (`.git`, + `.claude`, `.gemini`, `.github`, and so on) is a tool's own state or + configuration, not a project's own scoped convention, and is skipped + unconditionally. + - **Generated output.** `build/`, `dist/`, `out/`, and `target/` + directories are never inspected — nothing generated by a build step + is a source of hand-authored instructions. + - **`node_modules/`**, and any directory literally named `AGENTS` other + than the current project's own mirror. + + +## Safety: deliberately tracked files are left alone + +Discovery can reach a directory whose `AGENTS.md` or `CLAUDE.md` is +already committed to the project's own history on purpose — a team's +shared conventions file in a monorepo subdirectory, for instance, tracked +long before this configuration's owner ever cloned it. Replacing that +file with a symlink would change it from an ordinary tracked file into a +link pointing outside the repository the moment `agents-init` next runs, +which is not a decision this tool should make unattended on someone +else's behalf. + +A real file is left untouched, instead of adopted or replaced, whenever +**both** of the following hold: + + - it is tracked in git's index — staged or committed, checked with + `git ls-files`. A file that has never been `git add`ed is not tracked + by this definition, even if it sits right next to files that are. + - the project's `.gitignore` actually exists and has content in it. + +Neither condition alone is enough to protect a file. An untracked file is +always safe to adopt, regardless of what `.gitignore` says about it +(nothing has been committed yet, so nothing is lost). A tracked file in a +project with *no* established ignore conventions at all — no +`.gitignore`, or an empty one — is treated as the very first time this +convention has been applied to that project, rather than a deliberate +choice to keep tracking it: `agents-init` adopts it the same way it would +adopt any other real file, which is the same behavior this tool has +always had for a project's own root file. + +NOTE: In practice, this means a mature project with an established +`.gitignore` will have any already-committed `AGENTS.md`/`CLAUDE.md` left +alone across the board — root included — and will only ever adopt one +during that project's first encounter with this convention, before a +`.gitignore` entry for it exists yet. + +When a directory is skipped for this reason, `agents-init` prints a +warning naming the file and explaining why, rather than staying silent +about a directory it chose not to touch. + + +## plans/, specs/, and devlogs/ + +`agents-init --plugins` (the second half of what a bare `agents-init` run +does) wires up three more directories inside `AGENTS/`: `plans/` and +`specs/` for the superpowers skills' implementation plans and design +documents, and `devlogs/` for agent-authored development notes. Real +content from every legacy location this configuration has ever used for +these (`docs/plans`, `docs/superpowers/plans`, and an older +`AGENTS/plugins/` layer from before the sub-repository consolidated them) +is merged into the canonical `AGENTS/plans` and `AGENTS/specs` on first +run, and the legacy locations are removed once merged. + +`docs/superpowers/plans` and `docs/superpowers/specs` are always +symlinked to their `AGENTS/` counterparts, because the superpowers skills +expect to find them there by default. `docs/plans`, `docs/specs`, and +`docs/devlogs` are only created as symlinks when a project already had a +real directory by that name — nothing forces those paths to exist for a +project that never used them. + + +## The launch lifecycle + +The `claude` and `agy` wrapper functions each run `agents-init --quiet` +(full setup: both the `AGENTS.md` symlink step and the plans/specs/devlogs +wiring) before launching the real CLI, on every invocation. This is what +makes the whole system self-healing: a project that has drifted from the +expected layout — a stale symlink, a newly-added subdirectory's +instructions not yet adopted, a leftover `CLAUDE.md` — is corrected +automatically the next time an agent is launched there, with no separate +setup step for a person to remember. + +At the end of every `agents-init` run, any uncommitted change inside +`AGENTS/` is committed automatically, so whatever an agent wrote during +its session is captured without anyone needing to run `git add` on a +repository they were never meant to think about directly. That commit is +strictly local: `agents-init` never fetches or pushes, because a network +round trip running synchronously ahead of every agent launch would block +the launch itself for as long as an unreachable remote takes to time out. +A project's `AGENTS/` repository that has its own upstream is pulled and +pushed by hand, on its owner's own schedule. + +`agents-vault` is a related but distinct tool: where `AGENTS/` holds +*one project's* agent state, `agents-vault` backs up curated agent memory +that lives *outside* any project tree entirely — `~/.claude/projects/*/memory` +and similar host-scoped locations — into its own host-scoped repository. +The `claude`/`agy` wrappers sync both on every launch. See +`__fish_agent_vault_autopush` in Section 7 for its one user-facing +configuration variable; command-line usage for both tools is in Section 5. diff --git a/docs/manual/16-attribution.md b/docs/manual/17-attribution.md similarity index 74% rename from docs/manual/16-attribution.md rename to docs/manual/17-attribution.md index 34522fd..af223e9 100644 --- a/docs/manual/16-attribution.md +++ b/docs/manual/17-attribution.md @@ -1,8 +1,8 @@ --- title: Attribution -manTitle: 16. ATTRIBUTION +manTitle: 17. ATTRIBUTION sidebar: - order: 20 + order: 21 helpKeywords: - attribution - credits diff --git a/docs/manual/17-license.md b/docs/manual/18-license.md similarity index 76% rename from docs/manual/17-license.md rename to docs/manual/18-license.md index bc9a775..19e11af 100644 --- a/docs/manual/17-license.md +++ b/docs/manual/18-license.md @@ -1,8 +1,8 @@ --- title: License -manTitle: 17. LICENSE +manTitle: 18. LICENSE sidebar: - order: 21 + order: 22 helpKeywords: - license - licensing -- 2.54.0 From 1d714a84d07d2e44a2368f18fcfc942b9ab8f790 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 22:10:36 -0400 Subject: [PATCH 11/12] docs(agent-tooling): add scenario reference table, cross-reference from code Adds a 'Scenario reference' subsection to docs/manual/16-agent-tooling.md: two ruled tables covering every combination of what a directory can hold (only AGENTS.md, only CLAUDE.md, both identical, both different, an inverted mirror, an already-settled symlink) crossed with whether the file is deliberately git-tracked, plus the settled-mirror/later-arrival case separately. Verified against build-manual.py's actual table parser and the generated Starlight site output, not just visual inspection. Also closes the gap this section itself pointed out: nothing previously linked a reader of agents-init.fish or agents-vault.fish's own doc-header to this page, and nothing told a future contributor the page has to be kept in sync. Adds: - CONTRIBUTING.md: a new 'Dedicated manual sections for complex subsystems' subsection documenting the pattern in general (when to use one, and the update-it-in-the-same-change obligation verify-manual.py cannot check for you). - functions/agents-init.fish, functions/agents-vault.fish: a NOTES pointer to the section from each function's own header, so a reader who only sees the header still finds the fuller page. --- CONTRIBUTING.md | 25 ++++++++++++++++++++ docs/manual/16-agent-tooling.md | 42 +++++++++++++++++++++++++++++++++ functions/agents-init.fish | 9 +++++++ functions/agents-vault.fish | 7 ++++++ 4 files changed, 83 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f66bc1..a019c4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -455,6 +455,31 @@ via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards — `destructive`, `network`, `blocking-prompt`. Full tag definitions and placement rule: [`docs/function-classification-schema.md`](docs/function-classification-schema.md). +### Dedicated manual sections for complex subsystems + +A doc-header's `DESCRIPTION` is for that one function's usage — it stops +being the right place once a subsystem spans several functions, has its +own file layout, or has enough behavior (a decision table, a safety +model) that cramming it into one function's header would make that +header useless as a quick reference. When that happens, give the +subsystem its own numbered top-level section under `docs/manual/` +(follow the sibling sections' frontmatter shape: `title`, `manTitle`, +`sidebar.order`, `helpKeywords`) instead of stretching the header. +`docs/manual/16-agent-tooling.md` (`agents-init`/`agents-vault`/the +`AGENTS/` sub-repository) is the existing example — its own doc-headers +stay short and point there for the full picture, the same way this +document points at other reference files rather than repeating them. + +This is a genuine exception to "the doc-header is the single source of +truth" above, and it comes with an obligation `verify-manual.py` cannot +enforce for you: nothing checks that a dedicated section still describes +the function's *current* behavior. **Whenever you change what one of +these functions does, update its dedicated section in the same commit or +pull request** — not as a follow-up. A function with a dedicated section +should say so in its own `# NOTES` (see `functions/agents-init.fish` for +the pattern), so a later reader of just the header still finds the fuller +page. + ### Private/internal helper functions Functions named with a leading `_` (e.g. `_agents_init_ensure_gitignore`, diff --git a/docs/manual/16-agent-tooling.md b/docs/manual/16-agent-tooling.md index 205a9f9..30cb976 100644 --- a/docs/manual/16-agent-tooling.md +++ b/docs/manual/16-agent-tooling.md @@ -220,6 +220,48 @@ warning naming the file and explaining why, rather than staying silent about a directory it chose not to touch. +## Scenario reference + +Every combination of what a directory can hold, laid out directly. "No" +in the tracked column also covers a tracked file in a project with no +populated `.gitignore` (the bootstrap case, above) — both behave the same +way. Whenever the tracked column reads "Yes", that reason always wins +over the identical-or-different comparison below it, and the warning +printed names the file as tracked rather than as differing — the outcome +(left alone) is the same either way, only the explanation differs. + +Settling a directory for the first time — a real `AGENTS.md`, a real +`CLAUDE.md`, both, or neither, discovered fresh: + + Found Deliberately tracked? Result + -------------------------------- --------------------- ----------------------------------------------- + Only AGENTS.md (real) No Adopted into AGENTS/, symlinked back. + Only AGENTS.md (real) Yes Left exactly as it is; not adopted. + Only CLAUDE.md (real) No Adopted, renamed to AGENTS.md, symlinked back. + Only CLAUDE.md (real) Yes Left exactly as it is; not adopted or renamed. + Both, byte-identical No AGENTS.md adopted; duplicate CLAUDE.md dropped. + Both, byte-identical Yes (either) Left exactly as they are; neither touched. + Both, different content n/a Neither touched; warns, resolve by hand. + Mirror has CLAUDE.md (real) n/a Flipped in place: renamed, nothing lost. + Correct AGENTS.md symlink exists n/a Nothing happens -- already settled. + +A new real file appearing after a directory's mirror has already settled +— an agent's own `/init`-style command, for instance, writing a fresh +`CLAUDE.md` where an `AGENTS.md` is already symlinked: + + New file vs. mirror Deliberately tracked? Result + ------------------- --------------------- -------------------------------------------------------- + Byte-identical No Adopted as a duplicate; the new file is dropped. + Byte-identical Yes Left as it is; not adopted, even though content matches. + Different content No Left as it is; warns that it differs, resolve by hand. + Different content Yes Left as it is; warns that it's tracked, not adopted. + +And whatever a directory holds, it never gets this far at all if +discovery pruned it outright — see the containment rules above: nested +repositories, dot-directories, `node_modules/`, generated-output +directories, and any directory literally named `AGENTS`. + + ## plans/, specs/, and devlogs/ `agents-init --plugins` (the second half of what a bare `agents-init` run diff --git a/functions/agents-init.fish b/functions/agents-init.fish index dd3a4ed..6688ad9 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -110,6 +110,15 @@ # agents-init --agents # agents-init --plugins # agents-init --quiet +# +# NOTES +# This header covers usage only. The full concept/behavior/purpose +# write-up -- the AGENTS.md convention, the AGENTS/ sub-repository, the +# discovery and safety model, and a complete scenario-by-scenario +# reference table -- lives in its own manual section: +# docs/manual/16-agent-tooling.md. Update that section in the same +# change whenever this function's behavior changes; see "Dedicated +# manual sections for complex subsystems" in CONTRIBUTING.md. function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec files and plugin dirs' __fish_palette diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 125d9f9..a5183a4 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -188,6 +188,13 @@ # machine that has a real global memory directory would move it into a # throwaway directory and leave a dangling symlink behind, which is # strictly worse than having had no backup at all. +# +# This header covers usage only. The full concept/behavior/purpose +# write-up -- how this relates to the per-project AGENTS/ repository +# agents-init manages, and where each kind of agent state actually lives +# -- is in docs/manual/16-agent-tooling.md. Update that section in the +# same change whenever this function's behavior changes; see "Dedicated +# manual sections for complex subsystems" in CONTRIBUTING.md. function agents-vault --description 'track curated agent memory in a host-scoped vault repo' __fish_palette -- 2.54.0 From 911c6e95edd71f324d54d72419aa4a2d5fced2ac Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 24 Sep 2026 00:50:36 -0400 Subject: [PATCH 12/12] feat(docs): add manual-section CLASSIFICATION tag, wire it into the build A function with a dedicated manual section (docs/manual/16-agent-tooling.md, so far) now carries manual-section() in its own CLASSIFICATION line instead of relying on a NOTES pointer nobody can grep for. Applied to agents-init and agents-vault, both pointing at 16-agent-tooling. docs/build-manual.py: _resolve_manual_section reads the target page's own manTitle/title fresh at build time rather than duplicating a section number into the tag, so a renumbered section (like this one, twice already) never requires touching the tag -- only the slug (the filename) does, and only if the page itself is renamed. render_entry and render_entry_site both gained an optional root parameter and now emit a 'See also' line (plain text + relative path for the man page, a real markdown link on the site) whenever the tag resolves; omitted silently when it doesn't (a build isn't the place to fail on a bad slug). docs/verify-manual.py: unit tests for the new resolver and both renderers, plus a real-data scan (test_real_manual_section_tags_resolve) that fails the suite if any function's manual-section() tag points nowhere -- the actual enforcement half of the convention, since the build stays silent about it. docs/function-classification-schema.md, CONTRIBUTING.md: documents the tag, and widens CLASSIFICATION's own framing from strictly hazard/shadow tags to general-purpose (the user's call, not mine to make unilaterally -- scope-broadening an existing convention). The 'Dedicated manual sections' subsection (added earlier this branch) now names the tag as the machine-checked half of that convention, with NOTES demoted to a nice-to-have for a header-only reader. --- CONTRIBUTING.md | 39 ++++++--- docs/build-manual.py | 74 +++++++++++++++-- docs/function-classification-schema.md | 24 ++++++ docs/verify-manual.py | 111 +++++++++++++++++++++++++ functions/agents-init.fish | 2 +- functions/agents-vault.fish | 2 +- 6 files changed, 229 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a019c4d..0144ee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -391,7 +391,7 @@ all optional except where noted: | `CATEGORY` | **Required to appear in the manual at all** — see below. | | `COMPONENT` | Only for functions gated by the [opinionated-component system](#opinionated-components). | | `DEPENDENCIES` | Other functions, and external CLI tools, this one needs for full functionality — required or optional-with-fallback alike. | -| `CLASSIFICATION` | Hazard/shadow-interaction tags — see below. | +| `CLASSIFICATION` | Hazard/shadow-interaction tags, plus a couple of general-purpose ones (`manual-section`) — see below. | | `SYNOPSIS` | One-line usage form. | | `DESCRIPTION` | Prose description; can span multiple paragraphs. | | `ARGUMENTS` | Flags/positional args, one per line. | @@ -447,13 +447,17 @@ If your function genuinely doesn't fit any of these, add a new `docs/manual/05-functions/NN-your-category.md` stub (with frontmatter matching its siblings) rather than force-fitting it into an existing one. -**`CLASSIFICATION` flags hazards and shadow interactions, optional and -omitted when nothing applies:** whether the function calls a +**`CLASSIFICATION` is the general-purpose tag field, optional and omitted +when nothing applies:** mostly hazards and shadow interactions — whether +the function calls a [C1-shadowed command](docs/manual/08-components-reference/01-c1-command-shadows.md) bare wanting the override (`uses-shadow(ls)`) or bypasses it deliberately via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards — -`destructive`, `network`, `blocking-prompt`. Full tag definitions and -placement rule: [`docs/function-classification-schema.md`](docs/function-classification-schema.md). +`destructive`, `network`, `blocking-prompt` — but not exclusively: a +function with its own dedicated manual section (below) carries +`manual-section()` here too, so that fact is grep-able without +reading every `NOTES` field. Full tag definitions and placement rule: +[`docs/function-classification-schema.md`](docs/function-classification-schema.md). ### Dedicated manual sections for complex subsystems @@ -470,15 +474,24 @@ subsystem its own numbered top-level section under `docs/manual/` stay short and point there for the full picture, the same way this document points at other reference files rather than repeating them. +A function with a dedicated section carries `manual-section()` in +its own `# CLASSIFICATION` (see `functions/agents-init.fish`; full tag +definition in +[`docs/function-classification-schema.md`](docs/function-classification-schema.md)) +— that's what makes the section discoverable without reading every +function's `NOTES` by hand, and it's what `docs/build-manual.py` reads to +render the "See also" line on the function's generated entry. +`docs/verify-manual.py` fails the build if the slug doesn't resolve to a +real page, so a typo or a renamed file can't go unnoticed — but it cannot +check the *content* is current. A `# NOTES` line pointing at the same page +(see the existing example) is worth adding too, for a reader who only +reads the header text rather than the generated docs, but the tag is the +part something else actually verifies. + This is a genuine exception to "the doc-header is the single source of -truth" above, and it comes with an obligation `verify-manual.py` cannot -enforce for you: nothing checks that a dedicated section still describes -the function's *current* behavior. **Whenever you change what one of -these functions does, update its dedicated section in the same commit or -pull request** — not as a follow-up. A function with a dedicated section -should say so in its own `# NOTES` (see `functions/agents-init.fish` for -the pattern), so a later reader of just the header still finds the fuller -page. +truth" above. **Whenever you change what one of these functions does, +update its dedicated section in the same commit or pull request** — not +as a follow-up. ### Private/internal helper functions diff --git a/docs/build-manual.py b/docs/build-manual.py index cb7ac5f..cd1435a 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -202,7 +202,7 @@ def build_concat(root: Path) -> str: Only bodies are passed: the pandoc metadata block above is not prose and must survive byte-for-byte. """ - entries = build_entries(mt.parse_functions(FUNCTIONS)) + entries = build_entries(mt.parse_functions(FUNCTIONS), root=root) chunks: list[str] = [] pandoc_path = root / "_pandoc.yml" if pandoc_path.exists(): @@ -726,7 +726,44 @@ def _classification_tags(raw: list[str]) -> list[str]: return [t for t in tags if t] -def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str: +MANUAL_SECTION_RE = re.compile(r"^manual-section\(([\w./-]+)\)$") + + +def _manual_section_slug(tags: list[str]) -> str | None: + """Pull the slug out of a `manual-section()` CLASSIFICATION tag, if present.""" + for tag in tags: + m = MANUAL_SECTION_RE.match(tag) + if m: + return m.group(1) + return None + + +def _resolve_manual_section(root: Path | None, slug: str) -> tuple[str, str, str] | None: + """Resolve a manual-section() tag to (display label, site link, doc-relative path). + + Looks for .md (a top-level single-file section) or /index.md + (a directory-based section), matching the two shapes docs/manual/ + actually uses. The display label is read fresh from the target's own + frontmatter (manTitle, falling back to title) rather than duplicated in + the tag, so a renumbered section never needs its tag updated -- only + the slug (the filename) does, and that only changes if the page itself + is renamed. Returns None -- silently, this is a build, not a check; + verify-manual.py is where a dangling slug is a real failure -- when + is unset or neither candidate exists. + """ + if root is None: + return None + for relpath in (f"{slug}.md", f"{slug}/index.md"): + if (root / relpath).exists(): + fm, _ = mt.parse(root / relpath) + label = fm.get("manTitle") or fm.get("title", slug) + return label, f"/{slug}/", relpath + return None + + +def render_entry( + fn: dict[str, list[str]], used_by: list[str], link=None, root: Path | None = None +) -> str: """Render one parsed function header as a manual entry body. Emits the same man-page shape Section 5 was authored in — one 4-space @@ -759,15 +796,22 @@ def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str def names(raw: list[str]) -> list[str]: return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] + classification = _classification_tags(fn.get("CLASSIFICATION", [])) refs = [] for label, values in ( ("Dependencies", names(fn.get("DEPENDENCIES", []))), - ("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), + ("Classification", classification), ("Used by", sorted(used_by)), ): if values: rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) refs.append(f"**{label}:** {rendered}") + slug = _manual_section_slug(classification) + if slug: + resolved = _resolve_manual_section(root, slug) + if resolved: + label, _href, relpath = resolved + refs.append(f"**See also:** {label} (`docs/manual/{relpath}`)") if refs: block += "\n\n" + "\n\n".join(refs) return block @@ -871,7 +915,9 @@ SITE_SECTIONS = ( ) -def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) -> str: +def render_entry_site( + fn: dict[str, list[str]], used_by: list[str], link=None, root: Path | None = None +) -> str: """Render one parsed function header as a manual entry body for the site. Unlike `render_entry` (the single indented man-page block pandoc wants, @@ -905,15 +951,22 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) - def names(raw: list[str]) -> list[str]: return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] + classification = _classification_tags(fn.get("CLASSIFICATION", [])) refs = [] for label, values in ( ("Dependencies", names(fn.get("DEPENDENCIES", []))), - ("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), + ("Classification", classification), ("Used by", sorted(used_by)), ): if values: rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) refs.append(f"**{label}:** {rendered}") + slug = _manual_section_slug(classification) + if slug: + resolved = _resolve_manual_section(root, slug) + if resolved: + label, href, _relpath = resolved + refs.append(f"**See also:** [{label}]({href})") if refs: parts.append("\n\n".join(refs)) @@ -921,7 +974,7 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) - def build_entries( - functions: dict[str, dict], link=None, site: bool = False + functions: dict[str, dict], link=None, site: bool = False, root: Path | None = None ) -> dict[str, list[tuple[str, str]]]: """Group rendered entries by category stem, ordered by function name. @@ -929,6 +982,9 @@ def build_entries( authored: a bidirectional link maintained by hand drifts the moment one side is edited. `site` selects `render_entry_site` (headings + tables) over `render_entry` (the man-page indented block `build_concat` needs). + `root` (docs/manual/) resolves any `manual-section()` + CLASSIFICATION tag to that page's own title -- omitted, the default, + an entry with the tag just gets no "See also" line rather than failing. """ used_by: dict[str, list[str]] = {} for name, fn in functions.items(): @@ -940,7 +996,7 @@ def build_entries( out: dict[str, list[tuple[str, str]]] = {} for name in sorted(functions): fn = functions[name] - body = render(fn, used_by.get(name, []), link) + body = render(fn, used_by.get(name, []), link, root=root) out.setdefault(fn["CATEGORY"][0], []).append((name, body)) return out @@ -1041,7 +1097,9 @@ def build_site(root: Path, out: Path) -> list[dict]: out.mkdir(parents=True) functions = mt.parse_functions(FUNCTIONS) - entries = build_entries(functions, link=lambda n: _entry_link(n, functions), site=True) + entries = build_entries( + functions, link=lambda n: _entry_link(n, functions), site=True, root=root + ) sidebar: list[dict] = [{"label": "Home", "link": "/"}] standard_groups: dict = {} diff --git a/docs/function-classification-schema.md b/docs/function-classification-schema.md index 9631b44..18414b0 100644 --- a/docs/function-classification-schema.md +++ b/docs/function-classification-schema.md @@ -5,6 +5,14 @@ doc-header label. It's referenced from code comments and commit messages — link here, not to anything under `AGENTS/` (that tree is git-ignored local agent state, not part of the repo). +`CLASSIFICATION` isn't limited to command-safety hazards, even though most +of the closed set below is exactly that — it's the general-purpose place to +tag what a function touches or how it behaves, whenever that's worth +surfacing without reading the function's own body. `manual-section()` +is the one tag in the set that isn't a hazard at all: it marks a function +that has its own dedicated manual section beyond this header (see +[Dedicated manual sections for complex subsystems](../CONTRIBUTING.md#dedicated-manual-sections-for-complex-subsystems)). + See [Public function documentation header](../CONTRIBUTING.md#public-function-documentation-header) in `CONTRIBUTING.md` for where `CLASSIFICATION` fits among the other header labels, and [C1 — Command Shadows](manual/08-components-reference/01-c1-command-shadows.md) @@ -72,6 +80,22 @@ it empty as a placeholder. handler, an fzf-driven picker) — the hazard this tag exists for is a script or another function calling it unexpectedly, not a human running it themselves. +- **`manual-section()`** — this function has a dedicated manual + section beyond its own header; `` is that page's filename under + `docs/manual/` with the extension dropped (`16-agent-tooling` for + `docs/manual/16-agent-tooling.md`; a directory-based section like + `08-components-reference` uses its directory name the same way, resolved + against its `index.md`). `docs/build-manual.py` reads the target page's + own `manTitle`/`title` at build time and renders a **See also** line on + the function's generated entry — the tag only needs to keep pointing at + the right *file*; the displayed section number is never duplicated into + the tag, so it can't go stale on its own if the manual gets renumbered. + `docs/verify-manual.py` fails if the slug doesn't resolve to a real page. + Multiple functions may carry the same slug (`agents-init` and + `agents-vault` both point at `16-agent-tooling`, one section covering + both). See [Dedicated manual sections for complex subsystems](../CONTRIBUTING.md#dedicated-manual-sections-for-complex-subsystems) + in `CONTRIBUTING.md` for when a function's behavior has outgrown its + header and belongs in one of these instead. ## Placement diff --git a/docs/verify-manual.py b/docs/verify-manual.py index 4500fb7..9f7d479 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -1674,6 +1674,117 @@ def test_concat_section_five_stays_verbatim(): assert not offenders, f"backticks inside verbatim entries: {offenders[:3]}" +def test_manual_section_slug_extracts_tag(): + """`manual-section()` is found among other CLASSIFICATION tags, or not at all.""" + import build_manual + + assert build_manual._manual_section_slug(["destructive", "manual-section(foo-bar)"]) == "foo-bar" + assert build_manual._manual_section_slug(["network"]) is None + assert build_manual._manual_section_slug([]) is None + + +def test_resolve_manual_section_reads_target_frontmatter(): + """Resolves both page shapes (top-level file, directory index) and reports None cleanly.""" + import build_manual + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "solo.md").write_text( + "---\ntitle: Solo\nmanTitle: 9. SOLO\n---\nbody\n" + ) + (root / "grouped").mkdir() + (root / "grouped" / "index.md").write_text( + "---\ntitle: Grouped\nmanTitle: 10. GROUPED\n---\nbody\n" + ) + + label, href, relpath = build_manual._resolve_manual_section(root, "solo") + assert label == "9. SOLO", label + assert href == "/solo/", href + assert relpath == "solo.md", relpath + + label, href, relpath = build_manual._resolve_manual_section(root, "grouped") + assert label == "10. GROUPED", label + assert href == "/grouped/", href + assert relpath == "grouped/index.md", relpath + + assert build_manual._resolve_manual_section(root, "missing") is None + assert build_manual._resolve_manual_section(None, "solo") is None + + +def test_render_entry_see_also_appears_only_when_root_resolves(): + """The man-page See-also line needs both the tag and a root that resolves it.""" + import build_manual + + fn = { + "SYNOPSIS": ["thing"], + "DESCRIPTION": ["Does a thing."], + "CLASSIFICATION": ["manual-section(deep-dive)"], + } + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "deep-dive.md").write_text( + "---\ntitle: Deep Dive\nmanTitle: 20. DEEP DIVE\n---\nbody\n" + ) + + out = build_manual.render_entry(fn, [], root=root) + assert "**See also:** 20. DEEP DIVE (`docs/manual/deep-dive.md`)" in out, out + + # No root at all -- same as every other existing caller that never + # passes one -- silently omits the line rather than raising. + out_no_root = build_manual.render_entry(fn, []) + assert "See also" not in out_no_root, out_no_root + + # A root that exists but doesn't have the target page: also silent. + with tempfile.TemporaryDirectory() as empty: + out_missing = build_manual.render_entry(fn, [], root=Path(empty)) + assert "See also" not in out_missing, out_missing + + +def test_render_entry_site_see_also_is_a_real_link(): + """The site's See-also line is a markdown link to the resolved page's site path.""" + import build_manual + + fn = { + "SYNOPSIS": ["thing"], + "DESCRIPTION": ["Does a thing."], + "CLASSIFICATION": ["manual-section(deep-dive)"], + } + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "deep-dive.md").write_text( + "---\ntitle: Deep Dive\nmanTitle: 20. DEEP DIVE\n---\nbody\n" + ) + out = build_manual.render_entry_site(fn, [], root=root) + assert "**See also:** [20. DEEP DIVE](/deep-dive/)" in out, out + + +def test_real_manual_section_tags_resolve(): + """Every manual-section() tag on a real function points at a real page. + + This is the enforcement half of the convention: build-manual.py stays + silent about a dangling slug (it just skips the See-also line), so this + is the only thing that turns a typo'd or stale slug into a failure. + """ + import build_manual + + functions = mt.parse_functions(build_manual.FUNCTIONS) + checked = 0 + for name, fn in functions.items(): + tags = build_manual._classification_tags(fn.get("CLASSIFICATION", [])) + slug = build_manual._manual_section_slug(tags) + if slug is None: + continue + checked += 1 + resolved = build_manual._resolve_manual_section(build_manual.MANUAL, slug) + assert resolved is not None, ( + f"{name}'s manual-section({slug}) tag doesn't resolve to " + f"docs/manual/{slug}.md or docs/manual/{slug}/index.md" + ) + assert checked > 0, "expected at least one real function to carry manual-section(...)" + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")] diff --git a/functions/agents-init.fish b/functions/agents-init.fish index 6688ad9..bf0dfcb 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -8,7 +8,7 @@ # _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore # # CLASSIFICATION -# self-limiting(rm,mkdir,grep), bypasses-shadow(mv) +# self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling) # # SYNOPSIS # agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index a5183a4..a6b0cc0 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -10,7 +10,7 @@ # _agents_repo_install_tools, git, hostname # # CLASSIFICATION -# self-limiting(rm,mkdir) +# self-limiting(rm,mkdir), manual-section(16-agent-tooling) # # SYNOPSIS # agents-vault [--link] [--push] [--restore] [--status] -- 2.54.0