11"""Agent preferences loader for CodeFRAME v2.
22
3- Loads project-level preferences from AGENTS.md and CLAUDE.md files.
3+ Loads project-level preferences from CODEFRAME.md, AGENTS.md, and CLAUDE.md files.
44These preferences guide agent decision-making for tactical choices like
55tooling, file handling, and code style.
66
7+ CODEFRAME.md supports YAML front matter for typed configuration (engine, gates,
8+ batch settings, hooks) plus a Markdown body for agent instructions.
9+
710Supports the AGENTS.md industry standard (OpenAI, Google, GitHub, Anthropic)
811as well as CLAUDE.md for Anthropic-specific instructions.
912
1417- https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/
1518"""
1619
20+ import logging
1721import re
1822from dataclasses import dataclass , field
1923from pathlib import Path
24+ from typing import Optional
25+
26+ import yaml
27+
28+ logger = logging .getLogger (__name__ )
2029
2130
2231@dataclass
@@ -98,6 +107,29 @@ def to_prompt_section(self) -> str:
98107 return "\n " .join (sections )
99108
100109
110+ @dataclass
111+ class CodeframeConfig :
112+ """Typed configuration from CODEFRAME.md YAML front matter.
113+
114+ Attributes:
115+ engine: Execution engine (react, plan, claude-code, etc.)
116+ tech_stack: Natural language tech stack description
117+ batch: Batch execution settings (max_parallel, default_strategy)
118+ gates: Verification gates to run (ruff, pytest, mypy, etc.)
119+ hooks: Lifecycle hooks (before_task, after_task, on_failure, etc.)
120+ agent: Agent tuning parameters (max_iterations, verbose, etc.)
121+ raw: Full parsed YAML dict for extensibility
122+ """
123+
124+ engine : Optional [str ] = None
125+ tech_stack : Optional [str ] = None
126+ batch : dict = field (default_factory = dict )
127+ gates : list [str ] = field (default_factory = list )
128+ hooks : dict = field (default_factory = dict )
129+ agent : dict = field (default_factory = dict )
130+ raw : dict = field (default_factory = dict )
131+
132+
101133# Section header patterns for parsing AGENTS.md content
102134SECTION_PATTERNS = {
103135 "always_do" : re .compile (
@@ -270,6 +302,89 @@ def _parse_agents_md(content: str) -> AgentPreferences:
270302 return prefs
271303
272304
305+ def parse_codeframe_md (content : str ) -> tuple [CodeframeConfig , AgentPreferences ]:
306+ """Parse a CODEFRAME.md file with YAML front matter + Markdown body.
307+
308+ The file format is:
309+ ---
310+ engine: react
311+ tech_stack: "Python with uv"
312+ batch:
313+ max_parallel: 4
314+ gates:
315+ - ruff
316+ - pytest
317+ hooks:
318+ before_task: "git checkout -b cf/{{task_id}}"
319+ agent:
320+ max_iterations: 30
321+ ---
322+
323+ # Project Instructions
324+ Markdown content here becomes the agent system prompt supplement...
325+
326+ Returns:
327+ Tuple of (CodeframeConfig, AgentPreferences)
328+ CodeframeConfig has the typed YAML settings
329+ AgentPreferences has the Markdown body as raw_content + any extracted sections
330+ """
331+ config = CodeframeConfig ()
332+ prefs = AgentPreferences ()
333+
334+ if not content or not content .strip ():
335+ return config , prefs
336+
337+ # Extract YAML front matter between --- markers
338+ yaml_data = {}
339+ body = content
340+
341+ stripped = content .strip ()
342+ if stripped .startswith ("---" ):
343+ # Find the closing --- marker (skip the opening one)
344+ after_opening = stripped [3 :]
345+ closing_idx = after_opening .find ("\n ---" )
346+ if closing_idx >= 0 :
347+ yaml_text = after_opening [:closing_idx ].strip ()
348+ # Body starts after the closing ---
349+ rest = after_opening [closing_idx + 4 :] # skip "\n---"
350+ body = rest .strip ()
351+
352+ # Parse YAML
353+ try :
354+ parsed = yaml .safe_load (yaml_text )
355+ if isinstance (parsed , dict ):
356+ yaml_data = parsed
357+ except yaml .YAMLError :
358+ logger .warning ("Invalid YAML front matter in CODEFRAME.md, skipping" )
359+ body = content # Treat entire content as body on YAML failure
360+ else :
361+ # No closing --- found, treat entire content as body
362+ body = content
363+
364+ # Build CodeframeConfig from YAML
365+ if yaml_data :
366+ config = CodeframeConfig (
367+ engine = yaml_data .get ("engine" ),
368+ tech_stack = yaml_data .get ("tech_stack" ),
369+ batch = yaml_data .get ("batch" ) or {},
370+ gates = yaml_data .get ("gates" ) or [],
371+ hooks = yaml_data .get ("hooks" ) or {},
372+ agent = yaml_data .get ("agent" ) or {},
373+ raw = yaml_data ,
374+ )
375+
376+ # Parse Markdown body for AgentPreferences
377+ if body :
378+ prefs = _parse_agents_md (body )
379+ prefs .source_files = ["CODEFRAME.md" ]
380+
381+ # Cross-populate tech_stack into preferences tooling
382+ if config .tech_stack :
383+ prefs .tooling ["tech_stack" ] = config .tech_stack
384+
385+ return config , prefs
386+
387+
273388def _merge_preferences (
274389 base : AgentPreferences , override : AgentPreferences
275390) -> AgentPreferences :
@@ -295,15 +410,16 @@ def _merge_preferences(
295410
296411
297412def load_preferences (workspace_path : Path ) -> AgentPreferences :
298- """Load and merge agent preferences from AGENTS.md and CLAUDE.md files .
413+ """Load and merge agent preferences from CODEFRAME.md, AGENTS.md, and CLAUDE.md.
299414
300415 Search order (closest wins):
301- 1. workspace_path/AGENTS.md
302- 2. workspace_path/CLAUDE.md
303- 3. Parent directories (walking up)
304- 4. ~/.codeframe/AGENTS.md (global defaults)
416+ 1. workspace_path/CODEFRAME.md (highest priority)
417+ 2. workspace_path/AGENTS.md
418+ 3. workspace_path/CLAUDE.md (lowest priority)
419+ 4. Parent directories (walking up, same precedence order)
420+ 5. ~/.codeframe/AGENTS.md (global defaults)
305421
306- AGENTS.md takes precedence over CLAUDE .md at the same directory level .
422+ At each directory level, CODEFRAME .md > AGENTS.md > CLAUDE.md .
307423
308424 Args:
309425 workspace_path: Path to the workspace/repository root
@@ -316,12 +432,12 @@ def load_preferences(workspace_path: Path) -> AgentPreferences:
316432 found_files = []
317433
318434 # Search order: global defaults first, then walk up to workspace (closest wins)
319- search_paths = []
435+ search_paths : list [ tuple [ Path , bool ]] = [] # (path, is_codeframe_md)
320436
321437 # 1. Global defaults (lowest priority)
322438 global_config = Path .home () / ".codeframe" / "AGENTS.md"
323439 if global_config .exists ():
324- search_paths .append (global_config )
440+ search_paths .append (( global_config , False ) )
325441
326442 # 2. Walk from root to workspace (so workspace files override parents)
327443 current = workspace_path
@@ -332,22 +448,31 @@ def load_preferences(workspace_path: Path) -> AgentPreferences:
332448
333449 # Reverse so we go from ancestors to workspace (closest wins)
334450 for dir_path in reversed (path_chain ):
335- # CLAUDE.md first (lower priority at same level)
451+ # CLAUDE.md first (lowest priority at same level)
336452 claude_md = dir_path / "CLAUDE.md"
337453 if claude_md .exists ():
338- search_paths .append (claude_md )
454+ search_paths .append (( claude_md , False ) )
339455
340- # AGENTS.md second (higher priority at same level)
456+ # AGENTS.md second (medium priority at same level)
341457 agents_md = dir_path / "AGENTS.md"
342458 if agents_md .exists ():
343- search_paths .append (agents_md )
459+ search_paths .append ((agents_md , False ))
460+
461+ # CODEFRAME.md third (highest priority at same level)
462+ codeframe_md = dir_path / "CODEFRAME.md"
463+ if codeframe_md .exists ():
464+ search_paths .append ((codeframe_md , True ))
344465
345466 # Process all found files
346- for file_path in search_paths :
467+ for file_path , is_codeframe in search_paths :
347468 try :
348469 content = file_path .read_text (encoding = "utf-8" )
349- file_prefs = _parse_agents_md (content )
350- file_prefs .source_files = [str (file_path )]
470+ if is_codeframe :
471+ _ , file_prefs = parse_codeframe_md (content )
472+ file_prefs .source_files = [str (file_path )]
473+ else :
474+ file_prefs = _parse_agents_md (content )
475+ file_prefs .source_files = [str (file_path )]
351476 prefs = _merge_preferences (prefs , file_prefs )
352477 found_files .append (str (file_path ))
353478 except (OSError , UnicodeDecodeError ):
@@ -413,3 +538,32 @@ def get_default_preferences() -> AgentPreferences:
413538 raw_content = "" ,
414539 source_files = ["<defaults>" ],
415540 )
541+
542+
543+ def get_codeframe_config (workspace_path : Path ) -> Optional [CodeframeConfig ]:
544+ """Load CodeframeConfig from CODEFRAME.md if present.
545+
546+ Searches for CODEFRAME.md starting from workspace_path, walking up to root.
547+ Returns None if no CODEFRAME.md is found.
548+
549+ Args:
550+ workspace_path: Path to the workspace/repository root
551+
552+ Returns:
553+ CodeframeConfig if CODEFRAME.md is found, None otherwise
554+ """
555+ workspace_path = Path (workspace_path ).resolve ()
556+
557+ current = workspace_path
558+ while current != current .parent :
559+ codeframe_md = current / "CODEFRAME.md"
560+ if codeframe_md .exists ():
561+ try :
562+ content = codeframe_md .read_text (encoding = "utf-8" )
563+ config , _ = parse_codeframe_md (content )
564+ return config
565+ except (OSError , UnicodeDecodeError ):
566+ return None
567+ current = current .parent
568+
569+ return None
0 commit comments