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.