Skip to content

Commit 1d3f2d5

Browse files
authored
feat(config): unified CODEFRAME.md workflow config (#398)
## Summary - CODEFRAME.md parser: YAML front matter (engine, tech_stack, batch, gates, hooks, agent) + Markdown body - CodeframeConfig dataclass for typed config extraction - Precedence: CODEFRAME.md > AGENTS.md > CLAUDE.md in load_preferences() - Runtime fallback: load_environment_config() reads CODEFRAME.md when no config.yaml - CLI: `cf init --generate-config` scaffolds starter CODEFRAME.md with auto-detected settings - Backward compatible: no changes if CODEFRAME.md doesn't exist ## Validation - Tests: 41 new tests, all CI checks green - Lint: Clean - Backward compat: existing AGENTS.md/CLAUDE.md loading unchanged Closes #398
1 parent 1a15a27 commit 1d3f2d5

File tree

6 files changed

+1001
-24
lines changed

6 files changed

+1001
-24
lines changed

codeframe/cli/app.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ def init(
8282
"--tech-stack-interactive", "-i",
8383
help="Interactively configure tech stack",
8484
),
85+
generate_config: bool = typer.Option(
86+
False,
87+
"--generate-config",
88+
help="Generate starter CODEFRAME.md with project configuration",
89+
),
8590
) -> None:
8691
"""Initialize a CodeFRAME workspace for a repository.
8792
@@ -99,6 +104,8 @@ def init(
99104
codeframe init . --tech-stack "Rust project using cargo"
100105
codeframe init . --tech-stack "TypeScript monorepo with pnpm, Next.js frontend, FastAPI backend"
101106
codeframe init . --tech-stack-interactive
107+
codeframe init . --generate-config
108+
codeframe init . --detect --generate-config
102109
"""
103110
from codeframe.core.workspace import (
104111
create_or_load_workspace,
@@ -170,6 +177,15 @@ def init(
170177
else:
171178
console.print(f" Hook after_init: [yellow]failed[/yellow] ({hook_result.stderr[:100]})")
172179

180+
# Generate CODEFRAME.md if requested
181+
if generate_config:
182+
config_content = _generate_codeframe_md(
183+
tech_stack=final_tech_stack or "",
184+
)
185+
config_path = repo_path / "CODEFRAME.md"
186+
config_path.write_text(config_content)
187+
console.print(" Generated: CODEFRAME.md")
188+
173189
console.print()
174190
console.print("Next steps:")
175191
console.print(" codeframe prd add <file.md> Add a PRD")
@@ -317,6 +333,58 @@ def _interactive_tech_stack() -> str:
317333
return tech_stack
318334

319335

336+
def _generate_codeframe_md(tech_stack: str = "") -> str:
337+
"""Generate a starter CODEFRAME.md file with YAML front matter and body.
338+
339+
Args:
340+
tech_stack: Natural language tech stack description.
341+
342+
Returns:
343+
Complete CODEFRAME.md content string.
344+
"""
345+
import yaml as _yaml
346+
347+
yaml_section: dict = {
348+
"engine": "react",
349+
}
350+
if tech_stack:
351+
yaml_section["tech_stack"] = tech_stack
352+
353+
# Detect gates from tech stack
354+
gates: list[str] = []
355+
if tech_stack:
356+
ts_lower = tech_stack.lower()
357+
if "python" in ts_lower or "pytest" in ts_lower:
358+
gates.extend(["ruff", "pytest"])
359+
elif "typescript" in ts_lower or "jest" in ts_lower:
360+
gates.extend(["eslint", "jest"])
361+
if gates:
362+
yaml_section["gates"] = gates
363+
364+
yaml_section["batch"] = {"max_parallel": 2, "default_strategy": "auto"}
365+
yaml_section["agent"] = {"max_iterations": 30, "verbose": False}
366+
367+
front_matter = _yaml.dump(yaml_section, default_flow_style=False, sort_keys=False)
368+
369+
body = """# Project Agent Instructions
370+
371+
## Coding Standards
372+
- Follow existing code patterns and conventions
373+
- Write clear, self-documenting code
374+
- Include appropriate error handling
375+
376+
## Always Do
377+
- Run tests before considering a task complete
378+
- Follow the project's existing file structure
379+
380+
## Never Do
381+
- Delete or overwrite files without understanding their purpose
382+
- Introduce new dependencies without justification
383+
"""
384+
385+
return f"---\n{front_matter}---\n\n{body}"
386+
387+
320388
@app.command()
321389
def status(
322390
repo_path: Optional[Path] = typer.Argument(

codeframe/core/agents_config.py

Lines changed: 170 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
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.
44
These preferences guide agent decision-making for tactical choices like
55
tooling, 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+
710
Supports the AGENTS.md industry standard (OpenAI, Google, GitHub, Anthropic)
811
as well as CLAUDE.md for Anthropic-specific instructions.
912
@@ -14,9 +17,15 @@
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
1721
import re
1822
from dataclasses import dataclass, field
1923
from 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
102134
SECTION_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+
273388
def _merge_preferences(
274389
base: AgentPreferences, override: AgentPreferences
275390
) -> AgentPreferences:
@@ -295,15 +410,16 @@ def _merge_preferences(
295410

296411

297412
def 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

Comments
 (0)