Step 421: add one-call workspace onboarding MCP tool

This commit is contained in:
Bill
2026-02-16 16:30:06 -07:00
parent 6d5a2be423
commit 842f1afc79
4 changed files with 615 additions and 0 deletions

View File

@@ -13,6 +13,8 @@
#include <vector>
#include <map>
#include <functional>
#include <algorithm>
#include <cctype>
#include <nlohmann/json.hpp>
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<std::pair<int, json>> ranked;
std::map<std::string, int> 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();
}
};