Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions packages/core/src/services/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -58,7 +60,7 @@ export async function generateConfigs(options: GenerateOptions): Promise<Generat
files.push(
await writeOrPreview(
path.join(repoPath, ".vscode", "settings.json"),
renderVscodeSettings(analysis),
renderVscodeSettings(analysis, options.instructionFile),
{ dryRun, force }
)
);
Expand Down Expand Up @@ -96,16 +98,17 @@ function renderMcp(): string {
);
}

function renderVscodeSettings(analysis: RepoAnalysis): string {
function renderVscodeSettings(
analysis: RepoAnalysis,
instructionFile = ".github/copilot-instructions.md"
): string {
Comment thread
dsk-dev-ai marked this conversation as resolved.
const reviewFocus = analysis.frameworks.length
? `Focus on ${analysis.frameworks.join(", ")} best practices and repo conventions.`
: "Focus on repo conventions and maintainability.";

return JSON.stringify(
{
"github.copilot.chat.codeGeneration.instructions": [
{ file: ".github/copilot-instructions.md" }
],
"github.copilot.chat.codeGeneration.instructions": [{ file: instructionFile }],
"github.copilot.chat.reviewSelection.instructions": [{ text: reviewFocus }],
"chat.promptFiles": true,
"chat.mcp.enabled": true
Expand Down
73 changes: 66 additions & 7 deletions packages/core/src/services/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,49 @@ export function buildExistingInstructionsSection(ctx: ExistingInstructionsContex
return lines.join("\n");
}

/**
* Build a prompt section embedding the freshly-generated root instruction content
* so nested (per-area / per-crate) generation knows what the root file already
* covers and avoids restating it. Emits nothing when no root content is provided.
*/
export function buildRootContextSection(rootContent?: string): string {
const trimmed = rootContent?.trim();
if (!trimmed) return "";

return [
"",
"## Root instructions already cover",
"The root instruction file already covers the following workspace-wide content. " +
"Do NOT repeat or restate it in this file; cover only this area/crate's unique details:",
"",
trimmed,
""
].join("\n");
}

/**
* Normalize the top-level heading of generated AGENTS.md content so it matches
* the file type instead of leaking copilot-instructions.md conventions.
* Rewrites "# Copilot Instructions: <name>" → "# <name>" 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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -700,6 +747,7 @@ async function generateNestedHub(
childAreas?: Area[];
model?: string;
onProgress?: (message: string) => void;
rootContent?: string;
}
): Promise<HubResult> {
const progress = options.onProgress ?? (() => {});
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -800,6 +849,7 @@ async function generateNestedDetail(
area?: Area;
model?: string;
onProgress?: (message: string) => void;
rootContent?: string;
}
): Promise<string> {
const progress = options.onProgress ?? (() => {});
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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> 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 = {
Expand All @@ -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({
Expand Down Expand Up @@ -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
});
}
23 changes: 22 additions & 1 deletion plugin/skills/nested-hub/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> 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:
Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion src/commands/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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 },
Expand Down Expand Up @@ -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 = [
Expand Down
33 changes: 33 additions & 0 deletions src/services/__tests__/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading