Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dda16d1
fix(compile): skip instructions in CLAUDE.md when .claude/rules/ is p…
tillig May 4, 2026
f364aba
docs(changelog): add entry for #1138 (deduplicate Claude instructions)
tillig May 5, 2026
00f9008
fix(compile): add zero-file log message and strengthen tests
tillig May 5, 2026
1bf0430
Merge remote-tracking branch 'origin/main' into feature/double-claude…
tillig May 5, 2026
69271c5
fix: address Copilot review feedback
tillig May 5, 2026
45694d8
Potential fix for pull request finding
tillig May 5, 2026
defb307
fix: address remaining Copilot review feedback
tillig May 5, 2026
bb7b07d
test: add dry-run positive case for CLAUDE.md preview reporting
tillig May 5, 2026
4b5db50
Merge remote-tracking branch 'origin/main' into feature/double-claude…
tillig May 6, 2026
1d87c58
Order unreleased/fixed by PR descending.
tillig May 6, 2026
bd55f4d
Merge remote-tracking branch 'origin/main' into feature/double-claude…
tillig May 6, 2026
196a633
Merge remote-tracking branch 'origin/main' into feature/double-claude…
tillig May 7, 2026
c138599
Merge remote-tracking branch 'origin/main' into feature/double-claude…
tillig May 8, 2026
a051ae0
fix: address review panel findings (OSError fallback, symlink securit…
tillig May 8, 2026
4bcacc8
fix: address follow-up review feedback
tillig May 8, 2026
b5f89a5
fix: improve test robustness per review feedback
tillig May 8, 2026
b99348f
Merge branch 'main' into feature/double-claude-instructions
tillig May 8, 2026
8a998e0
Merge remote-tracking branch 'origin/main' into feature/double-claude…
tillig May 11, 2026
7c5d8c7
docs: add deduplication note to compile.md
tillig May 11, 2026
55d68d9
Merge branch 'main' into feature/double-claude-instructions
tillig May 11, 2026
ccf9ec2
refactor: address review feedback on skip-instructions logic
tillig May 11, 2026
644eccc
fix: improve skip-instructions log messages for clarity
tillig May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Deduplicate Claude Code instructions.** `apm compile --target claude` now omits the "Project Standards" section from `CLAUDE.md` when instructions are already deployed to `.claude/rules/` by `apm install`, avoiding duplicate content in Claude Code's context window. `CLAUDE.md` is still generated for constitution and dependency imports. (#1138)

## [0.12.2] - 2026-05-05

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/guides/compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ target: [claude, copilot] # multiple targets -- only these are compiled

> **Note**: `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md` contain **only instructions** (grouped by `applyTo` patterns). Prompts, agents, commands, hooks, and skills are integrated by `apm install`, not `apm compile`. See the [Integrations Guide](../../integrations/ide-tool-integration/) for details on how `apm install` populates `.github/prompts/`, `.github/agents/`, `.github/skills/`, `.claude/commands/`, `.cursor/rules/`, `.cursor/agents/`, `.opencode/agents/`, `.opencode/commands/`, `.codex/agents/`, `.gemini/commands/`, and `.agents/skills/`.
Comment thread
tillig marked this conversation as resolved.
Outdated

> **Deduplication**: When `apm install` has already deployed instructions to `.claude/rules/`, `apm compile --target claude` automatically omits the "Project Standards" section from `CLAUDE.md` to avoid duplicate content in Claude Code's context window. `CLAUDE.md` is still generated if it carries a constitution or dependency `@import` paths.

### How It Works

1. **Primitives Discovery**: Scans `.apm/` and `.github/` directories for instructions, prompts, and agents
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ Running `apm compile` is optional for Claude Code, which reads deployed primitiv
|------|---------|
| `CLAUDE.md` | Merged project instructions for Claude (instructions only, using `@import` syntax) |
Comment thread
tillig marked this conversation as resolved.
Outdated

> **Deduplication**: When `.claude/rules/` already contains `.md` files (deployed by `apm install`), `apm compile --target claude` omits the instructions section from `CLAUDE.md` to avoid duplicate context. `CLAUDE.md` is still generated if it carries a constitution or dependency imports.

When you run `apm install`, APM integrates package primitives into Claude's native structure:

| Location | Purpose |
Expand Down
25 changes: 24 additions & 1 deletion src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,23 @@ def _compile_claude_md(
debug=config.debug,
)

# Skip instructions in CLAUDE.md when they are already deployed to
# .claude/rules/ by `apm install` (avoids duplicate context in Claude Code).
claude_rules_dir = self.base_dir / ".claude" / "rules"
skip_instructions = claude_rules_dir.is_dir() and any(claude_rules_dir.glob("*.md"))
if skip_instructions:
self._log(
"progress",
"Instructions already in .claude/rules/ -- skipping from CLAUDE.md",
symbol="info",
)

Comment thread
tillig marked this conversation as resolved.
# Format CLAUDE.md files
claude_config = {"source_attribution": config.source_attribution, "debug": config.debug}
claude_config = {
"source_attribution": config.source_attribution,
"debug": config.debug,
"skip_instructions": skip_instructions,
}
claude_result = claude_formatter.format_distributed(
primitives, placement_map, claude_config
)
Expand Down Expand Up @@ -628,6 +643,14 @@ def _compile_claude_md(
stats = claude_result.stats.copy()
stats["claude_files_written"] = files_written

if files_written == 0 and skip_instructions:
self._log(
"progress",
"CLAUDE.md not generated (instructions in .claude/rules/, "
"no constitution or dependencies to emit)",
symbol="info",
)

# Display CLAUDE.md compilation output using standard formatter
# Get proper compilation results from distributed compiler (has optimization decisions)
from ..output.formatters import CompilationFormatter
Expand Down
31 changes: 26 additions & 5 deletions src/apm_cli/compilation/claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,42 @@ def format_distributed(
try:
config = config or {}
source_attribution = config.get("source_attribution", True)
self._skip_instructions = config.get("skip_instructions", False)
Comment thread
tillig marked this conversation as resolved.
Outdated

Comment thread
tillig marked this conversation as resolved.
# Generate Claude placements from the placement map
placements = self._generate_placements(
placement_map, primitives, source_attribution=source_attribution
)

# Generate content for each placement
# Generate content for each placement.
# When instructions are skipped (already in .claude/rules/), only
# emit root CLAUDE.md if it has other content (constitution or
# dependencies); subdirectory placements are omitted entirely.
content_map = {}
for placement in placements:
if self._skip_instructions:
try:
is_root = placement.claude_path.parent.resolve() == self.base_dir
except OSError:
is_root = placement.claude_path.parent == self.base_dir
if not is_root:
continue
has_constitution = bool(read_constitution(self.base_dir))
if not placement.dependencies and not has_constitution:
continue
Comment thread
tillig marked this conversation as resolved.
Outdated
content = self._generate_claude_content(placement, primitives)
content_map[placement.claude_path] = content
Comment thread
tillig marked this conversation as resolved.

# Filter placements to only those that produced content so stats
# and downstream consumers see an accurate picture.
emitted_placements = [p for p in placements if p.claude_path in content_map]

# Compile statistics
stats = self._compile_stats(placements, primitives)
stats = self._compile_stats(emitted_placements, primitives)

return ClaudeCompilationResult(
success=len(self.errors) == 0,
placements=placements,
placements=emitted_placements,
content_map=content_map,
warnings=self.warnings.copy(),
errors=self.errors.copy(),
Expand Down Expand Up @@ -273,8 +291,11 @@ def _generate_claude_content(
sections.append(constitution.strip())
sections.append("")

# Project Standards section (grouped by pattern)
if placement.instructions:
# Project Standards section (grouped by pattern).
# Skipped when instructions are already deployed to .claude/rules/ by
# `apm install`, since Claude Code reads both locations and would see
# duplicate content.
if placement.instructions and not self._skip_instructions:
Comment thread
tillig marked this conversation as resolved.
Outdated
sections.append("# Project Standards")
sections.append("")

Expand Down
132 changes: 132 additions & 0 deletions tests/unit/compilation/test_agents_compiler_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,5 +671,137 @@ def test_compile_claude_md_constitution_injection_failure(self):
)


# ---------------------------------------------------------------------------
# AgentsCompiler._compile_claude_md() -- skip_instructions when .claude/rules/ populated
# ---------------------------------------------------------------------------


class TestClaudeCompileSkipInstructions(unittest.TestCase):
"""Test that _compile_claude_md skips instructions when .claude/rules/ has files."""

def setUp(self):
self.original_dir = os.getcwd()
self.tmp = tempfile.mkdtemp()
self.tmp_resolved = str(Path(self.tmp).resolve())
os.chdir(self.tmp_resolved)
# Minimal apm.yml so discovery works
with open("apm.yml", "w") as f:
yaml.dump({"name": "test-project", "version": "1.0.0"}, f)
# Create .apm/instructions with a sample instruction
instr_dir = Path(self.tmp_resolved) / ".apm" / "instructions"
instr_dir.mkdir(parents=True)
(instr_dir / "style.instructions.md").write_text(
"---\ndescription: Style guide\napplyTo: '**/*.py'\n---\nUse type hints.\n"
)

def tearDown(self):
os.chdir(self.original_dir)
import shutil

shutil.rmtree(self.tmp, ignore_errors=True)

def test_instructions_included_without_rules_dir(self):
"""Without .claude/rules/, instructions appear in CLAUDE.md."""
compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=False)
result = compiler.compile(config)
assert result.success
claude_md = Path(self.tmp_resolved) / "CLAUDE.md"
assert claude_md.exists()
assert "# Project Standards" in claude_md.read_text()

def test_instructions_skipped_with_populated_rules_dir(self):
"""With .claude/rules/ containing .md files, instructions are skipped."""
rules_dir = Path(self.tmp_resolved) / ".claude" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "style.md").write_text("---\npaths:\n - '**/*.py'\n---\nUse type hints.\n")

compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=False)
result = compiler.compile(config)
assert result.success

# No constitution or dependencies, so CLAUDE.md should not be generated
claude_md = Path(self.tmp_resolved) / "CLAUDE.md"
assert not claude_md.exists()

def test_instructions_not_skipped_with_empty_rules_dir(self):
"""An empty .claude/rules/ does not trigger instruction skipping."""
rules_dir = Path(self.tmp_resolved) / ".claude" / "rules"
rules_dir.mkdir(parents=True)

compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=False)
result = compiler.compile(config)
assert result.success

claude_md = Path(self.tmp_resolved) / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text()
assert "# Project Standards" in content

def test_instructions_not_skipped_with_non_md_files_in_rules_dir(self):
"""Non-.md files in .claude/rules/ do not trigger instruction skipping."""
rules_dir = Path(self.tmp_resolved) / ".claude" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / ".gitkeep").write_text("")
(rules_dir / "notes.txt").write_text("some notes")

compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=False)
result = compiler.compile(config)
assert result.success

claude_md = Path(self.tmp_resolved) / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text()
assert "# Project Standards" in content

def test_skip_instructions_dry_run(self):
"""Dry-run respects skip_instructions when .claude/rules/ is populated."""
rules_dir = Path(self.tmp_resolved) / ".claude" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "style.md").write_text("---\npaths:\n - '**/*.py'\n---\nUse type hints.\n")

compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=True)
result = compiler.compile(config)
assert result.success
assert "Project Standards" not in result.content
Comment thread
tillig marked this conversation as resolved.
Outdated

def test_skip_instructions_stats_reflect_emitted_files(self):
"""Stats report zero files generated when all placements are skipped."""
rules_dir = Path(self.tmp_resolved) / ".claude" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "style.md").write_text("---\npaths:\n - '**/*.py'\n---\nUse type hints.\n")

compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=True)
result = compiler.compile(config)
assert result.success
assert result.stats.get("claude_files_generated", 0) == 0

def test_skip_instructions_emits_log_messages(self):
"""When instructions are skipped, informational log messages are emitted."""
rules_dir = Path(self.tmp_resolved) / ".claude" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "style.md").write_text("---\npaths:\n - '**/*.py'\n---\nUse type hints.\n")

compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=False)

mock_logger = MagicMock()
result = compiler.compile(config, logger=mock_logger)
assert result.success

# Collect all progress messages
progress_calls = [
str(call) for call in mock_logger.progress.call_args_list
]
joined = " ".join(progress_calls)
assert "Instructions already in .claude/rules/" in joined
assert "CLAUDE.md not generated" in joined


if __name__ == "__main__":
unittest.main()
128 changes: 128 additions & 0 deletions tests/unit/compilation/test_claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,3 +496,131 @@ def test_format_distributed_handles_exceptions(self, temp_project):
result = formatter.format_distributed(primitives, {temp_project: [instruction]})

assert isinstance(result, ClaudeCompilationResult)


class TestSkipInstructions:
"""Tests for skip_instructions behavior when .claude/rules/ is populated."""

@pytest.fixture
def temp_project(self):
"""Create a temporary project directory."""
temp_dir = tempfile.mkdtemp()
resolved = Path(temp_dir).resolve()
yield resolved
shutil.rmtree(resolved, ignore_errors=True)

@pytest.fixture
def sample_primitives(self, temp_project):
"""Create sample primitives for testing."""
primitives = PrimitiveCollection()
instruction = Instruction(
name="python-style",
file_path=temp_project / ".apm/instructions/python.instructions.md",
description="Python coding standards",
apply_to="**/*.py",
content="Use type hints and follow PEP 8.",
author="test",
source="local",
)
primitives.add_primitive(instruction)
return primitives

def test_skip_instructions_omits_project_standards(self, temp_project, sample_primitives):
"""When skip_instructions is True, Project Standards section is omitted."""
formatter = ClaudeFormatter(str(temp_project))
placement_map = {temp_project: list(sample_primitives.instructions)}

config = {"skip_instructions": True}
result = formatter.format_distributed(sample_primitives, placement_map, config)

# No files generated (no constitution or dependencies either)
assert result.success
assert len(result.content_map) == 0
# Stats reflect zero emitted files, not the original placement count
assert result.stats["claude_files_generated"] == 0
assert result.placements == []

def test_skip_instructions_preserves_constitution(self, temp_project, sample_primitives):
"""When skip_instructions is True, constitution is still included."""
from apm_cli.compilation.constitution import clear_constitution_cache

# Create a constitution file at the expected path
memory_dir = temp_project / ".specify" / "memory"
memory_dir.mkdir(parents=True)
(memory_dir / "constitution.md").write_text("Always be helpful.")

clear_constitution_cache()
formatter = ClaudeFormatter(str(temp_project))
placement_map = {temp_project: list(sample_primitives.instructions)}

config = {"skip_instructions": True}
result = formatter.format_distributed(sample_primitives, placement_map, config)

assert result.success
assert len(result.content_map) == 1
content = result.content_map[temp_project / "CLAUDE.md"]
assert "# Constitution" in content
assert "Always be helpful." in content
assert "# Project Standards" not in content

def test_skip_instructions_preserves_dependencies(self, temp_project, sample_primitives):
"""When skip_instructions is True, dependencies are still included."""
# Create a dependency with CLAUDE.md
dep_dir = temp_project / "apm_modules" / "owner" / "package"
dep_dir.mkdir(parents=True)
(dep_dir / "CLAUDE.md").write_text("# dep")

formatter = ClaudeFormatter(str(temp_project))
placement_map = {temp_project: list(sample_primitives.instructions)}

config = {"skip_instructions": True}
result = formatter.format_distributed(sample_primitives, placement_map, config)

assert result.success
assert len(result.content_map) == 1
content = result.content_map[temp_project / "CLAUDE.md"]
assert "# Dependencies" in content
assert "@apm_modules/owner/package/CLAUDE.md" in content
assert "# Project Standards" not in content

def test_skip_instructions_omits_subdirectory_files(self, temp_project, sample_primitives):
"""When skip_instructions is True, subdirectory CLAUDE.md files are not generated."""
subdir = temp_project / "src"
subdir.mkdir()

primitives = PrimitiveCollection()
instruction = Instruction(
name="src-style",
file_path=temp_project / ".apm/instructions/src.instructions.md",
description="Source standards",
apply_to="src/**/*.py",
content="Source-specific rules.",
author="test",
source="local",
)
primitives.add_primitive(instruction)

placement_map = {subdir: list(primitives.instructions)}

config = {"skip_instructions": True}
formatter = ClaudeFormatter(str(temp_project))
result = formatter.format_distributed(primitives, placement_map, config)

assert result.success
assert len(result.content_map) == 0

def test_no_skip_instructions_includes_project_standards(
self, temp_project, sample_primitives
):
"""When skip_instructions is False (default), instructions are included."""
formatter = ClaudeFormatter(str(temp_project))
placement_map = {temp_project: list(sample_primitives.instructions)}

config = {"skip_instructions": False}
result = formatter.format_distributed(sample_primitives, placement_map, config)

assert result.success
assert len(result.content_map) == 1
content = result.content_map[temp_project / "CLAUDE.md"]
assert "# Project Standards" in content
assert "Use type hints and follow PEP 8." in content
Loading