diff --git a/package.json b/package.json index 4267ef64566..2d3d0dddab9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@^1.3.5", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1294ab849e9..d1d935a0659 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -27,7 +27,15 @@ import { RGBA, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" +import type { + AssistantMessage, + Part, + ToolPart, + UserMessage, + TextPart, + ReasoningPart, + RulesPart, +} from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -1150,6 +1158,7 @@ function UserMessage(props: { const local = useLocal() const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) + const rules = createMemo(() => props.parts.filter((x) => x.type === "rules") as RulesPart[]) const sync = useSync() const { theme } = useTheme() const [hover, setHover] = createSignal(false) @@ -1203,6 +1212,7 @@ function UserMessage(props: { + {(part) => } (props.part.files ?? []).map((f) => normalizePath(f)).join(", ") + + return ( + + + rules applied ({paths()}) + + + ) } function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..0d6ff8df41d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -12,6 +12,14 @@ import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) { + return path.relative(process.cwd(), input) || "." + } + return input +} + export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() @@ -25,6 +33,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { diff: true, todo: true, lsp: true, + rules: true, }) // Sort MCP servers alphabetically for consistent display order @@ -257,6 +266,34 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { + 0}> + + (session().rules?.length ?? 0) > 2 && setExpanded("rules", !expanded.rules)} + > + 2}> + {expanded.rules ? "▼" : "▶"} + + + Rules + + + + + {(item) => ( + + •{" "} + {item.startsWith(sync.data.path.directory) + ? item.slice(sync.data.path.directory.length).replace(/^\//, "") + : item} + + )} + + + + diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b2142e29b94..b2cd9d7d5ef 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -888,6 +888,22 @@ export namespace Config { .record(z.string(), Command) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), + subdirectoryRules: z + .object({ + enabled: z.boolean().optional().default(false), + patterns: z.string().array().optional().describe("List of glob patterns to match rule files"), + exact: z + .boolean() + .optional() + .default(false) + .describe("If true, only load rules from the exact file directory"), + }) + .strict() + .meta({ + ref: "SubdirectoryRulesConfig", + }) + .optional() + .describe("Configuration for subdirectory rule discovery"), watcher: z .object({ ignore: z.array(z.string()).optional(), diff --git a/packages/opencode/src/config/rules.ts b/packages/opencode/src/config/rules.ts new file mode 100644 index 00000000000..45574c9e5e7 --- /dev/null +++ b/packages/opencode/src/config/rules.ts @@ -0,0 +1,159 @@ +import path from "path" +import { ConfigMarkdown } from "./markdown" +import { Config } from "./config" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { existsSync } from "fs" +import { SystemPrompt } from "@/session/system" + +export interface Rule { + filePath: string + paths?: string[] + content: string +} + +export namespace Rules { + async function parse(filePath: string): Promise { + const md = await ConfigMarkdown.parse(filePath) + const frontmatter = md.data as Record | undefined + + const paths = frontmatter?.paths + let pathsArray: string[] | undefined + + if (paths !== undefined) { + if (typeof paths === "string") { + pathsArray = [paths] + } else if (Array.isArray(paths)) { + pathsArray = paths.filter((p) => typeof p === "string") as string[] + } + } + + return { + filePath, + paths: pathsArray, + content: md.content.trim(), + } + } + + function sortPatternsBySpecificity(patterns: string[]): string[] { + const positives: string[] = [] + const negatives: string[] = [] + + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + negatives.push(pattern) + } else { + positives.push(pattern) + } + } + + positives.sort((a, b) => { + const aDepth = a.split("/").length + const bDepth = b.split("/").length + if (aDepth !== bDepth) return aDepth - bDepth + + const aWildcards = (a.match(/\*\*/g) || []).length + (a.match(/\*/g) || []).length + const bWildcards = (b.match(/\*\*/g) || []).length + (b.match(/\*/g) || []).length + if (aWildcards !== bWildcards) return bWildcards - aWildcards + + return a.localeCompare(b) + }) + + return [...positives, ...negatives] + } + + function matchesPattern(filepath: string, pattern: string): boolean { + return new Bun.Glob(pattern).match(filepath) + } + + export function matchRulesForFile(rules: Rule[], filepath: string): string[] { + const matchedContents: string[] = [] + const seenContents = new Set() + + const relative = path.isAbsolute(filepath) ? path.relative(Instance.worktree, filepath) : filepath + + const rulesWithPaths = rules.filter((r) => r.paths && r.paths.length > 0) + const globalRules = rules.filter((r) => !r.paths || r.paths.length === 0) + + for (const rule of globalRules) { + if (!seenContents.has(rule.content)) { + seenContents.add(rule.content) + matchedContents.push(rule.content) + } + } + + for (const rule of rulesWithPaths) { + const patterns = sortPatternsBySpecificity(rule.paths ?? []) + + let matched = false + let excluded = false + + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + if (matchesPattern(relative, pattern.slice(1))) { + excluded = true + break + } + } else { + if (matchesPattern(relative, pattern)) { + matched = true + } + } + } + + if (matched && !excluded) { + if (!seenContents.has(rule.content)) { + seenContents.add(rule.content) + matchedContents.push(rule.content) + } + } + } + + return matchedContents + } + + export async function loadForFile(filepath: string): Promise { + const config = await Config.get() + const rulesConfig = config.subdirectoryRules + + if (!rulesConfig?.enabled) return [] + + const patterns = rulesConfig?.patterns ?? ["**/AGENTS.md"] + const exact = rulesConfig?.exact ?? false + const directories = await Array.fromAsync( + Filesystem.up({ + targets: ["."], + start: path.dirname(filepath), + stop: Instance.worktree, + }), + ).then((dirs) => { + // Exclude root directory as it's already in the system prompt + const filtered = dirs.filter((dir) => dir !== Instance.worktree) + return exact ? (filtered.length > 0 ? [filtered[0]] : []) : filtered.reverse() + }) + + const results: Rule[] = [] + const seen = new Set() + const worktree = Instance.worktree + const rootExclusions = new Set(SystemPrompt.LOCAL_RULE_FILES.map((f) => path.join(worktree, f))) + + for (const dir of directories) { + if (!dir || !existsSync(dir)) continue + // Ensure we don't leak outside the worktree if it's set to root + if (worktree === "/" && !dir.startsWith(Instance.directory)) continue + for (const pattern of patterns) { + const glob = new Bun.Glob(pattern) + for await (const file of glob.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + })) { + if (rootExclusions.has(file) || seen.has(file)) continue + seen.add(file) + results.push(await parse(file)) + } + } + } + return results + } +} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ae69221288f..7b23505ee5e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,6 +14,7 @@ import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" import { Config } from "@/config/config" +import { SessionRules } from "./rules" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -83,6 +84,7 @@ export namespace SessionCompaction { if (part.state.status === "completed") { part.state.time.compacted = Date.now() await Session.updatePart(part) + SessionRules.onCallPruned(input.sessionID, part.callID) } } log.info("pruned", { count: toPrune.length }) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 3fcdab5238c..a1a45416803 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -9,6 +9,7 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" +import { SessionRules } from "./rules" import { Storage } from "../storage/storage" import { Log } from "../util/log" @@ -76,6 +77,7 @@ export namespace Session { diff: z.string().optional(), }) .optional(), + rules: z.string().array().optional(), }) .meta({ ref: "Session", @@ -341,6 +343,7 @@ export namespace Session { Bus.publish(Event.Deleted, { info: session, }) + await SessionRules.clearSessionRules(sessionID) } catch (e) { log.error(e) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d326976f1ae..6d4013e05d4 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -163,6 +163,15 @@ export namespace MessageV2 { }) export type CompactionPart = z.infer + export const RulesPart = PartBase.extend({ + type: z.literal("rules"), + rules: z.string().array(), + files: z.string().array().optional(), + }).meta({ + ref: "RulesPart", + }) + export type RulesPart = z.infer + export const SubtaskPart = PartBase.extend({ type: z.literal("subtask"), prompt: z.string(), @@ -339,6 +348,7 @@ export namespace MessageV2 { PatchPart, AgentPart, RetryPart, + RulesPart, CompactionPart, ]) .meta({ @@ -472,6 +482,12 @@ export namespace MessageV2 { text: "The following tool was executed by the user", }) } + if (part.type === "rules") { + userMessage.parts.push({ + type: "text", + text: `\n${part.rules.join("\n\n")}\n`, + }) + } } } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71db7f13677..bc9262ef747 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -16,6 +16,8 @@ import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" +import { SessionRules } from "./rules" + export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -188,6 +190,18 @@ export namespace SessionProcessor { }, }) + const pendingRules = SessionRules.consumePendingRules(input.sessionID) + if (pendingRules.length > 0) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "rules", + rules: pendingRules.map((r) => r.content), + files: pendingRules.map((r) => r.filePath), + }) + } + delete toolcalls[value.toolCallId] } break @@ -209,6 +223,18 @@ export namespace SessionProcessor { }, }) + const pendingRules = SessionRules.consumePendingRules(input.sessionID) + if (pendingRules.length > 0) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "rules", + rules: pendingRules.map((r) => r.content), + files: pendingRules.map((r) => r.filePath), + }) + } + if ( value.error instanceof PermissionNext.RejectedError || value.error instanceof Question.RejectedError @@ -394,6 +420,19 @@ export namespace SessionProcessor { } input.assistantMessage.time.completed = Date.now() await Session.updateMessage(input.assistantMessage) + + const pendingRules = SessionRules.consumePendingRules(input.sessionID) + if (pendingRules.length > 0) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "rules", + rules: pendingRules.map((r) => r.content), + files: pendingRules.map((r) => r.filePath), + }) + } + if (needsCompaction) return "compact" if (blocked) return "stop" if (input.assistantMessage.error) return "stop" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9325583acf7..b1fa067c788 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { SessionRules } from "./rules" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -924,6 +925,9 @@ export namespace SessionPrompt { switch (url.protocol) { case "data:": if (part.mime === "text/plain") { + if (part.filename) { + await SessionRules.notifyFileInContext(part.filename, input.sessionID, "user") + } return [ { id: Identifier.ascending("part"), @@ -1022,6 +1026,7 @@ export namespace SessionPrompt { ask: async () => {}, } const result = await t.execute(args, readCtx) + await SessionRules.notifyFileInContext(filepath, input.sessionID, "user") pieces.push({ id: Identifier.ascending("part"), messageID: info.id, @@ -1111,6 +1116,7 @@ export namespace SessionPrompt { const file = Bun.file(filepath) FileTime.read(input.sessionID, filepath) + await SessionRules.notifyFileInContext(filepath, input.sessionID, "user") return [ { id: Identifier.ascending("part"), @@ -1192,6 +1198,20 @@ export namespace SessionPrompt { await Session.updatePart(part) } + const pendingRules = SessionRules.consumePendingRules(input.sessionID) + if (pendingRules.length > 0) { + const rulesPart: MessageV2.Part = { + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "rules", + rules: pendingRules.map((r) => r.content), + files: pendingRules.map((r) => r.filePath), + } + await Session.updatePart(rulesPart) + parts.push(rulesPart) + } + return { info, parts, diff --git a/packages/opencode/src/session/rules.ts b/packages/opencode/src/session/rules.ts new file mode 100644 index 00000000000..2ca9fd73a61 --- /dev/null +++ b/packages/opencode/src/session/rules.ts @@ -0,0 +1,144 @@ +import { Rules } from "../config/rules" +import { Instance } from "../project/instance" +import { GlobalBus } from "@/bus/global" +import { Log } from "../util/log" +import { Session } from "." +import path from "path" + +interface InjectedRule { + filePath: string + content: string + injectedAt: number + callIDs: Set +} + +const injectedRules = new Map>() +const pendingRules = new Map() + +export namespace SessionRules { + const log = Log.create({ service: "session.rules" }) + + async function updateSessionRules(sessionID: string) { + const sessionRules = injectedRules.get(sessionID) + const paths = sessionRules ? Array.from(sessionRules.keys()) : [] + log.info("updating session rules", { sessionID, count: paths.length }) + await Session.update(sessionID, (draft) => { + draft.rules = paths + }).catch((e) => { + log.error("failed to update session rules", { sessionID, error: e }) + }) + } + + export async function getMatchingRules(filepath: string, sessionID: string, callID?: string): Promise { + const rulesFromPath = await Rules.loadForFile(filepath) + + const sessionRules = injectedRules.get(sessionID) ?? new Map() + const matchedRules: InjectedRule[] = [] + + const matching = rulesFromPath.filter((rule) => { + if (!rule.paths || rule.paths.length === 0) return true + const patterns = Rules.matchRulesForFile([rule], filepath) + return patterns.length > 0 + }) + + log.info("matched rules for file", { filepath, count: matching.length }) + + let changed = false + for (const rule of matching) { + const existing = sessionRules.get(rule.filePath) + + if (existing) { + if (callID) { + if (!existing.callIDs.has(callID)) { + log.debug("linking existing rule to new call", { filePath: rule.filePath, callID }) + existing.callIDs.add(callID) + } + } + continue + } + + log.info("injecting rule", { filePath: rule.filePath, callID }) + const injected = { + filePath: rule.filePath, + content: rule.content, + injectedAt: Date.now(), + callIDs: new Set(callID ? [callID] : []), + } + sessionRules.set(rule.filePath, injected) + matchedRules.push(injected) + changed = true + } + + injectedRules.set(sessionID, sessionRules) + + if (changed) { + await updateSessionRules(sessionID) + } + + if (matchedRules.length > 0) { + const existing = pendingRules.get(sessionID) ?? [] + for (const rule of matchedRules) { + if (!existing.some((r) => r.filePath === rule.filePath)) { + existing.push(rule) + } + } + pendingRules.set(sessionID, existing) + } + + return matchedRules.map((r) => r.content) + } + + export async function notifyFileInContext(filepath: string, sessionID: string, callID: string): Promise { + let resolvedPath = filepath + if (!path.isAbsolute(resolvedPath)) { + resolvedPath = path.join(Instance.directory, resolvedPath) + } + + return await getMatchingRules(resolvedPath, sessionID, callID) + } + + export async function onCallPruned(sessionID: string, callID: string) { + const sessionRules = injectedRules.get(sessionID) + if (!sessionRules) return + + let changed = false + for (const [filePath, rule] of sessionRules.entries()) { + if (rule.callIDs.delete(callID)) { + log.debug("removed call from rule", { filePath, callID }) + if (rule.callIDs.size === 0) { + log.info("pruning rule (no active calls)", { filePath }) + sessionRules.delete(filePath) + changed = true + } + } + } + + if (changed) { + await updateSessionRules(sessionID) + } + } + + export function consumePendingRules(sessionID: string): InjectedRule[] { + const rules = pendingRules.get(sessionID) ?? [] + if (rules.length > 0) { + log.debug("consuming pending rules", { sessionID, count: rules.length }) + } + pendingRules.delete(sessionID) + return rules + } + + export async function clearSessionRules(sessionID: string): Promise { + log.info("clearing session rules", { sessionID }) + injectedRules.delete(sessionID) + pendingRules.delete(sessionID) + await Session.update(sessionID, (draft) => { + draft.rules = [] + }).catch(() => {}) + } +} + +GlobalBus.on("event", (event) => { + if (event.payload.type === "session.compacted") { + void SessionRules.clearSessionRules(event.payload.properties.sessionID) + } +}) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index f0e2d96b7eb..aa000da9843 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -61,7 +61,7 @@ export namespace SystemPrompt { ] } - const LOCAL_RULE_FILES = [ + export const LOCAL_RULE_FILES = [ "AGENTS.md", "CLAUDE.md", "CONTEXT.md", // deprecated diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 26db5b22836..27561f61a3e 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -16,6 +16,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" +import { SessionRules } from "../session/rules" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -121,6 +122,11 @@ export const EditTool = Tool.define("edit", { let output = "Edit applied successfully." await LSP.touchFile(filePath, true) + + if (ctx.callID) { + await SessionRules.notifyFileInContext(filePath, ctx.sessionID, ctx.callID) + } + const diagnostics = await LSP.diagnostics() const normalizedFilePath = Filesystem.normalizePath(filePath) const issues = diagnostics[normalizedFilePath] ?? [] diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 097dedf4aaf..4aee6349d45 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -6,6 +6,7 @@ import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectory } from "./external-directory" +import { SessionRules } from "../session/rules" const MAX_LINE_LENGTH = 2000 @@ -116,6 +117,12 @@ export const GrepTool = Tool.define("grep", { } } + if (ctx.callID) { + for (const filePath of new Set(finalMatches.map((m) => m.path))) { + await SessionRules.notifyFileInContext(filePath, ctx.sessionID, ctx.callID) + } + } + const outputLines = [`Found ${finalMatches.length} matches`] let currentFile = "" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 3b1484cbc0f..d10292b3640 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" +import { SessionRules } from "../session/rules" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -23,7 +24,7 @@ export const ReadTool = Tool.define("read", { async execute(params, ctx) { let filepath = params.filePath if (!path.isAbsolute(filepath)) { - filepath = path.join(process.cwd(), filepath) + filepath = path.join(Instance.directory, filepath) } const title = path.relative(Instance.worktree, filepath) @@ -133,6 +134,10 @@ export const ReadTool = Tool.define("read", { LSP.touchFile(filepath, false) FileTime.read(ctx.sessionID, filepath) + if (ctx.callID) { + await SessionRules.notifyFileInContext(filepath, ctx.sessionID, ctx.callID) + } + return { title, output, diff --git a/packages/opencode/test/config/rules.test.ts b/packages/opencode/test/config/rules.test.ts new file mode 100644 index 00000000000..2f183cbe606 --- /dev/null +++ b/packages/opencode/test/config/rules.test.ts @@ -0,0 +1,153 @@ +import { test, expect, describe, beforeEach } from "bun:test" +import { Rules, type Rule } from "../../src/config/rules" +import { Instance } from "../../src/project/instance" +import { Config } from "../../src/config/config" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +describe("Rules", () => { + test("sorts patterns by specificity", () => { + const patterns = ["src/**/*.ts", "src/api/**/*.ts", "src/api/users.ts"] + const rules: Rule[] = [ + { + filePath: "/test/rules.md", + paths: patterns, + content: "test content", + }, + ] + const matched = Rules.matchRulesForFile(rules, "src/api/users.ts") + expect(matched.length).toBe(1) + expect(matched[0]).toBe("test content") + }) + + test("exclusion pattern removes matching rules", () => { + const rules: Rule[] = [ + { + filePath: "/test/test.md", + paths: ["src/**/*.ts", "!src/__tests__/**/*.ts"], + content: "Test rules", + }, + ] + expect(Rules.matchRulesForFile(rules, "src/utils.ts")).toContain("Test rules") + expect(Rules.matchRulesForFile(rules, "src/__tests__/utils.test.ts")).not.toContain("Test rules") + }) + + describe("loadForFile", () => { + test("is disabled by default", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "src"), { recursive: true }) + await fs.writeFile(path.join(dir, "src", "AGENTS.md"), "Src Rules") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const rules = await Rules.loadForFile(path.join(tmp.path, "src", "main.ts")) + expect(rules.length).toBe(0) + }, + }) + }) + + test("traverses up and collects subdirectory rules", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(path.join(dir, "a", "b"), { recursive: true }) + await fs.writeFile(path.join(dir, "a", "AGENTS.md"), "A Rules") + await fs.writeFile(path.join(dir, "a", "b", "AGENTS.md"), "B Rules") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const originalGet = Config.get + Config.get = async () => ({ subdirectoryRules: { enabled: true } }) as any + + const rules = await Rules.loadForFile(path.join(tmp.path, "a", "b", "file.ts")) + expect(rules.length).toBe(2) + expect(rules[0].content).toBe("A Rules") + expect(rules[1].content).toBe("B Rules") + + Config.get = originalGet + }, + }) + }) + + test("excludes project root LOCAL_RULE_FILES", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.writeFile(path.join(dir, "AGENTS.md"), "Root Rules") + await fs.mkdir(path.join(dir, "src"), { recursive: true }) + await fs.writeFile(path.join(dir, "src", "AGENTS.md"), "Src Rules") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const originalGet = Config.get + Config.get = async () => ({ subdirectoryRules: { enabled: true } }) as any + + const rules = await Rules.loadForFile(path.join(tmp.path, "src", "main.ts")) + // Should only contain src/AGENTS.md, root AGENTS.md is excluded + expect(rules.length).toBe(1) + expect(rules[0].content).toBe("Src Rules") + + Config.get = originalGet + }, + }) + }) + + test("exact: true only loads from current directory", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(path.join(dir, "a", "b"), { recursive: true }) + await fs.writeFile(path.join(dir, "a", "AGENTS.md"), "A Rules") + await fs.writeFile(path.join(dir, "a", "b", "AGENTS.md"), "B Rules") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const originalGet = Config.get + Config.get = async () => ({ subdirectoryRules: { enabled: true, exact: true } }) as any + + const rules = await Rules.loadForFile(path.join(tmp.path, "a", "b", "file.ts")) + expect(rules.length).toBe(1) + expect(rules[0].content).toBe("B Rules") + + Config.get = originalGet + }, + }) + }) + + test("supports custom glob patterns", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(path.join(dir, "src"), { recursive: true }) + await fs.writeFile(path.join(dir, "src", "test.custom.md"), "Custom Rule") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const originalGet = Config.get + Config.get = async () => + ({ + subdirectoryRules: { enabled: true, patterns: ["**/*.custom.md"] }, + }) as any + + const rules = await Rules.loadForFile(path.join(tmp.path, "src", "file.ts")) + expect(rules.length).toBe(1) + expect(rules[0].content).toBe("Custom Rule") + + Config.get = originalGet + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344a8..8a0bb63fe4b 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -20,6 +20,8 @@ export async function tmpdir(options?: TmpDirOptions) { await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { await $`git init`.cwd(dirpath).quiet() + await $`git config user.email "test@example.com"`.cwd(dirpath).quiet() + await $`git config user.name "Test User"`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } if (options?.config) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8442889020f..58b6c3347cb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -433,6 +433,15 @@ export type RetryPart = { } } +export type RulesPart = { + id: string + sessionID: string + messageID: string + type: "rules" + rules: Array + files?: Array +} + export type CompactionPart = { id: string sessionID: string @@ -466,6 +475,7 @@ export type Part = | PatchPart | AgentPart | RetryPart + | RulesPart | CompactionPart export type EventMessagePartUpdated = { @@ -775,6 +785,7 @@ export type Session = { snapshot?: string diff?: string } + rules?: Array } export type EventSessionCreated = { @@ -1327,6 +1338,21 @@ export type ServerConfig = { cors?: Array } +/** + * Configuration for rule discovery + */ +export type RulesConfig = { + enabled?: boolean + /** + * List of regular expressions to match rule files + */ + patterns?: Array + /** + * If true, only load rules from the exact file directory + */ + exact?: boolean +} + export type PermissionActionConfig = "ask" | "allow" | "deny" export type PermissionObjectConfig = { @@ -1623,6 +1649,7 @@ export type Config = { subtask?: boolean } } + rules?: RulesConfig watcher?: { ignore?: Array } diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 3a170019a7f..b803e84ffa5 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/packages/web/src/content/docs/rules.mdx @@ -96,6 +96,64 @@ The first matching file wins in each category. For example, if you have both `AG --- +## Subdirectory Rules + +In addition to session-wide instructions, opencode can dynamically load rules whenever a file is read into context. This allows you to provide localized instructions for specific parts of your codebase. + +To enable this feature: + +```json title="opencode.json" +{ + "subdirectoryRules": { + "enabled": true + } +} +``` + +By default, opencode traverses from the project root down to the file's directory, collecting all files matching the pattern `**/AGENTS.md` (excluding rule files in the project root that are already loaded). + +### Configuration + +You can customize the discovery behavior for subdirectory rules in your `opencode.json`: + +```json title="opencode.json" +{ + "subdirectoryRules": { + "enabled": true, + "patterns": ["**/*.AGENTS.md", "**/CLAUDE.md"], + "exact": false + } +} +``` + +| Option | Type | Description | +| ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Enable or disable automatic rule discovery. Defaults to `false`. | +| `patterns` | `string[]` | List of glob patterns to match rule files. Defaults to `["**/AGENTS.md"]`. | +| `exact` | `boolean` | If `true`, only load rules from the exact directory of the file being read. If `false`, searches upward to the project root. Defaults to `false`. | + +### Loading Order + +By default, rules are loaded in **top-down order** (root to leaf). This means a rule in `src/api/AGENTS.md` is inserted after any rules in parent directories, so the models see the more specific instruction last. + +### Path Filtering + +Hierarchical files support YAML frontmatter to further restrict their application: + +```markdown title="src/components/AGENTS.md" +--- +paths: ["**/*.tsx"] +--- + +# Component Rules + +Only apply these rules to React components. +``` + +`paths` is a list of glob expressions to match when opencode attempts to apply a rule. If the globs do not match, the rule is not included. + +--- + ## Custom Instructions You can specify custom instruction files in your `opencode.json` or the global `~/.config/opencode/opencode.json`. This allows you and your team to reuse existing rules rather than having to duplicate them to AGENTS.md.