Просмотр исходного кода

feat: add support for loading rules from global and project-local .roo directories (#5016)

Co-authored-by: Daniel Riccio <[email protected]>
Sam Hoang Van 6 месяцев назад
Родитель
Сommit
c8b92e0789

+ 230 - 0
src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts

@@ -0,0 +1,230 @@
+import * as path from "path"
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+
+// Use vi.hoisted to ensure mocks are available during hoisting
+const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } =
+	vi.hoisted(() => ({
+		mockHomedir: vi.fn(),
+		mockStat: vi.fn(),
+		mockReadFile: vi.fn(),
+		mockReaddir: vi.fn(),
+		mockGetRooDirectoriesForCwd: vi.fn(),
+		mockGetGlobalRooDirectory: vi.fn(),
+	}))
+
+// Mock os module
+vi.mock("os", () => ({
+	default: {
+		homedir: mockHomedir,
+	},
+	homedir: mockHomedir,
+}))
+
+// Mock fs/promises
+vi.mock("fs/promises", () => ({
+	default: {
+		stat: mockStat,
+		readFile: mockReadFile,
+		readdir: mockReaddir,
+	},
+}))
+
+// Mock the roo-config service
+vi.mock("../../../../services/roo-config", () => ({
+	getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd,
+	getGlobalRooDirectory: mockGetGlobalRooDirectory,
+}))
+
+import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
+
+describe("custom-instructions global .roo support", () => {
+	const mockCwd = "/mock/project"
+	const mockHomeDir = "/mock/home"
+	const globalRooDir = path.join(mockHomeDir, ".roo")
+	const projectRooDir = path.join(mockCwd, ".roo")
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+		mockHomedir.mockReturnValue(mockHomeDir)
+		mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir])
+		mockGetGlobalRooDirectory.mockReturnValue(globalRooDir)
+	})
+
+	afterEach(() => {
+		vi.restoreAllMocks()
+	})
+
+	describe("loadRuleFiles", () => {
+		it("should load global rules only when project rules do not exist", async () => {
+			// Mock directory existence checks in order:
+			// 1. Check if global rules dir exists
+			// 2. Check if project rules dir doesn't exist
+			mockStat
+				.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
+				.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check inside readTextFilesFromDirectory
+				.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
+
+			// Mock directory reading for global rules
+			mockReaddir.mockResolvedValueOnce([
+				{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
+			])
+
+			// Mock file reading for the rules.md file
+			mockReadFile.mockResolvedValueOnce("global rule content")
+
+			const result = await loadRuleFiles(mockCwd)
+
+			expect(result).toContain("# Rules from")
+			expect(result).toContain("rules.md:")
+			expect(result).toContain("global rule content")
+			expect(result).not.toContain("project rule content")
+		})
+
+		it("should load project rules only when global rules do not exist", async () => {
+			// Mock directory existence
+			mockStat
+				.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
+				.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
+
+			// Mock directory reading for project rules
+			mockReaddir.mockResolvedValueOnce([
+				{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
+			])
+
+			// Mock file reading
+			mockStat.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check
+			mockReadFile.mockResolvedValueOnce("project rule content")
+
+			const result = await loadRuleFiles(mockCwd)
+
+			expect(result).toContain("# Rules from")
+			expect(result).toContain("rules.md:")
+			expect(result).toContain("project rule content")
+			expect(result).not.toContain("global rule content")
+		})
+
+		it("should merge global and project rules with project rules after global", async () => {
+			// Mock directory existence - both exist
+			mockStat
+				.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
+				.mockResolvedValueOnce({ isFile: () => true } as any) // global file check
+				.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
+				.mockResolvedValueOnce({ isFile: () => true } as any) // project file check
+
+			// Mock directory reading
+			mockReaddir
+				.mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any])
+				.mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any])
+
+			// Mock file reading
+			mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content")
+
+			const result = await loadRuleFiles(mockCwd)
+
+			expect(result).toContain("# Rules from")
+			expect(result).toContain("global.md:")
+			expect(result).toContain("global rule content")
+			expect(result).toContain("project.md:")
+			expect(result).toContain("project rule content")
+
+			// Ensure project rules come after global rules
+			const globalIndex = result.indexOf("global rule content")
+			const projectIndex = result.indexOf("project rule content")
+			expect(globalIndex).toBeLessThan(projectIndex)
+		})
+
+		it("should fall back to legacy .roorules file when no .roo/rules directories exist", async () => {
+			// Mock directory existence - neither exist
+			mockStat
+				.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
+				.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
+
+			// Mock legacy file reading
+			mockReadFile.mockResolvedValueOnce("legacy rule content")
+
+			const result = await loadRuleFiles(mockCwd)
+
+			expect(result).toContain("# Rules from .roorules:")
+			expect(result).toContain("legacy rule content")
+		})
+
+		it("should return empty string when no rules exist anywhere", async () => {
+			// Mock directory existence - neither exist
+			mockStat
+				.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
+				.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
+
+			// Mock legacy file reading - both fail (using safeReadFile which catches errors)
+			// The safeReadFile function catches ENOENT errors and returns empty string
+			// So we don't need to mock rejections, just empty responses
+			mockReadFile
+				.mockResolvedValueOnce("") // .roorules returns empty (simulating ENOENT caught by safeReadFile)
+				.mockResolvedValueOnce("") // .clinerules returns empty (simulating ENOENT caught by safeReadFile)
+
+			const result = await loadRuleFiles(mockCwd)
+
+			expect(result).toBe("")
+		})
+	})
+
+	describe("addCustomInstructions mode-specific rules", () => {
+		it("should load global and project mode-specific rules", async () => {
+			const mode = "code"
+
+			// Mock directory existence for mode-specific rules
+			mockStat
+				.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules-code dir exists
+				.mockResolvedValueOnce({ isFile: () => true } as any) // global mode file check
+				.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules-code dir exists
+				.mockResolvedValueOnce({ isFile: () => true } as any) // project mode file check
+				.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist (for generic rules)
+				.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist (for generic rules)
+
+			// Mock directory reading for mode-specific rules
+			mockReaddir
+				.mockResolvedValueOnce([
+					{ name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
+				])
+				.mockResolvedValueOnce([
+					{ name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
+				])
+
+			// Mock file reading for mode-specific rules
+			mockReadFile
+				.mockResolvedValueOnce("global mode rule content")
+				.mockResolvedValueOnce("project mode rule content")
+				.mockResolvedValueOnce("") // .roorules legacy file (empty)
+				.mockResolvedValueOnce("") // .clinerules legacy file (empty)
+
+			const result = await addCustomInstructions("", "", mockCwd, mode)
+
+			expect(result).toContain("# Rules from")
+			expect(result).toContain("global-mode.md:")
+			expect(result).toContain("global mode rule content")
+			expect(result).toContain("project-mode.md:")
+			expect(result).toContain("project mode rule content")
+		})
+
+		it("should fall back to legacy mode-specific files when no mode directories exist", async () => {
+			const mode = "code"
+
+			// Mock directory existence - mode-specific dirs don't exist
+			mockStat
+				.mockRejectedValueOnce(new Error("ENOENT")) // global rules-code dir doesn't exist
+				.mockRejectedValueOnce(new Error("ENOENT")) // project rules-code dir doesn't exist
+				.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
+				.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
+
+			// Mock legacy mode file reading
+			mockReadFile
+				.mockResolvedValueOnce("legacy mode rule content") // .roorules-code
+				.mockResolvedValueOnce("") // generic .roorules (empty)
+				.mockResolvedValueOnce("") // generic .clinerules (empty)
+
+			const result = await addCustomInstructions("", "", mockCwd, mode)
+
+			expect(result).toContain("# Rules from .roorules-code:")
+			expect(result).toContain("legacy mode rule content")
+		})
+	})
+})

+ 66 - 0
src/core/prompts/sections/__tests__/custom-instructions-path-detection.spec.ts

@@ -0,0 +1,66 @@
+import { describe, it, expect, vi } from "vitest"
+import * as os from "os"
+import * as path from "path"
+
+describe("custom-instructions path detection", () => {
+	it("should use exact path comparison instead of string includes", () => {
+		// Test the logic that our fix implements
+		const fakeHomeDir = "/Users/john.roo.smith"
+		const globalRooDir = path.join(fakeHomeDir, ".roo") // "/Users/john.roo.smith/.roo"
+		const projectRooDir = "/projects/my-project/.roo"
+
+		// Old implementation (fragile):
+		// const isGlobal = rooDir.includes(path.join(os.homedir(), ".roo"))
+		// This could fail if the home directory path contains ".roo" elsewhere
+
+		// New implementation (robust):
+		// const isGlobal = path.resolve(rooDir) === path.resolve(getGlobalRooDirectory())
+
+		// Test the new logic
+		const isGlobalForGlobalDir = path.resolve(globalRooDir) === path.resolve(globalRooDir)
+		const isGlobalForProjectDir = path.resolve(projectRooDir) === path.resolve(globalRooDir)
+
+		expect(isGlobalForGlobalDir).toBe(true)
+		expect(isGlobalForProjectDir).toBe(false)
+
+		// Verify that the old implementation would have been problematic
+		// if the home directory contained ".roo" in the path
+		const oldLogicGlobal = globalRooDir.includes(path.join(fakeHomeDir, ".roo"))
+		const oldLogicProject = projectRooDir.includes(path.join(fakeHomeDir, ".roo"))
+
+		expect(oldLogicGlobal).toBe(true) // This works
+		expect(oldLogicProject).toBe(false) // This also works, but is fragile
+
+		// The issue was that if the home directory path itself contained ".roo",
+		// the includes() check could produce false positives in edge cases
+	})
+
+	it("should handle edge cases with path resolution", () => {
+		// Test various edge cases that exact path comparison handles better
+		const testCases = [
+			{
+				global: "/Users/test/.roo",
+				project: "/Users/test/project/.roo",
+				expected: { global: true, project: false },
+			},
+			{
+				global: "/home/user/.roo",
+				project: "/home/user/.roo", // Same directory
+				expected: { global: true, project: true },
+			},
+			{
+				global: "/Users/john.roo.smith/.roo",
+				project: "/projects/app/.roo",
+				expected: { global: true, project: false },
+			},
+		]
+
+		testCases.forEach(({ global, project, expected }) => {
+			const isGlobalForGlobal = path.resolve(global) === path.resolve(global)
+			const isGlobalForProject = path.resolve(project) === path.resolve(global)
+
+			expect(isGlobalForGlobal).toBe(expected.global)
+			expect(isGlobalForProject).toBe(expected.project)
+		})
+	})
+})

+ 45 - 25
src/core/prompts/sections/custom-instructions.ts

@@ -1,10 +1,12 @@
 import fs from "fs/promises"
 import fs from "fs/promises"
 import path from "path"
 import path from "path"
+import * as os from "os"
 import { Dirent } from "fs"
 import { Dirent } from "fs"
 
 
 import { isLanguage } from "@roo-code/types"
 import { isLanguage } from "@roo-code/types"
 
 
 import { LANGUAGES } from "../../../shared/language"
 import { LANGUAGES } from "../../../shared/language"
+import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config"
 
 
 /**
 /**
  * Safely read a file and return its trimmed content
  * Safely read a file and return its trimmed content
@@ -144,30 +146,39 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
 function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
 function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
 	if (files.length === 0) return ""
 	if (files.length === 0) return ""
 
 
-	return (
-		"\n\n" +
-		files
-			.map((file) => {
-				return `# Rules from ${file.filename}:\n${file.content}`
-			})
-			.join("\n\n")
-	)
+	return files
+		.map((file) => {
+			return `# Rules from ${file.filename}:\n${file.content}`
+		})
+		.join("\n\n")
 }
 }
 
 
 /**
 /**
- * Load rule files from the specified directory
+ * Load rule files from global and project-local directories
+ * Global rules are loaded first, then project-local rules which can override global ones
  */
  */
 export async function loadRuleFiles(cwd: string): Promise<string> {
 export async function loadRuleFiles(cwd: string): Promise<string> {
-	// Check for .roo/rules/ directory
-	const rooRulesDir = path.join(cwd, ".roo", "rules")
-	if (await directoryExists(rooRulesDir)) {
-		const files = await readTextFilesFromDirectory(rooRulesDir)
-		if (files.length > 0) {
-			return formatDirectoryContent(rooRulesDir, files)
+	const rules: string[] = []
+	const rooDirectories = getRooDirectoriesForCwd(cwd)
+
+	// Check for .roo/rules/ directories in order (global first, then project-local)
+	for (const rooDir of rooDirectories) {
+		const rulesDir = path.join(rooDir, "rules")
+		if (await directoryExists(rulesDir)) {
+			const files = await readTextFilesFromDirectory(rulesDir)
+			if (files.length > 0) {
+				const content = formatDirectoryContent(rulesDir, files)
+				rules.push(content)
+			}
 		}
 		}
 	}
 	}
 
 
-	// Fall back to existing behavior
+	// If we found rules in .roo/rules/ directories, return them
+	if (rules.length > 0) {
+		return "\n" + rules.join("\n\n")
+	}
+
+	// Fall back to existing behavior for legacy .roorules/.clinerules files
 	const ruleFiles = [".roorules", ".clinerules"]
 	const ruleFiles = [".roorules", ".clinerules"]
 
 
 	for (const file of ruleFiles) {
 	for (const file of ruleFiles) {
@@ -194,18 +205,27 @@ export async function addCustomInstructions(
 	let usedRuleFile = ""
 	let usedRuleFile = ""
 
 
 	if (mode) {
 	if (mode) {
-		// Check for .roo/rules-${mode}/ directory
-		const modeRulesDir = path.join(cwd, ".roo", `rules-${mode}`)
-		if (await directoryExists(modeRulesDir)) {
-			const files = await readTextFilesFromDirectory(modeRulesDir)
-			if (files.length > 0) {
-				modeRuleContent = formatDirectoryContent(modeRulesDir, files)
-				usedRuleFile = modeRulesDir
+		const modeRules: string[] = []
+		const rooDirectories = getRooDirectoriesForCwd(cwd)
+
+		// Check for .roo/rules-${mode}/ directories in order (global first, then project-local)
+		for (const rooDir of rooDirectories) {
+			const modeRulesDir = path.join(rooDir, `rules-${mode}`)
+			if (await directoryExists(modeRulesDir)) {
+				const files = await readTextFilesFromDirectory(modeRulesDir)
+				if (files.length > 0) {
+					const content = formatDirectoryContent(modeRulesDir, files)
+					modeRules.push(content)
+				}
 			}
 			}
 		}
 		}
 
 
-		// If no directory exists, fall back to existing behavior
-		if (!modeRuleContent) {
+		// If we found mode-specific rules in .roo/rules-${mode}/ directories, use them
+		if (modeRules.length > 0) {
+			modeRuleContent = "\n" + modeRules.join("\n\n")
+			usedRuleFile = `rules-${mode} directories`
+		} else {
+			// Fall back to existing behavior for legacy files
 			const rooModeRuleFile = `.roorules-${mode}`
 			const rooModeRuleFile = `.roorules-${mode}`
 			modeRuleContent = await safeReadFile(path.join(cwd, rooModeRuleFile))
 			modeRuleContent = await safeReadFile(path.join(cwd, rooModeRuleFile))
 			if (modeRuleContent) {
 			if (modeRuleContent) {

+ 301 - 0
src/services/roo-config/__tests__/index.spec.ts

@@ -0,0 +1,301 @@
+import * as path from "path"
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+
+// Use vi.hoisted to ensure mocks are available during hoisting
+const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({
+	mockStat: vi.fn(),
+	mockReadFile: vi.fn(),
+	mockHomedir: vi.fn(),
+}))
+
+// Mock fs/promises module
+vi.mock("fs/promises", () => ({
+	default: {
+		stat: mockStat,
+		readFile: mockReadFile,
+	},
+}))
+
+// Mock os module
+vi.mock("os", () => ({
+	homedir: mockHomedir,
+}))
+
+import {
+	getGlobalRooDirectory,
+	getProjectRooDirectoryForCwd,
+	directoryExists,
+	fileExists,
+	readFileIfExists,
+	getRooDirectoriesForCwd,
+	loadConfiguration,
+} from "../index"
+
+describe("RooConfigService", () => {
+	beforeEach(() => {
+		vi.clearAllMocks()
+		mockHomedir.mockReturnValue("/mock/home")
+	})
+
+	afterEach(() => {
+		vi.restoreAllMocks()
+	})
+
+	describe("getGlobalRooDirectory", () => {
+		it("should return correct path for global .roo directory", () => {
+			const result = getGlobalRooDirectory()
+			expect(result).toBe(path.join("/mock/home", ".roo"))
+		})
+
+		it("should handle different home directories", () => {
+			mockHomedir.mockReturnValue("/different/home")
+			const result = getGlobalRooDirectory()
+			expect(result).toBe(path.join("/different/home", ".roo"))
+		})
+	})
+
+	describe("getProjectRooDirectoryForCwd", () => {
+		it("should return correct path for given cwd", () => {
+			const cwd = "/custom/project/path"
+			const result = getProjectRooDirectoryForCwd(cwd)
+			expect(result).toBe(path.join(cwd, ".roo"))
+		})
+	})
+
+	describe("directoryExists", () => {
+		it("should return true for existing directory", async () => {
+			mockStat.mockResolvedValue({ isDirectory: () => true } as any)
+
+			const result = await directoryExists("/some/path")
+
+			expect(result).toBe(true)
+			expect(mockStat).toHaveBeenCalledWith("/some/path")
+		})
+
+		it("should return false for non-existing path", async () => {
+			const error = new Error("ENOENT") as any
+			error.code = "ENOENT"
+			mockStat.mockRejectedValue(error)
+
+			const result = await directoryExists("/non/existing/path")
+
+			expect(result).toBe(false)
+		})
+
+		it("should return false for ENOTDIR error", async () => {
+			const error = new Error("ENOTDIR") as any
+			error.code = "ENOTDIR"
+			mockStat.mockRejectedValue(error)
+
+			const result = await directoryExists("/not/a/directory")
+
+			expect(result).toBe(false)
+		})
+
+		it("should throw unexpected errors", async () => {
+			const error = new Error("Permission denied") as any
+			error.code = "EACCES"
+			mockStat.mockRejectedValue(error)
+
+			await expect(directoryExists("/permission/denied")).rejects.toThrow("Permission denied")
+		})
+
+		it("should return false for files", async () => {
+			mockStat.mockResolvedValue({ isDirectory: () => false } as any)
+
+			const result = await directoryExists("/some/file.txt")
+
+			expect(result).toBe(false)
+		})
+	})
+
+	describe("fileExists", () => {
+		it("should return true for existing file", async () => {
+			mockStat.mockResolvedValue({ isFile: () => true } as any)
+
+			const result = await fileExists("/some/file.txt")
+
+			expect(result).toBe(true)
+			expect(mockStat).toHaveBeenCalledWith("/some/file.txt")
+		})
+
+		it("should return false for non-existing file", async () => {
+			const error = new Error("ENOENT") as any
+			error.code = "ENOENT"
+			mockStat.mockRejectedValue(error)
+
+			const result = await fileExists("/non/existing/file.txt")
+
+			expect(result).toBe(false)
+		})
+
+		it("should return false for ENOTDIR error", async () => {
+			const error = new Error("ENOTDIR") as any
+			error.code = "ENOTDIR"
+			mockStat.mockRejectedValue(error)
+
+			const result = await fileExists("/not/a/directory/file.txt")
+
+			expect(result).toBe(false)
+		})
+
+		it("should throw unexpected errors", async () => {
+			const error = new Error("Permission denied") as any
+			error.code = "EACCES"
+			mockStat.mockRejectedValue(error)
+
+			await expect(fileExists("/permission/denied/file.txt")).rejects.toThrow("Permission denied")
+		})
+
+		it("should return false for directories", async () => {
+			mockStat.mockResolvedValue({ isFile: () => false } as any)
+
+			const result = await fileExists("/some/directory")
+
+			expect(result).toBe(false)
+		})
+	})
+
+	describe("readFileIfExists", () => {
+		it("should return file content for existing file", async () => {
+			mockReadFile.mockResolvedValue("file content")
+
+			const result = await readFileIfExists("/some/file.txt")
+
+			expect(result).toBe("file content")
+			expect(mockReadFile).toHaveBeenCalledWith("/some/file.txt", "utf-8")
+		})
+
+		it("should return null for non-existing file", async () => {
+			const error = new Error("ENOENT") as any
+			error.code = "ENOENT"
+			mockReadFile.mockRejectedValue(error)
+
+			const result = await readFileIfExists("/non/existing/file.txt")
+
+			expect(result).toBe(null)
+		})
+
+		it("should return null for ENOTDIR error", async () => {
+			const error = new Error("ENOTDIR") as any
+			error.code = "ENOTDIR"
+			mockReadFile.mockRejectedValue(error)
+
+			const result = await readFileIfExists("/not/a/directory/file.txt")
+
+			expect(result).toBe(null)
+		})
+
+		it("should return null for EISDIR error", async () => {
+			const error = new Error("EISDIR") as any
+			error.code = "EISDIR"
+			mockReadFile.mockRejectedValue(error)
+
+			const result = await readFileIfExists("/is/a/directory")
+
+			expect(result).toBe(null)
+		})
+
+		it("should throw unexpected errors", async () => {
+			const error = new Error("Permission denied") as any
+			error.code = "EACCES"
+			mockReadFile.mockRejectedValue(error)
+
+			await expect(readFileIfExists("/permission/denied/file.txt")).rejects.toThrow("Permission denied")
+		})
+	})
+
+	describe("getRooDirectoriesForCwd", () => {
+		it("should return directories for given cwd", () => {
+			const cwd = "/custom/project/path"
+
+			const result = getRooDirectoriesForCwd(cwd)
+
+			expect(result).toEqual([path.join("/mock/home", ".roo"), path.join(cwd, ".roo")])
+		})
+	})
+
+	describe("loadConfiguration", () => {
+		it("should load global configuration only when project does not exist", async () => {
+			const error = new Error("ENOENT") as any
+			error.code = "ENOENT"
+			mockReadFile.mockResolvedValueOnce("global content").mockRejectedValueOnce(error)
+
+			const result = await loadConfiguration("rules/rules.md", "/project/path")
+
+			expect(result).toEqual({
+				global: "global content",
+				project: null,
+				merged: "global content",
+			})
+		})
+
+		it("should load project configuration only when global does not exist", async () => {
+			const error = new Error("ENOENT") as any
+			error.code = "ENOENT"
+			mockReadFile.mockRejectedValueOnce(error).mockResolvedValueOnce("project content")
+
+			const result = await loadConfiguration("rules/rules.md", "/project/path")
+
+			expect(result).toEqual({
+				global: null,
+				project: "project content",
+				merged: "project content",
+			})
+		})
+
+		it("should merge global and project configurations with project overriding global", async () => {
+			mockReadFile.mockResolvedValueOnce("global content").mockResolvedValueOnce("project content")
+
+			const result = await loadConfiguration("rules/rules.md", "/project/path")
+
+			expect(result).toEqual({
+				global: "global content",
+				project: "project content",
+				merged: "global content\n\n# Project-specific rules (override global):\n\nproject content",
+			})
+		})
+
+		it("should return empty merged content when neither exists", async () => {
+			const error = new Error("ENOENT") as any
+			error.code = "ENOENT"
+			mockReadFile.mockRejectedValueOnce(error).mockRejectedValueOnce(error)
+
+			const result = await loadConfiguration("rules/rules.md", "/project/path")
+
+			expect(result).toEqual({
+				global: null,
+				project: null,
+				merged: "",
+			})
+		})
+
+		it("should propagate unexpected errors from global file read", async () => {
+			const error = new Error("Permission denied") as any
+			error.code = "EACCES"
+			mockReadFile.mockRejectedValueOnce(error)
+
+			await expect(loadConfiguration("rules/rules.md", "/project/path")).rejects.toThrow("Permission denied")
+		})
+
+		it("should propagate unexpected errors from project file read", async () => {
+			const globalError = new Error("ENOENT") as any
+			globalError.code = "ENOENT"
+			const projectError = new Error("Permission denied") as any
+			projectError.code = "EACCES"
+
+			mockReadFile.mockRejectedValueOnce(globalError).mockRejectedValueOnce(projectError)
+
+			await expect(loadConfiguration("rules/rules.md", "/project/path")).rejects.toThrow("Permission denied")
+		})
+
+		it("should use correct file paths", async () => {
+			mockReadFile.mockResolvedValue("content")
+
+			await loadConfiguration("rules/rules.md", "/project/path")
+
+			expect(mockReadFile).toHaveBeenCalledWith(path.join("/mock/home", ".roo", "rules/rules.md"), "utf-8")
+			expect(mockReadFile).toHaveBeenCalledWith(path.join("/project/path", ".roo", "rules/rules.md"), "utf-8")
+		})
+	})
+})

+ 252 - 0
src/services/roo-config/index.ts

@@ -0,0 +1,252 @@
+import * as path from "path"
+import * as os from "os"
+import fs from "fs/promises"
+
+/**
+ * Gets the global .roo directory path based on the current platform
+ *
+ * @returns The absolute path to the global .roo directory
+ *
+ * @example Platform-specific paths:
+ * ```
+ * // macOS/Linux: ~/.roo/
+ * // Example: /Users/john/.roo
+ *
+ * // Windows: %USERPROFILE%\.roo\
+ * // Example: C:\Users\john\.roo
+ * ```
+ *
+ * @example Usage:
+ * ```typescript
+ * const globalDir = getGlobalRooDirectory()
+ * // Returns: "/Users/john/.roo" (on macOS/Linux)
+ * // Returns: "C:\\Users\\john\\.roo" (on Windows)
+ * ```
+ */
+export function getGlobalRooDirectory(): string {
+	const homeDir = os.homedir()
+	return path.join(homeDir, ".roo")
+}
+
+/**
+ * Gets the project-local .roo directory path for a given cwd
+ *
+ * @param cwd - Current working directory (project path)
+ * @returns The absolute path to the project-local .roo directory
+ *
+ * @example
+ * ```typescript
+ * const projectDir = getProjectRooDirectoryForCwd('/Users/john/my-project')
+ * // Returns: "/Users/john/my-project/.roo"
+ *
+ * const windowsProjectDir = getProjectRooDirectoryForCwd('C:\\Users\\john\\my-project')
+ * // Returns: "C:\\Users\\john\\my-project\\.roo"
+ * ```
+ *
+ * @example Directory structure:
+ * ```
+ * /Users/john/my-project/
+ * ├── .roo/                    # Project-local configuration directory
+ * │   ├── rules/
+ * │   │   └── rules.md
+ * │   ├── custom-instructions.md
+ * │   └── config/
+ * │       └── settings.json
+ * ├── src/
+ * │   └── index.ts
+ * └── package.json
+ * ```
+ */
+export function getProjectRooDirectoryForCwd(cwd: string): string {
+	return path.join(cwd, ".roo")
+}
+
+/**
+ * Checks if a directory exists
+ */
+export async function directoryExists(dirPath: string): Promise<boolean> {
+	try {
+		const stat = await fs.stat(dirPath)
+		return stat.isDirectory()
+	} catch (error: any) {
+		// Only catch expected "not found" errors
+		if (error.code === "ENOENT" || error.code === "ENOTDIR") {
+			return false
+		}
+		// Re-throw unexpected errors (permission, I/O, etc.)
+		throw error
+	}
+}
+
+/**
+ * Checks if a file exists
+ */
+export async function fileExists(filePath: string): Promise<boolean> {
+	try {
+		const stat = await fs.stat(filePath)
+		return stat.isFile()
+	} catch (error: any) {
+		// Only catch expected "not found" errors
+		if (error.code === "ENOENT" || error.code === "ENOTDIR") {
+			return false
+		}
+		// Re-throw unexpected errors (permission, I/O, etc.)
+		throw error
+	}
+}
+
+/**
+ * Reads a file safely, returning null if it doesn't exist
+ */
+export async function readFileIfExists(filePath: string): Promise<string | null> {
+	try {
+		return await fs.readFile(filePath, "utf-8")
+	} catch (error: any) {
+		// Only catch expected "not found" errors
+		if (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "EISDIR") {
+			return null
+		}
+		// Re-throw unexpected errors (permission, I/O, etc.)
+		throw error
+	}
+}
+
+/**
+ * Gets the ordered list of .roo directories to check (global first, then project-local)
+ *
+ * @param cwd - Current working directory (project path)
+ * @returns Array of directory paths to check in order [global, project-local]
+ *
+ * @example
+ * ```typescript
+ * // For a project at /Users/john/my-project
+ * const directories = getRooDirectoriesForCwd('/Users/john/my-project')
+ * // Returns:
+ * // [
+ * //   '/Users/john/.roo',           // Global directory
+ * //   '/Users/john/my-project/.roo' // Project-local directory
+ * // ]
+ * ```
+ *
+ * @example Directory structure:
+ * ```
+ * /Users/john/
+ * ├── .roo/                    # Global configuration
+ * │   ├── rules/
+ * │   │   └── rules.md
+ * │   └── custom-instructions.md
+ * └── my-project/
+ *     ├── .roo/                # Project-specific configuration
+ *     │   ├── rules/
+ *     │   │   └── rules.md     # Overrides global rules
+ *     │   └── project-notes.md
+ *     └── src/
+ *         └── index.ts
+ * ```
+ */
+export function getRooDirectoriesForCwd(cwd: string): string[] {
+	const directories: string[] = []
+
+	// Add global directory first
+	directories.push(getGlobalRooDirectory())
+
+	// Add project-local directory second
+	directories.push(getProjectRooDirectoryForCwd(cwd))
+
+	return directories
+}
+
+/**
+ * Loads configuration from multiple .roo directories with project overriding global
+ *
+ * @param relativePath - The relative path within each .roo directory (e.g., 'rules/rules.md')
+ * @param cwd - Current working directory (project path)
+ * @returns Object with global and project content, plus merged content
+ *
+ * @example
+ * ```typescript
+ * // Load rules configuration for a project
+ * const config = await loadConfiguration('rules/rules.md', '/Users/john/my-project')
+ *
+ * // Returns:
+ * // {
+ * //   global: "Global rules content...",     // From ~/.roo/rules/rules.md
+ * //   project: "Project rules content...",   // From /Users/john/my-project/.roo/rules/rules.md
+ * //   merged: "Global rules content...\n\n# Project-specific rules (override global):\n\nProject rules content..."
+ * // }
+ * ```
+ *
+ * @example File paths resolved:
+ * ```
+ * relativePath: 'rules/rules.md'
+ * cwd: '/Users/john/my-project'
+ *
+ * Reads from:
+ * - Global: /Users/john/.roo/rules/rules.md
+ * - Project: /Users/john/my-project/.roo/rules/rules.md
+ *
+ * Other common relativePath examples:
+ * - 'custom-instructions.md'
+ * - 'config/settings.json'
+ * - 'templates/component.tsx'
+ * ```
+ *
+ * @example Merging behavior:
+ * ```
+ * // If only global exists:
+ * { global: "content", project: null, merged: "content" }
+ *
+ * // If only project exists:
+ * { global: null, project: "content", merged: "content" }
+ *
+ * // If both exist:
+ * {
+ *   global: "global content",
+ *   project: "project content",
+ *   merged: "global content\n\n# Project-specific rules (override global):\n\nproject content"
+ * }
+ * ```
+ */
+export async function loadConfiguration(
+	relativePath: string,
+	cwd: string,
+): Promise<{
+	global: string | null
+	project: string | null
+	merged: string
+}> {
+	const globalDir = getGlobalRooDirectory()
+	const projectDir = getProjectRooDirectoryForCwd(cwd)
+
+	const globalFilePath = path.join(globalDir, relativePath)
+	const projectFilePath = path.join(projectDir, relativePath)
+
+	// Read global configuration
+	const globalContent = await readFileIfExists(globalFilePath)
+
+	// Read project-local configuration
+	const projectContent = await readFileIfExists(projectFilePath)
+
+	// Merge configurations - project overrides global
+	let merged = ""
+
+	if (globalContent) {
+		merged += globalContent
+	}
+
+	if (projectContent) {
+		if (merged) {
+			merged += "\n\n# Project-specific rules (override global):\n\n"
+		}
+		merged += projectContent
+	}
+
+	return {
+		global: globalContent,
+		project: projectContent,
+		merged: merged || "",
+	}
+}
+
+// Export with backward compatibility alias
+export const loadRooConfiguration: typeof loadConfiguration = loadConfiguration