Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ bin/

# Local prompts and rules
/local-prompts
AGENTS.local.md

# Test environment
.test_env
Expand Down
132 changes: 132 additions & 0 deletions src/core/prompts/sections/__tests__/custom-instructions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1587,4 +1587,136 @@ describe("Rules directory reading", () => {
const result = await loadRuleFiles("/fake/path")
expect(result).toBe("\n# Rules from .roorules:\nfallback content\n")
})

it("should load AGENTS.local.md alongside AGENTS.md for personal overrides", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })

// Mock lstat to indicate both AGENTS.md and AGENTS.local.md exist (not symlinks)
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md") || pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})

readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve("Local overrides from AGENTS.local.md")
}
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("Base rules from AGENTS.md")
}
return Promise.reject({ code: "ENOENT" })
})

const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)

// Should contain both AGENTS.md and AGENTS.local.md content
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Base rules from AGENTS.md")
expect(result).toContain("# Agent Rules Local (AGENTS.local.md):")
expect(result).toContain("Local overrides from AGENTS.local.md")
})

it("should load AGENTS.local.md even when base AGENTS.md does not exist", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })

// Mock lstat to indicate only AGENTS.local.md exists (no base file)
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})

readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve("Local overrides without base file")
}
return Promise.reject({ code: "ENOENT" })
})

const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)

// Should contain AGENTS.local.md content even without base AGENTS.md
expect(result).toContain("# Agent Rules Local (AGENTS.local.md):")
expect(result).toContain("Local overrides without base file")
})

it("should load AGENTS.md without .local.md when local file does not exist", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })

// Mock lstat to indicate only AGENTS.md exists (no local override)
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})

readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("Base rules from AGENTS.md only")
}
return Promise.reject({ code: "ENOENT" })
})

const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)

// Should contain only AGENTS.md content
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Base rules from AGENTS.md only")
expect(result).not.toContain("AGENTS.local.md")
})
})
93 changes: 64 additions & 29 deletions src/core/prompts/sections/custom-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,48 @@ export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean =
return ""
}

/**
* Read content from an agent rules file (AGENTS.md, AGENT.md, etc.)
* Handles symlink resolution.
*
* @param filePath - Full path to the agent rules file
* @returns File content or empty string if file doesn't exist
*/
async function readAgentRulesFile(filePath: string): Promise<string> {
let resolvedPath = filePath

// Check if file exists and handle symlinks
try {
const stats = await fs.lstat(filePath)
if (stats.isSymbolicLink()) {
// Create a temporary fileInfo array to use with resolveSymLink
const fileInfo: Array<{
originalPath: string
resolvedPath: string
}> = []

// Use the existing resolveSymLink function to handle symlink resolution
await resolveSymLink(filePath, fileInfo, 0)

// Extract the resolved path from fileInfo
if (fileInfo.length > 0) {
resolvedPath = fileInfo[0].resolvedPath
}
}
} catch (err) {
// If lstat fails (file doesn't exist), return empty
return ""
}

// Read the content from the resolved path
return safeReadFile(resolvedPath)
}

/**
* Load AGENTS.md or AGENT.md file from a specific directory
* Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
* Also loads AGENTS.local.md for personal overrides (not checked in to version control)
* AGENTS.local.md can be loaded even if AGENTS.md doesn't exist
*
* @param directory - Directory to check for AGENTS.md
* @param showPath - Whether to include the directory path in the header
Expand All @@ -253,50 +292,46 @@ async function loadAgentRulesFileFromDirectory(
): Promise<string> {
// Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative)
const filenames = ["AGENTS.md", "AGENT.md"]
const results: string[] = []
const displayPath = cwd ? path.relative(cwd, directory) : directory

for (const filename of filenames) {
try {
const agentPath = path.join(directory, filename)
let resolvedPath = agentPath

// Check if file exists and handle symlinks
try {
const stats = await fs.lstat(agentPath)
if (stats.isSymbolicLink()) {
// Create a temporary fileInfo array to use with resolveSymLink
const fileInfo: Array<{
originalPath: string
resolvedPath: string
}> = []

// Use the existing resolveSymLink function to handle symlink resolution
await resolveSymLink(agentPath, fileInfo, 0)

// Extract the resolved path from fileInfo
if (fileInfo.length > 0) {
resolvedPath = fileInfo[0].resolvedPath
}
}
} catch (err) {
// If lstat fails (file doesn't exist), try next filename
continue
}
const content = await readAgentRulesFile(agentPath)

// Read the content from the resolved path
const content = await safeReadFile(resolvedPath)
if (content) {
// Compute relative path for display if cwd is provided
const displayPath = cwd ? path.relative(cwd, directory) : directory
const header = showPath
? `# Agent Rules Standard (${filename}) from ${displayPath}:`
: `# Agent Rules Standard (${filename}):`
return `${header}\n${content}`
results.push(`${header}\n${content}`)

// Found a standard file, don't check alternative
break
}
} catch (err) {
// Silently ignore errors - agent rules files are optional
}
}
return ""

// Always try to load AGENTS.local.md for personal overrides (even if AGENTS.md doesn't exist)
try {
const localFilename = "AGENTS.local.md"
const localPath = path.join(directory, localFilename)
const localContent = await readAgentRulesFile(localPath)

if (localContent) {
const localHeader = showPath
? `# Agent Rules Local (${localFilename}) from ${displayPath}:`
: `# Agent Rules Local (${localFilename}):`
results.push(`${localHeader}\n${localContent}`)
}
} catch (err) {
// Silently ignore errors - local agent rules file is optional
}

return results.join("\n\n")
}

/**
Expand Down
Loading