fix: discover parent AGENTS files

- 🛠️ Continue through workspace parents under home.\n- ✅ Preserve repository boundaries outside home.\n- 🧪 Cover nested, boundary, and hidden paths.
This commit is contained in:
Listell McLean
2026-08-05 11:29:09 +01:00
parent 06477855d1
commit 37825fbd6c
4 changed files with 163 additions and 26 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ Put broad, durable project background in `AGENTS.md`. Reserve `RULES.md` for sho
| `opencode` | `.config/opencode/AGENTS.md` | User | User file `~/.config/opencode/AGENTS.md` only. |
| `github` | `.github/copilot-instructions.md` | User + project | Project file `<cwd>/.github/copilot-instructions.md` only (no ancestor walk-up), plus a user-global `~/.copilot/copilot-instructions.md` (relocate with `COPILOT_HOME`). `AGENTS.md` candidates from `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` are also considered at user scope, where normal one-user-file deduplication applies. |
| `agents` | `.agent/AGENTS.md`, `.agents/AGENTS.md` | User + project | User files from `~/.agent/` and `~/.agents/`; project files discovered while walking up from the current directory to the repository root. |
| `agents-md` | `AGENTS.md` | Project | Standalone (non-config-directory) `AGENTS.md` files, discovered by walking up from the current directory to the repository root (or home when no repo root is known). Files whose parent directory name starts with `.` are ignored — those belong to a config-directory provider instead. |
| `agents-md` | `AGENTS.md` | Project | Standalone (non-config-directory) `AGENTS.md` files, discovered by walking up from the current directory to the repository root and, when that repository is nested under the user's home directory, through enclosing workspace directories up to but not including the home directory. With no repository root, discovery uses the home directory as the boundary for sessions under home. Files whose parent directory name starts with `.` are ignored — those belong to a config-directory provider instead. |
| `github` | `.github/instructions/**/*.instructions.md` | Project rules | GitHub Copilot / VS Code instruction files become rules. `applyTo: '*'`, `applyTo: '**'`, or `applyTo: '**/*'` is injected as always-apply content; other `applyTo` globs are listed in the rulebook with a generated description when needed and are readable as `rule://<name>`. Missing `applyTo` also produces a rulebook entry and a discovery warning. |
Providers marked "(no ancestor walk-up)" only look in the current working directory's config directory. If you need ancestor walk-up behavior, prefer the native `.omp/AGENTS.md` format or a standalone `AGENTS.md` (the `agents-md` provider), or launch `omp` from the directory that holds the config directory.
+1 -1
View File
@@ -202,7 +202,7 @@ No fallback search is performed for missing assets.
- **Skills**: named, optional capability packs selected by task context or explicitly requested
- **AGENTS.md/context files**: persistent instruction files loaded as context-file capability and merged by level/depth rules
`src/discovery/agents-md.ts` specifically walks ancestor directories from `cwd` to discover standalone `AGENTS.md` files (stopping at the repo root, or home when no repo root is known), skipping files whose containing directory name starts with a dot.
`src/discovery/agents-md.ts` walks ancestor directories from `cwd` to discover standalone `AGENTS.md` files. For repositories nested under the user's home directory, it continues through enclosing workspace directories up to but not including the home directory. Otherwise it stops at the repository root, or at the filesystem root when no repository root is known outside home. Files in hidden owner directories are skipped.
### Skills vs slash commands
@@ -16,42 +16,79 @@ const PROVIDER_ID = "agents-md";
const DISPLAY_NAME = "AGENTS.md";
/**
* Load standalone AGENTS.md files.
* Compare paths while tolerating Windows drive casing.
*/
async function loadAgentsMd(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === "win32"
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
/**
* Return whether `child` is at or below `parent`.
*/
function isWithin(parent: string, child: string): boolean {
const normalizedParent = path.resolve(parent);
const normalizedChild = path.resolve(child);
const relative = path.relative(
process.platform === "win32" ? normalizedParent.toLowerCase() : normalizedParent,
process.platform === "win32" ? normalizedChild.toLowerCase() : normalizedChild,
);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
/**
* Load standalone AGENTS.md files.
*
* When a repository is nested below the user's home directory, continue past
* the Git root to discover workspace-level AGENTS.md files, but stop before
* loading the home directory's own AGENTS.md as project context.
*/
export async function loadAgentsMd(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
const home = path.resolve(ctx.home);
const cwd = path.resolve(ctx.cwd);
const repoRoot = ctx.repoRoot ? path.resolve(ctx.repoRoot) : null;
const filesystemRoot = path.parse(cwd).root;
const cwdIsUnderHome = isWithin(home, cwd);
const repoIsUnderHome = repoRoot !== null && isWithin(home, repoRoot);
const scanToHome = cwdIsUnderHome && (repoRoot === null || repoIsUnderHome);
const boundary = scanToHome ? home : (repoRoot ?? filesystemRoot);
const includeBoundary = !samePath(boundary, home) && !(repoRoot === null && !cwdIsUnderHome);
// Walk up from cwd looking for AGENTS.md files
let current = ctx.cwd;
let current = cwd;
while (true) {
const candidate = path.join(current, "AGENTS.md");
const content = await readFile(candidate);
const atBoundary = samePath(current, boundary);
const atHome = samePath(current, home);
if (!atHome && !(atBoundary && !includeBoundary)) {
const candidate = path.join(current, "AGENTS.md");
const content = await readFile(candidate);
if (content !== null) {
const parent = path.dirname(candidate);
const baseName = parent.split(path.sep).pop() ?? "";
if (content !== null) {
const parent = path.dirname(candidate);
const baseName = parent.split(path.sep).pop() ?? "";
if (!baseName.startsWith(".")) {
const fileDir = path.dirname(candidate);
const calculatedDepth = calculateDepth(ctx.cwd, fileDir, path.sep);
if (!baseName.startsWith(".")) {
const fileDir = path.dirname(candidate);
const calculatedDepth = calculateDepth(cwd, fileDir, path.sep);
items.push({
path: candidate,
content,
level: "project",
depth: calculatedDepth,
_source: createSourceMeta(PROVIDER_ID, candidate, "project"),
});
items.push({
path: candidate,
content,
level: "project",
depth: calculatedDepth,
_source: createSourceMeta(PROVIDER_ID, candidate, "project"),
});
}
}
}
if (atBoundary) break;
if (current === (ctx.repoRoot ?? ctx.home)) break; // scanned repo root or home, stop
// Move to parent directory
const parent = path.dirname(current);
if (parent === current) break; // Reached filesystem root
if (parent === current) break;
current = parent;
}
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types";
import { loadAgentsMd } from "@oh-my-pi/pi-coding-agent/discovery/agents-md";
import { removeSyncWithRetries } from "@oh-my-pi/pi-utils";
function writeAgents(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
describe("standalone AGENTS.md discovery", () => {
let tempDir!: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-agents-md-"));
});
afterEach(() => {
removeSyncWithRetries(tempDir);
});
test("finds workspace AGENTS.md above a nested repository without loading home context", async () => {
const home = path.join(tempDir, "home");
const workspaceRoot = path.join(home, "repos", "writer");
const repoRoot = path.join(workspaceRoot, "internal", "service");
const cwd = path.join(repoRoot, "src");
fs.mkdirSync(cwd, { recursive: true });
const repoAgents = path.join(repoRoot, "AGENTS.md");
const workspaceAgents = path.join(workspaceRoot, "AGENTS.md");
const homeAgents = path.join(home, "AGENTS.md");
writeAgents(repoAgents, "repo context");
writeAgents(workspaceAgents, "workspace context");
writeAgents(homeAgents, "home context");
const context: LoadContext = { cwd, home, repoRoot };
const result = await loadAgentsMd(context);
expect(result.items.map(file => file.path)).toEqual([repoAgents, workspaceAgents]);
});
test("loads cwd and intermediate context with no repository root under home", async () => {
const home = path.join(tempDir, "home");
const workspaceRoot = path.join(home, "workspace");
const intermediate = path.join(workspaceRoot, "packages");
const cwd = path.join(intermediate, "service");
fs.mkdirSync(cwd, { recursive: true });
const cwdAgents = path.join(cwd, "AGENTS.md");
const intermediateAgents = path.join(intermediate, "AGENTS.md");
const homeAgents = path.join(home, "AGENTS.md");
writeAgents(cwdAgents, "cwd context");
writeAgents(intermediateAgents, "intermediate context");
writeAgents(homeAgents, "home context");
const context: LoadContext = { cwd, home, repoRoot: null };
const result = await loadAgentsMd(context);
expect(result.items.map(file => file.path)).toEqual([cwdAgents, intermediateAgents]);
});
test("keeps the repository root boundary when the repository is outside home", async () => {
const home = path.join(tempDir, "home");
const workspaceRoot = path.join(tempDir, "workspace");
const repoRoot = path.join(workspaceRoot, "service");
const cwd = path.join(repoRoot, "src");
fs.mkdirSync(cwd, { recursive: true });
const repoAgents = path.join(repoRoot, "AGENTS.md");
const workspaceAgents = path.join(workspaceRoot, "AGENTS.md");
writeAgents(repoAgents, "repo context");
writeAgents(workspaceAgents, "workspace context");
const context: LoadContext = { cwd, home, repoRoot };
const result = await loadAgentsMd(context);
expect(result.items.map(file => file.path)).toEqual([repoAgents]);
});
test("skips AGENTS.md inside a hidden owner directory", async () => {
const home = path.join(tempDir, "home");
const repoRoot = path.join(home, "repo");
const hiddenRoot = path.join(repoRoot, ".hidden");
const cwd = path.join(hiddenRoot, "service");
fs.mkdirSync(cwd, { recursive: true });
const hiddenAgents = path.join(hiddenRoot, "AGENTS.md");
const repoAgents = path.join(repoRoot, "AGENTS.md");
writeAgents(hiddenAgents, "hidden context");
writeAgents(repoAgents, "repo context");
const context: LoadContext = { cwd, home, repoRoot };
const result = await loadAgentsMd(context);
expect(result.items.map(file => file.path)).toEqual([repoAgents]);
});
});