Browse Source

feat: recursively load .roo/rules and AGENTS.md from subdirectories (#10446)

Co-authored-by: Roo Code <[email protected]>
Matt Rubens 2 months ago
parent
commit
08c3431fc5
36 changed files with 737 additions and 129 deletions
  1. 1 0
      packages/types/src/global-settings.ts
  2. 64 15
      src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts
  3. 81 90
      src/core/prompts/sections/__tests__/custom-instructions.spec.ts
  4. 113 23
      src/core/prompts/sections/custom-instructions.ts
  5. 2 0
      src/core/prompts/types.ts
  6. 2 0
      src/core/task/Task.ts
  7. 3 0
      src/core/webview/ClineProvider.ts
  8. 1 0
      src/core/webview/__tests__/ClineProvider.spec.ts
  9. 2 0
      src/core/webview/generateSystemPrompt.ts
  10. 194 1
      src/services/roo-config/__tests__/index.spec.ts
  11. 148 0
      src/services/roo-config/index.ts
  12. 1 0
      src/shared/ExtensionMessage.ts
  13. 25 0
      src/shared/string-extensions.d.ts
  14. 17 0
      webview-ui/src/components/settings/ContextManagementSettings.tsx
  15. 3 0
      webview-ui/src/components/settings/SettingsView.tsx
  16. 3 0
      webview-ui/src/context/ExtensionStateContext.tsx
  17. 1 0
      webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
  18. 4 0
      webview-ui/src/i18n/locales/ca/settings.json
  19. 4 0
      webview-ui/src/i18n/locales/de/settings.json
  20. 4 0
      webview-ui/src/i18n/locales/en/settings.json
  21. 4 0
      webview-ui/src/i18n/locales/es/settings.json
  22. 4 0
      webview-ui/src/i18n/locales/fr/settings.json
  23. 4 0
      webview-ui/src/i18n/locales/hi/settings.json
  24. 4 0
      webview-ui/src/i18n/locales/id/settings.json
  25. 4 0
      webview-ui/src/i18n/locales/it/settings.json
  26. 4 0
      webview-ui/src/i18n/locales/ja/settings.json
  27. 4 0
      webview-ui/src/i18n/locales/ko/settings.json
  28. 4 0
      webview-ui/src/i18n/locales/nl/settings.json
  29. 4 0
      webview-ui/src/i18n/locales/pl/settings.json
  30. 4 0
      webview-ui/src/i18n/locales/pt-BR/settings.json
  31. 4 0
      webview-ui/src/i18n/locales/ru/settings.json
  32. 4 0
      webview-ui/src/i18n/locales/tr/settings.json
  33. 4 0
      webview-ui/src/i18n/locales/vi/settings.json
  34. 4 0
      webview-ui/src/i18n/locales/zh-CN/settings.json
  35. 4 0
      webview-ui/src/i18n/locales/zh-TW/settings.json
  36. 4 0
      webview-ui/vite.config.ts

+ 1 - 0
packages/types/src/global-settings.ts

@@ -143,6 +143,7 @@ export const globalSettingsSchema = z.object({
 	maxOpenTabsContext: z.number().optional(),
 	maxWorkspaceFiles: z.number().optional(),
 	showRooIgnoredFiles: z.boolean().optional(),
+	enableSubfolderRules: z.boolean().optional(),
 	maxReadFileLine: z.number().optional(),
 	maxImageFileSize: z.number().optional(),
 	maxTotalImageSize: z.number().optional(),

+ 64 - 15
src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts

@@ -1,15 +1,27 @@
 import * as path from "path"
 
 // 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(),
-	}))
+const {
+	mockHomedir,
+	mockStat,
+	mockReadFile,
+	mockReaddir,
+	mockLstat,
+	mockGetRooDirectoriesForCwd,
+	mockGetAllRooDirectoriesForCwd,
+	mockGetAgentsDirectoriesForCwd,
+	mockGetGlobalRooDirectory,
+} = vi.hoisted(() => ({
+	mockHomedir: vi.fn(),
+	mockStat: vi.fn(),
+	mockReadFile: vi.fn(),
+	mockReaddir: vi.fn(),
+	mockLstat: vi.fn(),
+	mockGetRooDirectoriesForCwd: vi.fn(),
+	mockGetAllRooDirectoriesForCwd: vi.fn(),
+	mockGetAgentsDirectoriesForCwd: vi.fn(),
+	mockGetGlobalRooDirectory: vi.fn(),
+}))
 
 // Mock os module
 vi.mock("os", () => ({
@@ -25,12 +37,15 @@ vi.mock("fs/promises", () => ({
 		stat: mockStat,
 		readFile: mockReadFile,
 		readdir: mockReaddir,
+		lstat: mockLstat,
 	},
 }))
 
 // Mock the roo-config service
 vi.mock("../../../../services/roo-config", () => ({
 	getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd,
+	getAllRooDirectoriesForCwd: mockGetAllRooDirectoriesForCwd,
+	getAgentsDirectoriesForCwd: mockGetAgentsDirectoriesForCwd,
 	getGlobalRooDirectory: mockGetGlobalRooDirectory,
 }))
 
@@ -46,7 +61,13 @@ describe("custom-instructions global .roo support", () => {
 		vi.clearAllMocks()
 		mockHomedir.mockReturnValue(mockHomeDir)
 		mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir])
+		// getAllRooDirectoriesForCwd is now async and returns the same directories by default
+		mockGetAllRooDirectoriesForCwd.mockResolvedValue([globalRooDir, projectRooDir])
+		// getAgentsDirectoriesForCwd returns parent directories (without .roo)
+		mockGetAgentsDirectoriesForCwd.mockResolvedValue([mockCwd])
 		mockGetGlobalRooDirectory.mockReturnValue(globalRooDir)
+		// Default lstat to reject (file not found)
+		mockLstat.mockRejectedValue(new Error("ENOENT"))
 	})
 
 	afterEach(() => {
@@ -65,7 +86,11 @@ describe("custom-instructions global .roo support", () => {
 
 			// Mock directory reading for global rules
 			mockReaddir.mockResolvedValueOnce([
-				{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
+				{
+					name: "rules.md",
+					isFile: () => true,
+					isSymbolicLink: () => false,
+				} as any,
 			])
 
 			// Mock file reading for the rules.md file
@@ -87,7 +112,11 @@ describe("custom-instructions global .roo support", () => {
 
 			// Mock directory reading for project rules
 			mockReaddir.mockResolvedValueOnce([
-				{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
+				{
+					name: "rules.md",
+					isFile: () => true,
+					isSymbolicLink: () => false,
+				} as any,
 			])
 
 			// Mock file reading
@@ -112,8 +141,20 @@ describe("custom-instructions global .roo support", () => {
 
 			// Mock directory reading
 			mockReaddir
-				.mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any])
-				.mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any])
+				.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")
@@ -182,10 +223,18 @@ describe("custom-instructions global .roo support", () => {
 			// Mock directory reading for mode-specific rules
 			mockReaddir
 				.mockResolvedValueOnce([
-					{ name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
+					{
+						name: "global-mode.md",
+						isFile: () => true,
+						isSymbolicLink: () => false,
+					} as any,
 				])
 				.mockResolvedValueOnce([
-					{ name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
+					{
+						name: "project-mode.md",
+						isFile: () => true,
+						isSymbolicLink: () => false,
+					} as any,
 				])
 
 			// Mock file reading for mode-specific rules

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

@@ -36,7 +36,16 @@ vi.mock("path", async () => ({
 			.map((arg) => arg.toString().replace(/[/\\]+/g, separator))
 		return cleanArgs.join(separator)
 	}),
-	relative: vi.fn().mockImplementation((from, to) => to),
+	relative: vi.fn().mockImplementation((from, to) => {
+		// Simple relative path computation for test scenarios
+		const separator = process.platform === "win32" ? "\\" : "/"
+		const normalizedFrom = from.replace(/[/\\]+$/, "") // Remove trailing slashes
+		const normalizedTo = to.replace(/[/\\]+/g, separator)
+		if (normalizedTo.startsWith(normalizedFrom + separator)) {
+			return normalizedTo.slice(normalizedFrom.length + 1)
+		}
+		return to
+	}),
 	dirname: vi.fn().mockImplementation((path) => {
 		const separator = process.platform === "win32" ? "\\" : "/"
 		const parts = path.split(/[/\\]/)
@@ -200,16 +209,16 @@ describe("loadRuleFiles", () => {
 		})
 
 		const result = await loadRuleFiles("/fake/path")
-		const expectedPath1 =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt"
-		const expectedPath2 =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt"
-		expect(result).toContain(`# Rules from ${expectedPath1}:`)
+		// Paths in output should be relative to cwd
+		const expectedRelativePath1 = process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt"
+		const expectedRelativePath2 = process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt"
+		expect(result).toContain(`# Rules from ${expectedRelativePath1}:`)
 		expect(result).toContain("content of file1")
-		expect(result).toContain(`# Rules from ${expectedPath2}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativePath2}:`)
 		expect(result).toContain("content of file2")
 
 		// We expect both checks because our new implementation checks the files again for validation
+		// These are the absolute paths used internally
 		const expectedRulesDir = process.platform === "win32" ? "\\fake\\path\\.roo\\rules" : "/fake/path/.roo/rules"
 		const expectedFile1Path =
 			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt"
@@ -436,28 +445,25 @@ describe("loadRuleFiles", () => {
 
 		const result = await loadRuleFiles("/fake/path")
 
-		// Check root file content
-		const expectedRootPath =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt"
-		const expectedNested1Path =
+		// Check root file content - paths in output should be relative
+		const expectedRelativeRootPath = process.platform === "win32" ? ".roo\\rules\\root.txt" : ".roo/rules/root.txt"
+		const expectedRelativeNested1Path =
+			process.platform === "win32" ? ".roo\\rules\\subdir\\nested1.txt" : ".roo/rules/subdir/nested1.txt"
+		const expectedRelativeNested2Path =
 			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules\\subdir\\nested1.txt"
-				: "/fake/path/.roo/rules/subdir/nested1.txt"
-		const expectedNested2Path =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules\\subdir\\subdir2\\nested2.txt"
-				: "/fake/path/.roo/rules/subdir/subdir2/nested2.txt"
+				? ".roo\\rules\\subdir\\subdir2\\nested2.txt"
+				: ".roo/rules/subdir/subdir2/nested2.txt"
 
-		expect(result).toContain(`# Rules from ${expectedRootPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeRootPath}:`)
 		expect(result).toContain("root file content")
 
 		// Check nested files content
-		expect(result).toContain(`# Rules from ${expectedNested1Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeNested1Path}:`)
 		expect(result).toContain("nested file 1 content")
-		expect(result).toContain(`# Rules from ${expectedNested2Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeNested2Path}:`)
 		expect(result).toContain("nested file 2 content")
 
-		// Verify correct paths were checked
+		// Verify correct absolute paths were checked internally
 		const expectedRootPath2 =
 			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt"
 		const expectedNested1Path2 =
@@ -1053,39 +1059,34 @@ describe("addCustomInstructions", () => {
 			{ language: "es" },
 		)
 
-		const expectedTestModeDir =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode"
-		const expectedRule1Path =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt"
-				: "/fake/path/.roo/rules-test-mode/rule1.txt"
-		const expectedRule2Path =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt"
-				: "/fake/path/.roo/rules-test-mode/rule2.txt"
+		// Paths in output should be relative
+		const expectedRelativeRule1Path =
+			process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt"
+		const expectedRelativeRule2Path =
+			process.platform === "win32" ? ".roo\\rules-test-mode\\rule2.txt" : ".roo/rules-test-mode/rule2.txt"
 
-		expect(result).toContain(`# Rules from ${expectedTestModeDir}`)
-		expect(result).toContain(`# Rules from ${expectedRule1Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`)
 		expect(result).toContain("mode specific rule 1")
-		expect(result).toContain(`# Rules from ${expectedRule2Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeRule2Path}:`)
 		expect(result).toContain("mode specific rule 2")
 
-		const expectedTestModeDir2 =
+		// Verify absolute paths were used internally
+		const expectedAbsTestModeDir =
 			process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode"
-		const expectedRule1Path2 =
+		const expectedAbsRule1Path =
 			process.platform === "win32"
 				? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt"
 				: "/fake/path/.roo/rules-test-mode/rule1.txt"
-		const expectedRule2Path2 =
+		const expectedAbsRule2Path =
 			process.platform === "win32"
 				? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt"
 				: "/fake/path/.roo/rules-test-mode/rule2.txt"
 
-		expect(statMock).toHaveBeenCalledWith(expectedTestModeDir2)
-		expect(statMock).toHaveBeenCalledWith(expectedRule1Path2)
-		expect(statMock).toHaveBeenCalledWith(expectedRule2Path2)
-		expect(readFileMock).toHaveBeenCalledWith(expectedRule1Path2, "utf-8")
-		expect(readFileMock).toHaveBeenCalledWith(expectedRule2Path2, "utf-8")
+		expect(statMock).toHaveBeenCalledWith(expectedAbsTestModeDir)
+		expect(statMock).toHaveBeenCalledWith(expectedAbsRule1Path)
+		expect(statMock).toHaveBeenCalledWith(expectedAbsRule2Path)
+		expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule1Path, "utf-8")
+		expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule2Path, "utf-8")
 	})
 
 	it("should fall back to .roorules-test-mode when .roo/rules-test-mode/ does not exist", async () => {
@@ -1188,15 +1189,11 @@ describe("addCustomInstructions", () => {
 			"test-mode",
 		)
 
-		const expectedTestModeDir =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode"
-		const expectedRule1Path =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt"
-				: "/fake/path/.roo/rules-test-mode/rule1.txt"
+		// Paths in output should be relative
+		const expectedRelativeRule1Path =
+			process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt"
 
-		expect(result).toContain(`# Rules from ${expectedTestModeDir}`)
-		expect(result).toContain(`# Rules from ${expectedRule1Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`)
 		expect(result).toContain("mode specific rule content")
 
 		expect(statCallCount).toBeGreaterThan(0)
@@ -1338,31 +1335,25 @@ describe("Rules directory reading", () => {
 
 		const result = await loadRuleFiles("/fake/path")
 
-		// Verify both regular file and symlink target content are included
-		const expectedRegularPath =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules\\regular.txt"
-				: "/fake/path/.roo/rules/regular.txt"
-		const expectedSymlinkPath =
+		// Verify both regular file and symlink target content are included (paths should be relative)
+		const expectedRelativeRegularPath =
+			process.platform === "win32" ? ".roo\\rules\\regular.txt" : ".roo/rules/regular.txt"
+		const expectedRelativeSymlinkPath =
+			process.platform === "win32" ? ".roo\\symlink-target.txt" : ".roo/symlink-target.txt"
+		const expectedRelativeSubdirPath =
 			process.platform === "win32"
-				? "\\fake\\path\\.roo\\symlink-target.txt"
-				: "/fake/path/.roo/symlink-target.txt"
-		const expectedSubdirPath =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\rules\\symlink-target-dir\\subdir_link.txt"
-				: "/fake/path/.roo/rules/symlink-target-dir/subdir_link.txt"
-		const expectedNestedPath =
-			process.platform === "win32"
-				? "\\fake\\path\\.roo\\nested-symlink-target.txt"
-				: "/fake/path/.roo/nested-symlink-target.txt"
+				? ".roo\\rules\\symlink-target-dir\\subdir_link.txt"
+				: ".roo/rules/symlink-target-dir/subdir_link.txt"
+		const expectedRelativeNestedPath =
+			process.platform === "win32" ? ".roo\\nested-symlink-target.txt" : ".roo/nested-symlink-target.txt"
 
-		expect(result).toContain(`# Rules from ${expectedRegularPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeRegularPath}:`)
 		expect(result).toContain("regular file content")
-		expect(result).toContain(`# Rules from ${expectedSymlinkPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeSymlinkPath}:`)
 		expect(result).toContain("symlink target content")
-		expect(result).toContain(`# Rules from ${expectedSubdirPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeSubdirPath}:`)
 		expect(result).toContain("regular file content under symlink target dir")
-		expect(result).toContain(`# Rules from ${expectedNestedPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeNestedPath}:`)
 		expect(result).toContain("nested symlink target content")
 
 		// Verify readlink was called with the symlink path
@@ -1424,18 +1415,19 @@ describe("Rules directory reading", () => {
 
 		const result = await loadRuleFiles("/fake/path")
 
-		const expectedFile1Path =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt"
-		const expectedFile2Path =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt"
-		const expectedFile3Path =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file3.txt" : "/fake/path/.roo/rules/file3.txt"
+		// Paths in output should be relative
+		const expectedRelativeFile1Path =
+			process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt"
+		const expectedRelativeFile2Path =
+			process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt"
+		const expectedRelativeFile3Path =
+			process.platform === "win32" ? ".roo\\rules\\file3.txt" : ".roo/rules/file3.txt"
 
-		expect(result).toContain(`# Rules from ${expectedFile1Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeFile1Path}:`)
 		expect(result).toContain("content of file1")
-		expect(result).toContain(`# Rules from ${expectedFile2Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeFile2Path}:`)
 		expect(result).toContain("content of file2")
-		expect(result).toContain(`# Rules from ${expectedFile3Path}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeFile3Path}:`)
 		expect(result).toContain("content of file3")
 	})
 
@@ -1483,17 +1475,16 @@ describe("Rules directory reading", () => {
 		expect(alphaIndex).toBeLessThan(betaIndex)
 		expect(betaIndex).toBeLessThan(zebraIndex)
 
-		// Verify the expected file paths are in the result
-		const expectedAlphaPath =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\alpha.txt" : "/fake/path/.roo/rules/alpha.txt"
-		const expectedBetaPath =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\Beta.txt" : "/fake/path/.roo/rules/Beta.txt"
-		const expectedZebraPath =
-			process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\zebra.txt" : "/fake/path/.roo/rules/zebra.txt"
-
-		expect(result).toContain(`# Rules from ${expectedAlphaPath}:`)
-		expect(result).toContain(`# Rules from ${expectedBetaPath}:`)
-		expect(result).toContain(`# Rules from ${expectedZebraPath}:`)
+		// Verify the expected file paths are in the result (should be relative)
+		const expectedRelativeAlphaPath =
+			process.platform === "win32" ? ".roo\\rules\\alpha.txt" : ".roo/rules/alpha.txt"
+		const expectedRelativeBetaPath = process.platform === "win32" ? ".roo\\rules\\Beta.txt" : ".roo/rules/Beta.txt"
+		const expectedRelativeZebraPath =
+			process.platform === "win32" ? ".roo\\rules\\zebra.txt" : ".roo/rules/zebra.txt"
+
+		expect(result).toContain(`# Rules from ${expectedRelativeAlphaPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeBetaPath}:`)
+		expect(result).toContain(`# Rules from ${expectedRelativeZebraPath}:`)
 	})
 
 	it("should sort symlinks by their symlink names, not target names", async () => {

+ 113 - 23
src/core/prompts/sections/custom-instructions.ts

@@ -9,7 +9,12 @@ import type { SystemPromptSettings } from "../types"
 import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types"
 
 import { LANGUAGES } from "../../../shared/language"
-import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config"
+import {
+	getRooDirectoriesForCwd,
+	getAllRooDirectoriesForCwd,
+	getAgentsDirectoriesForCwd,
+	getGlobalRooDirectory,
+} from "../../../services/roo-config"
 
 /**
  * Safely read a file and return its trimmed content
@@ -87,9 +92,15 @@ async function resolveSymLink(
 		const stats = await fs.stat(resolvedTarget)
 		if (stats.isFile()) {
 			// For symlinks to files, store the symlink path as original and target as resolved
-			fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget })
+			fileInfo.push({
+				originalPath: symlinkPath,
+				resolvedPath: resolvedTarget,
+			})
 		} else if (stats.isDirectory()) {
-			const anotherEntries = await fs.readdir(resolvedTarget, { withFileTypes: true, recursive: true })
+			const anotherEntries = await fs.readdir(resolvedTarget, {
+				withFileTypes: true,
+				recursive: true,
+			})
 			// Collect promises for recursive calls within the directory
 			const directoryPromises: Promise<void>[] = []
 			for (const anotherEntry of anotherEntries) {
@@ -111,7 +122,10 @@ async function resolveSymLink(
  */
 async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ filename: string; content: string }>> {
 	try {
-		const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true })
+		const entries = await fs.readdir(dirPath, {
+			withFileTypes: true,
+			recursive: true,
+		})
 
 		// Process all entries - regular files and symlinks that might point to files
 		// Store both original path (for sorting) and resolved path (for reading)
@@ -168,32 +182,40 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
 
 /**
  * Format content from multiple files with filenames as headers
+ * @param files - Array of files with filename (absolute path) and content
+ * @param cwd - Current working directory for computing relative paths
  */
-function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
+function formatDirectoryContent(files: Array<{ filename: string; content: string }>, cwd: string): string {
 	if (files.length === 0) return ""
 
 	return files
 		.map((file) => {
-			return `# Rules from ${file.filename}:\n${file.content}`
+			// Compute relative path for display
+			const displayPath = path.relative(cwd, file.filename)
+			return `# Rules from ${displayPath}:\n${file.content}`
 		})
 		.join("\n\n")
 }
 
 /**
- * Load rule files from global and project-local directories
- * Global rules are loaded first, then project-local rules which can override global ones
+ * Load rule files from global, project-local, and optionally subfolder directories
+ * Rules are loaded in order: global first, then project-local, then subfolders (alphabetically)
+ *
+ * @param cwd - Current working directory (project root)
+ * @param enableSubfolderRules - Whether to include rules from subdirectories (default: false)
  */
-export async function loadRuleFiles(cwd: string): Promise<string> {
+export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean = false): Promise<string> {
 	const rules: string[] = []
-	const rooDirectories = getRooDirectoriesForCwd(cwd)
+	// Use recursive discovery only if enableSubfolderRules is true
+	const rooDirectories = enableSubfolderRules ? await getAllRooDirectoriesForCwd(cwd) : getRooDirectoriesForCwd(cwd)
 
-	// Check for .roo/rules/ directories in order (global first, then project-local)
+	// Check for .roo/rules/ directories in order (global, project-local, and optionally subfolders)
 	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)
+				const content = formatDirectoryContent(files, cwd)
 				rules.push(content)
 			}
 		}
@@ -201,7 +223,7 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
 
 	// If we found rules in .roo/rules/ directories, return them
 	if (rules.length > 0) {
-		return "\n" + rules.join("\n\n")
+		return "\n# Rules from .roo directories:\n\n" + rules.join("\n\n")
 	}
 
 	// Fall back to existing behavior for legacy .roorules/.clinerules files
@@ -218,16 +240,24 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
 }
 
 /**
- * Load AGENTS.md or AGENT.md file from the project root if it exists
+ * Load AGENTS.md or AGENT.md file from a specific directory
  * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
+ *
+ * @param directory - Directory to check for AGENTS.md
+ * @param showPath - Whether to include the directory path in the header
+ * @param cwd - Current working directory for computing relative paths (optional)
  */
-async function loadAgentRulesFile(cwd: string): Promise<string> {
+async function loadAgentRulesFileFromDirectory(
+	directory: string,
+	showPath: boolean = false,
+	cwd?: string,
+): Promise<string> {
 	// Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative)
 	const filenames = ["AGENTS.md", "AGENT.md"]
 
 	for (const filename of filenames) {
 		try {
-			const agentPath = path.join(cwd, filename)
+			const agentPath = path.join(directory, filename)
 			let resolvedPath = agentPath
 
 			// Check if file exists and handle symlinks
@@ -235,7 +265,10 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
 				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 }> = []
+					const fileInfo: Array<{
+						originalPath: string
+						resolvedPath: string
+					}> = []
 
 					// Use the existing resolveSymLink function to handle symlink resolution
 					await resolveSymLink(agentPath, fileInfo, 0)
@@ -253,7 +286,12 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
 			// Read the content from the resolved path
 			const content = await safeReadFile(resolvedPath)
 			if (content) {
-				return `# Agent Rules Standard (${filename}):\n${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}`
 			}
 		} catch (err) {
 			// Silently ignore errors - agent rules files are optional
@@ -262,6 +300,51 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
 	return ""
 }
 
+/**
+ * Load AGENTS.md or AGENT.md file from the project root if it exists
+ * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
+ *
+ * @deprecated Use loadAllAgentRulesFiles for loading from all directories
+ */
+async function loadAgentRulesFile(cwd: string): Promise<string> {
+	return loadAgentRulesFileFromDirectory(cwd, false, cwd)
+}
+
+/**
+ * Load all AGENTS.md files from project root and optionally subdirectories with .roo folders
+ * Returns combined content with clear path headers for each file
+ *
+ * @param cwd - Current working directory (project root)
+ * @param enableSubfolderRules - Whether to include AGENTS.md from subdirectories (default: false)
+ * @returns Combined AGENTS.md content from all locations
+ */
+async function loadAllAgentRulesFiles(cwd: string, enableSubfolderRules: boolean = false): Promise<string> {
+	const agentRules: string[] = []
+
+	// When subfolder rules are disabled, only load from root
+	if (!enableSubfolderRules) {
+		const content = await loadAgentRulesFileFromDirectory(cwd, false, cwd)
+		if (content && content.trim()) {
+			agentRules.push(content.trim())
+		}
+		return agentRules.join("\n\n")
+	}
+
+	// When enabled, load from root and all subdirectories with .roo folders
+	const directories = await getAgentsDirectoriesForCwd(cwd)
+
+	for (const directory of directories) {
+		// Show path for all directories except the root
+		const showPath = directory !== cwd
+		const content = await loadAgentRulesFileFromDirectory(directory, showPath, cwd)
+		if (content && content.trim()) {
+			agentRules.push(content.trim())
+		}
+	}
+
+	return agentRules.join("\n\n")
+}
+
 export async function addCustomInstructions(
 	modeCustomInstructions: string,
 	globalCustomInstructions: string,
@@ -275,21 +358,27 @@ export async function addCustomInstructions(
 ): Promise<string> {
 	const sections = []
 
+	// Get the enableSubfolderRules setting (default: false)
+	const enableSubfolderRules = options.settings?.enableSubfolderRules ?? false
+
 	// Load mode-specific rules if mode is provided
 	let modeRuleContent = ""
 	let usedRuleFile = ""
 
 	if (mode) {
 		const modeRules: string[] = []
-		const rooDirectories = getRooDirectoriesForCwd(cwd)
+		// Use recursive discovery only if enableSubfolderRules is true
+		const rooDirectories = enableSubfolderRules
+			? await getAllRooDirectoriesForCwd(cwd)
+			: getRooDirectoriesForCwd(cwd)
 
-		// Check for .roo/rules-${mode}/ directories in order (global first, then project-local)
+		// Check for .roo/rules-${mode}/ directories in order (global, project-local, and optionally subfolders)
 		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)
+					const content = formatDirectoryContent(files, cwd)
 					modeRules.push(content)
 				}
 			}
@@ -350,15 +439,16 @@ export async function addCustomInstructions(
 	}
 
 	// Add AGENTS.md content if enabled (default: true)
+	// Load from root and optionally subdirectories with .roo folders based on enableSubfolderRules setting
 	if (options.settings?.useAgentRules !== false) {
-		const agentRulesContent = await loadAgentRulesFile(cwd)
+		const agentRulesContent = await loadAllAgentRulesFiles(cwd, enableSubfolderRules)
 		if (agentRulesContent && agentRulesContent.trim()) {
 			rules.push(agentRulesContent.trim())
 		}
 	}
 
 	// Add generic rules
-	const genericRuleContent = await loadRuleFiles(cwd)
+	const genericRuleContent = await loadRuleFiles(cwd, enableSubfolderRules)
 	if (genericRuleContent && genericRuleContent.trim()) {
 		rules.push(genericRuleContent.trim())
 	}

+ 2 - 0
src/core/prompts/types.ts

@@ -8,6 +8,8 @@ export interface SystemPromptSettings {
 	todoListEnabled: boolean
 	browserToolEnabled?: boolean
 	useAgentRules: boolean
+	/** When true, recursively discover and load .roo/rules from subdirectories */
+	enableSubfolderRules?: boolean
 	newTaskRequireTodos: boolean
 	toolProtocol?: ToolProtocol
 	/** When true, model should hide vendor/company identity in responses */

+ 2 - 0
src/core/task/Task.ts

@@ -3505,6 +3505,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			maxConcurrentFileReads,
 			maxReadFileLine,
 			apiConfiguration,
+			enableSubfolderRules,
 		} = state ?? {}
 
 		return await (async () => {
@@ -3557,6 +3558,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 					browserToolEnabled: browserToolEnabled ?? true,
 					useAgentRules:
 						vscode.workspace.getConfiguration(Package.name).get<boolean>("useAgentRules") ?? true,
+					enableSubfolderRules: enableSubfolderRules ?? false,
 					newTaskRequireTodos: vscode.workspace
 						.getConfiguration(Package.name)
 						.get<boolean>("newTaskRequireTodos", false),

+ 3 - 0
src/core/webview/ClineProvider.ts

@@ -1873,6 +1873,7 @@ export class ClineProvider
 			browserToolEnabled,
 			telemetrySetting,
 			showRooIgnoredFiles,
+			enableSubfolderRules,
 			language,
 			maxReadFileLine,
 			maxImageFileSize,
@@ -2020,6 +2021,7 @@ export class ClineProvider
 			telemetryKey,
 			machineId,
 			showRooIgnoredFiles: showRooIgnoredFiles ?? false,
+			enableSubfolderRules: enableSubfolderRules ?? false,
 			language: language ?? formatLanguage(vscode.env.language),
 			renderContext: this.renderContext,
 			maxReadFileLine: maxReadFileLine ?? -1,
@@ -2270,6 +2272,7 @@ export class ClineProvider
 			browserToolEnabled: stateValues.browserToolEnabled ?? true,
 			telemetrySetting: stateValues.telemetrySetting || "unset",
 			showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false,
+			enableSubfolderRules: stateValues.enableSubfolderRules ?? false,
 			maxReadFileLine: stateValues.maxReadFileLine ?? -1,
 			maxImageFileSize: stateValues.maxImageFileSize ?? 5,
 			maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,

+ 1 - 0
src/core/webview/__tests__/ClineProvider.spec.ts

@@ -568,6 +568,7 @@ describe("ClineProvider", () => {
 			browserToolEnabled: true,
 			telemetrySetting: "unset",
 			showRooIgnoredFiles: false,
+			enableSubfolderRules: false,
 			renderContext: "sidebar",
 			maxReadFileLine: 500,
 			maxImageFileSize: 5,

+ 2 - 0
src/core/webview/generateSystemPrompt.ts

@@ -27,6 +27,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
 		language,
 		maxReadFileLine,
 		maxConcurrentFileReads,
+		enableSubfolderRules,
 	} = await provider.getState()
 
 	// Check experiment to determine which diff strategy to use
@@ -93,6 +94,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
 			maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
 			todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
 			useAgentRules: vscode.workspace.getConfiguration(Package.name).get<boolean>("useAgentRules") ?? true,
+			enableSubfolderRules: enableSubfolderRules ?? false,
 			newTaskRequireTodos: vscode.workspace
 				.getConfiguration(Package.name)
 				.get<boolean>("newTaskRequireTodos", false),

+ 194 - 1
src/services/roo-config/__tests__/index.spec.ts

@@ -1,10 +1,11 @@
 import * as path from "path"
 
 // Use vi.hoisted to ensure mocks are available during hoisting
-const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({
+const { mockStat, mockReadFile, mockHomedir, mockExecuteRipgrep } = vi.hoisted(() => ({
 	mockStat: vi.fn(),
 	mockReadFile: vi.fn(),
 	mockHomedir: vi.fn(),
+	mockExecuteRipgrep: vi.fn(),
 }))
 
 // Mock fs/promises module
@@ -20,6 +21,11 @@ vi.mock("os", () => ({
 	homedir: mockHomedir,
 }))
 
+// Mock executeRipgrep from search service
+vi.mock("../../search/file-search", () => ({
+	executeRipgrep: mockExecuteRipgrep,
+}))
+
 import {
 	getGlobalRooDirectory,
 	getProjectRooDirectoryForCwd,
@@ -27,6 +33,9 @@ import {
 	fileExists,
 	readFileIfExists,
 	getRooDirectoriesForCwd,
+	getAllRooDirectoriesForCwd,
+	getAgentsDirectoriesForCwd,
+	discoverSubfolderRooDirectories,
 	loadConfiguration,
 } from "../index"
 
@@ -297,4 +306,188 @@ describe("RooConfigService", () => {
 			expect(mockReadFile).toHaveBeenCalledWith(path.join("/project/path", ".roo", "rules/rules.md"), "utf-8")
 		})
 	})
+
+	describe("discoverSubfolderRooDirectories", () => {
+		it("should return empty array when no subfolder .roo directories found", async () => {
+			mockExecuteRipgrep.mockResolvedValue([])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			expect(result).toEqual([])
+		})
+
+		it("should discover .roo directories from subfolders", async () => {
+			// Find any file inside .roo directories
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "package-a/.roo/rules/rule.md", type: "file" },
+				{ path: "package-b/.roo/rules-code/rule.md", type: "file" },
+			])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			expect(result).toEqual([
+				path.join("/project/path", "package-a", ".roo"),
+				path.join("/project/path", "package-b", ".roo"),
+			])
+		})
+
+		it("should sort discovered directories alphabetically", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "zebra/.roo/rules/rule.md", type: "file" },
+				{ path: "apple/.roo/rules/rule.md", type: "file" },
+				{ path: "mango/.roo/rules/rule.md", type: "file" },
+			])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			expect(result).toEqual([
+				path.join("/project/path", "apple", ".roo"),
+				path.join("/project/path", "mango", ".roo"),
+				path.join("/project/path", "zebra", ".roo"),
+			])
+		})
+
+		it("should exclude root .roo directory", async () => {
+			// This would match the root .roo, which should be excluded
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: ".roo/rules/rule.md", type: "file" }, // This is root - should be excluded
+				{ path: "subfolder/.roo/rules/rule.md", type: "file" },
+			])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			// Should only include subfolder, not root
+			expect(result).toEqual([path.join("/project/path", "subfolder", ".roo")])
+		})
+
+		it("should handle nested subdirectories", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "packages/core/.roo/rules/rule.md", type: "file" },
+				{ path: "packages/utils/.roo/rules-code/rule.md", type: "file" },
+			])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			expect(result).toEqual([
+				path.join("/project/path", "packages/core", ".roo"),
+				path.join("/project/path", "packages/utils", ".roo"),
+			])
+		})
+
+		it("should return empty array on ripgrep error", async () => {
+			mockExecuteRipgrep.mockRejectedValue(new Error("ripgrep failed"))
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			expect(result).toEqual([])
+		})
+
+		it("should deduplicate .roo directories from multiple files", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "package-a/.roo/rules/rule1.md", type: "file" },
+				{ path: "package-a/.roo/rules/rule2.md", type: "file" },
+				{ path: "package-a/.roo/rules-code/rule3.md", type: "file" },
+			])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			// Should only include package-a/.roo once
+			expect(result).toEqual([path.join("/project/path", "package-a", ".roo")])
+		})
+
+		it("should discover .roo directories with any content", async () => {
+			// Should find .roo directories regardless of what's inside them
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "package-a/.roo/rules/rule.md", type: "file" },
+				{ path: "package-b/.roo/rules-code/code-rule.md", type: "file" },
+				{ path: "package-c/.roo/rules-architect/arch-rule.md", type: "file" },
+				{ path: "package-d/.roo/config/settings.json", type: "file" },
+			])
+
+			const result = await discoverSubfolderRooDirectories("/project/path")
+
+			expect(result).toEqual([
+				path.join("/project/path", "package-a", ".roo"),
+				path.join("/project/path", "package-b", ".roo"),
+				path.join("/project/path", "package-c", ".roo"),
+				path.join("/project/path", "package-d", ".roo"),
+			])
+		})
+	})
+
+	describe("getAllRooDirectoriesForCwd", () => {
+		it("should return global, project, and subfolder directories", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([{ path: "subfolder/.roo/rules/rule.md", type: "file" }])
+
+			const result = await getAllRooDirectoriesForCwd("/project/path")
+
+			expect(result).toEqual([
+				path.join("/mock/home", ".roo"), // global
+				path.join("/project/path", ".roo"), // project
+				path.join("/project/path", "subfolder", ".roo"), // subfolder
+			])
+		})
+
+		it("should return only global and project when no subfolders", async () => {
+			mockExecuteRipgrep.mockResolvedValue([])
+
+			const result = await getAllRooDirectoriesForCwd("/project/path")
+
+			expect(result).toEqual([path.join("/mock/home", ".roo"), path.join("/project/path", ".roo")])
+		})
+
+		it("should maintain order: global, project, subfolders (alphabetically)", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "zebra/.roo/rules/rule.md", type: "file" },
+				{ path: "apple/.roo/rules/rule.md", type: "file" },
+			])
+
+			const result = await getAllRooDirectoriesForCwd("/project/path")
+
+			expect(result).toEqual([
+				path.join("/mock/home", ".roo"), // global first
+				path.join("/project/path", ".roo"), // project second
+				path.join("/project/path", "apple", ".roo"), // subfolders alphabetically
+				path.join("/project/path", "zebra", ".roo"),
+			])
+		})
+	})
+
+	describe("getAgentsDirectoriesForCwd", () => {
+		it("should return root directory and parent directories of subfolder .roo dirs", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([{ path: "package-a/.roo/rules/rule.md", type: "file" }])
+
+			const result = await getAgentsDirectoriesForCwd("/project/path")
+
+			expect(result).toEqual([
+				"/project/path", // root
+				path.join("/project/path", "package-a"), // parent of .roo
+			])
+		})
+
+		it("should always include root even when no subfolders", async () => {
+			mockExecuteRipgrep.mockResolvedValue([])
+
+			const result = await getAgentsDirectoriesForCwd("/project/path")
+
+			expect(result).toEqual(["/project/path"])
+		})
+
+		it("should include multiple subfolder parent directories", async () => {
+			mockExecuteRipgrep.mockResolvedValueOnce([
+				{ path: "package-a/.roo/rules/rule.md", type: "file" },
+				{ path: "package-b/.roo/rules-code/rule.md", type: "file" },
+				{ path: "packages/core/.roo/rules/rule.md", type: "file" },
+			])
+
+			const result = await getAgentsDirectoriesForCwd("/project/path")
+
+			expect(result).toEqual([
+				"/project/path",
+				path.join("/project/path", "package-a"),
+				path.join("/project/path", "package-b"),
+				path.join("/project/path", "packages/core"),
+			])
+		})
+	})
 })

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

@@ -111,6 +111,89 @@ export async function readFileIfExists(filePath: string): Promise<string | null>
 	}
 }
 
+/**
+ * Discovers all .roo directories in subdirectories of the workspace
+ *
+ * @param cwd - Current working directory (workspace root)
+ * @returns Array of absolute paths to .roo directories found in subdirectories,
+ *          sorted alphabetically. Does not include the root .roo directory.
+ *
+ * @example
+ * ```typescript
+ * const subfolderRoos = await discoverSubfolderRooDirectories('/Users/john/monorepo')
+ * // Returns:
+ * // [
+ * //   '/Users/john/monorepo/package-a/.roo',
+ * //   '/Users/john/monorepo/package-b/.roo',
+ * //   '/Users/john/monorepo/packages/shared/.roo'
+ * // ]
+ * ```
+ *
+ * @example Directory structure:
+ * ```
+ * /Users/john/monorepo/
+ * ├── .roo/                    # Root .roo (NOT included - use getProjectRooDirectoryForCwd)
+ * ├── package-a/
+ * │   └── .roo/                # Included
+ * │       └── rules/
+ * ├── package-b/
+ * │   └── .roo/                # Included
+ * │       └── rules-code/
+ * └── packages/
+ *     └── shared/
+ *         └── .roo/            # Included (nested)
+ *             └── rules/
+ * ```
+ */
+export async function discoverSubfolderRooDirectories(cwd: string): Promise<string[]> {
+	try {
+		// Dynamic import to avoid vscode dependency at module load time
+		// This is necessary because file-search.ts imports vscode, which is not
+		// available in the webview context
+		const { executeRipgrep } = await import("../search/file-search")
+
+		// Use ripgrep to find any file inside any .roo directory
+		// This efficiently discovers all .roo folders regardless of their content
+		const args = [
+			"--files",
+			"--hidden",
+			"--follow",
+			"-g",
+			"**/.roo/**",
+			"-g",
+			"!node_modules/**",
+			"-g",
+			"!.git/**",
+			cwd,
+		]
+
+		const results = await executeRipgrep({ args, workspacePath: cwd })
+
+		// Extract unique .roo directory paths
+		const rooDirs = new Set<string>()
+		const rootRooDir = path.join(cwd, ".roo")
+
+		for (const result of results) {
+			// Match paths like "subfolder/.roo/anything" or "subfolder/nested/.roo/anything"
+			// Handle both forward slashes (Unix) and backslashes (Windows)
+			const match = result.path.match(/^(.+?)[/\\]\.roo[/\\]/)
+			if (match) {
+				const rooDir = path.join(cwd, match[1], ".roo")
+				// Exclude the root .roo directory (already handled by getProjectRooDirectoryForCwd)
+				if (rooDir !== rootRooDir) {
+					rooDirs.add(rooDir)
+				}
+			}
+		}
+
+		// Return sorted alphabetically
+		return Array.from(rooDirs).sort()
+	} catch (error) {
+		// If discovery fails (e.g., ripgrep not available), return empty array
+		return []
+	}
+}
+
 /**
  * Gets the ordered list of .roo directories to check (global first, then project-local)
  *
@@ -156,6 +239,71 @@ export function getRooDirectoriesForCwd(cwd: string): string[] {
 	return directories
 }
 
+/**
+ * Gets the ordered list of all .roo directories including subdirectories
+ *
+ * @param cwd - Current working directory (project path)
+ * @returns Array of directory paths in order: [global, project-local, ...subfolders (alphabetically)]
+ *
+ * @example
+ * ```typescript
+ * // For a monorepo at /Users/john/monorepo with .roo in subfolders
+ * const directories = await getAllRooDirectoriesForCwd('/Users/john/monorepo')
+ * // Returns:
+ * // [
+ * //   '/Users/john/.roo',                    // Global directory
+ * //   '/Users/john/monorepo/.roo',           // Project-local directory
+ * //   '/Users/john/monorepo/package-a/.roo', // Subfolder (alphabetical)
+ * //   '/Users/john/monorepo/package-b/.roo'  // Subfolder (alphabetical)
+ * // ]
+ * ```
+ */
+export async function getAllRooDirectoriesForCwd(cwd: string): Promise<string[]> {
+	const directories: string[] = []
+
+	// Add global directory first
+	directories.push(getGlobalRooDirectory())
+
+	// Add project-local directory second
+	directories.push(getProjectRooDirectoryForCwd(cwd))
+
+	// Discover and add subfolder .roo directories
+	const subfolderDirs = await discoverSubfolderRooDirectories(cwd)
+	directories.push(...subfolderDirs)
+
+	return directories
+}
+
+/**
+ * Gets parent directories containing .roo folders, in order from root to subfolders
+ *
+ * @param cwd - Current working directory (project path)
+ * @returns Array of parent directory paths (not .roo paths) containing AGENTS.md or .roo
+ *
+ * @example
+ * ```typescript
+ * const dirs = await getAgentsDirectoriesForCwd('/Users/john/monorepo')
+ * // Returns: ['/Users/john/monorepo', '/Users/john/monorepo/package-a', ...]
+ * ```
+ */
+export async function getAgentsDirectoriesForCwd(cwd: string): Promise<string[]> {
+	const directories: string[] = []
+
+	// Always include the root directory
+	directories.push(cwd)
+
+	// Get all subfolder .roo directories
+	const subfolderRooDirs = await discoverSubfolderRooDirectories(cwd)
+
+	// Extract parent directories (remove .roo from path)
+	for (const rooDir of subfolderRooDirs) {
+		const parentDir = path.dirname(rooDir)
+		directories.push(parentDir)
+	}
+
+	return directories
+}
+
 /**
  * Loads configuration from multiple .roo directories with project overriding global
  *

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -308,6 +308,7 @@ export type ExtensionState = Pick<
 	maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
 	maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
 	showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
+	enableSubfolderRules: boolean // Whether to load rules from subdirectories
 	maxReadFileLine: number // Maximum number of lines to read from a file before truncating
 	maxImageFileSize: number // Maximum size of image files to process in MB
 	maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB

+ 25 - 0
src/shared/string-extensions.d.ts

@@ -0,0 +1,25 @@
+/**
+ * Global string extensions declaration.
+ * This file provides type declarations for String.prototype extensions
+ * that are used across the codebase.
+ *
+ * The actual implementation is in src/utils/path.ts.
+ *
+ * This separate declaration file is necessary because the webview-ui package
+ * includes ../src/shared in its tsconfig.json but not ../src/utils/path.ts.
+ * Without this file, the webview-ui compilation would fail when processing
+ * files that use the toPosix() method.
+ */
+declare global {
+	interface String {
+		/**
+		 * Convert a path string to POSIX format (forward slashes).
+		 * Extended-Length Paths in Windows (\\?\) are preserved.
+		 * @returns The path with backslashes converted to forward slashes
+		 */
+		toPosix(): string
+	}
+}
+
+// This export is needed to make this file a module
+export {}

+ 17 - 0
webview-ui/src/components/settings/ContextManagementSettings.tsx

@@ -19,6 +19,7 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
 	maxOpenTabsContext: number
 	maxWorkspaceFiles: number
 	showRooIgnoredFiles?: boolean
+	enableSubfolderRules?: boolean
 	maxReadFileLine?: number
 	maxImageFileSize?: number
 	maxTotalImageSize?: number
@@ -36,6 +37,7 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
 		| "maxOpenTabsContext"
 		| "maxWorkspaceFiles"
 		| "showRooIgnoredFiles"
+		| "enableSubfolderRules"
 		| "maxReadFileLine"
 		| "maxImageFileSize"
 		| "maxTotalImageSize"
@@ -57,6 +59,7 @@ export const ContextManagementSettings = ({
 	maxOpenTabsContext,
 	maxWorkspaceFiles,
 	showRooIgnoredFiles,
+	enableSubfolderRules,
 	setCachedStateField,
 	maxReadFileLine,
 	maxImageFileSize,
@@ -203,6 +206,20 @@ export const ContextManagementSettings = ({
 					</div>
 				</div>
 
+				<div>
+					<VSCodeCheckbox
+						checked={enableSubfolderRules}
+						onChange={(e: any) => setCachedStateField("enableSubfolderRules", e.target.checked)}
+						data-testid="enable-subfolder-rules-checkbox">
+						<label className="block font-medium mb-1">
+							{t("settings:contextManagement.enableSubfolderRules.label")}
+						</label>
+					</VSCodeCheckbox>
+					<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
+						{t("settings:contextManagement.enableSubfolderRules.description")}
+					</div>
+				</div>
+
 				<div>
 					<div className="flex flex-col gap-2">
 						<span className="font-medium">{t("settings:contextManagement.maxReadFile.label")}</span>

+ 3 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -188,6 +188,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 		terminalZdotdir,
 		writeDelayMs,
 		showRooIgnoredFiles,
+		enableSubfolderRules,
 		remoteBrowserEnabled,
 		maxReadFileLine,
 		maxImageFileSize,
@@ -395,6 +396,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 					maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
 					maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500),
 					showRooIgnoredFiles: showRooIgnoredFiles ?? true,
+					enableSubfolderRules: enableSubfolderRules ?? false,
 					maxReadFileLine: maxReadFileLine ?? -1,
 					maxImageFileSize: maxImageFileSize ?? 5,
 					maxTotalImageSize: maxTotalImageSize ?? 20,
@@ -771,6 +773,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 							maxOpenTabsContext={maxOpenTabsContext}
 							maxWorkspaceFiles={maxWorkspaceFiles ?? 200}
 							showRooIgnoredFiles={showRooIgnoredFiles}
+							enableSubfolderRules={enableSubfolderRules}
 							maxReadFileLine={maxReadFileLine}
 							maxImageFileSize={maxImageFileSize}
 							maxTotalImageSize={maxTotalImageSize}

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -73,6 +73,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setAlwaysAllowSubtasks: (value: boolean) => void
 	setBrowserToolEnabled: (value: boolean) => void
 	setShowRooIgnoredFiles: (value: boolean) => void
+	setEnableSubfolderRules: (value: boolean) => void
 	setShowAnnouncement: (value: boolean) => void
 	setAllowedCommands: (value: string[]) => void
 	setDeniedCommands: (value: string[]) => void
@@ -234,6 +235,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		browserToolEnabled: true,
 		telemetrySetting: "unset",
 		showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
+		enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories
 		renderContext: "sidebar",
 		maxReadFileLine: -1, // Default max read file line limit
 		maxImageFileSize: 5, // Default max image file size in MB
@@ -538,6 +540,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
 		setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })),
 		setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })),
+		setEnableSubfolderRules: (value) => setState((prevState) => ({ ...prevState, enableSubfolderRules: value })),
 		setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })),
 		setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })),
 		setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })),

+ 1 - 0
webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx

@@ -198,6 +198,7 @@ describe("mergeExtensionState", () => {
 			apiConfiguration: { providerId: "openrouter" } as ProviderSettings,
 			telemetrySetting: "unset",
 			showRooIgnoredFiles: true,
+			enableSubfolderRules: false,
 			renderContext: "sidebar",
 			maxReadFileLine: 500,
 			cloudUserInfo: null,

+ 4 - 0
webview-ui/src/i18n/locales/ca/settings.json

@@ -661,6 +661,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status màx. fitxers",
 			"description": "Nombre màxim d'entrades de fitxers a incloure en el context d'estat de git. Establiu a 0 per desactivar. La informació de la branca i els commits sempre es mostren quan és > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Activa les regles dels subdirectoris",
+			"description": "Descobreix i carrega recursivament fitxers .roo/rules i AGENTS.md des de subdirectoris. Útil per a monorepos amb regles per paquet."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/de/settings.json

@@ -661,6 +661,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git-Status max. Dateien",
 			"description": "Maximale Anzahl von Dateieinträgen, die in den Git-Status-Kontext aufgenommen werden sollen. Auf 0 setzen, um zu deaktivieren. Branch-Informationen und Commits werden immer angezeigt, wenn > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Unterordner-Regeln aktivieren",
+			"description": "Rekursiv .roo/rules und AGENTS.md-Dateien aus Unterverzeichnissen laden. Nützlich für Monorepos mit paketspezifischen Regeln."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/en/settings.json

@@ -670,6 +670,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status max files",
 			"description": "Maximum number of file entries to include in git status context. Set to 0 to disable. Branch info is always shown when > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Enable subfolder rules",
+			"description": "Recursively discover and load .roo/rules and AGENTS.md files from subdirectories. Useful for monorepos with per-package rules."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/es/settings.json

@@ -661,6 +661,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status máx. archivos",
 			"description": "Número máximo de entradas de archivo para incluir en el contexto de estado de git. Establézcalo en 0 para deshabilitar. La información de la rama y los commits siempre se muestran cuando es > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Activar reglas de subcarpetas",
+			"description": "Descubrir y cargar recursivamente archivos .roo/rules y AGENTS.md desde subdirectorios. Útil para monorepos con reglas por paquete."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/fr/settings.json

@@ -661,6 +661,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status max fichiers",
 			"description": "Nombre maximum de fichiers à inclure dans le contexte de statut git. Mettre à 0 pour désactiver. Les informations de branche et les commits sont toujours affichés si > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Activer les règles des sous-dossiers",
+			"description": "Découvrir et charger récursivement les fichiers .roo/rules et AGENTS.md depuis les sous-répertoires. Utile pour les monorepos avec des règles par package."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/hi/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "गिट स्थिति अधिकतम फ़ाइलें",
 			"description": "गिट स्थिति संदर्भ में शामिल करने के लिए फ़ाइल प्रविष्टियों की अधिकतम संख्या। अक्षम करने के लिए 0 पर सेट करें। शाखा जानकारी और कमिट हमेशा दिखाए जाते हैं जब > 0 होता है।"
+		},
+		"enableSubfolderRules": {
+			"label": "सबफ़ोल्डर नियम सक्षम करें",
+			"description": "उपनिर्देशिकाओं से .roo/rules और AGENTS.md फ़ाइलों को पुनरावर्ती रूप से खोजें और लोड करें। प्रति-पैकेज नियमों वाले मोनोरेपो के लिए उपयोगी।"
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/id/settings.json

@@ -666,6 +666,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status maks file",
 			"description": "Jumlah maksimum entri file untuk disertakan dalam konteks status git. Atur ke 0 untuk menonaktifkan. Info cabang dan commit selalu ditampilkan saat > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Aktifkan aturan subfolder",
+			"description": "Temukan dan muat file .roo/rules dan AGENTS.md secara rekursif dari subdirektori. Berguna untuk monorepo dengan aturan per-paket."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/it/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status max file",
 			"description": "Numero massimo di voci di file da includere nel contesto dello stato di git. Imposta a 0 per disabilitare. Le informazioni sul ramo e sui commit vengono sempre mostrate quando > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Abilita regole sottocartelle",
+			"description": "Scopri e carica ricorsivamente i file .roo/rules e AGENTS.md dalle sottodirectory. Utile per i monorepo con regole per pacchetto."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/ja/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Gitステータス最大ファイル数",
 			"description": "gitステータスコンテキストに含めるファイルエントリの最大数。無効にするには0に設定します。ブランチ情報とコミットは、> 0の場合に常に表示されます。"
+		},
+		"enableSubfolderRules": {
+			"label": "サブフォルダルールを有効化",
+			"description": "サブディレクトリから.roo/rulesとAGENTS.mdファイルを再帰的に検出してロードします。パッケージごとのルールを持つモノレポに便利です。"
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/ko/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git 상태 최대 파일",
 			"description": "git 상태 컨텍스트에 포함할 최대 파일 항목 수입니다. 비활성화하려면 0으로 설정하세요. 분기 정보와 커밋은 > 0일 때 항상 표시됩니다."
+		},
+		"enableSubfolderRules": {
+			"label": "하위 폴더 규칙 활성화",
+			"description": "하위 디렉토리에서 .roo/rules 및 AGENTS.md 파일을 재귀적으로 검색하고 로드합니다. 패키지별 규칙이 있는 모노레포에 유용합니다."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/nl/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status max bestanden",
 			"description": "Maximum aantal bestandsvermeldingen dat in de git-statuscontext moet worden opgenomen. Stel in op 0 om uit te schakelen. Branch-info en commits worden altijd getoond wanneer > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Submap-regels inschakelen",
+			"description": "Recursief .roo/rules en AGENTS.md-bestanden uit submappen ontdekken en laden. Handig voor monorepo's met regels per pakket."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/pl/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status maks. plików",
 			"description": "Maksymalna liczba wpisów plików do uwzględnienia w kontekście statusu git. Ustaw na 0, aby wyłączyć. Informacje o gałęzi i zatwierdzenia są zawsze pokazywane, gdy > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Włącz reguły podfolderów",
+			"description": "Rekursywnie odkrywaj i ładuj pliki .roo/rules i AGENTS.md z podkatalogów. Przydatne dla monorepo z regułami dla poszczególnych pakietów."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/pt-BR/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status máx. arquivos",
 			"description": "Número máximo de entradas de arquivo a serem incluídas no contexto de status do git. Defina como 0 para desativar. Informações sobre o branch e os commits são sempre exibidos quando > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Ativar regras de subpastas",
+			"description": "Descobrir e carregar recursivamente arquivos .roo/rules e AGENTS.md de subdiretórios. Útil para monorepos com regras por pacote."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/ru/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git статус макс. файлов",
 			"description": "Максимальное количество записей файлов для включения в контекст статуса git. Установите значение 0, чтобы отключить. Информация о ветке и коммитах всегда отображается, если значение > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Включить правила подпапок",
+			"description": "Рекурсивно обнаруживать и загружать файлы .roo/rules и AGENTS.md из подкаталогов. Полезно для монорепозиториев с правилами для каждого пакета."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/tr/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git durumu maks. dosya",
 			"description": "Git durum bağlamına dahil edilecek maksimum dosya girişi sayısı. Devre dışı bırakmak için 0 olarak ayarlayın. Dal bilgisi ve commit'ler > 0 olduğunda her zaman gösterilir."
+		},
+		"enableSubfolderRules": {
+			"label": "Alt klasör kurallarını etkinleştir",
+			"description": "Alt dizinlerden .roo/rules ve AGENTS.md dosyalarını yinelemeli olarak keşfet ve yükle. Paket başına kuralları olan monorepo'lar için kullanışlı."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/vi/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git status tệp tối đa",
 			"description": "Số lượng mục tệp tối đa để bao gồm trong ngữ cảnh trạng thái git. Đặt thành 0 để tắt. Thông tin nhánh và các commit luôn được hiển thị khi > 0."
+		},
+		"enableSubfolderRules": {
+			"label": "Bật quy tắc thư mục con",
+			"description": "Khám phá và tải đệ quy các tệp .roo/rules và AGENTS.md từ các thư mục con. Hữu ích cho monorepo có quy tắc theo gói."
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/zh-CN/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git 状态最大文件数",
 			"description": "git状态上下文中包含的最大文件条目数。设为0禁用。分支信息和提交在>0时始终显示。"
+		},
+		"enableSubfolderRules": {
+			"label": "启用子文件夹规则",
+			"description": "递归发现并加载子目录中的 .roo/rules 和 AGENTS.md 文件。适用于具有每包规则的 monorepo。"
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/src/i18n/locales/zh-TW/settings.json

@@ -662,6 +662,10 @@
 		"maxGitStatusFiles": {
 			"label": "Git 狀態最大檔案數",
 			"description": "git狀態上下文中包含的最大檔案條目數。設為0禁用。分支資訊和提交在>0時始終顯示。"
+		},
+		"enableSubfolderRules": {
+			"label": "啟用子資料夾規則",
+			"description": "遞迴發現並載入子目錄中的 .roo/rules 和 AGENTS.md 檔案。適用於具有每包規則的 monorepo。"
 		}
 	},
 	"terminal": {

+ 4 - 0
webview-ui/vite.config.ts

@@ -103,6 +103,10 @@ export default defineConfig(({ mode }) => {
 			// Use a single combined CSS bundle so both webviews share styles
 			cssCodeSplit: false,
 			rollupOptions: {
+				// Externalize vscode module - it's imported by file-search.ts which is
+				// dynamically imported by roo-config/index.ts, but should never be bundled
+				// in the webview since it's not available in the browser context
+				external: ["vscode"],
 				input: {
 					index: resolve(__dirname, "index.html"),
 					"browser-panel": resolve(__dirname, "browser-panel.html"),