linting gods pleased now?

This commit is contained in:
AndyMik90
2025-12-16 21:57:45 +01:00
parent 140f11fccd
commit 6aea4bb830
95 changed files with 1232 additions and 624 deletions
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from core.agent instead.""" """Backward compatibility shim - import from core.agent instead."""
from core.agent import * # noqa: F403 from core.agent import * # noqa: F403
+7 -7
View File
@@ -24,13 +24,13 @@ TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT). # Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner. # These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
ELECTRON_TOOLS = [ ELECTRON_TOOLS = [
"mcp__electron__electron_connect", # Connect to Electron app via DevTools "mcp__electron__electron_connect", # Connect to Electron app via DevTools
"mcp__electron__electron_screenshot", # Take screenshot of Electron window "mcp__electron__electron_screenshot", # Take screenshot of Electron window
"mcp__electron__electron_click", # Click element in Electron app "mcp__electron__electron_click", # Click element in Electron app
"mcp__electron__electron_fill", # Fill input field in Electron app "mcp__electron__electron_fill", # Fill input field in Electron app
"mcp__electron__electron_evaluate", # Execute JS in Electron renderer "mcp__electron__electron_evaluate", # Execute JS in Electron renderer
"mcp__electron__electron_get_window_info", # Get window state/bounds "mcp__electron__electron_get_window_info", # Get window state/bounds
"mcp__electron__electron_get_console", # Get console logs from renderer "mcp__electron__electron_get_console", # Get console logs from renderer
] ]
# Base tools available to all agents # Base tools available to all agents
@@ -6,7 +6,6 @@ Manages which tools are allowed for each agent type to prevent context
pollution and accidental misuse. pollution and accidental misuse.
""" """
from .models import ( from .models import (
BASE_READ_TOOLS, BASE_READ_TOOLS,
BASE_WRITE_TOOLS, BASE_WRITE_TOOLS,
@@ -97,7 +97,9 @@ class EnvironmentDetector(BaseAnalyzer):
"sensitive": is_sensitive, "sensitive": is_sensitive,
} }
def _parse_env_example(self, env_vars: dict[str, Any], required_vars: set[str]) -> None: def _parse_env_example(
self, env_vars: dict[str, Any], required_vars: set[str]
) -> None:
"""Parse .env.example to find required variables.""" """Parse .env.example to find required variables."""
example_content = self._read_file(".env.example") or self._read_file( example_content = self._read_file(".env.example") or self._read_file(
".env.sample" ".env.sample"
@@ -156,7 +158,9 @@ class EnvironmentDetector(BaseAnalyzer):
"sensitive": False, "sensitive": False,
} }
def _parse_code_references(self, env_vars: dict[str, Any], optional_vars: set[str]) -> None: def _parse_code_references(
self, env_vars: dict[str, Any], optional_vars: set[str]
) -> None:
"""Scan code for os.getenv() / process.env usage to find optional vars.""" """Scan code for os.getenv() / process.env usage to find optional vars."""
entry_files = [ entry_files = [
"app.py", "app.py",
@@ -32,7 +32,9 @@ class JobsDetector(BaseAnalyzer):
jobs_info = None jobs_info = None
# Try each job system in order # Try each job system in order
jobs_info = self._detect_celery() or self._detect_bullmq() or self._detect_sidekiq() jobs_info = (
self._detect_celery() or self._detect_bullmq() or self._detect_sidekiq()
)
if jobs_info: if jobs_info:
self.analysis["background_jobs"] = jobs_info self.analysis["background_jobs"] = jobs_info
@@ -98,7 +98,10 @@ class MonitoringDetector(BaseAnalyzer):
def _get_apm_tools(self) -> list[str] | None: def _get_apm_tools(self) -> list[str] | None:
"""Get APM tools from existing services analysis.""" """Get APM tools from existing services analysis."""
if "services" not in self.analysis or "monitoring" not in self.analysis["services"]: if (
"services" not in self.analysis
or "monitoring" not in self.analysis["services"]
):
return None return None
return [s["type"] for s in self.analysis["services"]["monitoring"]] return [s["type"] for s in self.analysis["services"]["monitoring"]]
@@ -152,7 +152,9 @@ class ServicesDetector(BaseAnalyzer):
return all_deps return all_deps
def _detect_databases(self, all_deps: set[str], databases: list[dict[str, str]]) -> None: def _detect_databases(
self, all_deps: set[str], databases: list[dict[str, str]]
) -> None:
"""Detect database clients.""" """Detect database clients."""
for dep, db_type in self.DATABASE_INDICATORS.items(): for dep, db_type in self.DATABASE_INDICATORS.items():
if dep in all_deps: if dep in all_deps:
@@ -164,7 +166,9 @@ class ServicesDetector(BaseAnalyzer):
if indicator in all_deps: if indicator in all_deps:
cache.append({"type": indicator}) cache.append({"type": indicator})
def _detect_message_queues(self, all_deps: set[str], queues: list[dict[str, str]]) -> None: def _detect_message_queues(
self, all_deps: set[str], queues: list[dict[str, str]]
) -> None:
"""Detect message queue systems.""" """Detect message queue systems."""
for dep, queue_type in self.QUEUE_INDICATORS.items(): for dep, queue_type in self.QUEUE_INDICATORS.items():
if dep in all_deps: if dep in all_deps:
@@ -176,25 +180,33 @@ class ServicesDetector(BaseAnalyzer):
if dep in all_deps: if dep in all_deps:
email.append({"provider": email_type, "client": dep}) email.append({"provider": email_type, "client": dep})
def _detect_payments(self, all_deps: set[str], payments: list[dict[str, str]]) -> None: def _detect_payments(
self, all_deps: set[str], payments: list[dict[str, str]]
) -> None:
"""Detect payment processors.""" """Detect payment processors."""
for dep, payment_type in self.PAYMENT_INDICATORS.items(): for dep, payment_type in self.PAYMENT_INDICATORS.items():
if dep in all_deps: if dep in all_deps:
payments.append({"provider": payment_type, "client": dep}) payments.append({"provider": payment_type, "client": dep})
def _detect_storage(self, all_deps: set[str], storage: list[dict[str, str]]) -> None: def _detect_storage(
self, all_deps: set[str], storage: list[dict[str, str]]
) -> None:
"""Detect storage services.""" """Detect storage services."""
for dep, storage_type in self.STORAGE_INDICATORS.items(): for dep, storage_type in self.STORAGE_INDICATORS.items():
if dep in all_deps: if dep in all_deps:
storage.append({"provider": storage_type, "client": dep}) storage.append({"provider": storage_type, "client": dep})
def _detect_auth_providers(self, all_deps: set[str], auth: list[dict[str, str]]) -> None: def _detect_auth_providers(
self, all_deps: set[str], auth: list[dict[str, str]]
) -> None:
"""Detect authentication providers.""" """Detect authentication providers."""
for dep, auth_type in self.AUTH_INDICATORS.items(): for dep, auth_type in self.AUTH_INDICATORS.items():
if dep in all_deps: if dep in all_deps:
auth.append({"type": auth_type, "client": dep}) auth.append({"type": auth_type, "client": dep})
def _detect_monitoring(self, all_deps: set[str], monitoring: list[dict[str, str]]) -> None: def _detect_monitoring(
self, all_deps: set[str], monitoring: list[dict[str, str]]
) -> None:
"""Detect monitoring and observability tools.""" """Detect monitoring and observability tools."""
for dep, monitoring_type in self.MONITORING_INDICATORS.items(): for dep, monitoring_type in self.MONITORING_INDICATORS.items():
if dep in all_deps: if dep in all_deps:
@@ -19,7 +19,7 @@ class RouteDetector(BaseAnalyzer):
"""Detects API routes across multiple web frameworks.""" """Detects API routes across multiple web frameworks."""
# Directories to exclude from route detection # Directories to exclude from route detection
EXCLUDED_DIRS = {'node_modules', '.venv', 'venv', '__pycache__', '.git'} EXCLUDED_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".git"}
def __init__(self, path: Path): def __init__(self, path: Path):
super().__init__(path) super().__init__(path)
@@ -58,7 +58,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_fastapi_routes(self) -> list[dict]: def _detect_fastapi_routes(self) -> list[dict]:
"""Detect FastAPI routes.""" """Detect FastAPI routes."""
routes = [] routes = []
files_to_check = [f for f in self.path.glob("**/*.py") if self._should_include_file(f)] files_to_check = [
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
]
for file_path in files_to_check: for file_path in files_to_check:
try: try:
@@ -120,7 +122,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_flask_routes(self) -> list[dict]: def _detect_flask_routes(self) -> list[dict]:
"""Detect Flask routes.""" """Detect Flask routes."""
routes = [] routes = []
files_to_check = [f for f in self.path.glob("**/*.py") if self._should_include_file(f)] files_to_check = [
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
]
for file_path in files_to_check: for file_path in files_to_check:
try: try:
@@ -167,7 +171,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_django_routes(self) -> list[dict]: def _detect_django_routes(self) -> list[dict]:
"""Detect Django routes from urls.py files.""" """Detect Django routes from urls.py files."""
routes = [] routes = []
url_files = [f for f in self.path.glob("**/urls.py") if self._should_include_file(f)] url_files = [
f for f in self.path.glob("**/urls.py") if self._should_include_file(f)
]
for file_path in url_files: for file_path in url_files:
try: try:
@@ -201,8 +207,12 @@ class RouteDetector(BaseAnalyzer):
def _detect_express_routes(self) -> list[dict]: def _detect_express_routes(self) -> list[dict]:
"""Detect Express/Fastify/Koa routes.""" """Detect Express/Fastify/Koa routes."""
routes = [] routes = []
js_files = [f for f in self.path.glob("**/*.js") if self._should_include_file(f)] js_files = [
ts_files = [f for f in self.path.glob("**/*.ts") if self._should_include_file(f)] f for f in self.path.glob("**/*.js") if self._should_include_file(f)
]
ts_files = [
f for f in self.path.glob("**/*.ts") if self._should_include_file(f)
]
files_to_check = js_files + ts_files files_to_check = js_files + ts_files
for file_path in files_to_check: for file_path in files_to_check:
try: try:
@@ -256,7 +266,11 @@ class RouteDetector(BaseAnalyzer):
app_dir = self.path / "app" app_dir = self.path / "app"
if app_dir.exists(): if app_dir.exists():
# Find all route.ts/js files # Find all route.ts/js files
route_files = [f for f in app_dir.glob("**/route.{ts,js,tsx,jsx}") if self._should_include_file(f)] route_files = [
f
for f in app_dir.glob("**/route.{ts,js,tsx,jsx}")
if self._should_include_file(f)
]
for route_file in route_files: for route_file in route_files:
# Convert file path to route path # Convert file path to route path
# app/api/users/[id]/route.ts -> /api/users/:id # app/api/users/[id]/route.ts -> /api/users/:id
@@ -290,9 +304,13 @@ class RouteDetector(BaseAnalyzer):
# Next.js Pages Router (pages/api directory) # Next.js Pages Router (pages/api directory)
pages_api = self.path / "pages" / "api" pages_api = self.path / "pages" / "api"
if pages_api.exists(): if pages_api.exists():
api_files = [f for f in pages_api.glob("**/*.{ts,js,tsx,jsx}") if self._should_include_file(f)] api_files = [
f
for f in pages_api.glob("**/*.{ts,js,tsx,jsx}")
if self._should_include_file(f)
]
for api_file in api_files: for api_file in api_files:
if api_file.name.startswith('_'): if api_file.name.startswith("_"):
continue continue
# Convert file path to route # Convert file path to route
@@ -322,7 +340,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_go_routes(self) -> list[dict]: def _detect_go_routes(self) -> list[dict]:
"""Detect Go framework routes (Gin, Echo, Chi, Fiber).""" """Detect Go framework routes (Gin, Echo, Chi, Fiber)."""
routes = [] routes = []
go_files = [f for f in self.path.glob("**/*.go") if self._should_include_file(f)] go_files = [
f for f in self.path.glob("**/*.go") if self._should_include_file(f)
]
for file_path in go_files: for file_path in go_files:
try: try:
@@ -356,7 +376,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_rust_routes(self) -> list[dict]: def _detect_rust_routes(self) -> list[dict]:
"""Detect Rust framework routes (Axum, Actix).""" """Detect Rust framework routes (Axum, Actix)."""
routes = [] routes = []
rust_files = [f for f in self.path.glob("**/*.rs") if self._should_include_file(f)] rust_files = [
f for f in self.path.glob("**/*.rs") if self._should_include_file(f)
]
for file_path in rust_files: for file_path in rust_files:
try: try:
+4 -1
View File
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
# Check for Claude SDK availability # Check for Claude SDK availability
try: try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
SDK_AVAILABLE = True SDK_AVAILABLE = True
except ImportError: except ImportError:
SDK_AVAILABLE = False SDK_AVAILABLE = False
@@ -330,7 +331,9 @@ def _format_attempt_history(attempts: list[dict]) -> str:
return "\n".join(lines) return "\n".join(lines)
async def run_insight_extraction(inputs: dict, project_dir: Path | None = None) -> dict | None: async def run_insight_extraction(
inputs: dict, project_dir: Path | None = None
) -> dict | None:
""" """
Run the insight extraction using Claude Agent SDK. Run the insight extraction using Claude Agent SDK.
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from analysis.analyzer instead.""" """Backward compatibility shim - import from analysis.analyzer instead."""
from analysis.analyzer import * # noqa: F403 from analysis.analyzer import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from analysis.analyzers instead.""" """Backward compatibility shim - import from analysis.analyzers instead."""
from analysis.analyzers import * # noqa: F403 from analysis.analyzers import * # noqa: F403
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from analysis.ci_discovery instead.""" """Backward compatibility shim - import from analysis.ci_discovery instead."""
from analysis.ci_discovery import ( from analysis.ci_discovery import (
HAS_YAML, HAS_YAML,
CIConfig, CIConfig,
+1 -3
View File
@@ -318,9 +318,7 @@ def _handle_build_interrupt(
from agent import run_autonomous_agent from agent import run_autonomous_agent
# Print paused banner # Print paused banner
print_paused_banner( print_paused_banner(spec_dir, spec_dir.name, has_worktree=bool(worktree_manager))
spec_dir, spec_dir.name, has_worktree=bool(worktree_manager)
)
# Update status file # Update status file
status_manager = StatusManager(project_dir) status_manager = StatusManager(project_dir)
+5 -1
View File
@@ -302,14 +302,18 @@ def main() -> None:
# Handle build management commands # Handle build management commands
if args.merge_preview: if args.merge_preview:
from cli.workspace_commands import handle_merge_preview_command from cli.workspace_commands import handle_merge_preview_command
result = handle_merge_preview_command(project_dir, spec_dir.name) result = handle_merge_preview_command(project_dir, spec_dir.name)
# Output as JSON for the UI to parse # Output as JSON for the UI to parse
import json import json
print(json.dumps(result)) print(json.dumps(result))
return return
if args.merge: if args.merge:
success = handle_merge_command(project_dir, spec_dir.name, no_commit=args.no_commit) success = handle_merge_command(
project_dir, spec_dir.name, no_commit=args.no_commit
)
if not success: if not success:
sys.exit(1) sys.exit(1)
return return
+112 -51
View File
@@ -40,13 +40,28 @@ try:
is_debug_enabled, is_debug_enabled,
) )
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_detailed(*args, **kwargs): pass def debug(*args, **kwargs):
def debug_verbose(*args, **kwargs): pass pass
def debug_success(*args, **kwargs): pass
def debug_error(*args, **kwargs): pass def debug_detailed(*args, **kwargs):
def debug_section(*args, **kwargs): pass pass
def is_debug_enabled(): return False
def debug_verbose(*args, **kwargs):
pass
def debug_success(*args, **kwargs):
pass
def debug_error(*args, **kwargs):
pass
def debug_section(*args, **kwargs):
pass
def is_debug_enabled():
return False
MODULE = "cli.workspace_commands" MODULE = "cli.workspace_commands"
@@ -208,7 +223,9 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
result["commits_behind"] = commits_behind result["commits_behind"] = commits_behind
if commits_behind > 0: if commits_behind > 0:
result["needs_rebase"] = True result["needs_rebase"] = True
debug(MODULE, f"Main is {commits_behind} commits ahead of worktree base") debug(
MODULE, f"Main is {commits_behind} commits ahead of worktree base"
)
# Get commit hashes for merge-tree # Get commit hashes for merge-tree
main_commit_result = subprocess.run( main_commit_result = subprocess.run(
@@ -234,7 +251,15 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Use git merge-tree to check for conflicts WITHOUT touching working directory # Use git merge-tree to check for conflicts WITHOUT touching working directory
# This is a plumbing command that does a 3-way merge in memory # This is a plumbing command that does a 3-way merge in memory
merge_tree_result = subprocess.run( merge_tree_result = subprocess.run(
["git", "merge-tree", "--write-tree", "--no-messages", merge_base, main_commit, spec_commit], [
"git",
"merge-tree",
"--write-tree",
"--no-messages",
merge_base,
main_commit,
spec_commit,
],
cwd=project_dir, cwd=project_dir,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -253,7 +278,11 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
if "CONFLICT" in line: if "CONFLICT" in line:
# Extract file path from conflict message # Extract file path from conflict message
import re import re
match = re.search(r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()", line)
match = re.search(
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
line,
)
if match: if match:
file_path = match.group(1).strip() file_path = match.group(1).strip()
if file_path and file_path not in result["conflicting_files"]: if file_path and file_path not in result["conflicting_files"]:
@@ -268,7 +297,11 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
capture_output=True, capture_output=True,
text=True, text=True,
) )
main_files = set(main_files_result.stdout.strip().split("\n")) if main_files_result.stdout.strip() else set() main_files = (
set(main_files_result.stdout.strip().split("\n"))
if main_files_result.stdout.strip()
else set()
)
# Files changed in spec branch since merge-base # Files changed in spec branch since merge-base
spec_files_result = subprocess.run( spec_files_result = subprocess.run(
@@ -277,12 +310,18 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
capture_output=True, capture_output=True,
text=True, text=True,
) )
spec_files = set(spec_files_result.stdout.strip().split("\n")) if spec_files_result.stdout.strip() else set() spec_files = (
set(spec_files_result.stdout.strip().split("\n"))
if spec_files_result.stdout.strip()
else set()
)
# Files modified in both = potential conflicts # Files modified in both = potential conflicts
conflicting = main_files & spec_files conflicting = main_files & spec_files
result["conflicting_files"] = list(conflicting) result["conflicting_files"] = list(conflicting)
debug(MODULE, f"Found {len(conflicting)} files modified in both branches") debug(
MODULE, f"Found {len(conflicting)} files modified in both branches"
)
debug(MODULE, f"Conflicting files: {result['conflicting_files']}") debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
else: else:
@@ -291,6 +330,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
except Exception as e: except Exception as e:
debug_error(MODULE, f"Error checking git conflicts: {e}") debug_error(MODULE, f"Error checking git conflicts: {e}")
import traceback import traceback
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc()) debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
return result return result
@@ -316,16 +356,22 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
Dictionary with preview information Dictionary with preview information
""" """
debug_section(MODULE, "Merge Preview Command") debug_section(MODULE, "Merge Preview Command")
debug(MODULE, "handle_merge_preview_command() called", debug(
project_dir=str(project_dir), MODULE,
spec_name=spec_name) "handle_merge_preview_command() called",
project_dir=str(project_dir),
spec_name=spec_name,
)
from merge import MergeOrchestrator from merge import MergeOrchestrator
from workspace import get_existing_build_worktree from workspace import get_existing_build_worktree
worktree_path = get_existing_build_worktree(project_dir, spec_name) worktree_path = get_existing_build_worktree(project_dir, spec_name)
debug(MODULE, "Worktree lookup result", debug(
worktree_path=str(worktree_path) if worktree_path else None) MODULE,
"Worktree lookup result",
worktree_path=str(worktree_path) if worktree_path else None,
)
if not worktree_path: if not worktree_path:
debug_error(MODULE, f"No existing build found for '{spec_name}'") debug_error(MODULE, f"No existing build found for '{spec_name}'")
@@ -340,7 +386,7 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
"conflictFiles": 0, "conflictFiles": 0,
"totalConflicts": 0, "totalConflicts": 0,
"autoMergeable": 0, "autoMergeable": 0,
} },
} }
try: try:
@@ -367,36 +413,47 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
# Transform semantic conflicts to UI-friendly format # Transform semantic conflicts to UI-friendly format
conflicts = [] conflicts = []
for c in preview.get("conflicts", []): for c in preview.get("conflicts", []):
debug_verbose(MODULE, "Processing semantic conflict", debug_verbose(
file=c.get("file", ""), MODULE,
severity=c.get("severity", "unknown")) "Processing semantic conflict",
conflicts.append({ file=c.get("file", ""),
"file": c.get("file", ""), severity=c.get("severity", "unknown"),
"location": c.get("location", ""), )
"tasks": c.get("tasks", []), conflicts.append(
"severity": c.get("severity", "unknown"), {
"canAutoMerge": c.get("can_auto_merge", False), "file": c.get("file", ""),
"strategy": c.get("strategy"), "location": c.get("location", ""),
"reason": c.get("reason", ""), "tasks": c.get("tasks", []),
"type": "semantic", "severity": c.get("severity", "unknown"),
}) "canAutoMerge": c.get("can_auto_merge", False),
"strategy": c.get("strategy"),
"reason": c.get("reason", ""),
"type": "semantic",
}
)
# Add git conflicts to the list # Add git conflicts to the list
for file_path in git_conflicts.get("conflicting_files", []): for file_path in git_conflicts.get("conflicting_files", []):
conflicts.append({ conflicts.append(
"file": file_path, {
"location": "file-level", "file": file_path,
"tasks": [spec_name, git_conflicts["base_branch"]], "location": "file-level",
"severity": "high", "tasks": [spec_name, git_conflicts["base_branch"]],
"canAutoMerge": False, "severity": "high",
"strategy": None, "canAutoMerge": False,
"reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point", "strategy": None,
"type": "git", "reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point",
}) "type": "git",
}
)
summary = preview.get("summary", {}) summary = preview.get("summary", {})
total_conflicts = summary.get("total_conflicts", 0) + len(git_conflicts.get("conflicting_files", [])) total_conflicts = summary.get("total_conflicts", 0) + len(
conflict_files = summary.get("conflict_files", 0) + len(git_conflicts.get("conflicting_files", [])) git_conflicts.get("conflicting_files", [])
)
conflict_files = summary.get("conflict_files", 0) + len(
git_conflicts.get("conflicting_files", [])
)
result = { result = {
"success": True, "success": True,
@@ -416,20 +473,24 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
"totalConflicts": total_conflicts, "totalConflicts": total_conflicts,
"autoMergeable": summary.get("auto_mergeable", 0), "autoMergeable": summary.get("auto_mergeable", 0),
"hasGitConflicts": git_conflicts["has_conflicts"], "hasGitConflicts": git_conflicts["has_conflicts"],
} },
} }
debug_success(MODULE, "Merge preview complete", debug_success(
total_files=result["summary"]["totalFiles"], MODULE,
total_conflicts=result["summary"]["totalConflicts"], "Merge preview complete",
has_git_conflicts=git_conflicts["has_conflicts"], total_files=result["summary"]["totalFiles"],
auto_mergeable=result["summary"]["autoMergeable"]) total_conflicts=result["summary"]["totalConflicts"],
has_git_conflicts=git_conflicts["has_conflicts"],
auto_mergeable=result["summary"]["autoMergeable"],
)
return result return result
except Exception as e: except Exception as e:
debug_error(MODULE, "Merge preview failed", error=str(e)) debug_error(MODULE, "Merge preview failed", error=str(e))
import traceback import traceback
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc()) debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
return { return {
"success": False, "success": False,
@@ -442,5 +503,5 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
"conflictFiles": 0, "conflictFiles": 0,
"totalConflicts": 0, "totalConflicts": 0,
"autoMergeable": 0, "autoMergeable": 0,
} },
} }
+3
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from core.client instead.""" """Backward compatibility shim - import from core.client instead."""
import os import os
import sys import sys
@@ -7,8 +8,10 @@ _auto_claude_dir = os.path.dirname(os.path.abspath(__file__))
if _auto_claude_dir not in sys.path: if _auto_claude_dir not in sys.path:
sys.path.insert(0, _auto_claude_dir) sys.path.insert(0, _auto_claude_dir)
# Use lazy imports to avoid circular dependency # Use lazy imports to avoid circular dependency
def __getattr__(name): def __getattr__(name):
"""Lazy import to avoid circular imports with auto_claude_tools.""" """Lazy import to avoid circular imports with auto_claude_tools."""
from core import client as _client from core import client as _client
return getattr(_client, name) return getattr(_client, name)
+3 -1
View File
@@ -22,7 +22,9 @@ except ImportError:
return [] return []
async def fetch_graph_hints(query: str, project_id: str, max_results: int = 5) -> list[dict]: async def fetch_graph_hints(
query: str, project_id: str, max_results: int = 5
) -> list[dict]:
""" """
Get historical hints from Graphiti knowledge graph. Get historical hints from Graphiti knowledge graph.
+6
View File
@@ -16,21 +16,27 @@ __all__ = [
"ProgressTracker", "ProgressTracker",
] ]
def __getattr__(name): def __getattr__(name):
"""Lazy imports to avoid circular dependencies and heavy imports.""" """Lazy imports to avoid circular dependencies and heavy imports."""
if name in ("run_autonomous_agent", "run_followup_planner"): if name in ("run_autonomous_agent", "run_followup_planner"):
from .agent import run_autonomous_agent, run_followup_planner from .agent import run_autonomous_agent, run_followup_planner
return locals()[name] return locals()[name]
elif name == "WorkspaceManager": elif name == "WorkspaceManager":
from .workspace import WorkspaceManager from .workspace import WorkspaceManager
return WorkspaceManager return WorkspaceManager
elif name == "WorktreeManager": elif name == "WorktreeManager":
from .worktree import WorktreeManager from .worktree import WorktreeManager
return WorktreeManager return WorktreeManager
elif name == "ProgressTracker": elif name == "ProgressTracker":
from .progress import ProgressTracker from .progress import ProgressTracker
return ProgressTracker return ProgressTracker
elif name in ("create_claude_client", "ClaudeClient"): elif name in ("create_claude_client", "ClaudeClient"):
from . import client as _client from . import client as _client
return getattr(_client, name) return getattr(_client, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+16 -9
View File
@@ -106,13 +106,13 @@ GRAPHITI_MCP_TOOLS = [
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT). # Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner. # These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
ELECTRON_TOOLS = [ ELECTRON_TOOLS = [
"mcp__electron__electron_connect", # Connect to Electron app via DevTools "mcp__electron__electron_connect", # Connect to Electron app via DevTools
"mcp__electron__electron_screenshot", # Take screenshot of Electron window "mcp__electron__electron_screenshot", # Take screenshot of Electron window
"mcp__electron__electron_click", # Click element in Electron app "mcp__electron__electron_click", # Click element in Electron app
"mcp__electron__electron_fill", # Fill input field in Electron app "mcp__electron__electron_fill", # Fill input field in Electron app
"mcp__electron__electron_evaluate", # Execute JS in Electron renderer "mcp__electron__electron_evaluate", # Execute JS in Electron renderer
"mcp__electron__electron_get_window_info", # Get window state/bounds "mcp__electron__electron_get_window_info", # Get window state/bounds
"mcp__electron__electron_get_console", # Get console logs from renderer "mcp__electron__electron_get_console", # Get console logs from renderer
] ]
# Built-in tools # Built-in tools
@@ -217,7 +217,12 @@ def create_client(
# Allow Graphiti MCP tools for knowledge graph memory (if enabled) # Allow Graphiti MCP tools for knowledge graph memory (if enabled)
*(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []), *(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []),
# Allow Electron MCP tools for QA agents only (if enabled) # Allow Electron MCP tools for QA agents only (if enabled)
*(ELECTRON_TOOLS if electron_mcp_enabled and agent_type in ("qa_reviewer", "qa_fixer") else []), *(
ELECTRON_TOOLS
if electron_mcp_enabled
and agent_type in ("qa_reviewer", "qa_fixer")
else []
),
], ],
}, },
} }
@@ -238,7 +243,9 @@ def create_client(
if graphiti_mcp_enabled: if graphiti_mcp_enabled:
mcp_servers_list.append("graphiti-memory (knowledge graph)") mcp_servers_list.append("graphiti-memory (knowledge graph)")
if electron_mcp_enabled: if electron_mcp_enabled:
mcp_servers_list.append(f"electron (desktop automation, port {get_electron_debug_port()})") mcp_servers_list.append(
f"electron (desktop automation, port {get_electron_debug_port()})"
)
if auto_claude_tools_enabled: if auto_claude_tools_enabled:
mcp_servers_list.append(f"auto-claude ({agent_type} tools)") mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
print(f" - MCP servers: {', '.join(mcp_servers_list)}") print(f" - MCP servers: {', '.join(mcp_servers_list)}")
+215 -90
View File
@@ -46,13 +46,28 @@ try:
is_debug_enabled, is_debug_enabled,
) )
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_detailed(*args, **kwargs): pass def debug(*args, **kwargs):
def debug_verbose(*args, **kwargs): pass pass
def debug_success(*args, **kwargs): pass
def debug_error(*args, **kwargs): pass def debug_detailed(*args, **kwargs):
def debug_warning(*args, **kwargs): pass pass
def is_debug_enabled(): return False
def debug_verbose(*args, **kwargs):
pass
def debug_success(*args, **kwargs):
pass
def debug_error(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
def is_debug_enabled():
return False
# Import merge system # Import merge system
from core.workspace.display import ( from core.workspace.display import (
@@ -110,6 +125,7 @@ MODULE = "workspace"
# - _merge_file_with_ai # - _merge_file_with_ai
# - _heuristic_merge # - _heuristic_merge
def merge_existing_build( def merge_existing_build(
project_dir: Path, project_dir: Path,
spec_name: str, spec_name: str,
@@ -207,7 +223,7 @@ def merge_existing_build(
print() print()
print_status( print_status(
f"{len(remaining)} conflict(s) require manual resolution:", f"{len(remaining)} conflict(s) require manual resolution:",
"warning" "warning",
) )
_print_conflict_info(smart_result) _print_conflict_info(smart_result)
@@ -291,10 +307,13 @@ def _try_smart_merge_inner(
no_commit: bool = False, no_commit: bool = False,
) -> dict | None: ) -> dict | None:
"""Inner implementation of smart merge (called with lock held).""" """Inner implementation of smart merge (called with lock held)."""
debug(MODULE, "=== SMART MERGE START ===", debug(
spec_name=spec_name, MODULE,
worktree_path=str(worktree_path), "=== SMART MERGE START ===",
no_commit=no_commit) spec_name=spec_name,
worktree_path=str(worktree_path),
no_commit=no_commit,
)
try: try:
print(muted(" Analyzing changes with intent-aware merge...")) print(muted(" Analyzing changes with intent-aware merge..."))
@@ -308,9 +327,12 @@ def _try_smart_merge_inner(
debug_warning(MODULE, f"Could not capture worktree state: {e}") debug_warning(MODULE, f"Could not capture worktree state: {e}")
# Initialize the orchestrator # Initialize the orchestrator
debug(MODULE, "Initializing MergeOrchestrator", debug(
project_dir=str(project_dir), MODULE,
enable_ai=True) "Initializing MergeOrchestrator",
project_dir=str(project_dir),
enable_ai=True,
)
orchestrator = MergeOrchestrator( orchestrator = MergeOrchestrator(
project_dir, project_dir,
enable_ai=True, # Enable AI for ambiguous conflicts enable_ai=True, # Enable AI for ambiguous conflicts
@@ -318,25 +340,38 @@ def _try_smart_merge_inner(
) )
# Refresh evolution data from the worktree # Refresh evolution data from the worktree
debug(MODULE, "Refreshing evolution data from git", debug(MODULE, "Refreshing evolution data from git", spec_name=spec_name)
spec_name=spec_name)
orchestrator.evolution_tracker.refresh_from_git(spec_name, worktree_path) orchestrator.evolution_tracker.refresh_from_git(spec_name, worktree_path)
# Check for git-level conflicts first (branch divergence) # Check for git-level conflicts first (branch divergence)
debug(MODULE, "Checking for git-level conflicts") debug(MODULE, "Checking for git-level conflicts")
git_conflicts = _check_git_conflicts(project_dir, spec_name) git_conflicts = _check_git_conflicts(project_dir, spec_name)
debug_detailed(MODULE, "Git conflict check result", debug_detailed(
has_conflicts=git_conflicts.get("has_conflicts"), MODULE,
conflicting_files=git_conflicts.get("conflicting_files", []), "Git conflict check result",
base_branch=git_conflicts.get("base_branch")) has_conflicts=git_conflicts.get("has_conflicts"),
conflicting_files=git_conflicts.get("conflicting_files", []),
base_branch=git_conflicts.get("base_branch"),
)
if git_conflicts.get("has_conflicts"): if git_conflicts.get("has_conflicts"):
print(muted(f" Branch has diverged from {git_conflicts.get('base_branch', 'main')}")) print(
print(muted(f" Conflicting files: {len(git_conflicts.get('conflicting_files', []))}")) muted(
f" Branch has diverged from {git_conflicts.get('base_branch', 'main')}"
)
)
print(
muted(
f" Conflicting files: {len(git_conflicts.get('conflicting_files', []))}"
)
)
debug(MODULE, "Starting AI conflict resolution", debug(
num_conflicts=len(git_conflicts.get("conflicting_files", []))) MODULE,
"Starting AI conflict resolution",
num_conflicts=len(git_conflicts.get("conflicting_files", [])),
)
# Try to resolve git conflicts with AI # Try to resolve git conflicts with AI
resolution_result = _resolve_git_conflicts_with_ai( resolution_result = _resolve_git_conflicts_with_ai(
@@ -349,16 +384,24 @@ def _try_smart_merge_inner(
) )
if resolution_result.get("success"): if resolution_result.get("success"):
debug_success(MODULE, "AI conflict resolution succeeded", debug_success(
resolved_files=resolution_result.get("resolved_files", []), MODULE,
stats=resolution_result.get("stats", {})) "AI conflict resolution succeeded",
resolved_files=resolution_result.get("resolved_files", []),
stats=resolution_result.get("stats", {}),
)
return resolution_result return resolution_result
else: else:
# AI couldn't resolve all conflicts # AI couldn't resolve all conflicts
debug_error(MODULE, "AI conflict resolution failed", debug_error(
remaining_conflicts=resolution_result.get("remaining_conflicts", []), MODULE,
resolved_files=resolution_result.get("resolved_files", []), "AI conflict resolution failed",
error=resolution_result.get("error")) remaining_conflicts=resolution_result.get(
"remaining_conflicts", []
),
resolved_files=resolution_result.get("resolved_files", []),
error=resolution_result.get("error"),
)
return { return {
"success": False, "success": False,
"conflicts": resolution_result.get("remaining_conflicts", []), "conflicts": resolution_result.get("remaining_conflicts", []),
@@ -382,10 +425,7 @@ def _try_smart_merge_inner(
print(muted(f" Auto-mergeable: {auto_mergeable}/{len(conflicts)}")) print(muted(f" Auto-mergeable: {auto_mergeable}/{len(conflicts)}"))
# Check if any conflicts need human review # Check if any conflicts need human review
needs_human = [ needs_human = [c for c in conflicts if not c.get("can_auto_merge")]
c for c in conflicts
if not c.get("can_auto_merge")
]
if needs_human: if needs_human:
return { return {
@@ -407,6 +447,7 @@ def _try_smart_merge_inner(
except Exception as e: except Exception as e:
# If smart merge fails, fall back to git # If smart merge fails, fall back to git
import traceback import traceback
print(muted(f" Smart merge error: {e}")) print(muted(f" Smart merge error: {e}"))
traceback.print_exc() traceback.print_exc()
return None return None
@@ -479,7 +520,15 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Use git merge-tree to check for conflicts WITHOUT touching working directory # Use git merge-tree to check for conflicts WITHOUT touching working directory
merge_tree_result = subprocess.run( merge_tree_result = subprocess.run(
["git", "merge-tree", "--write-tree", "--no-messages", merge_base, main_commit, spec_commit], [
"git",
"merge-tree",
"--write-tree",
"--no-messages",
merge_base,
main_commit,
spec_commit,
],
cwd=project_dir, cwd=project_dir,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -493,7 +542,10 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
output = merge_tree_result.stdout + merge_tree_result.stderr output = merge_tree_result.stdout + merge_tree_result.stderr
for line in output.split("\n"): for line in output.split("\n"):
if "CONFLICT" in line: if "CONFLICT" in line:
match = re.search(r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()", line) match = re.search(
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
line,
)
if match: if match:
file_path = match.group(1).strip() file_path = match.group(1).strip()
if file_path and file_path not in result["conflicting_files"]: if file_path and file_path not in result["conflicting_files"]:
@@ -507,7 +559,11 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
capture_output=True, capture_output=True,
text=True, text=True,
) )
main_files = set(main_files_result.stdout.strip().split("\n")) if main_files_result.stdout.strip() else set() main_files = (
set(main_files_result.stdout.strip().split("\n"))
if main_files_result.stdout.strip()
else set()
)
spec_files_result = subprocess.run( spec_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, spec_commit], ["git", "diff", "--name-only", merge_base, spec_commit],
@@ -515,7 +571,11 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
capture_output=True, capture_output=True,
text=True, text=True,
) )
spec_files = set(spec_files_result.stdout.strip().split("\n")) if spec_files_result.stdout.strip() else set() spec_files = (
set(spec_files_result.stdout.strip().split("\n"))
if spec_files_result.stdout.strip()
else set()
)
# Files modified in both = potential conflicts # Files modified in both = potential conflicts
conflicting = main_files & spec_files conflicting = main_files & spec_files
@@ -550,18 +610,24 @@ def _resolve_git_conflicts_with_ai(
Dict with success, resolved_files, remaining_conflicts Dict with success, resolved_files, remaining_conflicts
""" """
debug(MODULE, "=== AI CONFLICT RESOLUTION START ===", debug(
spec_name=spec_name, MODULE,
num_conflicting_files=len(git_conflicts.get("conflicting_files", []))) "=== AI CONFLICT RESOLUTION START ===",
spec_name=spec_name,
num_conflicting_files=len(git_conflicts.get("conflicting_files", [])),
)
conflicting_files = git_conflicts.get("conflicting_files", []) conflicting_files = git_conflicts.get("conflicting_files", [])
base_branch = git_conflicts.get("base_branch", "main") base_branch = git_conflicts.get("base_branch", "main")
spec_branch = git_conflicts.get("spec_branch", f"auto-claude/{spec_name}") spec_branch = git_conflicts.get("spec_branch", f"auto-claude/{spec_name}")
debug_detailed(MODULE, "Conflict resolution params", debug_detailed(
base_branch=base_branch, MODULE,
spec_branch=spec_branch, "Conflict resolution params",
conflicting_files=conflicting_files) base_branch=base_branch,
spec_branch=spec_branch,
conflicting_files=conflicting_files,
)
resolved_files = [] resolved_files = []
remaining_conflicts = [] remaining_conflicts = []
@@ -569,7 +635,9 @@ def _resolve_git_conflicts_with_ai(
ai_merged_count = 0 ai_merged_count = 0
print() print()
print_status(f"Resolving {len(conflicting_files)} conflicting file(s) with AI...", "progress") print_status(
f"Resolving {len(conflicting_files)} conflicting file(s) with AI...", "progress"
)
# Get merge-base commit # Get merge-base commit
merge_base_result = subprocess.run( merge_base_result = subprocess.run(
@@ -578,24 +646,38 @@ def _resolve_git_conflicts_with_ai(
capture_output=True, capture_output=True,
text=True, text=True,
) )
merge_base = merge_base_result.stdout.strip() if merge_base_result.returncode == 0 else None merge_base = (
debug(MODULE, "Found merge-base commit", merge_base=merge_base[:12] if merge_base else None) merge_base_result.stdout.strip() if merge_base_result.returncode == 0 else None
)
debug(
MODULE,
"Found merge-base commit",
merge_base=merge_base[:12] if merge_base else None,
)
# FIX: Copy NEW files FIRST before resolving conflicts # FIX: Copy NEW files FIRST before resolving conflicts
# This ensures dependencies exist before files that import them are written # This ensures dependencies exist before files that import them are written
changed_files = _get_changed_files_from_branch(project_dir, base_branch, spec_branch) changed_files = _get_changed_files_from_branch(
new_files = [(f, s) for f, s in changed_files if s == "A" and f not in conflicting_files] project_dir, base_branch, spec_branch
)
new_files = [
(f, s) for f, s in changed_files if s == "A" and f not in conflicting_files
]
if new_files: if new_files:
print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)...")) print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)..."))
for file_path, status in new_files: for file_path, status in new_files:
try: try:
content = _get_file_content_from_ref(project_dir, spec_branch, file_path) content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None: if content is not None:
target_path = project_dir / file_path target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8") target_path.write_text(content, encoding="utf-8")
subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path) resolved_files.append(file_path)
debug(MODULE, f"Copied new file: {file_path}") debug(MODULE, f"Copied new file: {file_path}")
except Exception as e: except Exception as e:
@@ -603,7 +685,9 @@ def _resolve_git_conflicts_with_ai(
# Categorize conflicting files for processing # Categorize conflicting files for processing
files_needing_ai_merge: list[ParallelMergeTask] = [] files_needing_ai_merge: list[ParallelMergeTask] = []
simple_merges: list[tuple[str, str | None]] = [] # (file_path, merged_content or None for delete) simple_merges: list[
tuple[str, str | None]
] = [] # (file_path, merged_content or None for delete)
debug(MODULE, "Categorizing conflicting files for parallel processing") debug(MODULE, "Categorizing conflicting files for parallel processing")
@@ -612,15 +696,21 @@ def _resolve_git_conflicts_with_ai(
try: try:
# Get content from main branch # Get content from main branch
main_content = _get_file_content_from_ref(project_dir, base_branch, file_path) main_content = _get_file_content_from_ref(
project_dir, base_branch, file_path
)
# Get content from worktree branch # Get content from worktree branch
worktree_content = _get_file_content_from_ref(project_dir, spec_branch, file_path) worktree_content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
# Get content from merge-base (common ancestor) # Get content from merge-base (common ancestor)
base_content = None base_content = None
if merge_base: if merge_base:
base_content = _get_file_content_from_ref(project_dir, merge_base, file_path) base_content = _get_file_content_from_ref(
project_dir, merge_base, file_path
)
if main_content is None and worktree_content is None: if main_content is None and worktree_content is None:
# File doesn't exist in either - skip # File doesn't exist in either - skip
@@ -643,22 +733,26 @@ def _resolve_git_conflicts_with_ai(
debug(MODULE, f" {file_path}: lock file (taking worktree version)") debug(MODULE, f" {file_path}: lock file (taking worktree version)")
else: else:
# Regular file - needs AI merge # Regular file - needs AI merge
files_needing_ai_merge.append(ParallelMergeTask( files_needing_ai_merge.append(
file_path=file_path, ParallelMergeTask(
main_content=main_content, file_path=file_path,
worktree_content=worktree_content, main_content=main_content,
base_content=base_content, worktree_content=worktree_content,
spec_name=spec_name, base_content=base_content,
)) spec_name=spec_name,
)
)
debug(MODULE, f" {file_path}: needs AI merge") debug(MODULE, f" {file_path}: needs AI merge")
except Exception as e: except Exception as e:
print(error(f" ✗ Failed to categorize {file_path}: {e}")) print(error(f" ✗ Failed to categorize {file_path}: {e}"))
remaining_conflicts.append({ remaining_conflicts.append(
"file": file_path, {
"reason": str(e), "file": file_path,
"severity": "high", "reason": str(e),
}) "severity": "high",
}
)
# Process simple merges first (fast, no AI) # Process simple merges first (fast, no AI)
if simple_merges: if simple_merges:
@@ -669,11 +763,17 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(merged_content, encoding="utf-8") target_path.write_text(merged_content, encoding="utf-8")
subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path) resolved_files.append(file_path)
# Determine the type for display # Determine the type for display
if _is_lock_file(file_path): if _is_lock_file(file_path):
print(success(f"{file_path} (lock file - took worktree version)")) print(
success(
f"{file_path} (lock file - took worktree version)"
)
)
else: else:
print(success(f"{file_path} (new file)")) print(success(f"{file_path} (new file)"))
else: else:
@@ -681,23 +781,33 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path target_path = project_dir / file_path
if target_path.exists(): if target_path.exists():
target_path.unlink() target_path.unlink()
subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) subprocess.run(
["git", "add", file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(file_path) resolved_files.append(file_path)
print(success(f"{file_path} (deleted)")) print(success(f"{file_path} (deleted)"))
except Exception as e: except Exception as e:
print(error(f"{file_path}: {e}")) print(error(f"{file_path}: {e}"))
remaining_conflicts.append({ remaining_conflicts.append(
"file": file_path, {
"reason": str(e), "file": file_path,
"severity": "high", "reason": str(e),
}) "severity": "high",
}
)
# Process AI merges in parallel # Process AI merges in parallel
if files_needing_ai_merge: if files_needing_ai_merge:
print() print()
print_status(f"Merging {len(files_needing_ai_merge)} file(s) with AI (parallel)...", "progress") print_status(
f"Merging {len(files_needing_ai_merge)} file(s) with AI (parallel)...",
"progress",
)
import time import time
start_time = time.time() start_time = time.time()
# Run parallel merges # Run parallel merges
@@ -717,7 +827,11 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / result.file_path target_path = project_dir / result.file_path
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8") target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(["git", "add", result.file_path], cwd=project_dir, capture_output=True) subprocess.run(
["git", "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(result.file_path) resolved_files.append(result.file_path)
if result.was_auto_merged: if result.was_auto_merged:
@@ -728,11 +842,13 @@ def _resolve_git_conflicts_with_ai(
print(success(f"{result.file_path} (AI merged)")) print(success(f"{result.file_path} (AI merged)"))
else: else:
print(error(f"{result.file_path}: {result.error}")) print(error(f"{result.file_path}: {result.error}"))
remaining_conflicts.append({ remaining_conflicts.append(
"file": result.file_path, {
"reason": result.error or "AI could not resolve the conflict", "file": result.file_path,
"severity": "high", "reason": result.error or "AI could not resolve the conflict",
}) "severity": "high",
}
)
# Print summary # Print summary
print() print()
@@ -749,7 +865,8 @@ def _resolve_git_conflicts_with_ai(
# Get list of modified/deleted files (new files already copied at start) # Get list of modified/deleted files (new files already copied at start)
non_conflicting = [ non_conflicting = [
(f, s) for f, s in changed_files (f, s)
for f, s in changed_files
if f not in conflicting_files and s != "A" # Skip new files, already copied if f not in conflicting_files and s != "A" # Skip new files, already copied
] ]
@@ -760,15 +877,21 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path target_path = project_dir / file_path
if target_path.exists(): if target_path.exists():
target_path.unlink() target_path.unlink()
subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
)
else: else:
# Added or modified - copy from worktree # Added or modified - copy from worktree
content = _get_file_content_from_ref(project_dir, spec_branch, file_path) content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None: if content is not None:
target_path = project_dir / file_path target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8") target_path.write_text(content, encoding="utf-8")
subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path) resolved_files.append(file_path)
except Exception as e: except Exception as e:
print(muted(f" Warning: Could not process {file_path}: {e}")) print(muted(f" Warning: Could not process {file_path}: {e}"))
@@ -796,7 +919,9 @@ def _resolve_git_conflicts_with_ai(
result["remaining_conflicts"] = remaining_conflicts result["remaining_conflicts"] = remaining_conflicts
result["partial_success"] = len(resolved_files) > 0 result["partial_success"] = len(resolved_files) > 0
print() print()
print(warning(f"{len(remaining_conflicts)} file(s) could not be auto-merged:")) print(
warning(f"{len(remaining_conflicts)} file(s) could not be auto-merged:")
)
for conflict in remaining_conflicts: for conflict in remaining_conflicts:
print(muted(f" - {conflict['file']}: {conflict['reason']}")) print(muted(f" - {conflict['file']}: {conflict['reason']}"))
print(muted(" These files may need manual review.")) print(muted(" These files may need manual review."))
+33 -32
View File
@@ -15,6 +15,7 @@ This package provides:
Public API exported from sub-modules. Public API exported from sub-modules.
""" """
import importlib.util import importlib.util
import sys import sys
from pathlib import Path from pathlib import Path
@@ -103,42 +104,42 @@ from .setup import (
__all__ = [ __all__ = [
# Merge Operations (from workspace.py) # Merge Operations (from workspace.py)
'merge_existing_build', "merge_existing_build",
# '_run_parallel_merges', # TODO: not yet implemented - Private but used by tests # '_run_parallel_merges', # TODO: not yet implemented - Private but used by tests
# Models # Models
'WorkspaceMode', "WorkspaceMode",
'WorkspaceChoice', "WorkspaceChoice",
'ParallelMergeTask', "ParallelMergeTask",
'ParallelMergeResult', "ParallelMergeResult",
'MergeLock', "MergeLock",
'MergeLockError', "MergeLockError",
# Git Utils # Git Utils
'has_uncommitted_changes', "has_uncommitted_changes",
'get_current_branch', "get_current_branch",
'get_existing_build_worktree', "get_existing_build_worktree",
'get_file_content_from_ref', "get_file_content_from_ref",
'get_changed_files_from_branch', "get_changed_files_from_branch",
'is_process_running', "is_process_running",
'is_binary_file', "is_binary_file",
'validate_merged_syntax', "validate_merged_syntax",
'create_conflict_file_with_git', "create_conflict_file_with_git",
# Setup # Setup
'choose_workspace', "choose_workspace",
'copy_spec_to_worktree', "copy_spec_to_worktree",
'setup_workspace', "setup_workspace",
'ensure_timeline_hook_installed', "ensure_timeline_hook_installed",
'initialize_timeline_tracking', "initialize_timeline_tracking",
# Display # Display
'show_build_summary', "show_build_summary",
'show_changed_files', "show_changed_files",
'print_merge_success', "print_merge_success",
'print_conflict_info', "print_conflict_info",
# Finalization # Finalization
'finalize_workspace', "finalize_workspace",
'handle_workspace_choice', "handle_workspace_choice",
'review_existing_build', "review_existing_build",
'discard_existing_build', "discard_existing_build",
'check_existing_build', "check_existing_build",
'list_all_worktrees', "list_all_worktrees",
'cleanup_all_worktrees', "cleanup_all_worktrees",
] ]
+20 -9
View File
@@ -6,7 +6,6 @@ Workspace Display
Functions for displaying workspace information and build summaries. Functions for displaying workspace information and build summaries.
""" """
from ui import ( from ui import (
bold, bold,
error, error,
@@ -91,17 +90,25 @@ def print_merge_success(no_commit: bool, stats: dict | None = None) -> None:
if stats: if stats:
lines.append("What changed:") lines.append("What changed:")
if stats.get("files_added", 0) > 0: if stats.get("files_added", 0) > 0:
lines.append(f" + {stats['files_added']} file{'s' if stats['files_added'] != 1 else ''} added") lines.append(
f" + {stats['files_added']} file{'s' if stats['files_added'] != 1 else ''} added"
)
if stats.get("files_modified", 0) > 0: if stats.get("files_modified", 0) > 0:
lines.append(f" ~ {stats['files_modified']} file{'s' if stats['files_modified'] != 1 else ''} modified") lines.append(
f" ~ {stats['files_modified']} file{'s' if stats['files_modified'] != 1 else ''} modified"
)
if stats.get("files_deleted", 0) > 0: if stats.get("files_deleted", 0) > 0:
lines.append(f" - {stats['files_deleted']} file{'s' if stats['files_deleted'] != 1 else ''} deleted") lines.append(
f" - {stats['files_deleted']} file{'s' if stats['files_deleted'] != 1 else ''} deleted"
)
lines.append("") lines.append("")
lines.extend([ lines.extend(
"Your new feature is now part of your project.", [
"The separate workspace has been cleaned up.", "Your new feature is now part of your project.",
]) "The separate workspace has been cleaned up.",
]
)
content = lines content = lines
print() print()
@@ -118,7 +125,11 @@ def print_conflict_info(result: dict) -> None:
return return
print() print()
print(warning(f" {len(conflicts)} file{'s' if len(conflicts) != 1 else ''} had conflicts:")) print(
warning(
f" {len(conflicts)} file{'s' if len(conflicts) != 1 else ''} had conflicts:"
)
)
for conflict_file in conflicts: for conflict_file in conflicts:
print(f" {highlight(conflict_file)}") print(f" {highlight(conflict_file)}")
print() print()
+88 -38
View File
@@ -18,26 +18,60 @@ MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
# These are auto-generated and should just take the worktree version # These are auto-generated and should just take the worktree version
# then regenerate via package manager install # then regenerate via package manager install
LOCK_FILES = { LOCK_FILES = {
'package-lock.json', "package-lock.json",
'pnpm-lock.yaml', "pnpm-lock.yaml",
'yarn.lock', "yarn.lock",
'bun.lockb', "bun.lockb",
'Pipfile.lock', "Pipfile.lock",
'poetry.lock', "poetry.lock",
'Cargo.lock', "Cargo.lock",
'Gemfile.lock', "Gemfile.lock",
'composer.lock', "composer.lock",
'go.sum', "go.sum",
} }
BINARY_EXTENSIONS = { BINARY_EXTENSIONS = {
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.bmp', '.svg', ".png",
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', ".jpg",
'.zip', '.tar', '.gz', '.rar', '.7z', ".jpeg",
'.exe', '.dll', '.so', '.dylib', '.bin', ".gif",
'.mp3', '.mp4', '.wav', '.avi', '.mov', '.mkv', ".ico",
'.woff', '.woff2', '.ttf', '.otf', '.eot', ".webp",
'.pyc', '.pyo', '.class', '.o', '.obj', ".bmp",
".svg",
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".zip",
".tar",
".gz",
".rar",
".7z",
".exe",
".dll",
".so",
".dylib",
".bin",
".mp3",
".mp4",
".wav",
".avi",
".mov",
".mkv",
".woff",
".woff2",
".ttf",
".otf",
".eot",
".pyc",
".pyo",
".class",
".o",
".obj",
} }
# Merge lock timeout in seconds # Merge lock timeout in seconds
@@ -88,7 +122,9 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
return None return None
def get_file_content_from_ref(project_dir: Path, ref: str, file_path: str) -> str | None: def get_file_content_from_ref(
project_dir: Path, ref: str, file_path: str
) -> str | None:
"""Get file content from a git ref (branch, commit, etc.).""" """Get file content from a git ref (branch, commit, etc.)."""
result = subprocess.run( result = subprocess.run(
["git", "show", f"{ref}:{file_path}"], ["git", "show", f"{ref}:{file_path}"],
@@ -127,6 +163,7 @@ def get_changed_files_from_branch(
def is_process_running(pid: int) -> bool: def is_process_running(pid: int) -> bool:
"""Check if a process with the given PID is running.""" """Check if a process with the given PID is running."""
import os import os
try: try:
os.kill(pid, 0) os.kill(pid, 0)
return True return True
@@ -149,7 +186,9 @@ def is_lock_file(file_path: str) -> bool:
return Path(file_path).name in LOCK_FILES return Path(file_path).name in LOCK_FILES
def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> tuple[bool, str]: def validate_merged_syntax(
file_path: str, content: str, project_dir: Path
) -> tuple[bool, str]:
""" """
Validate the syntax of merged code. Validate the syntax of merged code.
@@ -166,11 +205,11 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
ext = P(file_path).suffix.lower() ext = P(file_path).suffix.lower()
# TypeScript/JavaScript validation using esbuild # TypeScript/JavaScript validation using esbuild
if ext in {'.ts', '.tsx', '.js', '.jsx'}: if ext in {".ts", ".tsx", ".js", ".jsx"}:
try: try:
# Write to temp file in system temp dir (NOT project dir to avoid HMR triggers) # Write to temp file in system temp dir (NOT project dir to avoid HMR triggers)
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode='w', mode="w",
suffix=ext, suffix=ext,
delete=False, delete=False,
# Don't set dir= to avoid writing to project directory which triggers HMR # Don't set dir= to avoid writing to project directory which triggers HMR
@@ -185,14 +224,16 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
# Try to find esbuild in node_modules (works with pnpm, npm, yarn) # Try to find esbuild in node_modules (works with pnpm, npm, yarn)
for search_dir in [project_dir, project_dir.parent]: for search_dir in [project_dir, project_dir.parent]:
# pnpm stores it differently # pnpm stores it differently
pnpm_esbuild = search_dir / 'node_modules' / '.pnpm' pnpm_esbuild = search_dir / "node_modules" / ".pnpm"
if pnpm_esbuild.exists(): if pnpm_esbuild.exists():
for esbuild_dir in pnpm_esbuild.glob('esbuild@*/node_modules/esbuild/bin/esbuild'): for esbuild_dir in pnpm_esbuild.glob(
"esbuild@*/node_modules/esbuild/bin/esbuild"
):
if esbuild_dir.exists(): if esbuild_dir.exists():
esbuild_cmd = str(esbuild_dir) esbuild_cmd = str(esbuild_dir)
break break
# Standard npm/yarn location # Standard npm/yarn location
npm_esbuild = search_dir / 'node_modules' / '.bin' / 'esbuild' npm_esbuild = search_dir / "node_modules" / ".bin" / "esbuild"
if npm_esbuild.exists(): if npm_esbuild.exists():
esbuild_cmd = str(npm_esbuild) esbuild_cmd = str(npm_esbuild)
break break
@@ -201,10 +242,10 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
# Fall back to npx if not found # Fall back to npx if not found
if not esbuild_cmd: if not esbuild_cmd:
esbuild_cmd = 'npx' esbuild_cmd = "npx"
args = ['npx', 'esbuild', tmp_path, '--log-level=error'] args = ["npx", "esbuild", tmp_path, "--log-level=error"]
else: else:
args = [esbuild_cmd, tmp_path, '--log-level=error'] args = [esbuild_cmd, tmp_path, "--log-level=error"]
# Use esbuild for fast, accurate syntax validation # Use esbuild for fast, accurate syntax validation
# esbuild infers loader from extension (.tsx, .ts, etc.) # esbuild infers loader from extension (.tsx, .ts, etc.)
@@ -221,12 +262,15 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
# Filter out npm warnings and extract actual errors # Filter out npm warnings and extract actual errors
error_output = result.stderr.strip() error_output = result.stderr.strip()
error_lines = [ error_lines = [
line for line in error_output.split('\n') line
if line and not line.startswith('npm warn') and not line.startswith('npm WARN') for line in error_output.split("\n")
if line
and not line.startswith("npm warn")
and not line.startswith("npm WARN")
] ]
if error_lines: if error_lines:
# Extract just the error message, not full path # Extract just the error message, not full path
error_msg = '\n'.join(error_lines[:3]) error_msg = "\n".join(error_lines[:3])
return False, f"Syntax error: {error_msg}" return False, f"Syntax error: {error_msg}"
return True, "" return True, ""
@@ -242,15 +286,15 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
return True, "" # Other errors = skip validation return True, "" # Other errors = skip validation
# Python validation # Python validation
elif ext == '.py': elif ext == ".py":
try: try:
compile(content, file_path, 'exec') compile(content, file_path, "exec")
return True, "" return True, ""
except SyntaxError as e: except SyntaxError as e:
return False, f"Python syntax error: {e.msg} at line {e.lineno}" return False, f"Python syntax error: {e.msg} at line {e.lineno}"
# JSON validation # JSON validation
elif ext == '.json': elif ext == ".json":
try: try:
json.loads(content) json.loads(content)
return True, "" return True, ""
@@ -278,21 +322,27 @@ def create_conflict_file_with_git(
try: try:
# Create temp files for three-way merge # Create temp files for three-way merge
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.tmp') as main_f: with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".tmp"
) as main_f:
main_f.write(main_content) main_f.write(main_content)
main_path = main_f.name main_path = main_f.name
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.tmp') as wt_f: with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".tmp") as wt_f:
wt_f.write(worktree_content) wt_f.write(worktree_content)
wt_path = wt_f.name wt_path = wt_f.name
# Use empty base if not available # Use empty base if not available
if base_content: if base_content:
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.tmp') as base_f: with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".tmp"
) as base_f:
base_f.write(base_content) base_f.write(base_content)
base_path = base_f.name base_path = base_f.name
else: else:
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.tmp') as base_f: with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".tmp"
) as base_f:
base_f.write("") base_f.write("")
base_path = base_f.name base_path = base_f.name
@@ -300,7 +350,7 @@ def create_conflict_file_with_git(
# git merge-file <current> <base> <other> # git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error # Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
result = subprocess.run( result = subprocess.run(
['git', 'merge-file', '-p', main_path, base_path, wt_path], ["git", "merge-file", "-p", main_path, base_path, wt_path],
cwd=project_dir, cwd=project_dir,
capture_output=True, capture_output=True,
text=True, text=True,
+4
View File
@@ -30,6 +30,7 @@ class WorkspaceChoice(Enum):
@dataclass @dataclass
class ParallelMergeTask: class ParallelMergeTask:
"""A file merge task to be executed in parallel.""" """A file merge task to be executed in parallel."""
file_path: str file_path: str
main_content: str main_content: str
worktree_content: str worktree_content: str
@@ -40,6 +41,7 @@ class ParallelMergeTask:
@dataclass @dataclass
class ParallelMergeResult: class ParallelMergeResult:
"""Result of a parallel merge task.""" """Result of a parallel merge task."""
file_path: str file_path: str
merged_content: str | None merged_content: str | None
success: bool success: bool
@@ -49,6 +51,7 @@ class ParallelMergeResult:
class MergeLockError(Exception): class MergeLockError(Exception):
"""Raised when a merge lock cannot be acquired.""" """Raised when a merge lock cannot be acquired."""
pass pass
@@ -100,6 +103,7 @@ class MergeLock:
pid = int(self.lock_file.read_text().strip()) pid = int(self.lock_file.read_text().strip())
# Import locally to avoid circular dependency # Import locally to avoid circular dependency
import os as _os import os as _os
try: try:
_os.kill(pid, 0) _os.kill(pid, 0)
is_running = True is_running = True
+14 -5
View File
@@ -32,8 +32,13 @@ from .models import WorkspaceMode
try: try:
from debug import debug, debug_warning from debug import debug, debug_warning
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_warning(*args, **kwargs): pass def debug(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
# Track if we've already tried to install the git hook this session # Track if we've already tried to install the git hook this session
_git_hook_check_done = False _git_hook_check_done = False
@@ -274,6 +279,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
# Auto-install the hook (silent, non-intrusive) # Auto-install the hook (silent, non-intrusive)
from merge.install_hook import install_hook from merge.install_hook import install_hook
install_hook(project_dir) install_hook(project_dir)
debug(MODULE, "Auto-installed FileTimelineTracker git hook") debug(MODULE, "Auto-installed FileTimelineTracker git hook")
@@ -333,9 +339,12 @@ def initialize_timeline_tracking(
task_intent=task_intent, task_intent=task_intent,
task_title=task_title, task_title=task_title,
) )
debug(MODULE, f"Timeline tracking initialized for {spec_name}", debug(
files_tracked=len(files_to_modify), MODULE,
branch_point=branch_point[:8] if branch_point else None) f"Timeline tracking initialized for {spec_name}",
files_tracked=len(files_to_modify),
branch_point=branch_point[:8] if branch_point else None,
)
else: else:
# Initialize retroactively from worktree if no plan # Initialize retroactively from worktree if no plan
tracker.initialize_from_worktree( tracker.initialize_from_worktree(
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from spec.critique instead.""" """Backward compatibility shim - import from spec.critique instead."""
from spec.critique import * # noqa: F403 from spec.critique import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from core.debug instead.""" """Backward compatibility shim - import from core.debug instead."""
from core.debug import * # noqa: F403 from core.debug import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.graphiti.config instead.""" """Backward compatibility shim - import from integrations.graphiti.config instead."""
from integrations.graphiti.config import * # noqa: F403 from integrations.graphiti.config import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.graphiti.providers_pkg instead.""" """Backward compatibility shim - import from integrations.graphiti.providers_pkg instead."""
from integrations.graphiti.providers_pkg import * # noqa: F403 from integrations.graphiti.providers_pkg import * # noqa: F403
+1 -3
View File
@@ -46,9 +46,7 @@ class OutputStreamer:
Returns: Returns:
IdeationPhaseResult for the completed phase IdeationPhaseResult for the completed phase
""" """
result = await phase_executor.execute_ideation_type( result = await phase_executor.execute_ideation_type(ideation_type, max_retries)
ideation_type, max_retries
)
if result.success: if result.success:
# Signal that this type is complete - UI can now show these ideas # Signal that this type is complete - UI can now show these ideas
+1
View File
@@ -1,3 +1,4 @@
"""Backward compatibility shim - import from implementation_plan package instead.""" """Backward compatibility shim - import from implementation_plan package instead."""
from implementation_plan import * # noqa: F403 from implementation_plan import * # noqa: F403
from implementation_plan.main import * # noqa: F403 from implementation_plan.main import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.linear.config instead.""" """Backward compatibility shim - import from integrations.linear.config instead."""
from integrations.linear.config import * # noqa: F403 from integrations.linear.config import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.linear.integration instead.""" """Backward compatibility shim - import from integrations.linear.integration instead."""
from integrations.linear.integration import * # noqa: F403 from integrations.linear.integration import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.linear.updater instead.""" """Backward compatibility shim - import from integrations.linear.updater instead."""
from integrations.linear.updater import * # noqa: F403 from integrations.linear.updater import * # noqa: F403
+3 -1
View File
@@ -22,7 +22,9 @@ from .paths import get_session_insights_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def save_session_insights(spec_dir: Path, session_num: int, insights: dict[str, Any]) -> None: def save_session_insights(
spec_dir: Path, session_num: int, insights: dict[str, Any]
) -> None:
""" """
Save insights from a completed session. Save insights from a completed session.
+9 -5
View File
@@ -29,7 +29,9 @@ class ConflictContext:
file_path: str file_path: str
location: str location: str
baseline_code: str # The code before any task modified it baseline_code: str # The code before any task modified it
task_changes: list[tuple[str, str, list[SemanticChange]]] # (task_id, intent, changes) task_changes: list[
tuple[str, str, list[SemanticChange]]
] # (task_id, intent, changes)
conflict_description: str conflict_description: str
language: str = "unknown" language: str = "unknown"
@@ -60,10 +62,12 @@ class ConflictContext:
content = content[:500] + "... (truncated)" content = content[:500] + "... (truncated)"
lines.append(f" Code: {content}") lines.append(f" Code: {content}")
lines.extend([ lines.extend(
"", [
f"CONFLICT: {self.conflict_description}", "",
]) f"CONFLICT: {self.conflict_description}",
]
)
return "\n".join(lines) return "\n".join(lines)
+3 -1
View File
@@ -71,7 +71,9 @@ def looks_like_code(text: str, language: str) -> bool:
return any(ind in text for ind in lang_indicators) return any(ind in text for ind in lang_indicators)
# Generic code indicators # Generic code indicators
return any(ind in text for ind in ["=", "(", ")", "{", "}", "import", "def", "function"]) return any(
ind in text for ind in ["=", "(", ")", "{", "}", "import", "def", "function"]
)
def extract_batch_code_blocks( def extract_batch_code_blocks(
+4 -4
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
SYSTEM_PROMPT = "You are an expert code merge assistant. Be concise and precise." SYSTEM_PROMPT = "You are an expert code merge assistant. Be concise and precise."
# Main merge prompt template # Main merge prompt template
MERGE_PROMPT_TEMPLATE = '''You are a code merge assistant. Your task is to merge changes from multiple development tasks into a single coherent result. MERGE_PROMPT_TEMPLATE = """You are a code merge assistant. Your task is to merge changes from multiple development tasks into a single coherent result.
CONTEXT: CONTEXT:
{context} {context}
@@ -38,10 +38,10 @@ Return only the merged code block, wrapped in triple backticks with the language
merged code here merged code here
``` ```
Merge the code now:''' Merge the code now:"""
# Batch merge prompt template for multiple conflicts in the same file # Batch merge prompt template for multiple conflicts in the same file
BATCH_MERGE_PROMPT_TEMPLATE = '''You are a code merge assistant. Your task is to merge changes from multiple development tasks. BATCH_MERGE_PROMPT_TEMPLATE = """You are a code merge assistant. Your task is to merge changes from multiple development tasks.
There are {num_conflicts} conflict regions in {file_path}. Resolve each one. There are {num_conflicts} conflict regions in {file_path}. Resolve each one.
@@ -54,7 +54,7 @@ For each conflict region, output the merged code in a separate code block labele
merged code merged code
``` ```
Resolve all conflicts now:''' Resolve all conflicts now:"""
def format_merge_prompt(context: str, language: str) -> str: def format_merge_prompt(context: str, language: str) -> str:
+28 -16
View File
@@ -114,16 +114,20 @@ class AIResolver:
continue continue
relevant_changes = [ relevant_changes = [
c for c in snapshot.semantic_changes c
if c.location == conflict.location or locations_overlap(c.location, conflict.location) for c in snapshot.semantic_changes
if c.location == conflict.location
or locations_overlap(c.location, conflict.location)
] ]
if relevant_changes: if relevant_changes:
task_changes.append(( task_changes.append(
snapshot.task_id, (
snapshot.task_intent or "No intent specified", snapshot.task_id,
relevant_changes, snapshot.task_intent or "No intent specified",
)) relevant_changes,
)
)
# Determine language from file extension # Determine language from file extension
language = infer_language(conflict.file_path) language = infer_language(conflict.file_path)
@@ -264,9 +268,11 @@ class AIResolver:
if len(file_conflicts) == 1: if len(file_conflicts) == 1:
# Single conflict, resolve individually # Single conflict, resolve individually
baseline = baseline_codes.get(file_conflicts[0].location, "") baseline = baseline_codes.get(file_conflicts[0].location, "")
results.append(self.resolve_conflict( results.append(
file_conflicts[0], baseline, task_snapshots self.resolve_conflict(
)) file_conflicts[0], baseline, task_snapshots
)
)
else: else:
# Multiple conflicts in same file - batch resolve # Multiple conflicts in same file - batch resolve
result = self._resolve_file_batch( result = self._resolve_file_batch(
@@ -277,9 +283,9 @@ class AIResolver:
# Resolve each individually # Resolve each individually
for conflict in conflicts: for conflict in conflicts:
baseline = baseline_codes.get(conflict.location, "") baseline = baseline_codes.get(conflict.location, "")
results.append(self.resolve_conflict( results.append(
conflict, baseline, task_snapshots self.resolve_conflict(conflict, baseline, task_snapshots)
)) )
return results return results
@@ -317,7 +323,9 @@ class AIResolver:
results = [] results = []
for conflict in conflicts: for conflict in conflicts:
baseline = baseline_codes.get(conflict.location, "") baseline = baseline_codes.get(conflict.location, "")
results.append(self.resolve_conflict(conflict, baseline, task_snapshots)) results.append(
self.resolve_conflict(conflict, baseline, task_snapshots)
)
# Combine results # Combine results
merged = results[0] merged = results[0]
@@ -354,7 +362,9 @@ class AIResolver:
for conflict in conflicts: for conflict in conflicts:
# Try to find the resolution for this location # Try to find the resolution for this location
code_block = extract_batch_code_blocks(response, conflict.location, language) code_block = extract_batch_code_blocks(
response, conflict.location, language
)
if code_block: if code_block:
resolved.append(conflict) resolved.append(conflict)
@@ -364,7 +374,9 @@ class AIResolver:
# Return combined result # Return combined result
if resolved: if resolved:
return MergeResult( return MergeResult(
decision=MergeDecision.AI_MERGED if not remaining else MergeDecision.NEEDS_HUMAN_REVIEW, decision=MergeDecision.AI_MERGED
if not remaining
else MergeDecision.NEEDS_HUMAN_REVIEW,
file_path=file_path, file_path=file_path,
merged_content=response, # Full response for manual extraction merged_content=response, # Full response for manual extraction
conflicts_resolved=resolved, conflicts_resolved=resolved,
+9 -6
View File
@@ -24,7 +24,11 @@ class MergeHelpers:
stripped = line.strip() stripped = line.strip()
if MergeHelpers.is_import_line(stripped, ext): if MergeHelpers.is_import_line(stripped, ext):
last_import_line = i + 1 last_import_line = i + 1
elif stripped and not stripped.startswith("#") and not stripped.startswith("//"): elif (
stripped
and not stripped.startswith("#")
and not stripped.startswith("//")
):
# Non-empty, non-comment line after imports # Non-empty, non-comment line after imports
if last_import_line > 0: if last_import_line > 0:
break break
@@ -45,7 +49,9 @@ class MergeHelpers:
"""Extract the hook call from a change.""" """Extract the hook call from a change."""
if change.content_after: if change.content_after:
# Look for useXxx() pattern # Look for useXxx() pattern
match = re.search(r"(const\s+\{[^}]+\}\s*=\s*)?use\w+\([^)]*\);?", change.content_after) match = re.search(
r"(const\s+\{[^}]+\}\s*=\s*)?use\w+\([^)]*\);?", change.content_after
)
if match: if match:
return match.group(0) return match.group(0)
@@ -212,7 +218,4 @@ class MergeHelpers:
ChangeType.MODIFY_JSX_PROPS: 5, ChangeType.MODIFY_JSX_PROPS: 5,
} }
return sorted( return sorted(all_changes, key=lambda c: priority.get(c.change_type, 10))
all_changes,
key=lambda c: priority.get(c.change_type, 10)
)
@@ -27,7 +27,10 @@ class AppendFunctionsStrategy(MergeStrategyHandler):
for snapshot in context.task_snapshots: for snapshot in context.task_snapshots:
for change in snapshot.semantic_changes: for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_FUNCTION and change.content_after: if (
change.change_type == ChangeType.ADD_FUNCTION
and change.content_after
):
new_functions.append(change.content_after) new_functions.append(change.content_after)
# Append at the end (before any module.exports in JS) # Append at the end (before any module.exports in JS)
@@ -69,7 +72,9 @@ class AppendMethodsStrategy(MergeStrategyHandler):
for change in snapshot.semantic_changes: for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_METHOD and change.content_after: if change.change_type == ChangeType.ADD_METHOD and change.content_after:
# Extract class name from location # Extract class name from location
class_name = change.target.split(".")[0] if "." in change.target else None class_name = (
change.target.split(".")[0] if "." in change.target else None
)
if class_name: if class_name:
if class_name not in new_methods: if class_name not in new_methods:
new_methods[class_name] = [] new_methods[class_name] = []
@@ -77,7 +82,9 @@ class AppendMethodsStrategy(MergeStrategyHandler):
# Insert methods into their classes # Insert methods into their classes
for class_name, methods in new_methods.items(): for class_name, methods in new_methods.items():
content = MergeHelpers.insert_methods_into_class(content, class_name, methods) content = MergeHelpers.insert_methods_into_class(
content, class_name, methods
)
total_methods = sum(len(m) for m in new_methods.values()) total_methods = sum(len(m) for m in new_methods.values())
return MergeResult( return MergeResult(
@@ -83,7 +83,9 @@ class HooksThenWrapStrategy(MergeStrategyHandler):
# First add hooks # First add hooks
if hooks: if hooks:
content = MergeHelpers.insert_hooks_into_function(content, func_name, hooks) content = MergeHelpers.insert_hooks_into_function(
content, func_name, hooks
)
# Then apply wraps # Then apply wraps
for wrapper_name, wrapper_props in wraps: for wrapper_name, wrapper_props in wraps:
@@ -31,7 +31,10 @@ class ImportStrategy(MergeStrategyHandler):
for change in snapshot.semantic_changes: for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_IMPORT and change.content_after: if change.change_type == ChangeType.ADD_IMPORT and change.content_after:
imports_to_add.append(change.content_after.strip()) imports_to_add.append(change.content_after.strip())
elif change.change_type == ChangeType.REMOVE_IMPORT and change.content_before: elif (
change.change_type == ChangeType.REMOVE_IMPORT
and change.content_before
):
imports_to_remove.add(change.content_before.strip()) imports_to_remove.add(change.content_before.strip())
# Find where imports end in the file # Find where imports end in the file
@@ -48,7 +51,11 @@ class ImportStrategy(MergeStrategyHandler):
seen_imports = set() seen_imports = set()
new_imports = [] new_imports = []
for imp in imports_to_add: for imp in imports_to_add:
if imp not in existing_imports and imp not in imports_to_remove and imp not in seen_imports: if (
imp not in existing_imports
and imp not in imports_to_remove
and imp not in seen_imports
):
new_imports.append(imp) new_imports.append(imp)
seen_imports.add(imp) seen_imports.add(imp)
@@ -27,15 +27,27 @@ class OrderByDependencyStrategy(MergeStrategyHandler):
for change in ordered_changes: for change in ordered_changes:
if change.content_after: if change.content_after:
if change.change_type == ChangeType.ADD_HOOK_CALL: if change.change_type == ChangeType.ADD_HOOK_CALL:
func_name = change.target.split(".")[-1] if "." in change.target else change.target func_name = (
change.target.split(".")[-1]
if "." in change.target
else change.target
)
hook_call = MergeHelpers.extract_hook_call(change) hook_call = MergeHelpers.extract_hook_call(change)
if hook_call: if hook_call:
content = MergeHelpers.insert_hooks_into_function(content, func_name, [hook_call]) content = MergeHelpers.insert_hooks_into_function(
content, func_name, [hook_call]
)
elif change.change_type == ChangeType.WRAP_JSX: elif change.change_type == ChangeType.WRAP_JSX:
wrapper = MergeHelpers.extract_jsx_wrapper(change) wrapper = MergeHelpers.extract_jsx_wrapper(change)
if wrapper: if wrapper:
func_name = change.target.split(".")[-1] if "." in change.target else change.target func_name = (
content = MergeHelpers.wrap_function_return(content, func_name, wrapper[0], wrapper[1]) change.target.split(".")[-1]
if "." in change.target
else change.target
)
content = MergeHelpers.wrap_function_return(
content, func_name, wrapper[0], wrapper[1]
)
return MergeResult( return MergeResult(
decision=MergeDecision.AUTO_MERGED, decision=MergeDecision.AUTO_MERGED,
+38 -14
View File
@@ -132,7 +132,10 @@ class AutoMerger:
for change in snapshot.semantic_changes: for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_IMPORT and change.content_after: if change.change_type == ChangeType.ADD_IMPORT and change.content_after:
imports_to_add.append(change.content_after.strip()) imports_to_add.append(change.content_after.strip())
elif change.change_type == ChangeType.REMOVE_IMPORT and change.content_before: elif (
change.change_type == ChangeType.REMOVE_IMPORT
and change.content_before
):
imports_to_remove.add(change.content_before.strip()) imports_to_remove.add(change.content_before.strip())
# Find where imports end in the file # Find where imports end in the file
@@ -146,7 +149,8 @@ class AutoMerger:
existing_imports.add(stripped) existing_imports.add(stripped)
new_imports = [ new_imports = [
imp for imp in imports_to_add imp
for imp in imports_to_add
if imp not in existing_imports and imp not in imports_to_remove if imp not in existing_imports and imp not in imports_to_remove
] ]
@@ -261,7 +265,10 @@ class AutoMerger:
for snapshot in context.task_snapshots: for snapshot in context.task_snapshots:
for change in snapshot.semantic_changes: for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_FUNCTION and change.content_after: if (
change.change_type == ChangeType.ADD_FUNCTION
and change.content_after
):
new_functions.append(change.content_after) new_functions.append(change.content_after)
# Append at the end (before any module.exports in JS) # Append at the end (before any module.exports in JS)
@@ -299,7 +306,9 @@ class AutoMerger:
for change in snapshot.semantic_changes: for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_METHOD and change.content_after: if change.change_type == ChangeType.ADD_METHOD and change.content_after:
# Extract class name from location # Extract class name from location
class_name = change.target.split(".")[0] if "." in change.target else None class_name = (
change.target.split(".")[0] if "." in change.target else None
)
if class_name: if class_name:
if class_name not in new_methods: if class_name not in new_methods:
new_methods[class_name] = [] new_methods[class_name] = []
@@ -362,15 +371,27 @@ class AutoMerger:
for change in ordered_changes: for change in ordered_changes:
if change.content_after: if change.content_after:
if change.change_type == ChangeType.ADD_HOOK_CALL: if change.change_type == ChangeType.ADD_HOOK_CALL:
func_name = change.target.split(".")[-1] if "." in change.target else change.target func_name = (
change.target.split(".")[-1]
if "." in change.target
else change.target
)
hook_call = self._extract_hook_call(change) hook_call = self._extract_hook_call(change)
if hook_call: if hook_call:
content = self._insert_hooks_into_function(content, func_name, [hook_call]) content = self._insert_hooks_into_function(
content, func_name, [hook_call]
)
elif change.change_type == ChangeType.WRAP_JSX: elif change.change_type == ChangeType.WRAP_JSX:
wrapper = self._extract_jsx_wrapper(change) wrapper = self._extract_jsx_wrapper(change)
if wrapper: if wrapper:
func_name = change.target.split(".")[-1] if "." in change.target else change.target func_name = (
content = self._wrap_function_return(content, func_name, wrapper[0], wrapper[1]) change.target.split(".")[-1]
if "." in change.target
else change.target
)
content = self._wrap_function_return(
content, func_name, wrapper[0], wrapper[1]
)
return MergeResult( return MergeResult(
decision=MergeDecision.AUTO_MERGED, decision=MergeDecision.AUTO_MERGED,
@@ -441,7 +462,11 @@ class AutoMerger:
stripped = line.strip() stripped = line.strip()
if self._is_import_line(stripped, ext): if self._is_import_line(stripped, ext):
last_import_line = i + 1 last_import_line = i + 1
elif stripped and not stripped.startswith("#") and not stripped.startswith("//"): elif (
stripped
and not stripped.startswith("#")
and not stripped.startswith("//")
):
# Non-empty, non-comment line after imports # Non-empty, non-comment line after imports
if last_import_line > 0: if last_import_line > 0:
break break
@@ -460,7 +485,9 @@ class AutoMerger:
"""Extract the hook call from a change.""" """Extract the hook call from a change."""
if change.content_after: if change.content_after:
# Look for useXxx() pattern # Look for useXxx() pattern
match = re.search(r"(const\s+\{[^}]+\}\s*=\s*)?use\w+\([^)]*\);?", change.content_after) match = re.search(
r"(const\s+\{[^}]+\}\s*=\s*)?use\w+\([^)]*\);?", change.content_after
)
if match: if match:
return match.group(0) return match.group(0)
@@ -624,7 +651,4 @@ class AutoMerger:
ChangeType.MODIFY_JSX_PROPS: 5, ChangeType.MODIFY_JSX_PROPS: 5,
} }
return sorted( return sorted(all_changes, key=lambda c: priority.get(c.change_type, 10))
all_changes,
key=lambda c: priority.get(c.change_type, 10)
)
+3 -1
View File
@@ -322,7 +322,9 @@ def build_default_rules() -> list[CompatibilityRule]:
return rules return rules
def index_rules(rules: list[CompatibilityRule]) -> dict[tuple[ChangeType, ChangeType], CompatibilityRule]: def index_rules(
rules: list[CompatibilityRule],
) -> dict[tuple[ChangeType, ChangeType], CompatibilityRule]:
""" """
Create an index for fast rule lookup. Create an index for fast rule lookup.
+33 -14
View File
@@ -30,9 +30,16 @@ from .types import (
try: try:
from debug import debug, debug_detailed, debug_verbose from debug import debug, debug_detailed, debug_verbose
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_detailed(*args, **kwargs): pass def debug(*args, **kwargs):
def debug_verbose(*args, **kwargs): pass pass
def debug_detailed(*args, **kwargs):
pass
def debug_verbose(*args, **kwargs):
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE = "merge.conflict_analysis" MODULE = "merge.conflict_analysis"
@@ -53,8 +60,11 @@ def detect_conflicts(
List of detected conflict regions List of detected conflict regions
""" """
task_ids = list(task_analyses.keys()) task_ids = list(task_analyses.keys())
debug(MODULE, f"Detecting conflicts between {len(task_analyses)} tasks", debug(
tasks=task_ids) MODULE,
f"Detecting conflicts between {len(task_analyses)} tasks",
tasks=task_ids,
)
if len(task_analyses) <= 1: if len(task_analyses) <= 1:
debug(MODULE, "No conflicts possible with 0-1 tasks") debug(MODULE, "No conflicts possible with 0-1 tasks")
@@ -66,9 +76,12 @@ def detect_conflicts(
location_changes: dict[str, list[tuple[str, SemanticChange]]] = defaultdict(list) location_changes: dict[str, list[tuple[str, SemanticChange]]] = defaultdict(list)
for task_id, analysis in task_analyses.items(): for task_id, analysis in task_analyses.items():
debug_detailed(MODULE, f"Processing task {task_id}", debug_detailed(
changes_count=len(analysis.changes), MODULE,
file=analysis.file_path) f"Processing task {task_id}",
changes_count=len(analysis.changes),
file=analysis.file_path,
)
for change in analysis.changes: for change in analysis.changes:
location_changes[change.location].append((task_id, change)) location_changes[change.location].append((task_id, change))
@@ -79,18 +92,24 @@ def detect_conflicts(
if len(task_changes) <= 1: if len(task_changes) <= 1:
continue # No conflict at this location continue # No conflict at this location
debug_verbose(MODULE, f"Checking location {location}", debug_verbose(
task_changes_count=len(task_changes)) MODULE,
f"Checking location {location}",
task_changes_count=len(task_changes),
)
file_path = next(iter(task_analyses.values())).file_path file_path = next(iter(task_analyses.values())).file_path
conflict = analyze_location_conflict( conflict = analyze_location_conflict(
file_path, location, task_changes, rule_index file_path, location, task_changes, rule_index
) )
if conflict: if conflict:
debug_detailed(MODULE, f"Conflict detected at {location}", debug_detailed(
severity=conflict.severity.value, MODULE,
can_auto_merge=conflict.can_auto_merge, f"Conflict detected at {location}",
tasks=conflict.tasks_involved) severity=conflict.severity.value,
can_auto_merge=conflict.can_auto_merge,
tasks=conflict.tasks_involved,
)
conflicts.append(conflict) conflicts.append(conflict)
# Also check for implicit conflicts (e.g., changes to related code) # Also check for implicit conflicts (e.g., changes to related code)
+22 -8
View File
@@ -48,8 +48,13 @@ from .types import (
try: try:
from debug import debug, debug_success from debug import debug, debug_success
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_success(*args, **kwargs): pass def debug(*args, **kwargs):
pass
def debug_success(*args, **kwargs):
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE = "merge.conflict_detector" MODULE = "merge.conflict_detector"
@@ -81,7 +86,9 @@ class ConflictDetector:
debug(MODULE, "Initializing ConflictDetector") debug(MODULE, "Initializing ConflictDetector")
self._rules = build_default_rules() self._rules = build_default_rules()
self._rule_index = index_rules(self._rules) self._rule_index = index_rules(self._rules)
debug_success(MODULE, "ConflictDetector initialized", rule_count=len(self._rules)) debug_success(
MODULE, "ConflictDetector initialized", rule_count=len(self._rules)
)
def add_rule(self, rule: CompatibilityRule) -> None: def add_rule(self, rule: CompatibilityRule) -> None:
""" """
@@ -113,15 +120,21 @@ class ConflictDetector:
# Summary logging # Summary logging
auto_mergeable = sum(1 for c in conflicts if c.can_auto_merge) auto_mergeable = sum(1 for c in conflicts if c.can_auto_merge)
from .types import ConflictSeverity from .types import ConflictSeverity
critical = sum(1 for c in conflicts if c.severity == ConflictSeverity.CRITICAL) critical = sum(1 for c in conflicts if c.severity == ConflictSeverity.CRITICAL)
debug_success(MODULE, "Conflict detection complete", debug_success(
total_conflicts=len(conflicts), MODULE,
auto_mergeable=auto_mergeable, "Conflict detection complete",
critical=critical) total_conflicts=len(conflicts),
auto_mergeable=auto_mergeable,
critical=critical,
)
return conflicts return conflicts
def get_compatible_pairs(self) -> list[tuple[ChangeType, ChangeType, MergeStrategy]]: def get_compatible_pairs(
self,
) -> list[tuple[ChangeType, ChangeType, MergeStrategy]]:
""" """
Get all compatible change type pairs and their strategies. Get all compatible change type pairs and their strategies.
@@ -166,4 +179,5 @@ def analyze_compatibility(
detector = ConflictDetector() detector = ConflictDetector()
from .conflict_analysis import analyze_compatibility as analyze_compat_internal from .conflict_analysis import analyze_compatibility as analyze_compat_internal
return analyze_compat_internal(change_a, change_b, detector._rule_index) return analyze_compat_internal(change_a, change_b, detector._rule_index)
+3 -1
View File
@@ -34,7 +34,9 @@ def explain_conflict(conflict: ConflictRegion) -> str:
] ]
if conflict.can_auto_merge: if conflict.can_auto_merge:
lines.append(f"Can be auto-merged using strategy: {conflict.merge_strategy.value}") lines.append(
f"Can be auto-merged using strategy: {conflict.merge_strategy.value}"
)
else: else:
lines.append("Cannot be auto-merged:") lines.append("Cannot be auto-merged:")
lines.append(f" Reason: {conflict.reason}") lines.append(f" Reason: {conflict.reason}")
@@ -22,8 +22,13 @@ from .storage import EvolutionStorage
try: try:
from debug import debug, debug_success from debug import debug, debug_success
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_success(*args, **kwargs): pass def debug(*args, **kwargs):
pass
def debug_success(*args, **kwargs):
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE = "merge.file_evolution.baseline_capture" MODULE = "merge.file_evolution.baseline_capture"
@@ -31,10 +36,25 @@ MODULE = "merge.file_evolution.baseline_capture"
# Default extensions to track for baselines # Default extensions to track for baselines
DEFAULT_EXTENSIONS = { DEFAULT_EXTENSIONS = {
".py", ".js", ".ts", ".tsx", ".jsx", ".py",
".json", ".yaml", ".yml", ".toml", ".js",
".md", ".txt", ".html", ".css", ".scss", ".ts",
".go", ".rs", ".java", ".kt", ".swift", ".tsx",
".jsx",
".json",
".yaml",
".yml",
".toml",
".md",
".txt",
".html",
".css",
".scss",
".go",
".rs",
".java",
".kt",
".swift",
} }
@@ -142,8 +162,7 @@ class BaselineCapture:
if files is None: if files is None:
files = self.discover_trackable_files() files = self.discover_trackable_files()
debug(MODULE, f"Capturing baselines for {len(files)} files", debug(MODULE, f"Capturing baselines for {len(files)} files", task_id=task_id)
task_id=task_id)
for file_path in files: for file_path in files:
rel_path = self.storage.get_relative_path(file_path) rel_path = self.storage.get_relative_path(file_path)
@@ -183,6 +202,7 @@ class BaselineCapture:
evolution.add_task_snapshot(snapshot) evolution.add_task_snapshot(snapshot)
captured[rel_path] = evolution captured[rel_path] = evolution
debug_success(MODULE, f"Captured baselines for {len(captured)} files", debug_success(
task_id=task_id) MODULE, f"Captured baselines for {len(captured)} files", task_id=task_id
)
return captured return captured
@@ -148,10 +148,7 @@ class EvolutionQueries:
List of file paths modified by 2+ tasks List of file paths modified by 2+ tasks
""" """
file_tasks = self.get_files_modified_by_tasks(task_ids, evolutions) file_tasks = self.get_files_modified_by_tasks(task_ids, evolutions)
return [ return [file_path for file_path, tasks in file_tasks.items() if len(tasks) > 1]
file_path for file_path, tasks in file_tasks.items()
if len(tasks) > 1
]
def get_active_tasks( def get_active_tasks(
self, self,
@@ -238,10 +235,7 @@ class EvolutionQueries:
# Filter snapshots if task_ids specified # Filter snapshots if task_ids specified
snapshots = evolution.task_snapshots snapshots = evolution.task_snapshots
if task_ids: if task_ids:
snapshots = [ snapshots = [ts for ts in snapshots if ts.task_id in task_ids]
ts for ts in snapshots
if ts.task_id in task_ids
]
return { return {
"file_path": rel_path, "file_path": rel_path,
@@ -253,7 +247,9 @@ class EvolutionQueries:
"task_id": ts.task_id, "task_id": ts.task_id,
"intent": ts.task_intent, "intent": ts.task_intent,
"started_at": ts.started_at.isoformat(), "started_at": ts.started_at.isoformat(),
"completed_at": ts.completed_at.isoformat() if ts.completed_at else None, "completed_at": ts.completed_at.isoformat()
if ts.completed_at
else None,
"changes": [c.to_dict() for c in ts.semantic_changes], "changes": [c.to_dict() for c in ts.semantic_changes],
"hash_before": ts.content_hash_before, "hash_before": ts.content_hash_before,
"hash_after": ts.content_hash_after, "hash_after": ts.content_hash_after,
@@ -282,8 +278,7 @@ class EvolutionQueries:
# Remove task snapshots from evolutions # Remove task snapshots from evolutions
for evolution in evolutions.values(): for evolution in evolutions.values():
evolution.task_snapshots = [ evolution.task_snapshots = [
ts for ts in evolution.task_snapshots ts for ts in evolution.task_snapshots if ts.task_id != task_id
if ts.task_id != task_id
] ]
# Remove baseline directory if requested # Remove baseline directory if requested
@@ -23,8 +23,13 @@ from .storage import EvolutionStorage
try: try:
from debug import debug, debug_warning from debug import debug, debug_warning
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_warning(*args, **kwargs): pass def debug(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE = "merge.file_evolution.modification_tracker" MODULE = "merge.file_evolution.modification_tracker"
@@ -101,9 +106,7 @@ class ModificationTracker:
) )
# Analyze semantic changes # Analyze semantic changes
analysis = self.analyzer.analyze_diff( analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
rel_path, old_content, new_content
)
semantic_changes = analysis.changes semantic_changes = analysis.changes
# Update snapshot # Update snapshot
@@ -138,9 +141,12 @@ class ModificationTracker:
worktree_path: Path to the task's worktree worktree_path: Path to the task's worktree
evolutions: Current evolution data (will be updated) evolutions: Current evolution data (will be updated)
""" """
debug(MODULE, f"refresh_from_git() for task {task_id}", debug(
task_id=task_id, MODULE,
worktree_path=str(worktree_path)) f"refresh_from_git() for task {task_id}",
task_id=task_id,
worktree_path=str(worktree_path),
)
try: try:
# Get list of files changed in the worktree # Get list of files changed in the worktree
@@ -153,8 +159,13 @@ class ModificationTracker:
) )
changed_files = [f for f in result.stdout.strip().split("\n") if f] changed_files = [f for f in result.stdout.strip().split("\n") if f]
debug(MODULE, f"Found {len(changed_files)} changed files", debug(
changed_files=changed_files[:10] if len(changed_files) > 10 else changed_files) MODULE,
f"Found {len(changed_files)} changed files",
changed_files=changed_files[:10]
if len(changed_files) > 10
else changed_files,
)
for file_path in changed_files: for file_path in changed_files:
# Get the diff for this file # Get the diff for this file
@@ -197,7 +208,9 @@ class ModificationTracker:
raw_diff=diff_result.stdout, raw_diff=diff_result.stdout,
) )
logger.info(f"Refreshed {len(changed_files)} files from worktree for task {task_id}") logger.info(
f"Refreshed {len(changed_files)} files from worktree for task {task_id}"
)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Failed to refresh from git: {e}") logger.error(f"Failed to refresh from git: {e}")
+14 -8
View File
@@ -25,8 +25,13 @@ from .storage import EvolutionStorage
try: try:
from debug import debug, debug_success from debug import debug, debug_success
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_success(*args, **kwargs): pass def debug(*args, **kwargs):
pass
def debug_success(*args, **kwargs):
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE = "merge.file_evolution" MODULE = "merge.file_evolution"
@@ -72,8 +77,7 @@ class FileEvolutionTracker:
storage_dir: Directory for evolution data (default: .auto-claude/) storage_dir: Directory for evolution data (default: .auto-claude/)
semantic_analyzer: Optional pre-configured analyzer semantic_analyzer: Optional pre-configured analyzer
""" """
debug(MODULE, "Initializing FileEvolutionTracker", debug(MODULE, "Initializing FileEvolutionTracker", project_dir=str(project_dir))
project_dir=str(project_dir))
self.project_dir = Path(project_dir).resolve() self.project_dir = Path(project_dir).resolve()
storage_dir = storage_dir or (self.project_dir / ".auto-claude") storage_dir = storage_dir or (self.project_dir / ".auto-claude")
@@ -81,8 +85,7 @@ class FileEvolutionTracker:
# Initialize modular components # Initialize modular components
self.storage = EvolutionStorage(self.project_dir, storage_dir) self.storage = EvolutionStorage(self.project_dir, storage_dir)
self.baseline_capture = BaselineCapture( self.baseline_capture = BaselineCapture(
self.storage, self.storage, extensions=self.DEFAULT_EXTENSIONS
extensions=self.DEFAULT_EXTENSIONS
) )
self.modification_tracker = ModificationTracker( self.modification_tracker = ModificationTracker(
self.storage, self.storage,
@@ -93,8 +96,11 @@ class FileEvolutionTracker:
# Load existing evolution data # Load existing evolution data
self._evolutions: dict[str, FileEvolution] = self.storage.load_evolutions() self._evolutions: dict[str, FileEvolution] = self.storage.load_evolutions()
debug_success(MODULE, "FileEvolutionTracker initialized", debug_success(
evolutions_loaded=len(self._evolutions)) MODULE,
"FileEvolutionTracker initialized",
evolutions_loaded=len(self._evolutions),
)
# Expose storage_dir and baselines_dir for backward compatibility # Expose storage_dir and baselines_dir for backward compatibility
@property @property
-2
View File
@@ -67,7 +67,6 @@ from .timeline_tracker import FileTimelineTracker
__all__ = [ __all__ = [
# Main service # Main service
"FileTimelineTracker", "FileTimelineTracker",
# Core data models # Core data models
"MainBranchEvent", "MainBranchEvent",
"BranchPoint", "BranchPoint",
@@ -76,7 +75,6 @@ __all__ = [
"TaskFileView", "TaskFileView",
"FileTimeline", "FileTimeline",
"MergeContext", "MergeContext",
# Helper components (advanced usage) # Helper components (advanced usage)
"TimelineGitHelper", "TimelineGitHelper",
"TimelinePersistence", "TimelinePersistence",
+1 -3
View File
@@ -53,9 +53,7 @@ def find_worktree(project_dir: Path, task_id: str) -> Path | None:
return None return None
def get_file_from_branch( def get_file_from_branch(project_dir: Path, file_path: str, branch: str) -> str | None:
project_dir: Path, file_path: str, branch: str
) -> str | None:
""" """
Get file content from a specific git branch. Get file content from a specific git branch.
+5 -3
View File
@@ -14,7 +14,7 @@ import stat
import sys import sys
from pathlib import Path from pathlib import Path
HOOK_SCRIPT = '''#!/bin/bash HOOK_SCRIPT = """#!/bin/bash
# #
# Git post-commit hook for FileTimelineTracker # Git post-commit hook for FileTimelineTracker
# ============================================= # =============================================
@@ -53,7 +53,7 @@ fi
# Not main branch or in worktree, do nothing # Not main branch or in worktree, do nothing
exit 0 exit 0
''' """
def find_project_root() -> Path: def find_project_root() -> Path:
@@ -114,7 +114,9 @@ def install_hook(project_path: Path) -> bool:
print(f"Created new hook at {hook_path}") print(f"Created new hook at {hook_path}")
# Make executable # Make executable
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) hook_path.chmod(
hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
print("Hook is now executable") print("Hook is now executable")
return True return True
+4 -3
View File
@@ -94,11 +94,12 @@ class MergeReport:
"""Convert to dictionary for serialization.""" """Convert to dictionary for serialization."""
return { return {
"started_at": self.started_at.isoformat(), "started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat() if self.completed_at else None, "completed_at": self.completed_at.isoformat()
if self.completed_at
else None,
"tasks_merged": self.tasks_merged, "tasks_merged": self.tasks_merged,
"file_results": { "file_results": {
path: result.to_dict() path: result.to_dict() for path, result in self.file_results.items()
for path, result in self.file_results.items()
}, },
"stats": self.stats.to_dict(), "stats": self.stats.to_dict(),
"success": self.success, "success": self.success,
+76 -63
View File
@@ -103,16 +103,20 @@ def _build_main_evolution_section(context: MergeContext) -> str:
No changes have been made to main branch since this task started. No changes have been made to main branch since this task started.
""" """
lines = [f"MAIN BRANCH EVOLUTION ({len(context.main_evolution)} commits since task branched)"] lines = [
f"MAIN BRANCH EVOLUTION ({len(context.main_evolution)} commits since task branched)"
]
lines.append("" * 79) lines.append("" * 79)
lines.append("") lines.append("")
for event in context.main_evolution: for event in context.main_evolution:
source_label = event.source.upper() source_label = event.source.upper()
if event.source == 'merged_task' and event.merged_from_task: if event.source == "merged_task" and event.merged_from_task:
source_label = f"MERGED FROM {event.merged_from_task}" source_label = f"MERGED FROM {event.merged_from_task}"
lines.append(f'COMMIT {event.commit_hash[:12]} [{source_label}]: "{event.commit_message}"') lines.append(
f'COMMIT {event.commit_hash[:12]} [{source_label}]: "{event.commit_message}"'
)
lines.append(f"Timestamp: {event.timestamp}") lines.append(f"Timestamp: {event.timestamp}")
if event.diff_summary: if event.diff_summary:
@@ -144,7 +148,9 @@ No other tasks are pending for this file.
branch_point = task.get("branch_point", "unknown")[:12] branch_point = task.get("branch_point", "unknown")[:12]
commits_behind = task.get("commits_behind", 0) commits_behind = task.get("commits_behind", 0)
lines.append(f"{task_id} (branched at {branch_point}, {commits_behind} commits behind)") lines.append(
f"{task_id} (branched at {branch_point}, {commits_behind} commits behind)"
)
lines.append(f' Intent: "{intent}"') lines.append(f' Intent: "{intent}"')
lines.append("") lines.append("")
@@ -156,7 +162,9 @@ def _build_compatibility_instructions(context: MergeContext) -> str:
if not context.other_pending_tasks: if not context.other_pending_tasks:
return "- No other tasks pending for this file" return "- No other tasks pending for this file"
lines = [f"- {len(context.other_pending_tasks)} other task(s) will merge after this"] lines = [
f"- {len(context.other_pending_tasks)} other task(s) will merge after this"
]
lines.append(" - Structure your merge to accommodate their upcoming changes:") lines.append(" - Structure your merge to accommodate their upcoming changes:")
for task in context.other_pending_tasks: for task in context.other_pending_tasks:
@@ -188,13 +196,15 @@ def build_simple_merge_prompt(
if task_intent: if task_intent:
intent_section = f""" intent_section = f"""
=== FEATURE BRANCH INTENT ({spec_name}) === === FEATURE BRANCH INTENT ({spec_name}) ===
Task: {task_intent.get('title', spec_name)} Task: {task_intent.get("title", spec_name)}
Description: {task_intent.get('description', 'No description')} Description: {task_intent.get("description", "No description")}
""" """
if task_intent.get('spec_summary'): if task_intent.get("spec_summary"):
intent_section += f"Summary: {task_intent['spec_summary']}\n" intent_section += f"Summary: {task_intent['spec_summary']}\n"
base_section = base_content if base_content else "(File did not exist in common ancestor)" base_section = (
base_content if base_content else "(File did not exist in common ancestor)"
)
prompt = f'''You are a code merge expert. Merge the following conflicting versions of a file. prompt = f'''You are a code merge expert. Merge the following conflicting versions of a file.
@@ -259,17 +269,17 @@ def build_conflict_only_prompt(
intent_section = "" intent_section = ""
if task_intent: if task_intent:
intent_section = f""" intent_section = f"""
FEATURE INTENT: {task_intent.get('title', spec_name)} FEATURE INTENT: {task_intent.get("title", spec_name)}
{task_intent.get('description', '')} {task_intent.get("description", "")}
""" """
conflict_sections = [] conflict_sections = []
for i, conflict in enumerate(conflicts, 1): for i, conflict in enumerate(conflicts, 1):
context_before = conflict.get('context_before', '') context_before = conflict.get("context_before", "")
context_after = conflict.get('context_after', '') context_after = conflict.get("context_after", "")
main_lines = conflict.get('main_lines', '') main_lines = conflict.get("main_lines", "")
worktree_lines = conflict.get('worktree_lines', '') worktree_lines = conflict.get("worktree_lines", "")
conflict_id = conflict.get('id', f'CONFLICT_{i}') conflict_id = conflict.get("id", f"CONFLICT_{i}")
section = f""" section = f"""
--- {conflict_id} --- --- {conflict_id} ---
@@ -289,7 +299,7 @@ FEATURE BRANCH VERSION ({spec_name}):
all_conflicts = "\n".join(conflict_sections) all_conflicts = "\n".join(conflict_sections)
prompt = f'''You are a code merge expert. Resolve the following {len(conflicts)} conflict(s) in {file_path}. prompt = f"""You are a code merge expert. Resolve the following {len(conflicts)} conflict(s) in {file_path}.
{intent_section} {intent_section}
FILE: {file_path} FILE: {file_path}
LANGUAGE: {language} LANGUAGE: {language}
@@ -305,7 +315,7 @@ MERGE RULES:
For EACH conflict, output the resolved code in this exact format: For EACH conflict, output the resolved code in this exact format:
--- {conflicts[0].get('id', 'CONFLICT_1')} RESOLVED --- --- {conflicts[0].get("id", "CONFLICT_1")} RESOLVED ---
```{language} ```{language}
resolved code here resolved code here
``` ```
@@ -316,7 +326,7 @@ resolved code here
{"```" if len(conflicts) > 1 else ""} {"```" if len(conflicts) > 1 else ""}
(continue for each conflict) (continue for each conflict)
''' """
return prompt return prompt
@@ -344,43 +354,49 @@ def parse_conflict_markers(content: str) -> tuple[list[dict], list[str]]:
# content from incoming branch # content from incoming branch
# >>>>>>> branch_name or commit_hash # >>>>>>> branch_name or commit_hash
conflict_pattern = re.compile( conflict_pattern = re.compile(
r'<<<<<<<[^\n]*\n' # Start marker r"<<<<<<<[^\n]*\n" # Start marker
r'(.*?)' # Main/HEAD content (group 1) r"(.*?)" # Main/HEAD content (group 1)
r'=======\n' # Separator r"=======\n" # Separator
r'(.*?)' # Incoming/feature content (group 2) r"(.*?)" # Incoming/feature content (group 2)
r'>>>>>>>[^\n]*\n?', # End marker r">>>>>>>[^\n]*\n?", # End marker
re.DOTALL re.DOTALL,
) )
last_end = 0 last_end = 0
for i, match in enumerate(conflict_pattern.finditer(content), 1): for i, match in enumerate(conflict_pattern.finditer(content), 1):
# Get the clean section before this conflict # Get the clean section before this conflict
clean_before = content[last_end:match.start()] clean_before = content[last_end : match.start()]
clean_sections.append(clean_before) clean_sections.append(clean_before)
# Extract context (last 3 lines before conflict) # Extract context (last 3 lines before conflict)
before_lines = clean_before.rstrip().split('\n') before_lines = clean_before.rstrip().split("\n")
context_before = '\n'.join(before_lines[-3:]) if len(before_lines) >= 3 else clean_before.rstrip() context_before = (
"\n".join(before_lines[-3:])
if len(before_lines) >= 3
else clean_before.rstrip()
)
# Extract the conflict content # Extract the conflict content
main_lines = match.group(1).rstrip('\n') main_lines = match.group(1).rstrip("\n")
worktree_lines = match.group(2).rstrip('\n') worktree_lines = match.group(2).rstrip("\n")
# Get context after (first 3 lines after conflict) # Get context after (first 3 lines after conflict)
after_start = match.end() after_start = match.end()
after_content = content[after_start:after_start + 500] # Look ahead 500 chars after_content = content[after_start : after_start + 500] # Look ahead 500 chars
after_lines = after_content.split('\n')[:3] after_lines = after_content.split("\n")[:3]
context_after = '\n'.join(after_lines) context_after = "\n".join(after_lines)
conflicts.append({ conflicts.append(
'id': f'CONFLICT_{i}', {
'start': match.start(), "id": f"CONFLICT_{i}",
'end': match.end(), "start": match.start(),
'main_lines': main_lines, "end": match.end(),
'worktree_lines': worktree_lines, "main_lines": main_lines,
'context_before': context_before, "worktree_lines": worktree_lines,
'context_after': context_after, "context_before": context_before,
}) "context_after": context_after,
}
)
last_end = match.end() last_end = match.end()
@@ -408,32 +424,34 @@ def reassemble_with_resolutions(
Clean file with conflicts resolved Clean file with conflicts resolved
""" """
# Sort conflicts by start position (should already be sorted, but ensure it) # Sort conflicts by start position (should already be sorted, but ensure it)
sorted_conflicts = sorted(conflicts, key=lambda c: c['start']) sorted_conflicts = sorted(conflicts, key=lambda c: c["start"])
result_parts = [] result_parts = []
last_end = 0 last_end = 0
for conflict in sorted_conflicts: for conflict in sorted_conflicts:
# Add clean content before this conflict # Add clean content before this conflict
result_parts.append(original_content[last_end:conflict['start']]) result_parts.append(original_content[last_end : conflict["start"]])
# Add the resolution (or keep conflict if no resolution) # Add the resolution (or keep conflict if no resolution)
conflict_id = conflict['id'] conflict_id = conflict["id"]
if conflict_id in resolutions: if conflict_id in resolutions:
result_parts.append(resolutions[conflict_id]) result_parts.append(resolutions[conflict_id])
else: else:
# Fallback: prefer feature branch version if no resolution # Fallback: prefer feature branch version if no resolution
result_parts.append(conflict['worktree_lines']) result_parts.append(conflict["worktree_lines"])
last_end = conflict['end'] last_end = conflict["end"]
# Add remaining content after last conflict # Add remaining content after last conflict
result_parts.append(original_content[last_end:]) result_parts.append(original_content[last_end:])
return ''.join(result_parts) return "".join(result_parts)
def extract_conflict_resolutions(response: str, conflicts: list[dict], language: str) -> dict[str, str]: def extract_conflict_resolutions(
response: str, conflicts: list[dict], language: str
) -> dict[str, str]:
""" """
Extract resolved code for each conflict from AI response. Extract resolved code for each conflict from AI response.
@@ -452,28 +470,25 @@ def extract_conflict_resolutions(response: str, conflicts: list[dict], language:
# Pattern to match resolution blocks # Pattern to match resolution blocks
# --- CONFLICT_1 RESOLVED --- or similar variations # --- CONFLICT_1 RESOLVED --- or similar variations
resolution_pattern = re.compile( resolution_pattern = re.compile(
r'---\s*(CONFLICT_\d+)\s*RESOLVED\s*---\s*\n' r"---\s*(CONFLICT_\d+)\s*RESOLVED\s*---\s*\n"
r'```(?:\w+)?\n' r"```(?:\w+)?\n"
r'(.*?)' r"(.*?)"
r'```', r"```",
re.DOTALL | re.IGNORECASE re.DOTALL | re.IGNORECASE,
) )
for match in resolution_pattern.finditer(response): for match in resolution_pattern.finditer(response):
conflict_id = match.group(1).upper() conflict_id = match.group(1).upper()
resolved_code = match.group(2).rstrip('\n') resolved_code = match.group(2).rstrip("\n")
resolutions[conflict_id] = resolved_code resolutions[conflict_id] = resolved_code
# Fallback: if only one conflict and we can find a single code block # Fallback: if only one conflict and we can find a single code block
if len(conflicts) == 1 and not resolutions: if len(conflicts) == 1 and not resolutions:
code_block_pattern = re.compile( code_block_pattern = re.compile(r"```(?:\w+)?\n(.*?)```", re.DOTALL)
r'```(?:\w+)?\n(.*?)```',
re.DOTALL
)
matches = list(code_block_pattern.finditer(response)) matches = list(code_block_pattern.finditer(response))
if matches: if matches:
# Use the first (or only) code block # Use the first (or only) code block
resolutions[conflicts[0]['id']] = matches[0].group(1).rstrip('\n') resolutions[conflicts[0]["id"]] = matches[0].group(1).rstrip("\n")
return resolutions return resolutions
@@ -536,8 +551,6 @@ def optimize_prompt_for_length(
context.task_worktree_content = _trim_content( context.task_worktree_content = _trim_content(
context.task_worktree_content, "worktree" context.task_worktree_content, "worktree"
) )
context.current_main_content = _trim_content( context.current_main_content = _trim_content(context.current_main_content, "main")
context.current_main_content, "main"
)
return context return context
@@ -100,9 +100,7 @@ def extract_python_elements(
# Handle decorated functions/classes # Handle decorated functions/classes
for sub in child.children: for sub in child.children:
if sub.type in {"function_definition", "class_definition"}: if sub.type in {"function_definition", "class_definition"}:
extract_python_elements( extract_python_elements(child, elements, get_text, get_line, parent)
child, elements, get_text, get_line, parent
)
break break
# Recurse for other compound statements # Recurse for other compound statements
+29 -7
View File
@@ -23,9 +23,16 @@ logger = logging.getLogger(__name__)
try: try:
from debug import debug, debug_error, debug_warning from debug import debug, debug_error, debug_warning
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_error(*args, **kwargs): pass def debug(*args, **kwargs):
def debug_warning(*args, **kwargs): pass pass
def debug_error(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
MODULE = "merge.timeline_git" MODULE = "merge.timeline_git"
@@ -60,7 +67,9 @@ class TimelineGitHelper:
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return "unknown" return "unknown"
def get_file_content_at_commit(self, file_path: str, commit_hash: str) -> str | None: def get_file_content_at_commit(
self, file_path: str, commit_hash: str
) -> str | None:
""" """
Get file content at a specific commit. Get file content at a specific commit.
@@ -96,7 +105,14 @@ class TimelineGitHelper:
""" """
try: try:
result = subprocess.run( result = subprocess.run(
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash], [
"git",
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
commit_hash,
],
cwd=self.project_path, cwd=self.project_path,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -146,7 +162,11 @@ class TimelineGitHelper:
text=True, text=True,
) )
if result.returncode == 0: if result.returncode == 0:
info["diff_summary"] = result.stdout.strip().split("\n")[-1] if result.stdout.strip() else None info["diff_summary"] = (
result.stdout.strip().split("\n")[-1]
if result.stdout.strip()
else None
)
except Exception: except Exception:
pass pass
@@ -165,7 +185,9 @@ class TimelineGitHelper:
File content as string, or empty string if file doesn't exist File content as string, or empty string if file doesn't exist
""" """
# Extract spec name from task_id (remove 'task-' prefix if present) # Extract spec name from task_id (remove 'task-' prefix if present)
spec_name = task_id.replace("task-", "") if task_id.startswith("task-") else task_id spec_name = (
task_id.replace("task-", "") if task_id.startswith("task-") else task_id
)
worktree_path = self.project_path / ".worktrees" / spec_name / file_path worktree_path = self.project_path / ".worktrees" / spec_name / file_path
if worktree_path.exists(): if worktree_path.exists():
+23 -8
View File
@@ -26,6 +26,7 @@ class MainBranchEvent:
These events form the "spine" of the file's timeline - the authoritative These events form the "spine" of the file's timeline - the authoritative
history that all task worktrees diverge from and merge back into. history that all task worktrees diverge from and merge back into.
""" """
# Git identification # Git identification
commit_hash: str commit_hash: str
timestamp: datetime timestamp: datetime
@@ -34,7 +35,7 @@ class MainBranchEvent:
content: str content: str
# Source of change # Source of change
source: Literal['human', 'merged_task'] source: Literal["human", "merged_task"]
merged_from_task: str | None = None # If source is 'merged_task' merged_from_task: str | None = None # If source is 'merged_task'
# Intent/reason for change # Intent/reason for change
@@ -73,6 +74,7 @@ class MainBranchEvent:
@dataclass @dataclass
class BranchPoint: class BranchPoint:
"""The exact point a task branched from main.""" """The exact point a task branched from main."""
commit_hash: str commit_hash: str
content: str content: str
timestamp: datetime timestamp: datetime
@@ -96,6 +98,7 @@ class BranchPoint:
@dataclass @dataclass
class WorktreeState: class WorktreeState:
"""Current state of a file in a task's worktree.""" """Current state of a file in a task's worktree."""
content: str content: str
last_modified: datetime last_modified: datetime
@@ -116,6 +119,7 @@ class WorktreeState:
@dataclass @dataclass
class TaskIntent: class TaskIntent:
"""What the task intends to do with this file.""" """What the task intends to do with this file."""
title: str title: str
description: str description: str
from_plan: bool = False # True if extracted from implementation_plan.json from_plan: bool = False # True if extracted from implementation_plan.json
@@ -144,6 +148,7 @@ class TaskFileView:
This captures everything we need to know about how one task This captures everything we need to know about how one task
sees and modifies one file. sees and modifies one file.
""" """
task_id: str task_id: str
# The exact point this task branched from main # The exact point this task branched from main
@@ -159,14 +164,16 @@ class TaskFileView:
commits_behind_main: int = 0 commits_behind_main: int = 0
# Lifecycle status # Lifecycle status
status: Literal['active', 'merged', 'abandoned'] = 'active' status: Literal["active", "merged", "abandoned"] = "active"
merged_at: datetime | None = None merged_at: datetime | None = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"task_id": self.task_id, "task_id": self.task_id,
"branch_point": self.branch_point.to_dict(), "branch_point": self.branch_point.to_dict(),
"worktree_state": self.worktree_state.to_dict() if self.worktree_state else None, "worktree_state": self.worktree_state.to_dict()
if self.worktree_state
else None,
"task_intent": self.task_intent.to_dict(), "task_intent": self.task_intent.to_dict(),
"commits_behind_main": self.commits_behind_main, "commits_behind_main": self.commits_behind_main,
"status": self.status, "status": self.status,
@@ -178,11 +185,17 @@ class TaskFileView:
return cls( return cls(
task_id=data["task_id"], task_id=data["task_id"],
branch_point=BranchPoint.from_dict(data["branch_point"]), branch_point=BranchPoint.from_dict(data["branch_point"]),
worktree_state=WorktreeState.from_dict(data["worktree_state"]) if data.get("worktree_state") else None, worktree_state=WorktreeState.from_dict(data["worktree_state"])
task_intent=TaskIntent.from_dict(data["task_intent"]) if data.get("task_intent") else TaskIntent("", ""), if data.get("worktree_state")
else None,
task_intent=TaskIntent.from_dict(data["task_intent"])
if data.get("task_intent")
else TaskIntent("", ""),
commits_behind_main=data.get("commits_behind_main", 0), commits_behind_main=data.get("commits_behind_main", 0),
status=data.get("status", "active"), status=data.get("status", "active"),
merged_at=datetime.fromisoformat(data["merged_at"]) if data.get("merged_at") else None, merged_at=datetime.fromisoformat(data["merged_at"])
if data.get("merged_at")
else None,
) )
@@ -194,6 +207,7 @@ class FileTimeline:
This is the "file-centric" view - instead of asking "what did Task X change?", This is the "file-centric" view - instead of asking "what did Task X change?",
we ask "what happened to File Y over time, from ALL sources?" we ask "what happened to File Y over time, from ALL sources?"
""" """
file_path: str file_path: str
# Main branch evolution - the authoritative history # Main branch evolution - the authoritative history
@@ -213,7 +227,7 @@ class FileTimeline:
# Update commits_behind_main for all active tasks # Update commits_behind_main for all active tasks
for task_view in self.task_views.values(): for task_view in self.task_views.values():
if task_view.status == 'active': if task_view.status == "active":
task_view.commits_behind_main += 1 task_view.commits_behind_main += 1
def add_task_view(self, task_view: TaskFileView) -> None: def add_task_view(self, task_view: TaskFileView) -> None:
@@ -227,7 +241,7 @@ class FileTimeline:
def get_active_tasks(self) -> list[TaskFileView]: def get_active_tasks(self) -> list[TaskFileView]:
"""Get all tasks that are still active (not merged/abandoned).""" """Get all tasks that are still active (not merged/abandoned)."""
return [tv for tv in self.task_views.values() if tv.status == 'active'] return [tv for tv in self.task_views.values() if tv.status == "active"]
def get_events_since_commit(self, commit_hash: str) -> list[MainBranchEvent]: def get_events_since_commit(self, commit_hash: str) -> list[MainBranchEvent]:
"""Get all main branch events since a given commit.""" """Get all main branch events since a given commit."""
@@ -279,6 +293,7 @@ class MergeContext:
This is the "situational awareness" the AI needs to make intelligent This is the "situational awareness" the AI needs to make intelligent
merge decisions. merge decisions.
""" """
file_path: str file_path: str
# The task being merged # The task being merged
+4 -1
View File
@@ -27,7 +27,10 @@ logger = logging.getLogger(__name__)
try: try:
from debug import debug from debug import debug
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug(*args, **kwargs):
pass
MODULE = "merge.timeline_persistence" MODULE = "merge.timeline_persistence"
+79 -40
View File
@@ -34,9 +34,16 @@ logger = logging.getLogger(__name__)
try: try:
from debug import debug, debug_success, debug_warning from debug import debug, debug_success, debug_warning
except ImportError: except ImportError:
def debug(*args, **kwargs): pass
def debug_success(*args, **kwargs): pass def debug(*args, **kwargs):
def debug_warning(*args, **kwargs): pass pass
def debug_success(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
MODULE = "merge.timeline_tracker" MODULE = "merge.timeline_tracker"
@@ -56,8 +63,9 @@ class FileTimelineTracker:
project_path: Root directory of the project project_path: Root directory of the project
storage_path: Directory for timeline storage (default: .auto-claude/) storage_path: Directory for timeline storage (default: .auto-claude/)
""" """
debug(MODULE, "Initializing FileTimelineTracker", debug(
project_path=str(project_path)) MODULE, "Initializing FileTimelineTracker", project_path=str(project_path)
)
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
self.storage_path = storage_path or (self.project_path / ".auto-claude") self.storage_path = storage_path or (self.project_path / ".auto-claude")
@@ -72,8 +80,11 @@ class FileTimelineTracker:
# Load existing timelines # Load existing timelines
self._timelines = self.persistence.load_all_timelines() self._timelines = self.persistence.load_all_timelines()
debug_success(MODULE, "FileTimelineTracker initialized", debug_success(
timelines_loaded=len(self._timelines)) MODULE,
"FileTimelineTracker initialized",
timelines_loaded=len(self._timelines),
)
# ========================================================================= # =========================================================================
# EVENT HANDLERS # EVENT HANDLERS
@@ -103,9 +114,12 @@ class FileTimelineTracker:
task_intent: Description of what the task intends to do task_intent: Description of what the task intends to do
task_title: Short title for the task task_title: Short title for the task
""" """
debug(MODULE, f"on_task_start: {task_id}", debug(
files_to_modify=files_to_modify, MODULE,
branch_point=branch_point_commit) f"on_task_start: {task_id}",
files_to_modify=files_to_modify,
branch_point=branch_point_commit,
)
# Get actual branch point commit if not provided # Get actual branch point commit if not provided
if not branch_point_commit: if not branch_point_commit:
@@ -118,7 +132,9 @@ class FileTimelineTracker:
timeline = self._get_or_create_timeline(file_path) timeline = self._get_or_create_timeline(file_path)
# Get file content at branch point # Get file content at branch point
content = self.git.get_file_content_at_commit(file_path, branch_point_commit) content = self.git.get_file_content_at_commit(
file_path, branch_point_commit
)
if content is None: if content is None:
# File doesn't exist at this commit - might be created by task # File doesn't exist at this commit - might be created by task
content = "" content = ""
@@ -137,13 +153,15 @@ class FileTimelineTracker:
from_plan=bool(task_intent), from_plan=bool(task_intent),
), ),
commits_behind_main=0, commits_behind_main=0,
status='active', status="active",
) )
timeline.add_task_view(task_view) timeline.add_task_view(task_view)
self._persist_timeline(file_path) self._persist_timeline(file_path)
debug_success(MODULE, f"Task {task_id} registered with {len(files_to_modify)} files") debug_success(
MODULE, f"Task {task_id} registered with {len(files_to_modify)} files"
)
def on_main_branch_commit(self, commit_hash: str) -> None: def on_main_branch_commit(self, commit_hash: str) -> None:
""" """
@@ -180,17 +198,20 @@ class FileTimelineTracker:
commit_hash=commit_hash, commit_hash=commit_hash,
timestamp=datetime.now(), timestamp=datetime.now(),
content=content, content=content,
source='human', source="human",
commit_message=commit_info.get('message', ''), commit_message=commit_info.get("message", ""),
author=commit_info.get('author'), author=commit_info.get("author"),
diff_summary=commit_info.get('diff_summary'), diff_summary=commit_info.get("diff_summary"),
) )
timeline.add_main_event(event) timeline.add_main_event(event)
self._persist_timeline(file_path) self._persist_timeline(file_path)
debug_success(MODULE, f"Processed main commit {commit_hash[:8]}", debug_success(
files_updated=len(changed_files)) MODULE,
f"Processed main commit {commit_hash[:8]}",
files_updated=len(changed_files),
)
def on_task_worktree_change( def on_task_worktree_change(
self, self,
@@ -256,7 +277,7 @@ class FileTimelineTracker:
continue continue
# Mark task as merged # Mark task as merged
task_view.status = 'merged' task_view.status = "merged"
task_view.merged_at = datetime.now() task_view.merged_at = datetime.now()
# Add main branch event for the merge # Add main branch event for the merge
@@ -266,7 +287,7 @@ class FileTimelineTracker:
commit_hash=merge_commit, commit_hash=merge_commit,
timestamp=datetime.now(), timestamp=datetime.now(),
content=content, content=content,
source='merged_task', source="merged_task",
merged_from_task=task_id, merged_from_task=task_id,
commit_message=f"Merged from {task_id}", commit_message=f"Merged from {task_id}",
) )
@@ -294,7 +315,7 @@ class FileTimelineTracker:
task_view = timeline.get_task_view(task_id) task_view = timeline.get_task_view(task_id)
if task_view: if task_view:
task_view.status = 'abandoned' task_view.status = "abandoned"
self._persist_timeline(file_path) self._persist_timeline(file_path)
@@ -325,16 +346,26 @@ class FileTimelineTracker:
task_view = timeline.get_task_view(task_id) task_view = timeline.get_task_view(task_id)
if not task_view: if not task_view:
debug_warning(MODULE, f"Task {task_id} not found in timeline for {file_path}") debug_warning(
MODULE, f"Task {task_id} not found in timeline for {file_path}"
)
return None return None
# Get main evolution since task branched # Get main evolution since task branched
main_evolution = timeline.get_events_since_commit(task_view.branch_point.commit_hash) main_evolution = timeline.get_events_since_commit(
task_view.branch_point.commit_hash
)
# Get current main state # Get current main state
current_main = timeline.get_current_main_state() current_main = timeline.get_current_main_state()
current_main_content = current_main.content if current_main else task_view.branch_point.content current_main_content = (
current_main_commit = current_main.commit_hash if current_main else task_view.branch_point.commit_hash current_main.content if current_main else task_view.branch_point.content
)
current_main_commit = (
current_main.commit_hash
if current_main
else task_view.branch_point.commit_hash
)
# Get task's worktree content # Get task's worktree content
worktree_content = "" worktree_content = ""
@@ -348,12 +379,14 @@ class FileTimelineTracker:
other_tasks = [] other_tasks = []
for tv in timeline.get_active_tasks(): for tv in timeline.get_active_tasks():
if tv.task_id != task_id: if tv.task_id != task_id:
other_tasks.append({ other_tasks.append(
"task_id": tv.task_id, {
"intent": tv.task_intent.description, "task_id": tv.task_id,
"branch_point": tv.branch_point.commit_hash, "intent": tv.task_intent.description,
"commits_behind": tv.commits_behind_main, "branch_point": tv.branch_point.commit_hash,
}) "commits_behind": tv.commits_behind_main,
}
)
context = MergeContext( context = MergeContext(
file_path=file_path, file_path=file_path,
@@ -369,10 +402,13 @@ class FileTimelineTracker:
total_pending_tasks=len(other_tasks), total_pending_tasks=len(other_tasks),
) )
debug_success(MODULE, "Built merge context", debug_success(
commits_behind=task_view.commits_behind_main, MODULE,
main_events=len(main_evolution), "Built merge context",
other_tasks=len(other_tasks)) commits_behind=task_view.commits_behind_main,
main_events=len(main_evolution),
other_tasks=len(other_tasks),
)
return context return context
@@ -420,7 +456,7 @@ class FileTimelineTracker:
drift = {} drift = {}
for file_path, timeline in self._timelines.items(): for file_path, timeline in self._timelines.items():
task_view = timeline.get_task_view(task_id) task_view = timeline.get_task_view(task_id)
if task_view and task_view.status == 'active': if task_view and task_view.status == "active":
drift[file_path] = task_view.commits_behind_main drift[file_path] = task_view.commits_behind_main
return drift return drift
@@ -532,9 +568,12 @@ class FileTimelineTracker:
task_view.commits_behind_main = drift task_view.commits_behind_main = drift
self._persist_timeline(file_path) self._persist_timeline(file_path)
debug_success(MODULE, "Initialized from worktree", debug_success(
files=len(changed_files), MODULE,
branch_point=branch_point[:8]) "Initialized from worktree",
files=len(changed_files),
branch_point=branch_point[:8],
)
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize from worktree: {e}") logger.error(f"Failed to initialize from worktree: {e}")
+18 -17
View File
@@ -64,7 +64,9 @@ def cmd_show_timeline(args):
print(f"\n--- Main Branch History ({len(timeline.main_branch_history)} events) ---") print(f"\n--- Main Branch History ({len(timeline.main_branch_history)} events) ---")
for i, event in enumerate(timeline.main_branch_history): for i, event in enumerate(timeline.main_branch_history):
print(f" [{i+1}] {event.commit_hash[:8]} ({event.source}): {event.commit_message[:50]}...") print(
f" [{i + 1}] {event.commit_hash[:8]} ({event.source}): {event.commit_message[:50]}..."
)
print(f"\n--- Task Views ({len(timeline.task_views)} tasks) ---") print(f"\n--- Task Views ({len(timeline.task_views)} tasks) ---")
for task_id, view in timeline.task_views.items(): for task_id, view in timeline.task_views.items():
@@ -120,7 +122,9 @@ def cmd_show_context(args):
print(f"\n--- Main Evolution ({len(context.main_evolution)} events) ---") print(f"\n--- Main Evolution ({len(context.main_evolution)} events) ---")
for event in context.main_evolution: for event in context.main_evolution:
print(f" {event.commit_hash[:8]} ({event.source}): {event.commit_message[:50]}...") print(
f" {event.commit_hash[:8]} ({event.source}): {event.commit_message[:50]}..."
)
def cmd_list_files(args): def cmd_list_files(args):
@@ -136,7 +140,9 @@ def cmd_list_files(args):
for file_path in sorted(tracker._timelines.keys()): for file_path in sorted(tracker._timelines.keys()):
timeline = tracker._timelines[file_path] timeline = tracker._timelines[file_path]
active_tasks = len([tv for tv in timeline.task_views.values() if tv.status == 'active']) active_tasks = len(
[tv for tv in timeline.task_views.values() if tv.status == "active"]
)
main_events = len(timeline.main_branch_history) main_events = len(timeline.main_branch_history)
print(f" {file_path}: {active_tasks} active tasks, {main_events} main events") print(f" {file_path}: {active_tasks} active tasks, {main_events} main events")
@@ -171,47 +177,42 @@ def main():
# notify-commit # notify-commit
notify_parser = subparsers.add_parser( notify_parser = subparsers.add_parser(
"notify-commit", "notify-commit",
help="Notify tracker of a new commit (called by git post-commit hook)" help="Notify tracker of a new commit (called by git post-commit hook)",
) )
notify_parser.add_argument("commit_hash", help="The commit hash") notify_parser.add_argument("commit_hash", help="The commit hash")
notify_parser.set_defaults(func=cmd_notify_commit) notify_parser.set_defaults(func=cmd_notify_commit)
# show-timeline # show-timeline
timeline_parser = subparsers.add_parser( timeline_parser = subparsers.add_parser(
"show-timeline", "show-timeline", help="Show the timeline for a file"
help="Show the timeline for a file" )
timeline_parser.add_argument(
"file_path", help="The file path (relative to project)"
) )
timeline_parser.add_argument("file_path", help="The file path (relative to project)")
timeline_parser.set_defaults(func=cmd_show_timeline) timeline_parser.set_defaults(func=cmd_show_timeline)
# show-drift # show-drift
drift_parser = subparsers.add_parser( drift_parser = subparsers.add_parser(
"show-drift", "show-drift", help="Show commits-behind-main for a task"
help="Show commits-behind-main for a task"
) )
drift_parser.add_argument("task_id", help="The task ID") drift_parser.add_argument("task_id", help="The task ID")
drift_parser.set_defaults(func=cmd_show_drift) drift_parser.set_defaults(func=cmd_show_drift)
# show-context # show-context
context_parser = subparsers.add_parser( context_parser = subparsers.add_parser(
"show-context", "show-context", help="Show merge context for a task and file"
help="Show merge context for a task and file"
) )
context_parser.add_argument("task_id", help="The task ID") context_parser.add_argument("task_id", help="The task ID")
context_parser.add_argument("file_path", help="The file path") context_parser.add_argument("file_path", help="The file path")
context_parser.set_defaults(func=cmd_show_context) context_parser.set_defaults(func=cmd_show_context)
# list-files # list-files
list_parser = subparsers.add_parser( list_parser = subparsers.add_parser("list-files", help="List all tracked files")
"list-files",
help="List all tracked files"
)
list_parser.set_defaults(func=cmd_list_files) list_parser.set_defaults(func=cmd_list_files)
# init-from-worktree # init-from-worktree
init_parser = subparsers.add_parser( init_parser = subparsers.add_parser(
"init-from-worktree", "init-from-worktree", help="Initialize tracking from an existing worktree"
help="Initialize tracking from an existing worktree"
) )
init_parser.add_argument("task_id", help="The task ID") init_parser.add_argument("task_id", help="The task ID")
init_parser.add_argument("worktree_path", help="Path to the worktree") init_parser.add_argument("worktree_path", help="Path to the worktree")
+24 -10
View File
@@ -330,7 +330,9 @@ class ConflictRegion:
"change_types": [ct.value for ct in self.change_types], "change_types": [ct.value for ct in self.change_types],
"severity": self.severity.value, "severity": self.severity.value,
"can_auto_merge": self.can_auto_merge, "can_auto_merge": self.can_auto_merge,
"merge_strategy": self.merge_strategy.value if self.merge_strategy else None, "merge_strategy": self.merge_strategy.value
if self.merge_strategy
else None,
"reason": self.reason, "reason": self.reason,
} }
@@ -344,7 +346,9 @@ class ConflictRegion:
change_types=[ChangeType(ct) for ct in data["change_types"]], change_types=[ChangeType(ct) for ct in data["change_types"]],
severity=ConflictSeverity(data["severity"]), severity=ConflictSeverity(data["severity"]),
can_auto_merge=data["can_auto_merge"], can_auto_merge=data["can_auto_merge"],
merge_strategy=MergeStrategy(data["merge_strategy"]) if data.get("merge_strategy") else None, merge_strategy=MergeStrategy(data["merge_strategy"])
if data.get("merge_strategy")
else None,
reason=data.get("reason", ""), reason=data.get("reason", ""),
) )
@@ -383,7 +387,9 @@ class TaskSnapshot:
"task_id": self.task_id, "task_id": self.task_id,
"task_intent": self.task_intent, "task_intent": self.task_intent,
"started_at": self.started_at.isoformat(), "started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat() if self.completed_at else None, "completed_at": self.completed_at.isoformat()
if self.completed_at
else None,
"content_hash_before": self.content_hash_before, "content_hash_before": self.content_hash_before,
"content_hash_after": self.content_hash_after, "content_hash_after": self.content_hash_after,
"semantic_changes": [c.to_dict() for c in self.semantic_changes], "semantic_changes": [c.to_dict() for c in self.semantic_changes],
@@ -397,10 +403,14 @@ class TaskSnapshot:
task_id=data["task_id"], task_id=data["task_id"],
task_intent=data["task_intent"], task_intent=data["task_intent"],
started_at=datetime.fromisoformat(data["started_at"]), started_at=datetime.fromisoformat(data["started_at"]),
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None, completed_at=datetime.fromisoformat(data["completed_at"])
if data.get("completed_at")
else None,
content_hash_before=data.get("content_hash_before", ""), content_hash_before=data.get("content_hash_before", ""),
content_hash_after=data.get("content_hash_after", ""), content_hash_after=data.get("content_hash_after", ""),
semantic_changes=[SemanticChange.from_dict(c) for c in data.get("semantic_changes", [])], semantic_changes=[
SemanticChange.from_dict(c) for c in data.get("semantic_changes", [])
],
raw_diff=data.get("raw_diff"), raw_diff=data.get("raw_diff"),
) )
@@ -449,7 +459,9 @@ class FileEvolution:
baseline_captured_at=datetime.fromisoformat(data["baseline_captured_at"]), baseline_captured_at=datetime.fromisoformat(data["baseline_captured_at"]),
baseline_content_hash=data["baseline_content_hash"], baseline_content_hash=data["baseline_content_hash"],
baseline_snapshot_path=data["baseline_snapshot_path"], baseline_snapshot_path=data["baseline_snapshot_path"],
task_snapshots=[TaskSnapshot.from_dict(ts) for ts in data.get("task_snapshots", [])], task_snapshots=[
TaskSnapshot.from_dict(ts) for ts in data.get("task_snapshots", [])
],
) )
def get_task_snapshot(self, task_id: str) -> TaskSnapshot | None: def get_task_snapshot(self, task_id: str) -> TaskSnapshot | None:
@@ -463,8 +475,7 @@ class FileEvolution:
"""Add or update a task snapshot.""" """Add or update a task snapshot."""
# Remove existing snapshot for this task if present # Remove existing snapshot for this task if present
self.task_snapshots = [ self.task_snapshots = [
ts for ts in self.task_snapshots ts for ts in self.task_snapshots if ts.task_id != snapshot.task_id
if ts.task_id != snapshot.task_id
] ]
self.task_snapshots.append(snapshot) self.task_snapshots.append(snapshot)
# Keep sorted by start time # Keep sorted by start time
@@ -528,12 +539,15 @@ class MergeResult:
@property @property
def needs_human_review(self) -> bool: def needs_human_review(self) -> bool:
"""Check if human review is needed.""" """Check if human review is needed."""
return len(self.conflicts_remaining) > 0 or self.decision == MergeDecision.NEEDS_HUMAN_REVIEW return (
len(self.conflicts_remaining) > 0
or self.decision == MergeDecision.NEEDS_HUMAN_REVIEW
)
def compute_content_hash(content: str) -> str: def compute_content_hash(content: str) -> str:
"""Compute a hash of file content for comparison.""" """Compute a hash of file content for comparison."""
return hashlib.sha256(content.encode('utf-8')).hexdigest()[:16] return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
def sanitize_path_for_storage(file_path: str) -> str: def sanitize_path_for_storage(file_path: str) -> str:
+2 -5
View File
@@ -183,8 +183,7 @@ class InvestigationPlanGenerator(PlanGenerator):
description="Add detailed logging around suspected problem areas", description="Add detailed logging around suspected problem areas",
expected_output="Logs capture relevant state changes and events", expected_output="Logs capture relevant state changes and events",
files_to_modify=[ files_to_modify=[
f.get("path", "") f.get("path", "") for f in self.context.files_to_modify[:3]
for f in self.context.files_to_modify[:3]
], ],
), ),
Subtask( Subtask(
@@ -365,9 +364,7 @@ class RefactorPlanGenerator(PlanGenerator):
) )
def get_plan_generator( def get_plan_generator(context: PlannerContext, spec_dir: Path) -> PlanGenerator:
context: PlannerContext, spec_dir: Path
) -> PlanGenerator:
"""Factory function to get the appropriate plan generator.""" """Factory function to get the appropriate plan generator."""
if context.workflow_type == WorkflowType.INVESTIGATION: if context.workflow_type == WorkflowType.INVESTIGATION:
return InvestigationPlanGenerator(context, spec_dir) return InvestigationPlanGenerator(context, spec_dir)
-1
View File
@@ -2,7 +2,6 @@
Utility functions for implementation planner. Utility functions for implementation planner.
""" """
from implementation_plan import Verification, VerificationType from implementation_plan import Verification, VerificationType
from .models import PlannerContext from .models import PlannerContext
+1 -3
View File
@@ -64,9 +64,7 @@ class ChecklistFormatter:
# Escape pipe characters in content # Escape pipe characters in content
desc = issue.description.replace("|", "\\|") desc = issue.description.replace("|", "\\|")
prev = issue.prevention.replace("|", "\\|") prev = issue.prevention.replace("|", "\\|")
lines.append( lines.append(f"| {desc} | {issue.likelihood.capitalize()} | {prev} |")
f"| {desc} | {issue.likelihood.capitalize()} | {prev} |"
)
lines.append("") lines.append("")
return lines return lines
+3 -7
View File
@@ -210,17 +210,14 @@ def detect_work_type(subtask: dict) -> list[str]:
# API endpoint detection # API endpoint detection
if any( if any(
kw in description kw in description for kw in ["endpoint", "api", "route", "request", "response"]
for kw in ["endpoint", "api", "route", "request", "response"]
): ):
work_types.append("api_endpoint") work_types.append("api_endpoint")
if any("routes" in f or "api" in f for f in files): if any("routes" in f or "api" in f for f in files):
work_types.append("api_endpoint") work_types.append("api_endpoint")
# Database model detection # Database model detection
if any( if any(kw in description for kw in ["model", "database", "migration", "schema"]):
kw in description for kw in ["model", "database", "migration", "schema"]
):
work_types.append("database_model") work_types.append("database_model")
if any("models" in f or "migration" in f for f in files): if any("models" in f or "migration" in f for f in files):
work_types.append("database_model") work_types.append("database_model")
@@ -239,8 +236,7 @@ def detect_work_type(subtask: dict) -> list[str]:
# Authentication detection # Authentication detection
if any( if any(
kw in description kw in description for kw in ["auth", "login", "password", "token", "session"]
for kw in ["auth", "login", "password", "token", "session"]
): ):
work_types.append("authentication") work_types.append("authentication")
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from core.progress instead.""" """Backward compatibility shim - import from core.progress instead."""
from core.progress import * # noqa: F403 from core.progress import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from prompts_pkg.prompt_generator instead.""" """Backward compatibility shim - import from prompts_pkg.prompt_generator instead."""
from prompts_pkg.prompt_generator import * # noqa: F403 from prompts_pkg.prompt_generator import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from prompts_pkg.prompts instead.""" """Backward compatibility shim - import from prompts_pkg.prompts instead."""
from prompts_pkg.prompts import * # noqa: F403 from prompts_pkg.prompts import * # noqa: F403
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from qa package instead.""" """Backward compatibility shim - import from qa package instead."""
from qa import ( from qa import (
ISSUE_SIMILARITY_THRESHOLD, ISSUE_SIMILARITY_THRESHOLD,
# Configuration # Configuration
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from services.recovery instead.""" """Backward compatibility shim - import from services.recovery instead."""
from services.recovery import ( from services.recovery import (
FailureType, FailureType,
RecoveryAction, RecoveryAction,
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from analysis.risk_classifier instead.""" """Backward compatibility shim - import from analysis.risk_classifier instead."""
from analysis.risk_classifier import ( from analysis.risk_classifier import (
AssessmentFlags, AssessmentFlags,
ComplexityAnalysis, ComplexityAnalysis,
@@ -40,9 +40,7 @@ class ClaudeAnalysisClient:
def _validate_oauth_token(self) -> None: def _validate_oauth_token(self) -> None:
"""Validate that CLAUDE_CODE_OAUTH_TOKEN is set.""" """Validate that CLAUDE_CODE_OAUTH_TOKEN is set."""
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
raise ValueError( raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token")
"CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token"
)
async def run_analysis_query(self, prompt: str) -> str: async def run_analysis_query(self, prompt: str) -> str:
""" """
+1 -3
View File
@@ -92,9 +92,7 @@ class AIAnalyzerRunner:
print(" AI-ENHANCED PROJECT ANALYSIS") print(" AI-ENHANCED PROJECT ANALYSIS")
print("=" * 60 + "\n") print("=" * 60 + "\n")
def _get_analyzers_to_run( def _get_analyzers_to_run(self, selected_analyzers: list[str] | None) -> list[str]:
self, selected_analyzers: list[str] | None
) -> list[str]:
""" """
Determine which analyzers to run. Determine which analyzers to run.
@@ -53,16 +53,25 @@ class CompetitorAnalyzer:
) )
if not self.discovery_file.exists(): if not self.discovery_file.exists():
print_status("Discovery file not found, skipping competitor analysis", "warning") print_status(
self._create_error_analysis_file("Discovery file not found - cannot analyze competitors without project context") "Discovery file not found, skipping competitor analysis", "warning"
)
self._create_error_analysis_file(
"Discovery file not found - cannot analyze competitors without project context"
)
return RoadmapPhaseResult( return RoadmapPhaseResult(
"competitor_analysis", True, [str(self.analysis_file)], ["Discovery file not found"], 0 "competitor_analysis",
True,
[str(self.analysis_file)],
["Discovery file not found"],
0,
) )
errors = [] errors = []
for attempt in range(MAX_RETRIES): for attempt in range(MAX_RETRIES):
print_status( print_status(
f"Running competitor analysis agent (attempt {attempt + 1})...", "progress" f"Running competitor analysis agent (attempt {attempt + 1})...",
"progress",
) )
context = self._build_context() context = self._build_context()
@@ -82,7 +91,10 @@ class CompetitorAnalyzer:
) )
# Graceful degradation: if all retries fail, create empty analysis and continue # Graceful degradation: if all retries fail, create empty analysis and continue
print_status("Competitor analysis failed, continuing without competitor insights", "warning") print_status(
"Competitor analysis failed, continuing without competitor insights",
"warning",
)
for err in errors: for err in errors:
print(f" {muted('Error:')} {err}") print(f" {muted('Error:')} {err}")
@@ -117,12 +129,11 @@ Output your findings to competitor_analysis.json.
if "competitors" in data: if "competitors" in data:
competitor_count = len(data.get("competitors", [])) competitor_count = len(data.get("competitors", []))
pain_point_count = sum( pain_point_count = sum(
len(c.get("pain_points", [])) len(c.get("pain_points", [])) for c in data.get("competitors", [])
for c in data.get("competitors", [])
) )
print_status( print_status(
f"Analyzed {competitor_count} competitors, found {pain_point_count} pain points", f"Analyzed {competitor_count} competitors, found {pain_point_count} pain points",
"success" "success",
) )
return RoadmapPhaseResult( return RoadmapPhaseResult(
"competitor_analysis", True, [str(self.analysis_file)], [], 0 "competitor_analysis", True, [str(self.analysis_file)], [], 0
@@ -145,7 +156,7 @@ Output your findings to competitor_analysis.json.
"insights_summary": { "insights_summary": {
"top_pain_points": [], "top_pain_points": [],
"differentiator_opportunities": [], "differentiator_opportunities": [],
"market_trends": [] "market_trends": [],
}, },
"created_at": datetime.now().isoformat(), "created_at": datetime.now().isoformat(),
}, },
@@ -163,7 +174,7 @@ Output your findings to competitor_analysis.json.
"insights_summary": { "insights_summary": {
"top_pain_points": [], "top_pain_points": [],
"differentiator_opportunities": [], "differentiator_opportunities": [],
"market_trends": [] "market_trends": [],
}, },
"created_at": datetime.now().isoformat(), "created_at": datetime.now().isoformat(),
} }
+3 -1
View File
@@ -156,5 +156,7 @@ class AgentExecutor:
return True, response_text return True, response_text
except Exception as e: except Exception as e:
debug_error("roadmap_executor", f"Agent failed: {prompt_file}", error=str(e)) debug_error(
"roadmap_executor", f"Agent failed: {prompt_file}", error=str(e)
)
return False, str(e) return False, str(e)
@@ -36,13 +36,17 @@ class GraphHintsProvider:
hints_file=str(self.hints_file), hints_file=str(self.hints_file),
) )
print_status("graph_hints.json already exists", "success") print_status("graph_hints.json already exists", "success")
return RoadmapPhaseResult("graph_hints", True, [str(self.hints_file)], [], 0) return RoadmapPhaseResult(
"graph_hints", True, [str(self.hints_file)], [], 0
)
if not is_graphiti_enabled(): if not is_graphiti_enabled():
debug("roadmap_graph", "Graphiti not enabled, creating placeholder") debug("roadmap_graph", "Graphiti not enabled, creating placeholder")
print_status("Graphiti not enabled, skipping graph hints", "info") print_status("Graphiti not enabled, skipping graph hints", "info")
self._create_disabled_hints_file() self._create_disabled_hints_file()
return RoadmapPhaseResult("graph_hints", True, [str(self.hints_file)], [], 0) return RoadmapPhaseResult(
"graph_hints", True, [str(self.hints_file)], [], 0
)
debug("roadmap_graph", "Querying Graphiti for roadmap insights") debug("roadmap_graph", "Querying Graphiti for roadmap insights")
print_status("Querying Graphiti for roadmap insights...", "progress") print_status("Querying Graphiti for roadmap insights...", "progress")
@@ -63,7 +67,9 @@ class GraphHintsProvider:
else: else:
print_status("No relevant graph hints found", "info") print_status("No relevant graph hints found", "info")
return RoadmapPhaseResult("graph_hints", True, [str(self.hints_file)], [], 0) return RoadmapPhaseResult(
"graph_hints", True, [str(self.hints_file)], [], 0
)
except Exception as e: except Exception as e:
debug_error("roadmap_graph", "Graph query failed", error=str(e)) debug_error("roadmap_graph", "Graph query failed", error=str(e))
+10 -6
View File
@@ -52,7 +52,9 @@ class ProjectIndexPhase:
# Check if we can copy existing index # Check if we can copy existing index
if self.auto_build_index.exists() and not self.project_index.exists(): if self.auto_build_index.exists() and not self.project_index.exists():
debug("roadmap_phase", "Copying existing project_index.json from auto-claude") debug(
"roadmap_phase", "Copying existing project_index.json from auto-claude"
)
shutil.copy(self.auto_build_index, self.project_index) shutil.copy(self.auto_build_index, self.project_index)
print_status("Copied existing project_index.json", "success") print_status("Copied existing project_index.json", "success")
debug_success("roadmap_phase", "Project index copied successfully") debug_success("roadmap_phase", "Project index copied successfully")
@@ -111,7 +113,9 @@ class DiscoveryPhase:
if self.discovery_file.exists() and not self.refresh: if self.discovery_file.exists() and not self.refresh:
debug("roadmap_phase", "roadmap_discovery.json already exists, skipping") debug("roadmap_phase", "roadmap_discovery.json already exists, skipping")
print_status("roadmap_discovery.json already exists", "success") print_status("roadmap_discovery.json already exists", "success")
return RoadmapPhaseResult("discovery", True, [str(self.discovery_file)], [], 0) return RoadmapPhaseResult(
"discovery", True, [str(self.discovery_file)], [], 0
)
errors = [] errors = []
for attempt in range(MAX_RETRIES): for attempt in range(MAX_RETRIES):
@@ -189,9 +193,7 @@ Do NOT ask questions. Make educated inferences and create the file.
return None return None
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
debug_error( debug_error("roadmap_phase", "Invalid JSON in discovery file", error=str(e))
"roadmap_phase", "Invalid JSON in discovery file", error=str(e)
)
return None return None
@@ -313,7 +315,9 @@ Output the complete roadmap to roadmap.json.
) )
else: else:
if missing: if missing:
debug_warning("roadmap_phase", f"Missing required fields: {missing}") debug_warning(
"roadmap_phase", f"Missing required fields: {missing}"
)
else: else:
debug_warning( debug_warning(
"roadmap_phase", "roadmap_phase",
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from security.scan_secrets instead.""" """Backward compatibility shim - import from security.scan_secrets instead."""
from security.scan_secrets import * # noqa: F403 from security.scan_secrets import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from security module instead.""" """Backward compatibility shim - import from security module instead."""
from security import * # noqa: F403 from security import * # noqa: F403
@@ -5,7 +5,6 @@ Validator Registry
Central registry mapping command names to their validation functions. Central registry mapping command names to their validation functions.
""" """
from .database_validators import ( from .database_validators import (
validate_dropdb_command, validate_dropdb_command,
validate_dropuser_command, validate_dropuser_command,
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from analysis.security_scanner instead.""" """Backward compatibility shim - import from analysis.security_scanner instead."""
from analysis.security_scanner import * # noqa: F403 from analysis.security_scanner import * # noqa: F403
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from services.orchestrator instead.""" """Backward compatibility shim - import from services.orchestrator instead."""
from services.orchestrator import ( from services.orchestrator import (
OrchestrationResult, OrchestrationResult,
ServiceConfig, ServiceConfig,
+1 -3
View File
@@ -10,9 +10,7 @@ import sys
from pathlib import Path from pathlib import Path
def run_script( def run_script(project_dir: Path, script: str, args: list[str]) -> tuple[bool, str]:
project_dir: Path, script: str, args: list[str]
) -> tuple[bool, str]:
""" """
Run a Python script and return (success, output). Run a Python script and return (success, output).
+6 -7
View File
@@ -326,9 +326,7 @@ class SpecOrchestrator:
if linear_state: if linear_state:
print_status(f"Linear task created: {linear_state.task_id}", "success") print_status(f"Linear task created: {linear_state.task_id}", "success")
else: else:
print_status( print_status("Linear task creation failed (continuing without)", "warning")
"Linear task creation failed (continuing without)", "warning"
)
async def _phase_complexity_assessment_with_requirements( async def _phase_complexity_assessment_with_requirements(
self, self,
@@ -409,9 +407,7 @@ class SpecOrchestrator:
print_status(f"Complexity override: {comp.value.upper()}", "success") print_status(f"Complexity override: {comp.value.upper()}", "success")
return assessment return assessment
async def _run_ai_assessment( async def _run_ai_assessment(self, task_logger) -> complexity.ComplexityAssessment:
self, task_logger
) -> complexity.ComplexityAssessment:
"""Run AI-based complexity assessment. """Run AI-based complexity assessment.
Args: Args:
@@ -581,7 +577,10 @@ class SpecOrchestrator:
parent = self.spec_dir.parent parent = self.spec_dir.parent
prefix = self.spec_dir.name[:4] # e.g., "001-" prefix = self.spec_dir.name[:4] # e.g., "001-"
for candidate in parent.iterdir(): for candidate in parent.iterdir():
if candidate.name.startswith(prefix) and "pending" not in candidate.name: if (
candidate.name.startswith(prefix)
and "pending" not in candidate.name
):
self.spec_dir = candidate self.spec_dir = candidate
break break
return result return result
+3 -1
View File
@@ -62,7 +62,9 @@ class StreamingLogCapture:
if self.current_tool == tool_name: if self.current_tool == tool_name:
self.current_tool = None self.current_tool = None
def process_message(self, msg, verbose: bool = False, capture_detail: bool = True) -> None: def process_message(
self, msg, verbose: bool = False, capture_detail: bool = True
) -> None:
""" """
Process a message from the Claude SDK stream. Process a message from the Claude SDK stream.
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from analysis.test_discovery instead.""" """Backward compatibility shim - import from analysis.test_discovery instead."""
from analysis.test_discovery import ( from analysis.test_discovery import (
FRAMEWORK_PATTERNS, FRAMEWORK_PATTERNS,
TestDiscovery, TestDiscovery,
+1
View File
@@ -41,4 +41,5 @@ def __getattr__(name):
raise AttributeError(f"module 'validate_spec' has no attribute '{name}'") raise AttributeError(f"module 'validate_spec' has no attribute '{name}'")
__all__ = ["SpecValidator", "ValidationResult", "auto_fix_plan"] __all__ = ["SpecValidator", "ValidationResult", "auto_fix_plan"]
+3 -1
View File
@@ -36,4 +36,6 @@ def __getattr__(name):
if str(spec_dir) in sys.path: if str(spec_dir) in sys.path:
sys.path.remove(str(spec_dir)) sys.path.remove(str(spec_dir))
raise AttributeError(f"module 'validate_spec.spec_validator' has no attribute '{name}'") raise AttributeError(
f"module 'validate_spec.spec_validator' has no attribute '{name}'"
)
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from spec.validation_strategy instead.""" """Backward compatibility shim - import from spec.validation_strategy instead."""
from spec.validation_strategy import * # noqa: F403 from spec.validation_strategy import * # noqa: F403
+8 -8
View File
@@ -26,17 +26,17 @@ if str(_auto_claude_dir) not in sys.path:
sys.path.insert(0, str(_auto_claude_dir)) sys.path.insert(0, str(_auto_claude_dir))
# Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py) # Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py)
if 'core' not in sys.modules: if "core" not in sys.modules:
_core_module = ModuleType('core') _core_module = ModuleType("core")
_core_module.__file__ = str(_auto_claude_dir / 'core' / '__init__.py') _core_module.__file__ = str(_auto_claude_dir / "core" / "__init__.py")
_core_module.__path__ = [str(_auto_claude_dir / 'core')] _core_module.__path__ = [str(_auto_claude_dir / "core")]
sys.modules['core'] = _core_module sys.modules["core"] = _core_module
# Now load core.workspace package directly # Now load core.workspace package directly
_workspace_init = _auto_claude_dir / 'core' / 'workspace' / '__init__.py' _workspace_init = _auto_claude_dir / "core" / "workspace" / "__init__.py"
_spec = importlib.util.spec_from_file_location('core.workspace', _workspace_init) _spec = importlib.util.spec_from_file_location("core.workspace", _workspace_init)
_workspace_module = importlib.util.module_from_spec(_spec) _workspace_module = importlib.util.module_from_spec(_spec)
sys.modules['core.workspace'] = _workspace_module sys.modules["core.workspace"] = _workspace_module
_spec.loader.exec_module(_workspace_module) _spec.loader.exec_module(_workspace_module)
# Re-export everything from core.workspace # Re-export everything from core.workspace
+8 -8
View File
@@ -25,17 +25,17 @@ if str(_auto_claude_dir) not in sys.path:
sys.path.insert(0, str(_auto_claude_dir)) sys.path.insert(0, str(_auto_claude_dir))
# Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py) # Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py)
if 'core' not in sys.modules: if "core" not in sys.modules:
_core_module = ModuleType('core') _core_module = ModuleType("core")
_core_module.__file__ = str(_auto_claude_dir / 'core' / '__init__.py') _core_module.__file__ = str(_auto_claude_dir / "core" / "__init__.py")
_core_module.__path__ = [str(_auto_claude_dir / 'core')] _core_module.__path__ = [str(_auto_claude_dir / "core")]
sys.modules['core'] = _core_module sys.modules["core"] = _core_module
# Now load core.worktree directly # Now load core.worktree directly
_worktree_file = _auto_claude_dir / 'core' / 'worktree.py' _worktree_file = _auto_claude_dir / "core" / "worktree.py"
_spec = importlib.util.spec_from_file_location('core.worktree', _worktree_file) _spec = importlib.util.spec_from_file_location("core.worktree", _worktree_file)
_worktree_module = importlib.util.module_from_spec(_spec) _worktree_module = importlib.util.module_from_spec(_spec)
sys.modules['core.worktree'] = _worktree_module sys.modules["core.worktree"] = _worktree_module
_spec.loader.exec_module(_worktree_module) _spec.loader.exec_module(_worktree_module)
# Re-export everything from core.worktree # Re-export everything from core.worktree