Skip to content

[BUG] apm compile -t copilot writes empty AGENTS.md shells (root + every distributed subdir) when instructions are already in .github/instructions/ #1730

@tillig

Description

@tillig

Is this a bug? Yes — sibling of #1138 / #1550 / #1678 / #1729, but distinct from all of them.

Summary

When instructions have already been deployed to .github/instructions/ (the normal state after apm install for a Copilot target), apm compile --target copilot correctly skips folding those instructions into AGENTS.md to avoid duplicate context for Copilot — but it still writes content-free AGENTS.md shells (header + footer, no instruction bodies). This happens for:

  • the root AGENTS.md, and
  • every distributed per-directory AGENTS.md the placement engine would emit (e.g. docs/AGENTS.md, src/AGENTS.md) when instructions are scoped via applyTo.

The Claude path partially handles this: when .claude/rules/ is already populated, apm compile --target claude avoids creating a new CLAUDE.md (added in #1138 via PR #1146, agents_compiler.py:785-791). The Copilot dedup-parity work (#1550) ported the skip logic but not even that empty-file suppression, so Copilot litters the tree with meaningless shells.

This is the AGENTS.md analog of #1138 (empty CLAUDE.md), and unlike the single-file Claude case it can leave several empty files scattered across subdirectories.

Environment

  • APM version: 0.19.0
  • OS: macOS

Steps to reproduce

Case A — root AGENTS.md (global instructions)

mkdir -p /tmp/apm-empty/.apm/instructions && cd /tmp/apm-empty
cat > apm.yml <<'EOF'
name: test-empty-agents
version: 1.0.0
targets: [copilot]
EOF
cat > .apm/instructions/global.instructions.md <<'EOF'
---
description: A global instruction
---
This is global guidance that should reach Copilot.
EOF
# Simulate the post-`apm install` state:
mkdir -p .github/instructions
cp .apm/instructions/*.instructions.md .github/instructions/
apm compile --target copilot
cat AGENTS.md   # => header + footer only, 233 bytes, no instructions

Case B — distributed subdir AGENTS.md (scoped instructions) — the worse case

mkdir -p /tmp/apm-dist/.apm/instructions /tmp/apm-dist/docs /tmp/apm-dist/src && cd /tmp/apm-dist
cat > apm.yml <<'EOF'
name: test-dist
version: 1.0.0
targets: [copilot]
EOF
cat > .apm/instructions/docs.instructions.md <<'EOF'
---
description: Markdown docs
applyTo: "docs/**/*.md"
---
Docs writing guidance.
EOF
cat > .apm/instructions/src.instructions.md <<'EOF'
---
description: Python source
applyTo: "src/**/*.py"
---
Python coding guidance.
EOF
echo "# doc" > docs/readme.md
echo "x = 1" > src/main.py
mkdir -p .github/instructions
cp .apm/instructions/*.instructions.md .github/instructions/
apm compile --target copilot
find . -name AGENTS.md   # => ./docs/AGENTS.md and ./src/AGENTS.md, each an empty 233-byte shell

Expected behavior

  1. When instruction dedup fires (.github/instructions/ already holds the instructions), no content-free AGENTS.md should be written — not the root file and not any distributed subdir file. Skip any placement whose only content would have been the now-elided instructions, and emit the same INFO-style log the Claude path does ("AGENTS.md not generated — Copilot reads .github/instructions/ directly").
  2. On apm compile --clean, any existing APM-generated AGENTS.md that would now end up empty should be removed (it's stale from a previous run). This must be gated on the <!-- Generated by APM CLI ... --> marker so hand-authored AGENTS.md files are never deleted. Plain apm compile should remain non-destructive: it stops writing new empty shells but does not delete pre-existing files.

Actual behavior

  • Case A writes a 233-byte root AGENTS.md (header + footer, no bodies).
  • Case B writes a 233-byte docs/AGENTS.md and src/AGENTS.md, both empty shells.
  • apm compile --clean does not remove them — the distributed placement path regenerates them each run, so they're never classified as orphans.

(In all cases the instructions do correctly reach .github/instructions/ and .github/copilot-instructions.md — the empty AGENTS.md files are pure redundant noise.)

Multi-target is unaffected (verified)

With targets: [copilot, codex] and .github/instructions/ populated, dedup correctly does not fire and AGENTS.md retains full instruction content. can_dedup_agents_md_instructions (core/target_detection.py:281) returns True only for the exact set {vscode}; any wider target set keeps skip_instructions = False. The proposed suppression triggers only when skip_instructions is already True, so it can never strip content from Codex/OpenCode/Windsurf/Gemini or any multi-target build. The fix sits strictly on top of the #1678 target-aware gate.

Root cause

In src/apm_cli/compilation/:

  • _compile_claude_md (agents_compiler.py:785-791) suppresses creating an empty file: when files_written == 0 and skip_instructions, it writes nothing and logs the "reads .claude/rules/ directly" message.
  • _compile_distributed (agents_compiler.py:417-575) has no equivalent guard. It threads skip_instructions=True into DistributedAgentsCompiler.compile_distributed, whose _generate_agents_content (distributed_compiler.py:547-611) appends header + footer unconditionally and only gates the instruction body on skip_instructions (line 587). Every placement therefore yields a non-empty string and is written via _write_distributed_file, including subdir placements.
  • _find_orphaned_agents_files (distributed_compiler.py:643) only flags files not in the current run's generated set. Since the empty shells are regenerated each run, they're never orphans, so --clean leaves them.

Suggested fix

Mirror and extend the CLAUDE.md behavior on the AGENTS.md path, per-placement:

  1. In the distributed compiler, when a placement's content would be a header/footer-only shell (i.e. skip_instructions elided its only instructions and it carries no other content such as constitution/agents roll-up), exclude it from content_map so it's never written. Apply to root and all subdir placements uniformly. Emit the same INFO log the Claude path uses, adapted to AGENTS.md.
  2. Fold the would-be-empty, already-existing, APM-generated placement paths into --clean orphan cleanup so it removes stale shells from prior runs — gated on the <!-- Generated by APM CLI ... --> marker (reuse the hand-authored-protection convention already at agents_compiler.py:1131) so user-authored AGENTS.md files are never touched. Plain apm compile stays non-destructive (suppresses new writes only).

Cases that must remain unchanged:

  • AGENTS.md placements carrying non-instruction content (constitution, agents/prompts roll-up) still written.
  • Codex / OpenCode / Windsurf / Gemini / any multi-target set (already gated; skip_instructions is False).
  • --no-dedup / --force-instructions still produce full AGENTS.md.
  • Hand-authored AGENTS.md (no APM marker) never deleted by --clean.

Acceptance criteria

  • Failing test (Case A): target = copilot, .github/instructions/*.md present → no content-free root AGENTS.md written.
  • Failing test (Case B): same conditions with scoped instructions across subdirs → no empty docs/AGENTS.md / src/AGENTS.md written.
  • Same INFO-style "not generated — Copilot reads .github/instructions/ directly" message as the Claude path.
  • apm compile --clean removes pre-existing APM-generated AGENTS.md files that would now be empty; hand-authored AGENTS.md (no marker) untouched; plain apm compile deletes nothing.
  • Unchanged: non-empty placements, Codex/OpenCode/Windsurf/Gemini/multi-target, --no-dedup.
  • Mutation check: removing the new guard reintroduces the empty file(s).
  • CHANGELOG.md ### Fixed entry referencing this issue, [FEATURE] Skip instructions in CLAUDE.md when already deployed to .claude/rules/ #1138, and perf/dedup: parity for Copilot AGENTS.md vs .github/instructions/ (sibling of #1445) #1550.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions