SLOPSHOPPER

nested-agents-md

Loads nested AGENTS.md for files Claude touches through Bash, Edit or Write, where the built-in agents-md mod loads them only on Read.

newguardpromptagents
A shopper browsing a rack in a slop shop
README

nested-agents-md

A Claude Code mod. Claude Code's built-in agents-md mod loads a nested AGENTS.md only when Claude opens a file with the Read tool. Files Claude reads or changes through Bash (cat, sed, heredocs) or the Edit and Write tools get no nested guidance. This mod closes that gap.

When a Bash command names a file under the project root, or Edit or Write changes one, the mod attaches each AGENTS.md between the root and that file that this conversation has not had yet. It uses the same Contents of <path>: frame as agents-md.

  • Bash: words in the command that are existing files, plus redirect and tee targets (a heredoc that creates a file). Directories, patterns, and command output count for nothing, so grep -rn foo . attaches nothing.
  • Read: attaches nothing, but marks what agents-md delivers so the mod does not send it again. The reverse cannot be known: a file this mod sent first comes once more on a later Read of that folder.
  • Like agents-md in its default mode: a project with its own CLAUDE.md, or a folder with one, is left to the engine. Each file goes once per conversation and again after compaction or /clear.

Why it exists: anthropics/claude-code#90450. In Auto and bypass modes Claude Code tells the model to prefer Bash for reads and edits, so nested AGENTS.md never load. Delete this mod when Claude Code loads nested AGENTS.md on every file access.

Needs Claude Code 2.1.287 or later (mods are on by default). Test with claude plugin test plugins/nested-agents-md.

Source 1 files
hooks/register.ts 215 lines
1import type { EngineInterface, FsAncestor, On } from 'claude-code'
2
3const AGENTS_NAMES = ['AGENTS.md', '.claude/AGENTS.md'] as const
4const CLAUDE_NAMES = ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md'] as const
5const MAIN_LOOP = 'main'
6const MAX_TOKENS = 32
7const TOKEN = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([^\s"'|;&<>()`]+)/g
8const WRITTEN = /(?:>>?|\btee(?:\s+-a)?)\s*(?:"([^"]*)"|'([^']*)'|([^\s"'|;&<>()`]+))/g
9const CD = /(?:^|[;&|(]\s*)cd\s+(?:"([^"]*)"|'([^']*)'|([^\s"'|;&<>()`]+))/g
10const NONE: readonly FsAncestor[] = []
11
12type Target = { path: string; mayBeNew: boolean }
13
14export function register(on: On): void {
15  const given = new Map<string, Set<string>>()
16  let handed = new Set<string>()
17  let isClaudeProject: boolean | undefined
18  let rootSeen: string | undefined
19
20  on('prompt.context', ($, e, next) => {
21    const files = e.instructionFiles
22
23    return next(e).finally(() => {
24      given.clear()
25      handed = new Set(files?.map(file => normal(file.path)))
26    })
27  })
28
29  on('agent.spawn', { fork: true }, async ($, e, next) => {
30    const result = await next(e)
31
32    if (result.agentId !== undefined) {
33      given.set(result.agentId, new Set(given.get(e.parentAgentId ?? MAIN_LOOP)))
34    }
35
36    return result
37  })
38
39  on('tool.call', async ($, e, next) => {
40    const result = await next(e)
41    const paths = pathsOf(e)
42    const isSettled = paths === undefined || result.deny !== undefined || result.isError
43
44    if (isSettled || !(await attaches($))) {
45      return result
46    }
47
48    const root = await $.session.root()
49
50    if (root !== rootSeen) {
51      rootSeen = root
52      isClaudeProject = undefined
53      given.clear()
54    }
55
56    isClaudeProject ??= (await $.fs.ancestors({ names: CLAUDE_NAMES })).length > 0
57
58    if (isClaudeProject) {
59      return result
60    }
61
62    const loop = e.agentId ?? MAIN_LOOP
63    const sent = given.get(loop) ?? new Set<string>()
64    given.set(loop, sent)
65    const targets = await targetsOf($, paths, cdDirsOf(e), root)
66    const fresh: FsAncestor[] = []
67
68    for (const target of targets) {
69      for (const file of await nestedOf($, target, root)) {
70        const path = normal(file.parts[0]?.path ?? `${file.dir}/${file.name}`)
71
72        if (!sent.has(path) && !handed.has(path)) {
73          sent.add(path)
74          fresh.push(file)
75        }
76      }
77    }
78
79    const isObservedOnly = e.tool === 'Read' || fresh.length === 0
80
81    return isObservedOnly
82      ? result
83      : { ...result, context: [...(result.context ?? []), ...fresh.flatMap(frames)] }
84  })
85}
86
87function pathsOf(e: { tool: string } & Record<string, unknown>): Target[] | undefined {
88  if (e.tool === 'Bash' && typeof e.command === 'string') {
89    const written = [...e.command.matchAll(WRITTEN)].map(match => ({ path: match[1] ?? match[2] ?? match[3] ?? '', mayBeNew: true }))
90
91    return [...written, ...tokensOf(e.command).map(path => ({ path, mayBeNew: false }))]
92  }
93
94  const isFileTool = e.tool === 'Read' || e.tool === 'Edit' || e.tool === 'Write'
95
96  return isFileTool && typeof e.file_path === 'string' ? [{ path: e.file_path, mayBeNew: e.tool === 'Write' }] : undefined
97}
98
99const cdDirsOf = (e: { tool: string } & Record<string, unknown>): string[] =>
100  e.tool === 'Bash' && typeof e.command === 'string'
101    ? [...e.command.matchAll(CD)].map(match => match[1] ?? match[2] ?? match[3] ?? '').reverse()
102    : []
103
104function tokensOf(command: string): string[] {
105  const tokens: string[] = []
106
107  for (const match of command.matchAll(TOKEN)) {
108    const raw = match[1] ?? match[2] ?? match[3] ?? ''
109    const token = raw.includes('=') ? raw.slice(raw.lastIndexOf('=') + 1) : raw
110    const isPathLike = token !== '' && !token.startsWith('-') && !/[$*?[\]{}]/.test(token)
111
112    if (isPathLike) {
113      tokens.push(token)
114    }
115
116    if (tokens.length === MAX_TOKENS) {
117      break
118    }
119  }
120
121  return tokens
122}
123
124async function targetsOf($: EngineInterface, targets: Target[], cdDirs: string[], root: string): Promise<string[]> {
125  const [cwd, home] = await Promise.all([$.session.cwd(), $.env.get('HOME')])
126  const bases = [...cdDirs.map(dir => absoluteOf(dir, cwd, home)), cwd]
127  const kept = await Promise.all(targets.map(target => firstTargetOf($, target, bases, home, root)))
128
129  return [...new Set(kept.filter(path => path !== undefined))]
130}
131
132async function firstTargetOf(
133  $: EngineInterface,
134  { path, mayBeNew }: Target,
135  bases: string[],
136  home: string | undefined,
137  root: string,
138): Promise<string | undefined> {
139  for (const base of path === '' ? [] : bases) {
140    const absolute = absoluteOf(path, base, home)
141
142    if (isBelow(absolute, root) && (await isFileTarget($, absolute, mayBeNew))) {
143      return absolute
144    }
145  }
146
147  return undefined
148}
149
150async function isFileTarget($: EngineInterface, path: string, mayBeNew: boolean): Promise<boolean> {
151  const kind = await kindOf($, path)
152  const isNewFile = mayBeNew && kind === undefined && (await kindOf($, dirOf(path))) === 'dir'
153
154  return kind === 'file' || isNewFile
155}
156
157const kindOf = ($: EngineInterface, path: string) =>
158  $.fs.stat(path).then(
159    stat => stat.kind,
160    () => undefined,
161  )
162
163async function nestedOf($: EngineInterface, of: string, root: string): Promise<readonly FsAncestor[]> {
164  const [agents, claude] = await Promise.all([
165    $.fs.ancestors({ names: AGENTS_NAMES, of, below: root }),
166    $.fs.ancestors({ names: CLAUDE_NAMES, of, below: root }),
167  ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
168  const claudeDirs = new Set(claude.map(file => normal(file.dir)))
169
170  return agents.filter(file => !claudeDirs.has(normal(file.dir)) && isBelow(file.dir, root))
171}
172
173const frames = (file: FsAncestor): string[] =>
174  file.parts.map(part => `Contents of ${part.path}:\n\n${part.content}`)
175
176async function attaches($: EngineInterface): Promise<boolean> {
177  const [simple, attachmentsOff] = await Promise.all([
178    $.env.get('CLAUDE_CODE_SIMPLE'),
179    $.env.get('CLAUDE_CODE_DISABLE_ATTACHMENTS'),
180  ])
181
182  return !isOn(simple) && !isOn(attachmentsOff)
183}
184
185const isOn = (value: string | undefined): boolean =>
186  value !== undefined && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
187
188function absoluteOf(path: string, base: string, home: string | undefined): string {
189  if ((path === '~' || path.startsWith('~/')) && home !== undefined) {
190    return normal(`${home}${path.slice(1)}`)
191  }
192
193  return normal(path.startsWith('/') ? path : `${base}/${path}`)
194}
195
196function normal(path: string): string {
197  const parts: string[] = []
198
199  for (const part of path.replaceAll('\\', '/').split('/')) {
200    if (part === '..' && parts.length > 1) {
201      parts.pop()
202    } else if (part === '..') {
203      continue
204    } else if (part !== '.' && (part !== '' || parts.length === 0)) {
205      parts.push(part)
206    }
207  }
208
209  return parts.join('/') || '/'
210}
211
212const isBelow = (path: string, dir: string): boolean => normal(path).startsWith(`${normal(dir)}/`)
213
214const dirOf = (path: string): string => normal(path).replace(/\/[^/]*$/, '') || '/'
215