feat: support AGENTS.md project instructions

This commit is contained in:
程序员阿江(Relakkes)
2026-09-21 20:45:51 +08:00
parent 0e07a4e44f
commit 9bf6e76896
7 changed files with 707 additions and 51 deletions
+98
View File
@@ -0,0 +1,98 @@
import { expect, spyOn, test } from 'bun:test'
import React from 'react'
import { render } from 'ink'
import { PassThrough } from 'node:stream'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import * as ink from '../../ink.js'
import * as appState from '../../state/AppState.js'
import * as config from '../../utils/config.js'
import * as settings from '../../utils/settings/settings.js'
import * as memory from '../../utils/claudemd.js'
import * as tabs from '../design-system/Tabs.js'
import * as bindings from '../../keybindings/useKeybinding.js'
import * as search from '../../hooks/useSearchInput.js'
import * as terminal from '../../hooks/useTerminalSize.js'
import * as instructions from '../../utils/instructionFiles.js'
import { Config } from './Config.js'
const tick = () => new Promise(resolve => setTimeout(resolve, 20))
test('list Escape belongs to Config and restores project instructions', async () => {
const dir = mkdtempSync(join(tmpdir(), 'config-cancel-'))
const oldHome = process.env.HOME
const oldConfig = process.env.CLAUDE_CONFIG_DIR
process.env.HOME = dir
process.env.CLAUDE_CONFIG_DIR = dir
const spies: Array<{ mockRestore(): void }> = []
let keyDown: (event: any) => void = () => {}
const handlers = new Map<string, () => void>()
let ownsEsc = false
let closed: string | undefined
let state = appState.getDefaultAppState()
const initial = { pluginConfigs: { 'agents-md@builtin': { options: { instructionFiles: 'claude-md-or-agents-md', sibling: true } } } }
let user = structuredClone(initial)
const memoryPromise = Promise.resolve([])
const store = { getState: () => state }
spies.push(
spyOn(ink, 'Box').mockImplementation((props: any) => { if (props.onKeyDown) keyDown = props.onKeyDown; return null }),
spyOn(ink, 'useTheme').mockReturnValue([{}, () => {}] as any),
spyOn(ink, 'useThemeSetting').mockReturnValue('dark' as any),
spyOn(ink, 'useTerminalFocus').mockReturnValue(true),
spyOn(appState, 'useAppState').mockImplementation((selector: any) => selector(state)),
spyOn(appState, 'useSetAppState').mockReturnValue((updater: any) => { state = updater(state) }),
spyOn(appState, 'useAppStateStore').mockReturnValue(store as any),
spyOn(config, 'getGlobalConfig').mockReturnValue({} as any),
spyOn(config, 'saveGlobalConfig').mockImplementation(() => {}),
spyOn(config, 'getCurrentProjectConfig').mockReturnValue({} as any),
spyOn(settings, 'getInitialSettings').mockImplementation(() => user),
spyOn(settings, 'getSettingsForSource').mockImplementation(source => source === 'userSettings' ? user : {}),
spyOn(settings, 'updateSettingsForSource').mockImplementation((source, patch) => {
if (source === 'userSettings' && patch.pluginConfigs) {
user = { ...user, pluginConfigs: { ...user.pluginConfigs, 'agents-md@builtin': { options: { ...user.pluginConfigs['agents-md@builtin'].options, ...patch.pluginConfigs['agents-md@builtin']?.options } } } } as typeof user
}
return { error: null }
}),
spyOn(memory, 'getMemoryFiles').mockReturnValue(memoryPromise),
spyOn(memory, 'clearMemoryFileCaches').mockImplementation(() => {}),
spyOn(tabs, 'useTabHeaderFocus').mockReturnValue({ headerFocused: false, focusHeader: () => {} } as any),
spyOn(search, 'useSearchInput').mockReturnValue({ query: 'instructions', setQuery: () => {}, cursorOffset: 12 } as any),
spyOn(terminal, 'useTerminalSize').mockReturnValue({ rows: 30, columns: 100 } as any),
spyOn(bindings, 'useKeybinding').mockImplementation((action, handler, options) => {
if (options?.isActive) handlers.set(action, handler as () => void)
else handlers.delete(action)
}),
spyOn(bindings, 'useKeybindings').mockImplementation((actions, options) => {
for (const [action, handler] of Object.entries(actions)) {
if (options?.isActive) handlers.set(action, handler as () => void)
else handlers.delete(action)
}
}),
)
const app = render(<React.Suspense fallback={null}><Config context={{ options: { mcpClients: [] } } as any} onClose={value => { closed = value }} setTabsHidden={() => {}} onIsSearchModeChange={value => { ownsEsc = value }} /></React.Suspense>, {
stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough(), exitOnCtrlC: false, patchConsole: false,
})
try {
await new Promise(resolve => setTimeout(resolve, 350))
keyDown({ key: 'return', preventDefault() {} })
await tick()
expect(ownsEsc).toBe(true)
handlers.get('select:accept')?.()
await tick()
expect(instructions.getInstructionFilesModeFromSettings(user)).toBe('claude-md-and-agents-md')
handlers.get('confirm:no')?.()
await tick()
expect(closed).toBe('Config dialog dismissed')
expect(instructions.getInstructionFilesModeFromSettings(user)).toBe('claude-md-or-agents-md')
expect(user.pluginConfigs['agents-md@builtin'].options.sibling).toBe(true)
} finally {
app.unmount()
for (const spy of spies.reverse()) spy.mockRestore()
if (oldHome === undefined) delete process.env.HOME
else process.env.HOME = oldHome
if (oldConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = oldConfig
rmSync(dir, { recursive: true, force: true })
}
})
+26 -4
View File
@@ -27,7 +27,7 @@ import { Dialog } from '../design-system/Dialog.js';
import { Select } from '../CustomSelect/index.js';
import { OutputStylePicker } from '../OutputStylePicker.js';
import { LanguagePicker } from '../LanguagePicker.js';
import { getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes } from 'src/utils/claudemd.js';
import { clearMemoryFileCaches, getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes } from 'src/utils/claudemd.js';
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Byline } from '../design-system/Byline.js';
@@ -48,6 +48,8 @@ import { useSearchInput } from '../../hooks/useSearchInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel } from '../../utils/fastMode.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { getUserContext } from '../../context.js';
import { getInstructionFilesMode, INSTRUCTION_FILE_MODES, INSTRUCTION_FILES_PLUGIN, instructionFilesSettingsPatch } from '../../utils/instructionFiles.js';
type Props = {
onClose: (result?: string, options?: {
display?: CommandResultDisplay;
@@ -189,9 +191,10 @@ export function Config({
});
// Tell the parent when Config's own Esc handler is active so Settings cedes
// confirm:no. Only true when search mode owns the keyboard — not when the
// tab header is focused (then Settings must handle Esc-to-close).
const ownsEsc = isSearchMode && !headerFocused;
// confirm:no. Config owns both search and list input: list Escape must
// revert immediate settings writes before closing. The tab header still
// delegates Escape to Settings.
const ownsEsc = !headerFocused;
React.useEffect(() => {
onIsSearchModeChange?.(ownsEsc);
}, [ownsEsc, onIsSearchModeChange]);
@@ -432,6 +435,22 @@ export function Config({
});
}
}] : []), {
id: 'instructionFiles',
label: 'Project instructions',
value: getInstructionFilesMode(),
options: [...INSTRUCTION_FILE_MODES],
type: 'enum' as const,
onChange(value: string) {
const result = updateSettingsForSource('userSettings', instructionFilesSettingsPatch(value));
if (result.error) {
logError(result.error);
return;
}
clearMemoryFileCaches();
getUserContext.cache.clear?.();
setSettingsData(getInitialSettings());
}
}, {
id: 'workflows',
label: 'Dynamic workflows',
value: settingsData?.disableWorkflows === true ? false : settingsData?.enableWorkflows ?? true,
@@ -1249,6 +1268,9 @@ export function Config({
outputStyle: il?.outputStyle
});
const iu = initialUserSettings;
updateSettingsForSource('userSettings', instructionFilesSettingsPatch(iu?.pluginConfigs?.[INSTRUCTION_FILES_PLUGIN]?.options?.instructionFiles as string | undefined, iu?.pluginConfigs?.[INSTRUCTION_FILES_PLUGIN]?.options?.projectInstructions as string | undefined));
clearMemoryFileCaches();
getUserContext.cache.clear?.();
updateSettingsForSource('userSettings', {
alwaysThinkingEnabled: iu?.alwaysThinkingEnabled,
fastMode: iu?.fastMode,
+13
View File
@@ -16,6 +16,7 @@ import { execFileNoThrow } from './utils/execFileNoThrow.js'
import { getBranch, getDefaultBranch, getIsGit, gitExe } from './utils/git.js'
import { shouldIncludeGitInstructions } from './utils/gitSettings.js'
import { logError } from './utils/log.js'
import { getInstructionFilesMode, type InstructionFilesMode } from './utils/instructionFiles.js'
const MAX_STATUS_CHARS = 2000
@@ -152,6 +153,8 @@ export const getSystemContext = memoize(
/**
* This context is prepended to each conversation, and cached for the duration of the conversation.
*/
let cachedUserContextMode: InstructionFilesMode | undefined
export const getUserContext = memoize(
async (): Promise<{
[k: string]: string
@@ -186,4 +189,14 @@ export const getUserContext = memoize(
currentDate: `Today's date is ${getLocalISODate()}.`,
}
},
() => {
const mode = getInstructionFilesMode()
if (mode !== cachedUserContextMode) {
// Keep only the current mode so returning to an earlier mode also
// refreshes the classifier's cached instruction content.
getUserContext.cache.clear?.()
cachedUserContextMode = mode
}
return mode
},
)
+282
View File
@@ -0,0 +1,282 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, spyOn, test } from 'bun:test'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
let root: string
let cwd: string
let mode: string | undefined
let excludes: string[]
let additional: string[]
let disabledSources: Set<string>
let memory: typeof import('./claudemd.js')
let config: typeof import('./config.js')
let state: typeof import('../bootstrap/state.js')
let settings: typeof import('./settings/settings.js')
let sources: typeof import('./settings/constants.js')
let fsOperations: typeof import('./fsOperations.js')
let autoMem: typeof import('../memdir/paths.js')
let hooks: typeof import('./hooks.js')
const spies: Array<{ mockRestore(): void }> = []
const envKeys = ['HOME', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'] as const
const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
function put(path: string, content: string) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
}
beforeAll(async () => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'agents-md-memory-')))
process.env.HOME = join(root, 'home')
process.env.CLAUDE_CONFIG_DIR = join(root, 'config')
;[config, state, settings, sources, fsOperations, autoMem, hooks] = await Promise.all([
import('./config.js'), import('../bootstrap/state.js'), import('./settings/settings.js'),
import('./settings/constants.js'), import('./fsOperations.js'), import('../memdir/paths.js'), import('./hooks.js'),
])
memory = await import('./claudemd.js')
})
beforeEach(() => {
cwd = join(root, 'project', 'child')
rmSync(join(root, 'project'), { recursive: true, force: true })
mkdirSync(cwd, { recursive: true })
mode = undefined
excludes = []
additional = []
disabledSources = new Set()
delete process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD
const userSettings = () => ({ pluginConfigs: { 'agents-md@builtin': { options: { instructionFiles: mode } } } })
spies.push(
spyOn(state, 'getOriginalCwd').mockImplementation(() => cwd),
spyOn(state, 'getAdditionalDirectoriesForClaudeMd').mockImplementation(() => additional),
spyOn(config, 'getCurrentProjectConfig').mockReturnValue({} as ReturnType<typeof config.getCurrentProjectConfig>),
spyOn(config, 'getMemoryPath').mockImplementation(type => join(root, 'policy', type, 'CLAUDE.md')),
spyOn(config, 'getManagedClaudeRulesDir').mockReturnValue(join(root, 'policy', 'managed-rules')),
spyOn(config, 'getUserClaudeRulesDir').mockReturnValue(join(root, 'policy', 'user-rules')),
spyOn(settings, 'getInitialSettings').mockImplementation(() => ({ ...userSettings(), claudeMdExcludes: excludes })),
spyOn(settings, 'getSettingsForSource').mockImplementation(source => source === 'userSettings' ? userSettings() : {}),
spyOn(sources, 'isSettingSourceEnabled').mockImplementation(source => !disabledSources.has(source)),
spyOn(autoMem, 'isAutoMemoryEnabled').mockReturnValue(false),
spyOn(hooks, 'hasInstructionsLoadedHook').mockReturnValue(false),
)
// Real fixture reads, but never inspect instructions from host ancestors.
const fs = fsOperations.getFsImplementation()
spies.push(spyOn(fsOperations, 'getFsImplementation').mockReturnValue({
...fs,
readFile: async (path, options) => {
if (!path.startsWith(root + '/')) throw Object.assign(new Error('Outside fixture'), { code: 'ENOENT' })
return fs.readFile(path, options)
},
readdir: async path => path.startsWith(root + '/') ? fs.readdir(path) : [],
}))
memory.clearMemoryFileCaches()
})
afterEach(() => {
memory.clearMemoryFileCaches()
for (const spy of spies.splice(0).reverse()) spy.mockRestore()
rmSync(join(root, 'policy'), { recursive: true, force: true })
})
afterAll(() => {
for (const key of envKeys) {
const value = originalEnv[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
rmSync(root, { recursive: true, force: true })
})
const contents = async () => (await memory.getMemoryFiles()).map(file => file.content)
const nestedContents = async (dir = join(cwd, 'nested')) =>
(await memory.getMemoryFilesForNestedDirectory(dir, join(dir, 'file.ts'), new Set())).map(file => file.content)
test('loads ancestor and dot-directory AGENTS instructions without CLAUDE instructions', async () => {
put(join(dirname(cwd), 'AGENTS.md'), 'ancestor')
put(join(cwd, 'AGENTS.md'), 'project')
put(join(cwd, '.claude', 'AGENTS.md'), 'dot directory')
expect(await contents()).toEqual(['ancestor', 'project', 'dot directory'])
})
test.each(['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md'])('loaded ancestor %s suppresses all startup fallback', async name => {
put(join(dirname(cwd), name), 'claude')
put(join(cwd, 'AGENTS.md'), 'agents')
expect(await contents()).toEqual(['claude'])
})
test('empty CLAUDE file does not suppress AGENTS fallback', async () => {
put(join(cwd, 'CLAUDE.md'), '')
put(join(cwd, 'AGENTS.md'), 'agents')
expect(await contents()).toEqual(['agents'])
})
test('both mode deduplicates imported paths and equal contents', async () => {
mode = 'claude-md-and-agents-md'
put(join(cwd, 'CLAUDE.md'), '@./AGENTS.md\nclaude')
put(join(cwd, 'AGENTS.md'), 'agents')
put(join(cwd, '.claude', 'AGENTS.md'), 'additional agents')
put(join(dirname(cwd), 'AGENTS.md'), 'agents')
const loaded = await contents()
expect(loaded.filter(content => content === 'agents')).toHaveLength(1)
expect(loaded).toContain('additional agents')
expect(loaded).toHaveLength(3)
})
test('claude-only mode ignores AGENTS files', async () => {
mode = 'claude-md'
put(join(cwd, 'AGENTS.md'), 'agents')
expect(await contents()).toEqual([])
})
test('managed-only preserves managed memory and excludes project, local, and nested rules', async () => {
mode = 'managed-only'
put(join(root, 'policy', 'Managed', 'CLAUDE.md'), 'managed')
const autoPath = join(root, 'policy', 'MEMORY.md')
put(autoPath, 'auto memory')
spies.push(
spyOn(autoMem, 'isAutoMemoryEnabled').mockReturnValue(true),
spyOn(autoMem, 'getAutoMemEntrypoint').mockReturnValue(autoPath),
)
put(join(root, 'policy', 'User', 'CLAUDE.md'), 'user')
for (const dir of [cwd, join(cwd, 'nested')]) {
for (const file of ['CLAUDE.md', 'AGENTS.md', 'CLAUDE.local.md', '.claude/rules/test.md']) put(join(dir, file), file)
}
expect(await contents()).toEqual(['managed', 'auto memory'])
expect(await nestedContents()).toEqual([])
})
test('nested AGENTS loads unless session or directory CLAUDE instructions apply', async () => {
put(join(cwd, 'nested', 'AGENTS.md'), 'nested agents')
expect(await nestedContents()).toEqual(['nested agents'])
put(join(cwd, 'CLAUDE.md'), 'session claude')
memory.clearMemoryFileCaches()
expect(await nestedContents()).toEqual([])
rmSync(join(cwd, 'CLAUDE.md'))
put(join(cwd, 'nested', 'CLAUDE.md'), 'nested claude')
memory.clearMemoryFileCaches()
expect(await nestedContents()).toEqual(['nested claude'])
})
test('AGENTS imports retain excludes and project source gating', async () => {
put(join(cwd, 'AGENTS.md'), '@./detail.md\nproject')
put(join(cwd, 'detail.md'), 'included')
expect(await contents()).toEqual(['@./detail.md\nproject', 'included'])
excludes = ['**/AGENTS.md']
memory.clearMemoryFileCaches()
expect(await contents()).toEqual([])
excludes = []
disabledSources.add('projectSettings')
memory.clearMemoryFileCaches()
expect(await contents()).toEqual([])
})
test('explicit additional directories discover AGENTS when project sources are disabled', async () => {
additional = [join(cwd, 'extra')]
put(join(additional[0]!, 'AGENTS.md'), 'additional agents')
process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = '1'
disabledSources.add('projectSettings')
expect((await memory.getMemoryFiles(true)).map(file => file.content)).toEqual(['additional agents'])
})
test('recognizes AGENTS files for memory change tracking without broadening unrelated paths', () => {
expect(memory.isMemoryFilePath(join(cwd, 'AGENTS.md'))).toBe(true)
expect(memory.isMemoryFilePath(join(cwd, '.claude', 'AGENTS.md'))).toBe(true)
expect(memory.isMemoryFilePath(join(cwd, 'agents.md'))).toBe(false)
expect(memory.isMemoryFilePath(join(cwd, 'AGENTS.local.md'))).toBe(false)
})
test('user CLAUDE memory does not disable project AGENTS fallback', async () => {
put(join(root, 'policy', 'User', 'CLAUDE.md'), 'user')
put(join(cwd, 'AGENTS.md'), 'project agents')
expect(await contents()).toEqual(['user', 'project agents'])
})
test('excluded CLAUDE instructions do not suppress fallback', async () => {
put(join(cwd, 'CLAUDE.md'), 'excluded claude')
put(join(cwd, 'AGENTS.md'), 'agents')
excludes = ['**/CLAUDE.md']
expect(await contents()).toEqual(['agents'])
})
test('AGENTS external includes require the existing approval gate', async () => {
const external = join(root, 'policy', 'external.md')
put(external, 'external')
const text = `@${external}\nproject`
put(join(cwd, 'AGENTS.md'), text)
expect(await contents()).toEqual([text])
expect((await memory.getMemoryFiles(true)).map(file => file.content)).toEqual([text, 'external'])
})
test('disabled project source excludes nested AGENTS and project rules', async () => {
const dir = join(cwd, 'nested')
put(join(dir, 'AGENTS.md'), 'nested agents')
put(join(dir, '.claude', 'rules', 'test.md'), 'nested rule')
disabledSources.add('projectSettings')
expect(await nestedContents()).toEqual([])
})
test('user context follows instruction settings changes without clearing prompt caches', async () => {
const { getUserContext } = await import('../context.js')
getUserContext.cache.clear?.()
put(join(cwd, 'CLAUDE.md'), 'Claude context fixture')
put(join(cwd, 'AGENTS.md'), 'Agents context fixture')
try {
mode = 'claude-md'
expect((await getUserContext()).claudeMd).toContain('Claude context fixture')
expect((await getUserContext()).claudeMd).not.toContain('Agents context fixture')
mode = 'claude-md-and-agents-md'
expect((await getUserContext()).claudeMd).toContain('Agents context fixture')
mode = 'managed-only'
expect((await getUserContext()).claudeMd).toBeUndefined()
expect(state.getCachedClaudeMdContent()).toBeNull()
mode = 'claude-md'
expect((await getUserContext()).claudeMd).toContain('Claude context fixture')
expect(state.getCachedClaudeMdContent()).toContain('Claude context fixture')
expect(state.getCachedClaudeMdContent()).not.toContain('Agents context fixture')
} finally {
getUserContext.cache.clear?.()
}
})
test('additional directories make independent fallback decisions', async () => {
additional = [join(cwd, 'extra')]
process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = '1'
put(join(cwd, 'AGENTS.md'), 'project agents')
put(join(additional[0]!, 'CLAUDE.md'), 'additional claude')
put(join(additional[0]!, 'AGENTS.md'), 'additional agents')
expect(await contents()).toEqual(['project agents', 'additional claude'])
mode = 'claude-md-and-agents-md'
expect(await contents()).toEqual(['project agents', 'additional claude', 'additional agents'])
})
test('both mode inserts AGENTS after same-directory instructions and before descendants', async () => {
mode = 'claude-md-and-agents-md'
put(join(dirname(cwd), 'CLAUDE.md'), 'parent claude')
put(join(dirname(cwd), 'CLAUDE.local.md'), 'parent local')
put(join(dirname(cwd), '.claude/rules/rule.md'), 'parent rule')
put(join(dirname(cwd), 'AGENTS.md'), 'parent agents')
put(join(cwd, 'CLAUDE.md'), 'child claude')
expect(await contents()).toEqual(['parent claude', 'parent rule', 'parent local', 'parent agents', 'child claude'])
})
test('nested AGENTS does not repeat instructions already in initial context', async () => {
put(join(cwd, 'AGENTS.md'), 'shared instructions')
put(join(cwd, 'nested', 'AGENTS.md'), 'shared instructions')
expect(await contents()).toEqual(['shared instructions'])
expect(await nestedContents()).toEqual([])
})
test('nested worktrees skip checked-in parent instructions when deciding fallback', async () => {
const git = await import('./git.js')
const mainRoot = join(root, 'project')
cwd = join(mainRoot, '.claude', 'worktrees', 'fixture')
put(join(mainRoot, 'CLAUDE.md'), 'main checkout only')
put(join(cwd, 'AGENTS.md'), 'worktree instructions')
spies.push(
spyOn(git, 'findGitRoot').mockReturnValue(cwd),
spyOn(git, 'findCanonicalGitRoot').mockReturnValue(mainRoot),
)
expect(await contents()).toEqual(['worktree instructions'])
})
+132 -47
View File
@@ -3,7 +3,7 @@
*
* 1. Managed memory (eg. /etc/claude-code/CLAUDE.md) - Global instructions for all users
* 2. User memory (~/.claude/CLAUDE.md) - Private global instructions for all projects
* 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, and .claude/rules/*.md in project roots) - Instructions checked into the codebase
* 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, AGENTS.md fallback, and .claude/rules/*.md in project roots) - Instructions checked into the codebase
* 4. Local memory (CLAUDE.local.md in project roots) - Private project-specific instructions
*
* Files are loaded in reverse order of priority, i.e. the latest files are highest priority
@@ -77,6 +77,7 @@ import { expandPath } from './path.js'
import { pathInWorkingPath } from './permissions/filesystem.js'
import { isSettingSourceEnabled } from './settings/constants.js'
import { getInitialSettings } from './settings/settings.js'
import { getInstructionFilesMode, type InstructionFilesMode } from './instructionFiles.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM')
@@ -787,12 +788,100 @@ export async function processMdRules({
}
}
// Keep the walk shared by initial loading and AGENTS fallback detection. A nested
// worktree must not inherit checked-in instructions from its main checkout.
function getProjectInstructionDirectories(root: string) {
const dirs: { dir: string; skipProject: boolean }[] = []
const gitRoot = findGitRoot(root)
const canonicalRoot = findCanonicalGitRoot(root)
const nested = gitRoot !== null && canonicalRoot !== null &&
normalizePathForComparison(gitRoot) !== normalizePathForComparison(canonicalRoot) &&
pathInWorkingPath(gitRoot, canonicalRoot)
for (let dir = root; dir !== parse(dir).root; dir = dirname(dir)) {
dirs.push({
dir,
skipProject: Boolean(nested && pathInWorkingPath(dir, canonicalRoot!) &&
!pathInWorkingPath(dir, gitRoot!)),
})
}
return dirs.reverse()
}
async function hasClaudeInstructions(dir: string, skipProject = false, explicitProject = false): Promise<boolean> {
const candidates: [string, MemoryType][] = []
if (!skipProject && (explicitProject || isSettingSourceEnabled('projectSettings'))) {
candidates.push([join(dir, 'CLAUDE.md'), 'Project'], [join(dir, '.claude', 'CLAUDE.md'), 'Project'])
}
if (!explicitProject && isSettingSourceEnabled('localSettings')) {
candidates.push([join(dir, 'CLAUDE.local.md'), 'Local'])
}
for (const [file, type] of candidates) {
if ((await processMemoryFile(file, type, new Set(), false)).length > 0) return true
}
return false
}
// This is a project-wide decision, not a per-file fallback. User and managed
// CLAUDE.md files, rules, and --add-dir files do not disable AGENTS.md.
const canUseAgentsFallback = memoize(async (root: string): Promise<boolean> => {
for (const { dir, skipProject } of getProjectInstructionDirectories(root)) {
if (await hasClaudeInstructions(dir, skipProject)) return false
}
return true
})
async function processAgentsFiles(
dir: string,
processedPaths: Set<string>,
includeExternal: boolean,
): Promise<MemoryFileInfo[]> {
const files: MemoryFileInfo[] = []
for (const name of ['AGENTS.md', join('.claude', 'AGENTS.md')]) {
files.push(...await processMemoryFile(join(dir, name), 'Project', processedPaths, includeExternal))
}
return files
}
// CLAUDE.md may already import (or symlink to) AGENTS.md. Path deduplication
// happens in processMemoryFile; content deduplication also handles copied files.
function deduplicateAgentsFiles(
files: MemoryFileInfo[],
existing: MemoryFileInfo[] = [],
): MemoryFileInfo[] {
const byPath = new Map([...existing, ...files].map(file => [file.path, file]))
const fromAgents = (file: MemoryFileInfo) => {
const seen = new Set<string>()
while (file.parent && byPath.has(file.parent) && !seen.has(file.path)) {
seen.add(file.path)
file = byPath.get(file.parent)!
}
return file.type === 'Project' && basename(file.path) === 'AGENTS.md'
}
const contents = new Set([
...existing.filter(file => file.type === 'Project' || file.type === 'Local'),
...files.filter(file => !fromAgents(file) && (file.type === 'Project' || file.type === 'Local')),
].map(file => file.content.trim()))
return files.filter(file => {
if (!fromAgents(file)) return true
const content = file.content.trim()
if (content && contents.has(content)) return false
if (content) contents.add(content)
return true
})
}
let cachedInstructionMode: InstructionFilesMode | undefined
export const getMemoryFiles = memoize(
async (forceIncludeExternal: boolean = false): Promise<MemoryFileInfo[]> => {
const startTime = Date.now()
logForDiagnosticsNoPII('info', 'memory_files_started')
const result: MemoryFileInfo[] = []
let result: MemoryFileInfo[] = []
const mode = getInstructionFilesMode()
const managedOnly = mode === 'managed-only'
const loadAgents = mode === 'claude-md-and-agents-md' ||
(mode === 'claude-md-or-agents-md' && await canUseAgentsFallback(getOriginalCwd()))
const processedPaths = new Set<string>()
const config = getCurrentProjectConfig()
const includeExternal =
@@ -823,7 +912,7 @@ export const getMemoryFiles = memoize(
)
// Process User file (only if userSettings is enabled)
if (isSettingSourceEnabled('userSettings')) {
if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('userSettings')) {
const userClaudeMd = getMemoryPath('User')
result.push(
...(await processMemoryFile(
@@ -846,45 +935,10 @@ export const getMemoryFiles = memoize(
)
}
// Then process Project and Local files
const dirs: string[] = []
const originalCwd = getOriginalCwd()
let currentDir = originalCwd
while (currentDir !== parse(currentDir).root) {
dirs.push(currentDir)
currentDir = dirname(currentDir)
}
// When running from a git worktree nested inside its main repo (e.g.,
// .claude/worktrees/<name>/ from `claude -w`), the upward walk passes
// through both the worktree root and the main repo root. Both contain
// checked-in files like CLAUDE.md and .claude/rules/*.md, so the same
// content gets loaded twice. Skip Project-type (checked-in) files from
// directories above the worktree but within the main repo — the worktree
// already has its own checkout. CLAUDE.local.md is gitignored so it only
// exists in the main repo and is still loaded.
// See: https://github.com/anthropics/claude-code/issues/29599
const gitRoot = findGitRoot(originalCwd)
const canonicalRoot = findCanonicalGitRoot(originalCwd)
const isNestedWorktree =
gitRoot !== null &&
canonicalRoot !== null &&
normalizePathForComparison(gitRoot) !==
normalizePathForComparison(canonicalRoot) &&
pathInWorkingPath(gitRoot, canonicalRoot)
// Process from root downward to CWD
for (const dir of dirs.reverse()) {
// In a nested worktree, skip checked-in files from the main repo's
// working tree (dirs inside canonicalRoot but outside the worktree).
const skipProject =
isNestedWorktree &&
pathInWorkingPath(dir, canonicalRoot) &&
!pathInWorkingPath(dir, gitRoot)
// Process ancestors before descendants, preserving local worktree rules.
for (const { dir, skipProject } of getProjectInstructionDirectories(getOriginalCwd())) {
// Try reading CLAUDE.md (Project) - only if projectSettings is enabled
if (isSettingSourceEnabled('projectSettings') && !skipProject) {
if (!managedOnly && isSettingSourceEnabled('projectSettings') && !skipProject) {
const projectPath = join(dir, 'CLAUDE.md')
result.push(
...(await processMemoryFile(
@@ -920,7 +974,7 @@ export const getMemoryFiles = memoize(
}
// Try reading CLAUDE.local.md (Local) - only if localSettings is enabled
if (isSettingSourceEnabled('localSettings')) {
if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('localSettings')) {
const localPath = join(dir, 'CLAUDE.local.md')
result.push(
...(await processMemoryFile(
@@ -931,13 +985,16 @@ export const getMemoryFiles = memoize(
)),
)
}
if (!managedOnly && !skipProject && isSettingSourceEnabled('projectSettings') && loadAgents) {
result.push(...await processAgentsFiles(dir, processedPaths, includeExternal))
}
}
// Process CLAUDE.md from additional directories (--add-dir) if env var is enabled
// This is controlled by CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD and defaults to off
// Note: we don't check isSettingSourceEnabled('projectSettings') here because --add-dir
// is an explicit user action and the SDK defaults settingSources to [] when not specified
if (isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) {
if (!managedOnly && isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) {
const additionalDirs = getAdditionalDirectoriesForClaudeMd()
for (const dir of additionalDirs) {
// Try reading CLAUDE.md from the additional directory
@@ -973,6 +1030,11 @@ export const getMemoryFiles = memoize(
conditionalRule: false,
})),
)
// Explicit add-dir discovery uses that directory's own fallback decision.
if (mode === 'claude-md-and-agents-md' ||
(mode === 'claude-md-or-agents-md' && !await hasClaudeInstructions(dir, false, true))) {
result.push(...await processAgentsFiles(dir, processedPaths, includeExternal))
}
}
}
@@ -1006,6 +1068,8 @@ export const getMemoryFiles = memoize(
}
}
result = deduplicateAgentsFiles(result)
const totalContentLength = result.reduce(
(sum, f) => sum + f.content.length,
0,
@@ -1072,6 +1136,14 @@ export const getMemoryFiles = memoize(
return result
},
(forceIncludeExternal = false) => {
const mode = getInstructionFilesMode()
if (cachedInstructionMode !== mode) {
cachedInstructionMode = mode
clearMemoryFileCaches()
}
return `${mode}:${forceIncludeExternal}`
},
)
function isInstructionsMemoryType(
@@ -1119,6 +1191,7 @@ function consumeNextEagerLoadReason(): InstructionsLoadReason | undefined {
export function clearMemoryFileCaches(): void {
// ?.cache because tests spyOn this, which replaces the memoize wrapper.
getMemoryFiles.cache?.clear?.()
canUseAgentsFallback.cache.clear()
}
export function resetGetMemoryFilesCache(
@@ -1220,7 +1293,7 @@ export async function getManagedAndUserConditionalRules(
)),
)
if (isSettingSourceEnabled('userSettings')) {
if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('userSettings')) {
// Process User conditional .claude/rules/*.md files
const userClaudeRulesDir = getUserClaudeRulesDir()
result.push(
@@ -1251,6 +1324,7 @@ export async function getMemoryFilesForNestedDirectory(
targetPath: string,
processedPaths: Set<string>,
): Promise<MemoryFileInfo[]> {
if (getInstructionFilesMode() === 'managed-only') return []
const result: MemoryFileInfo[] = []
// Process project memory files (CLAUDE.md and .claude/CLAUDE.md)
@@ -1276,13 +1350,15 @@ export async function getMemoryFilesForNestedDirectory(
}
// Process local memory file (CLAUDE.local.md)
if (isSettingSourceEnabled('localSettings')) {
if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('localSettings')) {
const localPath = join(dir, 'CLAUDE.local.md')
result.push(
...(await processMemoryFile(localPath, 'Local', processedPaths, false)),
)
}
if (!isSettingSourceEnabled('projectSettings')) return result
const rulesDir = join(dir, '.claude', 'rules')
// Process project unconditional .claude/rules/*.md files, which were not eagerly loaded
@@ -1314,7 +1390,15 @@ export async function getMemoryFilesForNestedDirectory(
processedPaths.add(path)
}
return result
const mode = getInstructionFilesMode()
if (isSettingSourceEnabled('projectSettings') &&
(mode === 'claude-md-and-agents-md' ||
(mode === 'claude-md-or-agents-md' &&
await canUseAgentsFallback(getOriginalCwd()) && !await hasClaudeInstructions(dir)))) {
result.push(...await processAgentsFiles(dir, processedPaths, false))
}
return deduplicateAgentsFiles(result, await getMemoryFiles())
}
/**
@@ -1331,6 +1415,7 @@ export async function getConditionalRulesForCwdLevelDirectory(
targetPath: string,
processedPaths: Set<string>,
): Promise<MemoryFileInfo[]> {
if (getInstructionFilesMode() === 'managed-only' || !isSettingSourceEnabled('projectSettings')) return []
const rulesDir = join(dir, '.claude', 'rules')
return processConditionedMdRules(
targetPath,
@@ -1436,7 +1521,7 @@ export function isMemoryFilePath(filePath: string): boolean {
const name = basename(filePath)
// CLAUDE.md or CLAUDE.local.md anywhere
if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') {
if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md' || name === 'AGENTS.md') {
return true
}
+58
View File
@@ -0,0 +1,58 @@
import { getSettingsForSource } from './settings/settings.js'
import type { SettingsJson } from './settings/types.js'
export const INSTRUCTION_FILE_MODES = [
'claude-md',
'claude-md-or-agents-md',
'claude-md-and-agents-md',
'managed-only',
] as const
export type InstructionFilesMode = (typeof INSTRUCTION_FILE_MODES)[number]
export const INSTRUCTION_FILES_PLUGIN = 'agents-md@builtin'
export function normalizeInstructionFilesMode(value: unknown): InstructionFilesMode {
if (INSTRUCTION_FILE_MODES.includes(value as InstructionFilesMode)) {
return value as InstructionFilesMode
}
return 'claude-md-or-agents-md'
}
/** Sources are supplied in descending precedence. Each option inherits separately. */
export function getInstructionFilesModeFromSettings(
...sources: (SettingsJson | null | undefined)[]
): InstructionFilesMode {
const options = Object.fromEntries(sources.toReversed().flatMap(settings =>
Object.entries(settings?.pluginConfigs?.[INSTRUCTION_FILES_PLUGIN]?.options ?? {})
.filter(([, value]) => value !== undefined),
))
const mode = normalizeInstructionFilesMode(options.instructionFiles)
if (mode !== 'claude-md-or-agents-md' || options.projectInstructions === undefined) {
return mode
}
// Read-time migration matches the builtin plugin without rewriting user files.
switch (options.projectInstructions) {
case 'none': return 'managed-only'
case 'both': return 'claude-md-and-agents-md'
case 'agents-fallback': return 'claude-md-or-agents-md'
default: return 'claude-md'
}
}
export function getInstructionFilesMode(): InstructionFilesMode {
// Repository settings must not be able to switch off instructions themselves.
return getInstructionFilesModeFromSettings(
getSettingsForSource('policySettings'),
getSettingsForSource('flagSettings'),
getSettingsForSource('userSettings'),
)
}
/** Patch only this option; undefined deletes the key via settings' merge protocol. */
export function instructionFilesSettingsPatch(value: string | undefined, legacy?: string): SettingsJson {
return {
pluginConfigs: {
[INSTRUCTION_FILES_PLUGIN]: { options: { instructionFiles: value as string, projectInstructions: legacy as string } },
},
}
}
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getInstructionFilesModeFromSettings,
INSTRUCTION_FILE_MODES,
instructionFilesSettingsPatch,
} from '../../instructionFiles.js'
import { parseSettingsFile, updateSettingsForSource } from '../settings.js'
const directories: string[] = []
afterEach(async () => {
await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
async function readFixture(value: unknown) {
const dir = await mkdtemp(join(tmpdir(), 'instruction-files-'))
directories.push(dir)
await mkdir(join(dir, '.claude'))
const path = join(dir, '.claude', 'settings.json')
await writeFile(path, JSON.stringify(value))
return { settings: parseSettingsFile(path).settings, path, projectRoot: dir }
}
describe('project instruction settings compatibility', () => {
it('migrates an old settings file at read time without modifying user fields', async () => {
const fixture = { model: 'sonnet', customFutureField: { retained: true } }
const { settings, path } = await readFixture(fixture)
expect(getInstructionFilesModeFromSettings(settings)).toBe('claude-md-or-agents-md')
expect(settings).toMatchObject(fixture)
expect(JSON.parse(await readFile(path, 'utf8'))).toEqual(fixture)
})
for (const mode of INSTRUCTION_FILE_MODES) {
it(`reads official builtin plugin option ${mode}`, async () => {
const { settings } = await readFixture(instructionFilesSettingsPatch(mode))
expect(getInstructionFilesModeFromSettings(settings)).toBe(mode)
})
}
for (const [legacy, expected] of [
['none', 'managed-only'], ['claude', 'claude-md'],
['agents-fallback', 'claude-md-or-agents-md'], ['both', 'claude-md-and-agents-md'],
]) {
it(`migrates legacy projectInstructions ${legacy}`, async () => {
const { settings } = await readFixture({ pluginConfigs: { 'agents-md@builtin': { options: { projectInstructions: legacy } } }, customFutureField: true })
expect(getInstructionFilesModeFromSettings(settings)).toBe(expected)
expect(settings?.customFutureField).toBe(true)
})
}
it('writes and cancels one option while retaining sibling plugin settings', async () => {
const fixture = {
customFutureField: 'retained',
pluginConfigs: {
'agents-md@builtin': { options: { sibling: true } },
'other@plugin': { options: { option: 'preserved' } },
},
}
const { path, projectRoot } = await readFixture(fixture)
expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch('claude-md'), projectRoot).error).toBeNull()
expect(parseSettingsFile(path).settings?.pluginConfigs?.['agents-md@builtin']?.options).toEqual({ sibling: true, instructionFiles: 'claude-md' })
expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch(undefined), projectRoot).error).toBeNull()
expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject(fixture)
expect(parseSettingsFile(path).settings?.pluginConfigs?.['agents-md@builtin']?.options?.instructionFiles).toBeUndefined()
})
it('clears legacy options on selection and restores them on cancellation', async () => {
const fixture = { pluginConfigs: { 'agents-md@builtin': { options: { projectInstructions: 'both', sibling: true } } } }
const { path, projectRoot } = await readFixture(fixture)
expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch('claude-md-or-agents-md'), projectRoot).error).toBeNull()
expect(getInstructionFilesModeFromSettings(parseSettingsFile(path).settings)).toBe('claude-md-or-agents-md')
expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch(undefined, 'both'), projectRoot).error).toBeNull()
expect(getInstructionFilesModeFromSettings(parseSettingsFile(path).settings)).toBe('claude-md-and-agents-md')
expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject(fixture)
})
it('migrates legacy options after independently merging source precedence', () => {
expect(getInstructionFilesModeFromSettings(
instructionFilesSettingsPatch('claude-md-or-agents-md'),
{ pluginConfigs: { 'agents-md@builtin': { options: { projectInstructions: 'both' } } } },
)).toBe('claude-md-and-agents-md')
expect(getInstructionFilesModeFromSettings(instructionFilesSettingsPatch('unknown', 'unknown'))).toBe('claude-md')
expect(getInstructionFilesModeFromSettings(instructionFilesSettingsPatch('both'))).toBe('claude-md-or-agents-md')
expect(getInstructionFilesModeFromSettings({ projectInstructions: 'none' })).toBe('claude-md-or-agents-md')
})
it('inherits absent options and honors descending source precedence', () => {
expect(getInstructionFilesModeFromSettings({}, instructionFilesSettingsPatch('claude-md'))).toBe('claude-md')
expect(getInstructionFilesModeFromSettings(
instructionFilesSettingsPatch('managed-only'), instructionFilesSettingsPatch('claude-md'),
)).toBe('managed-only')
expect(getInstructionFilesModeFromSettings({
...instructionFilesSettingsPatch('claude-md', 'both'),
})).toBe('claude-md')
})
})