diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 03d451b..a14bc9f 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -4414,4 +4414,13 @@ target_link_libraries(step614_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step615_test tests/step615_test.cpp) +target_include_directories(step615_test PRIVATE src) +target_link_libraries(step615_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 fff51fa..d31de3e 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -19,6 +19,9 @@ #include "MarkdownSpecParser.h" #include "RequirementNormalizationConflictDetector.h" +#include "ScopeMilestoneDecomposer.h" +#include "TaskitemConfidenceAmbiguity.h" +#include "TaskitemGeneratorV2.h" using json = nlohmann::json; diff --git a/editor/src/mcp/RegisterArchitectIntakeTools.h b/editor/src/mcp/RegisterArchitectIntakeTools.h index 21411bf..30e0a57 100644 --- a/editor/src/mcp/RegisterArchitectIntakeTools.h +++ b/editor/src/mcp/RegisterArchitectIntakeTools.h @@ -10,6 +10,20 @@ [this](const json& args) { return runArchitectIntake(args); }; + + tools_.push_back({"whetstone_generate_taskitems", + "Generate and annotate taskitems from normalized intake requirements.", + {{"type", "object"}, {"properties", { + {"normalizedRequirements", {{"type", "array"}, + {"description", "Normalized requirements from whetstone_architect_intake."}}}, + {"conflicts", {{"type", "array"}, + {"description", "Optional requirement conflicts from whetstone_architect_intake."}}} + }}, {"required", json::array({"normalizedRequirements"})}} + }); + toolHandlers_["whetstone_generate_taskitems"] = + [this](const json& args) { + return runGenerateTaskitems(args); + }; } json runArchitectIntake(const json& args) { @@ -133,3 +147,165 @@ } return count; } + + json runGenerateTaskitems(const json& args) { + RequirementNormalizationResult normalized; + std::string error; + if (!parseNormalizedInput(args, &normalized, &error)) { + return { + {"success", false}, + {"error", error} + }; + } + + DecomposedScopePlan plan; + if (!ScopeMilestoneDecomposer::decompose(normalized, &plan, &error)) { + return { + {"success", false}, + {"error", error} + }; + } + + std::vector generated; + if (!TaskitemGeneratorV2::generate(plan, &generated, &error)) { + return { + {"success", false}, + {"error", error} + }; + } + + std::vector annotated; + if (!TaskitemConfidenceAmbiguity::annotate(generated, normalized, &annotated, &error)) { + return { + {"success", false}, + {"error", error} + }; + } + + int escalateCount = 0; + for (const auto& task : annotated) { + if (task.escalate) ++escalateCount; + } + + return { + {"success", true}, + {"tasks", annotatedTaskitemsToJson(annotated)}, + {"planSummary", { + {"milestoneCount", (int)plan.milestones.size()}, + {"overallUncertainty", plan.overallUncertainty} + }}, + {"conflictCount", (int)normalized.conflicts.size()}, + {"ambiguousRequirementCount", countAmbiguousRequirements(normalized.requirements)}, + {"escalateCount", escalateCount} + }; + } + + static bool parseNormalizedInput(const json& args, + RequirementNormalizationResult* out, + std::string* error) { + if (!out || !error) return false; + error->clear(); + out->requirements.clear(); + out->conflicts.clear(); + + if (!args.contains("normalizedRequirements")) { + *error = "normalized_requirements_missing"; + return false; + } + if (!args["normalizedRequirements"].is_array()) { + *error = "normalized_requirements_not_array"; + return false; + } + + for (const auto& requirementJson : args["normalizedRequirements"]) { + if (!requirementJson.is_object()) { + *error = "requirement_entry_invalid"; + return false; + } + NormalizedRequirement requirement; + requirement.requirementId = requirementJson.value("requirementId", ""); + requirement.normalizedText = requirementJson.value("normalizedText", ""); + requirement.anchor = requirementJson.value("anchor", ""); + requirement.sourceLine = requirementJson.value("sourceLine", 0); + requirement.ambiguous = requirementJson.value("ambiguous", false); + if (requirement.requirementId.empty() || requirement.normalizedText.empty()) { + *error = "requirement_entry_missing_fields"; + return false; + } + + if (!parseRequirementKind(requirementJson.value("kind", ""), &requirement.kind)) { + *error = "requirement_kind_invalid"; + return false; + } + out->requirements.push_back(requirement); + } + + if (out->requirements.empty()) { + *error = "normalized_requirements_empty"; + return false; + } + + if (!args.contains("conflicts")) return true; + if (!args["conflicts"].is_array()) { + *error = "conflicts_not_array"; + return false; + } + for (const auto& conflictJson : args["conflicts"]) { + if (!conflictJson.is_object()) { + *error = "conflict_entry_invalid"; + return false; + } + RequirementConflict conflict; + conflict.leftRequirementId = conflictJson.value("leftRequirementId", ""); + conflict.rightRequirementId = conflictJson.value("rightRequirementId", ""); + conflict.conflictType = conflictJson.value("conflictType", ""); + conflict.detail = conflictJson.value("detail", ""); + if (conflict.leftRequirementId.empty() || conflict.rightRequirementId.empty()) { + *error = "conflict_entry_missing_fields"; + return false; + } + out->conflicts.push_back(conflict); + } + return true; + } + + static bool parseRequirementKind(const std::string& kind, + NormalizedRequirementKind* outKind) { + if (!outKind) return false; + if (kind == "goal") { + *outKind = NormalizedRequirementKind::Goal; + return true; + } + if (kind == "constraint") { + *outKind = NormalizedRequirementKind::Constraint; + return true; + } + if (kind == "dependency") { + *outKind = NormalizedRequirementKind::Dependency; + return true; + } + if (kind == "acceptance") { + *outKind = NormalizedRequirementKind::Acceptance; + return true; + } + return false; + } + + static json annotatedTaskitemsToJson(const std::vector& tasks) { + json out = json::array(); + for (const auto& task : tasks) { + out.push_back({ + {"taskId", task.base.taskId}, + {"title", task.base.title}, + {"milestoneId", task.base.milestoneId}, + {"dependencyTaskIds", task.base.dependencyTaskIds}, + {"prerequisiteOps", task.base.prerequisiteOps}, + {"queueReady", task.base.queueReady}, + {"confidence", task.confidence}, + {"ambiguityCount", task.ambiguityCount}, + {"escalate", task.escalate}, + {"reasons", task.reasons} + }); + } + return out; + } diff --git a/editor/tests/step615_test.cpp b/editor/tests/step615_test.cpp new file mode 100644 index 0000000..80fd777 --- /dev/null +++ b/editor/tests/step615_test.cpp @@ -0,0 +1,220 @@ +// Step 615: whetstone_generate_taskitems MCP Tool (12 tests) + +#include "MCPServer.h" + +#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 callTool(MCPServer& mcp, const std::string& name, const json& args) { + json req = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "tools/call"}, + {"params", {{"name", name}, {"arguments", args}}} + }; + json resp = mcp.handleRequest(req); + std::string text = resp["result"]["content"][0].value("text", "{}"); + return json::parse(text); +} + +static std::string clearSpec() { + return + "## Goals\n" + "- Ship intake parser\n" + "\n" + "## Constraints\n" + "- Keep modules header only\n" + "\n" + "## Dependencies\n" + "- nlohmann json\n" + "\n" + "## Acceptance Criteria\n" + "- tests pass\n"; +} + +static std::string riskySpec() { + return + "## Goals\n" + "- maybe improve intake and maybe improve review\n" + "\n" + "## Constraints\n" + "- Do not use dynamic allocation in hot path\n" + "- Use dynamic allocation in hot path for warmup\n" + "\n" + "## Dependencies\n" + "- maybe add cache lib\n" + "\n" + "## Acceptance Criteria\n" + "- maybe latency under 50ms\n"; +} + +static json intakeOutput(MCPServer& mcp, const std::string& markdown) { + return callTool(mcp, "whetstone_architect_intake", {{"markdown", markdown}}); +} + +static json taskitemArgsFromIntake(const json& intake) { + return { + {"normalizedRequirements", intake["normalizedRequirements"]}, + {"conflicts", intake["conflicts"]} + }; +} + +void test_tool_registered_accessor() { + TEST(tool_registered_accessor); + MCPServer mcp; + bool found = false; + for (const auto& tool : mcp.getTools()) { + if (tool.name == "whetstone_generate_taskitems") found = true; + } + CHECK(found, "tool should be registered"); + PASS(); +} + +void test_tool_schema_requires_normalized_requirements() { + TEST(tool_schema_requires_normalized_requirements); + MCPServer mcp; + bool required = false; + for (const auto& tool : mcp.getTools()) { + if (tool.name != "whetstone_generate_taskitems") continue; + for (const auto& req : tool.inputSchema["required"]) { + if (req == "normalizedRequirements") required = true; + } + } + CHECK(required, "normalizedRequirements should be required"); + PASS(); +} + +void test_success_for_intake_output() { + TEST(success_for_intake_output); + MCPServer mcp; + json intake = intakeOutput(mcp, clearSpec()); + json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake)); + CHECK(out.value("success", false), "success should be true"); + PASS(); +} + +void test_returns_non_empty_tasks() { + TEST(returns_non_empty_tasks); + MCPServer mcp; + json intake = intakeOutput(mcp, clearSpec()); + json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake)); + CHECK(out["tasks"].is_array() && !out["tasks"].empty(), "tasks should be non-empty array"); + PASS(); +} + +void test_task_contains_annotation_fields() { + TEST(task_contains_annotation_fields); + MCPServer mcp; + json intake = intakeOutput(mcp, clearSpec()); + json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake)); + const auto& task = out["tasks"][0]; + CHECK(task.contains("confidence"), "confidence missing"); + CHECK(task.contains("ambiguityCount"), "ambiguityCount missing"); + CHECK(task.contains("escalate"), "escalate missing"); + CHECK(task.contains("reasons"), "reasons missing"); + PASS(); +} + +void test_conflict_count_roundtrips_from_input() { + TEST(conflict_count_roundtrips_from_input); + MCPServer mcp; + json intake = intakeOutput(mcp, riskySpec()); + json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake)); + CHECK(out.value("conflictCount", 0) >= 1, "expected conflict count >= 1"); + PASS(); +} + +void test_risky_input_triggers_escalation() { + TEST(risky_input_triggers_escalation); + MCPServer mcp; + json intake = intakeOutput(mcp, riskySpec()); + json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake)); + CHECK(out.value("escalateCount", 0) >= 1, "expected escalations"); + PASS(); +} + +void test_clear_input_has_non_escalated_task() { + TEST(clear_input_has_non_escalated_task); + MCPServer mcp; + json intake = intakeOutput(mcp, clearSpec()); + json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake)); + bool foundNonEscalated = false; + for (const auto& task : out["tasks"]) { + if (!task.value("escalate", true)) foundNonEscalated = true; + } + CHECK(foundNonEscalated, "expected at least one non-escalated task"); + PASS(); +} + +void test_missing_normalized_requirements_errors() { + TEST(missing_normalized_requirements_errors); + MCPServer mcp; + json out = callTool(mcp, "whetstone_generate_taskitems", json::object()); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "normalized_requirements_missing", "wrong error"); + PASS(); +} + +void test_normalized_requirements_must_be_array() { + TEST(normalized_requirements_must_be_array); + MCPServer mcp; + json out = callTool(mcp, "whetstone_generate_taskitems", + {{"normalizedRequirements", "bad"}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "normalized_requirements_not_array", "wrong error"); + PASS(); +} + +void test_rejects_empty_normalized_requirements() { + TEST(rejects_empty_normalized_requirements); + MCPServer mcp; + json out = callTool(mcp, "whetstone_generate_taskitems", + {{"normalizedRequirements", json::array()}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "normalized_requirements_empty", "wrong error"); + PASS(); +} + +void test_rejects_invalid_requirement_kind() { + TEST(rejects_invalid_requirement_kind); + MCPServer mcp; + json bad = json::array({{ + {"requirementId", "goal-1"}, + {"kind", "invalid-kind"}, + {"normalizedText", "ship it"}, + {"anchor", "goals"}, + {"sourceLine", 2}, + {"ambiguous", false} + }}); + json out = callTool(mcp, "whetstone_generate_taskitems", + {{"normalizedRequirements", bad}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "requirement_kind_invalid", "wrong error"); + PASS(); +} + +int main() { + std::cout << "Step 615: whetstone_generate_taskitems MCP Tool\n"; + + test_tool_registered_accessor(); // 1 + test_tool_schema_requires_normalized_requirements(); // 2 + test_success_for_intake_output(); // 3 + test_returns_non_empty_tasks(); // 4 + test_task_contains_annotation_fields(); // 5 + test_conflict_count_roundtrips_from_input(); // 6 + test_risky_input_triggers_escalation(); // 7 + test_clear_input_has_non_escalated_task(); // 8 + test_missing_normalized_requirements_errors(); // 9 + test_normalized_requirements_must_be_array(); // 10 + test_rejects_empty_normalized_requirements(); // 11 + test_rejects_invalid_requirement_kind(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index e7867dd..626d2bb 100644 --- a/progress.md +++ b/progress.md @@ -12089,3 +12089,37 @@ single MCP tool call for architect intake. - `editor/src/MCPServer.h` within header-size limit (`516` <= `600`) - `editor/tests/step614_test.cpp` within test-file size guidance (`193` lines) - Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md` + +### Step 615: `whetstone_generate_taskitems` MCP Tool +**Status:** PASS (12/12 tests) + +Wires `ScopeMilestoneDecomposer` + `TaskitemGeneratorV2` + +`TaskitemConfidenceAmbiguity` into a single MCP tool call that consumes +normalized intake JSON and returns annotated taskitems. + +**Files modified:** +- `editor/src/mcp/RegisterArchitectIntakeTools.h` - added: + - `whetstone_generate_taskitems` tool schema + handler + - normalized requirement/conflict JSON parsing and validation + - decompose/generate/annotate pipeline + annotated taskitem JSON output +- `editor/src/MCPServer.h` - include wiring for decomposition/taskitem modules +- `editor/CMakeLists.txt` - `step615_test` target + +**Files added:** +- `editor/tests/step615_test.cpp` - 12 tests covering: + - MCP tool registration/schema + - intake-output-to-taskitem generation path + - confidence/escalation/conflict behavior + - malformed input and validation error paths + +**Verification run:** +- `cmake -S editor -B editor/build-native` - PASS +- `cmake --build editor/build-native --target step615_test step614_test` - PASS +- `./editor/build-native/step615_test` - PASS (12/12) +- `./editor/build-native/step614_test` - PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/mcp/RegisterArchitectIntakeTools.h` within header-size limit (`311` <= `600`) +- `editor/src/MCPServer.h` within header-size limit (`519` <= `600`) +- `editor/tests/step615_test.cpp` within test-file size guidance (`220` lines) +- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`