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

feat(core): discover AGENTS.md up to the home directory (#41652)

Kit Langton 6 дней назад
Родитель
Сommit
136bad9f17

+ 4 - 2
packages/core/src/config/plugin/instruction.ts

@@ -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: [] } }
 

+ 75 - 7
packages/core/test/instruction-discovery.test.ts

@@ -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(

+ 5 - 4
packages/www/content/docs/(Configure)/instructions.mdx

@@ -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
packages/www/content/docs/migrate-v1.mdx

@@ -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