Merge pull request 'feat(agents-init): retire CLAUDE.md, discover and normalize AGENTS.md in every subdirectory' (#177) from docs/retire-claude-md-for-agents-md into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m25s
CI / docs (push) Successful in 4m11s

This commit was merged in pull request #177.
This commit is contained in:
2026-09-24 04:56:01 +00:00
committed by Gitea
16 changed files with 1457 additions and 134 deletions
+43 -5
View File
@@ -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,51 @@ 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(<slug>)` 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
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.
A function with a dedicated section carries `manual-section(<slug>)` 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. **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
+66 -8
View File
@@ -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(<slug>)` 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(<slug>) tag to (display label, site link, doc-relative path).
Looks for <slug>.md (a top-level single-file section) or <slug>/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
<root> 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(<slug>)`
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 = {}
+15 -8
View File
@@ -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
+24
View File
@@ -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(<slug>)`
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(<slug>)`** — this function has a dedicated manual
section beyond its own header; `<slug>` 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
@@ -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
+312
View File
@@ -0,0 +1,312 @@
---
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/<that path>/`,
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.
## 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
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.
@@ -1,8 +1,8 @@
---
title: Attribution
manTitle: 16. ATTRIBUTION
manTitle: 17. ATTRIBUTION
sidebar:
order: 20
order: 21
helpKeywords:
- attribution
- credits
@@ -1,8 +1,8 @@
---
title: License
manTitle: 17. LICENSE
manTitle: 18. LICENSE
sidebar:
order: 21
order: 22
helpKeywords:
- license
- licensing
+111
View File
@@ -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(<slug>)` 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(<slug>) 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_")]
@@ -0,0 +1,45 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# _agents_init_path_is_protected <root> <path>
#
# 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 <root> -- 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.
# - <root>/.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" --literal-pathspecs ls-files --error-unmatch -- "$path" >/dev/null 2>&1; or return 1
test -s "$root/.gitignore"; or return 1
return 0
end
@@ -0,0 +1,251 @@
# 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)
#
# SYNOPSIS
# _agents_init_sync_instructions <root> <agents_dir> <rel>
#
# DESCRIPTION
# Normalizes one directory's agent instruction file(s) into the
# AGENTS.md-only shape: <root>/<rel>/AGENTS.md becomes a symlink to the
# real file at <agents_dir>/<rel>/AGENTS.md (or, for the root itself,
# <agents_dir>/AGENTS.md directly), and no CLAUDE.md survives anywhere
# 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 <rel> 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. 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
# 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. A
# real project-level file found here (written after the mirror
# 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
# 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 <rel> 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
# One "→ ..." line per change made, on stdout; nothing when <rel> 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, "<rel>/..." 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
# 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"
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
# A real (non-symlink) file here arrived after the mirror settled. Same
# 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
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
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
return 0
end
+105 -101
View File
@@ -5,10 +5,10 @@
# 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)
# self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling)
#
# SYNOPSIS
# agents-init [-a | --agents] [-p | --plugins] [-v | --verbose]
@@ -17,8 +17,18 @@
# 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,
# 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
@@ -26,11 +36,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/<subdir>/AGENTS.md canonical spec for any subdir with its own
# scoped instructions (real file, discovered
# automatically -- see above)
# <root>/AGENTS.md → AGENTS/AGENTS.md
# <root>/CLAUDE.md → AGENTS/CLAUDE.md
# <root>/<subdir>/AGENTS.md → AGENTS/<subdir>/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 +53,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/<tgt>,
# docs/superpowers/<tgt>, and the old AGENTS/plugins/ layout) into the
# canonical AGENTS/<tgt>; the AGENTS/plugins/ layer is removed.
@@ -75,7 +92,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
@@ -92,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
@@ -105,7 +132,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"
@@ -140,7 +167,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
@@ -201,109 +230,84 @@ 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
# 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.
#
# Discovery stays inside this project: a non-git root (a lone
# agent file in, say, ~) syncs only itself -- walking it would
# reach into every unrelated tree below. In a git root, pruned:
# any AGENTS/ (a mirror, never a source), dot-directories (.git,
# .claude, .github: tool state, not scoped project dirs),
# node_modules, generated-output directories (build, dist, out,
# target: an instruction file there is a build artifact, never a
# source -- pruned outright, before tracked-file protection would
# even be consulted), and nested repos/submodules/worktrees (their
# own .git marks another project). -mindepth 1 keeps the root
# itself, which has a .git, from pruning the whole walk.
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
if test -f "$root/CLAUDE.md"; and not test -L "$root/CLAUDE.md"
set has_claude 1
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"
if test -n "$out"
set changed 1
if test $verbose -eq 1
for line in $out
echo "$c_ok$line$c_reset"
end
end
end
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
# ── 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_ok→ Linked $name → $want$c_reset"
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 ────────────────────────────────────────────────────────
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
+8 -1
View File
@@ -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]
@@ -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 -2
View File
@@ -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
+4 -3
View File
@@ -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
+465
View File
@@ -0,0 +1,465 @@
#!/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)
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)
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)
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)
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)
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
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