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

Filter by extension

Filter by extension

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

- Copilot, Codex, Cursor, Claude, Windsurf, OpenCode, and Gemini adapters handle MCP v0.1 `runtimeArguments`/`packageArguments` with `variables` (no `type` key), matching the VS Code fix from #1444. (#1461, closes #1452, thanks @sergio-sisternes-epam)
- **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.14.2] - 2026-05-22

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 @@ -64,6 +64,8 @@ mcp: in apm.yml -> per target: .mcp.json / settings.json / equivalent

Not every target supports every primitive type. When a primitive can't land on a target, APM emits a warning at install time. Skim [Targets matrix](../reference/targets-matrix/) to set expectations before adding a primitive.

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

## Common workflows

### Add a target to an existing project
Expand Down
18 changes: 17 additions & 1 deletion docs/src/content/docs/producer/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,15 @@ Per target, with the rules shape on disk after compile:
| Target | Root context file | Per-rule output | Compile required? |
|---|---|---|---|
| `copilot` | `AGENTS.md` | `.github/instructions/<name>.instructions.md` (preserves `applyTo`) | No -- Copilot reads the per-rule files natively |
| `claude` | `CLAUDE.md` | `.claude/rules/<name>.md` | Yes -- `CLAUDE.md` is the entry point |
| `claude` | `CLAUDE.md` | `.claude/rules/<name>.md` | Yes -- deduplicates with `.claude/rules/` (see [below](#claude-code-deduplication)) |
| `cursor` | -- | `.cursor/rules/<name>.mdc` | Yes -- `.mdc` is Cursor's rules format |
| `codex` | `AGENTS.md` (folded) | none -- compile-only, no per-file deploy | Yes -- folded into `AGENTS.md` |
| `gemini` | `GEMINI.md` (folded) | none -- compile-only, no per-file deploy | Yes -- folded into `GEMINI.md` |
| `opencode` | `AGENTS.md` (folded) | none -- compile-only, no per-file deploy | Yes -- folded into `AGENTS.md` |
| `windsurf` | -- | `.windsurf/rules/<name>.md` | Yes -- compiled to Windsurf rules |

> **Claude deduplication**: When `apm install` has already deployed instructions to `.claude/rules/`, `apm compile --target claude` omits the instructions 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.

## compile vs install

| You want to... | Run |
Expand All @@ -137,6 +139,20 @@ correct AGENTS.md / CLAUDE.md / GEMINI.md output. Reach for
`apm compile` directly when you are iterating on instructions and
do not want install's side effects.

:::note[Claude Code deduplication]
<a id="claude-code-deduplication"></a>
When `.claude/rules/` is already populated with instructions,
`apm compile --target claude` automatically omits the instructions
section from `CLAUDE.md` to avoid duplicate content in Claude Code's
context window. The directory can be populated by either
`apm install --target claude` or by an earlier `apm compile --target claude`
run -- both write per-file instruction rules into `.claude/rules/`.
`CLAUDE.md` is still generated when it carries a constitution or
dependency `@import` paths. If `.claude/rules/` is later removed,
re-running `apm compile` restores the instructions section to
`CLAUDE.md`.
:::

## Pitfalls

- **Confusing compile's scope.** Compile only handles **instructions**
Expand Down
57 changes: 54 additions & 3 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,38 @@ 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():
from ..utils.path_security import PathTraversalError, ensure_path_within

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

if skip_instructions:
self._log(
"progress",
"Instructions already in .claude/rules/ -- omitting from CLAUDE.md"
" to avoid duplicate context",
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 All @@ -568,9 +598,20 @@ def _compile_claude_md(
# Handle dry-run mode
if config.dry_run:
# Generate preview summary
count = len(claude_result.placements)
preview_lines = [
f"CLAUDE.md Preview: Would generate {len(claude_result.placements)} files"
f"CLAUDE.md Preview: Would generate {count} {'file' if count == 1 else 'files'}"
]
# Surface the deduplication skip so dry-run is self-explanatory
# for scripted consumers (otherwise "Would generate 0 files"
# looks like a no-op or a bug). The same skip appears in the
# non-dry-run path via the dedicated INFO log line.
if skip_instructions:
preview_lines.append(
" (instructions section skipped: .claude/rules/ already "
"populated -- avoids duplicate content in Claude Code's "
"context window)"
)
for claude_path in claude_result.content_map.keys(): # noqa: SIM118
rel_path = portable_relpath(claude_path, self.base_dir)
preview_lines.append(f" {rel_path}")
Expand Down Expand Up @@ -628,15 +669,25 @@ 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 -- Claude Code reads .claude/rules/ directly,"
" no further action needed",
symbol="info",
)

Comment thread
tillig marked this conversation as resolved.
# Display CLAUDE.md compilation output using standard formatter
# Get proper compilation results from distributed compiler (has optimization decisions)
# Skip formatter output when deduplication filtered out all placements to
# avoid contradicting the "not generated" log message above.
from ..output.formatters import CompilationFormatter
from ..output.models import CompilationResults

compilation_results = distributed_compiler.get_compilation_results_for_display(
is_dry_run=config.dry_run
)
if compilation_results:
if compilation_results and not (skip_instructions and files_written == 0):
# Update target name for CLAUDE.md output
formatter_results = CompilationResults(
project_analysis=compilation_results.project_analysis,
Expand Down
56 changes: 40 additions & 16 deletions src/apm_cli/compilation/claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class ClaudePlacement:
dependencies: builtins.list[str] = field(default_factory=list) # @import paths
coverage_patterns: builtins.set[str] = field(default_factory=set)
source_attribution: builtins.dict[str, str] = field(default_factory=dict)
is_root: bool = False


@dataclass
Expand Down Expand Up @@ -97,24 +98,40 @@ def format_distributed(
try:
config = config or {}
source_attribution = config.get("source_attribution", True)
skip_instructions = config.get("skip_instructions", False)

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.
has_constitution = bool(read_constitution(self.base_dir))
content_map = {}
for placement in placements:
content = self._generate_claude_content(placement, primitives)
if skip_instructions:
if not placement.is_root:
continue
if not placement.dependencies and not has_constitution:
continue
content = self._generate_claude_content(
placement, primitives, skip_instructions=skip_instructions
)
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 @@ -150,19 +167,20 @@ def _generate_placements(
"""
placements = []

# Handle empty placement map with constitution
# Handle empty placement map with constitution or dependencies
if not placement_map:
constitution = read_constitution(self.base_dir)
if constitution:
# Create root placement for constitution-only projects
dependencies = self._collect_dependencies()
if constitution or dependencies:
root_path = self.base_dir / "CLAUDE.md"
placement = ClaudePlacement(
claude_path=root_path,
instructions=[],
agents=list(primitives.chatmodes),
dependencies=self._collect_dependencies(),
dependencies=dependencies,
coverage_patterns=set(),
source_attribution={},
is_root=True,
)
placements.append(placement)
else:
Expand Down Expand Up @@ -196,6 +214,7 @@ def _generate_placements(
dependencies=self._collect_dependencies() if is_root else [],
coverage_patterns=patterns,
source_attribution=source_map,
is_root=is_root,
)

placements.append(placement)
Expand Down Expand Up @@ -235,13 +254,18 @@ def _collect_dependencies(self) -> builtins.list[str]:
return sorted(dependencies)

def _generate_claude_content(
self, placement: ClaudePlacement, primitives: PrimitiveCollection
self,
placement: ClaudePlacement,
primitives: PrimitiveCollection,
*,
skip_instructions: bool = False,
) -> str:
"""Generate CLAUDE.md content for a specific placement.

Args:
placement (ClaudePlacement): Placement result with instructions.
primitives (PrimitiveCollection): Full primitive collection.
skip_instructions (bool): If True, omit the Project Standards section.

Returns:
str: Generated CLAUDE.md content.
Expand All @@ -263,17 +287,19 @@ def _generate_claude_content(
sections.append("")

# Constitution section (only for root CLAUDE.md)
is_root = placement.claude_path.parent == self.base_dir
if is_root:
if placement.is_root:
constitution = read_constitution(self.base_dir)
if constitution:
sections.append("# Constitution")
sections.append("")
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 skip_instructions:
sections.append("# Project Standards")
sections.append("")

Expand All @@ -285,9 +311,7 @@ def _generate_claude_content(
)
)

# 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
Loading
Loading