diff --git a/packages/core/src/services/generator.ts b/packages/core/src/services/generator.ts index 2a1945b9..36380d48 100644 --- a/packages/core/src/services/generator.ts +++ b/packages/core/src/services/generator.ts @@ -20,6 +20,8 @@ export type GenerateOptions = { selections: string[]; force: boolean; dryRun?: boolean; + /** Relative path of the generated instruction file referenced by VS Code settings. Defaults to `.github/copilot-instructions.md`. */ + instructionFile?: string; }; async function writeOrPreview( @@ -58,7 +60,7 @@ export async function generateConfigs(options: GenerateOptions): Promise" → "# " using the provided + * component name (area/crate) when available, otherwise the name in the heading. + * Returns the content unchanged when no "Copilot Instructions" heading is present. + */ +export function normalizeAgentsHeading(content: string, componentName?: string): string { + // Only consider the first line as the document's top-level heading. + const headingRe = /^(?:\uFEFF)?#\s+Copilot Instructions(?:\s*:\s*(.*))?\s*$/iu; + const firstLineEnd = content.indexOf("\n"); + const firstLine = (firstLineEnd === -1 ? content : content.slice(0, firstLineEnd)).trimEnd(); + const match = headingRe.exec(firstLine); + if (!match) return content; + + const fallback = match[1]?.trim(); + const heading = componentName?.trim() || fallback; + if (!heading) return content; + + const replacedFirstLine = firstLine.replace(headingRe, `# ${heading}`); + return firstLineEnd === -1 ? replacedFirstLine : `${replacedFirstLine}\n${content.slice(firstLineEnd + 1)}`; +} + /** * Strip outer markdown code fences that LLMs sometimes wrap around generated file content. * Only removes a single outer fence (```markdown or bare ```) — internal fences are preserved. @@ -294,6 +337,8 @@ type GenerateInstructionsOptions = { strategy?: InstructionStrategy; detailDir?: string; claudeMd?: boolean; + /** Content of the already-generated root instruction file, used to avoid duplicating it in nested outputs. */ + rootContent?: string; }; export async function generateCopilotInstructions( @@ -392,6 +437,8 @@ type GenerateAreaInstructionsOptions = { strategy?: InstructionStrategy; detailDir?: string; claudeMd?: boolean; + /** Content of the already-generated root instruction file, used to avoid duplicating it in area outputs. */ + rootContent?: string; }; export async function generateAreaInstructions( @@ -700,6 +747,7 @@ async function generateNestedHub( childAreas?: Area[]; model?: string; onProgress?: (message: string) => void; + rootContent?: string; } ): Promise { const progress = options.onProgress ?? (() => {}); @@ -763,12 +811,13 @@ async function generateNestedHub( : ""; // Invoke the nested-hub skill with repo/area-specific context + const rootContextSection = buildRootContextSection(options.rootContent); const prompt = `/nested-hub Generate a lean AGENTS.md hub file (~90-120 lines).${areaContext}${parentContext} Detail files go in \`${options.detailDir}/\`.${childContext} Recommend 3-5 topics for deep-dive detail files. Each slug becomes: \`${options.detailDir}/{slug}.md\`. -${existingSection ? `\nDo NOT duplicate content from existing instruction files\n${existingSection}` : ""}`; +${rootContextSection}${existingSection ? `\nDo NOT duplicate content from existing instruction files\n${existingSection}` : ""}`; let sendError: unknown; try { @@ -800,6 +849,7 @@ async function generateNestedDetail( area?: Area; model?: string; onProgress?: (message: string) => void; + rootContent?: string; } ): Promise { const progress = options.onProgress ?? (() => {}); @@ -848,8 +898,9 @@ async function generateNestedDetail( : "Focus on the entire repository."; // Invoke the nested-detail skill with topic-specific context + const rootContextSection = buildRootContextSection(options.rootContent); const prompt = `/nested-detail Generate a deep-dive instruction file about "${options.topic.title}" for this codebase. -${areaContext} +${areaContext}${rootContextSection} Topic: ${options.topic.title} Description: ${options.topic.description}`; @@ -903,17 +954,23 @@ export async function generateNestedInstructions( area: options.area, childAreas: options.childAreas, model: options.model, - onProgress: options.onProgress + onProgress: options.onProgress, + rootContent: options.rootContent }); // Determine output paths const basePath = options.area?.path ?? "."; const hubRelativePath = path.join(basePath, "AGENTS.md"); + // Normalize the heading so generated AGENTS.md files don't carry a + // copilot-instructions.md title (e.g. "# Copilot Instructions: crate"). + const componentName = options.area?.name ?? path.basename(options.repoPath); + const normalizedHubContent = normalizeAgentsHeading(hubContent, componentName); + // Hub content: prepend frontmatter if area-scoped - let finalHubContent = hubContent; + let finalHubContent = normalizedHubContent; if (options.area) { - finalHubContent = `${buildAreaFrontmatter(options.area)}\n\n${hubContent}`; + finalHubContent = `${buildAreaFrontmatter(options.area)}\n\n${normalizedHubContent}`; } const result: NestedInstructionsResult = { @@ -931,7 +988,8 @@ export async function generateNestedInstructions( topic, area: options.area, model: options.model, - onProgress: options.onProgress + onProgress: options.onProgress, + rootContent: options.rootContent }); if (detailContent) { result.details.push({ @@ -980,6 +1038,7 @@ export async function generateNestedAreaInstructions( model: options.model, onProgress: options.onProgress, detailDir: options.detailDir, - claudeMd: options.claudeMd + claudeMd: options.claudeMd, + rootContent: options.rootContent }); } diff --git a/plugin/skills/nested-hub/SKILL.md b/plugin/skills/nested-hub/SKILL.md index 942bd213..3da399b3 100644 --- a/plugin/skills/nested-hub/SKILL.md +++ b/plugin/skills/nested-hub/SKILL.md @@ -16,6 +16,27 @@ The hub should contain: - Coding conventions and guardrails - A "## Detailed Instructions" section listing links to detail files +## Root Context + +The prompt may include a `## Root instructions already cover` section containing the workspace-wide content from the root instruction file. That content is already loaded by AI agents from the root file: + +- **Do NOT repeat or restate it** — not as prose, not as bullets, not as paraphrased summaries. +- **Cover only this area/crate's unique details** that are not addressed by the root file. +- If a topic is covered at the root, reference it with a single link (e.g. `See [AGENTS.md](../../AGENTS.md)`). + +## Area / Crate Hubs + +When generating a hub for a specific area or crate, include **only crate-unique context**: + +- Crate purpose and summary +- File structure → responsibility mapping +- Crate-specific conventions (e.g. binary resolution, auth patterns, helper APIs) +- Internal consumers / dependencies (only if non-obvious) + +Everything that applies workspace-wide — workspace dependencies, error types, logging conventions, testing setup, and standard build/test commands — lives exclusively in the root instruction file and must **not** be duplicated here. + +Use a simple heading that matches the component, e.g. `# crate`. Do **not** use a "Copilot Instructions:" heading for AGENTS.md files. + ## Topic Recommendations At the **end** of your output, emit a fenced JSON block with recommended topics for detail files: @@ -37,7 +58,7 @@ Recommend 3-5 topics that would benefit from deep-dive detail files. Each slug b - Keep the hub **lean** — overview and guardrails only, details go in separate files - The JSON block will be parsed and removed from the final output -- Do **not** duplicate content from existing instruction files +- Do **not** duplicate content from existing instruction files or the root instruction content ## Output Contract diff --git a/src/commands/instructions.ts b/src/commands/instructions.ts index e7a1297c..3bcf7322 100644 --- a/src/commands/instructions.ts +++ b/src/commands/instructions.ts @@ -75,6 +75,10 @@ export async function instructionsCommand(options: InstructionsOptions): Promise try { const dryRunFiles: { path: string; bytes: number }[] = []; + // Root instruction content, propagated to per-area generation so area/crate + // files don't duplicate what the root file already covers. + let rootContent: string | undefined; + // Generate root instructions unless --areas-only if (!options.areasOnly && !options.area) { if (strategy === "nested") { @@ -88,6 +92,7 @@ export async function instructionsCommand(options: InstructionsOptions): Promise detailDir, claudeMd }); + rootContent = nestedResult.hub.content; if (options.dryRun) { const dryFiles = [ { path: nestedResult.hub.relativePath, content: nestedResult.hub.content }, @@ -283,7 +288,8 @@ export async function instructionsCommand(options: InstructionsOptions): Promise model: options.model, onProgress: shouldLog(options) ? (msg) => progress.update(msg) : undefined, detailDir, - claudeMd + claudeMd, + rootContent }); if (options.dryRun) { const dryFiles = [ diff --git a/src/services/__tests__/generator.test.ts b/src/services/__tests__/generator.test.ts index 72352776..1b0109ba 100644 --- a/src/services/__tests__/generator.test.ts +++ b/src/services/__tests__/generator.test.ts @@ -80,6 +80,39 @@ describe("generateConfigs", () => { expect(reviewText).toContain("repo conventions"); }); + it("references the selected instruction file in vscode settings", async () => { + const analysis = makeAnalysis(); + await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["vscode"], + force: false, + instructionFile: "AGENTS.md" + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "settings.json"), "utf8"); + const parsed = JSON.parse(content); + expect(parsed["github.copilot.chat.codeGeneration.instructions"]).toEqual([ + { file: "AGENTS.md" } + ]); + }); + + it("defaults vscode settings to .github/copilot-instructions.md", async () => { + const analysis = makeAnalysis(); + await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["vscode"], + force: false + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "settings.json"), "utf8"); + const parsed = JSON.parse(content); + expect(parsed["github.copilot.chat.codeGeneration.instructions"]).toEqual([ + { file: ".github/copilot-instructions.md" } + ]); + }); + it("skips existing files without force", async () => { await fs.mkdir(path.join(tmpDir, ".vscode"), { recursive: true }); await fs.writeFile(path.join(tmpDir, ".vscode", "mcp.json"), "original", "utf8"); diff --git a/src/services/__tests__/instructions.test.ts b/src/services/__tests__/instructions.test.ts index cda36232..69eb44d3 100644 --- a/src/services/__tests__/instructions.test.ts +++ b/src/services/__tests__/instructions.test.ts @@ -9,6 +9,7 @@ import { generateAreaInstructions, generateCopilotInstructions, generateNestedInstructions, + generateNestedAreaInstructions, writeAreaInstruction, writeInstructionFile, writeNestedInstructions, @@ -17,6 +18,8 @@ import { areaInstructionPath, detectExistingInstructions, buildExistingInstructionsSection, + buildRootContextSection, + normalizeAgentsHeading, parseTopicsFromHub, stripMarkdownFences } from "@agentrc/core/services/instructions"; @@ -431,6 +434,23 @@ describe("buildExistingInstructionsSection", () => { }); }); +describe("buildRootContextSection", () => { + it("returns empty string when no root content is provided", () => { + expect(buildRootContextSection()).toBe(""); + expect(buildRootContextSection("")).toBe(""); + expect(buildRootContextSection(" ")).toBe(""); + }); + + it("embeds the root content under a do-not-repeat header", () => { + const root = "# Monorepo\n\nWorkspace crates use `{ workspace = true }`."; + const section = buildRootContextSection(root); + expect(section).toContain("## Root instructions already cover"); + expect(section).toContain("Do NOT repeat or restate it"); + expect(section).toContain("# Monorepo"); + expect(section).toContain("`{ workspace = true }`"); + }); +}); + describe("writeInstructionFile", () => { let tmpDir: string; @@ -739,6 +759,29 @@ describe("stripMarkdownFences", () => { }); }); +describe("normalizeAgentsHeading", () => { + it("returns content unchanged when no Copilot Instructions heading is present", () => { + const content = "# git crate\n\nBody."; + expect(normalizeAgentsHeading(content, "git")).toBe(content); + }); + + it("rewrites the heading to the component name", () => { + expect(normalizeAgentsHeading("# Copilot Instructions: git crate", "git")).toBe("# git"); + }); + + it("falls back to the name embedded in the heading when no component name", () => { + expect(normalizeAgentsHeading("# Copilot Instructions: git crate")).toBe("# git crate"); + }); + + it("matches the heading case-insensitively", () => { + expect(normalizeAgentsHeading("# copilot instructions: git crate", "git")).toBe("# git"); + }); + + it("returns content unchanged when heading has no name and no component name", () => { + expect(normalizeAgentsHeading("# Copilot Instructions")).toBe("# Copilot Instructions"); + }); +}); + describe("instruction generation sessions", () => { let tmpDir: string; @@ -1051,4 +1094,42 @@ describe("instruction generation sessions", () => { }) ).rejects.toThrow("Copilot CLI not logged in. Run `copilot` then `/login` to authenticate."); }); + + it("propagates root content and normalizes headings for nested area generation", async () => { + const area: Area = { + name: "git", + applyTo: "crates/git/**", + path: path.join(tmpDir, "crates", "git"), + source: "auto" + }; + const hub = createMockSession(); + const { createSession } = mockClient([hub.session]); + mockSdkTools(); + + const rootContent = "# Monorepo\n\nWorkspace crates use `{ workspace = true }`."; + let sentPrompt = ""; + + hub.session.sendAndWait.mockImplementation(async (args: { prompt: string }) => { + sentPrompt = args.prompt; + const [config] = createSession.mock.calls[0] as unknown as [ + { tools: Array<{ handler: Function }> } + ]; + await config.tools[0].handler({ + content: "```markdown\n# Copilot Instructions: git crate\n\nCrate-specific.\n```" + }); + }); + + const result = await generateNestedAreaInstructions({ + repoPath: tmpDir, + area, + detailDir: ".agents", + claudeMd: false, + rootContent + }); + + expect(result.hub.content).toContain("# git"); + expect(result.hub.content).not.toContain("Copilot Instructions"); + expect(sentPrompt).toContain("## Root instructions already cover"); + expect(sentPrompt).toContain("Workspace crates use `{ workspace = true }`"); + }); });