feat(agents-init): retire CLAUDE.md, discover and normalize AGENTS.md in every subdirectory #177

Merged
rootiest merged 12 commits from docs/retire-claude-md-for-agents-md into main 2026-09-24 04:56:06 +00:00
6 changed files with 229 additions and 23 deletions
Showing only changes of commit 911c6e95ed - Show all commits
+26 -13
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,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(<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
@@ -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(<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, 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
+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 = {}
+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
+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_")]
+1 -1
View File
@@ -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]
+1 -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]