Skip to content

Commit a92ea1c

Browse files
authored
mods/agents-md: the AGENTS.md project-instructions mod (#95409)
* mods/types: refresh the engine typings; diff's old-files fixture names isLink * mods/agents-md: the AGENTS.md project-instructions mod * mods/agents-md: the instructionFiles option with its legacy key, as shipped; typings at 2.1.277
1 parent 31a3b00 commit a92ea1c

76 files changed

Lines changed: 4083 additions & 301 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎mods/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
A mod is a Claude Code plugin whose behaviour lives in a hooks module: one
44
`register(on, options)` entry that hooks the engine's events as functions
5-
`($, e, next)`. These three ship inside Claude Code; this folder is their
5+
`($, e, next)`. These four ship inside Claude Code; this folder is their
66
source, published as it is built into the binary.
77

88
| Mod | What it does | Seated |
99
| --- | --- | --- |
1010
| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
1111
| [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
1212
| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in |
13+
| [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in |
1314

1415
Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
1516
`hooks/hooks.json` naming the module, and TypeScript under `hooks/` typed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "agents-md",
3+
"version": "0.1.0",
4+
"description": "AGENTS.md as project instructions, by one option. Under claude-md-or-agents-md a project with no instruction files of its own gets its AGENTS.md files instead: a prompt.context hook asks $.fs.ancestors for every AGENTS.md and .claude/AGENTS.md from the filesystem root down to the working directory and hands them to the engine as project instruction files, which it renders, announces and withholds from agents exactly as it does CLAUDE.md, skipping any file the engine already loaded by import or link; a tool.call hook on Read attaches the ones under the project root as the engine attaches a nested CLAUDE.md. Under claude-md-and-agents-md every AGENTS.md is loaded beside CLAUDE.md. Under managed-only the project's and the person's instruction files are dropped and the organization's managed ones kept. Under claude-md the engine's walk stands alone. claude-md-or-agents-md is the default.",
5+
"author": {
6+
"name": "Anthropic"
7+
},
8+
"userConfig": {
9+
"instructionFiles": {
10+
"type": "string",
11+
"title": "Project instructions",
12+
"description": "\"claude-md\": CLAUDE.md only, loaded by the engine as today. \"claude-md-or-agents-md\" (default): a project with no CLAUDE.md of its own gets its AGENTS.md files instead, loaded exactly where and how CLAUDE.md would be. \"claude-md-and-agents-md\": AGENTS.md files are loaded beside CLAUDE.md (a file CLAUDE.md already imports or links to is not loaded twice). \"managed-only\": the project's and your own instruction files are dropped; the organization's managed CLAUDE.md and memory stay.",
13+
"required": false,
14+
"default": "claude-md-or-agents-md",
15+
"options": [
16+
"claude-md",
17+
"claude-md-or-agents-md",
18+
"claude-md-and-agents-md",
19+
"managed-only"
20+
]
21+
}
22+
}
23+
}

‎mods/agents-md/README.md‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# agents-md
2+
3+
`AGENTS.md` read the way Claude Code reads `CLAUDE.md`, as a plugin, under
4+
one option, `instructionFiles`:
5+
6+
- `claude-md`: only `CLAUDE.md` is loaded, by the engine, as today. The plugin
7+
adds nothing.
8+
- `claude-md-or-agents-md` (the default): a project with no instruction files
9+
of its own gets its `AGENTS.md` files instead, loaded exactly where and how
10+
`CLAUDE.md` would be. "Of its own" is read off what the engine loaded for
11+
the context: a `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` in any
12+
directory from the root down to the working directory leaves the whole
13+
project to the engine, and the plugin stays out (the organization's managed
14+
file, the person's `~/.claude/CLAUDE.md`, a `.claude/rules` file and an
15+
added directory's `CLAUDE.md` do not count, as the nested walk does not see
16+
them either). With none, every `AGENTS.md` and `.claude/AGENTS.md` on that
17+
path joins the instruction files the engine renders, and a `Read` under a
18+
subdirectory attaches that directory's `AGENTS.md` unless a `CLAUDE.md`
19+
there claims it.
20+
- `claude-md-and-agents-md`: every `AGENTS.md` is loaded beside `CLAUDE.md`,
21+
up and down the tree; a file `CLAUDE.md` already `@`-imports, or is a link
22+
to, is not loaded a second time (compared by path, then by content).
23+
- `managed-only`: the project's checked-in and private instruction files and
24+
the person's own are dropped from the context; the organization's managed
25+
`CLAUDE.md` and the engine's memory stay. The engine's nested `CLAUDE.md`
26+
attachments on `Read` are not an event yet and still arrive. (The engine's
27+
`claudeMdExcludes` setting also exists, for user, project and local files,
28+
and applies to the `AGENTS.md` files this plugin reads too.)
29+
30+
How the files reach the model is the engine's doing, not the plugin's:
31+
`prompt.context` hands a hook the instruction files behind `claudeMd`
32+
(`{ path, kind, content, parent? }`, kinds `managed`, `user`, `project`,
33+
`local`, `memory`, in load order) and a hook answers the list changed. The
34+
engine then renders `claudeMd` from the answered files with its own preamble
35+
and framing, announces them by name, and keeps only the `managed` ones for an
36+
agent that omits project instructions (Explore, Plan, a custom agent with
37+
`omitClaudeMd`). So an `AGENTS.md` this plugin adds as a `project` file is, to
38+
everything downstream, a project instruction file: same place in the context,
39+
same framing, same omission rules, same announcement. An organization's
40+
prepended plugin on `prompt.context` sits above this one and has the last
41+
word on the files.
42+
43+
`hooks/register.ts` is the module; everything under `hooks/` is its parts,
44+
importing `claude-code` and one another alone. `tests/` runs under
45+
`claude plugin test <this folder>`.
46+
47+
## Setting the option
48+
49+
As a built-in its option is the `/config` row "Project instructions", a
50+
picker over the four values, each described there. By hand it is
51+
52+
```json
53+
{
54+
"pluginConfigs": {
55+
"agents-md@builtin": {
56+
"options": { "instructionFiles": "claude-md-and-agents-md" }
57+
}
58+
}
59+
}
60+
```
61+
62+
in user settings (`~/.claude/settings.json`), `--settings`, or managed
63+
settings; a project's `.claude/settings.json` is not read for plugin
64+
options. Changing it reloads the module, and the next context the engine
65+
builds (the next turn after the reload, a new conversation, `/clear`, a
66+
compaction) carries the new mode's files. A hand-typed value outside the
67+
four is told once in the transcript and reads as the default. `/plugin`
68+
lists the plugin among the built-ins, where a person can turn it off; with
69+
it off the engine reads `CLAUDE.md` alone.
70+
71+
The option was first keyed `projectInstructions`, with the values `claude`,
72+
`agents-fallback`, `both` and `none`. A value still stored under that key is
73+
honoured for now while `instructionFiles` reads as its default: `none` as
74+
`managed-only`, `claude` as `claude-md`, `agents-fallback` as
75+
`claude-md-or-agents-md`, `both` as `claude-md-and-agents-md`, any other
76+
value as `claude-md` (which adds nothing, never as the default, which loads
77+
`AGENTS.md`); the first `session.start` of a load says in the transcript how
78+
it was read. Once `instructionFiles` is set to anything but its default, the
79+
old key is not read and the transcript says to remove it.
80+
81+
Run from this folder instead (`claude --plugin-dir mods/agents-md`), the
82+
same entry is keyed `"agents-md"`.
83+
84+
## What it hooks
85+
86+
| event | what the hook does |
87+
| --- | --- |
88+
| `session.start` | in every mode: passes the start straight through and floats the usage row for the configured mode, never awaited; the first start of a load logs how a stored `projectInstructions` value is read. The session's start never waits on this plugin |
89+
| `prompt.context` | under `claude-md-or-agents-md` and `claude-md-and-agents-md`: walks `$.fs.ancestors` for the `AGENTS.md` files above the working directory and answers them as `project` instruction files, each `@` import its own entry after its file, each placed where a project file of its directory stands (root first, before the first deeper project file, else after the last project file, before memory); files the engine already holds by path or by content are left out; under `claude-md-or-agents-md` it answers nothing when the project has a `CLAUDE.md` of its own (among the handed files, else found by a `$.fs.ancestors` walk, so a `CLAUDE.md` the engine loaded and then withheld still counts), and logs which files it loaded once, and again after a move to another project root; handed unknown files (a hook above rewrote the `claudeMd` text) it adds nothing; the first context of a load sends the load row (counts) and the feature mark; under `managed-only` (matcher: a `project`, `local` or `user` file present): answers the list without those kinds |
90+
| `agent.spawn` on `fork: true` | under `claude-md-or-agents-md` and `claude-md-and-agents-md`: a fork the Agent tool starts shares its parent's prompt prefix, so the parent loop's delivered nested files are copied to the fork's loop and not attached to it again (a `/fork` or `/subtask` fork does not raise `agent.spawn` yet and starts from an empty set, as every fork did before; a fork started in the same tool batch as a `Read` inherits that Read's file although its prefix holds a placeholder for it) |
91+
| `tool.call` on `Read` | under `claude-md-or-agents-md` and `claude-md-and-agents-md`, for a file under the session's project root (`$.session.root()`, read live, so `/cd`, a host's directory change and worktree moves are followed and a moved root starts the delivered sets and the fallback decision over; a file elsewhere gets nothing, as the engine attaches no nested `CLAUDE.md` there): walks only the directories strictly between the root and the read file (`$.fs.ancestors` with `below: root`, as the engine walks only those for a nested `CLAUDE.md`, never up to the filesystem root again) and attaches their `AGENTS.md` files not yet given to that agent loop, not already among the context's instruction files (by path or, for a project file, by text) and not claimed by a `CLAUDE.md` of the same directory (or imported by one), as `context` after the tool result, framed `Contents of <path>:` byte for byte as the engine frames a nested `CLAUDE.md`, whatever its size; each file once per loop and conversation (the context's recomputation after a compaction or `/clear` starts the count over), the context's files never; a Read that attached files sends the nested row. A `~` or `~/` path is read under the home directory as the Read tool reads it |
92+
93+
## What it calls on `$`
94+
95+
`fs.ancestors` (with each found file's `parts`: the file and its imports
96+
apart; with `below` on a Read; it finds nothing on a thin client, whose
97+
workspace files are remote, as the engine's own walk does), `session.root`,
98+
`session.cwd`, `env.get` (`HOME` and `USERPROFILE`, once per load, the
99+
profile first on a Windows spelling of the working directory, so a `~/` path
100+
the model hands a Read resolves where the Read tool reads it), `ui.log`,
101+
`telemetry.log` and `telemetry.mark`.
102+
103+
`$.telemetry` is the [telemetry](../telemetry) plugin's noun; where that
104+
plugin is not seated the calls find no noun and are dropped without a trace,
105+
and nothing else changes.
106+
107+
## What it logs
108+
109+
Counts and closed choices only; no path and no file text. Each row goes
110+
through `$.telemetry.log`, so it exists only where the telemetry plugin
111+
does:
112+
113+
| event | when | properties |
114+
| --- | --- | --- |
115+
| `agents_md_mode` | once per fresh load, at `session.start` | `mode` (`claude-md` \| `claude-md-or-agents-md` \| `claude-md-and-agents-md` \| `managed-only`), `is_interactive` |
116+
| `agents_md_load` | the first context of a load, under `claude-md-or-agents-md` and `claude-md-and-agents-md` | `mode`, `file_count` (`AGENTS.md` files handed to the engine), `import_count` (their `@` imports), `total_content_length`, `yielded` (`claude-md-or-agents-md` stood down for a `CLAUDE.md` of the project's own), `walk_failed`; with it one `$.telemetry.mark` for feature `agents_md`: `ok`, or `sad` with reason `walk_failed` |
117+
| `agents_md_nested` | a Read that attached nested files | `mode`, `file_count` |
118+
119+
## Where it still differs from CLAUDE.md
120+
121+
All of these apply only to the modes that load `AGENTS.md`,
122+
`claude-md-or-agents-md` (the default) and `claude-md-and-agents-md`. Each
123+
names a loader fact a plugin cannot reach through the events it has today.
124+
125+
1. Nested files attach on a text `Read` only. The engine also attaches a
126+
directory's `CLAUDE.md` for a file `@`-mentioned in the prompt, for the
127+
IDE's opened file or selection, and for the `Read` tool's notebook, image
128+
and PDF results.
129+
2. A nested file the plugin attaches is not registered in the loop's
130+
read-file state, so after a compaction the engine does not restore it
131+
among the recently read files (the plugin attaches it again at the next
132+
`Read` under that directory instead), and a change to it mid-session is
133+
not re-announced.
134+
3. `/cd` carries the new tree's `CLAUDE.md` in its own notice; the plugin's
135+
files for the new tree arrive in the same next request through the
136+
engine's instructions announcement instead.
137+
4. Paths compare by spelling; the engine resolves a symlinked alias of the
138+
working directory before deciding a file is inside it.
139+
5. `--add-dir` directories contribute no `AGENTS.md`, where the engine can
140+
load their `CLAUDE.md`.
141+
6. `/memory` and the `#` shortcut do not know `AGENTS.md` files, and the
142+
engine's own initial-load row does not count them (this plugin's
143+
`agents_md_load` row does).
144+
7. An `@` import outside the working directory inside an `AGENTS.md` is
145+
honoured only once the approval the engine asks for a `CLAUDE.md`'s
146+
external imports has been given (without it the import is left out, as a
147+
`CLAUDE.md`'s is); the approval dialog itself is raised for `CLAUDE.md`
148+
imports alone.
149+
8. A subagent that is not a fork gets a nested `AGENTS.md` at its own first
150+
`Read` under that directory even when its parent's loop was already given
151+
it; the engine does not hand such a subagent the nested `CLAUDE.md` again.
152+
A fork matches the engine on both sides.
153+
154+
## Testing
155+
156+
claude plugin test mods/agents-md
157+
158+
`tests/register.test.ts` covers the default mode: a project with `AGENTS.md`
159+
alone gets it as a project instruction file and one transcript line naming
160+
it, a project with a `CLAUDE.md` of its own is left to the engine without a
161+
walk, a failed walk leaves the context as handed, and the start hands
162+
`$.telemetry` the mode row alone where a test seats a provider for that
163+
noun, and goes on untouched where none is seated.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { InstructionFile } from 'claude-code'
2+
3+
/**
4+
* The path of the file at the head of an instruction file's `@`-import
5+
* chain; the file's own path when nothing imported it.
6+
*
7+
* Follows `parent` among the given files until a file nothing imported.
8+
*
9+
* @param file the instruction file
10+
* @param byPath the files it may have come through, by path
11+
* @returns the chain head's path
12+
*/
13+
export function chainRootOf(
14+
file: InstructionFile,
15+
byPath: ReadonlyMap<string, InstructionFile>,
16+
): string {
17+
const seen = new Set<string>([file.path])
18+
let path = file.path
19+
let parent = file.parent
20+
21+
while (parent !== undefined && !seen.has(parent)) {
22+
seen.add(parent)
23+
path = parent
24+
parent = byPath.get(parent)?.parent
25+
}
26+
27+
return path
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { InstructionFileKind } from 'claude-code'
2+
3+
/**
4+
* The kinds `managed-only` drops: the project's checked-in and private
5+
* instruction files and the person's own; what it keeps is the organization's
6+
* and memory.
7+
*/
8+
export const DROPPED_KINDS: readonly InstructionFileKind[] = [
9+
'project',
10+
'local',
11+
'user',
12+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { FsAncestor, InstructionFile } from 'claude-code'
2+
3+
/**
4+
* The found AGENTS.md files as project instruction files, one per file and
5+
* per file it `@`-imported, in walk and load order.
6+
*
7+
* An import names the file that brought it as its parent.
8+
*
9+
* @param found what `$.fs.ancestors` found, root first
10+
* @returns the instruction files, kind `project`
11+
*/
12+
export const filesOf = (found: readonly FsAncestor[]): InstructionFile[] =>
13+
found.flatMap(entry =>
14+
entry.parts.map((part, index) => ({
15+
path: part.path,
16+
kind: 'project' as const,
17+
content: part.content,
18+
...(index > 0 && { parent: entry.parts[0]?.path ?? part.path }),
19+
})),
20+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export * from './chain-root-of.js'
2+
export * from './dropped-kinds.js'
3+
export * from './files-of.js'
4+
export * from './insertion-index.js'
5+
export * from './is-claude-file-on-walk.js'
6+
export * from './is-kept-without-instructions.js'
7+
export * from './is-project-own.js'
8+
export * from './project-dir-of.js'
9+
export * from './unseen-files.js'
10+
export * from './with-project-files.js'
11+
12+
export * as default from '.'
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { InstructionFile } from 'claude-code'
2+
3+
import Frames from '../frames'
4+
import { chainRootOf } from './chain-root-of.js'
5+
import { isProjectOwn } from './is-project-own.js'
6+
import { projectDirOf } from './project-dir-of.js'
7+
8+
/**
9+
* Where a directory's AGENTS.md files go among the files the engine loaded,
10+
* as the engine orders project files root first.
11+
*
12+
* Before the first project file of a deeper directory (an imported file
13+
* counts with the file at the head of its chain), else after the last
14+
* project file, else before the engine's memory, else at the end.
15+
*
16+
* @param files the list so far
17+
* @param dir the directory the AGENTS.md files stand in
18+
* @returns the index to insert at
19+
*/
20+
export function insertionIndex(
21+
files: readonly InstructionFile[],
22+
dir: string,
23+
): number {
24+
const byPath = new Map(files.map(file => [file.path, file]))
25+
const deeper = files.findIndex(
26+
file =>
27+
isProjectOwn(file) &&
28+
Frames.isBelow(projectDirOf(chainRootOf(file, byPath)), dir),
29+
)
30+
31+
if (deeper !== -1) {
32+
return deeper
33+
}
34+
35+
const lastOwn = files.findLastIndex(isProjectOwn)
36+
37+
if (lastOwn !== -1) {
38+
return lastOwn + 1
39+
}
40+
41+
const memory = files.findIndex(file => file.kind === 'memory')
42+
43+
return memory === -1 ? files.length : memory
44+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { InstructionFile } from 'claude-code'
2+
3+
import Frames from '../frames'
4+
import Names from '../names'
5+
import { isProjectOwn } from './is-project-own.js'
6+
import { projectDirOf } from './project-dir-of.js'
7+
8+
/**
9+
* Whether a handed instruction file is a CLAUDE.md, `.claude/CLAUDE.md` or
10+
* CLAUDE.local.md of a directory on the walk down to the session's root.
11+
*
12+
* What makes a project "have a CLAUDE.md of its own" for
13+
* `claude-md-or-agents-md`, the same files the Read walk asks for; a rules
14+
* file, an imported file or an added directory's CLAUDE.md is not one.
15+
*
16+
* @param file the handed instruction file
17+
* @param root the session's project root, absolute
18+
* @returns true for the project's own CLAUDE.md files on the walk
19+
*/
20+
export function isClaudeFileOnWalk(
21+
file: InstructionFile,
22+
root: string,
23+
): boolean {
24+
const isOwnClaudeFile =
25+
isProjectOwn(file) &&
26+
file.parent === undefined &&
27+
Names.CLAUDE_NAMES.some(name =>
28+
Frames.normalSpellingOf(file.path).endsWith(`/${name}`),
29+
)
30+
31+
if (!isOwnClaudeFile) {
32+
return false
33+
}
34+
35+
const dir = projectDirOf(file.path)
36+
const spelledRoot = Frames.normalSpellingOf(root)
37+
38+
return dir === spelledRoot || Frames.isBelow(spelledRoot, dir)
39+
}

0 commit comments

Comments
 (0)