Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def init(
"--tech-stack-interactive", "-i",
help="Interactively configure tech stack",
),
generate_config: bool = typer.Option(
False,
"--generate-config",
help="Generate starter CODEFRAME.md with project configuration",
),
) -> None:
"""Initialize a CodeFRAME workspace for a repository.

Expand All @@ -99,6 +104,8 @@ def init(
codeframe init . --tech-stack "Rust project using cargo"
codeframe init . --tech-stack "TypeScript monorepo with pnpm, Next.js frontend, FastAPI backend"
codeframe init . --tech-stack-interactive
codeframe init . --generate-config
codeframe init . --detect --generate-config
"""
from codeframe.core.workspace import (
create_or_load_workspace,
Expand Down Expand Up @@ -170,6 +177,15 @@ def init(
else:
console.print(f" Hook after_init: [yellow]failed[/yellow] ({hook_result.stderr[:100]})")

# Generate CODEFRAME.md if requested
if generate_config:
config_content = _generate_codeframe_md(
tech_stack=final_tech_stack or "",
)
config_path = repo_path / "CODEFRAME.md"
config_path.write_text(config_content)
console.print(" Generated: CODEFRAME.md")
Comment on lines +180 to +187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid clobbering an existing CODEFRAME.md.

Re-running cf init --generate-config currently truncates any checked-in CODEFRAME.md without confirmation. Since that file now carries both runtime settings and agent instructions, this should fail fast or require an explicit force flag.

🛠️ Suggested guard
         # Generate CODEFRAME.md if requested
         if generate_config:
-            config_content = _generate_codeframe_md(
-                tech_stack=final_tech_stack or "",
-            )
             config_path = repo_path / "CODEFRAME.md"
-            config_path.write_text(config_content)
+            if config_path.exists():
+                console.print("[red]Error:[/red] CODEFRAME.md already exists. Use --force to overwrite it.")
+                raise typer.Exit(1)
+            config_content = _generate_codeframe_md(tech_stack=final_tech_stack or "")
+            config_path.write_text(config_content, encoding="utf-8")
             console.print("  Generated: CODEFRAME.md")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/cli/app.py` around lines 180 - 187, The current generation code
unconditionally overwrites CODEFRAME.md; modify the logic around
generate_config/_generate_codeframe_md so that before calling
config_path.write_text you check if (repo_path / "CODEFRAME.md").exists() and if
so fail fast (raise an error or console.print and exit) unless an explicit force
flag is provided; add or wire a boolean force parameter (e.g., --force or
force_generate) into the CLI handler and use it in this branch to allow
overwriting when true, otherwise do not clobber the existing file and return a
clear message referencing CODEFRAME.md and the --force option.


console.print()
console.print("Next steps:")
console.print(" codeframe prd add <file.md> Add a PRD")
Expand Down Expand Up @@ -317,6 +333,58 @@ def _interactive_tech_stack() -> str:
return tech_stack


def _generate_codeframe_md(tech_stack: str = "") -> str:
"""Generate a starter CODEFRAME.md file with YAML front matter and body.

Args:
tech_stack: Natural language tech stack description.

Returns:
Complete CODEFRAME.md content string.
"""
import yaml as _yaml

yaml_section: dict = {
"engine": "react",
}
if tech_stack:
yaml_section["tech_stack"] = tech_stack

# Detect gates from tech stack
gates: list[str] = []
if tech_stack:
ts_lower = tech_stack.lower()
if "python" in ts_lower or "pytest" in ts_lower:
gates.extend(["ruff", "pytest"])
elif "typescript" in ts_lower or "jest" in ts_lower:
gates.extend(["eslint", "jest"])
if gates:
yaml_section["gates"] = gates
Comment on lines +353 to +362

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Infer gates for every detected stack, not just the first one.

_detect_tech_stack() can return mixed or JavaScript-only descriptions, but the if/elif chain only emits the first matching gate set and ignores JS stacks that do not mention typescript. The generated CODEFRAME.md ends up incomplete for monorepos and plain JS repos.

🛠️ Suggested fix
     # Detect gates from tech stack
     gates: list[str] = []
     if tech_stack:
         ts_lower = tech_stack.lower()
         if "python" in ts_lower or "pytest" in ts_lower:
             gates.extend(["ruff", "pytest"])
-        elif "typescript" in ts_lower or "jest" in ts_lower:
-            gates.extend(["eslint", "jest"])
+        if any(token in ts_lower for token in ("typescript", "javascript", "jest", "vitest", "eslint")):
+            gates.append("eslint")
+        if "jest" in ts_lower:
+            gates.append("jest")
+        elif "vitest" in ts_lower:
+            gates.append("vitest")
+    gates = list(dict.fromkeys(gates))
     if gates:
         yaml_section["gates"] = gates
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Detect gates from tech stack
gates: list[str] = []
if tech_stack:
ts_lower = tech_stack.lower()
if "python" in ts_lower or "pytest" in ts_lower:
gates.extend(["ruff", "pytest"])
elif "typescript" in ts_lower or "jest" in ts_lower:
gates.extend(["eslint", "jest"])
if gates:
yaml_section["gates"] = gates
# Detect gates from tech stack
gates: list[str] = []
if tech_stack:
ts_lower = tech_stack.lower()
if "python" in ts_lower or "pytest" in ts_lower:
gates.extend(["ruff", "pytest"])
if any(token in ts_lower for token in ("typescript", "javascript", "jest", "vitest", "eslint")):
gates.append("eslint")
if "jest" in ts_lower:
gates.append("jest")
elif "vitest" in ts_lower:
gates.append("vitest")
gates = list(dict.fromkeys(gates))
if gates:
yaml_section["gates"] = gates
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/cli/app.py` around lines 353 - 362, The current gate inference uses
an if/elif chain so only the first match is emitted; modify the logic around
tech_stack/ts_lower and gates so each detected runtime/framework adds its gates
independently (replace the if/elif with separate if checks), and add checks for
plain JavaScript indicators like "javascript" or "node" (so eslint/jest are
added when TypeScript isn't mentioned). Ensure you still append results into the
gates list and assign yaml_section["gates"] when non-empty.


yaml_section["batch"] = {"max_parallel": 2, "default_strategy": "auto"}
yaml_section["agent"] = {"max_iterations": 30, "verbose": False}

front_matter = _yaml.dump(yaml_section, default_flow_style=False, sort_keys=False)

body = """# Project Agent Instructions

## Coding Standards
- Follow existing code patterns and conventions
- Write clear, self-documenting code
- Include appropriate error handling

## Always Do
- Run tests before considering a task complete
- Follow the project's existing file structure

## Never Do
- Delete or overwrite files without understanding their purpose
- Introduce new dependencies without justification
"""

return f"---\n{front_matter}---\n\n{body}"


@app.command()
def status(
repo_path: Optional[Path] = typer.Argument(
Expand Down
186 changes: 170 additions & 16 deletions codeframe/core/agents_config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Agent preferences loader for CodeFRAME v2.

Loads project-level preferences from AGENTS.md and CLAUDE.md files.
Loads project-level preferences from CODEFRAME.md, AGENTS.md, and CLAUDE.md files.
These preferences guide agent decision-making for tactical choices like
tooling, file handling, and code style.

CODEFRAME.md supports YAML front matter for typed configuration (engine, gates,
batch settings, hooks) plus a Markdown body for agent instructions.

Supports the AGENTS.md industry standard (OpenAI, Google, GitHub, Anthropic)
as well as CLAUDE.md for Anthropic-specific instructions.

Expand All @@ -14,9 +17,15 @@
- https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/
"""

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import yaml

logger = logging.getLogger(__name__)


@dataclass
Expand Down Expand Up @@ -98,6 +107,29 @@ def to_prompt_section(self) -> str:
return "\n".join(sections)


@dataclass
class CodeframeConfig:
"""Typed configuration from CODEFRAME.md YAML front matter.

Attributes:
engine: Execution engine (react, plan, claude-code, etc.)
tech_stack: Natural language tech stack description
batch: Batch execution settings (max_parallel, default_strategy)
gates: Verification gates to run (ruff, pytest, mypy, etc.)
hooks: Lifecycle hooks (before_task, after_task, on_failure, etc.)
agent: Agent tuning parameters (max_iterations, verbose, etc.)
raw: Full parsed YAML dict for extensibility
"""

engine: Optional[str] = None
tech_stack: Optional[str] = None
batch: dict = field(default_factory=dict)
gates: list[str] = field(default_factory=list)
hooks: dict = field(default_factory=dict)
agent: dict = field(default_factory=dict)
raw: dict = field(default_factory=dict)


# Section header patterns for parsing AGENTS.md content
SECTION_PATTERNS = {
"always_do": re.compile(
Expand Down Expand Up @@ -270,6 +302,89 @@ def _parse_agents_md(content: str) -> AgentPreferences:
return prefs


def parse_codeframe_md(content: str) -> tuple[CodeframeConfig, AgentPreferences]:
"""Parse a CODEFRAME.md file with YAML front matter + Markdown body.

The file format is:
---
engine: react
tech_stack: "Python with uv"
batch:
max_parallel: 4
gates:
- ruff
- pytest
hooks:
before_task: "git checkout -b cf/{{task_id}}"
agent:
max_iterations: 30
---

# Project Instructions
Markdown content here becomes the agent system prompt supplement...

Returns:
Tuple of (CodeframeConfig, AgentPreferences)
CodeframeConfig has the typed YAML settings
AgentPreferences has the Markdown body as raw_content + any extracted sections
"""
config = CodeframeConfig()
prefs = AgentPreferences()

if not content or not content.strip():
return config, prefs

# Extract YAML front matter between --- markers
yaml_data = {}
body = content

stripped = content.strip()
if stripped.startswith("---"):
# Find the closing --- marker (skip the opening one)
after_opening = stripped[3:]
closing_idx = after_opening.find("\n---")
if closing_idx >= 0:
yaml_text = after_opening[:closing_idx].strip()
# Body starts after the closing ---
rest = after_opening[closing_idx + 4:] # skip "\n---"
body = rest.strip()

# Parse YAML
try:
parsed = yaml.safe_load(yaml_text)
if isinstance(parsed, dict):
yaml_data = parsed
except yaml.YAMLError:
logger.warning("Invalid YAML front matter in CODEFRAME.md, skipping")
body = content # Treat entire content as body on YAML failure
else:
# No closing --- found, treat entire content as body
body = content

# Build CodeframeConfig from YAML
if yaml_data:
config = CodeframeConfig(
engine=yaml_data.get("engine"),
tech_stack=yaml_data.get("tech_stack"),
batch=yaml_data.get("batch") or {},
gates=yaml_data.get("gates") or [],
hooks=yaml_data.get("hooks") or {},
agent=yaml_data.get("agent") or {},
raw=yaml_data,
)

# Parse Markdown body for AgentPreferences
if body:
prefs = _parse_agents_md(body)
prefs.source_files = ["CODEFRAME.md"]

# Cross-populate tech_stack into preferences tooling
if config.tech_stack:
prefs.tooling["tech_stack"] = config.tech_stack

return config, prefs


def _merge_preferences(
base: AgentPreferences, override: AgentPreferences
) -> AgentPreferences:
Expand All @@ -295,15 +410,16 @@ def _merge_preferences(


def load_preferences(workspace_path: Path) -> AgentPreferences:
"""Load and merge agent preferences from AGENTS.md and CLAUDE.md files.
"""Load and merge agent preferences from CODEFRAME.md, AGENTS.md, and CLAUDE.md.

Search order (closest wins):
1. workspace_path/AGENTS.md
2. workspace_path/CLAUDE.md
3. Parent directories (walking up)
4. ~/.codeframe/AGENTS.md (global defaults)
1. workspace_path/CODEFRAME.md (highest priority)
2. workspace_path/AGENTS.md
3. workspace_path/CLAUDE.md (lowest priority)
4. Parent directories (walking up, same precedence order)
5. ~/.codeframe/AGENTS.md (global defaults)

AGENTS.md takes precedence over CLAUDE.md at the same directory level.
At each directory level, CODEFRAME.md > AGENTS.md > CLAUDE.md.

Args:
workspace_path: Path to the workspace/repository root
Expand All @@ -316,12 +432,12 @@ def load_preferences(workspace_path: Path) -> AgentPreferences:
found_files = []

# Search order: global defaults first, then walk up to workspace (closest wins)
search_paths = []
search_paths: list[tuple[Path, bool]] = [] # (path, is_codeframe_md)

# 1. Global defaults (lowest priority)
global_config = Path.home() / ".codeframe" / "AGENTS.md"
if global_config.exists():
search_paths.append(global_config)
search_paths.append((global_config, False))

# 2. Walk from root to workspace (so workspace files override parents)
current = workspace_path
Expand All @@ -332,22 +448,31 @@ def load_preferences(workspace_path: Path) -> AgentPreferences:

# Reverse so we go from ancestors to workspace (closest wins)
for dir_path in reversed(path_chain):
# CLAUDE.md first (lower priority at same level)
# CLAUDE.md first (lowest priority at same level)
claude_md = dir_path / "CLAUDE.md"
if claude_md.exists():
search_paths.append(claude_md)
search_paths.append((claude_md, False))

# AGENTS.md second (higher priority at same level)
# AGENTS.md second (medium priority at same level)
agents_md = dir_path / "AGENTS.md"
if agents_md.exists():
search_paths.append(agents_md)
search_paths.append((agents_md, False))

# CODEFRAME.md third (highest priority at same level)
codeframe_md = dir_path / "CODEFRAME.md"
if codeframe_md.exists():
search_paths.append((codeframe_md, True))

# Process all found files
for file_path in search_paths:
for file_path, is_codeframe in search_paths:
try:
content = file_path.read_text(encoding="utf-8")
file_prefs = _parse_agents_md(content)
file_prefs.source_files = [str(file_path)]
if is_codeframe:
_, file_prefs = parse_codeframe_md(content)
file_prefs.source_files = [str(file_path)]
else:
file_prefs = _parse_agents_md(content)
file_prefs.source_files = [str(file_path)]
prefs = _merge_preferences(prefs, file_prefs)
found_files.append(str(file_path))
except (OSError, UnicodeDecodeError):
Expand Down Expand Up @@ -413,3 +538,32 @@ def get_default_preferences() -> AgentPreferences:
raw_content="",
source_files=["<defaults>"],
)


def get_codeframe_config(workspace_path: Path) -> Optional[CodeframeConfig]:
"""Load CodeframeConfig from CODEFRAME.md if present.

Searches for CODEFRAME.md starting from workspace_path, walking up to root.
Returns None if no CODEFRAME.md is found.

Args:
workspace_path: Path to the workspace/repository root

Returns:
CodeframeConfig if CODEFRAME.md is found, None otherwise
"""
workspace_path = Path(workspace_path).resolve()

current = workspace_path
while current != current.parent:
codeframe_md = current / "CODEFRAME.md"
if codeframe_md.exists():
try:
content = codeframe_md.read_text(encoding="utf-8")
config, _ = parse_codeframe_md(content)
return config
except (OSError, UnicodeDecodeError):
return None
current = current.parent

return None
Loading
Loading