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."""
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).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
ELECTRON_TOOLS = [
"mcp__electron__electron_connect", # Connect to Electron app via DevTools
"mcp__electron__electron_screenshot", # Take screenshot of Electron window
"mcp__electron__electron_click", # Click element 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_get_window_info", # Get window state/bounds
"mcp__electron__electron_get_console", # Get console logs from renderer
"mcp__electron__electron_connect", # Connect to Electron app via DevTools
"mcp__electron__electron_screenshot", # Take screenshot of Electron window
"mcp__electron__electron_click", # Click element 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_get_window_info", # Get window state/bounds
"mcp__electron__electron_get_console", # Get console logs from renderer
]
# 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.
"""
from .models import (
BASE_READ_TOOLS,
BASE_WRITE_TOOLS,
@@ -97,7 +97,9 @@ class EnvironmentDetector(BaseAnalyzer):
"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."""
example_content = self._read_file(".env.example") or self._read_file(
".env.sample"
@@ -156,7 +158,9 @@ class EnvironmentDetector(BaseAnalyzer):
"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."""
entry_files = [
"app.py",
@@ -32,7 +32,9 @@ class JobsDetector(BaseAnalyzer):
jobs_info = None
# 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:
self.analysis["background_jobs"] = jobs_info
@@ -98,7 +98,10 @@ class MonitoringDetector(BaseAnalyzer):
def _get_apm_tools(self) -> list[str] | None:
"""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 [s["type"] for s in self.analysis["services"]["monitoring"]]
@@ -152,7 +152,9 @@ class ServicesDetector(BaseAnalyzer):
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."""
for dep, db_type in self.DATABASE_INDICATORS.items():
if dep in all_deps:
@@ -164,7 +166,9 @@ class ServicesDetector(BaseAnalyzer):
if indicator in all_deps:
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."""
for dep, queue_type in self.QUEUE_INDICATORS.items():
if dep in all_deps:
@@ -176,25 +180,33 @@ class ServicesDetector(BaseAnalyzer):
if dep in all_deps:
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."""
for dep, payment_type in self.PAYMENT_INDICATORS.items():
if dep in all_deps:
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."""
for dep, storage_type in self.STORAGE_INDICATORS.items():
if dep in all_deps:
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."""
for dep, auth_type in self.AUTH_INDICATORS.items():
if dep in all_deps:
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."""
for dep, monitoring_type in self.MONITORING_INDICATORS.items():
if dep in all_deps:
@@ -19,7 +19,7 @@ class RouteDetector(BaseAnalyzer):
"""Detects API routes across multiple web frameworks."""
# 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):
super().__init__(path)
@@ -58,7 +58,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_fastapi_routes(self) -> list[dict]:
"""Detect FastAPI 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:
try:
@@ -120,7 +122,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_flask_routes(self) -> list[dict]:
"""Detect Flask 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:
try:
@@ -167,7 +171,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_django_routes(self) -> list[dict]:
"""Detect Django routes from urls.py files."""
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:
try:
@@ -201,8 +207,12 @@ class RouteDetector(BaseAnalyzer):
def _detect_express_routes(self) -> list[dict]:
"""Detect Express/Fastify/Koa routes."""
routes = []
js_files = [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)]
js_files = [
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
for file_path in files_to_check:
try:
@@ -256,7 +266,11 @@ class RouteDetector(BaseAnalyzer):
app_dir = self.path / "app"
if app_dir.exists():
# 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:
# Convert file path to route path
# app/api/users/[id]/route.ts -> /api/users/:id
@@ -290,9 +304,13 @@ class RouteDetector(BaseAnalyzer):
# Next.js Pages Router (pages/api directory)
pages_api = self.path / "pages" / "api"
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:
if api_file.name.startswith('_'):
if api_file.name.startswith("_"):
continue
# Convert file path to route
@@ -322,7 +340,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_go_routes(self) -> list[dict]:
"""Detect Go framework routes (Gin, Echo, Chi, Fiber)."""
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:
try:
@@ -356,7 +376,9 @@ class RouteDetector(BaseAnalyzer):
def _detect_rust_routes(self) -> list[dict]:
"""Detect Rust framework routes (Axum, Actix)."""
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:
try:
+4 -1
View File
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
# Check for Claude SDK availability
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
SDK_AVAILABLE = True
except ImportError:
SDK_AVAILABLE = False
@@ -330,7 +331,9 @@ def _format_attempt_history(attempts: list[dict]) -> str:
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.
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from analysis.analyzer instead."""
from analysis.analyzer import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from analysis.analyzers instead."""
from analysis.analyzers import * # noqa: F403
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from analysis.ci_discovery instead."""
from analysis.ci_discovery import (
HAS_YAML,
CIConfig,
+1 -3
View File
@@ -318,9 +318,7 @@ def _handle_build_interrupt(
from agent import run_autonomous_agent
# Print paused banner
print_paused_banner(
spec_dir, spec_dir.name, has_worktree=bool(worktree_manager)
)
print_paused_banner(spec_dir, spec_dir.name, has_worktree=bool(worktree_manager))
# Update status file
status_manager = StatusManager(project_dir)
+5 -1
View File
@@ -302,14 +302,18 @@ def main() -> None:
# Handle build management commands
if args.merge_preview:
from cli.workspace_commands import handle_merge_preview_command
result = handle_merge_preview_command(project_dir, spec_dir.name)
# Output as JSON for the UI to parse
import json
print(json.dumps(result))
return
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:
sys.exit(1)
return
+112 -51
View File
@@ -40,13 +40,28 @@ try:
is_debug_enabled,
)
except ImportError:
def debug(*args, **kwargs): pass
def debug_detailed(*args, **kwargs): pass
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
def debug(*args, **kwargs):
pass
def debug_detailed(*args, **kwargs):
pass
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"
@@ -208,7 +223,9 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
result["commits_behind"] = commits_behind
if commits_behind > 0:
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
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
# This is a plumbing command that does a 3-way merge in memory
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,
capture_output=True,
text=True,
@@ -253,7 +278,11 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
if "CONFLICT" in line:
# Extract file path from conflict message
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:
file_path = match.group(1).strip()
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,
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
spec_files_result = subprocess.run(
@@ -277,12 +310,18 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
capture_output=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
conflicting = main_files & spec_files
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']}")
else:
@@ -291,6 +330,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
except Exception as e:
debug_error(MODULE, f"Error checking git conflicts: {e}")
import traceback
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
return result
@@ -316,16 +356,22 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
Dictionary with preview information
"""
debug_section(MODULE, "Merge Preview Command")
debug(MODULE, "handle_merge_preview_command() called",
project_dir=str(project_dir),
spec_name=spec_name)
debug(
MODULE,
"handle_merge_preview_command() called",
project_dir=str(project_dir),
spec_name=spec_name,
)
from merge import MergeOrchestrator
from workspace import get_existing_build_worktree
worktree_path = get_existing_build_worktree(project_dir, spec_name)
debug(MODULE, "Worktree lookup result",
worktree_path=str(worktree_path) if worktree_path else None)
debug(
MODULE,
"Worktree lookup result",
worktree_path=str(worktree_path) if worktree_path else None,
)
if not worktree_path:
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,
"totalConflicts": 0,
"autoMergeable": 0,
}
},
}
try:
@@ -367,36 +413,47 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
# Transform semantic conflicts to UI-friendly format
conflicts = []
for c in preview.get("conflicts", []):
debug_verbose(MODULE, "Processing semantic conflict",
file=c.get("file", ""),
severity=c.get("severity", "unknown"))
conflicts.append({
"file": c.get("file", ""),
"location": c.get("location", ""),
"tasks": c.get("tasks", []),
"severity": c.get("severity", "unknown"),
"canAutoMerge": c.get("can_auto_merge", False),
"strategy": c.get("strategy"),
"reason": c.get("reason", ""),
"type": "semantic",
})
debug_verbose(
MODULE,
"Processing semantic conflict",
file=c.get("file", ""),
severity=c.get("severity", "unknown"),
)
conflicts.append(
{
"file": c.get("file", ""),
"location": c.get("location", ""),
"tasks": c.get("tasks", []),
"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
for file_path in git_conflicts.get("conflicting_files", []):
conflicts.append({
"file": file_path,
"location": "file-level",
"tasks": [spec_name, git_conflicts["base_branch"]],
"severity": "high",
"canAutoMerge": False,
"strategy": None,
"reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point",
"type": "git",
})
conflicts.append(
{
"file": file_path,
"location": "file-level",
"tasks": [spec_name, git_conflicts["base_branch"]],
"severity": "high",
"canAutoMerge": False,
"strategy": None,
"reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point",
"type": "git",
}
)
summary = preview.get("summary", {})
total_conflicts = summary.get("total_conflicts", 0) + len(git_conflicts.get("conflicting_files", []))
conflict_files = summary.get("conflict_files", 0) + len(git_conflicts.get("conflicting_files", []))
total_conflicts = summary.get("total_conflicts", 0) + len(
git_conflicts.get("conflicting_files", [])
)
conflict_files = summary.get("conflict_files", 0) + len(
git_conflicts.get("conflicting_files", [])
)
result = {
"success": True,
@@ -416,20 +473,24 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
"totalConflicts": total_conflicts,
"autoMergeable": summary.get("auto_mergeable", 0),
"hasGitConflicts": git_conflicts["has_conflicts"],
}
},
}
debug_success(MODULE, "Merge preview complete",
total_files=result["summary"]["totalFiles"],
total_conflicts=result["summary"]["totalConflicts"],
has_git_conflicts=git_conflicts["has_conflicts"],
auto_mergeable=result["summary"]["autoMergeable"])
debug_success(
MODULE,
"Merge preview complete",
total_files=result["summary"]["totalFiles"],
total_conflicts=result["summary"]["totalConflicts"],
has_git_conflicts=git_conflicts["has_conflicts"],
auto_mergeable=result["summary"]["autoMergeable"],
)
return result
except Exception as e:
debug_error(MODULE, "Merge preview failed", error=str(e))
import traceback
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
return {
"success": False,
@@ -442,5 +503,5 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
"conflictFiles": 0,
"totalConflicts": 0,
"autoMergeable": 0,
}
},
}
+3
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from core.client instead."""
import os
import sys
@@ -7,8 +8,10 @@ _auto_claude_dir = os.path.dirname(os.path.abspath(__file__))
if _auto_claude_dir not in sys.path:
sys.path.insert(0, _auto_claude_dir)
# Use lazy imports to avoid circular dependency
def __getattr__(name):
"""Lazy import to avoid circular imports with auto_claude_tools."""
from core import client as _client
return getattr(_client, name)
+3 -1
View File
@@ -22,7 +22,9 @@ except ImportError:
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.
+6
View File
@@ -16,21 +16,27 @@ __all__ = [
"ProgressTracker",
]
def __getattr__(name):
"""Lazy imports to avoid circular dependencies and heavy imports."""
if name in ("run_autonomous_agent", "run_followup_planner"):
from .agent import run_autonomous_agent, run_followup_planner
return locals()[name]
elif name == "WorkspaceManager":
from .workspace import WorkspaceManager
return WorkspaceManager
elif name == "WorktreeManager":
from .worktree import WorktreeManager
return WorktreeManager
elif name == "ProgressTracker":
from .progress import ProgressTracker
return ProgressTracker
elif name in ("create_claude_client", "ClaudeClient"):
from . import client as _client
return getattr(_client, name)
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).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
ELECTRON_TOOLS = [
"mcp__electron__electron_connect", # Connect to Electron app via DevTools
"mcp__electron__electron_screenshot", # Take screenshot of Electron window
"mcp__electron__electron_click", # Click element 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_get_window_info", # Get window state/bounds
"mcp__electron__electron_get_console", # Get console logs from renderer
"mcp__electron__electron_connect", # Connect to Electron app via DevTools
"mcp__electron__electron_screenshot", # Take screenshot of Electron window
"mcp__electron__electron_click", # Click element 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_get_window_info", # Get window state/bounds
"mcp__electron__electron_get_console", # Get console logs from renderer
]
# Built-in tools
@@ -217,7 +217,12 @@ def create_client(
# Allow Graphiti MCP tools for knowledge graph memory (if enabled)
*(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []),
# 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:
mcp_servers_list.append("graphiti-memory (knowledge graph)")
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:
mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
+215 -90
View File
@@ -46,13 +46,28 @@ try:
is_debug_enabled,
)
except ImportError:
def debug(*args, **kwargs): pass
def debug_detailed(*args, **kwargs): pass
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
def debug(*args, **kwargs):
pass
def debug_detailed(*args, **kwargs):
pass
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
from core.workspace.display import (
@@ -110,6 +125,7 @@ MODULE = "workspace"
# - _merge_file_with_ai
# - _heuristic_merge
def merge_existing_build(
project_dir: Path,
spec_name: str,
@@ -207,7 +223,7 @@ def merge_existing_build(
print()
print_status(
f"{len(remaining)} conflict(s) require manual resolution:",
"warning"
"warning",
)
_print_conflict_info(smart_result)
@@ -291,10 +307,13 @@ def _try_smart_merge_inner(
no_commit: bool = False,
) -> dict | None:
"""Inner implementation of smart merge (called with lock held)."""
debug(MODULE, "=== SMART MERGE START ===",
spec_name=spec_name,
worktree_path=str(worktree_path),
no_commit=no_commit)
debug(
MODULE,
"=== SMART MERGE START ===",
spec_name=spec_name,
worktree_path=str(worktree_path),
no_commit=no_commit,
)
try:
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}")
# Initialize the orchestrator
debug(MODULE, "Initializing MergeOrchestrator",
project_dir=str(project_dir),
enable_ai=True)
debug(
MODULE,
"Initializing MergeOrchestrator",
project_dir=str(project_dir),
enable_ai=True,
)
orchestrator = MergeOrchestrator(
project_dir,
enable_ai=True, # Enable AI for ambiguous conflicts
@@ -318,25 +340,38 @@ def _try_smart_merge_inner(
)
# Refresh evolution data from the worktree
debug(MODULE, "Refreshing evolution data from git",
spec_name=spec_name)
debug(MODULE, "Refreshing evolution data from git", spec_name=spec_name)
orchestrator.evolution_tracker.refresh_from_git(spec_name, worktree_path)
# Check for git-level conflicts first (branch divergence)
debug(MODULE, "Checking for git-level conflicts")
git_conflicts = _check_git_conflicts(project_dir, spec_name)
debug_detailed(MODULE, "Git conflict check result",
has_conflicts=git_conflicts.get("has_conflicts"),
conflicting_files=git_conflicts.get("conflicting_files", []),
base_branch=git_conflicts.get("base_branch"))
debug_detailed(
MODULE,
"Git conflict check result",
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"):
print(muted(f" Branch has diverged from {git_conflicts.get('base_branch', 'main')}"))
print(muted(f" Conflicting files: {len(git_conflicts.get('conflicting_files', []))}"))
print(
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",
num_conflicts=len(git_conflicts.get("conflicting_files", [])))
debug(
MODULE,
"Starting AI conflict resolution",
num_conflicts=len(git_conflicts.get("conflicting_files", [])),
)
# Try to 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"):
debug_success(MODULE, "AI conflict resolution succeeded",
resolved_files=resolution_result.get("resolved_files", []),
stats=resolution_result.get("stats", {}))
debug_success(
MODULE,
"AI conflict resolution succeeded",
resolved_files=resolution_result.get("resolved_files", []),
stats=resolution_result.get("stats", {}),
)
return resolution_result
else:
# AI couldn't resolve all conflicts
debug_error(MODULE, "AI conflict resolution failed",
remaining_conflicts=resolution_result.get("remaining_conflicts", []),
resolved_files=resolution_result.get("resolved_files", []),
error=resolution_result.get("error"))
debug_error(
MODULE,
"AI conflict resolution failed",
remaining_conflicts=resolution_result.get(
"remaining_conflicts", []
),
resolved_files=resolution_result.get("resolved_files", []),
error=resolution_result.get("error"),
)
return {
"success": False,
"conflicts": resolution_result.get("remaining_conflicts", []),
@@ -382,10 +425,7 @@ def _try_smart_merge_inner(
print(muted(f" Auto-mergeable: {auto_mergeable}/{len(conflicts)}"))
# Check if any conflicts need human review
needs_human = [
c for c in conflicts
if not c.get("can_auto_merge")
]
needs_human = [c for c in conflicts if not c.get("can_auto_merge")]
if needs_human:
return {
@@ -407,6 +447,7 @@ def _try_smart_merge_inner(
except Exception as e:
# If smart merge fails, fall back to git
import traceback
print(muted(f" Smart merge error: {e}"))
traceback.print_exc()
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
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,
capture_output=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
for line in output.split("\n"):
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:
file_path = match.group(1).strip()
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,
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(
["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,
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
conflicting = main_files & spec_files
@@ -550,18 +610,24 @@ def _resolve_git_conflicts_with_ai(
Dict with success, resolved_files, remaining_conflicts
"""
debug(MODULE, "=== AI CONFLICT RESOLUTION START ===",
spec_name=spec_name,
num_conflicting_files=len(git_conflicts.get("conflicting_files", [])))
debug(
MODULE,
"=== AI CONFLICT RESOLUTION START ===",
spec_name=spec_name,
num_conflicting_files=len(git_conflicts.get("conflicting_files", [])),
)
conflicting_files = git_conflicts.get("conflicting_files", [])
base_branch = git_conflicts.get("base_branch", "main")
spec_branch = git_conflicts.get("spec_branch", f"auto-claude/{spec_name}")
debug_detailed(MODULE, "Conflict resolution params",
base_branch=base_branch,
spec_branch=spec_branch,
conflicting_files=conflicting_files)
debug_detailed(
MODULE,
"Conflict resolution params",
base_branch=base_branch,
spec_branch=spec_branch,
conflicting_files=conflicting_files,
)
resolved_files = []
remaining_conflicts = []
@@ -569,7 +635,9 @@ def _resolve_git_conflicts_with_ai(
ai_merged_count = 0
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
merge_base_result = subprocess.run(
@@ -578,24 +646,38 @@ def _resolve_git_conflicts_with_ai(
capture_output=True,
text=True,
)
merge_base = 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)
merge_base = (
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
# This ensures dependencies exist before files that import them are written
changed_files = _get_changed_files_from_branch(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]
changed_files = _get_changed_files_from_branch(
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:
print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)..."))
for file_path, status in new_files:
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:
target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
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)
debug(MODULE, f"Copied new file: {file_path}")
except Exception as e:
@@ -603,7 +685,9 @@ def _resolve_git_conflicts_with_ai(
# Categorize conflicting files for processing
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")
@@ -612,15 +696,21 @@ def _resolve_git_conflicts_with_ai(
try:
# 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
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)
base_content = None
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:
# 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)")
else:
# Regular file - needs AI merge
files_needing_ai_merge.append(ParallelMergeTask(
file_path=file_path,
main_content=main_content,
worktree_content=worktree_content,
base_content=base_content,
spec_name=spec_name,
))
files_needing_ai_merge.append(
ParallelMergeTask(
file_path=file_path,
main_content=main_content,
worktree_content=worktree_content,
base_content=base_content,
spec_name=spec_name,
)
)
debug(MODULE, f" {file_path}: needs AI merge")
except Exception as e:
print(error(f" ✗ Failed to categorize {file_path}: {e}"))
remaining_conflicts.append({
"file": file_path,
"reason": str(e),
"severity": "high",
})
remaining_conflicts.append(
{
"file": file_path,
"reason": str(e),
"severity": "high",
}
)
# Process simple merges first (fast, no AI)
if simple_merges:
@@ -669,11 +763,17 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
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)
# Determine the type for display
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:
print(success(f"{file_path} (new file)"))
else:
@@ -681,23 +781,33 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path
if target_path.exists():
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)
print(success(f"{file_path} (deleted)"))
except Exception as e:
print(error(f"{file_path}: {e}"))
remaining_conflicts.append({
"file": file_path,
"reason": str(e),
"severity": "high",
})
remaining_conflicts.append(
{
"file": file_path,
"reason": str(e),
"severity": "high",
}
)
# Process AI merges in parallel
if files_needing_ai_merge:
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
start_time = time.time()
# Run parallel merges
@@ -717,7 +827,11 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / result.file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
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)
if result.was_auto_merged:
@@ -728,11 +842,13 @@ def _resolve_git_conflicts_with_ai(
print(success(f"{result.file_path} (AI merged)"))
else:
print(error(f"{result.file_path}: {result.error}"))
remaining_conflicts.append({
"file": result.file_path,
"reason": result.error or "AI could not resolve the conflict",
"severity": "high",
})
remaining_conflicts.append(
{
"file": result.file_path,
"reason": result.error or "AI could not resolve the conflict",
"severity": "high",
}
)
# Print summary
print()
@@ -749,7 +865,8 @@ def _resolve_git_conflicts_with_ai(
# Get list of modified/deleted files (new files already copied at start)
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
]
@@ -760,15 +877,21 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path
if target_path.exists():
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:
# 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:
target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
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)
except Exception as 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["partial_success"] = len(resolved_files) > 0
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:
print(muted(f" - {conflict['file']}: {conflict['reason']}"))
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.
"""
import importlib.util
import sys
from pathlib import Path
@@ -103,42 +104,42 @@ from .setup import (
__all__ = [
# Merge Operations (from workspace.py)
'merge_existing_build',
"merge_existing_build",
# '_run_parallel_merges', # TODO: not yet implemented - Private but used by tests
# Models
'WorkspaceMode',
'WorkspaceChoice',
'ParallelMergeTask',
'ParallelMergeResult',
'MergeLock',
'MergeLockError',
"WorkspaceMode",
"WorkspaceChoice",
"ParallelMergeTask",
"ParallelMergeResult",
"MergeLock",
"MergeLockError",
# Git Utils
'has_uncommitted_changes',
'get_current_branch',
'get_existing_build_worktree',
'get_file_content_from_ref',
'get_changed_files_from_branch',
'is_process_running',
'is_binary_file',
'validate_merged_syntax',
'create_conflict_file_with_git',
"has_uncommitted_changes",
"get_current_branch",
"get_existing_build_worktree",
"get_file_content_from_ref",
"get_changed_files_from_branch",
"is_process_running",
"is_binary_file",
"validate_merged_syntax",
"create_conflict_file_with_git",
# Setup
'choose_workspace',
'copy_spec_to_worktree',
'setup_workspace',
'ensure_timeline_hook_installed',
'initialize_timeline_tracking',
"choose_workspace",
"copy_spec_to_worktree",
"setup_workspace",
"ensure_timeline_hook_installed",
"initialize_timeline_tracking",
# Display
'show_build_summary',
'show_changed_files',
'print_merge_success',
'print_conflict_info',
"show_build_summary",
"show_changed_files",
"print_merge_success",
"print_conflict_info",
# Finalization
'finalize_workspace',
'handle_workspace_choice',
'review_existing_build',
'discard_existing_build',
'check_existing_build',
'list_all_worktrees',
'cleanup_all_worktrees',
"finalize_workspace",
"handle_workspace_choice",
"review_existing_build",
"discard_existing_build",
"check_existing_build",
"list_all_worktrees",
"cleanup_all_worktrees",
]
+20 -9
View File
@@ -6,7 +6,6 @@ Workspace Display
Functions for displaying workspace information and build summaries.
"""
from ui import (
bold,
error,
@@ -91,17 +90,25 @@ def print_merge_success(no_commit: bool, stats: dict | None = None) -> None:
if stats:
lines.append("What changed:")
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:
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:
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.extend([
"Your new feature is now part of your project.",
"The separate workspace has been cleaned up.",
])
lines.extend(
[
"Your new feature is now part of your project.",
"The separate workspace has been cleaned up.",
]
)
content = lines
print()
@@ -118,7 +125,11 @@ def print_conflict_info(result: dict) -> None:
return
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:
print(f" {highlight(conflict_file)}")
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
# then regenerate via package manager install
LOCK_FILES = {
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'bun.lockb',
'Pipfile.lock',
'poetry.lock',
'Cargo.lock',
'Gemfile.lock',
'composer.lock',
'go.sum',
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"Pipfile.lock",
"poetry.lock",
"Cargo.lock",
"Gemfile.lock",
"composer.lock",
"go.sum",
}
BINARY_EXTENSIONS = {
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.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',
".png",
".jpg",
".jpeg",
".gif",
".ico",
".webp",
".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
@@ -88,7 +122,9 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
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.)."""
result = subprocess.run(
["git", "show", f"{ref}:{file_path}"],
@@ -127,6 +163,7 @@ def get_changed_files_from_branch(
def is_process_running(pid: int) -> bool:
"""Check if a process with the given PID is running."""
import os
try:
os.kill(pid, 0)
return True
@@ -149,7 +186,9 @@ def is_lock_file(file_path: str) -> bool:
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.
@@ -166,11 +205,11 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
ext = P(file_path).suffix.lower()
# TypeScript/JavaScript validation using esbuild
if ext in {'.ts', '.tsx', '.js', '.jsx'}:
if ext in {".ts", ".tsx", ".js", ".jsx"}:
try:
# Write to temp file in system temp dir (NOT project dir to avoid HMR triggers)
with tempfile.NamedTemporaryFile(
mode='w',
mode="w",
suffix=ext,
delete=False,
# 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)
for search_dir in [project_dir, project_dir.parent]:
# pnpm stores it differently
pnpm_esbuild = search_dir / 'node_modules' / '.pnpm'
pnpm_esbuild = search_dir / "node_modules" / ".pnpm"
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():
esbuild_cmd = str(esbuild_dir)
break
# Standard npm/yarn location
npm_esbuild = search_dir / 'node_modules' / '.bin' / 'esbuild'
npm_esbuild = search_dir / "node_modules" / ".bin" / "esbuild"
if npm_esbuild.exists():
esbuild_cmd = str(npm_esbuild)
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
if not esbuild_cmd:
esbuild_cmd = 'npx'
args = ['npx', 'esbuild', tmp_path, '--log-level=error']
esbuild_cmd = "npx"
args = ["npx", "esbuild", tmp_path, "--log-level=error"]
else:
args = [esbuild_cmd, tmp_path, '--log-level=error']
args = [esbuild_cmd, tmp_path, "--log-level=error"]
# Use esbuild for fast, accurate syntax validation
# 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
error_output = result.stderr.strip()
error_lines = [
line for line in error_output.split('\n')
if line and not line.startswith('npm warn') and not line.startswith('npm WARN')
line
for line in error_output.split("\n")
if line
and not line.startswith("npm warn")
and not line.startswith("npm WARN")
]
if error_lines:
# 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 True, ""
@@ -242,15 +286,15 @@ def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> t
return True, "" # Other errors = skip validation
# Python validation
elif ext == '.py':
elif ext == ".py":
try:
compile(content, file_path, 'exec')
compile(content, file_path, "exec")
return True, ""
except SyntaxError as e:
return False, f"Python syntax error: {e.msg} at line {e.lineno}"
# JSON validation
elif ext == '.json':
elif ext == ".json":
try:
json.loads(content)
return True, ""
@@ -278,21 +322,27 @@ def create_conflict_file_with_git(
try:
# 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_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_path = wt_f.name
# Use empty base if not available
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_path = base_f.name
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_path = base_f.name
@@ -300,7 +350,7 @@ def create_conflict_file_with_git(
# git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
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,
capture_output=True,
text=True,
+4
View File
@@ -30,6 +30,7 @@ class WorkspaceChoice(Enum):
@dataclass
class ParallelMergeTask:
"""A file merge task to be executed in parallel."""
file_path: str
main_content: str
worktree_content: str
@@ -40,6 +41,7 @@ class ParallelMergeTask:
@dataclass
class ParallelMergeResult:
"""Result of a parallel merge task."""
file_path: str
merged_content: str | None
success: bool
@@ -49,6 +51,7 @@ class ParallelMergeResult:
class MergeLockError(Exception):
"""Raised when a merge lock cannot be acquired."""
pass
@@ -100,6 +103,7 @@ class MergeLock:
pid = int(self.lock_file.read_text().strip())
# Import locally to avoid circular dependency
import os as _os
try:
_os.kill(pid, 0)
is_running = True
+14 -5
View File
@@ -32,8 +32,13 @@ from .models import WorkspaceMode
try:
from debug import debug, debug_warning
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
_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)
from merge.install_hook import install_hook
install_hook(project_dir)
debug(MODULE, "Auto-installed FileTimelineTracker git hook")
@@ -333,9 +339,12 @@ def initialize_timeline_tracking(
task_intent=task_intent,
task_title=task_title,
)
debug(MODULE, f"Timeline tracking initialized for {spec_name}",
files_tracked=len(files_to_modify),
branch_point=branch_point[:8] if branch_point else None)
debug(
MODULE,
f"Timeline tracking initialized for {spec_name}",
files_tracked=len(files_to_modify),
branch_point=branch_point[:8] if branch_point else None,
)
else:
# Initialize retroactively from worktree if no plan
tracker.initialize_from_worktree(
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from spec.critique instead."""
from spec.critique import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from core.debug instead."""
from core.debug import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.graphiti.config instead."""
from integrations.graphiti.config import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.graphiti.providers_pkg instead."""
from integrations.graphiti.providers_pkg import * # noqa: F403
+1 -3
View File
@@ -46,9 +46,7 @@ class OutputStreamer:
Returns:
IdeationPhaseResult for the completed phase
"""
result = await phase_executor.execute_ideation_type(
ideation_type, max_retries
)
result = await phase_executor.execute_ideation_type(ideation_type, max_retries)
if result.success:
# 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."""
from implementation_plan 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."""
from integrations.linear.config import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.linear.integration instead."""
from integrations.linear.integration import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from integrations.linear.updater instead."""
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__)
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.
+9 -5
View File
@@ -29,7 +29,9 @@ class ConflictContext:
file_path: str
location: str
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
language: str = "unknown"
@@ -60,10 +62,12 @@ class ConflictContext:
content = content[:500] + "... (truncated)"
lines.append(f" Code: {content}")
lines.extend([
"",
f"CONFLICT: {self.conflict_description}",
])
lines.extend(
[
"",
f"CONFLICT: {self.conflict_description}",
]
)
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)
# 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(
+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."
# 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}
@@ -38,10 +38,10 @@ Return only the merged code block, wrapped in triple backticks with the language
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 = '''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.
@@ -54,7 +54,7 @@ For each conflict region, output the merged code in a separate code block labele
merged code
```
Resolve all conflicts now:'''
Resolve all conflicts now:"""
def format_merge_prompt(context: str, language: str) -> str:
+28 -16
View File
@@ -114,16 +114,20 @@ class AIResolver:
continue
relevant_changes = [
c for c in snapshot.semantic_changes
if c.location == conflict.location or locations_overlap(c.location, conflict.location)
c
for c in snapshot.semantic_changes
if c.location == conflict.location
or locations_overlap(c.location, conflict.location)
]
if relevant_changes:
task_changes.append((
snapshot.task_id,
snapshot.task_intent or "No intent specified",
relevant_changes,
))
task_changes.append(
(
snapshot.task_id,
snapshot.task_intent or "No intent specified",
relevant_changes,
)
)
# Determine language from file extension
language = infer_language(conflict.file_path)
@@ -264,9 +268,11 @@ class AIResolver:
if len(file_conflicts) == 1:
# Single conflict, resolve individually
baseline = baseline_codes.get(file_conflicts[0].location, "")
results.append(self.resolve_conflict(
file_conflicts[0], baseline, task_snapshots
))
results.append(
self.resolve_conflict(
file_conflicts[0], baseline, task_snapshots
)
)
else:
# Multiple conflicts in same file - batch resolve
result = self._resolve_file_batch(
@@ -277,9 +283,9 @@ class AIResolver:
# Resolve each individually
for conflict in conflicts:
baseline = baseline_codes.get(conflict.location, "")
results.append(self.resolve_conflict(
conflict, baseline, task_snapshots
))
results.append(
self.resolve_conflict(conflict, baseline, task_snapshots)
)
return results
@@ -317,7 +323,9 @@ class AIResolver:
results = []
for conflict in conflicts:
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
merged = results[0]
@@ -354,7 +362,9 @@ class AIResolver:
for conflict in conflicts:
# 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:
resolved.append(conflict)
@@ -364,7 +374,9 @@ class AIResolver:
# Return combined result
if resolved:
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,
merged_content=response, # Full response for manual extraction
conflicts_resolved=resolved,
+9 -6
View File
@@ -24,7 +24,11 @@ class MergeHelpers:
stripped = line.strip()
if MergeHelpers.is_import_line(stripped, ext):
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
if last_import_line > 0:
break
@@ -45,7 +49,9 @@ class MergeHelpers:
"""Extract the hook call from a change."""
if change.content_after:
# 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:
return match.group(0)
@@ -212,7 +218,4 @@ class MergeHelpers:
ChangeType.MODIFY_JSX_PROPS: 5,
}
return sorted(
all_changes,
key=lambda c: priority.get(c.change_type, 10)
)
return sorted(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 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)
# Append at the end (before any module.exports in JS)
@@ -69,7 +72,9 @@ class AppendMethodsStrategy(MergeStrategyHandler):
for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_METHOD and change.content_after:
# 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 not in new_methods:
new_methods[class_name] = []
@@ -77,7 +82,9 @@ class AppendMethodsStrategy(MergeStrategyHandler):
# Insert methods into their classes
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())
return MergeResult(
@@ -83,7 +83,9 @@ class HooksThenWrapStrategy(MergeStrategyHandler):
# First add 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
for wrapper_name, wrapper_props in wraps:
@@ -31,7 +31,10 @@ class ImportStrategy(MergeStrategyHandler):
for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_IMPORT and change.content_after:
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())
# Find where imports end in the file
@@ -48,7 +51,11 @@ class ImportStrategy(MergeStrategyHandler):
seen_imports = set()
new_imports = []
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)
seen_imports.add(imp)
@@ -27,15 +27,27 @@ class OrderByDependencyStrategy(MergeStrategyHandler):
for change in ordered_changes:
if change.content_after:
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)
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:
wrapper = MergeHelpers.extract_jsx_wrapper(change)
if wrapper:
func_name = change.target.split(".")[-1] if "." in change.target else change.target
content = MergeHelpers.wrap_function_return(content, func_name, wrapper[0], wrapper[1])
func_name = (
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(
decision=MergeDecision.AUTO_MERGED,
+38 -14
View File
@@ -132,7 +132,10 @@ class AutoMerger:
for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_IMPORT and change.content_after:
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())
# Find where imports end in the file
@@ -146,7 +149,8 @@ class AutoMerger:
existing_imports.add(stripped)
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
]
@@ -261,7 +265,10 @@ class AutoMerger:
for snapshot in context.task_snapshots:
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)
# Append at the end (before any module.exports in JS)
@@ -299,7 +306,9 @@ class AutoMerger:
for change in snapshot.semantic_changes:
if change.change_type == ChangeType.ADD_METHOD and change.content_after:
# 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 not in new_methods:
new_methods[class_name] = []
@@ -362,15 +371,27 @@ class AutoMerger:
for change in ordered_changes:
if change.content_after:
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)
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:
wrapper = self._extract_jsx_wrapper(change)
if wrapper:
func_name = change.target.split(".")[-1] if "." in change.target else change.target
content = self._wrap_function_return(content, func_name, wrapper[0], wrapper[1])
func_name = (
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(
decision=MergeDecision.AUTO_MERGED,
@@ -441,7 +462,11 @@ class AutoMerger:
stripped = line.strip()
if self._is_import_line(stripped, ext):
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
if last_import_line > 0:
break
@@ -460,7 +485,9 @@ class AutoMerger:
"""Extract the hook call from a change."""
if change.content_after:
# 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:
return match.group(0)
@@ -624,7 +651,4 @@ class AutoMerger:
ChangeType.MODIFY_JSX_PROPS: 5,
}
return sorted(
all_changes,
key=lambda c: priority.get(c.change_type, 10)
)
return sorted(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
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.
+33 -14
View File
@@ -30,9 +30,16 @@ from .types import (
try:
from debug import debug, debug_detailed, debug_verbose
except ImportError:
def debug(*args, **kwargs): pass
def debug_detailed(*args, **kwargs): pass
def debug_verbose(*args, **kwargs): pass
def debug(*args, **kwargs):
pass
def debug_detailed(*args, **kwargs):
pass
def debug_verbose(*args, **kwargs):
pass
logger = logging.getLogger(__name__)
MODULE = "merge.conflict_analysis"
@@ -53,8 +60,11 @@ def detect_conflicts(
List of detected conflict regions
"""
task_ids = list(task_analyses.keys())
debug(MODULE, f"Detecting conflicts between {len(task_analyses)} tasks",
tasks=task_ids)
debug(
MODULE,
f"Detecting conflicts between {len(task_analyses)} tasks",
tasks=task_ids,
)
if len(task_analyses) <= 1:
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)
for task_id, analysis in task_analyses.items():
debug_detailed(MODULE, f"Processing task {task_id}",
changes_count=len(analysis.changes),
file=analysis.file_path)
debug_detailed(
MODULE,
f"Processing task {task_id}",
changes_count=len(analysis.changes),
file=analysis.file_path,
)
for change in analysis.changes:
location_changes[change.location].append((task_id, change))
@@ -79,18 +92,24 @@ def detect_conflicts(
if len(task_changes) <= 1:
continue # No conflict at this location
debug_verbose(MODULE, f"Checking location {location}",
task_changes_count=len(task_changes))
debug_verbose(
MODULE,
f"Checking location {location}",
task_changes_count=len(task_changes),
)
file_path = next(iter(task_analyses.values())).file_path
conflict = analyze_location_conflict(
file_path, location, task_changes, rule_index
)
if conflict:
debug_detailed(MODULE, f"Conflict detected at {location}",
severity=conflict.severity.value,
can_auto_merge=conflict.can_auto_merge,
tasks=conflict.tasks_involved)
debug_detailed(
MODULE,
f"Conflict detected at {location}",
severity=conflict.severity.value,
can_auto_merge=conflict.can_auto_merge,
tasks=conflict.tasks_involved,
)
conflicts.append(conflict)
# Also check for implicit conflicts (e.g., changes to related code)
+22 -8
View File
@@ -48,8 +48,13 @@ from .types import (
try:
from debug import debug, debug_success
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__)
MODULE = "merge.conflict_detector"
@@ -81,7 +86,9 @@ class ConflictDetector:
debug(MODULE, "Initializing ConflictDetector")
self._rules = build_default_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:
"""
@@ -113,15 +120,21 @@ class ConflictDetector:
# Summary logging
auto_mergeable = sum(1 for c in conflicts if c.can_auto_merge)
from .types import ConflictSeverity
critical = sum(1 for c in conflicts if c.severity == ConflictSeverity.CRITICAL)
debug_success(MODULE, "Conflict detection complete",
total_conflicts=len(conflicts),
auto_mergeable=auto_mergeable,
critical=critical)
debug_success(
MODULE,
"Conflict detection complete",
total_conflicts=len(conflicts),
auto_mergeable=auto_mergeable,
critical=critical,
)
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.
@@ -166,4 +179,5 @@ def analyze_compatibility(
detector = ConflictDetector()
from .conflict_analysis import analyze_compatibility as analyze_compat_internal
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:
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:
lines.append("Cannot be auto-merged:")
lines.append(f" Reason: {conflict.reason}")
@@ -22,8 +22,13 @@ from .storage import EvolutionStorage
try:
from debug import debug, debug_success
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__)
MODULE = "merge.file_evolution.baseline_capture"
@@ -31,10 +36,25 @@ MODULE = "merge.file_evolution.baseline_capture"
# Default extensions to track for baselines
DEFAULT_EXTENSIONS = {
".py", ".js", ".ts", ".tsx", ".jsx",
".json", ".yaml", ".yml", ".toml",
".md", ".txt", ".html", ".css", ".scss",
".go", ".rs", ".java", ".kt", ".swift",
".py",
".js",
".ts",
".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:
files = self.discover_trackable_files()
debug(MODULE, f"Capturing baselines for {len(files)} files",
task_id=task_id)
debug(MODULE, f"Capturing baselines for {len(files)} files", task_id=task_id)
for file_path in files:
rel_path = self.storage.get_relative_path(file_path)
@@ -183,6 +202,7 @@ class BaselineCapture:
evolution.add_task_snapshot(snapshot)
captured[rel_path] = evolution
debug_success(MODULE, f"Captured baselines for {len(captured)} files",
task_id=task_id)
debug_success(
MODULE, f"Captured baselines for {len(captured)} files", task_id=task_id
)
return captured
@@ -148,10 +148,7 @@ class EvolutionQueries:
List of file paths modified by 2+ tasks
"""
file_tasks = self.get_files_modified_by_tasks(task_ids, evolutions)
return [
file_path for file_path, tasks in file_tasks.items()
if len(tasks) > 1
]
return [file_path for file_path, tasks in file_tasks.items() if len(tasks) > 1]
def get_active_tasks(
self,
@@ -238,10 +235,7 @@ class EvolutionQueries:
# Filter snapshots if task_ids specified
snapshots = evolution.task_snapshots
if task_ids:
snapshots = [
ts for ts in snapshots
if ts.task_id in task_ids
]
snapshots = [ts for ts in snapshots if ts.task_id in task_ids]
return {
"file_path": rel_path,
@@ -253,7 +247,9 @@ class EvolutionQueries:
"task_id": ts.task_id,
"intent": ts.task_intent,
"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],
"hash_before": ts.content_hash_before,
"hash_after": ts.content_hash_after,
@@ -282,8 +278,7 @@ class EvolutionQueries:
# Remove task snapshots from evolutions
for evolution in evolutions.values():
evolution.task_snapshots = [
ts for ts in evolution.task_snapshots
if ts.task_id != task_id
ts for ts in evolution.task_snapshots if ts.task_id != task_id
]
# Remove baseline directory if requested
@@ -23,8 +23,13 @@ from .storage import EvolutionStorage
try:
from debug import debug, debug_warning
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__)
MODULE = "merge.file_evolution.modification_tracker"
@@ -101,9 +106,7 @@ class ModificationTracker:
)
# Analyze semantic changes
analysis = self.analyzer.analyze_diff(
rel_path, old_content, new_content
)
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
semantic_changes = analysis.changes
# Update snapshot
@@ -138,9 +141,12 @@ class ModificationTracker:
worktree_path: Path to the task's worktree
evolutions: Current evolution data (will be updated)
"""
debug(MODULE, f"refresh_from_git() for task {task_id}",
task_id=task_id,
worktree_path=str(worktree_path))
debug(
MODULE,
f"refresh_from_git() for task {task_id}",
task_id=task_id,
worktree_path=str(worktree_path),
)
try:
# 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]
debug(MODULE, f"Found {len(changed_files)} changed files",
changed_files=changed_files[:10] if len(changed_files) > 10 else changed_files)
debug(
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:
# Get the diff for this file
@@ -197,7 +208,9 @@ class ModificationTracker:
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:
logger.error(f"Failed to refresh from git: {e}")
+14 -8
View File
@@ -25,8 +25,13 @@ from .storage import EvolutionStorage
try:
from debug import debug, debug_success
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__)
MODULE = "merge.file_evolution"
@@ -72,8 +77,7 @@ class FileEvolutionTracker:
storage_dir: Directory for evolution data (default: .auto-claude/)
semantic_analyzer: Optional pre-configured analyzer
"""
debug(MODULE, "Initializing FileEvolutionTracker",
project_dir=str(project_dir))
debug(MODULE, "Initializing FileEvolutionTracker", project_dir=str(project_dir))
self.project_dir = Path(project_dir).resolve()
storage_dir = storage_dir or (self.project_dir / ".auto-claude")
@@ -81,8 +85,7 @@ class FileEvolutionTracker:
# Initialize modular components
self.storage = EvolutionStorage(self.project_dir, storage_dir)
self.baseline_capture = BaselineCapture(
self.storage,
extensions=self.DEFAULT_EXTENSIONS
self.storage, extensions=self.DEFAULT_EXTENSIONS
)
self.modification_tracker = ModificationTracker(
self.storage,
@@ -93,8 +96,11 @@ class FileEvolutionTracker:
# Load existing evolution data
self._evolutions: dict[str, FileEvolution] = self.storage.load_evolutions()
debug_success(MODULE, "FileEvolutionTracker initialized",
evolutions_loaded=len(self._evolutions))
debug_success(
MODULE,
"FileEvolutionTracker initialized",
evolutions_loaded=len(self._evolutions),
)
# Expose storage_dir and baselines_dir for backward compatibility
@property
-2
View File
@@ -67,7 +67,6 @@ from .timeline_tracker import FileTimelineTracker
__all__ = [
# Main service
"FileTimelineTracker",
# Core data models
"MainBranchEvent",
"BranchPoint",
@@ -76,7 +75,6 @@ __all__ = [
"TaskFileView",
"FileTimeline",
"MergeContext",
# Helper components (advanced usage)
"TimelineGitHelper",
"TimelinePersistence",
+1 -3
View File
@@ -53,9 +53,7 @@ def find_worktree(project_dir: Path, task_id: str) -> Path | None:
return None
def get_file_from_branch(
project_dir: Path, file_path: str, branch: str
) -> str | None:
def get_file_from_branch(project_dir: Path, file_path: str, branch: str) -> str | None:
"""
Get file content from a specific git branch.
+5 -3
View File
@@ -14,7 +14,7 @@ import stat
import sys
from pathlib import Path
HOOK_SCRIPT = '''#!/bin/bash
HOOK_SCRIPT = """#!/bin/bash
#
# Git post-commit hook for FileTimelineTracker
# =============================================
@@ -53,7 +53,7 @@ fi
# Not main branch or in worktree, do nothing
exit 0
'''
"""
def find_project_root() -> Path:
@@ -114,7 +114,9 @@ def install_hook(project_path: Path) -> bool:
print(f"Created new hook at {hook_path}")
# 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")
return True
+4 -3
View File
@@ -94,11 +94,12 @@ class MergeReport:
"""Convert to dictionary for serialization."""
return {
"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,
"file_results": {
path: result.to_dict()
for path, result in self.file_results.items()
path: result.to_dict() for path, result in self.file_results.items()
},
"stats": self.stats.to_dict(),
"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.
"""
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("")
for event in context.main_evolution:
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}"
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}")
if event.diff_summary:
@@ -144,7 +148,9 @@ No other tasks are pending for this file.
branch_point = task.get("branch_point", "unknown")[:12]
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("")
@@ -156,7 +162,9 @@ def _build_compatibility_instructions(context: MergeContext) -> str:
if not context.other_pending_tasks:
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:")
for task in context.other_pending_tasks:
@@ -188,13 +196,15 @@ def build_simple_merge_prompt(
if task_intent:
intent_section = f"""
=== FEATURE BRANCH INTENT ({spec_name}) ===
Task: {task_intent.get('title', spec_name)}
Description: {task_intent.get('description', 'No description')}
Task: {task_intent.get("title", spec_name)}
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"
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.
@@ -259,17 +269,17 @@ def build_conflict_only_prompt(
intent_section = ""
if task_intent:
intent_section = f"""
FEATURE INTENT: {task_intent.get('title', spec_name)}
{task_intent.get('description', '')}
FEATURE INTENT: {task_intent.get("title", spec_name)}
{task_intent.get("description", "")}
"""
conflict_sections = []
for i, conflict in enumerate(conflicts, 1):
context_before = conflict.get('context_before', '')
context_after = conflict.get('context_after', '')
main_lines = conflict.get('main_lines', '')
worktree_lines = conflict.get('worktree_lines', '')
conflict_id = conflict.get('id', f'CONFLICT_{i}')
context_before = conflict.get("context_before", "")
context_after = conflict.get("context_after", "")
main_lines = conflict.get("main_lines", "")
worktree_lines = conflict.get("worktree_lines", "")
conflict_id = conflict.get("id", f"CONFLICT_{i}")
section = f"""
--- {conflict_id} ---
@@ -289,7 +299,7 @@ FEATURE BRANCH VERSION ({spec_name}):
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}
FILE: {file_path}
LANGUAGE: {language}
@@ -305,7 +315,7 @@ MERGE RULES:
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}
resolved code here
```
@@ -316,7 +326,7 @@ resolved code here
{"```" if len(conflicts) > 1 else ""}
(continue for each conflict)
'''
"""
return prompt
@@ -344,43 +354,49 @@ def parse_conflict_markers(content: str) -> tuple[list[dict], list[str]]:
# content from incoming branch
# >>>>>>> branch_name or commit_hash
conflict_pattern = re.compile(
r'<<<<<<<[^\n]*\n' # Start marker
r'(.*?)' # Main/HEAD content (group 1)
r'=======\n' # Separator
r'(.*?)' # Incoming/feature content (group 2)
r'>>>>>>>[^\n]*\n?', # End marker
re.DOTALL
r"<<<<<<<[^\n]*\n" # Start marker
r"(.*?)" # Main/HEAD content (group 1)
r"=======\n" # Separator
r"(.*?)" # Incoming/feature content (group 2)
r">>>>>>>[^\n]*\n?", # End marker
re.DOTALL,
)
last_end = 0
for i, match in enumerate(conflict_pattern.finditer(content), 1):
# 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)
# Extract context (last 3 lines before conflict)
before_lines = clean_before.rstrip().split('\n')
context_before = '\n'.join(before_lines[-3:]) if len(before_lines) >= 3 else clean_before.rstrip()
before_lines = clean_before.rstrip().split("\n")
context_before = (
"\n".join(before_lines[-3:])
if len(before_lines) >= 3
else clean_before.rstrip()
)
# Extract the conflict content
main_lines = match.group(1).rstrip('\n')
worktree_lines = match.group(2).rstrip('\n')
main_lines = match.group(1).rstrip("\n")
worktree_lines = match.group(2).rstrip("\n")
# Get context after (first 3 lines after conflict)
after_start = match.end()
after_content = content[after_start:after_start + 500] # Look ahead 500 chars
after_lines = after_content.split('\n')[:3]
context_after = '\n'.join(after_lines)
after_content = content[after_start : after_start + 500] # Look ahead 500 chars
after_lines = after_content.split("\n")[:3]
context_after = "\n".join(after_lines)
conflicts.append({
'id': f'CONFLICT_{i}',
'start': match.start(),
'end': match.end(),
'main_lines': main_lines,
'worktree_lines': worktree_lines,
'context_before': context_before,
'context_after': context_after,
})
conflicts.append(
{
"id": f"CONFLICT_{i}",
"start": match.start(),
"end": match.end(),
"main_lines": main_lines,
"worktree_lines": worktree_lines,
"context_before": context_before,
"context_after": context_after,
}
)
last_end = match.end()
@@ -408,32 +424,34 @@ def reassemble_with_resolutions(
Clean file with conflicts resolved
"""
# 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 = []
last_end = 0
for conflict in sorted_conflicts:
# 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)
conflict_id = conflict['id']
conflict_id = conflict["id"]
if conflict_id in resolutions:
result_parts.append(resolutions[conflict_id])
else:
# 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
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.
@@ -452,28 +470,25 @@ def extract_conflict_resolutions(response: str, conflicts: list[dict], language:
# Pattern to match resolution blocks
# --- CONFLICT_1 RESOLVED --- or similar variations
resolution_pattern = re.compile(
r'---\s*(CONFLICT_\d+)\s*RESOLVED\s*---\s*\n'
r'```(?:\w+)?\n'
r'(.*?)'
r'```',
re.DOTALL | re.IGNORECASE
r"---\s*(CONFLICT_\d+)\s*RESOLVED\s*---\s*\n"
r"```(?:\w+)?\n"
r"(.*?)"
r"```",
re.DOTALL | re.IGNORECASE,
)
for match in resolution_pattern.finditer(response):
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
# Fallback: if only one conflict and we can find a single code block
if len(conflicts) == 1 and not resolutions:
code_block_pattern = re.compile(
r'```(?:\w+)?\n(.*?)```',
re.DOTALL
)
code_block_pattern = re.compile(r"```(?:\w+)?\n(.*?)```", re.DOTALL)
matches = list(code_block_pattern.finditer(response))
if matches:
# 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
@@ -536,8 +551,6 @@ def optimize_prompt_for_length(
context.task_worktree_content = _trim_content(
context.task_worktree_content, "worktree"
)
context.current_main_content = _trim_content(
context.current_main_content, "main"
)
context.current_main_content = _trim_content(context.current_main_content, "main")
return context
@@ -100,9 +100,7 @@ def extract_python_elements(
# Handle decorated functions/classes
for sub in child.children:
if sub.type in {"function_definition", "class_definition"}:
extract_python_elements(
child, elements, get_text, get_line, parent
)
extract_python_elements(child, elements, get_text, get_line, parent)
break
# Recurse for other compound statements
+29 -7
View File
@@ -23,9 +23,16 @@ logger = logging.getLogger(__name__)
try:
from debug import debug, debug_error, debug_warning
except ImportError:
def debug(*args, **kwargs): pass
def debug_error(*args, **kwargs): pass
def debug_warning(*args, **kwargs): pass
def debug(*args, **kwargs):
pass
def debug_error(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
MODULE = "merge.timeline_git"
@@ -60,7 +67,9 @@ class TimelineGitHelper:
except subprocess.CalledProcessError:
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.
@@ -96,7 +105,14 @@ class TimelineGitHelper:
"""
try:
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,
capture_output=True,
text=True,
@@ -146,7 +162,11 @@ class TimelineGitHelper:
text=True,
)
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:
pass
@@ -165,7 +185,9 @@ class TimelineGitHelper:
File content as string, or empty string if file doesn't exist
"""
# 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
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
history that all task worktrees diverge from and merge back into.
"""
# Git identification
commit_hash: str
timestamp: datetime
@@ -34,7 +35,7 @@ class MainBranchEvent:
content: str
# Source of change
source: Literal['human', 'merged_task']
source: Literal["human", "merged_task"]
merged_from_task: str | None = None # If source is 'merged_task'
# Intent/reason for change
@@ -73,6 +74,7 @@ class MainBranchEvent:
@dataclass
class BranchPoint:
"""The exact point a task branched from main."""
commit_hash: str
content: str
timestamp: datetime
@@ -96,6 +98,7 @@ class BranchPoint:
@dataclass
class WorktreeState:
"""Current state of a file in a task's worktree."""
content: str
last_modified: datetime
@@ -116,6 +119,7 @@ class WorktreeState:
@dataclass
class TaskIntent:
"""What the task intends to do with this file."""
title: str
description: str
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
sees and modifies one file.
"""
task_id: str
# The exact point this task branched from main
@@ -159,14 +164,16 @@ class TaskFileView:
commits_behind_main: int = 0
# Lifecycle status
status: Literal['active', 'merged', 'abandoned'] = 'active'
status: Literal["active", "merged", "abandoned"] = "active"
merged_at: datetime | None = None
def to_dict(self) -> dict:
return {
"task_id": self.task_id,
"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(),
"commits_behind_main": self.commits_behind_main,
"status": self.status,
@@ -178,11 +185,17 @@ class TaskFileView:
return cls(
task_id=data["task_id"],
branch_point=BranchPoint.from_dict(data["branch_point"]),
worktree_state=WorktreeState.from_dict(data["worktree_state"]) if data.get("worktree_state") else None,
task_intent=TaskIntent.from_dict(data["task_intent"]) if data.get("task_intent") else TaskIntent("", ""),
worktree_state=WorktreeState.from_dict(data["worktree_state"])
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),
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?",
we ask "what happened to File Y over time, from ALL sources?"
"""
file_path: str
# Main branch evolution - the authoritative history
@@ -213,7 +227,7 @@ class FileTimeline:
# Update commits_behind_main for all active tasks
for task_view in self.task_views.values():
if task_view.status == 'active':
if task_view.status == "active":
task_view.commits_behind_main += 1
def add_task_view(self, task_view: TaskFileView) -> None:
@@ -227,7 +241,7 @@ class FileTimeline:
def get_active_tasks(self) -> list[TaskFileView]:
"""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]:
"""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
merge decisions.
"""
file_path: str
# The task being merged
+4 -1
View File
@@ -27,7 +27,10 @@ logger = logging.getLogger(__name__)
try:
from debug import debug
except ImportError:
def debug(*args, **kwargs): pass
def debug(*args, **kwargs):
pass
MODULE = "merge.timeline_persistence"
+79 -40
View File
@@ -34,9 +34,16 @@ logger = logging.getLogger(__name__)
try:
from debug import debug, debug_success, debug_warning
except ImportError:
def debug(*args, **kwargs): pass
def debug_success(*args, **kwargs): pass
def debug_warning(*args, **kwargs): pass
def debug(*args, **kwargs):
pass
def debug_success(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
MODULE = "merge.timeline_tracker"
@@ -56,8 +63,9 @@ class FileTimelineTracker:
project_path: Root directory of the project
storage_path: Directory for timeline storage (default: .auto-claude/)
"""
debug(MODULE, "Initializing FileTimelineTracker",
project_path=str(project_path))
debug(
MODULE, "Initializing FileTimelineTracker", project_path=str(project_path)
)
self.project_path = Path(project_path).resolve()
self.storage_path = storage_path or (self.project_path / ".auto-claude")
@@ -72,8 +80,11 @@ class FileTimelineTracker:
# Load existing timelines
self._timelines = self.persistence.load_all_timelines()
debug_success(MODULE, "FileTimelineTracker initialized",
timelines_loaded=len(self._timelines))
debug_success(
MODULE,
"FileTimelineTracker initialized",
timelines_loaded=len(self._timelines),
)
# =========================================================================
# EVENT HANDLERS
@@ -103,9 +114,12 @@ class FileTimelineTracker:
task_intent: Description of what the task intends to do
task_title: Short title for the task
"""
debug(MODULE, f"on_task_start: {task_id}",
files_to_modify=files_to_modify,
branch_point=branch_point_commit)
debug(
MODULE,
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
if not branch_point_commit:
@@ -118,7 +132,9 @@ class FileTimelineTracker:
timeline = self._get_or_create_timeline(file_path)
# 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:
# File doesn't exist at this commit - might be created by task
content = ""
@@ -137,13 +153,15 @@ class FileTimelineTracker:
from_plan=bool(task_intent),
),
commits_behind_main=0,
status='active',
status="active",
)
timeline.add_task_view(task_view)
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:
"""
@@ -180,17 +198,20 @@ class FileTimelineTracker:
commit_hash=commit_hash,
timestamp=datetime.now(),
content=content,
source='human',
commit_message=commit_info.get('message', ''),
author=commit_info.get('author'),
diff_summary=commit_info.get('diff_summary'),
source="human",
commit_message=commit_info.get("message", ""),
author=commit_info.get("author"),
diff_summary=commit_info.get("diff_summary"),
)
timeline.add_main_event(event)
self._persist_timeline(file_path)
debug_success(MODULE, f"Processed main commit {commit_hash[:8]}",
files_updated=len(changed_files))
debug_success(
MODULE,
f"Processed main commit {commit_hash[:8]}",
files_updated=len(changed_files),
)
def on_task_worktree_change(
self,
@@ -256,7 +277,7 @@ class FileTimelineTracker:
continue
# Mark task as merged
task_view.status = 'merged'
task_view.status = "merged"
task_view.merged_at = datetime.now()
# Add main branch event for the merge
@@ -266,7 +287,7 @@ class FileTimelineTracker:
commit_hash=merge_commit,
timestamp=datetime.now(),
content=content,
source='merged_task',
source="merged_task",
merged_from_task=task_id,
commit_message=f"Merged from {task_id}",
)
@@ -294,7 +315,7 @@ class FileTimelineTracker:
task_view = timeline.get_task_view(task_id)
if task_view:
task_view.status = 'abandoned'
task_view.status = "abandoned"
self._persist_timeline(file_path)
@@ -325,16 +346,26 @@ class FileTimelineTracker:
task_view = timeline.get_task_view(task_id)
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
# 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
current_main = timeline.get_current_main_state()
current_main_content = 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
current_main_content = (
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
worktree_content = ""
@@ -348,12 +379,14 @@ class FileTimelineTracker:
other_tasks = []
for tv in timeline.get_active_tasks():
if tv.task_id != task_id:
other_tasks.append({
"task_id": tv.task_id,
"intent": tv.task_intent.description,
"branch_point": tv.branch_point.commit_hash,
"commits_behind": tv.commits_behind_main,
})
other_tasks.append(
{
"task_id": tv.task_id,
"intent": tv.task_intent.description,
"branch_point": tv.branch_point.commit_hash,
"commits_behind": tv.commits_behind_main,
}
)
context = MergeContext(
file_path=file_path,
@@ -369,10 +402,13 @@ class FileTimelineTracker:
total_pending_tasks=len(other_tasks),
)
debug_success(MODULE, "Built merge context",
commits_behind=task_view.commits_behind_main,
main_events=len(main_evolution),
other_tasks=len(other_tasks))
debug_success(
MODULE,
"Built merge context",
commits_behind=task_view.commits_behind_main,
main_events=len(main_evolution),
other_tasks=len(other_tasks),
)
return context
@@ -420,7 +456,7 @@ class FileTimelineTracker:
drift = {}
for file_path, timeline in self._timelines.items():
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
return drift
@@ -532,9 +568,12 @@ class FileTimelineTracker:
task_view.commits_behind_main = drift
self._persist_timeline(file_path)
debug_success(MODULE, "Initialized from worktree",
files=len(changed_files),
branch_point=branch_point[:8])
debug_success(
MODULE,
"Initialized from worktree",
files=len(changed_files),
branch_point=branch_point[:8],
)
except Exception as 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) ---")
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) ---")
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) ---")
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):
@@ -136,7 +140,9 @@ def cmd_list_files(args):
for file_path in sorted(tracker._timelines.keys()):
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)
print(f" {file_path}: {active_tasks} active tasks, {main_events} main events")
@@ -171,47 +177,42 @@ def main():
# notify-commit
notify_parser = subparsers.add_parser(
"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.set_defaults(func=cmd_notify_commit)
# show-timeline
timeline_parser = subparsers.add_parser(
"show-timeline",
help="Show the timeline for a file"
"show-timeline", 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)
# show-drift
drift_parser = subparsers.add_parser(
"show-drift",
help="Show commits-behind-main for a task"
"show-drift", help="Show commits-behind-main for a task"
)
drift_parser.add_argument("task_id", help="The task ID")
drift_parser.set_defaults(func=cmd_show_drift)
# show-context
context_parser = subparsers.add_parser(
"show-context",
help="Show merge context for a task and file"
"show-context", help="Show merge context for a task and file"
)
context_parser.add_argument("task_id", help="The task ID")
context_parser.add_argument("file_path", help="The file path")
context_parser.set_defaults(func=cmd_show_context)
# list-files
list_parser = subparsers.add_parser(
"list-files",
help="List all tracked files"
)
list_parser = subparsers.add_parser("list-files", help="List all tracked files")
list_parser.set_defaults(func=cmd_list_files)
# init-from-worktree
init_parser = subparsers.add_parser(
"init-from-worktree",
help="Initialize tracking from an existing worktree"
"init-from-worktree", help="Initialize tracking from an existing worktree"
)
init_parser.add_argument("task_id", help="The task ID")
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],
"severity": self.severity.value,
"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,
}
@@ -344,7 +346,9 @@ class ConflictRegion:
change_types=[ChangeType(ct) for ct in data["change_types"]],
severity=ConflictSeverity(data["severity"]),
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", ""),
)
@@ -383,7 +387,9 @@ class TaskSnapshot:
"task_id": self.task_id,
"task_intent": self.task_intent,
"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_after": self.content_hash_after,
"semantic_changes": [c.to_dict() for c in self.semantic_changes],
@@ -397,10 +403,14 @@ class TaskSnapshot:
task_id=data["task_id"],
task_intent=data["task_intent"],
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_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"),
)
@@ -449,7 +459,9 @@ class FileEvolution:
baseline_captured_at=datetime.fromisoformat(data["baseline_captured_at"]),
baseline_content_hash=data["baseline_content_hash"],
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:
@@ -463,8 +475,7 @@ class FileEvolution:
"""Add or update a task snapshot."""
# Remove existing snapshot for this task if present
self.task_snapshots = [
ts for ts in self.task_snapshots
if ts.task_id != snapshot.task_id
ts for ts in self.task_snapshots if ts.task_id != snapshot.task_id
]
self.task_snapshots.append(snapshot)
# Keep sorted by start time
@@ -528,12 +539,15 @@ class MergeResult:
@property
def needs_human_review(self) -> bool:
"""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:
"""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:
+2 -5
View File
@@ -183,8 +183,7 @@ class InvestigationPlanGenerator(PlanGenerator):
description="Add detailed logging around suspected problem areas",
expected_output="Logs capture relevant state changes and events",
files_to_modify=[
f.get("path", "")
for f in self.context.files_to_modify[:3]
f.get("path", "") for f in self.context.files_to_modify[:3]
],
),
Subtask(
@@ -365,9 +364,7 @@ class RefactorPlanGenerator(PlanGenerator):
)
def get_plan_generator(
context: PlannerContext, spec_dir: Path
) -> PlanGenerator:
def get_plan_generator(context: PlannerContext, spec_dir: Path) -> PlanGenerator:
"""Factory function to get the appropriate plan generator."""
if context.workflow_type == WorkflowType.INVESTIGATION:
return InvestigationPlanGenerator(context, spec_dir)
-1
View File
@@ -2,7 +2,6 @@
Utility functions for implementation planner.
"""
from implementation_plan import Verification, VerificationType
from .models import PlannerContext
+1 -3
View File
@@ -64,9 +64,7 @@ class ChecklistFormatter:
# Escape pipe characters in content
desc = issue.description.replace("|", "\\|")
prev = issue.prevention.replace("|", "\\|")
lines.append(
f"| {desc} | {issue.likelihood.capitalize()} | {prev} |"
)
lines.append(f"| {desc} | {issue.likelihood.capitalize()} | {prev} |")
lines.append("")
return lines
+3 -7
View File
@@ -210,17 +210,14 @@ def detect_work_type(subtask: dict) -> list[str]:
# API endpoint detection
if any(
kw in description
for kw in ["endpoint", "api", "route", "request", "response"]
kw in description for kw in ["endpoint", "api", "route", "request", "response"]
):
work_types.append("api_endpoint")
if any("routes" in f or "api" in f for f in files):
work_types.append("api_endpoint")
# Database model detection
if any(
kw in description for kw in ["model", "database", "migration", "schema"]
):
if any(kw in description for kw in ["model", "database", "migration", "schema"]):
work_types.append("database_model")
if any("models" in f or "migration" in f for f in files):
work_types.append("database_model")
@@ -239,8 +236,7 @@ def detect_work_type(subtask: dict) -> list[str]:
# Authentication detection
if any(
kw in description
for kw in ["auth", "login", "password", "token", "session"]
kw in description for kw in ["auth", "login", "password", "token", "session"]
):
work_types.append("authentication")
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from core.progress instead."""
from core.progress import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from prompts_pkg.prompt_generator instead."""
from prompts_pkg.prompt_generator import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from prompts_pkg.prompts instead."""
from prompts_pkg.prompts import * # noqa: F403
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from qa package instead."""
from qa import (
ISSUE_SIMILARITY_THRESHOLD,
# Configuration
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from services.recovery instead."""
from services.recovery import (
FailureType,
RecoveryAction,
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from analysis.risk_classifier instead."""
from analysis.risk_classifier import (
AssessmentFlags,
ComplexityAnalysis,
@@ -40,9 +40,7 @@ class ClaudeAnalysisClient:
def _validate_oauth_token(self) -> None:
"""Validate that CLAUDE_CODE_OAUTH_TOKEN is set."""
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
raise ValueError(
"CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token"
)
raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token")
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("=" * 60 + "\n")
def _get_analyzers_to_run(
self, selected_analyzers: list[str] | None
) -> list[str]:
def _get_analyzers_to_run(self, selected_analyzers: list[str] | None) -> list[str]:
"""
Determine which analyzers to run.
@@ -53,16 +53,25 @@ class CompetitorAnalyzer:
)
if not self.discovery_file.exists():
print_status("Discovery file not found, skipping competitor analysis", "warning")
self._create_error_analysis_file("Discovery file not found - cannot analyze competitors without project context")
print_status(
"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(
"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 = []
for attempt in range(MAX_RETRIES):
print_status(
f"Running competitor analysis agent (attempt {attempt + 1})...", "progress"
f"Running competitor analysis agent (attempt {attempt + 1})...",
"progress",
)
context = self._build_context()
@@ -82,7 +91,10 @@ class CompetitorAnalyzer:
)
# 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:
print(f" {muted('Error:')} {err}")
@@ -117,12 +129,11 @@ Output your findings to competitor_analysis.json.
if "competitors" in data:
competitor_count = len(data.get("competitors", []))
pain_point_count = sum(
len(c.get("pain_points", []))
for c in data.get("competitors", [])
len(c.get("pain_points", [])) for c in data.get("competitors", [])
)
print_status(
f"Analyzed {competitor_count} competitors, found {pain_point_count} pain points",
"success"
"success",
)
return RoadmapPhaseResult(
"competitor_analysis", True, [str(self.analysis_file)], [], 0
@@ -145,7 +156,7 @@ Output your findings to competitor_analysis.json.
"insights_summary": {
"top_pain_points": [],
"differentiator_opportunities": [],
"market_trends": []
"market_trends": [],
},
"created_at": datetime.now().isoformat(),
},
@@ -163,7 +174,7 @@ Output your findings to competitor_analysis.json.
"insights_summary": {
"top_pain_points": [],
"differentiator_opportunities": [],
"market_trends": []
"market_trends": [],
},
"created_at": datetime.now().isoformat(),
}
+3 -1
View File
@@ -156,5 +156,7 @@ class AgentExecutor:
return True, response_text
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)
@@ -36,13 +36,17 @@ class GraphHintsProvider:
hints_file=str(self.hints_file),
)
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():
debug("roadmap_graph", "Graphiti not enabled, creating placeholder")
print_status("Graphiti not enabled, skipping graph hints", "info")
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")
print_status("Querying Graphiti for roadmap insights...", "progress")
@@ -63,7 +67,9 @@ class GraphHintsProvider:
else:
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:
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
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)
print_status("Copied existing project_index.json", "success")
debug_success("roadmap_phase", "Project index copied successfully")
@@ -111,7 +113,9 @@ class DiscoveryPhase:
if self.discovery_file.exists() and not self.refresh:
debug("roadmap_phase", "roadmap_discovery.json already exists, skipping")
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 = []
for attempt in range(MAX_RETRIES):
@@ -189,9 +193,7 @@ Do NOT ask questions. Make educated inferences and create the file.
return None
except json.JSONDecodeError as e:
debug_error(
"roadmap_phase", "Invalid JSON in discovery file", error=str(e)
)
debug_error("roadmap_phase", "Invalid JSON in discovery file", error=str(e))
return None
@@ -313,7 +315,9 @@ Output the complete roadmap to roadmap.json.
)
else:
if missing:
debug_warning("roadmap_phase", f"Missing required fields: {missing}")
debug_warning(
"roadmap_phase", f"Missing required fields: {missing}"
)
else:
debug_warning(
"roadmap_phase",
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from security.scan_secrets instead."""
from security.scan_secrets import * # noqa: F403
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from security module instead."""
from security import * # noqa: F403
@@ -5,7 +5,6 @@ Validator Registry
Central registry mapping command names to their validation functions.
"""
from .database_validators import (
validate_dropdb_command,
validate_dropuser_command,
+1
View File
@@ -1,2 +1,3 @@
"""Backward compatibility shim - import from analysis.security_scanner instead."""
from analysis.security_scanner import * # noqa: F403
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from services.orchestrator instead."""
from services.orchestrator import (
OrchestrationResult,
ServiceConfig,
+1 -3
View File
@@ -10,9 +10,7 @@ import sys
from pathlib import Path
def run_script(
project_dir: Path, script: str, args: list[str]
) -> tuple[bool, str]:
def run_script(project_dir: Path, script: str, args: list[str]) -> tuple[bool, str]:
"""
Run a Python script and return (success, output).
+6 -7
View File
@@ -326,9 +326,7 @@ class SpecOrchestrator:
if linear_state:
print_status(f"Linear task created: {linear_state.task_id}", "success")
else:
print_status(
"Linear task creation failed (continuing without)", "warning"
)
print_status("Linear task creation failed (continuing without)", "warning")
async def _phase_complexity_assessment_with_requirements(
self,
@@ -409,9 +407,7 @@ class SpecOrchestrator:
print_status(f"Complexity override: {comp.value.upper()}", "success")
return assessment
async def _run_ai_assessment(
self, task_logger
) -> complexity.ComplexityAssessment:
async def _run_ai_assessment(self, task_logger) -> complexity.ComplexityAssessment:
"""Run AI-based complexity assessment.
Args:
@@ -581,7 +577,10 @@ class SpecOrchestrator:
parent = self.spec_dir.parent
prefix = self.spec_dir.name[:4] # e.g., "001-"
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
break
return result
+3 -1
View File
@@ -62,7 +62,9 @@ class StreamingLogCapture:
if self.current_tool == tool_name:
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.
+1
View File
@@ -1,4 +1,5 @@
"""Backward compatibility shim - import from analysis.test_discovery instead."""
from analysis.test_discovery import (
FRAMEWORK_PATTERNS,
TestDiscovery,
+1
View File
@@ -41,4 +41,5 @@ def __getattr__(name):
raise AttributeError(f"module 'validate_spec' has no attribute '{name}'")
__all__ = ["SpecValidator", "ValidationResult", "auto_fix_plan"]
+3 -1
View File
@@ -36,4 +36,6 @@ def __getattr__(name):
if str(spec_dir) in sys.path:
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."""
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))
# Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py)
if 'core' not in sys.modules:
_core_module = ModuleType('core')
_core_module.__file__ = str(_auto_claude_dir / 'core' / '__init__.py')
_core_module.__path__ = [str(_auto_claude_dir / 'core')]
sys.modules['core'] = _core_module
if "core" not in sys.modules:
_core_module = ModuleType("core")
_core_module.__file__ = str(_auto_claude_dir / "core" / "__init__.py")
_core_module.__path__ = [str(_auto_claude_dir / "core")]
sys.modules["core"] = _core_module
# Now load core.workspace package directly
_workspace_init = _auto_claude_dir / 'core' / 'workspace' / '__init__.py'
_spec = importlib.util.spec_from_file_location('core.workspace', _workspace_init)
_workspace_init = _auto_claude_dir / "core" / "workspace" / "__init__.py"
_spec = importlib.util.spec_from_file_location("core.workspace", _workspace_init)
_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)
# 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))
# Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py)
if 'core' not in sys.modules:
_core_module = ModuleType('core')
_core_module.__file__ = str(_auto_claude_dir / 'core' / '__init__.py')
_core_module.__path__ = [str(_auto_claude_dir / 'core')]
sys.modules['core'] = _core_module
if "core" not in sys.modules:
_core_module = ModuleType("core")
_core_module.__file__ = str(_auto_claude_dir / "core" / "__init__.py")
_core_module.__path__ = [str(_auto_claude_dir / "core")]
sys.modules["core"] = _core_module
# Now load core.worktree directly
_worktree_file = _auto_claude_dir / 'core' / 'worktree.py'
_spec = importlib.util.spec_from_file_location('core.worktree', _worktree_file)
_worktree_file = _auto_claude_dir / "core" / "worktree.py"
_spec = importlib.util.spec_from_file_location("core.worktree", _worktree_file)
_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)
# Re-export everything from core.worktree