diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 29d144744f..4571ee5b6a 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -13,9 +13,11 @@ """ import copy +import fnmatch import json import logging import os +import re import threading import time from pathlib import Path @@ -442,6 +444,455 @@ def load_claude_md(project_dir: Path) -> str | None: return None +# ============================================================================= +# Claude Code Rules Support (.claude/rules/) +# ============================================================================= +# Supports Claude Code's path-based rules convention where rules in .claude/rules/ +# are automatically loaded based on which files are being modified. +# +# Rule files use YAML frontmatter with 'paths' array containing glob patterns: +# +# --- +# paths: +# - src/app/api/**/*.ts +# - src/components/**/*.tsx +# --- +# +# # Rule Content Here +# ... + + +def should_use_claude_rules() -> bool: + """ + Check if .claude/rules/ should be loaded based on file paths. + + When USE_CLAUDE_MD is enabled, rules are also enabled by default. + Can be explicitly disabled with USE_CLAUDE_RULES=false. + """ + explicit_setting = os.environ.get("USE_CLAUDE_RULES", "").lower() + if explicit_setting == "false": + return False + if explicit_setting == "true": + return True + # Default: enabled if USE_CLAUDE_MD is enabled + return should_use_claude_md() + + +def _parse_rule_frontmatter(content: str) -> tuple[list[str], list[dict], str]: + """ + Parse YAML frontmatter from a rule file to extract path patterns and required skills. + + Args: + content: Full content of the rule file + + Returns: + Tuple of (path_patterns, required_skills, rule_content_without_frontmatter) + + required_skills is a list of dicts with format: + {"skill": "/review", "when": "end_of_coding", "paths": ["src/**"]} + + Supported 'when' values: + - "planning": Planner should include in implementation_plan.json + - "per_subtask": Coder runs on each matching subtask (default) + - "end_of_coding": Coder runs once after ALL subtasks complete + - "qa_phase": QA agent runs during review + + Supported frontmatter formats: + # Simple (backwards compatible) - defaults to when: per_subtask + require_skills: + - /review + - /audit + + # Structured with timing control + require_skills: + - skill: /review + when: end_of_coding + - skill: /security-audit + when: per_subtask + paths: [src/app/api/**] + """ + if not content.startswith("---"): + return [], [], content + + # Find the closing --- + lines = content.split("\n") + end_idx = -1 + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end_idx = i + break + + if end_idx == -1: + return [], [], content + + # Parse the frontmatter + frontmatter_lines = lines[1:end_idx] + paths = [] + skills: list[dict] = [] + + in_paths = False + in_skills = False + in_skill_paths = False # Track when parsing nested paths: list under a skill + current_skill: dict | None = None # Track structured skill entry + skill_base_indent: int = 0 # Track base indent of current skill list item + + for line in frontmatter_lines: + stripped = line.strip() + # Calculate indentation for detecting nested properties + indent = len(line) - len(line.lstrip()) + + # Parse paths: array + if stripped.startswith("paths:") and not in_skills: + in_paths = True + in_skills = False + current_skill = None + # Check for inline array: paths: [a, b, c] + if "[" in stripped: + match = re.search(r"\[(.*)\]", stripped) + if match: + paths = [p.strip().strip("'\"") for p in match.group(1).split(",")] + in_paths = False + continue + # Parse require_skills: array + if stripped.startswith("require_skills:"): + in_skills = True + in_paths = False + current_skill = None + # Check for inline array: require_skills: [/skill1, /skill2] + if "[" in stripped: + match = re.search(r"\[(.*)\]", stripped) + if match: + # Simple inline format - convert to dicts with default 'when' + for s in match.group(1).split(","): + skill_name = s.strip().strip("'\"") + if skill_name: + skills.append({"skill": skill_name, "when": "per_subtask"}) + in_skills = False + continue + if in_paths: + if stripped.startswith("- "): + paths.append(stripped[2:].strip().strip("'\"")) + continue + elif stripped and not stripped.startswith("#"): + # End of paths array - fall through to check for other keys + in_paths = False + if in_skills: + # Collect nested paths list items for current skill + if ( + current_skill is not None + and in_skill_paths + and stripped.startswith("- ") + and indent > skill_base_indent + ): + current_skill.setdefault("paths", []).append( + stripped[2:].strip().strip("'\"") + ) + continue + + # Check for new list item + if stripped.startswith("- "): + # Save previous skill entry if exists + if current_skill and current_skill.get("skill"): + skills.append(current_skill) + current_skill = None + in_skill_paths = False + + item_content = stripped[2:].strip() + # Track the indent of this list item for nested property detection + skill_base_indent = indent + + # Check if it's structured format: "- skill: /review" + if item_content.startswith("skill:"): + skill_name = item_content[6:].strip().strip("'\"") + current_skill = {"skill": skill_name, "when": "per_subtask"} + else: + # Simple format: "- /review" - convert to dict immediately + skill_name = item_content.strip("'\"") + if skill_name: + skills.append({"skill": skill_name, "when": "per_subtask"}) + # Check for nested properties of current skill (indented more than list item) + elif current_skill is not None and indent > skill_base_indent: + if stripped.startswith("when:"): + when_value = stripped[5:].strip().strip("'\"") + if when_value in ( + "planning", + "per_subtask", + "end_of_coding", + "qa_phase", + ): + current_skill["when"] = when_value + elif stripped.startswith("paths:"): + in_skill_paths = True + current_skill.setdefault("paths", []) + # Check for inline array: paths: [src/**, lib/**] + if "[" in stripped: + match = re.search(r"\[(.*)\]", stripped) + if match: + current_skill["paths"] = [ + p.strip().strip("'\"") + for p in match.group(1).split(",") + ] + in_skill_paths = False + elif ( + stripped + and not stripped.startswith("#") + and indent <= skill_base_indent + ): + # End of skills array (same or less indent, non-empty, non-comment line) + if current_skill and current_skill.get("skill"): + skills.append(current_skill) + current_skill = None + in_skills = False + in_skill_paths = False + # Reprocess this line - it might be a top-level paths: that follows require_skills: + if stripped.startswith("paths:") and not in_paths: + in_paths = True + # Check for inline array: paths: [a, b, c] + if "[" in stripped: + match = re.search(r"\[(.*)\]", stripped) + if match: + paths = [ + p.strip().strip("'\"") + for p in match.group(1).split(",") + ] + in_paths = False + + # Don't forget to save the last skill entry + if current_skill and current_skill.get("skill"): + skills.append(current_skill) + + # Return content without frontmatter + rule_content = "\n".join(lines[end_idx + 1 :]).strip() + return paths, skills, rule_content + + +def _match_glob_pattern(pattern: str, filepath: str) -> bool: + """ + Match a glob pattern against a file path. + + Supports: + - ** for any directory depth (including multiple ** in a pattern) + - * for any characters within a path segment + - Direct path matching + + Args: + pattern: Glob pattern (e.g., "src/app/api/**/*.ts") + filepath: File path to check (e.g., "src/app/api/films/route.ts") + + Returns: + True if the pattern matches the filepath + """ + # Normalize paths + pattern = pattern.replace("\\", "/").strip("/") + filepath = filepath.replace("\\", "/").strip("/") + + # Handle ** patterns by converting to regex + if "**" in pattern: + # Convert glob pattern to regex + # Escape regex special characters except * and ? + regex_pattern = "" + i = 0 + while i < len(pattern): + if pattern[i : i + 2] == "**": + i += 2 + # Check if ** is followed by / + if i < len(pattern) and pattern[i] == "/": + # **/ matches zero or more directory segments (segment-aware) + # Use (?:.*/)? to match zero or more path segments including slashes + regex_pattern += "(?:.*/)?" + i += 1 # Skip the / + else: + # ** at end or before non-/ matches remaining path + regex_pattern += ".*" + elif pattern[i] == "*": + # * matches any characters except / + regex_pattern += "[^/]*" + i += 1 + elif pattern[i] == "?": + # ? matches any single character except / + regex_pattern += "[^/]" + i += 1 + elif pattern[i] in ".^$+{}[]|()": + # Escape regex special characters + regex_pattern += "\\" + pattern[i] + i += 1 + else: + regex_pattern += pattern[i] + i += 1 + + # Match the full path + regex_pattern = "^" + regex_pattern + "$" + return bool(re.match(regex_pattern, filepath)) + + # Simple glob matching for patterns without ** + return fnmatch.fnmatch(filepath, pattern) + + +def _discover_rules_directory(project_dir: Path) -> Path | None: + """ + Find the .claude/rules/ directory if it exists. + + Args: + project_dir: Root directory of the project + + Returns: + Path to rules directory if found, None otherwise + """ + rules_dir = project_dir / ".claude" / "rules" + if rules_dir.exists() and rules_dir.is_dir(): + return rules_dir + return None + + +def _collect_all_rules( + rules_dir: Path, +) -> list[tuple[Path, list[str], list[dict], str]]: + """ + Recursively collect all rule files from the rules directory. + + Args: + rules_dir: Path to .claude/rules/ + + Returns: + List of tuples: (rule_path, path_patterns, required_skills, rule_content) + where required_skills is a list of dicts with 'skill', 'when', and optional 'paths' keys + """ + rules = [] + + for rule_path in rules_dir.rglob("*.md"): + try: + content = rule_path.read_text(encoding="utf-8") + paths, skills, rule_content = _parse_rule_frontmatter(content) + if paths and rule_content: + rules.append((rule_path, paths, skills, rule_content)) + elif rule_content and not paths: + logger.debug( + f"Skipping rule {rule_path}: no paths defined in frontmatter" + ) + except Exception as e: + logger.debug(f"Failed to read rule file {rule_path}: {e}") + + return rules + + +def load_claude_rules( + project_dir: Path, + files_to_check: list[str] | None = None, +) -> tuple[str, list[str], list[dict]]: + """ + Load Claude Code rules from .claude/rules/ that match the given files. + + Rules are markdown files with YAML frontmatter containing path patterns + and optional required skills. + + Only rules whose patterns match at least one file in files_to_check are loaded. + + Args: + project_dir: Root directory of the project + files_to_check: List of file paths being modified/created. + If None, loads ALL rules (useful for planning phases). + + Returns: + Tuple of (combined_rules_content, list_of_matched_rule_names, list_of_required_skills) + where required_skills is a list of dicts with 'skill', 'when', and optional 'paths' keys + """ + rules_dir = _discover_rules_directory(project_dir) + if not rules_dir: + return "", [], [] + + all_rules = _collect_all_rules(rules_dir) + if not all_rules: + return "", [], [] + + matched_rules = [] + matched_names = [] + # Track skills by (name, when, paths) to preserve different configurations + # of the same skill across phases or with different path filters + seen_skills: set[tuple[str, str, tuple[str, ...]]] = set() + all_required_skills: list[dict] = [] + + def _add_skill(skill: dict) -> None: + """Add skill if not already seen with same configuration.""" + skill_name = skill.get("skill", "") + if not skill_name: + return + when = skill.get("when", "per_subtask") + paths_key = tuple(sorted(skill.get("paths", []) or [])) + key = (skill_name, when, paths_key) + if key not in seen_skills: + seen_skills.add(key) + all_required_skills.append(skill) + + for rule_path, patterns, skills, content in all_rules: + # Get relative name for display + rel_name = str(rule_path.relative_to(rules_dir)) + + # If no files specified, load all rules + if files_to_check is None: + matched_rules.append((rel_name, content)) + matched_names.append(rel_name) + for skill in skills: + _add_skill(skill) + continue + + # Check if any pattern matches any file + for pattern in patterns: + for filepath in files_to_check: + if _match_glob_pattern(pattern, filepath): + matched_rules.append((rel_name, content)) + matched_names.append(rel_name) + for skill in skills: + _add_skill(skill) + break + else: + continue + break + + if not matched_rules: + return "", [], [] + + # Combine matched rules into a single string + combined = [] + for name, content in matched_rules: + combined.append(f"## Rule: {name}\n\n{content}") + + return "\n\n---\n\n".join(combined), matched_names, all_required_skills + + +def _get_files_from_implementation_plan(spec_dir: Path) -> list[str]: + """ + Extract all files_to_modify and files_to_create from implementation_plan.json. + + Args: + spec_dir: Directory containing the spec + + Returns: + List of all file paths mentioned in the plan + """ + plan_path = spec_dir / "implementation_plan.json" + if not plan_path.exists(): + return [] + + try: + plan = json.loads(plan_path.read_text(encoding="utf-8")) + files = set() + + # Collect from all phases and subtasks + for phase in plan.get("phases", []): + for subtask in phase.get("subtasks", []): + files.update(subtask.get("files_to_modify", [])) + files.update(subtask.get("files_to_create", [])) + + # Also check top-level if present + files.update(plan.get("files_to_modify", [])) + files.update(plan.get("files_to_create", [])) + + return list(files) + except Exception as e: + logger.debug(f"Failed to read implementation plan: {e}") + return [] + + def create_client( project_dir: Path, spec_dir: Path, @@ -793,6 +1244,140 @@ def create_client( print(" - CLAUDE.md: not found in project root") else: print(" - CLAUDE.md: disabled by project settings") + + # Include .claude/rules/ if enabled + # Rules are matched based on files being modified in the implementation plan + required_skills: list[dict] = [] + if should_use_claude_rules(): + files_in_plan = _get_files_from_implementation_plan(spec_dir) + if files_in_plan: + rules_content, matched_rules, required_skills = load_claude_rules( + project_dir, files_in_plan + ) + if rules_content: + base_prompt = f"{base_prompt}\n\n# Project Rules (from .claude/rules/)\n\n{rules_content}" + print(f" - .claude/rules/: {len(matched_rules)} rules matched") + for rule_name in matched_rules[:5]: # Show first 5 + print(f" • {rule_name}") + if len(matched_rules) > 5: + print(f" • ... and {len(matched_rules) - 5} more") + else: + print(" - .claude/rules/: no matching rules for files in plan") + else: + # No implementation plan yet (planning phase) - load all rules + rules_content, matched_rules, required_skills = load_claude_rules( + project_dir, None + ) + if rules_content: + base_prompt = f"{base_prompt}\n\n# Project Rules (from .claude/rules/)\n\n{rules_content}" + print(f" - .claude/rules/: {len(matched_rules)} rules loaded (all)") + else: + print(" - .claude/rules/: no rules found") + + # Add required skills instruction if any rules specify them + # Filter skills based on agent_type and 'when' timing + if required_skills: + # Map 'when' values to agent types + WHEN_TO_AGENTS = { + "planning": ["planner", "spec_gatherer"], + "per_subtask": ["coder"], + "end_of_coding": ["coder"], + "qa_phase": ["qa_reviewer", "qa_fixer"], + } + + # Filter skills relevant to this agent type + relevant_skills = [ + s + for s in required_skills + if agent_type in WHEN_TO_AGENTS.get(s.get("when", "per_subtask"), []) + ] + + if relevant_skills: + # Group skills by 'when' for appropriate instructions + planning_skills = [ + s for s in relevant_skills if s.get("when") == "planning" + ] + per_subtask_skills = [ + s for s in relevant_skills if s.get("when") == "per_subtask" + ] + end_of_coding_skills = [ + s for s in relevant_skills if s.get("when") == "end_of_coding" + ] + qa_skills = [s for s in relevant_skills if s.get("when") == "qa_phase"] + + skill_instructions = [] + + # Planning phase instructions + if planning_skills: + skills_list = ", ".join(f"`{s['skill']}`" for s in planning_skills) + skill_instructions.append( + f"**Planning skills** ({skills_list}):\n" + f"Include these as verification_steps in implementation_plan.json with " + f"appropriate timing (e.g., as final verification step)." + ) + + # Per-subtask instructions + if per_subtask_skills: + skills_list = ", ".join( + f"`{s['skill']}`" for s in per_subtask_skills + ) + paths_info = [] + for s in per_subtask_skills: + if s.get("paths"): + paths_info.append( + f" - `{s['skill']}` on files matching: {', '.join(s['paths'])}" + ) + paths_section = "\n".join(paths_info) if paths_info else "" + skill_instructions.append( + f"**Per-subtask skills** ({skills_list}):\n" + f"Run these skills on relevant files during subtask implementation.\n" + f"{paths_section}" + ) + + # End-of-coding instructions + if end_of_coding_skills: + skills_list = ", ".join( + f"`{s['skill']}`" for s in end_of_coding_skills + ) + skill_instructions.append( + f"**End-of-coding skills** ({skills_list}):\n" + f"Run these skills ONCE after ALL subtasks are complete, before signaling completion.\n" + f"Do NOT run on every subtask - only at the very end." + ) + + # QA phase instructions + if qa_skills: + skills_list = ", ".join(f"`{s['skill']}`" for s in qa_skills) + skill_instructions.append( + f"**QA phase skills** ({skills_list}):\n" + f"Run these skills during your QA review before sign-off." + ) + + if skill_instructions: + combined_instructions = "\n\n".join(skill_instructions) + skill_instruction = ( + f"\n\n# Required Skills\n\n" + f"The following skills are required based on project rules:\n\n" + f"{combined_instructions}\n\n" + f"Skills are invoked automatically when relevant based on context. " + f"Ensure the required skills run during your workflow phase.\n\n" + f"Do NOT mark your work as complete until all required skills for your phase have been run." + ) + base_prompt = f"{base_prompt}{skill_instruction}" + + # Print summary of relevant skills + skill_names = [s["skill"] for s in relevant_skills] + print( + f" - Required skills ({agent_type}): {', '.join(skill_names)}" + ) + else: + # Skills exist but none for this agent type + all_skill_names = [s["skill"] for s in required_skills] + print( + f" - Required skills: {', '.join(all_skill_names)} (not for {agent_type})" + ) + else: + print(" - .claude/rules/: disabled") print() # Build options dict, conditionally including output_format diff --git a/apps/backend/core/test_client.py b/apps/backend/core/test_client.py new file mode 100644 index 0000000000..63c5d492f8 --- /dev/null +++ b/apps/backend/core/test_client.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Unit tests for core/client.py glob pattern matching. + +Run with: pytest apps/backend/core/test_client.py -v +""" + +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core.client import _match_glob_pattern + + +class TestMatchGlobPattern: + """Tests for _match_glob_pattern function covering nested-path glob semantics.""" + + def test_nested_directory_wildcard(self): + """Test ** matches arbitrary nested directories.""" + assert _match_glob_pattern("src/**/*.ts", "src/a/b/c/file.ts") is True + + def test_leading_and_trailing_double_star(self): + """Test **/dir/**/*.ext pattern matches paths with test in middle.""" + assert _match_glob_pattern("**/test/**/*.ts", "foo/test/bar/baz.ts") is True + + def test_deeply_nested_test_directory(self): + """Test ** handles deeply nested paths with target directory.""" + assert ( + _match_glob_pattern("src/**/test/**", "src/deep/nested/test/files") is True + ) + + def test_double_star_does_not_match_partial_names(self): + """Test ** does not incorrectly match partial directory names (contest vs test).""" + assert _match_glob_pattern("src/**/test/*.ts", "src/contest.ts") is False + + # Edge cases requested by CodeRabbit review + def test_standalone_double_star_matches_any_path(self): + """Test ** alone matches any non-empty filepath.""" + assert _match_glob_pattern("**", "any/path/file.ts") is True + assert _match_glob_pattern("**", "file.ts") is True + assert _match_glob_pattern("**", "deep/nested/path/to/file.tsx") is True + + def test_standalone_double_star_matches_empty_path(self): + """Test ** matches empty filepath (edge case).""" + assert _match_glob_pattern("**", "") is True + + def test_double_star_no_slash_suffix(self): + """Test **.ts (no slash after **) matches files ending in .ts.""" + # Should match .ts files at any depth + assert _match_glob_pattern("**.ts", "file.ts") is True + assert _match_glob_pattern("**.ts", "src/file.ts") is True + assert _match_glob_pattern("**.ts", "deep/nested/file.ts") is True + + def test_double_star_no_slash_negative_cases(self): + """Test **.ts does not match non-.ts files.""" + assert _match_glob_pattern("**.ts", "file.tsx") is False + assert _match_glob_pattern("**.ts", "file.js") is False + assert _match_glob_pattern("**.ts", "src/file.tsx") is False + + def test_empty_filepath_with_specific_pattern(self): + """Test empty filepath does not match specific patterns.""" + assert _match_glob_pattern("src/**/*.ts", "") is False + assert _match_glob_pattern("*.ts", "") is False + + def test_empty_pattern_edge_case(self): + """Test empty pattern behavior.""" + assert _match_glob_pattern("", "") is True + assert _match_glob_pattern("", "file.ts") is False diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 9cc7e827c3..a021cc74b4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude-ui", - "version": "2.7.5", + "version": "2.7.4", "type": "module", "description": "Desktop UI for Auto Claude autonomous coding framework", "homepage": "https://github.com/AndyMik90/Auto-Claude", @@ -31,11 +31,11 @@ "python:download": "node scripts/download-python.cjs", "python:download:all": "node scripts/download-python.cjs --all", "python:verify": "node scripts/verify-python-bundling.cjs", - "package": "node scripts/package-with-python.cjs", - "package:mac": "node scripts/package-with-python.cjs --mac", - "package:win": "node scripts/package-with-python.cjs --win", - "package:linux": "node scripts/package-with-python.cjs --linux", - "package:flatpak": "node scripts/package-with-python.cjs --linux flatpak", + "package": "npm run python:download && electron-vite build && electron-builder --publish never", + "package:mac": "npm run python:download && electron-vite build && electron-builder --mac --publish never", + "package:win": "npm run python:download && electron-vite build && electron-builder --win --publish never", + "package:linux": "npm run python:download && electron-vite build && electron-builder --linux --publish never", + "package:flatpak": "npm run python:download && electron-vite build && electron-builder --linux flatpak --publish never", "start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app", "start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"", "start:packaged:linux": "./dist/linux-unpacked/auto-claude", @@ -43,9 +43,8 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "npx playwright test --config=e2e/playwright.config.ts", - "lint": "biome check .", - "lint:fix": "biome check --write .", - "format": "biome format --write .", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -102,13 +101,12 @@ "zustand": "^5.0.9" }, "devDependencies": { - "@biomejs/biome": "2.3.11", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@electron/rebuild": "^4.0.2", + "@eslint/js": "^9.39.1", "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.17", - "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.1.0", "@types/minimatch": "^5.1.2", @@ -121,21 +119,25 @@ "autoprefixer": "^10.4.22", "cross-env": "^10.1.0", "electron": "39.2.7", - "electron-builder": "^26.4.0", + "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^17.0.0", "husky": "^9.1.7", "jsdom": "^27.3.0", "lint-staged": "^16.2.7", "postcss": "^8.5.6", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", + "typescript-eslint": "^8.50.1", "vite": "^7.2.7", "vitest": "^4.0.16" }, "overrides": { "electron-builder-squirrel-windows": "^26.0.12", - "dmg-builder": "^26.0.12", - "@electron/rebuild": "4.0.2" + "dmg-builder": "^26.0.12" }, "build": { "appId": "com.autoclaude.ui", @@ -156,9 +158,6 @@ "out/**/*", "package.json" ], - "asarUnpack": [ - "out/main/node_modules/@lydell/node-pty-*/**" - ], "extraResources": [ { "from": "resources/icon.ico", @@ -237,8 +236,8 @@ } }, "lint-staged": { - "*.{ts,tsx,js,jsx,json}": [ - "biome check --write --no-errors-on-unmatched" + "*.{ts,tsx}": [ + "eslint --fix" ] } } diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 76f0a4b450..f1ce0e7fe0 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -416,6 +416,15 @@ export class AgentProcessManager { if (project.settings.useClaudeMd !== false) { env['USE_CLAUDE_MD'] = 'true'; } + + // .claude/rules/ integration (follows useClaudeMd by default) + // Can be explicitly enabled/disabled via useClaudeRules setting + if (project.settings.useClaudeRules === true) { + env['USE_CLAUDE_RULES'] = 'true'; + } else if (project.settings.useClaudeRules === false) { + env['USE_CLAUDE_RULES'] = 'false'; + } + // If undefined, the backend will default to following USE_CLAUDE_MD } return env; diff --git a/apps/frontend/src/shared/types/project.ts b/apps/frontend/src/shared/types/project.ts index a0bd234b4c..c9ffc2b026 100644 --- a/apps/frontend/src/shared/types/project.ts +++ b/apps/frontend/src/shared/types/project.ts @@ -26,6 +26,8 @@ export interface ProjectSettings { mainBranch?: string; /** Include CLAUDE.md instructions in agent system prompt (default: true) */ useClaudeMd?: boolean; + /** Include .claude/rules/ path-based rules in agent system prompt (default: follows useClaudeMd) */ + useClaudeRules?: boolean; } export interface NotificationSettings { diff --git a/guides/features/claude-rules-support.md b/guides/features/claude-rules-support.md new file mode 100644 index 0000000000..cd21fedb65 --- /dev/null +++ b/guides/features/claude-rules-support.md @@ -0,0 +1,246 @@ +# Claude Code Rules Support (`.claude/rules/`) + +Auto-Claude now supports Claude Code's path-based rules convention, automatically loading project-specific rules based on which files are being modified. + +## Overview + +When `USE_CLAUDE_MD=true` is set, Auto-Claude will: + +1. Read the project's `.claude/rules/` directory +2. Parse YAML frontmatter from each rule file to extract path patterns +3. Match patterns against files in the implementation plan +4. Inject matched rules into the agent's system prompt + +This enables project-specific coding standards, security patterns, and conventions to be automatically enforced during autonomous development. + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `USE_CLAUDE_MD` | `false` | Enable CLAUDE.md loading (also enables rules by default) | +| `USE_CLAUDE_RULES` | (inherits from USE_CLAUDE_MD) | Explicitly enable/disable rules loading | + +### Project Setup + +Create `.auto-claude/.env` in your project: + +```bash +USE_CLAUDE_MD=true +# USE_CLAUDE_RULES is automatically enabled when USE_CLAUDE_MD=true +# To disable rules while keeping CLAUDE.md: USE_CLAUDE_RULES=false +``` + +## Rule File Format + +Rules are markdown files in `.claude/rules/` with YAML frontmatter specifying path patterns: + +```markdown +--- +paths: + - src/app/api/**/*.ts + - src/app/api/**/*.tsx +--- + +# API Route Security Rules + +All API routes must follow these patterns: + +1. Wrap POST/PUT/PATCH/DELETE handlers with `withCsrfProtection` +2. Validate user permissions before database operations +3. Return consistent error responses +... +``` + +### Requiring Skills + +Rules can require specific skills to be run. Add `require_skills` to the frontmatter: + +```markdown +--- +paths: + - src/app/api/**/*.ts +require_skills: + - /security-audit +--- +``` + +#### Structured Format with Timing Control + +For more control over when skills run, use the structured format: + +```markdown +--- +paths: + - src/**/*.ts +require_skills: + - skill: /review + when: end_of_coding + - skill: /security-audit + when: per_subtask + paths: + - src/app/api/** +--- +``` + +#### `when` Options + +| Value | Agent | Description | +|-------|-------|-------------| +| `planning` | Planner | Include in implementation plan as a requirement | +| `per_subtask` | Coder | Run on each matching subtask (default) | +| `end_of_coding` | Coder | Run once after ALL subtasks complete | +| `qa_phase` | QA Reviewer | Run during QA validation | + +#### `paths` Filter (Optional) + +Narrow skill execution to specific file patterns within the rule's scope: + +```yaml +require_skills: + - skill: /security-audit + when: per_subtask + paths: + - src/app/api/** + - supabase/functions/** +``` + +If omitted, the skill applies to all files matched by the rule's `paths`. + +### Supported Path Patterns + +| Pattern | Matches | +|---------|---------| +| `src/app/api/**/*.ts` | Any `.ts` file under `src/app/api/` at any depth | +| `src/components/*.tsx` | `.tsx` files directly in `src/components/` | +| `**/*.test.ts` | Any test file anywhere in the project | +| `supabase/migrations/*.sql` | SQL files in migrations directory | + +## How It Works + +### During Planning Phase + +When no implementation plan exists yet (planning phase), **all rules are loaded** to give the planner full context about project conventions. + +### During Coding Phase + +When an implementation plan exists, rules are **selectively loaded** based on `files_to_modify` and `files_to_create` in the plan: + +```text +Implementation Plan: + - files_to_modify: ["src/app/api/films/route.ts"] + - files_to_create: ["src/components/FilmModal.tsx"] + +Matched Rules: + - security/api-routes.md (matches src/app/api/**) + - security/csrf-protection.md (matches src/app/api/**) + - frontend/patterns.md (matches src/components/**) +``` + +### Console Output + +When Auto-Claude starts, you'll see which rules were loaded: + +```text +Security settings: .claude_settings.json + - Sandbox enabled (OS-level bash isolation) + - Filesystem restricted to: /path/to/project + - Bash commands restricted to allowlist + - Extended thinking: disabled + - MCP servers: context7 (documentation) + - CLAUDE.md: included in system prompt + - .claude/rules/: 3 rules matched + • security/api-routes.md + • security/csrf-protection.md + • frontend/patterns.md +``` + +## Example Rules Structure + +```text +.claude/rules/ +├── core/ +│ └── working-principles.md # General coding principles +├── database/ +│ ├── migrations.md # Migration patterns +│ └── realtime.md # Realtime subscription patterns +├── frontend/ +│ ├── patterns.md # Component patterns +│ └── permissions.md # Permission system usage +├── security/ +│ ├── api-routes.md # IDOR, injection prevention +│ ├── csrf-protection.md # CSRF token handling +│ └── auth-patterns.md # Authentication flows +└── testing/ + └── playwright.md # E2E test patterns +``` + +## Benefits + +1. **Automatic Pattern Enforcement** - Security rules, coding standards, and conventions are injected automatically +2. **Context-Aware Loading** - Only relevant rules are loaded based on files being modified +3. **Reduced Token Usage** - Planning phase gets all rules; coding phase gets only matched rules +4. **Consistent Code Quality** - Agents follow project-specific patterns without manual prompting + +## Compatibility + +- Works with existing Claude Code projects that use `.claude/rules/` +- No changes required to rule file format +- Falls back gracefully if rules directory doesn't exist + +## Troubleshooting + +### Rules Not Being Applied + +1. **Check USE_CLAUDE_MD is set:** + + ```bash + cat .auto-claude/.env | grep USE_CLAUDE_MD + # Should show: USE_CLAUDE_MD=true + ``` + +2. **Check console output for "rules matched":** + + ```text + - .claude/rules/: 3 rules matched + ``` + + If you see `0 rules matched`, check your path patterns in frontmatter. + +3. **Verify frontmatter format:** + + ```yaml + --- + paths: + - src/app/api/**/*.ts + --- + ``` + + The `paths:` key must be a YAML list, not a comma-separated string. + +### Rules Loaded But Not Followed + +Check `context.json` in the spec folder - it should contain distilled patterns from your rules: + +```json +{ + "patterns": { + "api_route_pattern": "Use withCsrfProtection wrapper...", + "..." + } +} +``` + +If patterns are empty, the rule parsing may have failed silently. + +### All Rules Loading Every Time + +During planning phase, all rules are intentionally loaded. During coding phase, only matched rules should load. If all rules load during coding, check that: +1. `implementation_plan.json` exists +2. It contains `files_to_modify` and `files_to_create` arrays +3. Your rule path patterns actually match those files + +## Related + +- Skills Support - Loading `.claude/skills/` (documentation coming soon)