Skip to content

Commit 6613d77

Browse files
committed
feat: add support for AGENTS.local.md personal override files
1 parent 7f6272a commit 6613d77

File tree

3 files changed

+242
-28
lines changed

3 files changed

+242
-28
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ bin/
1818

1919
# Local prompts and rules
2020
/local-prompts
21+
AGENTS.local.md
22+
AGENT.local.md
2123

2224
# Test environment
2325
.test_env

src/core/prompts/sections/__tests__/custom-instructions.spec.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,4 +1587,186 @@ describe("Rules directory reading", () => {
15871587
const result = await loadRuleFiles("/fake/path")
15881588
expect(result).toBe("\n# Rules from .roorules:\nfallback content\n")
15891589
})
1590+
1591+
it("should load AGENTS.local.md alongside AGENTS.md for personal overrides", async () => {
1592+
// Simulate no .roo/rules-test-mode directory
1593+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1594+
1595+
// Mock lstat to indicate both AGENTS.md and AGENTS.local.md exist (not symlinks)
1596+
lstatMock.mockImplementation((filePath: PathLike) => {
1597+
const pathStr = filePath.toString()
1598+
if (pathStr.endsWith("AGENTS.md") || pathStr.endsWith("AGENTS.local.md")) {
1599+
return Promise.resolve({
1600+
isSymbolicLink: vi.fn().mockReturnValue(false),
1601+
})
1602+
}
1603+
return Promise.reject({ code: "ENOENT" })
1604+
})
1605+
1606+
readFileMock.mockImplementation((filePath: PathLike) => {
1607+
const pathStr = filePath.toString()
1608+
if (pathStr.endsWith("AGENTS.local.md")) {
1609+
return Promise.resolve("Local overrides from AGENTS.local.md")
1610+
}
1611+
if (pathStr.endsWith("AGENTS.md")) {
1612+
return Promise.resolve("Base rules from AGENTS.md")
1613+
}
1614+
return Promise.reject({ code: "ENOENT" })
1615+
})
1616+
1617+
const result = await addCustomInstructions(
1618+
"mode instructions",
1619+
"global instructions",
1620+
"/fake/path",
1621+
"test-mode",
1622+
{
1623+
settings: {
1624+
todoListEnabled: true,
1625+
useAgentRules: true,
1626+
newTaskRequireTodos: false,
1627+
},
1628+
},
1629+
)
1630+
1631+
// Should contain both AGENTS.md and AGENTS.local.md content
1632+
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
1633+
expect(result).toContain("Base rules from AGENTS.md")
1634+
expect(result).toContain("# Agent Rules Local (AGENTS.local.md):")
1635+
expect(result).toContain("Local overrides from AGENTS.local.md")
1636+
})
1637+
1638+
it("should load AGENT.local.md alongside AGENT.md when AGENTS.md is not found", async () => {
1639+
// Simulate no .roo/rules-test-mode directory
1640+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1641+
1642+
// Mock lstat to indicate AGENTS.md doesn't exist but AGENT.md and AGENT.local.md do
1643+
lstatMock.mockImplementation((filePath: PathLike) => {
1644+
const pathStr = filePath.toString()
1645+
if (pathStr.endsWith("AGENTS.md") || pathStr.endsWith("AGENTS.local.md")) {
1646+
return Promise.reject({ code: "ENOENT" })
1647+
}
1648+
if (pathStr.endsWith("AGENT.md") || pathStr.endsWith("AGENT.local.md")) {
1649+
return Promise.resolve({
1650+
isSymbolicLink: vi.fn().mockReturnValue(false),
1651+
})
1652+
}
1653+
return Promise.reject({ code: "ENOENT" })
1654+
})
1655+
1656+
readFileMock.mockImplementation((filePath: PathLike) => {
1657+
const pathStr = filePath.toString()
1658+
if (pathStr.endsWith("AGENT.local.md")) {
1659+
return Promise.resolve("Local overrides from AGENT.local.md")
1660+
}
1661+
if (pathStr.endsWith("AGENT.md")) {
1662+
return Promise.resolve("Base rules from AGENT.md")
1663+
}
1664+
return Promise.reject({ code: "ENOENT" })
1665+
})
1666+
1667+
const result = await addCustomInstructions(
1668+
"mode instructions",
1669+
"global instructions",
1670+
"/fake/path",
1671+
"test-mode",
1672+
{
1673+
settings: {
1674+
todoListEnabled: true,
1675+
useAgentRules: true,
1676+
newTaskRequireTodos: false,
1677+
},
1678+
},
1679+
)
1680+
1681+
// Should contain both AGENT.md and AGENT.local.md content
1682+
expect(result).toContain("# Agent Rules Standard (AGENT.md):")
1683+
expect(result).toContain("Base rules from AGENT.md")
1684+
expect(result).toContain("# Agent Rules Local (AGENT.local.md):")
1685+
expect(result).toContain("Local overrides from AGENT.local.md")
1686+
})
1687+
1688+
it("should not load AGENTS.local.md when base AGENTS.md does not exist", async () => {
1689+
// Simulate no .roo/rules-test-mode directory
1690+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1691+
1692+
// Mock lstat to indicate only AGENTS.local.md exists (no base file)
1693+
lstatMock.mockImplementation((filePath: PathLike) => {
1694+
const pathStr = filePath.toString()
1695+
if (pathStr.endsWith("AGENTS.local.md")) {
1696+
return Promise.resolve({
1697+
isSymbolicLink: vi.fn().mockReturnValue(false),
1698+
})
1699+
}
1700+
return Promise.reject({ code: "ENOENT" })
1701+
})
1702+
1703+
readFileMock.mockImplementation((filePath: PathLike) => {
1704+
const pathStr = filePath.toString()
1705+
if (pathStr.endsWith("AGENTS.local.md")) {
1706+
return Promise.resolve("Local overrides without base file")
1707+
}
1708+
return Promise.reject({ code: "ENOENT" })
1709+
})
1710+
1711+
const result = await addCustomInstructions(
1712+
"mode instructions",
1713+
"global instructions",
1714+
"/fake/path",
1715+
"test-mode",
1716+
{
1717+
settings: {
1718+
todoListEnabled: true,
1719+
useAgentRules: true,
1720+
newTaskRequireTodos: false,
1721+
},
1722+
},
1723+
)
1724+
1725+
// Should NOT contain AGENTS.local.md content since there's no base AGENTS.md
1726+
expect(result).not.toContain("AGENTS.local.md")
1727+
expect(result).not.toContain("Local overrides without base file")
1728+
})
1729+
1730+
it("should load AGENTS.md without .local.md when local file does not exist", async () => {
1731+
// Simulate no .roo/rules-test-mode directory
1732+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1733+
1734+
// Mock lstat to indicate only AGENTS.md exists (no local override)
1735+
lstatMock.mockImplementation((filePath: PathLike) => {
1736+
const pathStr = filePath.toString()
1737+
if (pathStr.endsWith("AGENTS.md")) {
1738+
return Promise.resolve({
1739+
isSymbolicLink: vi.fn().mockReturnValue(false),
1740+
})
1741+
}
1742+
return Promise.reject({ code: "ENOENT" })
1743+
})
1744+
1745+
readFileMock.mockImplementation((filePath: PathLike) => {
1746+
const pathStr = filePath.toString()
1747+
if (pathStr.endsWith("AGENTS.md")) {
1748+
return Promise.resolve("Base rules from AGENTS.md only")
1749+
}
1750+
return Promise.reject({ code: "ENOENT" })
1751+
})
1752+
1753+
const result = await addCustomInstructions(
1754+
"mode instructions",
1755+
"global instructions",
1756+
"/fake/path",
1757+
"test-mode",
1758+
{
1759+
settings: {
1760+
todoListEnabled: true,
1761+
useAgentRules: true,
1762+
newTaskRequireTodos: false,
1763+
},
1764+
},
1765+
)
1766+
1767+
// Should contain only AGENTS.md content
1768+
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
1769+
expect(result).toContain("Base rules from AGENTS.md only")
1770+
expect(result).not.toContain("AGENTS.local.md")
1771+
})
15901772
})

src/core/prompts/sections/custom-instructions.ts

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,47 @@ export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean =
238238
return ""
239239
}
240240

241+
/**
242+
* Read content from an agent rules file (AGENTS.md, AGENT.md, etc.)
243+
* Handles symlink resolution.
244+
*
245+
* @param filePath - Full path to the agent rules file
246+
* @returns File content or empty string if file doesn't exist
247+
*/
248+
async function readAgentRulesFile(filePath: string): Promise<string> {
249+
let resolvedPath = filePath
250+
251+
// Check if file exists and handle symlinks
252+
try {
253+
const stats = await fs.lstat(filePath)
254+
if (stats.isSymbolicLink()) {
255+
// Create a temporary fileInfo array to use with resolveSymLink
256+
const fileInfo: Array<{
257+
originalPath: string
258+
resolvedPath: string
259+
}> = []
260+
261+
// Use the existing resolveSymLink function to handle symlink resolution
262+
await resolveSymLink(filePath, fileInfo, 0)
263+
264+
// Extract the resolved path from fileInfo
265+
if (fileInfo.length > 0) {
266+
resolvedPath = fileInfo[0].resolvedPath
267+
}
268+
}
269+
} catch (err) {
270+
// If lstat fails (file doesn't exist), return empty
271+
return ""
272+
}
273+
274+
// Read the content from the resolved path
275+
return safeReadFile(resolvedPath)
276+
}
277+
241278
/**
242279
* Load AGENTS.md or AGENT.md file from a specific directory
243280
* Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
281+
* Also loads corresponding .local.md files for personal overrides
244282
*
245283
* @param directory - Directory to check for AGENTS.md
246284
* @param showPath - Whether to include the directory path in the header
@@ -253,50 +291,42 @@ async function loadAgentRulesFileFromDirectory(
253291
): Promise<string> {
254292
// Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative)
255293
const filenames = ["AGENTS.md", "AGENT.md"]
294+
const results: string[] = []
256295

257296
for (const filename of filenames) {
258297
try {
259298
const agentPath = path.join(directory, filename)
260-
let resolvedPath = agentPath
261-
262-
// Check if file exists and handle symlinks
263-
try {
264-
const stats = await fs.lstat(agentPath)
265-
if (stats.isSymbolicLink()) {
266-
// Create a temporary fileInfo array to use with resolveSymLink
267-
const fileInfo: Array<{
268-
originalPath: string
269-
resolvedPath: string
270-
}> = []
271-
272-
// Use the existing resolveSymLink function to handle symlink resolution
273-
await resolveSymLink(agentPath, fileInfo, 0)
274-
275-
// Extract the resolved path from fileInfo
276-
if (fileInfo.length > 0) {
277-
resolvedPath = fileInfo[0].resolvedPath
278-
}
279-
}
280-
} catch (err) {
281-
// If lstat fails (file doesn't exist), try next filename
282-
continue
283-
}
299+
const content = await readAgentRulesFile(agentPath)
284300

285-
// Read the content from the resolved path
286-
const content = await safeReadFile(resolvedPath)
287301
if (content) {
288302
// Compute relative path for display if cwd is provided
289303
const displayPath = cwd ? path.relative(cwd, directory) : directory
290304
const header = showPath
291305
? `# Agent Rules Standard (${filename}) from ${displayPath}:`
292306
: `# Agent Rules Standard (${filename}):`
293-
return `${header}\n${content}`
307+
results.push(`${header}\n${content}`)
308+
309+
// Also try to load the corresponding .local.md file for personal overrides
310+
const localFilename = filename.replace(".md", ".local.md")
311+
const localPath = path.join(directory, localFilename)
312+
const localContent = await readAgentRulesFile(localPath)
313+
314+
if (localContent) {
315+
const localHeader = showPath
316+
? `# Agent Rules Local (${localFilename}) from ${displayPath}:`
317+
: `# Agent Rules Local (${localFilename}):`
318+
results.push(`${localHeader}\n${localContent}`)
319+
}
320+
321+
// Found a standard file (and optionally its local override), don't check alternative
322+
break
294323
}
295324
} catch (err) {
296325
// Silently ignore errors - agent rules files are optional
297326
}
298327
}
299-
return ""
328+
329+
return results.join("\n\n")
300330
}
301331

302332
/**

0 commit comments

Comments
 (0)