diff --git a/src/cli.ts b/src/cli.ts index eeb9ee1..2389662 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,6 +74,7 @@ export function runCli(argv: string[]): void { .option("--force", "Overwrite existing files") .option("--per-app", "Generate per-app in monorepos") .option("--model ", "Model for instructions generation", DEFAULT_MODEL) + .option("--strategy ", "Instruction strategy (flat or nested)") .action(withGlobalOpts(generateCommand)); program @@ -116,6 +117,8 @@ export function runCli(argv: string[]): void { .option("--areas", "Also generate file-based instructions for detected areas") .option("--areas-only", "Generate only file-based area instructions (skip root)") .option("--area ", "Generate file-based instructions for a specific area") + .option("--strategy ", "Instruction strategy (flat or nested)") + .option("--claude-md", "Generate CLAUDE.md files alongside AGENTS.md (nested strategy)") .action(withGlobalOpts(instructionsCommand)); program diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 468e0ec..65b5b50 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -14,6 +14,7 @@ type GenerateOptions = { model?: string; json?: boolean; quiet?: boolean; + strategy?: string; }; export async function generateCommand( @@ -33,7 +34,8 @@ export async function generateCommand( model: options.model, json: options.json, quiet: options.quiet, - areas: options.perApp + areas: options.perApp, + strategy: options.strategy }); return; } diff --git a/src/commands/instructions.ts b/src/commands/instructions.ts index 630ca29..004ed1e 100644 --- a/src/commands/instructions.ts +++ b/src/commands/instructions.ts @@ -1,15 +1,25 @@ import path from "path"; -import { analyzeRepo } from "../services/analyzer"; +import { analyzeRepo, loadAgentrcConfig } from "../services/analyzer"; +import type { InstructionStrategy } from "../services/instructions"; import { generateCopilotInstructions, generateAreaInstructions, - writeAreaInstruction + generateNestedInstructions, + generateNestedAreaInstructions, + writeAreaInstruction, + writeNestedInstructions } from "../services/instructions"; import { ensureDir, safeWriteFile } from "../utils/fs"; import type { CommandResult } from "../utils/output"; import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; +function skipReason(action: string): string { + if (action === "symlink") return "symlink"; + if (action === "empty") return "empty content"; + return "exists, use --force"; +} + type InstructionsOptions = { repo?: string; output?: string; @@ -20,6 +30,8 @@ type InstructionsOptions = { areas?: boolean; areasOnly?: boolean; area?: string; + strategy?: string; + claudeMd?: boolean; }; export async function instructionsCommand(options: InstructionsOptions): Promise { @@ -30,59 +42,130 @@ export async function instructionsCommand(options: InstructionsOptions): Promise const progress = createProgressReporter(!shouldLog(options)); const wantAreas = options.areas || options.areasOnly || options.area; + // Load config for strategy merge (CLI flag > config > default "flat") + let strategy: InstructionStrategy = "flat"; + let detailDir = ".agents"; + let claudeMd = false; try { - // Generate root instructions unless --areas-only - if (!options.areasOnly && !options.area) { - let content = ""; - try { - progress.update("Generating instructions..."); - content = await generateCopilotInstructions({ - repoPath, - model: options.model - }); - } catch (error) { - const msg = - "Failed to generate instructions with Copilot SDK. " + - "Ensure the Copilot CLI is installed (copilot --version) and logged in. " + - (error instanceof Error ? error.message : String(error)); - outputError(msg, Boolean(options.json)); - if (!wantAreas) return; - } - if (!content && !wantAreas) { - outputError("No instructions were generated.", Boolean(options.json)); - return; - } + const config = await loadAgentrcConfig(repoPath); + strategy = (options.strategy as InstructionStrategy) ?? config?.strategy ?? "flat"; + detailDir = config?.detailDir ?? ".agents"; + claudeMd = options.claudeMd ?? config?.claudeMd ?? false; + } catch { + // Config loading failure is non-fatal; use defaults + if (options.strategy === "flat" || options.strategy === "nested") { + strategy = options.strategy; + } + if (options.claudeMd) claudeMd = true; + } - if (content) { - await ensureDir(path.dirname(outputPath)); - const { wrote, reason } = await safeWriteFile(outputPath, content, Boolean(options.force)); + // Validate strategy value + if (strategy !== "flat" && strategy !== "nested") { + outputError(`Invalid strategy "${strategy}". Use "flat" or "nested".`, Boolean(options.json)); + return; + } - if (!wrote) { - const relPath = path.relative(process.cwd(), outputPath); - const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + try { + // Generate root instructions unless --areas-only + if (!options.areasOnly && !options.area) { + if (strategy === "nested") { + // Nested: generate AGENTS.md hub + detail files + try { + progress.update("Generating nested instructions..."); + const nestedResult = await generateNestedInstructions({ + repoPath, + model: options.model, + onProgress: shouldLog(options) ? (msg) => progress.update(msg) : undefined, + detailDir, + claudeMd + }); + const actions = await writeNestedInstructions(repoPath, nestedResult, options.force); + for (const action of actions) { + const relPath = path.relative(process.cwd(), action.path); + if (action.action === "wrote") { + if (shouldLog(options)) progress.succeed(`Wrote ${relPath}`); + } else if (shouldLog(options)) { + progress.update(`Skipped ${relPath} (${skipReason(action.action)})`); + } + } + for (const warning of nestedResult.warnings) { + if (shouldLog(options)) progress.update(`Warning: ${warning}`); + } if (options.json) { - const result: CommandResult<{ outputPath: string; skipped: true; reason: string }> = { + const result: CommandResult<{ files: typeof actions }> = { ok: true, - status: "noop", - data: { outputPath, skipped: true, reason: why } + status: "success", + data: { files: actions } }; outputResult(result, true); - } else if (shouldLog(options)) { - progress.update(`Skipped ${relPath}: ${why}`); } - } else { - const byteCount = Buffer.byteLength(content, "utf8"); + } catch (error) { + const msg = + "Failed to generate nested instructions. " + + (error instanceof Error ? error.message : String(error)); + outputError(msg, Boolean(options.json)); + if (!wantAreas) return; + } + } else { + // Flat: existing behavior + let content = ""; + try { + progress.update("Generating instructions..."); + content = await generateCopilotInstructions({ + repoPath, + model: options.model + }); + } catch (error) { + const msg = + "Failed to generate instructions with Copilot SDK. " + + "Ensure the Copilot CLI is installed (copilot --version) and logged in. " + + (error instanceof Error ? error.message : String(error)); + outputError(msg, Boolean(options.json)); + if (!wantAreas) return; + } + if (!content && !wantAreas) { + outputError("No instructions were generated.", Boolean(options.json)); + return; + } - if (options.json) { - const result: CommandResult<{ outputPath: string; model: string; byteCount: number }> = - { + if (content) { + await ensureDir(path.dirname(outputPath)); + const { wrote, reason } = await safeWriteFile( + outputPath, + content, + Boolean(options.force) + ); + + if (!wrote) { + const relPath = path.relative(process.cwd(), outputPath); + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + if (options.json) { + const result: CommandResult<{ outputPath: string; skipped: true; reason: string }> = { + ok: true, + status: "noop", + data: { outputPath, skipped: true, reason: why } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + progress.update(`Skipped ${relPath}: ${why}`); + } + } else { + const byteCount = Buffer.byteLength(content, "utf8"); + + if (options.json) { + const result: CommandResult<{ + outputPath: string; + model: string; + byteCount: number; + }> = { ok: true, status: "success", data: { outputPath, model: options.model ?? "default", byteCount } }; - outputResult(result, true); - } else if (shouldLog(options)) { - progress.succeed(`Updated ${path.relative(process.cwd(), outputPath)}`); + outputResult(result, true); + } else if (shouldLog(options)) { + progress.succeed(`Updated ${path.relative(process.cwd(), outputPath)}`); + } } } } @@ -133,35 +216,63 @@ export async function instructionsCommand(options: InstructionsOptions): Promise `Generating for "${area.name}" (${Array.isArray(area.applyTo) ? area.applyTo.join(", ") : area.applyTo})...` ); } - const body = await generateAreaInstructions({ - repoPath, - area, - model: options.model, - onProgress: shouldLog(options) ? (msg) => progress.update(msg) : undefined - }); - if (!body.trim()) { - if (shouldLog(options)) { - progress.update(`Skipped "${area.name}" — no content generated.`); + if (strategy === "nested") { + // Nested: per-area AGENTS.md hub + detail files + const childAreas = areas.filter((a) => a.parentArea === area.name); + const nestedResult = await generateNestedAreaInstructions({ + repoPath, + area, + childAreas, + model: options.model, + onProgress: shouldLog(options) ? (msg) => progress.update(msg) : undefined, + detailDir, + claudeMd + }); + const actions = await writeNestedInstructions(repoPath, nestedResult, options.force); + for (const action of actions) { + const relPath = path.relative(process.cwd(), action.path); + if (action.action === "wrote") { + if (shouldLog(options)) progress.succeed(`Wrote ${relPath}`); + } else if (shouldLog(options)) { + progress.update(`Skipped ${relPath} (${skipReason(action.action)})`); + } } - continue; - } + for (const warning of nestedResult.warnings) { + if (shouldLog(options)) progress.update(`Warning: ${warning}`); + } + } else { + // Flat: existing behavior + const body = await generateAreaInstructions({ + repoPath, + area, + model: options.model, + onProgress: shouldLog(options) ? (msg) => progress.update(msg) : undefined + }); - const result = await writeAreaInstruction(repoPath, area, body, options.force); - if (result.status === "skipped") { - if (shouldLog(options)) { - progress.update(`Skipped "${area.name}" — file exists (use --force to overwrite).`); + if (!body.trim()) { + if (shouldLog(options)) { + progress.update(`Skipped "${area.name}" — no content generated.`); + } + continue; + } + + const result = await writeAreaInstruction(repoPath, area, body, options.force); + if (result.status === "skipped") { + if (shouldLog(options)) { + progress.update(`Skipped "${area.name}" — file exists (use --force to overwrite).`); + } + continue; + } + if (result.status === "symlink") { + if (shouldLog(options)) { + progress.update(`Skipped "${area.name}" — path is a symlink.`); + } + continue; } - continue; - } - if (result.status === "symlink") { if (shouldLog(options)) { - progress.update(`Skipped "${area.name}" — path is a symlink.`); + progress.succeed(`Wrote ${path.relative(process.cwd(), result.filePath)}`); } - continue; - } - if (shouldLog(options)) { - progress.succeed(`Wrote ${path.relative(process.cwd(), result.filePath)}`); } } catch (error) { if (shouldLog(options)) { diff --git a/src/services/__tests__/analyzer.test.ts b/src/services/__tests__/analyzer.test.ts index 39c8132..2d6160c 100644 --- a/src/services/__tests__/analyzer.test.ts +++ b/src/services/__tests__/analyzer.test.ts @@ -512,6 +512,25 @@ describe("analyzeRepo", () => { expect(apiArea?.hasTsConfig).toBe(true); }); + it("propagates parentArea from config to detected areas", async () => { + const repoPath = await makeTmpDir(); + await fs.mkdir(path.join(repoPath, "api"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "api", "package.json"), JSON.stringify({ name: "api" })); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ + areas: [ + { name: "root", applyTo: "src/**" }, + { name: "API", applyTo: "api/**", parentArea: "root" } + ] + }) + ); + const result = await analyzeRepo(repoPath); + const apiArea = result.areas?.find((a) => a.name === "API"); + expect(apiArea).toBeDefined(); + expect(apiArea?.parentArea).toBe("root"); + }); + it("detects C++ language from CMakeLists.txt", async () => { const repoPath = await makeTmpDir(); await fs.writeFile( @@ -891,6 +910,135 @@ describe("loadAgentrcConfig", () => { const config = await loadAgentrcConfig(repoPath); expect(config?.policies).toBeUndefined(); }); + + it("parses strategy field", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ strategy: "nested", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.strategy).toBe("nested"); + }); + + it("ignores invalid strategy values", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ strategy: "invalid", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.strategy).toBeUndefined(); + }); + + it("parses detailDir field", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ detailDir: "docs-ai", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.detailDir).toBe("docs-ai"); + }); + + it("rejects detailDir with path traversal", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ detailDir: "../etc", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.detailDir).toBeUndefined(); + }); + + it("rejects absolute detailDir", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ detailDir: "/tmp/evil", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.detailDir).toBeUndefined(); + }); + + it("rejects detailDir in blocklist", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ detailDir: "node_modules", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.detailDir).toBeUndefined(); + }); + + it("rejects detailDir with backslash traversal", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ detailDir: "..\\..\\etc", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.detailDir).toBeUndefined(); + }); + + it("rejects multi-segment detailDir", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ detailDir: "docs/agents", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.detailDir).toBeUndefined(); + }); + + it("parses claudeMd boolean", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ claudeMd: true, areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.claudeMd).toBe(true); + }); + + it("ignores non-boolean claudeMd", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ claudeMd: "yes", areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadAgentrcConfig(repoPath); + expect(config?.claudeMd).toBeUndefined(); + }); + + it("validates parentArea references existing area", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ + areas: [ + { name: "root", applyTo: "src/**" }, + { name: "child", applyTo: "src/child/**", parentArea: "root" } + ] + }) + ); + const config = await loadAgentrcConfig(repoPath); + const child = config?.areas?.find((a) => a.name === "child"); + expect(child?.parentArea).toBe("root"); + }); + + it("clears invalid parentArea references", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "agentrc.config.json"), + JSON.stringify({ + areas: [{ name: "child", applyTo: "src/child/**", parentArea: "nonexistent" }] + }) + ); + const config = await loadAgentrcConfig(repoPath); + const child = config?.areas?.find((a) => a.name === "child"); + expect(child?.parentArea).toBeUndefined(); + }); }); describe("sanitizeAreaName", () => { diff --git a/src/services/__tests__/instructions.test.ts b/src/services/__tests__/instructions.test.ts index bd002b8..42f7e8c 100644 --- a/src/services/__tests__/instructions.test.ts +++ b/src/services/__tests__/instructions.test.ts @@ -7,12 +7,16 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Area } from "../analyzer"; import { writeAreaInstruction, + writeInstructionFile, + writeNestedInstructions, buildAreaFrontmatter, buildAreaInstructionContent, areaInstructionPath, detectExistingInstructions, - buildExistingInstructionsSection + buildExistingInstructionsSection, + parseTopicsFromHub } from "../instructions"; +import type { NestedInstructionsResult } from "../instructions"; describe("writeAreaInstruction", () => { let tmpDir: string; @@ -356,7 +360,8 @@ describe("buildExistingInstructionsSection", () => { const result = buildExistingInstructionsSection({ agentsMdFiles: [], claudeMdFiles: [], - instructionMdFiles: [] + instructionMdFiles: [], + detailFiles: [] }); expect(result).toBe(""); }); @@ -365,7 +370,8 @@ describe("buildExistingInstructionsSection", () => { const result = buildExistingInstructionsSection({ agentsMdFiles: ["AGENTS.md", "backend/api/AGENTS.md"], claudeMdFiles: ["CLAUDE.md"], - instructionMdFiles: [".github/instructions/frontend.instructions.md"] + instructionMdFiles: [".github/instructions/frontend.instructions.md"], + detailFiles: [] }); expect(result).toContain("`AGENTS.md`"); expect(result).toContain("`backend/api/AGENTS.md`"); @@ -378,7 +384,8 @@ describe("buildExistingInstructionsSection", () => { const result = buildExistingInstructionsSection({ agentsMdFiles: ["AGENTS.md"], claudeMdFiles: [], - instructionMdFiles: [] + instructionMdFiles: [], + detailFiles: [] }); expect(result).toContain("### Output rules"); expect(result).toContain("do not restate it"); @@ -389,7 +396,8 @@ describe("buildExistingInstructionsSection", () => { const result = buildExistingInstructionsSection({ agentsMdFiles: [], claudeMdFiles: ["CLAUDE.md"], - instructionMdFiles: [] + instructionMdFiles: [], + detailFiles: [] }); expect(result).toContain("`CLAUDE.md`"); expect(result).toContain("### Output rules"); @@ -399,9 +407,287 @@ describe("buildExistingInstructionsSection", () => { const result = buildExistingInstructionsSection({ agentsMdFiles: [], claudeMdFiles: [], - instructionMdFiles: [".github/instructions/api.instructions.md"] + instructionMdFiles: [".github/instructions/api.instructions.md"], + detailFiles: [] }); expect(result).toContain("`.github/instructions/api.instructions.md`"); expect(result).toContain("### Output rules"); }); + + it("includes detail files in listing", () => { + const result = buildExistingInstructionsSection({ + agentsMdFiles: ["AGENTS.md"], + claudeMdFiles: [], + instructionMdFiles: [], + detailFiles: [".agents/testing.md", ".agents/architecture.md"] + }); + expect(result).toContain("`.agents/testing.md`"); + expect(result).toContain("`.agents/architecture.md`"); + }); +}); + +describe("writeInstructionFile", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-wif-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes file to arbitrary relative path", async () => { + const result = await writeInstructionFile( + tmpDir, + "docs/guide.md", + "# Guide\n\nContent.", + false + ); + + expect(result.status).toBe("written"); + const content = await fs.readFile(result.filePath, "utf8"); + expect(content).toBe("# Guide\n\nContent."); + }); + + it("creates parent directories", async () => { + const result = await writeInstructionFile(tmpDir, "deep/nested/dir/file.md", "content", false); + + expect(result.status).toBe("written"); + expect(await fs.readFile(result.filePath, "utf8")).toBe("content"); + }); + + it("returns empty status for empty content", async () => { + const result = await writeInstructionFile(tmpDir, "empty.md", " \n ", false); + + expect(result.status).toBe("empty"); + }); + + it("rejects path that escapes repo root", async () => { + await expect( + writeInstructionFile(tmpDir, "../../../etc/passwd", "evil", false) + ).rejects.toThrow("escapes repository root"); + }); + + it("skips existing file without force", async () => { + const filePath = path.join(tmpDir, "existing.md"); + await fs.writeFile(filePath, "original"); + + const result = await writeInstructionFile(tmpDir, "existing.md", "new content", false); + + expect(result.status).toBe("skipped"); + expect(await fs.readFile(filePath, "utf8")).toBe("original"); + }); + + it("overwrites existing file with force", async () => { + const filePath = path.join(tmpDir, "existing.md"); + await fs.writeFile(filePath, "original"); + + const result = await writeInstructionFile(tmpDir, "existing.md", "new content", true); + + expect(result.status).toBe("written"); + expect(await fs.readFile(filePath, "utf8")).toBe("new content"); + }); +}); + +describe("writeNestedInstructions", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-wni-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes hub and detail files", async () => { + const result: NestedInstructionsResult = { + hub: { relativePath: "AGENTS.md", content: "# Hub\n\nOverview." }, + details: [ + { relativePath: ".agents/testing.md", content: "# Testing\n\nGuide.", topic: "Testing" }, + { + relativePath: ".agents/arch.md", + content: "# Architecture\n\nPatterns.", + topic: "Architecture" + } + ], + warnings: [] + }; + + const actions = await writeNestedInstructions(tmpDir, result, false); + + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual({ path: path.join(tmpDir, "AGENTS.md"), action: "wrote" }); + expect(actions[1]).toEqual({ path: path.join(tmpDir, ".agents/testing.md"), action: "wrote" }); + expect(actions[2]).toEqual({ path: path.join(tmpDir, ".agents/arch.md"), action: "wrote" }); + + expect(await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf8")).toBe("# Hub\n\nOverview."); + expect(await fs.readFile(path.join(tmpDir, ".agents/testing.md"), "utf8")).toBe( + "# Testing\n\nGuide." + ); + }); + + it("writes optional CLAUDE.md", async () => { + const result: NestedInstructionsResult = { + hub: { relativePath: "AGENTS.md", content: "# Hub" }, + details: [], + claudeMd: { relativePath: "CLAUDE.md", content: "@AGENTS.md\n" }, + warnings: [] + }; + + const actions = await writeNestedInstructions(tmpDir, result, false); + + expect(actions).toHaveLength(2); + expect(await fs.readFile(path.join(tmpDir, "CLAUDE.md"), "utf8")).toBe("@AGENTS.md\n"); + }); + + it("skips existing files without force", async () => { + await fs.writeFile(path.join(tmpDir, "AGENTS.md"), "existing"); + + const result: NestedInstructionsResult = { + hub: { relativePath: "AGENTS.md", content: "new content" }, + details: [], + warnings: [] + }; + + const actions = await writeNestedInstructions(tmpDir, result, false); + + expect(actions[0].action).toBe("skipped"); + expect(await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf8")).toBe("existing"); + }); + + it("reports empty action for whitespace-only content", async () => { + const result: NestedInstructionsResult = { + hub: { relativePath: "AGENTS.md", content: " \n " }, + details: [], + warnings: [] + }; + + const actions = await writeNestedInstructions(tmpDir, result, false); + + expect(actions[0].action).toBe("empty"); + }); +}); + +describe("detectExistingInstructions with detail files", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-det-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("finds detail files in .agents directory", async () => { + await fs.mkdir(path.join(tmpDir, ".agents"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, ".agents", "testing.md"), "# Testing"); + await fs.writeFile(path.join(tmpDir, ".agents", "arch.md"), "# Arch"); + + const ctx = await detectExistingInstructions(tmpDir); + + expect(ctx.detailFiles).toEqual([".agents/arch.md", ".agents/testing.md"]); + }); + + it("finds detail files in custom detail directory", async () => { + await fs.mkdir(path.join(tmpDir, "docs-ai"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "docs-ai", "guide.md"), "# Guide"); + + const ctx = await detectExistingInstructions(tmpDir, "docs-ai"); + + expect(ctx.detailFiles).toEqual(["docs-ai/guide.md"]); + }); + + it("finds detail files in nested area directories", async () => { + await fs.mkdir(path.join(tmpDir, "frontend", ".agents"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "frontend", ".agents", "components.md"), "# Comp"); + + const ctx = await detectExistingInstructions(tmpDir); + + expect(ctx.detailFiles).toEqual(["frontend/.agents/components.md"]); + }); + + it("returns empty array when no detail directory exists", async () => { + const ctx = await detectExistingInstructions(tmpDir); + + expect(ctx.detailFiles).toEqual([]); + }); +}); + +describe("parseTopicsFromHub", () => { + it("parses valid topics from fenced JSON block", () => { + const content = `# Hub\n\nSome content\n\n\`\`\`json\n[{"slug":"testing","title":"Testing","description":"How to test"}]\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics).toEqual([ + { slug: "testing", title: "Testing", description: "How to test" } + ]); + expect(result.cleanContent).toBe("# Hub\n\nSome content"); + }); + + it("returns empty topics when no JSON block exists", () => { + const result = parseTopicsFromHub("# Hub\n\nNo JSON here"); + + expect(result.topics).toEqual([]); + expect(result.cleanContent).toBe("# Hub\n\nNo JSON here"); + }); + + it("returns empty topics for malformed JSON", () => { + const content = `# Hub\n\n\`\`\`json\n{not valid json\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics).toEqual([]); + expect(result.cleanContent).toBe(content); + }); + + it("filters out entries missing required fields", () => { + const content = `# Hub\n\n\`\`\`json\n[{"slug":"valid","title":"Valid"},{"slug":"no-title"},{"title":"no-slug"}]\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics).toHaveLength(1); + expect(result.topics[0].slug).toBe("valid"); + }); + + it("defaults missing description to empty string", () => { + const content = `# Hub\n\n\`\`\`json\n[{"slug":"topic","title":"Topic"}]\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics).toHaveLength(1); + expect(result.topics[0].description).toBe(""); + }); + + it("caps topics at 7", () => { + const topics = Array.from({ length: 10 }, (_, i) => ({ slug: `t${i}`, title: `T${i}` })); + const content = `# Hub\n\n\`\`\`json\n${JSON.stringify(topics)}\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics).toHaveLength(7); + }); + + it("sanitizes slugs with path traversal characters", () => { + const content = `# Hub\n\n\`\`\`json\n[{"slug":"../../../etc/passwd","title":"Evil"}]\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics[0].slug).toBe("etc-passwd"); + expect(result.topics[0].slug).not.toContain(".."); + expect(result.topics[0].slug).not.toContain("/"); + }); + + it("sanitizes slugs with slashes and special characters", () => { + const content = `# Hub\n\n\`\`\`json\n[{"slug":"api/v2","title":"API v2"},{"slug":"my file name","title":"Spaces"}]\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics[0].slug).toBe("api-v2"); + expect(result.topics[1].slug).toBe("my-file-name"); + }); + + it("returns non-array JSON as empty topics", () => { + const content = `# Hub\n\n\`\`\`json\n{"not":"an array"}\n\`\`\``; + const result = parseTopicsFromHub(content); + + expect(result.topics).toEqual([]); + expect(result.cleanContent).toBe(content); + }); }); diff --git a/src/services/analyzer.ts b/src/services/analyzer.ts index 8f197a0..3e25c3c 100644 --- a/src/services/analyzer.ts +++ b/src/services/analyzer.ts @@ -26,6 +26,7 @@ export type Area = { source: "auto" | "config"; scripts?: Record; hasTsConfig?: boolean; + parentArea?: string; }; export type RepoAnalysis = { @@ -987,7 +988,8 @@ async function detectAreas(repoPath: string, analysis: RepoAnalysis): Promise { @@ -1060,7 +1068,8 @@ export async function loadAgentrcConfig(repoPath: string): Promise typeof p === "string" && p.trim() !== ""); } - return { areas, policies: policies?.length ? policies : undefined }; + // Parse strategy + let strategy: InstructionStrategy | undefined; + if (typeof json.strategy === "string") { + const s = json.strategy as string; + if (s === "flat" || s === "nested") { + strategy = s; + } + } + + // Parse detailDir with safety validation + let detailDir: string | undefined; + if (typeof json.detailDir === "string") { + // Normalize separators so validation works on both Windows and POSIX + const dir = (json.detailDir as string).trim().replace(/\\+/g, "/"); + const blocklist = new Set([".git", "node_modules", ".github", "dist", "build"]); + // Must be a single path segment — no slashes, no traversal, not in blocklist + if ( + dir && + !path.isAbsolute(dir) && + !dir.includes("/") && + !dir.includes("..") && + !blocklist.has(dir) + ) { + detailDir = dir; + } + } + + // Parse claudeMd + const claudeMd = json.claudeMd === true ? true : undefined; + + // Validate parentArea references + const areaNames = new Set(areas.map((a) => a.name.toLowerCase())); + for (const area of areas) { + if (area.parentArea && !areaNames.has(area.parentArea.toLowerCase())) { + area.parentArea = undefined; + } + } + + return { + areas, + policies: policies?.length ? policies : undefined, + strategy, + detailDir, + claudeMd + }; } return undefined; diff --git a/src/services/generator.ts b/src/services/generator.ts index eaedef1..371c960 100644 --- a/src/services/generator.ts +++ b/src/services/generator.ts @@ -6,7 +6,7 @@ import type { RepoAnalysis } from "./analyzer"; export type FileAction = { path: string; - action: "wrote" | "skipped"; + action: "wrote" | "skipped" | "symlink" | "empty"; }; export type GenerateResult = { diff --git a/src/services/instructions.ts b/src/services/instructions.ts index 6498597..b3f9174 100644 --- a/src/services/instructions.ts +++ b/src/services/instructions.ts @@ -4,10 +4,27 @@ import path from "path"; import { DEFAULT_MODEL } from "../config"; import { ensureDir, safeWriteFile } from "../utils/fs"; -import type { Area } from "./analyzer"; +import type { Area, InstructionStrategy } from "./analyzer"; import { sanitizeAreaName } from "./analyzer"; import { assertCopilotCliReady } from "./copilot"; import { createCopilotClient } from "./copilotSdk"; +import type { FileAction } from "./generator"; + +type CopilotClient = Awaited>; + +export type { InstructionStrategy }; + +export type NestedInstructionsResult = { + hub: { relativePath: string; content: string }; + details: Array<{ relativePath: string; content: string; topic: string }>; + claudeMd?: { relativePath: string; content: string }; + warnings: string[]; +}; + +export type NestedHub = { + hubContent: string; + topics: Array<{ slug: string; title: string; description: string }>; +}; export type ExistingInstructionsContext = { /** AGENTS.md files found in the repo tree. */ @@ -16,19 +33,23 @@ export type ExistingInstructionsContext = { claudeMdFiles: string[]; /** .github/instructions/*.instructions.md files. */ instructionMdFiles: string[]; + /** Detail files found in nested strategy directories (e.g. .agents/*.md). */ + detailFiles: string[]; }; /** * Detect existing AI instruction files in a repository. - * Returns context about AGENTS.md, CLAUDE.md, and .instructions.md files + * Returns context about AGENTS.md, CLAUDE.md, .instructions.md, and nested detail files * so instruction generation can avoid duplicating content they already cover. */ export async function detectExistingInstructions( - repoPath: string + repoPath: string, + detailDirName = ".agents" ): Promise { const { agentsMdFiles, claudeMdFiles } = await findInstructionMarkerFiles(repoPath); const instructionMdFiles = await findModularInstructionFiles(repoPath); - return { agentsMdFiles, claudeMdFiles, instructionMdFiles }; + const detailFiles = await findDetailFiles(repoPath, detailDirName); + return { agentsMdFiles, claudeMdFiles, instructionMdFiles, detailFiles }; } /** @@ -76,13 +97,58 @@ async function findModularInstructionFiles(repoPath: string): Promise .sort(); } +/** + * Find detail files in nested strategy directories. + * Walks the repo tree looking for directories matching detailDirName + * and lists .md files inside them. + */ +async function findDetailFiles(repoPath: string, detailDirName: string): Promise { + const detailFiles: string[] = []; + const excludeDirs = new Set([".git", "node_modules", "apm_modules", ".apm"]); + + async function walk(dir: string, relPath: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (excludeDirs.has(entry.name)) continue; + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (entry.name === detailDirName) { + // Found a detail directory — list .md files inside + const detailDir = path.join(dir, entry.name); + const detailEntries = await fs + .readdir(detailDir, { withFileTypes: true }) + .catch(() => []); + for (const de of detailEntries) { + if (!de.isSymbolicLink() && de.isFile() && de.name.endsWith(".md")) { + const rel = relPath + ? `${relPath}/${entry.name}/${de.name}` + : `${entry.name}/${de.name}`; + detailFiles.push(rel); + } + } + } else { + await walk(path.join(dir, entry.name), relPath ? `${relPath}/${entry.name}` : entry.name); + } + } + } + } + + await walk(repoPath, ""); + return detailFiles.sort(); +} + /** * Build a prompt section listing existing instruction files. * Only emits content when instruction files actually exist, * so the LLM knows what content is already covered. */ export function buildExistingInstructionsSection(ctx: ExistingInstructionsContext): string { - const allFiles = [...ctx.agentsMdFiles, ...ctx.claudeMdFiles, ...ctx.instructionMdFiles]; + const allFiles = [ + ...ctx.agentsMdFiles, + ...ctx.claudeMdFiles, + ...ctx.instructionMdFiles, + ...ctx.detailFiles + ]; if (allFiles.length === 0) return ""; const lines: string[] = [ @@ -105,6 +171,9 @@ type GenerateInstructionsOptions = { repoPath: string; model?: string; onProgress?: (message: string) => void; + strategy?: InstructionStrategy; + detailDir?: string; + claudeMd?: boolean; }; export async function generateCopilotInstructions( @@ -199,6 +268,9 @@ type GenerateAreaInstructionsOptions = { area: Area; model?: string; onProgress?: (message: string) => void; + strategy?: InstructionStrategy; + detailDir?: string; + claudeMd?: boolean; }; export async function generateAreaInstructions( @@ -359,3 +431,403 @@ export async function writeAreaInstruction( } return { status: "written", filePath }; } + +/** + * Write an instruction file to an arbitrary repo-relative path. + * Validates the path stays within the repo root. + */ +export async function writeInstructionFile( + repoPath: string, + relativePath: string, + content: string, + force?: boolean +): Promise { + const resolvedRoot = path.resolve(repoPath); + const filePath = path.resolve(repoPath, relativePath); + if (!filePath.startsWith(resolvedRoot + path.sep) && filePath !== resolvedRoot) { + throw new Error(`Invalid path: escapes repository root (${relativePath})`); + } + if (!content.trim()) return { status: "empty", filePath }; + await ensureDir(path.dirname(filePath)); + const { wrote, reason } = await safeWriteFile(filePath, content, Boolean(force)); + if (!wrote) { + return { status: reason === "symlink" ? "symlink" : "skipped", filePath }; + } + return { status: "written", filePath }; +} + +function statusToAction(status: WriteAreaResult["status"]): FileAction["action"] { + switch (status) { + case "written": + return "wrote"; + case "symlink": + return "symlink"; + case "empty": + return "empty"; + default: + return "skipped"; + } +} + +/** + * Write all files for a nested instruction set (hub + details + optional CLAUDE.md). + */ +export async function writeNestedInstructions( + repoPath: string, + result: NestedInstructionsResult, + force?: boolean +): Promise { + const actions: FileAction[] = []; + + // Write hub + const hubResult = await writeInstructionFile( + repoPath, + result.hub.relativePath, + result.hub.content, + force + ); + actions.push({ + path: hubResult.filePath, + action: statusToAction(hubResult.status) + }); + + // Write detail files + for (const detail of result.details) { + const detailResult = await writeInstructionFile( + repoPath, + detail.relativePath, + detail.content, + force + ); + actions.push({ + path: detailResult.filePath, + action: statusToAction(detailResult.status) + }); + } + + // Write optional CLAUDE.md + if (result.claudeMd) { + const claudeResult = await writeInstructionFile( + repoPath, + result.claudeMd.relativePath, + result.claudeMd.content, + force + ); + actions.push({ + path: claudeResult.filePath, + action: statusToAction(claudeResult.status) + }); + } + + return actions; +} + +// ─── Nested strategy generation ─── + +type NestedTopic = { + slug: string; + title: string; + description: string; +}; + +type HubResult = { + hubContent: string; + topics: NestedTopic[]; +}; + +/** + * Parse topics JSON from a fenced code block at the end of hub content. + * Returns parsed topics and content with the JSON block stripped. + */ +export function parseTopicsFromHub(content: string): { + cleanContent: string; + topics: NestedTopic[]; +} { + // Match last fenced JSON block + const jsonBlockRe = /```json\s*\n([\s\S]*?)\n```\s*$/; + const match = jsonBlockRe.exec(content); + if (!match) return { cleanContent: content, topics: [] }; + + try { + const parsed = JSON.parse(match[1]) as unknown; + if (!Array.isArray(parsed)) return { cleanContent: content, topics: [] }; + const topics = parsed + .filter( + (t): t is Record => + typeof t === "object" && + t !== null && + typeof (t as Record).slug === "string" && + typeof (t as Record).title === "string" + ) + .map((t) => ({ + slug: sanitizeAreaName(t.slug as string), + title: t.title as string, + description: typeof t.description === "string" ? t.description : "" + })) + .slice(0, 7); // Cap at 7 topics + const cleanContent = content.slice(0, match.index).trimEnd(); + return { cleanContent, topics }; + } catch { + return { cleanContent: content, topics: [] }; + } +} + +async function generateNestedHub( + client: CopilotClient, + options: { + repoPath: string; + detailDir: string; + area?: Area; + childAreas?: Area[]; + model?: string; + onProgress?: (message: string) => void; + } +): Promise { + const progress = options.onProgress ?? (() => {}); + const model = options.model ?? DEFAULT_MODEL; + + const existingCtx = await detectExistingInstructions(options.repoPath, options.detailDir); + const existingSection = buildExistingInstructionsSection(existingCtx); + + const session = await client.createSession({ + model, + streaming: true, + workingDirectory: options.repoPath, + systemMessage: { + content: options.area + ? `You are an expert codebase analyst. Generate a lean AGENTS.md hub file for the "${options.area.name}" area. Use tools to explore the codebase. Output ONLY the final markdown content.` + : "You are an expert codebase analyst. Generate a lean AGENTS.md hub file for this repository. Use tools to explore the codebase. Output ONLY the final markdown content." + }, + infiniteSessions: { enabled: false } + }); + + let content = ""; + session.on((event) => { + const e = event as { type: string; data?: Record }; + if (e.type === "assistant.message_delta") { + const delta = e.data?.deltaContent as string | undefined; + if (delta) { + content += delta; + progress( + options.area ? `Generating hub for "${options.area.name}"...` : "Generating hub..." + ); + } + } else if (e.type === "tool.execution_start") { + const toolName = e.data?.toolName as string | undefined; + progress(`Using tool: ${toolName ?? "..."}`); + } + }); + + const areaContext = options.area + ? `\nThis hub is for the "${options.area.name}" area (files matching: ${Array.isArray(options.area.applyTo) ? options.area.applyTo.join(", ") : options.area.applyTo}).${options.area.description ? ` ${options.area.description}` : ""}` + : ""; + + const childContext = options.childAreas?.length + ? `\n\nThis area has sub-projects:\n${options.childAreas.map((c) => `- ${c.name} (${c.path ?? "unknown path"})`).join("\n")}\nInclude a "## Sub-Projects" section with links to each child's AGENTS.md.` + : ""; + + const parentContext = options.area?.parentArea + ? `\nThis is a sub-project of "${options.area.parentArea}". Include a note linking back to the parent area.` + : ""; + + const prompt = `Analyze this codebase and generate a lean AGENTS.md hub file (~90-120 lines).${areaContext}${parentContext} + +Use tools to explore the codebase structure, tech stack, and conventions. + +The hub should contain: +- Project overview and purpose +- Key concepts and architecture +- Coding conventions and guardrails +- A "## Detailed Instructions" section listing links to detail files in \`${options.detailDir}/\`${childContext} + +At the END of your output, emit a fenced JSON block with recommended topics for detail files: +\`\`\`json +[{"slug":"testing","title":"Testing Guide","description":"How to write and run tests"},{"slug":"architecture","title":"Architecture","description":"Codebase structure and patterns"}] +\`\`\` + +Recommend 3-5 topics that would benefit from deep-dive detail files. Each slug becomes a filename: \`${options.detailDir}/{slug}.md\`. + +IMPORTANT: +- 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 +${existingSection ? `- Do NOT duplicate content from existing instruction files\n${existingSection}` : ""} +- Output ONLY markdown content (no code fences wrapping the whole output), ending with the JSON topic block`; + + await session.sendAndWait({ prompt }, 180000); + await session.destroy(); + + const { cleanContent, topics } = parseTopicsFromHub(content.trim()); + + return { hubContent: cleanContent, topics }; +} + +async function generateNestedDetail( + client: CopilotClient, + options: { + repoPath: string; + topic: NestedTopic; + area?: Area; + model?: string; + onProgress?: (message: string) => void; + } +): Promise { + const progress = options.onProgress ?? (() => {}); + const model = options.model ?? DEFAULT_MODEL; + + const session = await client.createSession({ + model, + streaming: true, + workingDirectory: options.repoPath, + systemMessage: { + content: `You are an expert codebase analyst. Generate a deep-dive instruction file about "${options.topic.title}". Use tools to explore the codebase. Output ONLY the final markdown content.` + }, + infiniteSessions: { enabled: false } + }); + + let content = ""; + session.on((event) => { + const e = event as { type: string; data?: Record }; + if (e.type === "assistant.message_delta") { + const delta = e.data?.deltaContent as string | undefined; + if (delta) { + content += delta; + progress(`Generating detail: ${options.topic.title}...`); + } + } else if (e.type === "tool.execution_start") { + const toolName = e.data?.toolName as string | undefined; + progress(`${options.topic.slug}: using tool ${toolName ?? "..."}`); + } + }); + + const areaContext = options.area + ? `Focus on the "${options.area.name}" area (files matching: ${Array.isArray(options.area.applyTo) ? options.area.applyTo.join(", ") : options.area.applyTo}).` + : "Focus on the entire repository."; + + const prompt = `Generate a deep-dive instruction file about "${options.topic.title}" for this codebase. +${areaContext} + +Topic: ${options.topic.title} +Description: ${options.topic.description} + +Use tools to explore the codebase and understand the specific patterns, APIs, and conventions related to this topic. + +The file should: +- Start with \`# ${options.topic.title}\` +- Include \`**When to read:** {one-line trigger condition}\` right after the heading +- Cover ~50-100 lines of practical, actionable guidance +- Include code patterns and examples found in the actual codebase +- Be specific to this codebase, not generic advice + +Output ONLY the markdown content, no code fences wrapping the whole output.`; + + await session.sendAndWait({ prompt }, 180000); + await session.destroy(); + + return content.trim() || ""; +} + +/** + * Generate a full nested instruction set (hub + detail files + optional CLAUDE.md). + * Reuses a single CopilotClient across all SDK sessions. + */ +export async function generateNestedInstructions( + options: GenerateInstructionsOptions & { + detailDir: string; + claudeMd: boolean; + area?: Area; + childAreas?: Area[]; + } +): Promise { + const progress = options.onProgress ?? (() => {}); + + progress("Checking Copilot CLI..."); + const cliConfig = await assertCopilotCliReady(); + + progress("Starting Copilot SDK..."); + const client = await createCopilotClient(cliConfig); + + try { + // Step 1: Generate hub + const { hubContent, topics } = await generateNestedHub(client, { + repoPath: options.repoPath, + detailDir: options.detailDir, + area: options.area, + childAreas: options.childAreas, + model: options.model, + onProgress: options.onProgress + }); + + // Determine output paths + const basePath = options.area?.path ?? "."; + const hubRelativePath = path.join(basePath, "AGENTS.md"); + + // Hub content: prepend frontmatter if area-scoped + let finalHubContent = hubContent; + if (options.area) { + finalHubContent = `${buildAreaFrontmatter(options.area)}\n\n${hubContent}`; + } + + const result: NestedInstructionsResult = { + hub: { relativePath: hubRelativePath, content: finalHubContent }, + details: [], + warnings: [] + }; + + // Step 2: Generate detail files (sequential, one session per topic) + for (const [i, topic] of topics.entries()) { + progress(`Generating detail ${i + 1}/${topics.length}: ${topic.title}...`); + try { + const detailContent = await generateNestedDetail(client, { + repoPath: options.repoPath, + topic, + area: options.area, + model: options.model, + onProgress: options.onProgress + }); + if (detailContent) { + result.details.push({ + relativePath: path.join(basePath, options.detailDir, `${topic.slug}.md`), + content: detailContent, + topic: topic.title + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + result.warnings.push(`Failed to generate detail for "${topic.title}": ${msg}`); + } + } + + // Step 3: Optional CLAUDE.md + if (options.claudeMd) { + result.claudeMd = { + relativePath: path.join(basePath, "CLAUDE.md"), + content: "@AGENTS.md\n" + }; + } + + return result; + } finally { + await client.stop(); + } +} + +/** + * Generate nested instructions for a specific area. + */ +export async function generateNestedAreaInstructions( + options: GenerateAreaInstructionsOptions & { + detailDir: string; + claudeMd: boolean; + childAreas?: Area[]; + } +): Promise { + return generateNestedInstructions({ + repoPath: options.repoPath, + area: options.area, + childAreas: options.childAreas, + model: options.model, + onProgress: options.onProgress, + detailDir: options.detailDir, + claudeMd: options.claudeMd + }); +} diff --git a/src/utils/output.ts b/src/utils/output.ts index d1b996d..c98fc75 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -64,12 +64,12 @@ export function outputResult(result: CommandResult, json: boolean): void { } } -export function deriveFileStatus(files: { action: "wrote" | "skipped" }[]): { +export function deriveFileStatus(files: { action: string }[]): { ok: boolean; status: "success" | "partial" | "noop"; } { const hasWrites = files.some((f) => f.action === "wrote"); - const hasSkips = files.some((f) => f.action === "skipped"); + const hasSkips = files.some((f) => f.action !== "wrote"); if (hasWrites && hasSkips) return { ok: true, status: "partial" }; if (hasWrites || files.length === 0) return { ok: true, status: "success" }; return { ok: true, status: "noop" }; diff --git a/vscode-extension/src/commands/instructions.ts b/vscode-extension/src/commands/instructions.ts index af0ad18..882a44a 100644 --- a/vscode-extension/src/commands/instructions.ts +++ b/vscode-extension/src/commands/instructions.ts @@ -3,9 +3,13 @@ import path from "node:path"; import { generateCopilotInstructions, generateAreaInstructions, + generateNestedInstructions, + generateNestedAreaInstructions, writeAreaInstruction, + writeNestedInstructions, safeWriteFile, - analyzeRepo + analyzeRepo, + loadAgentrcConfig } from "../services.js"; import { VscodeProgressReporter } from "../progress.js"; import { getWorkspacePath, getCachedAnalysis, setCachedAnalysis } from "./analyze.js"; @@ -25,6 +29,19 @@ const FORMAT_OPTIONS = [ } ]; +const STRATEGY_OPTIONS = [ + { + label: "$(file) Flat", + description: "Single instruction file (default)", + value: "flat" as const + }, + { + label: "$(list-tree) Nested", + description: "Hub + detail files in .agents/", + value: "nested" as const + } +]; + export async function instructionsCommand(): Promise { const workspacePath = getWorkspacePath(); if (!workspacePath) return; @@ -37,6 +54,46 @@ export async function instructionsCommand(): Promise { }); if (!formatPick) return; + // Pick strategy + let strategy: "flat" | "nested" = "flat"; + let detailDir = ".agents"; + let claudeMd = false; + + // Load config to get defaults + try { + const config = await loadAgentrcConfig(workspacePath); + if (config?.strategy === "flat" || config?.strategy === "nested") { + strategy = config.strategy; + } + if (config?.detailDir) detailDir = config.detailDir; + if (config?.claudeMd) claudeMd = true; + } catch { + // Non-fatal + } + + const strategyPick = await vscode.window.showQuickPick( + STRATEGY_OPTIONS.map((s) => ({ ...s, picked: s.value === strategy })), + { placeHolder: "Choose instruction strategy" } + ); + if (!strategyPick) return; + strategy = strategyPick.value; + + // For nested strategy, ask about CLAUDE.md + if (strategy === "nested" && !claudeMd) { + const claudePick = await vscode.window.showQuickPick( + [ + { label: "No", description: "Skip CLAUDE.md generation", value: false }, + { + label: "Yes", + description: "Generate CLAUDE.md with @AGENTS.md transclusion", + value: true + } + ], + { placeHolder: "Generate CLAUDE.md alongside AGENTS.md?" } + ); + if (claudePick) claudeMd = claudePick.value; + } + // Ensure analysis is available before starting progress let analysis = getCachedAnalysis(); if (!analysis) { @@ -74,84 +131,147 @@ export async function instructionsCommand(): Promise { try { const reporter = new VscodeProgressReporter(progress); - reporter.update("Generating root instructions…"); - const content = await generateCopilotInstructions({ - repoPath: workspacePath, - model, - onProgress: (msg) => reporter.update(msg) - }); - - let rootSkipped = false; - if (content.trim()) { - const dir = path.dirname(instructionFile); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(dir)); - const { wrote } = await safeWriteFile(instructionFile, content, false); - if (!wrote) rootSkipped = true; - } + if (strategy === "nested") { + // Nested strategy: generate hub + detail files + reporter.update("Generating nested instructions…"); + const nestedResult = await generateNestedInstructions({ + repoPath: workspacePath, + model, + onProgress: (msg) => reporter.update(msg), + detailDir, + claudeMd + }); + const actions = await writeNestedInstructions(workspacePath, nestedResult, false); + let wroteCount = actions.filter((a) => a.action === "wrote").length; + let skippedCount = actions.filter((a) => a.action !== "wrote").length; + for (const warning of nestedResult.warnings) { + reporter.update(`Warning: ${warning}`); + } - let areasSkipped = 0; - const areaBodies = new Map(); - if (selectedAreas) { - for (const area of selectedAreas) { - reporter.update(`Generating instructions for ${area.name}…`); - const body = await generateAreaInstructions({ - repoPath: workspacePath, - area, - model, - onProgress: (msg) => reporter.update(msg) - }); - areaBodies.set(area.name, body); - if (body.trim()) { - const result = await writeAreaInstruction(workspacePath, area, body, false); - if (result.status === "skipped") areasSkipped++; + // Handle areas for nested + if (selectedAreas) { + const allAreas = analysis!.areas ?? []; + for (const area of selectedAreas) { + reporter.update(`Generating nested instructions for ${area.name}…`); + const childAreas = allAreas.filter((a) => a.parentArea === area.name); + const areaResult = await generateNestedAreaInstructions({ + repoPath: workspacePath, + area, + childAreas, + model, + onProgress: (msg) => reporter.update(msg), + detailDir, + claudeMd + }); + const areaActions = await writeNestedInstructions(workspacePath, areaResult, false); + const areaWrote = areaActions.filter((a) => a.action === "wrote").length; + const areaSkipped = areaActions.filter((a) => a.action !== "wrote").length; + wroteCount += areaWrote; + skippedCount += areaSkipped; + if (areaWrote > 0) reporter.succeed(`Wrote ${areaWrote} files for ${area.name}`); + if (areaSkipped > 0) + reporter.update(`Skipped ${areaSkipped} existing files for ${area.name}`); + for (const warning of areaResult.warnings) { + reporter.update(`Warning: ${warning}`); + } } } - } - const totalSkipped = (rootSkipped ? 1 : 0) + areasSkipped; - const areasWithContent = selectedAreas - ? selectedAreas.filter((a) => (areaBodies.get(a.name) ?? "").trim()).length - : 0; - const totalFiles = (content.trim() ? 1 : 0) + areasWithContent; - - if (totalSkipped > 0 && totalSkipped === totalFiles) { - reporter.succeed("All instruction files already exist."); - const overwrite = "Overwrite"; - const action = await vscode.window.showWarningMessage( - `AgentRC: All ${totalSkipped} instruction files already exist.`, - overwrite - ); - if (action === overwrite) { - try { - reporter.update("Overwriting instructions…"); - if (content.trim()) { - await safeWriteFile(instructionFile, content, true); + if (skippedCount > 0 && wroteCount === 0) { + reporter.succeed("All instruction files already exist."); + } else { + reporter.succeed(`Generated ${wroteCount} instruction files.`); + } + + // Open the hub file + try { + const hubPath = path.join(workspacePath, nestedResult.hub.relativePath); + const doc = await vscode.workspace.openTextDocument(hubPath); + await vscode.window.showTextDocument(doc); + } catch { + // Hub may not exist if all were skipped + } + } else { + // Flat strategy: existing behavior + reporter.update("Generating root instructions…"); + const content = await generateCopilotInstructions({ + repoPath: workspacePath, + model, + onProgress: (msg) => reporter.update(msg) + }); + + let rootSkipped = false; + if (content.trim()) { + const dir = path.dirname(instructionFile); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(dir)); + const { wrote } = await safeWriteFile(instructionFile, content, false); + if (!wrote) rootSkipped = true; + } + + let areasSkipped = 0; + const areaBodies = new Map(); + if (selectedAreas) { + for (const area of selectedAreas) { + reporter.update(`Generating instructions for ${area.name}…`); + const body = await generateAreaInstructions({ + repoPath: workspacePath, + area, + model, + onProgress: (msg) => reporter.update(msg) + }); + areaBodies.set(area.name, body); + if (body.trim()) { + const result = await writeAreaInstruction(workspacePath, area, body, false); + if (result.status === "skipped") areasSkipped++; } - if (selectedAreas) { - for (const area of selectedAreas) { - const body = areaBodies.get(area.name) ?? ""; - if (body.trim()) { - await writeAreaInstruction(workspacePath, area, body, true); + } + } + + const totalSkipped = (rootSkipped ? 1 : 0) + areasSkipped; + const areasWithContent = selectedAreas + ? selectedAreas.filter((a) => (areaBodies.get(a.name) ?? "").trim()).length + : 0; + const totalFiles = (content.trim() ? 1 : 0) + areasWithContent; + + if (totalSkipped > 0 && totalSkipped === totalFiles) { + reporter.succeed("All instruction files already exist."); + const overwrite = "Overwrite"; + const action = await vscode.window.showWarningMessage( + `AgentRC: All ${totalSkipped} instruction files already exist.`, + overwrite + ); + if (action === overwrite) { + try { + reporter.update("Overwriting instructions…"); + if (content.trim()) { + await safeWriteFile(instructionFile, content, true); + } + if (selectedAreas) { + for (const area of selectedAreas) { + const body = areaBodies.get(area.name) ?? ""; + if (body.trim()) { + await writeAreaInstruction(workspacePath, area, body, true); + } } } + reporter.succeed("Instructions overwritten."); + } catch (err) { + vscode.window.showErrorMessage( + `AgentRC: Instruction overwrite failed — ${err instanceof Error ? err.message : String(err)}` + ); } - reporter.succeed("Instructions overwritten."); - } catch (err) { - vscode.window.showErrorMessage( - `AgentRC: Instruction overwrite failed — ${err instanceof Error ? err.message : String(err)}` - ); } + } else { + reporter.succeed("Instructions generated."); } - } else { - reporter.succeed("Instructions generated."); - } - // Open the generated file - try { - const doc = await vscode.workspace.openTextDocument(instructionFile); - await vscode.window.showTextDocument(doc); - } catch { - // File may not exist if generation produced no content + // Open the generated file + try { + const doc = await vscode.workspace.openTextDocument(instructionFile); + await vscode.window.showTextDocument(doc); + } catch { + // File may not exist if generation produced no content + } } } catch (err) { vscode.window.showErrorMessage( diff --git a/vscode-extension/src/services.ts b/vscode-extension/src/services.ts index 5e93b14..94fcb71 100644 --- a/vscode-extension/src/services.ts +++ b/vscode-extension/src/services.ts @@ -1,9 +1,12 @@ -export { analyzeRepo } from "agentrc/services/analyzer.js"; +export { analyzeRepo, loadAgentrcConfig } from "agentrc/services/analyzer.js"; export { generateConfigs } from "agentrc/services/generator.js"; export { generateCopilotInstructions, generateAreaInstructions, - writeAreaInstruction + generateNestedInstructions, + generateNestedAreaInstructions, + writeAreaInstruction, + writeNestedInstructions } from "agentrc/services/instructions.js"; export { runEval } from "agentrc/services/evaluator.js"; export { generateEvalScaffold } from "agentrc/services/evalScaffold.js";