diff --git a/README.md b/README.md index a1eef4304..6bc8a7e5d 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ flowchart TB | `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level | | `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams | -### Agent skills installed to `.claude/skills/` automatically +### Agent skills installed to `.claude/skills/` and `.agents/skills/` (if `.agents/` exists) automatically - **Exploring** — navigate unfamiliar code using the knowledge graph - **Debugging** — trace bugs through call chains @@ -198,6 +198,8 @@ flowchart TB **Repo-specific skills** — run `gitnexus analyze --skills` and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under `.claude/skills/gitnexus-area-/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each `--skills` run to stay current. +When a repo contains an `.agents/` directory, the standard and generated skills are also mirrored to `.agents/skills/` (e.g. `.agents/skills/gitnexus-cli/`, `.agents/skills/gitnexus-area-/`) so agents that read repo-local `.agents/skills/` (like Codex) stay in sync. + ## Editor Setup `gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`. @@ -395,7 +397,7 @@ gitnexus analyze --skills # Generate repo-specific skill files from detec gitnexus analyze --skip-embeddings # Skip embedding generation (faster) gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search) gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits -gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files +gitnexus analyze --skip-skills # Skip installing standard skill files under .claude/skills/ and .agents/skills/ gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref) gitnexus analyze --verbose # Log skipped files when parsers are unavailable @@ -451,7 +453,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana // over its fix on every analyze. (Alias: "branch".) "defaultBranch": "develop", "skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md - "skipSkills": true, // don't install standard .claude/skills/gitnexus-* skills + "skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/ "embeddings": true, // generate embeddings by default "workerTimeout": 60, } diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 780408451..ec5ae6170 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -362,13 +362,32 @@ async function upsertGitNexusSection( } /** - * Install GitNexus skills as direct children of .claude/skills/ - * Works natively with Claude Code, Cursor, and GitHub Copilot + * Some agents read skills from a repo-local `.agents/skills/` directory and + * prefer it over the global `~/.agents/skills/` install. When the repo contains + * an `.agents/` directory, skills written to `.claude/skills/` are mirrored + * there too so those agents serve the up-to-date copies. */ -async function installSkills(repoPath: string): Promise { +export async function shouldMirrorSkillsToAgents(repoPath: string): Promise { + try { + const stat = await fs.stat(path.join(repoPath, '.agents')); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Install GitNexus skills as direct children of .claude/skills/ + * Works natively with Claude Code, Cursor, and GitHub Copilot. + * Mirrored to .agents/skills/ when .agents/ exists. + */ +async function installSkills( + repoPath: string, +): Promise<{ skills: string[]; agentsMirror: boolean }> { const skillsDir = path.join(repoPath, '.claude', 'skills'); const legacySkillsDir = path.join(skillsDir, 'gitnexus'); const installedSkills: string[] = []; + const agentsMirror = await shouldMirrorSkillsToAgents(repoPath); for (const skill of STANDARD_SKILL_CATALOG.filter( (entry) => entry.distributions.project && entry.distributions.npm, @@ -402,6 +421,18 @@ Use GitNexus tools to accomplish this task. } await fs.writeFile(skillPath, skillContent, 'utf-8'); + + // Mirror to .agents/skills/ for agents that read repo-local skills + if (agentsMirror) { + try { + const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name); + await fs.mkdir(agentsSkillDir, { recursive: true }); + await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8'); + } catch (err) { + logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`); + } + } + installedSkills.push(skill.name); // Previous releases installed these known standard skills one level too @@ -418,7 +449,7 @@ Use GitNexus tools to accomplish this task. } } - return installedSkills; + return { skills: installedSkills, agentsMirror }; } /** @@ -496,9 +527,14 @@ export async function generateAIContextFiles( // Install standard skills directly under .claude/skills/ (unless --skip-skills) if (!options?.skipSkills) { - const installedSkills = await installSkills(repoPath); + const { skills: installedSkills, agentsMirror } = await installSkills(repoPath); if (installedSkills.length > 0) { createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`); + if (agentsMirror) { + createdFiles.push( + `.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`, + ); + } } } else { createdFiles.push('.claude/skills/gitnexus-*/ (skipped via --skip-skills)'); diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 3262a1084..bbec28e2c 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -185,7 +185,7 @@ export const en = { 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md', 'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md', 'help.option.analyze.skipSkills': - 'Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.', + 'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.', 'help.option.analyze.indexOnly': 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)', 'help.option.skipGit': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index b0010748e..7506b8d4b 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -176,7 +176,7 @@ export const zhCN = { 'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块', 'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数', 'help.option.analyze.skipSkills': - '跳过直接安装在 .claude/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。', + '跳过直接安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。', 'help.option.analyze.indexOnly': '纯索引模式:跳过所有文件注入(AGENTS.md、CLAUDE.md、skills)', 'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录', 'help.option.analyze.name': diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 21de56203..952604002 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -94,7 +94,7 @@ program .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') .option( '--skip-skills', - 'Skip installing standard GitNexus skill files directly under .claude/skills/. ' + + 'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. ' + 'Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). ' + 'Use --index-only to skip all AI-context file injection.', ) diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index f4c947d63..9e46b1e5c 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -13,6 +13,7 @@ import { PipelineResult } from '../types/pipeline.js'; import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js'; import { ProcessNode } from '../core/ingestion/process-processor.js'; import { KnowledgeGraph } from '../core/graph/types.js'; +import { shouldMirrorSkillsToAgents } from './ai-context.js'; const GENERATED_SKILL_PREFIX = 'gitnexus-area-'; const MAX_SKILL_NAME_LENGTH = 64; @@ -74,6 +75,12 @@ export const generateSkillFiles = async ( const { communityResult, processResult, graph } = pipelineResult; const outputDir = path.join(repoPath, '.claude', 'skills'); const legacyOutputDir = path.join(outputDir, 'generated'); + // Some agents prioritize repo-local .agents/skills over the global + // ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/ + // exists, mirror the generated community skills there too so those agents + // serve the up-to-date copies. + const agentsOutputDir = path.join(repoPath, '.agents', 'skills'); + let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath); // Community skills used to live under an undiscoverable `generated/` // grouping directory. Clear that GitNexus-owned legacy output and @@ -95,6 +102,24 @@ export const generateSkillFiles = async ( /* legacy output may not exist */ } + // Mirror cleanup: clear only stale GitNexus-generated community skills under + // .agents/skills/ (reserved gitnexus-area-* namespace), preserving mirrored + // standard skills and any user-authored skills. Never clear the whole root. + if (mirrorToAgents) { + try { + const entries = await fs.readdir(agentsOutputDir, { withFileTypes: true }); + await Promise.all( + entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_PREFIX)) + .map((entry) => + fs.rm(path.join(agentsOutputDir, entry.name), { recursive: true, force: true }), + ), + ); + } catch { + /* mirror root may not exist yet */ + } + } + if (!communityResult || !communityResult.memberships.length) { console.log('\n Skills: no communities detected, skipping skill generation'); return { skills: [], outputPath: outputDir }; @@ -135,6 +160,20 @@ export const generateSkillFiles = async ( // Step 4: Ensure the shared project-skill root exists. Never clear it: it // also contains user-authored and standard GitNexus skills. await fs.mkdir(outputDir, { recursive: true }); + // The .agents/ mirror is a side flow: keep it a weak dependency. If the + // mirror root cannot be created (e.g. `.agents/skills` exists as a file), + // warn and disable mirroring for this run instead of aborting canonical + // community-skill generation. Canonical writes below stay unaffected. + if (mirrorToAgents) { + try { + await fs.mkdir(agentsOutputDir, { recursive: true }); + } catch (err) { + console.log( + `Warning: Could not create mirror root ${agentsOutputDir} — .agents/skills mirroring disabled for this run: ${err}`, + ); + mirrorToAgents = false; + } + } // Step 5: Generate skill files const skills: GeneratedSkillInfo[] = []; @@ -185,6 +224,19 @@ export const generateSkillFiles = async ( await fs.mkdir(skillDir, { recursive: true }); await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); + // Mirror to .agents/skills/ for agents that read repo-local skills + // (see mirrorToAgents above). Best-effort: a per-skill mirror failure + // must not abort canonical community-skill generation. + if (mirrorToAgents) { + try { + const agentsSkillDir = path.join(agentsOutputDir, skillName); + await fs.mkdir(agentsSkillDir, { recursive: true }); + await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8'); + } catch (err) { + console.log(`Warning: Could not mirror skill ${skillName} to .agents/skills: ${err}`); + } + } + const info: GeneratedSkillInfo = { name: skillName, label: community.label, @@ -201,6 +253,11 @@ export const generateSkillFiles = async ( console.log( `\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_PREFIX}*/`, ); + if (mirrorToAgents) { + console.log( + ` ${skills.length} skills mirrored \u2192 .agents/skills/${GENERATED_SKILL_PREFIX}*/ (.agents)`, + ); + } return { skills, outputPath: outputDir }; }; diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 4b4fdd0f1..df4bbaa1c 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -10,10 +10,13 @@ const chompGitOutput = (value: Buffer): string => value.toString().replace(/\r?\ /** * True when the working tree has uncommitted changes that analyze would * re-index, even at a matching HEAD. Excludes the paths GitNexus writes during - * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md) so its own - * output never counts as dirty (regression vs PR #1233 behavior). Conservative - * on any git failure. Shared so `analyze`'s fast-path gate and `status`'s - * freshness report agree on what "dirty" means. + * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the + * repo-local .agents/ mirror) so its own output never counts as dirty + * (regression vs PR #1233 behavior). The entire .agents/ tree is excluded, + * matching the .claude/ treatment, because the skill mirror writes across + * .agents/skills/ and deeper paths. Conservative on any git failure. Shared + * so `analyze`'s fast-path gate and `status`'s freshness report agree on what + * "dirty" means. */ export const isWorkingTreeDirty = (repoPath: string): boolean => { try { @@ -32,6 +35,8 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => { ':(exclude).cursor/**', ':(exclude)AGENTS.md', ':(exclude)CLAUDE.md', + ':(exclude).agents', + ':(exclude).agents/**', ], { cwd: repoPath, diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index ecf801d2e..cbe3bf457 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -429,6 +429,181 @@ Old content here. } }); + it('mirrors standard skills to .agents/skills/ when .agents/ exists', async () => { + // Some agents prefer repo-local .agents/skills over the global + // ~/.agents/skills install. When the repo contains an .agents/ directory, + // installSkills() must mirror the same SKILL.md files there so those agents + // serve up-to-date repo-specific skills instead of stale global copies. + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-')); + const agentsStorage = path.join(agentsDir, '.gitnexus'); + await fs.mkdir(agentsStorage, { recursive: true }); + // Opt-in: create the repo-local .agents/ directory. + await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + + // Canonical .claude copy is always written (flat layout, #2434). + expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); + // Mirror is reported. + expect(result.files).toContain('.agents/skills/gitnexus-*/ (6 skills mirrored for .agents)'); + + const claudeSkill = await fs.readFile( + path.join(agentsDir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + const agentsSkill = await fs.readFile( + path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(agentsSkill).toBe(claudeSkill); + expect(agentsSkill.length).toBeGreaterThan(0); + } finally { + await fs.rm(agentsDir, { recursive: true, force: true }); + } + }); + + it('does not mirror skills to .agents/ when the directory is absent', async () => { + // Without an .agents/ opt-in, only the canonical .claude/skills/ copy is + // written — no .agents/ tree should be created. + const noAgentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-agents-')); + const noAgentsStorage = path.join(noAgentsDir, '.gitnexus'); + await fs.mkdir(noAgentsStorage, { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles( + noAgentsDir, + noAgentsStorage, + 'TestProject', + stats, + ); + + expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); + expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); + await expect(fs.access(path.join(noAgentsDir, '.agents'))).rejects.toThrow(); + } finally { + await fs.rm(noAgentsDir, { recursive: true, force: true }); + } + }); + + it('keeps canonical skills intact when .agents/skills is a file (mirror mkdir fails)', async () => { + // MEDIUM 1 (standard-skill half): when .agents/skills exists as a regular + // file, the per-skill mirror mkdir fails. The failure must be warned per + // skill and canonical .claude/skills/ must still hold all 6 skills. + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-file-')); + const agentsStorage = path.join(agentsDir, '.gitnexus'); + await fs.mkdir(agentsStorage, { recursive: true }); + await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); + // .agents/skills is a file — per-skill mirror mkdir will EEXIST. + await fs.writeFile(path.join(agentsDir, '.agents', 'skills'), 'not a directory'); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + + // Canonical 6 skills are all present. + expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); + for (const name of [ + 'gitnexus-exploring', + 'gitnexus-debugging', + 'gitnexus-impact-analysis', + 'gitnexus-refactoring', + 'gitnexus-guide', + 'gitnexus-cli', + ]) { + await expect( + fs.readFile(path.join(agentsDir, '.claude', 'skills', name, 'SKILL.md'), 'utf-8'), + ).resolves.toHaveProperty('length'); + } + // Mirror failures were warned, not thrown. + const warned = cap.records().some((r) => r.level === 40); // pino warn level + expect(warned).toBe(true); + } finally { + cap.restore(); + await fs.rm(agentsDir, { recursive: true, force: true }); + } + }); + + it('does not mirror and does not create .agents/ when .agents is a file (gate is false)', async () => { + // The gate checks isDirectory(); a file at .agents must NOT trigger + // mirroring and must not throw. + const fileDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-filegate-')); + const fileStorage = path.join(fileDir, '.gitnexus'); + await fs.mkdir(fileStorage, { recursive: true }); + await fs.writeFile(path.join(fileDir, '.agents'), 'not a directory'); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles(fileDir, fileStorage, 'TestProject', stats); + + expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); + expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); + } finally { + await fs.rm(fileDir, { recursive: true, force: true }); + } + }); + + it('is idempotent across repeated runs (no duplicates, stable mirror content)', async () => { + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-idem-')); + const agentsStorage = path.join(agentsDir, '.gitnexus'); + await fs.mkdir(agentsStorage, { recursive: true }); + await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + const first = await fs.readFile( + path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + + // Second run — must not duplicate or corrupt. + await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + const second = await fs.readFile( + path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(second).toBe(first); + + // Mirror tree has exactly one dir per standard skill (no duplicates). + const entries = await fs.readdir(path.join(agentsDir, '.agents', 'skills'), { + withFileTypes: true, + }); + const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + expect(skillDirs).toContain('gitnexus-cli'); + expect(skillDirs.filter((n) => n === 'gitnexus-cli')).toHaveLength(1); + } finally { + await fs.rm(agentsDir, { recursive: true, force: true }); + } + }); + + it('does not mirror standard skills when --skip-skills is set', async () => { + const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-mirror-')); + const skipStorage = path.join(skipDir, '.gitnexus'); + await fs.mkdir(skipStorage, { recursive: true }); + await fs.mkdir(path.join(skipDir, '.agents'), { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles( + skipDir, + skipStorage, + 'TestProject', + stats, + undefined, + { + skipSkills: true, + }, + ); + + expect(result.files).toContain('.claude/skills/gitnexus-*/ (skipped via --skip-skills)'); + expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); + await expect( + fs.access(path.join(skipDir, '.agents', 'skills', 'gitnexus-cli')), + ).rejects.toThrow(); + } finally { + await fs.rm(skipDir, { recursive: true, force: true }); + } + }); + it('writes nothing when both skipAgentsMd and skipSkills are true (--index-only, #742)', async () => { // Regression guard for #742. analyzeCommand() resolves --index-only // into BOTH skipAgentsMd=true and skipSkills=true. This test pins diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts index d04524d5c..ae0277d9b 100644 --- a/gitnexus/test/unit/git-utils.test.ts +++ b/gitnexus/test/unit/git-utils.test.ts @@ -341,3 +341,186 @@ describe('getCanonicalRepoRoot', () => { } }); }); + +// ─── isWorkingTreeDirty ─────────────────────────────────────────────────── +// +// analyze's fast-path gate. GitNexus writes to .gitnexus/, .claude/, .cursor/, +// AGENTS.md, CLAUDE.md, and the repo-local .agents/ skill mirror during a run; +// those writes must never count as "dirty" or the up-to-date fast path is +// defeated on every re-run. Real temporary git repos exercise the actual +// `git status --porcelain` pathspec exclude list. + +/** Create a fresh git repo in an isolated temp dir and return its path. */ +function makeIsolatedGitRepo(): string { + const dir = makeIsolatedTempDir('gn-dirty-'); + execSync('git init -q', { cwd: dir, stdio: 'ignore' }); + // Set a stable identity so commit doesn't fail on environments without + // global git config (CI containers, fresh sandboxes). + execSync('git config user.email t@t', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.name t', { cwd: dir, stdio: 'ignore' }); + return dir; +} + +describe('isWorkingTreeDirty', () => { + it('returns false for a clean tree with only GitNexus-managed paths written', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + // Initial commit so the tree has a HEAD. + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // Simulate GitNexus writing its managed outputs. + fs.mkdirSync(path.join(repo, '.gitnexus'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}'); + fs.mkdirSync(path.join(repo, '.claude', 'skills', 'gitnexus-cli'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), 'x'); + fs.mkdirSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth'), { + recursive: true, + }); + fs.writeFileSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth', 'SKILL.md'), 'x'); + fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x'); + fs.writeFileSync(path.join(repo, 'CLAUDE.md'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('returns true when a real source file changes (regression: excludes must not mask real edits)', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // A real business-file edit alongside GitNexus writes. + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'foo.ts'), 'export const x = 2;'); + fs.mkdirSync(path.join(repo, '.agents', 'skills', 'x'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.agents', 'skills', 'x', 'SKILL.md'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('treats the entire .agents/ tree as excluded (root file, nested skills, deep paths)', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // Root-level file under .agents/. + fs.mkdirSync(path.join(repo, '.agents'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.agents', 'foo.txt'), 'x'); + // Deep nested mirror path. + fs.mkdirSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth'), { + recursive: true, + }); + fs.writeFileSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth', 'SKILL.md'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does not error when .agents/ does not exist (no pathspec failure)', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does not error when .agents is a file rather than a directory', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // .agents exists as a regular file (e.g. user created it by mistake). + fs.writeFileSync(path.join(repo, '.agents'), 'not a directory'); + + // Must not throw; the tree is otherwise clean so it is not dirty. + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does NOT exclude prefix-colliding names like .agentsrc or .claudefoo', async () => { + // pathspec `:(exclude).agents` must not swallow `.agentsrc` (no path + // separator). A change to such a colliding name still counts as dirty. + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + fs.writeFileSync(path.join(repo, '.agentsrc'), 'x'); + fs.writeFileSync(path.join(repo, '.claudefoo'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does NOT exclude a nested .agents/ inside a subdirectory (root-relative pathspec)', async () => { + // `:(exclude).agents` is relative to the repo root; a subdirectory's + // .agents/ is unrelated and must still count as dirty. + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + fs.mkdirSync(path.join(repo, 'subdir', '.agents'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'subdir', '.agents', 'x'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('returns true (conservative) when called outside a git repository', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const dir = makeIsolatedTempDir('gn-nongit-'); + try { + // No git init — git status fails, and the gate must fail closed (dirty). + expect(isWorkingTreeDirty(dir)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns true (conservative) when git is not on PATH', async () => { + // PATH cleared so `git` cannot be found. The catch block must return true + // (fail closed) rather than silently treating the tree as clean — a clean + // false-positive would skip re-indexing of a genuinely-changed repo. + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + const savedPath = process.env.PATH; + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + process.env.PATH = ''; + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + process.env.PATH = savedPath; + fs.rmSync(repo, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/skill-gen.test.ts b/gitnexus/test/unit/skill-gen.test.ts index 797f67c4c..0ffb60ec0 100644 --- a/gitnexus/test/unit/skill-gen.test.ts +++ b/gitnexus/test/unit/skill-gen.test.ts @@ -643,6 +643,266 @@ describe('generateSkillFiles — file output', () => { await expect(fs.access(path.join(skillsRoot, 'gitnexus-area-old'))).rejects.toThrow(); }); + /** + * When the repo contains an .agents/ directory, generated community skills + * must be mirrored to .agents/skills/ (flat gitnexus-area-* layout, #2434) + * so agents that prefer repo-local .agents/skills over the global + * ~/.agents/skills install serve the up-to-date set. The mirror content must + * match the .claude copy. + */ + it('mirrors generated skills to .agents/skills/ when .agents/ exists', async () => { + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ + graph, + repoPath: tmpDir, + communities, + memberships, + }), + ); + + const claudeAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + const agentsAlpha = await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + const agentsBeta = await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(agentsAlpha).toBe(claudeAlpha); + expect(agentsBeta.length).toBeGreaterThan(0); + }); + + /** + * Without an .agents/ opt-in, no .agents/skills/ tree should be created — + * only the canonical .claude/skills/ copy is written. + */ + it('does not mirror generated skills to .agents/ when the directory is absent', async () => { + const { graph, communities, memberships } = twoCommSetup(); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ + graph, + repoPath: tmpDir, + communities, + memberships, + }), + ); + + // Canonical copy exists, mirror does not. + const claudeAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + expect(claudeAlpha.length).toBeGreaterThan(0); + await expect(fs.access(path.join(tmpDir, '.agents'))).rejects.toThrow(); + }); + + /** + * MEDIUM 1 (reviewer repro): when `.agents/skills` exists as a regular file, + * the mirror root mkdir fails. Mirroring must degrade gracefully (warn + + * disable) and the canonical community skills under .claude/skills/ must + * still be written in full — never deleted-then-not-rewritten. + */ + it('keeps canonical skills intact when .agents/skills is a file (mirror root mkdir fails)', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { graph, communities, memberships } = twoCommSetup(); + // .agents/ exists, but .agents/skills is a file — mkdir will EEXIST. + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'not a directory'); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Canonical skills are fully present. + const claudeAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + const claudeBeta = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(claudeAlpha.length).toBeGreaterThan(0); + expect(claudeBeta.length).toBeGreaterThan(0); + // Mirror was disabled with a warning, not a thrown error. + expect(logSpy).toHaveBeenCalled(); + }); + + /** + * MEDIUM 1 per-skill: the mirror root is writable, but an individual skill's + * mirror write fails. The failure must be warned and contained — other + * communities' canonical AND mirror writes still succeed. + */ + it('isolates a per-skill mirror write failure to that skill (best-effort)', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + + // Sabotage only the alpha mirror dir: make it a read-only file so the + // per-skill mkdir(agentsSkillDir) throws EEXIST (not a dir) and is caught. + await fs.mkdir(path.join(tmpDir, '.agents', 'skills'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha'), + 'file blocks dir', + ); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Canonical for both communities is intact. + await expect( + fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ), + ).resolves.toHaveProperty('length'); + const claudeBeta = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(claudeBeta.length).toBeGreaterThan(0); + // Beta mirror still written (alpha failure did not abort the loop). + const agentsBeta = await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(agentsBeta).toBe(claudeBeta); + expect(logSpy).toHaveBeenCalled(); + }); + + /** + * MEDIUM 1 delete-then-rewrite ordering: the canonical gitnexus-area-* + * cleanup runs before the mirror writes. A mirror failure after cleanup + * must not leave canonical missing — canonical is rewritten regardless. + */ + it('rewrites canonical skills after cleanup even when mirroring fails', async () => { + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + + // First run: write canonical + mirror normally. + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + const firstAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + + // Second run with mirror broken: .agents/skills becomes a file. + await fs.rm(path.join(tmpDir, '.agents', 'skills'), { recursive: true, force: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'now a file'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Canonical alpha is still present and content is stable (cleanup deleted + // the old dir, then canonical rewrote it — not lost). + const secondAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + expect(secondAlpha).toBe(firstAlpha); + }); + + /** + * Mirror cleanup is namespace-scoped: only stale gitnexus-area-* mirror + * dirs are removed; mirrored standard skills and user-authored skills under + * .agents/skills/ survive a re-run. + */ + it('clears only stale gitnexus-area-* mirror dirs, preserving others', async () => { + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + // Pre-existing non-community content that must survive. + await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'standard', + ); + await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'user-author'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills', 'user-author', 'SKILL.md'), 'mine'); + // Stale community mirror from a prior run. + await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old'), { + recursive: true, + }); + await fs.writeFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old', 'SKILL.md'), + 'stale', + ); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Stale community mirror gone; non-community content preserved. + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old')), + ).rejects.toThrow(); + expect( + await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ), + ).toBe('standard'); + expect( + await fs.readFile(path.join(tmpDir, '.agents', 'skills', 'user-author', 'SKILL.md'), 'utf-8'), + ).toBe('mine'); + // Fresh community mirrors written. + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha', 'SKILL.md')), + ).resolves.toBeUndefined(); + }); + + /** + * Empty edge case: no significant communities + .agents/ present must not + * write or mirror anything, and must not throw. + */ + it('writes nothing when no communities are significant, even with .agents/ present', async () => { + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + const graph = createKnowledgeGraph(); + // 2-symbol community — below the 3-symbol threshold. + for (let i = 0; i < 2; i++) { + graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/f${i}.ts`, 1, false)); + } + const communities = [makeCommunity('c1', 'Tiny', 2)]; + const memberships = [makeMembership('fn:n0', 'c1'), makeMembership('fn:n1', 'c1')]; + + const result = await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + expect(result.skills).toEqual([]); + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-tiny')), + ).rejects.toThrow(); + }); + /** * SKILL.md files should start with YAML frontmatter containing * name and description fields. diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts index 0b3bed936..9163e8692 100644 --- a/gitnexus/test/unit/skip-git-cli.test.ts +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -45,6 +45,7 @@ describe('--skip-git CLI flag', () => { expect(helpOutput).toContain('--skip-agents-md'); expect(helpOutput).toContain('--skip-skills'); expect(helpOutput).toContain('directly under .claude/skills/'); + expect(helpOutput).toContain('.agents/skills/'); expect(helpOutput).toContain('.claude/skills/gitnexus-area-*'); expect(helpOutput).toContain('--index-only'); expect(helpOutput).not.toContain('--no-git');