2929# Marker present in every APM-generated distributed AGENTS.md.
3030# Used to gate --clean deletion: files without this marker are
3131# hand-authored and must never be removed automatically.
32- _AGENTS_MD_GENERATED_MARKER = "<!-- Generated by APM CLI from distributed .apm/ primitives -->"
32+ #
33+ # Public (no leading underscore) because it is a stable contract surface:
34+ # it gates file deletion and is depended on by tests. Mirrors the public
35+ # CLAUDE_HEADER marker on the Claude path.
36+ AGENTS_MD_GENERATED_MARKER = "<!-- Generated by APM CLI from distributed .apm/ primitives -->"
3337
3438# CRITICAL: Shadow Click commands to prevent namespace collision
3539set = builtins .set
@@ -221,22 +225,30 @@ def compile_distributed(
221225 # Multi-target builds where skip_instructions=False are unaffected.
222226 content_map : builtins .dict [Path , str ] = {}
223227 suppressed_paths : builtins .list [Path ] = []
228+ # Cache constitution-existence per directory for the duration of this
229+ # compile so repeated placements under the same tree do not re-read the
230+ # same constitution file from disk (O(placements) reads -> O(dirs)).
231+ constitution_cache : builtins .dict [Path , bool ] = {}
224232
225233 for p in placements :
226- content = self . _generate_agents_content (
227- p , primitives , skip_instructions = skip_instructions
228- )
234+ # Decide suppression BEFORE generating content: a suppressed
235+ # placement is never written, so generating + link-resolving its
236+ # content would be wasted work.
229237 if skip_instructions and self ._is_placement_empty_shell (
230- p , with_constitution = with_constitution
238+ p ,
239+ with_constitution = with_constitution ,
240+ constitution_cache = constitution_cache ,
231241 ):
232242 suppressed_paths .append (p .agents_path )
233243 _logger .debug (
234244 "AGENTS.md suppressed (would-be-empty shell, instructions already"
235245 " in .github/instructions/): %s" ,
236246 p .agents_path ,
237247 )
238- else :
239- content_map [p .agents_path ] = content
248+ continue
249+ content_map [p .agents_path ] = self ._generate_agents_content (
250+ p , primitives , skip_instructions = skip_instructions
251+ )
240252
241253 # Phase 4: Handle orphaned file cleanup.
242254 # generated_paths = ALL placement paths (written + suppressed this run).
@@ -621,7 +633,7 @@ def _generate_agents_content(
621633
622634 # Header with source attribution
623635 sections .append ("# AGENTS.md" )
624- sections .append (_AGENTS_MD_GENERATED_MARKER )
636+ sections .append (AGENTS_MD_GENERATED_MARKER )
625637 sections .append (BUILD_ID_PLACEHOLDER )
626638 sections .append (f"<!-- APM Version: { get_version ()} -->" )
627639
@@ -665,7 +677,11 @@ def _generate_agents_content(
665677 return content
666678
667679 def _is_placement_empty_shell (
668- self , placement : PlacementResult , * , with_constitution : bool = True
680+ self ,
681+ placement : PlacementResult ,
682+ * ,
683+ with_constitution : bool = True ,
684+ constitution_cache : builtins .dict [Path , bool ] | None = None ,
669685 ) -> bool :
670686 """Return True when a placement would produce a content-free AGENTS.md shell.
671687
@@ -683,25 +699,39 @@ def _is_placement_empty_shell(
683699 a constitution file exists, so the predicate must agree and report "empty".
684700
685701 Target logic:
686- empty = not (with_constitution and read_constitution (placement.agents_path.parent) is not None )
702+ empty = not (with_constitution and constitution_exists (placement.agents_path.parent))
687703
688704 Args:
689705 placement: The placement to evaluate.
690706 with_constitution: Whether the caller will inject a constitution at
691707 write time (mirrors ``CompilationConfig.with_constitution``).
692708 Defaults to True for back-compat.
709+ constitution_cache: Optional ``dict[Path, bool]`` keyed by placement
710+ directory, used to memoize constitution-existence checks across
711+ placements within a single ``compile_distributed()`` call so the
712+ same directory is not read from disk repeatedly. When omitted,
713+ the check reads from disk directly (back-compat for direct callers).
693714
694715 Returns:
695716 bool: True if writing this placement would produce a content-free file.
696717 """
697- # With constitution injection enabled, check whether a constitution file
698- # actually exists in the placement directory. ConstitutionInjector uses
699- # agents_path.parent as its base_dir, so we mirror that here.
700- # If with_constitution is False the writer will skip injection regardless
701- # of what is on disk, so we treat it as "no constitution".
702- return not (
703- with_constitution and read_constitution (placement .agents_path .parent ) is not None
704- )
718+ # When with_constitution is False the writer skips injection regardless of
719+ # what is on disk, so short-circuit without touching the filesystem.
720+ if not with_constitution :
721+ return True
722+
723+ # ConstitutionInjector uses agents_path.parent as its base_dir, so mirror
724+ # that here. Memoize per directory to avoid repeated disk I/O in large
725+ # repos with many placements sharing a tree.
726+ directory = placement .agents_path .parent
727+ if constitution_cache is not None and directory in constitution_cache :
728+ has_constitution = constitution_cache [directory ]
729+ else :
730+ has_constitution = read_constitution (directory ) is not None
731+ if constitution_cache is not None :
732+ constitution_cache [directory ] = has_constitution
733+
734+ return not has_constitution
705735
706736 def _validate_coverage (
707737 self ,
@@ -791,7 +821,7 @@ def _find_orphaned_agents_files(
791821 file_content = agents_file .read_text (encoding = "utf-8" )
792822 except OSError :
793823 continue
794- if _AGENTS_MD_GENERATED_MARKER not in file_content :
824+ if AGENTS_MD_GENERATED_MARKER not in file_content :
795825 continue # Hand-authored -- skip silently
796826
797827 orphaned_files .append (agents_file )
@@ -844,10 +874,12 @@ def _cleanup_orphaned_files(
844874 ) -> builtins .list [str ]:
845875 """Actually remove orphaned AGENTS.md files.
846876
847- Only APM-generated files (those bearing ``_AGENTS_MD_GENERATED_MARKER``)
848- are removed. Hand-authored files are warned about but left untouched.
849- (The marker gate is also applied in ``_find_orphaned_agents_files``; this
850- check is defense-in-depth for direct callers.)
877+ Only APM-generated files (those bearing ``AGENTS_MD_GENERATED_MARKER``)
878+ are removed. Hand-authored files are skipped silently and recorded at
879+ DEBUG level only -- no user-facing message is appended to the returned
880+ list. (The marker gate is also applied in ``_find_orphaned_agents_files``,
881+ so reaching the skip branch here is a defense-in-depth path for direct
882+ callers and is not expected in normal operation.)
851883
852884 Args:
853885 orphaned_files: Orphaned files to remove (should be pre-screened by
@@ -882,7 +914,7 @@ def _cleanup_orphaned_files(
882914 except OSError :
883915 cleanup_messages .append (f" x Failed to read { rel_path } -- skipping" )
884916 continue
885- if _AGENTS_MD_GENERATED_MARKER not in existing_content :
917+ if AGENTS_MD_GENERATED_MARKER not in existing_content :
886918 # Defense-in-depth: _find_orphaned_agents_files already
887919 # marker-gates, so this branch is unreachable in normal
888920 # operation. Emit at debug level only to avoid a stray
0 commit comments