SLOPSHOPPER

agents-md

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…

newguardpromptagents
A shopper browsing a rack in a slop shop
README

agents-md

AGENTS.md read the way Claude Code reads CLAUDE.md, as a plugin, under one option, instructionFiles:

  • claude-md: only CLAUDE.md is loaded, by the engine, as today. The plugin adds nothing.
  • claude-md-or-agents-md (the default): a project with no instruction files of its own gets its AGENTS.md files instead, loaded exactly where and how CLAUDE.md would be. "Of its own" is read off what the engine loaded for the context: a CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md in any directory from the root down to the working directory leaves the whole project to the engine, and the plugin stays out (the organization's managed file, the person's ~/.claude/CLAUDE.md, a .claude/rules file and an added directory's CLAUDE.md do not count, as the nested walk does not see them either). With none, every AGENTS.md and .claude/AGENTS.md on that path joins the instruction files the engine renders, and a Read under a subdirectory attaches that directory's AGENTS.md unless a CLAUDE.md there claims it.
  • claude-md-and-agents-md: every AGENTS.md is loaded beside CLAUDE.md, up and down the tree; a file CLAUDE.md already @-imports, or is a link to, is not loaded a second time (compared by path, then by content).
  • managed-only: the project's checked-in and private instruction files and the person's own are dropped from the context; the organization's managed CLAUDE.md and the engine's memory stay. The engine's nested CLAUDE.md attachments on Read are not an event yet and still arrive. (The engine's claudeMdExcludes setting also exists, for user, project and local files, and applies to the AGENTS.md files this plugin reads too.)

How the files reach the model is the engine's doing, not the plugin's: prompt.context hands a hook the instruction files behind claudeMd ({ path, kind, content, parent? }, kinds managed, user, project, local, memory, in load order) and a hook answers the list changed. The engine then renders claudeMd from the answered files with its own preamble and framing, announces them by name, and keeps only the managed ones for an agent that omits project instructions (Explore, Plan, a custom agent with omitClaudeMd). So an AGENTS.md this plugin adds as a project file is, to everything downstream, a project instruction file: same place in the context, same framing, same omission rules, same announcement. An organization's prepended plugin on prompt.context sits above this one and has the last word on the files.

hooks/register.ts is the module; everything under hooks/ is its parts, importing claude-code and one another alone. tests/ runs under claude plugin test <this folder>.

Setting the option

As a built-in its option is the /config row "Project instructions", a picker over the four values, each described there. By hand it is

{
  "pluginConfigs": {
    "agents-md@builtin": {
      "options": { "instructionFiles": "claude-md-and-agents-md" }
    }
  }
}

in user settings (~/.claude/settings.json), --settings, or managed settings; a project's .claude/settings.json is not read for plugin options. Changing it reloads the module, and the next context the engine builds (the next turn after the reload, a new conversation, /clear, a compaction) carries the new mode's files. A hand-typed value outside the four is told once in the transcript and reads as the default. /plugin lists the plugin among the built-ins, where a person can turn it off; with it off the engine reads CLAUDE.md alone. No hooks setting or CLI mode turns it off (disableAllHooks, allowManagedHooksOnly and --bare govern settings hooks and installed plugins, not built-ins); where the engine loads no instruction files (--bare without --add-dir, --safe-mode, CLAUDE_CODE_DISABLE_CLAUDE_MDS) its walk finds none and it adds none, CLAUDE.md and AGENTS.md alike.

The option was first keyed projectInstructions, with the values claude, agents-fallback, both and none. A value still stored under that key is honoured for now while instructionFiles reads as its default: none as managed-only, claude as claude-md, agents-fallback as claude-md-or-agents-md, both as claude-md-and-agents-md, any other value as claude-md (which adds nothing, never as the default, which loads AGENTS.md); the first session.start of a load says in the transcript how it was read. Once instructionFiles is set to anything but its default, the old key is not read and the transcript says to remove it.

Run from this folder instead (claude --plugin-dir mods/agents-md), the same entry is keyed "agents-md".

What it hooks

eventwhat the hook does
session.startin 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
prompt.contextunder 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 names the files it loaded in the debug log ($.ui.log with to: "debug": no CLAUDE.md found; AGENTS.md loaded: <paths>, nothing in the transcript) 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
agent.spawn on fork: trueunder 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)
tool.call on Readunder 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; and nothing anywhere in a run where the engine attaches nothing to a turn, --bare with its CLAUDE_CODE_SIMPLE or CLAUDE_CODE_DISABLE_ATTACHMENTS, read on every Read through $.env.get as the engine reads them on every turn): 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

What it calls on $

fs.ancestors (with each found file's parts: the file and its imports apart; with below on a Read; it finds nothing on a thin client, whose workspace files are remote, as the engine's own walk does), session.root, session.cwd, env.get (HOME and USERPROFILE, once per load, the profile first on a Windows spelling of the working directory, so a ~/ path the model hands a Read resolves where the Read tool reads it; CLAUDE_CODE_SIMPLE and CLAUDE_CODE_DISABLE_ATTACHMENTS on every Read), ui.log, telemetry.log and telemetry.mark.

$.telemetry is the telemetry plugin's noun; where that plugin is not seated the calls find no noun and are dropped without a trace, and nothing else changes.

What it logs

Counts and closed choices only; no path and no file text. Each row goes through $.telemetry.log, so it exists only where the telemetry plugin does:

eventwhenproperties
agents_md_modeonce per fresh load, at session.startmode (claude-md \claude-md-or-agents-md \claude-md-and-agents-md \managed-only), is_interactive
agents_md_loadthe first context of a load, under claude-md-or-agents-md and claude-md-and-agents-mdmode, 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
agents_md_nesteda Read that attached nested filesmode, file_count

Where it still differs from CLAUDE.md

All of these apply only to the modes that load AGENTS.md, claude-md-or-agents-md (the default) and claude-md-and-agents-md. Each names a loader fact a plugin cannot reach through the events it has today.

  1. Nested files attach on a text Read only. The engine also attaches a directory's CLAUDE.md for a file @-mentioned in the prompt, for the IDE's opened file or selection, and for the Read tool's notebook, image and PDF results.
  2. A nested file the plugin attaches is not registered in the loop's read-file state, so after a compaction the engine does not restore it among the recently read files (the plugin attaches it again at the next Read under that directory instead), and a change to it mid-session is not re-announced.
  3. /cd carries the new tree's CLAUDE.md in its own notice; the plugin's files for the new tree arrive in the same next request through the engine's instructions announcement instead.
  4. Paths compare by spelling; the engine resolves a symlinked alias of the working directory before deciding a file is inside it.
  5. --add-dir directories contribute no AGENTS.md, where the engine can load their CLAUDE.md.
  6. /memory and the # shortcut do not know AGENTS.md files, and the engine's own initial-load row does not count them (this plugin's agents_md_load row does).
  7. An @ import outside the working directory inside an AGENTS.md is honoured only once the approval the engine asks for a CLAUDE.md's external imports has been given (without it the import is left out, as a CLAUDE.md's is); the approval dialog itself is raised for CLAUDE.md imports alone.
  8. A subagent that is not a fork gets a nested AGENTS.md at its own first Read under that directory even when its parent's loop was already given it; the engine does not hand such a subagent the nested CLAUDE.md again. A fork matches the engine on both sides.

Testing

claude plugin test mods/agents-md

tests/register.test.ts covers the default mode: a project with AGENTS.md alone gets it as a project instruction file and one debug-log line naming it, with nothing in the transcript, a project with a CLAUDE.md of its own is left to the engine without a walk, a failed walk leaves the context as handed, and the start hands $.telemetry the mode row alone where a test seats a provider for that noun, and goes on untouched where none is seated.

Source 40 files
hooks/register.ts 306 lines
1import type {
2  EngineInterface,
3  FsAncestor,
4  InstructionFile,
5  On,
6  PluginOptions,
7} from 'claude-code'
8
9import Files from './files'
10import Frames from './frames'
11import Modes from './modes'
12import Names from './names'
13import Switches from './switches'
14import Telemetry from './telemetry'
15
16/**
17 * No files: what a walk not taken, or one that failed, found.
18 */
19const NONE: readonly FsAncestor[] = []
20
21/**
22 * Registers the plugin's hooks for the mode `instructionFiles` names
23 * (`claude-md-or-agents-md` when unset; the manifest lists the four, nothing
24 * else arrives).
25 *
26 * `claude-md`: nothing beyond the usage row, the engine's CLAUDE.md walk
27 * standing alone. `managed-only`: the project's and the person's instruction
28 * files dropped, the organization's kept. `claude-md-or-agents-md` (a project
29 * with none of its own) and `claude-md-and-agents-md`: AGENTS.md files joined
30 * to the engine's instruction files, nested ones on a Read except in a run
31 * where the engine attaches nothing to a turn (--bare, which sets
32 * CLAUDE_CODE_SIMPLE, or CLAUDE_CODE_DISABLE_ATTACHMENTS). Every mode sends
33 * its usage rows through `$.telemetry` where that noun is seated and drops
34 * them where it is not.
35 *
36 * @param on the engine's registrar
37 * @param options the plugin's options; `instructionFiles` is `claude-md`,
38 * `claude-md-or-agents-md`, `claude-md-and-agents-md` or `managed-only`
39 */
40export function register(on: On, options: PluginOptions): void {
41  const named = Modes.modeOf(options.instructionFiles)
42  // COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions
43  // mapping once stored settings have migrated. The host fills in the default,
44  // so an instructionFiles set to it cannot be told from one left unset.
45  const legacy = Modes.legacyModeOf(options.projectInstructions)
46  const isLegacyRead = legacy !== undefined && named === Modes.DEFAULT_MODE
47  const mode = isLegacyRead ? legacy : named
48  let isRenameTold = legacy === undefined
49
50  on('session.start', ($, e, next) => {
51    Telemetry.quietly(() =>
52      $.telemetry.log(Telemetry.modeRowOf(mode, e.isInteractive)),
53    )
54
55    if (!isRenameTold) {
56      isRenameTold = true
57      $.ui.log(
58        isLegacyRead
59          ? 'option projectInstructions in settings is honoured for now, ' +
60              `read as instructionFiles ${mode}; set instructionFiles to ` +
61              `${mode} and remove projectInstructions`
62          : `option projectInstructions in settings is not read: ` +
63              `instructionFiles ${mode} is set; remove projectInstructions`,
64      )
65    }
66
67    return next(e)
68  })
69
70  if (mode === 'claude-md') {
71    return
72  }
73
74  if (mode === 'managed-only') {
75    on(
76      'prompt.context',
77      { instructionFiles: { kind: Files.DROPPED_KINDS } },
78      ($, e, next) =>
79        next({
80          ...e,
81          instructionFiles: e.instructionFiles?.filter(
82            Files.isKeptWithoutInstructions,
83          ),
84        }),
85    )
86
87    return
88  }
89
90  const isFallback = mode === 'claude-md-or-agents-md'
91  const given = new Map<string, Set<string>>()
92  let inContext: readonly InstructionFile[] = []
93  let home: string | undefined
94  let isClaudeProject: boolean | undefined
95  let rootSeen: string | undefined
96  let rootLogged: string | undefined
97  let isCounted = false
98
99  on('prompt.context', async ($, e, next) => {
100    const handed = e.instructionFiles
101
102    if (handed === undefined) {
103      return next(e).finally(() => {
104        given.clear()
105      })
106    }
107
108    const root = isFallback ? await $.session.root() : undefined
109    rootSeen = root ?? rootSeen
110    let isWalkFailed = false
111    // The walk, not only what was handed: the engine can withhold a project
112    // CLAUDE.md it loaded, and the project still has one.
113    isClaudeProject =
114      root !== undefined &&
115      (handed.some(file => Files.isClaudeFileOnWalk(file, root)) ||
116        (await $.fs.ancestors({ names: Names.CLAUDE_NAMES }).then(
117          files => files.length > 0,
118          () => {
119            isWalkFailed = true
120
121            return true
122          },
123        )))
124    const found = isClaudeProject
125      ? NONE
126      : await $.fs.ancestors({ names: Names.AGENTS_NAMES }).catch(() => {
127          isWalkFailed = true
128
129          return NONE
130        })
131    const added = Files.unseenFiles(Files.filesOf(found), handed)
132
133    if (!isCounted) {
134      isCounted = true
135      const counts = Telemetry.loadCountsOf(
136        added,
137        isClaudeProject && !isWalkFailed,
138        isWalkFailed,
139      )
140      Telemetry.quietly(() =>
141        $.telemetry.log(Telemetry.loadRowOf(mode, counts)),
142      )
143      Telemetry.quietly(() => $.telemetry.mark(Telemetry.loadMarkOf(counts)))
144    }
145
146    const isFirstLoad =
147      root !== undefined && root !== rootLogged && added.length > 0
148
149    if (isFirstLoad) {
150      rootLogged = root
151      $.ui.log(
152        'no CLAUDE.md found; AGENTS.md loaded: ' +
153          added
154            .filter(file => file.parent === undefined)
155            .map(file => file.path)
156            .join(', '),
157        { to: 'debug' },
158      )
159    }
160
161    const instructionFiles = Files.withProjectFiles(handed, added)
162
163    return next({ ...e, instructionFiles }).finally(() => {
164      given.clear()
165      inContext = instructionFiles
166    })
167  })
168
169  on('agent.spawn', { fork: true }, async ($, e, next) => {
170    const result = await next(e)
171
172    if (result.agentId !== undefined) {
173      const parent = given.get(e.parentAgentId ?? Names.MAIN_LOOP)
174      given.set(result.agentId, new Set(parent))
175    }
176
177    return result
178  })
179
180  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
181    const result = await next(e)
182    const isSettledElsewhere =
183      e.tool !== 'Read' || result.deny !== undefined || result.isError
184
185    if (isSettledElsewhere) {
186      return result
187    }
188
189    if (!(await attachesOnRead($))) {
190      return result
191    }
192
193    const [root, cwd] = await Promise.all([$.session.root(), $.session.cwd()])
194    home ??= await homeOf($, cwd)
195    const read = Frames.absoluteOf(e.file_path, cwd, home)
196
197    if (root !== rootSeen) {
198      rootSeen = root
199      isClaudeProject = undefined
200      given.clear()
201    }
202
203    isClaudeProject ??=
204      isFallback &&
205      (await $.fs.ancestors({ names: Names.CLAUDE_NAMES })).length > 0
206    const isOutOfReach = isClaudeProject || !Frames.isBelow(read, root)
207
208    if (isOutOfReach) {
209      return result
210    }
211
212    const [stack, claude] = await Promise.all([
213      $.fs.ancestors({ names: Names.AGENTS_NAMES, of: read, below: root }),
214      $.fs.ancestors({ names: Names.CLAUDE_NAMES, of: read, below: root }),
215    ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
216    const loop = e.agentId ?? Names.MAIN_LOOP
217    const sent = given.get(loop) ?? new Set<string>()
218    given.set(loop, sent)
219    const nested = (
220      isFallback ? Frames.outsideClaudeDirs(stack, claude) : stack
221    ).filter(file => Frames.isBelow(file.dir, root))
222    const fresh = Files.unseenFiles(Files.filesOf(nested), [
223      ...inContext,
224      ...Files.filesOf(claude),
225    ]).filter(file => !sent.has(file.path))
226    const attached = fresh.filter(file => !Frames.isFileAt(file, read))
227    const isWholeRead = e.offset === undefined && e.limit === undefined
228
229    for (const file of fresh) {
230      const isSent =
231        attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead)
232
233      if (isSent) {
234        sent.add(file.path)
235      }
236    }
237
238    const hasAttached = attached.length > 0
239
240    if (hasAttached) {
241      Telemetry.quietly(() =>
242        $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)),
243      )
244    }
245
246    return hasAttached
247      ? {
248          ...result,
249          context: [
250            ...(result.context ?? []),
251            ...attached.map(Frames.nestedFrame),
252          ],
253        }
254      : result
255  })
256}
257
258/**
259 * Whether a Read attaches nested AGENTS.md files in this run: not where the
260 * engine attaches nothing to a turn, a nested CLAUDE.md included, which is a
261 * --bare run (it sets CLAUDE_CODE_SIMPLE) or one with
262 * CLAUDE_CODE_DISABLE_ATTACHMENTS on.
263 *
264 * Read on every Read, as the engine reads them on every turn: a settings
265 * `env` block or a managed delivery can flip either mid-session. The files of
266 * the walk itself need no such check: where the engine loads no instruction
267 * files `$.fs.ancestors` finds none.
268 *
269 * @param $ the engine, as the `tool.call` hook holds it
270 * @returns whether nested files ride a Read's result here
271 */
272async function attachesOnRead($: EngineInterface): Promise<boolean> {
273  const [simple, attachmentsOff] = await Promise.all([
274    $.env.get('CLAUDE_CODE_SIMPLE'),
275    $.env.get('CLAUDE_CODE_DISABLE_ATTACHMENTS'),
276  ])
277
278  return (
279    !Switches.isSwitchedOn(simple) && !Switches.isSwitchedOn(attachmentsOff)
280  )
281}
282
283/**
284 * The home directory a `~` in a Read's path stands for, read the way the
285 * Read tool reads it.
286 *
287 * The profile directory on a Windows spelling of the working directory,
288 * else `HOME`, each falling back to the other.
289 *
290 * @param $ the engine, as the `tool.call` hook holds it
291 * @param cwd the session's working directory, whose spelling names the platform
292 * @returns the home directory, or undefined when the environment names none
293 */
294async function homeOf(
295  $: EngineInterface,
296  cwd: string,
297): Promise<string | undefined> {
298  const [home, profile] = await Promise.all([
299    $.env.get('HOME'),
300    $.env.get('USERPROFILE'),
301  ])
302  const isWindowsSpelling = cwd.includes('\\')
303
304  return isWindowsSpelling ? (profile ?? home) : (home ?? profile)
305}
306
hooks/files/index.ts 13 lines
1export * from './chain-root-of.js'
2export * from './dropped-kinds.js'
3export * from './files-of.js'
4export * from './insertion-index.js'
5export * from './is-claude-file-on-walk.js'
6export * from './is-kept-without-instructions.js'
7export * from './is-project-own.js'
8export * from './project-dir-of.js'
9export * from './unseen-files.js'
10export * from './with-project-files.js'
11
12export * as default from '.'
13
hooks/frames/index.ts 9 lines
1export * from './absolute-of.js'
2export * from './is-below.js'
3export * from './is-file-at.js'
4export * from './nested-frame.js'
5export * from './normal-spelling-of.js'
6export * from './outside-claude-dirs.js'
7
8export * as default from '.'
9
hooks/modes/index.ts 8 lines
1export * from './default-mode.js'
2export * from './legacy-mode-of.js'
3export * from './mode-of.js'
4export * from './modes.js'
5export * from './types'
6
7export * as default from '.'
8
hooks/names/index.ts 6 lines
1export * from './agents-names.js'
2export * from './claude-names.js'
3export * from './main-loop.js'
4
5export * as default from '.'
6
hooks/switches/index.ts 4 lines
1export * from './is-switched-on.js'
2
3export * as default from '.'
4
hooks/telemetry/index.ts 16 lines
1export * from './feature-name.js'
2export * from './load-counts-of.js'
3export * from './load-event.js'
4export * from './load-mark-of.js'
5export * from './load-row-of.js'
6export * from './mode-choice-of.js'
7export * from './mode-event.js'
8export * from './mode-row-of.js'
9export * from './nested-event.js'
10export * from './nested-row-of.js'
11export * from './quietly.js'
12export * from './types'
13export * from './walk-failed-reason.js'
14
15export * as default from '.'
16
hooks/files/chain-root-of.ts 29 lines
1import 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 */
13export 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}
29
hooks/files/dropped-kinds.ts 13 lines
1import 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 */
8export const DROPPED_KINDS: readonly InstructionFileKind[] = [
9  'project',
10  'local',
11  'user',
12]
13
hooks/files/files-of.ts 21 lines
1import 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 */
12export 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  )
21
hooks/files/insertion-index.ts 45 lines
1import type { InstructionFile } from 'claude-code'
2
3import Frames from '../frames'
4import { chainRootOf } from './chain-root-of.js'
5import { isProjectOwn } from './is-project-own.js'
6import { 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 */
20export 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}
45
hooks/files/is-claude-file-on-walk.ts 40 lines
1import type { InstructionFile } from 'claude-code'
2
3import Frames from '../frames'
4import Names from '../names'
5import { isProjectOwn } from './is-project-own.js'
6import { 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 */
20export 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}
40