spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

mods/agents-md: the AGENTS.md project-instructions mod (#95409)Browse files
* 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
Alice T'Poteat authored on Sep 18, 2026 · GitHub committed on Sep 18, 2026 parent 31a3b00 · commit a92ea1cdb11a
76 changed files+1568 -4compared with 31a3b00
Mmods/README.md
Amods/agents-md/.claude-plugin/plugin.json
Amods/agents-md/README.md
Amods/agents-md/hooks/files/chain-root-of.ts
Amods/agents-md/hooks/files/dropped-kinds.ts
Amods/agents-md/hooks/files/files-of.ts
Amods/agents-md/hooks/files/index.ts
Amods/agents-md/hooks/files/insertion-index.ts
Amods/agents-md/hooks/files/is-claude-file-on-walk.ts
Amods/agents-md/hooks/files/is-kept-without-instructions.ts
Amods/agents-md/hooks/files/is-project-own.ts
Amods/agents-md/hooks/files/project-dir-of.ts
Amods/agents-md/hooks/files/unseen-files.ts
Amods/agents-md/hooks/files/with-project-files.ts
Amods/agents-md/hooks/frames/absolute-of.ts
Amods/agents-md/hooks/frames/index.ts
Amods/agents-md/hooks/frames/is-below.ts
Amods/agents-md/hooks/frames/is-file-at.ts
Amods/agents-md/hooks/frames/nested-frame.ts
Amods/agents-md/hooks/frames/normal-spelling-of.ts
Amods/agents-md/hooks/frames/outside-claude-dirs.ts
Amods/agents-md/hooks/hooks.json
Amods/agents-md/hooks/index.ts
Amods/agents-md/hooks/modes/default-mode.ts
Amods/agents-md/hooks/modes/index.ts
Amods/agents-md/hooks/modes/legacy-mode-of.ts
Amods/agents-md/hooks/modes/mode-of.ts
Amods/agents-md/hooks/modes/modes.ts
Amods/agents-md/hooks/modes/types/index.ts
Amods/agents-md/hooks/modes/types/mode/index.ts
Amods/agents-md/hooks/modes/types/mode/mode.ts
Amods/agents-md/hooks/names/agents-names.ts
Amods/agents-md/hooks/names/claude-names.ts
Amods/agents-md/hooks/names/index.ts
Amods/agents-md/hooks/names/main-loop.ts
Amods/agents-md/hooks/register.ts
Amods/agents-md/hooks/telemetry/feature-name.ts
Amods/agents-md/hooks/telemetry/index.ts
Amods/agents-md/hooks/telemetry/load-counts-of.ts
Amods/agents-md/hooks/telemetry/load-event.ts
Amods/agents-md/hooks/telemetry/load-mark-of.ts
Amods/agents-md/hooks/telemetry/load-row-of.ts
Amods/agents-md/hooks/telemetry/mode-choice-of.ts
Amods/agents-md/hooks/telemetry/mode-event.ts
Amods/agents-md/hooks/telemetry/mode-row-of.ts
Amods/agents-md/hooks/telemetry/nested-event.ts
Amods/agents-md/hooks/telemetry/nested-row-of.ts
Amods/agents-md/hooks/telemetry/quietly.ts
Amods/agents-md/hooks/telemetry/types/index.ts
Amods/agents-md/hooks/telemetry/types/load-counts/index.ts
Amods/agents-md/hooks/telemetry/types/load-counts/load-counts.ts
Amods/agents-md/hooks/telemetry/types/telemetry-mark/index.ts
Amods/agents-md/hooks/telemetry/types/telemetry-mark/telemetry-mark.ts
Amods/agents-md/hooks/telemetry/types/telemetry-prop/index.ts
Amods/agents-md/hooks/telemetry/types/telemetry-prop/telemetry-prop.ts
Amods/agents-md/hooks/telemetry/types/telemetry-row/index.ts
Amods/agents-md/hooks/telemetry/types/telemetry-row/telemetry-row.ts
Amods/agents-md/hooks/telemetry/walk-failed-reason.ts
Amods/agents-md/tests/fixtures/ancestor-of.ts
Amods/agents-md/tests/fixtures/index.ts
Amods/agents-md/tests/fixtures/project-of.ts
Amods/agents-md/tests/fixtures/recording.ts
Amods/agents-md/tests/fixtures/rows-of.ts
Amods/agents-md/tests/fixtures/session.ts
Amods/agents-md/tests/fixtures/started-of.ts
Amods/agents-md/tests/fixtures/types/index.ts
Amods/agents-md/tests/fixtures/types/row/index.ts
Amods/agents-md/tests/fixtures/types/row/row.ts
Amods/agents-md/tests/fixtures/types/started/index.ts
Amods/agents-md/tests/fixtures/types/started/started.ts
Amods/agents-md/tests/fixtures/types/toast-asked/index.ts
Amods/agents-md/tests/fixtures/types/toast-asked/toast-asked.ts
Amods/agents-md/tests/fixtures/unreadable-project-of.ts
Amods/agents-md/tests/register.test.ts
Mmods/diff/tests/fixtures/old-files.ts
Mmods/types/claude-code.d.ts
@@ -2,7 +2,7 @@
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 |
@@ -10,6 +10,7 @@
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
@@ -1,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+}
@@ -1,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.
@@ -1,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+}
@@ -1,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+]
@@ -1,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+ )
@@ -1,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 '.'
@@ -1,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+}
@@ -1,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+}
@@ -1,0 +1,13 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+import { DROPPED_KINDS } from './dropped-kinds.js'
4+
5+/**
6+ * Whether `managed-only` keeps an instruction file: the organization's managed
7+ * files and the engine's memory stay, the project's and the person's go.
8+ *
9+ * @param file the instruction file
10+ * @returns true for a file `managed-only` leaves in place
11+ */
12+export const isKeptWithoutInstructions = (file: InstructionFile): boolean =>
13+ !DROPPED_KINDS.includes(file.kind)
@@ -1,0 +1,11 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+/**
4+ * Whether an instruction file is one of the project's own (checked in or
5+ * private), as opposed to the organization's, the person's or memory.
6+ *
7+ * @param file the instruction file
8+ * @returns true for a `project` or `local` file
9+ */
10+export const isProjectOwn = (file: InstructionFile): boolean =>
11+ file.kind === 'project' || file.kind === 'local'
@@ -1,0 +1,16 @@
1+import Frames from '../frames'
2+
3+/**
4+ * The project directory an instruction file speaks for: the directory
5+ * holding it, or the one holding its `.claude` folder.
6+ *
7+ * @param path the file's path, absolute
8+ * @returns the directory, in the walk's normal spelling
9+ */
10+export function projectDirOf(path: string): string {
11+ const spelled = Frames.normalSpellingOf(path)
12+ const claudeAt = spelled.lastIndexOf('/.claude/')
13+ const cut = claudeAt === -1 ? spelled.lastIndexOf('/') : claudeAt
14+
15+ return cut <= 0 ? '/' : spelled.slice(0, cut)
16+}
@@ -1,0 +1,40 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+import Frames from '../frames'
4+import { isProjectOwn } from './is-project-own.js'
5+
6+/**
7+ * The candidate files the engine has not already loaded, and no candidate
8+ * twice.
9+ *
10+ * Leaves out one whose path is among the handed files (a CLAUDE.md that
11+ * `@`-imports AGENTS.md) or whose text equals a handed project file's (a
12+ * CLAUDE.md symlinked to AGENTS.md); other kinds' text does not count.
13+ *
14+ * @param candidates the AGENTS.md instruction files of a walk
15+ * @param handed the files the engine handed the hook
16+ * @returns the candidates worth adding, in order
17+ */
18+export function unseenFiles(
19+ candidates: readonly InstructionFile[],
20+ handed: readonly InstructionFile[],
21+): InstructionFile[] {
22+ const paths = new Set(handed.map(file => Frames.normalSpellingOf(file.path)))
23+ const texts = new Set(
24+ handed.filter(isProjectOwn).map(file => file.content.trim()),
25+ )
26+ const kept: InstructionFile[] = []
27+
28+ for (const file of candidates) {
29+ const path = Frames.normalSpellingOf(file.path)
30+ const text = file.content.trim()
31+ const isSeen = paths.has(path) || (text !== '' && texts.has(text))
32+
33+ if (!isSeen) {
34+ paths.add(path)
35+ kept.push(file)
36+ }
37+ }
38+
39+ return kept
40+}
@@ -1,0 +1,37 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+import { chainRootOf } from './chain-root-of.js'
4+import { insertionIndex } from './insertion-index.js'
5+import { projectDirOf } from './project-dir-of.js'
6+
7+/**
8+ * The engine's instruction files with the AGENTS.md files placed where the
9+ * engine would place a project file of their directory; as is when none.
10+ *
11+ * @param handed the files the engine handed the hook, in its order
12+ * @param added the AGENTS.md instruction files to add, root first
13+ * @returns the merged list
14+ */
15+export function withProjectFiles(
16+ handed: readonly InstructionFile[],
17+ added: readonly InstructionFile[],
18+): readonly InstructionFile[] {
19+ if (added.length === 0) {
20+ return handed
21+ }
22+
23+ const merged = [...handed]
24+ const byPath = new Map(added.map(file => [file.path, file]))
25+ const byDir = new Map<string, InstructionFile[]>()
26+
27+ for (const file of added) {
28+ const dir = projectDirOf(chainRootOf(file, byPath))
29+ byDir.set(dir, [...(byDir.get(dir) ?? []), file])
30+ }
31+
32+ for (const [dir, files] of byDir) {
33+ merged.splice(insertionIndex(merged, dir), 0, ...files)
34+ }
35+
36+ return merged
37+}
@@ -1,0 +1,33 @@
1+/**
2+ * A path the model handed a Read, made absolute as the Read tool reads it.
3+ *
4+ * As given when it is absolute; under the home directory (less any trailing
5+ * separator) for `~` or a `~/` spelling when a home is known; else under the
6+ * working directory.
7+ *
8+ * @param path the Read's `file_path` as the model spelled it
9+ * @param root the session's working directory, absolute
10+ * @param home the home directory, absolute, when the environment names one
11+ * @returns the absolute path
12+ */
13+export function absoluteOf(path: string, root: string, home?: string): string {
14+ const isHomeSpelling = path === '~' || path.startsWith('~/')
15+
16+ if (isHomeSpelling && home !== undefined) {
17+ const bare = home.replace(/(?<=.)[\\/]+$/, '')
18+
19+ return path === '~' ? bare : `${bare}${separatorOf(bare)}${path.slice(2)}`
20+ }
21+
22+ return /^(?:[A-Za-z]:)?[\\/]/.test(path)
23+ ? path
24+ : `${root}${separatorOf(root)}${path}`
25+}
26+
27+/**
28+ * The separator a directory's own spelling uses.
29+ *
30+ * @param dir an absolute directory
31+ * @returns `\` for a backslash spelling, `/` otherwise
32+ */
33+const separatorOf = (dir: string): string => (dir.includes('\\') ? '\\' : '/')
@@ -1,0 +1,8 @@
1+export * from './absolute-of.js'
2+export * from './is-below.js'
3+export * from './is-file-at.js'
4+export * from './nested-frame.js'
5+export * from './normal-spelling-of.js'
6+export * from './outside-claude-dirs.js'
7+
8+export * as default from '.'
@@ -1,0 +1,12 @@
1+import { normalSpellingOf } from './normal-spelling-of.js'
2+
3+/**
4+ * Whether a path lies strictly inside a directory, by spelling (no links
5+ * followed), as the engine bounds its nested CLAUDE.md walk.
6+ *
7+ * @param path an absolute path
8+ * @param dir an absolute directory
9+ * @returns true when `path` is under `dir` and not `dir` itself
10+ */
11+export const isBelow = (path: string, dir: string): boolean =>
12+ normalSpellingOf(path).startsWith(`${normalSpellingOf(dir)}/`)
@@ -1,0 +1,14 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+import { normalSpellingOf } from './normal-spelling-of.js'
4+
5+/**
6+ * Whether an instruction file is the file at a path, however either is
7+ * spelled.
8+ *
9+ * @param file the instruction file
10+ * @param path an absolute path
11+ * @returns true when both name one path
12+ */
13+export const isFileAt = (file: InstructionFile, path: string): boolean =>
14+ normalSpellingOf(file.path) === normalSpellingOf(path)
@@ -1,0 +1,11 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+/**
4+ * An instruction file as a Read attaches it, framed as the engine frames a
5+ * nested CLAUDE.md: the text as loaded, trailing newline and all.
6+ *
7+ * @param file the file
8+ * @returns the `Contents of <path>` frame over its text
9+ */
10+export const nestedFrame = (file: InstructionFile): string =>
11+ `Contents of ${file.path}:\n\n${file.content}`
@@ -1,0 +1,9 @@
1+/**
2+ * A path with one separator spelling and no trailing separator, so two
3+ * spellings of one path compare equal (`C:/repo/x` and `C:\repo\x`).
4+ *
5+ * @param path an absolute path as a walk or the model spelled it
6+ * @returns the path with `/` for every separator, no trailing one
7+ */
8+export const normalSpellingOf = (path: string): string =>
9+ path.replaceAll('\\', '/').replace(/(?<=.)\/+$/, '')
@@ -1,0 +1,21 @@
1+import type { FsAncestor } from 'claude-code'
2+
3+/**
4+ * The AGENTS.md files standing in a directory with no CLAUDE.md of its own,
5+ * in walk order.
6+ *
7+ * Where the engine attaches a directory's CLAUDE.md, that directory's
8+ * AGENTS.md is left out rather than merged.
9+ *
10+ * @param agents the AGENTS.md files of a walk, root first
11+ * @param claude the CLAUDE.md files of the same walk
12+ * @returns the AGENTS.md files whose directory holds no CLAUDE.md
13+ */
14+export function outsideClaudeDirs(
15+ agents: readonly FsAncestor[],
16+ claude: readonly FsAncestor[],
17+): readonly FsAncestor[] {
18+ const taken = new Set(claude.map(file => file.dir))
19+
20+ return agents.filter(file => !taken.has(file.dir))
21+}
@@ -1,0 +1,4 @@
1+{
2+ "description": "AGENTS.md up and down the tree like CLAUDE.md, by the instructionFiles option: under claude-md-or-agents-md (the default; where the project has no CLAUDE.md of its own on the walk) or claude-md-and-agents-md, a prompt.context hook hands every ancestor AGENTS.md to the engine among its instruction files, skipping ones it already loaded, and a tool.call hook on Read attaches the AGENTS.md files of the directories between the project root and a read file, once a conversation and agent loop (an Agent-tool fork inherits its parent's); under managed-only, a prompt.context hook keeps only the organization's managed files and memory; under claude-md, nothing is added; in every mode a session.start hook sends the plugin's usage row through $.telemetry where that noun is seated",
3+ "modules": ["./register.ts"]
4+}
@@ -1,0 +1,8 @@
1+export * from './files'
2+export * from './frames'
3+export * from './modes'
4+export * from './names'
5+export * from './register.js'
6+export * from './telemetry'
7+
8+export * as default from '.'
@@ -1,0 +1,8 @@
1+import type { Mode } from './types'
2+
3+/**
4+ * The mode an unset option reads as: AGENTS.md where the project has no
5+ * CLAUDE.md of its own, the engine's CLAUDE.md walk standing alone where it
6+ * has one.
7+ */
8+export const DEFAULT_MODE: Mode = 'claude-md-or-agents-md'
@@ -1,0 +1,7 @@
1+export * from './default-mode.js'
2+export * from './legacy-mode-of.js'
3+export * from './mode-of.js'
4+export * from './modes.js'
5+export * from './types'
6+
7+export * as default from '.'
@@ -1,0 +1,33 @@
1+import type { Mode } from './types'
2+
3+/**
4+ * Each value the option took under its old key, `projectInstructions`, and
5+ * the mode that keeps its meaning.
6+ */
7+const LEGACY_MODES: Readonly<Record<string, Mode>> = {
8+ none: 'managed-only',
9+ claude: 'claude-md',
10+ 'agents-fallback': 'claude-md-or-agents-md',
11+ both: 'claude-md-and-agents-md',
12+}
13+
14+/**
15+ * The mode a value stored under the old `projectInstructions` key keeps; a
16+ * value it never took reads as `claude-md`, which adds nothing, never as the
17+ * default, which loads AGENTS.md.
18+ *
19+ * COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions
20+ * mapping once stored settings have migrated.
21+ *
22+ * @param value the `projectInstructions` option as stored
23+ * @returns the mode it keeps, undefined when nothing is stored there
24+ */
25+export function legacyModeOf(value: unknown): Mode | undefined {
26+ if (value === undefined) {
27+ return undefined
28+ }
29+
30+ return (
31+ (typeof value === 'string' ? LEGACY_MODES[value] : undefined) ?? 'claude-md'
32+ )
33+}
@@ -1,0 +1,13 @@
1+import { DEFAULT_MODE } from './default-mode.js'
2+import { MODES } from './modes.js'
3+import type { Mode } from './types'
4+
5+/**
6+ * The mode a stored option names; unset, a typo or a value of another type
7+ * names the default.
8+ *
9+ * @param value the `instructionFiles` option as stored
10+ * @returns the mode named, DEFAULT_MODE for anything else
11+ */
12+export const modeOf = (value: unknown): Mode =>
13+ MODES.find(mode => mode === value) ?? DEFAULT_MODE
@@ -1,0 +1,11 @@
1+import type { Mode } from './types'
2+
3+/**
4+ * Every mode, in the order the option's description names them.
5+ */
6+export const MODES: readonly Mode[] = [
7+ 'claude-md',
8+ 'claude-md-or-agents-md',
9+ 'claude-md-and-agents-md',
10+ 'managed-only',
11+]
@@ -1,0 +1,3 @@
1+export * from './mode'
2+
3+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './mode.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * Which project instructions the plugin loads: the `instructionFiles`
3+ * option.
4+ *
5+ * `claude-md` loads none of its own, the engine's CLAUDE.md walk standing
6+ * alone; `claude-md-or-agents-md` loads AGENTS.md where the project has no
7+ * CLAUDE.md; `claude-md-and-agents-md` loads AGENTS.md beside CLAUDE.md;
8+ * `managed-only` loads neither, taking the project's and the person's
9+ * CLAUDE.md files out of the conversation's context.
10+ */
11+export type Mode =
12+ | 'claude-md'
13+ | 'claude-md-or-agents-md'
14+ | 'claude-md-and-agents-md'
15+ | 'managed-only'
@@ -1,0 +1,5 @@
1+/**
2+ * The instruction files read in each directory, in the order the engine
3+ * reads CLAUDE.md and .claude/CLAUDE.md.
4+ */
5+export const AGENTS_NAMES = ['AGENTS.md', '.claude/AGENTS.md'] as const
@@ -1,0 +1,11 @@
1+/**
2+ * The engine's own instruction files, as it reads them in each directory of
3+ * its walk and under a read file.
4+ *
5+ * A directory holding one has project instructions the engine loads itself.
6+ */
7+export const CLAUDE_NAMES = [
8+ 'CLAUDE.md',
9+ '.claude/CLAUDE.md',
10+ 'CLAUDE.local.md',
11+] as const
@@ -1,0 +1,5 @@
1+export * from './agents-names.js'
2+export * from './claude-names.js'
3+export * from './main-loop.js'
4+
5+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The key the main conversation's delivered files are kept under; a
3+ * subagent's are kept under its `agentId`.
4+ */
5+export const MAIN_LOOP = 'main'
@@ -1,0 +1,272 @@
1+import type {
2+ EngineInterface,
3+ FsAncestor,
4+ InstructionFile,
5+ On,
6+ PluginOptions,
7+} from 'claude-code'
8+
9+import Files from './files'
10+import Frames from './frames'
11+import Modes from './modes'
12+import Names from './names'
13+import Telemetry from './telemetry'
14+
15+/**
16+ * No files: what a walk not taken, or one that failed, found.
17+ */
18+const NONE: readonly FsAncestor[] = []
19+
20+/**
21+ * Registers the plugin's hooks for the mode `instructionFiles` names
22+ * (`claude-md-or-agents-md` when unset; the manifest lists the four, nothing
23+ * else arrives).
24+ *
25+ * `claude-md`: nothing beyond the usage row, the engine's CLAUDE.md walk
26+ * standing alone. `managed-only`: the project's and the person's instruction
27+ * files dropped, the organization's kept. `claude-md-or-agents-md` (a project
28+ * with none of its own) and `claude-md-and-agents-md`: AGENTS.md files joined
29+ * to the engine's instruction files, nested ones on a Read. Every mode sends
30+ * its usage rows through `$.telemetry` where that noun is seated and drops
31+ * them where it is not.
32+ *
33+ * @param on the engine's registrar
34+ * @param options the plugin's options; `instructionFiles` is `claude-md`,
35+ * `claude-md-or-agents-md`, `claude-md-and-agents-md` or `managed-only`
36+ */
37+export function register(on: On, options: PluginOptions): void {
38+ const named = Modes.modeOf(options.instructionFiles)
39+ // COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions
40+ // mapping once stored settings have migrated. The host fills in the default,
41+ // so an instructionFiles set to it cannot be told from one left unset.
42+ const legacy = Modes.legacyModeOf(options.projectInstructions)
43+ const isLegacyRead = legacy !== undefined && named === Modes.DEFAULT_MODE
44+ const mode = isLegacyRead ? legacy : named
45+ let isRenameTold = legacy === undefined
46+
47+ on('session.start', ($, e, next) => {
48+ Telemetry.quietly(() =>
49+ $.telemetry.log(Telemetry.modeRowOf(mode, e.isInteractive)),
50+ )
51+
52+ if (!isRenameTold) {
53+ isRenameTold = true
54+ $.ui.log(
55+ isLegacyRead
56+ ? 'option projectInstructions in settings is honoured for now, ' +
57+ `read as instructionFiles ${mode}; set instructionFiles to ` +
58+ `${mode} and remove projectInstructions`
59+ : `option projectInstructions in settings is not read: ` +
60+ `instructionFiles ${mode} is set; remove projectInstructions`,
61+ )
62+ }
63+
64+ return next(e)
65+ })
66+
67+ if (mode === 'claude-md') {
68+ return
69+ }
70+
71+ if (mode === 'managed-only') {
72+ on(
73+ 'prompt.context',
74+ { instructionFiles: { kind: Files.DROPPED_KINDS } },
75+ ($, e, next) =>
76+ next({
77+ ...e,
78+ instructionFiles: e.instructionFiles?.filter(
79+ Files.isKeptWithoutInstructions,
80+ ),
81+ }),
82+ )
83+
84+ return
85+ }
86+
87+ const isFallback = mode === 'claude-md-or-agents-md'
88+ const given = new Map<string, Set<string>>()
89+ let inContext: readonly InstructionFile[] = []
90+ let home: string | undefined
91+ let isClaudeProject: boolean | undefined
92+ let rootSeen: string | undefined
93+ let rootLogged: string | undefined
94+ let isCounted = false
95+
96+ on('prompt.context', async ($, e, next) => {
97+ const handed = e.instructionFiles
98+
99+ if (handed === undefined) {
100+ return next(e).finally(() => {
101+ given.clear()
102+ })
103+ }
104+
105+ const root = isFallback ? await $.session.root() : undefined
106+ rootSeen = root ?? rootSeen
107+ let isWalkFailed = false
108+ // The walk, not only what was handed: the engine can withhold a project
109+ // CLAUDE.md it loaded, and the project still has one.
110+ isClaudeProject =
111+ root !== undefined &&
112+ (handed.some(file => Files.isClaudeFileOnWalk(file, root)) ||
113+ (await $.fs.ancestors({ names: Names.CLAUDE_NAMES }).then(
114+ files => files.length > 0,
115+ () => {
116+ isWalkFailed = true
117+
118+ return true
119+ },
120+ )))
121+ const found = isClaudeProject
122+ ? NONE
123+ : await $.fs.ancestors({ names: Names.AGENTS_NAMES }).catch(() => {
124+ isWalkFailed = true
125+
126+ return NONE
127+ })
128+ const added = Files.unseenFiles(Files.filesOf(found), handed)
129+
130+ if (!isCounted) {
131+ isCounted = true
132+ const counts = Telemetry.loadCountsOf(
133+ added,
134+ isClaudeProject && !isWalkFailed,
135+ isWalkFailed,
136+ )
137+ Telemetry.quietly(() =>
138+ $.telemetry.log(Telemetry.loadRowOf(mode, counts)),
139+ )
140+ Telemetry.quietly(() => $.telemetry.mark(Telemetry.loadMarkOf(counts)))
141+ }
142+
143+ const isFirstLoad =
144+ root !== undefined && root !== rootLogged && added.length > 0
145+
146+ if (isFirstLoad) {
147+ rootLogged = root
148+ $.ui.log(
149+ 'no CLAUDE.md found; AGENTS.md loaded: ' +
150+ added
151+ .filter(file => file.parent === undefined)
152+ .map(file => file.path)
153+ .join(', '),
154+ )
155+ }
156+
157+ const instructionFiles = Files.withProjectFiles(handed, added)
158+
159+ return next({ ...e, instructionFiles }).finally(() => {
160+ given.clear()
161+ inContext = instructionFiles
162+ })
163+ })
164+
165+ on('agent.spawn', { fork: true }, async ($, e, next) => {
166+ const result = await next(e)
167+
168+ if (result.agentId !== undefined) {
169+ const parent = given.get(e.parentAgentId ?? Names.MAIN_LOOP)
170+ given.set(result.agentId, new Set(parent))
171+ }
172+
173+ return result
174+ })
175+
176+ on('tool.call', { tool: 'Read' }, async ($, e, next) => {
177+ const result = await next(e)
178+ const isSettledElsewhere =
179+ e.tool !== 'Read' || result.deny !== undefined || result.isError
180+
181+ if (isSettledElsewhere) {
182+ return result
183+ }
184+
185+ const [root, cwd] = await Promise.all([$.session.root(), $.session.cwd()])
186+ home ??= await homeOf($, cwd)
187+ const read = Frames.absoluteOf(e.file_path, cwd, home)
188+
189+ if (root !== rootSeen) {
190+ rootSeen = root
191+ isClaudeProject = undefined
192+ given.clear()
193+ }
194+
195+ isClaudeProject ??=
196+ isFallback &&
197+ (await $.fs.ancestors({ names: Names.CLAUDE_NAMES })).length > 0
198+ const isOutOfReach = isClaudeProject || !Frames.isBelow(read, root)
199+
200+ if (isOutOfReach) {
201+ return result
202+ }
203+
204+ const [stack, claude] = await Promise.all([
205+ $.fs.ancestors({ names: Names.AGENTS_NAMES, of: read, below: root }),
206+ $.fs.ancestors({ names: Names.CLAUDE_NAMES, of: read, below: root }),
207+ ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
208+ const loop = e.agentId ?? Names.MAIN_LOOP
209+ const sent = given.get(loop) ?? new Set<string>()
210+ given.set(loop, sent)
211+ const nested = (
212+ isFallback ? Frames.outsideClaudeDirs(stack, claude) : stack
213+ ).filter(file => Frames.isBelow(file.dir, root))
214+ const fresh = Files.unseenFiles(Files.filesOf(nested), [
215+ ...inContext,
216+ ...Files.filesOf(claude),
217+ ]).filter(file => !sent.has(file.path))
218+ const attached = fresh.filter(file => !Frames.isFileAt(file, read))
219+ const isWholeRead = e.offset === undefined && e.limit === undefined
220+
221+ for (const file of fresh) {
222+ const isSent =
223+ attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead)
224+
225+ if (isSent) {
226+ sent.add(file.path)
227+ }
228+ }
229+
230+ const hasAttached = attached.length > 0
231+
232+ if (hasAttached) {
233+ Telemetry.quietly(() =>
234+ $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)),
235+ )
236+ }
237+
238+ return hasAttached
239+ ? {
240+ ...result,
241+ context: [
242+ ...(result.context ?? []),
243+ ...attached.map(Frames.nestedFrame),
244+ ],
245+ }
246+ : result
247+ })
248+}
249+
250+/**
251+ * The home directory a `~` in a Read's path stands for, read the way the
252+ * Read tool reads it.
253+ *
254+ * The profile directory on a Windows spelling of the working directory,
255+ * else `HOME`, each falling back to the other.
256+ *
257+ * @param $ the engine, as the `tool.call` hook holds it
258+ * @param cwd the session's working directory, whose spelling names the platform
259+ * @returns the home directory, or undefined when the environment names none
260+ */
261+async function homeOf(
262+ $: EngineInterface,
263+ cwd: string,
264+): Promise<string | undefined> {
265+ const [home, profile] = await Promise.all([
266+ $.env.get('HOME'),
267+ $.env.get('USERPROFILE'),
268+ ])
269+ const isWindowsSpelling = cwd.includes('\\')
270+
271+ return isWindowsSpelling ? (profile ?? home) : (home ?? profile)
272+}
@@ -1,0 +1,5 @@
1+/**
2+ * The plugin's name on the product-wide feature surface: its marks are
3+ * `$.telemetry.mark` rows carrying this `feature_name`.
4+ */
5+export const FEATURE_NAME = 'agents_md'
@@ -1,0 +1,15 @@
1+export * from './feature-name.js'
2+export * from './load-counts-of.js'
3+export * from './load-event.js'
4+export * from './load-mark-of.js'
5+export * from './load-row-of.js'
6+export * from './mode-choice-of.js'
7+export * from './mode-event.js'
8+export * from './mode-row-of.js'
9+export * from './nested-event.js'
10+export * from './nested-row-of.js'
11+export * from './quietly.js'
12+export * from './types'
13+export * from './walk-failed-reason.js'
14+
15+export * as default from '.'
@@ -1,0 +1,35 @@
1+import type { InstructionFile } from 'claude-code'
2+
3+import type { LoadCounts } from './types'
4+
5+/**
6+ * The first context's counts from the files it added: an entry with a parent
7+ * is an `@` import, one without is an AGENTS.md file.
8+ *
9+ * @param added the AGENTS.md instruction files handed to the engine, imports
10+ * among them
11+ * @param isYielded whether `claude-md-or-agents-md` stood down for a CLAUDE.md
12+ * @param isWalkFailed whether the walk threw
13+ * @returns the counts the load row and mark are built from
14+ */
15+export function loadCountsOf(
16+ added: readonly InstructionFile[],
17+ isYielded: boolean,
18+ isWalkFailed: boolean,
19+): LoadCounts {
20+ const importCount = added.reduce(
21+ (sum, file) => sum + (file.parent === undefined ? 0 : 1),
22+ 0,
23+ )
24+
25+ return {
26+ fileCount: added.length - importCount,
27+ importCount,
28+ totalContentLength: added.reduce(
29+ (sum, file) => sum + file.content.length,
30+ 0,
31+ ),
32+ isYielded,
33+ isWalkFailed,
34+ }
35+}
@@ -1,0 +1,7 @@
1+/**
2+ * The row the first context of a load sends under `claude-md-or-agents-md` and
3+ * `claude-md-and-agents-md`: what was handed on, in counts.
4+ *
5+ * The engine's own initial-load row counts its CLAUDE.md files the same way.
6+ */
7+export const LOAD_EVENT = 'agents_md_load'
@@ -1,0 +1,15 @@
1+import { FEATURE_NAME } from './feature-name.js'
2+import type { LoadCounts, TelemetryMark } from './types'
3+import { WALK_FAILED_REASON } from './walk-failed-reason.js'
4+
5+/**
6+ * The first context's feature mark: `ok`, or `sad` when the walk failed and
7+ * the context went on without AGENTS.md.
8+ *
9+ * @param counts what the first context handed on
10+ * @returns the mark to send
11+ */
12+export const loadMarkOf = (counts: LoadCounts): TelemetryMark =>
13+ counts.isWalkFailed
14+ ? { feature: FEATURE_NAME, kind: 'sad', reason: WALK_FAILED_REASON }
15+ : { feature: FEATURE_NAME, kind: 'ok' }
@@ -1,0 +1,27 @@
1+import type Modes from '../modes'
2+import { LOAD_EVENT } from './load-event.js'
3+import { modeChoiceOf } from './mode-choice-of.js'
4+import type { LoadCounts, TelemetryRow } from './types'
5+
6+/**
7+ * The first-context row: the mode and what was handed to the engine, in
8+ * counts only; no path and no text.
9+ *
10+ * @param mode the configured mode
11+ * @param counts what the first context handed on
12+ * @returns the row to send
13+ */
14+export const loadRowOf = (
15+ mode: Modes.Mode,
16+ counts: LoadCounts,
17+): TelemetryRow => ({
18+ event: LOAD_EVENT,
19+ props: {
20+ mode: modeChoiceOf(mode),
21+ file_count: counts.fileCount,
22+ import_count: counts.importCount,
23+ total_content_length: counts.totalContentLength,
24+ yielded: counts.isYielded,
25+ walk_failed: counts.isWalkFailed,
26+ },
27+})
@@ -1,0 +1,13 @@
1+import Modes from '../modes'
2+import type { TelemetryProp } from './types'
3+
4+/**
5+ * The mode as a row carries it: the value with the four it is chosen from.
6+ *
7+ * @param mode the configured mode
8+ * @returns the `mode` property
9+ */
10+export const modeChoiceOf = (mode: Modes.Mode): TelemetryProp => ({
11+ value: mode,
12+ of: Modes.MODES,
13+})
@@ -1,0 +1,5 @@
1+/**
2+ * The row a fresh load sends at `session.start`, whatever the mode: which
3+ * mode is configured.
4+ */
5+export const MODE_EVENT = 'agents_md_mode'
@@ -1,0 +1,20 @@
1+import type Modes from '../modes'
2+import { modeChoiceOf } from './mode-choice-of.js'
3+import { MODE_EVENT } from './mode-event.js'
4+import type { TelemetryRow } from './types'
5+
6+/**
7+ * The `session.start` row: the configured mode and whether a person is at
8+ * the terminal.
9+ *
10+ * @param mode the configured mode
11+ * @param isInteractive whether a person is at the terminal
12+ * @returns the row to send
13+ */
14+export const modeRowOf = (
15+ mode: Modes.Mode,
16+ isInteractive: boolean,
17+): TelemetryRow => ({
18+ event: MODE_EVENT,
19+ props: { mode: modeChoiceOf(mode), is_interactive: isInteractive },
20+})
@@ -1,0 +1,5 @@
1+/**
2+ * The row a Read sends when it attached nested AGENTS.md files; a Read that
3+ * attached none sends nothing.
4+ */
5+export const NESTED_EVENT = 'agents_md_nested'
@@ -1,0 +1,20 @@
1+import type Modes from '../modes'
2+import { modeChoiceOf } from './mode-choice-of.js'
3+import { NESTED_EVENT } from './nested-event.js'
4+import type { TelemetryRow } from './types'
5+
6+/**
7+ * The row of a Read that attached nested AGENTS.md files: the mode and how
8+ * many it attached.
9+ *
10+ * @param mode the configured mode
11+ * @param fileCount the files attached to this Read's result
12+ * @returns the row to send
13+ */
14+export const nestedRowOf = (
15+ mode: Modes.Mode,
16+ fileCount: number,
17+): TelemetryRow => ({
18+ event: NESTED_EVENT,
19+ props: { mode: modeChoiceOf(mode), file_count: fileCount },
20+})
@@ -1,0 +1,13 @@
1+/**
2+ * Runs one `$.telemetry` call and drops whatever it throws or rejects with.
3+ *
4+ * A build with no telemetry noun, a refused row or an ingest that will not
5+ * take it never disturbs the instructions.
6+ *
7+ * @param send the call
8+ */
9+export const quietly = (send: () => Promise<void>): void => {
10+ void Promise.resolve()
11+ .then(send)
12+ .catch(() => undefined)
13+}
@@ -1,0 +1,6 @@
1+export * from './load-counts'
2+export * from './telemetry-mark'
3+export * from './telemetry-prop'
4+export * from './telemetry-row'
5+
6+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './load-counts.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+/**
2+ * What the first context of a load handed the engine, as counts.
3+ *
4+ * The AGENTS.md files added, the files they `@`-imported, their characters
5+ * in all, whether `claude-md-or-agents-md` stood down for a CLAUDE.md of the
6+ * project's own, and whether the walk threw so that nothing was found.
7+ */
8+export type LoadCounts = {
9+ fileCount: number
10+ importCount: number
11+ totalContentLength: number
12+ isYielded: boolean
13+ isWalkFailed: boolean
14+}
@@ -1,0 +1,3 @@
1+export type * from './telemetry-mark.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+import type { EngineInterface } from 'claude-code'
2+
3+/**
4+ * What `$.telemetry.mark` takes, as the telemetry mod's contract declares
5+ * it: the feature, how it went, and why when not ok.
6+ */
7+export type TelemetryMark = Parameters<EngineInterface['telemetry']['mark']>[0]
@@ -1,0 +1,3 @@
1+export type * from './telemetry-prop.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+import type { TelemetryRow } from '../telemetry-row'
2+
3+/**
4+ * One property value a `$.telemetry` row may carry.
5+ *
6+ * As the telemetry mod's contract declares it: a finite number, a boolean,
7+ * or a string chosen from the list declared beside it.
8+ */
9+export type TelemetryProp = NonNullable<TelemetryRow['props']>[string]
@@ -1,0 +1,3 @@
1+export type * from './telemetry-row.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+import type { EngineInterface } from 'claude-code'
2+
3+/**
4+ * What `$.telemetry.log` takes, as the telemetry mod's contract declares
5+ * it: the event's name and its properties by snake_case key.
6+ */
7+export type TelemetryRow = Parameters<EngineInterface['telemetry']['log']>[0]
@@ -1,0 +1,5 @@
1+/**
2+ * The `sad` mark's reason when the AGENTS.md walk threw and the load went on
3+ * with nothing found.
4+ */
5+export const WALK_FAILED_REASON = 'walk_failed'
@@ -1,0 +1,20 @@
1+import type { FsAncestor } from 'claude-code'
2+
3+/**
4+ * One instruction file a walk found.
5+ *
6+ * @param dir the directory the file stands in
7+ * @param name the file's name as the walk asked for it
8+ * @param content the file's text
9+ * @returns the found file
10+ */
11+export const ancestorOf = (
12+ dir: string,
13+ name: string,
14+ content: string,
15+): FsAncestor => ({
16+ dir,
17+ name,
18+ content,
19+ parts: [{ path: `${dir}/${name}`, content }],
20+})
@@ -1,0 +1,10 @@
1+export * from './ancestor-of.js'
2+export * from './project-of.js'
3+export * from './recording.js'
4+export * from './rows-of.js'
5+export * from './session.js'
6+export * from './started-of.js'
7+export * from './types'
8+export * from './unreadable-project-of.js'
9+
10+export * as default from '.'
@@ -1,0 +1,31 @@
1+import type { FsAncestor, On } from 'claude-code'
2+
3+import { startedOf } from './started-of.js'
4+import type { Started } from './types'
5+
6+/**
7+ * A project whose walks up from the working directory find the given files,
8+ * one list per set of names, its session starting untouched (startedOf);
9+ * each walk's first name is kept in `walks`.
10+ *
11+ * @param on the test's `on`
12+ * @param agents what a walk for AGENTS.md and .claude/AGENTS.md finds
13+ * @param claude what a walk for CLAUDE.md, .claude/CLAUDE.md and
14+ * CLAUDE.local.md finds
15+ * @returns the toasts, lines and walks the plugins raised, in order
16+ */
17+export function projectOf(
18+ on: On,
19+ agents: readonly FsAncestor[],
20+ claude: readonly FsAncestor[],
21+): Started {
22+ const started = startedOf(on)
23+
24+ on('fs.ancestors', ($, e) => {
25+ started.walks.push(e.names[0] ?? '')
26+
27+ return { value: e.names.includes('AGENTS.md') ? agents : claude }
28+ })
29+
30+ return started
31+}
@@ -1,0 +1,29 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin standing in for the telemetry built-in: it adds the `telemetry`
5+ * noun at `engine.create` over the nouns beneath.
6+ *
7+ * Its `log` and `mark` write the entry they are handed as one `$.ui.log`
8+ * line, `<op> <json>`, which startedOf keeps and rowsOf reads back.
9+ */
10+export const RECORDING: Plugin = {
11+ name: 'recording',
12+ tier: 'builtin',
13+ register(on) {
14+ on('engine.create', async ($, e, next) => {
15+ const beneath = await next(e)
16+ const built = { ...beneath }
17+ built.telemetry = {
18+ log: async entry => {
19+ await beneath.ui.log(`log ${JSON.stringify(entry)}`)
20+ },
21+ mark: async entry => {
22+ await beneath.ui.log(`mark ${JSON.stringify(entry)}`)
23+ },
24+ }
25+
26+ return built
27+ })
28+ },
29+}
@@ -1,0 +1,17 @@
1+import type { Row, Started } from './types'
2+
3+/**
4+ * What the plugin handed the telemetry noun so far, read off the transcript
5+ * lines RECORDING wrote, in the order handed; other lines are passed over.
6+ *
7+ * @param started the session's start, whose lines hold the rows
8+ * @returns the `[op, entry]` pairs
9+ */
10+export const rowsOf = (started: Started): Row[] =>
11+ started.lines
12+ .filter(line => line.startsWith('log ') || line.startsWith('mark '))
13+ .map((line): Row => {
14+ const at = line.indexOf(' ')
15+
16+ return [line.slice(0, at), JSON.parse(line.slice(at + 1))]
17+ })
@@ -1,0 +1,10 @@
1+import type { SessionStartInput } from 'claude-code'
2+
3+/**
4+ * An interactive terminal session two directories under /repo.
5+ */
6+export const SESSION: SessionStartInput = {
7+ surface: 'terminal',
8+ isInteractive: true,
9+ cwd: '/repo/a/b',
10+}
@@ -1,0 +1,36 @@
1+import type { On } from 'claude-code'
2+import { mock } from 'claude-code/testing'
3+
4+import { SESSION } from './session.js'
5+import type { Started, ToastAsked } from './types'
6+
7+/**
8+ * A session that starts untouched beneath the plugins, rooted at SESSION's
9+ * working directory, every toast and transcript line they raise kept for the
10+ * test to read, on a mock clock the test settles before it reads them.
11+ *
12+ * @param on the test's `on`
13+ * @returns the toasts, lines and walks raised, in order, and the clock
14+ */
15+export function startedOf(on: On): Started {
16+ const toasts: ToastAsked[] = []
17+ const lines: string[] = []
18+
19+ on('session.start', ($, e) => ({ cwd: e.cwd }))
20+
21+ on('session.root', () => ({ value: SESSION.cwd }))
22+
23+ on('ui.toast', ($, e) => {
24+ toasts.push(e)
25+
26+ return { value: undefined }
27+ })
28+
29+ on('ui.log', ($, e) => {
30+ lines.push(e.text)
31+
32+ return { value: undefined }
33+ })
34+
35+ return { toasts, lines, walks: [], clock: mock.clock(on) }
36+}
@@ -1,0 +1,5 @@
1+export * from './row'
2+export * from './started'
3+export * from './toast-asked'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './row.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * One entry the plugin handed the telemetry noun, as RECORDING wrote it:
3+ * which call (`log` or `mark`) and the entry.
4+ */
5+export type Row = [op: string, entry: unknown]
@@ -1,0 +1,3 @@
1+export type * from './started.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+import type { MockClock } from 'claude-code/testing'
2+
3+import type { ToastAsked } from '../toast-asked'
4+
5+/**
6+ * What the plugins raised while a session ran: each toast as asked for, each
7+ * transcript line, the first name of each walk they asked for; `clock`
8+ * settles what they floated.
9+ */
10+export type Started = {
11+ toasts: ToastAsked[]
12+ lines: string[]
13+ walks: string[]
14+ clock: MockClock
15+}
@@ -1,0 +1,3 @@
1+export type * from './toast-asked.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+/**
2+ * One `$.ui.toast` call as the plugin made it: the text and, when it named
3+ * one, how long the bar keeps it.
4+ */
5+export type ToastAsked = {
6+ text: string
7+ timeoutMs?: number
8+}
@@ -1,0 +1,20 @@
1+import type { On } from 'claude-code'
2+
3+import { startedOf } from './started-of.js'
4+import type { Started } from './types'
5+
6+/**
7+ * A project whose walk fails, its session starting untouched (startedOf).
8+ *
9+ * @param on the test's `on`
10+ * @returns the toasts and lines the plugins raised, in order
11+ */
12+export function unreadableProjectOf(on: On): Started {
13+ const started = startedOf(on)
14+
15+ on('fs.ancestors', () => {
16+ throw new Error('no walk today')
17+ })
18+
19+ return started
20+}
@@ -1,0 +1,137 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Hooks from '../hooks'
4+import Fixtures from './fixtures'
5+
6+tier('builtin')
7+
8+/**
9+ * The blocks the engine hands `prompt.context`.
10+ */
11+const BLOCKS = [
12+ { name: 'claudeMd', text: 'x' },
13+ { name: 'currentDate', text: 'today' },
14+]
15+
16+describe('register', () => {
17+ test('by default a project with AGENTS.md alone gets it', async ($, on) => {
18+ const started = Fixtures.projectOf(
19+ on,
20+ [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')],
21+ [],
22+ )
23+
24+ on('prompt.context', ($, e) => ({
25+ blocks: e.blocks,
26+ instructionFiles: e.instructionFiles,
27+ }))
28+
29+ const context = await $.prompt.context({
30+ blocks: BLOCKS,
31+ instructionFiles: [],
32+ })
33+
34+ expect(context.instructionFiles).toEqual([
35+ { path: '/repo/AGENTS.md', kind: 'project', content: '# top\n' },
36+ ])
37+ expect(context.blocks?.[0]?.text).toContain(
38+ 'Contents of /repo/AGENTS.md (project instructions, checked into the ' +
39+ 'codebase):\n\n# top',
40+ )
41+
42+ await started.clock.settle()
43+
44+ expect(started.lines).toEqual([
45+ 'no CLAUDE.md found; AGENTS.md loaded: /repo/AGENTS.md',
46+ ])
47+ })
48+
49+ test('by default a project with a CLAUDE.md is left to the engine', async ($, on) => {
50+ const started = Fixtures.projectOf(
51+ on,
52+ [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')],
53+ [Fixtures.ancestorOf('/repo/a/b', 'CLAUDE.md', '# mine\n')],
54+ )
55+ const handed = [
56+ { path: '/repo/a/b/CLAUDE.md', kind: 'project' as const, content: '#' },
57+ ]
58+
59+ on('prompt.context', ($, e) => ({
60+ blocks: e.blocks,
61+ instructionFiles: e.instructionFiles,
62+ }))
63+
64+ expect(
65+ await $.prompt.context({ blocks: BLOCKS, instructionFiles: handed }),
66+ ).toEqual({ blocks: BLOCKS, instructionFiles: handed })
67+
68+ await started.clock.settle()
69+
70+ expect(started.walks).toEqual([])
71+ expect(started.lines).toEqual([])
72+ })
73+
74+ test('a failed walk leaves the context as handed', async ($, on) => {
75+ Fixtures.unreadableProjectOf(on)
76+
77+ on('prompt.context', ($, e) => ({
78+ blocks: e.blocks,
79+ instructionFiles: e.instructionFiles,
80+ }))
81+
82+ expect(
83+ await $.prompt.context({ blocks: BLOCKS, instructionFiles: [] }),
84+ ).toEqual({ blocks: BLOCKS, instructionFiles: [] })
85+ })
86+
87+ test(
88+ 'the start sends the mode row alone: no walk, no toast',
89+ { plugins: [Fixtures.RECORDING] },
90+ async ($, on) => {
91+ const started = Fixtures.projectOf(
92+ on,
93+ [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')],
94+ [],
95+ )
96+
97+ expect(await $.session.start(Fixtures.SESSION)).toEqual({
98+ cwd: '/repo/a/b',
99+ })
100+
101+ await started.clock.settle()
102+
103+ expect(Fixtures.rowsOf(started)).toEqual([
104+ [
105+ 'log',
106+ {
107+ event: Hooks.MODE_EVENT,
108+ props: {
109+ mode: { value: 'claude-md-or-agents-md', of: [...Hooks.MODES] },
110+ is_interactive: true,
111+ },
112+ },
113+ ],
114+ ])
115+ expect(Hooks.MODE_EVENT).toBe('agents_md_mode')
116+ expect(started.walks).toEqual([])
117+ expect(started.toasts).toEqual([])
118+ },
119+ )
120+
121+ test('with no telemetry noun the start goes on untouched', async ($, on) => {
122+ const started = Fixtures.projectOf(
123+ on,
124+ [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')],
125+ [],
126+ )
127+
128+ expect(
129+ await $.session.start({ ...Fixtures.SESSION, isInteractive: false }),
130+ ).toEqual({ cwd: '/repo/a/b' })
131+
132+ await started.clock.settle()
133+
134+ expect(Fixtures.rowsOf(started)).toEqual([])
135+ expect(started.toasts).toEqual([])
136+ })
137+})
@@ -9,10 +9,12 @@
99 export function oldFiles(on: On) {
1010 on('fs.list', () => ({
1111 value: [
12- { name: 'old.ts', kind: 'file', size: 2 },
13- { name: 'moved.ts', kind: 'file', size: 2 },
12+ { name: 'old.ts', kind: 'file', size: 2, isLink: false },
13+ { name: 'moved.ts', kind: 'file', size: 2, isLink: false },
1414 ],
1515 }))
1616
17- on('fs.stat', () => ({ value: { kind: 'file', size: 2, mtimeMs: 0 } }))
17+ on('fs.stat', () => ({
18+ value: { kind: 'file', size: 2, mtimeMs: 0, isLink: false },
19+ }))
1820 }
Large file, diff not shown.