Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 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)
- `shared/apm.md` no longer wraps the `target` input in a `|| 'all'` fallback. The defensive expression broke gh-aw's bare-expression substitution regex, causing consumer-supplied `target:` values to be silently dropped; the `import-schema` default already covers the omitted-input case. (#1185)
- `apm install --target all` no longer enumerates the experimental `copilot-cowork` target, which was crashing project-scope installs with a "requires --global" error and made `gh aw` workflows that pin `target: all` unusable. (#1191)
- Stabilized `test_install_over_defer_threshold_starts_live_once` on slow CI runners by joining the deferred-start timer thread instead of relying on a 100ms grace window. (#1191)
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/guides/compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ When you run `apm compile` without specifying a target, APM automatically detect
| Project Structure | Target | What Gets Generated |
|-------------------|--------|---------------------|
| `.github/` folder only | `copilot` | AGENTS.md (instructions only) |
| `.claude/` folder only | `claude` | CLAUDE.md (instructions only) |
| `.claude/` folder only | `claude` | CLAUDE.md |
| `.codex/` folder exists | `codex` | AGENTS.md (instructions only) |
| `.gemini/` folder exists | `gemini` | GEMINI.md (instructions only) |
| `.windsurf/` folder exists | `windsurf` | AGENTS.md (instructions only) |
Expand Down Expand Up @@ -65,7 +65,9 @@ target: [claude, copilot] # multiple targets -- only these are compiled

> **Aliases**: `vscode` and `agents` are accepted as aliases for `copilot`.

> **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/`.
> **Note**: `AGENTS.md` and `GEMINI.md` contain **only instructions** (grouped by `applyTo` patterns). `CLAUDE.md` also includes constitution content and dependency `@import` paths when present. 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/`.

> **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

Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ Running `apm compile` is optional for Claude Code, which reads deployed primitiv

| File | Purpose |
|------|---------|
| `CLAUDE.md` | Merged project instructions for Claude (instructions only, using `@import` syntax) |
| `CLAUDE.md` | Merged Claude context for the project, including instructions, constitution content, and dependency imports (using `@import` syntax where applicable) |

> **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:

Expand Down Expand Up @@ -373,7 +375,7 @@ apm compile

# Generate only Claude formats
apm compile --target claude
# Creates: CLAUDE.md (instructions only)
# Creates: CLAUDE.md

# Generate only VS Code/Copilot formats
apm compile --target copilot
Expand Down
38 changes: 37 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,36 @@ 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 = False
if claude_rules_dir.is_dir() and any(claude_rules_dir.glob("*.md")):
from ..utils.path_security import PathTraversalError, ensure_path_within

try:
ensure_path_within(claude_rules_dir, self.base_dir)
skip_instructions = True
except PathTraversalError:
self._log(
"progress",
".claude/rules/ is a symlink outside the project root -- ignoring",
symbol="warning",
)
Comment thread
tillig marked this conversation as resolved.

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 +656,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
36 changes: 28 additions & 8 deletions src/apm_cli/compilation/claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def __init__(self, base_dir: str = "."):

self.warnings: builtins.list[str] = []
self.errors: builtins.list[str] = []
self._skip_instructions: bool = False
Comment thread
tillig marked this conversation as resolved.
Outdated

def format_distributed(
self,
Expand All @@ -98,24 +99,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.absolute() == 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
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 +292,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 All @@ -296,9 +318,7 @@ def _emit(instruction: Instruction) -> builtins.list[str]:
)
)

# Note: CLAUDE.md only contains instructions (Project Standards).
# Agents/workflows are NOT included - they go to .github/agents/ as separate files.
# This matches AGENTS.md behavior which also only contains instructions.
# Agents/workflows go to .github/agents/ as separate files, not here.

# Footer
sections.append("---")
Expand Down
171 changes: 171 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,176 @@ 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 result.stats.get("claude_files_generated", 0) == 0
assert "Would generate 0 files" in result.content

def test_dry_run_reports_file_without_skip(self):
"""Dry-run without .claude/rules/ reports CLAUDE.md would be generated."""
compiler = AgentsCompiler(self.tmp_resolved)
config = CompilationConfig(target="claude", dry_run=True)
result = compiler.compile(config)
assert result.success
assert "Would generate 1 files" in result.content
Comment thread
tillig marked this conversation as resolved.
Outdated
assert "CLAUDE.md" in result.content

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


def test_skip_instructions_ignores_symlink_outside_project(self):
"""A .claude/rules/ symlinked outside the project does not trigger skip."""
import shutil

# Create a rules directory outside the project
external_dir = Path(self.tmp_resolved).parent / "external_rules"
external_dir.mkdir(parents=True, exist_ok=True)
(external_dir / "style.md").write_text("---\npaths:\n - '**/*.py'\n---\nHacked.\n")

# Symlink .claude/rules/ to the external directory
claude_dir = Path(self.tmp_resolved) / ".claude"
claude_dir.mkdir(parents=True, exist_ok=True)
rules_link = claude_dir / "rules"
rules_link.symlink_to(external_dir)
Comment thread
tillig marked this conversation as resolved.
Outdated

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

# Instructions should NOT be skipped (symlink escapes project root)
claude_md = Path(self.tmp_resolved) / "CLAUDE.md"
assert claude_md.exists()
assert "# Project Standards" in claude_md.read_text()

# Cleanup external dir
shutil.rmtree(external_dir, ignore_errors=True)


Comment thread
tillig marked this conversation as resolved.
if __name__ == "__main__":
unittest.main()
Loading
Loading