Skip to content

[BUG] Sub-directory AGENTS.md is overwritten whole, ignoring managed_section and foreign Git roots #2560

Description

@SaulMoro

Describe the bug

_prepare_distributed_file() treats every sub-directory AGENTS.md as APM-owned and overwrites it whole. Two layouts break under that assumption:

  • A. A pre-existing AGENTS.md in a sub-directory loses its hand-authored content, even with compilation.agents_md.mode: managed_section set and correct <!-- apm:start --> / <!-- apm:end --> markers in the file. The root AGENTS.md in the same repository, same run, keeps its content.
  • B. When the placement directory is the root of a nested independent Git repository, compile modifies a tracked file in a repository it was never pointed at. The nested repo is left with an uncommitted modification.

AGENTS.md is a general convention, so a sub-directory copy may predate APM or belong to another repository. #1764 fixed this for the root only. The delete path already knows about Git boundaries after #2436; the write path does not.

Affects codex, copilot and cursor. claude is unaffected, since it keeps instruction output at the root and in .claude/rules/.

To Reproduce

Case A, markers ignored below the root:

mkdir -p /tmp/apm-a/.apm/instructions /tmp/apm-a/apps/x && cd /tmp/apm-a

cat > apm.yml <<'YAML'
name: repro-a
version: 0.0.1
description: Repro.
compilation:
  agents_md:
    mode: managed_section
YAML

cat > .apm/instructions/s.instructions.md <<'MD'
---
description: Scoped instruction.
applyTo: '**/*.ts'
---
- Generated rule.
MD

touch apps/x/a.ts

for f in AGENTS.md apps/x/AGENTS.md; do cat > "$f" <<'MD'
# Context

SENTINEL: hand-authored, outside the managed block.

<!-- apm:start -->
<!-- apm:end -->
MD
done

apm compile --local-only --target copilot
grep -c SENTINEL AGENTS.md         # 1
grep -c SENTINEL apps/x/AGENTS.md  # 0

Case B, write into a nested independent Git repository:

mkdir -p /tmp/apm-b/.apm/instructions && cd /tmp/apm-b && git init -q .

cat > apm.yml <<'YAML'
name: repro-b
version: 0.0.1
description: Repro.
YAML

cat > .apm/instructions/s.instructions.md <<'MD'
---
description: Scoped instruction.
applyTo: '**/*.ts'
---
- Generated rule.
MD

mkdir child && cd child && git init -q .
printf '# Child context\nSENTINEL: owned by this repository.\n' > AGENTS.md
git add AGENTS.md && git -c user.email=a@b -c user.name=a commit -qm "context"
touch svc.ts && cd ..

apm compile --local-only --target copilot
# Placement: child/AGENTS.md
grep -c SENTINEL child/AGENTS.md   # 0
git -C child status --short        # M AGENTS.md

Placement lands on the deepest directory the matches share, so Case B needs the matching files to sit only inside the nested repository. Add a second matching file outside it and placement moves up to the root, where neither case reproduces.

Expected behavior

Case A: honour managed_section at any depth, or refuse to write a file that has markers and content APM did not generate.

Case B: do not write below a Git root other than the one containing the active apm.yml. Report the skipped placement instead. distributed_compiler.py already skips foreign Git roots when removing orphans; the write path can use the same check.

Environment

  • OS: macOS 26.5.2 (arm64)
  • APM Version: 0.28.0 (e041462), standalone binary
  • Targets: reproduced on copilot, codex, cursor. Not reproduced on claude

Root cause

src/apm_cli/compilation/agents_compiler.py:1677-1683:

# Honour managed_section mode for the root AGENTS.md (issue #1764).
# Sub-directory files are fully APM-generated and always overwritten.
is_root = agents_path.parent.resolve() == self.base_dir.resolve()
if is_root and config.agents_md_mode == "managed_section":
    return self._prepare_output_content_with_config(
        str(agents_path), final_content, config
    )
return final_content

The is_root guard is what #1764's fix added, and the comment states the assumption behind it. Case A is that assumption meeting a file APM did not create. _write_distributed_file() then passes the content straight to CompiledOutputWriter().write(), so nothing downstream compares it against what is already on disk.

For Case B, placement comes from _create_placements() in distributed_compiler.py, which derives agents_path from the directory map with no ownership check. agents_compiler.py contains no reference to .git at all, while distributed_compiler.py has the skip-list added for #2436.

Suggested fix

  1. Drop is_root from the managed_section condition, so a marked file is reconciled at any depth.
  2. For a file with no markers that APM did not generate, either write markers around the generated block or skip it and report the path. Overwriting it is the one option that loses data.
  3. Resolve the Git root of each placement directory and skip any that differs from the working root's, reusing the boundary check from the orphan sweep.

Additional context

We hit this from a workspace that coordinates several independent Git repositories under one APM working root, where an instruction scoped to one stack matches files in exactly one repository. Related: #1764 (root-only fix), #2436 (same boundary on the delete path), #1730 (sub-directory placement), #1220 (per-instruction placement opt-out, closed as not planned).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/cliCLI command surface, flags, help text (cross-cutting).priority/highShips in current or next milestonestatus/triagedInitial agentic triage complete; pending maintainer ratification (silence = approval).theme/governanceGoverned by policy. apm-policy, audit, enforcement, enterprise rollout.theme/securitySecure by default. Content scanning, lockfile integrity, MCP trust boundaries.type/bugSomething does not work as documented.

    Type

    No type

    Projects

    Status
    In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions