diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 08bd7be..de25a13 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2668,4 +2668,13 @@ target_link_libraries(step420_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step421_test tests/step421_test.cpp) +target_include_directories(step421_test PRIVATE src) +target_link_libraries(step421_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/MCPServer.h b/editor/src/MCPServer.h index 710e626..b5e6d04 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include using json = nlohmann::json; @@ -303,6 +305,189 @@ private: return resp; } + static bool hasCallError(const json& value) { + return value.is_object() && value.contains("error"); + } + + static void appendWarning(json& warnings, const std::string& message) { + warnings.push_back(message); + } + + static std::string toLowerCopy(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + return s; + } + + static std::string languageForPath(const std::string& path) { + auto dot = path.find_last_of('.'); + if (dot == std::string::npos) return ""; + std::string ext = toLowerCopy(path.substr(dot)); + if (ext == ".cpp" || ext == ".cc" || ext == ".cxx" || + ext == ".hpp" || ext == ".h" || ext == ".hh" || + ext == ".hxx") return "cpp"; + if (ext == ".py") return "python"; + if (ext == ".js" || ext == ".mjs" || ext == ".cjs") return "javascript"; + if (ext == ".ts" || ext == ".tsx") return "typescript"; + if (ext == ".java") return "java"; + if (ext == ".rs") return "rust"; + if (ext == ".go") return "go"; + return ""; + } + + static bool isIgnoredOnboardPath(const std::string& path) { + std::string lower = toLowerCopy(path); + auto hasSegment = [&](const std::string& segment) { + return lower.find(segment) == 0 || + lower.find("/" + segment) != std::string::npos; + }; + return hasSegment(".git/") || + hasSegment("build/") || + hasSegment("build-native/") || + hasSegment("node_modules/") || + hasSegment(".whetstone/"); + } + + static int filePriority(const std::string& path) { + std::string lower = toLowerCopy(path); + if (lower.find("main.") != std::string::npos || + lower.find("app.") != std::string::npos || + lower.find("index.") != std::string::npos || + lower.find("lib.") != std::string::npos) return 0; + if (lower.find("/src/") != std::string::npos) return 1; + return 2; + } + + static json selectOnboardingCandidates(const json& files, int maxFiles) { + std::vector> ranked; + std::map languageCounts; + json candidates = json::array(); + for (const auto& f : files) { + if (!f.is_object()) continue; + if (f.value("isDir", false)) continue; + std::string path = f.value("path", ""); + if (path.empty() || isIgnoredOnboardPath(path)) continue; + std::string language = languageForPath(path); + if (language.empty()) continue; + languageCounts[language] += 1; + ranked.push_back({filePriority(path), { + {"path", path}, + {"language", language} + }}); + } + std::sort(ranked.begin(), ranked.end(), + [](const auto& a, const auto& b) { + if (a.first != b.first) return a.first < b.first; + return a.second.value("path", "") < b.second.value("path", ""); + }); + for (const auto& item : ranked) { + if ((int)candidates.size() >= maxFiles) break; + candidates.push_back(item.second); + } + json langJson = json::object(); + for (const auto& [lang, count] : languageCounts) langJson[lang] = count; + return { + {"candidates", candidates}, + {"languageCounts", langJson} + }; + } + + json runWorkspaceOnboarding(const json& args) { + json warnings = json::array(); + int maxFiles = args.value("maxFiles", 8); + if (maxFiles < 1) maxFiles = 1; + if (maxFiles > 20) maxFiles = 20; + + json indexParams = json::object(); + std::string root = args.value("root", ""); + if (!root.empty()) indexParams["root"] = root; + json indexRes = callWhetstone("indexWorkspace", indexParams); + if (hasCallError(indexRes)) { + return { + {"success", false}, + {"error", indexRes["error"]}, + {"warnings", warnings} + }; + } + + json listRes = callWhetstone("workspaceList", json::object()); + json files = listRes.value("files", json::array()); + json selection = selectOnboardingCandidates(files, maxFiles); + json candidates = selection["candidates"]; + json processed = json::array(); + int inferredCount = 0; + int savedCount = 0; + + for (const auto& c : candidates) { + std::string path = c.value("path", ""); + std::string language = c.value("language", ""); + + json openRes = callWhetstone("openFile", { + {"path", path}, + {"language", language} + }); + if (hasCallError(openRes)) { + appendWarning(warnings, "openFile failed for " + path); + continue; + } + + json inferRes = callWhetstone("inferAnnotations", json::object()); + if (hasCallError(inferRes)) { + appendWarning(warnings, "inferAnnotations failed for " + path); + continue; + } + inferredCount += inferRes.value("count", 0); + + std::string openedPath = openRes.value("path", path); + json saveRes = callWhetstone("saveAnnotatedAST", {{"path", openedPath}}); + if (hasCallError(saveRes)) { + appendWarning(warnings, "saveAnnotatedAST failed for " + openedPath); + } else { + savedCount += 1; + } + + processed.push_back({ + {"path", openedPath}, + {"language", language}, + {"suggestionCount", inferRes.value("count", 0)}, + {"saved", !hasCallError(saveRes)} + }); + } + + std::string readme = + "# Whetstone Workspace\n" + "This directory stores onboarding metadata and sidecar annotation artifacts.\n"; + json mkDirRes = callWhetstone("fileCreate", { + {"path", ".whetstone/README.md"}, + {"content", readme} + }); + if (hasCallError(mkDirRes)) + appendWarning(warnings, "Unable to create .whetstone/README.md"); + + return { + {"success", true}, + {"workspace", indexRes.value("root", root)}, + {"fileCount", indexRes.value("fileCount", 0)}, + {"selectedFileCount", (int)candidates.size()}, + {"processedFileCount", (int)processed.size()}, + {"inferredAnnotationCount", inferredCount}, + {"savedSidecarCount", savedCount}, + {"languageCounts", selection["languageCounts"]}, + {"processedFiles", processed}, + {"workflowSuggestion", { + {"nextTools", json::array({ + "whetstone_create_skeleton", + "whetstone_create_workflow", + "whetstone_orchestrate_run_deterministic", + "whetstone_get_blockers", + "whetstone_get_review_queue" + })}, + {"note", "Onboarding complete. Use skeleton/workflow tools to start routed execution."} + }}, + {"warnings", warnings} + }; + } + // --------------------------------------------------------------- // Step 208: Register AST query and mutation tools // --------------------------------------------------------------- @@ -1714,6 +1899,25 @@ private: }; } + void registerOnboardingTools() { + // whetstone_onboard_workspace + tools_.push_back({"whetstone_onboard_workspace", + "Run first-time workspace onboarding in one call: index files, detect key " + "languages/files, run annotation inference on selected files, save sidecars, " + "and bootstrap .whetstone metadata.", + {{"type", "object"}, {"properties", { + {"root", {{"type", "string"}, + {"description", "Workspace root override (optional)"}}}, + {"maxFiles", {{"type", "integer"}, + {"description", "Max key files to process (default 8, max 20)"}}} + }}} + }); + toolHandlers_["whetstone_onboard_workspace"] = + [this](const json& args) { + return runWorkspaceOnboarding(args); + }; + } + void registerWhetstoneTools() { registerASTTools(); registerAnnotationTools(); @@ -1731,5 +1935,6 @@ private: registerRoutingTools(); registerOrchestratorTools(); registerReviewTools(); + registerOnboardingTools(); } }; diff --git a/editor/tests/step421_test.cpp b/editor/tests/step421_test.cpp new file mode 100644 index 0000000..f464962 --- /dev/null +++ b/editor/tests/step421_test.cpp @@ -0,0 +1,355 @@ +// Step 421: Workspace Onboarding Flow Tests (12 tests) + +#include "MCPServer.h" +#include "HeadlessEditorState.h" + +#include +#include +#include +#include +#include + +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 rpcResult(const json& result, int id = 1) { + return {{"jsonrpc", "2.0"}, {"id", id}, {"result", result}}; +} + +static json parseToolResult(const json& toolCallResponse) { + if (!toolCallResponse.contains("result")) return json::object(); + auto content = toolCallResponse["result"].value("content", json::array()); + if (!content.is_array() || content.empty()) return json::object(); + std::string text = content[0].value("text", "{}"); + try { + return json::parse(text); + } catch (...) { + return json::object(); + } +} + +static json callTool(MCPServer& server, const std::string& name, + const json& args = json::object()) { + auto resp = server.handleRequest({ + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "tools/call"}, + {"params", {{"name", name}, {"arguments", args}}} + }); + return parseToolResult(resp); +} + +static std::string makeTempDir(const std::string& name) { + auto dir = std::filesystem::temp_directory_path() / ("whetstone_step421_" + name); + std::filesystem::create_directories(dir); + return dir.string(); +} + +void test_onboard_tool_registered() { + TEST(onboard_tool_registered); + MCPServer server; + bool found = false; + for (const auto& t : server.getTools()) { + if (t.name == "whetstone_onboard_workspace") found = true; + } + CHECK(found, "missing whetstone_onboard_workspace tool"); + PASS(); +} + +void test_onboard_calls_index_workspace_and_list() { + TEST(onboard_calls_index_workspace_and_list); + MCPServer server; + std::vector methods; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + methods.push_back(method); + if (method == "indexWorkspace") + return rpcResult({{"root", "/repo"}, {"fileCount", 3}}); + if (method == "workspaceList") + return rpcResult({{"files", json::array()}}); + if (method == "fileCreate") + return rpcResult({{"created", ".whetstone/README.md"}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(res.value("success", false), "expected success"); + CHECK(methods.size() >= 2, "expected at least index+list calls"); + CHECK(methods[0] == "indexWorkspace", "first call should indexWorkspace"); + CHECK(methods[1] == "workspaceList", "second call should workspaceList"); + PASS(); +} + +void test_onboard_passes_root_to_index_workspace() { + TEST(onboard_passes_root_to_index_workspace); + MCPServer server; + std::string rootParam; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") { + rootParam = req["params"].value("root", ""); + return rpcResult({{"root", rootParam}, {"fileCount", 1}}); + } + if (method == "workspaceList") return rpcResult({{"files", json::array()}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace", {{"root", "/tmp/repo"}}); + CHECK(res.value("success", false), "expected success"); + CHECK(rootParam == "/tmp/repo", "root param not forwarded"); + PASS(); +} + +void test_onboard_respects_max_files_limit() { + TEST(onboard_respects_max_files_limit); + MCPServer server; + int openCount = 0; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") + return rpcResult({{"root", "/repo"}, {"fileCount", 4}}); + if (method == "workspaceList") + return rpcResult({{"files", json::array({ + {{"path", "a.cpp"}, {"isDir", false}}, + {{"path", "b.cpp"}, {"isDir", false}}, + {{"path", "c.py"}, {"isDir", false}}, + {{"path", "d.ts"}, {"isDir", false}} + })}}); + if (method == "openFile") { ++openCount; return rpcResult(req["params"]); } + if (method == "inferAnnotations") return rpcResult({{"count", 2}}); + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace", {{"maxFiles", 2}}); + CHECK(res.value("success", false), "expected success"); + CHECK(openCount == 2, "expected openFile called exactly maxFiles times"); + PASS(); +} + +void test_onboard_filters_ignored_and_non_source_files() { + TEST(onboard_filters_ignored_and_non_source_files); + MCPServer server; + int openCount = 0; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") + return rpcResult({{"root", "/repo"}, {"fileCount", 6}}); + if (method == "workspaceList") + return rpcResult({{"files", json::array({ + {{"path", "src/main.cpp"}, {"isDir", false}}, + {{"path", "README.md"}, {"isDir", false}}, + {{"path", "build/tmp.cpp"}, {"isDir", false}}, + {{"path", "node_modules/pkg/index.js"}, {"isDir", false}}, + {{"path", ".whetstone/cache.py"}, {"isDir", false}}, + {{"path", "scripts/run.sh"}, {"isDir", false}} + })}}); + if (method == "openFile") { ++openCount; return rpcResult(req["params"]); } + if (method == "inferAnnotations") return rpcResult({{"count", 1}}); + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(res.value("success", false), "expected success"); + CHECK(openCount == 1, "expected only source file outside ignored dirs"); + PASS(); +} + +void test_onboard_reports_language_counts() { + TEST(onboard_reports_language_counts); + MCPServer server; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") + return rpcResult({{"root", "/repo"}, {"fileCount", 4}}); + if (method == "workspaceList") + return rpcResult({{"files", json::array({ + {{"path", "a.cpp"}, {"isDir", false}}, + {{"path", "b.cpp"}, {"isDir", false}}, + {{"path", "c.py"}, {"isDir", false}}, + {{"path", "d.go"}, {"isDir", false}} + })}}); + if (method == "openFile") return rpcResult(req["params"]); + if (method == "inferAnnotations") return rpcResult({{"count", 1}}); + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(res["languageCounts"].value("cpp", 0) == 2, "cpp count mismatch"); + CHECK(res["languageCounts"].value("python", 0) == 1, "python count mismatch"); + CHECK(res["languageCounts"].value("go", 0) == 1, "go count mismatch"); + PASS(); +} + +void test_onboard_aggregates_inference_and_sidecar_counts() { + TEST(onboard_aggregates_inference_and_sidecar_counts); + MCPServer server; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") + return rpcResult({{"root", "/repo"}, {"fileCount", 2}}); + if (method == "workspaceList") + return rpcResult({{"files", json::array({ + {{"path", "a.cpp"}, {"isDir", false}}, + {{"path", "b.py"}, {"isDir", false}} + })}}); + if (method == "openFile") return rpcResult(req["params"]); + if (method == "inferAnnotations") return rpcResult({{"count", 3}}); + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(res.value("inferredAnnotationCount", -1) == 6, "inference count mismatch"); + CHECK(res.value("savedSidecarCount", -1) == 2, "saved sidecar count mismatch"); + PASS(); +} + +void test_onboard_warns_and_continues_on_open_failure() { + TEST(onboard_warns_and_continues_on_open_failure); + MCPServer server; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") return rpcResult({{"root", "/repo"}, {"fileCount", 2}}); + if (method == "workspaceList") return rpcResult({{"files", json::array({ + {{"path", "a.cpp"}, {"isDir", false}}, + {{"path", "b.py"}, {"isDir", false}} + })}}); + if (method == "openFile") { + std::string path = req["params"].value("path", ""); + if (path == "a.cpp") + return json{{"jsonrpc", "2.0"}, {"id", 1}, + {"error", {{"code", -1}, {"message", "open fail"}}}}; + return rpcResult(req["params"]); + } + if (method == "inferAnnotations") return rpcResult({{"count", 2}}); + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(res.value("success", false), "expected overall success"); + CHECK(res.value("processedFileCount", -1) == 1, "expected one processed file"); + CHECK(res["warnings"].is_array() && !res["warnings"].empty(), "expected warnings"); + PASS(); +} + +void test_onboard_warns_and_continues_on_infer_failure() { + TEST(onboard_warns_and_continues_on_infer_failure); + MCPServer server; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") return rpcResult({{"root", "/repo"}, {"fileCount", 2}}); + if (method == "workspaceList") return rpcResult({{"files", json::array({ + {{"path", "a.cpp"}, {"isDir", false}}, + {{"path", "b.py"}, {"isDir", false}} + })}}); + if (method == "openFile") return rpcResult(req["params"]); + if (method == "inferAnnotations") { + static int callIdx = 0; + ++callIdx; + if (callIdx == 1) + return json{{"jsonrpc", "2.0"}, {"id", 1}, + {"error", {{"code", -1}, {"message", "infer fail"}}}}; + return rpcResult({{"count", 4}}); + } + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") return rpcResult({{"created", true}}); + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(res.value("success", false), "expected overall success"); + CHECK(res.value("processedFileCount", -1) == 1, "expected one processed file"); + CHECK(res.value("savedSidecarCount", -1) == 1, "expected one saved sidecar"); + CHECK(res["warnings"].is_array() && !res["warnings"].empty(), "expected warnings"); + PASS(); +} + +void test_onboard_returns_failure_when_index_fails() { + TEST(onboard_returns_failure_when_index_fails); + MCPServer server; + server.setRpcCallback([&](const json& req) { + if (req.value("method", "") == "indexWorkspace") + return json{{"jsonrpc", "2.0"}, {"id", 1}, + {"error", {{"code", -32602}, {"message", "missing root"}}}}; + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(!res.value("success", true), "expected failure"); + CHECK(res.contains("error"), "expected error payload"); + PASS(); +} + +void test_onboard_creates_whetstone_readme_and_suggestion() { + TEST(onboard_creates_whetstone_readme_and_suggestion); + MCPServer server; + std::string createdPath; + server.setRpcCallback([&](const json& req) { + std::string method = req.value("method", ""); + if (method == "indexWorkspace") return rpcResult({{"root", "/repo"}, {"fileCount", 1}}); + if (method == "workspaceList") return rpcResult({{"files", json::array({{{"path", "a.cpp"}, {"isDir", false}}})}}); + if (method == "openFile") return rpcResult(req["params"]); + if (method == "inferAnnotations") return rpcResult({{"count", 1}}); + if (method == "saveAnnotatedAST") return rpcResult({{"success", true}}); + if (method == "fileCreate") { + createdPath = req["params"].value("path", ""); + return rpcResult({{"created", true}}); + } + return rpcResult(json::object()); + }); + auto res = callTool(server, "whetstone_onboard_workspace"); + CHECK(createdPath == ".whetstone/README.md", "expected readme bootstrap path"); + CHECK(res["workflowSuggestion"]["nextTools"].is_array(), "missing nextTools suggestion"); + CHECK(!res["workflowSuggestion"].value("note", "").empty(), "missing suggestion note"); + PASS(); +} + +void test_onboard_headless_end_to_end() { + TEST(onboard_headless_end_to_end); + auto root = std::filesystem::path(makeTempDir("e2e")); + std::filesystem::create_directories(root / "src"); + std::ofstream(root / "src" / "main.cpp") << "int add(int a,int b){return a+b;}\n"; + std::ofstream(root / "src" / "util.py") << "def inc(x):\n return x + 1\n"; + + HeadlessEditorState state; + state.setAgentRole("sess", AgentRole::Refactor); + state.workspaceRoot = root.string(); + + MCPServer server; + server.setRpcCallback([&](const json& req) { + return state.processAgentRequest(req, "sess"); + }); + + auto res = callTool(server, "whetstone_onboard_workspace", + {{"root", root.string()}, {"maxFiles", 2}}); + CHECK(res.value("success", false), "expected onboarding success"); + CHECK(res.value("processedFileCount", 0) >= 1, "expected at least one processed file"); + CHECK(std::filesystem::exists(root / ".whetstone" / "README.md"), + "expected .whetstone readme"); + PASS(); +} + +int main() { + std::cout << "Step 421: Workspace Onboarding Flow Tests\n"; + + test_onboard_tool_registered(); // 1 + test_onboard_calls_index_workspace_and_list(); // 2 + test_onboard_passes_root_to_index_workspace(); // 3 + test_onboard_respects_max_files_limit(); // 4 + test_onboard_filters_ignored_and_non_source_files(); // 5 + test_onboard_reports_language_counts(); // 6 + test_onboard_aggregates_inference_and_sidecar_counts(); // 7 + test_onboard_warns_and_continues_on_open_failure(); // 8 + test_onboard_warns_and_continues_on_infer_failure(); // 9 + test_onboard_returns_failure_when_index_fails(); // 10 + test_onboard_creates_whetstone_readme_and_suggestion(); // 11 + test_onboard_headless_end_to_end(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) + << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 2e79d1c..e19eda5 100644 --- a/progress.md +++ b/progress.md @@ -4424,6 +4424,52 @@ headless JSON-RPC path. - `editor/src/MCPServer.h` (`1735` > `600`) - `editor/src/HeadlessAgentRPCHandler.h` (`2768` > `600`) +### Step 421: Workspace Onboarding Flow +**Status:** PASS (12/12 tests) + +Added one-call MCP workspace onboarding so a client can bootstrap workflow +state on a new repo by indexing files, selecting key source files, running +annotation inference, saving sidecars, and creating `.whetstone` metadata. + +**Files created:** +- `editor/tests/step421_test.cpp` — 12 tests covering: + 1. `whetstone_onboard_workspace` tool registration + 2. index/list call sequencing + 3. `root` forwarding to `indexWorkspace` + 4. `maxFiles` processing cap + 5. ignored/non-source path filtering + 6. language-count aggregation + 7. inference + sidecar aggregate counters + 8. open-file failure warning/continue behavior + 9. infer failure warning/continue behavior + 10. index failure hard-stop behavior + 11. `.whetstone/README.md` bootstrap + workflow suggestion payload + 12. headless end-to-end onboarding against a temp workspace + +**Files modified:** +- `editor/src/MCPServer.h` — added Step 421 onboarding support: + - `whetstone_onboard_workspace` tool registration + - composite onboarding execution (`runWorkspaceOnboarding`) with: + - `indexWorkspace` + - `workspaceList` + - candidate source-file selection + language detection + - `openFile` + `inferAnnotations` + `saveAnnotatedAST` loop + - `.whetstone/README.md` bootstrap via `fileCreate` + - warning collection and next-tool workflow suggestion +- `editor/CMakeLists.txt` — `step421_test` target + +**Verification run:** +- `step421_test` — PASS (12/12) new step coverage +- `step420_test` — PASS (12/12) regression coverage +- `step419_test` — PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/tests/step421_test.cpp` within test-file size guidance (`355` lines) +- Legacy oversized headers persist: + - `editor/src/ast/Serialization.h` (`1427` > `600`) + - `editor/src/MCPServer.h` (`1940` > `600`) + - `editor/src/HeadlessAgentRPCHandler.h` (`2768` > `600`) + # Roadmap Planning — Sprints 12-25+ ## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)