diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7bfdfeffc..d9a4516e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added project-level discovery for `.agent/` and `.agents/` directories, walking up from cwd to repo root (matching behavior of other providers like `.omp`, `.claude`, `.codex`). Applies to skills, rules, prompts, commands, context files (AGENTS.md), and system prompts (SYSTEM.md) + ### Fixed - Fixed `:thinking` suffix in `modelRoles` config values silently breaking model resolution (e.g., `slow: anthropic/claude-opus-4-6:high`) and being stripped on Ctrl+P role cycling diff --git a/packages/coding-agent/src/capability/context-file.ts b/packages/coding-agent/src/capability/context-file.ts index 079b0448e..574869fac 100644 --- a/packages/coding-agent/src/capability/context-file.ts +++ b/packages/coding-agent/src/capability/context-file.ts @@ -27,9 +27,12 @@ export const contextFileCapability = defineCapability({ id: "context-files", displayName: "Context Files", description: "Persistent instruction files (CLAUDE.md, AGENTS.md, etc.) that guide agent behavior", - // Deduplicate by level: one user-level file, one project-level file - // Higher-priority providers shadow lower-priority ones at the same scope - key: file => file.level, + // Deduplicate by scope: one user-level file, and one project-level file per directory depth. + // Within each depth level, higher-priority providers shadow lower-priority ones. + // This supports monorepo hierarchies where AGENTS.md exists at multiple ancestor levels. + // Clamp depth >= 0: files inside config subdirectories of an ancestor (e.g. .claude/, .github/) + // are same-scope as the ancestor itself. + key: file => (file.level === "user" ? "user" : `project:${Math.max(0, file.depth ?? 0)}`), validate: file => { if (!file.path) return "Missing path"; if (file.content === undefined) return "Missing content"; diff --git a/packages/coding-agent/src/capability/fs.ts b/packages/coding-agent/src/capability/fs.ts index c21dab5c8..94764592b 100644 --- a/packages/coding-agent/src/capability/fs.ts +++ b/packages/coding-agent/src/capability/fs.ts @@ -66,6 +66,24 @@ export async function walkUp( } } +/** + * Walk up from startDir looking for a `.git` entry (file or directory). + * Returns the directory containing `.git` (the repo root), or null if not in a git repo. + * Results are based on the cached readDirEntries, so repeated calls are cheap. + */ +export async function findRepoRoot(startDir: string): Promise { + let current = resolvePath(startDir); + while (true) { + const entries = await readDirEntries(current); + if (entries.some(e => e.name === ".git")) { + return current; + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + export function cacheStats(): { content: number; dir: number } { return { content: contentCache.size, diff --git a/packages/coding-agent/src/capability/index.ts b/packages/coding-agent/src/capability/index.ts index 574badb28..b9921e300 100644 --- a/packages/coding-agent/src/capability/index.ts +++ b/packages/coding-agent/src/capability/index.ts @@ -11,7 +11,7 @@ import * as path from "node:path"; import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { Settings } from "../config/settings"; -import { clearCache as clearFsCache, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs"; +import { clearCache as clearFsCache, findRepoRoot, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs"; import type { Capability, CapabilityInfo, @@ -220,7 +220,8 @@ export async function loadCapability(capabilityId: string, options: LoadOptio const cwd = options.cwd ?? getProjectDir(); const home = os.homedir(); - const ctx: LoadContext = { cwd, home }; + const repoRoot = await findRepoRoot(cwd); + const ctx: LoadContext = { cwd, home, repoRoot }; const providers = filterProviders(capability, options); return await loadImpl(capability, providers, ctx, options); diff --git a/packages/coding-agent/src/capability/types.ts b/packages/coding-agent/src/capability/types.ts index 277afb2b0..f979f97e4 100644 --- a/packages/coding-agent/src/capability/types.ts +++ b/packages/coding-agent/src/capability/types.ts @@ -14,6 +14,8 @@ export interface LoadContext { cwd: string; /** User home directory */ home: string; + /** Git repository root (directory containing .git), or null if not in a repo */ + repoRoot: string | null; } /** diff --git a/packages/coding-agent/src/discovery/agents-md.ts b/packages/coding-agent/src/discovery/agents-md.ts index 233c743fc..8519e7290 100644 --- a/packages/coding-agent/src/discovery/agents-md.ts +++ b/packages/coding-agent/src/discovery/agents-md.ts @@ -14,7 +14,6 @@ import { calculateDepth, createSourceMeta } from "./helpers"; const PROVIDER_ID = "agents-md"; const DISPLAY_NAME = "AGENTS.md"; -const MAX_DEPTH = 20; // Prevent walking up excessively far from cwd /** * Load standalone AGENTS.md files. @@ -25,9 +24,8 @@ async function loadAgentsMd(ctx: LoadContext): Promise> // Walk up from cwd looking for AGENTS.md files let current = ctx.cwd; - let depth = 0; - while (depth < MAX_DEPTH) { + while (true) { const candidate = path.join(current, "AGENTS.md"); const content = await readFile(candidate); @@ -49,11 +47,12 @@ async function loadAgentsMd(ctx: LoadContext): Promise> } } + 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 current = parent; - depth++; } return { items, warnings }; diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 9296901a2..e565e1cd3 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -1,7 +1,9 @@ /** * Agents (standard) Provider * - * Loads user-level skills, rules, prompts, commands, context files, and system prompts from ~/.agent/. + * Loads skills, rules, prompts, commands, context files, and system prompts + * from .agent/ and .agents/ directories at both user (~/) and project levels. + * Project-level discovery walks up from cwd to repoRoot. */ import * as path from "node:path"; import { registerProvider } from "../capability"; @@ -13,76 +15,91 @@ import { type Skill, skillCapability } from "../capability/skill"; import { type SlashCommand, slashCommandCapability } from "../capability/slash-command"; import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt"; import type { LoadContext, LoadResult } from "../capability/types"; -import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers"; +import { buildRuleFromMarkdown, calculateDepth, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers"; const PROVIDER_ID = "agents"; const DISPLAY_NAME = "Agents (standard)"; const PRIORITY = 70; -const USER_AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; +const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; -function getUserAgentPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { - return USER_AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments)); +/** User-level paths: ~/.agent/ and ~/.agents/. */ +function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { + return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments)); } -async function loadSkills(ctx: LoadContext): Promise> { - const items: Skill[] = []; - const warnings: string[] = []; - for (const userSkillsDir of getUserAgentPathCandidates(ctx, "skills")) { - const result = await scanSkillsFromDir(ctx, { - dir: userSkillsDir, - providerId: PROVIDER_ID, - level: "user", - }); - items.push(...result.items); - warnings.push(...(result.warnings ?? [])); +/** Project-level paths: walk up from cwd to repoRoot, returning .agent/ and .agents/ at each level. */ +function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { + const paths: string[] = []; + let current = ctx.cwd; + while (true) { + for (const baseDir of AGENT_DIR_CANDIDATES) { + paths.push(path.join(current, baseDir, ...segments)); + } + if (current === (ctx.repoRoot ?? ctx.home)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; } + return paths; +} + +// Skills +async function loadSkills(ctx: LoadContext): Promise> { + const projectScans = getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ); + const userScans = getUserPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "user" }), + ); + + const results = await Promise.all([...projectScans, ...userScans]); + return { - items, - warnings, + items: results.flatMap(r => r.items), + warnings: results.flatMap(r => r.warnings ?? []), }; } registerProvider(skillCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load skills from ~/.agent/skills (fallback ~/.agents/skills)", + description: "Load skills from .agent/skills and .agents/skills (project walk-up + user home)", priority: PRIORITY, load: loadSkills, }); // Rules async function loadRules(ctx: LoadContext): Promise> { - const items: Rule[] = []; - const warnings: string[] = []; - for (const userRulesDir of getUserAgentPathCandidates(ctx, "rules")) { - const result = await loadFilesFromDir(ctx, userRulesDir, PROVIDER_ID, "user", { + const load = (dir: string, level: "user" | "project") => + loadFilesFromDir(ctx, dir, PROVIDER_ID, level, { extensions: ["md", "mdc"], transform: (name, content, filePath, source) => buildRuleFromMarkdown(name, content, filePath, source, { stripNamePattern: /\.(md|mdc)$/ }), }); - items.push(...result.items); - warnings.push(...(result.warnings ?? [])); - } + + const results = await Promise.all([ + ...getProjectPathCandidates(ctx, "rules").map(dir => load(dir, "project")), + ...getUserPathCandidates(ctx, "rules").map(dir => load(dir, "user")), + ]); + return { - items, - warnings, + items: results.flatMap(r => r.items), + warnings: results.flatMap(r => r.warnings ?? []), }; } registerProvider(ruleCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load rules from ~/.agent/rules (fallback ~/.agents/rules)", + description: "Load rules from .agent/rules and .agents/rules (project walk-up + user home)", priority: PRIORITY, load: loadRules, }); // Prompts async function loadPrompts(ctx: LoadContext): Promise> { - const items: Prompt[] = []; - const warnings: string[] = []; - for (const userPromptsDir of getUserAgentPathCandidates(ctx, "prompts")) { - const result = await loadFilesFromDir(ctx, userPromptsDir, PROVIDER_ID, "user", { + const load = (dir: string, level: "user" | "project") => + loadFilesFromDir(ctx, dir, PROVIDER_ID, level, { extensions: ["md"], transform: (name, content, filePath, source) => ({ name: name.replace(/\.md$/, ""), @@ -91,109 +108,106 @@ async function loadPrompts(ctx: LoadContext): Promise> { _source: source, }), }); - items.push(...result.items); - warnings.push(...(result.warnings ?? [])); - } + + const results = await Promise.all([ + ...getProjectPathCandidates(ctx, "prompts").map(dir => load(dir, "project")), + ...getUserPathCandidates(ctx, "prompts").map(dir => load(dir, "user")), + ]); + return { - items, - warnings, + items: results.flatMap(r => r.items), + warnings: results.flatMap(r => r.warnings ?? []), }; } registerProvider(promptCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load prompts from ~/.agent/prompts (fallback ~/.agents/prompts)", + description: "Load prompts from .agent/prompts and .agents/prompts (project walk-up + user home)", priority: PRIORITY, load: loadPrompts, }); // Slash Commands async function loadSlashCommands(ctx: LoadContext): Promise> { - const items: SlashCommand[] = []; - const warnings: string[] = []; - for (const userCommandsDir of getUserAgentPathCandidates(ctx, "commands")) { - const result = await loadFilesFromDir(ctx, userCommandsDir, PROVIDER_ID, "user", { + const load = (dir: string, level: "user" | "project") => + loadFilesFromDir(ctx, dir, PROVIDER_ID, level, { extensions: ["md"], transform: (name, content, filePath, source) => ({ name: name.replace(/\.md$/, ""), path: filePath, content, - level: "user", + level, _source: source, }), }); - items.push(...result.items); - warnings.push(...(result.warnings ?? [])); - } + + const results = await Promise.all([ + ...getProjectPathCandidates(ctx, "commands").map(dir => load(dir, "project")), + ...getUserPathCandidates(ctx, "commands").map(dir => load(dir, "user")), + ]); + return { - items, - warnings, + items: results.flatMap(r => r.items), + warnings: results.flatMap(r => r.warnings ?? []), }; } registerProvider(slashCommandCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load commands from ~/.agent/commands (fallback ~/.agents/commands)", + description: "Load commands from .agent/commands and .agents/commands (project walk-up + user home)", priority: PRIORITY, load: loadSlashCommands, }); // Context Files (AGENTS.md) async function loadContextFiles(ctx: LoadContext): Promise> { - const items: ContextFile[] = []; - for (const agentsPath of getUserAgentPathCandidates(ctx, "AGENTS.md")) { - const content = await readFile(agentsPath); - if (!content) { - continue; - } - items.push({ - path: agentsPath, - content, - level: "user", - _source: createSourceMeta(PROVIDER_ID, agentsPath, "user"), - }); - } - return { - items, - warnings: [], + const load = async (filePath: string, level: "user" | "project"): Promise => { + const content = await readFile(filePath); + if (!content) return null; + // filePath is /.agent(s)/AGENTS.md — go up past the config dir to the ancestor + const ancestorDir = path.dirname(path.dirname(filePath)); + const depth = level === "project" ? calculateDepth(ctx.cwd, ancestorDir, path.sep) : undefined; + return { path: filePath, content, level, depth, _source: createSourceMeta(PROVIDER_ID, filePath, level) }; }; + + const results = await Promise.all([ + ...getProjectPathCandidates(ctx, "AGENTS.md").map(p => load(p, "project")), + ...getUserPathCandidates(ctx, "AGENTS.md").map(p => load(p, "user")), + ]); + + return { items: results.filter((r): r is ContextFile => r !== null), warnings: [] }; } registerProvider(contextFileCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load AGENTS.md from ~/.agent (fallback ~/.agents)", + description: "Load AGENTS.md from .agent and .agents (project walk-up + user home)", priority: PRIORITY, load: loadContextFiles, }); // System Prompt (SYSTEM.md) async function loadSystemPrompt(ctx: LoadContext): Promise> { - const items: SystemPrompt[] = []; - for (const systemPath of getUserAgentPathCandidates(ctx, "SYSTEM.md")) { - const content = await readFile(systemPath); - if (!content) { - continue; - } - items.push({ - path: systemPath, - content, - level: "user", - _source: createSourceMeta(PROVIDER_ID, systemPath, "user"), - }); - } - return { - items, - warnings: [], + const load = async (filePath: string, level: "user" | "project"): Promise => { + const content = await readFile(filePath); + if (!content) return null; + return { path: filePath, content, level, _source: createSourceMeta(PROVIDER_ID, filePath, level) }; }; + + const results = await Promise.all([ + ...getProjectPathCandidates(ctx, "SYSTEM.md").map(p => load(p, "project")), + ...getUserPathCandidates(ctx, "SYSTEM.md").map(p => load(p, "user")), + ]); + + return { items: results.filter((r): r is SystemPrompt => r !== null), warnings: [] }; } registerProvider(systemPromptCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load SYSTEM.md from ~/.agent (fallback ~/.agents)", + description: "Load SYSTEM.md from .agent and .agents (project walk-up + user home)", priority: PRIORITY, load: loadSystemPrompt, }); diff --git a/packages/coding-agent/src/discovery/builtin.ts b/packages/coding-agent/src/discovery/builtin.ts index 72d8de9a6..7b8cb35f8 100644 --- a/packages/coding-agent/src/discovery/builtin.ts +++ b/packages/coding-agent/src/discovery/builtin.ts @@ -68,12 +68,13 @@ async function getConfigDirs(ctx: LoadContext): Promise { +function getAncestorDirs(cwd: string, stopAt?: string | null): Array<{ dir: string; depth: number }> { const ancestors: Array<{ dir: string; depth: number }> = []; let current = cwd; let depth = 0; while (true) { ancestors.push({ dir: current, depth }); + if (stopAt && current === stopAt) break; const parent = path.dirname(current); if (parent === current) break; current = parent; @@ -82,8 +83,11 @@ function getAncestorDirs(cwd: string): Array<{ dir: string; depth: number }> { return ancestors; } -async function findNearestProjectConfigDir(cwd: string): Promise<{ dir: string; depth: number } | null> { - for (const ancestor of getAncestorDirs(cwd)) { +async function findNearestProjectConfigDir( + cwd: string, + repoRoot?: string | null, +): Promise<{ dir: string; depth: number } | null> { + for (const ancestor of getAncestorDirs(cwd, repoRoot)) { const configDir = await ifNonEmptyDir(ancestor.dir, PATHS.projectDir); if (configDir) return { dir: configDir, depth: ancestor.depth }; } @@ -215,7 +219,7 @@ async function loadSystemPrompt(ctx: LoadContext): Promise(systemPromptCapability.id, { // Skills async function loadSkills(ctx: LoadContext): Promise> { - const configDirs = await getConfigDirs(ctx); - const results = await Promise.all( - configDirs.map(({ dir, level }) => - scanSkillsFromDir(ctx, { - dir: path.join(dir, "skills"), - providerId: PROVIDER_ID, - level, - requireDescription: true, - }), - ), + // Walk up from cwd finding .omp/skills/ in ancestors (closest first) + const ancestors = getAncestorDirs(ctx.cwd, ctx.repoRoot ?? ctx.home); + const projectScans = ancestors.map(({ dir }) => + scanSkillsFromDir(ctx, { + dir: path.join(dir, PATHS.projectDir, "skills"), + providerId: PROVIDER_ID, + level: "project", + requireDescription: true, + }), ); + // User-level scan from ~/.omp/agent/skills/ + const userScan = scanSkillsFromDir(ctx, { + dir: path.join(ctx.home, PATHS.userAgent, "skills"), + providerId: PROVIDER_ID, + level: "user", + requireDescription: true, + }); + + const results = await Promise.all([...projectScans, userScan]); + return { items: results.flatMap(r => r.items), warnings: results.flatMap(r => r.warnings ?? []), @@ -795,7 +808,7 @@ async function loadContextFiles(ctx: LoadContext): Promise> { const userSkillsDir = path.join(getUserClaude(ctx), "skills"); - const projectSkillsDir = path.join(getProjectClaude(ctx), "skills"); - const [userResult, projectResult] = await Promise.allSettled([ + // Walk up from cwd finding .claude/skills/ in ancestors + const projectScans: Promise>[] = []; + let current = ctx.cwd; + while (true) { + projectScans.push( + scanSkillsFromDir(ctx, { + dir: path.join(current, CONFIG_DIR, "skills"), + providerId: PROVIDER_ID, + level: "project", + }), + ); + if (current === (ctx.repoRoot ?? ctx.home)) break; + const parent = path.dirname(current); + if (parent === current) break; // filesystem root + current = parent; + } + + const [userResult, ...projectResults] = await Promise.allSettled([ scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }), - scanSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project" }), + ...projectScans, ]); const items: Skill[] = []; @@ -181,11 +197,13 @@ async function loadSkills(ctx: LoadContext): Promise> { warnings.push(`Failed to scan Claude user skills in ${userSkillsDir}: ${String(userResult.reason)}`); } - if (projectResult.status === "fulfilled") { - items.push(...projectResult.value.items); - warnings.push(...(projectResult.value.warnings ?? [])); - } else if (!isMissingDirectoryError(projectResult.reason)) { - warnings.push(`Failed to scan Claude project skills in ${projectSkillsDir}: ${String(projectResult.reason)}`); + for (const projectResult of projectResults) { + if (projectResult.status === "fulfilled") { + items.push(...projectResult.value.items); + warnings.push(...(projectResult.value.warnings ?? [])); + } else if (!isMissingDirectoryError(projectResult.reason)) { + warnings.push(`Failed to scan Claude project skills: ${String(projectResult.reason)}`); + } } return { items, warnings }; diff --git a/packages/coding-agent/src/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index 304aaf95e..0882ef95a 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -209,26 +209,33 @@ function extractMCPServersFromToml(toml: Record): Record> { const userSkillsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "skills"); - const codexDir = getProjectCodexDir(ctx); - const projectSkillsDir = path.join(codexDir, "skills"); + + // Walk up from cwd finding .codex/skills/ in ancestors + const projectScans: Promise>[] = []; + let current = ctx.cwd; + while (true) { + projectScans.push( + scanSkillsFromDir(ctx, { + dir: path.join(current, ".codex", "skills"), + providerId: PROVIDER_ID, + level: "project", + }), + ); + if (current === (ctx.repoRoot ?? ctx.home)) break; + const parent = path.dirname(current); + if (parent === current) break; // filesystem root + current = parent; + } const results = await Promise.all([ - scanSkillsFromDir(ctx, { - dir: userSkillsDir, - providerId: PROVIDER_ID, - level: "user", - }), - scanSkillsFromDir(ctx, { - dir: projectSkillsDir, - providerId: PROVIDER_ID, - level: "project", - }), + scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }), + ...projectScans, ]); - const items = results.flatMap(r => r.items); - const warnings = results.flatMap(r => r.warnings || []); - - return { items, warnings }; + return { + items: results.flatMap(r => r.items), + warnings: results.flatMap(r => r.warnings || []), + }; } // ============================================================================= diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index 5cb211666..dd4c8011f 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -144,8 +144,7 @@ async function loadContextFiles(ctx: LoadContext): Promise> { const userSkillsDir = getUserPath(ctx, "opencode", "skills"); - const projectSkillsDir = getProjectPath(ctx, "opencode", "skills"); const promises: Promise>[] = []; @@ -197,14 +197,25 @@ async function loadSkills(ctx: LoadContext): Promise> { ); } - if (projectSkillsDir) { - promises.push( - scanSkillsFromDir(ctx, { - dir: projectSkillsDir, - providerId: PROVIDER_ID, - level: "project", - }), - ); + // Walk up from cwd finding .opencode/skills/ in ancestors + const projectDir = SOURCE_PATHS.opencode.projectDir; + if (projectDir) { + const projectScans: Promise>[] = []; + let current = ctx.cwd; + while (true) { + projectScans.push( + scanSkillsFromDir(ctx, { + dir: path.join(current, projectDir, "skills"), + providerId: PROVIDER_ID, + level: "project", + }), + ); + if (current === (ctx.repoRoot ?? ctx.home)) break; + const parent = path.dirname(current); + if (parent === current) break; // filesystem root + current = parent; + } + promises.push(...projectScans); } const results = await Promise.all(promises); diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index 217669177..7309b04dc 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -40,7 +40,7 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom const providerId = rawProviderId || "custom"; const level: "user" | "project" = rawLevel === "project" ? "project" : "user"; const result = await scanSkillsFromDir( - { cwd: getProjectDir(), home: os.homedir() }, + { cwd: getProjectDir(), home: os.homedir(), repoRoot: null }, { dir: options.dir, providerId, @@ -175,7 +175,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise { const expandedDir = expandTilde(dir); const scanResult = await scanSkillsFromDir( - { cwd, home: os.homedir() }, + { cwd, home: os.homedir(), repoRoot: null }, { dir: expandedDir, providerId: "custom", diff --git a/packages/coding-agent/test/discovery/agents-monorepo-skills.test.ts b/packages/coding-agent/test/discovery/agents-monorepo-skills.test.ts new file mode 100644 index 000000000..f3d9e3bc7 --- /dev/null +++ b/packages/coding-agent/test/discovery/agents-monorepo-skills.test.ts @@ -0,0 +1,445 @@ +/** + * Tests that the agents provider walks up from cwd to find capabilities in ancestor + * .agent/ and .agents/ directories (project-level discovery). + * + * Instead of testing the full provider flow (which requires the entire capability registry), + * this test verifies the building blocks (scanSkillsFromDir, loadFilesFromDir, readFile) + * with the same walk-up pattern used by the agents provider. + */ +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 { clearCache, readFile } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import type { Skill } from "@oh-my-pi/pi-coding-agent/capability/skill"; +import type { LoadContext, LoadResult } from "@oh-my-pi/pi-coding-agent/capability/types"; +import { + buildRuleFromMarkdown, + calculateDepth, + createSourceMeta, + loadFilesFromDir, + scanSkillsFromDir, +} from "@oh-my-pi/pi-coding-agent/discovery/helpers"; +import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule"; + +const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; +const PROVIDER_ID = "agents"; + +function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { + const paths: string[] = []; + let current = ctx.cwd; + while (true) { + for (const baseDir of AGENT_DIR_CANDIDATES) { + paths.push(path.join(current, baseDir, ...segments)); + } + if (current === (ctx.repoRoot ?? ctx.home)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function writeSkill(dir: string, name: string, description: string): void { + const skillDir = path.join(dir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nSkill content.\n`, + ); +} + +function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +describe("agents provider project-level discovery", () => { + let tempDir!: string; + let repoRoot!: string; + let subProject!: string; + let ctx!: LoadContext; + + beforeEach(() => { + clearCache(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-agents-monorepo-")); + repoRoot = path.join(tempDir, "repo"); + subProject = path.join(repoRoot, "packages", "my-app"); + fs.mkdirSync(subProject, { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + ctx = { cwd: subProject, home: tempDir, repoRoot }; + }); + + afterEach(() => { + clearCache(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + // ========================================================================= + // Skills + // ========================================================================= + + describe("skills", () => { + test("finds .agents/skills in monorepo root from sub-project cwd", async () => { + writeSkill(path.join(repoRoot, ".agents", "skills"), "root-skill", "From repo root"); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("root-skill"); + }); + + test("finds .agent/skills in monorepo root from sub-project cwd", async () => { + writeSkill(path.join(repoRoot, ".agent", "skills"), "root-skill", "From repo root"); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("root-skill"); + }); + + test("finds skills at both sub-project and repo root, closest first", async () => { + writeSkill(path.join(subProject, ".agents", "skills"), "local-skill", "From sub-project"); + writeSkill(path.join(repoRoot, ".agents", "skills"), "root-skill", "From repo root"); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("local-skill"); + expect(names).toContain("root-skill"); + expect(names.indexOf("local-skill")).toBeLessThan(names.indexOf("root-skill")); + }); + + test("discovers skills from both .agent and .agents at same level", async () => { + writeSkill(path.join(repoRoot, ".agent", "skills"), "agent-skill", "From .agent"); + writeSkill(path.join(repoRoot, ".agents", "skills"), "agents-skill", "From .agents"); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("agent-skill"); + expect(names).toContain("agents-skill"); + }); + + test("walk-up stops at repo root", async () => { + writeSkill(path.join(tempDir, ".agents", "skills"), "above-repo-skill", "Above repo"); + writeSkill(path.join(repoRoot, ".agents", "skills"), "root-skill", "At repo root"); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("root-skill"); + expect(names).not.toContain("above-repo-skill"); + }); + + test("walk-up stops at home when no repo root", async () => { + // ctx without repoRoot + const noRepoCtx: LoadContext = { cwd: subProject, home: repoRoot, repoRoot: null }; + // Skill above home (should NOT be found) + writeSkill(path.join(tempDir, ".agents", "skills"), "above-home-skill", "Above home"); + // Skill at home (should be found) + writeSkill(path.join(repoRoot, ".agents", "skills"), "home-skill", "At home"); + + const results = await Promise.all( + getProjectPathCandidates(noRepoCtx, "skills").map(dir => + scanSkillsFromDir(noRepoCtx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("home-skill"); + expect(names).not.toContain("above-home-skill"); + }); + + test("returns empty when no ancestor has skills", async () => { + const results = await Promise.all( + getProjectPathCandidates(ctx, "skills").map(dir => + scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }), + ), + ); + expect(results.flatMap(r => r.items)).toHaveLength(0); + }); + }); + + // ========================================================================= + // Rules + // ========================================================================= + + describe("rules", () => { + test("finds .agents/rules in monorepo root from sub-project cwd", async () => { + writeFile(path.join(repoRoot, ".agents", "rules", "my-rule.md"), "# My Rule\n\nDo the thing."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "rules").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md", "mdc"], + transform: (name, content, filePath, source) => + buildRuleFromMarkdown(name, content, filePath, source, { + stripNamePattern: /\.(md|mdc)$/, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(r => r.name); + expect(names).toContain("my-rule"); + }); + + test("finds rules at both sub-project and repo root, closest first", async () => { + writeFile(path.join(subProject, ".agents", "rules", "local-rule.md"), "# Local\n\nLocal rule."); + writeFile(path.join(repoRoot, ".agents", "rules", "root-rule.md"), "# Root\n\nRoot rule."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "rules").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md", "mdc"], + transform: (name, content, filePath, source) => + buildRuleFromMarkdown(name, content, filePath, source, { + stripNamePattern: /\.(md|mdc)$/, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(r => r.name); + expect(names).toContain("local-rule"); + expect(names).toContain("root-rule"); + expect(names.indexOf("local-rule")).toBeLessThan(names.indexOf("root-rule")); + }); + + test("walk-up stops at repo root", async () => { + writeFile(path.join(tempDir, ".agents", "rules", "above-rule.md"), "# Above\n\nAbove rule."); + writeFile(path.join(repoRoot, ".agents", "rules", "root-rule.md"), "# Root\n\nRoot rule."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "rules").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md", "mdc"], + transform: (name, content, filePath, source) => + buildRuleFromMarkdown(name, content, filePath, source, { + stripNamePattern: /\.(md|mdc)$/, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(r => r.name); + expect(names).toContain("root-rule"); + expect(names).not.toContain("above-rule"); + }); + }); + + // ========================================================================= + // Prompts + // ========================================================================= + + describe("prompts", () => { + test("finds .agents/prompts in monorepo root from sub-project cwd", async () => { + writeFile(path.join(repoRoot, ".agents", "prompts", "my-prompt.md"), "You are a helpful assistant."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "prompts").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md"], + transform: (name, content, filePath, source) => ({ + name: name.replace(/\.md$/, ""), + path: filePath, + content, + _source: source, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(p => p.name); + expect(names).toContain("my-prompt"); + }); + + test("finds prompts at both sub-project and repo root, closest first", async () => { + writeFile(path.join(subProject, ".agents", "prompts", "local.md"), "Local prompt."); + writeFile(path.join(repoRoot, ".agents", "prompts", "root.md"), "Root prompt."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "prompts").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md"], + transform: (name, content, filePath, source) => ({ + name: name.replace(/\.md$/, ""), + path: filePath, + content, + _source: source, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(p => p.name); + expect(names).toContain("local"); + expect(names).toContain("root"); + expect(names.indexOf("local")).toBeLessThan(names.indexOf("root")); + }); + }); + + // ========================================================================= + // Commands + // ========================================================================= + + describe("commands", () => { + test("finds .agents/commands in monorepo root from sub-project cwd", async () => { + writeFile(path.join(repoRoot, ".agents", "commands", "deploy.md"), "Run the deploy pipeline."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "commands").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md"], + transform: (name, content, filePath, source) => ({ + name: name.replace(/\.md$/, ""), + path: filePath, + content, + level: "project" as const, + _source: source, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(c => c.name); + expect(names).toContain("deploy"); + }); + + test("finds commands at both sub-project and repo root, closest first", async () => { + writeFile(path.join(subProject, ".agents", "commands", "local-cmd.md"), "Local command."); + writeFile(path.join(repoRoot, ".agents", "commands", "root-cmd.md"), "Root command."); + + const results = await Promise.all( + getProjectPathCandidates(ctx, "commands").map(dir => + loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", { + extensions: ["md"], + transform: (name, content, filePath, source) => ({ + name: name.replace(/\.md$/, ""), + path: filePath, + content, + level: "project" as const, + _source: source, + }), + }), + ), + ); + const names = results.flatMap(r => r.items).map(c => c.name); + expect(names).toContain("local-cmd"); + expect(names).toContain("root-cmd"); + expect(names.indexOf("local-cmd")).toBeLessThan(names.indexOf("root-cmd")); + }); + }); + + // ========================================================================= + // Context Files (AGENTS.md) + // ========================================================================= + + describe("context files (AGENTS.md)", () => { + test("finds .agents/AGENTS.md in monorepo root from sub-project cwd", async () => { + writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Project Rules\n\nFollow these rules."); + + const paths = getProjectPathCandidates(ctx, "AGENTS.md"); + const results = await Promise.all(paths.map(p => readFile(p))); + const found = results.filter(r => r !== null); + expect(found).toHaveLength(1); + expect(found[0]).toContain("Project Rules"); + }); + + test("finds AGENTS.md at both sub-project and repo root", async () => { + writeFile(path.join(subProject, ".agents", "AGENTS.md"), "# Local Rules"); + writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Root Rules"); + + const paths = getProjectPathCandidates(ctx, "AGENTS.md"); + const results = await Promise.all(paths.map(p => readFile(p))); + const found = results.filter(r => r !== null); + expect(found).toHaveLength(2); + // Closest first (sub-project before root) + expect(found[0]).toContain("Local Rules"); + expect(found[1]).toContain("Root Rules"); + }); + + test("walk-up stops at repo root", async () => { + writeFile(path.join(tempDir, ".agents", "AGENTS.md"), "# Above Repo"); + writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Root Rules"); + + const paths = getProjectPathCandidates(ctx, "AGENTS.md"); + const results = await Promise.all(paths.map(p => readFile(p))); + const found = results.filter(r => r !== null); + expect(found).toHaveLength(1); + expect(found[0]).toContain("Root Rules"); + }); + + test("multi-level context files get distinct depth values for dedup", async () => { + writeFile(path.join(subProject, ".agents", "AGENTS.md"), "# Local Rules"); + writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Root Rules"); + + const paths = getProjectPathCandidates(ctx, "AGENTS.md"); + const items: Array<{ content: string; depth: number }> = []; + for (const p of paths) { + const content = await readFile(p); + if (!content) continue; + const ancestorDir = path.dirname(path.dirname(p)); + const depth = calculateDepth(ctx.cwd, ancestorDir, path.sep); + items.push({ content, depth }); + } + + expect(items).toHaveLength(2); + // Depths must differ so dedup keys are distinct + expect(items[0]!.depth).not.toBe(items[1]!.depth); + // Local (depth 0) before root (positive depth) + expect(items[0]!.depth).toBe(0); + expect(items[0]!.content).toContain("Local Rules"); + expect(items[1]!.depth).toBeGreaterThan(0); + expect(items[1]!.content).toContain("Root Rules"); + }); + }); + + // ========================================================================= + // System Prompt (SYSTEM.md) + // ========================================================================= + + describe("system prompt (SYSTEM.md)", () => { + test("finds .agents/SYSTEM.md in monorepo root from sub-project cwd", async () => { + writeFile(path.join(repoRoot, ".agents", "SYSTEM.md"), "You are a coding assistant."); + + const paths = getProjectPathCandidates(ctx, "SYSTEM.md"); + const results = await Promise.all(paths.map(p => readFile(p))); + const found = results.filter(r => r !== null); + expect(found).toHaveLength(1); + expect(found[0]).toContain("coding assistant"); + }); + + test("finds SYSTEM.md at both sub-project and repo root", async () => { + writeFile(path.join(subProject, ".agents", "SYSTEM.md"), "# Local System"); + writeFile(path.join(repoRoot, ".agents", "SYSTEM.md"), "# Root System"); + + const paths = getProjectPathCandidates(ctx, "SYSTEM.md"); + const results = await Promise.all(paths.map(p => readFile(p))); + const found = results.filter(r => r !== null); + expect(found).toHaveLength(2); + expect(found[0]).toContain("Local System"); + expect(found[1]).toContain("Root System"); + }); + + test("walk-up stops at repo root", async () => { + writeFile(path.join(tempDir, ".agents", "SYSTEM.md"), "# Above Repo"); + writeFile(path.join(repoRoot, ".agents", "SYSTEM.md"), "# Root System"); + + const paths = getProjectPathCandidates(ctx, "SYSTEM.md"); + const results = await Promise.all(paths.map(p => readFile(p))); + const found = results.filter(r => r !== null); + expect(found).toHaveLength(1); + expect(found[0]).toContain("Root System"); + }); + }); +}); diff --git a/packages/coding-agent/test/discovery/context-file-dedup.test.ts b/packages/coding-agent/test/discovery/context-file-dedup.test.ts new file mode 100644 index 000000000..0a3c102d3 --- /dev/null +++ b/packages/coding-agent/test/discovery/context-file-dedup.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file"; + +function makeContextFile(overrides: Partial & Pick): ContextFile { + return { + content: `content of ${overrides.path}`, + depth: undefined, + _source: { provider: "test", providerName: "Test", path: overrides.path, level: overrides.level }, + ...overrides, + }; +} + +describe("contextFileCapability.key", () => { + const key = contextFileCapability.key.bind(contextFileCapability); + + test("user-level files share the same key regardless of depth", () => { + const a = makeContextFile({ path: "/home/user/.omp/agent/AGENTS.md", level: "user" }); + const b = makeContextFile({ path: "/home/user/.claude/CLAUDE.md", level: "user" }); + expect(key(a)).toBe("user"); + expect(key(b)).toBe("user"); + expect(key(a)).toBe(key(b)); + }); + + test("project-level files at the same depth share the same key", () => { + const a = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 }); + const b = makeContextFile({ path: "/repo/.claude/CLAUDE.md", level: "project", depth: 0 }); + expect(key(a)).toBe(key(b)); + }); + + test("project-level files at different depths have different keys", () => { + const atCwd = makeContextFile({ path: "/repo/packages/app/AGENTS.md", level: "project", depth: 0 }); + const atParent = makeContextFile({ path: "/repo/packages/AGENTS.md", level: "project", depth: 1 }); + const atRoot = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 2 }); + + expect(key(atCwd)).not.toBe(key(atParent)); + expect(key(atParent)).not.toBe(key(atRoot)); + expect(key(atCwd)).not.toBe(key(atRoot)); + }); + + test("project-level file with no depth uses 0 as default", () => { + const withDepth = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 }); + const noDepth = makeContextFile({ path: "/repo/AGENTS.md", level: "project" }); + expect(key(withDepth)).toBe(key(noDepth)); + }); + + test("user key never collides with any project key", () => { + const user = makeContextFile({ path: "/home/user/.omp/AGENTS.md", level: "user" }); + for (let depth = 0; depth < 20; depth++) { + const project = makeContextFile({ path: `/repo/AGENTS.md`, level: "project", depth }); + expect(key(user)).not.toBe(key(project)); + } + }); +}); + +describe("contextFileCapability.validate", () => { + test("accepts valid context file", () => { + const file = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 }); + expect(contextFileCapability.validate!(file)).toBeUndefined(); + }); + + test("rejects missing path", () => { + const file = makeContextFile({ path: "", level: "project" }); + expect(contextFileCapability.validate!(file)).toBe("Missing path"); + }); +}); diff --git a/packages/coding-agent/test/discovery/monorepo-skills.test.ts b/packages/coding-agent/test/discovery/monorepo-skills.test.ts new file mode 100644 index 000000000..4e00e4af9 --- /dev/null +++ b/packages/coding-agent/test/discovery/monorepo-skills.test.ts @@ -0,0 +1,184 @@ +/** + * Tests that skill discovery walks up from cwd to find skills in ancestor config directories. + * + * Instead of testing the full provider flow (which requires the entire capability registry), + * this test verifies scanSkillsFromDir — the building block every provider uses — finds skills + * at different directory levels, and that the walk-up pattern produces the correct ordering. + */ +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 { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import type { Skill } from "@oh-my-pi/pi-coding-agent/capability/skill"; +import type { LoadContext, LoadResult } from "@oh-my-pi/pi-coding-agent/capability/types"; +import { scanSkillsFromDir } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; + +function writeSkill(dir: string, name: string, description: string): void { + const skillDir = path.join(dir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nSkill content.\n`, + ); +} + +describe("monorepo skill discovery", () => { + let tempDir!: string; + let repoRoot!: string; + let subProject!: string; + let ctx!: LoadContext; + + beforeEach(() => { + clearCache(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-monorepo-skills-")); + repoRoot = path.join(tempDir, "repo"); + subProject = path.join(repoRoot, "packages", "my-app"); + fs.mkdirSync(subProject, { recursive: true }); + // Create .git at repo root so findRepoRoot can detect it + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + ctx = { cwd: subProject, home: tempDir, repoRoot }; + }); + + afterEach(() => { + clearCache(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("finds skills in ancestor .omp/skills/ directories", async () => { + // Root has a skill + writeSkill(path.join(repoRoot, ".omp", "skills"), "root-skill", "From repo root"); + // Sub-project has a skill + writeSkill(path.join(subProject, ".omp", "skills"), "local-skill", "From sub-project"); + + // Simulate the walk-up pattern used by the builtin provider + const results: LoadResult[] = []; + let current = subProject; + while (true) { + const result = await scanSkillsFromDir(ctx, { + dir: path.join(current, ".omp", "skills"), + providerId: "native", + level: "project", + }); + results.push(result); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + const allItems = results.flatMap(r => r.items); + const names = allItems.map(s => s.name); + + // Both skills found + expect(names).toContain("local-skill"); + expect(names).toContain("root-skill"); + // Local skill appears first (closest to cwd wins on dedup) + expect(names.indexOf("local-skill")).toBeLessThan(names.indexOf("root-skill")); + }); + + test("closest skill wins when same name exists at multiple levels", async () => { + // Same skill name at root and sub-project + writeSkill(path.join(repoRoot, ".omp", "skills"), "shared-skill", "Root version"); + writeSkill(path.join(subProject, ".omp", "skills"), "shared-skill", "Local version"); + + const results: LoadResult[] = []; + let current = subProject; + while (true) { + const result = await scanSkillsFromDir(ctx, { + dir: path.join(current, ".omp", "skills"), + providerId: "native", + level: "project", + }); + results.push(result); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + const allItems = results.flatMap(r => r.items); + const sharedSkills = allItems.filter(s => s.name === "shared-skill"); + + // Both found (dedup happens at capability level, not here) + expect(sharedSkills).toHaveLength(2); + // Closest comes first — will win dedup + expect(sharedSkills[0]!.path).toContain("my-app"); + }); + + test("works when no ancestor has skills", async () => { + // No skills anywhere + const results: LoadResult[] = []; + let current = subProject; + while (true) { + const result = await scanSkillsFromDir(ctx, { + dir: path.join(current, ".omp", "skills"), + providerId: "native", + level: "project", + }); + results.push(result); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + const allItems = results.flatMap(r => r.items); + expect(allItems).toHaveLength(0); + }); + + test("finds skills across multiple ancestor levels", async () => { + // Three levels: repo root, packages/, and sub-project + const packagesDir = path.join(repoRoot, "packages"); + writeSkill(path.join(repoRoot, ".omp", "skills"), "root-skill", "Root"); + writeSkill(path.join(packagesDir, ".omp", "skills"), "packages-skill", "Packages"); + writeSkill(path.join(subProject, ".omp", "skills"), "app-skill", "App"); + + const results: LoadResult[] = []; + let current = subProject; + while (true) { + const result = await scanSkillsFromDir(ctx, { + dir: path.join(current, ".omp", "skills"), + providerId: "native", + level: "project", + }); + results.push(result); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("root-skill"); + expect(names).toContain("packages-skill"); + expect(names).toContain("app-skill"); + + // Ordering: closest first + expect(names.indexOf("app-skill")).toBeLessThan(names.indexOf("packages-skill")); + expect(names.indexOf("packages-skill")).toBeLessThan(names.indexOf("root-skill")); + }); + + test("walk-up stops at repo root and does not find skills above it", async () => { + // Skill ABOVE the repo root (should NOT be found) + writeSkill(path.join(tempDir, ".omp", "skills"), "above-repo-skill", "Above repo"); + // Skill AT the repo root (should be found) + writeSkill(path.join(repoRoot, ".omp", "skills"), "root-skill", "At repo root"); + + // Simulate the walk-up with repo root boundary (matching builtin provider pattern) + const results: LoadResult[] = []; + let current = subProject; + while (true) { + const result = await scanSkillsFromDir(ctx, { + dir: path.join(current, ".omp", "skills"), + providerId: "native", + level: "project", + }); + results.push(result); + if (current === (ctx.repoRoot ?? ctx.home)) break; // stop at repo root or home + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + const names = results.flatMap(r => r.items).map(s => s.name); + expect(names).toContain("root-skill"); + expect(names).not.toContain("above-repo-skill"); + }); +}); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index fe3f2b3f0..2114ad1d9 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -183,7 +183,7 @@ describe("skills", () => { const claudeProvider = capability?.providers.find(provider => provider.id === "claude"); expect(claudeProvider).toBeDefined(); - const result = await claudeProvider!.load({ cwd: tempProjectDir, home: tempHomeDir }); + const result = await claudeProvider!.load({ cwd: tempProjectDir, home: tempHomeDir, repoRoot: null }); expect(result.items.some(skill => skill.name === "user-only-skill" && skill.level === "user")).toBe(true); } finally { await fs.rm(tempProjectDir, { recursive: true, force: true });