Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 414bd3e668 feat(core): discover AGENTS.md up to the home directory 2026-08-10 22:38:37 -04:00
4 changed files with 86 additions and 15 deletions
@@ -27,8 +27,10 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const root = yield* fs.resolve(location.project.directory)
const home = yield* fs.resolve(global.home)
const project = discovery.project && FSUtil.contains(root, start)
const stop = FSUtil.contains(home, start) ? home : root
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
@@ -24,6 +24,7 @@ const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config?: string
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
@@ -34,7 +35,15 @@ const instructionLayer = (input: {
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@@ -112,10 +121,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const sharedFile = path.join(shared, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
@@ -124,6 +136,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(outside, "outside")
await fs.writeFile(globalFile, "global")
await fs.writeFile(sharedFile, "shared")
await fs.writeFile(projectFile, "project")
await fs.writeFile(packageFile, "package")
})
@@ -135,13 +148,20 @@ describe("ConfigInstructionPlugin.Plugin", () => {
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
{ path: sharedFile, type: "file" },
{ path: path.join(home, "AGENTS.md"), type: "file" },
])
expect(yield* watcher.subscriptions()).not.toContainEqual({
path: path.join(tmp.path, "AGENTS.md"),
type: "file",
})
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${packageFile}\npackage`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
expect(initialized.text).not.toContain("outside")
@@ -159,6 +179,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
@@ -166,6 +187,8 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
yield* Effect.promise(() => fs.rm(sharedFile))
yield* emitAndWait({ type: "delete", path: sharedFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
@@ -173,6 +196,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -215,15 +239,17 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("discovers a newly created instruction file in an intermediate directory", () =>
it.live("discovers a newly created instruction file above the project root", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const intermediate = path.join(shared, "AGENTS.md")
const directory = path.join(project, "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
@@ -235,7 +261,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
"\n\n",
),
)
@@ -243,6 +269,48 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("stops instruction candidates at the project root outside home", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const project = path.join(tmp.path, "scratch", "repo")
const directory = path.join(project, "packages", "core")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(global, "AGENTS.md"), type: "file" },
{ path: path.join(directory, "AGENTS.md"), type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: path.join(project, "AGENTS.md"), type: "file" },
])
}).pipe(
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -19,8 +19,9 @@ V2 loads:
1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally
`~/.config/opencode/AGENTS.md`.
2. Every `AGENTS.md` from the current Location up to and including the project
root.
2. Every `AGENTS.md` from the current Location up to and including the home
directory when the Location is inside it. For Locations outside home, the
scan stops at the project root.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
@@ -35,9 +36,9 @@ my-project/
```
The files are combined rather than selecting a single winner. They are rendered
in this order: global, then project files from the Location toward the project
in this order: global, then files from the Location toward home or the project
root. OpenCode does not resolve conflicts between their contents, so keep broad
guidance global and put scoped guidance in the relevant project directory.
guidance global and put scoped guidance in the relevant directory.
If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
+2 -2
View File
@@ -504,8 +504,8 @@ require a V2 rewrite. See [Skills](/skills).
### Instruction files
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and project `AGENTS.md`
files from the current directory up to the project root.
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and ambient `AGENTS.md`
files from the current directory up to home. For projects outside home, discovery stops at the project root.
If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the applicable `AGENTS.md`. V2 currently only
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected