diff --git a/docs/context-files.md b/docs/context-files.md index 8be48caba..814734763 100644 --- a/docs/context-files.md +++ b/docs/context-files.md @@ -54,7 +54,7 @@ Put broad, durable project background in `AGENTS.md`. Reserve `RULES.md` for sho ## Other supported context conventions -`omp` also discovers the context files of other agent tools so existing projects keep working without migration. The table lists context-file behavior only; each provider may also contribute other capabilities. +`omp` also discovers the context and rule files of other agent tools so existing projects keep working without migration. | Provider id | Convention path | Scope | Notes | |---|---|---|---| @@ -66,6 +66,7 @@ Put broad, durable project background in `AGENTS.md`. Reserve `RULES.md` for sho | `github` | `.github/copilot-instructions.md` | Project | Project-only GitHub Copilot instructions from `/.github/copilot-instructions.md`. | | `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. | +| `github` | `.github/instructions/**/*.instructions.md` | Project rules | GitHub Copilot / VS Code instruction files become rules. `applyTo: '*'` or `applyTo: '**'` is injected as always-apply context; other `applyTo` globs are listed in the rulebook with `description` and are readable as `rule://`. | 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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce58d520d..69abab9ea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot `.github/instructions/*.instructions.md` discovery by loading those files as rules that honor `applyTo` scoping, including always-apply `**` files and `rule://` access for glob-scoped entries ([#2731](https://github.com/can1357/oh-my-pi/issues/2731)). + ## [16.0.1] - 2026-06-15 ### Breaking Changes diff --git a/packages/coding-agent/src/discovery/github.ts b/packages/coding-agent/src/discovery/github.ts index f61d0fe03..eed93d486 100644 --- a/packages/coding-agent/src/discovery/github.ts +++ b/packages/coding-agent/src/discovery/github.ts @@ -11,7 +11,7 @@ * * Capabilities: * - context-files: copilot-instructions.md in .github/ and ~/.copilot/; AGENTS.md in each COPILOT_CUSTOM_INSTRUCTIONS_DIRS - * - instructions: *.instructions.md under .github/instructions/ (project) and /.github/instructions/ for each custom dir (applyTo frontmatter) + * - rules: *.instructions.md under .github/instructions/ and /.github/instructions/ for each custom dir (applyTo frontmatter) * - prompts: *.prompt.md in .github/prompts/ (VS Code Copilot prompt files) * - skills: /SKILL.md in .github/skills/ (GitHub Agent Skills layout) */ @@ -22,10 +22,12 @@ import { type ContextFile, contextFileCapability } from "../capability/context-f import { readFile } from "../capability/fs"; import { type Instruction, instructionCapability } from "../capability/instruction"; import { type Prompt, promptCapability } from "../capability/prompt"; +import { type Rule, ruleCapability } from "../capability/rule"; import { type Skill, skillCapability } from "../capability/skill"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { + buildRuleFromMarkdown, calculateDepth, createSourceMeta, getProjectPath, @@ -152,6 +154,91 @@ function transformInstruction(name: string, content: string, filePath: string, s }; } +// ============================================================================= +// Rules +// ============================================================================= + +async function loadRules(ctx: LoadContext): Promise> { + const items: Rule[] = []; + const warnings: string[] = []; + + const load = async (dir: string, level: "user" | "project") => { + const applyToWarnings: string[] = []; + const result = await loadFilesFromDir(ctx, dir, PROVIDER_ID, level, { + extensions: ["md"], + transform: (name, content, filePath, source) => + transformInstructionRule(name, content, filePath, source, applyToWarnings), + recursive: true, + }); + items.push(...result.items); + if (result.warnings) warnings.push(...result.warnings); + warnings.push(...applyToWarnings); + }; + + const instructionsDir = getProjectPath(ctx, "github", "instructions"); + if (instructionsDir) { + await load(instructionsDir, "project"); + } + + for (const dir of copilotCustomInstructionDirs()) { + await load(path.join(dir, ".github", "instructions"), "user"); + } + + return { items, warnings }; +} + +function transformInstructionRule( + name: string, + content: string, + filePath: string, + source: SourceMeta, + warnings: string[], +): Rule | null { + if (!name.endsWith(".instructions.md")) { + return null; + } + + const { frontmatter } = parseFrontmatter(content, { source: filePath }); + const applyToGlobs = normalizeApplyToGlobs(frontmatter.applyTo); + if (!applyToGlobs) { + warnings.push(`Missing applyTo in ${filePath}; loaded without GitHub glob scoping.`); + } + + const rule = buildRuleFromMarkdown(name, content, filePath, source, { + stripNamePattern: /\.instructions\.md$/, + }); + if (applyToGlobs?.some(isAlwaysApplyGlob)) { + return { ...rule, alwaysApply: true, globs: undefined }; + } + + const description = rule.description ?? describeInstructionRule(applyToGlobs); + return { ...rule, alwaysApply: false, globs: applyToGlobs, description }; +} + +function normalizeApplyToGlobs(value: unknown): string[] | undefined { + if (typeof value === "string") { + const glob = value.trim(); + return glob ? [glob] : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + const globs = value + .filter((item): item is string => typeof item === "string") + .map(item => item.trim()) + .filter(Boolean); + return globs.length > 0 ? globs : undefined; +} + +function isAlwaysApplyGlob(glob: string): boolean { + return glob === "*" || glob === "**"; +} + +function describeInstructionRule(globs: string[] | undefined): string { + if (!globs) return "GitHub Copilot instructions without applyTo metadata"; + return `GitHub Copilot instructions for ${globs.join(", ")}`; +} + // ============================================================================= // Prompts // ============================================================================= @@ -232,6 +319,13 @@ registerProvider(instructionCapability.id, { load: loadInstructions, }); +registerProvider(ruleCapability.id, { + id: PROVIDER_ID, + displayName: DISPLAY_NAME, + description: "Load *.instructions.md from .github/instructions/ as Copilot-scoped rules", + priority: PRIORITY, + load: loadRules, +}); registerProvider(skillCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 08eed1ab7..0a485216c 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -615,7 +615,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): resolvedCustomPrompt, resolvedAppendPrompt, ]); - const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt]; + const contextPromptSources = contextFiles.map(file => file.content); + const promptSources = [ + effectiveSystemPromptCustomization, + resolvedCustomPrompt, + resolvedAppendPrompt, + ...contextPromptSources, + ]; const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources); const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo); diff --git a/packages/coding-agent/test/discovery/github-copilot.test.ts b/packages/coding-agent/test/discovery/github-copilot.test.ts index 8a0b92059..846c282d5 100644 --- a/packages/coding-agent/test/discovery/github-copilot.test.ts +++ b/packages/coding-agent/test/discovery/github-copilot.test.ts @@ -12,14 +12,17 @@ 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 { loadCapability } from "@oh-my-pi/pi-coding-agent/capability"; +import { loadCapability, setDisabledProviders } from "@oh-my-pi/pi-coding-agent/capability"; import type { ContextFile } from "@oh-my-pi/pi-coding-agent/capability/context-file"; import { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; import type { Instruction } from "@oh-my-pi/pi-coding-agent/capability/instruction"; import type { Prompt } from "@oh-my-pi/pi-coding-agent/capability/prompt"; +import { type Rule, resetActiveRulesForTests, setActiveRules } from "@oh-my-pi/pi-coding-agent/capability/rule"; +import { RuleProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/rule-protocol"; import "@oh-my-pi/pi-coding-agent/capability/context-file"; import "@oh-my-pi/pi-coding-agent/capability/instruction"; import "@oh-my-pi/pi-coding-agent/capability/prompt"; +import "@oh-my-pi/pi-coding-agent/capability/rule"; import "@oh-my-pi/pi-coding-agent/discovery/github"; const ENV_KEYS = ["COPILOT_HOME", "COPILOT_CUSTOM_INSTRUCTIONS_DIRS"] as const; @@ -48,6 +51,8 @@ describe("github discovery — Copilot user-global surface", () => { afterEach(() => { clearCache(); + resetActiveRulesForTests(); + setDisabledProviders([]); for (const key of ENV_KEYS) { if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; @@ -133,4 +138,47 @@ describe("github discovery — Copilot user-global surface", () => { expect(review?._source.level).toBe("project"); expect(result.all.find(p => p.name === "notes")).toBeUndefined(); }); + + test("loads project .github/instructions/*.instructions.md as Copilot-scoped rules (#2731)", async () => { + write( + path.join(cwd, ".github", "instructions", "always.instructions.md"), + "---\napplyTo: '**'\ndescription: Always guidance\n---\nAlways body\n", + ); + write( + path.join(cwd, ".github", "instructions", "cs.instructions.md"), + "---\napplyTo: '**/*.cs'\ndescription: C# guidance\n---\nC# body\n", + ); + + const result = await loadCapability("rules", { cwd, providers: ["github"] }); + + const always = result.items.find(rule => rule.name === "always"); + expect(always?.alwaysApply).toBe(true); + expect(always?.globs).toBeUndefined(); + expect(always?.content.trim()).toBe("Always body"); + + const scoped = result.items.find(rule => rule.name === "cs"); + expect(scoped?.alwaysApply).toBe(false); + expect(scoped?.globs).toEqual(["**/*.cs"]); + expect(scoped?.description).toBe("C# guidance"); + setActiveRules(result.items); + const resource = await new RuleProtocolHandler().resolve(Object.assign(new URL("rule://cs"), { rawHost: "cs" })); + expect(resource.content.trim()).toBe("C# body"); + }); + + test("disabled github provider suppresses copilot instructions and instruction-file rules (#2731)", async () => { + write(path.join(cwd, ".github", "copilot-instructions.md"), "project guidance"); + write( + path.join(cwd, ".github", "instructions", "always.instructions.md"), + "---\napplyTo: '**'\n---\nAlways body\n", + ); + setDisabledProviders(["github"]); + + const contextFiles = await loadCapability("context-files", { cwd, providers: ["github"] }); + const instructions = await loadCapability("instructions", { cwd, providers: ["github"] }); + const rules = await loadCapability("rules", { cwd, providers: ["github"] }); + + expect(contextFiles.all).toHaveLength(0); + expect(instructions.all).toHaveLength(0); + expect(rules.all).toHaveLength(0); + }); }); diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index 92fdd84d2..f351d6421 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -124,4 +124,28 @@ describe("SYSTEM.md prompt assembly", () => { expect(promptText).toContain("Root context instructions"); expect(promptText).toContain("Near context instructions"); }); + + it("drops always-apply rule content already present through expanded context imports", async () => { + const projectDir = path.join(tempDir, "project"); + const instructionPath = path.join(projectDir, ".github", "instructions", "shared.instructions.md"); + const sharedContent = "Shared imported guidance"; + fs.mkdirSync(path.dirname(instructionPath), { recursive: true }); + fs.writeFileSync(path.join(projectDir, "AGENTS.md"), "Use @.github/instructions/shared.instructions.md\n"); + fs.writeFileSync(instructionPath, `---\napplyTo: '**'\n---\n\n${sharedContent}\n`); + + const contextFiles = await loadProjectContextFiles({ cwd: projectDir }); + const { systemPrompt } = await buildSystemPrompt({ + cwd: projectDir, + customPrompt: "Base prompt", + contextFiles, + skills: [], + rules: [], + alwaysApplyRules: [{ name: "shared", path: instructionPath, content: sharedContent }], + toolNames: [], + }); + + const promptText = systemPrompt.join("\n\n"); + const matches = promptText.match(new RegExp(escapeRegExp(sharedContent), "g")) ?? []; + expect(matches).toHaveLength(1); + }); });