Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
ef9a4f4
refactor: address review feedback on skip-instructions logic
tillig May 12, 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 @@ -44,6 +44,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)
- `apm install` no longer silently overwrites pre-existing governance files; `check_collision()` now treats `managed_files=None` (first install, no lockfile) as an empty set so hand-rolled files in `.github/instructions/` are detected and protected. (#1256)
- Policy inheritance now fails closed: child policies that omit `unmanaged_files` inherit the parent's action instead of silently defaulting to `ignore`. (#1253)
- MCP server token injection now requires both an allowlisted server name and a verified HTTPS GitHub hostname, preventing PAT exfiltration via poisoned registry entries. (#1239)
Expand Down
12 changes: 11 additions & 1 deletion docs/src/content/docs/producer/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ 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 -- `CLAUDE.md` is the entry point (omitted when `.claude/rules/` already has instructions; see [deduplication note](#claude-code-deduplication) below) |
| `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` |
Expand All @@ -137,6 +137,16 @@ 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]
When `apm install` has already deployed instructions to
`.claude/rules/`, `apm compile --target claude` automatically omits
the instructions section from `CLAUDE.md` to avoid duplicate content
in Claude Code's context window. `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`.
Comment thread
tillig marked this conversation as resolved.
:::

## Pitfalls

- **Confusing compile's scope.** Compile only handles **instructions**
Expand Down
48 changes: 45 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,39 @@ 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(
"progress",
".claude/rules/ is a symlink outside the project root -- ignoring",
symbol="warning",
)
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,8 +599,9 @@ 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'}"
]
for claude_path in claude_result.content_map.keys(): # noqa: SIM118
rel_path = portable_relpath(claude_path, self.base_dir)
Expand Down Expand Up @@ -628,15 +660,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 @@ -37,6 +37,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 @@ -98,24 +99,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 @@ -151,19 +168,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 @@ -197,6 +215,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 @@ -236,13 +255,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 @@ -264,17 +288,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 @@ -296,9 +322,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
Loading
Loading