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
- Drop
is_root from the managed_section condition, so a marked file is reconciled at any depth.
- 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.
- 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).
Describe the bug
_prepare_distributed_file()treats every sub-directoryAGENTS.mdas APM-owned and overwrites it whole. Two layouts break under that assumption:AGENTS.mdin a sub-directory loses its hand-authored content, even withcompilation.agents_md.mode: managed_sectionset and correct<!-- apm:start -->/<!-- apm:end -->markers in the file. The rootAGENTS.mdin the same repository, same run, keeps its content.AGENTS.mdis 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,copilotandcursor.claudeis unaffected, since it keeps instruction output at the root and in.claude/rules/.To Reproduce
Case A, markers ignored below the root:
Case B, write into a nested independent Git repository:
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_sectionat 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.pyalready skips foreign Git roots when removing orphans; the write path can use the same check.Environment
copilot,codex,cursor. Not reproduced onclaudeRoot cause
src/apm_cli/compilation/agents_compiler.py:1677-1683:The
is_rootguard 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 toCompiledOutputWriter().write(), so nothing downstream compares it against what is already on disk.For Case B, placement comes from
_create_placements()indistributed_compiler.py, which derivesagents_pathfrom the directory map with no ownership check.agents_compiler.pycontains no reference to.gitat all, whiledistributed_compiler.pyhas the skip-list added for #2436.Suggested fix
is_rootfrom themanaged_sectioncondition, so a marked file is reconciled at any depth.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).