diff --git a/.gitignore b/.gitignore
index d47af41..3a4eb0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,5 @@ frontend/dist/
*.log
.claude/settings.local.json
*.tsbuildinfo
+tools/dotnet-reflector/bin/
+tools/dotnet-reflector/obj/
diff --git a/backend/container.py b/backend/container.py
index abbf792..34294b2 100644
--- a/backend/container.py
+++ b/backend/container.py
@@ -76,7 +76,7 @@ class Container(containers.DeclarativeContainer):
git_service = providers.Factory(GitService, db=db)
# --- Planning & Decomposition ---
- planner = providers.Factory(PlannerService, db=db, budget=budget)
+ planner = providers.Factory(PlannerService, db=db, budget=budget, tool_registry=tool_registry)
decomposer = providers.Factory(DecomposerService, db=db)
# --- Executor (depends on all services) ---
diff --git a/backend/services/claude_agent.py b/backend/services/claude_agent.py
index ca93bba..105124c 100644
--- a/backend/services/claude_agent.py
+++ b/backend/services/claude_agent.py
@@ -17,6 +17,52 @@
logger = logging.getLogger("orchestration.executor")
+def _extract_csharp_context(context: list[dict]) -> dict | None:
+ """Extract C# method task context entries if present.
+
+ Returns a dict with target_signature, available_methods, constructor_params,
+ or None if this is not a C# method task.
+ """
+ result = {}
+ for ctx in context:
+ ctx_type = ctx.get("type", "")
+ if ctx_type == "target_signature":
+ result["target_signature"] = ctx.get("content", "")
+ elif ctx_type == "available_methods":
+ result["available_methods"] = ctx.get("content", "")
+ elif ctx_type == "constructor_params":
+ result["constructor_params"] = ctx.get("content", "")
+ return result if "target_signature" in result else None
+
+
+def _build_csharp_worker_prompt(csharp_ctx: dict, task_row: dict) -> str:
+ """Build a structured WorkerInstruction prompt for C# method tasks."""
+ signature = csharp_ctx.get("target_signature", "")
+ available = csharp_ctx.get("available_methods", "None")
+ ctor_params = csharp_ctx.get("constructor_params", "None")
+ description = task_row.get("description", "")
+
+ return (
+ "\n"
+ " \n"
+ f" {signature}\n"
+ f" {ctor_params}\n"
+ f" \n{available}\n \n"
+ " \n\n"
+ f" \n{description}\n \n\n"
+ " \n"
+ " Output ONLY the method body. Do not wrap in a class or add using statements.\n"
+ " Use the AvailableMethods strictly. Do not hallucinate helper methods that don't exist.\n"
+ " If the LogicGoal requires a method that doesn't exist in AvailableMethods, "
+ "note it clearly in your output so the orchestrator can create it.\n"
+ " Keep the implementation under 50 lines. If more is needed, "
+ "split logic into private helpers and note them.\n"
+ " \n\n"
+ " Return raw C# code only. No markdown formatting blocks.\n"
+ ""
+ )
+
+
async def run_claude_task(
*,
task_row,
@@ -48,7 +94,14 @@ async def run_claude_task(
# Build context
context = json.loads(task_row["context_json"]) if task_row["context_json"] else []
- system_parts = [task_row["system_prompt"] or "You are a focused task executor."]
+
+ # Check for C# method task — uses structured WorkerInstruction prompt
+ csharp_context = _extract_csharp_context(context)
+ if csharp_context:
+ system_parts = [_build_csharp_worker_prompt(csharp_context, task_row)]
+ else:
+ system_parts = [task_row["system_prompt"] or "You are a focused task executor."]
+
system_parts.append(
"\n\n"
"If you discover any constraints, gotchas, API quirks, or architectural "
@@ -58,6 +111,8 @@ async def run_claude_task(
)
for ctx in context:
ctx_type = ctx.get("type", "context")
+ if ctx_type in ("target_signature", "available_methods", "constructor_params"):
+ continue # Already injected into WorkerInstruction
system_parts.append(f"\n<{ctx_type}>\n{ctx.get('content', '')}\n{ctx_type}>")
# Inject project knowledge from earlier tasks
diff --git a/backend/services/decomposer.py b/backend/services/decomposer.py
index e277677..830b47d 100644
--- a/backend/services/decomposer.py
+++ b/backend/services/decomposer.py
@@ -131,6 +131,19 @@ async def decompose(self, project_id: str, plan_id: str) -> dict:
if files:
context.append({"type": "affected_files", "content": ", ".join(files)})
+ # C# reflection context: inject typed contracts for method-level tasks
+ target_sig = task_def.get("target_signature")
+ if target_sig:
+ context.append({"type": "target_signature", "content": target_sig})
+ available_methods = task_def.get("available_methods")
+ if available_methods:
+ content = "\n".join(available_methods) if isinstance(available_methods, list) else str(available_methods)
+ context.append({"type": "available_methods", "content": content})
+ ctor_params = task_def.get("constructor_params")
+ if ctor_params:
+ content = ", ".join(ctor_params) if isinstance(ctor_params, list) else str(ctor_params)
+ context.append({"type": "constructor_params", "content": content})
+
# Requirement traceability
requirement_ids = task_def.get("requirement_ids", [])
@@ -181,6 +194,13 @@ async def decompose(self, project_id: str, plan_id: str) -> dict:
(task_ids[i], task_ids[dep_idx]),
))
+ # Auto-create assembly tasks for C# method phases
+ # Groups csharp_method tasks by target_class, creates one assembly task per class
+ _create_csharp_assembly_tasks(
+ tasks_data, task_ids, waves, phase_names,
+ project_id, plan_id, now, write_statements,
+ )
+
# Mark plan as approved
write_statements.append((
"UPDATE plans SET status = ? WHERE id = ?",
@@ -213,6 +233,85 @@ async def decompose_plan(project_id: str, plan_id: str, *, db) -> dict:
return await DecomposerService(db=db).decompose(project_id, plan_id)
+def _create_csharp_assembly_tasks(
+ tasks_data, task_ids, waves, phase_names,
+ project_id, plan_id, now, write_statements,
+):
+ """Auto-create assembly tasks for C# method phases.
+
+ Groups csharp_method tasks by target_class. For each class, creates
+ an assembly task that depends on all its method tasks. The assembly
+ task stitches method bodies into the class file and runs dotnet build.
+ """
+ from collections import defaultdict
+
+ # Group task indices by target_class
+ class_tasks: dict[str, list[int]] = defaultdict(list)
+ for i, task_def in enumerate(tasks_data):
+ if task_def.get("task_type") == "csharp_method" and task_def.get("target_class"):
+ class_tasks[task_def["target_class"]].append(i)
+
+ if not class_tasks:
+ return
+
+ for target_class, method_indices in class_tasks.items():
+ assembly_id = uuid.uuid4().hex[:12]
+ class_name = target_class.split(".")[-1]
+
+ # Collect affected files from method tasks
+ affected = set()
+ for idx in method_indices:
+ for f in tasks_data[idx].get("affected_files", []):
+ affected.add(f)
+
+ # Assembly wave = max wave of its methods + 1
+ assembly_wave = max(waves[i] for i in method_indices) + 1
+
+ description = (
+ f"Assemble method implementations for {target_class} into the class file. "
+ f"Stitch the method bodies from {len(method_indices)} completed tasks into "
+ f"the class shell, then run 'dotnet build' to verify compilation."
+ )
+
+ context = [
+ {"type": "task_description", "content": description},
+ {"type": "target_class", "content": target_class},
+ ]
+ if affected:
+ context.append({"type": "affected_files", "content": ", ".join(sorted(affected))})
+
+ # Get the phase name from the first method task
+ phase = phase_names[method_indices[0]] if method_indices else None
+
+ write_statements.append((
+ "INSERT INTO tasks (id, project_id, plan_id, title, description, task_type, "
+ "priority, status, model_tier, context_json, tools_json, "
+ "max_tokens, wave, phase, requirement_ids_json, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (assembly_id, project_id, plan_id,
+ f"Assemble {class_name}", description, "csharp_assembly",
+ 9999, TaskStatus.PENDING, "sonnet", # high priority number = runs last
+ json.dumps(context), json.dumps(["read_file", "write_file"]),
+ 4096, assembly_wave, phase,
+ json.dumps([]), now, now),
+ ))
+
+ # Add dependency edges: assembly depends on all its method tasks
+ for method_idx in method_indices:
+ write_statements.append((
+ "INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
+ (assembly_id, task_ids[method_idx]),
+ ))
+
+ task_ids.append(assembly_id)
+ waves.append(assembly_wave)
+
+ logger.info(
+ "Created %d assembly tasks for C# classes: %s",
+ len(class_tasks), ", ".join(class_tasks.keys()),
+ )
+
+
def _check_for_cycles(tasks_data: list[dict]) -> None:
"""Detect dependency cycles in the task graph before creating rows.
diff --git a/backend/services/planner.py b/backend/services/planner.py
index d4d88ff..b436d0f 100644
--- a/backend/services/planner.py
+++ b/backend/services/planner.py
@@ -6,6 +6,7 @@
# Used by: routes/projects.py, container.py
import json
+import logging
import time
import uuid
@@ -17,6 +18,8 @@
from backend.services.model_router import calculate_cost
from backend.utils.json_utils import extract_json_object, parse_requirements
+logger = logging.getLogger("orchestration.planner")
+
# Token estimates for budget reservation before API calls
_EST_PLANNING_INPUT_TOKENS = 2000 # system prompt (~1.5k) + requirements
_EST_PLANNING_OUTPUT_TOKENS = 2000 # plan JSON response
@@ -196,12 +199,139 @@ def _build_system_prompt(rigor: PlanningRigor) -> str:
return _PLANNING_PREAMBLE + _RIGOR_SUFFIXES[rigor]
+# ---------------------------------------------------------------------------
+# C# Reflection-based decomposition strategy
+# ---------------------------------------------------------------------------
+
+_CSHARP_PLANNING_PREAMBLE = """You are a C# code architect for an AI orchestration engine. Your job is to decompose a feature request into method-level implementation tasks using reflected type metadata from the target assembly.
+
+You will receive:
+1. The feature requirements (numbered [R1], [R2], etc.)
+2. A reflected type map showing existing classes, methods, properties, and constructors from the .NET assembly.
+
+
+- Each task implements exactly ONE method body. The method signature is already defined.
+- Tasks are organized into phases, one phase per class being modified or created.
+- Each task receives: the target method signature, injected dependencies (constructor params), and available sibling methods.
+- The AI worker will output ONLY the method body — no class wrapper, no using statements.
+- A final assembly task per class stitches method bodies into the class file and runs dotnet build.
+- Keep each method under 50 lines of logic. If a method needs more, split into private helpers and add those as separate tasks.
+
+
+
+- Use the reflected type map strictly. Do not invent classes or interfaces that don't exist.
+- If a new class is needed, create a "scaffold" task that generates the class shell first.
+- Map depends_on to the task indices (0-based, global across phases) of methods that must complete before this one.
+- For new methods on existing classes, include the existing method signatures in available_methods.
+- For methods that modify shared state, note potential concurrency concerns in the description.
+
+
+"""
+
+_CSHARP_TASK_SCHEMA = """{
+ "title": "ClassName.MethodName",
+ "description": "What this method does, including behavioral contract and edge cases",
+ "task_type": "csharp_method",
+ "complexity": "simple|medium|complex",
+ "depends_on": [],
+ "target_class": "Namespace.ClassName",
+ "target_signature": "public async Task MethodName(ParamType param)",
+ "available_methods": ["signatures of other methods in the same class or injected services"],
+ "constructor_params": ["IDbContext db", "ILogger logger"],
+ "requirement_ids": ["R1"],
+ "verification_criteria": "How to verify this method works correctly",
+ "affected_files": ["src/Services/MyService.cs"]
+ }"""
+
+_CSHARP_RIGOR_SUFFIX = f"""Produce a JSON plan organized into phases. Each phase corresponds to one class being modified or created.
+
+{{
+ "summary": "Brief summary of the feature being implemented",
+ "phases": [
+ {{
+ "name": "ClassName (e.g. 'UserService', 'OrderValidator')",
+ "description": "What this class does and why these methods are needed",
+ "tasks": [
+ {_CSHARP_TASK_SCHEMA}
+ ]
+ }}
+ ],
+ "open_questions": [
+ {{
+ "question": "An ambiguity or decision in the requirements",
+ "proposed_answer": "How you propose to handle it",
+ "impact": "What changes if the answer differs"
+ }}
+ ],
+ "assembly_config": {{
+ "new_files": ["Paths to new .cs files that need to be created"],
+ "modified_files": ["Paths to existing .cs files that will be modified"]
+ }}
+}}
+
+Phase guidelines:
+- One phase per class. Phase name = class name.
+- Within a phase, order tasks so independent methods come first.
+- depends_on indices are GLOBAL across all phases (0-based from the first task in the first phase).
+- After all method tasks in a phase, the system will auto-create an assembly task to stitch and build.
+
+Open questions:
+- Surface 1-5 ambiguities about the requirements or existing code structure.
+
+Respond with ONLY the JSON plan, no markdown fences or explanation."""
+
+
+def _build_csharp_system_prompt(type_map: str) -> str:
+ """Build the system prompt for C# reflection-based planning."""
+ return (
+ _CSHARP_PLANNING_PREAMBLE
+ + f"\n{type_map}\n\n\n"
+ + _CSHARP_RIGOR_SUFFIX
+ )
+
+
class PlannerService:
"""Injectable service that generates plans from project requirements."""
- def __init__(self, *, db, budget):
+ def __init__(self, *, db, budget, tool_registry=None):
self._db = db
self._budget = budget
+ self._tool_registry = tool_registry
+
+ async def _get_csharp_type_map(self, config: dict) -> str | None:
+ """Run .NET reflection to get the type map for C# planning.
+
+ Reads assembly_path or csproj_path from project config.
+ Returns formatted type map string, or None if reflection fails/unavailable.
+ """
+ assembly_path = config.get("assembly_path")
+ csproj_path = config.get("csproj_path")
+
+ if not assembly_path and not csproj_path:
+ logger.warning("csharp_reflection strategy requires assembly_path or csproj_path in config")
+ return None
+
+ try:
+ from backend.tools.dotnet_reflection import (
+ build_project,
+ format_type_map,
+ reflect_assembly,
+ )
+
+ # Build from csproj if needed
+ if csproj_path and not assembly_path:
+ success, result = await build_project(csproj_path)
+ if not success:
+ logger.warning("C# build failed: %s", result)
+ return None
+ assembly_path = result
+
+ ns_filter = config.get("namespace_filter")
+ data = await reflect_assembly(assembly_path, ns_filter)
+ return format_type_map(data)
+ except Exception as e:
+ logger.warning("C# reflection failed, falling back to generic planner: %s", e)
+ return None
async def generate(
self,
@@ -235,8 +365,18 @@ async def generate(
except ValueError:
rigor = PlanningRigor.L2
- system_prompt = _build_system_prompt(rigor)
- max_tokens = _MAX_TOKENS_BY_RIGOR[rigor]
+ # Check for C# reflection decomposition strategy
+ decomposition_strategy = config.get("decomposition_strategy")
+ csharp_type_map = None
+ if decomposition_strategy == "csharp_reflection":
+ csharp_type_map = await self._get_csharp_type_map(config)
+
+ if csharp_type_map is not None:
+ system_prompt = _build_csharp_system_prompt(csharp_type_map)
+ max_tokens = 8192 # C# plans are detailed
+ else:
+ system_prompt = _build_system_prompt(rigor)
+ max_tokens = _MAX_TOKENS_BY_RIGOR[rigor]
# Reserve budget before making the API call (prevents TOCTOU race)
estimated_cost = calculate_cost(PLANNING_MODEL, _EST_PLANNING_INPUT_TOKENS, _EST_PLANNING_OUTPUT_TOKENS)
diff --git a/backend/services/task_lifecycle.py b/backend/services/task_lifecycle.py
index 9c15541..809ea87 100644
--- a/backend/services/task_lifecycle.py
+++ b/backend/services/task_lifecycle.py
@@ -599,3 +599,29 @@ async def complete_task_external(
logger.warning("Knowledge extraction failed for external task %s: %s", task_id, e)
return {"status": TaskStatus.COMPLETED}
+
+
+async def verify_csharp_build(csproj_path: str) -> tuple[bool, str]:
+ """Run dotnet build as a verification step for C# tasks.
+
+ Returns (success, output). On failure, output contains compiler errors
+ suitable for injection as retry feedback.
+ """
+ from backend.tools.dotnet_reflection import _run_subprocess
+
+ code, stdout, stderr = await _run_subprocess(
+ ["dotnet", "build", csproj_path, "-c", "Release", "--nologo", "-v", "q"],
+ timeout=120,
+ )
+ if code == 0:
+ return True, "Build succeeded"
+
+ # Extract just the error lines for concise feedback
+ output = stderr or stdout
+ error_lines = [
+ line for line in output.splitlines()
+ if "error CS" in line or "error :" in line
+ ]
+ if error_lines:
+ return False, "Build errors:\n" + "\n".join(error_lines[:20])
+ return False, f"Build failed:\n{output[:2000]}"
diff --git a/backend/tools/dotnet_reflection.py b/backend/tools/dotnet_reflection.py
new file mode 100644
index 0000000..274700a
--- /dev/null
+++ b/backend/tools/dotnet_reflection.py
@@ -0,0 +1,207 @@
+# Orchestration Engine - .NET Reflection Tool
+#
+# Wraps the dotnet-reflector console app to extract type metadata
+# from .NET assemblies. Used by the C# planner prompt to generate
+# function-level decomposition with typed contracts.
+#
+# Depends on: tools/base.py, tools/dotnet-reflector/ (.NET console app)
+# Used by: tools/registry.py, services/planner.py
+
+import asyncio
+import json
+import logging
+import shutil
+from pathlib import Path
+
+from backend.tools.base import Tool
+
+logger = logging.getLogger("orchestration.tools.dotnet_reflection")
+
+# Path to the reflector project
+_REFLECTOR_DIR = Path(__file__).resolve().parent.parent.parent / "tools" / "dotnet-reflector"
+
+
+def is_dotnet_available() -> bool:
+ """Check if the dotnet CLI is available on this machine."""
+ return shutil.which("dotnet") is not None
+
+
+async def _run_subprocess(cmd: list[str], cwd: str | None = None, timeout: float = 60) -> tuple[int, str, str]:
+ """Run a subprocess asynchronously and return (returncode, stdout, stderr)."""
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=cwd,
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.communicate()
+ return -1, "", "Process timed out"
+ return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
+
+
+async def build_project(csproj_path: str) -> tuple[bool, str]:
+ """Build a .NET project and return (success, output_dll_path_or_error)."""
+ csproj = Path(csproj_path)
+ if not csproj.exists():
+ return False, f"Project file not found: {csproj_path}"
+
+ code, stdout, stderr = await _run_subprocess(
+ ["dotnet", "build", str(csproj), "-c", "Release", "--nologo", "-v", "q"],
+ timeout=120,
+ )
+ if code != 0:
+ return False, f"Build failed:\n{stderr or stdout}"
+
+ # Find the output DLL
+ project_dir = csproj.parent
+ project_name = csproj.stem
+ # Check common output paths
+ for pattern in [
+ project_dir / "bin" / "Release" / "**" / f"{project_name}.dll",
+ ]:
+ matches = list(project_dir.glob(f"bin/Release/**/{project_name}.dll"))
+ if matches:
+ return True, str(matches[0])
+
+ return False, "Build succeeded but could not find output DLL"
+
+
+async def reflect_assembly(
+ assembly_path: str,
+ namespace_filter: str | None = None,
+) -> dict:
+ """Run the reflector against an assembly and return parsed JSON.
+
+ Args:
+ assembly_path: Path to the .dll to reflect.
+ namespace_filter: Optional namespace prefix to filter types.
+
+ Returns:
+ Parsed reflection metadata dict.
+
+ Raises:
+ RuntimeError: If the reflector fails.
+ """
+ reflector_dll = _REFLECTOR_DIR / "bin" / "Release" / "net8.0" / "dotnet-reflector.dll"
+
+ # Build the reflector if not already built
+ if not reflector_dll.exists():
+ code, _, stderr = await _run_subprocess(
+ ["dotnet", "build", str(_REFLECTOR_DIR / "dotnet-reflector.csproj"), "-c", "Release", "--nologo", "-v", "q"],
+ timeout=60,
+ )
+ if code != 0:
+ raise RuntimeError(f"Failed to build reflector: {stderr}")
+
+ cmd = ["dotnet", str(reflector_dll), assembly_path]
+ if namespace_filter:
+ cmd.extend(["--namespace", namespace_filter])
+
+ code, stdout, stderr = await _run_subprocess(cmd, timeout=30)
+ if code != 0:
+ raise RuntimeError(f"Reflection failed: {stderr}")
+
+ return json.loads(stdout)
+
+
+def format_type_map(reflection_data: dict) -> str:
+ """Format reflection data as a human-readable type map for LLM prompts.
+
+ Returns a structured text representation suitable for injection into
+ system prompts or context blocks.
+ """
+ lines = []
+ assembly_name = reflection_data.get("assembly_name", "Unknown")
+ lines.append(f"Assembly: {assembly_name}")
+ lines.append("")
+
+ for cls in reflection_data.get("classes", []):
+ kind = cls.get("kind", "class")
+ name = cls.get("name", "?")
+ ns = cls.get("namespace", "")
+ full_name = f"{ns}.{name}" if ns else name
+
+ header = f"{kind} {full_name}"
+ if cls.get("base_class"):
+ header += f" : {cls['base_class']}"
+ if cls.get("interfaces"):
+ ifaces = ", ".join(cls["interfaces"])
+ header += f", {ifaces}" if cls.get("base_class") else f" : {ifaces}"
+
+ lines.append(header)
+
+ # Constructors
+ for ctor in cls.get("constructors") or []:
+ params = ", ".join(f"{p['type']} {p['name']}" for p in ctor.get("parameters", []))
+ lines.append(f" ctor({params})")
+
+ # Properties
+ for prop in cls.get("properties") or []:
+ accessors = ""
+ if prop.get("has_getter"):
+ accessors += "get; "
+ if prop.get("has_setter"):
+ accessors += "set; "
+ lines.append(f" {prop['type']} {prop['name']} {{ {accessors.strip()} }}")
+
+ # Methods
+ for method in cls.get("methods", []):
+ lines.append(f" {method['signature']}")
+
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+class DotNetReflectionTool(Tool):
+ """Tool for extracting .NET assembly type metadata via reflection."""
+
+ name = "dotnet_reflection"
+ description = (
+ "Reflect on a .NET assembly or project to extract type metadata "
+ "(classes, methods, signatures, dependencies). Useful for understanding "
+ "existing code structure before generating implementations."
+ )
+ parameters = {
+ "type": "object",
+ "properties": {
+ "assembly_path": {
+ "type": "string",
+ "description": "Path to a .dll assembly file to reflect on.",
+ },
+ "csproj_path": {
+ "type": "string",
+ "description": "Path to a .csproj file. Will build first, then reflect on the output.",
+ },
+ "namespace_filter": {
+ "type": "string",
+ "description": "Optional namespace prefix to filter types (e.g., 'MyApp.Services').",
+ },
+ },
+ "required": [],
+ }
+
+ async def execute(self, params: dict) -> str:
+ assembly_path = params.get("assembly_path")
+ csproj_path = params.get("csproj_path")
+ ns_filter = params.get("namespace_filter")
+
+ if not assembly_path and not csproj_path:
+ return "Error: Provide either assembly_path or csproj_path."
+
+ # Build from csproj if needed
+ if csproj_path and not assembly_path:
+ success, result = await build_project(csproj_path)
+ if not success:
+ return f"Error: {result}"
+ assembly_path = result
+
+ try:
+ data = await reflect_assembly(assembly_path, ns_filter)
+ return format_type_map(data)
+ except Exception as e:
+ return f"Error: {e}"
diff --git a/backend/tools/registry.py b/backend/tools/registry.py
index 8b07466..3c18429 100644
--- a/backend/tools/registry.py
+++ b/backend/tools/registry.py
@@ -75,6 +75,12 @@ def _write_file():
from backend.tools.file import WriteFileTool
return WriteFileTool()
+ def _dotnet_reflection():
+ from backend.tools.dotnet_reflection import DotNetReflectionTool, is_dotnet_available
+ if not is_dotnet_available():
+ raise RuntimeError("dotnet CLI not found — skipping DotNetReflectionTool")
+ return DotNetReflectionTool()
+
tool_factories = [
("SearchKnowledgeTool", _search_knowledge),
("LookupTypeTool", _lookup_type),
@@ -82,6 +88,7 @@ def _write_file():
("GenerateImageTool", _generate_image),
("ReadFileTool", _read_file),
("WriteFileTool", _write_file),
+ ("DotNetReflectionTool", _dotnet_reflection),
]
for name, factory in tool_factories:
diff --git a/tests/unit/test_csharp_decomposer.py b/tests/unit/test_csharp_decomposer.py
new file mode 100644
index 0000000..49b8e55
--- /dev/null
+++ b/tests/unit/test_csharp_decomposer.py
@@ -0,0 +1,202 @@
+# Orchestration Engine - C# Decomposer Tests
+#
+# Tests for C# method task handling and assembly task auto-creation.
+#
+# Depends on: backend/services/decomposer.py
+# Used by: CI
+
+import json
+from unittest.mock import AsyncMock, patch
+
+from backend.services.decomposer import _create_csharp_assembly_tasks
+
+
+class TestCsharpContextInjection:
+ """Test that target_signature, available_methods, constructor_params
+ are injected into task context_json during decomposition."""
+
+ async def test_csharp_method_context_injected(self, tmp_db):
+ """Verify C# fields appear in context_json after decomposition."""
+ from backend.services.decomposer import DecomposerService
+
+ # Set up project + plan with C# method tasks
+ now = 1000.0
+ await tmp_db.execute_write(
+ "INSERT INTO projects (id, name, requirements, status, config_json, created_at, updated_at) "
+ "VALUES (?, ?, ?, 'ready', '{}', ?, ?)",
+ ("proj1", "Test", "Build stuff", now, now),
+ )
+
+ plan_data = {
+ "summary": "Implement UserService",
+ "phases": [{
+ "name": "UserService",
+ "tasks": [{
+ "title": "UserService.GetUser",
+ "description": "Fetch user by ID",
+ "task_type": "csharp_method",
+ "complexity": "medium",
+ "depends_on": [],
+ "target_class": "MyApp.Services.UserService",
+ "target_signature": "public async Task GetUser(Guid id)",
+ "available_methods": ["public void Save(User u)"],
+ "constructor_params": ["IDbContext db", "ILogger logger"],
+ "requirement_ids": ["R1"],
+ }],
+ }],
+ }
+
+ await tmp_db.execute_write(
+ "INSERT INTO plans (id, project_id, version, model_used, plan_json, status, created_at) "
+ "VALUES (?, ?, 1, 'test', ?, 'approved', ?)",
+ ("plan1", "proj1", json.dumps(plan_data), now),
+ )
+
+ decomposer = DecomposerService(db=tmp_db)
+ result = await decomposer.decompose("proj1", "plan1")
+
+ # Should have created 1 method task + 1 assembly task
+ assert result["tasks_created"] >= 1
+
+ # Check the method task has C# context
+ task = await tmp_db.fetchone(
+ "SELECT context_json FROM tasks WHERE project_id = ? AND task_type = 'csharp_method'",
+ ("proj1",),
+ )
+ assert task is not None
+ context = json.loads(task["context_json"])
+ context_types = [c["type"] for c in context]
+ assert "target_signature" in context_types
+ assert "available_methods" in context_types
+ assert "constructor_params" in context_types
+
+ # Check signature content
+ sig_entry = next(c for c in context if c["type"] == "target_signature")
+ assert "GetUser" in sig_entry["content"]
+
+
+class TestCreateCsharpAssemblyTasks:
+ def test_creates_assembly_task_per_class(self):
+ tasks_data = [
+ {"task_type": "csharp_method", "target_class": "MyApp.UserService",
+ "title": "GetUser", "affected_files": ["UserService.cs"]},
+ {"task_type": "csharp_method", "target_class": "MyApp.UserService",
+ "title": "SaveUser", "affected_files": ["UserService.cs"]},
+ {"task_type": "csharp_method", "target_class": "MyApp.OrderService",
+ "title": "CreateOrder", "affected_files": ["OrderService.cs"]},
+ ]
+ task_ids = ["t1", "t2", "t3"]
+ waves = [0, 0, 0]
+ phase_names = ["UserService", "UserService", "OrderService"]
+ write_statements = []
+
+ _create_csharp_assembly_tasks(
+ tasks_data, task_ids, waves, phase_names,
+ "proj1", "plan1", 1000.0, write_statements,
+ )
+
+ # Should have 2 assembly tasks (UserService, OrderService)
+ # Each has 1 INSERT + N dependency INSERTs
+ inserts = [s for s in write_statements if "INSERT INTO tasks" in s[0]]
+ assert len(inserts) == 2
+
+ # UserService assembly depends on t1 and t2
+ dep_inserts = [s for s in write_statements if "INSERT INTO task_deps" in s[0]]
+ user_deps = [s for s in dep_inserts if s[1][1] in ("t1", "t2")]
+ assert len(user_deps) == 2
+
+ # OrderService assembly depends on t3
+ order_deps = [s for s in dep_inserts if s[1][1] == "t3"]
+ assert len(order_deps) == 1
+
+ def test_no_assembly_tasks_for_non_csharp(self):
+ tasks_data = [
+ {"task_type": "code", "title": "Do stuff"},
+ ]
+ task_ids = ["t1"]
+ waves = [0]
+ phase_names = [None]
+ write_statements = []
+
+ _create_csharp_assembly_tasks(
+ tasks_data, task_ids, waves, phase_names,
+ "proj1", "plan1", 1000.0, write_statements,
+ )
+
+ assert len(write_statements) == 0
+
+ def test_assembly_wave_is_after_methods(self):
+ tasks_data = [
+ {"task_type": "csharp_method", "target_class": "Foo",
+ "title": "M1", "affected_files": []},
+ {"task_type": "csharp_method", "target_class": "Foo",
+ "title": "M2", "affected_files": []},
+ ]
+ task_ids = ["t1", "t2"]
+ waves = [0, 1] # M2 is in wave 1
+ phase_names = ["Foo", "Foo"]
+ write_statements = []
+
+ _create_csharp_assembly_tasks(
+ tasks_data, task_ids, waves, phase_names,
+ "proj1", "plan1", 1000.0, write_statements,
+ )
+
+ # Assembly task should be in wave 2 (max(0,1) + 1)
+ insert = [s for s in write_statements if "INSERT INTO tasks" in s[0]][0]
+ # wave is at index 12 in the VALUES tuple
+ assembly_wave = insert[1][12]
+ assert assembly_wave == 2
+
+ def test_assembly_task_type(self):
+ tasks_data = [
+ {"task_type": "csharp_method", "target_class": "Foo",
+ "title": "M1", "affected_files": []},
+ ]
+ task_ids = ["t1"]
+ waves = [0]
+ phase_names = ["Foo"]
+ write_statements = []
+
+ _create_csharp_assembly_tasks(
+ tasks_data, task_ids, waves, phase_names,
+ "proj1", "plan1", 1000.0, write_statements,
+ )
+
+ insert = [s for s in write_statements if "INSERT INTO tasks" in s[0]][0]
+ # task_type is at index 5 in VALUES
+ assert insert[1][5] == "csharp_assembly"
+ # title should contain "Assemble"
+ assert "Assemble" in insert[1][3]
+
+
+class TestBuildVerification:
+ async def test_build_success(self):
+ from backend.services.task_lifecycle import verify_csharp_build
+
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(0, "Build succeeded.", ""),
+ ):
+ success, output = await verify_csharp_build("/fake/Test.csproj")
+ assert success is True
+ assert "succeeded" in output
+
+ async def test_build_failure_extracts_errors(self):
+ from backend.services.task_lifecycle import verify_csharp_build
+
+ stderr = (
+ "Program.cs(10,5): error CS1002: ; expected\n"
+ "Program.cs(15,1): error CS0246: The type 'Foo' could not be found\n"
+ "Build FAILED.\n"
+ )
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(1, "", stderr),
+ ):
+ success, output = await verify_csharp_build("/fake/Test.csproj")
+ assert success is False
+ assert "error CS1002" in output
+ assert "error CS0246" in output
diff --git a/tests/unit/test_csharp_planner.py b/tests/unit/test_csharp_planner.py
new file mode 100644
index 0000000..5fbdfbf
--- /dev/null
+++ b/tests/unit/test_csharp_planner.py
@@ -0,0 +1,145 @@
+# Orchestration Engine - C# Planner Prompt Tests
+#
+# Tests for the C# reflection-based planning strategy.
+#
+# Depends on: backend/services/planner.py
+# Used by: CI
+
+from unittest.mock import AsyncMock, patch
+
+from backend.services.planner import (
+ PlannerService,
+ _build_csharp_system_prompt,
+ _build_system_prompt,
+)
+from backend.models.enums import PlanningRigor
+
+
+class TestCsharpSystemPrompt:
+ def test_includes_type_map(self):
+ type_map = "class MyApp.UserService\n public async Task GetUser(Guid id)"
+ prompt = _build_csharp_system_prompt(type_map)
+ assert "" in prompt
+ assert "UserService" in prompt
+ assert "GetUser" in prompt
+
+ def test_includes_csharp_preamble(self):
+ prompt = _build_csharp_system_prompt("some types")
+ assert "C# code architect" in prompt
+ assert "method-level implementation tasks" in prompt
+
+ def test_includes_task_schema(self):
+ prompt = _build_csharp_system_prompt("types")
+ assert "target_signature" in prompt
+ assert "target_class" in prompt
+ assert "available_methods" in prompt
+ assert "constructor_params" in prompt
+
+ def test_includes_strategy_rules(self):
+ prompt = _build_csharp_system_prompt("types")
+ assert "50 lines" in prompt
+ assert "assembly task" in prompt
+ assert "dotnet build" in prompt
+
+ def test_does_not_include_generic_preamble(self):
+ prompt = _build_csharp_system_prompt("types")
+ # Should NOT have the generic planner's task_type list
+ assert "task_type \"research\"" not in prompt
+
+ def test_generic_prompt_unchanged(self):
+ """Verify the generic prompt path still works."""
+ prompt = _build_system_prompt(PlanningRigor.L2)
+ assert "project planner" in prompt
+ assert "reflected_types" not in prompt
+
+
+def _make_planner_db_mock(config_json):
+ """Create a mock db that returns the right values for PlannerService.generate()."""
+ project_row = {
+ "id": "proj1",
+ "name": "Test",
+ "requirements": "Build a user service",
+ "config_json": config_json,
+ "status": "draft",
+ }
+ version_row = {"v": 0}
+ mock_db = AsyncMock()
+ mock_db.fetchone = AsyncMock(side_effect=[project_row, version_row])
+ mock_db.execute_write = AsyncMock()
+ return mock_db
+
+
+def _make_anthropic_mock(response_text='{"summary": "test", "phases": []}'):
+ """Create a mock anthropic module + client."""
+ mock_anthropic = AsyncMock()
+ mock_client = AsyncMock()
+ mock_response = AsyncMock()
+ mock_response.content = [AsyncMock(text=response_text)]
+ mock_response.usage = AsyncMock(input_tokens=100, output_tokens=200)
+ mock_client.messages.create = AsyncMock(return_value=mock_response)
+ mock_client.close = AsyncMock()
+ mock_anthropic.AsyncAnthropic.return_value = mock_client
+ return mock_anthropic, mock_client
+
+
+class TestPlannerServiceCsharpStrategy:
+ async def test_csharp_strategy_calls_reflection(self):
+ """When decomposition_strategy is csharp_reflection, planner calls reflection."""
+ config = '{"decomposition_strategy": "csharp_reflection", "csproj_path": "/fake/Test.csproj"}'
+ mock_db = _make_planner_db_mock(config)
+ mock_budget = AsyncMock()
+ mock_budget.reserve_spend = AsyncMock(return_value=True)
+
+ planner = PlannerService(db=mock_db, budget=mock_budget)
+
+ with patch.object(planner, "_get_csharp_type_map", new_callable=AsyncMock) as mock_reflect:
+ mock_reflect.return_value = "class Foo\n public void Bar()"
+
+ with patch("backend.services.planner.anthropic") as mock_anthropic_mod:
+ mock_anthropic, mock_client = _make_anthropic_mock()
+ mock_anthropic_mod.AsyncAnthropic.return_value = mock_client
+
+ await planner.generate("proj1")
+
+ mock_reflect.assert_called_once()
+
+ async def test_csharp_strategy_fallback_on_reflection_failure(self):
+ """If reflection fails, falls back to generic planner."""
+ config = '{"decomposition_strategy": "csharp_reflection", "csproj_path": "/fake/Test.csproj"}'
+ mock_db = _make_planner_db_mock(config)
+ mock_budget = AsyncMock()
+ mock_budget.reserve_spend = AsyncMock(return_value=True)
+
+ planner = PlannerService(db=mock_db, budget=mock_budget)
+
+ with patch.object(planner, "_get_csharp_type_map", new_callable=AsyncMock) as mock_reflect:
+ mock_reflect.return_value = None # Reflection failed
+
+ with patch("backend.services.planner.anthropic") as mock_anthropic_mod:
+ mock_anthropic, mock_client = _make_anthropic_mock('{"summary": "test", "tasks": []}')
+ mock_anthropic_mod.AsyncAnthropic.return_value = mock_client
+
+ await planner.generate("proj1")
+
+ # Should have called create with a generic prompt (no reflected_types)
+ call_kwargs = mock_client.messages.create.call_args[1]
+ assert "reflected_types" not in call_kwargs["system"]
+
+ async def test_get_csharp_type_map_no_paths(self):
+ """Returns None when no assembly/csproj paths are configured."""
+ planner = PlannerService(db=AsyncMock(), budget=AsyncMock())
+ result = await planner._get_csharp_type_map({})
+ assert result is None
+
+ async def test_get_csharp_type_map_with_assembly(self):
+ """Calls reflect_assembly when assembly_path is provided."""
+ planner = PlannerService(db=AsyncMock(), budget=AsyncMock())
+
+ mock_data = {"assembly_name": "Test", "classes": []}
+ with patch("backend.tools.dotnet_reflection.reflect_assembly", new_callable=AsyncMock) as mock_reflect, \
+ patch("backend.tools.dotnet_reflection.format_type_map") as mock_format:
+ mock_reflect.return_value = mock_data
+ mock_format.return_value = "formatted"
+
+ result = await planner._get_csharp_type_map({"assembly_path": "/fake.dll"})
+ assert result == "formatted"
diff --git a/tests/unit/test_csharp_worker.py b/tests/unit/test_csharp_worker.py
new file mode 100644
index 0000000..c6bae98
--- /dev/null
+++ b/tests/unit/test_csharp_worker.py
@@ -0,0 +1,96 @@
+# Orchestration Engine - C# Worker Prompt Tests
+#
+# Tests for the WorkerInstruction prompt builder in claude_agent.py.
+#
+# Depends on: backend/services/claude_agent.py
+# Used by: CI
+
+from backend.services.claude_agent import (
+ _build_csharp_worker_prompt,
+ _extract_csharp_context,
+)
+
+
+class TestExtractCsharpContext:
+ def test_extracts_csharp_fields(self):
+ context = [
+ {"type": "project_summary", "content": "A project"},
+ {"type": "target_signature", "content": "public async Task GetUser(Guid id)"},
+ {"type": "available_methods", "content": "public void Save(User u)\npublic User Find(Guid id)"},
+ {"type": "constructor_params", "content": "IDbContext db, ILogger logger"},
+ ]
+ result = _extract_csharp_context(context)
+ assert result is not None
+ assert result["target_signature"] == "public async Task GetUser(Guid id)"
+ assert "Save" in result["available_methods"]
+ assert "IDbContext" in result["constructor_params"]
+
+ def test_returns_none_without_target_signature(self):
+ context = [
+ {"type": "project_summary", "content": "A project"},
+ {"type": "available_methods", "content": "some methods"},
+ ]
+ result = _extract_csharp_context(context)
+ assert result is None
+
+ def test_returns_none_for_empty_context(self):
+ assert _extract_csharp_context([]) is None
+
+ def test_minimal_csharp_context(self):
+ context = [{"type": "target_signature", "content": "public void DoStuff()"}]
+ result = _extract_csharp_context(context)
+ assert result is not None
+ assert "target_signature" in result
+
+
+class TestBuildCsharpWorkerPrompt:
+ def test_includes_worker_instruction_tags(self):
+ ctx = {
+ "target_signature": "public async Task ValidateUser(Guid id)",
+ "available_methods": "public User Find(Guid id)",
+ "constructor_params": "IUserStore store",
+ }
+ task_row = {"description": "Check if user exists and has active subscription"}
+ prompt = _build_csharp_worker_prompt(ctx, task_row)
+
+ assert "" in prompt
+ assert "" in prompt
+ assert "" in prompt
+ assert "ValidateUser" in prompt
+
+ def test_includes_logic_goal(self):
+ ctx = {"target_signature": "public void Foo()"}
+ task_row = {"description": "Do the foo thing"}
+ prompt = _build_csharp_worker_prompt(ctx, task_row)
+ assert "" in prompt
+ assert "Do the foo thing" in prompt
+
+ def test_includes_execution_rules(self):
+ ctx = {"target_signature": "public void Foo()"}
+ task_row = {"description": "test"}
+ prompt = _build_csharp_worker_prompt(ctx, task_row)
+ assert "ONLY the method body" in prompt
+ assert "Do not hallucinate" in prompt
+ assert "50 lines" in prompt
+
+ def test_includes_dependencies(self):
+ ctx = {
+ "target_signature": "public void Foo()",
+ "constructor_params": "IDbContext db, ILogger log",
+ "available_methods": "public void Bar()\npublic int Baz()",
+ }
+ task_row = {"description": "test"}
+ prompt = _build_csharp_worker_prompt(ctx, task_row)
+ assert "" in prompt
+ assert "IDbContext db" in prompt
+ assert "" in prompt
+ assert "Bar()" in prompt
+ assert "Baz()" in prompt
+
+ def test_handles_missing_optional_fields(self):
+ ctx = {"target_signature": "public void Foo()"}
+ task_row = {"description": "test"}
+ prompt = _build_csharp_worker_prompt(ctx, task_row)
+ # Should still produce valid XML without crashing
+ assert "public void Foo()" in prompt
+ assert "None" in prompt # default for missing fields
diff --git a/tests/unit/test_dotnet_reflection.py b/tests/unit/test_dotnet_reflection.py
new file mode 100644
index 0000000..1b7913c
--- /dev/null
+++ b/tests/unit/test_dotnet_reflection.py
@@ -0,0 +1,228 @@
+# Orchestration Engine - .NET Reflection Tool Tests
+#
+# Tests for the Python wrapper around the dotnet-reflector.
+# Uses mocked subprocess calls so CI doesn't need .NET SDK.
+#
+# Depends on: conftest.py, backend/tools/dotnet_reflection.py
+# Used by: CI
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.tools.dotnet_reflection import (
+ DotNetReflectionTool,
+ build_project,
+ format_type_map,
+ reflect_assembly,
+)
+
+
+# Sample reflection output matching the reflector's JSON schema
+SAMPLE_REFLECTION = {
+ "assembly_name": "MyApp",
+ "classes": [
+ {
+ "name": "UserService",
+ "namespace": "MyApp.Services",
+ "kind": "class",
+ "base_class": None,
+ "interfaces": ["IUserService"],
+ "constructors": [
+ {"parameters": [{"name": "db", "type": "IDbContext"}, {"name": "logger", "type": "ILogger"}]}
+ ],
+ "methods": [
+ {
+ "name": "GetUser",
+ "return_type": "Task",
+ "parameters": [{"name": "id", "type": "Guid"}],
+ "accessibility": "public",
+ "is_async": True,
+ "is_static": None,
+ "signature": "public async Task GetUser(Guid id)",
+ },
+ {
+ "name": "ValidateEmail",
+ "return_type": "bool",
+ "parameters": [{"name": "email", "type": "string"}],
+ "accessibility": "private",
+ "is_async": None,
+ "is_static": None,
+ "signature": "private bool ValidateEmail(string email)",
+ },
+ ],
+ "properties": [
+ {"name": "CacheEnabled", "type": "bool", "has_getter": True, "has_setter": True}
+ ],
+ },
+ {
+ "name": "IUserService",
+ "namespace": "MyApp.Services",
+ "kind": "interface",
+ "methods": [
+ {
+ "name": "GetUser",
+ "return_type": "Task",
+ "parameters": [{"name": "id", "type": "Guid"}],
+ "accessibility": "public",
+ "is_async": True,
+ "is_static": None,
+ "signature": "public async Task GetUser(Guid id)",
+ }
+ ],
+ },
+ ],
+}
+
+
+class TestFormatTypeMap:
+ def test_formats_class_with_methods(self):
+ result = format_type_map(SAMPLE_REFLECTION)
+ assert "Assembly: MyApp" in result
+ assert "class MyApp.Services.UserService : IUserService" in result
+ assert "public async Task GetUser(Guid id)" in result
+ assert "private bool ValidateEmail(string email)" in result
+
+ def test_formats_constructor(self):
+ result = format_type_map(SAMPLE_REFLECTION)
+ assert "ctor(IDbContext db, ILogger logger)" in result
+
+ def test_formats_properties(self):
+ result = format_type_map(SAMPLE_REFLECTION)
+ assert "bool CacheEnabled { get; set; }" in result
+
+ def test_formats_interface(self):
+ result = format_type_map(SAMPLE_REFLECTION)
+ assert "interface MyApp.Services.IUserService" in result
+
+ def test_empty_assembly(self):
+ result = format_type_map({"assembly_name": "Empty", "classes": []})
+ assert "Assembly: Empty" in result
+
+
+class TestReflectAssembly:
+ async def test_calls_reflector_subprocess(self):
+ mock_output = json.dumps(SAMPLE_REFLECTION)
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(0, mock_output, ""),
+ ), patch(
+ "backend.tools.dotnet_reflection._REFLECTOR_DIR",
+ ) as mock_dir:
+ # Simulate reflector DLL exists
+ mock_dir.__truediv__ = lambda self, x: type("P", (), {
+ "__truediv__": lambda s, y: type("P2", (), {
+ "__truediv__": lambda s2, z: type("P3", (), {
+ "__truediv__": lambda s3, w: type("P4", (), {
+ "exists": lambda s4: True, "__str__": lambda s4: "/fake/reflector.dll"
+ })()
+ })()
+ })()
+ })()
+
+ result = await reflect_assembly("/fake/assembly.dll")
+ assert result["assembly_name"] == "MyApp"
+ assert len(result["classes"]) == 2
+
+ async def test_raises_on_failure(self):
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(1, "", "Some error"),
+ ), patch(
+ "backend.tools.dotnet_reflection._REFLECTOR_DIR",
+ ) as mock_dir:
+ mock_dir.__truediv__ = lambda self, x: type("P", (), {
+ "__truediv__": lambda s, y: type("P2", (), {
+ "__truediv__": lambda s2, z: type("P3", (), {
+ "__truediv__": lambda s3, w: type("P4", (), {
+ "exists": lambda s4: True, "__str__": lambda s4: "/fake/reflector.dll"
+ })()
+ })()
+ })()
+ })()
+
+ with pytest.raises(RuntimeError, match="Reflection failed"):
+ await reflect_assembly("/fake/assembly.dll")
+
+
+class TestBuildProject:
+ async def test_build_success(self, tmp_path):
+ # Create a fake csproj and output DLL
+ csproj = tmp_path / "Test.csproj"
+ csproj.write_text("")
+ dll_dir = tmp_path / "bin" / "Release" / "net8.0"
+ dll_dir.mkdir(parents=True)
+ (dll_dir / "Test.dll").write_text("fake")
+
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(0, "Build succeeded", ""),
+ ):
+ success, result = await build_project(str(csproj))
+ assert success is True
+ assert result.endswith("Test.dll")
+
+ async def test_build_failure(self, tmp_path):
+ csproj = tmp_path / "Test.csproj"
+ csproj.write_text("")
+
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(1, "", "error CS1234: something broke"),
+ ):
+ success, result = await build_project(str(csproj))
+ assert success is False
+ assert "Build failed" in result
+
+ async def test_missing_csproj(self):
+ success, result = await build_project("/nonexistent/Test.csproj")
+ assert success is False
+ assert "not found" in result
+
+
+class TestDotNetReflectionTool:
+ def test_tool_metadata(self):
+ tool = DotNetReflectionTool()
+ assert tool.name == "dotnet_reflection"
+ assert "assembly_path" in tool.parameters["properties"]
+ assert "csproj_path" in tool.parameters["properties"]
+
+ async def test_execute_with_assembly(self):
+ mock_output = json.dumps(SAMPLE_REFLECTION)
+ with patch(
+ "backend.tools.dotnet_reflection._run_subprocess",
+ new_callable=AsyncMock,
+ return_value=(0, mock_output, ""),
+ ), patch(
+ "backend.tools.dotnet_reflection._REFLECTOR_DIR",
+ ) as mock_dir:
+ mock_dir.__truediv__ = lambda self, x: type("P", (), {
+ "__truediv__": lambda s, y: type("P2", (), {
+ "__truediv__": lambda s2, z: type("P3", (), {
+ "__truediv__": lambda s3, w: type("P4", (), {
+ "exists": lambda s4: True, "__str__": lambda s4: "/fake/reflector.dll"
+ })()
+ })()
+ })()
+ })()
+
+ tool = DotNetReflectionTool()
+ result = await tool.execute({"assembly_path": "/fake/assembly.dll"})
+ assert "UserService" in result
+ assert "GetUser" in result
+
+ async def test_execute_no_paths_returns_error(self):
+ tool = DotNetReflectionTool()
+ result = await tool.execute({})
+ assert "Error" in result
+
+ async def test_to_claude_tool(self):
+ tool = DotNetReflectionTool()
+ claude_def = tool.to_claude_tool()
+ assert claude_def["name"] == "dotnet_reflection"
+ assert "input_schema" in claude_def
diff --git a/tools/dotnet-reflector/Program.cs b/tools/dotnet-reflector/Program.cs
new file mode 100644
index 0000000..56289eb
--- /dev/null
+++ b/tools/dotnet-reflector/Program.cs
@@ -0,0 +1,241 @@
+// Orchestration Engine - .NET Assembly Reflector
+//
+// Loads a .NET assembly via MetadataLoadContext (no execution) and extracts
+// type metadata as structured JSON for AI-driven code decomposition.
+//
+// Depends on: System.Reflection.MetadataLoadContext
+// Used by: backend/tools/dotnet_reflection.py
+
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+if (args.Length < 1)
+{
+ Console.Error.WriteLine("Usage: dotnet-reflector [--namespace ]");
+ return 1;
+}
+
+var assemblyPath = args[0];
+string? nsFilter = null;
+
+for (int i = 1; i < args.Length - 1; i++)
+{
+ if (args[i] == "--namespace" && i + 1 < args.Length)
+ nsFilter = args[i + 1];
+}
+
+if (!File.Exists(assemblyPath))
+{
+ Console.Error.WriteLine($"Assembly not found: {assemblyPath}");
+ return 1;
+}
+
+try
+{
+ var result = ReflectAssembly(assemblyPath, nsFilter);
+ var options = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ };
+ Console.Write(JsonSerializer.Serialize(result, options));
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine($"Reflection failed: {ex.Message}");
+ return 1;
+}
+
+static AssemblyInfo ReflectAssembly(string assemblyPath, string? nsFilter)
+{
+ // Resolve runtime assemblies for MetadataLoadContext
+ var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
+ var resolver = new PathAssemblyResolver(
+ Directory.GetFiles(runtimeDir, "*.dll")
+ .Append(assemblyPath)
+ );
+
+ using var mlc = new MetadataLoadContext(resolver);
+ var assembly = mlc.LoadFromAssemblyPath(assemblyPath);
+
+ var classes = new List();
+
+ foreach (var type in assembly.GetTypes())
+ {
+ if (type.IsCompilerGenerated())
+ continue;
+
+ if (nsFilter != null && type.Namespace?.StartsWith(nsFilter) != true)
+ continue;
+
+ // Only process classes, structs, and interfaces
+ if (!type.IsClass && !type.IsValueType && !type.IsInterface)
+ continue;
+
+ var classInfo = new ClassInfo
+ {
+ Name = type.Name,
+ Namespace = type.Namespace,
+ Kind = type.IsInterface ? "interface" : type.IsValueType ? "struct" : "class",
+ BaseClass = type.BaseType?.Name is "Object" or "ValueType" ? null : type.BaseType?.Name,
+ Interfaces = type.GetInterfaces()
+ .Where(i => !i.IsCompilerGenerated())
+ .Select(i => FormatTypeName(i))
+ .ToList(),
+ Constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)
+ .Select(c => new ConstructorInfo_
+ {
+ Parameters = c.GetParameters().Select(ToParamInfo).ToList(),
+ })
+ .ToList(),
+ Methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
+ | BindingFlags.Instance | BindingFlags.Static
+ | BindingFlags.DeclaredOnly)
+ .Where(m => !m.IsSpecialName && !m.IsCompilerGenerated())
+ .Select(m => new MethodInfo_
+ {
+ Name = m.Name,
+ ReturnType = FormatTypeName(m.ReturnType),
+ Parameters = m.GetParameters().Select(ToParamInfo).ToList(),
+ Accessibility = m.IsPublic ? "public" : m.IsFamily ? "protected"
+ : m.IsPrivate ? "private" : "internal",
+ IsStatic = m.IsStatic ? true : null,
+ IsAsync = m.ReturnType.Name.StartsWith("Task") ? true : null,
+ Signature = FormatMethodSignature(m),
+ })
+ .ToList(),
+ Properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance
+ | BindingFlags.DeclaredOnly)
+ .Select(p => new PropertyInfo_
+ {
+ Name = p.Name,
+ Type = FormatTypeName(p.PropertyType),
+ HasGetter = p.GetMethod != null,
+ HasSetter = p.SetMethod != null,
+ })
+ .ToList(),
+ };
+
+ // Drop empty lists for cleaner output
+ if (classInfo.Interfaces.Count == 0) classInfo.Interfaces = null;
+ if (classInfo.Constructors.Count == 0) classInfo.Constructors = null;
+ if (classInfo.Properties.Count == 0) classInfo.Properties = null;
+
+ classes.Add(classInfo);
+ }
+
+ return new AssemblyInfo
+ {
+ AssemblyName = assembly.GetName().Name ?? Path.GetFileNameWithoutExtension(assemblyPath),
+ Classes = classes,
+ };
+}
+
+static ParamInfo ToParamInfo(ParameterInfo p) => new()
+{
+ Name = p.Name ?? "arg",
+ Type = FormatTypeName(p.ParameterType),
+ HasDefault = p.HasDefaultValue ? true : null,
+};
+
+static string FormatTypeName(Type t)
+{
+ if (t.IsGenericType)
+ {
+ var name = t.Name.Split('`')[0];
+ var args = string.Join(", ", t.GetGenericArguments().Select(FormatTypeName));
+ return $"{name}<{args}>";
+ }
+ return t.Name switch
+ {
+ "String" => "string",
+ "Int32" => "int",
+ "Int64" => "long",
+ "Boolean" => "bool",
+ "Single" => "float",
+ "Double" => "double",
+ "Decimal" => "decimal",
+ "Void" => "void",
+ "Object" => "object",
+ _ => t.Name,
+ };
+}
+
+static string FormatMethodSignature(MethodInfo m)
+{
+ var access = m.IsPublic ? "public" : m.IsFamily ? "protected"
+ : m.IsPrivate ? "private" : "internal";
+ var staticMod = m.IsStatic ? " static" : "";
+ var asyncMod = m.ReturnType.Name.StartsWith("Task") ? " async" : "";
+ var returnType = FormatTypeName(m.ReturnType);
+ var parameters = string.Join(", ",
+ m.GetParameters().Select(p => $"{FormatTypeName(p.ParameterType)} {p.Name}"));
+ return $"{access}{staticMod}{asyncMod} {returnType} {m.Name}({parameters})";
+}
+
+static class TypeExtensions
+{
+ public static bool IsCompilerGenerated(this Type t) =>
+ t.Name.StartsWith('<') || t.Name.Contains("__") ||
+ t.GetCustomAttributesData().Any(a =>
+ a.AttributeType.Name == "CompilerGeneratedAttribute");
+
+ public static bool IsCompilerGenerated(this MethodInfo m) =>
+ m.Name.StartsWith('<') ||
+ m.GetCustomAttributesData().Any(a =>
+ a.AttributeType.Name == "CompilerGeneratedAttribute");
+}
+
+// --- JSON output models ---
+
+record AssemblyInfo
+{
+ public string AssemblyName { get; init; } = "";
+ public List Classes { get; init; } = new();
+}
+
+record ClassInfo
+{
+ public string Name { get; init; } = "";
+ public string? Namespace { get; init; }
+ public string Kind { get; init; } = "class";
+ public string? BaseClass { get; init; }
+ public List? Interfaces { get; set; }
+ public List? Constructors { get; set; }
+ public List Methods { get; init; } = new();
+ public List? Properties { get; set; }
+}
+
+record ConstructorInfo_
+{
+ public List Parameters { get; init; } = new();
+}
+
+record MethodInfo_
+{
+ public string Name { get; init; } = "";
+ public string ReturnType { get; init; } = "void";
+ public List Parameters { get; init; } = new();
+ public string Accessibility { get; init; } = "public";
+ public bool? IsStatic { get; init; }
+ public bool? IsAsync { get; init; }
+ public string Signature { get; init; } = "";
+}
+
+record PropertyInfo_
+{
+ public string Name { get; init; } = "";
+ public string Type { get; init; } = "";
+ public bool HasGetter { get; init; }
+ public bool HasSetter { get; init; }
+}
+
+record ParamInfo
+{
+ public string Name { get; init; } = "";
+ public string Type { get; init; } = "";
+ public bool? HasDefault { get; init; }
+}
diff --git a/tools/dotnet-reflector/dotnet-reflector.csproj b/tools/dotnet-reflector/dotnet-reflector.csproj
new file mode 100644
index 0000000..c66ce1d
--- /dev/null
+++ b/tools/dotnet-reflector/dotnet-reflector.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+