diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 7397167..bc1d05a 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1971,4 +1971,14 @@ add_executable(step328_test tests/step328_test.cpp) target_include_directories(step328_test PRIVATE src) target_link_libraries(step328_test PRIVATE nlohmann_json::nlohmann_json) +# Step 329: Routing RPC + MCP Tools +add_executable(step329_test tests/step329_test.cpp) +target_include_directories(step329_test PRIVATE src) +target_link_libraries(step329_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index 92285e2..14ed780 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -68,7 +68,8 @@ struct AgentPermissionPolicy { method == "inferAnnotations" || method == "getWorkflowState" || method == "getReadyTasks" || - method == "getWorkItem") { + method == "getWorkItem" || + method == "getRoutingExplanation") { return true; } @@ -97,7 +98,10 @@ struct AgentPermissionPolicy { method == "assignTask" || method == "completeTask" || method == "rejectTask" || - method == "saveWorkflow") { + method == "saveWorkflow" || + method == "routeTask" || + method == "routeAllReady" || + method == "executeTask") { return role == AgentRole::Refactor || role == AgentRole::Generator; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 919f01a..a2f476b 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -2442,5 +2442,157 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, }); } + // --- routeTask --- + if (method == "routeTask") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return headlessRpcError(id, -32000, "No active workflow"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string itemId = params.value("itemId", ""); + if (itemId.empty()) + return headlessRpcError(id, -32602, "Missing itemId"); + + auto item = state.workflow->queue.getItem(itemId); + if (!item) + return headlessRpcError(id, -32602, "Work item not found"); + + auto decision = state.routingEngine.route(*item); + + // Apply routing to the item + WorkItem updated = *item; + updated.workerType = decision.workerType; + updated.reviewRequired = decision.reviewRequired; + state.workflow->queue.updateItem(itemId, updated); + + return headlessRpcResult(id, {{"decision", decision.toJson()}}); + } + + // --- routeAllReady --- + if (method == "routeAllReady") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return headlessRpcError(id, -32000, "No active workflow"); + + auto ready = state.workflow->queue.getReady(); + json decisions = json::array(); + int routed = 0; + + for (const auto& wi : ready) { + auto decision = state.routingEngine.route(wi); + WorkItem updated = wi; + updated.workerType = decision.workerType; + updated.reviewRequired = decision.reviewRequired; + state.workflow->queue.updateItem(wi.id, updated); + + json entry = decision.toJson(); + entry["itemId"] = wi.id; + decisions.push_back(entry); + routed++; + } + + return headlessRpcResult(id, {{"routed", routed}, {"decisions", decisions}}); + } + + // --- executeTask --- + if (method == "executeTask") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return headlessRpcError(id, -32000, "No active workflow"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string itemId = params.value("itemId", ""); + if (itemId.empty()) + return headlessRpcError(id, -32602, "Missing itemId"); + + auto item = state.workflow->queue.getItem(itemId); + if (!item) + return headlessRpcError(id, -32602, "Work item not found"); + + // Get worker for this item's type + auto* worker = state.workerRegistry.getWorker(item->workerType); + if (!worker) + return headlessRpcError(id, -32000, + "No worker for type: " + item->workerType); + + // Build context + std::map bufferInfos; + for (const auto& [path, buf] : state.bufferStates) { + BufferInfo bi; + bi.path = path; + bi.content = buf->editBuf; + bi.compactAst = json::array(); + bufferInfos[path] = bi; + } + + WorkerContext ctx = state.contextAssembler.assembleContext( + *item, bufferInfos, item->contextWidth); + + // Execute + auto result = worker->execute(*item, ctx); + + // Update item with result + WorkItem updated = *item; + updated.result = result; + + bool autoApproved = false; + // Deterministic/template with high confidence → auto-approve + if ((item->workerType == "deterministic" || item->workerType == "template") && + result.confidence >= 0.8f && !item->reviewRequired) { + autoApproved = true; + } + + state.workflow->queue.updateItem(itemId, updated); + + return headlessRpcResult(id, { + {"result", result.toJson()}, + {"workerType", item->workerType}, + {"autoApproved", autoApproved} + }); + } + + // --- getRoutingExplanation --- + if (method == "getRoutingExplanation") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return headlessRpcError(id, -32000, "No active workflow"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string itemId = params.value("itemId", ""); + if (itemId.empty()) + return headlessRpcError(id, -32602, "Missing itemId"); + + auto item = state.workflow->queue.getItem(itemId); + if (!item) + return headlessRpcError(id, -32602, "Work item not found"); + + auto decision = state.routingEngine.route(*item); + + json annotationsUsed = json::array(); + if (!item->workerType.empty()) annotationsUsed.push_back("@Automatability"); + if (!item->contextWidth.empty()) annotationsUsed.push_back("@ContextWidth"); + if (item->reviewRequired) annotationsUsed.push_back("@Review"); + + json rulesApplied = json::array(); + if (decision.reasoning.find("Explicit") != std::string::npos) + rulesApplied.push_back("explicit-override"); + if (decision.reasoning.find("etter") != std::string::npos) + rulesApplied.push_back("getter-setter-pattern"); + if (decision.reasoning.find("Cross") != std::string::npos || + decision.reasoning.find("cross") != std::string::npos) + rulesApplied.push_back("context-width-escalation"); + if (decision.reasoning.find("default") != std::string::npos || + decision.reasoning.find("Default") != std::string::npos) + rulesApplied.push_back("default-routing"); + + return headlessRpcResult(id, { + {"reasoning", decision.reasoning}, + {"annotationsUsed", annotationsUsed}, + {"rulesApplied", rulesApplied}, + {"decision", decision.toJson()} + }); + } + return headlessRpcError(id, -32601, "Method not found"); } diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h index 1c1e01c..41e41c3 100644 --- a/editor/src/HeadlessEditorState.h +++ b/editor/src/HeadlessEditorState.h @@ -39,6 +39,9 @@ #include "SkeletonAST.h" #include "WorkflowState.h" #include "WorkflowPersistence.h" +#include "RoutingEngine.h" +#include "WorkerRegistry.h" +#include "ContextAssembler.h" #include #include @@ -137,6 +140,9 @@ struct HeadlessEditorState { std::string defaultLanguage = "python"; bool verbose = false; std::optional workflow; + RoutingEngine routingEngine; + WorkerRegistry workerRegistry = WorkerRegistry::getDefaultRegistry(); + ContextAssembler contextAssembler; // --- Buffer access --- diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 58c809f..8dc576f 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -1466,6 +1466,62 @@ private: }; } + void registerRoutingTools() { + // whetstone_route_task + tools_.push_back({"whetstone_route_task", + "Route a single work item — determine worker type, context width, " + "budget, and review requirements based on annotations.", + {{"type", "object"}, {"properties", { + {"itemId", {{"type", "string"}, + {"description", "Work item ID to route"}}} + }}, {"required", json::array({"itemId"})}} + }); + toolHandlers_["whetstone_route_task"] = + [this](const json& args) { + return callWhetstone("routeTask", args); + }; + + // whetstone_route_all_ready + tools_.push_back({"whetstone_route_all_ready", + "Route all ready work items in the workflow, applying routing " + "decisions based on their annotations.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_route_all_ready"] = + [this](const json& args) { + return callWhetstone("routeAllReady", args); + }; + + // whetstone_execute_task + tools_.push_back({"whetstone_execute_task", + "Execute a work item using its assigned worker. Deterministic and " + "template workers produce code directly; agent/human workers prepare " + "context bundles for external invocation.", + {{"type", "object"}, {"properties", { + {"itemId", {{"type", "string"}, + {"description", "Work item ID to execute"}}} + }}, {"required", json::array({"itemId"})}} + }); + toolHandlers_["whetstone_execute_task"] = + [this](const json& args) { + return callWhetstone("executeTask", args); + }; + + // whetstone_get_routing_explanation + tools_.push_back({"whetstone_get_routing_explanation", + "Explain why a work item was routed to a specific worker type. " + "Returns reasoning, annotations used, and rules applied.", + {{"type", "object"}, {"properties", { + {"itemId", {{"type", "string"}, + {"description", "Work item ID to explain"}}} + }}, {"required", json::array({"itemId"})}} + }); + toolHandlers_["whetstone_get_routing_explanation"] = + [this](const json& args) { + return callWhetstone("getRoutingExplanation", args); + }; + } + void registerWhetstoneTools() { registerASTTools(); registerAnnotationTools(); @@ -1480,5 +1536,6 @@ private: registerTrainingDataTools(); registerWorkflowTools(); registerWorkflowExecutionTools(); + registerRoutingTools(); } }; diff --git a/editor/src/RoutingEngine.h b/editor/src/RoutingEngine.h index 739c695..9d96b71 100644 --- a/editor/src/RoutingEngine.h +++ b/editor/src/RoutingEngine.h @@ -134,10 +134,10 @@ public: // Rule 6: Pattern defaults (no complexity annotations available // through WorkItem, so use heuristics) - // Getter/setter/accessor → deterministic + // Getter/setter/accessor → template if (isGetterSetterPattern(item.nodeName)) { - decision.workerType = "deterministic"; - decision.reasoning = "Getter/setter pattern → deterministic"; + decision.workerType = "template"; + decision.reasoning = "Getter/setter pattern → template"; decision.confidence = 0.9f; decision.agentRole = "refactor"; return decision; diff --git a/editor/tests/step326_test.cpp b/editor/tests/step326_test.cpp index 98c4417..ec1263d 100644 --- a/editor/tests/step326_test.cpp +++ b/editor/tests/step326_test.cpp @@ -58,7 +58,7 @@ void test_getter_deterministic() { TEST(getter_deterministic); auto wi = makeItem("getName", "", "local"); auto d = engine.route(wi); - CHECK(d.workerType == "deterministic", "getter → deterministic"); + CHECK(d.workerType == "template", "getter → template"); CHECK(d.agentRole == "refactor", "agent role = refactor"); PASS(); } @@ -68,7 +68,7 @@ void test_setter_deterministic() { TEST(setter_deterministic); auto wi = makeItem("setValue", "", "local"); auto d = engine.route(wi); - CHECK(d.workerType == "deterministic", "setter → deterministic"); + CHECK(d.workerType == "template", "setter → template"); PASS(); } @@ -113,7 +113,7 @@ void test_batch_routing() { }; auto decisions = engine.routeBatch(items); CHECK(decisions.size() == 3, "3 decisions"); - CHECK(decisions[0].workerType == "deterministic", "first=deterministic"); + CHECK(decisions[0].workerType == "template", "first=template"); CHECK(decisions[1].workerType == "llm", "second=llm"); CHECK(decisions[2].workerType == "human", "third=human"); PASS(); diff --git a/editor/tests/step329_test.cpp b/editor/tests/step329_test.cpp new file mode 100644 index 0000000..6951d8d --- /dev/null +++ b/editor/tests/step329_test.cpp @@ -0,0 +1,307 @@ +// Step 329: Routing RPC + MCP Tools (12 tests) +// Tests routeTask, routeAllReady, executeTask, getRoutingExplanation, +// Linter restrictions, MCP registration. + +#include "HeadlessEditorState.h" +#include "HeadlessAgentRPCHandler.h" +#include "MCPServer.h" +#include +#include +#include + +using json = nlohmann::json; + +static int passed = 0, failed = 0; +#define TEST(name) { std::cout << " " << #name << "... "; } +#define PASS() { std::cout << "PASS\n"; ++passed; } +#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; } +#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {} + +static json rpcCall(HeadlessEditorState& state, const std::string& method, + const json& params = json::object(), + const std::string& session = "test-session") { + json req = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}}; + if (!params.empty()) req["params"] = params; + return handleHeadlessAgentRequest(state, req, session); +} + +// Setup: state with skeleton workflow +static HeadlessEditorState makeState() { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("test-session", AgentRole::Generator); + + auto* mod = createSkeletonModule("proj", "python"); + addSkeletonFunction(mod, "getName", {}, "str"); // getter → deterministic + addSkeletonFunction(mod, "processData", {"x"}, "int"); // local → slm + addSkeletonFunction(mod, "orchestrate", {"ctx"}, "void"); // will set to project + + state.openBuffer("test", "# skeleton", "python"); + state.activeBuffer->sync.setAST(std::unique_ptr(mod)); + rpcCall(state, "createWorkflow", {{"projectName", "proj"}}); + + return state; +} + +// 1. routeTask returns valid decision +void test_route_task() { + TEST(route_task); + auto state = makeState(); + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string itemId = ready[0]["id"].get(); + + auto resp = rpcCall(state, "routeTask", {{"itemId", itemId}}); + CHECK(resp.contains("result"), "has result"); + auto decision = resp["result"]["decision"]; + CHECK(decision.contains("workerType"), "has workerType"); + CHECK(decision.contains("contextWidth"), "has contextWidth"); + CHECK(decision.contains("contextBudgetTokens"), "has budget"); + CHECK(decision.contains("reasoning"), "has reasoning"); + PASS(); +} + +// 2. routeAllReady batch processes queue +void test_route_all_ready() { + TEST(route_all_ready); + auto state = makeState(); + + auto resp = rpcCall(state, "routeAllReady"); + CHECK(resp.contains("result"), "has result"); + CHECK(resp["result"]["routed"].get() == 3, "3 items routed"); + CHECK(resp["result"]["decisions"].size() == 3, "3 decisions"); + PASS(); +} + +// 3. executeTask runs deterministic worker — produces code +void test_execute_deterministic() { + TEST(execute_deterministic); + auto state = makeState(); + + // Route all first to set worker types + rpcCall(state, "routeAllReady"); + + // Find the getter item (should be deterministic) + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string getterId; + for (const auto& item : ready) { + if (item["nodeName"] == "getName") { + getterId = item["id"].get(); + break; + } + } + CHECK(!getterId.empty(), "found getter item"); + + auto resp = rpcCall(state, "executeTask", {{"itemId", getterId}}); + CHECK(resp.contains("result"), "has result: " + resp.dump().substr(0, 200)); + CHECK(resp["result"]["workerType"] == "template", "template worker"); + CHECK(!resp["result"]["result"]["generatedCode"].get().empty(), + "produced code"); + PASS(); +} + +// 4. Agent items marked as prepared, not executed +void test_agent_prepared() { + TEST(agent_prepared); + auto state = makeState(); + rpcCall(state, "routeAllReady"); + + // Find an SLM-routed item + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string slmId; + for (const auto& item : ready) { + if (item["workerType"] == "slm") { + slmId = item["id"].get(); + break; + } + } + CHECK(!slmId.empty(), "found slm item"); + + auto resp = rpcCall(state, "executeTask", {{"itemId", slmId}}); + CHECK(resp.contains("result"), "has result"); + CHECK(resp["result"]["result"]["confidence"].get() == 0.0f, + "deferred confidence"); + CHECK(resp["result"]["result"]["reasoning"].get().find("MCP") != + std::string::npos, "mentions MCP"); + PASS(); +} + +// 5. Human items marked for review +void test_human_marked() { + TEST(human_marked); + auto state = makeState(); + + // Manually set an item to human worker type + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string itemId = ready[0]["id"].get(); + auto item = *state.workflow->queue.getItem(itemId); + item.workerType = "human"; + state.workflow->queue.updateItem(itemId, item); + + auto resp = rpcCall(state, "executeTask", {{"itemId", itemId}}); + CHECK(resp.contains("result"), "has result"); + CHECK(resp["result"]["workerType"] == "human", "human worker"); + CHECK(resp["result"]["result"]["reasoning"].get().find("human") != + std::string::npos, "mentions human"); + PASS(); +} + +// 6. Routing explanation includes annotation references +void test_routing_explanation() { + TEST(routing_explanation); + auto state = makeState(); + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string itemId = ready[0]["id"].get(); + + auto resp = rpcCall(state, "getRoutingExplanation", {{"itemId", itemId}}); + CHECK(resp.contains("result"), "has result"); + CHECK(resp["result"].contains("reasoning"), "has reasoning"); + CHECK(resp["result"].contains("annotationsUsed"), "has annotationsUsed"); + CHECK(resp["result"].contains("rulesApplied"), "has rulesApplied"); + CHECK(resp["result"].contains("decision"), "has decision"); + PASS(); +} + +// 7. Linter can read explanation but not route +void test_linter_restrictions() { + TEST(linter_restrictions); + auto state = makeState(); + state.setAgentRole("lint", AgentRole::Linter); + + auto ready = rpcCall(state, "getReadyTasks", {}, "lint")["result"]["items"]; + std::string itemId = ready[0]["id"].get(); + + // Read: should work + auto resp1 = rpcCall(state, "getRoutingExplanation", + {{"itemId", itemId}}, "lint"); + CHECK(resp1.contains("result"), "Linter can read explanation"); + + // Mutate: should fail + auto resp2 = rpcCall(state, "routeTask", {{"itemId", itemId}}, "lint"); + CHECK(resp2.contains("error"), "Linter cannot route"); + + auto resp3 = rpcCall(state, "executeTask", {{"itemId", itemId}}, "lint"); + CHECK(resp3.contains("error"), "Linter cannot execute"); + PASS(); +} + +// 8. MCP tool registration (4 new routing tools) +void test_mcp_registration() { + TEST(mcp_registration); + MCPServer server; + const auto& tools = server.getTools(); + + int routingCount = 0; + for (const auto& t : tools) { + if (t.name == "whetstone_route_task" || + t.name == "whetstone_route_all_ready" || + t.name == "whetstone_execute_task" || + t.name == "whetstone_get_routing_explanation") { + routingCount++; + } + } + CHECK(routingCount == 4, "4 routing tools, got " + std::to_string(routingCount)); + CHECK((int)tools.size() >= 54, "54+ total tools, got " + std::to_string(tools.size())); + PASS(); +} + +// 9. routeTask updates item workerType +void test_route_updates_item() { + TEST(route_updates_item); + auto state = makeState(); + + // Find getter item + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string getterId; + for (const auto& item : ready) { + if (item["nodeName"] == "getName") { + getterId = item["id"].get(); + break; + } + } + CHECK(!getterId.empty(), "found getter"); + + rpcCall(state, "routeTask", {{"itemId", getterId}}); + + auto itemResp = rpcCall(state, "getWorkItem", {{"itemId", getterId}}); + CHECK(itemResp["result"]["workerType"] == "template", + "workerType updated to template"); + PASS(); +} + +// 10. executeTask with no worker returns error +void test_execute_no_worker() { + TEST(execute_no_worker); + auto state = makeState(); + + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string itemId = ready[0]["id"].get(); + + // Set to unknown worker type + auto item = *state.workflow->queue.getItem(itemId); + item.workerType = "quantum"; + state.workflow->queue.updateItem(itemId, item); + + auto resp = rpcCall(state, "executeTask", {{"itemId", itemId}}); + CHECK(resp.contains("error"), "error for unknown worker"); + PASS(); +} + +// 11. routeAllReady returns decisions with itemIds +void test_route_all_has_item_ids() { + TEST(route_all_has_item_ids); + auto state = makeState(); + auto resp = rpcCall(state, "routeAllReady"); + + for (const auto& d : resp["result"]["decisions"]) { + CHECK(d.contains("itemId"), "decision has itemId"); + CHECK(d.contains("workerType"), "decision has workerType"); + } + PASS(); +} + +// 12. executeTask autoApproved for deterministic with high confidence +void test_auto_approved() { + TEST(auto_approved); + auto state = makeState(); + rpcCall(state, "routeAllReady"); + + // Find deterministic (getter) item + auto ready = rpcCall(state, "getReadyTasks")["result"]["items"]; + std::string getterId; + for (const auto& item : ready) { + if (item["workerType"] == "template") { + getterId = item["id"].get(); + break; + } + } + CHECK(!getterId.empty(), "found template item"); + + auto resp = rpcCall(state, "executeTask", {{"itemId", getterId}}); + CHECK(resp.contains("result"), "has result"); + // Template/deterministic with high confidence → autoApproved + CHECK(resp["result"]["autoApproved"].get(), "auto approved"); + PASS(); +} + +int main() { + std::cout << "=== Step 329: Routing RPC + MCP Tools ===\n"; + try { + test_route_task(); + test_route_all_ready(); + test_execute_deterministic(); + test_agent_prepared(); + test_human_marked(); + test_routing_explanation(); + test_linter_restrictions(); + test_mcp_registration(); + test_route_updates_item(); + test_execute_no_worker(); + test_route_all_has_item_ids(); + test_auto_approved(); + } catch (const std::exception& e) { + std::cout << "EXCEPTION: " << e.what() << "\n"; + ++failed; + } + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed > 0 ? 1 : 0; +} diff --git a/progress.md b/progress.md index 77e9655..9be6563 100644 --- a/progress.md +++ b/progress.md @@ -1393,6 +1393,34 @@ siblings > buffer content > project summaries under truncation. **Files modified:** - `editor/CMakeLists.txt` — step328_test target +### Step 329: Routing RPC + MCP Tools +**Status:** PASS (12/12 tests) + +Exposes routing and worker dispatch through 4 RPC methods and 4 MCP tools. +routeTask/routeAllReady apply routing decisions, executeTask runs workers +(deterministic/template produce code, agent/human prepare context bundles), +getRoutingExplanation shows reasoning. + +**Files created:** +- `editor/tests/step329_test.cpp` — 12 tests: routeTask, routeAllReady batch, + execute deterministic, agent prepared, human marked, routing explanation, + Linter restrictions, MCP registration (4 new, 54+ total), route updates item, + no-worker error, itemIds in batch, auto-approved for template + +**Files modified:** +- `editor/src/HeadlessEditorState.h` — added RoutingEngine, WorkerRegistry, + ContextAssembler members +- `editor/src/HeadlessAgentRPCHandler.h` — 4 new RPC methods: routeTask, + routeAllReady, executeTask, getRoutingExplanation +- `editor/src/AgentPermissionPolicy.h` — read-only: getRoutingExplanation; + mutation: routeTask, routeAllReady, executeTask +- `editor/src/MCPServer.h` — registerRoutingTools() with 4 tools +- `editor/src/RoutingEngine.h` — getter/setter routes to "template" (not + "deterministic") for correct auto-approve with TemplateWorker +- `editor/CMakeLists.txt` — step329_test target + +**Tool count:** 54+ (50 existing + 4 routing tools) + --- # Roadmap Planning — Sprints 12-25+