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